fix: close the six defects an independent review found in the trial fixes

Independent read-only review of c56f852..2d29966 (2026-10-08), each defect
reproduced by the reviewer; each new test fails on that code.

- test-lock green ran ANY allowlisted command: `green --cmd "git status"`
  marked a red step green and the stop guard let it stop. The lock now
  stores the step's test command (`lock --cmd`); `green` runs only that
  one (a different --cmd, or a lock without one, exits 2). trekexecute D1
  calls `green` with no command, which also removes the quoting hazard.
- plan-gate --accept-open worked at round 1 and passed the gate; it now
  refuses while rounds are left.
- findings-schema --repo-root swallowed a following flag, or took a root
  that does not exist, and silently skipped the line check: exit 2.
- brief-review-stamp hashed only the body, so a changed `framing` or
  `research_topics` stayed "current"; it now covers the reviewed
  frontmatter too (only the post-review stamps are left out).
- red-proof red_kind read an assertion message naming SyntaxError as a
  load error; the signatures are now specific.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-08 07:17:45 +02:00
commit 5f9faeed4a
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
12 changed files with 192 additions and 35 deletions

View file

@ -48,7 +48,9 @@ and never touch a file the step does not list.
skip steps 1–3, write "no Test first: no test written, no proof of red" in
the report, and go to step 4.
3. **Lock the tests** (when the CLI exists):
`node {voyage_root}/lib/util/test-lock.mjs lock --files <test files> --step {step}`.
`node {voyage_root}/lib/util/test-lock.mjs lock --files <test files> --step {step} --cmd "{Verify command}"`
(the lock stores the step's test command; only that command can later
mark the step green).
If the file does not exist, write "not-locked (test-lock.mjs missing)" in the
report. Never silent. Once locked you cannot edit the tests, and that is the
point: the code changes to meet the test, never the reverse.

View file

