merge: release 6.3.0 (rounds 5-6 of the release review and the release commit)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-11 08:01:48 +02:00
commit 0f400d3461
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
28 changed files with 805 additions and 156 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "voyage", "name": "voyage",
"description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline. /trekbrief, /trekplan, and /trekreview each end by building a self-contained operator-annotation HTML (scripts/annotate.mjs, pencil-toggle annotation pattern): select text or click any element, pick intent (Fiks/Endre/Spørsmål), write comment, copy structured prompt, paste back, Claude revises the .md.", "description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline. /trekbrief, /trekplan, and /trekreview each end by building a self-contained operator-annotation HTML (scripts/annotate.mjs, pencil-toggle annotation pattern): select text or click any element, pick intent (Fiks/Endre/Spørsmål), write comment, copy structured prompt, paste back, Claude revises the .md.",
"version": "6.2.0", "version": "6.3.0",
"author": { "author": {
"name": "Kjell Tore Guttormsen" "name": "Kjell Tore Guttormsen"
}, },

View file

@ -4,6 +4,157 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## v6.3.0 — 2026-10-11 — The executor is coupled to the review gate, three skills, and a drilled release runbook
Wave 2 of the practice build (five orders, merged 2026-10-09) and the fix round
after it, which closes finding 1 of the test repo's third trial: a step of only
tests can never be proven red. Every fix began with a test that failed on the
old code. Suite 2194 (2192 pass, 0 fail, 2 skipped).
Two changes ask something of users (see Doors): `/trekexecute` now refuses a
plan that has no logged approval for its current content, and a check in a
brief or plan runs with no shell, as plain words with a runner's read-only
options. Plan 1.7/1.8, brief
2.0–2.2 and review 1.0/1.1 files still validate; the new step fields are
optional.
### Doors
- one-way: `/trekexecute` refuses a plan whose logged approval does not cover its current sha256 (K2) — a plan written before 6.2.0, written by hand, or edited after its approval stops at Phase 1; run `/trekplan --project <dir>` and answer "Approve" (from 6.3.0).
- two-way: `/trekexecute` reviews by itself in the closing run (`/simplify` → `/trekreview` → verdict gate, K7) and keeps the main merge closed on BLOCK or an undecided verdict; step check (K9), `Regression:` (K10), architecture rules (K5) and plan-file sweep (K6) run at step end and after the last step — a revert restores the old executor; nothing to do for users.
- two-way: the approval guard refuses a merge into main, a push, a tag or publish and destructive git in the permission mode that shows no prompts — a revert lets them through again; nothing to migrate.
- two-way: `progress-validator` rejects the runner's `exitCode` in `plan_verification.failed_criteria` (C15) — the executor's own prose always wrote `exit_code`.
- two-way: skills workshop — form check, model-free routing test, `test-first` and `debugging` preloaded into `step-implementer` — new files and one `skills:` line; a revert removes them.
- two-way: `researching-sources` skill, preloaded by the five web-search agents and named by `/trekresearch` — the agents' copied rule text moved into the skill; a revert puts it back.
- two-way: `Regression:` (K10) and `On failure:` action words (K11) — an optional field and a warning; plans without them validate as before.
- two-way: trial 2's minor fixes (B8, B11, B12, C13, C14, C16, C17) — checks, prose and agent text; a revert restores the old behaviour.
- two-way: release check and runbook — new files; a revert removes them, and nothing else reads them.
- two-way: tests-only steps (`PLAN_STEP_TESTS_ONLY`, red-proof `--plan/--step` → `tests_only`, which also reads the step's own diff, executor routes them to the plan) — a warning and an optional flag; a revert removes them, plans stay valid.
- two-way: timing tests under load (hook helper kill limit 30 s, SC #14 fastest of three against 800 ms) — test-only.
- two-way: example plan `On failure:` lines open with a K11 word, and its verbose-content test moves into the step that wires the output (6 steps, progress.json follows) — text in an example.
- two-way: `/trekreview` saves the reviewer's whole answer for the findings check and names `FINDING_PROSE_LINE_OUT_OF_RANGE` — command text.
- two-way: the criteria runner's commands run without `NODE_TEST_CONTEXT` — a false green under `node --test` is now red; nothing to do for users.
- two-way: the fixes from the release review of 6.3.0 (rounds 1-6) — checks and prose; `progress.json` may now say `simplify: "revert-refused"` (an optional value); a revert restores the old behaviour.
- one-way: a check in a brief or plan (criteria, `Regression:`, proof of red, the done line, the test lock) runs with no shell: its words reach the runner as written, so only plain words run (no quote, backslash, `$`, brace, or `~`/`@` at a word's start; a glob only for `node --test` on Node 21 and later; no path that climbs out with `..` or starts at `/`, git included; a trailing `# comment` in a shell fence is dropped) with a short list of read-only options per runner — git without `--output` or `--no-index`, `node --test` with a built-in `--test-reporter` only, nothing after `make test`, only paths after `npm test --`; an option that writes a file or loads code is refused — a criterion that used one (`git log --grep="x"`, `pytest -k foo`, `pytest tests/test_*.py`, `jest --coverage`, `go test -timeout 30s`) is now NOT RUN at review and not runnable on a 1.8 plan; wrap it in `bash tests/<script>.sh` (from 6.3.0).
- two-way: the phase-model resolver text says `effort` comes only from the brief's phase signal — prose only, no code change.
### Added
- **`/trekexecute` is coupled to the gates** (order 6).
- Plan approval (K2): `approval-log.mjs verify --kind plan` before step 1;
`not-logged`, `mismatch`, `expired`, `broken` and `diverged` halt with what
to do. Exit 2 is never a pass.
- Step end: the step check (K9; exit 2 is "not measured") and the step's
`Regression:` command (K10, new `lib/verification/regression-check.mjs`
through the criteria runner) run green before the checkpoint.
- After the last step: architecture rules (K5) and the plan-file sweep
(K6); a violation or a file no step names turns `completed` into
`partial`.
- The closing run reviews by itself (Phase 7.2): run the thing,
`/simplify`, `/trekreview`, verdict gate. A BLOCK adds the review's
remediation steps after the plan validator accepts them and the operator
re-approves the plan, at most `TREKEXECUTE_MAX_REVIEW_ROUNDS` (3) times.
- The main-merge gate is a lock on the verdict, and where the guard asks,
the executor does not ask again (one question per merge).
- `progress.json` gains optional coupling fields (Handover 5).
- **Skills** (orders 7a and 7b), the first three: `voyage:test-first` and
`voyage:debugging`, preloaded by `step-implementer`, and
`voyage:researching-sources`, preloaded by the five web-search agents and
named by `/trekresearch` at spawn. `lib/skills/skill-lint.mjs` checks the
form; `lib/skills/routing.mjs` is a model-free routing test over the
skills, the 7 commands and the 21 spawnable agents (collision at 0.75 fails;
thresholds pinned). Eval cases `evals/skill-*` are written, not run.
Authoring method: `docs/skills-authoring.md`.
- **Release and rollback** (order 8). `docs/release-runbook.md` (before, the
release, both rollback paths drilled in a sandbox) and
`scripts/release-check.mjs`: clean tree, `npm test` at HEAD, review verdict
not BLOCK and covering HEAD, a `### Doors` list. 0 ready, 1 not ready, 2
could not run.
- **Plan step rules** (order 9). `Regression:` — one allowlisted command,
unpiped (`PLAN_STEP_REGRESSION_NOT_RUNNABLE`); `On failure:` opens with
revert | retry | skip | escalate, or `PLAN_STEP_ON_FAILURE_UNKNOWN` warns.
Minors open after the last critic round are listed for the operator, and
`--extra-round` records the round they pick.
- **Tests-only steps** (order 10). `PLAN_STEP_TESTS_ONLY` warns on a step
whose files are all tests; `red-proof --plan/--step` reports `tests_only`,
and `/trekexecute` sends such a step to the plan instead of into fix rounds
that cannot succeed. `/trekplan`, the plan template and `plan-critic` say
the test belongs in the step that builds the behaviour.
### Fixed
- The approval guard refused only a merge into main and a push in the
permission mode that shows no prompts; a tag, a publish and destructive git
went through unattended. Now every call it would ask about is refused there.
- Plan-review dedup merges two findings on the same file:line with the same
content; the Jaccard threshold for every other pair is unchanged.
- `/trekbrief` Step 4c says the brief reviewer runs in the background and is
waited for.
- The Phase 4 size count includes `.mjs`, `.cjs`, `.mts`, `.cts`.
- `criteria-runner --evidence` shows the first output line on PASS rows.
- The findings schema checks `path:line` citations in finding prose against
the file; `/trekreview` checks the reviewer's whole answer and names
`FINDING_PROSE_LINE_OUT_OF_RANGE`.
- `git-historian` never proposes changing a file the brief froze, and names
the remote from `git remote -v`.
- The criteria runner's commands run without `NODE_TEST_CONTEXT`, so a test
run inside `node --test` can no longer pass falsely.
- Timing tests tolerate load (fastest of three; hook helper kill limit 30 s),
and the K2 hook tests use unique session ids per run.
- The example plan's `On failure:` lines use the K11 words, and its
verbose-content test moves into the step that wires the output. Steps 1-2
(golden capture, stability harness) keep their tests-only warning; a step
type for them is a later question, and step 2's heading no longer says it
must fail.
- The resolver text in four commands says `effort` comes only from the
brief's phase signal.
- Release review of 6.3.0 (seven rounds; rounds 1-3 BLOCK, rounds 4-7 WARN;
every finding of rounds 1-6 fixed with a test that failed first, round 7's
under Known issues): the guard refuses where
no one can be asked also when its rules will not load; review steps appended
by `/trekexecute` are held to the step rules on any plan version and need a
`Verify:` (`plan-validator --steps-from`); a restore the guard refuses is
recorded as `revert-refused` and stops the run before review, never as
reverted; every check Voyage runs runs with no shell (round 5, the
operator's structural fix after the patching of the shell screen did not
converge), as plain words and each runner's read-only options; the release
check compares version files without their version, lets the README change
only in the badge's version, reads `scope_sha_end` as a commit sha, sees a
file moved after the review, ignores Voyage's own artifacts committed after
the review, runs the cheap checks first, stops a suite that never ends,
works for a plugin in a subdirectory and never reports a crash as "not
ready"; the plan-file sweep reads `Files:` as v6.2.0 did, and the
tests-only check has its own reader, and red proof also reads the step's
diff (see Known issues); skill frontmatter and routing read YAML values, comments and tags
as YAML does; `regression-check` prints the progress.json status and runs
through the criteria runner itself. `/trekexecute` says plainly that review
steps in the template's own form stop with a re-plan.
### Known issues — for 6.3.1
The seventh round of the release review is WARN: 0 BLOCKER, 12 MAJOR, 0
MINOR. The operator chose to release 6.3.0 with it (2026-10-11); the findings
go into 6.3.1.
- Tests-only steps: when red proof decides, the step's diff in the documented
flow holds only the test, so it cannot overrule the plan's `Files:` reading;
a change already in the working tree when the step started counts against
tests-only; an unbackticked file next to a backticked path in `Files:` is
not read.
- Red proof reports a command killed by its timeout as "could not start". The
release check calls a suite killed for too much output a timeout, and reads
`--review` from the current directory instead of `--repo`.
- A trailing `# comment` is dropped in a fenced check but not in a backticked
one.
- The example plan's steps 1-2 keep their tests-only warning (operator
decision 2026-10-09; a step type for them is a later question).
- Four cleanups: the 15-minute timeout defined in three places, a line count
read once per citation, `isMain` copied into five more CLIs, and a
roundabout condition in the guard.
- Found while writing the review, and present in 6.2.0 too: the findings check
ends a reviewer's json block at the first triple backtick, even one inside a
JSON string.
## v6.2.0 — 2026-10-08 — Intent, plan gate, step helpers, guards and a review that is a gate ## v6.2.0 — 2026-10-08 — Intent, plan gate, step helpers, guards and a review that is a gate
Wave 1 of the practice build (five parallel orders, merged 2026-10-06), the Wave 1 of the practice build (five parallel orders, merged 2026-10-06), the

View file

@ -2,7 +2,7 @@
Contract-driven Claude Code pipeline: brief, research, plan, execute, review. Agent swarms, research triangulation, adversarial review, multi-session resumption. Contract-driven Claude Code pipeline: brief, research, plan, execute, review. Agent swarms, research triangulation, adversarial review, multi-session resumption.
![Version](https://img.shields.io/badge/version-6.2.0-blue) ![Version](https://img.shields.io/badge/version-6.3.0-blue)
![License](https://img.shields.io/badge/license-MIT-green) ![License](https://img.shields.io/badge/license-MIT-green)
![Platform](https://img.shields.io/badge/platform-Claude%20Code-purple) ![Platform](https://img.shields.io/badge/platform-Claude%20Code-purple)

View file

@ -98,7 +98,7 @@ A criterion whose command was **outside the allowlist** is reported as
`NOT RUN` too: the runner executes a criterion only when its command is a known `NOT RUN` too: the runner executes a criterion only when its command is a known
test runner (`npm test`, `node --test`, `pytest`, `bash tests/<script>.sh`, a test runner (`npm test`, `node --test`, `pytest`, `bash tests/<script>.sh`, a
read-only git subcommand, …) carrying no shell operator. Everything else never read-only git subcommand, …) carrying no shell operator. Everything else never
reaches a shell. Like any `NOT RUN` it is the ABSENCE of a measurement, never runs (and what runs, runs with no shell: its words reach the runner as written). Like any `NOT RUN` it is the ABSENCE of a measurement, never
evidence about the code, and never a finding of its own. evidence about the code, and never a finding of its own.
**A `NOT RUN` result is never on its own a finding.** It says nothing about **A `NOT RUN` result is never on its own a finding.** It says nothing about
@ -111,6 +111,7 @@ column names the reason the runner gave:
| `placeholder` | the text is a template placeholder (`{exact command}`) | | `placeholder` | the text is a template placeholder (`{exact command}`) |
| `not-a-command` | the sentence opened by NAMING a flag or a path (`--verbose`, `tests/`) rather than by invoking something. Running it would have produced exit 2 or exit 126 — a number about the sentence, not about the code | | `not-a-command` | the sentence opened by NAMING a flag or a path (`--verbose`, `tests/`) rather than by invoking something. Running it would have produced exit 2 or exit 126 — a number about the sentence, not about the code |
| `outside the allowlist` | the command is not a known test-runner invocation, or it carries a shell operator. It was never run — say so; do not treat it as a broken criterion | | `outside the allowlist` | the command is not a known test-runner invocation, or it carries a shell operator. It was never run — say so; do not treat it as a broken criterion |
| `the program could not start` | the runner is not installed where the review ran. Nothing was measured — say so; do not treat it as a broken criterion |
In every one of those cases, judge the criterion on delivered code alone and In every one of those cases, judge the criterion on delivered code alone and
say in the Evidence column that no measurement exists. Emit `MISSING_TEST` say in the Evidence column that no measurement exists. Emit `MISSING_TEST`

View file

@ -61,6 +61,9 @@ is one test-runner invocation with no pipe, redirect or `&&`: `npm test`,
`npm run <script>`, `node --test <file>`, `vitest`, `jest`, `pytest`, `npm run <script>`, `node --test <file>`, `vitest`, `jest`, `pytest`,
`python -m pytest`, `uv run pytest`, `cargo test`, `go test`, `make test`, `python -m pytest`, `uv run pytest`, `cargo test`, `go test`, `make test`,
`bash tests/<script>.sh` (or `/bin/bash tests/<script>.sh`), or `git status|log|diff|show|ls-files`. `bash tests/<script>.sh` (or `/bin/bash tests/<script>.sh`), or `git status|log|diff|show|ls-files`.
It runs with no shell, as plain words: no quote, `$`, brace, leading `~` or
`@`, no glob outside `node --test` (Node 21+), no `..` or absolute path, and
only the runner's read-only options.
Anything else — the product's own binary (`node bin/x.mjs …`), `jq`, `grep`, Anything else — the product's own binary (`node bin/x.mjs …`), `jq`, `grep`,
`git grep`, a pipe — is reported NOT RUN. Never PROPOSE such a command as a `git grep`, a pipe — is reported NOT RUN. Never PROPOSE such a command as a
criterion. When a criterion needs one, propose wrapping it in a script under criterion. When a criterion needs one, propose wrapping it in a script under

View file

@ -1486,7 +1486,7 @@ already rejects such a step (`PLAN_STEP_VERIFY_NOT_RUNNABLE`).
| Exit | `steps.{N}.red_proof` | Executor | | Exit | `steps.{N}.red_proof` | Executor |
|------|-----------------------|----------| |------|-----------------------|----------|
| 0 | `"proven"` | continue | | 0 | `"proven"` | continue |
| 1, `not-red-at-base`, `tests_only: true` | `"rejected"` | every file in the step's `Files:` is a test (the plan validator's `PLAN_STEP_TESTS_ONLY`, the same reading): the behaviour its tests cover already exists at the start point (an earlier step built it, or it was there before the plan), so the step can never be red and no rewrite of the test can fix that. The plan is what failed: the step goes straight up to the plan (Sub-step F, `fix_rounds = 4`, stop class 4), with no fix round — the planner moves the test into the step that builds the behaviour, or makes a test meant to pass from the start the `Regression:` of the step that changes the code | | 1, `not-red-at-base`, `tests_only: true` | `"rejected"` | every file in the step's `Files:` is a test (the plan validator's `PLAN_STEP_TESTS_ONLY`, the same reading), and so is every file the step changed since its start point: the behaviour its tests cover already exists at the start point (an earlier step built it, or it was there before the plan), so the step can never be red and no rewrite of the test can fix that. The plan is what failed: the step goes straight up to the plan (Sub-step F, `fix_rounds = 4`, stop class 4), with no fix round — the planner moves the test into the step that builds the behaviour, or makes a test meant to pass from the start the `Regression:` of the step that changes the code |
| 1, `not-red-at-base`, otherwise | `"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. `tests_only: null` (the plan or the step could not be read) counts here too | | 1, `not-red-at-base`, otherwise | `"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. `tests_only: null` (the plan or the step could not be read) counts here too |
| 1, `not-green-at-result` | `"rejected"` | the change does not make the test pass: a finding for Sub-step F | | 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. **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 | | 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 |
@ -1742,7 +1742,7 @@ CLI already speaks progress.json's words (`"blocked"` → `"unrunnable"`,
|------|------------------------|----------| |------|------------------------|----------|
| 0 | `"passed"`, or `"absent"` when the step has no `Regression:` (nothing new happens) | continue | | 0 | `"passed"`, or `"absent"` when the step has no `Regression:` (nothing new happens) | continue |
| 1 | `"failed"` (the suite went red) or `"unrunnable"` (not one allowlisted command; `"blocked"` by the denylist counts here too) | the step is not done: a finding for Sub-step F, exactly like a red Verify — it goes into the fix rounds, never to the checkpoint | | 1 | `"failed"` (the suite went red) or `"unrunnable"` (not one allowlisted command; `"blocked"` by the denylist counts here too) | the step is not done: a finding for Sub-step F, exactly like a red Verify — it goes into the fix rounds, never to the checkpoint |
| 2 | `"not-run"` | the CLI could not run (plan unreadable, no such step): a finding for Sub-step F, never green | | 2 | `"not-run"` | the CLI could not run (plan unreadable, no such step), or the runner could not start (not installed): a finding for Sub-step F, never green |
#### Sub-step E — Control #### Sub-step E — Control
@ -1986,8 +1986,9 @@ If any fail: record which failed. Include in final report.
Run the criteria runner over the plan file parsed in Phase 1 Run the criteria runner over the plan file parsed in Phase 1
(`{plan_path}`). It parses the `## Verification` section, screens every command (`{plan_path}`). It parses the `## Verification` section, screens every command
through the plugin's own PreToolUse denylist before it reaches a shell, and through the plugin's own PreToolUse denylist before it runs (with no shell:
runs what survives — in the FOREGROUND, never backgrounded. (A command spawned its words reach the runner as written), and runs what survives — in the
FOREGROUND, never backgrounded. (A command spawned
from node does not pass through the Bash tool, so that hook cannot fire by from node does not pass through the Bash tool, so that hook cannot fire by
itself; the runner invokes it over its documented stdin protocol instead of itself; the runner invokes it over its documented stdin protocol instead of
carrying a second copy of the denylist.) carrying a second copy of the denylist.)

View file

@ -790,7 +790,13 @@ Write the plan following the template structure. The plan MUST include:
`go test`, `make test`), `bash <script under tests/>` (also `go test`, `make test`), `bash <script under tests/>` (also
`/bin/bash <script under tests/>`, to pin the system shell), or a read-only `/bin/bash <script under tests/>`, to pin the system shell), or a read-only
`git` subcommand (`status`, `log`, `diff`, `show`, `ls-files` — not `git `git` subcommand (`status`, `log`, `diff`, `show`, `ls-files` — not `git
grep`) — with no pipe, redirect or `&&`. Anything else is reported NOT grep`) — with no pipe, redirect or `&&`. It runs with no shell: its words
reach the runner as written, so a word with a quote, backslash, `$`,
brace, or a leading `~` or `@` is refused, a glob only reaches
`node --test` on Node 21 and later (which expands its own patterns), no
path climbs out with `..` or starts at `/` (git included), each runner
takes a short list of read-only options, and nothing may follow
`make test`. A trailing `# comment` on a line of a shell fence is dropped. Anything else is reported NOT
RUN, never executed and never failed. A check that needs more than that is RUN, never executed and never failed. A check that needs more than that is
wrapped in a script under `tests/` and declared as `bash tests/<script>.sh`. wrapped in a script under `tests/` and declared as `bash tests/<script>.sh`.
9. **Estimated Scope** — File counts and complexity rating. 9. **Estimated Scope** — File counts and complexity rating.

View file

@ -245,7 +245,7 @@ if [ ! -f "$VOYAGE_ROOT/lib/verification/criteria-runner.mjs" ]; then
exit 2 exit 2
fi fi
# Every command is screened twice before it reaches a shell: the runner's own # Every command is screened twice before it runs (with no shell): the runner's own
# ALLOWLIST of test runners (npm test, node --test, pytest, bash tests/<script>, # ALLOWLIST of test runners (npm test, node --test, pytest, bash tests/<script>,
# a read-only git subcommand) reports anything else as NOT RUN, and the executor # a read-only git subcommand) reports anything else as NOT RUN, and the executor
# denylist (catastrophe) reports BLOCKED. Neither is ever run. Foreground only. # denylist (catastrophe) reports BLOCKED. Neither is ever run. Foreground only.

View file

@ -269,7 +269,7 @@ prose form (relaxed in S26 so the template's prose emission parses).
7. **Every plan_version, no version bump** (a plan without the field validates unchanged; voyage-lekerepo trial 2, 2026-10-08): 7. **Every plan_version, no version bump** (a plan without the field validates unchanged; voyage-lekerepo trial 2, 2026-10-08):
- **K10 — `Regression:`** (optional step field): ONE backticked command the criteria runner's allowlist runs, with no pipe and no `&&` (first backticked span, as for `Verify:`). The executor runs it green before the step's checkpoint; a step without it runs nothing new. `Verify:` stays the step's own red-first test. One that can never run is `PLAN_STEP_REGRESSION_NOT_RUNNABLE` (an error; a warning under `--soft`). - **K10 — `Regression:`** (optional step field): ONE backticked command the criteria runner's allowlist runs, with no pipe and no `&&` (first backticked span, as for `Verify:`). The executor runs it green before the step's checkpoint; a step without it runs nothing new. `Verify:` stays the step's own red-first test. One that can never run is `PLAN_STEP_REGRESSION_NOT_RUNNABLE` (an error; a warning under `--soft`).
- **K11 — `On failure:`** opens with one of `revert`, `retry`, `skip`, `escalate` (case-insensitive; a leading `**`, `_`, backtick or `-` is ignored, other leading characters are not; `onFailureAction` in `lib/parsers/plan-schema.mjs`), the words `/trekexecute` reads; any other opening is the warning `PLAN_STEP_ON_FAILURE_UNKNOWN`. A missing `On failure:` is unchanged (the executor defaults to `escalate`). - **K11 — `On failure:`** opens with one of `revert`, `retry`, `skip`, `escalate` (case-insensitive; a leading `**`, `_`, backtick or `-` is ignored, other leading characters are not; `onFailureAction` in `lib/parsers/plan-schema.mjs`), the words `/trekexecute` reads; any other opening is the warning `PLAN_STEP_ON_FAILURE_UNKNOWN`. A missing `On failure:` is unchanged (the executor defaults to `escalate`).
- **A step of only tests** (order 10, voyage-lekerepo trial 3, 2026-10-09): when every file in a step's `Files:` is a test (its `Test first:` file, or a path `lib/util/test-path.mjs` reads as a test: under `tests/`, `test/`, `__tests__/`, `spec/`, `specs/`, or named `*.test.*`, `*.spec.*`, `*_test.*`, `*_spec.*`, `test_*.py`, but never a document such as `docs/specs/api.md`; `stepTestsOnly` in `lib/parsers/plan-schema.mjs`), the warning is `PLAN_STEP_TESTS_ONLY`: the behaviour its tests cover exists at its start point, so it can never be proven red. A step without `Files:` is not judged. At run time `red-proof.mjs --plan <plan.md> --step <N>` puts the same reading on a `not-red-at-base` as `tests_only`, and `/trekexecute` sends a `tests_only: true` step straight up to the plan. - **A step of only tests** (order 10, voyage-lekerepo trial 3, 2026-10-09): when every file in a step's `Files:` is a test (its `Test first:` file, or a path `lib/util/test-path.mjs` reads as a test: under `tests/`, `test/`, `__tests__/`, `spec/`, `specs/`, or named `*.test.*`, `*.spec.*`, `*_test.*`, `*_spec.*`, `test_*.py`, but never a document such as `docs/specs/api.md`; `stepTestsOnly` in `lib/parsers/plan-schema.mjs`), the warning is `PLAN_STEP_TESTS_ONLY`: the behaviour its tests cover exists at its start point, so it can never be proven red. A step without `Files:` is not judged. At run time `red-proof.mjs --plan <plan.md> --step <N>` puts the same reading on a `not-red-at-base` as `tests_only`, true only when every file the step changed since its start point is a test too, and `/trekexecute` sends a `tests_only: true` step straight up to the plan.
**Validation strategy:** **Validation strategy:**

View file

@ -85,7 +85,7 @@ or stability harness comes before behavior change.
must_contain: [] must_contain: []
``` ```
### Step 2: Add stability test (must FAIL initially — verbose not yet wired) ### Step 2: Add stability test (passes today — the canary for step 5 onwards)
- **Files:** `tests/cli-no-verbose-stability.test.mjs` (new file) - **Files:** `tests/cli-no-verbose-stability.test.mjs` (new file)
- **Changes:** Three subtests, one per golden file. Each runs `node src/cli.mjs <cmd> ...` and asserts stdout `===` `readFileSync('tests/golden/<cmd>.stdout')`. The test should PASS today (no behavior change yet) — it's the canary for step 5 onwards. - **Changes:** Three subtests, one per golden file. Each runs `node src/cli.mjs <cmd> ...` and asserts stdout `===` `readFileSync('tests/golden/<cmd>.stdout')`. The test should PASS today (no behavior change yet) — it's the canary for step 5 onwards.

View file

@ -28,7 +28,7 @@
}, },
{ {
"n": 2, "n": 2,
"title": "Add stability test (must FAIL initially — verbose not yet wired)", "title": "Add stability test (passes today — the canary for step 5 onwards)",
"status": "completed", "status": "completed",
"started_at": "2026-05-01T10:16:01Z", "started_at": "2026-05-01T10:16:01Z",
"completed_at": "2026-05-01T10:18:42Z", "completed_at": "2026-05-01T10:18:42Z",

View file

@ -289,9 +289,9 @@ try {
} }
// In the permission mode where Claude Code shows no prompts, an "ask" reaches // In the permission mode where Claude Code shows no prompts, an "ask" reaches
// no one and the call runs unattended. Everything this guard would ask about — // no one and the call runs unattended. Everything this guard would ask about -
// merge into main, push, tag/release, destructive git, and a command it could // merge into main, push, tag/release, destructive git, and a command it could
// not read — is refused there instead, with the way forward (operator // not read - is refused there instead, with the way forward (operator
// decisions 2026-10-09: first merge and push, then the other two classes). // decisions 2026-10-09: first merge and push, then the other two classes).
// /trekexecute relies on this: its main-merge gate is a lock, and this guard's // /trekexecute relies on this: its main-merge gate is a lock, and this guard's
// question is the one question. // question is the one question.

View file

@ -285,48 +285,69 @@ export function onFailureAction(text) {
// ---- a step that holds nothing but tests ----------------------------------- // ---- a step that holds nothing but tests -----------------------------------
/** /**
* The paths a field names, in order. A backticked span is a path as written, * The paths a step's `Files:` field names, in order, for the tests-only check
* parentheses and all (`app/(auth)/x.sh`), also with punctuation after it * below (the K6 sweep keeps v6.2.0's own reader: plan-scope-sweep.mjs,
* (`a.mjs`.) and inside a note ("(fixture: `t/a.json`)"). In the plain text a * operator 2026-10-11). The convention in the plan template is a backticked
* parenthesised note with no span in it is not a path ("(new: covers x, y)"), * path, and in every plan measured (520 Files: fields in 182 plans, 11.10)
* and every word that looks like a path is one: a name with an extension, a * a field with backticked paths had no other path-like word beside them:
* path with a slash, a directory (`docs/`) or a glob (`src/*.mjs`). A word *
* with no slash and no extension counts only alone in its entry and * - A field with a backticked path names its backticked spans that pass the
* capitalised, like `Makefile`; "format" or "v6.3.0" in a description is no * path test (a `.`, `/` or `\`; no URL, option or call like `parse()`),
* path. Release review of 6.3.0, rounds 1-4. * wherever they sit (`a.mjs`/`b.mjs`, inside a note), and a backticked name
* with no extension that stands alone in its entry (`Makefile`). Its descriptions are
* not read: "covers pass/fail", "JSON, YAML" or a directory named in passing
* is no path. An entry of its own with no backticks (", .gitignore") counts
* by its first word, and only when that is clearly a file or a directory.
* - A field with no backticked path names every path-like word: a name with an
* extension, a dotfile, a path with a slash, a directory (`docs/`) or a glob
* (`src/*.mjs`); a parenthesised note is not read. A word with no slash and
* no extension counts only alone in its entry and capitalised (`Makefile`);
* "format" or "v6.3.0" in a description is no path.
*
* A backslash is read as a slash, a leading `./` is dropped. Release review of
* 6.3.0, rounds 1-5.
*/ */
const SEG = '[\\w@.*?()-]'; const SEG = '[\\w@.*?()-]';
const PATH_WORD = new RegExp(`^(?:${SEG}+\\/)+${SEG}*$|^[\\w@*?-][\\w@.*?-]*\\.[A-Za-z*][A-Za-z0-9*]*$`); const PATH_WORD = new RegExp(`^(?:${SEG}+\\/)+${SEG}*$|^[\\w@*?-][\\w@.*?-]*\\.[A-Za-z*][A-Za-z0-9*]*$|^\\.[\\w-][\\w.-]*$`);
// Abbreviations that PATH_WORD would read as a name with an extension. // Abbreviations that PATH_WORD would read as a name with an extension.
const NOT_A_PATH = /^(?:e\.g|i\.e|n\.b|cf|etc|vs)$/i; const NOT_A_PATH = /^(?:e\.g|i\.e|n\.b|cf|etc|vs)$/i;
const BARE_NAME = /^[A-Z][\w.-]*$/; const BARE_NAME = /^[A-Z][\w.-]*$/;
const dotSlash = (p) => p.replace(/^\.\//, ''); // The last segment is a dotfile or a name with an extension, or the path is a directory.
/** const FILE_OR_DIR = /(?:^|\/)(?:\.[\w-][\w.-]*|[^/]*\.[A-Za-z*][A-Za-z0-9*]*)$|\/$/;
* The one reader of a step's `Files:` field: this file's tests-only check and const norm = (p) => p.replace(/\\/g, '/').replace(/^\.\//, '');
* the plan-file sweep (lib/verification/plan-scope-sweep.mjs) both use it, so // A backticked span that is a path, not a URL, an option or a call (`parse()`, `p.parse()`).
* the two never judge different lists for one step. A leading `./` is dropped. const tickedPath = (s) => /[./\\]/.test(s) && !/^https?:/.test(s) && !s.startsWith('-') && !/^[\w$.]+\(.*\)$/.test(s);
*/ // A plain word without the brackets and punctuation around it, `\` read as `/`.
const plainWord = (w) => w.replace(/^[(\[]+/, '').replace(/[.,;:)\]]+$/, '').replace(/\\/g, '/');
const isPathWord = (w) => PATH_WORD.test(w) && !NOT_A_PATH.test(w);
export function fieldPaths(text) { export function fieldPaths(text) {
const spans = []; const spans = [];
const masked = String(text ?? '') const masked = String(text ?? '')
.replace(/`([^`\n]+)`/g, (_, p) => `\u0000${spans.push(p.trim()) - 1}\u0000`) .replace(/`([^`\n]+)`/g, (_, p) => `\u0000${spans.push(p.trim()) - 1}\u0000`)
.replace(/(^|\s)\(([^()\n]*)\)/g, (m, pre, inner) => (inner.includes('\u0000') ? m : `${pre} `)); .replace(/(^|\s)\(([^()\n]*)\)/g, (m, pre, inner) => (inner.includes('\u0000') ? m : `${pre} `));
const isTicked = spans.map(tickedPath);
const tickedField = isTicked.some(Boolean);
const out = []; const out = [];
for (const raw of masked.split(/[,\n]/)) { for (const raw of masked.split(/[,\n]/)) {
const entry = raw.trim().replace(/^[-*]\s+/, ''); const entry = raw.trim().replace(/^[-*]\s+/, '');
if (entry === '') continue; if (entry === '') continue;
const words = entry.split(/\s+/); const inEntry = [...entry.matchAll(/\u0000(\d+)\u0000/g)].map((m) => Number(m[1]));
const found = []; const ticked = inEntry.filter((i) => isTicked[i]);
for (const word of words) { if (ticked.length) { out.push(...ticked.map((i) => spans[i])); continue; }
const w = word.replace(/^[(\[]+/, '').replace(/[.,;:)\]]+$/, ''); // A backticked `Makefile` alone in its entry is a file (round 6).
const span = /^\u0000(\d+)\u0000$/.exec(w); if (inEntry.length === 1 && /^\u0000\d+\u0000$/.test(entry) && BARE_NAME.test(spans[inEntry[0]])) { out.push(spans[inEntry[0]]); continue; }
if (span) found.push(spans[Number(span[1])]); const words = entry.split(/\s+/).filter((w) => !w.includes('\u0000')).map(plainWord).filter(Boolean);
else if (PATH_WORD.test(w) && !NOT_A_PATH.test(w)) found.push(w); if (tickedField) {
const first = plainWord(entry.split(/\s+/)[0]);
if (!first.includes('\u0000') && isPathWord(first) && FILE_OR_DIR.test(first)) out.push(first);
continue;
} }
if (found.length === 0 && words.length === 1 && BARE_NAME.test(words[0])) found.push(words[0]); const found = words.filter(isPathWord);
if (found.length === 0 && words.length === 1 && entry.split(/\s+/).length === 1 && BARE_NAME.test(words[0])) found.push(words[0]);
out.push(...found); out.push(...found);
} }
return out.filter((p) => p && !p.includes('\u0000')).map(dotSlash); return out.filter(Boolean).map(norm);
} }
// A document or a data file outside tests/ is never the test here: // A document or a data file outside tests/ is never the test here:
@ -354,15 +375,23 @@ export function stepTestsOnly(body) {
return stepTestsOnlyFromFields(parseStepFields(body)); return stepTestsOnlyFromFields(parseStepFields(body));
} }
/**
* Whether a path counts as a test for this step: its own `Test first:` file, or
* a path that is clearly a test. red-proof.mjs uses it on the step's diff.
* @returns {(path: string) => boolean}
*/
export function stepTestFile(f) {
const testFirst = f.has('Test first')
? [...f.get('Test first').text.matchAll(/\bFile:\**[ \t]*`([^`\n]+)`/gi)].map((m) => norm(m[1].trim()))
: [];
return (p) => testFirst.includes(p) ||
(isTestPath(p) && (UNDER_TESTS_RE.test(p) || !(DOC_RE.test(p) || DATA_RE.test(p))));
}
/** `stepTestsOnly` for a caller that already holds the step's parsed fields. */ /** `stepTestsOnly` for a caller that already holds the step's parsed fields. */
export function stepTestsOnlyFromFields(f) { export function stepTestsOnlyFromFields(f) {
const files = f.has('Files') ? fieldPaths(f.get('Files').text) : []; const files = f.has('Files') ? fieldPaths(f.get('Files').text) : [];
const testFirst = f.has('Test first') return { testsOnly: files.length > 0 && files.every(stepTestFile(f)), files };
? [...f.get('Test first').text.matchAll(/\bFile:\**[ \t]*`([^`\n]+)`/gi)].map((m) => dotSlash(m[1].trim()))
: [];
const isTest = (p) => testFirst.includes(p) ||
(isTestPath(p) && (UNDER_TESTS_RE.test(p) || !(DOC_RE.test(p) || DATA_RE.test(p))));
return { testsOnly: files.length > 0 && files.every(isTest), files };
} }
// ---- sections (fence-aware) ------------------------------------------------ // ---- sections (fence-aware) ------------------------------------------------

View file

@ -10,6 +10,14 @@
// real one. Claude Code loads a skill whose YAML does not parse "with no // real one. Claude Code loads a skill whose YAML does not parse "with no
// fields set", so the description is gone and the skill cannot match; a trap // fields set", so the description is gone and the skill cannot match; a trap
// here is an error, not a style note. // here is an error, not a style note.
//
// A value is read as YAML reads it: trailing space on the line is nothing, and
// a comment (` #`, or `#` at the start) ends a plain value, a flow list or a
// block indicator (`model: opus # pinned` is `opus`). Two comments are still
// traps: one that is the whole value (the value is empty), and one that cuts a
// plain `description`, the text Claude Code routes on (`Use when issue #42
// …` would route on "Use when issue"); quote the description if "#" belongs to
// it. Release review of 6.3.0, rounds 3-5.
const KEY_RE = /^([A-Za-z0-9_-]+):(.*)$/; const KEY_RE = /^([A-Za-z0-9_-]+):(.*)$/;
const BLOCK_RE = /^([|>])([+-]?)\s*$/; const BLOCK_RE = /^([|>])([+-]?)\s*$/;
@ -27,7 +35,7 @@ export function splitFile(text) {
return { ok: true, fmLines: lines.slice(1, end), body: lines.slice(end + 1).join('\n'), bodyStartLine: end + 2 }; return { ok: true, fmLines: lines.slice(1, end), body: lines.slice(end + 1).join('\n'), bodyStartLine: end + 2 };
} }
/** The YAML traps in one plain (unquoted, single-line) scalar value. */ /** The YAML traps in one plain (unquoted, single-line) scalar value, meant whole. */
export function plainScalarHazards(value) { export function plainScalarHazards(value) {
const out = []; const out = [];
if (/:\s/.test(value) || /:$/.test(value)) out.push('unquoted ": " (YAML reads a mapping and the line fails)'); if (/:\s/.test(value) || /:$/.test(value)) out.push('unquoted ": " (YAML reads a mapping and the line fails)');
@ -39,6 +47,24 @@ export function plainScalarHazards(value) {
return out; return out;
} }
// Where a YAML comment starts in a value: `#` at the start or after a space or
// tab, outside a quoted item (a quote opens only at the start of a token, so
// the apostrophe in `don't` is text). -1 when there is none.
function commentAt(v) {
let q = null;
for (let i = 0; i < v.length; i++) {
const c = v[i];
if (q) {
if (q === '"' && c === '\\') i++;
else if (c === q) q = null;
continue;
}
if ((c === '"' || c === "'") && (i === 0 || /[\s,[]/.test(v[i - 1]))) { q = c; continue; }
if (c === '#' && (i === 0 || v[i - 1] === ' ' || v[i - 1] === '\t')) return i;
}
return -1;
}
// A quoted value ends at its closing quote; after it only a YAML comment may // A quoted value ends at its closing quote; after it only a YAML comment may
// follow (`name: "x" # pinned`). Inside double quotes a backslash escapes the // follow (`name: "x" # pinned`). Inside double quotes a backslash escapes the
// next character; inside single quotes '' is one quote. // next character; inside single quotes '' is one quote.
@ -101,7 +127,7 @@ export function readFrontmatterFields(text) {
const m = KEY_RE.exec(line); const m = KEY_RE.exec(line);
if (!m) { hazards.push({ line: lineNo, message: `not a "key: value" line: ${line.slice(0, 60)}` }); i++; continue; } if (!m) { hazards.push({ line: lineNo, message: `not a "key: value" line: ${line.slice(0, 60)}` }); i++; continue; }
const key = m[1]; const key = m[1];
const rest = m[2].replace(/^[ \t]+/, ''); const rest = m[2].trim();
if (m[2] !== '' && !/^[ \t]/.test(m[2])) hazards.push({ line: lineNo, message: `"${key}:" needs a space after the colon` }); if (m[2] !== '' && !/^[ \t]/.test(m[2])) hazards.push({ line: lineNo, message: `"${key}:" needs a space after the colon` });
if (key in fields) hazards.push({ line: lineNo, message: `duplicate key "${key}"` }); if (key in fields) hazards.push({ line: lineNo, message: `duplicate key "${key}"` });
@ -111,16 +137,23 @@ export function readFrontmatterFields(text) {
while (j < fmLines.length && (fmLines[j].trim() === '' || /^\s/.test(fmLines[j]))) { cont.push(fmLines[j]); j++; } while (j < fmLines.length && (fmLines[j].trim() === '' || /^\s/.test(fmLines[j]))) { cont.push(fmLines[j]); j++; }
while (cont.length && cont[cont.length - 1].trim() === '') { cont.pop(); } while (cont.length && cont[cont.length - 1].trim() === '') { cont.pop(); }
const block = BLOCK_RE.exec(rest); // A quoted value keeps its own comment rule (unquote); any other value
// ends where a comment starts.
const quoted = rest[0] === '"' || rest[0] === "'";
const cut = quoted ? -1 : commentAt(rest);
const value = cut < 0 ? rest : rest.slice(0, cut).trimEnd();
const block = BLOCK_RE.exec(value);
if (block) { if (block) {
fields[key] = { value: blockValue(cont, block[1], block[2]), line: lineNo, style: 'block' }; fields[key] = { value: blockValue(cont, block[1], block[2]), line: lineNo, style: 'block' };
} else if (rest === '') { } else if (value === '') {
const items = cont.filter((l) => l.trim() !== ''); const items = cont.filter((l) => l.trim() !== '');
if (items.length && items.every((l) => /^\s+-\s/.test(l))) { if (items.length && items.every((l) => /^\s+-\s/.test(l))) {
fields[key] = { value: items.map((l) => l.replace(/^\s+-\s+/, '').trim()), line: lineNo, style: 'list' }; fields[key] = { value: items.map((l) => l.replace(/^\s+-\s+/, '').trim()), line: lineNo, style: 'list' };
} else { } else {
fields[key] = { value: items.join('\n'), line: lineNo, style: items.length ? 'map' : 'empty' }; fields[key] = { value: items.join('\n'), line: lineNo, style: items.length ? 'map' : 'empty' };
} }
if (cut === 0 && !items.length) hazards.push({ line: lineNo, message: `${key}: "#" at the start is a YAML comment: the value is empty` });
} else { } else {
if (cont.length) hazards.push({ line: lineNo, message: `"${key}" has a value and indented lines under it` }); if (cont.length) hazards.push({ line: lineNo, message: `"${key}" has a value and indented lines under it` });
const q = unquote(rest); const q = unquote(rest);
@ -129,15 +162,18 @@ export function readFrontmatterFields(text) {
fields[key] = { value: rest, line: lineNo, style: 'broken' }; fields[key] = { value: rest, line: lineNo, style: 'broken' };
} else if (q) { } else if (q) {
fields[key] = { value: q.value, line: lineNo, style: 'quoted' }; fields[key] = { value: q.value, line: lineNo, style: 'quoted' };
} else if (/^\[.*\]$/.test(rest)) { } else if (/^\[.*\]$/.test(value)) {
const inner = rest.slice(1, -1).trim(); const inner = value.slice(1, -1).trim();
fields[key] = { fields[key] = {
value: inner === '' ? [] : inner.split(',').map((s) => { const t = s.trim(); const u = unquote(t); return u && !u.error ? u.value : t; }), value: inner === '' ? [] : inner.split(',').map((s) => { const t = s.trim(); const u = unquote(t); return u && !u.error ? u.value : t; }),
line: lineNo, style: 'flow', line: lineNo, style: 'flow',
}; };
} else { } else {
for (const h of plainScalarHazards(rest)) hazards.push({ line: lineNo, message: `${key}: ${h}` }); for (const h of plainScalarHazards(value)) hazards.push({ line: lineNo, message: `${key}: ${h}` });
fields[key] = { value: rest, line: lineNo, style: 'plain' }; if (cut > 0 && key === 'description') {
hazards.push({ line: lineNo, message: `${key}: " #" starts a YAML comment: the description is cut to "${value}"; quote it if "#" belongs to it` });
}
fields[key] = { value, line: lineNo, style: 'plain' };
} }
} }
i = j; i = j;

View file

@ -42,10 +42,9 @@ import { join, resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process';
import { resolveRepo, canonicalPath } from '../verification/repo-tools.mjs'; import { resolveRepo, canonicalPath } from '../verification/repo-tools.mjs';
import { allowedCommand, screenCommand } from '../verification/criteria-runner.mjs'; import { allowedCommand, runCommand, screenCommand } from '../verification/criteria-runner.mjs';
import { commandEnv, DEFAULT_TIMEOUT_MS } from '../verification/red-proof.mjs'; import { DEFAULT_TIMEOUT_MS } from '../verification/red-proof.mjs';
export const LOCK_VERSION = 1; export const LOCK_VERSION = 1;
@ -179,10 +178,10 @@ function main(argv) {
if (!allowed.allowed) fail(2, `command not run: ${allowed.reason}`); if (!allowed.allowed) fail(2, `command not run: ${allowed.reason}`);
const screen = screenCommand(testCmd); const screen = screenCommand(testCmd);
if (!screen.allowed) fail(2, `command blocked: ${screen.rule}`); if (!screen.allowed) fail(2, `command blocked: ${screen.rule}`);
const r = spawnSync('/bin/sh', ['-c', testCmd], { // No shell: the words the screen checked are the words that run.
cwd: cur.repo.root, env: commandEnv(), encoding: 'utf8', timeout: DEFAULT_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024, const r = runCommand(testCmd, { cwd: cur.repo.root, timeoutMs: DEFAULT_TIMEOUT_MS });
}); // With no shell, 126/127 is the test program's own exit: a red, not "could not run".
if (r.error || r.status === null || r.status === 126 || r.status === 127) { if (r.error || r.status === null) {
fail(2, `the test command did not run to an exit code (${r.error?.message ?? r.signal ?? `exit ${r.status}`})`); fail(2, `the test command did not run to an exit code (${r.error?.message ?? r.signal ?? `exit ${r.status}`})`);
} }
if (r.status !== 0) fail(1, `the test command exited ${r.status}; the lock stays unmarked`); if (r.status !== 0) fail(1, `the test command exited ${r.status}; the lock stays unmarked`);

View file

@ -21,7 +21,17 @@
// tests/<script>.sh`, a read-only git subcommand, …) — anything else is NOT // tests/<script>.sh`, a read-only git subcommand, …) — anything else is NOT
// RUN, never a failure — and what survives is then screened through the // RUN, never a failure — and what survives is then screened through the
// plugin's own PreToolUse denylist (hooks/scripts/pre-bash-executor.mjs) — // plugin's own PreToolUse denylist (hooks/scripts/pre-bash-executor.mjs) —
// catastrophe — before it reaches a shell. // catastrophe — before it runs.
//
// No shell. A command runs as its words (`runCommand`): the runner is started
// directly with the words the screen checked, so no quoting, glob, tilde,
// brace or variable can turn them into other words after the check. Five
// review rounds of 6.3.0 found one more way /bin/sh rewrote a checked word
// (quotes, tilde, a glob that reached `..`); with no shell there is nothing
// left to rewrite (operator 2026-10-11). The same function runs every check
// Voyage runs: Phase 7 criteria, `Regression:` (regression-check.mjs), proof
// of red (red-proof.mjs), the done line (done-line.mjs), the test lock
// (lib/util/test-lock.mjs).
// Commands spawned from here do not pass through the Bash tool, so that hook // Commands spawned from here do not pass through the Bash tool, so that hook
// would not otherwise fire; the hook is invoked over its documented stdin // would not otherwise fire; the hook is invoked over its documented stdin
// protocol rather than copied, so there is exactly one denylist. // protocol rather than copied, so there is exactly one denylist.
@ -151,11 +161,14 @@ export function firstCommand(text) {
// A command line inside a shell-tagged fence. A console block may prefix the // A command line inside a shell-tagged fence. A console block may prefix the
// line with a `$` or `>` prompt; blank and comment-only lines declare nothing. // line with a `$` or `>` prompt; blank and comment-only lines declare nothing.
// A `#` root prompt is NOT stripped: it is indistinguishable from a comment, // A `#` root prompt is NOT stripped: it is indistinguishable from a comment,
// and running a comment is the worse of the two mistakes. // and running a comment is the worse of the two mistakes. A trailing comment
// (`npm test # all green`) is not part of the command: a shell dropped it,
// and with no shell (runCommand) its words would reach the screen and make an
// honest check NOT RUN (release review of 6.3.0, round 6).
function fencedCommand(text) { function fencedCommand(text) {
const line = text.trim(); const line = text.trim();
if (line === '' || line.startsWith('#')) return null; if (line === '' || line.startsWith('#')) return null;
return line.replace(/^[$>]\s+/, ''); return line.replace(/^[$>]\s+/, '').replace(/\s+#.*$/, '');
} }
function parseSection(markdown, heading, prefix) { function parseSection(markdown, heading, prefix) {
@ -257,13 +270,21 @@ export function screenCommand(command, opts = {}) {
// - It is not a sandbox. `npm test`, `npm run <script>` and `make test` run // - It is not a sandbox. `npm test`, `npm run <script>` and `make test` run
// whatever the repo's own package.json/Makefile says they run, including a // whatever the repo's own package.json/Makefile says they run, including a
// script that pushes. That is the repo's responsibility, not this file's. // script that pushes. That is the repo's responsibility, not this file's.
// - It is not an option parser. Each runner takes only plain words (no // - It is not an option parser. Each runner takes only plain words and a
// quote, backslash, `$` or brace; a glob only inside a path) and a short // short list of options that only read (RUNNER_ARGS); an option that
// list of options that only read (RUNNER_ARGS); an option that writes a // writes a file or loads code (`--test-reporter-destination`,
// file or loads code (`--test-reporter-destination`, `--outputFile`, // `--outputFile`, `--junitxml`, `--basetemp`, `--import`, `--config`,
// `--junitxml`, `--basetemp`, `--import`, `--config`, `-o`, `-exec`) is on // `-o`, `-exec`) is on no list (release review of 6.3.0, round 3: rounds
// no list (release review of 6.3.0, round 3: rounds 1-2 closed this for // 1-2 closed this for git only).
// git only). // - Plain words: the words reach the runner as written, so a word that
// would mean something else in a shell — a quote, backslash, `$`, brace,
// a leading `~` — is refused rather than passed on with a meaning its
// author did not intend. A glob is passed on only to `node --test`, which
// expands its own patterns (Node 21 and later); no other runner here
// expands one, so elsewhere it is refused. A path that climbs out with
// `..` or starts at `/` is refused: a check runs in the repo. A word that
// starts with `@` is refused: pytest (8.2 and later) reads `@file` as a
// file of more arguments, options the list would refuse.
// - It rejects plenty of honest commands: an env prefix (`CI=1 npm test`), a // - It rejects plenty of honest commands: an env prefix (`CI=1 npm test`), a
// project's own binary (`./build/app --check`), anything piped. A check // project's own binary (`./build/app --check`), anything piped. A check
// that needs one of those is declared through a runner instead — // that needs one of those is declared through a runner instead —
@ -274,8 +295,6 @@ const SHELL_OPERATORS = /[;&|<>]|\$\(|`/;
// Read-only git subcommands: they report, they do not write a repository. // Read-only git subcommands: they report, they do not write a repository.
const READONLY_GIT = new Set(['status', 'log', 'diff', 'show', 'ls-files']); const READONLY_GIT = new Set(['status', 'log', 'diff', 'show', 'ls-files']);
// A word /bin/sh hands to git unchanged (refs like HEAD~1 and HEAD^, formats like %h, paths).
const GIT_LITERAL_WORD = /^[A-Za-z0-9_@%^~:.,=+/-]+$/;
// `--output`, `--output=<file>`, or an abbreviation of it git may accept. // `--output`, `--output=<file>`, or an abbreviation of it git may accept.
function writesOutput(word) { function writesOutput(word) {
const name = word.split('=')[0]; const name = word.split('=')[0];
@ -285,10 +304,18 @@ function writesOutput(word) {
const deny = (reason) => ({ allowed: false, reason }); const deny = (reason) => ({ allowed: false, reason });
const permit = () => ({ allowed: true, reason: '' }); const permit = () => ({ allowed: true, reason: '' });
// A word /bin/sh hands on unchanged, or a path with a glob in it: a glob can // A word that means the same with or without a shell (also git's refs like
// only expand to names that start with the word's first character, so it // HEAD~1 and HEAD^, formats like %h, paths).
// cannot make an option out of a path. const PLAIN_WORD = /^[A-Za-z0-9_@%^~:.,=+/-]+$/;
const PLAIN_WORD = /^(?:[A-Za-z0-9_@%^~:.,=+/-]+|[A-Za-z0-9_./][A-Za-z0-9_@%^~:.,=+/*?-]*)$/; // The same, with a glob in it: passed on only to `node --test`, which expands
// its own patterns from Node 21 on (sh would have expanded `.?` to `..`).
const GLOB_WORD = /^[A-Za-z0-9_./][A-Za-z0-9_@%^~:.,=+/*?-]*$/;
const NODE_GLOB_MAJOR = 21;
const runningNodeMajor = () => Number(process.versions.node.split('.')[0]);
// A path outside the repo: one that climbs with `..` or starts at `/`.
const leavesRepo = (w) => w.startsWith('/') || w.split('/').includes('..');
// The same for a git word, which may also name a path as `<rev>:<path>`.
const gitLeavesRepo = (w) => leavesRepo(w) || (w.includes(':') && leavesRepo(w.slice(w.indexOf(':') + 1)));
// After each runner's fixed words: the options it may take (`name` or // After each runner's fixed words: the options it may take (`name` or
// `name=value`; `value` lists the ones whose value may be the next word, and // `name=value`; `value` lists the ones whose value may be the next word, and
@ -386,15 +413,19 @@ const RUNNERS = {
'/bin/bash': (t) => (isTestScript(t[1]) ? permit() : deny('/bin/bash, but not a script under tests/')), '/bin/bash': (t) => (isTestScript(t[1]) ? permit() : deny('/bin/bash, but not a script under tests/')),
git: (t) => { git: (t) => {
if (!READONLY_GIT.has(t[1])) return deny(`git, but not a read-only subcommand first (${[...READONLY_GIT].join('|')})`); if (!READONLY_GIT.has(t[1])) return deny(`git, but not a read-only subcommand first (${[...READONLY_GIT].join('|')})`);
// The command runs through /bin/sh, which unquotes and expands what it is // The words reach git as written (no shell), and only words that mean the
// given, so a check on the words here holds only for words the shell passes // same with or without one run: no quote, backslash, `$`, glob or brace.
// through unchanged: no quote, backslash, `$`, glob or brace. Then // Then `--output` (and an abbreviation of it) is refused: it makes
// `--output` (and an abbreviation of it) is refused: it makes diff/log/show // diff/log/show write to a file (release review of 6.3.0, 2026-10-10: a
// write to a file (release review of 6.3.0, 2026-10-10: a plan's check // plan's check could overwrite any file, first plainly, then through
// could overwrite any file, first plainly, then through quoting). // quoting while a shell still ran it).
const odd = t.find((w) => !GIT_LITERAL_WORD.test(w)); const odd = t.find((w) => !PLAIN_WORD.test(w));
if (odd !== undefined) return deny(`git with a word the shell would rewrite (${odd}): only plain words are checked`); if (odd !== undefined) return deny(`git with a word that means something else in a shell (${odd}): only plain words run, as written`);
if (t.some(writesOutput)) return deny('git with --output writes a file, so it is not a read-only check'); if (t.some(writesOutput)) return deny('git with --output writes a file, so it is not a read-only check');
// A check runs in the repo: no path that climbs out or starts at /, and no
// --no-index, which makes git diff compare any two files (round 6).
const out = t.slice(2).find((w) => w === '--no-index' || (!w.startsWith('-') && gitLeavesRepo(w)));
if (out !== undefined) return deny(`git with a path outside the repo (${out}): a check runs in the repo`);
return permit(); return permit();
}, },
}; };
@ -431,27 +462,62 @@ function screenShape(command, opts, writeTime) {
const operator = text.match(SHELL_OPERATORS); const operator = text.match(SHELL_OPERATORS);
if (operator) return deny(`a shell operator (${operator[0]}) — one criterion declares one command, unpiped`); if (operator) return deny(`a shell operator (${operator[0]}) — one criterion declares one command, unpiped`);
const tokens = text.trim().split(/\s+/); const tokens = commandWords(text);
if (!Object.hasOwn(RUNNERS, tokens[0])) return deny(`\`${tokens[0]}\` is not a known test runner`); if (!Object.hasOwn(RUNNERS, tokens[0])) return deny(`\`${tokens[0]}\` is not a known test runner`);
// /bin/sh expands a tilde at the start of a word (and after = or : in an // A shell expands a tilde at the start of a word (and after = or : in an
// assignment), so such a word is not one the shell passes on unchanged. // assignment); no shell runs this, so the runner would get the ~ as written.
const tilde = tokens.find((w) => /(^|[=:])~/.test(w)); const tilde = tokens.find((w) => /(^|[=:])~/.test(w));
if (tilde !== undefined) return deny(`a word the shell would rewrite (${tilde}): a leading ~ is expanded`); if (tilde !== undefined) return deny(`a word that means something else in a shell (${tilde}): a leading ~ is not expanded here`);
const form = RUNNERS[tokens[0]](tokens, cwd, writeTime); const form = RUNNERS[tokens[0]](tokens, cwd, writeTime);
if (!form.allowed || tokens[0] === 'git') return form; if (!form.allowed || tokens[0] === 'git') return form;
const odd = tokens.find((w) => !PLAIN_WORD.test(w)); const words = tokens.slice(1);
if (odd !== undefined) return deny(`a word the shell would rewrite (${odd}): only plain words are checked`); const nodeTest = tokens[0] === 'node' && tokens[1] === '--test';
const nodeMajor = opts.nodeMajor ?? runningNodeMajor();
const odd = words.find((w) => !PLAIN_WORD.test(w) && !(nodeTest && GLOB_WORD.test(w)));
if (odd !== undefined) {
return deny(GLOB_WORD.test(odd)
? `a glob (${odd}): no shell runs this, and \`${tokens[0]}\` does not expand one itself; name the files, or use \`node --test\``
: `a word that means something else in a shell (${odd}): only plain words run, as written`);
}
const glob = nodeTest && nodeMajor < NODE_GLOB_MAJOR ? words.find((w) => !PLAIN_WORD.test(w)) : undefined;
if (glob !== undefined) {
return deny(`a glob (${glob}): node ${nodeMajor} does not expand one itself (Node ${NODE_GLOB_MAJOR} and later do), and no shell runs this; name the files`);
}
const at = words.find((w) => w.startsWith('@'));
if (at !== undefined) return deny(`a word that starts with @ (${at}): pytest reads @file as a file of more arguments`);
const out = words.find((w) => !w.startsWith('-') && leavesRepo(w));
if (out !== undefined) return deny(`a path outside the repo (${out}): a check runs in the repo, with no .. and no absolute path`);
return Object.hasOwn(RUNNER_ARGS, tokens[0]) ? screenArgs(tokens, RUNNER_ARGS[tokens[0]]) : permit(); return Object.hasOwn(RUNNER_ARGS, tokens[0]) ? screenArgs(tokens, RUNNER_ARGS[tokens[0]]) : permit();
} }
/** The words of a command: what the screen checks is exactly what runs. */
export function commandWords(command) {
return String(command ?? '').trim().split(/\s+/);
}
/**
* Run a screened command with no shell: its first word is the program (found
* on PATH), the rest are its arguments, as written. Without NODE_TEST_CONTEXT
* (lib/util/command-env.mjs): started under `node --test`, a nested `node
* --test` reported to the parent and exited 0 on a red test, a false green
* (order 10, 2026-10-09). Returns spawnSync's result: `status` is null when
* the program could not start (`error`) or was killed (`signal`).
*/
export function runCommand(command, { cwd, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
const [program, ...args] = commandWords(command);
return spawnSync(program, args, { cwd, env: commandEnv(), encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });
}
// --- running ---------------------------------------------------------------- // --- running ----------------------------------------------------------------
// Without NODE_TEST_CONTEXT (lib/util/command-env.mjs): started under // A program that could not start (not installed: ENOENT) has no exit code and
// `node --test`, a nested `node --test` criterion reported to the parent and // measured nothing: `startError` says so, and runCriteria reports it NOT RUN.
// exited 0 on a red test - a false green (order 10, 2026-10-09). // One killed by the timeout did start, and a hang is a red (`failed`).
function defaultExec(command, { cwd, timeoutMs }) { function defaultExec(command, { cwd, timeoutMs }) {
const r = spawnSync('/bin/sh', ['-c', command], { cwd, encoding: 'utf8', timeout: timeoutMs, env: commandEnv() }); const r = runCommand(command, { cwd, timeoutMs });
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }; if (r.status === null && r.signal === null && r.error) return { status: null, stdout: '', stderr: '', startError: r.error.message };
const why = r.signal ? `killed (${r.signal})\n` : '';
return { status: r.status, stdout: r.stdout ?? '', stderr: `${r.stderr ?? ''}${why}` };
} }
function cap(text, max) { function cap(text, max) {
@ -463,9 +529,10 @@ function cap(text, max) {
* Run each criterion that has a command. Returns one result per criterion: * Run each criterion that has a command. Returns one result per criterion:
* passed the command exited 0 * passed the command exited 0
* failed the command exited non-zero (or was killed by the timeout) * failed the command exited non-zero (or was killed by the timeout)
* blocked the executor denylist denied it — it never reached a shell * blocked the executor denylist denied it — it never ran
* unrunnable there is nothing to run: no command (placeholder or prose-only * unrunnable there is nothing to run: no command (placeholder or prose-only
* criterion), or a command outside the allowlist * criterion), a command outside the allowlist, or a runner that
* could not start (`startError`: not installed)
*/ */
export function runCriteria(criteria, opts = {}) { export function runCriteria(criteria, opts = {}) {
const exec = opts.exec ?? defaultExec; const exec = opts.exec ?? defaultExec;
@ -495,6 +562,9 @@ export function runCriteria(criteria, opts = {}) {
return { ...base, status: 'blocked', exitCode: null, output: `blocked: ${verdict.rule}` }; return { ...base, status: 'blocked', exitCode: null, output: `blocked: ${verdict.rule}` };
} }
const r = exec(c.command, { cwd, timeoutMs }); const r = exec(c.command, { cwd, timeoutMs });
if (r.startError) {
return { ...base, status: 'unrunnable', exitCode: null, output: `not runnable: the program could not start (${r.startError})`, startError: r.startError };
}
const output = cap(`${r.stdout ?? ''}${r.stderr ?? ''}`, maxOutput); const output = cap(`${r.stdout ?? ''}${r.stderr ?? ''}`, maxOutput);
return { return {
...base, ...base,
@ -716,8 +786,8 @@ export function formatCriteriaEvidence(rep) {
out.push( out.push(
'**NOT RUN is not a pass.** You cannot run these commands yourself — never infer, assume or ' + '**NOT RUN is not a pass.** You cannot run these commands yourself — never infer, assume or ' +
'reconstruct a result for a criterion that has none. A criterion whose result is FAILED or ' + 'reconstruct a result for a criterion that has none. A criterion whose result is FAILED or ' +
'BLOCKED is `Broken`; a criterion marked NOT RUN declared no command, or a command outside ' + 'BLOCKED is `Broken`; a criterion marked NOT RUN declared no command, a command outside ' +
'the allowlist of test runners, so it never reached a shell — that is the ABSENCE of a ' + 'the allowlist of test runners, or a runner that could not start, so it never ran — that is the ABSENCE of a ' +
'measurement and never on its own a finding; a criterion with no result is judged on ' + 'measurement and never on its own a finding; a criterion with no result is judged on ' +
'delivered code alone, and you say so in the Evidence column.' 'delivered code alone, and you say so in the Evidence column.'
); );

View file

@ -7,7 +7,8 @@
// appended to the step's report file. Exit 0 = written, 1 = the command failed // appended to the step's report file. Exit 0 = written, 1 = the command failed
// (nothing written), 2 = could not run (nothing written; never green): no // (nothing written), 2 = could not run (nothing written; never green): no
// --step, a command outside the test-runner allowlist, a command that cannot // --step, a command outside the test-runner allowlist, a command that cannot
// start, a working tree that is not a git repo. // start (not installed) or was killed, a working tree that is not a git repo.
// With no shell, an exit 126/127 is the test program's own and a failure.
// //
// `--check --step <N> --report <file> [--commit <sha>]`: exit 0 when the report // `--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 // carries a well-formed line for step N (at exactly that commit, with
@ -23,8 +24,8 @@ import { appendFileSync, existsSync, readFileSync, realpathSync } from 'node:fs'
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { allowedCommand, screenCommand } from './criteria-runner.mjs'; import { allowedCommand, runCommand, screenCommand } from './criteria-runner.mjs';
import { commandEnv, DEFAULT_TIMEOUT_MS, repoRoot } from './red-proof.mjs'; import { DEFAULT_TIMEOUT_MS, repoRoot } from './red-proof.mjs';
export function doneLineRe(step, commit) { export function doneLineRe(step, commit) {
const sha = commit ? String(commit).replace(/[^0-9a-f]/g, '') : '[0-9a-f]{40}'; const sha = commit ? String(commit).replace(/[^0-9a-f]/g, '') : '[0-9a-f]{40}';
@ -55,8 +56,9 @@ export function writeDoneLine({ cwd, step, cmd, report, timeoutMs = DEFAULT_TIME
if (!screen.allowed) return { status: 'not-run', reason: `command blocked: ${screen.rule}` }; if (!screen.allowed) return { status: 'not-run', reason: `command blocked: ${screen.rule}` };
const head = spawnSync('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], { cwd: dir, encoding: 'utf8' }); const head = spawnSync('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], { cwd: dir, encoding: 'utf8' });
if (head.status !== 0) return { status: 'not-run', reason: `no git HEAD in ${dir}` }; if (head.status !== 0) return { status: 'not-run', reason: `no git HEAD in ${dir}` };
const r = spawnSync('/bin/sh', ['-c', cmd], { cwd: dir, env: commandEnv(), encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 }); // No shell: the words the screen checked are the words that run.
if (r.error || r.status === null || r.status === 126 || r.status === 127) { const r = runCommand(cmd, { cwd: dir, timeoutMs });
if (r.error || r.status === null) {
return { status: 'not-run', reason: `the command did not run to an exit code (${r.error?.message ?? r.signal ?? `exit ${r.status}`})`, exit: r.status }; return { status: 'not-run', reason: `the command did not run to an exit code (${r.error?.message ?? r.signal ?? `exit ${r.status}`})`, exit: r.status };
} }
if (r.status !== 0) return { status: 'failed', reason: `the test command exited ${r.status}`, exit: r.status }; if (r.status !== 0) return { status: 'failed', reason: `the test command exited ${r.status}`, exit: r.status };

View file

@ -33,7 +33,7 @@ import { readFileSync, existsSync, realpathSync } from 'node:fs';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { resolve, relative, dirname, isAbsolute, sep } from 'node:path'; import { resolve, relative, dirname, isAbsolute, sep } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { sliceSteps, parseStepFields, fieldPaths } from '../parsers/plan-schema.mjs'; import { sliceSteps, parseStepFields } from '../parsers/plan-schema.mjs';
import { parseManifest } from '../parsers/manifest-yaml.mjs'; import { parseManifest } from '../parsers/manifest-yaml.mjs';
// Files Voyage's own process writes while planning and executing. // Files Voyage's own process writes while planning and executing.
@ -44,8 +44,9 @@ export const PROCESS_FILES = Object.freeze([
]); ]);
const BACKTICK_SPAN = /`([^`\n]+)`/g; const BACKTICK_SPAN = /`([^`\n]+)`/g;
// A backticked path in `Test first:` (spaces allowed: `docs/my file.md`). // Backticked: spaces allowed (`docs/my file.md`); bare: no whitespace.
const looksLikeTickedPath = s => /[./]/.test(s) && !/^https?:/.test(s) && !s.startsWith('-') && s.trim() === s; const looksLikeTickedPath = s => /[./]/.test(s) && !/^https?:/.test(s) && !s.startsWith('-') && s.trim() === s;
const looksLikePath = s => !/\s/.test(s) && looksLikeTickedPath(s);
function pathsIn(text) { function pathsIn(text) {
const out = []; const out = [];
@ -61,9 +62,20 @@ export function plannedPaths(planText) {
for (const s of sliceSteps(planText)) { for (const s of sliceSteps(planText)) {
const f = parseStepFields(s.body); const f = parseStepFields(s.body);
const files = f.get('Files'); const files = f.get('Files');
// One reader with the tests-only check (plan-schema.mjs fieldPaths): // v6.2.0's own reading, kept as it was (operator 2026-10-11): every
// backticked spans as written, else every path-like word. // backticked span that looks like a path, and plain words only when the
if (files) for (const p of fieldPaths(files.text)) out.add(norm(p)); // field has none. A field that names its paths in backticks plans those,
// never a path its description mentions. Release review of 6.3.0, rounds
// 3-5: reading Files: through the tests-only check's reader
// (plan-schema.mjs fieldPaths) dropped paths v6.2.0 planned and planned
// directories a description named, so K6 has its own reader again; the
// two answer different questions (what may change; is every named file a test).
if (files) {
const ticked = pathsIn(files.text);
// Older plans write bare paths: `- Files: a.ts, b.ts`.
const bare = ticked.length ? [] : files.text.split(/[,\s]+/).filter(looksLikePath);
for (const p of [...ticked, ...bare]) out.add(norm(p));
}
const tf = f.get('Test first'); const tf = f.get('Test first');
if (tf) { if (tf) {
for (const line of tf.text.split('\n')) { for (const line of tf.text.split('\n')) {

View file

@ -15,8 +15,9 @@
// run, and only the full run is authoritative. // run, and only the full run is authoritative.
// //
// `--plan <plan.md> --step <N>` adds `tests_only` to a `not-red-at-base`: true // `--plan <plan.md> --step <N>` adds `tests_only` to a `not-red-at-base`: true
// when every file in the step's `Files:` is a test, so the step can never be // when every file in the step's `Files:` is a test and so is every file the
// red and goes up to the plan (see `testsOnly` below). // step changed since its start point, so the step can never be red and goes
// up to the plan (see `testsOnly` below).
// //
// Exit 0 = proven, 1 = rejected (`not-red-at-base` or `not-green-at-result`), // 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 // 2 = could not run (`not-run`: no test files, an unknown start point, a command
@ -42,6 +43,7 @@
// (criteria-runner.mjs `allowedCommand`), and it is screened through the // (criteria-runner.mjs `allowedCommand`), and it is screened through the
// plugin's own PreToolUse denylist (`screenCommand`): a command spawned from // plugin's own PreToolUse denylist (`screenCommand`): a command spawned from
// node never passes through the Bash tool, so the hook cannot fire by itself. // node never passes through the Bash tool, so the hook cannot fire by itself.
// It runs with no shell (`runCommand`): the words checked are the words run.
// //
// When the output is TAP (`--test-reporter=tap`, pytest-tap, ...), the counts // When the output is TAP (`--test-reporter=tap`, pytest-tap, ...), the counts
// `# tests N` / `# fail N` are reported per run. They show WHAT the red was: // `# tests N` / `# fail N` are reported per run. They show WHAT the red was:
@ -56,11 +58,11 @@
import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync } from 'node:fs'; import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync } from 'node:fs';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { dirname, isAbsolute, join, resolve } from 'node:path'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { allowedCommand, screenCommand } from './criteria-runner.mjs'; import { allowedCommand, runCommand, screenCommand } from './criteria-runner.mjs';
import { commandEnv } from '../util/command-env.mjs'; import { commandEnv } from '../util/command-env.mjs';
import { sliceSteps, stepTestsOnly } from '../parsers/plan-schema.mjs'; import { parseStepFields, sliceSteps, stepTestFile, stepTestsOnlyFromFields } from '../parsers/plan-schema.mjs';
export const DEFAULT_TIMEOUT_MS = 900_000; export const DEFAULT_TIMEOUT_MS = 900_000;
const TAIL = 2000; const TAIL = 2000;
@ -90,8 +92,9 @@ export function repoRoot(dir) {
// failing test (independent review, 2026-10-08). // 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; const LOAD_ERROR = /^\s*SyntaxError: |ERR_MODULE_NOT_FOUND|does not provide an export named|Cannot find module|ERR_REQUIRE_ESM|ModuleNotFoundError|ImportError: /m;
// No shell: the words the screen checked are the words that run.
function run(cmd, cwd, timeoutMs) { function run(cmd, cwd, timeoutMs) {
const r = spawnSync('/bin/sh', ['-c', cmd], { cwd, env: commandEnv(), encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 }); const r = runCommand(cmd, { cwd, timeoutMs });
const output = `${r.stdout ?? ''}${r.stderr ?? ''}`; const output = `${r.stdout ?? ''}${r.stderr ?? ''}`;
return { exit: r.status, signal: r.signal ?? null, tap: tap(output), tail: output.slice(-TAIL), spawnError: r.error ? r.error.message : null, loadError: LOAD_ERROR.test(output) }; return { exit: r.status, signal: r.signal ?? null, tap: tap(output), tail: output.slice(-TAIL), spawnError: r.error ? r.error.message : null, loadError: LOAD_ERROR.test(output) };
} }
@ -108,22 +111,42 @@ const notRun = (reason, extra = {}) => ({ status: 'not-run', reason, ...extra })
// the executor sends the step up to the plan instead of into fix rounds // the executor sends the step up to the plan instead of into fix rounds
// (voyage-lekerepo trial 3, 2026-10-09). `null` = the plan or the step could // (voyage-lekerepo trial 3, 2026-10-09). `null` = the plan or the step could
// not be read; without `--plan` the field is absent and nothing new happens. // not be read; without `--plan` the field is absent and nothing new happens.
function testsOnly(o) { //
// The plan's `Files:` is prose, read by a reader that can misread it; the
// step's own diff is a fact. So a step the plan calls tests-only is so only
// when every file it changed since the start point is a test too (release
// review of 6.3.0, round 6). Before building (`--red-only`) the diff holds the
// step's test alone, so there the plan decides. The plan file itself and
// Voyage's own files under .claude/ (progress, approvals) are not the step's change.
function testsOnly(o, cwd, baseSha) {
if (!o.plan) return {}; if (!o.plan) return {};
let text; let text;
try { text = readFileSync(o.plan, 'utf8'); } try { text = readFileSync(o.plan, 'utf8'); }
catch (e) { return { tests_only: null, tests_only_note: `cannot read the plan: ${e.message}` }; } catch (e) { return { tests_only: null, tests_only_note: `cannot read the plan: ${e.message}` }; }
const step = sliceSteps(text).find((s) => Number(s.n) === Number(o.step)); const step = sliceSteps(text).find((s) => Number(s.n) === Number(o.step));
if (!step) return { tests_only: null, tests_only_note: `no "### Step ${o.step}:" in the plan` }; if (!step) return { tests_only: null, tests_only_note: `no "### Step ${o.step}:" in the plan` };
return { tests_only: stepTestsOnly(step.body).testsOnly }; const fields = parseStepFields(step.body);
if (!stepTestsOnlyFromFields(fields).testsOnly) return { tests_only: false };
const isTest = stepTestFile(fields);
const given = new Set(o.tests ?? []);
const changed = [
...lines(git(cwd, ['diff', '--name-only', '--no-renames', baseSha]).stdout),
...lines(git(cwd, ['ls-files', '--others', '--exclude-standard']).stdout),
];
const planRel = relative(cwd, realpathSync(o.plan)).split(sep).join('/');
const other = [...new Set(changed)].filter((p) => p !== planRel && !p.startsWith('.claude/') && !given.has(p) && !isTest(p));
if (other.length === 0) return { tests_only: true };
return { tests_only: false, tests_only_note: `the plan's Files: names only tests, but the step's diff also changes ${other.slice(0, 5).join(', ')}` };
} }
// A shell that could not find or execute the command (126/127), a signal (the // A program that could not start (a spawn error: not installed) or a signal
// timeout), or a spawn error is not a test verdict at all. // (the timeout) is not a test verdict at all. With no shell, an exit 126/127
// is the test program's own (a script whose tool the step has not built yet):
// a red like any other, and the control run tells an environment fault apart
// (release review of 6.3.0, round 6).
function unrunnable(r) { function unrunnable(r) {
if (r.spawnError) return `the command could not start: ${r.spawnError}`; if (r.spawnError) return `the command could not start: ${r.spawnError}`;
if (r.exit === null) return `the command was killed (${r.signal ?? 'timeout'})`; if (r.exit === null) return `the command was killed (${r.signal ?? 'timeout'})`;
if (r.exit === 126 || r.exit === 127) return `the shell could not run the command (exit ${r.exit})`;
return null; return null;
} }
@ -218,7 +241,7 @@ export function proveRed(o) {
const baseBroken = unrunnable(base); const baseBroken = unrunnable(base);
if (baseBroken) return notRun(`at the start point: ${baseBroken}`, { base, result, linked }); if (baseBroken) return notRun(`at the start point: ${baseBroken}`, { base, result, linked });
const out = { base_sha: baseSha, linked, base, result }; const out = { base_sha: baseSha, linked, base, result };
if (base.exit === 0) return { status: 'rejected', reason: 'not-red-at-base', ...testsOnly(o), ...out }; if (base.exit === 0) return { status: 'rejected', reason: 'not-red-at-base', ...testsOnly(o, cwd, baseSha), ...out };
if (o.redOnly) return { status: 'proven', reason: 'red at the start point (--red-only: the result was not run)', red_kind: redKind(base), base_sha: baseSha, linked, base }; if (o.redOnly) return { status: 'proven', reason: 'red at the start point (--red-only: the result was not run)', red_kind: redKind(base), base_sha: baseSha, linked, base };
if (result.exit !== 0) return { status: 'rejected', reason: 'not-green-at-result', ...out }; if (result.exit !== 0) return { status: 'rejected', reason: 'not-green-at-result', ...out };

View file

@ -9,7 +9,9 @@
// 1 = it failed, the executor denylist blocked it, or it is not runnable // 1 = it failed, the executor denylist blocked it, or it is not runnable
// (no command, or a command outside the test-runner allowlist): the // (no command, or a command outside the test-runner allowlist): the
// step is not done, and that is a finding for the fix rounds // step is not done, and that is a finding for the fix rounds
// 2 = could not run: bad usage, a plan that cannot be read, no step N // 2 = could not run: bad usage, a plan that cannot be read, no step N, or
// a runner that could not start (not installed): nothing was measured,
// as red-proof and done-line say for the same case
// JSON on stdout: {step, status, progress_status, command, exit_code, output} // JSON on stdout: {step, status, progress_status, command, exit_code, output}
// status: passed | failed | blocked | unrunnable | absent | error // status: passed | failed | blocked | unrunnable | absent | error
// progress_status: the value progress.json takes for steps.N.regression // progress_status: the value progress.json takes for steps.N.regression
@ -26,24 +28,17 @@
// The runner reports `exitCode`; this CLI writes `exit_code`, the key // The runner reports `exitCode`; this CLI writes `exit_code`, the key
// progress.json uses (C15). Nothing else is translated. // progress.json uses (C15). Nothing else is translated.
// //
// The command runs the way the runner runs it (`/bin/sh -c`), but without // The command runs exactly as the runner runs a criterion: its own run, with
// NODE_TEST_CONTEXT in its environment (lib/util/command-env.mjs): measured // no shell and without NODE_TEST_CONTEXT (criteria-runner.mjs `runCommand`).
// 2026-10-08, a red `node --test` regression read as passed when this CLI was // This file had a copy of that code, and a fix made in one copy would have
// itself started under `node --test`, because the nested run reported to the // left K10 regressions running differently from Phase 7 criteria (release
// parent and exited 0. // review of 6.3.0, round 5).
import { readFileSync, realpathSync } from 'node:fs'; import { readFileSync, realpathSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { sliceSteps, parseStepFields } from '../parsers/plan-schema.mjs'; import { sliceSteps, parseStepFields } from '../parsers/plan-schema.mjs';
import { firstCommand, runCriteria } from './criteria-runner.mjs'; import { firstCommand, runCriteria } from './criteria-runner.mjs';
import { commandEnv } from '../util/command-env.mjs';
function exec(command, { cwd, timeoutMs }) {
const r = spawnSync('/bin/sh', ['-c', command], { cwd, encoding: 'utf8', timeout: timeoutMs, env: commandEnv() });
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
}
export const REGRESSION_FIELD = 'Regression'; export const REGRESSION_FIELD = 'Regression';
const EXIT = Object.freeze({ passed: 0, absent: 0, failed: 1, blocked: 1, unrunnable: 1, error: 2 }); const EXIT = Object.freeze({ passed: 0, absent: 0, failed: 1, blocked: 1, unrunnable: 1, error: 2 });
@ -54,7 +49,7 @@ const withProgress = (r) => ({ ...r, progress_status: PROGRESS[r.status] ?? 'not
* @param {{ planText: string, step: number, cwd?: string, run?: Function }} o * @param {{ planText: string, step: number, cwd?: string, run?: Function }} o
* @returns {{ step: number, status: string, command: string|null, exit_code: number|null, output: string, exit: 0|1|2 }} * @returns {{ step: number, status: string, command: string|null, exit_code: number|null, output: string, exit: 0|1|2 }}
*/ */
export function checkRegression({ planText, step, cwd = process.cwd(), run = (c, o) => runCriteria(c, { ...o, exec }) }) { export function checkRegression({ planText, step, cwd = process.cwd(), run = (c, o) => runCriteria(c, o) }) {
const base = { step, command: null, exit_code: null, output: '' }; const base = { step, command: null, exit_code: null, output: '' };
const found = sliceSteps(planText).find((s) => Number(s.n) === step); const found = sliceSteps(planText).find((s) => Number(s.n) === step);
if (!found) return withProgress({ ...base, status: 'error', output: `no "### Step ${step}:" in the plan`, exit: EXIT.error }); if (!found) return withProgress({ ...base, status: 'error', output: `no "### Step ${step}:" in the plan`, exit: EXIT.error });
@ -62,13 +57,15 @@ export function checkRegression({ planText, step, cwd = process.cwd(), run = (c,
if (!field) return withProgress({ ...base, status: 'absent', output: 'no Regression: field — nothing to run', exit: EXIT.absent }); if (!field) return withProgress({ ...base, status: 'absent', output: 'no Regression: field — nothing to run', exit: EXIT.absent });
const { command, reason } = firstCommand(field.text); const { command, reason } = firstCommand(field.text);
const [r] = run([{ label: `R${step}`, text: field.text, command, reason }], { cwd }); const [r] = run([{ label: `R${step}`, text: field.text, command, reason }], { cwd });
// A runner that could not start measured nothing: not a red suite (round 6).
const status = r.startError ? 'error' : r.status;
return withProgress({ return withProgress({
step, step,
status: r.status, status,
command: r.command ?? null, command: r.command ?? null,
exit_code: r.exitCode ?? null, exit_code: r.exitCode ?? null,
output: r.output ?? '', output: r.output ?? '',
exit: EXIT[r.status] ?? EXIT.error, exit: EXIT[status] ?? EXIT.error,
}); });
} }

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "voyage", "name": "voyage",
"version": "6.2.0", "version": "6.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "voyage", "name": "voyage",
"version": "6.2.0", "version": "6.3.0",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=18"

View file

@ -1,6 +1,6 @@
{ {
"name": "voyage", "name": "voyage",
"version": "6.2.0", "version": "6.3.0",
"private": true, "private": true,
"description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline. /trekbrief, /trekplan, and /trekreview each end by building a self-contained operator-annotation HTML (scripts/annotate.mjs, pencil-toggle annotation pattern): select text or click any heading/paragraph/list-item, pick intent (Fiks/Endre/Spørsmål), write comment, copy structured prompt, paste back, Claude revises the .md.", "description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline. /trekbrief, /trekplan, and /trekreview each end by building a self-contained operator-annotation HTML (scripts/annotate.mjs, pencil-toggle annotation pattern): select text or click any heading/paragraph/list-item, pick intent (Fiks/Endre/Spørsmål), write comment, copy structured prompt, paste back, Claude revises the .md.",
"type": "module", "type": "module",

View file

@ -156,10 +156,16 @@ function checkTree(repo) {
return { id: 'tree', status: 'pass', detail: 'working tree clean' }; return { id: 'tree', status: 'pass', detail: 'working tree clean' };
} }
function checkSuite(repo, treeClean) { // A suite that never ends is stopped, as every other check runner here stops
// its command (15 minutes), and read as not measured (release review of
// 6.3.0, round 6): the operator always gets an answer.
export const SUITE_TIMEOUT_MS = 900_000;
function checkSuite(repo, treeClean, timeoutMs = SUITE_TIMEOUT_MS) {
if (!treeClean) return { id: 'suite', status: 'fail', detail: 'suite not run: the tree has uncommitted changes, so it would not measure HEAD' }; if (!treeClean) return { id: 'suite', status: 'fail', detail: 'suite not run: the tree has uncommitted changes, so it would not measure HEAD' };
// commandEnv: under a parent `node --test` the suite would report to it and exit 0 whatever it found. // commandEnv: under a parent `node --test` the suite would report to it and exit 0 whatever it found.
const r = spawnSync('npm', ['test'], { cwd: repo, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, env: commandEnv() }); const r = spawnSync('npm', ['test'], { cwd: repo, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, env: commandEnv(), timeout: timeoutMs });
if (r.signal) return { id: 'suite', status: 'error', detail: `npm test was killed (${r.signal}) after the ${Math.round(timeoutMs / 1000)} s timeout — not measured` };
if (r.error) return { id: 'suite', status: 'error', detail: `npm test could not start: ${r.error.message} — not measured` }; if (r.error) return { id: 'suite', status: 'error', detail: `npm test could not start: ${r.error.message} — not measured` };
const out = `${r.stdout ?? ''}\n${r.stderr ?? ''}`; const out = `${r.stdout ?? ''}\n${r.stderr ?? ''}`;
const summary = out.split('\n').filter((l) => /^ℹ (tests|pass|fail|skipped) /.test(l)).map((l) => l.slice(2)); const summary = out.split('\n').filter((l) => /^ℹ (tests|pass|fail|skipped) /.test(l)).map((l) => l.slice(2));
@ -206,7 +212,9 @@ function checkCoverage(repo, reviewText) {
// --relative: the paths, and the files that count, are those under the plugin's // --relative: the paths, and the files that count, are those under the plugin's
// own directory, also when it sits in a subdirectory of a larger repo. // own directory, also when it sits in a subdirectory of a larger repo.
const names = git(repo, ['diff', '--relative', '--name-only', full, 'HEAD']); // --no-renames: a file moved into an allowed place after the review shows
// both its old path and its new one, so its removal is seen (round 5).
const names = git(repo, ['diff', '--relative', '--no-renames', '--name-only', full, 'HEAD']);
if (names.status !== 0) return { id: 'coverage', status: 'error', detail: `git diff failed: ${names.stderr.trim()}` }; if (names.status !== 0) return { id: 'coverage', status: 'error', detail: `git diff failed: ${names.stderr.trim()}` };
const changed = names.stdout.split('\n').filter(Boolean); const changed = names.stdout.split('\n').filter(Boolean);
const outside = changed.filter((f) => !RELEASE_BOOKKEEPING.includes(f) && !PROCESS_PREFIXES.some((p) => f.startsWith(p))); const outside = changed.filter((f) => !RELEASE_BOOKKEEPING.includes(f) && !PROCESS_PREFIXES.some((p) => f.startsWith(p)));
@ -221,7 +229,7 @@ function checkCoverage(repo, reviewText) {
if (!same) outside.push(`${file} (lines other than the version)`); if (!same) outside.push(`${file} (lines other than the version)`);
} }
if (changed.includes('README.md')) { if (changed.includes('README.md')) {
const d = git(repo, ['diff', '--relative', '-U0', full, 'HEAD', '--', 'README.md']); const d = git(repo, ['diff', '--relative', '--no-renames', '-U0', full, 'HEAD', '--', 'README.md']);
if (d.status !== 0) return { id: 'coverage', status: 'error', detail: `git diff of README.md failed: ${d.stderr.trim()} — not measured` }; if (d.status !== 0) return { id: 'coverage', status: 'error', detail: `git diff of README.md failed: ${d.stderr.trim()} — not measured` };
if (!onlyBadgeVersion(changedLines(d.stdout))) outside.push('README.md (lines other than the version badge)'); if (!onlyBadgeVersion(changedLines(d.stdout))) outside.push('README.md (lines other than the version badge)');
} }
@ -261,7 +269,7 @@ function resolveVersion(repo, explicit) {
* Run every check. Pure of process exit; the CLI below prints and exits. * Run every check. Pure of process exit; the CLI below prints and exits.
* @returns {{ exit: 0|1|2, version: string|null, repo: string, checks: object[], reason?: string }} * @returns {{ exit: 0|1|2, version: string|null, repo: string, checks: object[], reason?: string }}
*/ */
export function releaseCheck({ repo, review, version: explicitVersion }) { export function releaseCheck({ repo, review, version: explicitVersion, suiteTimeoutMs }) {
const top = git(repo, ['rev-parse', '--show-toplevel']); const top = git(repo, ['rev-parse', '--show-toplevel']);
if (top.error || top.status !== 0) return { exit: EXIT.UNDECIDED, version: null, repo, checks: [], reason: `${repo} is not a git repository` }; if (top.error || top.status !== 0) return { exit: EXIT.UNDECIDED, version: null, repo, checks: [], reason: `${repo} is not a git repository` };
// The plugin's own directory, not the git top level: a plugin may sit in a // The plugin's own directory, not the git top level: a plugin may sit in a
@ -279,7 +287,7 @@ export function releaseCheck({ repo, review, version: explicitVersion }) {
const undecided = [tree, rev, coverage, door].filter((c) => c.status === 'error'); const undecided = [tree, rev, coverage, door].filter((c) => c.status === 'error');
const suite = undecided.length > 0 const suite = undecided.length > 0
? { id: 'suite', status: 'skip', detail: `not run: ${undecided.map((c) => c.id).join(', ')} could not be measured, so the answer is exit 2 already` } ? { id: 'suite', status: 'skip', detail: `not run: ${undecided.map((c) => c.id).join(', ')} could not be measured, so the answer is exit 2 already` }
: checkSuite(root, tree.status === 'pass'); : checkSuite(root, tree.status === 'pass', suiteTimeoutMs);
const checks = [tree, suite, rev, coverage, door]; const checks = [tree, suite, rev, coverage, door];
let exit = EXIT.READY; let exit = EXIT.READY;

View file

@ -113,7 +113,8 @@ verifiable by running a specific command or observing a specific system behavior
- {criterion — e.g., "No TypeScript errors: `npm run typecheck` exits 0"} - {criterion — e.g., "No TypeScript errors: `npm run typecheck` exits 0"}
One command per criterion, from the runner's allowlist (`npm test`, `npm run <script>`, One command per criterion, from the runner's allowlist (`npm test`, `npm run <script>`,
`node --test`, `pytest`, `bash tests/<script>.sh`, …), with no pipe or `&&`. `node --test`, `pytest`, `bash tests/<script>.sh`, …), with no pipe or `&&`,
in plain words (it runs with no shell: no quote, `$` or glob outside `node --test`).
Anything else is wrapped in a script under `tests/`; otherwise the review Anything else is wrapped in a script under `tests/`; otherwise the review
reports the criterion as NOT RUN. reports the criterion as NOT RUN.

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:1130-1145 | profile, phase_models, profile_source | none | | 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:1130-1145 | 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 | | 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 | | 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:2891-2913 | 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:2892-2914 | 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 (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) | | 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 | | 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

@ -394,7 +394,9 @@ const SHELL_BLOCK_PLAN = [
test('parsePlanVerification: a fenced shell block yields one criterion per command line', () => { test('parsePlanVerification: a fenced shell block yields one criterion per command line', () => {
const criteria = parsePlanVerification(SHELL_BLOCK_PLAN); const criteria = parsePlanVerification(SHELL_BLOCK_PLAN);
assert.deepEqual(criteria.map((c) => c.label), ['V1', 'V2']); assert.deepEqual(criteria.map((c) => c.label), ['V1', 'V2']);
assert.equal(criteria[0].command, 'npm test # all green'); // A trailing comment is not part of the command: with no shell to drop it,
// its words would make the check NOT RUN (release review of 6.3.0, round 6).
assert.equal(criteria[0].command, 'npm test');
assert.equal(criteria[1].command, 'node --test tests/lib/x.test.mjs'); assert.equal(criteria[1].command, 'node --test tests/lib/x.test.mjs');
}); });

View file

@ -7,7 +7,7 @@
import { test } from 'node:test'; import { test } from 'node:test';
import { strict as assert } from 'node:assert'; import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@ -139,11 +139,14 @@ test('review 630 / 2261016d: a SKILL.md of 499 lines ending in a newline passes
// ---- cb34c32e: regression-check takes commandEnv from the shared util ------------ // ---- cb34c32e: regression-check takes commandEnv from the shared util ------------
test('review 630 / cb34c32e: regression-check imports commandEnv from lib/util/command-env.mjs', () => { test('review 630 / cb34c32e: regression-check takes commandEnv from lib/util/command-env.mjs', () => {
// Round 5 (MINOR 0e98d5c7): regression-check has no exec of its own any more;
// it runs through the criteria runner's runCommand, which takes commandEnv
// from lib/util/command-env.mjs.
const src = read('lib/verification/regression-check.mjs'); const src = read('lib/verification/regression-check.mjs');
assert.match(src, /import \{ commandEnv \} from '\.\.\/util\/command-env\.mjs';/);
assert.doesNotMatch(src, /from '\.\/red-proof\.mjs'/); assert.doesNotMatch(src, /from '\.\/red-proof\.mjs'/);
assert.ok(src.indexOf('function exec(') > src.lastIndexOf('\nimport '), 'exec is defined after the imports'); assert.match(src, /runCriteria\(c, o\)/);
assert.match(read('lib/verification/criteria-runner.mjs'), /import \{ commandEnv \} from '\.\.\/util\/command-env\.mjs';/);
}); });
// ---- CLAUDE.md names the mode-read exemptions as they are ----------------------- // ---- CLAUDE.md names the mode-read exemptions as they are -----------------------
@ -331,11 +334,15 @@ test('review 630 r3: a fixture document under tests/ is a test file, and "e.g."
assert.deepEqual(stepTestsOnly(step('`./tests/a.test.mjs`')).files, ['tests/a.test.mjs'], './ is stripped'); assert.deepEqual(stepTestsOnly(step('`./tests/a.test.mjs`')).files, ['tests/a.test.mjs'], './ is stripped');
}); });
test('review 630 r3: the plan-file sweep reads Files: with the same reader as the tests-only check', async () => { test('review 630 r3: the plan-file sweep and the tests-only check both drop a leading ./', async () => {
// Round 3 had the sweep read Files: through the tests-only reader; the
// operator reversed that on 2026-10-11 (the K6 sweep keeps v6.2.0's reader,
// round 5). What the round-3 finding named still holds: `./` is dropped by both.
const { plannedPaths } = await import('../../lib/verification/plan-scope-sweep.mjs'); const { plannedPaths } = await import('../../lib/verification/plan-scope-sweep.mjs');
const plan = '## Implementation Plan\n\n### Step 1: x\n\n- **Files:** `./src/a.mjs`, tests/b.test.mjs + src/c.mjs\n- **Changes:** x\n'; const plan = '## Implementation Plan\n\n### Step 1: x\n\n- **Files:** `./src/a.mjs`, `./tests/b.test.mjs`\n- **Changes:** x\n';
const body = plan.slice(plan.indexOf('### Step 1')); const body = plan.slice(plan.indexOf('### Step 1'));
assert.deepEqual(plannedPaths(plan), [...stepTestsOnly(body).files].sort()); assert.deepEqual(plannedPaths(plan), ['src/a.mjs', 'tests/b.test.mjs']);
assert.deepEqual(stepTestsOnly(body).files, ['src/a.mjs', 'tests/b.test.mjs']);
}); });
test('review 630 r3: --steps-from refuses an appended step with no Verify: on a 1.8 plan too', () => { test('review 630 r3: --steps-from refuses an appended step with no Verify: on a 1.8 plan too', () => {
@ -423,3 +430,277 @@ test('review 630 r4: no tilde word, and nothing after `make test`', async () =>
assert.equal(allowedCommand(cmd, { cwd: ROOT }).allowed, true, `the control: ${cmd}`); assert.equal(allowedCommand(cmd, { cwd: ROOT }).allowed, true, `the control: ${cmd}`);
} }
}); });
// ==== round 5 of the release review (2026-10-11), each red on d37e538 ===========
// Operator 2026-10-11: no more patching of the same two places. A check runs
// with no shell (its words reach the runner as written), and the K6 sweep has
// its own v6.2.0 reader again; the shared reader stays for the tests-only check.
const PASS_SCREENS = { allow: () => ({ allowed: true, reason: '' }), screen: () => ({ allowed: true, rule: '' }) };
test('review 630 r5 / da7584e7: a check runs without a shell - its words reach the runner as written', async () => {
const { runCriteria } = await import('../../lib/verification/criteria-runner.mjs');
const dir = mkdtempSync(join(tmpdir(), 'r630-noshell-'));
try {
mkdirSync(join(dir, 'tests'));
writeFileSync(join(dir, 'tests', 'args.sh'), 'printf "%s\\n" "$@"\n');
const [r] = runCriteria([{ label: 'X1', text: 'x', command: 'bash tests/args.sh ~/x *.sh $HOME' }], { cwd: dir, ...PASS_SCREENS });
assert.equal(r.status, 'passed', r.output);
assert.equal(r.output, '~/x\n*.sh\n$HOME\n', 'no shell expanded a tilde, a glob or a variable');
const [missing] = runCriteria([{ label: 'X2', text: 'x', command: 'no-such-runner-r630 --x' }], { cwd: dir, ...PASS_SCREENS });
// Round 6: a runner that could not start is NOT RUN (no measurement), never a pass and never a red.
assert.equal(missing.status, 'unrunnable', 'a runner that is not installed is not run, never a pass');
assert.match(missing.output, /could not start.*ENOENT/i, missing.output);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('review 630 r5 / c25f8704: a glob cannot climb out of tests/ or the repo', async () => {
const { runCriteria, allowedCommand } = await import('../../lib/verification/criteria-runner.mjs');
const base = mkdtempSync(join(tmpdir(), 'r630-escape-'));
const repo = join(base, 'repo');
try {
mkdirSync(join(repo, 'tests'), { recursive: true });
mkdirSync(join(repo, 'outside'));
writeFileSync(join(repo, 'outside', 'evil.sh'), 'echo ESCAPED > escaped-bash.txt\n');
writeFileSync(join(repo, 'tests', 'a.test.mjs'), "import { test } from 'node:test';\ntest('in', () => {});\n");
writeFileSync(join(base, 'outside.test.mjs'),
`import { test } from 'node:test';\nimport { writeFileSync } from 'node:fs';\ntest('out', () => { writeFileSync(${JSON.stringify(join(base, 'escaped-node.txt'))}, 'x'); });\n`);
const results = runCriteria([
{ label: 'E1', text: 'x', command: 'bash tests/.?/outside/evil.sh' },
{ label: 'E2', text: 'x', command: 'node --test .*' },
], { cwd: repo });
assert.equal(existsSync(join(repo, 'escaped-bash.txt')), false, `bash ran a script outside tests/: ${JSON.stringify(results[0])}`);
assert.equal(existsSync(join(base, 'escaped-node.txt')), false, `node --test ran a test file outside the repo: ${JSON.stringify(results[1])}`);
assert.notEqual(results[0].status, 'passed');
for (const cmd of ['node --test ../outside.test.mjs', 'node --test tests/../../outside.test.mjs', 'node --test /tmp/x.test.mjs',
'pytest ../tests', 'go test ../...', 'npm test -- ../x.test.mjs', 'jest /abs/x.test.js', 'vitest run ../x', 'bash tests/*.sh',
'pytest tests/test_*.py', 'jest src/*.test.js', 'npm test -- tests/*.mjs']) {
assert.equal(allowedCommand(cmd, { cwd: ROOT }).allowed, false, `${cmd} must not run`);
}
for (const cmd of ['node --test tests/lib/*.test.mjs', 'node --test tests/**/*.test.mjs', 'go test ./...', '/bin/bash tests/fixtures/criteria-exit-0.sh',
'node --test tests/', 'pytest tests/', 'git diff HEAD~1..HEAD --stat']) {
const r = allowedCommand(cmd, { cwd: ROOT });
assert.equal(r.allowed, true, `the control: ${cmd} (${r.reason})`);
}
} finally { rmSync(base, { recursive: true, force: true }); }
});
test('review 630 r5 / 8f3cf84d: a word starting with @ is refused (pytest reads @file as more arguments)', async () => {
const { allowedCommand } = await import('../../lib/verification/criteria-runner.mjs');
for (const cmd of ['pytest @tests/args.txt', 'python -m pytest @tests/args.txt', 'python3 -m pytest -q @a', 'uv run pytest @a', 'node --test @a', 'jest @a']) {
const r = allowedCommand(cmd, { cwd: ROOT });
assert.equal(r.allowed, false, `${cmd} must not run`);
assert.match(r.reason, /@/);
}
});
test('review 630 r5 / da7584e7: every place that runs a check runs it with no shell', () => {
for (const rel of ['lib/verification/criteria-runner.mjs', 'lib/verification/regression-check.mjs', 'lib/verification/red-proof.mjs',
'lib/verification/done-line.mjs', 'lib/util/test-lock.mjs']) {
const src = read(rel);
assert.doesNotMatch(src, /spawnSync\(\s*['"](?:\/bin\/)?(?:ba)?sh['"]/, `${rel} starts a shell`);
assert.doesNotMatch(src, /shell:\s*true/, `${rel} asks spawn for a shell`);
if (rel !== 'lib/verification/criteria-runner.mjs') assert.match(src, /\brunCommand\b/, `${rel} runs through criteria-runner's runCommand`);
}
assert.doesNotMatch(read('lib/verification/regression-check.mjs'), /function exec\(/, 'regression-check has no exec of its own (MINOR 0e98d5c7)');
});
test('review 630 r5 / da7584e7: the done line runs its command with no shell', async () => {
// sh reads `**` as `*` (one level); node --test expands its own pattern at
// every depth. Only with no shell does the top-level test run.
const { writeDoneLine } = await import('../../lib/verification/done-line.mjs');
const repo = mkdtempSync(join(tmpdir(), 'r630-done-'));
try {
mkdirSync(join(repo, 'tests', 'sub'), { recursive: true });
writeFileSync(join(repo, 'tests', 'sub', 'deep.test.mjs'), "import { test } from 'node:test';\ntest('deep', () => {});\n");
writeFileSync(join(repo, 'tests', 'top.test.mjs'),
`import { test } from 'node:test';\nimport { writeFileSync } from 'node:fs';\ntest('top', () => { writeFileSync(${JSON.stringify(join(repo, 'top-ran.txt'))}, 'x'); });\n`);
for (const args of [['init', '-q'], ['-c', 'user.email=t@example.invalid', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'x']]) {
assert.equal(spawnSync('git', args, { cwd: repo }).status, 0);
}
const r = writeDoneLine({ cwd: repo, step: 1, cmd: 'node --test tests/**/*.test.mjs', report: join(repo, 'report.md') });
assert.equal(r.status, 'written', JSON.stringify(r));
assert.equal(existsSync(join(repo, 'top-ran.txt')), true, 'the pattern reached node as written');
} finally { rmSync(repo, { recursive: true, force: true }); }
});
test('review 630 r5 / ff80c7ef 5dc53c7e 02cea4aa: the K6 sweep plans what v6.2.0 planned, with its own reader', async () => {
const { plannedPaths } = await import('../../lib/verification/plan-scope-sweep.mjs');
const plan = (files) => `## Implementation Plan\n\n### Step 1: x\n\n- **Files:** ${files}\n- **Changes:** x\n`;
const cases = [
['`src/a.mjs` — replaces the helper in lib/util.mjs and touches config.json; see Node.js docs', ['src/a.mjs']],
['`src/cli.mjs` — follows the pattern in lib/', ['src/cli.mjs']],
['.gitignore, src/a.mjs', ['.gitignore', 'src/a.mjs']],
['src\\cli.mjs', ['src/cli.mjs']],
['`src/a.mjs`/`src/b.mjs`', ['src/a.mjs', 'src/b.mjs']],
['`src/a.mjs`—adds', ['src/a.mjs']],
['`src/cli.mjs`: add the flag; `src/b.mjs`.', ['src/b.mjs', 'src/cli.mjs']],
];
for (const [files, want] of cases) assert.deepEqual(plannedPaths(plan(files)), want, files);
assert.doesNotMatch(read('lib/verification/plan-scope-sweep.mjs'), /import \{[^}]*\bfieldPaths\b/, 'K6 does not read Files: through the tests-only reader');
});
test('review 630 r5 / a4c8005d 02cea4aa: the tests-only check takes only what passes a path test', () => {
const step = (files) => `- **Files:** ${files}\n- **Changes:** x\n`;
const cases = [
['`tests/parse.test.mjs` (asserts `parse()` throws)', true, ['tests/parse.test.mjs']],
['`tests/a.test.mjs` — covers pass/fail', true, ['tests/a.test.mjs']],
['`tests/a.test.mjs` — covers JSON, YAML', true, ['tests/a.test.mjs']],
['`tests/a.test.mjs` — calls `parser.parse()` twice', true, ['tests/a.test.mjs']],
['`tests/a.test.mjs`, .gitignore', false, ['tests/a.test.mjs', '.gitignore']],
['tests/a.test.mjs, .gitignore', false, ['tests/a.test.mjs', '.gitignore']],
['`tests/a.test.mjs`/`src/b.mjs`', false, ['tests/a.test.mjs', 'src/b.mjs']],
['`tests\\a.test.mjs`', true, ['tests/a.test.mjs']],
['tests\\a.test.mjs, src\\cli.mjs', false, ['tests/a.test.mjs', 'src/cli.mjs']],
['Makefile.', false, ['Makefile']],
];
for (const [files, testsOnly, want] of cases) assert.deepEqual(stepTestsOnly(step(files)), { testsOnly, files: want }, files);
});
test('review 630 r5 / e1ff583a f582b9c2: frontmatter reads trailing space and a comment after a plain value as YAML does', async () => {
const { readFrontmatterFields } = await import('../../lib/skills/skill-frontmatter.mjs');
const r = readFrontmatterFields('---\nname: "debugging" \nmodel: opus # operator pin\neffort: high # pinned\ntags: [a] \n' +
'allowed-tools: [Read, Grep] # read only\ndescription: | # folded below\n Use when x.\n---\nbody\n');
assert.deepEqual(r.hazards, [], JSON.stringify(r.hazards));
assert.equal(r.fields.name.value, 'debugging');
assert.equal(r.fields.model.value, 'opus');
assert.equal(r.fields.effort.value, 'high');
assert.deepEqual(r.fields.tags.value, ['a']);
assert.deepEqual(r.fields['allowed-tools'].value, ['Read', 'Grep']);
assert.equal(r.fields.description.value, 'Use when x.\n');
// The control: a description is the text Claude Code routes on, so a comment
// that cuts it is still a hazard (quote it), and so is a value that is only a comment.
for (const fm of ['description: Use when x happens #1.', 'description: Use when x # note', 'name: # x']) {
assert.ok(readFrontmatterFields(`---\n${fm}\n---\nbody\n`).hazards.length > 0, fm);
}
});
test('review 630 r5 / 41c43285 (operator 2026-10-09): the example plan keeps steps 1-2, and step 2 no longer says it must fail', () => {
const plan = read('examples/01-add-verbose-flag/plan.md');
const heading = plan.match(/^### Step 2: .*$/m)[0];
assert.doesNotMatch(heading, /must FAIL/i, heading);
assert.match(plan.slice(plan.indexOf(heading)), /should PASS today/, 'the step body still says it passes today');
const progress = JSON.parse(read('examples/01-add-verbose-flag/progress.json'));
const title = JSON.stringify(progress).match(/"title":"(Add stability test[^"]*)"/)?.[1];
assert.equal(`### Step 2: ${title}`, heading, 'progress.json carries the same step title');
const r = validatePlanContent(plan, { strict: false });
const warned = r.warnings.filter((w) => w.code === 'PLAN_STEP_TESTS_ONLY').map((w) => w.message);
assert.equal(warned.length, 2, `the operator kept the warning on steps 1-2: ${JSON.stringify(warned)}`);
});
// ==== round 6 of the release review (2026-10-11), each red on c3641ff ===========
function gitRepo(prefix) {
const dir = mkdtempSync(join(tmpdir(), prefix));
for (const args of [['init', '-q'], ['config', 'user.email', 't@example.invalid'], ['config', 'user.name', 't'], ['config', 'commit.gpgsign', 'false']]) {
assert.equal(spawnSync('git', args, { cwd: dir }).status, 0);
}
return dir;
}
const commitAll = (dir, msg) => {
assert.equal(spawnSync('git', ['add', '-A'], { cwd: dir }).status, 0);
assert.equal(spawnSync('git', ['commit', '-q', '-m', msg], { cwd: dir }).status, 0);
return spawnSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).stdout.trim();
};
test('review 630 r6 / criteria-runner.mjs:464: a git check stays in the repo too', async () => {
const { allowedCommand } = await import('../../lib/verification/criteria-runner.mjs');
for (const cmd of ['git diff /etc/hosts /dev/null', 'git diff ../x tests/a.test.mjs', 'git show HEAD:../x', 'git log -- /etc',
'git diff --no-index tests/a tests/b']) {
assert.equal(allowedCommand(cmd, { cwd: ROOT }).allowed, false, `${cmd} must not run`);
}
for (const cmd of ['git diff HEAD~1..HEAD --stat', 'git show HEAD:package.json', 'git log -- lib/', 'git show @~1 --stat']) {
const r = allowedCommand(cmd, { cwd: ROOT });
assert.equal(r.allowed, true, `the control: ${cmd} (${r.reason})`);
}
});
test('review 630 r6 / criteria-runner.mjs:168: a trailing # comment in a shell fence is not part of the command', async () => {
const { parsePlanVerification, allowedCommand } = await import('../../lib/verification/criteria-runner.mjs');
const plan = '## Verification\n\n```bash\nnpm test # all green\nnode --test tests/a.test.mjs #1\n```\n';
assert.deepEqual(parsePlanVerification(plan).map((c) => c.command), ['npm test', 'node --test tests/a.test.mjs']);
const example = parsePlanVerification(read('examples/01-add-verbose-flag/plan.md'))[0];
assert.equal(example.command, 'node --test tests/');
assert.equal(allowedCommand(example.command, { cwd: ROOT }).allowed, true, "the shipped example's first check runs again");
});
test('review 630 r6 / criteria-runner.mjs:466: a glob reaches node --test only where node expands it (Node 21+)', async () => {
const { allowedCommand } = await import('../../lib/verification/criteria-runner.mjs');
const old = allowedCommand('node --test tests/lib/*.test.mjs', { cwd: ROOT, nodeMajor: 20 });
assert.equal(old.allowed, false);
assert.match(old.reason, /21/);
assert.equal(allowedCommand('node --test tests/lib/*.test.mjs', { cwd: ROOT, nodeMajor: 22 }).allowed, true);
assert.equal(allowedCommand('node --test tests/lib/review-630.test.mjs', { cwd: ROOT, nodeMajor: 20 }).allowed, true, 'the control: no glob, any Node');
});
test('review 630 r6 / plan-schema.mjs:318: a backticked file name with no extension is a path', () => {
const step = (files) => `- **Files:** ${files}\n- **Changes:** x\n`;
assert.deepEqual(stepTestsOnly(step('`tests/build.test.mjs`, `Makefile`')), { testsOnly: false, files: ['tests/build.test.mjs', 'Makefile'] });
assert.deepEqual(stepTestsOnly(step('`spec/a_spec.rb`, `Gemfile`')).testsOnly, false);
assert.deepEqual(stepTestsOnly(step('`tests/a.test.mjs` — covers `parse` and `JSON`')).files, ['tests/a.test.mjs'], 'a span inside a description is no path');
});
test('review 630 r6 / plan-schema.mjs:329 (altitude): red proof checks tests-only against the step\'s own diff', async () => {
const { proveRed } = await import('../../lib/verification/red-proof.mjs');
const work = mkdtempSync(join(tmpdir(), 'r630-diff-'));
const repo = gitRepo('r630-diff-repo-');
try {
writeFileSync(join(repo, 'README.md'), 'x\n');
const base = commitAll(repo, 'base');
mkdirSync(join(repo, 'tests'));
writeFileSync(join(repo, 'tests', 'a.test.mjs'), "import { test } from 'node:test';\ntest('a', () => {});\n");
writeFileSync(join(repo, 'Makefile'), 'test:\n\tnode --test tests/\n');
// The prose reads only the test (a plain Makefile beside backticked paths is a description word).
const plan = join(work, 'plan.md');
writeFileSync(plan, '## Implementation Plan\n\n### Step 1: x\n\n- **Files:** `tests/a.test.mjs`, Makefile\n- **Changes:** x\n');
const o = { cwd: repo, base, cmd: 'node --test tests/a.test.mjs', tests: ['tests/a.test.mjs'], plan, step: 1 };
const r = proveRed(o);
assert.equal(r.reason, 'not-red-at-base', JSON.stringify(r));
assert.equal(r.tests_only, false, `the step's diff changes Makefile: ${JSON.stringify(r.tests_only_note ?? null)}`);
assert.match(r.tests_only_note ?? '', /Makefile/);
rmSync(join(repo, 'Makefile'));
assert.equal(proveRed(o).tests_only, true, 'the control: a step whose diff holds only its test is tests-only');
} finally { rmSync(work, { recursive: true, force: true }); rmSync(repo, { recursive: true, force: true }); }
});
test('review 630 r6 / red-proof.mjs:129: exit 127 from the test program itself is a red, not "could not run"', async () => {
const { proveRed } = await import('../../lib/verification/red-proof.mjs');
const { writeDoneLine } = await import('../../lib/verification/done-line.mjs');
const repo = gitRepo('r630-127-');
try {
writeFileSync(join(repo, 'README.md'), 'x\n');
const base = commitAll(repo, 'base');
mkdirSync(join(repo, 'tests'));
mkdirSync(join(repo, 'bin'));
writeFileSync(join(repo, 'tests', 'e2e.sh'), './bin/tool\n');
writeFileSync(join(repo, 'bin', 'tool'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
const r = proveRed({ cwd: repo, base, cmd: 'bash tests/e2e.sh', tests: ['tests/e2e.sh'], redOnly: true });
assert.equal(r.status, 'proven', JSON.stringify(r));
assert.equal(r.base.exit, 127);
rmSync(join(repo, 'bin'), { recursive: true });
const d = writeDoneLine({ cwd: repo, step: 1, cmd: 'bash tests/e2e.sh', report: join(repo, 'report.md') });
assert.equal(d.status, 'failed', `a test that exits 127 failed: ${JSON.stringify(d)}`);
} finally { rmSync(repo, { recursive: true, force: true }); }
});
test('review 630 r6 / regression-check.mjs:64: a Regression: whose runner cannot start is not-run, not a red suite', async () => {
const { checkRegression } = await import('../../lib/verification/regression-check.mjs');
const empty = mkdtempSync(join(tmpdir(), 'r630-path-'));
const saved = process.env.PATH;
try {
process.env.PATH = empty;
const planText = '## Implementation Plan\n\n### Step 1: x\n\n- **Files:** `src/a.mjs`\n- **Regression:** `vitest run`\n';
const r = checkRegression({ planText, step: 1, cwd: ROOT });
assert.equal(r.status, 'error', JSON.stringify(r));
assert.equal(r.progress_status, 'not-run');
assert.equal(r.exit, 2);
assert.match(r.output, /could not start/);
} finally { process.env.PATH = saved; rmSync(empty, { recursive: true, force: true }); }
});
test('review 630 r6 / hooks are ASCII: the lines this release added to pre-bash-executor.mjs', () => {
const src = read('hooks/scripts/pre-bash-executor.mjs');
const block = src.slice(src.indexOf('Everything this guard would ask about') - 200, src.indexOf('Everything this guard would ask about') + 300);
assert.ok(block.includes('Everything this guard would ask about'));
assert.doesNotMatch(block, /[^\x00-\x7f]/, 'a hook script is ASCII (~/.claude/CLAUDE.md, Miljø)');
});

View file

@ -435,3 +435,30 @@ test('release-check r4 — other text on the badge line is not release bookkeepi
assert.match(r.stdout, /README\.md/); assert.match(r.stdout, /README\.md/);
} finally { fx.cleanup(); } } finally { fx.cleanup(); }
}); });
// ==== round 5 of the release review of 6.3.0 (2026-10-11) ====================
test('release-check r5 — a test moved into an allowed place after the review is still a removal → 1', () => {
const fx = fixture({ files: { 'guard.test.mjs': 'export const guarded = true;\n' } });
try {
mkdirSync(join(fx.repo, '.claude', 'voyage'), { recursive: true });
git(fx.repo, 'mv', 'guard.test.mjs', '.claude/voyage/guard.test.mjs');
git(fx.repo, 'commit', '-q', '-m', 'move the test out of the suite');
const r = run(fx);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(r.stdout, /guard\.test\.mjs/);
} finally { fx.cleanup(); }
});
// ==== round 6 of the release review of 6.3.0 (2026-10-11) ====================
test('release-check r6 — a suite that never ends is stopped and read as not measured → 2', () => {
const fx = fixture({ testScript: 'node -e "setTimeout(() => {}, 4000)"' });
try {
const r = mod.releaseCheck({ repo: fx.repo, review: fx.review, suiteTimeoutMs: 1000 });
const suite = r.checks.find((c) => c.id === 'suite');
assert.equal(suite.status, 'error', JSON.stringify(suite));
assert.match(suite.detail, /timeout|killed/i);
assert.equal(r.exit, 2);
} finally { fx.cleanup(); }
});