fix(execute): the step loop survives an adversarial review

An independent review of abc3083 found 15 defects, the two worst verified
in scratch repos. Each fix started with a test that failed.

- Proof of red could be "proven" by the environment: a start point
  unpacked with git archive had no node_modules and no .git, so any test
  failed there. The start point is now a git clone --shared checked out at
  start_sha, gitignored top-level entries are linked in, and a control run
  (start point + the change) must pass, or the result is not-run.
- The Test first file was never committed (the template lists it apart
  from Files:) and leaked into the next step's diff. The step's scope is
  now Files: + test files, staged at the checkpoint.
- New files outside Files: were invisible to every check. Every untracked
  file is registered with --intent-to-add; a revert unregisters them.
- The floor guard paired renames (foo.test.js -> .bak read as clean) and
  missed skipIf and node's t.skip/t.todo; prose files no longer count as
  stubs.
- Phase 7.5 audited from the last step's start_sha; it now uses the run's
  session_start_sha.
- The done line is written after the checkpoint and checked with --commit.
- fix_rounds never passes 5; attempts are never reset; re-control keeps
  the spec/quality verdicts and earlier floor-guard rulings; the closed
  stop list maps every existing stop; failed vs stopped made consistent;
  the controller's effort is said, not assumed.

Tests: 1470 (1468 pass, 0 fail, 2 skipped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-06 20:17:39 +02:00
commit 44dcc779a9
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
11 changed files with 466 additions and 148 deletions

View file

@ -304,7 +304,7 @@ Per step: record the start point → **one fresh helper** (`step-implementer`) w
|------|--------------|-----|
| Fresh helper per step | `voyage:step-implementer` (opus, effort high, no Agent tool) builds one step and writes a report: **ran · saw · did NOT check** | Each step starts from the repository and the plan, not from a long context |
| Start point | `start_sha` recorded before the step; every check reads `git diff {start_sha}` | The review package is the diff, not a story |
| Proof of red | the step's test must fail at the start point and pass at the result — run by `red-proof.mjs`, not claimed | A test that passes without the change proves nothing |
| Proof of red | the step's test must fail at the start point and pass at the result, and a control run (the start point plus the change) must pass too — run by `red-proof.mjs`, not claimed | A test that passes without the change proves nothing; a start point that fails for an environment reason proves nothing either |
| Test lock | the helper locks its test files after the red proof (`test-lock.mjs`, when installed; otherwise "not-locked" is reported) | The code moves to meet the test, never the reverse |
| Floor guard | `test-integrity.mjs` reads the diff for weakened tests; exit 2 ("could not run") is never green | Cheating the tests is caught by a command |
| Controller | one general helper, two verdicts (spec, quality), re-checks only what a fix round changed | An independent judge of the diff |

View file

@ -18,7 +18,7 @@ and never touch a file the step does not list.
## What the executor gives you
- `plan` — path to the plan; `step` — the step number; the step's text verbatim
- `plan` — path to the plan; `plan_version`; `step` — the step number; the step's text verbatim
(Files, Changes, Reuses, Test first, Verify, and when present Interface in,
Interface out, Review focus).
- `start_sha` — the commit the step starts from. Everything you change is read
@ -33,20 +33,28 @@ and never touch a file the step does not list.
1. **Test first.** Read the step's `Test first:` and write that test (and only
that test) first. Follow the `Pattern:` file's style when one is named.
Then register every new file you created so the executor's diff sees it:
`git add --intent-to-add <path>` (this stages nothing).
`git add --intent-to-add -- <path>` (this stages nothing).
2. **Prove it red before you build.** Run, from the repository root:
`node {voyage_root}/lib/verification/red-proof.mjs --red-only --base {start_sha} --cmd "<the step's Verify command>" --tests <test files>`
`node {voyage_root}/lib/verification/red-proof.mjs --red-only --base {start_sha} --cmd "<the step's Verify command>" --tests <test files as ONE comma-separated argument: a,b>`
Exit 0 = the test fails at the start point: continue. Exit 1 = the test
already passes at the start point, so it proves nothing: rewrite the test
until it fails for the reason the step exists, and run the check again.
Exit 2 = it could not run (the Verify command is not one test-runner
invocation, or a file is missing): do not build; say so in the report and stop.
invocation, or a file is missing): on `plan_version` ≥ 1.8 do not build;
say so in the report and stop. Below 1.8, write "red proof not run:
{reason}" in the report and go on to step 4 without a lock.
**A step with no `Test first:`** (allowed only below plan_version 1.8):
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}`.
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.
4. **Implement exactly the step's `Changes:`** in the files its `Files:` lists.
4. **Implement exactly the step's `Changes:`** in the files its `Files:` lists
(and its test files from step 1 — nothing else; register any new file
with `git add --intent-to-add -- <path>`).
Read `Reuses:` first. No extra scope, no refactors on the side, no "while I
was here". If the step cannot be done as written, stop and say why in the
report: the executor sends it up to the plan; you do not improvise.
@ -60,6 +68,11 @@ else. The tests stay locked: if a finding says the test itself is wrong, do not
work around it - say so in the report; that goes up to the plan. Run the
step's `Verify:` again.
The one exception: a `not-red-at-base` finding (the test passes without the
change). The executor has released the lock for that round. Rewrite the test
so it fails for the reason the step exists, pass the `--red-only` gate again
(build step 2), and lock it again (build step 3).
## Never
- Never edit a test to make it pass: no deleted assert, no skip, no looser
@ -70,7 +83,12 @@ step's `Verify:` again.
the test command exits 0.
- Never commit, push, merge or tag. Never write outside the repository.
## The report (write it to `report_path`, then answer with one line)
## The report (APPEND it to `report_path`, then answer with one line)
The report file holds every round of the step and the controller's verdicts.
Add your section at the end — with Edit, or by rewriting the file with every
earlier section kept word for word. Never remove or rewrite what is there.
Use the round label the executor gave you in the heading.
```markdown
## Step {N} - {build | fix round R}

View file