@ -1371,7 +1371,7 @@ runner prints TAP) says what the red was: `fail 1 of 1` on a node test file is
usually a load error, not a named failing test — say so in the step report.
**The test lock (K8).** After its red gate the helper runs
`node ${CLAUDE_PLUGIN_ROOT}/lib/util/test-lock.mjs lock --files {test files} --step {N}`;
`node ${CLAUDE_PLUGIN_ROOT}/lib/util/test-lock.mjs lock --files {test files} --step {N} --cmd "{Verify command}"`;
from then on the guard refuses Write/Edit/NotebookEdit on those files, so the
code moves to meet the test and never the reverse. The executor reads
`node ${CLAUDE_PLUGIN_ROOT}/lib/util/test-lock.mjs status` and records
@ -1492,10 +1492,12 @@ controller (Sub-step E) without the stop guard refusing every turn — the
test files stay locked against edits (operator decision 2026-10-08):
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/util/test-lock.mjs green --cmd "{Verify command}"
node ${CLAUDE_PLUGIN_ROOT}/lib/util/test-lock.mjs green
```
It runs the test command itself and marks only on exit 0; exit 1 (the test
It runs the test command the helper stored in the lock (`lock --cmd`), never
one given now, and marks only on exit 0; a lock without a stored command is
never marked (exit 2), so the stop guard keeps holding as before; exit 1 (the test
did not pass this time) is a finding for Sub-step F like a FAIL, exit 2 (could
not run) is never green. A step that is `"not-locked"` skips it.
If **FAIL**: the failure output is a finding for Sub-step F; still run D2 and

View file

@ -76,7 +76,7 @@ Every validator exposes a CLI: `node lib/validators/<name>.mjs --json <path>` re
| `framing` | string | **required at ≥ 2.2** (v5.5) | `preserve \| refine \| replace \| new-direction` | How this brief relates to prior operator intent. Enum-checked on any version when present; missing → `BRIEF_MISSING_FRAMING` at `brief_version ≥ 2.2`. Layer 1 of the framing-alignment defense. |
| `intent_approved_hash` | string | optional in the schema; **required by `/trekplan`** (veikart steg 1) | `"sha256:<64 hex>"` | Hash over the normalized `## Intent` + `## Goal`. Written only by `lib/validators/intent-approval.mjs --stamp`, which `/trekbrief` runs on the operator's explicit "Approve" (Phase 4h, or `--approve <project-dir \| brief-file>`). |
| `intent_approved_at` | string | optional | ISO-8601 | When the stamp was written. Informational; the gate reads the hash. |
| `brief_reviewed_hash` / `brief_reviewed_at` | string | optional, additive (no `brief_version` bump) | `"sha256:<64 hex>"` / ISO-8601 | Hash over the brief's body (after the frontmatter), written by `lib/validators/brief-review-stamp.mjs --stamp` when `/trekbrief`'s brief-reviewer gate passes. `/trekplan` Phase 4b skips its own brief review only while the hash is current; absent (any other producer) or stale means it reviews. A trace, not a signature. |
| `brief_reviewed_hash` / `brief_reviewed_at` | string | optional, additive (no `brief_version` bump) | `"sha256:<64 hex>"` / ISO-8601 | Hash over the brief's body and the frontmatter the reviewer judges (everything but this stamp and the intent marker), written by `lib/validators/brief-review-stamp.mjs --stamp` when `/trekbrief`'s brief-reviewer gate passes. `/trekplan` Phase 4b skips its own brief review only while the hash is current; absent (any other producer) or stale (body or reviewed frontmatter such as `framing` changed) means it reviews. A trace, not a signature. |
| `ceremony` | string | optional (K1) | `small \| bounded \| architectural` | The brief's size class: sets `/trekbrief`'s question budget and review cap, and what downstream phases scale to. **Absent (or unreadable) = `architectural`**, the heaviest, so another producer's brief is never treated lighter by accident. Enum-checked when present (`BRIEF_INVALID_CEREMONY`). Read it through `briefCeremony(fm)` in `brief-validator.mjs`. |
| `artifact_store` | string | optional | `git \| local` | Whether the brief and plan are committed to the target repo (`git`) or kept out of git through `.git/info/exclude` (`local`). The approval log is committed either way. **Absent = `local`**: nothing is published without the operator's yes. Enum-checked when present (`BRIEF_INVALID_ARTIFACT_STORE`). |
@ -294,7 +294,7 @@ The strongest validation in the entire pipeline. Phase 5.5 (planning-orchestrato
| `PLAN_MODULE_MAP_UNPARSED` | 1.8 architectural: a module-map line that looks like a link but gives no edge | Write it as `a --> b` |
| `PLAN_ARCH_RULE_NO_ENFORCER` / `PLAN_ARCH_RULE_NO_PROOF` | 1.8 architectural: a rule without an enforcer, or a checked rule without an existing proving step | Name the enforcer; name the step |
**The plan gate (K3).** `node lib/validators/plan-gate.mjs <plan.md> [--json]` decides whether a plan may go on: **0** = it validates strict, its `## Critic Verdict` matches the current text, no BLOCKER is open, and every open MAJOR was accepted by the operator; **1** = stop, with `reasons` (`PLAN_GATE_INVALID_PLAN`, `PLAN_GATE_VERSION` — below plan_version 1.8, `PLAN_GATE_NO_VERDICT`, `PLAN_GATE_VERDICT_MALFORMED`, `PLAN_GATE_VERDICT_STALE`, `PLAN_GATE_OPEN_BLOCKER`, `PLAN_GATE_ROUNDS_EXHAUSTED`, `PLAN_GATE_OPEN_MAJOR` — rounds left: revise, `PLAN_GATE_OPEN_MAJOR_FINAL` — after the last round: ask the operator) and `warnings` (`PLAN_GATE_OPEN_MAJOR` for majors the operator accepted); **2** = could not read the plan. `--hash <plan.md>` prints the operative hash; Phase 9 takes it before the reviewers read the plan. `--record <plan.md> --reviewed <hash>` reads plan-review-dedup output on stdin (which lists the reviewers that delivered, `sources`) and writes the next round; exit 2 when a reviewer is missing or the plan changed since `--hash`; it refuses round 4 (exit 1, stop and ask) unless `--extra-round` follows the operator's yes, and refuses that flag before the cap is spent. Unrated findings count as blockers. The review log is excluded from the hash only up to its first other heading, so a step placed under it is hashed. `--accept-open <plan.md>` records the operator's "build as is" for the current round's open MAJORs (`accepted_open` in the verdict block, bound to the round and the plan hash; never for a BLOCKER; exit 1 when stale, a blocker is open or no major is). Operator decision 2026-10-08: open majors used to be warnings, and a weakness the critics named was built and came back from `/trekreview` as MAJOR. plan-critic and scope-guardian rate on the review catalogue's tiers. The verdict is a trace of the review, not a signature.
**The plan gate (K3).** `node lib/validators/plan-gate.mjs <plan.md> [--json]` decides whether a plan may go on: **0** = it validates strict, its `## Critic Verdict` matches the current text, no BLOCKER is open, and every open MAJOR was accepted by the operator; **1** = stop, with `reasons` (`PLAN_GATE_INVALID_PLAN`, `PLAN_GATE_VERSION` — below plan_version 1.8, `PLAN_GATE_NO_VERDICT`, `PLAN_GATE_VERDICT_MALFORMED`, `PLAN_GATE_VERDICT_STALE`, `PLAN_GATE_OPEN_BLOCKER`, `PLAN_GATE_ROUNDS_EXHAUSTED`, `PLAN_GATE_OPEN_MAJOR` — rounds left: revise, `PLAN_GATE_OPEN_MAJOR_FINAL` — after the last round: ask the operator) and `warnings` (`PLAN_GATE_OPEN_MAJOR` for majors the operator accepted); **2** = could not read the plan. `--hash <plan.md>` prints the operative hash; Phase 9 takes it before the reviewers read the plan. `--record <plan.md> --reviewed <hash>` reads plan-review-dedup output on stdin (which lists the reviewers that delivered, `sources`) and writes the next round; exit 2 when a reviewer is missing or the plan changed since `--hash`; it refuses round 4 (exit 1, stop and ask) unless `--extra-round` follows the operator's yes, and refuses that flag before the cap is spent. Unrated findings count as blockers. The review log is excluded from the hash only up to its first other heading, so a step placed under it is hashed. `--accept-open <plan.md>` records the operator's "build as is" for the current round's open MAJORs (`accepted_open` in the verdict block, bound to the round and the plan hash; only after the last round; never for a BLOCKER; exit 1 when rounds are left, the verdict is stale, a blocker is open or no major is). Operator decision 2026-10-08: open majors used to be warnings, and a weakness the critics named was built and came back from `/trekreview` as MAJOR. plan-critic and scope-guardian rate on the review catalogue's tiers. The verdict is a trace of the review, not a signature.
**Verification CLIs that read a plan** (0/1/2, JSON on stdout, 2 is never green): `lib/verification/arch-rules.mjs --repo <dir>` runs the target repo's own dependency-cruiser or import-linter (installs nothing; `--prove --rule <name> --plant <file>=<dest>` shows pass → fail on that rule → pass, refusing a destination that exists or resolves outside the repo); `lib/verification/plan-scope-sweep.mjs --plan <plan.md> --base <ref>` lists changed files no step names (`Files:`, `Test first:` File, manifest `expected_paths`) and no process path covers.

View file

@ -9,7 +9,7 @@ Imported from `CLAUDE.md` via pointer.
- `lib/util/{frontmatter,result,atomic-write,autonomy-gate}.mjs` — shared YAML-frontmatter parser + Result helpers + `atomicWriteJson(path, obj)` for tmp+rename writes + autonomy-gate state machine (v3.4.0)
- `lib/parsers/{plan-schema,manifest-yaml,project-discovery,arg-parser,bash-normalize,jaccard,finding-id}.mjs` — pure parsers (no I/O), unit-tested. `manifest-yaml` extended in v3.4.0 with additive `skip_commit_check` + `memory_write` flags (forward-compat: unknown keys ignored)
- `lib/review/{rule-catalogue,plan-review-dedup}.mjs` — version-pinned rule catalogue (14 keys) + Phase 9 inline dedup helpers (v3.4.0)
- `lib/review/{verdict-gate,engine-findings,recurrence}.mjs` — the review gate (K7: 0 ALLOW/WARN · 1 BLOCK · 2 never green), Claude Code `/code-review` + `/security-review` findings → reviewer payload, recurring finding classes → operator-approved project-memory lines (review_version 1.1); `lib/review/fill-finding-ids.mjs` computes the coordinator's `@@ID_<n>@@` placeholders over `(file, line, rule_key)`; `lib/validators/brief-review-stamp.mjs` lets `/trekplan` skip a brief review `/trekbrief` already ran on the same body
- `lib/review/{verdict-gate,engine-findings,recurrence}.mjs` — the review gate (K7: 0 ALLOW/WARN · 1 BLOCK · 2 never green), Claude Code `/code-review` + `/security-review` findings → reviewer payload, recurring finding classes → operator-approved project-memory lines (review_version 1.1); `lib/review/fill-finding-ids.mjs` computes the coordinator's `@@ID_<n>@@` placeholders over `(file, line, rule_key)`; `lib/validators/brief-review-stamp.mjs` lets `/trekplan` skip a brief review `/trekbrief` already ran on the same text
- `lib/stats/event-emit.mjs` — single-source stats event emitter for autonomy-gate transitions and main-merge-gate (v3.4.0)
- `lib/validators/{brief,research,plan,progress,session-state}-validator.mjs` — schema validators with CLI shims (`node lib/validators/X.mjs --json <path>`)
- `lib/validators/architecture-discovery.mjs` — drift-WARN external-contract discovery for `architecture/overview.md`

View file

@ -190,6 +190,10 @@ export function validateReviewerOutput(rawText, opts = {}) {
// ---- CLI shim ----------------------------------------------------------------
function isDir(p) {
try { return statSync(p).isDirectory(); } catch { return false; }
}
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
@ -205,7 +209,10 @@ if (isMain()) {
// Without the flag nothing is skipped (rootIdx + 1 would be 0: the file).
const skipIdx = rootIdx >= 0 ? rootIdx + 1 : -1;
const filePath = args.find((a, i) => !a.startsWith('--') && i !== skipIdx);
if (!filePath || (rootIdx >= 0 && !repoRoot)) {
// A missing value, a flag in its place, or a root that is not a directory
// would switch the line check off without a word: a usage error instead.
const badRoot = rootIdx >= 0 && (!repoRoot || repoRoot.startsWith('--') || !isDir(repoRoot));
if (!filePath || badRoot) {
process.stderr.write('Usage: findings-schema.mjs [--json] [--repo-root <dir>] <reviewer-output.txt|.md>\n');
process.exit(2);
}

View file

@ -10,10 +10,10 @@
// test-lock.json), so it can never be committed, and each worktree has its own.
//
// Usage:
// node lib/util/test-lock.mjs lock --files <a,b> --step <N> [--cwd <dir>]
// node lib/util/test-lock.mjs lock --files <a,b> --step <N> [--cmd "<test command>"] [--cwd <dir>]
// node lib/util/test-lock.mjs unlock [--cwd <dir>]
// node lib/util/test-lock.mjs status [--cwd <dir>] (JSON on stdout)
// node lib/util/test-lock.mjs green --cmd "<test command>" [--cwd <dir>]
// node lib/util/test-lock.mjs green [--cmd "<test command>"] [--cwd <dir>]
// node lib/util/test-lock.mjs pending [--cwd <dir>]
// Exit: 0 = done, 1 = refused (another step holds the lock; for green: no
// lock, or the test command did not exit 0), 2 = could not run (not a
@ -24,7 +24,10 @@
// three times while the executor waited for its controller, with the step's
// test already green (voyage-lekerepo trial, 2026-10-06). `green` RUNS the
// step's test command (the same allowlist and denylist as done-line.mjs) and
// records `green_at` only on exit 0; the stop guard then lets the session
// records `green_at` only on exit 0. The command is the one stored at `lock
// --cmd` time, never one chosen at mark time: an independent review showed
// `green --cmd "git status"` marking a red step green. A lock without a
// stored command is never marked (exit 2); a --cmd that differs is refused; the stop guard then lets the session
// stop, while the write guard keeps refusing the locked files. `pending`
// clears the mark when a fix round starts; a new `lock` starts without one.
// Paths in --files are relative to the --cwd (default: the current directory).
@ -157,7 +160,7 @@ function main(argv) {
const l = cur.lock || {};
process.stdout.write(JSON.stringify({
locked: cur.state === 'locked', step: l.step ?? null, files: l.files ?? [], session: l.session ?? null,
locked_at: l.locked_at ?? null, green_at: l.green_at ?? null, lock_path: cur.path,
locked_at: l.locked_at ?? null, cmd: l.cmd ?? null, green_at: l.green_at ?? null, lock_path: cur.path,
}, null, 2) + '\n');
process.exit(0);
}
@ -169,12 +172,14 @@ function main(argv) {
if (cmd === 'pending') {
delete lock.green_at;
} else {
if (!args.cmd) fail(2, 'green needs --cmd "<the step\'s test command>"');
const allowed = allowedCommand(args.cmd, { cwd: cur.repo.root });
const testCmd = typeof lock.cmd === 'string' && lock.cmd.trim() !== '' ? lock.cmd : null;
if (!testCmd) fail(2, 'the lock names no test command (lock with --cmd "<Verify>"); never marked green');
if (args.cmd !== undefined && args.cmd !== testCmd) fail(2, `--cmd is not the step's test command stored in the lock (${testCmd})`);
const allowed = allowedCommand(testCmd, { cwd: cur.repo.root });
if (!allowed.allowed) fail(2, `command not run: ${allowed.reason}`);
const screen = screenCommand(args.cmd);
const screen = screenCommand(testCmd);
if (!screen.allowed) fail(2, `command blocked: ${screen.rule}`);
const r = spawnSync('/bin/sh', ['-c', args.cmd], {
const r = spawnSync('/bin/sh', ['-c', testCmd], {
cwd: cur.repo.root, env: commandEnv(), encoding: 'utf8', timeout: DEFAULT_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024,
});
if (r.error || r.status === null || r.status === 126 || r.status === 127) {
@ -211,6 +216,10 @@ function main(argv) {
session: process.env.CLAUDE_CODE_SESSION_ID || null,
locked_at: new Date().toISOString(),
};
if (args.cmd !== undefined) {
if (String(args.cmd).trim() === '') fail(2, '--cmd is empty');
lock.cmd = String(args.cmd);
}
mkdirSync(dirname(cur.path), { recursive: true });
writeFileSync(cur.path, JSON.stringify(lock, null, 2) + '\n');
writeSessionPointer(lock.session, cur.path);

View file

@ -10,10 +10,13 @@
// brief_reviewed_at: "<ISO-8601>"
//
// /trekplan Phase 4b runs `--check`: `current` skips the second review,
// `stale` (the body changed after the review) or `absent` (no stamp: a gate
// `stale` (the brief changed after the review) or `absent` (no stamp: a gate
// that did not pass, or a brief from another producer) runs it as before.
// Frontmatter is outside the hash, so the intent approval stamp written after
// the review keeps it current; CRLF and trailing whitespace do not count.
// The hash covers the body AND the frontmatter the reviewer judges
// (`framing`, `research_topics`, ...): an independent review showed a
// body-only hash staying current after `framing` changed. Only the stamps
// written after the review are left out (this one and the intent approval
// marker); CRLF and trailing whitespace do not count.
//
// WHAT THE STAMP DOES NOT PROVE: the same trust class as the intent marker
// (lib/validators/intent-approval.mjs) — any session that can edit the brief
@ -40,10 +43,15 @@ function split(text) {
return { m, front: m[2], body: String(text).slice(m.index + m[0].length) };
}
/** sha256 over the body: CRLF -> LF, trailing whitespace per line and at the end dropped. */
export function computeBodyHash(body) {
const norm = String(body ?? '').replace(/\r\n?/g, '\n').split('\n').map((l) => l.replace(/\s+$/, '')).join('\n').replace(/\n+$/, '');
return `sha256:${createHash('sha256').update(norm, 'utf8').digest('hex')}`;
// Frontmatter written AFTER the review; everything else is what was reviewed.
const POST_REVIEW_FIELDS = Object.freeze([REVIEWED_HASH_FIELD, REVIEWED_AT_FIELD, 'intent_approved_hash', 'intent_approved_at']);
const normText = (t) => String(t ?? '').replace(/\r\n?/g, '\n').split('\n').map((l) => l.replace(/\s+$/, '')).join('\n').replace(/\n+$/, '');
/** sha256 over the reviewed frontmatter + the body: CRLF -> LF, trailing whitespace dropped. */
export function computeReviewHash(front, body) {
const kept = normText(front).split('\n').filter((l) => !POST_REVIEW_FIELDS.some((f) => l.startsWith(`${f}:`))).join('\n');
return `sha256:${createHash('sha256').update(`${kept}\n---\n${normText(body)}`, 'utf8').digest('hex')}`;
}
function fieldValue(front, name) {
@ -56,7 +64,7 @@ function fieldValue(front, name) {
export function stampBriefReview(text, now = new Date()) {
const parts = split(text);
if (!parts) return { stamped: false, text, reason: 'no frontmatter block' };
const hash = computeBodyHash(parts.body);
const hash = computeReviewHash(parts.front, parts.body);
const kept = parts.front.split(/\r?\n/).filter((l) =>
!l.startsWith(`${REVIEWED_HASH_FIELD}:`) && !l.startsWith(`${REVIEWED_AT_FIELD}:`));
kept.push(`${REVIEWED_HASH_FIELD}: "${hash}"`, `${REVIEWED_AT_FIELD}: "${now.toISOString()}"`);
@ -70,7 +78,7 @@ export function checkBriefReview(text) {
const parts = split(text);
if (!parts) return { state: 'absent', stamped: null, actual: null };
const stamped = fieldValue(parts.front, REVIEWED_HASH_FIELD);
const actual = computeBodyHash(parts.body);
const actual = computeReviewHash(parts.front, parts.body);
if (!stamped) return { state: 'absent', stamped: null, actual };
return { state: stamped === actual ? 'current' : 'stale', stamped, actual };
}

View file

@ -25,8 +25,8 @@
// After the operator's explicit yes to "build as is": records the open
// MAJORs of the current round as accepted (`accepted_open` in the verdict
// block, bound to the round and the plan hash). 0 = written; 1 = refused
// (a BLOCKER is open, no MAJOR is open, or the verdict is stale); 2 = could
// not run.
// (rounds left, a BLOCKER is open, no MAJOR is open, or the verdict is
// stale); 2 = could not run.
//
// Open MAJORs (operator decision 2026-10-08, voyage-lekerepo trial point 6):
// they used to pass as warnings, and after the last round there was no way to
@ -271,6 +271,10 @@ export function acceptOpen(rawText, opts = {}) {
if (v.findings.some(f => sevOf(f) !== 'major' && sevOf(f) !== 'minor')) return { code: 1, message: 'a BLOCKER is open; a blocker is never accepted' };
const majors = v.findings.filter(f => sevOf(f) === 'major');
if (majors.length === 0) return { code: 1, message: 'no MAJOR is open; nothing to accept' };
// Only the last round: with rounds left the answer is to revise, not accept
// (found by the independent review: acceptance at round 1 passed the gate).
const left = v.max_rounds + (v.extra_rounds ?? 0) - v.round;
if (left > 0) return { code: 1, message: `${left} critic round(s) left: revise the plan and run the next round; acceptance is only for after the last round` };
const accepted = {
...v,
accepted_open: {

View file

@ -84,7 +84,10 @@ export function repoRoot(dir) {
// missing — but the result says which kind, in a field (`red_kind`), instead
// of leaving it to a reader of `tap` (null without a TAP reporter: the
// voyage-lekerepo trial's step 1, 2026-10-06).
const LOAD_ERROR = /SyntaxError|ERR_MODULE_NOT_FOUND|does not provide an export named|Cannot find module|ERR_REQUIRE_ESM|ModuleNotFoundError|ImportError/;
// Specific signatures only: a bare `SyntaxError` anywhere also matched an
// assertion message («Missing expected exception (SyntaxError)») of a named
// failing test (independent review, 2026-10-08).
const LOAD_ERROR = /^\s*SyntaxError: |ERR_MODULE_NOT_FOUND|does not provide an export named|Cannot find module|ERR_REQUIRE_ESM|ModuleNotFoundError|ImportError: /m;
function run(cmd, cwd, timeoutMs) {
const r = spawnSync('/bin/sh', ['-c', cmd], { cwd, env: commandEnv(), encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });

View file

@ -22,7 +22,7 @@
| trekbrief-stats | ts, task, slug, mode, interview_turns, review_iterations, brief_quality, research_topics, auto_research, auto_result, intent_approved, project_dir, order_id, order_claimed_at, order_reason | commands/trekbrief.md (orchestrator-emit Phase 7; order_* added by lib/stats/stats-append.mjs) | trekbrief.md:1124-1139 | profile, phase_models, profile_source | none |
| trekresearch-stats | ts, question, mode, scope, engine, slug, project_dir, brief_path, dimensions, dimensions_baseline, dimensions_baseline_preserved, effort, conv_turns, empty_turns, unique_sources, agents_local, agents_external, gemini_used, confidence, contradictions, open_questions | lib/stats/stats-append.mjs (trekresearch.md Stats tracking) | trekresearch.md:782-808 | profile, phase_models, parallel_agents, external_research_enabled, profile_source | none |
| trekplan-stats | ts, task, mode, slug, brief_path, project_dir, codebase_size, codebase_files, agents_deployed, deep_dives, research_briefs_used, research_scout_used, critic_verdict, guardian_verdict, outcome | lib/stats/stats-append.mjs (trekplan.md Phase 12) | trekplan.md:1115-1135 | profile, phase_models, parallel_agents, profile_source | none |
| trekexecute-stats (Phase 9 record) | ts, plan, plan_type, mode, result, steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step | lib/stats/stats-append.mjs (trekexecute.md Phase 9) | trekexecute.md:2341-2363 | profile, phase_models, profile_source | none |
| trekexecute-stats (Phase 9 record) | ts, plan, plan_type, mode, result, steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step | lib/stats/stats-append.mjs (trekexecute.md Phase 9) | trekexecute.md:2343-2365 | profile, phase_models, profile_source | none |
| trekexecute-stats (autonomy events) | ts, event, known_event, payload | lib/stats/event-emit.mjs `emit()` | event-emit.mjs:78-99 | payload.profile, payload.phase_models, payload.profile_source | none |
| trekexecute-stats (PostToolUse Bash) | ts, session_id, command_excerpt, duration_ms, success | hooks/scripts/post-bash-stats.mjs (Bash PostToolUse) | post-bash-stats.mjs:42-54 | none (hook is plugin-level, not profile-aware) | command_excerpt (CWE-212) |
| trekreview-stats | ts, slug, verdict, counts (BLOCKER/MAJOR/MINOR/SUGGESTION), reviewed_files_count, mode, duration_ms | lib/stats/stats-append.mjs (trekreview.md Phase 8) | trekreview.md:628-632 | profile, phase_models, profile_source | none |

View file

@ -22,6 +22,7 @@ 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()}`;
const CMD = 'node --test tests/step.test.mjs';
function cli(repo, args) {
const r = spawnSync(process.execPath, [CLI, ...args], { cwd: repo, encoding: 'utf8', env: { ...process.env, CLAUDE_CODE_SESSION_ID: SESS } });
@ -37,11 +38,9 @@ function lockedRepo(testBody) {
`test('step', () => { ${testBody} });`,
'',
].join('\n'));
assert.equal(cli(repo, ['lock', '--files', 'tests/step.test.mjs', '--step', '3']).code, 0);
assert.equal(cli(repo, ['lock', '--files', 'tests/step.test.mjs', '--step', '3', '--cmd', CMD]).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' } });
@ -62,7 +61,7 @@ test('green: the test fails -> exit 1, no mark, Stop is still refused', async ()
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', () => {
test('green: a --cmd that is not the stored test command 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);
@ -85,12 +84,13 @@ test('pending: a fix round clears the mark, and Stop is refused again', async ()
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(cli(repo, ['lock', '--files', 'tests/step.test.mjs', '--step', '3', '--cmd', CMD]).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 green\n/);
assert.match(md, /test-lock\.mjs lock --files \{test files\} --step \{N\} --cmd "\{Verify command\}"/);
assert.match(md, /test-lock\.mjs pending/);
});

View file

@ -0,0 +1,122 @@
// tests/lib/independent-review-fixes.test.mjs
// Findings of the independent review of c56f852..2d29966 (2026-10-08), each
// verified by the reviewer by running it; each test fails on that code.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { runHook } from '../helpers/hook-helper.mjs';
import { makeRepo, parseJsonOut } from '../hooks/guard-helpers.mjs';
import { recordVerdict, planHash, acceptOpen, gatePlan } from '../../lib/validators/plan-gate.mjs';
import { stampBriefReview, checkBriefReview } from '../../lib/validators/brief-review-stamp.mjs';
import { stampIntentApproval } from '../../lib/validators/intent-approval.mjs';
import { buildPlan } from '../helpers/plan-v18-builder.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const LOCK = join(ROOT, 'lib', 'util', 'test-lock.mjs');
const STOP = join(ROOT, 'hooks', 'scripts', 'stop-test-lock.mjs');
const SESS = `sess-review-${process.pid}-${Date.now()}`;
// --- 1 + 5: the green mark runs the step's OWN test command, stored at lock time
function lockRepo(withCmd) {
const repo = makeRepo('main');
mkdirSync(join(repo, 'tests'), { recursive: true });
writeFileSync(join(repo, 'tests', 'a.test.mjs'), "import { test } from 'node:test';\nimport assert from 'node:assert';\ntest('a', () => assert.equal(1, 2));\n");
const args = ['lock', '--files', 'tests/a.test.mjs', '--step', '1'];
if (withCmd) args.push('--cmd', 'node --test tests/a.test.mjs');
const r = spawnSync(process.execPath, [LOCK, ...args], { cwd: repo, encoding: 'utf8', env: { ...process.env, CLAUDE_CODE_SESSION_ID: SESS } });
assert.equal(r.status, 0, r.stderr);
return repo;
}
const lockCli = (repo, args) => spawnSync(process.execPath, [LOCK, ...args], { cwd: repo, encoding: 'utf8', env: { ...process.env, CLAUDE_CODE_SESSION_ID: SESS } });
test('green: another allowed command (git status) can never mark a red step green', async () => {
const repo = lockRepo(true);
assert.notEqual(lockCli(repo, ['green', '--cmd', 'git status']).status, 0);
assert.equal(lockCli(repo, ['green']).status, 1, 'the stored test command is red');
assert.equal(JSON.parse(lockCli(repo, ['status']).stdout).green_at, null);
const stop = await runHook(STOP, { hook_event_name: 'Stop', session_id: SESS, cwd: repo, stop_hook_active: false });
assert.equal(parseJsonOut(stop.stdout)?.decision, 'block');
});
test('green: a lock that names no test command is never marked green (exit 2)', () => {
const repo = lockRepo(false);
assert.equal(lockCli(repo, ['green', '--cmd', 'node --test tests/a.test.mjs']).status, 2);
});
test('green without --cmd runs the stored command; status shows it', () => {
const repo = lockRepo(true);
writeFileSync(join(repo, 'tests', 'a.test.mjs'), "import { test } from 'node:test';\ntest('a', () => {});\n");
assert.equal(lockCli(repo, ['green']).status, 0);
assert.equal(JSON.parse(lockCli(repo, ['status']).stdout).cmd, 'node --test tests/a.test.mjs');
});
test('prose: the helper locks with --cmd, and D1 calls green without re-quoting the command', () => {
assert.match(readFileSync(join(ROOT, 'agents', 'step-implementer.md'), 'utf8'), /test-lock\.mjs lock --files <test files> --step \{step\} --cmd "\{Verify command\}"/);
const ex = readFileSync(join(ROOT, 'commands', 'trekexecute.md'), 'utf8');
assert.match(ex, /test-lock\.mjs green`?\n/);
assert.doesNotMatch(ex, /test-lock\.mjs green --cmd/);
});
// --- 2: --accept-open only after the last round -------------------------------
const BOTH = [{ agent: 'plan-critic', present: true }, { agent: 'scope-guardian', present: true }];
test('--accept-open refuses while rounds are left: revise instead', () => {
const t = buildPlan();
const r = recordVerdict(t, { findings: [{ id: 'm', severity: 'major', text: 'weak test' }], sources: BOTH }, { reviewed: planHash(t) });
const a = acceptOpen(r.text);
assert.equal(a.code, 1);
assert.match(a.message, /round\(s\) left/);
assert.equal(gatePlan(r.text).code, 1);
});
// --- 3: --repo-root never swallows a flag or accepts a missing root ----------
test('findings-schema: --repo-root followed by a flag, or naming no directory, is a usage error (exit 2)', () => {
const dir = mkdtempSync(join(tmpdir(), 'voyage-root-'));
try {
const out = join(dir, 'r.md');
writeFileSync(out, '```json\n{"reviewer":"x","findings":[]}\n```\n');
const cli = (...a) => spawnSync(process.execPath, [join(ROOT, 'lib', 'review', 'findings-schema.mjs'), ...a], { encoding: 'utf8' });
assert.equal(cli('--repo-root', '--json', out).status, 2);
assert.equal(cli('--repo-root', join(dir, 'nope'), out).status, 2);
assert.equal(cli('--repo-root', dir, out).status, 0);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// --- 4: the stamp covers the frontmatter the reviewer judges -----------------
const BRIEF = ['---', 'type: trekbrief', 'framing: preserve', 'research_topics: 2', '---', '', '## Intent', '', 'Why.', '', '## Goal', '', 'What.', ''].join('\n');
test('brief stamp: changing framing or research_topics after the review makes it stale', () => {
const s = stampBriefReview(BRIEF).text;
assert.equal(checkBriefReview(s.replace('framing: preserve', 'framing: replace')).state, 'stale');
assert.equal(checkBriefReview(s.replace('research_topics: 2', 'research_topics: 0')).state, 'stale');
assert.equal(checkBriefReview(stampIntentApproval(s).text).state, 'current', 'the intent stamp still keeps it current');
});
// --- 6: red_kind does not read an assertion message as a load error ----------
test('red proof: a named test whose assertion mentions SyntaxError is a test-failure', async () => {
const { proveRed } = await import('../../lib/verification/red-proof.mjs');
const repo = makeRepo('main');
mkdirSync(join(repo, 'lib'));
writeFileSync(join(repo, 'lib', 'p.mjs'), 'export function parse(s) { return s; }\n');
spawnSync('git', ['add', 'lib/p.mjs'], { cwd: repo });
spawnSync('git', ['commit', '-q', '-m', 'base'], { cwd: repo });
const base = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).stdout.trim();
mkdirSync(join(repo, 'tests'));
writeFileSync(join(repo, 'tests', 'p.test.mjs'), "import { test } from 'node:test';\nimport assert from 'node:assert';\nimport { parse } from '../lib/p.mjs';\ntest('throws on bad input', () => { assert.throws(() => parse('{'), SyntaxError); });\n");
writeFileSync(join(repo, 'lib', 'p.mjs'), 'export function parse(s) { return JSON.parse(s); }\n');
const r = proveRed({ cwd: repo, base, cmd: 'node --test tests/p.test.mjs', tests: ['tests/p.test.mjs'] });
assert.equal(r.status, 'proven', JSON.stringify(r).slice(0, 400));
assert.equal(r.red_kind, 'test-failure');
});