fix(guards): a step whose test is seen green may stop while it waits for its controller

voyage-lekerepo trial (2026-10-06) point 7, operator decision 2026-10-08
(«grønt merke på låsen»): the stop guard blocked Stop three times while the
executor waited for the controller, with the step's test already green.

- test-lock.mjs green --cmd "<Verify>" runs the step's test command (same
  allowlist + denylist as done-line) and records green_at only on exit 0;
  pending clears it; a new lock starts without it.
- stop-test-lock lets a green-marked lock stop; the write guard still
  refuses the locked test files.
- trekexecute: D1 marks green after a PASS; each fix round marks pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-08 06:59:14 +02:00
commit 393b8e5a0a
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
5 changed files with 163 additions and 10 deletions

View file

@ -0,0 +1,96 @@
// tests/hooks/test-lock-green.test.mjs
// The test lock's green mark (operator decision 2026-10-08, voyage-lekerepo
// trial point 7). The stop guard blocked Stop three times while the executor
// waited for the controller in the background, though the step's test was
// already green. Now:
// - `test-lock.mjs green --cmd "<Verify>"` RUNS the step's test command and
// marks the lock "seen green" only on exit 0 (a command proves it, never a
// word);
// - the stop guard lets a green-marked lock stop; the write guard still
// refuses the locked files — the lock still protects the test;
// - `test-lock.mjs pending` clears the mark when a fix round starts.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { writeFileSync, mkdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { runHook } from '../helpers/hook-helper.mjs';
import { ROOT, makeRepo, parseJsonOut } from './guard-helpers.mjs';
const CLI = join(ROOT, 'lib', 'util', 'test-lock.mjs');
const STOP = join(ROOT, 'hooks', 'scripts', 'stop-test-lock.mjs');
const PRE_WRITE = join(ROOT, 'hooks', 'scripts', 'pre-write-executor.mjs');
const SESS = `sess-green-${process.pid}-${Date.now()}`;
function cli(repo, args) {
const r = spawnSync(process.execPath, [CLI, ...args], { cwd: repo, encoding: 'utf8', env: { ...process.env, CLAUDE_CODE_SESSION_ID: SESS } });
return { code: r.status, stdout: r.stdout, stderr: r.stderr };
}
function lockedRepo(testBody) {
const repo = makeRepo('main');
mkdirSync(join(repo, 'tests'), { recursive: true });
writeFileSync(join(repo, 'tests', 'step.test.mjs'), [
"import { test } from 'node:test';",
"import { strict as assert } from 'node:assert';",
`test('step', () => { ${testBody} });`,
'',
].join('\n'));
assert.equal(cli(repo, ['lock', '--files', 'tests/step.test.mjs', '--step', '3']).code, 0);
return repo;
}
const CMD = 'node --test tests/step.test.mjs';
const stop = (repo) => ({ hook_event_name: 'Stop', session_id: SESS, cwd: repo, stop_hook_active: false });
const edit = (file_path) => ({ hook_event_name: 'PreToolUse', tool_name: 'Edit', tool_input: { file_path, old_string: 'x', new_string: 'z' } });
test('green: the step\'s test passes -> the lock is marked green, Stop goes through, the test file stays locked', async () => {
const repo = lockedRepo('assert.equal(1, 1);');
const g = cli(repo, ['green', '--cmd', CMD]);
assert.equal(g.code, 0, g.stderr);
assert.ok(JSON.parse(cli(repo, ['status']).stdout).green_at, 'status shows green_at');
assert.equal(parseJsonOut((await runHook(STOP, stop(repo))).stdout), null, 'a green lock does not block Stop');
const w = await runHook(PRE_WRITE, edit(join(repo, 'tests', 'step.test.mjs')));
assert.equal(w.code, 2, 'the test file is still locked against edits');
});
test('green: the test fails -> exit 1, no mark, Stop is still refused', async () => {
const repo = lockedRepo('assert.equal(1, 2);');
assert.equal(cli(repo, ['green', '--cmd', CMD]).code, 1);
assert.equal(JSON.parse(cli(repo, ['status']).stdout).green_at, null);
assert.equal(parseJsonOut((await runHook(STOP, stop(repo))).stdout)?.decision, 'block');
});
test('green: a command outside the test-runner allowlist is never run -> exit 2, no mark', () => {
const repo = lockedRepo('assert.equal(1, 1);');
const g = cli(repo, ['green', '--cmd', 'echo ok']);
assert.equal(g.code, 2, g.stderr);
assert.equal(JSON.parse(cli(repo, ['status']).stdout).green_at, null);
});
test('green: no lock to mark -> exit 1', () => {
const repo = makeRepo('main');
assert.equal(cli(repo, ['green', '--cmd', CMD]).code, 1);
});
test('pending: a fix round clears the mark, and Stop is refused again', async () => {
const repo = lockedRepo('assert.equal(1, 1);');
assert.equal(cli(repo, ['green', '--cmd', CMD]).code, 0);
assert.equal(cli(repo, ['pending']).code, 0);
assert.equal(JSON.parse(cli(repo, ['status']).stdout).green_at, null);
assert.equal(parseJsonOut((await runHook(STOP, stop(repo))).stdout)?.decision, 'block');
});
test('re-lock of the same step starts without a mark', () => {
const repo = lockedRepo('assert.equal(1, 1);');
assert.equal(cli(repo, ['green', '--cmd', CMD]).code, 0);
assert.equal(cli(repo, ['lock', '--files', 'tests/step.test.mjs', '--step', '3']).code, 0);
assert.equal(JSON.parse(cli(repo, ['status']).stdout).green_at, null);
});
test('trekexecute: D1 marks the lock green through the command, a fix round marks it pending', () => {
const md = readFileSync(join(ROOT, 'commands', 'trekexecute.md'), 'utf8');
assert.match(md, /test-lock\.mjs green --cmd "\{Verify command\}"/);
assert.match(md, /test-lock\.mjs pending/);
});