@ -1048,6 +1048,7 @@ progress file.
"mode": "{execute | resume | step}",
"total_steps": 0,
"current_step": 0,
"session_start_sha": null,
"status": "{in_progress | completed | failed | stopped | partial}",
"steps": {
"1": { "status": "pending", "attempts": 0, "fix_rounds": 0, "start_sha": null, "report_path": null, "red_proof": null, "test_lock": null, "error": null, "completed_at": null, "commit": null }
@ -1067,7 +1068,8 @@ a progress file without them still validates and resumes:
| Field | Written | Meaning |
|---|---|---|
| `steps.{N}.start_sha` | Sub-step 0 | the commit the step started from; every check reads `git diff {start_sha}` |
| `session_start_sha` | Phase 3 | the commit the run started from; Phase 7.5 audits the whole run from it |
| `steps.{N}.start_sha` | Sub-step 0 | the commit the step started from; every check of the step reads `git diff {start_sha}` |
| `steps.{N}.report_path` | Sub-step 0 | the step's report file (helper rounds + the done line) |
| `steps.{N}.red_proof` | Sub-step B | `"proven" \| "rejected" \| "not-run"` — the exit of `red-proof.mjs` |
| `steps.{N}.test_lock` | Sub-steps B, G | `"locked" \| "released" \| "not-locked"` (K8) |
@ -1102,6 +1104,12 @@ present in the spec at minimum — there is **no `(if wired)` conditional**.
### Mode-specific behavior
**Run start point (every mode that executes):** a fresh progress file records
`session_start_sha` = `git rev-parse HEAD` before step 1; a resumed one keeps
the value it has (a legacy file without it is backfilled with the parent of
the first completed step's `commit`, or `HEAD` when none is completed). Phase
7.5 audits the whole run from it — never from a step's own `start_sha`.
**mode = execute (fresh):**
- If a progress file exists with status `in_progress`, `failed`, `stopped` or `partial`: warn that
`--resume` is available, then wait 3 seconds (`sleep 3`) and start fresh.
@ -1218,7 +1226,9 @@ Every stop names its class in the final report (Phase 8 `### Stops`).
1. **Something that cannot be undone.** A step or command whose effect cannot
be reverted from git: deleting data outside the repo's tracked files, a
migration against a live store, anything the plan marks irreversible.
migration against a live store, anything the plan marks irreversible — and
a dirty main checkout before a multi-session wave (Phase 2.55 Check 1:
merging would lose the uncommitted work).
2. **Something security-sensitive.** A command the executor denylist blocks
(Phase 2.4, Sub-step D1), a write to a security-sensitive path (Hard
rule 16), credentials or secrets in a diff.
@ -1228,7 +1238,13 @@ Every stop names its class in the final report (Phase 8 `### Stops`).
4. **A plan so broken that everything after it is guesswork.** The plan fails
to parse, a ≥ 1.8 step has no `Test first:`, a step's own `On failure:
escalate` fired (the plan's author declared that past this point is
guesswork), or a step went up the fix ladder to the plan (Sub-step F).
guesswork), a step went up the fix ladder (Sub-step F), the global
iteration budget reached 0 — and the plan's own preconditions failing: an
unmet entry condition (Phase 4), or the sandbox pre-flight exiting 77
(Sub-step D1: the plan cannot be carried out where it runs).
Every stop the executor can make maps to one of these four; `stop_class` in
the summary is that number.
Not on the list, and therefore never a stop: a failing test (that is a fix
round), an ambiguity the plan leaves open (decide, and log it), a style
@ -1311,8 +1327,11 @@ Treat this as an automatic `escalate`. Jump to the stop-and-report logic.
The step's test is written first, and a command proves it red — never the
model's word that "it failed first". This sub-step blocks: a step whose test
is not proven red at its start point and green at its result does not pass.
(A plan below 1.8 whose step has no `Test first:` skips it: `red_proof` =
`"not-run"`, counted in the final report, never called proven.)
**The step's test files** are the `File:` paths under its `Test first:`. The
plan template lists them apart from `Files:`, so everywhere below **the step's
scope = its `Files:` + its test files**: both are built, both are committed at
the checkpoint, and neither is a scope finding.
**The test command** is the step's `Verify:` command. It must be ONE
test-runner invocation without shell operators (the allowlist of
@ -1321,25 +1340,28 @@ with exit 2.
1. **The helper proves red before it builds** (Sub-step C): it writes the test
from `Test first:`, runs the gate
`red-proof.mjs --red-only --base {start_sha} --cmd "{Verify}" --tests {files}`
`red-proof.mjs --red-only --base {start_sha} --cmd "{Verify}" --tests {test files, ONE comma-separated argument}`
(the test must FAIL at the start point), locks the test files, and only then
implements.
2. **The executor proves it both ways after the helper returns:**
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/verification/red-proof.mjs --base {start_sha} --cmd "{Verify command}" --tests {Test first File paths, comma-separated} --json
node ${CLAUDE_PLUGIN_ROOT}/lib/verification/red-proof.mjs --base {start_sha} --cmd "{Verify command}" --tests {test files, ONE comma-separated argument} --json
```
The test must FAIL against the start point (`start_sha` unpacked with
`git archive`, the step's test files copied in) and must PASS against the
result (the working tree). The start point holds tracked files only: add
`--link node_modules` (or the repo's untracked dependency directory) when
the test needs it.
The test must FAIL against the start point and PASS against the result
(the working tree), and a **control run** must PASS: the start-point copy
with the step's whole change laid on top. The start point is a temp
`git clone --shared` checked out at `start_sha` (a real git checkout), with
the step's test files copied in and every gitignored top-level entry of the
working tree (`node_modules`, `.venv`, …) linked in; `--link a,b` adds
deeper dependency paths. A start point that fails for an environment reason
fails the control too, so it is never read as red.
| Exit | `steps.{N}.red_proof` | Executor |
|------|-----------------------|----------|
| 0 | `"proven"` | continue |
| 1, `not-red-at-base` | `"rejected"` | the test passes without the change, so it proves nothing: a finding for Sub-step F |
| 1, `not-red-at-base` | `"rejected"` | the test passes without the change, so the TEST is wrong: a finding for Sub-step F, and that fix round is the one exception to the lock — the executor releases the step's test lock for it, the fix helper rewrites the test, passes the `--red-only` gate and locks again |
| 1, `not-green-at-result` | `"rejected"` | the change does not make the test pass: a finding for Sub-step F |
| 2 | `"not-run"` | never a pass and never proven. When the reason is the command (the `Verify:` is not one test-runner invocation) on a plan ≥ 1.8, the plan is what failed: the step goes straight up to the plan (Sub-step F, `fix_rounds = 4`, stop class 4). Any other reason (a missing test file, an unresolvable start point) is a finding for Sub-step F |
| 2 | `"not-run"` | never a pass and never proven. **plan_version ≥ 1.8:** when the reason is the command (the `Verify:` is not one test-runner invocation), the plan is what failed: the step goes straight up to the plan (Sub-step F, `fix_rounds = 4`, stop class 4); any other reason (a missing test file, an unresolvable start point, a control run that stays red) is a finding for Sub-step F. **Below 1.8:** a step with no `Test first:`, or whose `Verify:` is not a test-runner command, is tolerated as `"not-run"` — counted in the final report among the steps without proof of red, never called proven; any other reason is a finding |
The exit code is the verdict, the output text is not. `base.tap` (when the
runner prints TAP) says what the red was: `fail 1 of 1` on a node test file is
@ -1363,10 +1385,15 @@ the step with the Agent tool:
Launch `subagent_type: "voyage:step-implementer"` with a short `description`
("step {N}: {title}") and a prompt that carries, as files and values, not as a
summary: the plan path, the step number, the step's text verbatim (every
field, including `Interface in:`, `Interface out:` and `Review focus:` when
present), `start_sha`, `report_path`, `mode: build`, and
`voyage_root: ${CLAUDE_PLUGIN_ROOT}`.
summary: the plan path, `plan_version`, the step number, the step's text
verbatim (every field, including `Interface in:`, `Interface out:` and
`Review focus:` when present), `start_sha`, `report_path`, `mode: build`, the
round label (`build`, or `fix round R`), and `voyage_root: ${CLAUDE_PLUGIN_ROOT}`.
**A step at `fix_rounds` 5 is never built again** (Sub-step F): skip the
launch and run only the checks (B, D1, D2, D3, E) on whatever the operator
changed. A step at `fix_rounds` 4 on `--resume` was re-planned: launch a fresh
`build` helper for the revised step.
- **Never pass the Agent tool's `name` parameter.** A named spawn becomes an
`in_process_teammate`: it does the work, but its result never reaches this
@ -1382,21 +1409,31 @@ present), `start_sha`, `report_path`, `mode: build`, and
- **Wait for the helper to finish** before anything else; when it runs in the
background, its completion notification is the signal. Never start the next
sub-step or step while a helper runs.
- `steps.{N}.attempts` = the number of helpers launched for the step (1 here).
- `steps.{N}.attempts` += 1 for every helper launched for the step — this
one, each fix round's, and the `build` helper after a re-plan. It is never
reset, and every launch after a step's first also takes 1 from
`iterations_remaining`, so the completion gate's reconciliation
(`Σ (attempts − 1)`) holds across re-plans.
The helper's one-line answer is not evidence. After it returns:
1. The report must exist at `report_path` with its `### Ran`, `### Saw` and
`### Did NOT check` sections (a missing report is a finding for Sub-step F).
2. Register the step's new files so the diff sees them: for each path in
`Files:` that `git status --porcelain` shows as `??`, run
`git add --intent-to-add {path}` (stages nothing).
3. A changed path outside the step's `Files:` is a scope finding (Hard rule 2)
for the controller. The executor does not revert it on its own.
1. The report at `report_path` must carry a NEW section for this round
(`## Step {N} - {round label}`) with its `### Ran`, `### Saw` and
`### Did NOT check`; earlier rounds' sections and verdicts stay. A missing
section is a finding for Sub-step F (stale sections do not count).
2. Register EVERY new file so the diff and every check see it — not only the
listed ones: for each path `git ls-files --others --exclude-standard`
prints, run `git add --intent-to-add -- {path}` (stages nothing). A new file
the step did not list is otherwise invisible to `git diff`, to the floor
guard and to the controller. Voyage's own progress file and step reports
are the one exception: never registered.
3. A changed or new path outside the step's scope (`Files:` + test files) is a
scope finding (Hard rule 2) for the controller. The executor does not
revert it on its own.
**Rules the helper is held to** (its agent file carries them; the controller
checks them in the diff): follow `Changes:` exactly — no improvising, no added
scope; touch only the files in `Files:`; read `Reuses:` first; never weaken a
test; never write a stub; never commit.
scope; touch only the step's scope (`Files:` + test files); read `Reuses:`
first; never weaken a test; never write a stub; never commit.
#### Sub-step D1 — Command verification
@ -1513,7 +1550,10 @@ every one with its ruling.
— whatever they said — launch one controller with the Agent tool:
`subagent_type: "general-purpose"`, `model: "opus"`, no `name` (Sub-step C),
no `isolation`. It is a general helper with the prompt below, not a second
Voyage agent. It reads the diff, not the helper's summary: it is never given
Voyage agent. The Agent tool sets no reasoning effort, so the controller runs
at the session's effort: run `/trekexecute` at effort `high` or above (the
standard). A session below `high` is a decision-log entry ("controller ran at
effort {level}"), never silent. It reads the diff, not the helper's summary: it is never given
the step report, and it judges what changed, not what the helper says changed.
The prompt (fill in every `{…}`; pass the mechanical results verbatim as JSON):
@ -1554,41 +1594,50 @@ parseable block is a failed control (a finding for Sub-step F), never a pass.
Append both verdicts, the findings and `not_judged` to the step report.
**The round passes** only when ALL hold: `red_proof` is `"proven"` (or, below
plan_version 1.8, `"not-run"` because the step has no `Test first:`), Verify
PASS, manifest pass, every floor-guard finding ruled `justified` (and the guard
plan_version 1.8, the tolerated `"not-run"` of Sub-step B), Verify PASS,
manifest pass, every floor-guard finding ruled `justified` (and the guard
ran), `"spec": "pass"` and `"quality": "pass"`. Then go to Sub-step G.
Otherwise go to Sub-step F with every finding.
**Re-control after a fix** checks only what was fixed. A fresh controller gets
the previous round's findings and the round's own change — before the fix
round the executor saves `git diff {start_sha}` to
`{report dir}/step-{N}-round-{R}.diff`, and afterwards hands over
`diff -u` of that file against the new `git diff {start_sha}` — and answers,
per previous finding, fixed or not, plus whether the round changed anything
the findings did not name (that is a new finding). It does not re-judge the
rest of the step.
the previous round's verdicts, findings and floor-guard rulings, and the
round's own change — before the fix round the executor saves
`git diff {start_sha}` to `{report dir}/step-{N}-round-{R}.diff`, and afterwards
hands over `diff -u` of that file against the new `git diff {start_sha}`. It
answers in the SAME JSON block as above:
- per previous finding, fixed or not — a finding not fixed stays in
`findings`; whether the round changed anything the findings did not name
(that is a new finding);
- `"spec"` and `"quality"` for the step as a whole: the previous verdict
carries over for every part the round did not touch, re-judged only where
the round changed something;
- `floor_guard`: a finding identical (same kind, file and text) to one the
previous round already ruled keeps that ruling; only new floor-guard
findings are ruled.
It does not re-judge the rest of the step.
#### Sub-step F — Fix rounds
A failed round never loops sideways. **The fix rounds are capped at maximum 5 fix rounds per step, and they escalate upward** — to a fresh helper, then to
the plan, then to the operator. The executor does not improvise a new design,
and there is no ladder of cheaper models: every helper and every controller
runs `opus` (Voyage runs no helper below Opus 5.5 High).
runs `opus` (the step helper pins effort `high`; the controller follows the
session's effort, Sub-step E).
| `fix_rounds` | Goes to | What happens |
|---|---|---|
| 1, 2, 3 | a fresh helper | Launch `voyage:step-implementer` again (no `name`, no `isolation`), `mode: fix`, with the findings (file, line, problem, fix) and the mechanical failures. It fixes only those; the tests stay locked. Then B (both ways), D1, D2, D3 run again and a fresh controller re-controls only what was fixed (Sub-step E). `attempts` +1; `iterations_remaining` −1 |
| 1, 2, 3 | a fresh helper | Launch `voyage:step-implementer` again (no `name`, no `isolation`), `mode: fix`, with the findings (file, line, problem, fix) and the mechanical failures. It fixes only those; the tests stay locked (except in a `not-red-at-base` round, Sub-step B). Then B (both ways), D1, D2, D3 run again and a fresh controller re-controls only what was fixed (Sub-step E). `attempts` +1; `iterations_remaining` −1 |
| 4 | the plan | **After three failed fix rounds the question is the architecture, not a fourth fix.** The step goes back to planning (below). Stop class 4 |
| 5 | the operator | A step already sent to the plan (`fix_rounds` 4) that fails its control again after re-planning goes to the operator. The executor never builds this step again: on a later `--resume` it only runs the checks (B, D1, D2, D3, E) on whatever the operator changed. Stop class 4 |
| 5 | the operator | A step already sent to the plan (`fix_rounds` 4) that fails its control again after re-planning goes to the operator. The executor never builds this step again: on a later `--resume` it only runs the checks (B, D1, D2, D3, E) on whatever the operator changed. If they fail again the step stays at 5 — there is no sixth round, `fix_rounds` never exceeds 5 — and the run stops again. Stop class 4 |
**Back to planning** (`fix_rounds = 4`): save `git diff {start_sha}` to
`{report dir}/step-{N}-upward.diff` and write `{report dir}/step-{N}-upward.md`:
the step verbatim, every round's findings and verdicts, and the question for
the planner — which assumption in the step (its Changes, its Interface, its
test) makes it fail. Then stop: step `failed`, run `stopped`, and tell the
test) makes it fail. Then stop: step `failed`, run `failed`, and tell the
operator to re-plan the step (`/trekplan` revises it) and run
`/trekexecute --resume`, which launches a fresh `build` helper for the revised
step and keeps `fix_rounds` at 4. A step whose proof of red cannot run on a
step (`attempts` +1, `iterations_remaining` −1) and keeps `fix_rounds` at 4. A step whose proof of red cannot run on a
≥ 1.8 plan because its `Verify:` is not one test-runner invocation goes here
directly (Sub-step B).
@ -1600,9 +1649,13 @@ directly (Sub-step B).
- When the step goes up (round 4 or 5), first save the upward files, then
revert this step's changes (nothing of the step is committed yet):
```bash
git checkout -- {files from Files: field}
git checkout -- {the step's tracked files changed since start_sha}
git rm --cached -q -- {the step's new files: registered with --intent-to-add since start_sha}
```
Record failure. **Do NOT proceed to next step.** Jump to Phase 7.
The new files stay on disk, untracked, and `step-{N}-upward.md` names them —
nothing the step made is deleted, and none of it is left registered.
Record failure. **Do NOT proceed to next step.** Jump to Phase 7.5 (Phase 7
runs only when every step passed).
**`On failure: skip`**
- Mark step as skipped at its first failed round, without fix rounds. Continue
@ -1624,38 +1677,46 @@ directly (Sub-step B).
```bash
git add {files from completed steps' Files: fields} && git commit -m "wip: trekexecute stopped at step {N} — escalation needed"
```
Jump to Phase 7.
Jump to Phase 7.5 (Phase 7 runs only when every step passed).
The same wip commit of completed work runs whenever a step goes up (round 4
or 5). Release the step's test lock before stopping
(`node ${CLAUDE_PLUGIN_ROOT}/lib/util/test-lock.mjs unlock`, `test_lock` =
`"released"`), so the operator and the planner can edit the test.
#### Sub-step G — Done line, unlock, checkpoint
#### Sub-step G — Checkpoint, done line, unlock
1. **The done line.** A step is done only when a script says so, and the script
writes its line only when the test command exits 0:
1. **Checkpoint.** Stage the step's scope — its `Files:` and its test files,
and only those, never `git add -A` — then run the `Checkpoint:` git commit
command exactly as written in the plan. (The test files are listed apart
from `Files:` in the plan; staging only `Files:` would leave the step's
test uncommitted and charge it to the next step's diff.)
If the commit fails (nothing to commit, etc.): warn but do NOT fail the
step. The step's verification already passed — the commit is bookkeeping.
2. **The done line**, at the step's own commit. A step is done only when a
script says so, and the script writes its line only when the test command
exits 0:
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/verification/done-line.mjs --step {N} --cmd "{Verify command}" --report {report_path}
```
Exit 0 = `DONE step {N}: …` appended to the step report. Exit 1 = the test
command failed after all: back to Sub-step F. Exit 2 = it could not run:
never green — on a ≥ 1.8 plan that is the plan's `Verify:` (up to the plan,
as in Sub-step B); below 1.8, a step whose `Verify:` is not a test-runner
command records "no done line (Verify is not a test-runner command)" in its
report and rests on Sub-step D1's exit code. Confirm with
`node ${CLAUDE_PLUGIN_ROOT}/lib/verification/done-line.mjs --check --step {N} --report {report_path}`
(exit 0 = present). Neither the helper nor the executor ever types a DONE
line.
2. **Release the test lock**:
Exit 0 = `DONE step {N}: … exit 0 at {the checkpoint sha} …` appended to the
step report. Confirm with
`node ${CLAUDE_PLUGIN_ROOT}/lib/verification/done-line.mjs --check --step {N} --report {report_path} --commit {hash}`
(exit 0 = the line names this step's commit). Neither the helper nor the
executor ever types a DONE line.
- Exit 1 = the test went red after the checkpoint (it passed in Sub-step E
moments before: a flaky test or an order dependency). A finding for
Sub-step F; the fix lands as a second commit with the same message, and
Phase 7.5's commit count reports that extra commit as drift — said, not
hidden.
- Exit 2 = it could not run: never green — on a ≥ 1.8 plan that is the
plan's `Verify:` (up to the plan, as in Sub-step B); below 1.8, a step
whose `Verify:` is not a test-runner command records "no done line
(Verify is not a test-runner command)" in its report and rests on
Sub-step D1's exit code.
3. **Release the test lock**:
`node ${CLAUDE_PLUGIN_ROOT}/lib/util/test-lock.mjs unlock` → `test_lock` =
`"released"` (a step that was `"not-locked"` stays so).
3. **Checkpoint.** Stage the step's `Files:` — and only those, never
`git add -A` — then run the `Checkpoint:` git commit command exactly as
written in the plan.
If the commit fails (nothing to commit, etc.): warn but do NOT fail the step.
The step's verification already passed — the commit is bookkeeping.
```
Step {N}: PASS (committed: {hash})
@ -1821,22 +1882,23 @@ produces an independent truth based on objective state, not self-narrative.
2. **Filesystem check:** for each expected_path, confirm the file exists.
3. **Commit-count check:** count commits since session-start
(`git rev-list --count {start_sha}..HEAD`). Expected count =
(`git rev-list --count {session_start_sha}..HEAD`). Expected count =
number of steps with `status=completed` (excludes Step 0 if sandbox_preflight).
4. **Commit-message pattern sweep:** walk `git log {start_sha}..HEAD` and
4. **Commit-message pattern sweep:** walk `git log {session_start_sha}..HEAD` and
confirm each commit matches one of the declared
`commit_message_patterns` (in any order — order is advisory).
5. **Bash syntax sweep:** for every `.sh` file in
`git diff --name-only {start_sha} HEAD`, run `bash -n`. Collect failures.
`git diff --name-only {session_start_sha} HEAD`, run `bash -n`. Collect failures.
6. **Forbidden-path sweep:** for each `scope_forbidden` entry, check
`git diff --name-only {start_sha} HEAD -- {path}` is empty.
`git diff --name-only {session_start_sha} HEAD -- {path}` is empty.
7. **Done-line sweep:** for every step with `status=completed` that carries a
`report_path`, run
`node ${CLAUDE_PLUGIN_ROOT}/lib/verification/done-line.mjs --check --step {N} --report {report_path}`.
`node ${CLAUDE_PLUGIN_ROOT}/lib/verification/done-line.mjs --check --step {N} --report {report_path} --commit {steps.N.commit}`
(the line must name the step's own checkpoint commit).
Exit 1 on a plan ≥ 1.8 = drift (`{check: "done-line", expected: "DONE step N", actual: "absent"}`);
below 1.8 it is drift only when the step's report does not say why it has
none (Sub-step G).
@ -2213,8 +2275,8 @@ To continue (multi-session waves): re-run the same /trekexecute command (not --r
Phase 7.6 recovery incomplete, OR the run is not verified end to end
(Phase 7, gate clause 4)
- `blocked` — Step 0 sandbox pre-flight exited 77; no real work attempted
- `failed` — a step failed and On failure was revert/retry (retry cap hit)
- `stopped` — On failure: escalate triggered
- `failed` — a step failed and went up the fix ladder (On failure revert/retry: round 4 to the plan, or 5 to the operator)
- `stopped` — On failure: escalate triggered, or another stop on the closed stop list (Phase 5.5)
**JSON summary block** (always at the end, machine-parseable):

View file

@ -9,9 +9,11 @@
// --step, a command outside the test-runner allowlist, a command that cannot
// start, a working tree that is not a git repo.
//
// `--check --step <N> --report <file>`: exit 0 when the report carries a
// well-formed line for step N, 1 when it does not. A hand-typed "DONE" is not
// the script's line.
// `--check --step <N> --report <file> [--commit <sha>]`: exit 0 when the report
// carries a well-formed line for step N (at exactly that commit, with
// --commit), 1 when it does not. /trekexecute writes the line right after the
// step's checkpoint and checks it with --commit, so the line is bound to the
// commit the test passed on.
//
// Adapted from obra/superpowers@8ca22db (MIT, (c) 2025 Jesse Vincent),
// skills/executing-plans/scripts/task-done: the completion line is appended
@ -24,13 +26,20 @@ import { fileURLToPath } from 'node:url';
import { allowedCommand, screenCommand } from './criteria-runner.mjs';
import { commandEnv, DEFAULT_TIMEOUT_MS, repoRoot } from './red-proof.mjs';
export function doneLineRe(step) {
return new RegExp(`^DONE step ${step}: \`.+\` exit 0 at [0-9a-f]{40} \\d{4}-\\d{2}-\\d{2}T\\S+$`, 'm');
export function doneLineRe(step, commit) {
const sha = commit ? String(commit).replace(/[^0-9a-f]/g, '') : '[0-9a-f]{40}';
return new RegExp(`^DONE step ${step}: \`.+\` exit 0 at ${sha} \\d{4}-\\d{2}-\\d{2}T\\S+$`, 'm');
}
export function hasDoneLine(reportPath, step) {
/**
* With `commit` (the step's checkpoint sha, full 40 hex), the line must name
* exactly that commit. A regex cannot tell a typed line from a written one; the
* commit binds the line to the state the test ran on (/trekexecute writes the
* line right after the checkpoint, so HEAD is the step's own commit).
*/
export function hasDoneLine(reportPath, step, commit) {
if (!existsSync(reportPath)) return false;
return doneLineRe(step).test(readFileSync(reportPath, 'utf8'));
return doneLineRe(step, commit).test(readFileSync(reportPath, 'utf8'));
}
/** @returns {{status: 'written'|'failed'|'not-run', reason: string, line?: string, exit?: number|null}} */
@ -58,7 +67,7 @@ export function writeDoneLine({ cwd, step, cmd, report, timeoutMs = DEFAULT_TIME
const USAGE =
'usage: done-line.mjs --step <N> --cmd "<test command>" --report <file> [--cwd <dir>] [--json]\n' +
' done-line.mjs --check --step <N> --report <file>';
' done-line.mjs --check --step <N> --report <file> [--commit <sha>]';
export function main(argv) {
const o = {};
@ -72,12 +81,13 @@ export function main(argv) {
else if (a === '--cwd' && argv[i + 1]) o.cwd = resolve(argv[++i]);
else if (a === '--timeout' && argv[i + 1]) o.timeoutMs = Number(argv[++i]);
else if (a === '--check') check = true;
else if (a === '--commit' && argv[i + 1]) o.commit = argv[++i];
else if (a === '--json') json = true;
else { process.stderr.write(`done-line: unknown argument ${a}\n${USAGE}\n`); return 2; }
}
if (check) {
if (!/^\d+$/.test(String(o.step ?? '')) || !o.report) { process.stderr.write(`done-line: --check needs --step and --report\n${USAGE}\n`); return 2; }
const ok = hasDoneLine(o.report, o.step);
const ok = hasDoneLine(o.report, o.step, o.commit);
process.stdout.write(json ? `${JSON.stringify({ step: Number(o.step), done: ok })}\n` : `step ${o.step}: ${ok ? 'DONE line present' : 'no DONE line - not done'}\n`);
return ok ? 0 : 1;
}

View file

@ -1,25 +1,38 @@
// lib/verification/red-proof.mjs
// Proof of red (P9 (1)). A step's test is run by a command twice:
// Proof of red (P9 (1)). A step's test is run by a command:
//
// 1. against the START POINT (the step's recorded start_sha, with the step's
// test files copied in): it must FAIL - otherwise the test proves nothing
// about the change;
// 2. against the RESULT (the working tree as the step left it): it must PASS.
// 2. against the RESULT (the working tree as the step left it): it must PASS;
// 3. CONTROL: the start-point copy with the step's whole change laid on top
// must PASS too. A copy that cannot go green even with the change failed
// step 1 for an environment reason (a missing dependency, a path, a tool),
// not because the change was missing - that is "not-run", never proven.
//
// `--red-only` runs step 1 alone: the gate the step helper passes BEFORE it
// builds (and locks the test). The executor's own check afterwards is the full
// two-sided run.
// run, and only the full run is authoritative.
//
// Exit 0 = proven, 1 = rejected (`not-red-at-base` or `not-green-at-result`),
// 2 = could not run (`not-run`: no test files, an unknown start point, a command
// outside the test-runner allowlist, a command that cannot start). 2 is never a
// pass and never "proven".
// outside the test-runner allowlist, a command that cannot start, a control run
// that stays red). 2 is never a pass and never "proven".
//
// The start point is materialized with `git archive <start_sha>` into a temp
// directory, never by checking anything out in the working tree: the step's
// work is not touched, and no worktree is registered in the repo. Tracked files
// only - pass `--link node_modules` (comma list) to symlink an untracked
// dependency directory from the working tree into the start-point copy.
// The start point is a temp `git clone --shared` of the repo checked out at
// start_sha - a real git checkout, so a git-reading command behaves as it does
// in the repo, and nothing in the working tree or the repo's worktree list is
// touched. Every gitignored top-level entry of the working tree (node_modules,
// .venv, a build cache) is symlinked in, so the copy has the same dependencies;
// `--link a,b` adds deeper ones. Measured 2026-10-06 (review): with a bare
// `git archive` copy, a test that needs node_modules "proved red" with no change.
// Known limit, failing safe: a dependency that points back INTO the working
// tree (an editable `pip install -e .`, a workspace symlink) makes the start
// point see the new code, and a correct step reads `not-red-at-base`.
//
// Everything runs at the root of --cwd's repository: plan paths and commands
// are root-relative, and `git archive`/`clone` paths from a subdirectory are
// not (measured 2026-10-06).
//
// The command must be ONE test-runner invocation without shell operators
// (criteria-runner.mjs `allowedCommand`), and it is screened through the
@ -27,8 +40,8 @@
// node never passes through the Bash tool, so the hook cannot fire by itself.
//
// When the output is TAP (`--test-reporter=tap`, pytest-tap, ...), the counts
// `# tests N` / `# fail N` are reported for both runs. They show WHAT the red
// was: "fail 1 of 1" on a node test file is usually a load error (an import the
// `# tests N` / `# fail N` are reported per run. They show WHAT the red was:
// "fail 1 of 1" on a node test file is usually a load error (an import the
// start point lacks), not named failing tests - see
// tests/fixtures/red-first/02243c6-always-allow-shim.mjs, re-measured through
// this tool in tests/lib/red-proof.test.mjs.
@ -36,7 +49,7 @@
// Borrowed: GitHub Spec Kit (MIT, github/spec-kit@2e941fe) - its test-first
// rule is an instruction there; here a command proves it.
import { cpSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs';
import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { dirname, isAbsolute, join, resolve } from 'node:path';
@ -61,6 +74,11 @@ export function commandEnv(env = process.env) {
return rest;
}
export function repoRoot(dir) {
const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: dir, encoding: 'utf8' });
return top.status === 0 ? top.stdout.trim() : null;
}
function run(cmd, cwd, timeoutMs) {
const r = spawnSync('/bin/sh', ['-c', cmd], { cwd, env: commandEnv(), encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });
const output = `${r.stdout ?? ''}${r.stderr ?? ''}`;
@ -78,35 +96,71 @@ function unrunnable(r) {
return null;
}
/**
* @param {{cwd: string, base: string, cmd: string, tests: string[], link?: string[], timeoutMs?: number}} o
* @returns {{status: 'proven'|'rejected'|'not-run', reason: string, base?: object, result?: object}}
*/
export function repoRoot(dir) {
const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: dir, encoding: 'utf8' });
return top.status === 0 ? top.stdout.trim() : null;
const git = (cwd, args) => spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
const lines = (s) => String(s ?? '').split('\n').filter(Boolean);
function safeRel(p) {
return p && !isAbsolute(p) && !p.split('/').includes('..');
}
function copyIn(from, to, rel) {
mkdirSync(dirname(join(to, rel)), { recursive: true });
rmSync(join(to, rel), { recursive: true, force: true });
cpSync(join(from, rel), join(to, rel), { recursive: true, verbatimSymlinks: true });
}
/** Symlink every gitignored top-level entry of the working tree, plus `extra`, into `tree`. */
function linkDependencies(root, tree, extra) {
const linked = [];
const ignored = lines(git(root, ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory']).stdout)
.map((p) => p.replace(/\/$/, '').split('/')[0]);
for (const l of new Set([...ignored, ...extra])) {
if (!safeRel(l) || l === '.git') continue;
if (existsSync(join(root, l)) && !existsSync(join(tree, l))) {
mkdirSync(dirname(join(tree, l)), { recursive: true });
symlinkSync(join(root, l), join(tree, l));
linked.push(l);
}
}
return linked;
}
/** Lay the working tree's change since `base` onto `tree` (tracked edits, deletions, untracked files). */
function applyResult(root, tree, base) {
const status = lines(git(root, ['diff', '--name-status', '--no-renames', base]).stdout);
for (const s of status) {
const [code, rel] = s.split('\t');
if (!safeRel(rel)) continue;
if (code.startsWith('D')) rmSync(join(tree, rel), { recursive: true, force: true });
else if (existsSync(join(root, rel)) || lstatSync(join(root, rel), { throwIfNoEntry: false })) copyIn(root, tree, rel);
}
for (const rel of lines(git(root, ['ls-files', '--others', '--exclude-standard']).stdout)) {
if (safeRel(rel)) copyIn(root, tree, rel);
}
}
/**
* @param {{cwd: string, base: string, cmd: string, tests: string[], link?: string[], redOnly?: boolean, timeoutMs?: number}} o
* @returns {{status: 'proven'|'rejected'|'not-run', reason: string, base?: object, result?: object, control?: object}}
*/
export function proveRed(o) {
const given = resolve(o.cwd ?? process.cwd());
const timeoutMs = o.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const tests = (o.tests ?? []).filter(Boolean);
if (!existsSync(given)) return notRun(`--cwd ${given} does not exist`);
// Plan paths and commands are relative to the repo root, and `git archive`
// run from a subdirectory packs only that subdirectory (measured
// 2026-10-06). So everything below runs at the root of --cwd's repo.
const cwd = repoRoot(given);
if (!cwd) return notRun(`--cwd ${given} is not inside a git repository`);
if (tests.length === 0) return notRun('no test files given: a step without its test cannot prove red');
for (const t of tests) {
if (isAbsolute(t) || t.split('/').includes('..')) return notRun(`test path must be repo-relative: ${t}`);
if (!safeRel(t)) return notRun(`test path must be repo-relative: ${t}`);
if (!existsSync(join(cwd, t))) return notRun(`test file not found in the working tree: ${t}`);
}
for (const l of o.link ?? []) if (!safeRel(l)) return notRun(`--link must be repo-relative: ${l}`);
const allowed = allowedCommand(o.cmd, { cwd });
if (!allowed.allowed) return notRun(`command not run: ${allowed.reason}`);
const screen = screenCommand(o.cmd);
if (!screen.allowed) return notRun(`command blocked: ${screen.rule}`);
const sha = spawnSync('git', ['rev-parse', '--verify', '--quiet', `${o.base}^{commit}`], { cwd, encoding: 'utf8' });
const sha = git(cwd, ['rev-parse', '--verify', '--quiet', `${o.base}^{commit}`]);
if (sha.status !== 0) return notRun(`cannot resolve the start point ${o.base} in ${cwd}`);
const baseSha = sha.stdout.trim();
@ -122,32 +176,29 @@ export function proveRed(o) {
const tmp = mkdtempSync(join(tmpdir(), 'voyage-red-proof-'));
try {
const tree = join(tmp, 'tree');
mkdirSync(tree);
const tar = join(tmp, 'start.tar');
const a = spawnSync('git', ['archive', '--format=tar', '-o', tar, baseSha], { cwd, encoding: 'utf8' });
if (a.status !== 0) return notRun(`git archive ${baseSha} failed: ${String(a.stderr).trim()}`, { result });
const x = spawnSync('tar', ['-xf', tar, '-C', tree], { encoding: 'utf8' });
if (x.status !== 0) return notRun(`tar could not unpack the start point: ${String(x.stderr).trim()}`, { result });
for (const t of tests) {
mkdirSync(dirname(join(tree, t)), { recursive: true });
cpSync(join(cwd, t), join(tree, t));
}
for (const l of o.link ?? []) {
if (!l || isAbsolute(l) || l.split('/').includes('..')) return notRun(`--link must be repo-relative: ${l}`, { result });
if (existsSync(join(cwd, l)) && !existsSync(join(tree, l))) {
mkdirSync(dirname(join(tree, l)), { recursive: true });
symlinkSync(join(cwd, l), join(tree, l));
}
}
const c = git(tmp, ['clone', '--quiet', '--shared', '--no-checkout', cwd, tree]);
if (c.status !== 0) return notRun(`could not clone the start point: ${String(c.stderr).trim()}`, { result });
const co = git(tree, ['checkout', '--quiet', '--detach', baseSha]);
if (co.status !== 0) return notRun(`could not check out ${baseSha}: ${String(co.stderr).trim()}`, { result });
for (const t of tests) copyIn(cwd, tree, t);
const linked = linkDependencies(cwd, tree, o.link ?? []);
const base = run(o.cmd, tree, timeoutMs);
const baseBroken = unrunnable(base);
if (baseBroken) return notRun(`at the start point: ${baseBroken}`, { base, result });
const out = { base_sha: baseSha, base, result };
if (baseBroken) return notRun(`at the start point: ${baseBroken}`, { base, result, linked });
const out = { base_sha: baseSha, linked, base, result };
if (base.exit === 0) return { status: 'rejected', reason: 'not-red-at-base', ...out };
if (o.redOnly) return { status: 'proven', reason: 'red at the start point (--red-only: the result was not run)', base_sha: baseSha, base };
if (o.redOnly) return { status: 'proven', reason: 'red at the start point (--red-only: the result was not run)', base_sha: baseSha, linked, base };
if (result.exit !== 0) return { status: 'rejected', reason: 'not-green-at-result', ...out };
return { status: 'proven', reason: 'red at the start point, green at the result', ...out };
// Control: the same copy with the whole change laid on. Red here means the
// copy itself cannot run the test green, so its red proved nothing.
applyResult(cwd, tree, baseSha);
const control = run(o.cmd, tree, timeoutMs);
if (control.exit !== 0) {
return notRun('the control run (start-point copy + the change) stayed red: the start point failed for an environment reason, not for the missing change', { ...out, control });
}
return { status: 'proven', reason: 'red at the start point, green at the result, green in the control', ...out, control };
} finally {
rmSync(tmp, { recursive: true, force: true });
}
@ -173,7 +224,7 @@ export function main(argv) {
else if (a === '--red-only') o.redOnly = true;
else if (a === '--timeout' && argv[i + 1]) o.timeoutMs = Number(argv[++i]);
else if (a === '--json') json = true;
else { process.stderr.write(`red-proof: unknown argument ${a}\n${USAGE}\n`); return 2; }
else { process.stderr.write(`red-proof: unknown argument ${a} (--tests takes ONE comma-separated list)\n${USAGE}\n`); return 2; }
}
if (!o.base || !o.cmd) { process.stderr.write(`red-proof: --base and --cmd are required\n${USAGE}\n`); return 2; }
if (o.timeoutMs !== undefined && !Number.isFinite(o.timeoutMs)) { process.stderr.write(`red-proof: --timeout must be a number\n${USAGE}\n`); return 2; }
@ -186,6 +237,8 @@ export function main(argv) {
process.stdout.write(`red proof: ${r.status.toUpperCase()} - ${r.reason}\n`);
if (r.base) process.stdout.write(` start point ${r.base_sha ?? o.base}: exit ${r.base.exit}${counts(r.base)}\n`);
if (r.result) process.stdout.write(` result: exit ${r.result.exit}${counts(r.result)}\n`);
if (r.control) process.stdout.write(` control: exit ${r.control.exit}${counts(r.control)}\n`);
if (r.linked?.length) process.stdout.write(` linked into the start point: ${r.linked.join(', ')}\n`);
}
return r.status === 'proven' ? 0 : r.status === 'rejected' ? 1 : 2;
}

View file

@ -45,6 +45,7 @@ export function isTestPath(p) {
}
const isCheckPath = (p) => isTestPath(p) || CHECK_CONFIG_RE.test(p);
const PROSE_RE = /\.(md|markdown|mdx|txt|rst|adoc)$/i;
// --- the patterns -------------------------------------------------------------
@ -55,7 +56,7 @@ const TEST_DECL_RE =
/^\s*(test|it|describe|suite)(\.\w+)?\s*\(|^\s*(async\s+)?def\s+test_\w*|^\s*func\s+Test\w*|^\s*#\[test\]|@Test\b/;
const SKIP_RE =
/\b(test|it|describe|suite)\.(skip|todo|only)\s*\(|\bx(it|describe|test)\s*\(|\bf(it|describe)\s*\(|\{\s*(skip|todo)\s*:\s*(true|['"`])|@pytest\.mark\.(skip|skipif|xfail)\b|\bpytest\.skip\s*\(|@unittest\.skip|\bt\.Skip(Now|f)?\s*\(|#\[ignore\]|@(Disabled|Ignore)\b/;
/\b(test|it|describe|suite)\.(skip|skipIf|todo|only)\s*\(|\bt\.(skip|todo)\s*\(|\bx(it|describe|test)\s*\(|\bf(it|describe)\s*\(|\{\s*(skip|todo)\s*:\s*(true|['"`])|@pytest\.mark\.(skip|skipif|xfail)\b|\bpytest\.skip\s*\(|@unittest\.skip|\bt\.Skip(Now|f)?\s*\(|#\[ignore\]|@(Disabled|Ignore)\b/;
// Inline suppressions silence a check wherever they appear.
const SUPPRESS_RE =
@ -183,6 +184,9 @@ export function analyzeDiff(text) {
const removed = all.filter((l) => l.type === '-');
const added = all.filter((l) => l.type === '+');
const test = isTestPath(f.path);
// Prose (docs, notes) describes code; a "not implemented" or an
// eslint-disable written in it is text, not a stub or a muted check.
const prose = PROSE_RE.test(f.path);
if (test) {
const lostAsserts = count(removed, ASSERT_RE) - count(added, ASSERT_RE);
@ -196,7 +200,7 @@ export function analyzeDiff(text) {
for (const l of addedNotRemoved(removed, added, SKIP_RE)) findings.push(finding('skipped-test', f.path, l));
}
for (const l of addedNotRemoved(removed, added, SUPPRESS_RE)) findings.push(finding('muted-check', f.path, l));
if (!prose) for (const l of addedNotRemoved(removed, added, SUPPRESS_RE)) findings.push(finding('muted-check', f.path, l));
if (isCheckPath(f.path)) {
for (const l of addedNotRemoved(removed, added, MUTE_RE)) findings.push(finding('muted-check', f.path, l));
for (const h of f.hunks) {
@ -211,7 +215,7 @@ export function analyzeDiff(text) {
}
}
if (!test) for (const l of addedNotRemoved(removed, added, STUB_RE)) findings.push(finding('stub', f.path, l));
if (!test && !prose) for (const l of addedNotRemoved(removed, added, STUB_RE)) findings.push(finding('stub', f.path, l));
}
return { status: findings.length ? 'findings' : 'clean', findings };
}
@ -227,7 +231,10 @@ function diffFromGit(cwd, base, head) {
const okHead = spawnSync('git', ['rev-parse', '--verify', '--quiet', `${head}^{commit}`], { cwd, encoding: 'utf8' });
if (okHead.status !== 0) return { error: `cannot resolve --head ${head} in ${cwd}` };
}
const args = ['diff', '--no-color', '--no-ext-diff', '--find-renames', base, ...(head ? [head] : [])];
// --no-renames: a rename is read as a deletion plus an addition. Paired as a
// rename, `foo.test.js -> foo.test.js.bak` showed no removed line at all and
// switched a test off silently (review, 2026-10-06).
const args = ['diff', '--no-color', '--no-ext-diff', '--no-renames', base, ...(head ? [head] : [])];
const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
if (r.status !== 0) return { error: `git diff failed: ${String(r.stderr).trim()}` };
return { text: r.stdout };

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:961-976 | 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 | commands/trekresearch.md (orchestrator-emit Stats tracking) | trekresearch.md:634-676 | 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 | commands/trekplan.md (orchestrator-emit Phase 12) | trekplan.md:805-826 | 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:2263-2285 | 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:2325-2347 | 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 | commands/trekreview.md (orchestrator-emit Phase 8) | trekreview.md:255 | profile, phase_models, profile_source | none |

View file

@ -81,8 +81,13 @@ test('done line: --check finds the line for that step only', () => {
assert.equal(run(['--check', '--step', '3', '--report', report]).status, 1, 'no report file = not done');
writeFileSync(report, '# Step 3 report\nDONE step 3 (typed by hand)\n');
assert.equal(run(['--check', '--step', '3', '--report', report]).status, 1, 'a hand-typed line is not the script line');
// A well-formed line typed by hand, at a sha that is not the step's commit.
writeFileSync(report, `# Step 3 report\nDONE step 3: \`node --test tests/ok.test.mjs\` exit 0 at ${'0'.repeat(40)} 2026-10-06T12:00:00.000Z\n`);
const head = sh(dir, 'git', ['rev-parse', 'HEAD']);
assert.equal(run(['--check', '--step', '3', '--report', report, '--commit', head]).status, 1, 'the line must be at the step commit');
run(['--cwd', dir, '--step', '3', '--cmd', 'node --test tests/ok.test.mjs', '--report', report]);
assert.equal(run(['--check', '--step', '3', '--report', report]).status, 0);
assert.equal(run(['--check', '--step', '3', '--report', report, '--commit', head]).status, 0);
assert.equal(run(['--check', '--step', '4', '--report', report]).status, 1);
} finally { rmSync(dir, { recursive: true, force: true }); }
});

View file

@ -141,16 +141,61 @@ test('K8: the test lock is used through its CLI, and a missing CLI is said out l
});
test('P9 (2): the floor guard runs on the step diff and exit 2 is never green', () => {
const text = read(CMD);
assert.match(text, /lib\/verification\/test-integrity\.mjs --base \{start_sha\}/);
assert.match(text, /exit 2[^\n]*never (green|a pass)/i);
const d3 = section(read(CMD), '#### Sub-step D3 — Floor guard');
assert.match(d3, /lib\/verification\/test-integrity\.mjs --base \{start_sha\}/);
assert.match(d3, /exit 2 is never green/i);
assert.match(d3, /justified/);
});
test('P9 (4): the done line is written by a script, only when the test command exits 0', () => {
test('P9 (4): the done line is written by a script, only when the test command exits 0, at the step commit', () => {
const g = section(read(CMD), '#### Sub-step G — Checkpoint, done line, unlock');
assert.match(g, /lib\/verification\/done-line\.mjs --step \{N\}/);
assert.match(g, /done-line\.mjs --check --step \{N\} --report \{report_path\} --commit \{hash\}/);
assert.match(g, /writes its line only when the test command\s+exits 0/i);
// The checkpoint comes first, so the line names the step's own commit.
assert.ok(g.indexOf('**Checkpoint.**') < g.indexOf('**The done line**'), 'checkpoint before the done line');
});
// --- the independent review of 2026-10-06 (15 findings): what the fixes must keep
test('review: the step test files are part of the scope and committed at the checkpoint', () => {
const text = read(CMD);
assert.match(text, /lib\/verification\/done-line\.mjs/);
assert.match(text, /done-line\.mjs --check/);
assert.match(text, /only (when|if) the test command exits 0/i);
assert.match(section(text, '#### Sub-step B — Test first and proof of red'), /scope = its `Files:` \+ its test files/);
assert.match(section(text, '#### Sub-step G — Checkpoint, done line, unlock'), /its `Files:` and its test files/);
});
test('review: every new file is registered (not only listed ones), so no check is blind to it', () => {
const c = section(read(CMD), '#### Sub-step C — Implement through one fresh helper');
assert.match(c, /git ls-files --others --exclude-standard/);
assert.match(c, /git add --intent-to-add -- \{path\}/);
});
test('review: proof of red has a control run and a real git checkout as its start point', () => {
const b = section(read(CMD), '#### Sub-step B — Test first and proof of red');
assert.match(b, /control run/);
assert.match(b, /git clone --shared/);
});
test('review: Phase 7.5 audits the run from session_start_sha, never a step start_sha', () => {
const text = read(CMD);
const audit = text.slice(text.indexOf('## Phase 7.5'), text.indexOf('### Machine-verifiable completion gate'));
assert.match(audit, /\{session_start_sha\}\.\.HEAD/);
assert.doesNotMatch(audit, /\{start_sha\}/);
});
test('review: no sixth round, attempts never reset, a step at 5 is never built again', () => {
const text = read(CMD);
assert.match(section(text, '#### Sub-step F — Fix rounds'), /no sixth round/);
const c = section(text, '#### Sub-step C — Implement through one fresh helper');
assert.match(c, /never\s+reset/);
assert.match(c, /A step at `fix_rounds` 5 is never built again/);
});
test('review: the helper appends its report, and passes tests as one comma-separated list', () => {
const agent = read(AGENT);
assert.match(agent, /APPEND it to `report_path`/);
assert.match(agent, /ONE comma-separated argument/);
assert.match(agent, /A step with no `Test first:`/);
});
test('P9 (6): without a run-the-thing check the result is "not verified end to end", never green', () => {

View file

@ -143,6 +143,76 @@ test('red proof: --cwd in a subdirectory still runs at the repo root (plan paths
} finally { rmSync(dir, { recursive: true, force: true }); }
});
// Review finding 2 (2026-10-06): a start-point copy that cannot run the test at
// all failed "red" for the wrong reason and was called proven. Three guards:
// the copy is a real git checkout, gitignored dependency dirs are linked in, and
// the same copy WITH the change must go green (the control run).
test('red proof: a gitignored dependency the test needs is linked into the start point - no false red', () => {
const { dir, base } = repo('a + b');
try {
writeFileSync(join(dir, '.gitignore'), 'node_modules/\n');
sh(dir, 'git', ['add', '.gitignore']);
sh(dir, 'git', ['commit', '-q', '-m', 'ignore']);
const base2 = sh(dir, 'git', ['rev-parse', 'HEAD']);
mkdirSync(join(dir, 'node_modules', 'dep'), { recursive: true });
writeFileSync(join(dir, 'node_modules', 'dep', 'index.mjs'), 'export const one = 1;\n');
mkdirSync(join(dir, 'tests'));
writeFileSync(join(dir, 'tests', 'dep.test.mjs'), [
"import { test } from 'node:test';",
"import { strict as assert } from 'node:assert';",
"import { one } from '../node_modules/dep/index.mjs';",
"import { sum } from '../lib/sum.mjs';",
"test('dep', () => { assert.equal(sum(one, one), 2); });",
'',
].join('\n'));
// The test already passes without any change: it proves nothing.
const r = proveRed({ cwd: dir, base: base2, cmd: 'node --test tests/dep.test.mjs', tests: ['tests/dep.test.mjs'] });
assert.equal(r.status, 'rejected', JSON.stringify({ status: r.status, reason: r.reason }));
assert.equal(r.reason, 'not-red-at-base');
void base;
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('red proof: the start point is a git checkout - a git-reading command does not fail there for lack of .git', () => {
const { dir, base } = repo('a - b');
try {
addTest(dir);
const r = proveRed({ cwd: dir, base, cmd: 'git status', tests: ['tests/sum.test.mjs'] });
assert.notEqual(r.status, 'proven', JSON.stringify({ status: r.status, reason: r.reason }));
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('red proof: a copy that cannot go green even with the change is "not-run" (environment), never proven', () => {
const { dir, base } = repo('a - b');
try {
mkdirSync(join(dir, 'tests'));
// Red anywhere outside the real working tree, whatever the code says.
writeFileSync(join(dir, 'tests', 'env.test.mjs'), [
"import { test } from 'node:test';",
"import { strict as assert } from 'node:assert';",
"test('env', () => { assert.ok(!process.cwd().includes('voyage-red-proof-')); });",
'',
].join('\n'));
writeFileSync(join(dir, 'lib', 'sum.mjs'), 'export function sum(a, b) { return a + b; }\n');
const r = proveRed({ cwd: dir, base, cmd: 'node --test tests/env.test.mjs', tests: ['tests/env.test.mjs'] });
assert.equal(r.status, 'not-run', JSON.stringify({ status: r.status, reason: r.reason }));
assert.match(r.reason, /control/);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('red proof: an untracked file the change adds (not a listed test) is part of the result, so the control run goes green', () => {
const { dir, base } = repo('a - b');
try {
addTest(dir);
// The fix lives in a NEW untracked module the step adds.
writeFileSync(join(dir, 'lib', 'add.mjs'), 'export const add = (a, b) => a + b;\n');
writeFileSync(join(dir, 'lib', 'sum.mjs'), "export { add as sum } from './add.mjs';\n");
const r = proveRed({ cwd: dir, base, cmd: TEST_CMD, tests: ['tests/sum.test.mjs'] });
assert.equal(r.status, 'proven', JSON.stringify({ status: r.status, reason: r.reason }));
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('red proof: the start-point run leaves the working tree untouched', () => {
const { dir, base } = repo('a - b');
try {

View file

@ -82,6 +82,54 @@ test('floor guard: hunk line counts decide what is content - a removed "-- x" li
assert.deepEqual(r.findings.map((f) => [f.kind, f.file]), [['removed-assert', 'tests/a.test.mjs']]);
});
test('floor guard: skipIf and node\'s lowercase t.skip() / t.todo() are skips too', () => {
const diff = (line) => [
'diff --git a/tests/a.test.mjs b/tests/a.test.mjs',
'--- a/tests/a.test.mjs',
'+++ b/tests/a.test.mjs',
'@@ -1,1 +1,2 @@',
" test('a', (t) => {",
`+${line}`,
].join('\n');
for (const l of [" it.skipIf(process.env.CI)('x', () => {});", ' t.skip();', " t.todo('later');", "test('b', { todo: true }, () => {});"]) {
assert.deepEqual(analyzeDiff(diff(l)).findings.map((f) => f.kind), ['skipped-test'], l);
}
});
test('floor guard: renaming a test out of the runner\'s pattern is a deleted test (--base never pairs renames)', () => {
const dir = mkdtempSync(join(tmpdir(), 'floor-guard-rename-'));
const git = (...a) => {
const r = spawnSync('git', a, { cwd: dir, encoding: 'utf8' });
assert.equal(r.status, 0, `git ${a.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
};
try {
git('init', '-q');
git('config', 'user.email', 't@example.invalid');
git('config', 'user.name', 'test');
writeFileSync(join(dir, 'foo.test.js'), "test('a', () => {\n assert.equal(1, 1);\n});\n");
git('add', 'foo.test.js');
git('commit', '-q', '-m', 'base');
const base = git('rev-parse', 'HEAD');
git('mv', 'foo.test.js', 'foo.test.js.bak');
const r = cli(['--base', base, '--cwd', dir, '--json']);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.ok(JSON.parse(r.stdout).findings.some((f) => f.kind === 'deleted-test'), r.stdout);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('floor guard: prose files are not code - "not implemented" in a .md is not a stub', () => {
const diff = [
'diff --git a/docs/guide.md b/docs/guide.md',
'--- a/docs/guide.md',
'+++ b/docs/guide.md',
'@@ -1,1 +1,2 @@',
' # Guide',
"+Never write `throw new Error('not implemented')` or an eslint-disable as a fix.",
].join('\n');
assert.deepEqual(analyzeDiff(diff).findings, []);
});
test('floor guard: an empty diff is clean (nothing changed weakens nothing)', () => {
assert.equal(analyzeDiff('').status, 'clean');
});