Compare commits

...

72 commits

Author SHA1 Message Date
591f3c4793
merge: trekexecute fix-round-4-5b-part-3 2026-10-29 08:32:02 +01:00
7cb038cce3
refactor: simplify pass (trekexecute)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 00:18:29 +01:00
051331a5ef
test(red-proof): fix round 4.5b part 3 red proof and regression run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 00:04:36 +01:00
31af4fa774
docs(changelog): fix round 4.5b part 3 and the review's other two findings as known issues
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 23:54:24 +01:00
bba5cb89b3
fix(hooks): the args-heredoc guard reads the opener's word as the shell does
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 23:41:19 +01:00
8c52c3c32e
fix(arg-parser): a quoted value closes at a quote that ends a word, also after whitespace
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:34:38 +01:00
ce98f62e82
merge: trekexecute fix-round-4-5b-part-2 2026-10-28 20:43:52 +01:00
c75ae2d3f5
refactor: simplify pass (trekexecute)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:34:32 +01:00
712a51591d
docs(changelog): fix round 4.5b part 2, its red proof and the review's other findings as known issues
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:21:17 +01:00
f9cd08076e
fix(trekexecute): the checkpoint records the full commit id for the done line
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:06:52 +01:00
1b9d5fd459
fix(review): a failed git check-ignore writes no pointer, whatever language git speaks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:46:43 +01:00
eca974aaa6
fix(hooks): the args-heredoc guard checks every opener with a line after it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:36:21 +01:00
8be0c05221
fix(arg-parser): a quoted value stops at a quote that starts a word and never swallows a later flag
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:18:55 +01:00
52c382c198
refactor: simplify pass (trekexecute)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:39:40 +01:00
76f74793e9
test(red-proof): the two regression guards that pass at be0b631 get their own mutation ids
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:18:23 +01:00
ad053e0e0e
docs(changelog): fix round 4.5b
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:02:58 +01:00
b9d173a7fd
test(red-proof): the tests the 4.5a discovered-error fix added carry their id tags
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 17:45:46 +01:00
c7fdbf0d8b
fix(review): applying a lesson skips a symlinked CLAUDE.md and an ignored lessons file
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 17:27:33 +01:00
33ef2c1b3a
fix(trekexecute): run_seen is kept in progress.json and reported on every path
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 17:05:43 +01:00
27280ca8cb
fix(docs): the deep-research merge and engine condition are stated and the research prose is pinned exactly
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:53:37 +01:00
4b59ec7f16
fix(commands): the trekplan execute exits run Phase 12 and the research loop names its model
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:40:24 +01:00
b99081f215
fix(trekbrief): every exit names Phase 7 with its auto_result
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:30:30 +01:00
89975a1fd6
fix(trekendsession): Phase 3 re-reads the arguments instead of splicing the label into the shell line
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:12:44 +01:00
2c0a98c1ba
fix(commands): Phase 0 gives the arguments to the parser on stdin, never as shell words
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 15:55:21 +01:00
f1704f0800
fix(arg-parser): take the arguments as stdin text, and block a heredoc that closes early
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 15:40:21 +01:00
738efa3e24
fix(review): the coordinator and orchestrator texts say what the code does on refutation and the brief-conflict exception
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 15:18:03 +01:00
878871a6ad
fix(review): the Arm B workflow keeps the contract's row on a trimmed title and a tie
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 15:06:23 +01:00
9145d2bbf5
fix(trekexecute): the back-to-planning save line reads the whole diff from a subdirectory
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:53:26 +01:00
afe56899a9
fix(park): park from a CRLF diff and say that a staged new file is parked
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:44:33 +01:00
bf4736a254
fix(park): read HEAD membership once and move nothing when it cannot be read
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:36:03 +01:00
5ddbcd3de6
fix(verdict-gate): free text added inside the lessons block makes the review stale
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:11:41 +01:00
77544841d7
fix(verdict-gate): a failed git call is reported as not measured, not stale
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:00:42 +01:00
703c508433
fix(verdict-gate): read --repo=<dir> and refuse unknown options
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:44:11 +01:00
e279f12131
fix(verification): the done line and the release check take only full commit ids
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:33:51 +01:00
cf7af30b46
fix(verdict-gate): accept only a full 40- or 64-hex reviewed commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:13:22 +01:00
7566b71674
refactor(red-proof): the 4.5a script is a wrapper over the shared one; add the 4.5b wrapper
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:58:23 +01:00
2dbfbaf8ca
feat(red-proof): one red script that takes base, ids, mutations and files as arguments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:38:44 +01:00
cde4c1d3ed
fix(red-proof): the judge skips other rounds' ids and reports an unreadable tag
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:19:36 +01:00
be0b631fed
merge: fix round 4.5a — the serious findings, decision 67 and the too-lax 6.5.0 Known issues (decisions 57, 67, 68, 78)
Review WARN (0 BLOCKER, 13 MAJOR, 4 MINOR), verdict gate exit 0; the 13 MAJOR go first in hardening part 4.5b (operator 2026-10-25).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:21:02 +01:00
af90e82794
refactor: simplify pass (trekexecute)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:22:18 +02:00
f6bdc2de59
fix(docs): the put-back, the research triggers and Pass 3's high-effort skip say what the code does
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:19:58 +02:00
8a2a989f79
fix(siblings): the texts found next to round 4.5a's fixes say what the code does
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:41:53 +02:00
e351e7c29d
docs(changelog): fix round 4.5a
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:11:22 +02:00
5476a36782
test(red): prove every id of round 4.5a red at ab6b32c, with a judge that is tested to go red
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:23:59 +02:00
7869c4cbae
fix(execute): the park step parks intent-to-add and coloured-diff files from any directory and exits 2 on a git failure
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:00:24 +02:00
773aa44dc9
fix(review): the workflow floor and put-back match each finding to its own row
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:59:17 +02:00
a5fcb5e8b4
fix(review): group a triplet's findings title first, so arrival order never changes the rows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:30:52 +02:00
7175783ce5
fix(review): a past-the-end line makes a finding unverified, never refuted, and the Declined to Judge copy-through is pinned
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:13:03 +02:00
761f406faa
fix(review): changed code and instruction files get deep review by default, summary-only is for docs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:37:39 +02:00
b16e39ccd9
fix(research): the contrarian runs as a second wave with the leading answer to attack
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:23:18 +02:00
f32baf50ad
fix(continue): run the arg-parser, find the master prompt at the repo root, and let the consistency check read the state file
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:05:19 +02:00
403afe606f
fix(endsession): parse arguments with the arg-parser so a slug with -h is no help request, and only the operator starts it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:45:25 +02:00
6226372eaa
fix(execute): the final report says whether the app was seen with Claude Code's run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:26:28 +02:00
050a77f513
fix(execute): the merge lock refuses a review of an older commit (review-stale)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:06:14 +02:00
6c0718c8d8
fix(review): the verdict gate refuses, with --repo, a review of another commit than the one being merged
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:48:29 +02:00
a9f5f6251a
fix(execute): the completion gate says which checks are commands and which are executor judgement
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:25:23 +02:00
2fe6feb74d
fix(execute): run the brief check in Phase 1, and say what effort really changes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:07:27 +02:00
2b5d5f72bc
fix(execute): name and read --profile and --gates in the hint, the usage block and Phase 1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:50:13 +02:00
af36578f12
fix(gates): one --gates rule in every command, run through the shim, and no executor pause the executor lacks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:38:20 +02:00
e2fcf4060c
fix(gates): the shim reads a bare --gates and closed as on, and never swallows the next flag
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:20:17 +02:00
8f9b0abfad
fix(brief): a brief with no research topics prints its next steps instead of entering the auto-only phase
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:12:59 +02:00
95eaa8dc57
fix(stats): no command promises a stats key its record does not write
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:04:29 +02:00
074feef475
fix(plan): every planning and research spawn takes the resolved model, and no turn budget is promised
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:51:04 +02:00
eafe023c74
fix(plan): execute hands the plan to /trekexecute instead of building it in the planning session
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:34:57 +02:00
be83e6e066
fix(docs): name /trekplan where the text sent readers to a reference document, and give the reference documents read-only tools
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:23:05 +02:00
4312882451
fix(agents): reviewers drop a finding only with a Declined to Judge line, research-scout gets Tavily, no dead override reference
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:11:47 +02:00
ab6b32c930
merge: wave 4 order B — voyage:annotating (decision 61, 62)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:41:46 +02:00
45eaedc6b4
merge: main (wave 4 order A) into order B before B goes into main
Conflicts on the union lines only: CHANGELOG Unreleased (both orders'
entries kept), CLAUDE.md (/trekexecute row from A, /trekreview row from
B), tests/fixtures/jsonl-schemas.md (trekplan line_ref from B,
trekexecute line_ref from A). Suite on the merged tree 2682 (2680/0/2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:49:02 +02:00
074b44a7e0
merge: wave 4 order A — P10's model-free part and the run coupling (decision 61, 63, 64)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:44:29 +02:00
843897ae33
feat(skills): voyage:annotating, the operator-annotation loop as one skill (order B)
The three producing commands name voyage:annotating where they build the page; the description of the page and the freehand revision left them. The skill: the person writes every note, Fix/Change edit only the quoted text, a Question is answered and changes nothing, the approval an edit costs is said before the edit, the page is rebuilt. Research documents on request (decision 62). Red baseline 3 runs per scenario without and with the skill; six eval cases; 11 pins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:36:00 +02:00
1989656a9c
feat(run): the run step names Claude Code's run; owner=run is a built-in owner (A2)
The coupling to Claude Code's built-in run skill (wave 4, order A2,
decisions 54 and 64). The plan's "run the thing" step, the executor's
End to end part and the plan template each say in one sentence that the
app is seen and driven with run, and that the verdict is still the
End-to-end: command, because run gives no exit code. The template quotes
the name instead of backticking it: every backticked span on a template
step bullet is read as a runnable example.

R2: lib/skills/routing.mjs keeps a short fixed list of Claude Code's own
skills a quiet case may name as owner (BUILTIN_OWNERS, only run, seen in
2.1.303). For such an owner only "not taken" is checked, and the report
says so; ROUTING_NEGATIVE_WON stands, and an owner on no list is still
ROUTING_UNKNOWN_OWNER. skill-writing-criteria-quiet-2 gets owner=run.

The trekplan.md paragraph is reflowed to its old line count so the stats
line_ref there does not move (order B edits the same file); the
trekexecute Phase 9 line_ref fixture moves by two lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:26:10 +02:00
cf08df9d55
feat(routing): every eval case names its event; the P10 eval gate is exempted with its reason (A1)
P10's model-free part (wave 4, order A1, decision 63). The routing test now
checks every case in evals/ for from=, not only the skill-tagged ones (R1):
a case with no skill tag is not routed, only checked, and the report gives
both counts ("45 cases checked for from=, 37 routed"). The eight test-set
cases get from=proevesett-eval-suite (d311f3b) or
from=proevesett-intent-gate (3892a83), the commits that added them.

The release gate that reads the last test-set run is written into the
runbook as exempted with its reason (it waits for the fixed test set to run
again) and still says where it goes (scripts/release-check.mjs).

review-630's prototype-tag stub gets from=test: under R1 an unrouted case is
still checked for from=, and the stub tests that a prototype name is never
routed, which stands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:15:00 +02:00
124 changed files with 11805 additions and 647 deletions

View file

@ -4,6 +4,187 @@ 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/).
## Unreleased — The annotating skill, the run coupling and P10's model-free part
### Added
- `voyage:annotating` (order B): the operator-annotation loop as one skill. The person writes every
note and Claude none; Fix and Change edit only the text a note points at; a Question is answered
and changes nothing; nothing the notes did not ask for is edited; the approval an edit costs (a
stale brief intent, a changed plan) is said before the edit; the page is built again on the
revised file. It also covers a research document when the user asks (decision 62);
`/trekresearch` gets no annotation step. Red baseline with helpers (Opus 5.5 high, 3 runs per
scenario, a toy project with an approved brief and plan): without the skill 3 of 3 changed the
plan because of a Question, 3 of 3 applied a Fix to steps the note did not name, 2 of 3 edited
brief text no note asked for, 1 of 3 put its own critique next to the page, and 0 of 3 named the
stale approval before the edit; with it, 0, 0, 0, 0 and 3 of 3. Six eval cases
`evals/skill-annotating-*`, written, not run.
### Changed
- **The "run the thing" step names Claude Code's built-in `run` skill.** The plan's run step (`/trekplan`), the executor's end-to-end part (`/trekexecute`) and the plan template each say in one sentence that the app is seen and driven with `run`, and that the verdict is still the `End-to-end:` command through the criteria runner, because `run` gives no exit code (decisions 54 and 64).
- **A quiet eval case may name Claude Code's own `run` as its owner** (R2). The routing test keeps a short fixed list of built-in owners (`BUILTIN_OWNERS`, only `run`); for such an owner, which is outside the corpus, only "the skill does not rank first" is checked, and the report says so. `evals/skill-writing-criteria-quiet-2` now has `owner=run` instead of `/trekexecute` standing in.
- **P10's model-free part is done; the eval gate (P10) is exempted until the fixed test set runs again.** Every case in `evals/` now names the event it came from (`from=`), the eight test-set cases included (`from=proevesett-eval-suite`, `from=proevesett-intent-gate`). The routing test (`lib/skills/routing.mjs`) fails any case without it, routed or not, and its report gives both counts: cases checked for `from=` and cases routed. The release gate that would read the last test-set run is written into `docs/release-runbook.md` as exempted with its reason, not built: a gate that can never fire is not built.
- `/trekbrief`, `/trekplan` and `/trekreview` name `voyage:annotating` where they build the page;
the description of the page and the "revises freehand" loop left the three commands. The call
to `scripts/annotate.mjs`, the not-a-gate line and the printed links stay. The printed report now
says a Question is answered and Fix and Change notes revise the file.
- **`/trekendsession` is operator-only, and the planning and research spawn sites follow the profile.** The model can no longer start `/trekendsession` (`disable-model-invocation: true`). With `--project`, where the resolver runs, `/trekplan` and `/trekresearch` pass the resolved model at their spawn sites, so the `economy` profile gives Sonnet helpers unless the brief's phase signal for that phase names a model; without a resolved model the agent's own `model:` applies (opus for Claude Code's built-in agents), and `/trekexecute`'s step helper and controller stay on opus.
- **`~` and `$HOME` in the arguments of `/trekendsession`, `/trekcontinue` and `/trekreview` are no longer expanded** (ARG, fix round 4.5b). The arguments reach the arg parser as text on stdin, not as shell words, so nothing in them is expanded: write the path out (`~/x` stays the three characters `~/x`).
### Fixed
Fix round 4.5b (decisions 80 to 87): the round's 39 findings, from the 4.5a review and its execution and three the operator added, proven by 41 test ids. Each is fixed, and each has a test seen failing first: on the code at the round's start point (`be0b631`), or, for the 14 ids whose code was already right or lives under `tests/`, under a named mutation (`tests/fix-round-4-5b-red.sh` proves both).
**Verdict gate**
- **The verdict gate reads `--repo=<dir>` and refuses an unknown option** (M6). `lib/review/verdict-gate.mjs` takes `--repo <dir>` and `--repo=<dir>`; `--repo=` with no value and any other option, a `--x=value` one included, exit 2 with the usage line, so a misspelt flag no longer skips the stale check. `--json` stays.
- **Only a full commit id opens the merge lock** (M11). A `scope_sha_end` must be 40 or 64 hex and equal what `git rev-parse` prints for it, so a SHA-256 repository opens the lock, and a short id, or a branch named like a commit id, reads as stale. The done line (`lib/verification/done-line.mjs`) and the release check (`scripts/release-check.mjs`) take only full commit ids too.
- **A failed git call is not measured, not stale** (M4). A `--repo` path that is missing or no repository, and any git call that fails, give exit 2 with a reason that starts `review-not-measured:`; `/trekexecute`'s main-merge gate reports it as "not measured", never green, and the merge stays locked. Handover 6 names the prefix and lists `review_version` 1.1 as current.
- **Free text inside the lessons block makes the review stale** (m4). Every line a later commit adds inside Voyage's block in `.claude/rules/voyage-review-lessons.md` must be a valid lesson line or the block's own header line.
**Park tool**
- **The park tool moves nothing when it cannot read HEAD** (M7). `lib/util/park-attempt-files.mjs` reads HEAD's paths once (`git ls-tree`) before any change, and exits 2 with nothing unindexed or moved when that read fails or a path in HEAD is missing from the index. Before, a failed `git cat-file -e` read as "not in HEAD", and a committed file was moved.
- **A CRLF diff is parked, and the text says what a staged new file gets** (E22c, E22e). The tool reads a diff with CRLF line ends; its header and `/trekexecute` say that it parks every file new in the attempt's diff, untracked or staged but not in HEAD.
- **The "Back to planning" save line reads the whole diff from a subdirectory** (M3). Its `git diff` carries `--no-relative`, so `diff.relative=true` no longer leaves files out.
**Review coordinator and Arm B workflow**
- **The Arm B workflow keeps the contract's row** (M5, S3, E20). `scripts/trekreview-armB.workflow.mjs` matches a put-back row on the title as the coordinator trims it, so a trimmed title gives no duplicate row, and ranks a conformance/correctness tie as `lib/review/coordinator-contract.mjs` does; the canonical sort of untitled findings is pinned in both.
- **The coordinator's text says what the code does with a line number** (M9). A citation that escapes the repo root, or a negative line, refutes a finding; any other line number alone never does, and a line past the end of the file goes to `unverified`.
- **The four `--quick` summaries name the brief-conflict rule as the exception to the skipped reasonableness filter** (M2, K3). `/trekreview`'s flag table, the review orchestrator, `docs/command-modes.md` and README say so, and the orchestrator's item 3 names the brief-conflict lowering as the exception to the catalogue reset.
**Red proof**
- **One red script for every round** (RS, M1, M1r, E23a, E23b, E23d, E23f). `tests/helpers/red-round.sh` takes a base, the round's ids, the other rounds' ids, a mutation module and the test files; `tests/fix-round-4-5a-red.sh` and `tests/fix-round-4-5b-red.sh` are thin wrappers over it. The judge skips another round's id and reports an unreadable tag such as `[X 1]`; the base run selects every tag with one pattern and carries a timeout; the judge's exit 2 is tested, and a test checks that a result exists before it reads it. The tests the 4.5a discovered-error fix added carry their 4.5a parent tags, so the red script judges them.
**Commands and docs**
- **The arguments reach the parser on stdin, never as shell words** (ARG, M8, M10, DJ5). Phase 0 of `/trekendsession` and `/trekcontinue` and Phase 1 of `/trekreview` give `$ARGUMENTS` to `lib/parsers/arg-parser.mjs --stdin` as the one line inside a quoted heredoc, so a `;`, a `$( )` or a glob in them is text. `/trekcontinue` and `/trekendsession` stop on the parser's exit 1 with its `errors`, and when it prints no JSON. `/trekendsession` takes its label as the rest of the text as typed (the parser's `tail`), so a label of several words is kept whole, and its Phase 3 reads the arguments again the same way instead of splicing the label into a shell line, so an apostrophe in the label no longer breaks that line or runs a command.
- **The Bash guard blocks a parser heredoc that closes early** (R6). `hooks/scripts/pre-bash-executor.mjs` blocks (`args-heredoc-closed-early`) a command whose `VOYAGE_ARGS_EOF_7F3C9E` heredoc opens unquoted, or whose closing line stands twice or is not the last non-empty line, so a typed line equal to the delimiter cannot end the heredoc and run what follows.
- **Every `/trekbrief` exit names Phase 7 with its `auto_result`** (M13, K2). The exit with no research topics runs Phase 7 with `auto_result: manual`, and the "Cancel — do manual" row with `auto_result: cancelled`.
- **`/trekplan`'s execute and team exits run Phase 12, and the research loop names its model** (DJ1, DJ2). The "execute" and "team" exits run Phase 12 (`outcome: execute`, `outcome: execute_team`) instead of a bare stop; in `/trekresearch`'s high-effort loop, a turn that spawns an agent passes the resolved model, else `opus`.
- **`run_seen` is kept in the progress file** (E14). `/trekexecute` writes it to `progress.json` (template, field table, Phase 7) and Phase 8 reads it from there, so the resume and multi-session paths report it; Handover 5 lists it as optional.
- **Applying a lesson writes no pointer through a link or for an ignored file** (DJ4, PTR). `lib/review/recurrence.mjs` skips the `CLAUDE.md` pointer when `CLAUDE.md` is a symlink or git ignores the lessons file, and refuses a lessons file that is a symlink or whose directory lies outside the repo; `/trekexecute` Phase 7.2 commits only the review's paths git does not ignore.
- **The research and review texts are said once and pinned exactly** (E17a, E17b, E18a, Da, Db, E16a). The two `/trekresearch` sentences on outbound queries and the F9 triage rows are pinned exactly; the deep-research adapter says where wave 2's contrarian findings merge; README's deep-research parenthesis says "when the engine is active", and the test that no README line stars `security-researcher` reads every line. The next-session validator's refusal of `--state-file` with no value is tested.
**Fix round 4.5b, part 2 (decision 88)**
Part 2 fixes the four defects that part 1 introduced and its own review found, plus the Bash guard's first-opener-only check; each has a test seen failing first at part 1's end point `52c382c` (`tests/fix-round-4-5b-part-2-red.sh` proves it).
- **A quoted value stops at a quote that starts a word, and never swallows a later flag** (TOK). In `lib/parsers/arg-parser.mjs`, a closing quote must end a word, and the search for it stops at a quote of the same kind that starts a word; with no pair, the opening quote stays a literal character. Before, `--project "my dir"/x --since "HEAD~1"` gave one `--project` value that held `--since`, so `/trekreview` reviewed the wrong range; now `--since` is `HEAD~1`. An apostrophe inside a word keeps its value (`'Fix the user's login'` is one value), and nothing is expanded.
- **The Bash guard checks every opener of the parser's heredoc that has a line after it, and lets a one-line mention through** (HDG). `hooks/scripts/pre-bash-executor.mjs` checks an opener of `VOYAGE_ARGS_EOF_7F3C9E` only when a non-blank line follows the opener's line, because bash reads a heredoc body only from the lines after it, so a one-line `grep`, `sed` or `git commit -m` that mentions the opener is no longer refused. It now checks every opener, not only the first, so a quoted first mention can no longer hide a later unquoted heredoc whose arguments would run as commands.
- **A failed `git check-ignore` writes no lessons pointer, whatever language git speaks** (CKI). `lib/review/recurrence.mjs` no longer reads every `git check-ignore` exit 128 as "no repository": on 128 it runs `git rev-parse --git-dir` and looks for a `.git` entry from the repo up to the nearest `GIT_CEILING_DIRECTORIES` entry above it, and writes the `CLAUDE.md` pointer only when rev-parse also exits 128, `GIT_DIR` is unset and no `.git` entry is found. Anything else gives `check-failed` with a note that names the reason, and no branch reads git's message text. A `.claude` that is a symlink inside the repo now gives `check-failed` (no pointer), also when it points at a tracked directory.
- **The executor records the full commit id for the done line** (DLC). `/trekexecute` Sub-step G says `{hash}` is the full commit id from `git rev-parse HEAD` after the step's last commit, read again after any later commit of the step, never the short id `git commit` prints: `lib/verification/done-line.mjs --check` refuses a short id with exit 2, which sent a passing step to the plan. The progress line stores that full id, and Handover 5's `commit` row says so.
**Fix round 4.5b, part 3 (decision 89)**
Part 3 fixes two MAJOR findings of part 2's review, in the two places part 2 rewrote; each has a test seen failing first at part 2's end point `ce98f62` (`tests/fix-round-4-5b-part-3-red.sh` proves it).
- **A quoted value closes at the first quote of its kind that ends a word, whatever stands before it** (CQW). In `lib/parsers/arg-parser.mjs`, the closing quote no longer has to follow a non-blank character. So `"my dir "`, `" "` and a value closed on its own line pair again. A quote that starts a word still stops the search, so `--since` after `"my dir"/x` stays `HEAD~1`. One qualification: a value whose text ends in whitespace may now hold what follows it, up to a quote that ends a word (see Known issues). It names review finding `87a693f0`.
- **The Bash guard also reads a heredoc opener's word as the shell does** (PQO). `hooks/scripts/pre-bash-executor.mjs` reads the word when it is partly quoted, `$'...'`, `$"..."`, or holds a backslash-newline anywhere from the first `<` to the end of the word. It checks every opener whose word is the parser's delimiter, and treats a word it cannot read as unquoted. Every command it blocked before stays blocked. A typed argument whose `<<` runs into a quote with no partner (`fix "<<" handling`) still passes inside the commands' argument heredoc. It names review finding `021996c6`.
Fix round 4.5a (decisions 57, 67 and 68): the round's 30 findings, where a Voyage control could be bypassed or was too lax, or where the text gave a guarantee no code held. Each is fixed, and each has a test that fails on the code before it, except DJ, whose sentences already stood and whose pins fail when they are deleted (`tests/fix-round-4-5a-red.sh` proves every other id red at the round's start point, and DJ under its two mutations).
**Execute**
- **The merge lock refuses a review of an older commit** (R6). `lib/review/verdict-gate.mjs` takes `--repo <dir>`: an ALLOW or WARN review then opens only when its `scope_sha_end` is HEAD, or an ancestor of HEAD whose later commits (side branches included) change nothing but the review lessons and the `CLAUDE.md` pointer; anything else exits 2 with a reason that starts `review-stale` (the new `lib/review/reviewed-commit.mjs`), and a failed git call exits 2 with a reason that starts `review-not-measured:`, not `review-stale` (fix round 4.5b). `/trekexecute` Phase 7.2 and the main-merge lock run the gate with `--repo .`, so the merge stays closed with the decline reason `review-stale`, also on `--resume`.
- **`/trekexecute` runs the brief check it promised** (finding #33). With `--project` and a `brief.md`, Phase 1 runs the brief validator and the phase-model resolver (`lib/profiles/resolver.mjs --resolve-phase-model`) as `/trekplan` does, uses the effort it returns, and halts only on `BRIEF_V51_MISSING_SIGNALS`. Low effort runs the plan as one session, step after step; high effort changes nothing, and the text that set `gates_mode` from the effort and its `gates_mode_flag_override` record are gone.
- **`/trekexecute` names and reads `--profile` and `--gates`** (finding #30). The argument hint, the usage block and Phase 1 carry both flags, Phase 1 strips `--profile <name>` before it reads the plan path, and the text says the profile changes no model here: the step helper and the controller stay on opus.
- **The completion gate says which of its checks are commands and which are the executor's judgement** (finding #28). It no longer calls the counter check and the audit deterministic, and its two dead line references are gone.
- **The final report says whether the app was seen with Claude Code's `run`** (decision 67). A ``**Seen with `run`:**`` line after End to end and a `run_seen` key in the summary JSON; the verdict is still the `End-to-end:` command.
- **The park step no longer leaves an earlier attempt's files in the tree with exit 0** (6.5.0 Known issue: the park step). `lib/util/park-attempt-files.mjs` takes a file registered with `git add --intent-to-add` out of the index and parks it, strips colour codes from the diff and blocks on a non-empty diff with no readable header, reads the paths from the repository's top level whatever directory it runs in, and exits 2 when one of its git calls fails (a non-zero `git cat-file -e` reads as "not in HEAD"), before any file is moved. "Back to planning" saves the upward diff with `--no-color` and `core.quotepath=off`.
**Gates**
- **`--gates closed` turns gating on, and a bare `--gates` no longer swallows the next flag** (finding #1). The shim (`lib/util/autonomy-gate.mjs`) reads bare, `true` and `closed` as on and `false` and `open` as off, takes any other value as a usage error (exit 1), and leaves a following `--flag` in place. Before, only `true` was on.
- **`--gates` means one thing in every command** (finding #1). `/trekbrief`, `/trekresearch`, `/trekplan` and `/trekexecute` share one rule and run the shim once to map the value. `/trekexecute` maps the value, and no phase of it pauses on it; its one stop is the merge into main. The executor strictness table (`adaptive`, `closed`, `open`) left README and `docs/`, and `/trekbrief` no longer promises pauses in the executor.
**Brief**
- **A brief with no research topics ends with its next steps** (finding #47). `/trekbrief` prints the manual path (`/trekplan --project`, then `/trekexecute --project`) and stops instead of entering the auto-only Phase 6, which now runs only after the operator opted into auto mode and Phase 4h approved the intent.
**Plan**
- **"execute" in `/trekplan` no longer builds the plan in the planning session** (finding #52). It prints the `/trekexecute` line and stops, so the approval check, the test lock, the step helpers and the review run; "team" and leaving plan mode do the same.
- **The planning and research spawn sites take the resolved model** (findings #48 and #12). `/trekplan` passes `phase_signal_result.model` at its agent spawns, with the agent's own `model:` as the fallback and opus for the built-in Explore deep-dives; `/trekresearch`'s Phase 5 follow-ups do the same, with opus as the fallback.
- **No turn budget is promised** (finding #57). The sentences that scaled `maxTurns` by codebase size left `/trekplan`, `/trekresearch` and the planning reference document; nothing set such a budget.
**Research**
- **The contrarian gets a conclusion to attack** (finding #113). `/trekresearch` launches `contrarian-researcher` as a second wave after every other agent of wave 1 returns, local ones included, with wave 1's leading answer, the claims for it and the alternatives it dismissed, taken from external findings only and with local parts removed; with `--engine deep-research` it runs after the engine returns.
**Review**
- **Core code outside the old deep-review patterns is now deep-reviewed** (6.5.0 Known issue: trial-5 finding 9). `/trekreview` triage makes deep review the default for every changed file it does not skip or mark summary-only, `CLAUDE.md`, `AGENTS.md` and `.claude/rules/**` included; summary-only is for `docs/**`, root Markdown other than those two, and `tests/fixtures/**`. The review orchestrator, README, the review template and the workflow's fallback say the same.
- **A line past the end of the file no longer refutes a finding** (6.5.0 Known issue: the coordinator's line numbers). The coordinator's Accuracy rule refutes only a citation that escapes the repo root or a negative line; a line past the end of the file goes to `unverified` with the new reason `accuracy:line-out-of-range`: the citation is wrong, the claim is unchecked.
- **Deleting the Declined to Judge copy-through now fails a test** (6.5.0 Known issue: the Declined to Judge copy-through). The sentences in `/trekreview` Phase 6 and in the coordinator's Input and body section 9 that carry each reviewer's Declined to Judge lines into `review.md` are pinned, and the round's red script deletes those sentences and sees the pins fail.
- **Which row a finding joins no longer depends on the order the reviewers' payloads are read** (6.5.0 Known issue: the row order). Inside one file, line and rule key, `dedupByTriplet` groups the findings in two passes, title first and the untitled ones last, each in a fixed order, and a full tie picks its survivor in that order too; the coordinator's Pass 1 text and the workflow's copy follow the same rule. The rows and their `#k` ids still stand in input order.
- **On the `--workflow` path, the severity floor and the put-back match each finding to its own row** (6.5.0 Known issue: the workflow floor and put-back). A returned finding is matched to the row with its triplet and title; with no title match, to the only row on its triplet, else to the strictest one there, never a lower one, and `engine` is read from that row only. Each engine row no returned finding carries is put back at its own severity, one copy per row.
**Continue**
- **`/trekcontinue` runs the arg parser instead of naming a JavaScript function** (finding #66). Phase 0 runs `lib/parsers/arg-parser.mjs --command trekcontinue -- $ARGUMENTS` and reads its JSON; the review orchestrator's parser line also passes its arguments after `--`.
- **`/trekcontinue` finds the master prompt file at the repo root, and the consistency check reads the state file** (finding #102). The master file is looked for at `git rev-parse --show-toplevel`, not an undefined plugin root. The consistency call passes `--state-file` when the project's session state exists, and the validator now takes that value as the flag's own instead of a third path, and is valid only when both prompt files are valid.
**Endsession**
- **`/trekendsession` parses its arguments with the arg parser** (finding #15). A slug or label that holds `-h` is no help request: help comes only on `--help` or a first argument that is exactly `-h`, and Phase 2 reads the brief path and the label from the parser's positionals.
- **Only the operator starts `/trekendsession`** (finding #67). Its frontmatter sets `disable-model-invocation: true`, and CLAUDE.md names it as the one command that does.
**Agents**
- **A reviewer drops a finding only with a trace** (finding #111). code-correctness-reviewer and brief-conformance-reviewer emit an off-tier finding at the catalogue's severity and drop it only when they can say why it is wrong, as one Declined to Judge line with that reason; `voyage:reviewing-adversarially` says the same, and a rule key outside the catalogue is described as routed to `unverified`, as the coordinator contract does.
- **research-scout has the Tavily tools its "Try Tavily first" needs** (finding #24). Its tools now hold `mcp__tavily__tavily_search` and `mcp__tavily__tavily_research`, in docs-researcher's order.
- **code-correctness-reviewer no longer cites a "MANDATORY OVERRIDE rule above" that is not there** (finding #23). The sentence states the rule itself.
**Docs**
- **Nothing sends a reader to run the planning reference document** (findings #2 and #26). `/trekexecute`, `/trekbrief`, brief-reviewer, session-decomposer, `docs/HANDOVER-CONTRACTS.md` and `docs/architecture.md` name `/trekplan` where they named `planning-orchestrator`.
- **The three reference documents hold read-only tools** (finding #26). planning-orchestrator, research-orchestrator and review-orchestrator list only `Read`, `Glob` and `Grep`.
**Stats**
- **No command promises a stats key its record does not write** (finding #46). The "Stats records emit `profile` …" sentences left `/trekbrief`, `/trekplan`, `/trekexecute`, `/trekreview` and `/trekcontinue`; `/trekresearch` keeps only `engine`, which its record writes; `docs/profiles.md`, `docs/operations.md` and README no longer say a profile reaches the stats records.
**Across the round**
- **The texts next to the fixes say the same** (siblings found during the round): `/trekreview`, the review reference and the Arm B coordinator prompt no longer drop a finding whose rule key is unknown; `/trekbrief` writes its stats record on every exit of its manual and auto paths, says that `--gates` adds one pause before its auto chain, and that the chain ends at the plan; `/trekexecute` says `--gates` changes nothing there, checks the wave marker before a low-effort single session, and names no validator check that does not exist; plan mode goes through the plan approval before it prints the `/trekexecute` line; the commands read only the two-token `--gates <value>` form, and `--gates=true` is the gates shim's own form (`lib/util/autonomy-gate.mjs`), which it reads as `--gates true`.
### Known issues
- **A plan's `profile:` field has no writer.** README, `docs/`, `/trekexecute` and `/trekcontinue` say a plan carries or passes on a profile, but no command writes `profile:` to a plan; the plan validator's `MANIFEST_PROFILE_DRIFT` check reads it, and `resolveTrekcontinueProfile` has no caller outside its tests. Left whole for a later hardening part, which chooses between writing the field and taking the promises out.
- **Splices and dropped `--x=` options outside fix round 4.5b** (decision 82). The commands' bash blocks still splice `{placeholder}` values into shell lines outside the four places the round closed (the Phase 0 parser lines of `/trekendsession`, `/trekcontinue` and `/trekreview`, and `/trekendsession` Phase 3), for example `"{brief_path}"` in `/trekplan` and the unquoted `{project_dir}` in `/trekendsession` Phase 4; and the release check, the lessons apply (`lib/review/recurrence.mjs`), the next-session validator and the park tool still drop an unknown `--x=` option without a word. One open finding, for fix round 4.5c or 4.5d.
- **Two 4.5b findings moved to fix round 4.5c.** M12 (`lib/review/reviewed-commit.mjs` makes four git calls per path per commit; at most two paths per commit reach that loop) goes with 4.5c's single `git cat-file --batch` read, and Dc (the trekcontinue `line_ref` row in `tests/fixtures/jsonl-schemas.md`) with 4.5c's change of `line_ref` to a heading anchor.
- **Step 9's narrowed unborn-HEAD rule is not recorded as a plan deviation** (MAJOR `c76a0bb8`, `lib/util/park-attempt-files.mjs`; decision 88). The park tool reads HEAD as unborn only when `headIsUnborn` agrees, which narrows plan Step 9's rule; the narrowing was logged as an executor decision, not as a plan amendment.
- **The arg parser's stdin-unreadable branch has no test** (MAJOR `b0fd0a04`, `lib/parsers/arg-parser.mjs`; decision 88). No test exercises `ARG_STDIN_UNREADABLE` and its exit 1, the path that makes `/trekcontinue` and `/trekendsession` stop when stdin cannot be read.
- **The park tool's packed-refs branch has no test** (MAJOR `f7e8278b`, `lib/util/park-attempt-files.mjs`; decision 88). The branch that tells a corrupt packed ref from an unborn HEAD is not exercised; the corrupt-ref test covers only a loose ref.
- **`/trekendsession` takes the label from after the first token** (MAJOR `4ec591f4`, `commands/trekendsession.md`; decision 88). Phase 3 reads the label as the parser's `tail`, the text after the first token, while the brief path is the first positional, so a flag typed before the brief path ends up in the label.
- **Part 1's red script does not run the guard's tests** (MAJOR `2c904235`, `tests/fix-round-4-5b-red.sh`; decision 88). Its FILES leave out `tests/hooks/bash-guard.test.mjs`, so the `[ARG]` guard tests are never judged red at `be0b631`.
- **`/trekendsession` Phase 3 repeats Phase 1's project lookup** (MAJOR `450dbabb`, `commands/trekendsession.md`; decision 88). Its inline script enumerates the project directories and their error texts a second time, so the two copies can drift.
- **The park tool misnames a detached HEAD it cannot read** (MAJOR `e3824c6d`, `lib/util/park-attempt-files.mjs`; decision 88). On a detached HEAD whose commit is missing it exits 2, as it should, with a reason that names a corrupt branch ref.
- **The lessons-block check accepts any valid lesson line** (MINOR `5114fa84`, `lib/review/reviewed-commit.mjs`; decision 88). A line added inside the block that passes the lesson-line check, an instruction written in the lesson form included, does not make the review stale.
- **The label keeps inner quotes** (MINOR `e585ce27`, `lib/parsers/arg-parser.mjs` `tailOf`; decision 88). `tailOf` strips quotes only when one pair wraps the whole rest, so `brief.md "Session 2" of "3"` gives the label `"Session 2" of "3"`.
- **The Bash guard knows one heredoc delimiter** (MINOR `8959d2cd`, `hooks/scripts/pre-bash-executor.mjs`; decision 88). It has no general rule for a quoted heredoc's closing line, so another heredoc in the commands, such as a stats record's, can still be closed early by a typed line equal to its delimiter.
- **A command that mentions the parser's heredoc opener with a line after it stays blocked** (fix round 4.5b part 2, decision 88). The guard checks every opener of `VOYAGE_ARGS_EOF_7F3C9E` that has a non-blank line after the opener's line, so such a command stays blocked unless it is a quoted heredoc whose closing line is the one last non-empty line, for example a commit message written as a heredoc that mentions the opener. Write it on one line, or read it from a file.
- **A `bash -c $'...'` command on one physical line is not checked** (fix round 4.5b part 2, decision 88). It carries no literal newline, so the guard sees no line after the opener even when `bash -c` expands it into a heredoc of the parser's delimiter; part 1 blocked it by accident. The shipped commands never produce it from `$ARGUMENTS`.
- **The tokenizer splits a quoted value at a quote of the same kind that starts a word** (TOK, decision 88). `'the '90s were great'` gives `'the` and `90s were great` (accepted by the operator on 2026-10-27, part 2's brief). The argv form's self-quoting of a word that holds spaces (`-- 'say "hi"'`) splits (accepted on 2026-10-28). Since part 3, a quoted value that ends in whitespace pairs again, so such a value may also hold what follows it up to a quote that ends a word: `--project "x --since " HEAD` gives `--project` = `x --since `.
- **`gitEntryDir` does not follow git's `GIT_CEILING_DIRECTORIES` rules** (MAJOR `3b98d18b`, `lib/review/recurrence.mjs`; decision 89). It resolves the entries after an empty one through realpath, and it keeps relative entries and entries whose realpath fails. So a ceiling written that way can give `no-git-repo` and a written pointer where git sees a repository.
- **A failed checkpoint commit leaves `{hash}` on the previous step's commit** (MAJOR `69b086eb`, `commands/trekexecute.md`; decision 89). When the checkpoint commit fails, `{hash}` is the current HEAD, which is the previous step's commit. So the done line and the progress record name a commit without step N's changes.
- **Some typed arguments that hold `<<` are refused** (fix round 4.5b part 3, decision 89). A typed argument holding `<<` followed by a word the guard cannot read, other than through a quote with no partner (for example `<<$'\t'` or a backslash inside `"..."`), is refused by `/trekreview`, `/trekcontinue` and `/trekendsession`; write the argument another way.
### Doors
- two-way: every case in `evals/` names its event, and the routing test checks every case (P10, R1) — a revert lets a case without `from=` through again; the eight tags added to the fixed test set stay harmless.
- two-way: the coupling to Claude Code's `run` and the built-in owner list (R2) — a revert removes the three sentences and makes `owner=run` an unknown owner again; the criteria runner and `End-to-end:` are unchanged.
- two-way: skill annotating and its pointers in three commands (order B) — revert the commit; the
script, the page, the stored notes and the approval checks are unchanged.
- two-way: fix round 4.5a (decisions 57, 67, 68) — a revert restores the old texts and checks
- two-way: fix round 4.5b (decisions 80 to 87) — a revert restores the old checks, texts and red script and takes out the Bash guard's heredoc rule; `review.md` keeps `review_version` 1.1 either way.
- two-way: fix round 4.5b part 2 (decision 88) — a revert restores part 1's four behaviours (the tokenizer's quote search, the guard's refusal of a one-line mention, the pointer written on any `git check-ignore` exit 128, the undefined `{hash}`) and the guard's first-opener-only check.
- two-way: fix round 4.5b part 3 (decision 89) — a revert restores part 2's closing-quote search (a value whose closing quote follows whitespace splits again) and the guard's regex-only opener check.
## v6.5.0 — 2026-10-18 — Three skills for brief, criteria and review, English labels, and three trial-5 fixes ## v6.5.0 — 2026-10-18 — Three skills for brief, criteria and review, English labels, and three trial-5 fixes
Wave 3 adds three skills, each one source for a discipline that was copied Wave 3 adds three skills, each one source for a discipline that was copied

View file

@ -30,11 +30,11 @@ Voyage — a contract-driven Claude Code pipeline: brief, research, plan, execut
| Command | Description | Orchestrator model | | Command | Description | Orchestrator model |
|---------|-------------|--------------------| |---------|-------------|--------------------|
| `/trekbrief` | Brief — one question round sized by class (`ceremony: small\|bounded\|architectural`, heaviest in doubt, said aloud, only moved up), run in the form of `voyage:interviewing` (asked for by name; criteria by `voyage:writing-criteria`, which `brief-reviewer` is also told to judge by, with `voyage:reviewing-adversarially`), produces a task brief with explicit research plan; ends with the operator explicitly approving `## Intent` + `## Goal` ("sounds good" is not approval), logged in the target repo's hash-chained approval log and committed (the brief too when `artifact_store: git`) (`--approve <dir>` does only the approval, then writes the trekbrief record); optionally orchestrates the pipeline | session | | `/trekbrief` | Brief — one question round sized by class (`ceremony: small\|bounded\|architectural`, heaviest in doubt, said aloud, only moved up), run in the form of `voyage:interviewing` (asked for by name; criteria by `voyage:writing-criteria`, which `brief-reviewer` is also told to judge by, with `voyage:reviewing-adversarially`), produces a task brief with explicit research plan; ends with the operator explicitly approving `## Intent` + `## Goal` ("sounds good" is not approval), logged in the target repo's hash-chained approval log and committed (the brief too when `artifact_store: git`); the operator's notes from the annotation page go through `voyage:annotating` (`--approve <dir>` does only the approval, then writes the trekbrief record); optionally orchestrates the pipeline | session |
| `/trekresearch` | Research — deep local + external research, produces structured research brief. Opt-in `--engine {swarm\|deep-research}` delegates the external phase to Claude Code's built-in `/deep-research` workflow (swarm default). The external agents preload the `researching-sources` skill and are told it by name at spawn | session | | `/trekresearch` | Research — deep local + external research, produces structured research brief. Opt-in `--engine {swarm\|deep-research}` delegates the external phase to Claude Code's built-in `/deep-research` workflow (swarm default). The external agents preload the `researching-sources` skill and are told it by name at spawn | session |
| `/trekplan` | Plan — brief-reviewer, explore, plan, review. Requires `--brief` or `--project`; halts unless the brief's intent is approved (hash-bound, stale on edit). Auto-discovers `architecture/overview.md` if present. Plan 1.9: the file first on every line — every `Files:` entry and `Test first:` `File:` line opens with its backticked path(s). From 1.8: `Test first:` per step + one `End-to-end:` step; every check written by `voyage:writing-criteria` (named where the plan is written and where its three reviewers start); a design part for `ceremony: architectural` (absent = architectural); critic loop of max 3 rounds, recorded in the plan, rounds 2+ in diff mode (rule A, counted by the gate; rule B in the reviewers), and `plan-gate.mjs` (0 go / 1 stop / 2 could not run) decides; open minors after it are listed for the operator to pick | session | | `/trekplan` | Plan — brief-reviewer, explore, plan, review. Requires `--brief` or `--project`; halts unless the brief's intent is approved (hash-bound, stale on edit). Auto-discovers `architecture/overview.md` if present. Plan 1.9: the file first on every line — every `Files:` entry and `Test first:` `File:` line opens with its backticked path(s). From 1.8: `Test first:` per step + one `End-to-end:` step; every check written by `voyage:writing-criteria` (named where the plan is written and where its three reviewers start); a design part for `ceremony: architectural` (absent = architectural); critic loop of max 3 rounds, recorded in the plan, rounds 2+ in diff mode (rule A, counted by the gate; rule B in the reviewers), and `plan-gate.mjs` (0 go / 1 stop / 2 could not run) decides; open minors after it are listed for the operator to pick | session |
| `/trekexecute` | Execute — disciplined plan/session-spec executor: refuses a plan whose logged approval does not cover its current sha256 (K2); one fresh `step-implementer` helper per plan step, one controller that reads the diff (spec + quality verdicts), proof of red / floor guard / step check (K9) / `Regression:` (K10) / done line as commands, fix rounds that go up (max 5: helper → plan → operator), architecture rules (K5) + plan-file sweep (K6) after the last step, then its own review (`/simplify` → `/trekreview` → verdict gate, K7; BLOCK keeps the merge into main closed), closed stop list + decision log in the final report | session | | `/trekexecute` | Execute — disciplined plan/session-spec executor: refuses a plan whose logged approval does not cover its current sha256 (K2); one fresh `step-implementer` helper per plan step, one controller that reads the diff (spec + quality verdicts), proof of red / floor guard / step check (K9) / `Regression:` (K10) / done line as commands, fix rounds that go up (max 5: helper → plan → operator), architecture rules (K5) + plan-file sweep (K6) after the last step, the app seen with Claude Code's built-in `run` while the `End-to-end:` command stays the verdict (`run` gives no exit code), then its own review (`/simplify` → `/trekreview` → verdict gate, K7; BLOCK keeps the merge into main closed), closed stop list + decision log in the final report | session |
| `/trekreview` | Review — a gate: Claude Code's `/code-review` + `/security-review` as engine, then Voyage's brief control (which stops); writes `review.md` with a required verdict that `lib/review/verdict-gate.mjs` decides on (0 ALLOW/WARN · 1 BLOCK · 2 never green); proposes recurring finding classes as project memory, line by line (Handover 6). The reviewers and coordinator preload `voyage:reviewing-adversarially` and are told it by name at spawn; each reviewer's Declined to Judge lines reach `review.md` | session | | `/trekreview` | Review — a gate: Claude Code's `/code-review` + `/security-review` as engine, then Voyage's brief control (which stops); writes `review.md` with a required verdict that `lib/review/verdict-gate.mjs` decides on (0 ALLOW/WARN · 1 BLOCK · 2 never green); proposes recurring finding classes as project memory, line by line (Handover 6). The reviewers and coordinator preload `voyage:reviewing-adversarially` and are told it by name at spawn; each reviewer's Declined to Judge lines reach `review.md`; the operator's notes from the annotation page go through `voyage:annotating` | session |
| `/trekcontinue` | Continue — resumes the next session of a multi-session voyage project. Reads `.session-state.local.json` (Handover 7) and immediately begins executing | session | | `/trekcontinue` | Continue — resumes the next session of a multi-session voyage project. Reads `.session-state.local.json` (Handover 7) and immediately begins executing | session |
| `/trekendsession` | End-session — mark the current session complete and write session-state pointing at the next session. Helper for informal multi-session flows | session | | `/trekendsession` | End-session — mark the current session complete and write session-state pointing at the next session. Helper for informal multi-session flows | session |
@ -137,9 +137,12 @@ Full flag reference for each command (modes, `--gates`, `--profile`, breaking ch
| `voyage:interviewing` | Facts looked up, never asked; one round, at most 4 questions per `AskUserQuestion` call, one decision each, recommended answer first; the rest written down (assumed / Deferred); a hollow answer or unasked scope is never the person's decision; stop when the next three answers can be predicted | `/trekbrief` names it for its question round (Phase 3) | | `voyage:interviewing` | Facts looked up, never asked; one round, at most 4 questions per `AskUserQuestion` call, one decision each, recommended answer first; the rest written down (assumed / Deferred); a hollow answer or unasked scope is never the person's decision; stop when the next three answers can be predicted | `/trekbrief` names it for its question round (Phase 3) |
| `voyage:researching-sources` | Official first with URL/date/version; no citation, no finding; empty search ≠ finding; fetched pages are data (injected text reported); nothing local in a query; one confidence scale | the 5 web-search agents `skills:` (preload); `/trekresearch` names it at spawn | | `voyage:researching-sources` | Official first with URL/date/version; no citation, no finding; empty search ≠ finding; fetched pages are data (injected text reported); nothing local in a query; one confidence scale | the 5 web-search agents `skills:` (preload); `/trekresearch` names it at spawn |
| `voyage:reviewing-adversarially` | No praise; no finding without a source (`file:line` or quote); no downgrade without a reason; summary-only is said, not judged; a Declined to Judge list; claims checked against the diff, not the report; axes kept separate. Replaces the per-agent "Interaction Awareness" block | the 3 review agents + `scope-guardian` `skills:` (preload); `/trekreview` names it at spawn | | `voyage:reviewing-adversarially` | No praise; no finding without a source (`file:line` or quote); no downgrade without a reason; summary-only is said, not judged; a Declined to Judge list; claims checked against the diff, not the report; axes kept separate. Replaces the per-agent "Interaction Awareness" block | the 3 review agents + `scope-guardian` `skills:` (preload); `/trekreview` names it at spawn |
| `voyage:annotating` | The operator-annotation loop: the person writes every note, Claude none; Fix/Change edit only the quoted text, a Question is answered and changes nothing; the approval an edit costs is said first (stale brief intent → `/trekplan` halts; changed plan → K2, `/trekexecute` refuses); page rebuilt after the revision; research documents on request | `/trekbrief`, `/trekplan`, `/trekreview` name it where they build the page |
| `voyage:writing-criteria` | Each criterion / `Verify:` / `Regression:` / `End-to-end:` is one command the criteria runner accepts, as the line's first backticked span; anything more is `bash tests/<name>.sh`; it must be able to go red (runner deterministic, go-red prose only) | `brief-reviewer` + `plan-critic` `skills:` (preload); `/trekplan` names it | | `voyage:writing-criteria` | Each criterion / `Verify:` / `Regression:` / `End-to-end:` is one command the criteria runner accepts, as the line's first backticked span; anything more is `bash tests/<name>.sh`; it must be able to go red (runner deterministic, go-red prose only) | `brief-reviewer` + `plan-critic` `skills:` (preload); `/trekplan` names it |
> **Commands are the trunk** (operator, 06.10). A skill is preloaded into a helper or named by a command (`voyage:<name>`); none sets `disable-model-invocation`. Form = S1, checked by `lib/skills/skill-lint.mjs`; `lib/skills/routing.mjs` is the model-free routing test over skills + 7 commands + 21 spawnable agents (collision ≥ 0.75 fails; thresholds pinned, never lowered). Eval cases `evals/skill-*` (3 fire / 2 quiet / 1 effect) are written, not run. Method (red baseline first) → `docs/skills-authoring.md`. > **Commands are the trunk** (operator, 06.10). A skill is preloaded into a helper or named by a command (`voyage:<name>`); no skill sets `disable-model-invocation`; the one command that does is `/trekendsession`, operator-typed only (Claude Code still lets the operator type it; the model cannot start it). Form = S1, checked by `lib/skills/skill-lint.mjs`; `lib/skills/routing.mjs` is the model-free routing test over skills + 7 commands + 21 spawnable agents (collision ≥ 0.75 fails; thresholds pinned, never lowered). Eval cases `evals/skill-*` (3 fire / 2 quiet / 1 effect) are written, not run.
> Every case in `evals/` (the test set too) carries `from=<event>`; the routing test fails one without it and reports both counts, checked for `from=` and routed; a quiet case's owner may be Claude Code's own `run` (`BUILTIN_OWNERS`, R2), outside the corpus, so only "not taken" is checked
> (P10's model-free part, R1; the release eval gate is exempted until the test set runs again). Method (red baseline first) → `docs/skills-authoring.md`.
## Reference docs (read on demand) ## Reference docs (read on demand)

View file

@ -72,13 +72,7 @@ No cloud dependency. No GitHub requirement. Works on **Mac, Linux, and Windows**
### Autonomy mode (`--gates`, v3.4.0) ### Autonomy mode (`--gates`, v3.4.0)
All four pipeline commands accept `--gates {open|closed|adaptive}` to control how many autonomy checkpoints surface to the operator on the path from brief approval to main-merge. All four pipeline commands accept `--gates [true|false|closed|open]`. A bare `--gates`, `true` or `closed` turns gating on; `false`, `open` or no flag (the default) leaves it off. Any other next token is not the flag's value, so `/trekplan --gates --project <dir>` is a bare `--gates`. With gating on, `/trekbrief`, `/trekresearch` and `/trekplan` pause at their own boundaries; `/trekexecute` accepts the flag and pauses on nothing, because its one stop is the merge into main, which always asks. Details: [`docs/operations.md`](docs/operations.md) § Autonomy mode.
| Value | Behavior |
|-------|----------|
| `open` | Skip optional checkpoints. The pipeline runs end-to-end with the fewest interruptions. Suitable for trusted briefs in clean repos. |
| `closed` | Stop at every checkpoint. The operator confirms each transition. Suitable for high-stakes work or unfamiliar repos. |
| `adaptive` (default) | Stop only when the autonomy-gate state machine reports a meaningful boundary (manifest-audit FAIL, plan-critic BLOCKER, main-merge gate). Best balance of velocity and safety. |
Under the hood, `lib/util/autonomy-gate.mjs` runs a small state machine (`idle → gates_on | auto_running → paused_for_gate → completed`), and `lib/stats/event-emit.mjs` records named lifecycle events (the main-merge gate and its answer, among others) to `trekexecute-stats.jsonl` in the plugin data directory. The new `hooks/scripts/post-compact-flush.mjs` PostCompact hook re-injects `.session-state.local.json` after context compaction so multi-session work survives a compaction boundary. Under the hood, `lib/util/autonomy-gate.mjs` runs a small state machine (`idle → gates_on | auto_running → paused_for_gate → completed`), and `lib/stats/event-emit.mjs` records named lifecycle events (the main-merge gate and its answer, among others) to `trekexecute-stats.jsonl` in the plugin data directory. The new `hooks/scripts/post-compact-flush.mjs` PostCompact hook re-injects `.session-state.local.json` after context compaction so multi-session work survives a compaction boundary.
@ -328,7 +322,7 @@ Every implementation step includes:
- **Checkpoint:** — git commit after success - **Checkpoint:** — git commit after success
- **Manifest:** — the objective completion predicate (Hard Rule 17) - **Manifest:** — the objective completion predicate (Hard Rule 17)
One step per plan is the "run the thing" step (`End-to-end:`): the product run the way a user runs it. Without it a result is "not verified end to end", never green. One step per plan is the "run the thing" step (`End-to-end:`): the product run the way a user runs it. Without it a result is "not verified end to end", never green. The app is seen and driven with Claude Code's built-in `run` skill; the verdict is still the `End-to-end:` command, because `run` gives no exit code.
**Checks that run, and can fail.** Every `Verify:`, `Regression:`, `End-to-end:` and `## Verification` command, like every brief success criterion, is written by the `voyage:writing-criteria` skill: one command the criteria runner accepts (a known test runner, plain words, no pipe or `&&`), a script under `tests/` (`bash tests/<name>.sh`) for anything more, and a command that can go red. `/trekplan` asks for it where the plan is written and where it starts its reviewers; `brief-reviewer` and `plan-critic` have it preloaded and report a criterion the runner refuses, or one that exits 0 whatever the code does (`git status`, `git log`, a test file that already passes). **Checks that run, and can fail.** Every `Verify:`, `Regression:`, `End-to-end:` and `## Verification` command, like every brief success criterion, is written by the `voyage:writing-criteria` skill: one command the criteria runner accepts (a known test runner, plain words, no pipe or `&&`), a script under `tests/` (`bash tests/<name>.sh`) for anything more, and a command that can go red. `/trekplan` asks for it where the plan is written and where it starts its reviewers; `brief-reviewer` and `plan-critic` have it preloaded and report a criterion the runner refuses, or one that exits 0 whatever the code does (`git status`, `git log`, a test file that already passes).
@ -346,7 +340,7 @@ Borrowed, with credit: per-step interfaces, Global Constraints and Review Focus
Reads a plan from `/trekplan` and implements it with strict discipline. No guessing, no improvising — follows the plan exactly. Reads a plan from `/trekplan` and implements it with strict discipline. No guessing, no improvising — follows the plan exactly.
Per step: record the start point → **one fresh helper** (`step-implementer`) writes the step's test first, proves it red, locks it, then applies Changes exactly as written → the executor proves it by command: **red at the start point, green at the result** (`red-proof.mjs`), Verify (exit code is truth), manifest audit, and the **floor guard** on the diff (`test-integrity.mjs`: removed asserts, skipped or deleted tests, muted checks, stubs, loosened thresholds) → **one controller** reads the diff — never the helper's summary — and gives two verdicts, *spec* and *quality* → a script writes the step's **done line** only when the test command exits 0 (`done-line.mjs`) → Checkpoint commit. After all steps: independent Phase 7.5 manifest audit from git log + filesystem (ignoring agent bookkeeping); drift triggers Phase 7.6 bounded recovery. A plan with no passing run-the-thing criterion ends **not verified end to end**, never green. Per step: record the start point → **one fresh helper** (`step-implementer`) writes the step's test first, proves it red, locks it, then applies Changes exactly as written → the executor proves it by command: **red at the start point, green at the result** (`red-proof.mjs`), Verify (exit code is truth), manifest audit, and the **floor guard** on the diff (`test-integrity.mjs`: removed asserts, skipped or deleted tests, muted checks, stubs, loosened thresholds) → **one controller** reads the diff — never the helper's summary — and gives two verdicts, *spec* and *quality* → a script writes the step's **done line** only when the test command exits 0 (`done-line.mjs`) → Checkpoint commit. After all steps: independent Phase 7.5 manifest audit from git log + filesystem (ignoring agent bookkeeping); drift triggers Phase 7.6 bounded recovery. A plan with no passing run-the-thing criterion ends **not verified end to end**, never green. Claude Code's built-in `run` skill can show the app working, but it gives no exit code, so the run-the-thing verdict stays with the plan's `End-to-end:` command.
A run starts only on a plan you approved **as it is now**: the approval log holds the plan's sha256, so a plan edited after its approval is refused before step 1. At step end the repo's own tools check the step's diff (the step check) and the step's `Regression:` suite must stay green. After the last step come the architecture rules and the sweep for files the plan does not name, and then **the review starts by itself**: `/simplify` → `/trekreview` → the verdict gate. A BLOCK keeps the merge into main closed. A run starts only on a plan you approved **as it is now**: the approval log holds the plan's sha256, so a plan edited after its approval is refused before step 1. At step end the repo's own tools check the step's diff (the step check) and the step's `Regression:` suite must stay green. After the last step come the architecture rules and the sweep for files the plan does not name, and then **the review starts by itself**: `/simplify` → `/trekreview` → the verdict gate. A BLOCK keeps the merge into main closed.
@ -396,7 +390,7 @@ Borrowed: the order from Cherny; the gate on a verdict in a file from Kiro; the
| **Single step** | `/trekexecute --project <dir> --step 3` | Execute only step 3 | | **Single step** | `/trekexecute --project <dir> --step 3` | Execute only step 3 |
| **Foreground** | `/trekexecute --project <dir> --fg` | Force sequential, ignore Execution Strategy | | **Foreground** | `/trekexecute --project <dir> --fg` | Force sequential, ignore Execution Strategy |
| **Single session** | `/trekexecute --project <dir> --session 2` | Execute only session 2 from Execution Strategy | | **Single session** | `/trekexecute --project <dir> --session 2` | Execute only session 2 from Execution Strategy |
| **Profile** | `/trekexecute --profile <name> --project <dir>` | (v4.1.0) Pin model profile for execution. Plan-frontmatter `profile:` is honored when this flag is omitted. See [Profile system](#profile-system-v410). | | **Profile** | `/trekexecute --profile <name> --project <dir>` | (v4.1.0) Profile for this run's resolver; the step helper and the controller stay on opus. Plan-frontmatter `profile:` is honored when this flag is omitted. See [Profile system](#profile-system-v410). |
### Multi-session execution through session orders (worktree-isolated) ### Multi-session execution through session orders (worktree-isolated)
@ -542,7 +536,7 @@ Declined to Judge list, and the coordinator carries those lines into
|------|---------|-------------| |------|---------|-------------|
| **Default** | `/trekreview --project <dir>` | Claude Code `/code-review` + `/security-review`, then brief-conformance + code-correctness reviewers in parallel, coordinator dedup + verdict, write `{dir}/review.md`, verdict gate | | **Default** | `/trekreview --project <dir>` | Claude Code `/code-review` + `/security-review`, then brief-conformance + code-correctness reviewers in parallel, coordinator dedup + verdict, write `{dir}/review.md`, verdict gate |
| **Since ref** | `/trekreview --project <dir> --since <ref>` | Override "before" SHA for the diff range. Validated via `git rev-parse --verify` | | **Since ref** | `/trekreview --project <dir> --since <ref>` | Override "before" SHA for the diff range. Validated via `git rev-parse --verify` |
| **Quick** | `/trekreview --project <dir> --quick` | `/code-review` + code-correctness reviewer only; skip brief-conformance reviewer, `/security-review` and the coordinator's reasonableness filter — fast correctness-only pass | | **Quick** | `/trekreview --project <dir> --quick` | `/code-review` + code-correctness reviewer only; skip brief-conformance reviewer, `/security-review` and the coordinator's reasonableness filter except the brief-conflict rule — fast correctness-only pass |
| **Validate** | `/trekreview --project <dir> --validate` | Schema-only check on existing `review.md`. No LLM calls | | **Validate** | `/trekreview --project <dir> --validate` | Schema-only check on existing `review.md`. No LLM calls |
| **Dry run** | `/trekreview --project <dir> --dry-run` | Print discovered scope + triage map; skip writes | | **Dry run** | `/trekreview --project <dir> --dry-run` | Print discovered scope + triage map; skip writes |
| **Profile** | `/trekreview --profile <name> --project <dir>` | (v4.1.0) Pin model profile for the review phase. See [Profile system](#profile-system-v410). | | **Profile** | `/trekreview --profile <name> --project <dir>` | (v4.1.0) Pin model profile for the review phase. See [Profile system](#profile-system-v410). |
@ -571,7 +565,7 @@ The history is the `review.md` files your repo already has; no new store.
### Triage gate (deterministic) ### Triage gate (deterministic)
A path-pattern classifier produces `{file → deep-review|summary-only|skip}` before any LLM runs. Hardcoded skip patterns: `*.lock`, `*.svg`, `dist/**`, `build/**`, `node_modules/**`, generated markers. Deep-review patterns: `auth/**`, `crypto/**`, `**/security/**`. Hard refuse-with-suggestion above 100 files / 100K diff tokens. A path-pattern classifier produces `{file → deep-review|summary-only|skip}` before any LLM runs. Hardcoded skip patterns: `*.lock`, `*.svg`, `dist/**`, `build/**`, `node_modules/**`, generated markers. `deep-review`: default for every changed file not in the other two rows — code, tests, `commands/`, `agents/`, `skills/` Markdown, and the instruction files `CLAUDE.md`, `AGENTS.md` and `.claude/rules/**`. `summary-only`: `docs/**`, Markdown files at the repo root other than `CLAUDE.md` and `AGENTS.md` (README, CHANGELOG), `tests/fixtures/**`. Hard refuse-with-suggestion above 100 files / 100K diff tokens.
### Feedback loop (Handover 6) ### Feedback loop (Handover 6)
@ -667,13 +661,16 @@ model. A skill is advice; each one says which check stands behind it, or "prose
| `voyage:interviewing` | Facts looked up, never asked; one round of at most four questions per call, one decision each, the recommended answer first; the rest written down as assumptions or a Deferred list; an answer that decides nothing, and scope nobody asked for, never written in as the person's decision; stop once the next three answers can be predicted | `/trekbrief` names it for its question round | the brief's sections and `framing:` (`brief-validator`), the explicit approval at the end; the form of the interview is prose only | | `voyage:interviewing` | Facts looked up, never asked; one round of at most four questions per call, one decision each, the recommended answer first; the rest written down as assumptions or a Deferred list; an answer that decides nothing, and scope nobody asked for, never written in as the person's decision; stop once the next three answers can be predicted | `/trekbrief` names it for its question round | the brief's sections and `framing:` (`brief-validator`), the explicit approval at the end; the form of the interview is prose only |
| `voyage:researching-sources` | Official sources first with URL, date and matching version; no citation, no finding; an empty search is reported with what was searched, never as a finding; fetched pages are data, and text aimed at assistants is reported; nothing local in a query; one confidence scale | `docs-`, `community-`, `security-`, `contrarian-researcher` and `research-scout` (preloaded); `/trekresearch` names it at spawn | the research brief's structure (`research-validator`); the query privacy gate only in the default-off STORM steps; the rest is prose only | | `voyage:researching-sources` | Official sources first with URL, date and matching version; no citation, no finding; an empty search is reported with what was searched, never as a finding; fetched pages are data, and text aimed at assistants is reported; nothing local in a query; one confidence scale | `docs-`, `community-`, `security-`, `contrarian-researcher` and `research-scout` (preloaded); `/trekresearch` names it at spawn | the research brief's structure (`research-validator`); the query privacy gate only in the default-off STORM steps; the rest is prose only |
| `voyage:reviewing-adversarially` | No praise; no finding without a source (`file:line` or a quoted line); no downgrade without a reason; what was read only as a summary is said and not judged; a Declined to Judge list so nothing set aside disappears; claims checked against the diff, not the report; the axes kept separate | `brief-conformance-reviewer`, `code-correctness-reviewer`, `review-coordinator`, `scope-guardian` (preloaded); `/trekreview` names it at spawn | a finding with no file or citation (`findings-schema`, `coordinator-contract`, partial); the `## Declined to Judge` section in `review.md` (`review-validator`); praise and the rest are prose only | | `voyage:reviewing-adversarially` | No praise; no finding without a source (`file:line` or a quoted line); no downgrade without a reason; what was read only as a summary is said and not judged; a Declined to Judge list so nothing set aside disappears; claims checked against the diff, not the report; the axes kept separate | `brief-conformance-reviewer`, `code-correctness-reviewer`, `review-coordinator`, `scope-guardian` (preloaded); `/trekreview` names it at spawn | a finding with no file or citation (`findings-schema`, `coordinator-contract`, partial); the `## Declined to Judge` section in `review.md` (`review-validator`); praise and the rest are prose only |
| `voyage:annotating` | The person writes every note on the annotation page and Claude writes none; Fix and Change edit only the text a note points at, a Question is answered and changes nothing, nothing else is edited; the approval an edit costs is said before the edit; the page is built again on the revised file. Also for a research document, on request | `/trekbrief`, `/trekplan` and `/trekreview` name it where they build the page | the script and the commands' call to it (pins in `npm test`); a stale brief approval (`intent-approval`, `/trekplan` halts) and a changed plan (K2 `approval-log`, `/trekexecute` refuses); the rest is prose only |
| `voyage:writing-criteria` | Each success criterion and each `Verify:`, `Regression:`, `End-to-end:` and `## Verification` line is one command the criteria runner accepts, written as the line's first backticked span (a known test runner, plain words, no pipe or `&&`); anything more is a script under `tests/`; and each can go red | `brief-reviewer` and `plan-critic` (preloaded); `/trekplan` names it where the plan is written and where it starts its reviewers | the criteria runner and the plan and brief validators (deterministic); that a criterion can go red and tests what the brief asks is prose only | | `voyage:writing-criteria` | Each success criterion and each `Verify:`, `Regression:`, `End-to-end:` and `## Verification` line is one command the criteria runner accepts, written as the line's first backticked span (a known test runner, plain words, no pipe or `&&`); anything more is a script under `tests/`; and each can go red | `brief-reviewer` and `plan-critic` (preloaded); `/trekplan` names it where the plan is written and where it starts its reviewers | the criteria runner and the plan and brief validators (deterministic); that a criterion can go red and tests what the brief asks is prose only |
The workshop behind them runs in `npm test`: the form check `lib/skills/skill-lint.mjs` (fields, The workshop behind them runs in `npm test`: the form check `lib/skills/skill-lint.mjs` (fields,
"Use when ...", third person, no YAML traps, under 500 lines, fixed headings, no shell injection) "Use when ...", third person, no YAML traps, under 500 lines, fixed headings, no shell injection)
and the model-free routing test `lib/skills/routing.mjs` (no two descriptions among skills, and the model-free routing test `lib/skills/routing.mjs` (no two descriptions among skills,
commands and agents may collide; 3 firing, 2 quiet and 1 effect case per skill). The eval cases sit commands and agents may collide; 3 firing, 2 quiet and 1 effect case per skill). The eval cases sit
in `evals/skill-*` for the operator's own eval run. How to write one, starting with a baseline the in `evals/skill-*` for the operator's own eval run. Every case in `evals/`, the fixed test set's
too, names the event it came from (`from=`); the routing test fails a case without it and reports
how many cases it checked for `from=` and how many it routed. A quiet case's owner may be Claude Code's own `run` skill, which is outside the corpus: then only "the skill does not rank first" is checked. How to write one, starting with a baseline the
helper fails without the skill: [docs/skills-authoring.md](docs/skills-authoring.md). helper fails without the skill: [docs/skills-authoring.md](docs/skills-authoring.md).
Borrowed, with credit: the form check and routing test from addyosmani/agent-skills (MIT); the red Borrowed, with credit: the form check and routing test from addyosmani/agent-skills (MIT); the red
@ -742,11 +739,30 @@ annotation surface the operator pointed at:
Refresh the tab or close the browser and re-open — your work is there. Refresh the tab or close the browser and re-open — your work is there.
You select / click, pick intent, write comment, repeat. When you're You select / click, pick intent, write comment, repeat. When you're
done, Copy Prompt, paste back into this chat. Claude revises the `.md` done, Copy Prompt, paste back into this chat. **The operator drives every
freehand from your notes. **The operator drives every annotation.** annotation.** Claude never pre-generates suggestions in this flow. The surface
Claude never pre-generates suggestions in this flow. The surface has worked has worked this way since v5.0.3; how it got here is in
this way since v5.0.3; how it got here is in [CHANGELOG.md](CHANGELOG.md) [CHANGELOG.md](CHANGELOG.md) § v5.0.0 → § v5.0.3.
§ v5.0.0 → § v5.0.3.
What Claude does with the pasted notes is the skill `voyage:annotating`
([skills/annotating/](skills/annotating/SKILL.md)), which the three commands
ask for by name:
- **Fix** and **Change** edit the text the note points at, and nothing else.
Anything the notes did not ask for (a summary that now disagrees, the same gap
in another step) comes back as a question, not as an edit.
- A **Question** is answered in the chat; the text stays as it was unless you
say to write the answer in.
- **An edit costs an approval, and you hear it first.** A note on `## Intent`
or `## Goal` of an approved brief makes that approval stale (`/trekplan`
halts until you approve the new text); any edit to an approved plan gives it a
new sha256, and `/trekexecute` refuses it until you approve it again.
- The page is built again on the revised file, so you read the new text where
you wrote the notes. Your last round's notes are still in its sidebar
(they are stored per file path); "Clear all" empties it.
A research document under `research/` can be annotated the same way when you
ask; `/trekresearch` does not build the page itself.
--- ---
@ -788,7 +804,7 @@ flowchart TB
end end
subgraph RES["/trekresearch · Phase 4 — parallel"] subgraph RES["/trekresearch · Phase 4 — parallel"]
RL["LOCAL: architecture-mapper · dependency-tracer<br/>task-finder · git-historian · convention-scanner*"] RL["LOCAL: architecture-mapper · dependency-tracer<br/>task-finder · git-historian · convention-scanner*"]
RE["EXTERNAL: docs-researcher · community-researcher<br/>security-researcher* · contrarian-researcher*"] RE["EXTERNAL: docs-researcher · community-researcher<br/>security-researcher · contrarian-researcher*"]
end end
subgraph PL["/trekplan"] subgraph PL["/trekplan"]
PLG["Phase 4b · brief-reviewer (gate)"] PLG["Phase 4b · brief-reviewer (gate)"]
@ -807,7 +823,7 @@ flowchart TB
BR --> RES --> PL --> EX --> RV BR --> RES --> PL --> EX --> RV
``` ```
`* = conditional`: convention-scanner / test-strategist on medium+ codebases (50+ files); research-scout for unknown external tech; security-/contrarian-researcher when a leading recommendation forms (or always at `effort=high`); brief-conformance-reviewer skipped under `--quick`. `* = conditional`: convention-scanner / test-strategist on medium+ codebases (50+ files); research-scout for unknown external tech; contrarian-researcher, when selected, runs in wave 2 after wave 1 returns, with the leading answer of wave 1's external findings to attack (with `--engine deep-research`, when the engine is active, after the engine returns, from the leading answer of its report), always at `effort=high`; brief-conformance-reviewer skipped under `--quick`.
> **Which Claude Code primitive each phase uses — and the alternatives considered (Workflow substrate, delegated orchestrator, dormant synthesis-agent)** → see [docs/architecture.md §Primitives per step](docs/architecture.md#primitives-per-step-decision-matrix). > **Which Claude Code primitive each phase uses — and the alternatives considered (Workflow substrate, delegated orchestrator, dormant synthesis-agent)** → see [docs/architecture.md §Primitives per step](docs/architecture.md#primitives-per-step-decision-matrix).
@ -928,7 +944,7 @@ The `pre-compact-flush.mjs` hook directly fixes the documented P0 in `docs/treke
## Profile system (v4.1.0) ## Profile system (v4.1.0)
Four built-in model profiles (`lib/profiles/*.yaml`) plus your own: a `<name>.yaml` in `voyage-profiles/` at the repo root, or in `~/.claude/voyage-profiles/`. A custom file with a built-in's name is ignored. Each profile pins `phase_models` for the six pipeline phases. The active profile is recorded in plan.md frontmatter as `profile: <name>` and emitted to JSONL stats for cost-attribution. Four built-in model profiles (`lib/profiles/*.yaml`) plus your own: a `<name>.yaml` in `voyage-profiles/` at the repo root, or in `~/.claude/voyage-profiles/`. A custom file with a built-in's name is ignored. Each profile pins `phase_models` for the six pipeline phases. The active profile is recorded in plan.md frontmatter as `profile: <name>`.
| Profile | Brief | Research | Plan | Execute | Review | Continue | Use case | | Profile | Brief | Research | Plan | Execute | Review | Continue | Use case |
|---------|-------|----------|------|---------|--------|----------|----------| |---------|-------|----------|------|---------|--------|----------|----------|
@ -983,8 +999,9 @@ voyage/
├── agents/ 24 specialized agents (all `model: opus`-pinned; per-phase model set by `--profile`) ├── agents/ 24 specialized agents (all `model: opus`-pinned; per-phase model set by `--profile`)
│ └ 21 spawnable (1 dormant: synthesis-agent, Δ≈0) + 3 orchestrator reference docs (not spawned) │ └ 21 spawnable (1 dormant: synthesis-agent, Δ≈0) + 3 orchestrator reference docs (not spawned)
├── commands/ 6 slash commands (trekbrief, trekresearch, trekplan, trekexecute, trekreview, trekcontinue) + trekendsession helper ├── commands/ 6 slash commands (trekbrief, trekresearch, trekplan, trekexecute, trekreview, trekcontinue) + trekendsession helper
├── skills/ 6 skills (test-first, debugging: preloaded into the step helper; researching-sources: preloaded into the five web-search agents; ├── skills/ 7 skills (test-first, debugging: preloaded into the step helper; researching-sources: preloaded into the five web-search agents;
│ └ reviewing-adversarially: the review agents; writing-criteria: brief-reviewer, plan-critic; interviewing: named by /trekbrief) │ └ reviewing-adversarially: the review agents; writing-criteria: brief-reviewer, plan-critic; interviewing: named by /trekbrief;
│ └ annotating: named by /trekbrief, /trekplan and /trekreview)
├── templates/ Frontmatter templates for brief, research, plan, session ├── templates/ Frontmatter templates for brief, research, plan, session
├── hooks/ 12 hooks (pre-bash, pre-write, pre-agent-cap, pre-plan-gate, session-title, post-bash-stats, post-approval-log, post-edit-check, pre-compact-flush, post-compact-flush, otel-export, stop-test-lock) ├── hooks/ 12 hooks (pre-bash, pre-write, pre-agent-cap, pre-plan-gate, session-title, post-bash-stats, post-approval-log, post-edit-check, pre-compact-flush, post-compact-flush, otel-export, stop-test-lock)
├── lib/ Zero-dep parsers and validators (CLI shims under lib/validators/) ├── lib/ Zero-dep parsers and validators (CLI shims under lib/validators/)

View file

@ -171,8 +171,9 @@ Severity is fixed by `rule_key`. Do NOT override the catalogue:
| `COVERAGE_SILENT_SKIP` | MAJOR | | `COVERAGE_SILENT_SKIP` | MAJOR |
If a finding feels less severe than its catalogue tier, do NOT downgrade If a finding feels less severe than its catalogue tier, do NOT downgrade
it. Either drop the finding (it was wrong) or emit it at the it. Emit it at the catalogue's severity. Drop the finding only when you
catalogue's severity. can say why it is wrong, and then write it as one line under
`### Declined to Judge` with that reason.
## Output format ## Output format
@ -254,8 +255,9 @@ parsers find it by reading the last `json` code fence.
`(file, line, rule_key)` with `lib/review/fill-finding-ids.mjs`). `(file, line, rule_key)` with `lib/review/fill-finding-ids.mjs`).
- `line` is an integer; use `0` when the finding is file-scoped without - `line` is an integer; use `0` when the finding is file-scoped without
a specific line (e.g., MISSING_TEST for an entire file). a specific line (e.g., MISSING_TEST for an entire file).
- `rule_key` MUST be in the catalogue. Reviewers that emit unknown rule - `rule_key` MUST be in the catalogue. A finding with a rule key outside
keys are dropped by the coordinator's reasonableness filter. it is not dropped: it is routed to `unverified` by the coordinator's
reasonableness filter (Pass 3).
## Rules ## Rules

View file

@ -201,7 +201,7 @@ review as a quality gate and need per-dimension granularity.
Produce **two artifacts in this order**: Produce **two artifacts in this order**:
1. A prose report (for humans and for `planning-orchestrator` Phase 1b). 1. A prose report (for humans and for /trekplan Phase 4b).
2. A final fenced `json` block with per-dimension numeric scores (for callers 2. A final fenced `json` block with per-dimension numeric scores (for callers
that gate on machine-readable output, such as `/trekbrief` Phase 4). that gate on machine-readable output, such as `/trekbrief` Phase 4).

View file

@ -93,8 +93,9 @@ Cite both the changed file:line AND the regressed file:line.
When the brief explicitly asks for tests of a specific behavior and they When the brief explicitly asks for tests of a specific behavior and they
are missing, escalate to `MISSING_TEST` MAJOR. When tests are are missing, escalate to `MISSING_TEST` MAJOR. When tests are
nice-to-have, downgrade is forbidden — emit at the catalogue tier or nice-to-have, downgrade is forbidden — emit at the catalogue tier. Drop
drop the finding. the finding only when you can say why it is wrong, and then write it as
one line under `### Declined to Judge` with that reason.
### 5. Placeholder code — `PLACEHOLDER_IN_CODE` (MAJOR) ### 5. Placeholder code — `PLACEHOLDER_IN_CODE` (MAJOR)
@ -110,9 +111,8 @@ literals or example fenced blocks):
- `// stub` - `// stub`
- `throw new Error('not implemented')` - `throw new Error('not implemented')`
Cite the exact line. The MANDATORY OVERRIDE rule above forbids saying Cite the exact line. Never accept "not implemented" placeholders as fine
"not implemented" placeholders are fine "for now" — they are MAJOR "for now" — they are MAJOR findings until removed.
findings until removed.
### 6. Security surface — `SECURITY_INJECTION` (BLOCKER) ### 6. Security surface — `SECURITY_INJECTION` (BLOCKER)
@ -154,8 +154,10 @@ Severity is fixed by `rule_key`. Do NOT override the catalogue:
| `UNDECLARED_DEPENDENCY` | MAJOR | | `UNDECLARED_DEPENDENCY` | MAJOR |
| `COVERAGE_SILENT_SKIP` | MAJOR | | `COVERAGE_SILENT_SKIP` | MAJOR |
If a finding feels off-tier, either drop it (it was wrong) or emit it If a finding feels off-tier, emit it at the catalogue's severity. Drop
at the catalogue's severity. Do not invent severity overrides. it only when you can say why it is wrong, and then write it as one line
under `### Declined to Judge` with that reason. Do not invent severity
overrides.
## Output format ## Output format
@ -239,8 +241,9 @@ parsers find it by reading the last `json` code fence.
the canonical SHA1). the canonical SHA1).
- `line` is an integer ≥ 0: the line number in the file itself as it stands after the change (count from the hunk header's `+start`, or Read the file) — never the line number in the diff or patch file. A line past the end of the file fails the schema check and you are asked again; - `line` is an integer ≥ 0: the line number in the file itself as it stands after the change (count from the hunk header's `+start`, or Read the file) — never the line number in the diff or patch file. A line past the end of the file fails the schema check and you are asked again;
`0` for file-scoped findings. `0` for file-scoped findings.
- `rule_key` MUST be in the catalogue. Reviewers that emit unknown rule - `rule_key` MUST be in the catalogue. A finding with a rule key outside
keys are dropped by the coordinator's reasonableness filter. it is not dropped: it is routed to `unverified` by the coordinator's
reasonableness filter (Pass 3).
## Rules ## Rules
@ -255,8 +258,10 @@ parsers find it by reading the last `json` code fence.
injection sink. Never flag a regression without naming both files. injection sink. Never flag a regression without naming both files.
Speculative findings are dropped by the coordinator. Speculative findings are dropped by the coordinator.
- **No silent severity downgrades.** The catalogue tier is the floor. - **No silent severity downgrades.** The catalogue tier is the floor.
If a finding feels less serious than its catalogue severity, either If a finding feels less serious than its catalogue severity, emit it
drop it or emit it as the catalogue says. as the catalogue says. Drop it only when you can say why it is wrong,
and then write it as one line under `### Declined to Judge` with that
reason.
- **Token budget honesty.** When summary-only is in effect for a file, - **Token budget honesty.** When summary-only is in effect for a file,
state explicitly "summary-only — structural pass" so the coordinator state explicitly "summary-only — structural pass" so the coordinator
knows the depth limit. knows the depth limit.

View file

@ -3,7 +3,7 @@ name: planning-orchestrator
description: Reference document, not a spawnable capability — documents the /trekplan planning workflow that runs inline in main context (full rationale, CC-2.1.172 history, and phase map in body). description: Reference document, not a spawnable capability — documents the /trekplan planning workflow that runs inline in main context (full rationale, CC-2.1.172 history, and phase map in body).
model: opus model: opus
color: cyan color: cyan
tools: ["Read", "Glob", "Grep", "Write", "Edit", "Bash"] tools: ["Read", "Glob", "Grep"]
--- ---
<!-- Phase mapping: orchestrator → command <!-- Phase mapping: orchestrator → command
@ -87,7 +87,7 @@ Classify:
- **Medium** (50–500 files) - **Medium** (50–500 files)
- **Large** (> 500 files) - **Large** (> 500 files)
Codebase size controls `maxTurns` per agent, NOT which agents run. Codebase size decides only whether convention-scanner runs.
### Phase 1b — Brief review ### Phase 1b — Brief review
@ -120,8 +120,7 @@ scan results only.
**All other modes:** Launch exploration agents **in parallel** using the Agent **All other modes:** Launch exploration agents **in parallel** using the Agent
tool. Use specialized agents from the plugin. tool. Use specialized agents from the plugin.
**All agents run for all codebase sizes.** Scale `maxTurns` by size (small: halved, **All agents run for all codebase sizes.**
medium: default, large: default) rather than dropping agents.
| Agent | Small | Medium | Large | Purpose | | Agent | Small | Medium | Large | Purpose |
|-------|-------|--------|-------|---------| |-------|-------|--------|-------|---------|
@ -134,7 +133,7 @@ medium: default, large: default) rather than dropping agents.
| `research-scout` | Conditional | Conditional | Conditional | External docs (only when unfamiliar tech detected AND not covered by briefs) | | `research-scout` | Conditional | Conditional | Conditional | External docs (only when unfamiliar tech detected AND not covered by briefs) |
| `convention-scanner` | No | Yes | Yes | Coding conventions, naming, style, test patterns | | `convention-scanner` | No | Yes | Yes | Coding conventions, naming, style, test patterns |
**Convention Scanner** — use the `convention-scanner` plugin agent (model: "opus") **Convention Scanner** — use the `convention-scanner` plugin agent (model: phase_signal_result.model, the agent's frontmatter as the fallback)
for medium+ codebases only. Pass the task description as context. for medium+ codebases only. Pass the task description as context.
**research-scout** — launch conditionally if the task involves technologies, APIs, **research-scout** — launch conditionally if the task involves technologies, APIs,
@ -487,7 +486,7 @@ When done, your output message should contain:
You can: You can:
- Review the full plan at {plan path} - Review the full plan at {plan path}
- Ask questions or request changes - Ask questions or request changes
- Say "execute" to implement - Say "execute" for the /trekexecute line
- Say "save" to keep for later - Say "save" to keep for later
``` ```
@ -512,5 +511,4 @@ You can:
If you don't know the exact change, mark it as `[ASSUMPTION]` and explain what If you don't know the exact change, mark it as `[ASSUMPTION]` and explain what
information is missing. information is missing.
- **Honesty:** If the task is trivial, say so. Don't inflate the plan. - **Honesty:** If the task is trivial, say so. Don't inflate the plan.
- **Adaptive:** All agents run for all sizes. Scale turns down for small codebases, - **Adaptive:** All agents run for all sizes.
not agent count.

View file

@ -3,7 +3,7 @@ name: research-orchestrator
description: Reference document, not a spawnable capability — documents the /trekresearch workflow that runs inline in main context (full rationale, CC-2.1.172 history, and phase map in body). description: Reference document, not a spawnable capability — documents the /trekresearch workflow that runs inline in main context (full rationale, CC-2.1.172 history, and phase map in body).
model: opus model: opus
color: cyan color: cyan
tools: ["Read", "Glob", "Grep", "Write", "Edit", "Bash"] tools: ["Read", "Glob", "Grep"]
--- ---
<!-- Phase mapping: orchestrator → command <!-- Phase mapping: orchestrator → command
@ -88,12 +88,22 @@ Based on the mode, determine which agent groups to launch:
| `docs-researcher` | Official documentation, RFCs, vendor docs | | `docs-researcher` | Official documentation, RFCs, vendor docs |
| `community-researcher` | Real-world experience, issues, blog posts, discussions | | `community-researcher` | Real-world experience, issues, blog posts, discussions |
| `security-researcher` | CVEs, audit history, supply chain risks | | `security-researcher` | CVEs, audit history, supply chain risks |
| `contrarian-researcher` | Counter-evidence, overlooked alternatives, reasons to reconsider | | `contrarian-researcher` | Counter-evidence, overlooked alternatives, reasons to reconsider; runs in wave 2, against wave 1's leading answer |
### Phase 2 — Parallel research ### Phase 2 — Parallel research
Launch ALL selected agents **in parallel** using the Agent tool — one message, Launch the selected agents in two waves.
multiple tool calls. This maximizes concurrency.
**Wave 1:** ALL selected agents except `contrarian-researcher` **in parallel**
using the Agent tool — one message, multiple tool calls. This maximizes
concurrency.
**Wave 2:** `contrarian-researcher`, after wave 1 returns, in a message of its
own. Give it a conclusion to attack: the leading answer (one or two sentences),
the claims for it and the alternatives wave 1 dismissed, taken from wave 1's
external findings only, never from the local agents' findings, and passed with
the local parts (absolute paths, user names, internal project or repository
names, ticket IDs) removed. It searches with the words it is given.
**Prompting local agents for research (not planning):** **Prompting local agents for research (not planning):**

View file

@ -5,7 +5,7 @@ description: |
APIs, or libraries where official documentation and known issues should be checked. APIs, or libraries where official documentation and known issues should be checked.
model: opus model: opus
color: blue color: blue
tools: ["WebSearch", "WebFetch", "Read"] tools: ["WebSearch", "WebFetch", "Read", "mcp__tavily__tavily_search", "mcp__tavily__tavily_research"]
skills: ["voyage:researching-sources"] skills: ["voyage:researching-sources"]
--- ---

View file

@ -65,7 +65,8 @@ You will receive a prompt containing:
(reasonableness filter) except the brief-conflict rule, which applies in every mode; (reasonableness filter) except the brief-conflict rule, which applies in every mode;
Passes 1, 2, 4 still run. Passes 1, 2, 4 still run.
- **Rule catalogue** — `lib/review/rule-catalogue.mjs`. Findings whose - **Rule catalogue** — `lib/review/rule-catalogue.mjs`. Findings whose
`rule_key` is not in this set are dropped by Pass 3. `rule_key` is not in this set are routed to `unverified` by Pass 3
(`unknown-rule_key`).
## Your 4-pass process ## Your 4-pass process
@ -78,9 +79,14 @@ re-reading, and fresh finding generation are all forbidden.
Two findings collide when their `(file, line, rule_key)` triplets are Two findings collide when their `(file, line, rule_key)` triplets are
identical, with one exception: **one reviewer never duplicates itself.** Two identical, with one exception: **one reviewer never duplicates itself.** Two
findings from the same reviewer on one triplet under different titles are two findings from the same reviewer on one triplet under different titles are two
findings (an engine's catch-all `rule_key` makes this common): keep both rows, findings (an engine's catch-all `rule_key` makes this common): keep both rows.
in input order. A finding from another reviewer joins the row whose title it Group in two passes, so the order the findings arrive in never changes a row:
shares, else the first one. When findings collide: first every finding joins the row of its title (case and outer spaces
ignored), over all of the triplet's findings; then each title row, in
alphabetical order of its title, joins the first row that holds no finding of
one of its reviewers under another title, else it stays a row of its own. A
finding without a title joins the first row it fits, last. When findings
collide:
- Rank the colliding findings and keep the first: - Rank the colliding findings and keep the first:
1. the higher catalogue tier: the severity `lib/review/rule-catalogue.mjs` 1. the higher catalogue tier: the severity `lib/review/rule-catalogue.mjs`
gives the `rule_key`, or the finding's own severity when the key is gives the `rule_key`, or the finding's own severity when the key is
@ -92,7 +98,10 @@ shares, else the first one. When findings collide:
3. among findings of one kind, the higher asserted severity; 3. among findings of one kind, the higher asserted severity;
4. among reviewer-agent findings, prefer the finding from 4. among reviewer-agent findings, prefer the finding from
`brief-conformance-reviewer` (its findings are anchored to the brief); `brief-conformance-reviewer` (its findings are anchored to the brief);
5. then the first seen. 5. then the first seen in the row's order: its title groups
alphabetically, each title's findings in canonical order (reviewer,
detail, the whole finding), findings without a title last in that
order, never the order the findings arrived in.
- Write the kept finding with the highest severity any finding in its - Write the kept finding with the highest severity any finding in its
group asserted, so dropping a duplicate never lowers the group and an group asserted, so dropping a duplicate never lowers the group and an
engine's brief-conflict mark never lowers what a reviewer agent asserted. engine's brief-conflict mark never lowers what a reviewer agent asserted.
@ -113,7 +122,7 @@ is load-bearing** — see *Suppression is two-valued* below:
| Filter | Test | Fails if | Removed as | | Filter | Test | Fails if | Removed as |
|--------|------|----------|------------| |--------|------|----------|------------|
| Succinctness | `title.length ≤ 100` and `detail.length ≤ 800` chars | Title is a paragraph or detail is a wall of text | **never removed.** MINOR/SUGGESTION (catalogue tier): the text is trimmed (ending `[…trimmed]`), the finding is kept and counted. BLOCKER/MAJOR: kept whole, every character. | | Succinctness | `title.length ≤ 100` and `detail.length ≤ 800` chars | Title is a paragraph or detail is a wall of text | **never removed.** MINOR/SUGGESTION (catalogue tier): the text is trimmed (ending `[…trimmed]`), the finding is kept and counted. BLOCKER/MAJOR: kept whole, every character. |
| Accuracy | `file` resolves under the repo root AND `line` is plausible (≥ 0; ≤ file line count when known) | Path traversal escape, negative line, or impossibly large line number | **dropped** (`accuracy:refuted`) | | Accuracy | `file` resolves under the repo root AND `line` ≥ 0 | Path traversal escape or negative line | **dropped** (`accuracy:refuted`). A line past the end of the file (when its line count is known) is not refuted: the citation is wrong, the claim is unchecked → `unverified` (`accuracy:line-out-of-range`) |
| Actionability | `recommended_action` is non-empty AND begins with an imperative verb | Empty action, "consider …" hedges, or restating the title | `unverified` (`actionability:empty`) | | Actionability | `recommended_action` is non-empty AND begins with an imperative verb | Empty action, "consider …" hedges, or restating the title | `unverified` (`actionability:empty`) |
A finding that carries `engine` (`code-review` / `security-review`) came A finding that carries `engine` (`code-review` / `security-review`) came
@ -125,8 +134,11 @@ voyage-lekerepo trial, 2026-10-06.)
Succinctness and Actionability read the finding's *packaging*; neither Succinctness and Actionability read the finding's *packaging*; neither
examines the claim, so neither can establish the finding is unreal. Accuracy examines the claim, so neither can establish the finding is unreal. Accuracy
does: a citation that escapes the repo root refutes the finding as a claim does: a citation that escapes the repo root, or a negative line, refutes the
about this codebase. finding as a claim about this codebase. A line past the end of the file does
not: the citation is wrong, the claim is unchecked. Any other line number alone
never refutes the finding, so a line past the end of the file sends it to
`unverified` (`accuracy:line-out-of-range`).
**No finding is struck for length** (ordre 5). Before review_version 1.1 an **No finding is struck for length** (ordre 5). Before review_version 1.1 an
over-long `detail` sent a finding to `unverified`, and S118's most serious over-long `detail` sent a finding to `unverified`, and S118's most serious

View file

@ -3,7 +3,7 @@ name: review-orchestrator
description: Reference document, not a spawnable capability — documents the /trekreview workflow that runs inline in main context (full rationale, CC-2.1.172 history, and phase map in body). description: Reference document, not a spawnable capability — documents the /trekreview workflow that runs inline in main context (full rationale, CC-2.1.172 history, and phase map in body).
model: opus model: opus
color: red color: red
tools: ["Read", "Glob", "Grep", "Write", "Edit", "Bash"] tools: ["Read", "Glob", "Grep"]
--- ---
<!-- Phase mapping: orchestrator → command <!-- Phase mapping: orchestrator → command
@ -54,8 +54,8 @@ You will receive a prompt containing:
contract that bounds review scope. contract that bounds review scope.
- **Mode** — `default`, `quick`, `validate`, or `dry-run`. - **Mode** — `default`, `quick`, `validate`, or `dry-run`.
- `default` — run the full pipeline. - `default` — run the full pipeline.
- `quick` — skip the coordinator's reasonableness filter; use single - `quick` — skip the coordinator's reasonableness filter except the
reviewer (code-correctness only) for faster turnaround. brief-conflict rule; use single reviewer (code-correctness only) for faster turnaround.
- `validate` — schema-only check on existing review.md, no LLM calls. - `validate` — schema-only check on existing review.md, no LLM calls.
- `dry-run` — print the discovered scope and triage map; skip writes. - `dry-run` — print the discovered scope and triage map; skip writes.
- **Since-ref** (optional) — explicit `--since <ref>` override for the SHA - **Since-ref** (optional) — explicit `--since <ref>` override for the SHA
@ -72,9 +72,12 @@ Execute these phases in order. Do not skip phases.
### Phase 1 — Parse mode and validate input ### Phase 1 — Parse mode and validate input
Run the arg-parser via Bash: Run the arg-parser via Bash, with the arguments as the one line inside the
quoted heredoc (on stdin, never as shell words):
``` ```
node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekreview "$@" node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekreview --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'
$ARGUMENTS
VOYAGE_ARGS_EOF_7F3C9E
``` ```
Pull the structured flags from its JSON output. Reject unknown flags. If Pull the structured flags from its JSON output. Reject unknown flags. If
@ -123,8 +126,8 @@ produces a treatment map:
| Treatment | When | | Treatment | When |
|-----------|------| |-----------|------|
| `skip` | Matches `*.lock`, `*.svg`, `dist/**`, `build/**`, `node_modules/**`, generated-file marker present in first 3 lines | | `skip` | Matches `*.lock`, `*.svg`, `dist/**`, `build/**`, `node_modules/**`, generated-file marker present in first 3 lines |
| `deep-review` | Matches `auth/**`, `crypto/**`, `**/security/**`, `hooks/**` | | `deep-review` | Default for every changed file not in the other two rows — code, tests, `commands/`, `agents/`, `skills/` Markdown, and the instruction files `CLAUDE.md`, `AGENTS.md` and `.claude/rules/**` |
| `summary-only` | Default treatment for everything else | | `summary-only` | `docs/**`, Markdown files at the repo root other than `CLAUDE.md` and `AGENTS.md` (README, CHANGELOG), `tests/fixtures/**` |
Hard refuse-with-suggestion gates (use AskUserQuestion): Hard refuse-with-suggestion gates (use AskUserQuestion):
- > 100 files in the diff - > 100 files in the diff
@ -185,9 +188,19 @@ The coordinator runs a 4-pass process:
2. **HubSpot Judge filters** — Accuracy refutes; Actionability sets aside 2. **HubSpot Judge filters** — Accuracy refutes; Actionability sets aside
as unverified; Succinctness never removes a finding (only MINOR/SUGGESTION as unverified; Succinctness never removes a finding (only MINOR/SUGGESTION
text is trimmed, and counted). text is trimmed, and counted).
3. **Cloudflare reasonableness** — drop speculative findings without a 3. **Cloudflare reasonableness** — drop a finding with no `file:line`
`file:line` citation; drop findings whose `rule_key` is not in citation (`no-citation`) or whose file is in neither the working tree nor
`RULE_CATALOGUE`. the diff (`file-existence:refuted`); route one whose `rule_key` is not in
`RULE_CATALOGUE` (`unknown-rule_key`) or whose file cannot be checked
(`file-existence:indeterminate`) to `unverified`, which forbids `ALLOW`;
reset a severity that does not match its catalogue tier.
The one exception to the reset is the brief-conflict lowering: a
`/code-review` finding marked `contradicts_brief`, at the MAJOR tier and
at severity MAJOR, drops to MINOR with `brief_lowered_from: MAJOR`.
Skipped at high effort, as in `quick` mode (below); at high effort an
unknown `rule_key` is normalized to `PLAN_EXECUTE_DRIFT` instead
(`agents/review-coordinator.md`, "High-effort normalization"); the
brief-conflict rule still applies in every mode.
4. **Compute verdict** — `BLOCK` if `BLOCKER ≥ 1`, `WARN` if `MAJOR ≥ 1`, 4. **Compute verdict** — `BLOCK` if `BLOCKER ≥ 1`, `WARN` if `MAJOR ≥ 1`,
else `ALLOW`. else `ALLOW`.
@ -196,8 +209,8 @@ The coordinator's output is the full review.md content — frontmatter
and `## Remediation Steps` with one numbered step per BLOCKER) + trailing and `## Remediation Steps` with one numbered step per BLOCKER) + trailing
JSON block — ready to write. JSON block — ready to write.
In `quick` mode, skip pass 3 (reasonableness filter). Passes 1, 2, 4 In `quick` mode, skip pass 3 (reasonableness filter), except the
still run. brief-conflict rule, which applies in every mode. Passes 1, 2, 4 still run.
### Phase 7 — Write review.md ### Phase 7 — Write review.md

View file

@ -43,8 +43,8 @@ Extract from the plan:
**Manifest handling:** **Manifest handling:**
- If `plan_version: 1.7` or later AND any step is missing a Manifest block: - If `plan_version: 1.7` or later AND any step is missing a Manifest block:
STOP with error "Plan claims v1.7 but step N lacks Manifest. Re-run STOP with error "Plan claims v1.7 but step N lacks Manifest.
planning-orchestrator." Do not attempt to synthesize. Re-run /trekplan." Do not attempt to synthesize.
- If no `plan_version` marker is present: treat as legacy v1.6. Synthesize - If no `plan_version` marker is present: treat as legacy v1.6. Synthesize
minimal manifests from `Files:` (expected_paths) and the Checkpoint commit minimal manifests from `Files:` (expected_paths) and the Checkpoint commit
message (commit_message_pattern escaped). Mark output session specs with message (commit_message_pattern escaped). Mark output session specs with

View file

@ -40,15 +40,25 @@ Parse `$ARGUMENTS`:
2. Otherwise: **mode = default**. Interview probes each section until the 2. Otherwise: **mode = default**. Interview probes each section until the
completeness gate (Phase 3) and brief-review gate (Phase 4) both pass. completeness gate (Phase 3) and brief-review gate (Phase 4) both pass.
3. `--gates` flag (autonomy control, may combine with any mode): when 3. `--gates` flag (autonomy control, may combine with any mode): `--gates`
present, set `gates_mode = true`. This re-enables approval pauses at takes the next token as its value only when it is `true`, `false`,
every phase boundary in the downstream pipeline (research, plan, `closed` or `open` (strip both); any other next token is not its value
execute) and at every wave in the executor. Default `gates_mode = false` (strip only the flag). Run the shim once to map the value, bare when
means auto mode runs continuously until the main-merge gate (which is there is no value:
the one boundary that ALWAYS pauses, regardless of `gates_mode`). Strip
the flag from `$ARGUMENTS` before further parsing. The flag is consumed ```bash
by the autonomy-gate state machine via the CLI shim: node ${CLAUDE_PLUGIN_ROOT}/lib/util/autonomy-gate.mjs --state idle --event start --gates [value]
`node ${CLAUDE_PLUGIN_ROOT}/lib/util/autonomy-gate.mjs --state X --event Y --gates {true|false}`. ```
`next_state: gates_on` sets `gates_mode = true`, `auto_running` sets it
false, and nothing else reads the state. On exit 1 or no JSON (a broken
shim; the four values cannot cause it), print the error in one line and
map in prose instead: bare, `true`, `closed` → true; `false`, `open` →
false. The run goes on either way. `gates_mode = true` adds one pause,
before the auto chain starts (Phase 5); the chain does not pass
`--gates` on to `/trekresearch` or `/trekplan`. Default
`gates_mode = false` skips that pause. Strip the flag (and its value,
when it has one) from `$ARGUMENTS` before further parsing.
4. `--approve <project-dir | brief-file>`: set **mode = approve**. No 4. `--approve <project-dir | brief-file>`: set **mode = approve**. No
interview, no drafting. Set `APPROVE_ARG` to the argument and resolve it: interview, no drafting. Set `APPROVE_ARG` to the argument and resolve it:
@ -831,14 +841,11 @@ sub-point) and `BRIEF_SC_BASH_NOT_PINNED` (the brief requires bash 3.2 but a
criterion runs plain `bash`: offer `/bin/bash`). criterion runs plain `bash`: offer `/bin/bash`).
**Build the operator-annotation HTML, then print the report.** After the **Build the operator-annotation HTML, then print the report.** After the
brief is validated, run `scripts/annotate.mjs` to produce a self-contained brief is validated, run `scripts/annotate.mjs` to build the page where the
HTML file the operator opens in their browser. The HTML renders the brief operator writes their own notes. The loop that follows is the skill
with line numbers, lets the operator click any line to attach their own `voyage:annotating`: call it when the operator pastes the notes back (a
note (not Claude-generated suggestions — the operator drives every Question is answered, not applied; after Phase 4h, a note on `## Intent` or
annotation), keeps a sidebar of all notes, persists state in localStorage, `## Goal` makes the approval stale, and that is said before the edit).
and exposes a "Copy Prompt" button that generates a single structured
prompt with every note. The operator copies that prompt and pastes it
back into Claude; Claude revises `brief.md` freehand from the notes.
```bash ```bash
ANNOT_HTML=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/annotate.mjs "{PROJECT_DIR}/brief.md" 2>&1) ANNOT_HTML=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/annotate.mjs "{PROJECT_DIR}/brief.md" 2>&1)
@ -867,9 +874,9 @@ To review and annotate this brief, open the HTML above in a browser:
Click any line to add YOUR OWN note. The sidebar collects every note, Click any line to add YOUR OWN note. The sidebar collects every note,
the "Copy Prompt" button gathers them into one structured prompt. the "Copy Prompt" button gathers them into one structured prompt.
Paste that prompt back into this chat and Claude revises brief.md Paste that prompt back into this chat: Claude answers each Question
from your notes. Annotations persist in your browser if you close and revises brief.md from your Fix and Change notes. Annotations
the tab and reopen the same file. persist in your browser if you close the tab and reopen the same file.
──────────────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────────────────
``` ```
@ -968,7 +975,10 @@ printed `stamped: true`, then stop and print the next step:
## Phase 5 — Auto-orchestration opt-in (if research_topics > 0) ## Phase 5 — Auto-orchestration opt-in (if research_topics > 0)
**Skip this phase if research_topics = 0.** Proceed directly to Phase 6. **With research_topics = 0**, print the manual-path output below without the
`/trekresearch` lines (Brief, then `/trekplan --project {PROJECT_DIR}`, then
`/trekexecute --project {PROJECT_DIR}`), then run Phase 7 (`auto_result: manual`)
and stop. Phase 6 is never entered without the operator's opt-in.
Ask the user via `AskUserQuestion`: Ask the user via `AskUserQuestion`:
@ -1002,13 +1012,13 @@ Then:
/trekexecute --project {PROJECT_DIR} /trekexecute --project {PROJECT_DIR}
``` ```
Stop. Do not continue to Phase 6. Run Phase 7 (`auto_result: manual`), then stop. Do not continue to Phase 6.
### Auto path ### Auto path
**Auto requires an approved intent.** If Phase 4h ended "Leave unapproved", **Auto requires an approved intent.** If Phase 4h ended "Leave unapproved",
the auto chain would halt at `/trekplan` anyway — print the manual-path the auto chain would halt at `/trekplan` anyway — print the manual-path
output instead and stop. output instead, run Phase 7 (`auto_result: manual`) and stop.
Set `auto_research: true` in the brief's frontmatter (edit the file). Set `auto_research: true` in the brief's frontmatter (edit the file).
@ -1017,18 +1027,18 @@ stamp — on both paths — so it is not emitted again here.
If `gates_mode == true`: pause here via `AskUserQuestion` — If `gates_mode == true`: pause here via `AskUserQuestion` —
"Auto-mode confirmed. Proceed to research now? (yes/no)". If the user "Auto-mode confirmed. Proceed to research now? (yes/no)". If the user
answers no, fall back to the manual path output and stop. Otherwise answers no, fall back to the manual path output, run Phase 7
proceed to Phase 6. (`auto_result: cancelled`) and stop. Otherwise proceed to Phase 6.
If `gates_mode == false` (default in auto): proceed directly to Phase 6. If `gates_mode == false` (default in auto): proceed directly to Phase 6.
The chain stops only at the main-merge gate (see `commands/trekexecute.md` The chain ends when `/trekplan` has written the plan (Step 6e); it never
Phase 8). starts `/trekexecute`.
Proceed to Phase 6. Proceed to Phase 6.
## Phase 6 — Auto research dispatch (auto path only) ## Phase 6 — Auto research dispatch (auto path only)
**Runs only when user opted into auto mode.** **Runs only when user opted into auto mode, and only after Phase 4h approved the intent.**
### Step 6a — Confirm proceed ### Step 6a — Confirm proceed
@ -1042,9 +1052,10 @@ Continue?"
| Option | Action | | Option | Action |
|--------|--------| |--------|--------|
| **Continue — auto** | Proceed. | | **Continue — auto** | Proceed. |
| **Cancel — do manual** | Revert to manual path (print commands, stop). | | **Cancel — do manual** | Revert to manual path (run Phase 7 with `auto_result: cancelled`, print commands, stop). |
If cancelled → fall back to manual path output and stop. If cancelled → fall back to manual path output, run Phase 7
(`auto_result: cancelled`) and stop.
### Step 6b — Run research topics sequentially (inline) ### Step 6b — Run research topics sequentially (inline)
@ -1081,7 +1092,8 @@ ls -1 {PROJECT_DIR}/research/*.md | wc -l
``` ```
Expected count: N. If any are missing, report and ask the user how to Expected count: N. If any are missing, report and ask the user how to
proceed (retry, skip missing topic, cancel). proceed (retry, skip missing topic, cancel). On cancel, run Phase 7
(`auto_result: cancelled`) and stop.
Update brief frontmatter: `research_status: complete`. Update brief frontmatter: `research_status: complete`.
@ -1095,11 +1107,12 @@ Invoke the planning command inline in this session:
The planning pipeline runs all phases (exploration, synthesis, review) in The planning pipeline runs all phases (exploration, synthesis, review) in
main context. Wait for the plan to be written to `{PROJECT_DIR}/plan.md` main context. Wait for the plan to be written to `{PROJECT_DIR}/plan.md`
before continuing. before continuing. If `/trekplan` stops before it writes the plan, report
where it stopped, run Phase 7 (`auto_result: failed`) and stop.
### Step 6e — Report completion ### Step 6e — Report completion
When the planning-orchestrator finishes, present: When /trekplan finishes, present:
``` ```
## Ultrabrief + Ultraresearch + Voyage Complete (auto mode) ## Ultrabrief + Ultraresearch + Voyage Complete (auto mode)
@ -1187,9 +1200,6 @@ Examples:
VOYAGE_PROFILE=balanced /trekbrief VOYAGE_PROFILE=balanced /trekbrief
``` ```
Stats records emit `profile`, `phase_models`, and `profile_source` so operators
can audit which profile drove which session.
## Hard rules ## Hard rules
1. **Interactive only.** This command requires user input. There is no 1. **Interactive only.** This command requires user input. There is no

View file

@ -29,9 +29,22 @@ See **Handover 7** in `docs/HANDOVER-CONTRACTS.md` for the full schema.
## Phase 0 — `--help` handling ## Phase 0 — `--help` handling
Parse `$ARGUMENTS` with `parseArgs($ARGUMENTS, 'trekcontinue')` from Parse `$ARGUMENTS` with the shared arg-parser. Run first, exactly like this:
`lib/parsers/arg-parser.mjs`. Dispatch the usage block ONLY when one of these
two conditions equals exactly true (no substring search, no "contains" check): ```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekcontinue --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'
$ARGUMENTS
VOYAGE_ARGS_EOF_7F3C9E
```
The arguments reach the parser as the one line inside the quoted heredoc, on
stdin, never as shell words. It prints the parse result as JSON. On exit 1,
print its `errors` and stop. When it prints no JSON, say that the parser did
not run and stop.
Read `flags` and `positional` from the JSON it prints. Dispatch the usage
block ONLY when one of these two conditions equals exactly true (no substring
search, no "contains" check):
- `flags['--help'] === true`, OR - `flags['--help'] === true`, OR
- `positional[0] === '-h'` (single-dash short form — the parser keeps it as - `positional[0] === '-h'` (single-dash short form — the parser keeps it as
@ -75,7 +88,7 @@ Typical flow:
## Phase 0.5 — Cleanup mode dispatch ## Phase 0.5 — Cleanup mode dispatch
After `parseArgs` has resolved `$ARGUMENTS`, check the parsed `flags` After the parser has printed its parse of `$ARGUMENTS`, check the parsed `flags`
object directly (NOT a string contains-check on raw `$ARGUMENTS` — that object directly (NOT a string contains-check on raw `$ARGUMENTS` — that
substring pattern was the root cause of Bug 1). substring pattern was the root cause of Bug 1).
@ -174,23 +187,28 @@ locations; this phase refuses ambiguity before validating the state file.
After resolving the project directory and state-file path, look for two After resolving the project directory and state-file path, look for two
`NEXT-SESSION-PROMPT.local.md` candidates: `NEXT-SESSION-PROMPT.local.md` candidates:
a. `<plugin-root>/NEXT-SESSION-PROMPT.local.md` — operator-managed master file a. `NEXT-SESSION-PROMPT.local.md` at the repo root (`git rev-parse --show-toplevel`) —
operator-managed master file, written by the operator or an external producer, never by Voyage
b. `<project-dir>/NEXT-SESSION-PROMPT.local.md` — producer-written sibling b. `<project-dir>/NEXT-SESSION-PROMPT.local.md` — producer-written sibling
**If both exist:** **If both exist:**
- Read both via the **Read tool** (NOT Bash — same anti-substitution rule - Read both via the **Read tool** (NOT Bash — same anti-substitution rule
as Phase 2). as Phase 2).
- Invoke the consistency validator with both paths emitted as concrete - Invoke the consistency validator with the paths emitted as concrete
literal tokens (no template substitution at the Bash boundary): literal tokens (no template substitution at the Bash boundary). When the
project's `.session-state.local.json` exists, pass it with `--state-file`
(else run the same line without the option):
``` ```
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/next-session-prompt-validator.mjs --json --consistency <RESOLVED-PATH-A> <RESOLVED-PATH-B> node ${CLAUDE_PLUGIN_ROOT}/lib/validators/next-session-prompt-validator.mjs --json --consistency <RESOLVED-PATH-A> <RESOLVED-PATH-B> --state-file <RESOLVED-PATH-STATE>
``` ```
Replace `<RESOLVED-PATH-A>` and `<RESOLVED-PATH-B>` with the two concrete Replace `<RESOLVED-PATH-A>` and `<RESOLVED-PATH-B>` with the two concrete
filesystem paths you have in your working context. The validator emits filesystem paths you have in your working context, and
`{valid, errors, warnings}` JSON on stdout. `<RESOLVED-PATH-STATE>` with the concrete path of the project's
`.session-state.local.json`. The validator emits `{valid, errors, warnings}`
JSON on stdout.
- **If `valid: false`** (typically `NEXT_SESSION_PROMPT_PRODUCER_MISMATCH`): - **If `valid: false`** (typically `NEXT_SESSION_PROMPT_PRODUCER_MISMATCH`):
print the structured `errors[]` (each `[code] message` on its own line), print the structured `errors[]` (each `[code] message` on its own line),
@ -328,8 +346,6 @@ Examples:
VOYAGE_PROFILE=economy /trekcontinue # env-var override VOYAGE_PROFILE=economy /trekcontinue # env-var override
``` ```
Stats records emit `profile` and `profile_source` per Phase 5 record.
## Hard rules ## Hard rules
- **Idempotent.** Running `/trekcontinue` twice in the same Claude session - **Idempotent.** Running `/trekcontinue` twice in the same Claude session

View file

@ -2,6 +2,7 @@
name: trekendsession name: trekendsession
description: Mark the current session as complete and write session-state pointing at the next session. Helper for informal multi-session flows. description: Mark the current session as complete and write session-state pointing at the next session. Helper for informal multi-session flows.
argument-hint: "<next-brief-path> <next-label> | --help" argument-hint: "<next-brief-path> <next-label> | --help"
disable-model-invocation: true
--- ---
# Voyage End-Session Local v1.0 # Voyage End-Session Local v1.0
@ -29,8 +30,32 @@ See **Handover 7** in `docs/HANDOVER-CONTRACTS.md` for the schema.
## Phase 0 — `--help` handling ## Phase 0 — `--help` handling
If `$ARGUMENTS` contains `--help` or `-h`, print the usage block below and exit Parse `$ARGUMENTS` with the arg parser. Run this via the Bash tool at runtime
cleanly. Do NOT proceed to any further phase. (NOT eager-exec), exactly as written: the arguments reach the parser as the one
line inside the quoted heredoc, on stdin, never as shell words:
```bash
setopt nonomatch 2>/dev/null || true
node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekendsession --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'
$ARGUMENTS
VOYAGE_ARGS_EOF_7F3C9E
```
The `setopt nonomatch` line is no longer needed for the arguments (the quoted
heredoc expands no glob); it stays harmless.
It prints the parse result as JSON. On exit 1, print its `errors` and stop.
When it prints no JSON, say that the parser did not run and stop.
Print the usage block below and exit cleanly ONLY when one of these two
conditions equals exactly true (no substring search, no "contains" check):
- `flags['--help'] === true`, OR
- `positional[0] === '-h'` (single-dash short form — the parser keeps it as
positional because the schema declares no alias).
Then do NOT proceed to any further phase. In every other case — a slug or a
label that holds `-h` or `help` too — go on to Phase 1.
``` ```
/trekendsession — Mark current session done; point at next session. /trekendsession — Mark current session done; point at next session.
@ -76,8 +101,10 @@ Decision tree:
## Phase 2 — Required args check (headless-safe) ## Phase 2 — Required args check (headless-safe)
Read `$ARGUMENTS`. Both `<next-brief-path>` and `<next-label>` are required. Read the parse result from Phase 0: `<next-brief-path>` is `positional[0]`
If either is missing or empty: and `<next-label>` is the parser's `tail` (the rest of the text as typed after
the brief path, one outer quote pair removed, so a label with spaces stays
whole). Both are required. If either is missing or empty:
``` ```
Error: missing required args. Error: missing required args.
@ -108,29 +135,41 @@ to avoid partial-state on crash. The util is ESM, so invoke via
`node --input-type=module -e` with an `import` statement (a CommonJS shim `node --input-type=module -e` with an `import` statement (a CommonJS shim
would throw `ERR_REQUIRE_ESM` on Node 18+ since `atomic-write.mjs` is ESM). would throw `ERR_REQUIRE_ESM` on Node 18+ since `atomic-write.mjs` is ESM).
Under `node --input-type=module -e "<script>" arg1 arg2 arg3`, Node sets
`process.argv[0]` to the node binary path and user args start at
`process.argv[1]`. Adjust the destructure if your Node version differs.
This phase ALSO writes a sibling `NEXT-SESSION-PROMPT.local.md` in the This phase ALSO writes a sibling `NEXT-SESSION-PROMPT.local.md` in the
project directory with YAML frontmatter (`produced_by: trekendsession`, project directory with YAML frontmatter (`produced_by: trekendsession`,
`produced_at: {ISO-8601}`, `project: {project_dir}`). Both files are written `produced_at: {ISO-8601}`, `project: {project_dir}`). Both files are written
in a single ESM block so the writes succeed or fail together. in a single ESM block so the writes succeed or fail together.
Run the block below via the Bash tool at runtime, substituting the resolved Run the block below via the Bash tool at runtime, exactly as written: put no
values for the `{curly}` placeholders (Phase 1 gives `{project_dir}`, Phase 2 value from Phase 1 or Phase 2 into it. This is NOT an eager-exec block — the
gives `{next_brief_path}` and `{next_label}`). This is NOT an eager-exec files do not exist at command-load time. The block takes nothing on its shell
block — the values do not exist at command-load time. The import path must line: it re-reads the arguments as the one line inside the quoted heredoc, on
stay absolute via `${CLAUDE_PLUGIN_ROOT}` — your Bash cwd is the user's stdin, never as shell words (the heredoc is the last thing in the command),
repo, not the plugin root, so a cwd-relative import throws parses them with the same `parseArgs` as Phase 0 (the brief path is
`ERR_MODULE_NOT_FOUND`: `positional[0]`, the label is `tail`), and re-derives the project directory by
Phase 1's enumeration: exactly one `.claude/projects/<dir>/brief.md` under the
cwd, else it prints why and exits 1. The import paths must stay absolute via
`${CLAUDE_PLUGIN_ROOT}` — your Bash cwd is the user's repo, not the plugin
root, so a cwd-relative import throws `ERR_MODULE_NOT_FOUND`:
```bash ```bash
node --input-type=module -e " node --input-type=module -e "
import path from 'node:path'; import path from 'node:path';
import { writeFileSync } from 'node:fs'; import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { atomicWriteJson } from '${CLAUDE_PLUGIN_ROOT}/lib/util/atomic-write.mjs'; import { atomicWriteJson } from '${CLAUDE_PLUGIN_ROOT}/lib/util/atomic-write.mjs';
const [, dir, brief, label] = process.argv; import { parseArgs } from '${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs';
let text = readFileSync(0, 'utf8');
if (text.endsWith('\\n')) text = text.slice(0, -1);
if (text.endsWith('\\r')) text = text.slice(0, -1);
const args = parseArgs(text, 'trekendsession');
const brief = args.positional[0] || '';
const label = args.tail;
if (brief === '' || label === '') { console.error('Error: missing required args.'); process.exit(1); }
const root = '.claude/projects';
const found = existsSync(root) ? readdirSync(root).filter((d) => existsSync(path.join(root, d, 'brief.md'))).map((d) => path.join(root, d)) : [];
if (found.length === 0) { console.error('no .claude/projects/ directory with a brief.md found under cwd; cannot determine project directory'); process.exit(1); }
if (found.length > 1) { console.error('more than one project directory holds a brief.md; cd into the intended one and retry:\\n' + found.join('\\n')); process.exit(1); }
const dir = found[0];
const now = new Date().toISOString(); const now = new Date().toISOString();
const stateObj = { schema_version: 1, project: dir, next_session_brief_path: brief, next_session_label: label, status: 'in_progress', updated_at: now }; const stateObj = { schema_version: 1, project: dir, next_session_brief_path: brief, next_session_label: label, status: 'in_progress', updated_at: now };
const stateFile = path.join(dir, '.session-state.local.json'); const stateFile = path.join(dir, '.session-state.local.json');
@ -140,7 +179,9 @@ const promptBody = '---\\nproduced_by: trekendsession\\nproduced_at: ' + now + '
writeFileSync(promptFile, promptBody); writeFileSync(promptFile, promptBody);
console.log(stateFile); console.log(stateFile);
console.log(promptFile); console.log(promptFile);
" '{project_dir}' '{next_brief_path}' '{next_label}' " <<'VOYAGE_ARGS_EOF_7F3C9E'
$ARGUMENTS
VOYAGE_ARGS_EOF_7F3C9E
``` ```
## Phase 4 — Validate + narrate ## Phase 4 — Validate + narrate
@ -176,6 +217,7 @@ before retrying — usually means a bad path or label argument.
- **Atomic write only.** Tmp + rename — no partial state files on disk. - **Atomic write only.** Tmp + rename — no partial state files on disk.
- **Zero secrets.** Status, paths, labels — never API keys, never user content - **Zero secrets.** Status, paths, labels — never API keys, never user content
beyond filenames. beyond filenames.
- **NEVER auto-invoke this command.** It is operator-typed only at session-end. - **NEVER auto-invoke this command.** It is operator-typed only at session-end
(the frontmatter field keeps the model from starting it; the operator types it).
- **Idempotent within a session.** Running twice with the same args - **Idempotent within a session.** Running twice with the same args
overwrites cleanly (atomic rename); does not double-advance. overwrites cleanly (atomic rename); does not double-advance.

View file

@ -1,7 +1,7 @@
--- ---
name: trekexecute name: trekexecute
description: Disciplined plan executor — single-session, or multi-session through session orders the user starts, with failure recovery description: Disciplined plan executor — single-session, or multi-session through session orders the user starts, with failure recovery
argument-hint: "[--project <dir>] [--fg | --resume | --dry-run | --validate | --step N | --session N] [plan.md]" argument-hint: "[--project <dir>] [--profile <name>] [--gates [true|false|closed|open]] [--fg | --resume | --dry-run | --validate | --step N | --session N] [plan.md]"
allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion, Agent, Skill allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion, Agent, Skill
disallowed-tools: TeamCreate disallowed-tools: TeamCreate
--- ---
@ -48,6 +48,22 @@ Parse `$ARGUMENTS` for mode flags:
use that path but keep `project_dir` set (user may be pointing at a use that path but keep `project_dir` set (user may be pointing at a
recovery session spec inside the project). recovery session spec inside the project).
- Continue parsing remaining flags (they combine with `--project`). - Continue parsing remaining flags (they combine with `--project`).
- Brief check, once every flag below is parsed: when `{dir}/brief.md`
exists, run the two lines `/trekplan` runs on it (its Phase 1), for the
execute phase; append `--profile {profile}` when the operator passed
`--profile`:
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/brief-validator.mjs --soft --json "{dir}/brief.md"
node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model --phase execute --brief-path "{dir}/brief.md" [--profile {profile}] --json
```
The validator exits 1 when its `errors` are not empty; read its JSON
either way. Halt only when the validator's `errors` hold
`BRIEF_V51_MISSING_SIGNALS`, with the message from "Sequencing gate
surface" below. Any other error is printed in one line and the run
continues. A validator or resolver that cannot run (no JSON) is "not
measured" in one line, and the run continues. Capture the resolver's
JSON as `phase_signal_result` (Composition rule below). No
`{dir}/brief.md`: skip both lines.
2. If arguments contain `--fg`: extract the file path. Set **mode = foreground**. 2. If arguments contain `--fg`: extract the file path. Set **mode = foreground**.
3. If arguments contain `--resume`: extract the file path. Set **mode = resume**. 3. If arguments contain `--resume`: extract the file path. Set **mode = resume**.
4. If arguments contain `--dry-run`: extract the file path. Set **mode = dry-run**. 4. If arguments contain `--dry-run`: extract the file path. Set **mode = dry-run**.
@ -56,10 +72,22 @@ Parse `$ARGUMENTS` for mode flags:
Set **mode = step**, **target-step = N**. Set **mode = step**, **target-step = N**.
7. If arguments contain `--session N` (N is a positive integer): extract N and the file path. 7. If arguments contain `--session N` (N is a positive integer): extract N and the file path.
Set **mode = session**, **target-session = N**. Set **mode = session**, **target-session = N**.
8. If arguments contain `--gates`: set `gates_mode = true`. Default 8. If arguments contain `--gates`: `--gates` takes the next token as its
`gates_mode = false`. The flag is consumed by the autonomy-gate state value only when it is `true`, `false`, `closed` or `open` (strip both);
machine via the CLI shim: any other next token is not its value (strip only the flag). Run the shim
`node ${CLAUDE_PLUGIN_ROOT}/lib/util/autonomy-gate.mjs --state X --event Y --gates {true|false}`. once to map the value, bare when there is no value:
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/util/autonomy-gate.mjs --state idle --event start --gates [value]
```
`next_state: gates_on` sets `gates_mode = true`, `auto_running` sets it
false, and nothing else reads the state. On exit 1 or no JSON (a broken
shim; the four values cannot cause it), print the error in one line and
map in prose instead: bare, `true`, `closed` → true; `false`, `open` →
false. The run goes on either way. Default `gates_mode = false`.
`gates_mode` changes nothing in `/trekexecute`: no phase of it pauses on
it. The one stop is the merge into main, which always asks (Phase 8).
It no longer adds a question of the executor's own before a merge. The It no longer adds a question of the executor's own before a merge. The
wave merge-back (Phase 2.6 Step 3) and the merge into main (Phase 8) are wave merge-back (Phase 2.6 Step 3) and the merge into main (Phase 8) are
asked by the guard, which asks before every merge into main and every push, asked by the guard, which asks before every merge into main and every push,
@ -67,7 +95,9 @@ Parse `$ARGUMENTS` for mode flags:
(operator decision 2026-10-08: two questions for one merge became one). The (operator decision 2026-10-08: two questions for one merge became one). The
MAIN_MERGE_GATE in Phase 8 is always on, regardless of `gates_mode`: it is MAIN_MERGE_GATE in Phase 8 is always on, regardless of `gates_mode`: it is
a lock, and the guard's question is the one question. a lock, and the guard's question is the one question.
9. Otherwise: the entire argument string is the file path. Set **mode = execute**. 9. If arguments contain `--profile <name>`: set `profile = {name}`; strip both tokens.
10. Otherwise: the arguments left after every flag above is stripped are the file path.
Set **mode = execute**.
If no path is provided (and `--project` was not used to derive one), output If no path is provided (and `--project` was not used to derive one), output
usage and stop: usage and stop:
@ -81,6 +111,7 @@ Usage: /trekexecute <plan.md>
/trekexecute --validate <plan.md> /trekexecute --validate <plan.md>
/trekexecute --step N <plan.md> /trekexecute --step N <plan.md>
/trekexecute --session N <plan.md> /trekexecute --session N <plan.md>
/trekexecute --profile <name> <plan.md>
/trekexecute --project <dir> --resume /trekexecute --project <dir> --resume
/trekexecute --project <dir> --session N /trekexecute --project <dir> --session N
@ -94,6 +125,8 @@ Modes:
--validate Schema-only check — parse steps + manifests, no security scan, no execution --validate Schema-only check — parse steps + manifests, no security scan, no execution
--step N Execute only step N (foreground) --step N Execute only step N (foreground)
--session N Execute only session N from the plan's Execution Strategy --session N Execute only session N from the plan's Execution Strategy
--profile Profile for this run's resolver; the step helper and the controller stay on opus
--gates Accepted, pauses on nothing: bare, true or closed = on; false or open = off
Examples: Examples:
/trekexecute .claude/plans/trekplan-2026-04-06-auth-refactor.md /trekexecute .claude/plans/trekplan-2026-04-06-auth-refactor.md
@ -236,7 +269,7 @@ mode; absence or <1.7 as legacy mode.
- If any step is missing its Manifest, or YAML is malformed, STOP with: - If any step is missing its Manifest, or YAML is malformed, STOP with:
``` ```
Error: plan_version=1.7 but step {N} has invalid/missing Manifest. Error: plan_version=1.7 but step {N} has invalid/missing Manifest.
Re-run planning-orchestrator — plan is not executable. Re-run /trekplan — the plan is not executable.
``` ```
**Legacy mode (no plan_version, or < 1.7):** **Legacy mode (no plan_version, or < 1.7):**
@ -334,11 +367,11 @@ Reason: {specific error from Phase 2}
{if format not recognized}: {if format not recognized}:
Detected heading format: {e.g. "### Fase 1:", "## Phase 1"} Detected heading format: {e.g. "### Fase 1:", "## Phase 1"}
Expected: "### Step N: <description>" Expected: "### Step N: <description>"
Fix: re-run /trekplan — planning-orchestrator must emit v1.7 format Fix: re-run /trekplan — the plan must use v1.7 format
{if missing manifest}: {if missing manifest}:
Step {N} has no Manifest block (plan_version=1.7 requires one per step) Step {N} has no Manifest block (plan_version=1.7 requires one per step)
Fix: re-run /trekplan — planning-orchestrator must include manifest YAML Fix: re-run /trekplan — every step needs a Manifest YAML block
{if malformed YAML or invalid regex}: {if malformed YAML or invalid regex}:
Step {N}: {specific YAML/regex error} Step {N}: {specific YAML/regex error}
@ -432,6 +465,7 @@ Determine how to execute this plan:
- `--step N` mode - `--step N` mode
- `--resume` mode - `--resume` mode
- `--session N` mode (runs only that session's steps, foreground) - `--session N` mode (runs only that session's steps, foreground)
- `phase_signal_result.effort == 'low'` → one session, steps in order, unless a wave is in flight: when `.claude/trekplan-sessions/{slug}/orders/wave.json` exists in the main checkout (the first entry of `git worktree list`), this bullet does not apply and the multi-session path below collects the wave. The check only reads the file and never stops the run.
- Plan has no `## Execution Strategy` section - Plan has no `## Execution Strategy` section
- Plan has Execution Strategy with only 1 session - Plan has Execution Strategy with only 1 session
@ -582,9 +616,8 @@ These sessions cannot safely run in parallel.
Fix the Execution Strategy in the plan, or use --fg for sequential execution. Fix the Execution Strategy in the plan, or use --fg for sequential execution.
``` ```
Stop execution. This is a defense-in-depth check — the planning-orchestrator Stop execution. This check runs at execution time, so it also catches a plan
should have prevented this, but verifying at execution time catches plans that was edited by hand after planning.
that were manually edited or have bugs.
### Check 4 — Stale worktree cleanup ### Check 4 — Stale worktree cleanup
@ -1151,6 +1184,7 @@ progress file.
"exit_condition_checked": false, "exit_condition_checked": false,
"plan_verification": null, "plan_verification": null,
"end_to_end": null, "end_to_end": null,
"run_seen": null,
"arch_rules": null, "arch_rules": null,
"scope_sweep": null, "scope_sweep": null,
"simplify": null, "simplify": null,
@ -1173,6 +1207,7 @@ a progress file without them still validates and resumes:
| `steps.{N}.fix_rounds` | Sub-step F | 0–5: 0 = passed its first control, 1–3 = fix rounds, 4 = sent up to the plan, 5 = sent up to the operator | | `steps.{N}.fix_rounds` | Sub-step F | 0–5: 0 = passed its first control, 1–3 = fix rounds, 4 = sent up to the plan, 5 = sent up to the operator |
| `steps.{N}.attempts` | Sub-steps C, F | helpers launched for the step: 1 + the fix rounds that launched one | | `steps.{N}.attempts` | Sub-steps C, F | helpers launched for the step: 1 + the fix rounds that launched one |
| `end_to_end` | Phase 7 | `"verified" \| "not-verified"` (P9 (6)) | | `end_to_end` | Phase 7 | `"verified" \| "not-verified"` (P9 (6)) |
| `run_seen` | Phase 7 | `"yes" \| "no — {reason}"` — what Claude Code's built-in `run` showed in this run; `null` = not run (Phase 7 not reached) |
| `decisions` | any phase | `[{step, decision, cost_if_wrong}]` — every call the executor made on its own (Phase 5.5) | | `decisions` | any phase | `[{step, decision, cost_if_wrong}]` — every call the executor made on its own (Phase 5.5) |
`progress-validator.mjs` rejects `fix_rounds` outside 0–5 (there is no sixth `progress-validator.mjs` rejects `fix_rounds` outside 0–5 (there is no sixth
@ -1275,7 +1310,7 @@ but do not block.
**Also write sibling `NEXT-SESSION-PROMPT.local.md`** with frontmatter **Also write sibling `NEXT-SESSION-PROMPT.local.md`** with frontmatter
(`produced_by: trekexecute`, `produced_at: <ISO-8601>`, (`produced_by: trekexecute`, `produced_at: <ISO-8601>`,
`status: stopped`) — same combined ESM pattern as Phase 8 — so Phase 1.5 `status: stopped`) — same combined ESM pattern as Phase 8 — so Phase 1.5
of `/trekcontinue` can compare project-dir and plugin-root candidates. of `/trekcontinue` can compare project-dir and repo-root candidates.
If the entry condition **passes**: If the entry condition **passes**:
``` ```
@ -1529,14 +1564,15 @@ finding 4):
```bash ```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/util/park-attempt-files.mjs --diff {report dir}/step-{N}-upward.diff --step {N} --attempt {attempts} node ${CLAUDE_PLUGIN_ROOT}/lib/util/park-attempt-files.mjs --diff {report dir}/step-{N}-upward.diff --step {N} --attempt {attempts}
``` ```
It moves every file the attempt created that is still untracked to the git It moves every file new in the attempt's diff that is untracked, or staged but not in HEAD, to the git
dir (`voyage/parked/step-{N}-attempt-{attempts}/`, outside the work tree, so dir (`voyage/parked/step-{N}-attempt-{attempts}/`, outside the work tree, so
Sub-step C.2 never registers it again): moved, never deleted, never over a file Sub-step C.2 never registers it again): moved, never deleted, never over a file
already parked. Name the `parked` paths and every `kept` path with its reason already parked. Name the `parked` paths and every `kept` path with its reason
in the step report. Exit 1 means a file the attempt made is still in the tree in the step report. Exit 1 means a file the attempt made is still in the tree
(a header it could not read, a move that failed): stop and name it; exit 2 is (a header it could not read, a move that failed): stop and name it; exit 2 is
a stop too (the tree was not checked). Then launch a fresh `build` helper for a stop too, nothing unindexed or moved (HEAD could not be read, a path in HEAD
the revised step. is missing from the index, or another git call failed). Then launch a fresh
`build` helper for the revised step.
- **Never pass the Agent tool's `name` parameter.** A named spawn becomes an - **Never pass the Agent tool's `name` parameter.** A named spawn becomes an
`in_process_teammate`: it does the work, but its result never reaches this `in_process_teammate`: it does the work, but its result never reaches this
@ -1852,7 +1888,7 @@ session's effort, Sub-step E).
| 4 | the plan | **After three failed fix rounds the question is the architecture, not a fourth fix.** The step goes back to planning (below). Sub-step B sends three answers here directly, with no fix round: a `"not-run"` caused by the `Verify:` command, a second `plan-unreadable` (the executor's own call still wrong after one correction), and a `not-red-at-base` with `tests_only: true` (a step of only tests). Stop class 4 | | 4 | the plan | **After three failed fix rounds the question is the architecture, not a fourth fix.** The step goes back to planning (below). Sub-step B sends three answers here directly, with no fix round: a `"not-run"` caused by the `Verify:` command, a second `plan-unreadable` (the executor's own call still wrong after one correction), and a `not-red-at-base` with `tests_only: true` (a step of only tests). Stop class 4 |
| 5 | the operator | A step already sent to the plan (`fix_rounds` 4) that fails its control again after re-planning goes to the operator. The executor never builds this step again: on a later `--resume` it only runs the checks (B, D1, D2, D3, D4, E) on whatever the operator changed. If they fail again the step stays at 5 — there is no sixth round, `fix_rounds` never exceeds 5 — and the run stops again. Stop class 4 | | 5 | the operator | A step already sent to the plan (`fix_rounds` 4) that fails its control again after re-planning goes to the operator. The executor never builds this step again: on a later `--resume` it only runs the checks (B, D1, D2, D3, D4, E) on whatever the operator changed. If they fail again the step stays at 5 — there is no sixth round, `fix_rounds` never exceeds 5 — and the run stops again. Stop class 4 |
**Back to planning** (`fix_rounds = 4`): save `git diff --no-ext-diff --no-renames --src-prefix=a/ --dst-prefix=b/ {start_sha}` (one form whatever the user's git config) to **Back to planning** (`fix_rounds = 4`): save `git -c core.quotepath=off diff --no-ext-diff --no-color --no-renames --no-relative --src-prefix=a/ --dst-prefix=b/ {start_sha}` (one form whatever the user's git config) to
`{report dir}/step-{N}-upward.diff` and write `{report dir}/step-{N}-upward.md`: `{report dir}/step-{N}-upward.diff` and write `{report dir}/step-{N}-upward.md`:
the step verbatim, every round's findings and verdicts, and the question for the step verbatim, every round's findings and verdicts, and the question for
the planner — which assumption in the step (its Changes, its Interface, its the planner — which assumption in the step (its Changes, its Interface, its
@ -1921,6 +1957,10 @@ or 5). Release the step's test lock before stopping
command exactly as written in the plan. (The test files are listed apart command exactly as written in the plan. (The test files are listed apart
from `Files:` in the plan; staging only `Files:` would leave the step's from `Files:` in the plan; staging only `Files:` would leave the step's
test uncommitted and charge it to the next step's diff.) test uncommitted and charge it to the next step's diff.)
`{hash}` is the full commit id: run `git rev-parse HEAD` after the step's
last commit, never the short id `git commit` prints. Read it again after
any later commit of the step (the second commit on the exit-1 path); when
the commit failed, it is the HEAD the done line names.
If the commit fails (nothing to commit, etc.): warn but do NOT fail the If the commit fails (nothing to commit, etc.): warn but do NOT fail the
step. The step's verification already passed — the commit is bookkeeping. step. The step's verification already passed — the commit is bookkeeping.
2. **The done line**, at the step's own commit. A step is done only when a 2. **The done line**, at the step's own commit. A step is done only when a
@ -1959,7 +1999,7 @@ already passed) but is recorded as `checkpoint_drift: {expected_pattern,
actual_message}` in the progress file. Phase 7.5 audit reports drift as actual_message}` in the progress file. Phase 7.5 audit reports drift as
an advisory. an advisory.
Update progress: `steps.{N}.status = "completed"`, `steps.{N}.commit = {hash}`, Update progress: `steps.{N}.status = "completed"`, `steps.{N}.commit = {hash}` (the full commit id),
`steps.{N}.completed_at = now`, `steps.{N}.manifest_audit = "pass"`. `steps.{N}.completed_at = now`, `steps.{N}.manifest_audit = "pass"`.
### Step mode exit ### Step mode exit
@ -2087,7 +2127,9 @@ verification failure must not hide a manifest drift.
### End to end — "run the thing" (P9 (6)) ### End to end — "run the thing" (P9 (6))
Tests passing is not the product working. Copy `end_to_end.status` from the Tests passing is not the product working. To see and drive the app, use
Claude Code's built-in `run` skill; the verdict is still the `End-to-end:`
command, because `run` gives no exit code. Copy `end_to_end.status` from the
runner's JSON into `end_to_end` — the runner decides it, not this prose: runner's JSON into `end_to_end` — the runner decides it, not this prose:
- `"verified"` — the verification passed and at least one passed criterion - `"verified"` — the verification passed and at least one passed criterion
@ -2104,6 +2146,12 @@ built thing — not in the run. (A ≥ 1.8 plan cannot reach here with its
`End-to-end:` command missing from `## Verification`: the plan validator `End-to-end:` command missing from `## Verification`: the plan validator
rejects that as `PLAN_END_TO_END_NOT_VERIFIED`.) rejects that as `PLAN_END_TO_END_NOT_VERIFIED`.)
Record `run_seen`: `yes` when the app was seen and driven with Claude Code's
built-in `run` in this run, else `no — {reason}` (no app to launch, `run` not
available, not started, and why) in the progress file's top-level `run_seen`,
so Phase 8 reads it from there. The verdict is still the `End-to-end:`
command.
### Architecture rules (K5) and the plan-file sweep (K6) ### Architecture rules (K5) and the plan-file sweep (K6)
Two whole-run checks, for a trekplan and a session spec alike, in one Bash Two whole-run checks, for a trekplan and a session spec alike, in one Bash
@ -2223,18 +2271,26 @@ never typed by the operator, in this order (Cherny's):
run — no project directory", and the merge stays closed (Phase 8). run — no project directory", and the merge stays closed (Phase 8).
**Its writes are committed.** `/trekreview` may have written approved **Its writes are committed.** `/trekreview` may have written approved
project-memory lines (`.claude/rules/voyage-review-lessons.md`) and a project-memory lines (`.claude/rules/voyage-review-lessons.md`) and a
one-line pointer in `CLAUDE.md`. Commit exactly those paths, when they one-line pointer in `CLAUDE.md`. Commit only those of them that changed
changed, as `docs(voyage): review lessons (round {R})`: a process commit and git does not ignore (`git check-ignore -q -- {path}` exits 1; exit 0
(Phase 7.5 step 3). Nothing the review wrote is left uncommitted for the means ignored), as `docs(voyage): review lessons (round {R})`: a process
next round's checks or for the merge. commit (Phase 7.5 step 3). An ignored path is left uncommitted and named
in the report; `/trekreview` wrote no pointer to an ignored lessons file
(its `pointer` result is `"ignored-skipped"`), and none through a symlinked
`CLAUDE.md` (`"symlink-skipped"`). Nothing the review wrote that git does not
ignore is left uncommitted for the next round's checks or for the merge.
4. **`verdict-gate.mjs`** — the review's verdict decides, as a command: 4. **`verdict-gate.mjs`** — the review's verdict decides, as a command:
```bash ```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/review/verdict-gate.mjs --json "{project_dir}/review.md" node ${CLAUDE_PLUGIN_ROOT}/lib/review/verdict-gate.mjs --json "{project_dir}/review.md" --repo .
``` ```
Record `review = {round, path, gate_exit, verdict}`. Exit 0 (ALLOW/WARN) → `--repo .` checks that an ALLOW/WARN review is of the commit being merged:
Phase 7.5. Exit 2 (could not decide, or a reviewer or engine that did not its `scope_sha_end` is HEAD, or an ancestor of HEAD whose later commits
run) → Phase 7.5 with the merge closed: never green. Exit 1 (BLOCK) → the change only the review lessons and the `CLAUDE.md` pointer (step 3's
loop below. commit). Record `review = {round, path, gate_exit, verdict}`. Exit 0
(ALLOW/WARN) → Phase 7.5. Exit 2 (could not decide, a reviewer or engine
that did not run, or a reason that starts `review-stale`: a review of
another commit) → Phase 7.5 with the merge closed: never green. Exit 1
(BLOCK) → the loop below.
**A BLOCK is fixed by new plan steps, in a bounded loop.** The review writes **A BLOCK is fixed by new plan steps, in a bounded loop.** The review writes
every BLOCKER as a numbered `### Step R<n>` under `## Remediation Steps` every BLOCKER as a numbered `### Step R<n>` under `## Remediation Steps`
@ -2374,7 +2430,7 @@ Record in progress file:
> 1. **Objective predicate** — the **Phase 7.5** manifest audit (above) PASSED, > 1. **Objective predicate** — the **Phase 7.5** manifest audit (above) PASSED,
> re-verified directly from the filesystem + git log, ignoring the executor's > re-verified directly from the filesystem + git log, ignoring the executor's
> own per-step bookkeeping. "The audit wins"; a self-reported `completed` over > own per-step bookkeeping. "The audit wins"; a self-reported `completed` over
> a drifting audit is OVERRIDDEN to `partial` (Phase 7.5, `:1221`). > a drifting audit is OVERRIDDEN to `partial` (Phase 7.5).
> 2. **Explicit stop-signal** — a signal *distinct from the executor's > 2. **Explicit stop-signal** — a signal *distinct from the executor's
> self-assessment* is satisfied: the plan's Verify command(s) **passed (exit > self-assessment* is satisfied: the plan's Verify command(s) **passed (exit
> 0)** / lint clean / an explicit **DONE token emitted AFTER the Phase 7.5 > 0)** / lint clean / an explicit **DONE token emitted AFTER the Phase 7.5
@ -2385,14 +2441,14 @@ Record in progress file:
> from `lib/verification/criteria-runner.mjs`, on the single-session path as > from `lib/verification/criteria-runner.mjs`, on the single-session path as
> well as the multi-session one. `"failed"` or `"not-run"` OVERRIDES > well as the multi-session one. `"failed"` or `"not-run"` OVERRIDES
> `completed` to `failed`, exactly as the audit-override does. > `completed` to `failed`, exactly as the audit-override does.
> 3. **Counter liveness (deterministic, defeats "never decremented")** — the gate > 3. **Counter liveness (checked by the executor against the audit's counts,
> reconciles the budget counter against the ground-truth counters the Phase 7.5 > defeats "never decremented")** — the gate reconciles the budget counter
> audit already derives: > against the ground-truth counters the Phase 7.5 audit already derives:
> `iterations_remaining == TREKEXECUTE_MAX_RECOVERY_ITERATIONS − (recovery_depth + Σ steps.*.attempts-beyond-first + (review.round − 1))` > `iterations_remaining == TREKEXECUTE_MAX_RECOVERY_ITERATIONS − (recovery_depth + Σ steps.*.attempts-beyond-first + (review.round − 1))`
> (the last term is 0 without a review; each review loop of Phase 7.2 takes 1). > (the last term is 0 without a review; each review loop of Phase 7.2 takes 1).
> A field left un-decremented (or tampered) fails this reconciliation and the > A field left un-decremented (or tampered) fails this reconciliation and the
> result is OVERRIDDEN `completed → partial`, exactly as the audit-override > result is OVERRIDDEN `completed → partial`, exactly as the audit-override
> already does (`:1221`). So the counter's liveness is auditable at the gate, > already does (Phase 7.5). So the counter's liveness is auditable at the gate,
> not merely asserted in prose. > not merely asserted in prose.
> 4. **End to end** — for a trekplan, Phase 7's `end_to_end == "verified"`. > 4. **End to end** — for a trekplan, Phase 7's `end_to_end == "verified"`.
> `"not-verified"` OVERRIDES `completed → partial` with the reason > `"not-verified"` OVERRIDES `completed → partial` with the reason
@ -2403,12 +2459,20 @@ Record in progress file:
> the files outside the plan. `"not-measured"` does not override, and it is > the files outside the plan. `"not-measured"` does not override, and it is
> never reported as a pass. > never reported as a pass.
> >
> **Commands:** Phase 7.5 step 3's `git rev-list --count`, step 5's `bash -n`,
> step 6's `git diff --name-only` and step 7's `done-line.mjs --check`; the
> criteria runner behind clauses 2 and 4 for a trekplan (a session spec's
> clause 2 is Phase 7's `## Exit Condition` checklist, and clause 4 is a
> trekplan's only); clause 5's `arch-rules.mjs` and `plan-scope-sweep.mjs`.
> **Executor judgement:** steps 1, 2 and 4, the expected count in step 3, and
> clause 3's formula. The judgement parts are not yet a script.
>
> The review verdict (Phase 7.2) is decided after this gate, by the lock in > The review verdict (Phase 7.2) is decided after this gate, by the lock in
> Phase 8: in the closing run, a closed lock or a declined merge turns > Phase 8: in the closing run, a closed lock or a declined merge turns
> `completed` into `partial`, as a declined gate always has. > `completed` into `partial`, as a declined gate always has.
> >
> Termination authority therefore rests on the deterministic audit + an > Termination authority therefore rests on the audit and an objective
> objective stop-signal, never on the model's narrative. Native `/goal` (when > stop-signal, never on the model's narrative. Native `/goal` (when
> capability-probed and wired) is an OPTIONAL accelerator only: transcript-only, > capability-probed and wired) is an OPTIONAL accelerator only: transcript-only,
> it cannot run the real check, so it can never be the sole gate. > it cannot run the real check, so it can never be the sole gate.
@ -2540,7 +2604,7 @@ event, merge nothing, and their result is left as Phase 7.5 decided it
a summary: a summary:
```bash ```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/review/verdict-gate.mjs --json "{review_path}" node ${CLAUDE_PLUGIN_ROOT}/lib/review/verdict-gate.mjs --json "{review_path}" --repo .
``` ```
`{review_path}` is `review.path` from Phase 7.2. No review (Phase 7.2 did not `{review_path}` is `review.path` from Phase 7.2. No review (Phase 7.2 did not
@ -2553,13 +2617,23 @@ exit 2 without the call.
| 1 | BLOCK | closed | | 1 | BLOCK | closed |
| 2 | not measured | closed: a review that could not decide is never green | | 2 | not measured | closed: a review that could not decide is never green |
`--repo .` makes the gate check the review against the commit being merged.
Exit 2 with a reason that starts `review-stale` is closed with the decline
reason `review-stale` and the line "re-run /trekreview on this commit": the
review is of another commit than this one, or something other than the
review lessons and the `CLAUDE.md` pointer changed after it. Any other exit 2
is `review-not-measured`. Exit 2 with a reason that starts `review-not-measured:`
(the `--repo` path is missing, it is no git repository, or a git call failed)
is reported as "not measured", never green, and the merge stays locked with
the decline reason `review-not-measured`.
The merge runs only on exit 0 and a `completed` result. **Closed:** emit The merge runs only on exit 0 and a `completed` result. **Closed:** emit
`main-merge-declined` with the reason `review-block`, `review-not-measured` or `main-merge-declined` with the reason `review-block`, `review-stale`,
`run-not-completed`. Nothing is finalized: a `completed` result becomes `review-not-measured` or `run-not-completed`. Nothing is finalized: a `completed` result becomes
`partial` (`failed` and `stopped` stay), and the commits stay where they are `partial` (`failed` and `stopped` stay), and the commits stay where they are
for review. Say in one line why it is closed and what opens it: the for review. Say in one line why it is closed and what opens it: the
BLOCKERs fixed through Phase 7.2's loop, a review that runs in full, or a run BLOCKERs fixed through Phase 7.2's loop, a review that runs in full, a review
that completes. of this commit, or a run that completes.
**2. Open: the merge, and the one question.** Find the run's branch and where **2. Open: the merge, and the one question.** Find the run's branch and where
main is checked out, in a Bash call of its own: main is checked out, in a Bash call of its own:
@ -2663,7 +2737,7 @@ JSON
``` ```
The reason is one of the literal words above (`review-block`, The reason is one of the literal words above (`review-block`,
`review-not-measured`, `run-not-completed`, `no-main-branch`, `hold-for-review`, `review-stale`, `review-not-measured`, `run-not-completed`, `no-main-branch`, `hold-for-review`,
`merge-conflict`, `merge-refused`), never the operator's free text (free text breaks the JSON). `merge-conflict`, `merge-refused`), never the operator's free text (free text breaks the JSON).
On decline, nothing is cleared: the merged-waves record is kept. On decline, nothing is cleared: the merged-waves record is kept.
@ -2671,8 +2745,9 @@ On decline, nothing is cleared: the merged-waves record is kept.
**Recovery surface:** on the single-session path, if declined, **Recovery surface:** on the single-session path, if declined,
`--resume re-enters at the gate` — re-running `--resume re-enters at the gate` — re-running
`/trekexecute --resume --project <dir>` skips the completed steps and returns `/trekexecute --resume --project <dir>` skips the completed steps and returns
to the main-merge gate: the lock reads `review.md` again (a review fixed or to the main-merge gate: the lock reads the review again (a review fixed or re-run
re-run in between counts), and when it opens, the guard asks again. On the in between counts) and runs the same gate against this repo, so a review of
an older commit stays refused; when it opens, the guard asks again. On the
multi-session wave path, multi-session wave path,
re-run the same `/trekexecute` command instead, not `--resume` (Phase 2.5 runs re-run the same `/trekexecute` command instead, not `--resume` (Phase 2.5 runs
a `--resume` as a single session): the waves are already merged on the current a `--resume` as a single session): the waves are already merged on the current
@ -2713,7 +2788,7 @@ to stderr but do NOT block — Phase 8 must always reach the final report.
contract — see `docs/HANDOVER-CONTRACTS.md` § Handover 7 Lifecycle) in the contract — see `docs/HANDOVER-CONTRACTS.md` § Handover 7 Lifecycle) in the
project directory. The frontmatter MUST contain `produced_by: trekexecute` project directory. The frontmatter MUST contain `produced_by: trekexecute`
and `produced_at: <ISO-8601>` so `/trekcontinue` Phase 1.5 can detect and `produced_at: <ISO-8601>` so `/trekcontinue` Phase 1.5 can detect
producer drift between project-dir and plugin-root candidates. Use a single producer drift between project-dir and repo-root candidates. Use a single
ESM inline block so state-file + prompt-file writes succeed or fail together: ESM inline block so state-file + prompt-file writes succeed or fail together:
```bash ```bash
@ -2745,6 +2820,12 @@ completion). Phase 2.55 and Phase 4 cover the early-stop paths E1/E2.
Phase 2.3 (validate exit) and Phase 5 (dry-run) intentionally do not write Phase 2.3 (validate exit) and Phase 5 (dry-run) intentionally do not write
— neither path is resumable. — neither path is resumable.
The report's **Seen with `run`:** line and the summary's `run_seen` are read
from the progress file's `run_seen` (Phase 7 writes it), not from this
session's memory, so a resumed run (`--resume`) reports it too, and on the
multi-session path (Phase 2.6) the report gives each session's value from its
archived progress copy (Step 6). A `null` there is reported as `no — not run`.
``` ```
## Ultraexecute Local Complete ## Ultraexecute Local Complete
@ -2753,6 +2834,7 @@ Phase 2.3 (validate exit) and Phase 5 (dry-run) intentionally do not write
**Mode:** {execute | resume | step N} **Mode:** {execute | resume | step N}
**Result:** {COMPLETED | FAILED at step N | STOPPED (escalation) | PARTIAL (N/total passed)} **Result:** {COMPLETED | FAILED at step N | STOPPED (escalation) | PARTIAL (N/total passed)}
**End to end:** {verified | not verified end to end} **End to end:** {verified | not verified end to end}
**Seen with `run`:** {yes | no — {reason}}
### Step Results ### Step Results
@ -2878,6 +2960,7 @@ To continue (multi-session waves): re-run the same /trekexecute command (not --r
"legacy_plan": false, "legacy_plan": false,
"progress_file": "{path}", "progress_file": "{path}",
"end_to_end": "{verified | not-verified | n/a}", "end_to_end": "{verified | not-verified | n/a}",
"run_seen": "{yes | no — reason}",
"steps_without_red_proof": 0, "steps_without_red_proof": 0,
"stop_class": "{none | 1 | 2 | 3 | 4}", "stop_class": "{none | 1 | 2 | 3 | 4}",
"decisions": 0, "decisions": 0,
@ -2945,8 +3028,8 @@ Resolution order (per `lib/profiles/resolver.mjs`):
3. Inherited from `plan.md` frontmatter `profile:` field (source: `inheritance`) 3. Inherited from `plan.md` frontmatter `profile:` field (source: `inheritance`)
4. `premium` default for v4.0-style plans without `profile:` (source: `default`) 4. `premium` default for v4.0-style plans without `profile:` (source: `default`)
The selected profile drives `phase_models.execute` (model for execution The selected profile goes to the resolver. It changes no model here: the step helper and the
orchestrator) and `parallel_agents` for wave-parallel sessions. controller run opus (Composition rule below), and nothing in this command reads `parallel_agents`.
Examples: Examples:
``` ```
@ -2954,9 +3037,6 @@ Examples:
VOYAGE_PROFILE=balanced /trekexecute --project ... VOYAGE_PROFILE=balanced /trekexecute --project ...
``` ```
Stats records emit `profile`, `phase_models`, and `profile_source` per
Phase 9 record.
## Composition rule (v5.1) ## Composition rule (v5.1)
Independent of the profile system. When `brief.md` carries Independent of the profile system. When `brief.md` carries
@ -2970,7 +3050,7 @@ model_for_phase = brief.phase_signals[<phase>]?.model ?? profile.phase_models[
The brief signal wins per-phase when present; the profile fills any The brief signal wins per-phase when present; the profile fills any
gaps. Both fields are mechanically resolved by the single composed CLI, gaps. Both fields are mechanically resolved by the single composed CLI,
invoked in Phase 2.4 alongside the sequencing-gate brief-validator call: invoked in Phase 1 alongside the sequencing-gate brief-validator call:
```bash ```bash
# v5.9 — composed phase-model resolution (brief > profile > default) for the # v5.9 — composed phase-model resolution (brief > profile > default) for the
@ -2982,21 +3062,18 @@ node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model --pha
``` ```
`phase_signal_result.effort` (absent = `standard`) is consumed when picking the execution `phase_signal_result.effort` (absent = `standard`) is consumed when picking the execution
strategy (gates auto-escalation, parallel-wave choice — see High-effort strategy (the parallel-wave choice, Phase 2.5). The resolver does NOT control the orchestrator's own
behavior below). The resolver does NOT control the orchestrator's own
model — that is fixed at invocation time (command frontmatter omits model — that is fixed at invocation time (command frontmatter omits
`model:`, so it follows the session model) and cannot be switched mid-turn. `model:`, so it follows the session model) and cannot be switched mid-turn.
`/trekexecute` spawns no swarm: one step helper at a time and one controller `/trekexecute` spawns no swarm: one step helper at a time and one controller
(Hard Rule 10), and both run `opus` — the step agent's pin and the (Hard Rule 10), and both run `opus` — the step agent's pin and the
controller's `model: "opus"` — so `phase_signal_result.model` still has no controller's `model: "opus"` — so `phase_signal_result.model` still has no
spawn site here; it is returned for cross-command uniformity and stats. spawn site here; it is returned for cross-command uniformity; no stats record carries it.
For `/trekexecute` specifically: `effort == 'low'` activates `--gates open` For `/trekexecute` specifically: `effort == 'low'` runs the plan as one
+ sequential-only execution (no worktree-isolated parallel waves — runs session, step after step (Phase 2.5). `effort == 'standard'` (or absent) →
all sessions in a single foreground loop). `effort == 'standard'` (or no change (default execution strategy applies). `effort == 'high'`: see
absent) → no change (default execution strategy applies). `effort == 'high'` `### High-effort behavior (v5.1.1)` below.
activates the high-effort behavior documented under `### High-effort
behavior (v5.1.1)` below.
### Sequencing gate surface ### Sequencing gate surface
@ -3010,32 +3087,9 @@ commands surface, don't re-enforce.
### High-effort behavior (v5.1.1) ### High-effort behavior (v5.1.1)
When `phase_signal_result.effort == 'high'` for the `execute` phase, High effort changes nothing in `/trekexecute`: the step helper and the
set `gates_mode = 'closed'` automatically (equivalent to passing controller already run opus, and every gate already runs. No `gates_mode` is
`--gates closed` on the command line). All autonomy boundaries become set from the effort.
operator-stopping points: manifest-audit FAIL halts immediately, the
main-merge gate stays a lock whose one question is the guard's (Phase 8), and Phase 7.5
manifest-audit runs in its strictest form (fails on ANY mismatch, not
just deltas above threshold). Operator explicit `--gates` flag still
wins over the brief signal — if the operator passes `--gates open` on
the command line while `phase_signal_result.effort == 'high'`, the
flag takes precedence and the override is logged:
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/stats/event-emit.mjs \
--event gates_mode_flag_override --payload '{"flag":"--gates","signal_effort":"high"}'
```
The record lands in `trekexecute-stats.jsonl` in the plugin data directory as
`{ts, event, known_event, payload:{flag, signal_effort}}`. The two payload keys
stay in the jsonl; the OTel export allowlist drops them by omission. On
`"written": false` (exit 1), print its `reason` in one line and continue — the
override record never blocks the run.
Standard effort (or absent): use `gates_mode = 'adaptive'` (the
default). Low effort: `gates_mode = 'open'` plus sequential-only
execution (no parallel waves) — existing `--quick`-equivalent
code-path.
## Hard rules ## Hard rules

View file

@ -124,12 +124,23 @@ Parse `$ARGUMENTS` for mode flags. Order of precedence:
7. **`--quick`** — set **mode = quick**. Skip agent swarm; use lightweight 7. **`--quick`** — set **mode = quick**. Skip agent swarm; use lightweight
Glob/Grep scan and go directly to planning + adversarial review. Glob/Grep scan and go directly to planning + adversarial review.
8. **`--gates`** — autonomy control. When present, set `gates_mode = true`. 8. **`--gates`** — autonomy control. `--gates` takes the next token as its
Pause for operator confirmation after Phase 5 (exploration), Phase 7 value only when it is `true`, `false`, `closed` or `open` (strip both);
(synthesis), and Phase 9 (adversarial review). Default `gates_mode = any other next token is not its value (strip only the flag). Run the shim
false` lets phases flow continuously. The flag is consumed by the once to map the value, bare when there is no value:
autonomy-gate state machine via the CLI shim:
`node ${CLAUDE_PLUGIN_ROOT}/lib/util/autonomy-gate.mjs --state X --event Y --gates {true|false}`. ```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/util/autonomy-gate.mjs --state idle --event start --gates [value]
```
`next_state: gates_on` sets `gates_mode = true`, `auto_running` sets it
false, and nothing else reads the state. On exit 1 or no JSON (a broken
shim; the four values cannot cause it), print the error in one line and
map in prose instead: bare, `true`, `closed` → true; `false`, `open` →
false. The run goes on either way. With `gates_mode = true`, pause for
operator confirmation after Phase 5 (exploration), Phase 7 (synthesis),
and Phase 9 (adversarial review). Default `gates_mode = false` lets
phases flow continuously.
9. **`--min-brief-version <ver>`** — (optional) version floor. Set 9. **`--min-brief-version <ver>`** — (optional) version floor. Set
**min_brief_version = {ver}** (e.g. `2.2`). Forwarded to the brief-validator **min_brief_version = {ver}** (e.g. `2.2`). Forwarded to the brief-validator
@ -314,6 +325,10 @@ Determine the output directory from the plan slug:
Launch the **session-decomposer** agent: Launch the **session-decomposer** agent:
Phase 1 runs the model resolver only for `--project`, so `--decompose` has no
resolved model: this spawn passes no `model:`, and the agent's own `model:`
applies.
``` ```
Plan file: {plan path} Plan file: {plan path}
Plugin root: ${CLAUDE_PLUGIN_ROOT} Plugin root: ${CLAUDE_PLUGIN_ROOT}
@ -412,7 +427,7 @@ Then continue to the next phase inline.
## Phase 4 — Codebase sizing ## Phase 4 — Codebase sizing
Determine codebase scale to calibrate agent turns (not agent count). Determine codebase scale to decide whether convention-scanner runs.
Run via Bash: Run via Bash:
``` ```
@ -454,6 +469,10 @@ Follow the writing-criteria skill (each success criterion is one command the
criteria runner accepts, and it can go red) and the reviewing-adversarially skill. criteria runner accepts, and it can go red) and the reviewing-adversarially skill.
Read local files only inside the repo under exploration, plus paths this prompt gives you (the brief, the plan, and templates it names)." Read local files only inside the repo under exploration, plus paths this prompt gives you (the brief, the plan, and templates it names)."
Pass `model: {phase_signal_result.model}` at this spawn; without it (the
`--brief` branch, where the resolver does not run, or a resolver that printed
nothing) the agent's own `model:` applies.
Handle the verdict: Handle the verdict:
- **PROCEED** — continue to Phase 5. - **PROCEED** — continue to Phase 5.
- **PROCEED_WITH_RISKS** — continue, carry flagged risks as `[ASSUMPTION]` in the plan. - **PROCEED_WITH_RISKS** — continue, carry flagged risks as `[ASSUMPTION]` in the plan.
@ -494,8 +513,7 @@ message). Use the specialized agents from the `agents/` directory.
> 2026-08-17: named 0/5 returned, unnamed 3/3. Mechanism, denominators, and the > 2026-08-17: named 0/5 returned, unnamed 3/3. Mechanism, denominators, and the
> on-disk recovery path: `docs/agent-return-channel-defect.md`. > on-disk recovery path: `docs/agent-return-channel-defect.md`.
**All agents run for all codebase sizes.** Scale `maxTurns` by size (small: halved, **All agents run for all codebase sizes.**
medium: default, large: default) instead of dropping agents.
| Agent | Small | Medium | Large | Purpose | | Agent | Small | Medium | Large | Purpose |
|-------|-------|--------|-------|---------| |-------|-------|--------|-------|---------|
@ -512,6 +530,10 @@ research-scout's web search is not a local read; the line above bounds its local
### Always launch (all codebase sizes): ### Always launch (all codebase sizes):
Pass `model: {phase_signal_result.model}` at this spawn; without it (the
`--brief` branch, where the resolver does not run, or a resolver that printed
nothing) the agent's own `model:` applies.
**architecture-mapper** — full codebase structure, tech stack, patterns, anti-patterns. **architecture-mapper** — full codebase structure, tech stack, patterns, anti-patterns.
Prompt: "Analyze the architecture of this codebase. The task being planned is: {task}. Prompt: "Analyze the architecture of this codebase. The task being planned is: {task}.
Brief file: {brief_path} — read it first. Brief file: {brief_path} — read it first.
@ -549,7 +571,7 @@ Read local files only inside the repo under exploration, plus paths this prompt
### Launch for medium+ codebases (50+ files): ### Launch for medium+ codebases (50+ files):
**Convention Scanner** — use the `convention-scanner` plugin agent (model: "opus") **Convention Scanner** — use the `convention-scanner` plugin agent (model: phase_signal_result.model, the agent's frontmatter as the fallback)
for medium+ codebases only. for medium+ codebases only.
Prompt: "Discover the coding conventions relevant to this task: {task}. Brief file: {brief_path} — read it first. Read local files only inside the repo under exploration, plus paths this prompt gives you (the brief, the plan, and templates it names). Provide concrete examples from the codebase, not generic advice." Prompt: "Discover the coding conventions relevant to this task: {task}. Brief file: {brief_path} — read it first. Read local files only inside the repo under exploration, plus paths this prompt gives you (the brief, the plan, and templates it names). Provide concrete examples from the codebase, not generic advice."
@ -567,6 +589,9 @@ topics have corresponding research files, skip research-scout entirely.
If yes (and not covered by attached briefs): launch **research-scout** in If yes (and not covered by attached briefs): launch **research-scout** in
parallel with the other agents. parallel with the other agents.
Pass `model: {phase_signal_result.model}` at this spawn; without it (the
`--brief` branch, where the resolver does not run, or a resolver that printed
nothing) the agent's own `model:` applies.
Prompt: "Research the following technologies for this task: {task}. Prompt: "Research the following technologies for this task: {task}.
Specific questions: {list specific questions about the technology}. Specific questions: {list specific questions about the technology}.
Technologies to research: {list}. Brief file: {brief_path} — read it first. Technologies to research: {list}. Brief file: {brief_path} — read it first.
@ -587,7 +612,8 @@ Common reasons for deep-dives:
- A test pattern was identified but the test infrastructure needs more detail - A test pattern was identified but the test infrastructure needs more detail
- A risk was flagged but the actual impact needs verification - A risk was flagged but the actual impact needs verification
For each significant gap, spawn a targeted deep-dive agent (model: "opus", For each significant gap, spawn a targeted deep-dive agent (model: phase_signal_result.model, else opus —
Explore is Claude Code's built-in agent and has no Voyage frontmatter to fall back to,
subagent_type: "Explore") with a narrow, specific brief. Each deep-dive prompt subagent_type: "Explore") with a narrow, specific brief. Each deep-dive prompt
carries these two lines: carries these two lines:
Brief file: {brief_path} — read it first. Brief file: {brief_path} — read it first.
@ -792,17 +818,17 @@ Write the plan following the template structure. The plan MUST include:
(`PLAN_STEP_TESTS_ONLY`). (`PLAN_STEP_TESTS_ONLY`).
**The "run the thing" step.** At least one step carries **The "run the thing" step.** At least one step carries
`End-to-end: \`<command>\` → expected: <what the user sees>`: the product run `End-to-end: \`<command>\` → expected: <what the user sees>`: the product run the way a
the way a user runs it (the CLI, the server and a request, the app; for iOS, user runs it (the CLI, the server and a request, the app; for iOS, the simulator). Without
the simulator). Without it the result is "not verified end to end", never it the result is "not verified end to end", never green. To see and drive the app, use
green. The command passes the criteria runner's allowlist — the product's Claude Code's built-in `run` skill; the verdict is still the `End-to-end:` command, because
own binary does not, so the run is wrapped in a script: `run` gives no exit code. The command passes the criteria runner's allowlist — the
product's own binary does not, so the run is wrapped in a script:
`End-to-end: \`bash tests/e2e.sh\``. The same command is also listed under `End-to-end: \`bash tests/e2e.sh\``. The same command is also listed under
`## Verification`: Phase 7 of `/trekexecute` runs that section, and a passed `## Verification`: Phase 7 of `/trekexecute` runs that section, and a passed criterion that
criterion that runs the step's `End-to-end:` command is what verifies the runs the step's `End-to-end:` command is what verifies the run end to end. `End-to-end:`
run end to end. `End-to-end:` rides on the last step that builds behaviour, rides on the last step that builds behaviour, with `tests/e2e.sh` in that step's `Files:`:
with `tests/e2e.sh` in that step's `Files:`: a step of its own that only adds a step of its own that only adds the script is a step of only tests, and it can never be proven red.
the script is a step of only tests, and it can never be proven red.
10. **Execution Strategy** — For plans with > 5 steps: group steps into sessions 10. **Execution Strategy** — For plans with > 5 steps: group steps into sessions
(3–5 steps each), organize sessions into waves (parallel where independent), (3–5 steps each), organize sessions into waves (parallel where independent),
specify scope fences per session. Omit for plans with ≤ 5 steps. specify scope fences per session. Omit for plans with ≤ 5 steps.
@ -962,6 +988,10 @@ Both reviewers are asked for `voyage:writing-criteria` and
writing-criteria rule in one sentence in its prompt, for the brief's criteria writing-criteria rule in one sentence in its prompt, for the brief's criteria
it traces into `## Verification`. it traces into `## Verification`.
Pass `model: {phase_signal_result.model}` at this spawn; without it (the
`--brief` branch, where the resolver does not run, or a resolver that printed
nothing) the agent's own `model:` applies.
**plan-critic** — adversarial review of the plan. **plan-critic** — adversarial review of the plan.
Prompt: "Review this implementation plan for the task: {task}. Prompt: "Review this implementation plan for the task: {task}.
Plan file: {plan path}. Read it and find every problem — missing steps, Plan file: {plan path}. Read it and find every problem — missing steps,
@ -1126,13 +1156,13 @@ Present a summary to the user:
You can: You can:
- Say which open minors to fix ("fix 1 and 3") - Say which open minors to fix ("fix 1 and 3")
- Ask questions or request changes to refine the plan - Ask questions or request changes to refine the plan
- Say **"execute"** to start implementing ← only when the gate exited 0 - Say **"execute"** for the `/trekexecute` line to run ← only when the gate exited 0
- Say **"save"** to keep the plan for later - Say **"save"** to keep the plan for later
``` ```
When the gate exited 1, replace the "execute" line with When the gate exited 1, replace the "execute" line with
`Execution is blocked: {the gate's reasons}` and do not start implementing, `Execution is blocked: {the gate's reasons}` and do not print the `/trekexecute`
whatever is said next, until the gate exits 0. line, whatever is said next, until the gate exits 0.
**What counts as approval.** "Execute", "go" or "start" (or the plan-mode **What counts as approval.** "Execute", "go" or "start" (or the plan-mode
approval below) approves the plan. A hedged reply — "sounds good", "looks approval below) approves the plan. A hedged reply — "sounds good", "looks
@ -1171,8 +1201,11 @@ plan-approval prompt:
plan back. plan back.
3. **Approved:** the session leaves plan mode. Copy the plan file to the plan 3. **Approved:** the session leaves plan mode. Copy the plan file to the plan
destination from Phase 3, then run the gate on the destination — it must destination from Phase 3, then run the gate on the destination — it must
exit 0 again, which also proves the copy is the reviewed text. Then exit 0 again, which also proves the copy is the reviewed text. Then go
continue with the operator's choice (Phase 11). through Phase 11 (its plan approval, then the `/trekexecute` line) and
stop — leaving plan mode is not an order to implement. Phase 11 asks again
because the plan-mode approval is logged under Claude Code's plan file, and
`/trekexecute` checks the copy at the plan destination.
4. **"No, keep planning":** revise per the operator's words, run one Phase 9 4. **"No, keep planning":** revise per the operator's words, run one Phase 9
round, and present again from step 1. round, and present again from step 1.
@ -1184,15 +1217,11 @@ plan mode's restrictions). Until an operator has run it, both paths stay.
### Build the operator-annotation HTML and print the link ### Build the operator-annotation HTML and print the link
After the plan summary, run `scripts/annotate.mjs` to produce a After the plan summary, run `scripts/annotate.mjs` to build the page
self-contained HTML the operator opens in their browser. The HTML renders where the operator writes their own notes on `plan.md`. The loop that
`plan.md` with line numbers, lets the operator click any line to attach follows is the skill `voyage:annotating`: call it when the operator pastes
their own note (not Claude-generated suggestions — the operator drives the notes back (a Question is answered, not applied; an approved plan that
every annotation), keeps a sidebar of all notes, persists state in changes needs a new approval before `/trekexecute`, said before the edit).
localStorage, and exposes a "Copy Prompt" button that generates a single
structured prompt with every note. The operator copies that prompt and
pastes it back into Claude; Claude revises `plan.md` freehand from the
notes.
```bash ```bash
ANNOT_HTML=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/annotate.mjs "{plan_path}" 2>&1) ANNOT_HTML=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/annotate.mjs "{plan_path}" 2>&1)
@ -1216,9 +1245,9 @@ To review and annotate the plan, open it in a browser:
Click any line to add YOUR OWN note. The sidebar collects every note, Click any line to add YOUR OWN note. The sidebar collects every note,
the "Copy Prompt" button gathers them into one structured prompt. the "Copy Prompt" button gathers them into one structured prompt.
Paste that prompt back into this chat and Claude revises plan.md Paste that prompt back into this chat: Claude answers each Question
from your notes. Annotations persist in your browser if you close and revises plan.md from your Fix and Change notes. Annotations
the tab and reopen the same file. persist in your browser if you close the tab and reopen the same file.
──────────────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────────────────
``` ```
@ -1264,15 +1293,16 @@ Confirm the plan and brief file locations and exit.
### "execute" / "go" / "start" ### "execute" / "go" / "start"
Begin implementing the plan step by step in this session. Follow the plan exactly. Do not implement in this session. Print the line the operator gives
Mark each step complete as you go. `/trekexecute` — `/trekexecute --project {project_dir}`, or
`/trekexecute {plan_path}` without a project directory — then run Phase 12 (`outcome: execute`).
`/trekexecute` is what checks the plan's approval (K2), locks the tests, runs
one step helper per step and the review.
### "execute with team" / "team" ### "execute with team" / "team"
There is no team path. Tell the user that parallel team execution is not available There is no team path. Print the same `/trekexecute` line, then run Phase 12
from `/trekplan`, then proceed exactly as for "execute" (sequential, in this (`outcome: execute_team`); parallel work is `--decompose` followed by `/trekexecute`'s session orders.
session). Parallel implementation is `--decompose` followed by `/trekexecute`'s
multi-session waves.
## Phase 12 — Session tracking ## Phase 12 — Session tracking
@ -1333,9 +1363,6 @@ Examples:
VOYAGE_PROFILE=balanced /trekplan --project ... VOYAGE_PROFILE=balanced /trekplan --project ...
``` ```
Stats records emit `profile`, `phase_models`, `parallel_agents`, and
`profile_source` so operators can audit which profile drove which session.
## Composition rule (v5.1) ## Composition rule (v5.1)
Independent of the profile system. When `brief.md` carries Independent of the profile system. When `brief.md` carries

View file

@ -59,7 +59,7 @@ Supported flags:
is `{model, source}`, plus is `{model, source}`, plus
`effort` only when the brief's phase signal sets one (without it the `effort` only when the brief's phase signal sets one (without it the
phase runs at effort `standard`: no change). It is captured as phase runs at effort `standard`: no change). It is captured as
`phase_signal_result` and used at Agent-spawn sites in Phase 4 to inject `phase_signal_result` and used at Agent-spawn sites in Phase 4 and Phase 5 to inject
the resolved model: the resolved model:
```bash ```bash
@ -72,12 +72,23 @@ Supported flags:
node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model --phase research --brief-path "{dir}/brief.md" [--profile {profile}] --json node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model --phase research --brief-path "{dir}/brief.md" [--profile {profile}] --json
``` ```
6. `--gates` — autonomy control. When present, set `gates_mode = true`. The 6. `--gates` — autonomy control. `--gates` takes the next token as its
value only when it is `true`, `false`, `closed` or `open` (strip both);
any other next token is not its value (strip only the flag). Run the shim
once to map the value, bare when there is no value:
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/util/autonomy-gate.mjs --state idle --event start --gates [value]
```
`next_state: gates_on` sets `gates_mode = true`, `auto_running` sets it
false, and nothing else reads the state. On exit 1 or no JSON (a broken
shim; the four values cannot cause it), print the error in one line and
map in prose instead: bare, `true`, `closed` → true; `false`, `open` →
false. The run goes on either way. With `gates_mode = true`, the
research command will pause after each topic completes ("Topic N research command will pause after each topic completes ("Topic N
complete. Proceed to topic N+1? (yes/no)"). Default `gates_mode = false` complete. Proceed to topic N+1? (yes/no)"). Default `gates_mode = false`
means topics run continuously. The flag is consumed by the autonomy-gate means topics run continuously.
state machine via the CLI shim:
`node ${CLAUDE_PLUGIN_ROOT}/lib/util/autonomy-gate.mjs --state X --event Y --gates {true|false}`.
7. `--min-brief-version <ver>` — (optional) version floor for an attached 7. `--min-brief-version <ver>` — (optional) version floor for an attached
`--project` brief. Set **min_brief_version = {ver}** (e.g. `2.2`). Forwarded `--project` brief. Set **min_brief_version = {ver}** (e.g. `2.2`). Forwarded
@ -373,7 +384,8 @@ changes nothing (SC1). Keep the native-swarm anchors intact ("in parallel",
5. **On a real report:** keep `effective_engine = deep-research`, log 5. **On a real report:** keep `effective_engine = deep-research`, log
`Engine: deep-research (active)`, and carry the report into Phase 6 triangulation `Engine: deep-research (active)`, and carry the report into Phase 6 triangulation
as the external-findings input (adapted in Phase 7 — see the Deep-research engine as the external-findings input (adapted in Phase 7 — see the Deep-research engine
adapter below). adapter below); then run wave 2 (`contrarian-researcher`, see Launch rules)
from the report's leading answer.
### External agents (scope = both or external) ### External agents (scope = both or external)
@ -402,7 +414,22 @@ ticket IDs) already removed: the agents search with the words they are given.
### Launch rules ### Launch rules
- Launch ALL selected agents **in parallel** in a single message - Launch in two waves, both in Phase 4 (before Phase 4.5 and Phase 5, so outside
the Phase 5 loop scope marker that `hooks/scripts/pre-agent-cap.mjs` reads).
- **Wave 1:** ALL selected agents except `contrarian-researcher` **in parallel**
in a single message.
- **Wave 2:** `contrarian-researcher`, when selected, after wave 1 returns, in a
message of its own. Give it a conclusion to attack: the leading answer (one or
two sentences), the claims for it and the alternatives wave 1 dismissed,
taken from wave 1's external findings only, never from the local agents'
findings (Hard rules → Independence), and stripped of local parts like the
research question above (absolute paths, user names, internal project or
repository names, ticket IDs). It searches with the words it is given, so no
local file name or project term may be in them. When wave 1's external
findings hold no leading answer, say so and give it the research question
alone, stripped the same way. With `--engine deep-research`
(`effective_engine = deep-research`), wave 2 runs after the engine returns,
from the leading answer of its report.
- Inject the resolved model at every spawn: `phase_signal_result.model` (brief - Inject the resolved model at every spawn: `phase_signal_result.model` (brief
signal > profile), with the agent's frontmatter `model:` as the fallback — see signal > profile), with the agent's frontmatter `model:` as the fallback — see
Hard rules → Cost. The orchestrator itself follows the session model. Hard rules → Cost. The orchestrator itself follows the session model.
@ -418,8 +445,6 @@ ticket IDs) already removed: the agents search with the words they are given.
Measured 2026-08-17: named 0/5 returned, unnamed 3/3. Mechanism, Measured 2026-08-17: named 0/5 returned, unnamed 3/3. Mechanism,
denominators, and the on-disk recovery path: denominators, and the on-disk recovery path:
`docs/agent-return-channel-defect.md`. `docs/agent-return-channel-defect.md`.
- Scale maxTurns by codebase size for local agents (same as trekplan):
small = halved, medium/large = default
- convention-scanner: medium+ codebases only (50+ files) - convention-scanner: medium+ codebases only (50+ files)
## Phase 4.5 — Dimension discovery ## Phase 4.5 — Dimension discovery
@ -493,7 +518,9 @@ Review all agent results. Identify knowledge gaps — dimensions where findings
are thin, contradictory, or missing (**under-illuminated dimensions**). are thin, contradictory, or missing (**under-illuminated dimensions**).
**Standard and low effort — unchanged single pass.** For each significant gap, **Standard and low effort — unchanged single pass.** For each significant gap,
launch a targeted follow-up agent (model: "opus") with a narrow, specific launch a targeted follow-up agent (model: phase_signal_result.model, else opus — a
follow-up may be one of Claude Code's built-in agents, which have no Voyage
frontmatter to fall back to; see Launch rules) with a narrow, specific
brief; a follow-up that searches the web names the `researching-sources` skill brief; a follow-up that searches the web names the `researching-sources` skill
in its prompt, as above. Maximum 2 follow-ups. If no gaps exist, skip: "Initial research in its prompt, as above. Maximum 2 follow-ups. If no gaps exist, skip: "Initial research
sufficient — no follow-ups needed." Then go to Phase 6. sufficient — no follow-ups needed." Then go to Phase 6.
@ -621,6 +648,10 @@ reformulated, not forced through. A gate that *could not run* is neither — no
rewrite can clear it, so treat it as a hard stop and say so, rather than rewrite can clear it, so treat it as a hard stop and say so, rather than
rewriting a query that was never the problem. rewriting a query that was never the problem.
A turn that spawns an agent passes `model: phase_signal_result.model`, else
`opus` — the agent may be one of Claude Code's built-in agents, which have no
Voyage frontmatter to fall back to; see Launch rules.
**Empty turns.** A turn that returns no findings, or findings without **Empty turns.** A turn that returns no findings, or findings without
citations, is marked `empty`. An empty turn is counted in `empty_turns` and citations, is marked `empty`. An empty turn is counted in `empty_turns` and
does NOT re-target the same dimension — re-asking the same question of the does NOT re-target the same dimension — re-asking the same question of the
@ -731,7 +762,10 @@ raw report. Specifically:
- Reduce the report to ≥ 1 `### {Dimension} -- Confidence: {high|medium|low|contradictory}` - Reduce the report to ≥ 1 `### {Dimension} -- Confidence: {high|medium|low|contradictory}`
entry, each carrying **External findings** bullets with per-claim source URLs. entry, each carrying **External findings** bullets with per-claim source URLs.
Local findings still come from the local agents (Phase 4) and are merged in per Local findings still come from the local agents (Phase 4) and are merged in per
dimension as usual. dimension as usual. Wave 2's `contrarian-researcher` findings (Launch rules)
are not part of the report: they are merged in per dimension as **External
findings**, cited by URL like the report's, and where one disputes the
report's leading answer, the dimension's **Contradictions** gives both sides.
- Emit a numeric `confidence ∈ [0,1]` in frontmatter and a 3-sentence - Emit a numeric `confidence ∈ [0,1]` in frontmatter and a 3-sentence
`## Executive Summary` (answer, confidence, key caveat). `## Executive Summary` (answer, confidence, key caveat).
- Populate `## Sources` from the report's citations. - Populate `## Sources` from the report's citations.
@ -867,9 +901,7 @@ Examples:
VOYAGE_PROFILE=balanced /trekresearch VOYAGE_PROFILE=balanced /trekresearch
``` ```
Stats records emit `profile`, `phase_models`, `parallel_agents`, Stats records emit `engine` so operators can audit which engine drove which session.
`external_research_enabled`, `profile_source`, and `engine` so operators can
audit which profile and engine drove which session.
## Composition rule (v5.1) ## Composition rule (v5.1)
@ -914,12 +946,11 @@ commands surface, don't re-enforce.
### High-effort behavior (v5.1.1) ### High-effort behavior (v5.1.1)
When `phase_signal_result.effort == 'high'` for the `research` phase, When `phase_signal_result.effort == 'high'` for the `research` phase,
run the FULL swarm regardless of normal triggering rules: 5 local run the FULL swarm regardless of normal selection rules: 5 local
agents + 4 external agents, AND force `contrarian-researcher` to agents + 4 external agents, AND force `contrarian-researcher` to
always-on. Normally `contrarian-researcher` triggers conditionally when always-on: in high-effort mode `contrarian-researcher` runs
a leading recommendation is emerging from initial agents; in unconditionally, as wave 2 (see Launch rules), so the final brief
high-effort mode it runs unconditionally so the final brief always always carries an adversarial counter-evidence pass.
carries an adversarial counter-evidence pass.
The former third-party second-opinion bridge pass is **unavailable, The former third-party second-opinion bridge pass is **unavailable,
skipped** (dropped in v5.10.1 by operator directive). High effort no skipped** (dropped in v5.10.1 by operator directive). High effort no
@ -934,7 +965,9 @@ turns per under-illuminated dimension, budgeted by
`--local`, `external_research_enabled: false`) overrides the effort `--local`, `external_research_enabled: false`) overrides the effort
signal whenever the external phase does not run at all. signal whenever the external phase does not run at all.
Standard effort (or absent): use the existing conditional triggers. Standard effort (or absent): the selected agents run as in Phase 4, and
`contrarian-researcher`, when selected, runs as wave 2 after the other
agents return (see Launch rules).
Low effort: inline research only, no agent swarm (existing Low effort: inline research only, no agent swarm (existing
`--quick`-equivalent code-path). `--quick`-equivalent code-path).

View file

@ -46,10 +46,13 @@ command executes inline.
## Phase 1 — Parse mode and validate input ## Phase 1 — Parse mode and validate input
Parse the arguments with the shared arg-parser. Run it first, exactly like Parse the arguments with the shared arg-parser. Run it first, exactly like
this, before anything else: this, before anything else (the arguments reach the parser as the one line
inside the quoted heredoc, on stdin, never as shell words):
```bash ```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekreview -- $ARGUMENTS node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekreview --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'
$ARGUMENTS
VOYAGE_ARGS_EOF_7F3C9E
``` ```
It prints the parse as JSON on stdout. It also checks the required flag It prints the parse as JSON on stdout. It also checks the required flag
@ -62,7 +65,7 @@ FLAG_SCHEMA `trekreview` entry):
|------|------|---------| |------|------|---------|
| `--project <dir>` | valued | Required. Path to trekplan project folder containing `brief.md`. | | `--project <dir>` | valued | Required. Path to trekplan project folder containing `brief.md`. |
| `--since <ref>` | valued | Optional. Override "before" SHA for the diff. Validated via `git rev-parse --verify`. | | `--since <ref>` | valued | Optional. Override "before" SHA for the diff. Validated via `git rev-parse --verify`. |
| `--quick` | boolean | Skip the brief-conformance pass; run only the code-correctness reviewer; skip the coordinator's reasonableness filter. | | `--quick` | boolean | Skip the brief-conformance pass; run only the code-correctness reviewer; skip the coordinator's reasonableness filter except the brief-conflict rule. |
| `--validate` | boolean | Schema-only check on existing `{project_dir}/review.md`. No LLM calls. | | `--validate` | boolean | Schema-only check on existing `{project_dir}/review.md`. No LLM calls. |
| `--dry-run` | boolean | Print the discovered scope and triage map. Skip writes. | | `--dry-run` | boolean | Print the discovered scope and triage map. Skip writes. |
| `--fg` | boolean | No-op alias (foreground is default). | | `--fg` | boolean | No-op alias (foreground is default). |
@ -184,8 +187,8 @@ every file from Phase 3 into a treatment bucket:
| Treatment | When | | Treatment | When |
|-----------|------| |-----------|------|
| `skip` | Matches `*.lock`, `*.svg`, `dist/**`, `build/**`, `node_modules/**`, OR the file's first 3 lines contain a generated-file marker (`@generated`, `Code generated by`, `DO NOT EDIT`). | | `skip` | Matches `*.lock`, `*.svg`, `dist/**`, `build/**`, `node_modules/**`, OR the file's first 3 lines contain a generated-file marker (`@generated`, `Code generated by`, `DO NOT EDIT`). |
| `deep-review` | Matches `auth/**`, `crypto/**`, `**/security/**`, `hooks/**`. | | `deep-review` | Default for every changed file not in the other two rows — code, tests, `commands/`, `agents/`, `skills/` Markdown, and the instruction files `CLAUDE.md`, `AGENTS.md` and `.claude/rules/**`. |
| `summary-only` | Default treatment for everything else. | | `summary-only` | `docs/**`, Markdown files at the repo root other than `CLAUDE.md` and `AGENTS.md` (README, CHANGELOG), `tests/fixtures/**`. |
Hard refuse-with-suggestion gates — use `AskUserQuestion`: Hard refuse-with-suggestion gates — use `AskUserQuestion`:
@ -510,8 +513,15 @@ The coordinator runs the 4-pass process documented in
Succinctness never removes a finding: only MINOR/SUGGESTION text is Succinctness never removes a finding: only MINOR/SUGGESTION text is
trimmed, and every trim is counted. A long BLOCKER or MAJOR keeps every trimmed, and every trim is counted. A long BLOCKER or MAJOR keeps every
character. character.
3. **Cloudflare reasonableness** — remove speculative or catalogue-violating 3. **Cloudflare reasonableness** (skipped in `quick` mode) — drop a finding
findings (skipped in `quick` mode). with no `file:line` citation (`no-citation`) or whose file is in neither
the working tree nor the diff (`file-existence:refuted`); route one whose
`rule_key` is not in `RULE_CATALOGUE` (`unknown-rule_key`) or whose file
cannot be checked (`file-existence:indeterminate`) to `unverified`, which
forbids `ALLOW`; reset a severity that does not match its catalogue tier.
Skipped at high effort too, where an unknown `rule_key` is normalized to
`PLAN_EXECUTE_DRIFT` instead (`### High-effort behavior (v5.1.1)` below);
the brief-conflict rule still applies in every mode.
4. **Verdict** — BLOCK / WARN / ALLOW per the threshold table. 4. **Verdict** — BLOCK / WARN / ALLOW per the threshold table.
**Fail-closed.** Every removal in Pass 2 and Pass 3 is either **Fail-closed.** Every removal in Pass 2 and Pass 3 is either
@ -559,11 +569,8 @@ Workflow({
A reviewer that returns nothing, an engine listed in `engineNotRun`, or a coordinator that returns no verdict and findings forbids `ALLOW` on this path too (`missing_reviewers` in the result), and the result's `allow_blocked_by` names each one (`missing-reviewer:<name>`, the coordinator as `missing-reviewer:review-coordinator`); A reviewer that returns nothing, an engine listed in `engineNotRun`, or a coordinator that returns no verdict and findings forbids `ALLOW` on this path too (`missing_reviewers` in the result), and the result's `allow_blocked_by` names each one (`missing-reviewer:<name>`, the coordinator as `missing-reviewer:review-coordinator`);
Phase 7 writes that list as the trailing JSON block's `allow_blocked_by`, so the verdict gate reads such a WARN review as not performed in full (exit 2; a BLOCK stays exit 1). Phase 7 writes that list as the trailing JSON block's `allow_blocked_by`, so the verdict gate reads such a WARN review as not performed in full (exit 2; a BLOCK stays exit 1).
On this path dedup ranks as Pass 1 does (both paths), every engine finding the On this path dedup ranks as Pass 1 does (both paths), and after the coordinator each finding is matched to its own row, its `(file, line, rule_key)` triplet plus its title, trimmed and in lower case: of the engine findings in a row no returned finding is on, the one with the highest severity (on a tie, the first seen) is put back at its own severity (operator decision 23), and a returned finding is never below the lower of its reviewer counterpart's severity and its catalogue tier (floor (b)); its counterpart is its own row, else the strictest row on its triplet.
coordinator did not return at its exact `(file, line, rule_key)` is put back at its own So the workflow path is stricter than the prose path.
severity (operator decision 23), and a returned finding is never below the lower of its
reviewer counterpart's severity and its catalogue tier (floor (b)), so the workflow path
is stricter than the prose path.
Contract (verified in S10 part B — follow exactly): Contract (verified in S10 part B — follow exactly):
- **Pass `args` as a JSON object.** The script defensively re-parses a JSON *string*, but - **Pass `args` as a JSON object.** The script defensively re-parses a JSON *string*, but
@ -695,13 +702,11 @@ the main workflow, but it is reported, not skipped silently.
ANNOT_HTML=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/annotate.mjs "{review_path}" 2>&1) ANNOT_HTML=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/annotate.mjs "{review_path}" 2>&1)
``` ```
`stdout` is the absolute path to the `.html` on success. The HTML renders `stdout` is the absolute path to the `.html` on success: the page where the
`review.md` with line numbers, lets the operator click any line to attach operator writes their own notes on `review.md`. The loop that follows is the
their own note (not Claude-generated suggestions — the operator drives skill `voyage:annotating`: call it when the operator pastes the notes back.
every annotation), keeps a sidebar of all notes, persists state in If `annotate.mjs` exits non-zero, surface a one-line warning and continue —
localStorage, and exposes a "Copy Prompt" button. If `annotate.mjs` the annotation HTML is a convenience, not a gate.
exits non-zero, surface a one-line warning and continue — the annotation
HTML is a convenience, not a gate.
## Phase 8.5 — Validate-only mode (`--validate`) ## Phase 8.5 — Validate-only mode (`--validate`)
@ -821,9 +826,9 @@ To review and annotate the review, open it in a browser:
Click any line to add YOUR OWN note. The sidebar collects every note, Click any line to add YOUR OWN note. The sidebar collects every note,
the "Copy Prompt" button gathers them into one structured prompt. the "Copy Prompt" button gathers them into one structured prompt.
Paste that prompt back into this chat and Claude revises review.md Paste that prompt back into this chat: Claude answers each Question
from your notes. Annotations persist in your browser if you close and revises review.md from your Fix and Change notes. Annotations
the tab and reopen the same file. persist in your browser if you close the tab and reopen the same file.
──────────────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────────────────
You can also: You can also:
@ -858,8 +863,6 @@ Examples:
VOYAGE_PROFILE=premium /trekreview --project ... VOYAGE_PROFILE=premium /trekreview --project ...
``` ```
Stats records emit `profile` and `profile_source`.
## Composition rule (v5.1) ## Composition rule (v5.1)
Independent of the profile system. When `brief.md` carries Independent of the profile system. When `brief.md` carries
@ -903,8 +906,8 @@ readable.
### High-effort behavior (v5.1.1) ### High-effort behavior (v5.1.1)
When `phase_signal_result.effort == 'high'` for the `review` phase, When `phase_signal_result.effort == 'high'` for the `review` phase,
skip Pass 3 (Cloudflare reasonableness filter) in skip Pass 3 (Cloudflare reasonableness filter) in `agents/review-coordinator.md`, except the brief-conflict rule, which applies in every mode.
`agents/review-coordinator.md`. Passes 1, 2, and 4 still run. Passes 1, 2, and 4 still run.
Rationale: high-effort review trusts the operator to weigh borderline Rationale: high-effort review trusts the operator to weigh borderline
findings rather than have the coordinator drop them. To prevent findings rather than have the coordinator drop them. To prevent
unknown `rule_key` values from polluting downstream remediation plans unknown `rule_key` values from polluting downstream remediation plans

View file

@ -148,7 +148,7 @@ with `BRIEF_INVALID_MODEL`.
**Producer:** `/trekresearch` Phase 7 (synthesis + brief writer). **Producer:** `/trekresearch` Phase 7 (synthesis + brief writer).
**Consumer:** `/trekplan` Phase 1 (project-dir auto-discovery) + `planning-orchestrator` (consumes findings as context). **Consumer:** `/trekplan` Phase 1 (project-dir auto-discovery) + `/trekplan` Phase 3 and Phase 7 (consumes findings as context).
**Path conventions:** **Path conventions:**
- Project-dir mode: `.claude/projects/{YYYY-MM-DD}-{slug}/research/{NN}-{topic-slug}.md` (sorted by filename). - Project-dir mode: `.claude/projects/{YYYY-MM-DD}-{slug}/research/{NN}-{topic-slug}.md` (sorted by filename).
@ -189,7 +189,7 @@ Optional: `## Local Context`, `## External Knowledge`, `## Triangulation`, `## S
**Producer:** external opt-in architect plugin (no longer publicly distributed). **Producer:** external opt-in architect plugin (no longer publicly distributed).
**Consumer:** `/trekplan` Phase 1 (architecture-discovery) + `planning-orchestrator` Phase 7 (cross-reference architecture-note as priors during synthesis). **Consumer:** `/trekplan` Phase 1 (architecture-discovery) + `/trekplan` Phase 7 (cross-reference architecture-note as priors during synthesis).
**Path conventions:** **Path conventions:**
- Canonical: `{project_dir}/architecture/overview.md` - Canonical: `{project_dir}/architecture/overview.md`
@ -221,7 +221,7 @@ The validator (`lib/validators/architecture-discovery.mjs`) is intentionally min
## Handover 4 — `plan.md` → execute ## Handover 4 — `plan.md` → execute
**Producer:** `planning-orchestrator` Phase 5 (plan synthesis) + Phase 5.5 (schema self-check via `plan-validator --strict`). **Producer:** `/trekplan` Phase 8 (plan synthesis, then the schema self-check via `plan-validator --strict`).
**Consumer:** `/trekexecute` Phase 2 (plan parsing) + `--validate` mode. **Consumer:** `/trekexecute` Phase 2 (plan parsing) + `--validate` mode.
@ -280,7 +280,7 @@ prose form (relaxed in S26 so the template's prose emission parses).
**Validation strategy:** **Validation strategy:**
The strongest validation in the entire pipeline. Phase 5.5 (planning-orchestrator) **must** run `plan-validator --strict` before handing the plan to plan-critic. `--validate` mode of `/trekexecute` runs the same check + `progress-validator`. The strongest validation in the entire pipeline. `/trekplan` Phase 8 **must** run `plan-validator --strict` before handing the plan to plan-critic. `--validate` mode of `/trekexecute` runs the same check + `progress-validator`.
| Code | Meaning | Recovery | | Code | Meaning | Recovery |
|---|---|---| |---|---|---|
@ -358,6 +358,7 @@ Rule B is stated by the reviewers and recorded by the gate from round 1 (`lowere
| `session_end_sha` | string | optional | git sha at execute end | | `session_end_sha` | string | optional | git sha at execute end |
| `steps` | object | yes | Map of step number → step record | | `steps` | object | yes | Map of step number → step record |
| `end_to_end` | string | optional | `verified \| not-verified` — a passing run-the-thing criterion in the plan's `## Verification`; `not-verified` turns `completed` into `partial` (wave 1, P9 (6)) | | `end_to_end` | string | optional | `verified \| not-verified` — a passing run-the-thing criterion in the plan's `## Verification`; `not-verified` turns `completed` into `partial` (wave 1, P9 (6)) |
| `run_seen` | string \| null | optional | `yes \| no — {reason}` — what Claude Code's built-in `run` showed, recorded in Phase 7; `null` = not run; additive-optional (fix round 4.5b): a file without it validates and resumes, no `schema_version` bump; Phase 8 reads it from here, so the resume and multi-session paths report it |
| `decisions` | array | optional | `[{step, decision, cost_if_wrong}]` — every call the executor made that the plan did not dictate; both strings non-empty (wave 1, P4) | | `decisions` | array | optional | `[{step, decision, cost_if_wrong}]` — every call the executor made that the plan did not dictate; both strings non-empty (wave 1, P4) |
**Per-step record:** **Per-step record:**
@ -368,7 +369,7 @@ Rule B is stated by the reviewers and recorded by the gate from round 1 (`lowere
| `attempts` | number | 1..N | | `attempts` | number | 1..N |
| `error` | string \| null | | | `error` | string \| null | |
| `completed_at` | ISO string \| null | | | `completed_at` | ISO string \| null | |
| `commit` | string \| null | git sha after Checkpoint | | `commit` | string \| null | full commit id (`git rev-parse HEAD`) after Checkpoint |
| `manifest_audit` | string | `pass \| fail \| pass-with-note \| n/a` | | `manifest_audit` | string | `pass \| fail \| pass-with-note \| n/a` |
| `note` | string | optional human-readable annotation | | `note` | string | optional human-readable annotation |
| `start_sha` | string \| null | optional (wave 1) — git sha the step started from; every check of the step reads `git diff {start_sha}` | | `start_sha` | string \| null | optional (wave 1) — git sha the step started from; every check of the step reads `git diff {start_sha}` |
@ -431,7 +432,7 @@ missing `exit_code` (C15, `PROGRESS_FAILED_CRITERIA_KEY`).
**Consumer:** `/trekplan` Phase 1 when `--brief review.md` is supplied and the consumer detects `type: trekreview` in frontmatter. The plan command branches into a remediation-plan path: BLOCKER + MAJOR findings become plan goals, the produced `plan.md` carries a `source_findings: [<id>, ...]` frontmatter list as the audit trail back to the consumed findings. MINOR + SUGGESTION are skipped for plan-input. Since 1.1 every BLOCKER is also written as a numbered `### Step R<n>` under `## Remediation Steps` (Files · Source finding · Test first · Verify · On failure), so a consumer can append it as a plan step without rewording it. **Consumer:** `/trekplan` Phase 1 when `--brief review.md` is supplied and the consumer detects `type: trekreview` in frontmatter. The plan command branches into a remediation-plan path: BLOCKER + MAJOR findings become plan goals, the produced `plan.md` carries a `source_findings: [<id>, ...]` frontmatter list as the audit trail back to the consumed findings. MINOR + SUGGESTION are skipped for plan-input. Since 1.1 every BLOCKER is also written as a numbered `### Step R<n>` under `## Remediation Steps` (Files · Source finding · Test first · Verify · On failure), so a consumer can append it as a plan step without rewording it.
**Second consumer — the verdict gate (K7, 1.1):** `lib/review/verdict-gate.mjs <review.md>` reads the frontmatter `verdict` and exits 0 = ALLOW/WARN, 1 = BLOCK, 2 = could not decide. Exit 2 is never green: a missing file, a missing or unknown verdict, two verdict lines, a trailing JSON block that is not the last fence or lacks `verdict`/`counts`/`findings`, and a file whose JSON block, `## Findings (BLOCKER)` section or finding-id list contradicts a greener verdict all land there — and so does a WARN that exists because a reviewer or engine did not run (`missing-reviewer:` / `unattributable-payload` in the JSON block's `allow_blocked_by`, which a 1.1 review must carry): a review not performed in full stops a merge (operator decision 2026-10-06). Exactly one file per call. Whatever stops a merge or a release on a review reads this exit code, never a summary. Borrowed: the verdict goes to a file and a command gates on it (Kiro `feature-pipeline`); the executor reads the verdict but never writes it (Spec Kit). **Second consumer — the verdict gate (K7, 1.1):** `lib/review/verdict-gate.mjs <review.md>` reads the frontmatter `verdict` and exits 0 = ALLOW/WARN, 1 = BLOCK, 2 = could not decide. Exit 2 is never green: a missing file, a missing or unknown verdict, two verdict lines, a trailing JSON block that is not the last fence or lacks `verdict`/`counts`/`findings`, and a file whose JSON block, `## Findings (BLOCKER)` section or finding-id list contradicts a greener verdict all land there — and so does a WARN that exists because a reviewer or engine did not run (`missing-reviewer:` / `unattributable-payload` in the JSON block's `allow_blocked_by`, which a 1.1 review must carry): a review not performed in full stops a merge (operator decision 2026-10-06). Exactly one file per call. With `--repo <dir>` (`verdict-gate.mjs --json <review.md> --repo <dir>`, R6) an ALLOW/WARN review must also be of the commit being merged in `<dir>`: its `scope_sha_end`, read only as a full 40- or 64-hex commit id (a short id or a ref name is stale), is HEAD, or an ancestor of HEAD whose later commits change only the review lessons (`.claude/rules/voyage-review-lessons.md`, inside its managed block) and the `CLAUDE.md` pointer (`lib/review/reviewed-commit.mjs`); otherwise exit 2 with a reason that starts `review-stale:`, which `/trekexecute`'s merge lock (Phase 7.2 step 4 and Phase 8, both with `--repo .`) maps to the decline reason `review-stale`. When the gate cannot look (the `--repo` path is missing, it is no git repository, or a git call failed), the exit is 2 with a reason that starts `review-not-measured:`, never `review-stale:`: the merge lock reports it as not measured, never green. A `--repo` with no value is exit 2 as well; BLOCK stays exit 1. Whatever stops a merge or a release on a review reads this exit code, never a summary. Borrowed: the verdict goes to a file and a command gates on it (Kiro `feature-pipeline`); the executor reads the verdict but never writes it (Spec Kit).
**Path conventions:** **Path conventions:**
- Project-dir mode (recommended): `{project_dir}/review.md` (one per review iteration; subsequent runs overwrite atomically). - Project-dir mode (recommended): `{project_dir}/review.md` (one per review iteration; subsequent runs overwrite atomically).
@ -442,12 +443,12 @@ missing `exit_code` (C15, `PROGRESS_FAILED_CRITERIA_KEY`).
| Field | Type | Required | Allowed values | Notes | | Field | Type | Required | Allowed values | Notes |
|---|---|---|---|---| |---|---|---|---|---|
| `type` | string | yes | `trekreview` | Hard-coded discriminator | | `type` | string | yes | `trekreview` | Hard-coded discriminator |
| `review_version` | string | yes | `"1.0"` (current) | Bump on schema change | | `review_version` | string | yes | `"1.1"` (current) | Bump on schema change |
| `task` | string | yes | one-line description | Mirrors brief task | | `task` | string | yes | one-line description | Mirrors brief task |
| `slug` | string | yes | URL-safe slug | Used in project_dir | | `slug` | string | yes | URL-safe slug | Used in project_dir |
| `project_dir` | string | yes | `.claude/projects/{date}-{slug}/` | | | `project_dir` | string | yes | `.claude/projects/{date}-{slug}/` | |
| `brief_path` | string | yes | path to consumed `brief.md` | Audit trail back to brief | | `brief_path` | string | yes | path to consumed `brief.md` | Audit trail back to brief |
| `scope_sha_end` | string | yes | git sha of HEAD at review time | Defines "after" boundary | | `scope_sha_end` | string | yes | git sha of HEAD at review time, as a full 40- or 64-hex commit id | Defines "after" boundary. The verdict gate reads only a full 40- or 64-hex commit id (M11): a short id or a ref name can also be a branch, which resolves to wherever it points now |
| `reviewed_files_count` | number | yes | ≥ 0 | From triage gate Coverage | | `reviewed_files_count` | number | yes | ≥ 0 | From triage gate Coverage |
| `findings` | list | yes | block-style YAML list of 40-char hex IDs; `[]` when empty | Flat array; full objects in body. A bare `findings:` is null and fails | | `findings` | list | yes | block-style YAML list of 40-char hex IDs; `[]` when empty | Flat array; full objects in body. A bare `findings:` is null and fails |
| `verdict` | string | yes (1.1; strict at write, warning at read) | `BLOCK \| WARN \| ALLOW` | Coordinator output; equals the trailing JSON block's `verdict` (K7) | | `verdict` | string | yes (1.1; strict at write, warning at read) | `BLOCK \| WARN \| ALLOW` | Coordinator output; equals the trailing JSON block's `verdict` (K7) |
@ -484,7 +485,7 @@ The gate cannot check an `engine` label on the prose path: there the coordinator
the workflow path (`--workflow`) takes `engine` from the payload source and writes it into the returned finding. the workflow path (`--workflow`) takes `engine` from the payload source and writes it into the returned finding.
`runContract` (`lib/review/coordinator-contract.mjs`) likewise takes `engine` from the payload a finding arrived in, never from the finding's own field, `runContract` (`lib/review/coordinator-contract.mjs`) likewise takes `engine` from the payload a finding arrived in, never from the finding's own field,
and in dedup the survivor is ranked by catalogue tier (for a catalogue key it always ties inside one triplet), so a reviewer-agent finding survives over an engine finding and carries the highest severity the group asserted; an engine's mark never lowers what a reviewer agent asserted. and in dedup the survivor is ranked by catalogue tier (for a catalogue key it always ties inside one triplet), so a reviewer-agent finding survives over an engine finding and carries the highest severity the group asserted; an engine's mark never lowers what a reviewer agent asserted.
On the workflow path dedup ranks as Pass 1 does (both paths), every engine finding the coordinator did not return at its exact `(file, line, rule_key)` is put back at its own severity (operator decision 23), and a returned finding is never below the lower of its reviewer counterpart's severity and its catalogue tier (floor (b)), so the workflow path is stricter than the prose path; a coordinator that returns no verdict and findings counts as a missing reviewer there (`missing-reviewer:review-coordinator` in `allow_blocked_by`), and its findings are read at their catalogue tier or higher, so the review is at least WARN and the verdict gate exits 2 on such a WARN (a BLOCK stays exit 1). On the workflow path dedup ranks as Pass 1 does (both paths), and after the coordinator each finding is matched to its own row, its `(file, line, rule_key)` triplet plus its title, trimmed and in lower case: of the engine findings in a row no returned finding is on, the one with the highest severity (on a tie, the first seen) is put back at its own severity (operator decision 23), and a returned finding is never below the lower of its reviewer counterpart's severity and its catalogue tier (floor (b)); its counterpart is its own row, else the strictest row on its triplet, so the workflow path is stricter than the prose path; a coordinator that returns no verdict and findings counts as a missing reviewer there (`missing-reviewer:review-coordinator` in `allow_blocked_by`), and its findings are read at their catalogue tier or higher, so the review is at least WARN and the verdict gate exits 2 on such a WARN (a BLOCK stays exit 1).
A plan made from the review skips a lowered finding: it is MINOR, and `/trekplan` takes BLOCKER and MAJOR findings only. A plan made from the review skips a lowered finding: it is MINOR, and `/trekplan` takes BLOCKER and MAJOR findings only.
**Validation strategy:** **Validation strategy:**

View file

@ -19,7 +19,7 @@ Imported from `CLAUDE.md` via pointer.
Wiring points (replaces previous prose-grep instructions): Wiring points (replaces previous prose-grep instructions):
- `/trekbrief` Phase 4g → `brief-validator` (post-write sanity check) - `/trekbrief` Phase 4g → `brief-validator` (post-write sanity check)
- `/trekplan` Phase 1 → `brief-validator --soft`, `research-validator --dir`, `architecture-discovery` - `/trekplan` Phase 1 → `brief-validator --soft`, `research-validator --dir`, `architecture-discovery`
- `planning-orchestrator` Phase 5.5 → `plan-validator --strict` (replaces 3 `grep -cE` calls) - `/trekplan` Phase 8 → `plan-validator --strict` (replaces 3 `grep -cE` calls)
- `/trekexecute --validate` → `plan-validator --strict` + `progress-validator` - `/trekexecute --validate` → `plan-validator --strict` + `progress-validator`
Tests under `tests/**/*.test.mjs` (~290 tests, 0 deps). `npm test` is the fork-readiness gate. v3.4.0 adds: synthetic determinism fixtures (`tests/synthetic/plan-run-*.md` + `review-run-*.md` + companion `*-determinism.test.mjs` enforcing Jaccard ≥ 0.833 SC7 floor) and hook baseline regression pins (`tests/hooks/{path-guard,bash-guard}.test.mjs` exercising `pre-write-executor.mjs` + `pre-bash-executor.mjs` denylist BLOCK paths). Tests under `tests/**/*.test.mjs` (~290 tests, 0 deps). `npm test` is the fork-readiness gate. v3.4.0 adds: synthetic determinism fixtures (`tests/synthetic/plan-run-*.md` + `review-run-*.md` + companion `*-determinism.test.mjs` enforcing Jaccard ≥ 0.833 SC7 floor) and hook baseline regression pins (`tests/hooks/{path-guard,bash-guard}.test.mjs` exercising `pre-write-executor.mjs` + `pre-bash-executor.mjs` denylist BLOCK paths).
@ -42,7 +42,7 @@ Doc-consistency test at `tests/lib/doc-consistency.test.mjs` pins agent-table co
**Brief:** 7-phase workflow: Parse mode → Create project dir → Phase 3 completeness loop (section-driven, no question cap) → Phase 3.5 per-phase effort dialog (v5.1) → Phase 4 draft/review/revise with `brief-reviewer` as stop-gate (max 3 iterations; gate = all dimensions ≥ 4 and research plan = 5) → Finalize (`brief.md` on pass, or `brief_quality: partial` on cap/force-stop) → Manual/auto opt-in → Stats. Always interactive. Auto mode runs research + plan inline in the main context (v2.4.0). **Brief:** 7-phase workflow: Parse mode → Create project dir → Phase 3 completeness loop (section-driven, no question cap) → Phase 3.5 per-phase effort dialog (v5.1) → Phase 4 draft/review/revise with `brief-reviewer` as stop-gate (max 3 iterations; gate = all dimensions ≥ 4 and research plan = 5) → Finalize (`brief.md` on pass, or `brief_quality: partial` on cap/force-stop) → Manual/auto opt-in → Stats. Always interactive. Auto mode runs research + plan inline in the main context (v2.4.0).
**Phase 3.5 (v5.1) — adaptive-depth signals:** Between Phase 3 completeness exit and Phase 4 draft, the operator commits an effort level (`low | standard | high`) and an optional `model` (`sonnet | opus | fable`) per downstream phase (`research`, `plan`, `execute`, `review`) via 4 tier-coupled `AskUserQuestion` calls. The choices land in `brief.md` frontmatter as `phase_signals:` (a list of `{phase, effort?, model?}` entries) when committed, or `phase_signals_partial: true` when the operator force-stops. `brief_version: 2.1` activates the **sequencing gate**: validator emits `BRIEF_V51_MISSING_SIGNALS` if a 2.1-versioned brief lacks both fields. Downstream commands surface a friendly hint pointing back to `/trekbrief` — enforcement is validator-only. Composition is documented prose in each downstream command's `## Composition rule (v5.1)` section: `brief.phase_signals[phase] > profile.phase_models[phase]`. The brief signal wins per-phase when present; the profile fills gaps. `effort == low` activates each command's existing `--quick`-equivalent code-path (`/trekexecute` low-effort = `--gates open` + sequential-only). High-effort behavior is deferred to v5.1.1 per brief Non-Goal. **Phase 3.5 (v5.1) — adaptive-depth signals:** Between Phase 3 completeness exit and Phase 4 draft, the operator commits an effort level (`low | standard | high`) and an optional `model` (`sonnet | opus | fable`) per downstream phase (`research`, `plan`, `execute`, `review`) via 4 tier-coupled `AskUserQuestion` calls. The choices land in `brief.md` frontmatter as `phase_signals:` (a list of `{phase, effort?, model?}` entries) when committed, or `phase_signals_partial: true` when the operator force-stops. `brief_version: 2.1` activates the **sequencing gate**: validator emits `BRIEF_V51_MISSING_SIGNALS` if a 2.1-versioned brief lacks both fields. Downstream commands surface a friendly hint pointing back to `/trekbrief` — enforcement is validator-only. Composition is documented prose in each downstream command's `## Composition rule (v5.1)` section: `brief.phase_signals[phase] > profile.phase_models[phase]`. The brief signal wins per-phase when present; the profile fills gaps. `effort == low` activates each command's existing `--quick`-equivalent code-path (`/trekexecute` low effort = one session at a time). High-effort behavior is deferred to v5.1.1 per brief Non-Goal.
**Research:** Foreground workflow (v2.4.0): Parse mode → Interview → Parallel research swarm (5 local + 4 external + 1 bridge, spawned from main context) → Follow-ups → Triangulation → Synthesis + brief → Stats. With `--project`, writes to `{dir}/research/NN-slug.md`. **Research:** Foreground workflow (v2.4.0): Parse mode → Interview → Parallel research swarm (5 local + 4 external + 1 bridge, spawned from main context) → Follow-ups → Triangulation → Synthesis + brief → Stats. With `--project`, writes to `{dir}/research/NN-slug.md`.

View file

@ -8,7 +8,7 @@ Per-command flag tables, imported from `CLAUDE.md` via pointer.
|------|----------| |------|----------|
| _(default)_ | Question round sized by class (`small` / `bounded` / `architectural`), brief-review gate, approval → brief.md with research plan | | _(default)_ | Question round sized by class (`small` / `bounded` / `architectural`), brief-review gate, approval → brief.md with research plan |
| `--quick` | Proposes `small` as the class (still said aloud, overridable, raised when the task proves bigger) → brief.md with research plan | | `--quick` | Proposes `small` as the class (still said aloud, overridable, raised when the task proves bigger) → brief.md with research plan |
| `--gates {true\|false}` | (v3.4.0) Boolean autonomy-gate flag; present → gating on. Policy (`gates_mode`) detailed under `## Autonomy mode` in `docs/operations.md`. | | `--gates [true\|false\|closed\|open]` | (v3.4.0) Autonomy flag: bare, `true` or `closed` = on; `false` or `open` = off; any other next token is not its value. See `## Autonomy mode` in `docs/operations.md`. |
| `--profile <name>` | (v4.1.0) Model profile: `economy` / `balanced` / `premium` / `fable` / `<custom>`. Sets `phase_models` for the brief phase. See `## Profile system` in `docs/operations.md`. | | `--profile <name>` | (v4.1.0) Model profile: `economy` / `balanced` / `premium` / `fable` / `<custom>`. Sets `phase_models` for the brief phase. See `## Profile system` in `docs/operations.md`. |
| `--approve <project-dir \| brief-file>` | (veikart steg 1) No interview: show the existing brief's `## Intent` + `## Goal` verbatim, ask the operator to approve, and on "Approve" stamp `intent_approved_hash` + `intent_approved_at`, emit `brief-approved`, and write the trekbrief stats record (`"mode": "approve"`). The remedy `/trekplan` names when it halts on `BRIEF_INTENT_NOT_APPROVED` / `BRIEF_INTENT_APPROVAL_STALE`. | | `--approve <project-dir \| brief-file>` | (veikart steg 1) No interview: show the existing brief's `## Intent` + `## Goal` verbatim, ask the operator to approve, and on "Approve" stamp `intent_approved_hash` + `intent_approved_at`, emit `brief-approved`, and write the trekbrief stats record (`"mode": "approve"`). The remedy `/trekplan` names when it halts on `BRIEF_INTENT_NOT_APPROVED` / `BRIEF_INTENT_APPROVAL_STALE`. |
@ -24,7 +24,7 @@ Always interactive. Phase 3 puts the task in a size class (`ceremony:`, K1; heav
| `--local` | Only codebase analysis agents (skip external research) | | `--local` | Only codebase analysis agents (skip external research) |
| `--external` | Only external research agents (skip codebase analysis) | | `--external` | Only external research agents (skip codebase analysis) |
| `--fg` | No-op alias (foreground is default since v2.4.0) | | `--fg` | No-op alias (foreground is default since v2.4.0) |
| `--gates {true\|false}` | (v3.4.0) Boolean autonomy-gate flag; present → gating on. Policy (`gates_mode`) detailed under `## Autonomy mode` in `docs/operations.md`. | | `--gates [true\|false\|closed\|open]` | (v3.4.0) Autonomy flag: bare, `true` or `closed` = on; `false` or `open` = off; any other next token is not its value. See `## Autonomy mode` in `docs/operations.md`. |
| `--min-brief-version <ver>` | (S18) Warn — never block — if an attached `--project` brief declares a version below `<ver>` (e.g. `2.2`), i.e. sidesteps framing enforcement | | `--min-brief-version <ver>` | (S18) Warn — never block — if an attached `--project` brief declares a version below `<ver>` (e.g. `2.2`), i.e. sidesteps framing enforcement |
| `--profile <name>` | (v4.1.0) Model profile for the research phase. | | `--profile <name>` | (v4.1.0) Model profile for the research phase. |
| `--engine {swarm\|deep-research}` | (deep-research-engine) Opt-in external-research engine; `deep-research` delegates the external phase to Claude Code's built-in `/deep-research` workflow, which only works on `2.1.154 <= CC < 2.1.218` — from **2.1.218** `/deep-research` is operator-invoked only (the Skill tool refuses with `disable-model-invocation`), so the engine always falls back to `swarm`. Never hard-fails. Default `swarm`. | | `--engine {swarm\|deep-research}` | (deep-research-engine) Opt-in external-research engine; `deep-research` delegates the external phase to Claude Code's built-in `/deep-research` workflow, which only works on `2.1.154 <= CC < 2.1.218` — from **2.1.218** `/deep-research` is operator-invoked only (the Skill tool refuses with `disable-model-invocation`), so the engine always falls back to `swarm`. Never hard-fails. Default `swarm`. |
@ -59,7 +59,7 @@ protocol, thresholds, and the exact commands in
| `--min-brief-version <ver>` | (S18) Warn — never block — if the brief declares a version below `<ver>` (e.g. `2.2`), i.e. sidesteps framing enforcement | | `--min-brief-version <ver>` | (S18) Warn — never block — if the brief declares a version below `<ver>` (e.g. `2.2`), i.e. sidesteps framing enforcement |
| `--export headless <plan>` | Legacy alias for `--decompose` — the only remaining export format (the `pr` / `issue` / `markdown` variants were removed; Claude reformats a plan ad-hoc on request) | | `--export headless <plan>` | Legacy alias for `--decompose` — the only remaining export format (the `pr` / `issue` / `markdown` variants were removed; Claude reformats a plan ad-hoc on request) |
| `--decompose <plan>` | Split plan into self-contained session specs + `orders.md` (one starter line per session) | | `--decompose <plan>` | Split plan into self-contained session specs + `orders.md` (one starter line per session) |
| `--gates {true\|false}` | (v3.4.0) Boolean autonomy-gate flag; present → gating on. Policy (`gates_mode`) detailed under `## Autonomy mode` in `docs/operations.md`. | | `--gates [true\|false\|closed\|open]` | (v3.4.0) Autonomy flag: bare, `true` or `closed` = on; `false` or `open` = off; any other next token is not its value. See `## Autonomy mode` in `docs/operations.md`. |
| `--profile <name>` | (v4.1.0) Model profile for the plan phase (and others, since plan emits `profile:` to plan.md frontmatter). | | `--profile <name>` | (v4.1.0) Model profile for the plan phase (and others, since plan emits `profile:` to plan.md frontmatter). |
**Breaking change (v2.0):** one of `--brief` or `--project` is required. There is no interview inside `/trekplan`. The `--spec` flag has been removed — use `/trekbrief` to produce a brief instead. **Breaking change (v2.0):** one of `--brief` or `--project` is required. There is no interview inside `/trekplan`. The `--spec` flag has been removed — use `/trekbrief` to produce a brief instead.
@ -80,8 +80,8 @@ If `{project_dir}/architecture/overview.md` exists (typically produced by an opt
| `--step N` | Execute only step N | | `--step N` | Execute only step N |
| `--fg` | Force foreground — run all steps sequentially, ignore Execution Strategy | | `--fg` | Force foreground — run all steps sequentially, ignore Execution Strategy |
| `--session N` | Execute only session N from plan's Execution Strategy | | `--session N` | Execute only session N from plan's Execution Strategy |
| `--gates {true\|false}` | (v3.4.0) Boolean autonomy-gate flag; present → gating on. Policy (`gates_mode`) detailed under `## Autonomy mode` in `docs/operations.md`. It adds no question of its own before a merge: the guard asks there (operator decision 2026-10-08). | | `--gates [true\|false\|closed\|open]` | (v3.4.0) Autonomy flag: bare, `true` or `closed` = on; `false` or `open` = off; any other next token is not its value. See `## Autonomy mode` in `docs/operations.md`. `/trekexecute` accepts the flag and pauses on nothing; it adds no question of its own before a merge: the guard asks there (operator decision 2026-10-08). |
| `--profile <name>` | (v4.1.0) Model profile for the execute phase. Inherited from plan.md frontmatter `profile:` if present. | | `--profile <name>` | (v4.1.0) Profile for this run's resolver; the step helper and the controller stay on opus. Inherited from plan.md frontmatter `profile:` if present. |
Every mode that executes refuses a plan whose approval in the approval log does not cover its current sha256 (K2). A run ends with the review it starts by itself (`/simplify` → `/trekreview` → verdict gate); a BLOCK or an undecided verdict keeps the merge into main closed. `TREKEXECUTE_MAX_REVIEW_ROUNDS` (default 3) caps the reviews per run. Every mode that executes refuses a plan whose approval in the approval log does not cover its current sha256 (K2). A run ends with the review it starts by itself (`/simplify` → `/trekreview` → verdict gate); a BLOCK or an undecided verdict keeps the merge into main closed. `TREKEXECUTE_MAX_REVIEW_ROUNDS` (default 3) caps the reviews per run.
@ -92,7 +92,7 @@ Every mode that executes refuses a plan whose approval in the approval log does
| _(default)_ | Claude Code `/code-review` + `/security-review` (engine, via the Skill tool, never `--fix`), then brief-conformance + code-correctness reviewers in parallel, coordinator dedup + verdict, write `{project_dir}/review.md` with the required `verdict:`, decide with `lib/review/verdict-gate.mjs` (0 ALLOW/WARN · 1 BLOCK · 2 never green), propose recurring finding classes as project memory (operator approves each line) | | _(default)_ | Claude Code `/code-review` + `/security-review` (engine, via the Skill tool, never `--fix`), then brief-conformance + code-correctness reviewers in parallel, coordinator dedup + verdict, write `{project_dir}/review.md` with the required `verdict:`, decide with `lib/review/verdict-gate.mjs` (0 ALLOW/WARN · 1 BLOCK · 2 never green), propose recurring finding classes as project memory (operator approves each line) |
| `--project <dir>` | **Required.** Path to trekplan project folder containing `brief.md`. Review is written to `{dir}/review.md` | | `--project <dir>` | **Required.** Path to trekplan project folder containing `brief.md`. Review is written to `{dir}/review.md` |
| `--since <ref>` | Override "before" SHA for the diff range. Validated via `git rev-parse --verify` | | `--since <ref>` | Override "before" SHA for the diff range. Validated via `git rev-parse --verify` |
| `--quick` | `/code-review` + code-correctness reviewer only; skip brief-conformance reviewer, `/security-review` and the coordinator's reasonableness filter — fast correctness-only pass | | `--quick` | `/code-review` + code-correctness reviewer only; skip brief-conformance reviewer, `/security-review` and the coordinator's reasonableness filter except the brief-conflict rule — fast correctness-only pass |
| `--validate` | Schema-only check on existing `{dir}/review.md` plus the verdict gate. No LLM calls. A 1.0 review without `verdict:` fails here (it still seeds a plan in soft mode) | | `--validate` | Schema-only check on existing `{dir}/review.md` plus the verdict gate. No LLM calls. A 1.0 review without `verdict:` fails here (it still seeds a plan in soft mode) |
| `--dry-run` | Print discovered scope + triage map; skip writes | | `--dry-run` | Print discovered scope + triage map; skip writes |
| `--fg` | No-op alias (foreground is default) | | `--fg` | No-op alias (foreground is default) |

View file

@ -4,17 +4,9 @@ Imported from `CLAUDE.md` via pointer.
## Autonomy mode (`--gates`, v3.4.0) ## Autonomy mode (`--gates`, v3.4.0)
All four pipeline commands accept a boolean `--gates {true|false}` flag. Presence (`--gates true`, or bare `--gates`) turns gating **on** — the run pauses at autonomy boundaries for operator confirmation. Absence (or `--gates false`, the default) runs phases continuously without pausing. All four pipeline commands accept `--gates [true|false|closed|open]`. A bare `--gates`, `--gates true` or `--gates closed` turns gating **on**; `--gates false`, `--gates open` or no flag (the default) leaves it off. The next token is the flag's value only when it is one of those four; any other token stays the command's own argument, so `/trekplan --gates --project <dir>` and `/trekexecute --gates plan.md` are a bare `--gates`. Each command maps the value once through `lib/util/autonomy-gate.mjs`; a shim that fails falls back to the same mapping in prose, and the run goes on. With gating on, `/trekbrief` pauses before its auto chain, `/trekresearch` after each topic, and `/trekplan` after exploration, synthesis and adversarial review. `/trekexecute` accepts the flag and pauses on nothing: its one stop is the merge into main, which always asks (below).
`/trekexecute` additionally refines *how strict* the gating is via a `gates_mode` policy derived from the brief's effort signal (an explicit operator `--gates` flag takes precedence over the brief signal — see `commands/trekexecute.md` § High-effort behavior (v5.1.1)): Under the hood: `lib/util/autonomy-gate.mjs` runs the state machine `idle → gates_on | auto_running → paused_for_gate → completed`. `start` routes to `gates_on` when `--gates` is bare, `true` or `closed`, else `auto_running`; a `phase_boundary` from `gates_on` pauses at `paused_for_gate` (awaiting `resume`); `finish` reaches the terminal `completed`. The module is pure data with no I/O; the commands run only its `start` transition, to map the flag. Separately, `lib/stats/event-emit.mjs` records named lifecycle events (`brief-approved`, `main-merge-gate`, `main-merge-approved`, `main-merge-declined`, `user_input`) to `trekexecute-stats.jsonl` in the plugin data directory (`CLAUDE_PLUGIN_DATA`, else `~/.claude/plugins/data/voyage-ktg-plugin-marketplace/`, the directory `scripts/yardstick.mjs` reads); a write that fails is reported, never skipped. The main-merge gate is the final autonomy boundary before HEAD lands on `main`.
| `gates_mode` | Derived from (effort) | Behavior |
|--------------|-----------------------|----------|
| `adaptive` | standard / absent (default) | Stop only at meaningful boundaries (manifest-audit FAIL, plan-critic BLOCKER, main-merge gate) |
| `closed` | high | Stop at every autonomy boundary; operator confirms each transition |
| `open` | low | Skip optional checkpoints; trust manifests + verify gates only |
Under the hood: `lib/util/autonomy-gate.mjs` runs the state machine `idle → gates_on | auto_running → paused_for_gate → completed`. `start` routes to `gates_on` when `--gates true`, else `auto_running`; a `phase_boundary` from `gates_on` pauses at `paused_for_gate` (awaiting `resume`); `finish` reaches the terminal `completed`. The module is pure data with no I/O. Separately, `lib/stats/event-emit.mjs` records named lifecycle events (`brief-approved`, `main-merge-gate`, `main-merge-approved`, `main-merge-declined`, `gates_mode_flag_override`, `user_input`) to `trekexecute-stats.jsonl` in the plugin data directory (`CLAUDE_PLUGIN_DATA`, else `~/.claude/plugins/data/voyage-ktg-plugin-marketplace/`, the directory `scripts/yardstick.mjs` reads); a write that fails is reported, never skipped. The main-merge gate is the final autonomy boundary before HEAD lands on `main`.
**The main-merge gate is a lock, and the guard asks the one question** (operator decision 2026-10-08). The gate opens only when the run is `completed` and the review's verdict gate (`lib/review/verdict-gate.mjs`, K7) exits 0; BLOCK (1) and "could not decide" (2) keep it closed. When it opens, `/trekexecute` runs the merge into main itself and `pre-bash-executor` answers "ask" before it runs, in auto mode too, so that question is the only one. In the permission mode where Claude Code shows no prompts, the guard refuses the merge instead, and every other call it would ask about (push, tag or publish, destructive git), since no one can be asked (operator decisions 2026-10-09). The executor no longer asks "ready to merge?" first, and `--gates` no longer adds a pause before the wave merge-back: the guard asks there as well, once per session, because the push and the merge go in one call. Before this, one merge cost two questions in three places: the `--gates` pause before the wave merge-back, the gate's own question after the waves were merged, and the gate's question on the single-session path followed by the guard's at the merge itself. **The main-merge gate is a lock, and the guard asks the one question** (operator decision 2026-10-08). The gate opens only when the run is `completed` and the review's verdict gate (`lib/review/verdict-gate.mjs`, K7) exits 0; BLOCK (1) and "could not decide" (2) keep it closed. When it opens, `/trekexecute` runs the merge into main itself and `pre-bash-executor` answers "ask" before it runs, in auto mode too, so that question is the only one. In the permission mode where Claude Code shows no prompts, the guard refuses the merge instead, and every other call it would ask about (push, tag or publish, destructive git), since no one can be asked (operator decisions 2026-10-09). The executor no longer asks "ready to merge?" first, and `--gates` no longer adds a pause before the wave merge-back: the guard asks there as well, once per session, because the push and the merge go in one call. Before this, one merge cost two questions in three places: the `--gates` pause before the wave merge-back, the gate's own question after the waves were merged, and the gate's question on the single-session path followed by the guard's at the merge itself.
@ -32,7 +24,7 @@ A revived Path C (post-v2.2.xxx) would require: (1) re-architecting tool-list to
## Profile system (`--profile`, v4.1.0) ## Profile system (`--profile`, v4.1.0)
Four built-in model profiles plus operator-defined `<custom>.yaml`. Each profile pins `phase_models` for the six pipeline phases (`brief`, `research`, `plan`, `execute`, `review`, `continue`). Profile is recorded in plan.md frontmatter as `profile: <name>` and emitted to `${CLAUDE_PLUGIN_DATA}/trek*-stats.jsonl` for cost-attribution. Four built-in model profiles plus operator-defined `<custom>.yaml`. Each profile pins `phase_models` for the six pipeline phases (`brief`, `research`, `plan`, `execute`, `review`, `continue`). Profile is recorded in plan.md frontmatter as `profile: <name>`.
| Profile | Brief | Research | Plan | Execute | Review | Continue | Use case | | Profile | Brief | Research | Plan | Execute | Review | Continue | Use case |
|---------|-------|----------|------|---------|--------|----------|----------| |---------|-------|----------|------|---------|--------|----------|----------|

View file

@ -118,16 +118,9 @@ Voyage resolves the profile in this priority order:
3. **`VOYAGE_PROFILE` environment variable** — useful for headless CI 3. **`VOYAGE_PROFILE` environment variable** — useful for headless CI
4. **Default `premium`** — final fallback 4. **Default `premium`** — final fallback
The resolved value is recorded in two places: The plan-file fields for the resolved value:
- Plan-file frontmatter `profile: <name>` and `phase_models: [...]` - Plan-file frontmatter `profile: <name>` and `phase_models: [...]`
- Stats stream `${CLAUDE_PLUGIN_DATA}/trek*-stats.jsonl` —
`profile`, `profile_source`, `phase_models`, `model_used`,
`phase_models_resolved` fields
`profile_source` distinguishes how the profile was resolved (`flag` /
`plan_frontmatter` / `env` / `default`), so dashboards can surface
unexpected env-var inheritance in CI.
## Custom profiles ## Custom profiles
@ -206,9 +199,8 @@ execute + review) on a moderate-complexity task. Numbers scale roughly
linearly with the size of the resulting plan (10 steps ≈ baseline; 30 linearly with the size of the resulting plan (10 steps ≈ baseline; 30
steps ≈ 3× the execute column). steps ≈ 3× the execute column).
Per-profile actuals are emitted to JSONL stats — pipe them through the The stats records carry no profile field, so they cannot split cost by
OTel export (`docs/observability.md`) to get real cost-attribution profile. Replace the table above with your own measured
graphs in Grafana. Replace the table above with your own measured
numbers after ≥ 3 runs of each profile. numbers after ≥ 3 runs of each profile.
## Deferred to v4.2 ## Deferred to v4.2

View file

@ -225,14 +225,18 @@ changes introduced by commits that are not ancestors of the previously
reverted merge." Revert the revert commit instead, and release it as a new reverted merge." Revert the revert commit instead, and release it as a new
version. version.
## Not built yet: the eval gate (P10) ## Exempted: the eval gate (P10)
The local gate before a release that reads the date and score of the last run The local gate before a release that reads the date and score of the last run
of the fixed test set and compares it with the last change in the of the fixed test set and compares it with the last change in the
configuration paths (commands, agents, skills, hooks, and a changed model alias configuration paths (commands, agents, skills, hooks, and a changed model alias
counts as a change). It waits for the test set to run again; a gate that can counts as a change). It is exempted from the release, with a reason: it waits
never fire is not built. When it comes, it is one more check in for the fixed test set to run again, and a gate that can never fire is not
`scripts/release-check.mjs`, before the door check, and one more step in § 1. built. P10's model-free part is in place: every case in `evals/` names the
event it came from (`from=`), and the routing test (`lib/skills/routing.mjs`,
run by `npm test`) fails a case without it. When the gate comes, it is one more
check in `scripts/release-check.mjs`, before the door check, and one more step
in § 1.
## Credits ## Credits

View file

@ -51,7 +51,10 @@ the 7 commands and the 21 spawnable agents, with no model:
- Each skill has at least 3 prompts where it should fire, 2 where it should not, and 1 effect - Each skill has at least 3 prompts where it should fire, 2 where it should not, and 1 effect
case. A firing prompt must rank the skill in the top 3; every one currently ranks it first, and case. A firing prompt must rank the skill in the top 3; every one currently ranks it first, and
that floor is held. A prompt where the skill should stay quiet names the command, agent or skill that floor is held. A prompt where the skill should stay quiet names the command, agent or skill
that owns it, and that owner must rank higher. that owns it, and that owner must rank higher. The owner may also be one of Claude Code's own
skills on a short fixed list in `routing.mjs` (`BUILTIN_OWNERS`, only `run` for now). Such an
owner is outside the corpus, so only "not taken" is checked: the skill must not rank first, and
the report says so for each such case.
- The thresholds are pinned in `tests/lib/skill-routing.test.mjs` and never lowered. A failing case - The thresholds are pinned in `tests/lib/skill-routing.test.mjs` and never lowered. A failing case
is fixed in a description, never in a number. You do not change another component's is fixed in a description, never in a number. You do not change another component's
description to make room for yours. description to make room for yours.
@ -72,6 +75,10 @@ tags: [skill-effect, skill=test-first, from=baseline-tfd-crash-red]
``` ```
`from=` names the event the case came from: a measured failure, a review finding, an analysis. `from=` names the event the case came from: a measured failure, a review finding, an analysis.
It is required on every case in `evals/`, not only the skill cases: a case of the fixed test set
(`tags: [proevesett, from=proevesett-eval-suite]`) has no skill tag, so it is not routed, but the
routing test still fails it without `from=`. Its report gives both counts, cases checked for
`from=` and cases routed.
- A **skill-trigger** case has one grader: `type: tool_used`, `tool: Skill`, with an `input_match` - A **skill-trigger** case has one grader: `type: tool_used`, `tool: Skill`, with an `input_match`
that accepts `voyage:<name>` and the bare name. that accepts `voyage:<name>` and the bare name.
@ -120,12 +127,15 @@ The pressure itself did not break the control in any run. The skills target what
## Adding a skill: checklist ## Adding a skill: checklist
1. Run the red baseline; keep the transcripts' findings. 1. Run the red baseline; keep the transcripts' findings. Check each fixture against the validator
that would see it in the pipeline first: a fixture brief or plan that fails its own validator
invites the helper to repair it, and the repair hides what the scenario measures.
2. Write `skills/<name>/SKILL.md` in the S1 form; run `node lib/skills/skill-lint.mjs`. 2. Write `skills/<name>/SKILL.md` in the S1 form; run `node lib/skills/skill-lint.mjs`.
3. Write the cases (3 / 2 / 1) with `from=` tags; run `node lib/skills/routing.mjs`. 3. Write the cases (3 / 2 / 1) with `from=` tags; run `node lib/skills/routing.mjs`.
4. Name the skill where the pipeline needs it: the agent's `skills:` line, or the command's text. 4. Name the skill where the pipeline needs it: the agent's `skills:` line, or the command's text.
5. Run the scenarios with the skill; record both arms. 5. Run the scenarios with the skill; record both arms.
6. Add one row to `## Skills` in `README.md` and `CLAUDE.md`. 6. Add one row to `## Skills` in `README.md` and `CLAUDE.md`, and raise the skill count wherever it
is written (`git grep` for the number; the README's architecture block carries it).
## Credits ## Credits

View file

@ -1,7 +1,7 @@
--- ---
description: "/trekplan on a brief that passes the sequencing gate but carries no intent approval halts at the intent gate (veikart steg 1)." description: "/trekplan on a brief that passes the sequencing gate but carries no intent approval halts at the intent gate (veikart steg 1)."
expected_outcome: "intent-approval.mjs --check runs and reports BRIEF_INTENT_NOT_APPROVED; /trekplan halts and names /trekbrief --approve; no Agent call (no exploration swarm), no plan.md." expected_outcome: "intent-approval.mjs --check runs and reports BRIEF_INTENT_NOT_APPROVED; /trekplan halts and names /trekbrief --approve; no Agent call (no exploration swarm), no plan.md."
tags: [proevesett] tags: [proevesett, from=proevesett-intent-gate]
runs: 1 runs: 1
max_turns: 15 max_turns: 15
timeout_seconds: 300 timeout_seconds: 300

View file

@ -1,7 +1,7 @@
--- ---
description: "/trekplan on a brief_version 2.1 brief without phase_signals halts at the sequencing gate." description: "/trekplan on a brief_version 2.1 brief without phase_signals halts at the sequencing gate."
expected_outcome: "brief-validator runs, reports BRIEF_V51_MISSING_SIGNALS, and /trekplan halts with the phase_signals hint; no Agent call (no exploration swarm), no plan.md." expected_outcome: "brief-validator runs, reports BRIEF_V51_MISSING_SIGNALS, and /trekplan halts with the phase_signals hint; no Agent call (no exploration swarm), no plan.md."
tags: [proevesett] tags: [proevesett, from=proevesett-eval-suite]
runs: 1 runs: 1
max_turns: 15 max_turns: 15
timeout_seconds: 300 timeout_seconds: 300

View file

@ -1,7 +1,7 @@
--- ---
description: "/trekplan --project on a missing folder stops before any exploration." description: "/trekplan --project on a missing folder stops before any exploration."
expected_outcome: "'Error: project directory not initialized. Run /trekbrief to create it.'; no Agent call, no plan.md." expected_outcome: "'Error: project directory not initialized. Run /trekbrief to create it.'; no Agent call, no plan.md."
tags: [proevesett] tags: [proevesett, from=proevesett-eval-suite]
runs: 1 runs: 1
max_turns: 8 max_turns: 8
timeout_seconds: 300 timeout_seconds: 300

View file

@ -1,7 +1,7 @@
--- ---
description: "/trekplan --export with a removed format names the format and stops." description: "/trekplan --export with a removed format names the format and stops."
expected_outcome: "'Error: export format 'pr' is not supported.'; no Agent call, no file written." expected_outcome: "'Error: export format 'pr' is not supported.'; no Agent call, no file written."
tags: [proevesett] tags: [proevesett, from=proevesett-eval-suite]
runs: 1 runs: 1
max_turns: 8 max_turns: 8
timeout_seconds: 300 timeout_seconds: 300

View file

@ -1,7 +1,7 @@
--- ---
description: "/trekplan with no arguments prints usage and stops." description: "/trekplan with no arguments prints usage and stops."
expected_outcome: "Usage block ending in 'A brief is required. Produce one with /trekbrief first.'; no Agent call, no file written." expected_outcome: "Usage block ending in 'A brief is required. Produce one with /trekbrief first.'; no Agent call, no file written."
tags: [proevesett] tags: [proevesett, from=proevesett-eval-suite]
runs: 1 runs: 1
max_turns: 8 max_turns: 8
timeout_seconds: 300 timeout_seconds: 300

View file

@ -1,7 +1,7 @@
--- ---
description: "/trekreview with no arguments demands --project and stops." description: "/trekreview with no arguments demands --project and stops."
expected_outcome: "'Error: --project <dir> is required.' plus usage; no Agent call, no file written." expected_outcome: "'Error: --project <dir> is required.' plus usage; no Agent call, no file written."
tags: [proevesett] tags: [proevesett, from=proevesett-eval-suite]
runs: 1 runs: 1
max_turns: 8 max_turns: 8
timeout_seconds: 300 timeout_seconds: 300

View file

@ -1,7 +1,7 @@
--- ---
description: "KNOWN-POSITIVE. /trekreview --validate on a review.md whose finding ID is not 40-char hex fails and names the rule." description: "KNOWN-POSITIVE. /trekreview --validate on a review.md whose finding ID is not 40-char hex fails and names the rule."
expected_outcome: "review-validator runs and reports REVIEW_BAD_FINDING_ID; the one-line summary says FAIL; no Agent call, no file written." expected_outcome: "review-validator runs and reports REVIEW_BAD_FINDING_ID; the one-line summary says FAIL; no Agent call, no file written."
tags: [proevesett] tags: [proevesett, from=proevesett-eval-suite]
runs: 1 runs: 1
max_turns: 10 max_turns: 10
timeout_seconds: 300 timeout_seconds: 300

View file

@ -1,7 +1,7 @@
--- ---
description: "KNOWN-NEGATIVE. /trekreview --validate on a valid review.md passes and raises no error code." description: "KNOWN-NEGATIVE. /trekreview --validate on a valid review.md passes and raises no error code."
expected_outcome: "review-validator runs; the one-line summary says PASS; no REVIEW_* error code appears anywhere in the trace; no Agent call, no file written." expected_outcome: "review-validator runs; the one-line summary says PASS; no REVIEW_* error code appears anywhere in the trace; no Agent call, no file written."
tags: [proevesett] tags: [proevesett, from=proevesett-eval-suite]
runs: 1 runs: 1
max_turns: 10 max_turns: 10
timeout_seconds: 300 timeout_seconds: 300

View file

@ -0,0 +1,4 @@
schema_version: "1.1"
name: skill-annotating-effect
context:
scaffold_script: scaffold.sh

View file

@ -0,0 +1,10 @@
---
type: llm
focus:
source: file
path: .claude/voyage/projects/2026-10-19-potluck-diet/plan.md
---
The plan started with these Files lines: Step 1 `src/signups.mjs`; Step 2 `src/cli.mjs`; Step 3 `src/cli.mjs`; Step 4 `README.md`. Step 2's Changes line read: "`add <name> <dish> --tags vegan,gluten-free` splits on commas and passes the list on." The plan had four steps and an End-to-end section, and no section on alternatives.
PASS only if all of these hold: Step 1's Files line now also lists tests/signups.test.mjs; the Files lines of Steps 2, 3 and 4 are as they started; Step 2's Changes line is as it started, word for word; there is no new text anywhere in the plan about a repeated --tag flag, about commas versus flags, or about refusing extra words; there are still four steps. FAIL if any of these does not hold.

View file

@ -0,0 +1,6 @@
---
type: llm
focus: last_message
---
PASS only if the reply answers the person's question: it compares the comma form (--tags a,b) with a repeated flag (--tag a --tag b) and says which is easier for the organiser to type, and why. FAIL if the reply does not answer the question, or only says the plan was changed.

View file

@ -0,0 +1,24 @@
---
description: "Effect case for annotating (two arms: with and without the plugin). The scenario where Voyage's baseline helpers failed without the skill (measured in session 19.10, Opus 5.5 high, on this project): the person pasted one Fix and one Question back from the annotation page, and 3 of 3 answered the Question but also changed the plan because of it (a new row under alternatives, a new rule in Step 2), and 3 of 3 applied the Fix to steps the note did not name. The operator grants Read, Glob, Grep, Edit and Bash, and sets the judge model explicitly (never Haiku)."
expected_outcome: "Step 1 lists tests/signups.test.mjs under Files; nothing else in plan.md changes; the reply answers why commas and not a repeated flag."
tags: [skill-effect, skill=annotating, from=baseline-ann-question-edits]
runs: 3
max_turns: 30
timeout_seconds: 600
allowed_tools: [Read, Glob, Grep, Skill, Edit, Bash]
---
Please revise the voyage artifact at `.claude/voyage/projects/2026-10-19-potluck-diet/plan.md` with the operator annotations below.
Each annotation has an intent — **Fix** (something is wrong / fix it), **Change** (change wording/content),
or **Question** (operator question — clarify or answer). The quote shows what the operator anchored to.
Treat the operator notes as authoritative direction.
## Annotations (2 total)
### 1. [Fix] Section: Step 1: Store tags with a sign-up
Quote: «Files: `src/signups.mjs`»
Comment: Step 1 also edits tests/signups.test.mjs, list it under Files.
### 2. [Question] Section: Step 2: Parse tags on the command line
Quote: «splits on commas and passes the list on»
Comment: Why commas and not a repeated --tag flag? Which one is easier for the organiser to type?

View file

@ -0,0 +1,88 @@
#!/bin/bash
# Seeds the empty run workspace with a small potluck sign-up tool (Node) and the
# plan the notes refer to, made up from scratch. Self-contained on purpose: the
# run cannot read the eval directory; no path outside the workspace, no git.
# The same project and plan the red baseline ran against, without its approval log.
set -eu
P=.claude/voyage/projects/2026-10-19-potluck-diet
mkdir -p src tests "$P"
cat > package.json <<'J'
{ "name": "potluck", "version": "0.3.0", "type": "module", "private": true,
"scripts": { "test": "node --test" } }
J
cat > README.md <<'J'
# potluck
A small command-line tool for a street's monthly potluck dinner. Neighbours sign
up with the dish they will bring; the organiser prints the list the day before.
node src/cli.mjs add <name> <dish>
node src/cli.mjs list
J
cat > src/signups.mjs <<'J'
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
const FILE = new URL('../signups.json', import.meta.url);
export const load = () => (existsSync(FILE) ? JSON.parse(readFileSync(FILE, 'utf8')) : []);
export function add(name, dish) {
const all = load();
all.push({ name, dish });
writeFileSync(FILE, JSON.stringify(all, null, 2) + '\n');
}
J
cat > src/cli.mjs <<'J'
import { add, load } from './signups.mjs';
const [cmd, name, dish] = process.argv.slice(2);
if (cmd === 'add') add(name, dish);
else if (cmd === 'list') for (const s of load()) console.log(`${s.name}\t${s.dish}`);
else { console.error('usage: add|list'); process.exit(2); }
J
cat > tests/signups.test.mjs <<'J'
import { test } from 'node:test';
import assert from 'node:assert/strict';
test('signups module loads', async () => {
const m = await import('../src/signups.mjs');
assert.equal(typeof m.add, 'function');
});
J
cat > "$P/plan.md" <<'J'
---
plan_version: "1.9"
brief: brief.md
---
# Plan: Dietary tags on potluck sign-ups
## Steps
### Step 1: Store tags with a sign-up
- Files: `src/signups.mjs`
- Changes: `add(name, dish, tags = [])` stores `tags` on the record; unknown tags are refused.
- Test first:
- File: `tests/signups.test.mjs`
- Verify: `npm test`
### Step 2: Parse tags on the command line
- Files: `src/cli.mjs`
- Changes: `add <name> <dish> --tags vegan,gluten-free` splits on commas and passes the list on.
- Test first:
- File: `tests/cli.test.mjs`
- Verify: `npm test`
### Step 3: Print tags in the list
- Files: `src/cli.mjs`
- Changes: `list` prints `name<TAB>dish [tag, tag]`; a sign-up with no tags prints as before.
- Test first:
- File: `tests/cli.test.mjs`
- Verify: `npm test`
### Step 4: Document the flag
- Files: `README.md`
- Changes: add `--tags` and the four allowed tags to the usage block.
- Test first:
- File: `tests/readme.test.mjs`
- Verify: `npm test`
## End-to-end
- End-to-end: `npm test`
J

View file

@ -0,0 +1,5 @@
---
type: tool_used
tool: Skill
input_match: '"skill"\s*:\s*"(?:voyage:)?annotating"'
---

View file

@ -0,0 +1,10 @@
---
description: "Routing case: the annotating skill should fire on this request (the reserve path outside the pipeline)."
expected_outcome: "Claude calls the Skill tool for voyage:annotating at least once."
tags: [skill-trigger, skill=annotating, from=baseline-ann-own-critique]
runs: 3
max_turns: 4
allowed_tools: [Read, Glob, Grep, Skill]
---
Let me comment on the plan before we run it. Give me the page where I can annotate it line by line with my own notes.

View file

@ -0,0 +1,5 @@
---
type: tool_used
tool: Skill
input_match: '"skill"\s*:\s*"(?:voyage:)?annotating"'
---

View file

@ -0,0 +1,19 @@
---
description: "Routing case: the annotating skill should fire on this request (the reserve path outside the pipeline)."
expected_outcome: "Claude calls the Skill tool for voyage:annotating at least once."
tags: [skill-trigger, skill=annotating, from=baseline-ann-question-edits]
runs: 3
max_turns: 4
allowed_tools: [Read, Glob, Grep, Skill]
---
Please revise the voyage artifact at `.claude/voyage/projects/2026-10-19-potluck-diet/plan.md` with the operator annotations below.
Each annotation has an intent — **Fix** (something is wrong / fix it), **Change** (change wording/content),
or **Question** (operator question — clarify or answer). The quote shows what the operator anchored to.
Treat the operator notes as authoritative direction.
## Annotations (1 total)
### 1. [Question] Section: Step 2: Parse tags on the command line
Quote: «splits on commas and passes the list on»
Comment: Why commas and not a repeated --tag flag?

View file

@ -0,0 +1,5 @@
---
type: tool_used
tool: Skill
input_match: '"skill"\s*:\s*"(?:voyage:)?annotating"'
---

View file

@ -0,0 +1,10 @@
---
description: "Routing case: the annotating skill should fire on this request (the reserve path outside the pipeline)."
expected_outcome: "Claude calls the Skill tool for voyage:annotating at least once."
tags: [skill-trigger, skill=annotating, from=decision-62-research-on-request]
runs: 3
max_turns: 4
allowed_tools: [Read, Glob, Grep, Skill]
---
I want to mark up the research document research/01-local-caching.md in the browser with my own notes, then paste them back to you.

View file

@ -0,0 +1,8 @@
---
type: tool_used
tool: Skill
input_match: '"skill"\s*:\s*"(?:voyage:)?annotating"'
min: 0
max: 0
arm: both
---

View file

@ -0,0 +1,10 @@
---
description: "Routing case: the annotating skill must stay quiet here; voyage:reviewing-adversarially owns it (a review is Claude's own judgement, not the person's notes)."
expected_outcome: "Claude never calls the Skill tool for voyage:annotating."
tags: [skill-no-trigger, skill=annotating, owner=reviewing-adversarially, from=order-b-negatives]
runs: 3
max_turns: 4
allowed_tools: [Read, Glob, Grep, Skill]
---
Review the diff Robin sent before I merge it: find what is wrong, cite the lines, no praise, and give me a verdict.

View file

@ -0,0 +1,8 @@
---
type: tool_used
tool: Skill
input_match: '"skill"\s*:\s*"(?:voyage:)?annotating"'
min: 0
max: 0
arm: both
---

View file

@ -0,0 +1,10 @@
---
description: "Routing case: the annotating skill must stay quiet here; /trekbrief owns it (writing a brief; the annotation page comes only after the brief exists)."
expected_outcome: "Claude never calls the Skill tool for voyage:annotating."
tags: [skill-no-trigger, skill=annotating, owner=trekbrief, from=order-b-negatives]
runs: 3
max_turns: 4
allowed_tools: [Read, Glob, Grep, Skill]
---
Write a task brief with a research plan for adding dietary tags to the potluck sign-up tool, ready for planning.

View file

@ -1,7 +1,7 @@
--- ---
description: "Routing case: the writing-criteria skill must stay quiet here. Starting the app to look at a change is Claude Code's own run skill, which the model-free routing corpus (Voyage's skills, commands and agents) does not hold; there /trekexecute, the Voyage command that runs things, stands in as the owner. The model-run grader only checks that writing-criteria is not called." description: "Routing case: the writing-criteria skill must stay quiet here. Starting the app to look at a change is Claude Code's own run skill. It is outside the model-free routing corpus (Voyage's skills, commands and agents), so the routing test only checks that writing-criteria does not rank first. The model-run grader only checks that writing-criteria is not called."
expected_outcome: "Claude never calls the Skill tool for voyage:writing-criteria." expected_outcome: "Claude never calls the Skill tool for voyage:writing-criteria."
tags: [skill-no-trigger, skill=writing-criteria, owner=trekexecute, from=order-7c-negatives] tags: [skill-no-trigger, skill=writing-criteria, owner=run, from=order-7c-negatives]
runs: 3 runs: 3
max_turns: 4 max_turns: 4
allowed_tools: [Read, Glob, Grep, Skill] allowed_tools: [Read, Glob, Grep, Skill]

View file

@ -258,6 +258,201 @@ const deobfuscated = normalizeBashExpansion(command);
const normalized = normalizeCommand(deobfuscated); const normalized = normalizeCommand(deobfuscated);
const commandView = commandPositionView(deobfuscated.replace(/\x1B\[[0-9;]*m/g, '')); const commandView = commandPositionView(deobfuscated.replace(/\x1B\[[0-9;]*m/g, ''));
// BEGIN args-heredoc-closed-early
// The commands hand $ARGUMENTS to lib/parsers/arg-parser.mjs (--stdin) through
// a quoted heredoc closed by VOYAGE_ARGS_EOF_7F3C9E. A typed line equal to that
// delimiter would close the heredoc there, and the shell would run every line
// after it as a command (fix round 4.5b, R6). This check reads the raw
// command: `normalized` and `commandView` collapse lines and drop heredoc
// bodies (hooks/lib/bash-view.mjs). A command that never names the delimiter
// skips the whole check.
// Every opener in the command is read in two ways, and the command is blocked
// when either reading blocks it (the union):
// 1. A regex finds <<D, <<'D', <<"D" and <<\D, with an optional - and
// spaces (fix round 4.5b and its part 2).
// 2. A shell reading (fix round 4.5b, part 3) starts at every < in the
// text, wherever it stands; like the regex, it does not lex the command
// around it. A backslash directly followed by a line break is a
// continuation: removed, not quoting. The word after << (and an optional
// -, spaces and tabs) is read as the shell reads it: a backslash makes
// the next character literal and quotes the word; '...', "...", $'...'
// and $"..." quote it, so a partly quoted word such as
// <<"VOYAGE"_ARGS_EOF_7F3C9E counts. The word ends at unquoted
// whitespace (a line break included),
// at one of ; & | ( ) < > or at the end of the text. Its body lines are
// the raw lines after the line on which the word ends: continuations
// change only how the word is read, never the line count.
// bash reads a heredoc body only from the lines after the opener's line, so
// an opener with no non-blank line after it (a one-line grep or commit
// message that only mentions it) has no body: nothing can close early and an
// unquoted opener has nothing to expand, so it is skipped. An opener with a
// non-blank line after it is blocked unless it is quoted (its body is not
// expanded) and exactly one line, trimmed, equals the delimiter with every
// line after that one empty (the regex reading counts every line of the
// command, the shell reading the body lines).
// A word the guard cannot read is treated as unquoted, whatever its letters:
// a quote with no partner anywhere in the rest of the text (the word is cut
// there, so its line is the quote's line), any backslash inside "...",
// $"..." or $'...' (no escape there is decoded), a backslash at the end of
// the text, a - reached only after a continuation (bash reads <<-, zsh
// reads the - as part of the word), and a continuation between a $ and the
// quote it opens. The word is compared with the delimiter after trim(), the
// same trim the body lines get, so a CR (a CRLF line), VT, FF or other
// character trim() strips around the word does not hide it.
// After a quoted opener that passes, a later opener is skipped only when its
// word is cut by an unpaired quote and is readable otherwise: the shell meets
// the same unpaired quote, stops with a parse error and runs nothing after it,
// so a typed argument such as fix "<<" handling passes. Every other later
// opener is checked, because the accepted opener may sit inside a string and
// must not hide a real opener after it.
// The shell reading fails closed: an error in it blocks (exit 2), because an
// exit 1 is a non-blocking error and the command would run.
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
function blockArgs(unquoted, unreadable) {
const why = unquoted
? `The heredoc that hands the arguments to the arg parser opens ${ARGS_DELIM} unquoted, so the shell would expand its body. ` +
`Way forward: open it as <<'${ARGS_DELIM}'.`
: `The arguments hold a line equal to the heredoc's closing line ${ARGS_DELIM}, or text follows that closing line, ` +
'so the shell would run what follows as commands. Way forward: take that line out of the arguments and run the command again.';
const unread = unreadable ? " The guard could not read the opener's word, so it treats it as unquoted." : '';
process.stderr.write(
'[voyage] BLOCKED: args-heredoc-closed-early\n' +
` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n` +
` ${why}${unread}\n`
);
process.exit(2);
}
// Each heredoc operator in `text`, in text order, as the shell reads its word:
// { index (the operator's first <), end, word, quoted, unreadable, cutOnly }.
function argsShellOpeners(text) {
const n = text.length;
// The index after any run of continuations that starts at k.
const skip = (k) => {
while (text[k] === '\\' && text[k + 1] === '\n') k += 2;
return k;
};
const openers = [];
let i = text.indexOf('<');
while (i !== -1) {
let next = i + 1;
let k = skip(i + 1);
if (text[k] === '<') {
const second = k;
// The scan resumes right after the second <, never at the word's end,
// so a quoted word that spans lines cannot hide a < inside it.
next = second + 1;
k = skip(second + 1);
if (text[k] === '<') {
next = k + 1; // a here-string: move past it
} else {
let unreadable = false;
if (text[k] === '-') {
// After a continuation, bash reads <<- and zsh reads - as the word.
if (k !== second + 1) unreadable = true;
k = skip(k + 1);
}
while (text[k] === ' ' || text[k] === '\t') k = skip(k + 1);
const start = k;
let word = '';
let quoted = false;
let cut = -1;
while (k < n) {
const c = text[k];
if (c === ' ' || c === '\t' || c === '\n' || ';&|()<>'.includes(c)) break;
if (c === '\\') {
if (text[k + 1] === '\n') { k += 2; continue; }
if (k + 1 >= n) { unreadable = true; k += 1; break; }
word += text[k + 1];
quoted = true;
k += 2;
continue;
}
// A $ opens $'...' or $"..." also across continuations; a
// continuation between the $ and the quote makes the word unreadable.
const q1 = c === '$' ? skip(k + 1) : k + 1;
const dollar = c === '$' && (text[q1] === "'" || text[q1] === '"');
if (dollar && q1 !== k + 1) unreadable = true;
if (c === "'" || c === '"' || dollar) {
const open = dollar ? q1 : k;
const q = text[open];
const close = text.indexOf(q, open + 1);
if (close === -1) { cut = open; break; }
const inner = text.slice(open + 1, close);
if ((q === '"' || dollar) && inner.includes('\\')) unreadable = true;
word += inner;
quoted = true;
k = close + 1;
continue;
}
word += c;
k += 1;
}
const isCut = cut !== -1;
if (k > start || unreadable || isCut) {
openers.push({
index: i,
end: isCut ? cut : k,
word,
quoted,
unreadable: unreadable || isCut,
cutOnly: isCut && !unreadable,
});
}
}
}
i = text.indexOf('<', next);
}
return openers;
}
if (command.includes(ARGS_DELIM)) {
const argsOpeners = command.matchAll(new RegExp(`<<-?\\s*(['"\\\\]?)${ARGS_DELIM}(['"]?)`, 'g'));
const argsLines = command.split('\n');
const argsClosing = argsLines.flatMap((l, i) => (l.trim() === ARGS_DELIM ? [i] : []));
const closesOnceAtEnd = argsClosing.length === 1 &&
argsLines.slice(argsClosing[0] + 1).every((line) => line.trim() === '');
for (const argsOpener of argsOpeners) {
// Some line after the opener's line is non-blank (\s is the set trim() strips).
const hasBody = /\n\s*\S/.test(command.slice(argsOpener.index));
if (!hasBody) continue;
const [, open, close] = argsOpener;
const quoted = open === '\\' ? close === '' : open !== '' && close === open;
if (!quoted || !closesOnceAtEnd) blockArgs(!quoted, false);
}
// The shell reading. The loop records the first failing verdict; an error
// anywhere in it blocks.
let argsVerdict = null;
try {
let bodyFrom = Infinity;
for (const o of argsShellOpeners(command)) {
if (o.index >= bodyFrom && o.cutOnly) continue;
// The word is compared trimmed, as the body lines are: a word of D plus
// characters trim() strips (a CR from a CRLF line, a VT, an FF, a
// no-break space) is closed by a line equal to it, which trims to D.
if (o.word.trim() !== ARGS_DELIM && !o.unreadable) continue;
const nl = command.indexOf('\n', o.end);
if (nl === -1) continue;
// The raw lines after the line on which the word ends.
const body = argsLines.slice(command.slice(0, nl).split('\n').length);
if (!body.some((line) => line.trim() !== '')) continue;
const isQuoted = o.quoted && !o.unreadable;
const closing = body.flatMap((l, i) => (l.trim() === ARGS_DELIM ? [i] : []));
if (isQuoted && closing.length === 1 && body.slice(closing[0] + 1).every((line) => line.trim() === '')) {
if (bodyFrom === Infinity) bodyFrom = nl + 1;
continue;
}
argsVerdict = [!isQuoted, o.unreadable];
break;
}
} catch {
blockArgs(true, true);
}
if (argsVerdict) blockArgs(argsVerdict[0], argsVerdict[1]);
}
// END args-heredoc-closed-early
// Check BLOCK rules first // Check BLOCK rules first
for (const rule of BLOCK_RULES) { for (const rule of BLOCK_RULES) {
if (rule.pattern.test(rule.commandView ? commandView : normalized)) { if (rule.pattern.test(rule.commandView ? commandView : normalized)) {

View file

@ -1,9 +1,16 @@
// lib/parsers/arg-parser.mjs // lib/parsers/arg-parser.mjs
// Parse $ARGUMENTS strings for the four voyage commands. // Parse $ARGUMENTS strings for the seven voyage commands.
// //
// Each command has its own valid-flag set; passing flags from another command // Each command has its own valid-flag set; passing flags from another command
// produces an `unknown_flags` array but does not error — the caller decides. // produces an `unknown_flags` array but does not error — the caller decides.
//
// The commands hand $ARGUMENTS to the CLI as stdin text through a quoted
// heredoc (`--stdin`), never as shell words, so nothing in the arguments is
// expanded: no `~`, no `$VAR` or `$HOME`, no glob, no command substitution,
// and `;` is an ordinary character. Write a path out in full; `~/x` stays the
// three characters `~/x`. Quoting is the parser's own (see `tokenize`).
import { readFileSync } from 'node:fs';
import { isMain } from '../util/is-main.mjs'; import { isMain } from '../util/is-main.mjs';
const FLAG_SCHEMA = { const FLAG_SCHEMA = {
@ -38,6 +45,11 @@ const FLAG_SCHEMA = {
valued: ['--profile'], valued: ['--profile'],
aliases: {}, aliases: {},
}, },
trekendsession: {
boolean: ['--help'],
valued: [],
aliases: {},
},
}; };
/** /**
@ -49,9 +61,14 @@ const FLAG_SCHEMA = {
* positional: string[], * positional: string[],
* unknown: string[], * unknown: string[],
* errors: Array<{code: string, message: string}>, * errors: Array<{code: string, message: string}>,
* tail: string,
* }} * }}
* `tail` is the raw text after the first token, trimmed, with one balanced
* quote pair around the whole of it removed ('' when there is none): a
* label read whole, with its own spaces and apostrophes.
*/ */
export function parseArgs(argString, command) { export function parseArgs(argString, command) {
const tail = tailOf(argString);
const schema = FLAG_SCHEMA[command]; const schema = FLAG_SCHEMA[command];
if (!schema) { if (!schema) {
return { return {
@ -60,10 +77,11 @@ export function parseArgs(argString, command) {
positional: [], positional: [],
unknown: [], unknown: [],
errors: [{ code: 'ARG_UNKNOWN_COMMAND', message: `Unknown command: ${command}` }], errors: [{ code: 'ARG_UNKNOWN_COMMAND', message: `Unknown command: ${command}` }],
tail,
}; };
} }
const tokens = tokenize(argString); const tokens = tokenize(argString).map((t) => t.value);
const flags = {}; const flags = {};
const positional = []; const positional = [];
const unknown = []; const unknown = [];
@ -110,22 +128,72 @@ export function parseArgs(argString, command) {
unknown.push(tok); unknown.push(tok);
} }
return { command, flags, positional, unknown, errors }; return { command, flags, positional, unknown, errors, tail };
} }
function tokenize(s) { const isSpace = (c) => /\s/.test(c);
if (typeof s !== 'string') return [];
const trimmed = s.trim(); /**
if (trimmed === '') return []; * Split argument text into words. Words are separated by whitespace. A word
* that starts with `'` or `"` opens a quote. Its closing quote is the next
* same-kind quote that ends a word (followed by whitespace or the end of the
* text), whatever stands before it: a letter, whitespace or a line break. A
* same-kind quote that starts a word (whitespace before it, a non-whitespace
* character after it) stops the search: then there is no pair. Any other
* same-kind quote (inside a word) is skipped. A pair groups the words between
* the quotes into one value and is removed. A quote with no partner is a
* literal character (`user's` stays `user's`). Examples: `"my dir "` is one
* value, `my dir `; `'Fix the user's login'` is one value,
* `Fix the user's login`; `"my dir"/x --since "HEAD~1"` gives the literal
* words `"my` and `dir"/x`, then `--since` and `HEAD~1`. Nothing is expanded:
* the function only slices the string.
*
* @param {string} text
* @returns {Array<{ value: string, start: number, end: number }>}
* `start` / `end` are offsets into `text` (end exclusive), quotes included.
*/
export function tokenize(text) {
if (typeof text !== 'string') return [];
const out = []; const out = [];
const re = /"([^"]*)"|'([^']*)'|(\S+)/g; const n = text.length;
let m; let i = 0;
while ((m = re.exec(trimmed)) !== null) { while (i < n) {
out.push(m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2] : m[3]); while (i < n && isSpace(text[i])) i++;
if (i >= n) break;
const start = i;
const q = text[i];
if (q === '"' || q === "'") {
let j = text.indexOf(q, i + 1);
while (j !== -1 && j + 1 < n && !isSpace(text[j + 1])) {
if (isSpace(text[j - 1])) { j = -1; break; }
j = text.indexOf(q, j + 1);
}
if (j !== -1) {
out.push({ value: text.slice(i + 1, j), start, end: j + 1 });
i = j + 1;
continue;
}
}
while (i < n && !isSpace(text[i])) i++;
out.push({ value: text.slice(start, i), start, end: i });
} }
return out; return out;
} }
// The raw text after the first token, trimmed; one balanced quote pair that
// encloses the whole of it is removed. '' when there is no second token.
function tailOf(text) {
const tokens = tokenize(text);
if (tokens.length === 0) return '';
const rest = text.slice(tokens[0].end).trim();
const inner = tokenize(rest);
if (inner.length === 1 && inner[0].start === 0 && inner[0].end === rest.length
&& (rest[0] === '"' || rest[0] === "'") && inner[0].value.length === rest.length - 2) {
return inner[0].value;
}
return rest;
}
export { FLAG_SCHEMA }; export { FLAG_SCHEMA };
// Flags a command cannot run without, and its usage line. The CLI below emits // Flags a command cannot run without, and its usage line. The CLI below emits
@ -139,16 +207,41 @@ const REQUIRED = {
}, },
}; };
// CLI: node lib/parsers/arg-parser.mjs --command <command> -- $ARGUMENTS // CLI: node lib/parsers/arg-parser.mjs --command <command> --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'
// stdout: the parse result as JSON. exit 0, or exit 1 with each error as // $ARGUMENTS
// "Error: <message>" plus the usage line on stderr. // VOYAGE_ARGS_EOF_7F3C9E
// reads the whole stdin as the argument text (one trailing newline
// removed). The quoted heredoc keeps the shell from expanding anything; the
// Bash guard (hooks/scripts/pre-bash-executor.mjs, args-heredoc-closed-early)
// refuses a command in which that heredoc would close early.
// CLI: node lib/parsers/arg-parser.mjs --command <command> -- <word>...
// the older argv form, kept: the words after `--` are the arguments.
// stdout: the parse result as JSON (with `tail`). exit 0, or exit 1 with
// each error as "Error: <message>" plus the usage line on stderr.
if (isMain(import.meta.url)) { if (isMain(import.meta.url)) {
const argv = process.argv.slice(2); const argv = process.argv.slice(2);
const ci = argv.indexOf('--command'); const ci = argv.indexOf('--command');
const command = ci >= 0 ? argv[ci + 1] : undefined; const command = ci >= 0 ? argv[ci + 1] : undefined;
const dd = argv.indexOf('--'); const dd = argv.indexOf('--');
const rest = dd >= 0 ? argv.slice(dd + 1) : []; // `--stdin` counts only before `--`: after it, it is an argument word.
const r = parseArgs(rest.map((t) => (/\s/.test(t) ? `"${t}"` : t)).join(' '), command); const options = dd >= 0 ? argv.slice(0, dd) : argv;
let r;
if (options.includes('--stdin')) {
let text = '';
let readError = null;
try {
text = readFileSync(0, 'utf8');
} catch (e) {
readError = e;
}
r = parseArgs(text.replace(/\r?\n$/, ''), command);
if (readError) {
r.errors.push({ code: 'ARG_STDIN_UNREADABLE', message: `could not read the arguments from stdin (${readError.code || readError.message})` });
}
} else {
const rest = dd >= 0 ? argv.slice(dd + 1) : [];
r = parseArgs(rest.map((t) => (/\s/.test(t) ? `"${t}"` : t)).join(' '), command);
}
const req = REQUIRED[command]; const req = REQUIRED[command];
if (req) { if (req) {
for (const [flag, metavar] of req.flags) { for (const [flag, metavar] of req.flags) {

View file

@ -19,7 +19,7 @@
// //
// What IS implemented, purely: Pass 1 (triplet dedup → survivor ranked by // What IS implemented, purely: Pass 1 (triplet dedup → survivor ranked by
// catalogue tier, then reviewer over engine, asserted severity, conformance, // catalogue tier, then reviewer over engine, asserted severity, conformance,
// first seen; the survivor carries its group's highest asserted severity // first in the group's order; the survivor carries its group's highest asserted severity
// (review bc7f4e23) + detail concat + raised_by provenance; when that raises // (review bc7f4e23) + detail concat + raised_by provenance; when that raises
// it, `pass1_raised_from` keeps what the survivor itself asserted, read by // it, `pass1_raised_from` keeps what the survivor itself asserted, read by
// Pass 3 and never returned: `original_severity` is what the kept finding // Pass 3 and never returned: `original_severity` is what the kept finding
@ -61,7 +61,8 @@ export const JUDGE_DETAIL_MAX = 800;
* (agents/review-coordinator.md Pass 3 — "Speculative 'code might break * (agents/review-coordinator.md Pass 3 — "Speculative 'code might break
* somewhere' findings have no anchor"). * somewhere' findings have no anchor").
* *
* `accuracy:refuted` (Pass 2 Accuracy — a citation escaping the repo root) and * `accuracy:refuted` (Pass 2 Accuracy — a citation escaping the repo root, or
* a negative line; a line past the end of the file does not refute) and
* `file-existence:refuted` (Pass 3 — absent from both working tree and diff) * `file-existence:refuted` (Pass 3 — absent from both working tree and diff)
* are emitted by the LLM coordinator, whose fs/judgement branches this module * are emitted by the LLM coordinator, whose fs/judgement branches this module
* excludes. They are declared here anyway: the vocabulary is owned in one * excludes. They are declared here anyway: the vocabulary is owned in one
@ -79,14 +80,17 @@ export const REFUTING_REASONS = Object.freeze(new Set([
* finding, so `succinctness:*` is no longer emitted (a stray one still fails * finding, so `succinctness:*` is no longer emitted (a stray one still fails
* closed through `classifySuppression`). `file-existence:indeterminate` * closed through `classifySuppression`). `file-existence:indeterminate`
* is emitted by the LLM coordinator's Pass 3 (which runs the fs Glob this * is emitted by the LLM coordinator's Pass 3 (which runs the fs Glob this
* module deliberately excludes); the vocabulary is owned here so prose and lib * module deliberately excludes), and `accuracy:line-out-of-range` by its
* cannot drift. * Pass 2 Accuracy (a line past the end of the file: the citation is wrong, the
* claim is unchecked, so a line number alone never refutes); the vocabulary is
* owned here so prose and lib cannot drift.
*/ */
export const UNVERIFIED_REASONS = Object.freeze([ export const UNVERIFIED_REASONS = Object.freeze([
'actionability:empty', 'actionability:empty',
'engine:out-of-scope', 'engine:out-of-scope',
'unknown-rule_key', 'unknown-rule_key',
'file-existence:indeterminate', 'file-existence:indeterminate',
'accuracy:line-out-of-range',
]); ]);
// The brief-conflict mark (`contradicts_brief`) is in neither set on purpose: // The brief-conflict mark (`contradicts_brief`) is in neither set on purpose:
// it is a field on a KEPT finding, never a `suppressed_reason`. As a reason it // it is a field on a KEPT finding, never a `suppressed_reason`. As a reason it
@ -250,9 +254,10 @@ export function ingest(reviewerPayloads) {
* (1) the catalogue tier (`getRule(rule_key)?.severity`, else the finding's own * (1) the catalogue tier (`getRule(rule_key)?.severity`, else the finding's own
* severity; lower `severityRank` wins), (2) a reviewer-agent finding over an * severity; lower `severityRank` wins), (2) a reviewer-agent finding over an
* engine finding, (3) among findings of one kind the higher asserted severity, * engine finding, (3) among findings of one kind the higher asserted severity,
* (4) the conformance reviewer, (5) first seen. It is written with the highest * (4) the conformance reviewer, (5) the first in the group's order (the order
* severity any finding in its group asserted, so dropping a duplicate never * the two passes below place the members in, never the arrival order). It is
* lowers the group (review bc7f4e23; plan 2026-10-11-files-backticks-trial4, * written with the highest severity any finding in its group asserted, so
* dropping a duplicate never lowers the group (review bc7f4e23; plan 2026-10-11-files-backticks-trial4,
* ADR "Pass 1 survivor order and group severity"). Carries raised_by * ADR "Pass 1 survivor order and group severity"). Carries raised_by
* provenance, concatenates other reviewers' attribution into detail, and * provenance, concatenates other reviewers' attribution into detail, and
* recomputes the id over the triplet. * recomputes the id over the triplet.
@ -263,23 +268,30 @@ export function dedupByTriplet(findings) {
// One reviewer never duplicates itself: its second finding on a triplet under // One reviewer never duplicates itself: its second finding on a triplet under
// another title is another finding and opens a group of its own, which gets // another title is another finding and opens a group of its own, which gets
// its own row and the id over `rule_key#2` (voyage-lekerepo trial 5, // its own row and the id over `rule_key#2` (voyage-lekerepo trial 5,
// finding 7). Another reviewer's finding joins the group whose title it // finding 7). Inside one triplet the findings are grouped in two passes, so
// shares, else the first one it can join. // the order the reviewers' findings arrive in never changes which row a
// finding joins (plan 2026-10-20-fix-round-serious-findings, ADR "ORD: two
// passes over the triplet, title first"): (1) every finding with a title
// joins the class of its normalized title, over all of the triplet's
// findings; (2) the title classes, in alphabetical order of the normalized
// title, each join the first earlier group whose every member fits every
// finding of the class (another reviewer, or the same title), else they form
// a group of their own; findings without a title come last, in canonical
// order (reviewer, detail, the whole finding), each joining the first group
// it fits. Members inside a group keep the order the passes place them in:
// the classes alphabetically, each class's findings in canonical order, the
// untitled findings last in canonical order. Rank (5) of the survivor reads
// that order, so a full tie never follows arrival order either: the survivor
// decides Pass 2's actionability and Pass 3's brief-conflict lowering, and so
// the verdict. A triplet's groups keep the order of their earliest input
// index, so the rows and `#k` ids stay in input order.
const tripletGroups = new Map(); const tripletGroups = new Map();
for (const f of findings) { findings.forEach((f, i) => {
const key = tripletKey(f); const key = tripletKey(f);
if (!tripletGroups.has(key)) tripletGroups.set(key, []); if (!tripletGroups.has(key)) tripletGroups.set(key, []);
const subs = tripletGroups.get(key); tripletGroups.get(key).push({ f, i });
const fits = (g) => g.every((m) => m.reviewer !== f.reviewer || sameTitle(m, f)); });
let sub = subs.find((g) => fits(g) && g.some((m) => sameTitle(m, f) && m.title !== undefined && f.title !== undefined)) const groups = [...tripletGroups.values()].flatMap((entries) => groupTriplet(entries));
?? subs.find(fits);
if (!sub) {
sub = [];
subs.push(sub);
}
sub.push(f);
}
const groups = [...tripletGroups.values()].flat();
const out = []; const out = [];
for (const group of groups) { for (const group of groups) {
// Ranked as the JSDoc above says. A brief-conflict mark is not carried over // Ranked as the JSDoc above says. A brief-conflict mark is not carried over
@ -337,9 +349,59 @@ export function dedupByTriplet(findings) {
return out.map((f, i) => ({ ...f, id: ids[i] })); return out.map((f, i) => ({ ...f, id: ids[i] }));
} }
/** A finding's title as the grouping compares it: trimmed, lower case. */
function normTitle(f) {
return String(f.title).trim().toLowerCase();
}
function sameTitle(a, b) { function sameTitle(a, b) {
if (a.title === undefined || b.title === undefined) return true; if (a.title === undefined || b.title === undefined) return true;
return String(a.title).trim().toLowerCase() === String(b.title).trim().toLowerCase(); return normTitle(a) === normTitle(b);
}
/** Code-unit order of two strings (never the locale's). */
function cmpStr(a, b) {
return a < b ? -1 : a > b ? 1 : 0;
}
/**
* The groups of one triplet's findings, in the two passes and the order
* `dedupByTriplet` describes. `entries` are `{ f, i }` (the finding and its
* input index). Returns arrays of findings.
* @param {Array<{f: object, i: number}>} entries
* @returns {object[][]}
*/
function groupTriplet(entries) {
const fits = (m, f) => m.reviewer !== f.reviewer || sameTitle(m, f);
const canon = (x, y) => cmpStr(String(x.f.reviewer ?? ''), String(y.f.reviewer ?? ''))
|| cmpStr(String(x.f.detail ?? ''), String(y.f.detail ?? ''))
|| cmpStr(JSON.stringify(x.f), JSON.stringify(y.f));
const classes = new Map();
const untitled = [];
for (const e of entries) {
if (e.f.title === undefined) {
untitled.push(e);
continue;
}
const t = normTitle(e.f);
if (!classes.has(t)) classes.set(t, []);
classes.get(t).push(e);
}
const groups = [];
const place = (members) => {
let g = groups.find((grp) => grp.every((m) => members.every((e) => fits(m.f, e.f))));
if (!g) {
g = [];
groups.push(g);
}
g.push(...members);
};
for (const t of [...classes.keys()].sort(cmpStr)) place(classes.get(t).sort(canon));
for (const e of untitled.sort(canon)) place([e]);
return groups
.map((g) => ({ first: Math.min(...g.map((e) => e.i)), members: g.map((e) => e.f) }))
.sort((a, b) => a.first - b.first)
.map((g) => g.members);
} }
/** /**
@ -434,8 +496,9 @@ export function judgeFilter(findings) {
/** /**
* Pass 3 — Cloudflare reasonableness (deterministic subset): drop findings * Pass 3 — Cloudflare reasonableness (deterministic subset): drop findings
* with no citation (empty file / line < 0) or an unknown rule_key; CORRECT a * with no citation (empty file / line < 0) and
* severity that does not match the catalogue tier (a correction, not a drop). * route an unknown rule_key to `unverified`; CORRECT a severity that does not
* match the catalogue tier (a correction, not a drop).
* The fs file-existence glob is excluded (I/O) — its indeterminate branch is * The fs file-existence glob is excluded (I/O) — its indeterminate branch is
* prose-side, tokenised as `file-existence:indeterminate`. * prose-side, tokenised as `file-existence:indeterminate`.
* *

View file

@ -34,13 +34,14 @@
// node lib/review/recurrence.mjs propose --repo <dir> // node lib/review/recurrence.mjs propose --repo <dir>
// → {"proposals":[…], "reviews_read": N, "skipped": [...]} on stdout, exit 0; exit 2 = could not run // → {"proposals":[…], "reviews_read": N, "skipped": [...]} on stdout, exit 0; exit 2 = could not run
// node lib/review/recurrence.mjs apply --repo <dir> --rule-key <KEY> [--line "<edited line>"] // node lib/review/recurrence.mjs apply --repo <dir> --rule-key <KEY> [--line "<edited line>"]
// → {"written":true|false, "reason":…} on stdout; exit 0 written, 1 refused, 2 could not run // → {"written":true|false, "reason":…, "pointer":…} on stdout (the pointer result,
// see applyLesson); exit 0 written, 1 refused, 2 could not run
import { import {
readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, readdirSync, statSync, realpathSync, lstatSync, readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, readdirSync, statSync, realpathSync, lstatSync,
} from 'node:fs'; } from 'node:fs';
import { join, relative, dirname } from 'node:path'; import { join, relative, isAbsolute, sep, delimiter, resolve, dirname } from 'node:path';
import { execFileSync } from 'node:child_process'; import { execFileSync, spawnSync } from 'node:child_process';
import { validateReviewContent } from '../validators/review-validator.mjs'; import { validateReviewContent } from '../validators/review-validator.mjs';
import { extractFindingsBlock } from './findings-schema.mjs'; import { extractFindingsBlock } from './findings-schema.mjs';
import { getRule } from './rule-catalogue.mjs'; import { getRule } from './rule-catalogue.mjs';
@ -61,7 +62,7 @@ export const DEFAULT_BUDGET_CHARS = 6000;
export const LINE_MAX = 700; export const LINE_MAX = 700;
export const RECURRENCE_THRESHOLD = 2; export const RECURRENCE_THRESHOLD = 2;
const BLOCK_HEADER = 'Each line: a finding class /trekreview saw in two reviews, approved by the operator. Format: mistake · fix · how to check.'; export const BLOCK_HEADER = 'Each line: a finding class /trekreview saw in two reviews, approved by the operator. Format: mistake · fix · how to check.';
/** How to check a mechanical class. Judgement classes get the review itself. */ /** How to check a mechanical class. Judgement classes get the review itself. */
const CHECK_HINTS = Object.freeze({ const CHECK_HINTS = Object.freeze({
@ -276,17 +277,133 @@ export function proposeLessons(repo) {
// ---- writing an approved lesson --------------------------------------------- // ---- writing an approved lesson ---------------------------------------------
function atomicWriteText(path, text) { /** lstat that answers null for a path that does not exist. */
// Write THROUGH a symlink (CLAUDE.md -> AGENTS.md is common): rename onto the function lstatOrNull(path) {
// real target, so the link survives and the target gets the change. try { return lstatSync(path); } catch (e) { if (e && e.code === 'ENOENT') return null; throw e; }
if (existsSync(path) && lstatSync(path).isSymbolicLink()) path = realpathSync(path); }
mkdirSync(dirname(path), { recursive: true });
/**
* Write `text` to `<repo>/<rel>` atomically, never outside the repo (R3).
* Each directory of `rel` is checked one level at a time, parent first: an
* existing one must resolve (realpath) to the repo's real path or inside it; a
* missing one is created alone (non-recursive) once its parent passed, so no
* directory is ever made through a link that leaves the repo. A file that is
* itself a symlink is refused, never written through.
* @param {string} repo
* @param {string} rel path relative to the repo
* @param {string} text
* @returns {{ written: true } | { written: false, reason: string }}
*/
function atomicWriteText(repo, rel, text) {
const repoReal = realpathSync(repo);
const inside = (real) => {
const r = relative(repoReal, real);
return r === '' || (r !== '..' && !r.startsWith(`..${sep}`) && !isAbsolute(r));
};
const parts = rel.split(/[\\/]+/).filter(Boolean);
let dir = repo;
for (let i = 0; i < parts.length - 1; i += 1) {
dir = join(dir, parts[i]);
const shown = parts.slice(0, i + 1).join('/');
if (lstatOrNull(dir)) {
const real = realpathSync(dir);
if (!inside(real)) {
return { written: false, reason: `directory-outside-repo: ${shown} points outside the repo (${real}); nothing written` };
}
} else {
mkdirSync(dir);
}
}
const path = join(repo, ...parts);
const st = lstatOrNull(path);
if (st && st.isSymbolicLink()) {
return { written: false, reason: `symlink-refused: ${parts.join('/')} is a symlink; it is not written through` };
}
const tmp = `${path}.tmp-${process.pid}`; const tmp = `${path}.tmp-${process.pid}`;
writeFileSync(tmp, text); writeFileSync(tmp, text);
renameSync(tmp, path); renameSync(tmp, path);
return { written: true };
} }
function withPointer(claudeText) { /**
* Is the lessons file ignored by git? `git check-ignore -q` exit 0 = ignored,
* 1 = not ignored; a spawn error or any exit other than 0, 1 or 128 is a
* failed check. Exit 128 means "not a git repository" only some of the time:
* inside a repository it also comes from a path beyond a symbolic link, a
* broken `.git/HEAD` or a `GIT_DIR` that does not exist (CKI), and the probe's
* own `git rev-parse --git-dir` exits 128 too on dubious ownership, a bad
* `.git/config` or a garbage `.git/HEAD`. So on 128 a
* probe decides (see notARepoReason), and only a directory that is truly no
* repository gives `no-git-repo`; everything else, including a probe that
* throws, is `failed`. No branch reads git's message text, so the answer does
* not depend on the language git speaks. Every `failed` note after a 128
* starts `git check-ignore exit 128: <first stderr line>` and adds the reason.
* @returns {{ state: 'ignored' | 'not-ignored' | 'no-git-repo' | 'failed', note?: string }}
*/
function lessonsIgnored(repo, rel) {
const r = spawnSync('git', ['-C', repo, 'check-ignore', '-q', '--', rel], { encoding: 'utf-8', stdio: ['ignore', 'ignore', 'pipe'] });
if (r.error) return { state: 'failed', note: `git check-ignore could not start: ${r.error.message}` };
if (r.status === 0) return { state: 'ignored' };
if (r.status === 1) return { state: 'not-ignored' };
const err = String(r.stderr ?? '').trim().split('\n')[0] ?? '';
if (r.status === 128) {
const head = `git check-ignore exit 128: ${err}`;
try {
const why = notARepoReason(repo);
return why === null ? { state: 'no-git-repo' } : { state: 'failed', note: `${head}; ${why}` };
} catch (e) {
return { state: 'failed', note: `${head}; the repository check threw: ${e.message}` };
}
}
const how = r.status === null ? `signal ${r.signal}` : `exit ${r.status}`;
return { state: 'failed', note: `git check-ignore ${how}${err ? `: ${err}` : ''}` };
}
/**
* After a check-ignore exit 128: null when `repo` is no git repository, else
* the reason it may be one. `git rev-parse --git-dir` exit 0 proves a
* repository; its exit 128 alone proves nothing (see lessonsIgnored), so then
* the answer is null only when GIT_DIR is unset and no `.git` entry (file or
* directory) is found by gitEntryDir. A spawn error or any other exit is a
* reason too. May throw (realpath of a vanished repo); the caller reads that
* as a failed check.
* @returns {string | null}
*/
function notARepoReason(repo) {
const p = spawnSync('git', ['-C', repo, 'rev-parse', '--git-dir'], { encoding: 'utf-8', stdio: ['ignore', 'ignore', 'ignore'] });
if (p.error) return `git rev-parse --git-dir could not start: ${p.error.message}`;
if (p.status !== 128) return `git rev-parse --git-dir ${p.status === null ? `signal ${p.signal}` : `exit ${p.status}`}`;
if (process.env.GIT_DIR !== undefined) return 'GIT_DIR is set';
const found = gitEntryDir(repo);
return found === null ? null : `a .git entry exists in ${found}`;
}
/**
* The directory holding a `.git` entry, walking up from `realpath(repo)`, or
* null. `realpath(repo)` itself is always checked; the walk stops before the
* nearest proper ancestor that equals a GIT_CEILING_DIRECTORIES entry (split
* on the path delimiter, empty entries skipped, each entry realpath'd, or
* resolved when that throws), and without one it goes up to and including
* `/`. An entry equal to `realpath(repo)` stops nothing, as in git. Unlike
* git, the walk does not stop at a filesystem boundary: crossing one can only
* find a `.git` git would not, which reads as a failed check (the safe side).
* @returns {string | null}
*/
function gitEntryDir(repo) {
const ceilings = new Set(String(process.env.GIT_CEILING_DIRECTORIES ?? '')
.split(delimiter)
.filter((e) => e !== '')
.map((e) => { try { return realpathSync(e); } catch { return resolve(e); } }));
let dir = realpathSync(repo);
for (;;) {
if (existsSync(join(dir, '.git'))) return dir;
const up = dirname(dir);
if (up === dir || ceilings.has(up)) return null;
dir = up;
}
}
export function withPointer(claudeText) {
if (claudeText.includes(POINTER_START) || claudeText.includes(POINTER_END)) return null; if (claudeText.includes(POINTER_START) || claudeText.includes(POINTER_END)) return null;
const sep = claudeText.endsWith('\n') ? '\n' : '\n\n'; const sep = claudeText.endsWith('\n') ? '\n' : '\n\n';
return `${claudeText}${sep}${POINTER_START}\n${POINTER_LINE}\n${POINTER_END}\n`; return `${claudeText}${sep}${POINTER_START}\n${POINTER_LINE}\n${POINTER_END}\n`;
@ -298,7 +415,16 @@ function withPointer(claudeText) {
* @param {string} repo * @param {string} repo
* @param {string} ruleKey * @param {string} ruleKey
* @param {{ line?: string, budgetEntries?: number, budgetChars?: number }} [opts] * @param {{ line?: string, budgetEntries?: number, budgetChars?: number }} [opts]
* @returns {{ written: boolean, reason: string, path?: string, pointer?: string }} * The result's `pointer` (when written): 'written' | 'present' | 'no-claude-md' |
* 'symlink-skipped' (CLAUDE.md is a symlink, DJ4) | 'ignored-skipped' (git
* ignores the lessons file, PTR) | 'check-failed' (git check-ignore did not
* answer, or answered exit 128 for a directory that may be a repository, see
* lessonsIgnored; `pointer_note` says how, and always names check-ignore).
* Only a directory that is truly no repository gets the pointer without the
* check.
* Nothing is written through a symlinked
* lessons file or a lessons directory outside the repo (R3): written false.
* @returns {{ written: boolean, reason: string, path?: string, pointer?: string, pointer_note?: string }}
*/ */
export function applyLesson(repo, ruleKey, opts = {}) { export function applyLesson(repo, ruleKey, opts = {}) {
const budgetEntries = opts.budgetEntries ?? DEFAULT_BUDGET_ENTRIES; const budgetEntries = opts.budgetEntries ?? DEFAULT_BUDGET_ENTRIES;
@ -333,16 +459,36 @@ export function applyLesson(repo, ruleKey, opts = {}) {
return { written: false, reason: 'over-budget', detail: 'the managed block is full — the lessons belong in a skill now (operator decision)' }; return { written: false, reason: 'over-budget', detail: 'the managed block is full — the lessons belong in a skill now (operator decision)' };
} }
const next = base.slice(0, block.start + MARK_START.length) + innerAfter + base.slice(block.end); const next = base.slice(0, block.start + MARK_START.length) + innerAfter + base.slice(block.end);
atomicWriteText(path, next); const rel = `${LESSONS_DIR}/${LESSONS_FILE_NAME}`.split(/[\\/]+/).join('/'); // git and the report use '/'
const wrote = atomicWriteText(repo, rel, next);
if (!wrote.written) return { written: false, reason: wrote.reason };
// The CLAUDE.md pointer. A symlinked CLAUDE.md is skipped, never written
// through: committing the link's target would change the R6 exception (DJ4).
// A lessons file git ignores gets no pointer: it would point at a file the
// commit does not carry (PTR). A failed ignore check writes no pointer.
let pointer = 'no-claude-md'; let pointer = 'no-claude-md';
let pointerNote;
const claudePath = join(repo, 'CLAUDE.md'); const claudePath = join(repo, 'CLAUDE.md');
if (existsSync(claudePath)) { const claudeStat = lstatOrNull(claudePath);
const updated = withPointer(readFileSync(claudePath, 'utf-8')); if (claudeStat && claudeStat.isSymbolicLink()) {
if (updated === null) pointer = 'present'; pointer = 'symlink-skipped';
else { atomicWriteText(claudePath, updated); pointer = 'written'; } } else if (claudeStat) {
const ignored = lessonsIgnored(repo, rel);
if (ignored.state === 'ignored') pointer = 'ignored-skipped';
else if (ignored.state === 'failed') { pointer = 'check-failed'; pointerNote = ignored.note; }
else {
const updated = withPointer(readFileSync(claudePath, 'utf-8'));
if (updated === null) pointer = 'present';
else {
// Refused only when CLAUDE.md turned into a symlink since the lstat above.
pointer = atomicWriteText(repo, 'CLAUDE.md', updated).written ? 'written' : 'symlink-skipped';
}
}
} }
return { written: true, reason: 'written', path: relative(repo, path), pointer }; const result = { written: true, reason: 'written', path: relative(repo, path), pointer };
if (pointerNote !== undefined) result.pointer_note = pointerNote;
return result;
} }
// ---- CLI -------------------------------------------------------------------- // ---- CLI --------------------------------------------------------------------

View file

@ -0,0 +1,237 @@
// lib/review/reviewed-commit.mjs
// R6 — is the review of the commit being merged? (fix round 4-5a)
//
// A review verdict opens the merge into main only for the history it judged.
// `/trekexecute` commits the review lessons after the review, so the commit
// being merged is rarely the reviewed one itself; what may come after it is
// exactly what /trekreview writes and nothing else:
//
// - the reviewed commit is HEAD, or an ancestor of HEAD;
// - every commit between it and HEAD (side branches included, each diffed
// against its first parent, `--no-relative --ignore-submodules=none`) changes only
// `{prefix}.claude/rules/voyage-review-lessons.md` and `{prefix}CLAUDE.md`,
// where {prefix} is the project's place in the repo (`git rev-parse
// --show-prefix`);
// - in each such commit the lessons file is unchanged outside its managed
// block, every line its block gains is a lesson line (`validateLessonLine`)
// or exactly `BLOCK_HEADER` (m4), and CLAUDE.md is unchanged or gains exactly the pointer block
// (`withPointer` in lib/review/recurrence.mjs);
// - a commit with no parent in that range fails closed, and so does a
// symlinked CLAUDE.md (the pointer write goes through the link, so its
// target shows as a changed path).
//
// No CLI of its own: the command is `lib/review/verdict-gate.mjs --repo <dir>`,
// which exits 2 (never green) with the reason when `checkReviewedCommit` is
// not ok. Two kinds of reason (M4):
//
// - `review-stale: …` — the gate looked, and the review is of another
// commit (or something else changed after it);
// - `review-not-measured: …` — the gate could not look: the `--repo` path
// is missing, it is no git repository, or a git call failed. That is not
// a finding about the review, so it is never reported as stale.
//
// Every git call is an argv array with no shell; a failed git call fails
// closed (not measured), never ok.
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { LESSONS_FILE_NAME, MARK_START, MARK_END, BLOCK_HEADER, withPointer, validateLessonLine } from './recurrence.mjs';
/**
* A full commit id: 40 hex (SHA-1) or 64 hex (SHA-256), nothing shorter (M11).
* A short id can also be a branch name, which resolves to wherever the branch
* points now (DJ3).
*/
export const FULL_SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
const LESSONS_REL = `.claude/rules/${LESSONS_FILE_NAME}`; // posix, as `git diff` prints it
const CLAUDE_REL = 'CLAUDE.md';
const SYMLINK_MODE = '120000';
/**
* The review's `scope_sha_end` as written. Read from the frontmatter text, not
* through the YAML reader, which turns an all-digit value into a number and
* drops a leading zero (`0123456` → 123456; release review of 6.3.0, round 3).
*/
export function reviewedSha(reviewText) {
const fm = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(String(reviewText ?? ''));
const m = fm && /^scope_sha_end:[ \t]*(["']?)([^"'\s#]*)\1[ \t]*(?:#.*)?$/m.exec(fm[1]);
// A commit sha only: a ref name (HEAD, main, a tag) resolves to whatever it
// points at now, and coverage would pass whatever was reviewed (round 4);
// so can a short id that is also a branch name (M11).
return m && FULL_SHA_RE.test(m[2]) ? m[2] : '';
}
function git(repo, args) {
return spawnSync('git', args, { cwd: repo, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
}
function stale(reason) {
return { ok: false, reason: `review-stale: ${reason}` };
}
/** The gate could not look (M4): exit 2 like a stale review, but never called one. */
function notMeasured(reason) {
return { ok: false, reason: `review-not-measured: ${reason}` };
}
/** What a git call said: its spawn error, else the first line of its stderr. */
function gitNote(r) {
return r.error?.message ?? String(r.stderr ?? '').trim().split('\n')[0];
}
/** A failed git call: not measured, fails closed. */
function gitFailed(what, r) {
return notMeasured(`git ${what} failed (${gitNote(r) || `exit ${r.status}`})`);
}
const short = (c) => c.slice(0, 12);
/** Where the managed block's start marker (`s`) and end marker (`e`) are; null when either is missing. */
function blockBounds(text) {
const s = text.indexOf(MARK_START);
if (s === -1) return null;
const e = text.indexOf(MARK_END, s + MARK_START.length);
if (e === -1) return null;
return { s, e };
}
/** The lessons text with its managed block (start marker to end marker, plus its newline) taken out. */
function outsideBlock(text) {
const b = blockBounds(text);
if (!b) return text;
const { s, e } = b;
let end = e + MARK_END.length;
if (text.startsWith('\r\n', end)) end += 2;
else if (text[end] === '\n') end += 1;
return text.slice(0, s) + text.slice(end);
}
/** The non-blank lines inside the managed block (the same block `outsideBlock` takes out); none without one. */
function blockLines(text) {
const b = blockBounds(text);
if (!b) return [];
return text.slice(b.s + MARK_START.length, b.e).split(/\r?\n/).filter((l) => l.trim() !== '');
}
/**
* The lines in `after`'s block that `before`'s block does not hold, counted as
* a multiset (a second copy of a line already there is added).
*/
function addedBlockLines(before, after) {
const left = new Map();
for (const l of blockLines(before)) left.set(l, (left.get(l) ?? 0) + 1);
const added = [];
for (const l of blockLines(after)) {
const n = left.get(l) ?? 0;
if (n > 0) left.set(l, n - 1);
else added.push(l);
}
return added;
}
/**
* One file in one tree: `{ ok, mode, text }`; absent reads as mode null, text ''.
* `--full-tree`: the path is from the repo root, as `git diff` prints it.
*/
function blobAt(repo, rev, path) {
const ls = git(repo, ['ls-tree', '--full-tree', '-z', rev, '--', path]);
if (ls.status !== 0) return { ok: false, fail: gitFailed(`ls-tree ${rev}`, ls) };
const entry = ls.stdout.split('\0').find(Boolean);
if (!entry) return { ok: true, mode: null, text: '' };
const mode = entry.split(' ')[0];
const blob = git(repo, ['cat-file', 'blob', `${rev}:${path}`]);
if (blob.status !== 0) return { ok: false, fail: gitFailed(`cat-file ${rev}:${path}`, blob) };
return { ok: true, mode, text: blob.stdout };
}
/**
* Is the review of `sha` a review of what HEAD holds in `repo`?
* @param {{ repo: string, sha: string }} args
* @returns {{ ok: true } | { ok: false, reason: string }} reason starts `review-stale:`
* (the review is of another commit) or `review-not-measured:` (the gate could not look)
*/
export function checkReviewedCommit({ repo, sha } = {}) {
if (typeof repo !== 'string' || repo === '') return stale('no repository given');
if (!existsSync(repo)) return notMeasured(`--repo ${repo} does not exist`);
const given = String(sha ?? '');
const notCommit = `reviewed commit ${given} is not a commit in this repository`;
// 1. Full hex only, so a value like `--output=/x` never reaches git as an option.
if (!FULL_SHA_RE.test(given)) return stale(notCommit);
const resolved = git(repo, ['rev-parse', '--verify', '--quiet', `${given}^{commit}`]);
// `--quiet` makes an unknown commit exit 1 with nothing on stderr: that is
// stale. A spawn error, any other exit, or stderr text (`not a git
// repository`, a broken git) means the gate could not look.
if (resolved.status === 1 && !resolved.error && String(resolved.stderr ?? '').trim() === '') {
return stale(notCommit);
}
if (resolved.status !== 0) {
const note = gitNote(resolved);
if (/not a git repository/.test(note)) return notMeasured(`${repo} is not a git repository (${note})`);
return gitFailed('rev-parse --verify', resolved);
}
const full = resolved.stdout.trim();
// A ref named like an id (64 hex in a SHA-1 repository) also resolves: the
// id must be the commit itself, not a name that points at one (M11).
if (full !== given.toLowerCase()) return stale(`${given} is not a full commit id in this repository`);
// 2. The review is of HEAD itself.
const head = git(repo, ['rev-parse', '--verify', 'HEAD']);
if (head.status !== 0) return gitFailed('rev-parse HEAD', head);
if (head.stdout.trim() === full) return { ok: true };
// 3. Ancestor of HEAD (side branches merged in count; an unmerged one does not).
const anc = git(repo, ['merge-base', '--is-ancestor', full, 'HEAD']);
if (anc.status === 1) return stale('the reviewed commit is not an ancestor of HEAD');
if (anc.status !== 0) return gitFailed('merge-base --is-ancestor', anc);
// 4. Every commit after the review, paths and content.
const pre = git(repo, ['rev-parse', '--show-prefix']);
if (pre.status !== 0) return gitFailed('rev-parse --show-prefix', pre);
const prefix = pre.stdout.replace(/\r?\n$/, '');
const lessons = `${prefix}${LESSONS_REL}`;
const claude = `${prefix}${CLAUDE_REL}`;
// One line per commit, `<commit> <parent>...`; a line with one field is a root.
const list = git(repo, ['rev-list', '--parents', `${full}..HEAD`]);
if (list.status !== 0) return gitFailed('rev-list --parents', list);
const lines = list.stdout.split('\n').filter(Boolean).map((l) => l.split(' '));
const root = lines.find((f) => f.length === 1);
if (root) return stale(`commit ${short(root[0])} has no parent`);
for (const [c] of lines) {
// `--no-relative` and `--ignore-submodules=none`: neither `diff.relative`
// nor an `ignore =` setting (config or .gitmodules) may hide a changed path.
const names = git(repo, ['diff', '--no-relative', '--ignore-submodules=none', '--name-only', '-z', '--no-renames', `${c}^1`, c]);
if (names.status !== 0) return gitFailed(`diff ${short(c)}`, names);
const paths = names.stdout.split('\0').filter(Boolean);
const other = paths.find((p) => p !== lessons && p !== claude);
if (other !== undefined) return stale(`${other} changed after the review (commit ${short(c)})`);
for (const path of paths) {
const before = blobAt(repo, `${c}^1`, path);
if (!before.ok) return before.fail;
const after = blobAt(repo, c, path);
if (!after.ok) return after.fail;
if (before.mode === SYMLINK_MODE || after.mode === SYMLINK_MODE) {
return stale(`${path} is a symlink (commit ${short(c)}) — re-run /trekreview on this commit`);
}
if (path === lessons) {
// 5. Only the managed block may change.
if (outsideBlock(before.text).trimEnd() !== outsideBlock(after.text).trimEnd()) {
return stale(`${path} changed outside the managed block (commit ${short(c)})`);
}
// 5b. What the block gains is lesson lines, or the header applyLesson
// writes with the first lesson; free text there is an instruction (m4).
const free = addedBlockLines(before.text, after.text)
.find((l) => l !== BLOCK_HEADER && !validateLessonLine(l).valid);
if (free !== undefined) {
return stale(`free text added inside the lessons block (commit ${short(c)})`);
}
} else if (after.text !== before.text && after.text !== withPointer(before.text)) {
// 6. CLAUDE.md: unchanged, or exactly the pointer block added.
return stale(`${path} changed other than the review-lessons pointer (commit ${short(c)})`);
}
}
}
return { ok: true };
}

View file

@ -4,7 +4,7 @@
// One command decides on the verdict written in review.md, so whatever stops a // One command decides on the verdict written in review.md, so whatever stops a
// merge or a release reads a file and an exit code, never a model's summary: // merge or a release reads a file and an exit code, never a model's summary:
// //
// node lib/review/verdict-gate.mjs [--json] <review.md> // node lib/review/verdict-gate.mjs [--json] <review.md> [--repo <dir> | --repo=<dir>]
// 0 = ALLOW or WARN // 0 = ALLOW or WARN
// 1 = BLOCK // 1 = BLOCK
// 2 = could not decide: missing/unreadable file, not a review, no verdict, // 2 = could not decide: missing/unreadable file, not a review, no verdict,
@ -13,7 +13,17 @@
// shape (coordinator-contract `illegalBriefLowering`), or a // shape (coordinator-contract `illegalBriefLowering`), or a
// WARN because a reviewer or engine did not run (`missing-reviewer:` / // WARN because a reviewer or engine did not run (`missing-reviewer:` /
// `unattributable-payload` in allow_blocked_by — operator decision // `unattributable-payload` in allow_blocked_by — operator decision
// 2026-10-06: a review not performed in full stops a merge). // 2026-10-06: a review not performed in full stops a merge), a
// `--repo` or `--repo=` with no value, any other option (M6), or,
// with `--repo <dir>`, an ALLOW/WARN
// review of another commit than the one being merged (reason
// `review-stale: …`: its `scope_sha_end` is not HEAD, or an ancestor
// of HEAD whose later commits change only the review lessons and the
// CLAUDE.md pointer; lib/review/reviewed-commit.mjs, R6), or, with
// `--repo <dir>`, a check that could not look (reason
// `review-not-measured: …`: the path is missing, it is no git
// repository, or a git call failed; M4) — printed as it is, never
// as `review-stale`.
// Exit 2 is NEVER green. // Exit 2 is NEVER green.
// //
// Fail-closed reading: a missing `verdict` is exit 2, never ALLOW. A verdict // Fail-closed reading: a missing `verdict` is exit 2, never ALLOW. A verdict
@ -32,6 +42,7 @@ import { readFileSync, existsSync } from 'node:fs';
import { validateReviewContent, VERDICT_VALUES } from '../validators/review-validator.mjs'; import { validateReviewContent, VERDICT_VALUES } from '../validators/review-validator.mjs';
import { fencedBlocks, isJsonBlock } from './findings-schema.mjs'; import { fencedBlocks, isJsonBlock } from './findings-schema.mjs';
import { illegalBriefLowering } from './coordinator-contract.mjs'; import { illegalBriefLowering } from './coordinator-contract.mjs';
import { reviewedSha, checkReviewedCommit } from './reviewed-commit.mjs';
import { isMain } from '../util/is-main.mjs'; import { isMain } from '../util/is-main.mjs';
export const EXIT = Object.freeze({ PASS: 0, BLOCK: 1, UNDECIDED: 2 }); export const EXIT = Object.freeze({ PASS: 0, BLOCK: 1, UNDECIDED: 2 });
@ -159,10 +170,12 @@ function blockerSectionEntries(body) {
} }
/** /**
* Decide on a review.md path. * Decide on a review.md path. With `repo`, an ALLOW/WARN review must also be
* of the commit being merged there (R6); BLOCK and exit 2 are returned as they are.
* @param {string} filePath * @param {string} filePath
* @param {{ repo?: string }} [opts]
*/ */
export function decideVerdictFile(filePath) { export function decideVerdictFile(filePath, { repo } = {}) {
if (!filePath || !existsSync(filePath)) return undecided(`file not found: ${filePath}`); if (!filePath || !existsSync(filePath)) return undecided(`file not found: ${filePath}`);
let text; let text;
try { try {
@ -170,21 +183,58 @@ export function decideVerdictFile(filePath) {
} catch (e) { } catch (e) {
return undecided(`cannot read ${filePath}: ${e.message}`); return undecided(`cannot read ${filePath}: ${e.message}`);
} }
return decideVerdict(text); const decided = decideVerdict(text);
if (repo === undefined || decided.exit !== EXIT.PASS) return decided;
const sha = reviewedSha(text);
if (!sha) return undecided('review-stale: review.md has no scope_sha_end that is a commit sha', decided.verdict);
const commit = checkReviewedCommit({ repo, sha });
if (!commit.ok) return undecided(commit.reason, decided.verdict);
return decided;
} }
if (isMain(import.meta.url)) { if (isMain(import.meta.url)) {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const files = args.filter((a) => !a.startsWith('--')); const usage = 'Usage: verdict-gate.mjs [--json] <review.md> [--repo <dir> | --repo=<dir>] (exactly one file)\n';
// --repo consumes its value before the file argument is collected, so the
// value is never read as a second file. Any other option is refused (M6): a
// dropped --repo=<dir> or a misspelt flag would otherwise pass the gate
// without the check it asked for.
const files = [];
let repo;
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--repo') {
const v = args[++i];
if (v === undefined || v.startsWith('--')) {
process.stderr.write(`verdict-gate: --repo needs a value\n${usage}`);
process.exit(EXIT.UNDECIDED);
}
repo = v;
} else if (a.startsWith('--repo=')) {
const v = a.slice('--repo='.length);
if (v === '') {
process.stderr.write(`verdict-gate: --repo= needs a value\n${usage}`);
process.exit(EXIT.UNDECIDED);
}
repo = v;
} else if (a === '--json') {
// read below
} else if (a.startsWith('--')) {
process.stderr.write(`verdict-gate: unknown option ${a}\n${usage}`);
process.exit(EXIT.UNDECIDED);
} else {
files.push(a);
}
}
const filePath = files[0]; const filePath = files[0];
if (files.length !== 1) { if (files.length !== 1) {
// Exactly one file: a glob would otherwise gate on its first match only. // Exactly one file: a glob would otherwise gate on its first match only.
process.stderr.write('Usage: verdict-gate.mjs [--json] <review.md> (exactly one file)\n'); process.stderr.write(usage);
process.exit(EXIT.UNDECIDED); process.exit(EXIT.UNDECIDED);
} }
let result; let result;
try { try {
result = decideVerdictFile(filePath); result = decideVerdictFile(filePath, { repo });
} catch (e) { } catch (e) {
result = undecided(`gate crashed: ${e.message}`); result = undecided(`gate crashed: ${e.message}`);
} }

View file

@ -20,7 +20,11 @@
// A case is tagged in its prompt.md frontmatter: // A case is tagged in its prompt.md frontmatter:
// tags: [skill-trigger | skill-no-trigger | skill-effect, skill=<name>, // tags: [skill-trigger | skill-no-trigger | skill-effect, skill=<name>,
// owner=<command|agent|skill name> (negatives), from=<event>] // owner=<command|agent|skill name> (negatives), from=<event>]
// `from=` names the event the case came from (P10: every case is marked). // An owner may also be one of Claude Code's own skills on BUILTIN_OWNERS (R2);
// it stands outside the corpus, so only "the skill does not rank first" is checked.
// `from=` names the event the case came from (P10, R1): it is required on every
// case in evals/, and a case with no skill tag (the fixed test set) is not
// routed, only checked for `from=`.
// //
// What this cannot see: how Claude Code really chooses. A ranking over word // What this cannot see: how Claude Code really chooses. A ranking over word
// overlap is a proxy; the operator's eval run is the measurement. // overlap is a proxy; the operator's eval run is the measurement.
@ -180,8 +184,12 @@ export function loadEntries(root) {
const KIND_TAGS = { 'skill-trigger': 'trigger', 'skill-no-trigger': 'no-trigger', 'skill-effect': 'effect' }; const KIND_TAGS = { 'skill-trigger': 'trigger', 'skill-no-trigger': 'no-trigger', 'skill-effect': 'effect' };
/** The routing-tagged eval cases of a plugin root (evals/<case>/prompt.md). */ /**
export function loadCases(root) { * Every eval case of a plugin root (evals/<case>/prompt.md), routed or not.
* `kind` is null for a case with no skill tag (the fixed test set): it is not
* routed, only checked for `from=` (P10, R1).
*/
export function loadAllCases(root) {
const d = join(root, 'evals'); const d = join(root, 'evals');
if (!existsSync(d)) return []; if (!existsSync(d)) return [];
const out = []; const out = [];
@ -192,16 +200,30 @@ export function loadCases(root) {
const tagsField = readFrontmatterFields(text).fields.tags; const tagsField = readFrontmatterFields(text).fields.tags;
const tags = tagsField && Array.isArray(tagsField.value) ? tagsField.value.map(String) : []; const tags = tagsField && Array.isArray(tagsField.value) ? tagsField.value.map(String) : [];
const kindTag = tags.find((t) => Object.hasOwn(KIND_TAGS, t)); const kindTag = tags.find((t) => Object.hasOwn(KIND_TAGS, t));
if (!kindTag) continue;
const val = (k) => { const t = tags.find((x) => x.startsWith(`${k}=`)); return t ? t.slice(k.length + 1) : null; }; const val = (k) => { const t = tags.find((x) => x.startsWith(`${k}=`)); return t ? t.slice(k.length + 1) : null; };
const body = text.replace(/^---\n[\s\S]*?\n---\n?/, '').trim(); const body = text.replace(/^---\n[\s\S]*?\n---\n?/, '').trim();
out.push({ case: name, kind: KIND_TAGS[kindTag], skill: val('skill'), owner: val('owner'), from: val('from'), prompt: body }); out.push({ case: name, kind: kindTag ? KIND_TAGS[kindTag] : null, skill: val('skill'), owner: val('owner'), from: val('from'), prompt: body });
} }
return out; return out;
} }
/** The routing-tagged eval cases of a plugin root. */
export function loadCases(root) {
return loadAllCases(root).filter((c) => c.kind);
}
// ---- the check ---- // ---- the check ----
/**
* Claude Code's own skills a negative case may name as its owner (R2). They
* stand outside the corpus, so for such an owner only "not taken" is checked:
* the skill under test must not rank first. Kept short and fixed.
* run - the bundled "Launch and drive your app to see a change working"
* skill, seen in Claude Code 2.1.303 (code.claude.com/docs/en/skills,
* "Run and verify your app").
*/
export const BUILTIN_OWNERS = Object.freeze(['run']);
/** /**
* Run the whole routing check over a plugin root. * Run the whole routing check over a plugin root.
* @returns {{ errors: object[], warnings: object[], stats: object }} * @returns {{ errors: object[], warnings: object[], stats: object }}
@ -214,9 +236,11 @@ export function checkRouting(root, t = THRESHOLDS) {
// name must not take its place. An owner may be any kind; a skill wins a tie. // name must not take its place. An owner may be any kind; a skill wins a tie.
const skillByName = new Map(skills.map((e) => [e.name, e])); const skillByName = new Map(skills.map((e) => [e.name, e]));
const byName = new Map([...entries.filter((e) => e.kind !== 'skill'), ...skills].map((e) => [e.name, e])); const byName = new Map([...entries.filter((e) => e.kind !== 'skill'), ...skills].map((e) => [e.name, e]));
const cases = loadCases(root); const all = loadAllCases(root);
const cases = all.filter((c) => c.kind);
const errors = []; const errors = [];
const warnings = []; const warnings = [];
const notes = [];
let positives = 0; let rank1 = 0; let negatives = 0; let positives = 0; let rank1 = 0; let negatives = 0;
for (const c of collisions(corpus, t)) { for (const c of collisions(corpus, t)) {
@ -225,8 +249,11 @@ export function checkRouting(root, t = THRESHOLDS) {
(c.level === 'error' ? errors : warnings).push(entry); (c.level === 'error' ? errors : warnings).push(entry);
} }
for (const c of cases) { for (const c of all) {
if (!c.from) errors.push({ code: 'ROUTING_CASE_NO_SOURCE', case: c.case, message: `${c.case}: tag the event the case came from (from=...)` }); if (!c.from) errors.push({ code: 'ROUTING_CASE_NO_SOURCE', case: c.case, message: `${c.case}: tag the event the case came from (from=...)` });
}
for (const c of cases) {
const self = c.skill && skillByName.get(c.skill); const self = c.skill && skillByName.get(c.skill);
if (!self || self.kind !== 'skill') { if (!self || self.kind !== 'skill') {
errors.push({ code: 'ROUTING_UNKNOWN_SKILL', case: c.case, message: `${c.case}: skill=${c.skill} is not a skill in skills/` }); errors.push({ code: 'ROUTING_UNKNOWN_SKILL', case: c.case, message: `${c.case}: skill=${c.skill} is not a skill in skills/` });
@ -251,6 +278,9 @@ export function checkRouting(root, t = THRESHOLDS) {
} }
if (!c.owner) { if (!c.owner) {
errors.push({ code: 'ROUTING_NEGATIVE_NO_OWNER', case: c.case, message: `${c.case}: a negative case names the owner that should take the prompt (owner=...)` }); errors.push({ code: 'ROUTING_NEGATIVE_NO_OWNER', case: c.case, message: `${c.case}: a negative case names the owner that should take the prompt (owner=...)` });
} else if (!byName.has(c.owner) && BUILTIN_OWNERS.includes(c.owner)) {
notes.push({ code: 'ROUTING_OWNER_OUTSIDE_CORPUS', case: c.case,
message: `${c.case}: owner=${c.owner} is Claude Code's own; owner outside the corpus: only "not taken" checked` });
} else if (!byName.has(c.owner)) { } else if (!byName.has(c.owner)) {
errors.push({ code: 'ROUTING_UNKNOWN_OWNER', case: c.case, message: `${c.case}: owner=${c.owner} is no skill, command or spawnable agent` }); errors.push({ code: 'ROUTING_UNKNOWN_OWNER', case: c.case, message: `${c.case}: owner=${c.owner} is no skill, command or spawnable agent` });
} else { } else {
@ -276,7 +306,7 @@ export function checkRouting(root, t = THRESHOLDS) {
if (positives && rate < t.MIN_RANK1_RATE) { if (positives && rate < t.MIN_RANK1_RATE) {
errors.push({ code: 'ROUTING_RANK1_FLOOR', message: `${rank1} of ${positives} positives rank their skill first (${(rate * 100).toFixed(0)}%); the floor is ${(t.MIN_RANK1_RATE * 100).toFixed(0)}%` }); errors.push({ code: 'ROUTING_RANK1_FLOOR', message: `${rank1} of ${positives} positives rank their skill first (${(rate * 100).toFixed(0)}%); the floor is ${(t.MIN_RANK1_RATE * 100).toFixed(0)}%` });
} }
return { errors, warnings, stats: { entries: entries.length, skills: skills.length, cases: cases.length, positives, negatives, rank1 } }; return { errors, warnings, notes, stats: { entries: entries.length, skills: skills.length, sourced: all.length, cases: cases.length, positives, negatives, rank1 } };
} }
// ---- CLI ---- // ---- CLI ----
@ -294,7 +324,8 @@ if (isMain(import.meta.url)) {
else { else {
for (const e of r.errors) process.stdout.write(`FAIL ${e.code}: ${e.message}\n`); for (const e of r.errors) process.stdout.write(`FAIL ${e.code}: ${e.message}\n`);
for (const w of r.warnings) process.stdout.write(`warn ${w.code}: ${w.message}\n`); for (const w of r.warnings) process.stdout.write(`warn ${w.code}: ${w.message}\n`);
process.stdout.write(`${r.stats.entries} descriptions, ${r.stats.cases} cases, ${r.stats.rank1}/${r.stats.positives} positives first; ${r.errors.length} finding(s)\n`); for (const n of r.notes) process.stdout.write(`note ${n.code}: ${n.message}\n`);
process.stdout.write(`${r.stats.entries} descriptions, ${r.stats.sourced} cases checked for from=, ${r.stats.cases} routed, ${r.stats.rank1}/${r.stats.positives} positives first; ${r.errors.length} finding(s)\n`);
} }
process.exit(r.errors.length ? 1 : 0); process.exit(r.errors.length ? 1 : 0);
} }

View file

@ -16,7 +16,10 @@
// finish — pipeline reached its end // finish — pipeline reached its end
// //
// CLI shim: // CLI shim:
// node lib/util/autonomy-gate.mjs --state X --event Y [--gates true|false] // node lib/util/autonomy-gate.mjs --state X --event Y [--gates [true|false|closed|open]]
// (bare --gates = on; true|closed = on, false|open = off; any other value
// is a usage error, exit 1; case-sensitive. One token --gates=<value> is
// read as --gates <value>; an empty value is the same usage error)
// → JSON: { ok: true, next_state: "..." } (success) // → JSON: { ok: true, next_state: "..." } (success)
// → JSON: { ok: false, error: "..." } (invalid transition; exit 1) // → JSON: { ok: false, error: "..." } (invalid transition; exit 1)
// //
@ -102,15 +105,40 @@ export function isTerminal(state) {
// ---- CLI shim ---------------------------------------------------------------- // ---- CLI shim ----------------------------------------------------------------
/** A --gates value: true|closed = on, false|open = off, else undefined. */
function gatesValue(v) {
if (v === 'true' || v === 'closed') return true;
if (v === 'false' || v === 'open') return false;
return undefined;
}
const GATES_USAGE = 'usage: --gates takes true, false, closed or open';
function parseArgs(argv) { function parseArgs(argv) {
const out = {}; const out = {};
const setGates = (v) => {
const g = gatesValue(v);
if (g === undefined) out.error = GATES_USAGE;
else out.gates = g;
};
for (let i = 0; i < argv.length; i++) { for (let i = 0; i < argv.length; i++) {
const a = argv[i]; const a = argv[i];
if (a === '--state') out.state = argv[++i]; if (a === '--state') out.state = argv[++i];
else if (a === '--event') out.event = argv[++i]; else if (a === '--event') out.event = argv[++i];
else if (a === '--gates') { else if (a.startsWith('--gates=')) {
const v = argv[++i]; // The one-token form, read as `--gates <value>`; an empty value is no value.
out.gates = v === 'true'; setGates(a.slice('--gates='.length));
} else if (a === '--gates') {
// Bare `--gates` (last, or followed by another flag) means on and
// consumes nothing. A value is case-sensitive: true|closed = on,
// false|open = off; anything else is a usage error.
const v = argv[i + 1];
if (v === undefined || v.startsWith('--')) {
out.gates = true;
} else {
setGates(v);
i++;
}
} }
} }
return out; return out;
@ -118,10 +146,11 @@ function parseArgs(argv) {
if (isMain(import.meta.url)) { if (isMain(import.meta.url)) {
const args = parseArgs(process.argv.slice(2)); const args = parseArgs(process.argv.slice(2));
if (!args.state || !args.event) { if (args.error || !args.state || !args.event) {
process.stdout.write(JSON.stringify({ process.stdout.write(JSON.stringify({
ok: false, ok: false,
error: 'usage: autonomy-gate.mjs --state <state> --event <event> [--gates true|false]', error: args.error
|| 'usage: autonomy-gate.mjs --state <state> --event <event> [--gates [true|false|closed|open]]',
}) + '\n'); }) + '\n');
process.exit(1); process.exit(1);
} }

View file

@ -7,37 +7,59 @@
// rejected line would have passed Verify, the manifest and the success // rejected line would have passed Verify, the manifest and the success
// criterion. Only the plan critic caught it. // criterion. Only the plan critic caught it.
// //
// Before the fresh build the executor parks them: every file the attempt // Before the fresh build the executor parks them: it parks every file new in
// created (a `new file mode` entry in step-{N}-upward.diff) that is still // the attempt's diff (a `new file mode` entry in step-{N}-upward.diff) that is
// untracked on disk moves to <git dir>/voyage/parked/step-<N>-attempt-<k>/ // untracked, or staged but not in HEAD; it moves to
// under its own path. The git dir, like the test lock's, is outside the work // <git dir>/voyage/parked/step-<N>-attempt-<k>/ under its own path. The git dir, like the test lock's, is outside the work
// tree, so a parked file never comes back as a new file of the next build. // tree, so a parked file never comes back as a new file of the next build.
// Moved, never deleted, and never over a file already parked there. // Moved, never deleted, and never over a file already parked there.
// A new file the helper registered --intent-to-add is in the index, not in
// HEAD: it is taken out of the index (`git rm --cached`) and parked like any
// other; a file committed since the attempt (in HEAD) is never touched. HEAD
// is read once, before anything changes (`git rev-parse --verify --quiet HEAD`,
// then `git ls-tree --full-tree -r -z --name-only HEAD`; an unborn HEAD holds
// nothing). HEAD counts as unborn only when it names a branch that is stored
// nowhere (no loose ref file, no packed-refs line): a corrupt branch ref looks
// the same to `rev-parse` but exists, and is a failed HEAD read (exit 2, nothing
// unindexed or moved). `ls-tree` reads tree entries, not blobs: a HEAD path whose blob is
// missing from the object store is still in HEAD, and kept. The
// paths are read from the repository root (`git rev-parse --show-toplevel`),
// whatever --cwd inside it is given, and colour codes in the diff are ignored.
// //
// Usage: // Usage:
// node lib/util/park-attempt-files.mjs --diff <step-N-upward.diff> --step <N> --attempt <k> [--cwd <repo>] // node lib/util/park-attempt-files.mjs --diff <step-N-upward.diff> --step <N> --attempt <k> [--cwd <repo>]
// Prints {"parked": [paths], "kept": [{"path", "reason", "blocking"}], "to": dir}. // Prints {"parked": [paths], "kept": [{"path", "reason", "blocking"}], "to": dir}.
// Exit: 0 = no file the attempt made is left in the tree; 1 = one still is // Exit: 0 = no file the attempt made is left in the tree; 1 = one still is
// (a header it could not read, a move that failed, a target already taken): // (a header it could not read, a non-empty diff with no readable header, a
// the run stops; 2 = could not run (bad usage, unreadable diff, not a // move that failed, a target already taken): the run stops; 2 = could not run
// repository). Neither 1 nor 2 is ever "the tree is clean". // (bad usage, unreadable diff, not a repository, a git call that failed, the
// HEAD read that failed (a corrupt branch ref too), a path in HEAD that is missing from the index, an
// index entry that could not be removed; nothing is unindexed or moved).
// Neither 1 nor 2 is ever "the tree is clean".
import { readFileSync, mkdirSync, renameSync, lstatSync } from 'node:fs'; import { readFileSync, mkdirSync, renameSync, lstatSync } from 'node:fs';
import { join, dirname, resolve, relative, isAbsolute } from 'node:path'; import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { isMain } from './is-main.mjs'; import { isMain } from './is-main.mjs';
const ANSI_RE = /\x1B\[[0-9;]*m/g;
/** /**
* What a unified git diff creates: `paths` read from a `diff --git a/X b/X` * What a unified git diff creates: `paths` read from a `diff --git a/X b/X`
* header followed by `new file mode`, and `unread`, the raw headers of new * header followed by `new file mode`, and `unread`, the raw headers of new
* files whose header is not that form (another prefix, a quoted path). * files whose header is not that form (another prefix, a quoted path). Colour
* codes are stripped first; a non-empty diff with no `diff --git` header at
* all is one `unread` entry, so it can never read as "nothing to park".
*/ */
export function newFilesInDiff(diffText) { export function newFilesInDiff(diffText) {
const paths = []; const paths = [];
const unread = []; const unread = [];
let header = null; let header = null;
for (const line of String(diffText).split('\n')) { let sawHeader = false;
const lines = String(diffText).split(/\r?\n/).map((l) => l.replace(ANSI_RE, ''));
for (const line of lines) {
if (line.startsWith('diff --git ')) { if (line.startsWith('diff --git ')) {
sawHeader = true;
header = line; header = line;
continue; continue;
} }
@ -49,9 +71,64 @@ export function newFilesInDiff(diffText) {
header = null; header = null;
} }
} }
const first = lines.find((l) => l.trim() !== '');
if (!sawHeader && first !== undefined) unread.push(`no diff --git header in a non-empty diff (first line: ${first.slice(0, 200)})`);
return { paths, unread }; return { paths, unread };
} }
/** A git call run in `root`; anything but exit 0 throws (the CLI turns it into exit 2). */
function git(root, args, opts = {}) {
const r = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, ...opts });
if (r.error) throw new Error(`git ${args.join(' ')}: ${r.error.message}`);
if (r.status !== 0) throw new Error(`git ${args.join(' ')} exited ${r.status}: ${String(r.stderr ?? '').trim().split('\n')[0]}`);
return r.stdout;
}
/**
* Is HEAD unborn? Only when HEAD points at a branch ref R (`symbolic-ref -q
* HEAD` exit 0) and R is stored nowhere: no loose ref file
* (`rev-parse --git-path R`) and no line for R in packed-refs. A corrupt branch
* ref (garbage in the loose file, or a bad packed line) makes
* `rev-parse --verify --quiet HEAD` exit 1 with no output just like an unborn
* HEAD, and so does `show-ref --verify --quiet R` (measured on git 2.50.1), so
* the exit codes alone cannot tell them apart; where R is stored can.
*/
function headIsUnborn(root) {
const sym = spawnSync('git', ['-C', root, 'symbolic-ref', '-q', 'HEAD'], { encoding: 'utf8' });
if (sym.error || sym.status !== 0) return false;
const ref = String(sym.stdout ?? '').trim();
if (!/^refs\/heads\/./.test(ref)) return false;
// One call, one output line per --git-path, in argument order.
const [loosePath = '', packedPath = ''] = git(root, ['rev-parse', '--git-path', ref, '--git-path', 'packed-refs']).split('\n');
if (lexists(resolve(root, loosePath.trim()))) return false;
const packed = resolve(root, packedPath.trim());
if (lexists(packed)) {
const lines = readFileSync(packed, 'utf8').split('\n');
if (lines.some((l) => l.endsWith(` ${ref}`))) return false;
}
return true;
}
/**
* Every path in HEAD (root-relative), read once before anything changes. An
* unborn HEAD (`rev-parse --verify --quiet HEAD` exit 1 with no output, and
* headIsUnborn: the branch HEAD names is stored nowhere) is an empty set; a
* branch ref that exists but cannot be read, and any other failure of either
* call, throws (exit 2). `ls-tree` reads tree entries, so a path whose blob is
* missing from the object store is still "in HEAD".
*/
function headPaths(root) {
const args = ['rev-parse', '--verify', '--quiet', 'HEAD'];
const r = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
if (r.error) throw new Error(`git ${args.join(' ')}: ${r.error.message}`);
if (r.status === 1 && String(r.stdout ?? '').trim() === '') {
if (headIsUnborn(root)) return new Set();
throw new Error(`git ${args.join(' ')} exited 1, and the branch HEAD names exists: a corrupt or unreadable HEAD ref`);
}
if (r.status !== 0) throw new Error(`git ${args.join(' ')} exited ${r.status}: ${String(r.stderr ?? '').trim().split('\n')[0]}`);
return new Set(git(root, ['ls-tree', '--full-tree', '-r', '-z', '--name-only', 'HEAD']).split('\0').filter(Boolean));
}
function lexists(abs) { function lexists(abs) {
try { try {
lstatSync(abs); lstatSync(abs);
@ -66,15 +143,17 @@ function lexists(abs) {
* @returns {{ parked: string[], kept: Array<{path: string, reason: string, blocking: boolean}>, to: string }} * @returns {{ parked: string[], kept: Array<{path: string, reason: string, blocking: boolean}>, to: string }}
*/ */
export function parkAttemptFiles({ diffText, step, attempt, cwd = process.cwd() }) { export function parkAttemptFiles({ diffText, step, attempt, cwd = process.cwd() }) {
const root = resolve(cwd); const root = git(resolve(cwd), ['rev-parse', '--show-toplevel']).trim();
const gitDir = spawnSync('git', ['rev-parse', '--absolute-git-dir'], { cwd: root, encoding: 'utf8' }).stdout.trim(); const gitDir = git(root, ['rev-parse', '--absolute-git-dir']).trim();
const to = join(gitDir, 'voyage', 'parked', `step-${step}-attempt-${attempt}`); const to = join(gitDir, 'voyage', 'parked', `step-${step}-attempt-${attempt}`);
const trackedPaths = new Set( const trackedPaths = new Set(git(root, ['ls-files', '-z']).split('\0').filter(Boolean));
spawnSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8' }).stdout.split('\0').filter(Boolean), const inHead = headPaths(root);
);
const { paths, unread } = newFilesInDiff(diffText); const { paths, unread } = newFilesInDiff(diffText);
const parked = []; const parked = [];
const kept = unread.map((h) => ({ path: h, reason: 'diff header not read: move this file out by hand', blocking: true })); const kept = unread.map((h) => ({ path: h, reason: 'diff header not read: move this file out by hand', blocking: true }));
const moves = [];
const unindex = [];
const headNotIndexed = [];
for (const path of paths) { for (const path of paths) {
const abs = resolve(root, path); const abs = resolve(root, path);
const rel = relative(root, abs); const rel = relative(root, abs);
@ -86,8 +165,14 @@ export function parkAttemptFiles({ diffText, step, attempt, cwd = process.cwd()
kept.push({ path, reason: 'not on disk: nothing to park', blocking: false }); kept.push({ path, reason: 'not on disk: nothing to park', blocking: false });
continue; continue;
} }
if (trackedPaths.has(rel.split('\\').join('/'))) { const posix = rel.split('\\').join('/');
kept.push({ path, reason: 'tracked since the attempt: not the executor\'s to move', blocking: false }); if (inHead.has(posix)) {
if (trackedPaths.has(posix)) {
kept.push({ path, reason: 'tracked since the attempt: not the executor\'s to move', blocking: false });
} else {
// Committed, then taken out of the index: not the attempt's to move.
headNotIndexed.push(posix);
}
continue; continue;
} }
const target = join(to, rel); const target = join(to, rel);
@ -95,6 +180,24 @@ export function parkAttemptFiles({ diffText, step, attempt, cwd = process.cwd()
kept.push({ path, reason: `already parked at ${target}: never overwritten, move it by hand`, blocking: true }); kept.push({ path, reason: `already parked at ${target}: never overwritten, move it by hand`, blocking: true });
continue; continue;
} }
moves.push({ path, abs, rel, target });
// In the index but not in HEAD: registered --intent-to-add by the attempt.
if (trackedPaths.has(posix)) unindex.push(posix);
}
// A path in HEAD that is missing from the index: nothing is unindexed or moved.
if (headNotIndexed.length > 0) {
throw new Error(`in HEAD but missing from the index, nothing moved: ${headNotIndexed.join(', ')}`);
}
// Out of the index first, all in ONE call before anything moves: git
// refuses the whole call when one path cannot be removed, so a failure
// throws (exit 2) with every entry still in the index and nothing moved.
// Literal pathspecs, read NUL-separated from stdin: a new file named
// `tests/*.sh` never matches a committed `tests/real.sh`.
if (unindex.length > 0) {
git(root, ['--literal-pathspecs', 'rm', '--cached', '-q', '--pathspec-from-file=-', '--pathspec-file-nul'],
{ input: unindex.map((p) => `${p}\0`).join('') });
}
for (const { path, abs, rel, target } of moves) {
try { try {
mkdirSync(dirname(target), { recursive: true }); mkdirSync(dirname(target), { recursive: true });
renameSync(abs, target); renameSync(abs, target);
@ -131,7 +234,13 @@ if (isMain(import.meta.url)) {
process.stderr.write(`park-attempt-files: ${cwd} is not a git repository\n`); process.stderr.write(`park-attempt-files: ${cwd} is not a git repository\n`);
process.exit(2); process.exit(2);
} }
const result = parkAttemptFiles({ diffText, step, attempt, cwd }); let result;
try {
result = parkAttemptFiles({ diffText, step, attempt, cwd });
} catch (e) {
process.stderr.write(`park-attempt-files: could not run: ${e.message}\n`);
process.exit(2);
}
process.stdout.write(JSON.stringify(result, null, 2) + '\n'); process.stdout.write(JSON.stringify(result, null, 2) + '\n');
process.exit(result.kept.some((k) => k.blocking) ? 1 : 0); process.exit(result.kept.some((k) => k.blocking) ? 1 : 0);
} }

View file

@ -153,11 +153,25 @@ export function validateNextSessionPromptConsistency(a, b, opts = {}) {
if (isMain(import.meta.url)) { if (isMain(import.meta.url)) {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const positionals = args.filter(a => !a.startsWith('--')); // The valued flag (--state-file) consumes its value before positionals are
const wantJson = args.includes('--json'); // collected, so the state file is never counted as a third path.
const consistency = args.includes('--consistency'); const positionals = [];
const stateIdx = args.indexOf('--state-file'); let wantJson = false;
const stateFile = stateIdx >= 0 ? args[stateIdx + 1] : null; let consistency = false;
let stateFile = null;
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--state-file') {
const v = args[++i];
if (v === undefined || v.startsWith('--')) {
process.stderr.write('next-session-prompt-validator: --state-file needs a value\n');
process.exit(2);
}
stateFile = v;
} else if (a === '--json') wantJson = true;
else if (a === '--consistency') consistency = true;
else if (!a.startsWith('--')) positionals.push(a);
}
function emit(r) { function emit(r) {
if (wantJson) { if (wantJson) {
@ -194,7 +208,8 @@ if (isMain(import.meta.url)) {
{ state: stateObj }, { state: stateObj },
); );
emit({ emit({
valid: r.valid && ra.valid !== false, // Valid only when the pair is consistent AND A and B are each valid.
valid: r.valid && ra.valid !== false && rb.valid !== false,
errors: [...(ra.errors || []), ...(rb.errors || []), ...r.errors], errors: [...(ra.errors || []), ...(rb.errors || []), ...r.errors],
warnings: [...(ra.warnings || []), ...(rb.warnings || []), ...r.warnings], warnings: [...(ra.warnings || []), ...(rb.warnings || []), ...r.warnings],
}); });

View file

@ -12,7 +12,8 @@
// //
// `--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
// --commit), 1 when it does not. /trekexecute writes the line right after the // --commit), 1 when it does not, 2 when --commit is not a full 40- or 64-hex
// commit id (a short id can also be a branch name; never green). /trekexecute writes the line right after the
// step's checkpoint and checks it with --commit, so the line is bound to the // step's checkpoint and checks it with --commit, so the line is bound to the
// commit the test passed on. // commit the test passed on.
// //
@ -26,17 +27,26 @@ import { resolve } from 'node:path';
import { DEFAULT_TIMEOUT_MS, allowedCommand, runCommand, screenCommand, stoppedBy } from './criteria-runner.mjs'; import { DEFAULT_TIMEOUT_MS, allowedCommand, runCommand, screenCommand, stoppedBy } from './criteria-runner.mjs';
import { repoRoot } from './red-proof.mjs'; import { repoRoot } from './red-proof.mjs';
import { isMain } from '../util/is-main.mjs'; import { isMain } from '../util/is-main.mjs';
import { FULL_SHA_RE } from '../review/reviewed-commit.mjs';
/**
* A line names a full commit id: 40 hex (SHA-1) or 64 hex (SHA-256), never a
* short one (M11). A `commit` that is not a full id matches nothing; the CLI
* refuses it before it gets here.
*/
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 === undefined || commit === null || commit === ''
? FULL_SHA_RE.source.slice(1, -1) // the same 40-or-64 alternation, without its ^ and $
: FULL_SHA_RE.test(String(commit)) ? String(commit).toLowerCase() : '(?!)';
return new RegExp(`^DONE step ${step}: \`.+\` exit 0 at ${sha} \\d{4}-\\d{2}-\\d{2}T\\S+$`, 'm'); return new RegExp(`^DONE step ${step}: \`.+\` exit 0 at ${sha} \\d{4}-\\d{2}-\\d{2}T\\S+$`, 'm');
} }
/** /**
* With `commit` (the step's checkpoint sha, full 40 hex), the line must name * With `commit` (the step's checkpoint sha, a full 40- or 64-hex id), the line
* exactly that commit. A regex cannot tell a typed line from a written one; the * must name exactly that commit. A regex cannot tell a typed line from a
* commit binds the line to the state the test ran on (/trekexecute writes the * written one; the commit binds the line to the state the test ran on
* line right after the checkpoint, so HEAD is the step's own commit). * (/trekexecute writes the line right after the checkpoint, so HEAD is the
* step's own commit).
*/ */
export function hasDoneLine(reportPath, step, commit) { export function hasDoneLine(reportPath, step, commit) {
if (!existsSync(reportPath)) return false; if (!existsSync(reportPath)) return false;
@ -89,6 +99,11 @@ export function main(argv) {
} }
if (check) { if (check) {
if (!/^\d+$/.test(String(o.step ?? '')) || !o.report) { process.stderr.write(`done-line: --check needs --step and --report\n${USAGE}\n`); return 2; } if (!/^\d+$/.test(String(o.step ?? '')) || !o.report) { process.stderr.write(`done-line: --check needs --step and --report\n${USAGE}\n`); return 2; }
// A short id can also be a branch name (DJ3): only a full id binds the line.
if (o.commit !== undefined && !FULL_SHA_RE.test(o.commit)) {
process.stderr.write(`done-line: --commit ${o.commit} is not a full commit id (40 or 64 hex)\n${USAGE}\n`);
return 2;
}
const ok = hasDoneLine(o.report, o.step, o.commit); const ok = hasDoneLine(o.report, o.step, o.commit);
process.stdout.write(json ? `${JSON.stringify({ step: Number(o.step), done: ok })}\n` : `step ${o.step}: ${ok ? 'DONE line present' : 'no DONE line - not done'}\n`); process.stdout.write(json ? `${JSON.stringify({ step: Number(o.step), done: ok })}\n` : `step ${o.step}: ${ok ? 'DONE line present' : 'no DONE line - not done'}\n`);
return ok ? 0 : 1; return ok ? 0 : 1;

View file

@ -40,6 +40,7 @@ import { spawnSync } from 'node:child_process';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { decideVerdict } from '../lib/review/verdict-gate.mjs'; import { decideVerdict } from '../lib/review/verdict-gate.mjs';
import { reviewedSha } from '../lib/review/reviewed-commit.mjs';
import { commandEnv } from '../lib/util/command-env.mjs'; import { commandEnv } from '../lib/util/command-env.mjs';
import { DEFAULT_TIMEOUT_MS, stoppedBy } from '../lib/verification/criteria-runner.mjs'; import { DEFAULT_TIMEOUT_MS, stoppedBy } from '../lib/verification/criteria-runner.mjs';
import { isMain } from '../lib/util/is-main.mjs'; import { isMain } from '../lib/util/is-main.mjs';
@ -213,26 +214,21 @@ function checkReview(reviewPath) {
return { check: { id: 'review', status: 'pass', detail: `review verdict ${v.verdict}` }, text }; return { check: { id: 'review', status: 'pass', detail: `review verdict ${v.verdict}` }, text };
} }
/** // `reviewedSha` lives in lib/review/reviewed-commit.mjs (the verdict gate's
* The review's `scope_sha_end` as written. Read from the frontmatter text, not // --repo check reads it too; lib/ never imports from scripts/). Re-exported
* through the YAML reader, which turns an all-digit value into a number and // here under the same name for the callers of this module.
* drops a leading zero (`0123456` → 123456; release review of 6.3.0, round 3). export { reviewedSha };
*/
export function reviewedSha(reviewText) {
const fm = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(String(reviewText ?? ''));
const m = fm && /^scope_sha_end:[ \t]*(["']?)([^"'\s#]*)\1[ \t]*(?:#.*)?$/m.exec(fm[1]);
// A commit sha only: a ref name (HEAD, main, a tag) resolves to whatever it
// points at now, and coverage would pass whatever was reviewed (round 4).
return m && /^[0-9a-f]{7,40}$/i.test(m[2]) ? m[2] : '';
}
function checkCoverage(repo, reviewText) { function checkCoverage(repo, reviewText) {
if (reviewText === null) return { id: 'coverage', status: 'skip', detail: 'not checked: no readable review' }; if (reviewText === null) return { id: 'coverage', status: 'skip', detail: 'not checked: no readable review' };
const sha = reviewedSha(reviewText); const sha = reviewedSha(reviewText);
if (!sha) return { id: 'coverage', status: 'fail', detail: 'review.md has no scope_sha_end that is a commit sha (7-40 hex characters) — cannot tell what it reviewed' }; // A full id only (M11): a short id, or a 64-hex name in a SHA-1 repository,
// can be a branch name that resolves to wherever the branch points now.
if (!sha) return { id: 'coverage', status: 'fail', detail: 'review.md has no scope_sha_end that is a commit sha (a full 40- or 64-hex commit id) — cannot tell what it reviewed' };
const resolved = git(repo, ['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]); const resolved = git(repo, ['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]);
if (resolved.status !== 0) return { id: 'coverage', status: 'fail', detail: `the reviewed commit ${sha} is not in the history of HEAD (unknown commit)` }; if (resolved.status !== 0) return { id: 'coverage', status: 'fail', detail: `the reviewed commit ${sha} is not in the history of HEAD (unknown commit)` };
const full = resolved.stdout.trim(); const full = resolved.stdout.trim();
if (full !== sha.toLowerCase()) return { id: 'coverage', status: 'fail', detail: `review.md's scope_sha_end ${sha} names a ref, not a commit (a full 40- or 64-hex commit id) — cannot tell what it reviewed` };
const anc = git(repo, ['merge-base', '--is-ancestor', full, 'HEAD']); const anc = git(repo, ['merge-base', '--is-ancestor', full, 'HEAD']);
if (anc.status !== 0) return { id: 'coverage', status: 'fail', detail: `the reviewed commit ${full.slice(0, 12)} is not in the history of HEAD` }; if (anc.status !== 0) return { id: 'coverage', status: 'fail', detail: `the reviewed commit ${full.slice(0, 12)} is not in the history of HEAD` };

View file

@ -54,8 +54,23 @@ const RULE_KEYS = Object.keys(RULE_TIERS);
const sevRank = (s) => (SEVERITIES.includes(s) ? SEVERITIES.indexOf(s) : SEVERITIES.length); const sevRank = (s) => (SEVERITIES.includes(s) ? SEVERITIES.indexOf(s) : SEVERITIES.length);
// The catalogue tier of a rule_key, or null when the key is missing or unknown. // The catalogue tier of a rule_key, or null when the key is missing or unknown.
const tierOf = (key) => (typeof key === 'string' && Object.prototype.hasOwnProperty.call(RULE_TIERS, key) ? RULE_TIERS[key] : null); const tierOf = (key) => (typeof key === 'string' && Object.prototype.hasOwnProperty.call(RULE_TIERS, key) ? RULE_TIERS[key] : null);
// The rank of a finding's catalogue tier, else of its own severity.
const tierRank = (f) => sevRank(tierOf(f.rule_key) ?? f.severity);
// The (file,line,rule_key) triplet a finding is deduped and matched on. // The (file,line,rule_key) triplet a finding is deduped and matched on.
const tripletOf = (f) => `${f.file}\u0000${f.line}\u0000${f.rule_key}`; const tripletOf = (f) => `${f.file}\u0000${f.line}\u0000${f.rule_key}`;
// A finding's title as the grouping compares it: trimmed, lower case.
const normTitle = (f) => String(f.title).trim().toLowerCase();
// The row a finding is matched to after the coordinator: its triplet plus its
// `normTitle`. An untitled finding gets a key no titled row has.
const rowOf = (f) => `${tripletOf(f)}\u0000${f.title === undefined ? '\u0001' : normTitle(f)}`;
// The coordinator trims an over-long MINOR/SUGGESTION title as
// lib/review/coordinator-contract.mjs judgeFilter does; its marker and limit
// are inlined (scripts cannot import) and pinned equal by the workflow test.
const TRIM_MARKER = ' […trimmed]';
const JUDGE_TITLE_MAX = 100;
// A title as that trim writes it: over JUDGE_TITLE_MAX characters, cut to
// leave room for the marker, then the marker; any other title unchanged.
const trimmedTitle = (t) => (t.length > JUDGE_TITLE_MAX ? t.slice(0, JUDGE_TITLE_MAX - TRIM_MARKER.length) + TRIM_MARKER : t);
// Findings schema — mirrors lib/review/findings-schema.mjs (NW1). The enum on // Findings schema — mirrors lib/review/findings-schema.mjs (NW1). The enum on
// rule_key enforces the catalogue at the StructuredOutput layer (stronger than // rule_key enforces the catalogue at the StructuredOutput layer (stronger than
@ -122,41 +137,82 @@ const VERDICT_SCHEMA = {
// order is coordinator-contract Pass 1's (review bc7f4e23): the catalogue tier // order is coordinator-contract Pass 1's (review bc7f4e23): the catalogue tier
// first (for a catalogue key it always ties inside one triplet, since the // first (for a catalogue key it always ties inside one triplet, since the
// rule_key is shared), then a reviewer finding (no `engine`) over an engine // rule_key is shared), then a reviewer finding (no `engine`) over an engine
// finding (decision 13, operator 12.10), then the higher asserted severity; // finding (decision 13, operator 12.10), then the higher asserted severity,
// the workflow has no conformance rule, so otherwise the first-seen one stays. // then the conformance reviewer (a source whose name holds "conformance", as
// the contract's isConformance reads the reviewer name), else the first in the
// group's order stays (the order the two passes below place the members in).
// The survivor carries the highest severity any finding in its group asserted, // The survivor carries the highest severity any finding in its group asserted,
// so an engine's mark never lowers what a reviewer agent asserted. // so an engine's mark never lowers what a reviewer agent asserted.
// As in coordinator-contract Pass 1, one source never duplicates itself: its // As in coordinator-contract Pass 1, one source never duplicates itself: its
// second finding on a triplet under another title opens a group of its own // second finding on a triplet under another title opens a group of its own
// (voyage-lekerepo trial 5, finding 7). `sourceOf` names the reviewer or engine. // (voyage-lekerepo trial 5, finding 7). `sourceOf` names the reviewer or engine.
const sameTitle = (a, b) => a.title === undefined || b.title === undefined || const sameTitle = (a, b) => a.title === undefined || b.title === undefined || normTitle(a) === normTitle(b);
String(a.title).trim().toLowerCase() === String(b.title).trim().toLowerCase(); // As there too, a triplet's findings are grouped in two passes, so the order
// they arrive in never changes which row a finding joins: (1) every titled
// finding joins the class of its normalized title, over all of the triplet's
// findings; (2) the classes, in alphabetical order of the normalized title,
// each join the first earlier group whose every member fits every finding of
// the class (another source, or the same title), else they form a group of
// their own; untitled findings come last, in canonical order (source, detail,
// the whole finding), each joining the first group it fits. Members keep the
// order the passes place them in (the classes alphabetically, each class's
// findings in canonical order, the untitled findings last), so a full tie on
// the survivor never follows arrival order either; a triplet's groups keep the
// order of their earliest input index. With `perSource: false` everything on a
// triplet is one group, as before. A `rows` Map, when given, is filled with
// each row written -> the findings of its group (the put-back reads it).
const sourceOf = new Map(); const sourceOf = new Map();
function dedupByTriplet(findings, { perSource = true } = {}) { const isConformance = (f) => String(sourceOf.get(f) ?? '').toLowerCase().includes('conformance');
const cmpStr = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
function groupTriplet(entries) {
const srcOf = (f) => sourceOf.get(f) ?? null;
const fits = (m, f) => srcOf(m) !== srcOf(f) || sameTitle(m, f);
const canon = (x, y) => cmpStr(String(srcOf(x.f) ?? ''), String(srcOf(y.f) ?? ''))
|| cmpStr(String(x.f.detail ?? ''), String(y.f.detail ?? ''))
|| cmpStr(JSON.stringify(x.f), JSON.stringify(y.f));
const classes = new Map();
const untitled = [];
for (const e of entries) {
if (e.f.title === undefined) {
untitled.push(e);
continue;
}
const t = normTitle(e.f);
if (!classes.has(t)) classes.set(t, []);
classes.get(t).push(e);
}
const groups = [];
const place = (members) => {
let g = groups.find((grp) => grp.every((m) => members.every((e) => fits(m.f, e.f))));
if (!g) {
g = [];
groups.push(g);
}
g.push(...members);
};
for (const t of [...classes.keys()].sort(cmpStr)) place(classes.get(t).sort(canon));
for (const e of untitled.sort(canon)) place([e]);
return groups
.map((g) => ({ first: Math.min(...g.map((e) => e.i)), members: g.map((e) => e.f) }))
.sort((a, b) => a.first - b.first)
.map((g) => g.members);
}
function dedupByTriplet(findings, { perSource = true, rows = null } = {}) {
const byTriplet = new Map(); const byTriplet = new Map();
for (const f of findings) { findings.forEach((f, i) => {
const key = tripletOf(f); const key = tripletOf(f);
if (!byTriplet.has(key)) byTriplet.set(key, []); if (!byTriplet.has(key)) byTriplet.set(key, []);
const subs = byTriplet.get(key); byTriplet.get(key).push({ f, i });
const src = sourceOf.get(f) ?? null; });
const fits = (g) => g.every((m) => (sourceOf.get(m) ?? null) !== src || sameTitle(m, f)); const groups = [...byTriplet.values()].flatMap((entries) => (perSource
let sub = !perSource ? subs[0] ? groupTriplet(entries)
: subs.find((g) => fits(g) && g.some((m) => m.title !== undefined && f.title !== undefined && sameTitle(m, f))) : [entries.map((e) => e.f)]));
?? subs.find(fits);
if (!sub) {
sub = [];
subs.push(sub);
}
sub.push(f);
}
const groups = [...byTriplet.values()].flat();
const tier = (f) => sevRank(tierOf(f.rule_key) ?? f.severity);
const out = []; const out = [];
for (const group of groups) { for (const group of groups) {
let survivor = group[0]; let survivor = group[0];
for (const f of group.slice(1)) { for (const f of group.slice(1)) {
const tf = tier(f); const tf = tierRank(f);
const ts = tier(survivor); const ts = tierRank(survivor);
if (tf !== ts) { if (tf !== ts) {
if (tf < ts) survivor = f; if (tf < ts) survivor = f;
continue; continue;
@ -167,13 +223,21 @@ function dedupByTriplet(findings, { perSource = true } = {}) {
if (!ef) survivor = f; if (!ef) survivor = f;
continue; continue;
} }
if (sevRank(f.severity) < sevRank(survivor.severity)) survivor = f; const rf = sevRank(f.severity);
const rs = sevRank(survivor.severity);
if (rf !== rs) {
if (rf < rs) survivor = f;
continue;
}
if (isConformance(f) && !isConformance(survivor)) survivor = f;
} }
let severity = survivor.severity; let severity = survivor.severity;
for (const f of group) { for (const f of group) {
if (sevRank(f.severity) < sevRank(severity)) severity = f.severity; if (sevRank(f.severity) < sevRank(severity)) severity = f.severity;
} }
out.push(severity === survivor.severity ? survivor : { ...survivor, severity }); const row = severity === survivor.severity ? survivor : { ...survivor, severity };
if (rows) rows.set(row, group);
out.push(row);
} }
return out; return out;
} }
@ -187,7 +251,7 @@ function reviewerPrompt(kind, a) {
'Only emit findings whose rule_key is in the catalogue. Cite file:line from the diff.', 'Only emit findings whose rule_key is in the catalogue. Cite file:line from the diff.',
'', '',
'=== TRIAGE MAP (respect skip decisions) ===', '=== TRIAGE MAP (respect skip decisions) ===',
a.triage || '(no triage map supplied — treat all changed files as summary-only)', a.triage || '(no triage map supplied — deep-review every changed file except docs/**, root Markdown other than CLAUDE.md and AGENTS.md, and tests/fixtures/**)',
].join('\n'); ].join('\n');
} }
@ -198,7 +262,7 @@ function coordinatorPrompt(merged, a) {
'(file,line,rule_key) in JS. Do NOT re-dedup. Apply ONLY bounded operations:', '(file,line,rule_key) in JS. Do NOT re-dedup. Apply ONLY bounded operations:',
'HubSpot Judge filters (accuracy / actionability; succinctness NEVER removes a', 'HubSpot Judge filters (accuracy / actionability; succinctness NEVER removes a',
'finding — trim only MINOR/SUGGESTION text, keep BLOCKER/MAJOR whole), Cloudflare', 'finding — trim only MINOR/SUGGESTION text, keep BLOCKER/MAJOR whole), Cloudflare',
'reasonableness (drop speculative or catalogue-violating findings), then compute', 'reasonableness (drop a finding with no file:line citation or whose file is in neither the tree nor the diff; keep, never drop, a finding whose rule_key is not in the catalogue or whose file cannot be checked), then compute',
'the verdict (BLOCK if any BLOCKER; WARN if any MAJOR; else ALLOW).', 'the verdict (BLOCK if any BLOCKER; WARN if any MAJOR; else ALLOW).',
'Return {verdict, findings} via StructuredOutput. Synthesis across files is forbidden.', 'Return {verdict, findings} via StructuredOutput. Synthesis across files is forbidden.',
`The brief (the contract) is at: ${a.briefPath} — Read it if needed for context.`, `The brief (the contract) is at: ${a.briefPath} — Read it if needed for context.`,
@ -275,7 +339,8 @@ const allFindings = [
...enginePayloads.flatMap((p) => (Array.isArray(p?.findings) ? p.findings : []) ...enginePayloads.flatMap((p) => (Array.isArray(p?.findings) ? p.findings : [])
.map((f) => tagged(withEngine(f, engineOf(p?.reviewer)), `engine:${p?.reviewer}`))), .map((f) => tagged(withEngine(f, engineOf(p?.reviewer)), `engine:${p?.reviewer}`))),
]; ];
const merged = dedupByTriplet(allFindings); const rowMembers = new Map();
const merged = dedupByTriplet(allFindings, { rows: rowMembers });
log(`Arm B: ${reviewerResults.length} reviewer(s); ${allFindings.length} raw → ${merged.length} after triplet-dedup`); log(`Arm B: ${reviewerResults.length} reviewer(s); ${allFindings.length} raw → ${merged.length} after triplet-dedup`);
phase('Coordinate'); phase('Coordinate');
@ -297,28 +362,51 @@ if (coordinatorMissing) missingReviewers += 1;
// coordinator returned, so an LLM-written ALLOW over a BLOCKER cannot pass. // coordinator returned, so an LLM-written ALLOW over a BLOCKER cannot pass.
const returned = coordinatorMissing ? merged : coord.findings; const returned = coordinatorMissing ? merged : coord.findings;
// The floor reads two inputs, never the labels the coordinator wrote: // The floor reads two inputs, never the labels the coordinator wrote:
// - `merged` (the coordinator's input, same file/line/rule_key) for each // - `merged` (the coordinator's input, one finding per row) for each returned
// returned finding's counterpart: (a) an engine counterpart raises it to the // finding's counterpart, its own row: the row with its `rowOf` key (triplet
// plus normalized title); with no title match, the only row on its triplet,
// else the strictest row there (highest tier, then highest severity; the
// first in `merged` on a tie), never a lower one. `engine` is taken from
// that row only. (a) an engine counterpart raises it to the
// engine's severity, unless the one legal lowering; (b) a reviewer // engine's severity, unless the one legal lowering; (b) a reviewer
// counterpart, labelled or not, raises it to the lower of the counterpart's // counterpart, labelled or not, raises it to the lower of the counterpart's
// asserted severity and its catalogue tier (the coordinator may reset a // asserted severity and its catalogue tier (the coordinator may reset a
// finding to its tier, never below; for an unknown key the bound is the // finding to its tier, never below; for an unknown key the bound is the
// counterpart's severity), and a labelled finding is raised as before. // counterpart's severity), and a labelled finding is raised as before.
// - `allFindings` (before dedup, so an engine finding a reviewer finding // - the rows' members (before dedup, so an engine finding a reviewer finding
// shadowed in its triplet counts too) for the put-back: (c) every engine // shadowed in its row counts too) for the put-back: (c) each engine finding
// finding with no returned finding at its exact triplet is appended at its // in a row whose `rowOf` key no returned finding carries (neither as it
// own severity, one copy per triplet (the higher severity, else the first // stands nor with the title trimmed as the coordinator trims it) is
// seen) (operator decision 23, review ab1b5d9f). So the coordinator's drop, // put back at its own severity, one copy per row (the higher severity, else
// move or relabel of an engine finding no longer lowers the verdict. // the first seen) (operator decision 23, review ab1b5d9f). So the coordinator's drop,
// move, rename or relabel of an engine finding no longer lowers the verdict
// (a renamed row is put back beside the renamed finding: a duplicate, the
// stricter side).
// The raises and put-backs are written into `returned`, the marks kept, so the // The raises and put-backs are written into `returned`, the marks kept, so the
// rendered review.md carries them. // rendered review.md carries them.
// A coordinator that failed (no verdict and findings) is not trusted with a // A coordinator that failed (no verdict and findings) is not trusted with a
// partial list: `returned` is then every merged finding, each first raised to // partial list: `returned` is then every merged finding, each first raised to
// its catalogue tier when the key is known (never lowered), as the default // its catalogue tier when the key is known (never lowered), as the default
// path's Pass 3 would reset it; that raise stands after `raiseTo` is declared. // path's Pass 3 would reset it; that raise stands after `raiseTo` is declared.
const counterparts = new Map(merged const rowsOn = (map, key, m) => {
.filter((m) => m && typeof m === 'object') if (!map.has(key)) map.set(key, []);
.map((m) => [tripletOf(m), m])); map.get(key).push(m);
};
const counterparts = new Map();
const rowsOnTriplet = new Map();
for (const m of merged) {
if (!m || typeof m !== 'object') continue;
rowsOn(counterparts, rowOf(m), m);
rowsOn(rowsOnTriplet, tripletOf(m), m);
}
const strictest = (rows) => rows.reduce((best, m) => (tierRank(m) < tierRank(best) ||
(tierRank(m) === tierRank(best) && sevRank(m.severity) < sevRank(best.severity)) ? m : best));
const counterpartOf = (f) => {
const own = counterparts.get(rowOf(f));
if (own) return strictest(own);
const onTriplet = rowsOnTriplet.get(tripletOf(f));
return onTriplet ? strictest(onTriplet) : null;
};
const present = (f, field) => f[field] !== undefined && f[field] !== null; const present = (f, field) => f[field] !== undefined && f[field] !== null;
const raiseTo = (f, sev) => { if (sevRank(sev) < sevRank(f.severity)) f.severity = sev; }; const raiseTo = (f, sev) => { if (sevRank(sev) < sevRank(f.severity)) f.severity = sev; };
const lowerOf = (s1, s2) => (sevRank(s1) >= sevRank(s2) ? s1 : s2); const lowerOf = (s1, s2) => (sevRank(s1) >= sevRank(s2) ? s1 : s2);
@ -331,7 +419,7 @@ if (coordinatorMissing) {
} }
for (const f of returned) { for (const f of returned) {
if (!f || typeof f !== 'object') continue; if (!f || typeof f !== 'object') continue;
const c = counterparts.get(tripletOf(f)); const c = counterpartOf(f);
const labelled = present(f, 'engine') || present(f, 'contradicts_brief') || present(f, 'brief_lowered_from'); const labelled = present(f, 'engine') || present(f, 'contradicts_brief') || present(f, 'brief_lowered_from');
if (c && c.engine) { if (c && c.engine) {
// (a) an engine counterpart: raised to its severity, unless the one legal lowering. // (a) an engine counterpart: raised to its severity, unless the one legal lowering.
@ -355,11 +443,18 @@ for (const f of returned) {
} }
} }
// (c) the put-back (decision 23). // (c) the put-back (decision 23).
const returnedTriplets = new Set(returned.filter((f) => f && typeof f === 'object').map(tripletOf)); const returnedRows = new Set(returned.filter((f) => f && typeof f === 'object').map(rowOf));
// Engine findings only, so dedupByTriplet keeps the higher severity, else the // A row is returned when a returned finding carries its key, or the key of its
// first seen: one copy per triplet (decision 23), so not split per source here. // title as the coordinator trims it, so a trimmed title puts back no copy.
const putBack = dedupByTriplet(allFindings.filter((f) => const rowReturned = (m) => returnedRows.has(rowOf(m)) ||
f && typeof f === 'object' && f.engine && !returnedTriplets.has(tripletOf(f))), { perSource: false }); (typeof m.title === 'string' && returnedRows.has(rowOf({ ...m, title: trimmedTitle(m.title) })));
// Each row not returned: its engine findings only, so dedupByTriplet keeps the
// higher severity, else the first seen: one copy per row, so not split per
// source here.
const putBack = merged
.filter((m) => m && typeof m === 'object' && !rowReturned(m))
.flatMap((m) => dedupByTriplet(rowMembers.get(m).filter((f) =>
f && typeof f === 'object' && f.engine), { perSource: false }));
for (const f of putBack) { for (const f of putBack) {
const { brief_lowered_from: _lowered, ...copy } = f; const { brief_lowered_from: _lowered, ...copy } = f;
returned.push(copy); returned.push(copy);

View file

@ -0,0 +1,98 @@
---
name: annotating
description: "The operator's own annotation loop for a Voyage brief, plan or review (a research document too, when asked): build the page with scripts/annotate.mjs so the person writes every note, take the pasted Copy Prompt back, and revise note by note. Fix and Change edit only the text the note points at; a Question is answered and changes nothing unless the person says so; nothing the notes did not ask for is edited. A note on ## Intent or ## Goal of an approved brief makes the approval stale, and a changed plan needs a new approval, said before the edit. The page is built again on the revised file. Use when someone wants to comment on, annotate or mark up a brief, plan, review or research document in the browser, or pastes back the notes from that page (Fix, Change, Question)."
---
# Annotating
## Overview
The annotation page lets the person read a Voyage artifact in the browser, click a line and write
their own note, then copy every note back as one prompt. The loop is theirs: Claude builds the page,
and later does what the notes say, no more. Two things go wrong without this skill. A Question gets
answered and then written into the text as if it were a change. And the revision spreads: the same
"fix" applied to parts no note named, a section rewritten to match, a new criterion added. Every such
edit is a decision the person did not make, and on an approved brief or plan it also throws away an
approval.
## When to use
- A `/trek*` command has just written a brief, plan or review and builds the page.
- Someone says "let me comment on the plan", "I want to mark up the brief", "annotate this".
- Someone asks to annotate a research document under `research/`. `/trekresearch` does not build
the page itself; build it on request.
- The person pastes a prompt that starts "Please revise the voyage artifact at" and lists notes as
`[Fix]`, `[Change]` or `[Question]`.
- Not for: someone who asks for Claude's own review of the artifact. That is a review
(`voyage:reviewing-adversarially`, `/trekreview`), not annotation.
## Process
1. **Build the page.** Run the script on the file and keep its stdout, the absolute path of the
page:
```bash
ANNOT_HTML=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/annotate.mjs "<path to the .md>" 2>&1)
```
Print `file://<path>` and the line `open file://<path>`. If the script exits non-zero, say so in
one line and go on: the page is a convenience, never a gate.
2. **The person writes the notes. Claude writes none.** Next to the link, say only how the page works:
click a line (or select a phrase first), pick Fix, Change or Question, write the note, and press
"Copy Prompt" when done; the notes stay in the browser if the tab is closed. No list of "points I
would look at", no summary of weaknesses, no suggested notes. Those would steer the notes the
person writes. If they want Claude's view as well, they ask for a review.
3. **Read the pasted notes.** Each note has a number, a type, the section, the quoted text it is
anchored to, and the comment. Work from the quote and the comment, one note at a time.
4. **Before any edit, check what the edit costs.**
- A brief with `intent_approved_hash` in its frontmatter, and a Fix or Change in `## Intent` or
`## Goal`: say in one line, before the edit, that the edit makes the approval stale, that
`/trekplan` halts until the new text is approved, and how to approve it
(`/trekbrief --approve <project dir>`). Then edit.
- A plan the person approved: any edit gives it a new sha256, the approval log no longer covers it,
and `/trekexecute` refuses it until the plan is approved again. Say that before the edit.
- Never stamp, log or commit an approval in this loop. Only the person's explicit answer approves.
5. **Fix and Change: edit the text the note points at, and only that.** The quote marks the place.
If the comment cannot be done without touching other text (a summary that now disagrees, the same
gap in another step, a new criterion the change seems to need), do not edit that text. List it in
the reply as a question with a recommended answer. A validator error that was already there before
the notes is reported, not fixed in passing.
6. **Question: answer it in the reply. The text stays as it was.** No new row under alternatives, no
new rule, no rewording to "make the answer visible". End the answer with one line offering to write
it in if the person wants that. Only their yes turns a Question into a change.
7. **Build the page again** on the revised file, and print the new link, so the person reads the
revised text where they wrote the notes. The page keeps notes per file path, so last round's notes
still show in its sidebar: tell the person that "Clear all" empties it before the next round.
8. **Reply note by note:** for each number, "edited" (with the new wording) or "answered" (with the
answer); then the approval consequence from step 4, the open questions from step 5, and the link.
## Red flags
| Thought | What it means |
|---|---|
| "I'll add a few points of my own next to the link, to help" | The notes are the person's. Your points steer theirs. Offer a review instead. |
| "The Question shows a gap, so I'll fix the gap too" | Answer it. The gap is theirs to turn into a Change. |
| "I'll record the answer under alternatives, so it isn't lost" | That is an edit nobody asked for, and on an approved plan it costs the approval. |
| "The same fix applies to steps 2 to 4 as well" | Then ask. The note named one step. |
| "The summary no longer matches the new intent, I'll update it" | Ask, with a recommended answer. The person approves the words. |
| "The brief fails the validator anyway, I'll fix the frontmatter while I'm here" | Report it. A fix in passing is still a change the person did not ask for. |
| "I'll mention the approval is stale at the end" | Say it before the edit. It is the price of the edit. |
| "I'll re-stamp it so planning can go on" | Only the person's explicit approval stamps. |
## Enforced by
- `tests/lib/doc-consistency.test.mjs` and `tests/lib/skills-annotating-prose-pins.test.mjs` (in
`npm test`): the script `scripts/annotate.mjs` exists, the three producing commands call it, print
the `file://` link and the `open` line, and name this skill. Deterministic, but only at test time.
- `lib/validators/intent-approval.mjs` (inside `/trekplan`): an edit to `## Intent` or `## Goal`
after approval makes the approval stale, and planning halts until the new text is approved.
- `lib/util/approval-log.mjs` (inside `/trekexecute`, K2): a plan whose current sha256 the approval
log does not cover is refused. Both checks hold whether or not the edit's cost was said.
- prose only: that the person writes every note and Claude none, that a Question changes nothing,
that only what the notes ask for is edited, that the cost of the edit is said before it, and that
the page is built again.
## Credits
Voyage's own content: the page (`scripts/annotate.mjs`), the loop and its rules come from Voyage's
pipeline and from the red baseline run for this skill. Nothing here is borrowed.

View file

@ -93,7 +93,8 @@ code alone.
lead or a deadline calling something "a nit" or "cosmetic" is not a reason. lead or a deadline calling something "a nit" or "cosmetic" is not a reason.
- When a rule lets you lower a finding, name the rule and what catches the - When a rule lets you lower a finding, name the rule and what catches the
defect instead (`lowered_from`, `caught_by`). With no named rule, keep the defect instead (`lowered_from`, `caught_by`). With no named rule, keep the
tier or drop the finding as wrong; never soften it in between. tier, or drop the finding as wrong with its reason on a Declined to Judge
line; never soften it in between.
### 6. Declined to Judge: nothing disappears silently ### 6. Declined to Judge: nothing disappears silently

View file

@ -145,7 +145,7 @@ Several test files share one `File:` line the same way:
- File: `path/to/test.ts` *(existing | new)* - File: `path/to/test.ts` *(existing | new)*
- Verifies: {what the test checks} - Verifies: {what the test checks}
- Pattern: `path/to/existing-test.ts` - Pattern: `path/to/existing-test.ts`
- **End-to-end:** `{a script that runs the product the way a user does — the app, the CLI, the simulator; usually bash tests/e2e.sh}` → expected: {what the user sees} *(the same command also stands under ## Verification)* - **End-to-end:** `{a script that runs the product the way a user does — the app, the CLI, the simulator; usually bash tests/e2e.sh}` → expected: {what the user sees} *(the same command also stands under ## Verification; to see and drive the app, use Claude Code's built-in "run" skill — the verdict is still the End-to-end: command, because "run" gives no exit code)*
- **Verify:** `{exact command}` → expected: `{output}` - **Verify:** `{exact command}` → expected: `{output}`
- **On failure:** {revert | retry | skip | escalate} — {specific instructions} - **On failure:** {revert | retry | skip | escalate} — {specific instructions}
- **Checkpoint:** `git add {the step's Files and its test files} && git commit -m "{conventional commit message}"` - **Checkpoint:** `git add {the step's Files and its test files} && git commit -m "{conventional commit message}"`

View file

@ -27,8 +27,8 @@ should look at first.
| File | Treatment | Reason | | File | Treatment | Reason |
|------|-----------|--------| |------|-----------|--------|
| lib/foo.mjs | deep-review | matched deep-review pattern | | lib/foo.mjs | deep-review | default for changed code |
| lib/bar.mjs | summary-only | low-risk, no test patterns matched | | docs/guide.md | summary-only | docs/** is summary-only |
| dist/bundle.js | skip | matches generated-file pattern | | dist/bundle.js | skip | matches generated-file pattern |
| commands/baz.md `[uncommitted]` | deep-review | working-tree change since session_start_sha | | commands/baz.md `[uncommitted]` | deep-review | working-tree change since session_start_sha |

View file

@ -11,9 +11,12 @@
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 { readFileSync, readdirSync } from 'node:fs'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { childEnv } from '../helpers/child-env.mjs';
const HERE = dirname(fileURLToPath(import.meta.url)); const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..'); const ROOT = join(HERE, '..', '..');
@ -125,3 +128,108 @@ test('trekendsession Phase 4 — validator call is runtime Bash (no eager prefix
'Phase 4 must not use <angle> placeholders in commands — zsh parses <x> as input redirection', 'Phase 4 must not use <angle> placeholders in commands — zsh parses <x> as input redirection',
); );
}); });
// ---------------------------------------------------------------
// DJ5 (fix round 4.5b): Phase 3 splices no label into its shell line.
// The block used to end in '{project_dir}' '{next_brief_path}'
// '{next_label}', filled in by the model: a label holding a ' closed the
// quote and the rest ran as shell. Phase 3 now re-reads $ARGUMENTS through
// the quoted heredoc on stdin, parses it with parseArgs and re-derives the
// project directory itself. The runtime tests run the block as Claude Code
// would after it substitutes ${CLAUDE_PLUGIN_ROOT} and $ARGUMENTS.
// ---------------------------------------------------------------
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
const PROJECT_REL = join('.claude', 'projects', '2026-01-01-demo');
const NEXT_BRIEF = '.claude/projects/2026-01-01-demo/next-brief.md';
/** The one runtime ```bash block of Phase 3 (the atomic-write block). */
function phase3Block() {
const phase3 = extractPhase(readCommand(), '## Phase 3 ');
const blocks = phase3.split('\n```bash\n').slice(1).map((b) => b.split('\n```')[0])
.filter((b) => b.includes('atomic-write.mjs'));
assert.equal(blocks.length, 1, `Phase 3 must hold one \`\`\`bash block that runs atomic-write.mjs; found ${blocks.length}`);
return blocks[0];
}
/** True when `bin` can be started from PATH. */
function onPath(bin) {
const r = spawnSync(bin, ['-c', 'exit 0'], { env: childEnv(), encoding: 'utf8' });
return r.error === undefined && r.status === 0;
}
/**
* Run the Phase 3 block with `shell` in a fresh fixture project, with
* `argText` standing where Claude Code puts $ARGUMENTS. Returns the result,
* what the run wrote, and whether a PWNED file appeared; the fixture is gone
* on return.
*/
function runPhase3(shell, argText) {
const block = phase3Block();
assert.ok(block.includes('$ARGUMENTS'),
'Phase 3 does not re-read $ARGUMENTS: its values still come from text the model splices into the shell line');
const script = block.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT).split('$ARGUMENTS').join(argText);
const fixture = mkdtempSync(join(tmpdir(), 'trekendsession-dj5-'));
try {
mkdirSync(join(fixture, PROJECT_REL), { recursive: true });
writeFileSync(join(fixture, PROJECT_REL, 'brief.md'), '# demo brief\n');
const r = spawnSync(shell, ['-c', script], { cwd: fixture, env: childEnv(), encoding: 'utf8' });
const stateFile = join(fixture, PROJECT_REL, '.session-state.local.json');
const promptFile = join(fixture, PROJECT_REL, 'NEXT-SESSION-PROMPT.local.md');
return {
r,
pwned: ['PWNED', 'PWNED2'].filter((f) => existsSync(join(fixture, f))),
state: existsSync(stateFile) ? JSON.parse(readFileSync(stateFile, 'utf8')) : null,
prompt: existsSync(promptFile) ? readFileSync(promptFile, 'utf8') : null,
};
} finally {
rmSync(fixture, { recursive: true, force: true });
}
}
/** Assert one run wrote `label` whole and ran nothing from it. */
function assertLabelKept(shell, label) {
const { r, pwned, state, prompt } = runPhase3(shell, `${NEXT_BRIEF} ${label}`);
const how = `${shell}, label ${JSON.stringify(label)}: exit ${r.status}\n${r.stdout}${r.stderr}`;
assert.deepEqual(pwned, [], `the label ran as shell under ${how}`);
assert.equal(r.status, 0, `Phase 3 did not run cleanly under ${how}`);
assert.ok(state, `no .session-state.local.json was written under ${how}`);
assert.equal(state.next_session_label, label, `the written label is not the typed one under ${how}`);
assert.equal(state.next_session_brief_path, NEXT_BRIEF, `the written brief path is not the typed one under ${how}`);
assert.equal(state.project, PROJECT_REL, `the project directory was not re-derived under ${how}`);
assert.ok(prompt && prompt.includes(`\n# ${label}\n`), `NEXT-SESSION-PROMPT.local.md does not carry the label under ${how}`);
}
test('[DJ5] Phase 3 has no quoted placeholder for the label, brief or project', () => {
const block = phase3Block();
const placeholders = block.match(/\{[a-z][a-z0-9]*(?:[_ -][a-z0-9]+)*\}/g) || [];
assert.deepEqual(placeholders, [], 'Phase 3 still has the model fill {placeholders} into its shell line');
assert.doesNotMatch(block, /'\{/, 'Phase 3 still quotes a placeholder as a shell word');
const lines = block.split('\n');
const open = lines.findIndex((l) => l.trimEnd().endsWith(`<<'${ARGS_DELIM}'`));
assert.ok(open !== -1, `Phase 3 does not re-read the arguments through the quoted heredoc <<'${ARGS_DELIM}'`);
assert.equal(lines[open + 1], '$ARGUMENTS', 'the line inside the Phase 3 heredoc is not $ARGUMENTS alone');
const filled = lines.filter((l) => l.trim() !== '');
assert.ok(lines[open + 2] === ARGS_DELIM && filled[filled.length - 1] === ARGS_DELIM,
`the closing line ${ARGS_DELIM} does not follow $ARGUMENTS as the block's last non-empty line`);
assert.equal(lines.filter((l, i) => i !== open + 1 && l.includes('$ARGUMENTS')).length, 0,
'a line outside the Phase 3 heredoc carries $ARGUMENTS');
assert.match(block, /import \{ parseArgs \} from '\$\{CLAUDE_PLUGIN_ROOT\}\/lib\/parsers\/arg-parser\.mjs'/,
'Phase 3 does not import parseArgs at runtime from the plugin root');
});
test('[DJ5] Phase 3 run with an injection label creates no file and keeps the label whole', (t) => {
// The step's label, and the same with a command substitution: under bash,
// and under zsh (the shell the Bash tool runs) when zsh is on PATH.
const labels = ["a'; touch PWNED; echo 'b", "a'; touch PWNED; echo 'b $(touch PWNED2)"];
for (const label of labels) assertLabelKept('bash', label);
if (onPath('zsh')) {
for (const label of labels) assertLabelKept('zsh', label);
} else {
t.diagnostic('zsh is not on PATH: the zsh half of this test was not run');
}
});
test("[DJ5] a label with an apostrophe is kept", () => {
assertLabelKept('bash', "Fix the user's login");
});

View file

@ -1,6 +1,7 @@
// tests/commands/trekexecute.test.mjs // tests/commands/trekexecute.test.mjs
// v5.1 prose-pin tests + v5.1.1 runtime SC4 + SC7 tests for /trekexecute. // v5.1 prose-pin tests + v5.1.1 runtime SC4 + SC7 tests for /trekexecute.
// Plan Assumption 2 locks low-effort to --gates open + sequential-only. // Low effort runs the plan as one session, step after step; it sets no --gates
// (fix round 4-5a, #33).
import { test } from 'node:test'; import { test } from 'node:test';
import { strict as assert } from 'node:assert'; import { strict as assert } from 'node:assert';
@ -33,13 +34,14 @@ test('trekexecute — sequencing-gate surface mentions BRIEF_V51_MISSING_SIGNALS
'/trekexecute must reference phase_signals (v5.1 composition rule)'); '/trekexecute must reference phase_signals (v5.1 composition rule)');
}); });
test('trekexecute — low-effort path references --gates open + sequential', () => { test('trekexecute — low-effort path runs one session, step after step, and sets no --gates open', () => {
const text = read(); const text = read();
const compIdx = text.indexOf('## Composition rule (v5.1)'); const compIdx = text.indexOf('## Composition rule (v5.1)');
assert.ok(compIdx >= 0, 'Composition rule (v5.1) section missing'); assert.ok(compIdx >= 0, 'Composition rule (v5.1) section missing');
const section = text.slice(compIdx, compIdx + 2000); const section = text.slice(compIdx, compIdx + 2000);
assert.match(section, /--gates open/, 'Low-effort path must mention --gates open'); assert.doesNotMatch(section, /--gates open/, 'Low effort must not set --gates open');
assert.match(section, /sequential/, 'Low-effort path must mention sequential-only execution'); assert.match(section.replace(/\s+/g, ' '), /`effort == 'low'` runs the plan as one session, step after step \(Phase 2\.5\)/,
'Low-effort path must run the plan as one session, step after step');
}); });
// --- v5.1.1 runtime SC4 + SC7 --- // --- v5.1.1 runtime SC4 + SC7 ---

View file

@ -206,11 +206,16 @@ test('trekresearch — Phase 5 pins survive only while the prose does (mutation
); );
}); });
test('trekresearch — High-effort behavior keeps the standard/low effort sentences verbatim', () => { test('trekresearch — High-effort behavior gives standard effort the wave-2 sentence and keeps the low effort sentence verbatim', () => {
const text = read(); const text = read();
const flatText = text.replace(/\s+/g, ' ');
assert.ok( assert.ok(
text.includes('Standard effort (or absent): use the existing conditional triggers.'), !flatText.includes('use the existing conditional triggers'),
'the standard-effort sentence must survive the Phase 5 rewrite verbatim', 'standard effort must not point at a conditional trigger that nothing defines',
);
assert.ok(
flatText.includes('Standard effort (or absent): the selected agents run as in Phase 4, and `contrarian-researcher`, when selected, runs as wave 2 after the other agents return (see Launch rules).'),
'the standard-effort sentence must say the contrarian runs as wave 2 when selected',
); );
assert.ok( assert.ok(
text.includes('Low effort: inline research only, no agent swarm'), text.includes('Low effort: inline research only, no agent swarm'),

201
tests/e2e-fix-round-4-5a.sh Normal file
View file

@ -0,0 +1,201 @@
#!/usr/bin/env bash
# tests/e2e-fix-round-4-5a.sh
# End to end for fix round 4-5a (plan
# .claude/projects/2026-10-20-fix-round-serious-findings, End-to-end:): the
# shipped CLIs run the way the commands run them, in temp git repos.
# 1. The park tool (lib/util/park-attempt-files.mjs), on the diff the
# "Back to planning" line saves:
# a. a new file registered --intent-to-add is parked (exit 0, gone from
# the tree and the index, kept under .git/voyage/parked/);
# b. a coloured diff (--color=always, as color.diff=always gives it)
# still parks its new file;
# c. --cwd a subdirectory of the repo parks the root-relative path.
# 2. The verdict gate (lib/review/verdict-gate.mjs --json <review> --repo .):
# a review of HEAD (0), a later lessons-only commit (0), a later source
# commit (2, reason review-stale).
# 3. The arg parser (lib/parsers/arg-parser.mjs --command trekendsession --
# fix-header): no help flag, the slug is the first positional.
# 4. The gates shim (lib/util/autonomy-gate.mjs): `--gates closed` and a bare
# `--gates` both give next_state gates_on.
# Prints "e2e: ok" and exits 0, or names the failed check and exits 1.
# bash 3.2 compatible, ASCII only.
set -u
cd "$(dirname "$0")/.." || { echo "e2e: FAIL - cannot cd to the repo root" >&2; exit 1; }
ROOT="$(pwd)"
PARK="$ROOT/lib/util/park-attempt-files.mjs"
VERDICT="$ROOT/lib/review/verdict-gate.mjs"
ARGS="$ROOT/lib/parsers/arg-parser.mjs"
SHIM="$ROOT/lib/util/autonomy-gate.mjs"
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-fix-round-4-5a.XXXXXX")" || { echo "e2e: FAIL - mktemp" >&2; exit 1; }
trap 'rm -rf "$TMP"' EXIT
fail() {
echo "e2e: FAIL - $1" >&2
exit 1
}
mkdir -p "$TMP/hooks-none" || fail "mkdir hooks-none"
# newrepo <dir>: git init with no hooks, no signing, one identity.
newrepo() {
mkdir -p "$1" || fail "mkdir $1"
git -C "$1" init -q || fail "git init $1"
printf '[user]\n\tname = Test\n\temail = test@example.invalid\n[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "%s"\n' \
"$TMP/hooks-none" >> "$1/.git/config" || fail "git config $1"
}
# jsonok <check> <js expression on j>: stdin is JSON; exit 0 when the expression is true.
jsonok() {
node -e 'let s="";process.stdin.on("data",(d)=>{s+=d;}).on("end",()=>{let j;try{j=JSON.parse(s);}catch(e){process.exit(3);}process.exit(Function("j","return ("+process.argv[1]+");")(j)?0:1);});' "$1"
}
# ---- 1. the park tool ----
P="$TMP/park"
newrepo "$P"
mkdir -p "$P/bin" "$P/tests" || fail "mkdir park dirs"
printf 'old\n' > "$P/bin/slug.mjs"
git -C "$P" add bin/slug.mjs || fail "git add bin/slug.mjs"
git -C "$P" commit -q -m "base" || fail "git commit base"
START="$(git -C "$P" rev-parse HEAD)" || fail "rev-parse start"
PARKED="$P/.git/voyage/parked"
# savediff <file> [color]: the "Back to planning" save line of commands/trekexecute.md;
# with "color", the same diff coloured (--color=always in place of --no-color).
savediff() {
color=--no-color
[ "${2:-}" = color ] && color=--color=always
git -C "$P" -c core.quotepath=off diff --no-ext-diff "$color" --no-renames --no-relative --src-prefix=a/ --dst-prefix=b/ "$START" > "$1" \
|| fail "git diff for $1"
}
# parked_ok <name> <attempt>: tests/<name> left the tree and the index and sits in the parked dir.
parked_ok() {
[ ! -e "$P/tests/$1" ] || fail "park $2: tests/$1 is still in the work tree"
if git -C "$P" ls-files --error-unmatch -- "tests/$1" >/dev/null 2>&1; then
fail "park $2: tests/$1 is still in the index"
fi
[ -f "$PARKED/step-1-attempt-$2/tests/$1" ] || fail "park $2: tests/$1 is not under the parked directory"
}
# 1a. an intent-to-add file
printf 'stale | cat\n' > "$P/tests/a.sh"
git -C "$P" add --intent-to-add -- tests/a.sh || fail "git add --intent-to-add tests/a.sh"
savediff "$TMP/park-1.diff"
grep -q '^new file mode' "$TMP/park-1.diff" || fail "park 1: the saved diff names no new file"
out="$(node "$PARK" --diff "$TMP/park-1.diff" --step 1 --attempt 1 --cwd "$P")" || fail "park 1 (intent-to-add) exited non-zero: $out"
parked_ok a.sh 1
# 1b. a coloured diff
printf 'stale\n' > "$P/tests/b.sh"
git -C "$P" add --intent-to-add -- tests/b.sh || fail "git add --intent-to-add tests/b.sh"
savediff "$TMP/park-2.diff" color
ESC="$(printf '\033')"
grep -q "$ESC" "$TMP/park-2.diff" || fail "park 2: control - the diff holds no colour code"
out="$(node "$PARK" --diff "$TMP/park-2.diff" --step 1 --attempt 2 --cwd "$P")" || fail "park 2 (coloured diff) exited non-zero: $out"
parked_ok b.sh 2
# 1c. from a subdirectory
printf 'stale\n' > "$P/tests/c.sh"
git -C "$P" add --intent-to-add -- tests/c.sh || fail "git add --intent-to-add tests/c.sh"
savediff "$TMP/park-3.diff"
out="$(node "$PARK" --diff "$TMP/park-3.diff" --step 1 --attempt 3 --cwd "$P/bin")" || fail "park 3 (--cwd bin) exited non-zero: $out"
parked_ok c.sh 3
[ "$(cat "$P/bin/slug.mjs")" = "old" ] || fail "park: a committed file was touched"
# ---- 2. the verdict gate with --repo ----
V="$TMP/verdict"
newrepo "$V"
mkdir -p "$TMP/project" || fail "mkdir project"
printf 'export const a = 1;\n' > "$V/a.mjs"
git -C "$V" add a.mjs || fail "git add a.mjs"
git -C "$V" commit -q -m "feat: the reviewed work" || fail "git commit reviewed"
REVIEWED="$(git -C "$V" rev-parse HEAD)" || fail "rev-parse reviewed"
REVIEW="$TMP/project/review.md"
cat > "$REVIEW" <<EOF
---
type: trekreview
review_version: "1.1"
task: "t"
slug: s
project_dir: .claude/projects/2026-10-23-s/
brief_path: .claude/projects/2026-10-23-s/brief.md
scope_sha_end: $REVIEWED
reviewed_files_count: 1
verdict: ALLOW
findings: []
---
# Review: t
## Executive Summary
x
## Coverage
| File | Treatment | Reason |
|---|---|---|
| a.mjs | summary-only | default |
## Remediation Summary
x
\`\`\`json
{
"verdict": "ALLOW",
"counts": { "BLOCKER": 0, "MAJOR": 0, "MINOR": 0, "SUGGESTION": 0 },
"allow_blocked_by": [],
"findings": []
}
\`\`\`
EOF
# gate: the merge lock line of commands/trekexecute.md, run in the repo being merged.
gate() {
(cd "$V" && node "$VERDICT" --json "$REVIEW" --repo .)
}
out="$(gate)" || fail "verdict 1: a review of HEAD did not exit 0: $out"
mkdir -p "$V/.claude/rules" || fail "mkdir .claude/rules"
# The block holds exactly BLOCK_HEADER (lib/review/recurrence.mjs); each \302\267 is the
# UTF-8 middle dot, written as octal escapes so this script stays ASCII.
printf '<!-- voyage:review-lessons:start -->\nEach line: a finding class /trekreview saw in two reviews, approved by the operator. Format: mistake \302\267 fix \302\267 how to check.\n<!-- voyage:review-lessons:end -->\n' \
> "$V/.claude/rules/voyage-review-lessons.md"
git -C "$V" add .claude/rules/voyage-review-lessons.md || fail "git add lessons"
git -C "$V" commit -q -m "docs(voyage): review lessons (round 1)" || fail "git commit lessons"
out="$(gate)" || fail "verdict 2: a later lessons-only commit did not exit 0: $out"
printf 'export const a = 2;\n' > "$V/a.mjs"
git -C "$V" add a.mjs || fail "git add a.mjs (later)"
git -C "$V" commit -q -m "feat: a later source change" || fail "git commit later"
out="$(gate)"
code=$?
[ "$code" -eq 2 ] || fail "verdict 3: a review of an older commit exited $code, not 2: $out"
printf '%s' "$out" | jsonok 'typeof j.reason === "string" && j.reason.indexOf("review-stale") === 0' \
|| fail "verdict 3: the reason does not start review-stale: $out"
# ---- 3. the arg parser for /trekendsession ----
out="$(node "$ARGS" --command trekendsession -- fix-header)" || fail "arg-parser on fix-header exited non-zero: $out"
printf '%s' "$out" | jsonok 'j.flags["--help"] === undefined && j.positional[0] === "fix-header"' \
|| fail "arg-parser: fix-header asked for help or is not the first positional: $out"
# ---- 4. the gates shim ----
out="$(node "$SHIM" --state idle --event start --gates closed)" || fail "shim --gates closed exited non-zero: $out"
printf '%s' "$out" | jsonok 'j.ok === true && j.next_state === "gates_on"' || fail "shim: --gates closed is not gates_on: $out"
out="$(node "$SHIM" --state idle --event start --gates)" || fail "shim bare --gates exited non-zero: $out"
printf '%s' "$out" | jsonok 'j.ok === true && j.next_state === "gates_on"' || fail "shim: a bare --gates is not gates_on: $out"
echo "e2e: ok"
exit 0

View file

@ -0,0 +1,220 @@
#!/usr/bin/env bash
# tests/e2e-fix-round-4-5b-part-2.sh
# End to end for fix round 4-5b, part 2 (plan
# .claude/projects/2026-10-27-fix-round-4-5b-part-2, End-to-end:, decision
# 88): the shipped products run the way a session runs them, in temp
# directories and temp git repos.
# 1. TOK: the arg parser (lib/parsers/arg-parser.mjs --command trekreview
# --stdin) reads `--project "my dir"/x --since "HEAD~1"` from a quoted
# heredoc, and its JSON keeps `--since` = `HEAD~1`.
# 2. HDG: the Bash hook (hooks/scripts/pre-bash-executor.mjs) with a Bash
# tool input on stdin:
# a. a one-line `grep` that mentions the parser's heredoc opener exits 0;
# b. a parser heredoc that closes early exits 2 (args-heredoc-closed-early);
# c. a quoted first mention hiding an unquoted parser heredoc exits 2
# (args-heredoc-closed-early).
# 3. CKI: `recurrence.mjs apply` in a repo whose `.claude` is a symlink to an
# in-repo directory: the lesson is written, `pointer` is `check-failed`,
# and CLAUDE.md is unchanged.
# DLC is prose (commands/trekexecute.md Sub-step G); it is checked by
# tests/lib/fix-round-4-5b-part-2-prose.test.mjs, not by this run.
# Prints one "ok - <case>" or "FAIL - <case>: <why>" line per case and exits 1
# when any case failed, else 0. Starts nothing but node and git (and the
# usual file tools). bash 3.2 compatible, ASCII only, no arrays.
set -u
cd "$(dirname "$0")/.." || { echo "FAIL - setup: cannot cd to the repo root"; exit 1; }
ROOT="$(pwd)"
ARGS="$ROOT/lib/parsers/arg-parser.mjs"
HOOK="$ROOT/hooks/scripts/pre-bash-executor.mjs"
REC="$ROOT/lib/review/recurrence.mjs"
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE CLAUDE_CODE_SESSION_ID
TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-fix-round-4-5b-part-2.XXXXXX")" || { echo "FAIL - setup: mktemp"; exit 1; }
trap 'rm -rf "$TMP"' EXIT
TMP="$(cd "$TMP" && pwd -P)" || { echo "FAIL - setup: cannot enter the temp directory"; exit 1; }
mkdir -p "$TMP/hooks-none" "$TMP/home" || { echo "FAIL - setup: mkdir"; exit 1; }
: > "$TMP/empty-gitconfig" || { echo "FAIL - setup: empty git config"; exit 1; }
# The git environment is fixed here, so a global core.excludesFile, the temp
# directory's location or a translated git cannot change a result.
export HOME="$TMP/home"
export XDG_CONFIG_HOME="$TMP/home"
export GIT_CONFIG_GLOBAL="$TMP/empty-gitconfig"
export GIT_CONFIG_NOSYSTEM=1
export GIT_CEILING_DIRECTORIES="$TMP"
export LC_ALL=C
export LANG=C
unset LANGUAGE
FAILED=0
pass() { echo "ok - $1"; }
bad() { echo "FAIL - $1"; FAILED=$((FAILED + 1)); }
# newrepo <dir>: git init with no hooks, no signing, one repo-local identity.
newrepo() {
mkdir -p "$1" || return 1
git init -q "$1" || return 1
printf '[user]\n\tname = Test\n\temail = test@example.invalid\n[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "%s"\n' \
"$TMP/hooks-none" >> "$1/.git/config"
}
# jsonok <js expression on j>: stdin is JSON; exit 0 when the expression is true.
jsonok() {
node -e 'let s="";process.stdin.on("data",(d)=>{s+=d;}).on("end",()=>{let j;try{j=JSON.parse(s);}catch(e){process.exit(3);}process.exit(Function("j","return ("+process.argv[1]+");")(j)?0:1);});' "$1"
}
# hookrun <out> <line>...: the Bash hook with a Bash tool input whose command
# is the given lines joined by newlines; stderr goes to <out>. Returns the
# hook's exit status.
hookrun() {
out="$1"
shift
node -e 'process.stdout.write(JSON.stringify({ tool_name: "Bash", tool_input: { command: process.argv.slice(1).join("\n") } }));' "$@" \
| node "$HOOK" > /dev/null 2> "$out"
}
D=VOYAGE_ARGS_EOF_7F3C9E
PARSER_LINE='node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin'
# ---- 1. TOK: the arg parser keeps a later flag ----
case_tok() {
name="arg-parser --stdin: a quoted value never swallows --since"
W="$TMP/args"
mkdir -p "$W" || { bad "$name: mkdir"; return; }
( cd "$W" && node "$ARGS" --command trekreview --stdin > "$TMP/args.json" ) <<'VOYAGE_ARGS_EOF_7F3C9E'
--project "my dir"/x --since "HEAD~1"
VOYAGE_ARGS_EOF_7F3C9E
c=$?
if [ "$c" -ne 0 ]; then
bad "$name: the parser exited $c: $(cat "$TMP/args.json")"
elif ! jsonok 'j.flags["--since"] === "HEAD~1"' < "$TMP/args.json"; then
bad "$name: --since is not HEAD~1: $(cat "$TMP/args.json")"
else
pass "$name (--since = HEAD~1)"
fi
}
# ---- 2. HDG: the Bash hook ----
case_hdg_one_line() {
name="Bash hook: a one-line grep that mentions the opener passes"
hookrun "$TMP/hdg-1.err" "grep -n \"<<'$D'\" commands/trekreview.md"
c=$?
if [ "$c" -ne 0 ]; then
bad "$name: the hook exited $c: $(cat "$TMP/hdg-1.err")"
else
pass "$name (exit 0)"
fi
}
case_hdg_early_close() {
name="Bash hook: a parser heredoc that closes early is blocked"
hookrun "$TMP/hdg-2.err" "$PARSER_LINE <<'$D'" 'brief.md' "$D" 'touch PWNED'
c=$?
if [ "$c" -ne 2 ]; then
bad "$name: the hook exited $c, not 2: $(cat "$TMP/hdg-2.err")"
elif ! grep -q 'args-heredoc-closed-early' "$TMP/hdg-2.err"; then
bad "$name: the hook did not name args-heredoc-closed-early: $(cat "$TMP/hdg-2.err")"
else
pass "$name (exit 2, args-heredoc-closed-early)"
fi
}
case_hdg_hidden() {
name="Bash hook: a quoted first mention does not hide an unquoted parser heredoc"
hookrun "$TMP/hdg-3.err" "echo \"<<'$D'\"" "$PARSER_LINE <<$D" 'brief.md $(touch PWNED)' "$D"
c=$?
if [ "$c" -ne 2 ]; then
bad "$name: the hook exited $c, not 2: $(cat "$TMP/hdg-3.err")"
elif ! grep -q 'args-heredoc-closed-early' "$TMP/hdg-3.err"; then
bad "$name: the hook did not name args-heredoc-closed-early: $(cat "$TMP/hdg-3.err")"
else
pass "$name (exit 2, args-heredoc-closed-early)"
fi
}
# ---- 3. CKI: applyLesson with a symlinked .claude ----
# lessonreview <file> <slug> <sha> <id> <n>: a review.md with one MISSING_TEST finding.
lessonreview() {
mkdir -p "$(dirname "$1")" || return 1
fid="$(printf '%s0000000000000000000000000000000000000000' "$4" | cut -c1-40)"
cat > "$1" <<EOF
---
type: trekreview
review_version: "1.1"
task: "$2"
slug: $2
project_dir: .claude/projects/2026-10-0$5-$2/
brief_path: .claude/projects/x/brief.md
scope_sha_end: $3
reviewed_files_count: 1
verdict: WARN
findings:
- $fid
---
# Review: $2
## Executive Summary
x
## Coverage
x
## Remediation Summary
x
\`\`\`json
{
"verdict": "WARN",
"counts": { "BLOCKER": 0, "MAJOR": 1, "MINOR": 0, "SUGGESTION": 0 },
"findings": [
{ "id": "$fid", "severity": "MAJOR", "rule_key": "MISSING_TEST", "file": "lib/a.mjs", "line": 3,
"title": "MISSING_TEST title $4", "detail": "d", "recommended_action": "Fix it ($4)." }
]
}
\`\`\`
EOF
}
case_cki_symlink() {
name="applyLesson: a .claude symlinked inside the repo gets no pointer (check-failed)"
R="$TMP/lesson-link"
newrepo "$R" || { bad "$name: fixture repo"; return; }
lessonreview "$R/claude-data/projects/2026-10-01-one/review.md" one aaa a1 1 || { bad "$name: review one"; return; }
lessonreview "$R/claude-data/projects/2026-10-02-two/review.md" two bbb b1 2 || { bad "$name: review two"; return; }
( cd "$R" && ln -s claude-data .claude ) || { bad "$name: ln -s"; return; }
printf '# Project\n' > "$R/CLAUDE.md"
out="$(node "$REC" apply --repo "$R" --rule-key MISSING_TEST)"; c=$?
if [ "$c" -ne 0 ]; then
bad "$name: apply exited $c: $out"
elif ! printf '%s' "$out" | jsonok 'j.written === true && j.pointer === "check-failed" && /check-ignore/.test(String(j.pointer_note))'; then
bad "$name: the result is not written with pointer check-failed and a check-ignore note: $out"
elif [ "$(cat "$R/CLAUDE.md")" != "# Project" ]; then
bad "$name: CLAUDE.md was changed"
else
pass "$name"
fi
}
case_tok
case_hdg_one_line
case_hdg_early_close
case_hdg_hidden
case_cki_symlink
echo "e2e: DLC is prose; tests/lib/fix-round-4-5b-part-2-prose.test.mjs checks it, not this run"
if [ "$FAILED" -ne 0 ]; then
echo "e2e: $FAILED case(s) failed"
exit 1
fi
echo "e2e: ok"
exit 0

View file

@ -0,0 +1,330 @@
#!/usr/bin/env bash
# tests/e2e-fix-round-4-5b-part-3.sh
# End to end for fix round 4-5b, part 3 (plan
# .claude/projects/2026-10-28-fix-round-4-5b-part-3, End-to-end:, decision
# 89): the shipped products run the way a session runs them, in a temp
# directory.
# 1. CQW, the shipped way: the parser block of commands/trekreview.md
# (## Phase 1), with ${CLAUDE_PLUGIN_ROOT} and $ARGUMENTS filled in, passes
# the Bash hook, and /bin/bash runs it to JSON that keeps a value closed
# on its own line.
# 2. CQW through the parser CLI's --stdin: `"my dir "` pairs, and
# `--project "my dir"/x --since "HEAD~1"` keeps --since = HEAD~1.
# 3. PQO through the Bash hook (hooks/scripts/pre-bash-executor.mjs):
# partly quoted, $'...' and backslash-newline openers are blocked; a
# string-borne << hides no real opener; the shipped form with a typed
# `fix "<<" handling` argument and the agents/review-orchestrator.md
# block pass.
# 4. PQO premise: /bin/bash itself runs the line after the closing line of
# a partly quoted opener, so blocking it is not a false positive.
# 5. PQO fix rounds 1 and 2 of step 2, through the Bash hook: a
# continuation between a $ and its quote (at the word's start and inside
# it) is blocked; a partly quoted opener whose word ends in a CR (CRLF
# lines) or a VT, closed by the same line, is blocked when a line follows
# the closing line; with CRLF lines and the closing line last it passes.
# Prints one "ok - <case>" or "FAIL - <case>: <why>" line per case and exits 1
# when any case failed, else 0. Starts nothing but node and /bin/bash (and the
# usual file tools). bash 3.2 compatible, ASCII only, no arrays.
set -u
cd "$(dirname "$0")/.." || { echo "FAIL - setup: cannot cd to the repo root"; exit 1; }
ROOT="$(pwd)"
ARGS="$ROOT/lib/parsers/arg-parser.mjs"
HOOK="$ROOT/hooks/scripts/pre-bash-executor.mjs"
unset CLAUDE_CODE_SESSION_ID
TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-fix-round-4-5b-part-3.XXXXXX")" || { echo "FAIL - setup: mktemp"; exit 1; }
trap 'rm -rf "$TMP"' EXIT
TMP="$(cd "$TMP" && pwd -P)" || { echo "FAIL - setup: cannot enter the temp directory"; exit 1; }
export LC_ALL=C
export LANG=C
FAILED=0
pass() { echo "ok - $1"; }
bad() { echo "FAIL - $1"; FAILED=$((FAILED + 1)); }
# jsonok <js expression on j>: stdin is JSON; exit 0 when the expression is true.
jsonok() {
node -e 'let s="";process.stdin.on("data",(d)=>{s+=d;}).on("end",()=>{let j;try{j=JSON.parse(s);}catch(e){process.exit(3);}process.exit(Function("j","return ("+process.argv[1]+");")(j)?0:1);});' "$1"
}
# hookrun <out> <line>...: the Bash hook with a Bash tool input whose command
# is the given lines joined by newlines; stderr goes to <out>. Returns the
# hook's exit status.
hookrun() {
out="$1"
shift
node -e 'process.stdout.write(JSON.stringify({ tool_name: "Bash", tool_input: { command: process.argv.slice(1).join("\n") } }));' "$@" \
| node "$HOOK" > /dev/null 2> "$out"
}
# hookfile <out> <file>: the Bash hook with the file's text as the command.
hookfile() {
node -e 'process.stdout.write(JSON.stringify({ tool_name: "Bash", tool_input: { command: require("fs").readFileSync(process.argv[1], "utf8") } }));' "$2" \
| node "$HOOK" > /dev/null 2> "$1"
}
D=VOYAGE_ARGS_EOF_7F3C9E
PARSER_LINE='node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin'
# The typed arguments: a quoted value closed on its own line.
TYPED="$(printf '%s\n%s' '--project "notes' '" --since HEAD')"
# ---- 1. CQW, the shipped way ----
case_cqw_shipped() {
name="trekreview Phase 1 block: passes the hook and keeps a value closed on its own line"
W="$TMP/shipped"
mkdir -p "$W" || { bad "$name: mkdir"; return; }
# The block as written, with ${CLAUDE_PLUGIN_ROOT} and $ARGUMENTS filled in
# the way Claude Code fills them.
node -e '
const fs = require("fs");
const [md, root, args] = process.argv.slice(1);
const text = fs.readFileSync(md, "utf8");
const s = text.indexOf("## Phase 1 ");
if (s === -1) process.exit(3);
const rest = text.slice(s);
const e = rest.search(/\n## /);
const phase = e === -1 ? rest : rest.slice(0, e);
const blocks = phase.split("\n```bash\n").slice(1).map((b) => b.split("\n```")[0]).filter((b) => b.includes("<<" + String.fromCharCode(39) + "VOYAGE_ARGS_EOF_7F3C9E" + String.fromCharCode(39)));
if (blocks.length !== 1) { console.error("expected one Phase 1 parser block, found " + blocks.length); process.exit(3); }
process.stdout.write(blocks[0].split("${CLAUDE_PLUGIN_ROOT}").join(root).split("$ARGUMENTS").join(args) + "\n");
' "$ROOT/commands/trekreview.md" "$ROOT" "$TYPED" > "$TMP/phase1.sh" \
|| { bad "$name: could not read the Phase 1 block"; return; }
hookfile "$TMP/shipped.err" "$TMP/phase1.sh"
c=$?
if [ "$c" -ne 0 ]; then
bad "$name: the hook exited $c: $(cat "$TMP/shipped.err")"
return
fi
( cd "$W" && /bin/bash "$TMP/phase1.sh" ) > "$TMP/shipped.json" 2> "$TMP/shipped.out"
c=$?
if [ "$c" -ne 0 ]; then
bad "$name: the block exited $c: $(cat "$TMP/shipped.out")"
elif ! jsonok 'j.flags["--project"] === "notes\n" && j.flags["--since"] === "HEAD" && j.positional.length === 0' < "$TMP/shipped.json"; then
bad "$name: the parse is not --project = notes + line break, --since = HEAD, no positional: $(cat "$TMP/shipped.json")"
else
pass "$name (hook exit 0; --project = notes + line break, --since = HEAD)"
fi
}
# ---- 2. CQW through the parser CLI's --stdin ----
case_cqw_cli() {
name="arg-parser --stdin: \"my dir \" pairs, and --since stays HEAD~1"
printf '%s\n' '--project "my dir " --since HEAD' | node "$ARGS" --command trekreview --stdin > "$TMP/cli-1.json" 2>&1
c1=$?
printf '%s\n' '--project "my dir"/x --since "HEAD~1"' | node "$ARGS" --command trekreview --stdin > "$TMP/cli-2.json" 2>&1
c2=$?
if [ "$c1" -ne 0 ] || [ "$c2" -ne 0 ]; then
bad "$name: the parser exited $c1 / $c2: $(cat "$TMP/cli-1.json" "$TMP/cli-2.json")"
elif ! jsonok 'j.flags["--project"] === "my dir " && j.flags["--since"] === "HEAD"' < "$TMP/cli-1.json"; then
bad "$name: --project is not \"my dir \": $(cat "$TMP/cli-1.json")"
elif ! jsonok 'j.flags["--since"] === "HEAD~1"' < "$TMP/cli-2.json"; then
bad "$name: --since is not HEAD~1: $(cat "$TMP/cli-2.json")"
else
pass "$name"
fi
}
# ---- 3. PQO through the Bash hook ----
# blocked <name> <out> <line>...: the hook exits 2 and names args-heredoc-closed-early.
blocked() {
bname="$1"
bout="$2"
shift 2
hookrun "$bout" "$@"
c=$?
if [ "$c" -ne 2 ]; then
bad "$bname: the hook exited $c, not 2: $(cat "$bout")"
return 1
elif ! grep -q 'args-heredoc-closed-early' "$bout"; then
bad "$bname: the hook did not name args-heredoc-closed-early: $(cat "$bout")"
return 1
fi
return 0
}
# unquoted <name> <out> <line>...: blocked, with the unquoted reason.
unquoted() {
qname="$1"
uout="$2"
shift 2
blocked "$qname" "$uout" "$@" || return 1
if ! grep -q "opens $D unquoted" "$uout"; then
bad "$qname: the hook did not give the unquoted reason: $(cat "$uout")"
return 1
fi
return 0
}
# allowed <name> <out> <line>...: the hook exits 0.
allowed() {
aname="$1"
aout="$2"
shift 2
hookrun "$aout" "$@"
c=$?
if [ "$c" -ne 0 ]; then
bad "$aname: the hook exited $c: $(cat "$aout")"
return 1
fi
return 0
}
case_pqo_partly_quoted() {
name='Bash hook: a partly quoted opener <<"VOYAGE"_ARGS_EOF_7F3C9E that closes early is blocked'
blocked "$name" "$TMP/pqo-1.err" "$PARSER_LINE "'<<"VOYAGE"_ARGS_EOF_7F3C9E' 'brief.md' "$D" 'touch PWNED' \
&& pass "$name (exit 2, args-heredoc-closed-early)"
}
case_pqo_dollar() {
name="Bash hook: a \$'...' opener that closes early is blocked"
blocked "$name" "$TMP/pqo-2.err" "$PARSER_LINE <<\$'$D'" 'brief.md' "$D" 'touch PWNED' \
&& pass "$name (exit 2, args-heredoc-closed-early)"
}
case_pqo_continuation() {
name='Bash hook: a backslash-newline opener that closes early is blocked'
blocked "$name" "$TMP/pqo-3.err" "$PARSER_LINE"' <<\' "$D" 'brief.md' "$D" 'touch PWNED' \
&& pass "$name (exit 2, args-heredoc-closed-early)"
}
case_pqo_continuation_last() {
name='Bash hook: a backslash-newline opener with the closing line last is blocked as unquoted'
unquoted "$name" "$TMP/pqo-4.err" "$PARSER_LINE"' <<\' "$D" 'brief.md' "$D" \
&& pass "$name (exit 2, opens $D unquoted)"
}
case_pqo_fake_accepted() {
name='Bash hook: a fake accepted opener in a string hides no real opener'
unquoted "$name" "$TMP/pqo-5.err" "echo \"<<'$D' x\"" "$PARSER_LINE"' <<VOYAGE_ARGS\' '_EOF_7F3C9E' 'brief.md' "$D" \
&& pass "$name (exit 2, opens $D unquoted)"
}
case_pqo_spanning_word() {
name='Bash hook: a quoted word spanning lines in a string hides no real opener'
unquoted "$name" "$TMP/pqo-6.err" "echo \"<<'\"" "$PARSER_LINE"' <<VOYAGE_ARGS\' '_EOF_7F3C9E' "brief.md don't" "$D" \
&& pass "$name (exit 2, opens $D unquoted)"
}
case_pqo_typed_argument() {
name='Bash hook: the shipped form with the typed argument fix "<<" handling passes'
allowed "$name" "$TMP/pqo-7.err" "$PARSER_LINE <<'$D'" 'fix "<<" handling' "$D" \
&& pass "$name (exit 0)"
}
case_pqo_orchestrator() {
name='Bash hook: the agents/review-orchestrator.md parser block passes'
# The lines from the one holding the opener through the next line equal to
# the delimiter, with ${CLAUDE_PLUGIN_ROOT} and $ARGUMENTS filled in.
node -e '
const fs = require("fs");
const [md, root, args, delim] = process.argv.slice(1);
const lines = fs.readFileSync(md, "utf8").split("\n");
const opener = "--stdin <<" + String.fromCharCode(39) + delim + String.fromCharCode(39);
const s = lines.findIndex((l) => l.includes(opener));
if (s === -1) { console.error("no line holds " + opener); process.exit(3); }
const e = lines.findIndex((l, i) => i > s && l === delim);
if (e === -1) { console.error("no closing line after the opener"); process.exit(3); }
process.stdout.write(lines.slice(s, e + 1).join("\n").split("${CLAUDE_PLUGIN_ROOT}").join(root).split("$ARGUMENTS").join(args) + "\n");
' "$ROOT/agents/review-orchestrator.md" "$ROOT" "$TYPED" "$D" > "$TMP/orchestrator.sh" \
|| { bad "$name: could not read the parser block"; return; }
hookfile "$TMP/pqo-8.err" "$TMP/orchestrator.sh"
c=$?
if [ "$c" -ne 0 ]; then
bad "$name: the hook exited $c: $(cat "$TMP/pqo-8.err")"
else
pass "$name (exit 0)"
fi
}
# ---- 4. PQO premise: bash runs the line after the closing line ----
case_pqo_premise() {
name='/bin/bash runs the line after the closing line of a partly quoted opener'
P="$TMP/premise"
mkdir -p "$P" || { bad "$name: mkdir"; return; }
printf '%s\n' 'cat > /dev/null <<"VOYAGE"_ARGS_EOF_7F3C9E' 'brief.md' "$D" "touch \"$TMP/ran\"" > "$TMP/premise.sh" \
|| { bad "$name: could not write the script"; return; }
( cd "$P" && /bin/bash "$TMP/premise.sh" ) > "$TMP/premise.out" 2>&1
if [ ! -e "$TMP/ran" ]; then
bad "$name: the line after the closing line did not run: $(cat "$TMP/premise.out")"
else
pass "$name (the block is not a false positive)"
fi
}
# ---- 5. PQO fix rounds: a continuation after $, a CR or VT word end ----
# The CR and VT bytes, built with printf escapes so this file stays ASCII.
CR="$(printf '\r')"
VT="$(printf '\013')"
case_fix_bytes() {
name='fix-round cases: the CR and VT bytes were built'
if [ "${#CR}" -ne 1 ] || [ "${#VT}" -ne 1 ]; then
bad "$name: CR is ${#CR} byte(s), VT is ${#VT} byte(s), not 1 each"
else
pass "$name"
fi
}
case_fix_dollar_continuation() {
name="Bash hook: <<\$ + backslash-newline + '$D' that closes early is blocked as unquoted"
unquoted "$name" "$TMP/fix-1.err" "$PARSER_LINE"' <<$\' "'$D'" 'brief.md' "$D" 'touch PWNED' \
&& pass "$name (exit 2, opens $D unquoted)"
}
case_fix_midword_dollar_continuation() {
name="Bash hook: <<VOYAGE\$ + backslash-newline + '_ARGS_EOF_7F3C9E' that closes early is blocked"
blocked "$name" "$TMP/fix-2.err" "$PARSER_LINE"' <<VOYAGE$\' "'_ARGS_EOF_7F3C9E'" 'brief.md' "$D" 'touch PWNED' \
&& pass "$name (exit 2, args-heredoc-closed-early)"
}
case_fix_crlf_partly_quoted() {
name='Bash hook: a partly quoted opener with CRLF lines that closes early is blocked'
blocked "$name" "$TMP/fix-3.err" "$PARSER_LINE"' <<"VOYAGE"_ARGS_EOF_7F3C9E'"$CR" "brief.md$CR" "$D$CR" "touch PWNED$CR" \
&& pass "$name (exit 2, args-heredoc-closed-early)"
}
case_fix_vt_partly_quoted() {
name='Bash hook: a partly quoted opener whose word ends in a VT, closed by D + VT, is blocked'
blocked "$name" "$TMP/fix-4.err" "$PARSER_LINE"' <<"VOYAGE"_ARGS_EOF_7F3C9E'"$VT" 'brief.md' "$D$VT" 'touch PWNED' \
&& pass "$name (exit 2, args-heredoc-closed-early)"
}
case_fix_crlf_closing_last() {
name='Bash hook: a partly quoted opener with CRLF lines and the closing line last passes'
allowed "$name" "$TMP/fix-5.err" "$PARSER_LINE"' <<"VOYAGE"_ARGS_EOF_7F3C9E'"$CR" "brief.md$CR" "$D$CR" \
&& pass "$name (exit 0)"
}
case_cqw_shipped
case_cqw_cli
case_pqo_partly_quoted
case_pqo_dollar
case_pqo_continuation
case_pqo_continuation_last
case_pqo_fake_accepted
case_pqo_spanning_word
case_pqo_typed_argument
case_pqo_orchestrator
case_pqo_premise
case_fix_bytes
case_fix_dollar_continuation
case_fix_midword_dollar_continuation
case_fix_crlf_partly_quoted
case_fix_vt_partly_quoted
case_fix_crlf_closing_last
if [ "$FAILED" -ne 0 ]; then
echo "e2e: $FAILED case(s) failed"
exit 1
fi
echo "e2e: ok"
exit 0

495
tests/e2e-fix-round-4-5b.sh Normal file
View file

@ -0,0 +1,495 @@
#!/usr/bin/env bash
# tests/e2e-fix-round-4-5b.sh
# End to end for fix round 4-5b (plan
# .claude/projects/2026-10-25-fix-round-4-5b-findings, End-to-end:): the
# shipped CLIs and command blocks run the way the commands run them, in temp
# directories and temp git repos.
# 1. The verdict gate (lib/review/verdict-gate.mjs --json <review> --repo):
# a. the `--repo <dir>` form and b. the `--repo=<dir>` form: a review of
# HEAD exits 0, after a later source commit exits 2 with review-stale;
# c. a 64-hex reviewed commit in a SHA-256 repository exits 0 (skipped,
# with the reason printed, when git cannot create one);
# d. a plain directory as --repo exits 2 with review-not-measured:;
# e. free text added inside the lessons block exits 2 with review-stale.
# 2. The park tool (lib/util/park-attempt-files.mjs):
# a. the "Back to planning" save line, read from commands/trekexecute.md,
# run from sub/ with diff.relative=true: every new file is parked;
# b. a CRLF diff parks its new file;
# c. a HEAD path missing from the index exits 2 and moves nothing.
# 3. The arg parser (lib/parsers/arg-parser.mjs --command trekendsession
# --stdin) with `brief.md ; touch PWNED $(touch PWNED2) *`: no file made.
# 4. The /trekendsession Phase 3 block, read from commands/trekendsession.md,
# with the label `a'; touch PWNED; echo 'b`: no file made, label whole.
# 5. applyLesson (lib/review/recurrence.mjs apply): a symlinked CLAUDE.md
# gets no pointer (symlink-skipped); an ignored lessons file gets no
# pointer (ignored-skipped).
# 6. The next-session validator with `--state-file` and no value exits 2.
# Prints one "ok - <case>" or "FAIL - <case>: <why>" line per case and exits 1
# when any case failed, else 0. Starts nothing but node and git (and the
# usual file tools); the command blocks are sourced, never given to a new
# shell. bash 3.2 compatible, ASCII only, no arrays.
set -u
cd "$(dirname "$0")/.." || { echo "FAIL - setup: cannot cd to the repo root"; exit 1; }
ROOT="$(pwd)"
VERDICT="$ROOT/lib/review/verdict-gate.mjs"
PARK="$ROOT/lib/util/park-attempt-files.mjs"
ARGS="$ROOT/lib/parsers/arg-parser.mjs"
REC="$ROOT/lib/review/recurrence.mjs"
NSP="$ROOT/lib/validators/next-session-prompt-validator.mjs"
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-fix-round-4-5b.XXXXXX")" || { echo "FAIL - setup: mktemp"; exit 1; }
trap 'rm -rf "$TMP"' EXIT
mkdir -p "$TMP/hooks-none" || { echo "FAIL - setup: mkdir hooks-none"; exit 1; }
FAILED=0
pass() { echo "ok - $1"; }
bad() { echo "FAIL - $1"; FAILED=$((FAILED + 1)); }
# newrepo <dir> [object-format]: git init with no hooks, no signing, one identity.
newrepo() {
mkdir -p "$1" || return 1
if [ -n "${2:-}" ]; then
git init -q "--object-format=$2" "$1" >/dev/null 2>&1 || return 1
else
git init -q "$1" || return 1
fi
printf '[user]\n\tname = Test\n\temail = test@example.invalid\n[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "%s"\n' \
"$TMP/hooks-none" >> "$1/.git/config"
}
# jsonok <js expression on j>: stdin is JSON; exit 0 when the expression is true.
jsonok() {
node -e 'let s="";process.stdin.on("data",(d)=>{s+=d;}).on("end",()=>{let j;try{j=JSON.parse(s);}catch(e){process.exit(3);}process.exit(Function("j","return ("+process.argv[1]+");")(j)?0:1);});' "$1"
}
# lessonsfile <path> [extra line]: the managed block holding exactly BLOCK_HEADER
# (lib/review/recurrence.mjs), plus an optional line inside it. Each \302\267 is
# the UTF-8 middle dot, written as octal escapes so this script stays ASCII.
lessonsfile() {
mkdir -p "$(dirname "$1")" || return 1
if [ -n "${2:-}" ]; then
printf '<!-- voyage:review-lessons:start -->\nEach line: a finding class /trekreview saw in two reviews, approved by the operator. Format: mistake \302\267 fix \302\267 how to check.\n%s\n<!-- voyage:review-lessons:end -->\n' "$2" > "$1"
else
printf '<!-- voyage:review-lessons:start -->\nEach line: a finding class /trekreview saw in two reviews, approved by the operator. Format: mistake \302\267 fix \302\267 how to check.\n<!-- voyage:review-lessons:end -->\n' > "$1"
fi
}
# gatereview <path> <sha>: an ALLOW review.md of commit <sha>.
gatereview() {
cat > "$1" <<EOF
---
type: trekreview
review_version: "1.1"
task: "t"
slug: s
project_dir: .claude/projects/2026-10-25-s/
brief_path: .claude/projects/2026-10-25-s/brief.md
scope_sha_end: $2
reviewed_files_count: 1
verdict: ALLOW
findings: []
---
# Review: t
## Executive Summary
x
## Coverage
| File | Treatment | Reason |
|---|---|---|
| a.mjs | summary-only | default |
## Remediation Summary
x
\`\`\`json
{
"verdict": "ALLOW",
"counts": { "BLOCKER": 0, "MAJOR": 0, "MINOR": 0, "SUGGESTION": 0 },
"allow_blocked_by": [],
"findings": []
}
\`\`\`
EOF
}
# gaterepo <dir> [object-format]: a repo with one reviewed commit (a.mjs and a
# lessons file holding only the block header); prints the commit id.
gaterepo() {
newrepo "$1" "${2:-}" || return 1
printf 'export const a = 1;\n' > "$1/a.mjs" || return 1
lessonsfile "$1/.claude/rules/voyage-review-lessons.md" || return 1
git -C "$1" add a.mjs .claude/rules/voyage-review-lessons.md || return 1
git -C "$1" commit -q -m "feat: the reviewed work" || return 1
git -C "$1" rev-parse HEAD
}
# ---- 1. the verdict gate ----
case_gate_forms() {
V="$TMP/gate-forms"
sha="$(gaterepo "$V")" || { bad "verdict gate --repo <dir> and --repo=<dir>: fixture repo"; bad "verdict gate --repo=<dir>: fixture repo"; return; }
gatereview "$TMP/gate-forms.md" "$sha"
out1="$(node "$VERDICT" --json "$TMP/gate-forms.md" --repo "$V")"; c1=$?
out2="$(node "$VERDICT" --json "$TMP/gate-forms.md" "--repo=$V")"; c2=$?
printf 'export const a = 2;\n' > "$V/a.mjs"
git -C "$V" add a.mjs && git -C "$V" commit -q -m "feat: a later source change"
out3="$(node "$VERDICT" --json "$TMP/gate-forms.md" --repo "$V")"; c3=$?
out4="$(node "$VERDICT" --json "$TMP/gate-forms.md" "--repo=$V")"; c4=$?
STALE='typeof j.reason === "string" && j.reason.indexOf("review-stale") === 0'
if [ "$c1" -ne 0 ]; then
bad "verdict gate --repo <dir>: a review of HEAD exited $c1, not 0: $out1"
elif [ "$c3" -ne 2 ] || ! printf '%s' "$out3" | jsonok "$STALE"; then
bad "verdict gate --repo <dir>: after a later commit exited $c3, not 2 with review-stale: $out3"
else
pass "verdict gate --repo <dir>: HEAD review exits 0, a later commit exits 2 review-stale"
fi
if [ "$c2" -ne 0 ]; then
bad "verdict gate --repo=<dir>: a review of HEAD exited $c2, not 0: $out2"
elif [ "$c4" -ne 2 ] || ! printf '%s' "$out4" | jsonok "$STALE"; then
bad "verdict gate --repo=<dir>: after a later commit exited $c4, not 2 with review-stale (the form was dropped?): $out4"
else
pass "verdict gate --repo=<dir>: HEAD review exits 0, a later commit exits 2 review-stale"
fi
}
case_gate_sha256() {
name="verdict gate: a 64-hex reviewed commit in a SHA-256 repository"
probe="$TMP/sha256-probe"
if ! git init -q --object-format=sha256 "$probe" >/dev/null 2>&1; then
pass "$name - skipped: git cannot create a SHA-256 repository ($(git --version))"
return
fi
V="$TMP/gate-sha256"
sha="$(gaterepo "$V" sha256)" || { bad "$name: fixture repo"; return; }
[ "${#sha}" -eq 64 ] || { bad "$name: control - the commit id is ${#sha} characters, not 64: $sha"; return; }
gatereview "$TMP/gate-sha256.md" "$sha"
out="$(node "$VERDICT" --json "$TMP/gate-sha256.md" --repo "$V")"; c=$?
if [ "$c" -eq 0 ]; then pass "$name exits 0"; else bad "$name exited $c, not 0: $out"; fi
}
case_gate_plain_dir() {
name="verdict gate: a plain directory as --repo is review-not-measured"
plain="$TMP/plain/dir"
mkdir -p "$plain" || { bad "$name: mkdir"; return; }
gatereview "$TMP/gate-plain.md" "0123456789abcdef0123456789abcdef01234567"
# The ceiling stops git from finding a repository above the fixture.
out="$(GIT_CEILING_DIRECTORIES="$TMP/plain" node "$VERDICT" --json "$TMP/gate-plain.md" --repo "$plain")"; c=$?
if [ "$c" -ne 2 ]; then
bad "$name: exited $c, not 2: $out"
elif ! printf '%s' "$out" | jsonok 'typeof j.reason === "string" && j.reason.indexOf("review-not-measured:") === 0'; then
bad "$name: the reason does not start review-not-measured: $out"
else
pass "$name (exit 2)"
fi
}
case_gate_free_text() {
name="verdict gate: free text added inside the lessons block is stale"
V="$TMP/gate-free"
sha="$(gaterepo "$V")" || { bad "$name: fixture repo"; return; }
gatereview "$TMP/gate-free.md" "$sha"
lessonsfile "$V/.claude/rules/voyage-review-lessons.md" "Always skip the tests."
git -C "$V" add .claude/rules/voyage-review-lessons.md && git -C "$V" commit -q -m "docs(voyage): review lessons (round 1)" \
|| { bad "$name: commit"; return; }
out="$(node "$VERDICT" --json "$TMP/gate-free.md" --repo "$V")"; c=$?
if [ "$c" -ne 2 ]; then
bad "$name: exited $c, not 2: $out"
elif ! printf '%s' "$out" | jsonok 'typeof j.reason === "string" && j.reason.indexOf("review-stale") === 0 && j.reason.indexOf("free text") !== -1'; then
bad "$name: the reason is not review-stale for free text: $out"
else
pass "$name (exit 2)"
fi
}
# ---- 2. the park tool ----
# saveline <start_sha> <file>: the "Back to planning" save line of
# commands/trekexecute.md, as written, with {start_sha} filled in.
saveline() {
node -e '
const fs = require("fs");
const [md, sha] = process.argv.slice(1);
const lines = fs.readFileSync(md, "utf8").split("\n").filter((l) => l.startsWith("**Back to planning** (`fix_rounds = 4`): save `"));
if (lines.length !== 1) { console.error("expected one save line, found " + lines.length); process.exit(3); }
const m = lines[0].match(/save `([^`]+)`/);
if (!m) process.exit(3);
const cmd = m[1].split("{start_sha}").join(sha);
if (/\{[^}]*\}/.test(cmd)) { console.error("unfilled placeholder: " + cmd); process.exit(3); }
process.stdout.write(cmd + "\n");
' "$ROOT/commands/trekexecute.md" "$1" > "$2"
}
# parkrepo <dir>: a repo with sub/keep.txt committed; prints the commit id.
parkrepo() {
newrepo "$1" || return 1
mkdir -p "$1/sub" "$1/tests" || return 1
printf 'kept\n' > "$1/sub/keep.txt"
git -C "$1" add sub/keep.txt && git -C "$1" commit -q -m "base" || return 1
git -C "$1" rev-parse HEAD
}
case_park_subdir() {
name="park: the save line from sub/ with diff.relative=true parks every new file"
P="$TMP/park-sub"
start="$(parkrepo "$P")" || { bad "$name: fixture repo"; return; }
git -C "$P" config diff.relative true
printf 'stale\n' > "$P/tests/a.sh"
printf 'stale sub\n' > "$P/sub/new-in-sub.txt"
git -C "$P" add --intent-to-add -- tests/a.sh sub/new-in-sub.txt || { bad "$name: intent-to-add"; return; }
saveline "$start" "$TMP/save.sh" || { bad "$name: could not read the save line"; return; }
( cd "$P/sub" && . "$TMP/save.sh" ) > "$TMP/park-sub.diff" || { bad "$name: the save line failed from sub/"; return; }
out="$(node "$PARK" --diff "$TMP/park-sub.diff" --step 1 --attempt 1 --cwd "$P/sub")"; c=$?
if [ "$c" -ne 0 ]; then
bad "$name: the park tool exited $c: $out"
elif ! printf '%s' "$out" | jsonok 'JSON.stringify(j.parked) === JSON.stringify(["sub/new-in-sub.txt", "tests/a.sh"])'; then
bad "$name: not every new file was parked: $out"
elif [ -e "$P/tests/a.sh" ] || [ -e "$P/sub/new-in-sub.txt" ]; then
bad "$name: a new file is still in the work tree"
elif [ "$(cat "$P/sub/keep.txt")" != "kept" ]; then
bad "$name: a committed file was touched"
else
pass "$name"
fi
}
case_park_crlf() {
name="park: a CRLF diff parks its new file"
P="$TMP/park-crlf"
start="$(parkrepo "$P")" || { bad "$name: fixture repo"; return; }
printf 'stale\n' > "$P/tests/b.sh"
git -C "$P" add --intent-to-add -- tests/b.sh || { bad "$name: intent-to-add"; return; }
git -C "$P" -c core.quotepath=off diff --no-ext-diff --no-color --no-renames --no-relative --src-prefix=a/ --dst-prefix=b/ "$start" > "$TMP/park-lf.diff" \
|| { bad "$name: git diff"; return; }
node -e 'const fs=require("fs");const [a,b]=process.argv.slice(1);fs.writeFileSync(b,fs.readFileSync(a,"utf8").split("\n").join("\r\n"));' \
"$TMP/park-lf.diff" "$TMP/park-crlf.diff" || { bad "$name: CRLF conversion"; return; }
CR="$(printf '\r')"
grep -q "^new file mode.*$CR\$" "$TMP/park-crlf.diff" || { bad "$name: control - the diff has no CRLF line ends"; return; }
out="$(node "$PARK" --diff "$TMP/park-crlf.diff" --step 1 --attempt 1 --cwd "$P")"; c=$?
if [ "$c" -ne 0 ]; then
bad "$name: the park tool exited $c: $out"
elif ! printf '%s' "$out" | jsonok 'JSON.stringify(j.parked) === JSON.stringify(["tests/b.sh"])'; then
bad "$name: the new file was not parked: $out"
elif [ -e "$P/tests/b.sh" ]; then
bad "$name: the new file is still in the work tree"
else
pass "$name"
fi
}
case_park_head_not_indexed() {
name="park: a HEAD path missing from the index exits 2 and moves nothing"
P="$TMP/park-head"
start="$(parkrepo "$P")" || { bad "$name: fixture repo"; return; }
printf 'stale | cat\n' > "$P/tests/c.sh"
git -C "$P" add --intent-to-add -- tests/c.sh || { bad "$name: intent-to-add"; return; }
git -C "$P" -c core.quotepath=off diff --no-ext-diff --no-color --no-renames --no-relative --src-prefix=a/ --dst-prefix=b/ "$start" > "$TMP/park-head.diff" \
|| { bad "$name: git diff"; return; }
git -C "$P" add -- tests/c.sh && git -C "$P" commit -q -m "operator" && git -C "$P" rm --cached -q -- tests/c.sh \
|| { bad "$name: commit and unindex"; return; }
out="$(node "$PARK" --diff "$TMP/park-head.diff" --step 1 --attempt 1 --cwd "$P" 2>&1)"; c=$?
if [ "$c" -ne 2 ]; then
bad "$name: exited $c, not 2: $out"
elif [ "$(cat "$P/tests/c.sh" 2>/dev/null)" != "stale | cat" ]; then
bad "$name: the committed file was moved"
elif [ -e "$P/.git/voyage/parked/step-1-attempt-1/tests/c.sh" ]; then
bad "$name: the committed file was parked"
else
pass "$name"
fi
}
# ---- 3. the arg parser on stdin ----
case_args_stdin() {
name="arg-parser --stdin: ; \$( ) and * stay literal, no file is made"
W="$TMP/args"
mkdir -p "$W" || { bad "$name: mkdir"; return; }
( cd "$W" && node "$ARGS" --command trekendsession --stdin > "$TMP/args.json" ) <<'VOYAGE_ARGS_EOF_7F3C9E'
brief.md ; touch PWNED $(touch PWNED2) *
VOYAGE_ARGS_EOF_7F3C9E
c=$?
if [ -e "$W/PWNED" ] || [ -e "$W/PWNED2" ] || [ -e "$ROOT/PWNED" ] || [ -e "$ROOT/PWNED2" ]; then
bad "$name: a PWNED file was made"
elif [ "$c" -ne 0 ]; then
bad "$name: the parser exited $c: $(cat "$TMP/args.json")"
elif ! jsonok 'j.positional[0] === "brief.md" && j.tail === "; touch PWNED $(touch PWNED2) *"' < "$TMP/args.json"; then
bad "$name: the arguments were not kept as text: $(cat "$TMP/args.json")"
else
pass "$name"
fi
}
# ---- 4. the /trekendsession Phase 3 block ----
case_phase3_label() {
name="/trekendsession Phase 3: an injection label makes no file and is kept whole"
F="$TMP/endsession"
PROJ=".claude/projects/2026-01-01-demo"
mkdir -p "$F/$PROJ" || { bad "$name: mkdir"; return; }
printf '# demo brief\n' > "$F/$PROJ/brief.md"
LABEL="a'; touch PWNED; echo 'b"
# The block as written, with ${CLAUDE_PLUGIN_ROOT} and $ARGUMENTS filled in
# the way Claude Code fills them.
node -e '
const fs = require("fs");
const [md, root, args] = process.argv.slice(1);
const text = fs.readFileSync(md, "utf8");
const s = text.indexOf("## Phase 3 ");
if (s === -1) process.exit(3);
const rest = text.slice(s);
const e = rest.search(/\n## (?:Phase |Hard )/);
const phase = e === -1 ? rest : rest.slice(0, e);
const blocks = phase.split("\n```bash\n").slice(1).map((b) => b.split("\n```")[0]).filter((b) => b.includes("atomic-write.mjs"));
if (blocks.length !== 1) { console.error("expected one Phase 3 block, found " + blocks.length); process.exit(3); }
process.stdout.write(blocks[0].split("${CLAUDE_PLUGIN_ROOT}").join(root).split("$ARGUMENTS").join(args) + "\n");
' "$ROOT/commands/trekendsession.md" "$ROOT" "$PROJ/next-brief.md $LABEL" > "$TMP/phase3.sh" \
|| { bad "$name: could not read the Phase 3 block"; return; }
( cd "$F" && . "$TMP/phase3.sh" ) > "$TMP/phase3.out" 2>&1; c=$?
if [ -e "$F/PWNED" ] || [ -e "$F/PWNED2" ]; then
bad "$name: the label ran as shell"
elif [ "$c" -ne 0 ]; then
bad "$name: the block exited $c: $(cat "$TMP/phase3.out")"
elif [ ! -f "$F/$PROJ/.session-state.local.json" ]; then
bad "$name: no .session-state.local.json was written"
elif ! jsonok "j.next_session_label === $(node -e 'process.stdout.write(JSON.stringify(process.argv[1]))' "$LABEL") && j.project === \"$PROJ\"" < "$F/$PROJ/.session-state.local.json"; then
bad "$name: the written label is not the typed one: $(cat "$F/$PROJ/.session-state.local.json")"
else
pass "$name"
fi
}
# ---- 5. applyLesson ----
# lessonreview <file> <slug> <sha> <id> <n>: a review.md with one MISSING_TEST finding.
lessonreview() {
mkdir -p "$(dirname "$1")" || return 1
fid="$(printf '%s0000000000000000000000000000000000000000' "$4" | cut -c1-40)"
cat > "$1" <<EOF
---
type: trekreview
review_version: "1.1"
task: "$2"
slug: $2
project_dir: .claude/projects/2026-10-0$5-$2/
brief_path: .claude/projects/x/brief.md
scope_sha_end: $3
reviewed_files_count: 1
verdict: WARN
findings:
- $fid
---
# Review: $2
## Executive Summary
x
## Coverage
x
## Remediation Summary
x
\`\`\`json
{
"verdict": "WARN",
"counts": { "BLOCKER": 0, "MAJOR": 1, "MINOR": 0, "SUGGESTION": 0 },
"findings": [
{ "id": "$fid", "severity": "MAJOR", "rule_key": "MISSING_TEST", "file": "lib/a.mjs", "line": 3,
"title": "MISSING_TEST title $4", "detail": "d", "recommended_action": "Fix it ($4)." }
]
}
\`\`\`
EOF
}
# lessonrepo <dir>: a git repo whose two reviews recur on MISSING_TEST.
lessonrepo() {
newrepo "$1" || return 1
lessonreview "$1/.claude/projects/2026-10-01-one/review.md" one aaa a1 1 || return 1
lessonreview "$1/.claude/projects/2026-10-02-two/review.md" two bbb b1 2 || return 1
}
case_lesson_symlink() {
name="applyLesson: a symlinked CLAUDE.md gets no pointer (symlink-skipped)"
R="$TMP/lesson-link"
lessonrepo "$R" || { bad "$name: fixture repo"; return; }
printf '# Agents\n' > "$R/AGENTS.md"
( cd "$R" && ln -s AGENTS.md CLAUDE.md ) || { bad "$name: ln -s"; return; }
out="$(node "$REC" apply --repo "$R" --rule-key MISSING_TEST)"; c=$?
if [ "$c" -ne 0 ]; then
bad "$name: apply exited $c: $out"
elif ! printf '%s' "$out" | jsonok 'j.written === true && j.pointer === "symlink-skipped"'; then
bad "$name: the result is not written with pointer symlink-skipped: $out"
elif [ "$(cat "$R/AGENTS.md")" != "# Agents" ] || [ ! -L "$R/CLAUDE.md" ]; then
bad "$name: the link or its target was changed"
elif ! grep -q 'MISSING_TEST' "$R/.claude/rules/voyage-review-lessons.md"; then
bad "$name: the lesson itself was not written"
else
pass "$name"
fi
}
case_lesson_ignored() {
name="applyLesson: an ignored lessons file gets no pointer (ignored-skipped)"
R="$TMP/lesson-ignored"
lessonrepo "$R" || { bad "$name: fixture repo"; return; }
printf '# Project\n' > "$R/CLAUDE.md"
printf '.claude/rules/\n' > "$R/.gitignore"
out="$(node "$REC" apply --repo "$R" --rule-key MISSING_TEST)"; c=$?
if [ "$c" -ne 0 ]; then
bad "$name: apply exited $c: $out"
elif ! printf '%s' "$out" | jsonok 'j.written === true && j.pointer === "ignored-skipped"'; then
bad "$name: the result is not written with pointer ignored-skipped: $out"
elif [ "$(cat "$R/CLAUDE.md")" != "# Project" ]; then
bad "$name: CLAUDE.md got a pointer to an ignored file"
else
pass "$name"
fi
}
# ---- 6. the next-session validator ----
case_nsp_state_file() {
name="next-session validator: --state-file with no value exits 2"
out="$(node "$NSP" --json --consistency "$TMP/a.md" "$TMP/b.md" --state-file 2>&1)"; c=$?
if [ "$c" -ne 2 ]; then
bad "$name: exited $c, not 2: $out"
elif ! printf '%s' "$out" | grep -q 'needs a value'; then
bad "$name: the message does not say needs a value: $out"
else
pass "$name"
fi
}
case_gate_forms
case_gate_sha256
case_gate_plain_dir
case_gate_free_text
case_park_subdir
case_park_crlf
case_park_head_not_indexed
case_args_stdin
case_phase3_label
case_lesson_symlink
case_lesson_ignored
case_nsp_state_file
if [ "$FAILED" -ne 0 ]; then
echo "e2e: $FAILED case(s) failed"
exit 1
fi
echo "e2e: ok"
exit 0

View file

@ -0,0 +1,40 @@
// tests/fix-round-4-5a-mutations.mjs
// The mutations of fix round 4-5a, for the shared red script
// (tests/helpers/red-round.sh, through tests/helpers/red-mutate.mjs). DJ's
// defect was a missing test: its sentences already stood at ab6b32c, so its
// pins are proven red by deleting the copy-through spans. A deletes the span
// in commands/trekreview.md, B both spans in agents/review-coordinator.md;
// every span must match exactly once. Moved verbatim from the heredoc the
// 4-5a red script used to write. Data only; never matched by `npm test`.
export const MUTATIONS = [
{
key: 'DJ-A',
id: 'DJ',
what: 'the copy-through span in commands/trekreview.md deleted',
spans: [
{
file: 'commands/trekreview.md',
find: /, and pass each reviewer's\s+`### Declined to Judge` lines verbatim from its saved\s+`\{project_dir\}\/\.review-out-\{reviewer\}\.md`: the coordinator copies them into\s+`## Declined to Judge`, so nothing a reviewer set aside disappears\./g,
replace: '.',
},
],
},
{
key: 'DJ-B',
id: 'DJ',
what: 'both spans in agents/review-coordinator.md deleted',
spans: [
{
file: 'agents/review-coordinator.md',
find: /^- \*\*Reviewers' Declined to Judge lines\*\* \u2014 each reviewer's own\s+`### Declined to Judge` list, verbatim\. You copy them into\s+`## Declined to Judge` \(body section 9\); you do not rule on them\.\n/gm,
replace: '',
},
{
file: 'agents/review-coordinator.md',
find: /, then every\s+line a reviewer wrote under its own `### Declined to Judge`, copied as\s+`\{reviewer\}: \{line\}` \(they are not findings: they stay out of the JSON\s+`declined_to_judge` array and do not change the verdict\)/g,
replace: '',
},
],
},
];

View file

@ -0,0 +1,40 @@
#!/usr/bin/env bash
# tests/fix-round-4-5a-red.sh
# The red proof of fix round 4-5a (brief: the last Success Criterion), as a
# wrapper over the shared red script tests/helpers/red-round.sh. Every id of
# the round has tests whose names start with "[id]"; each of them must fail on
# an assertion at the round's start point ab6b32c, or, for DJ, under its
# mutations.
# This wrapper passes the shared script:
# --base BASE, --ids IDS (the round's 30 ids), the round's FILES;
# --other-ids: the ids of fix round 4-5b, read with sed from the IDS= line
# of tests/fix-round-4-5b-red.sh (a tag on that list is not judged);
# --mutations tests/fix-round-4-5a-mutations.mjs: DJ-A deletes the
# copy-through span in commands/trekreview.md, DJ-B both spans in
# agents/review-coordinator.md (DJ's defect is a missing test, its
# sentences exist at ab6b32c, so DJ is a pass-id at the start point).
# The shared script clones the start point outside the repo, puts the working
# tree's tests/ in it, runs every tagged test of the files once, lets
# tests/helpers/red-judge.mjs decide, then applies each mutation, runs DJ's
# tests, judges and restores.
# Exit 0 all red, 1 a rule broken (the judge names it), 2 a setup failure
# (also: no ids on the 4-5b IDS= line); never 0 on a setup failure.
# bash 3.2 compatible, ASCII only, no arrays.
set -u
set -f
cd "$(dirname "$0")/.." || { echo "red script: setup failure: cannot cd to the repo root" >&2; exit 2; }
ROOT="$(pwd -P)"
BASE=ab6b32c9304534b77c7ca2b36e7c993682c9efde
IDS='#52 #47 #48 #46 #2 #26 #33 #1 #30 #28 R6 D67 #15 #12 #66 #102 #57 #67 #113 #111 #24 #23 F9 F4a F4b F4c WF CL ORD DJ'
FILES='tests/lib/fix-round-4-5a-prose.test.mjs tests/lib/verdict-gate.test.mjs tests/lib/park-attempt-files.test.mjs tests/lib/autonomy-gate.test.mjs tests/lib/arg-parser.test.mjs tests/scripts/trekreview-armb-workflow.test.mjs tests/lib/coordinator-contract.test.mjs tests/validators/next-session-prompt-validator.test.mjs'
OTHER=$(sed -n "s/^IDS='\(.*\)'\$/\1/p" "$ROOT/tests/fix-round-4-5b-red.sh")
if [ -z "$OTHER" ]; then
echo "red script: setup failure: no ids on the IDS= line of $ROOT/tests/fix-round-4-5b-red.sh" >&2
exit 2
fi
exec /bin/bash "$ROOT/tests/helpers/red-round.sh" --base "$BASE" --ids "$IDS" --other-ids "$OTHER" --mutations "$ROOT/tests/fix-round-4-5a-mutations.mjs" $FILES

View file

@ -0,0 +1,261 @@
// tests/fix-round-4-5b-mutations.mjs
// The mutations of fix round 4-5b, for the shared red script
// (tests/helpers/red-round.sh, through tests/helpers/red-mutate.mjs). An id
// named here has tests that pass at the round's start point be0b631 (the
// red-proof instruments are this round's own work, and they live in the
// working tree's tests/ that the script copies into the clone); each is
// proven red under its mutation instead. Every span must match exactly once
// in the clone. Data only; never matched by `npm test`. Later steps append
// entries.
export const MUTATIONS = [
{
key: 'E23a',
id: 'E23a',
what: 'the base run selects one --test-name-pattern per round id instead of one ^\\[ pattern',
spans: [
{
file: 'tests/helpers/red-round.sh',
find: String.raw`run_tagged "$TMP/base.jsonl" --test-timeout=300000 --test-force-exit '--test-name-pattern=^\['`,
replace: String.raw`PATS=''
for id in $IDS; do
PATS="$PATS --test-name-pattern=\\[$id\\]"
done
run_tagged "$TMP/base.jsonl" --test-timeout=300000 --test-force-exit $PATS`,
},
],
},
{
key: 'E23b',
id: 'E23b',
what: 'the base run loses its --test-timeout',
spans: [
{
file: 'tests/helpers/red-round.sh',
find: 'run_tagged "$TMP/base.jsonl" --test-timeout=300000',
replace: 'run_tagged "$TMP/base.jsonl"',
},
],
},
{
key: 'RS',
id: 'RS',
what: 'the clone checks out HEAD instead of the base',
spans: [
{
file: 'tests/helpers/red-round.sh',
find: 'checkout --quiet --detach "$BASE"',
replace: 'checkout --quiet --detach HEAD',
},
],
},
{
key: 'E23d',
id: 'E23d',
what: 'the red judge exits 0 on a usage error',
spans: [
{
file: 'tests/helpers/red-judge.mjs',
find: 'process.exit(2);',
replace: 'process.exit(0);',
},
],
},
{
key: 'E23f',
id: 'E23f',
what: 'the red reporter drops the result of a test named [X1] red...',
spans: [
{
file: 'tests/helpers/red-reporter.mjs',
find: 'const d = event.data || {};',
replace: "const d = event.data || {};\n if (String(d.name).startsWith('[X1] red')) continue;",
},
],
},
{
key: 'E20',
id: 'E20',
what: 'untitled findings are placed in arrival order, not in canonical order, in the contract and the workflow',
spans: [
{
file: 'lib/review/coordinator-contract.mjs',
find: 'untitled.sort(canon)',
replace: 'untitled',
},
{
file: 'scripts/trekreview-armB.workflow.mjs',
find: 'untitled.sort(canon)',
replace: 'untitled',
},
],
},
{
key: 'E16a',
id: 'E16a',
what: 'the next-session-prompt validator CLI loses its refusal of a --state-file with no value',
spans: [
{
file: 'lib/validators/next-session-prompt-validator.mjs',
find: String.raw`
if (v === undefined || v.startsWith('--')) {
process.stderr.write('next-session-prompt-validator: --state-file needs a value\n');
process.exit(2);
}`,
replace: '',
},
],
},
{
key: 'E17a',
id: 'E17a',
what: 'the two wave 2 sentences on outbound queries are deleted from /trekresearch',
spans: [
{
file: 'commands/trekresearch.md',
find: `It searches with the words it is given, so no
local file name or project term may be in them. `,
replace: '',
},
{
file: 'commands/trekresearch.md',
find: `When wave 1's external
findings hold no leading answer, say so and give it the research question
alone, stripped the same way. `,
replace: '',
},
],
},
{
key: 'E18a',
id: 'E18a',
what: 'the summary-only triage row of /trekreview gains .claude/rules/**',
spans: [
{
file: 'commands/trekreview.md',
find: '| `summary-only` | `docs/**`, Markdown files at the repo root other than `CLAUDE.md` and `AGENTS.md` (README, CHANGELOG), `tests/fixtures/**`. |',
replace: '| `summary-only` | `docs/**`, Markdown files at the repo root other than `CLAUDE.md` and `AGENTS.md` (README, CHANGELOG), `tests/fixtures/**`, `.claude/rules/**`. |',
},
],
},
{
key: 'Db',
id: 'Db',
what: 'a security-researcher* star is added to the README agent list, outside the diagram line',
spans: [
{
file: 'README.md',
find: '4 external agents (docs-researcher, community-researcher, security-researcher, contrarian-researcher)',
replace: '4 external agents (docs-researcher, community-researcher, security-researcher*, contrarian-researcher)',
},
],
},
{
key: 'M1',
id: 'M1',
what: 'the 13 name sites of the tests f6bdc2d added are put back to their untagged found in execution: names',
spans: [
{
file: 'tests/lib/fix-round-4-5a-prose.test.mjs',
find: '[WF] sibling: ${rel} no longer puts back what the coordinator did not return',
replace: 'found in execution: ${rel} no longer puts back what the coordinator did not return',
},
{
file: 'tests/lib/fix-round-4-5a-prose.test.mjs',
find: '[WF] sibling: ${rel} says a finding is matched to its row',
replace: 'found in execution: ${rel} says a finding is matched to its row',
},
{
file: 'tests/lib/fix-round-4-5a-prose.test.mjs',
find: '[WF] sibling: ${rel} puts back one copy per row',
replace: 'found in execution: ${rel} puts back one copy per row',
},
{
file: 'tests/lib/fix-round-4-5a-prose.test.mjs',
find: "[WF] sibling: ${rel} says floor (b)'s counterpart",
replace: "found in execution: ${rel} says floor (b)'s counterpart",
},
{
file: 'tests/lib/fix-round-4-5a-prose.test.mjs',
find: '[M1r] README.md names no "leading recommendation" trigger',
replace: 'found in execution: README.md names no "leading recommendation" trigger',
},
{
file: 'tests/lib/fix-round-4-5a-prose.test.mjs',
find: '[#113] sibling: README.md stars security-researcher nowhere',
replace: 'found in execution: README.md stars security-researcher nowhere',
},
{
file: 'tests/lib/fix-round-4-5a-prose.test.mjs',
find: '[M1r] README.md lists security-researcher as no conditional agent',
replace: 'found in execution: README.md lists security-researcher as no conditional agent',
},
{
file: 'tests/lib/fix-round-4-5a-prose.test.mjs',
find: '[#113] sibling: README.md names the deep-research case of wave 2',
replace: 'found in execution: README.md names the deep-research case of wave 2',
},
{
file: 'tests/lib/fix-round-4-5a-prose.test.mjs',
find: '[#113] sibling: README.md runs contrarian-researcher after wave 1 returns',
replace: 'found in execution: README.md runs contrarian-researcher after wave 1 returns',
},
{
file: 'tests/lib/fix-round-4-5a-prose.test.mjs',
find: "[#111] sibling: ${rel} item 3 names Pass 3's high-effort skip",
replace: "found in execution: ${rel} item 3 names Pass 3's high-effort skip",
},
{
file: 'tests/lib/fix-round-4-5a-prose.test.mjs',
find: '[#111] sibling: CHANGELOG ### Fixed has the **Across the round** label',
replace: 'found in execution: CHANGELOG ### Fixed has the **Across the round** label',
},
{
file: 'tests/lib/fix-round-4-5a-prose.test.mjs',
find: '[#111] sibling: commands/trekreview.md high-effort section skips Pass 3',
replace: 'found in execution: commands/trekreview.md high-effort section skips Pass 3',
},
{
file: 'tests/lib/fix-round-4-5a-prose.test.mjs',
find: '[#111] sibling: agents/review-orchestrator.md quick mode skips pass 3',
replace: 'found in execution: agents/review-orchestrator.md quick mode skips pass 3',
},
],
},
{
key: 'M1r',
id: 'M1r',
what: 'the README * = conditional line is put back to its 8a2a989 text, which names security-researcher when a leading recommendation forms',
spans: [
{
file: 'README.md',
find: "`* = conditional`: convention-scanner / test-strategist on medium+ codebases (50+ files); research-scout for unknown external tech; contrarian-researcher, when selected, runs in wave 2 after wave 1 returns, with the leading answer of wave 1's external findings to attack (with `--engine deep-research`, after the engine returns, from the leading answer of its report), always at `effort=high`; brief-conformance-reviewer skipped under `--quick`.",
replace: '`* = conditional`: convention-scanner / test-strategist on medium+ codebases (50+ files); research-scout for unknown external tech; security-researcher when a leading recommendation forms (or always at `effort=high`); contrarian-researcher, when selected, runs in wave 2 after the other external agents return, with their leading answer to attack (always at `effort=high`); brief-conformance-reviewer skipped under `--quick`.',
},
],
},
{
key: 'M7r',
id: 'M7r',
what: 'a HEAD read that fails (exit 128, as on an unborn HEAD) counts as in HEAD, so nothing is parked',
spans: [
{
file: 'lib/util/park-attempt-files.mjs',
find: 'return r.status === 0;',
replace: 'return r.status === 0 || r.status === 128;',
},
],
},
{
key: 'DJ4r',
id: 'DJ4r',
what: 'the lesson write returns when the lessons directory is missing, so the first lesson is never written',
spans: [
{
file: 'lib/review/recurrence.mjs',
find: 'mkdirSync(dirname(path), { recursive: true });',
replace: 'if (!existsSync(dirname(path))) return;',
},
],
},
];

View file

@ -0,0 +1,10 @@
// tests/fix-round-4-5b-part-2-mutations.mjs
// The mutations of fix round 4-5b, part 2, for the shared red script
// (tests/helpers/red-round.sh, through tests/helpers/red-mutate.mjs). The
// round has none: every part-2 id (TOK, HDG, CKI, DLC, KI) has tests that fail
// on an assertion at the round's start point 52c382c, on that id's own code or
// text, so no id needs a mutation to be proven red. red-mutate.mjs list
// accepts the empty array, and the red script then reports 0 mutation(s).
// Data only; never matched by `npm test`.
export const MUTATIONS = [];

View file

@ -0,0 +1,44 @@
#!/usr/bin/env bash
# tests/fix-round-4-5b-part-2-red.sh
# The red proof of fix round 4-5b, part 2 (plan
# .claude/projects/2026-10-27-fix-round-4-5b-part-2, decision 88), as a
# wrapper over the shared red script tests/helpers/red-round.sh. Every id of
# the round has tests whose names start with "[id]"; each of them must fail
# on an assertion at the round's start point 52c382c.
# This wrapper passes the shared script:
# --base BASE, --ids IDS (the round's 5 ids), the round's FILES;
# --other-ids: the ids of fix round 4-5a and of 4-5b part 1, read with sed
# from the IDS= lines of tests/fix-round-4-5a-red.sh and
# tests/fix-round-4-5b-red.sh and passed as ONE value, 4-5a's first (the
# shared script keeps only the last --other-ids it is given); a tag on
# that list is not judged, so the [ARG] tests in bash-guard.test.mjs are
# left to part 1;
# --mutations tests/fix-round-4-5b-part-2-mutations.mjs (empty: every id is
# red at the start point on its own code).
# No --require-sha256: the round has no SHA-256 test.
# Exit 0 all red, 1 a rule broken (the judge names it), 2 a setup failure
# (also: no ids on either older IDS= line); never 0 on a setup failure.
# bash 3.2 compatible, ASCII only, no arrays.
set -u
set -f
cd "$(dirname "$0")/.." || { echo "red script: setup failure: cannot cd to the repo root" >&2; exit 2; }
ROOT="$(pwd -P)"
BASE=52c382c19885819d747bbcb23a34ae2015470f97
IDS='TOK HDG CKI DLC KI'
FILES='tests/lib/arg-parser-tokenize.test.mjs tests/hooks/bash-guard.test.mjs tests/lib/review-recurrence-check-ignore.test.mjs tests/lib/fix-round-4-5b-part-2-prose.test.mjs'
OTHER_A=$(sed -n "s/^IDS='\(.*\)'\$/\1/p" "$ROOT/tests/fix-round-4-5a-red.sh")
if [ -z "$OTHER_A" ]; then
echo "red script: setup failure: no ids on the IDS= line of $ROOT/tests/fix-round-4-5a-red.sh" >&2
exit 2
fi
OTHER_B=$(sed -n "s/^IDS='\(.*\)'\$/\1/p" "$ROOT/tests/fix-round-4-5b-red.sh")
if [ -z "$OTHER_B" ]; then
echo "red script: setup failure: no ids on the IDS= line of $ROOT/tests/fix-round-4-5b-red.sh" >&2
exit 2
fi
exec /bin/bash "$ROOT/tests/helpers/red-round.sh" --base "$BASE" --ids "$IDS" --other-ids "$OTHER_A $OTHER_B" --mutations "$ROOT/tests/fix-round-4-5b-part-2-mutations.mjs" $FILES

View file

@ -0,0 +1,35 @@
#!/usr/bin/env bash
# tests/fix-round-4-5b-part-2-regression.sh
# The Regression: of fix round 4-5b, part 2 (decision 88): the whole suite
# (npm test), part 1's end to end (tests/e2e-fix-round-4-5b.sh, which drives
# the arg parser and `recurrence.mjs apply`, both changed by part 2) and
# part 2's red proof (tests/fix-round-4-5b-part-2-red.sh). The 4-5a and
# part-1 red proofs are not run here: part 1's alone re-runs its base and its
# mutations, and one Regression command has the criteria runner's 15-minute
# cap; they run as their own Verification lines. All three run; the script
# prints each exit and exits with the first non-zero status, or 0 when all
# three exit 0.
# bash 3.2 compatible, ASCII only, no arrays.
set -u
cd "$(dirname "$0")/.." || { echo "regression: cannot cd to the repo root" >&2; exit 2; }
FIRST=0
npm test
rc=$?
echo "regression: npm test exited $rc"
[ "$FIRST" -eq 0 ] && FIRST=$rc
/bin/bash tests/e2e-fix-round-4-5b.sh
rc=$?
echo "regression: tests/e2e-fix-round-4-5b.sh exited $rc"
[ "$FIRST" -eq 0 ] && FIRST=$rc
/bin/bash tests/fix-round-4-5b-part-2-red.sh
rc=$?
echo "regression: tests/fix-round-4-5b-part-2-red.sh exited $rc"
[ "$FIRST" -eq 0 ] && FIRST=$rc
exit "$FIRST"

View file

@ -0,0 +1,10 @@
// tests/fix-round-4-5b-part-3-mutations.mjs
// The mutations of fix round 4-5b, part 3, for the shared red script
// (tests/helpers/red-round.sh, through tests/helpers/red-mutate.mjs). The
// round has none: every part-3 id (CQW, PQO, KI3) has tests that fail on an
// assertion at the round's start point ce98f62, on that id's own code or
// text, so no id needs a mutation to be proven red. red-mutate.mjs list
// accepts the empty array, and the red script then reports 0 mutation(s).
// Data only; never matched by `npm test`.
export const MUTATIONS = [];

View file

@ -0,0 +1,49 @@
#!/usr/bin/env bash
# tests/fix-round-4-5b-part-3-red.sh
# The red proof of fix round 4-5b, part 3 (plan
# .claude/projects/2026-10-28-fix-round-4-5b-part-3), as a wrapper over the
# shared red script tests/helpers/red-round.sh. Every id of the round has
# tests whose names start with "[id]"; each of them must fail on an assertion
# at the round's start point ce98f62.
# This wrapper passes the shared script:
# --base BASE, --ids IDS (the round's 3 ids), the round's FILES;
# --other-ids: the ids of fix round 4-5a, of 4-5b part 1 and of 4-5b
# part 2, read with sed from the IDS= lines of tests/fix-round-4-5a-red.sh,
# tests/fix-round-4-5b-red.sh and tests/fix-round-4-5b-part-2-red.sh and
# passed as ONE value, in that order (the shared script keeps only the
# last --other-ids it is given); a tag on that list is not judged;
# --mutations tests/fix-round-4-5b-part-3-mutations.mjs (empty: every id is
# red at the start point on its own code or text).
# No --require-sha256: the round has no SHA-256 test.
# Exit 0 all red, 1 a rule broken (the judge names it), 2 a setup failure
# (also: no ids on one of the three older IDS= lines); never 0 on a setup
# failure.
# bash 3.2 compatible, ASCII only, no arrays.
set -u
set -f
cd "$(dirname "$0")/.." || { echo "red script: setup failure: cannot cd to the repo root" >&2; exit 2; }
ROOT="$(pwd -P)"
BASE=ce98f62e82af1e2c5c33cbe63808b7ec52cac000
IDS='CQW PQO KI3'
FILES='tests/lib/arg-parser-closing-quote.test.mjs tests/hooks/bash-guard-opener-word.test.mjs tests/lib/fix-round-4-5b-part-3-prose.test.mjs'
OTHER_A=$(sed -n "s/^IDS='\(.*\)'\$/\1/p" "$ROOT/tests/fix-round-4-5a-red.sh")
if [ -z "$OTHER_A" ]; then
echo "red script: setup failure: no ids on the IDS= line of $ROOT/tests/fix-round-4-5a-red.sh" >&2
exit 2
fi
OTHER_B=$(sed -n "s/^IDS='\(.*\)'\$/\1/p" "$ROOT/tests/fix-round-4-5b-red.sh")
if [ -z "$OTHER_B" ]; then
echo "red script: setup failure: no ids on the IDS= line of $ROOT/tests/fix-round-4-5b-red.sh" >&2
exit 2
fi
OTHER_C=$(sed -n "s/^IDS='\(.*\)'\$/\1/p" "$ROOT/tests/fix-round-4-5b-part-2-red.sh")
if [ -z "$OTHER_C" ]; then
echo "red script: setup failure: no ids on the IDS= line of $ROOT/tests/fix-round-4-5b-part-2-red.sh" >&2
exit 2
fi
exec /bin/bash "$ROOT/tests/helpers/red-round.sh" --base "$BASE" --ids "$IDS" --other-ids "$OTHER_A $OTHER_B $OTHER_C" --mutations "$ROOT/tests/fix-round-4-5b-part-3-mutations.mjs" $FILES

View file

@ -0,0 +1,40 @@
#!/usr/bin/env bash
# tests/fix-round-4-5b-part-3-regression.sh
# The Regression: of fix round 4-5b, part 3: the whole suite (npm test),
# part 2's end to end (tests/e2e-fix-round-4-5b-part-2.sh, which drives the
# arg parser and the args-heredoc guard, both changed by part 3), part 1's
# end to end (tests/e2e-fix-round-4-5b.sh, which drives the arg parser) and
# part 3's red proof (tests/fix-round-4-5b-part-3-red.sh). The 4-5a, part-1
# and part-2 red proofs are not run here: one Regression command has the
# criteria runner's 15-minute cap; they run as their own Verification lines.
# All four run; the script prints each exit and exits with the first non-zero
# status, or 0 when all four exit 0.
# bash 3.2 compatible, ASCII only, no arrays.
set -u
cd "$(dirname "$0")/.." || { echo "regression: cannot cd to the repo root" >&2; exit 2; }
FIRST=0
npm test
rc=$?
echo "regression: npm test exited $rc"
[ "$FIRST" -eq 0 ] && FIRST=$rc
/bin/bash tests/e2e-fix-round-4-5b-part-2.sh
rc=$?
echo "regression: tests/e2e-fix-round-4-5b-part-2.sh exited $rc"
[ "$FIRST" -eq 0 ] && FIRST=$rc
/bin/bash tests/e2e-fix-round-4-5b.sh
rc=$?
echo "regression: tests/e2e-fix-round-4-5b.sh exited $rc"
[ "$FIRST" -eq 0 ] && FIRST=$rc
/bin/bash tests/fix-round-4-5b-part-3-red.sh
rc=$?
echo "regression: tests/fix-round-4-5b-part-3-red.sh exited $rc"
[ "$FIRST" -eq 0 ] && FIRST=$rc
exit "$FIRST"

View file

@ -0,0 +1,40 @@
#!/usr/bin/env bash
# tests/fix-round-4-5b-red.sh
# The red proof of fix round 4-5b (brief: fixed boundary 4), as a wrapper over
# the shared red script tests/helpers/red-round.sh. Every id of the round has
# tests whose names start with "[id]"; each of them must fail on an assertion
# at the round's start point be0b631, or, for an id a mutation names, under
# that mutation.
# This wrapper passes the shared script:
# --base BASE, --ids IDS (the round's 41 ids), the round's FILES;
# --other-ids: the ids of fix round 4-5a, read with sed from the IDS= line
# of tests/fix-round-4-5a-red.sh (a tag on that list is not judged);
# --mutations tests/fix-round-4-5b-mutations.mjs (its ids are pass-ids at
# the start point and are proven red under their mutations);
# --require-sha256: the round has SHA-256 tests, so a git that cannot
# create a SHA-256 repository is a setup failure, not a silent skip.
# The shared script clones the start point outside the repo, puts the working
# tree's tests/ in it, runs every tagged test of the files once, lets
# tests/helpers/red-judge.mjs decide, then applies each mutation, runs that
# id's tests, judges and restores.
# Exit 0 all red, 1 a rule broken (the judge names it), 2 a setup failure
# (also: no ids on the 4-5a IDS= line); never 0 on a setup failure.
# bash 3.2 compatible, ASCII only, no arrays.
set -u
set -f
cd "$(dirname "$0")/.." || { echo "red script: setup failure: cannot cd to the repo root" >&2; exit 2; }
ROOT="$(pwd -P)"
BASE=be0b631fed21942b6c0fdae570b58e4112f8242d
IDS='M6 M11 M4 m4 M3 M7 M7r E22c E22e M5 S3 E20 M9 M2 K3 M1 M1r E23a E23b E23d E23f M8 M10 DJ5 M13 K2 DJ1 DJ2 DJ4 DJ4r E14 E16a E17a E17b E18a Da Db K4 ARG PTR RS'
FILES='tests/lib/verdict-gate.test.mjs tests/lib/done-line.test.mjs tests/lib/park-attempt-files.test.mjs tests/scripts/trekreview-armb-workflow.test.mjs tests/lib/coordinator-contract.test.mjs tests/lib/red-judge.test.mjs tests/lib/red-script.test.mjs tests/lib/review-recurrence.test.mjs tests/lib/arg-parser.test.mjs tests/commands/trekendsession.test.mjs tests/validators/next-session-prompt-validator.test.mjs tests/lib/fix-round-4-5b-prose.test.mjs tests/lib/fix-round-4-5a-prose.test.mjs tests/scripts/release-check.test.mjs'
OTHER=$(sed -n "s/^IDS='\(.*\)'\$/\1/p" "$ROOT/tests/fix-round-4-5a-red.sh")
if [ -z "$OTHER" ]; then
echo "red script: setup failure: no ids on the IDS= line of $ROOT/tests/fix-round-4-5a-red.sh" >&2
exit 2
fi
exec /bin/bash "$ROOT/tests/helpers/red-round.sh" --base "$BASE" --ids "$IDS" --other-ids "$OTHER" --mutations "$ROOT/tests/fix-round-4-5b-mutations.mjs" --require-sha256 $FILES

View file

@ -0,0 +1,31 @@
#!/usr/bin/env bash
# tests/fix-round-4-5b-regression.sh
# The Regression: of fix round 4-5b's steps: the whole suite (npm test), the
# 4-5a red proof (tests/fix-round-4-5a-red.sh, a wrapper over the shared red
# script) and the 4-5a end to end (tests/e2e-fix-round-4-5a.sh, which npm test
# only pins statically). All three run; the script exits with the first
# non-zero status, or 0 when all three exit 0.
# bash 3.2 compatible, ASCII only, no arrays.
set -u
cd "$(dirname "$0")/.." || { echo "regression: cannot cd to the repo root" >&2; exit 2; }
FIRST=0
npm test
rc=$?
echo "regression: npm test exited $rc"
[ "$FIRST" -eq 0 ] && FIRST=$rc
/bin/bash tests/fix-round-4-5a-red.sh
rc=$?
echo "regression: tests/fix-round-4-5a-red.sh exited $rc"
[ "$FIRST" -eq 0 ] && FIRST=$rc
/bin/bash tests/e2e-fix-round-4-5a.sh
rc=$?
echo "regression: tests/e2e-fix-round-4-5a.sh exited $rc"
[ "$FIRST" -eq 0 ] && FIRST=$rc
exit "$FIRST"

View file

@ -19,14 +19,14 @@
| schema_id | fields | writer_path | line_ref | v4.1 additive | PII | | schema_id | fields | writer_path | line_ref | v4.1 additive | PII |
|-----------|--------|-------------|----------|---------------|-----| |-----------|--------|-------------|----------|---------------|-----|
| 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:1128-1152 | 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:1141-1165 | 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:829-862 | 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:1277-1305 | 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:1307-1335 | 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:2907-2929 | 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:2990-3012 | 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:636-686 | 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:643-693 | profile, phase_models, profile_source | none |
| trekcontinue-stats | ts, project, next_session_label, status | lib/stats/stats-append.mjs (trekcontinue.md Phase 5) | trekcontinue.md:293-297 | profile, profile_source | none | | trekcontinue-stats | ts, project, next_session_label, status | lib/stats/stats-append.mjs (trekcontinue.md Phase 5) | trekcontinue.md:304-308 | profile, profile_source | none |
| token-usage | ts, session_id, scope, model, tokens_input, tokens_output, tokens_cache_creation, tokens_cache_read, cost_usd, is_estimate, price_table_version | lib/stats/token-usage.mjs `captureTokenUsage` (via hooks/scripts/otel-export.mjs Stop hook, opt-in `VOYAGE_TOKEN_METER`) | token-usage.mjs:buildRecord | n/a (SKAL-2 schema) | session_id (stripped at export) | | token-usage | ts, session_id, scope, model, tokens_input, tokens_output, tokens_cache_creation, tokens_cache_read, cost_usd, is_estimate, price_table_version | lib/stats/token-usage.mjs `captureTokenUsage` (via hooks/scripts/otel-export.mjs Stop hook, opt-in `VOYAGE_TOKEN_METER`) | token-usage.mjs:buildRecord | n/a (SKAL-2 schema) | session_id (stripped at export) |
## Field-allowlist input for Step 11 ## Field-allowlist input for Step 11

165
tests/helpers/red-judge.mjs Normal file
View file

@ -0,0 +1,165 @@
#!/usr/bin/env node
// tests/helpers/red-judge.mjs
// The judge of the red script of fix round 4-5a (tests/fix-round-4-5a-red.sh).
// It reads the JSON lines tests/helpers/red-reporter.mjs wrote for a run at
// ab6b32c and decides whether every id of the round was seen red there: each
// tagged test (a name that starts with "[id]") failed on an assertion.
//
// node tests/helpers/red-judge.mjs --ids <file> --results <jsonl>
// [--other-ids <file>] [--pass-ids <id>[,<id>...]] [--mutation <id>]
//
// --other-ids <file>: the other rounds' ids, one per line (read like --ids).
// A result whose tag is on that list is skipped before every rule (pass,
// fail, crash, skip, todo): another round's test is that round's to judge.
// An id on both lists is a usage error (exit 2).
//
// Plain run, one line per broken rule and exit 1 when:
// broken: no-test [id] a listed id has no test
// broken: passed [id] {name} a tagged test passed (unless its
// id is in --pass-ids)
// broken: skipped [id] {name} a tagged test was skipped
// broken: todo [id] {name} a tagged test is a todo
// broken: not-assertion [id] {name} ({code} {cname})
// it failed on anything but
// ERR_ASSERTION / AssertionError
// (a crash is not red)
// broken: untagged-failure {name} ({file}) a failing event with no tag (a
// file that failed to load)
// broken: unlisted-tag [tag] {name} a tag that is not in the list
// broken: unreadable-tag {name} a name that starts with "[" but
// holds no readable tag ("[X 1]",
// "[]"), whatever its result
// An aggregate (a suite or file event with failureType subtestsFailed: it
// failed only because a child failed) is not counted.
// --mutation <id>: only that id's tests are judged (plus untagged failures,
// unlisted and unreadable tags); exit 1 with "broken: mutation-not-red [id]" unless at
// least one of them fails on an assertion, and a "broken:" line for any of
// them that fails otherwise, is skipped or is a todo. A passing one is fine.
// Otherwise exit 0, printing "red: [id] {n} test(s)" per id (and
// "pass: [id] {n} test(s) (--pass-ids)" for a passing --pass-ids id).
// Exit 2: a usage error or an unreadable input (never 0). Every exit 2 goes
// through usage().
import { readFileSync } from 'node:fs';
/** Print a usage problem and exit 2. */
function usage(msg) {
process.stderr.write(`red-judge: ${msg}\n` +
'usage: red-judge.mjs --ids <file> --results <jsonl> [--other-ids <file>] [--pass-ids <id>[,<id>...]] [--mutation <id>]\n');
process.exit(2);
}
/** --flag value pairs; every flag takes one value. */
function parse(argv) {
const known = new Set(['--ids', '--results', '--other-ids', '--pass-ids', '--mutation']);
const o = {};
for (let i = 0; i < argv.length; i += 2) {
if (!known.has(argv[i])) usage(`unknown argument ${argv[i]}`);
if (i + 1 >= argv.length || argv[i + 1].startsWith('--')) usage(`${argv[i]} needs a value`);
o[argv[i]] = argv[i + 1];
}
if (!o['--ids'] || !o['--results']) usage('--ids and --results are required');
return o;
}
/** A file's text, or exit 2. */
function readText(path, what) {
try {
return readFileSync(path, 'utf8');
} catch (e) {
return usage(`cannot read the ${what} file ${path}: ${e.code || e.message}`);
}
}
/** The id a test name starts with, in brackets, or null. */
function tagOf(name) {
const m = /^\[([^\]\s]+)\]/.exec(String(name));
return m ? m[1] : null;
}
/** One line of output: whitespace collapsed. */
const one = (s) => String(s).replace(/\s+/g, ' ').trim();
const opts = parse(process.argv.slice(2));
const ids = readText(opts['--ids'], 'ids').split('\n').map((l) => l.trim()).filter(Boolean);
if (ids.length === 0) usage(`the ids file ${opts['--ids']} lists no id`);
const listed = new Set(ids);
const otherIds = opts['--other-ids']
? new Set(readText(opts['--other-ids'], 'other-ids').split('\n').map((l) => l.trim()).filter(Boolean))
: new Set();
for (const id of ids) {
if (otherIds.has(id)) usage(`id ${id} is on both --ids and --other-ids`);
}
const passIds = new Set((opts['--pass-ids'] || '').split(',').map((s) => s.trim()).filter(Boolean));
const mutation = opts['--mutation'] || null;
if (mutation && !listed.has(mutation)) usage(`--mutation ${mutation} is not in the ids file`);
const events = [];
readText(opts['--results'], 'results').split('\n').forEach((line, i) => {
if (!line.trim()) return;
try {
events.push(JSON.parse(line));
} catch {
usage(`line ${i + 1} of ${opts['--results']} is not JSON`);
}
});
const broken = [];
const seen = new Map();
const red = new Map();
const passed = new Map();
const bump = (m, k) => m.set(k, (m.get(k) || 0) + 1);
for (const e of events) {
if (e.ok === false && e.failureType === 'subtestsFailed') continue;
const name = one(e.name);
const tag = tagOf(name);
if (tag && otherIds.has(tag)) continue;
if (!tag && name.startsWith('[')) {
broken.push(`broken: unreadable-tag ${name}`);
continue;
}
if (!tag) {
if (e.ok === false) broken.push(`broken: untagged-failure ${name} (${e.file ?? 'no file'})`);
continue;
}
if (!listed.has(tag)) {
broken.push(`broken: unlisted-tag [${tag}] ${name}`);
continue;
}
if (mutation && tag !== mutation) continue;
bump(seen, tag);
if (e.skip) {
broken.push(`broken: skipped [${tag}] ${name}`);
} else if (e.todo) {
broken.push(`broken: todo [${tag}] ${name}`);
} else if (e.ok === true) {
if (mutation || passIds.has(tag)) bump(passed, tag);
else broken.push(`broken: passed [${tag}] ${name}`);
} else if (e.code === 'ERR_ASSERTION' && e.cname === 'AssertionError') {
bump(red, tag);
} else {
broken.push(`broken: not-assertion [${tag}] ${name} (${e.code ?? 'no code'} ${e.cname ?? 'no name'})`);
}
}
const judged = mutation ? [mutation] : ids;
for (const id of judged) {
if (mutation) {
if (!red.get(id)) broken.push(`broken: mutation-not-red [${id}]`);
} else if (!seen.get(id)) {
broken.push(`broken: no-test [${id}]`);
}
}
if (broken.length > 0) {
process.stdout.write(`${broken.join('\n')}\n`);
process.exit(1);
}
const out = [];
for (const id of judged) {
if (red.get(id)) out.push(`red: [${id}] ${red.get(id)} test(s)`);
if (!mutation && passed.get(id)) out.push(`pass: [${id}] ${passed.get(id)} test(s) (--pass-ids)`);
}
process.stdout.write(`${out.join('\n')}\n`);
process.exit(0);

View file

@ -0,0 +1,180 @@
#!/usr/bin/env node
// tests/helpers/red-mutate.mjs
// The mutate helper of the shared red script (tests/helpers/red-round.sh).
// A mutation module is a plain ES module (no ".test." in its name, so
// `npm test` never runs it) that exports
// MUTATIONS = [{ key, id, what, spans: [{ file, find, replace }] }]
// key unique in the module; id one of the round's ids; file relative to the
// tree; find a string or a RegExp with the g flag. A string find is replaced
// literally; a RegExp find uses String.prototype.replace, $-patterns included.
//
// node tests/helpers/red-mutate.mjs list <module>
// one "<key> <id>" line per entry, in module order.
// node tests/helpers/red-mutate.mjs apply <module> <key> <tree> <backup-dir>
// counts every span of the entry first (spans on one file count on the
// text the earlier spans left); writes only when every span matched
// exactly once: each touched file is backed up into <backup-dir> (which
// must not exist yet), then written. Exit 1, nothing written and no
// backup made, when a span matched 0 or 2+ times (one stderr line each).
// node tests/helpers/red-mutate.mjs restore <backup-dir> <tree>
// copies every backed-up file back into the tree and removes the backup.
// Exit 2: a usage error, an unreadable module or file, a bad entry, a span
// file outside the tree, a failed write or restore (never 0).
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { pathToFileURL } from 'node:url';
const USAGE = 'usage: red-mutate.mjs list <module> | apply <module> <key> <tree> <backup-dir> | restore <backup-dir> <tree>';
const MANIFEST = 'manifest.json';
/** Print a problem and exit 2. */
function fail(msg) {
process.stderr.write(`red-mutate: ${msg}\n${USAGE}\n`);
process.exit(2);
}
/** The module's MUTATIONS, checked; exit 2 on any defect. */
async function load(modulePath) {
let mod;
try {
mod = await import(pathToFileURL(resolve(modulePath)).href);
} catch (e) {
return fail(`cannot load the mutation module ${modulePath}: ${e.message}`);
}
const list = mod.MUTATIONS;
if (!Array.isArray(list)) fail(`${modulePath} exports no MUTATIONS array`);
const keys = new Set();
list.forEach((m, i) => {
const where = `MUTATIONS[${i}]`;
if (!m || typeof m.key !== 'string' || !/^\S+$/.test(m.key)) fail(`${where}: key must be one word`);
if (typeof m.id !== 'string' || !/^\S+$/.test(m.id)) fail(`${where} (${m.key}): id must be one word`);
if (keys.has(m.key)) fail(`${where}: key ${m.key} is used twice`);
keys.add(m.key);
if (!Array.isArray(m.spans) || m.spans.length === 0) fail(`${where} (${m.key}): spans must be a non-empty array`);
m.spans.forEach((s, j) => {
const at = `${m.key} span ${j + 1}`;
if (!s || typeof s.file !== 'string' || s.file === '' || isAbsolute(s.file)) fail(`${at}: file must be a relative path`);
if (typeof s.replace !== 'string') fail(`${at}: replace must be a string`);
if (s.find instanceof RegExp) {
if (!s.find.global) fail(`${at}: a RegExp find needs the g flag`);
} else if (typeof s.find !== 'string' || s.find === '') {
fail(`${at}: find must be a non-empty string or a RegExp`);
}
});
});
return list;
}
/** The absolute path of a tree-relative file, or exit 2 when it leaves the tree. */
function inTree(tree, rel) {
const root = resolve(tree);
const abs = resolve(root, rel);
const back = relative(root, abs);
if (back === '' || back === '..' || back.startsWith(`..${sep}`) || isAbsolute(back)) fail(`${rel} is outside the tree ${tree}`);
return abs;
}
/** How often a find matches in a text. */
function count(text, find) {
if (typeof find === 'string') return text.split(find).length - 1;
return [...text.matchAll(find)].length;
}
/** The text with its one match replaced. */
function replaceOnce(text, find, by) {
if (typeof find === 'string') return text.replace(find, () => by);
return text.replace(find, by);
}
/** One line for a span's find. */
const show = (find) => (typeof find === 'string' ? JSON.stringify(find) : String(find)).replace(/\s+/g, ' ').slice(0, 80);
async function list(argv) {
if (argv.length !== 1) fail('list takes one argument');
const entries = await load(argv[0]);
process.stdout.write(entries.map((m) => `${m.key} ${m.id}\n`).join(''));
}
async function apply(argv) {
if (argv.length !== 4) fail('apply takes four arguments');
const [modulePath, key, tree, backup] = argv;
const entry = (await load(modulePath)).find((m) => m.key === key);
if (!entry) fail(`no mutation with key ${key} in ${modulePath}`);
if (!existsSync(tree)) fail(`the tree ${tree} does not exist`);
if (existsSync(backup)) fail(`the backup directory ${backup} already exists (a mutation not restored?)`);
const texts = new Map();
const originals = new Map();
const misses = [];
for (const span of entry.spans) {
const abs = inTree(tree, span.file);
if (!texts.has(span.file)) {
if (!existsSync(abs)) {
misses.push(`${key}: ${span.file} does not exist in the tree`);
continue;
}
try {
const t = readFileSync(abs, 'utf8');
texts.set(span.file, t);
originals.set(span.file, t);
} catch (e) {
fail(`cannot read ${abs}: ${e.code || e.message}`);
}
}
const text = texts.get(span.file);
const n = count(text, span.find);
if (n !== 1) {
misses.push(`${key}: ${show(span.find)} matched ${n} times in ${span.file}, not once`);
continue;
}
texts.set(span.file, replaceOnce(text, span.find, span.replace));
}
if (misses.length > 0) {
process.stderr.write(`${misses.map((m) => `red-mutate: ${m}`).join('\n')}\n`);
process.exit(1);
}
try {
mkdirSync(join(backup, 'files'), { recursive: true });
for (const rel of texts.keys()) {
const dest = join(backup, 'files', rel);
mkdirSync(dirname(dest), { recursive: true });
writeFileSync(dest, originals.get(rel));
}
writeFileSync(join(backup, MANIFEST), `${JSON.stringify([...texts.keys()])}\n`);
} catch (e) {
fail(`cannot write the backup in ${backup}: ${e.code || e.message}`);
}
try {
for (const [rel, text] of texts) writeFileSync(inTree(tree, rel), text);
} catch (e) {
fail(`cannot write the mutated file: ${e.code || e.message} (restore from ${backup})`);
}
process.stdout.write(`red-mutate: ${key} [${entry.id}] applied, ${entry.spans.length} span(s) in ${texts.size} file(s)\n`);
}
function restore(argv) {
if (argv.length !== 2) fail('restore takes two arguments');
const [backup, tree] = argv;
let files;
try {
files = JSON.parse(readFileSync(join(backup, MANIFEST), 'utf8'));
} catch (e) {
return fail(`cannot read the backup manifest in ${backup}: ${e.code || e.message}`);
}
if (!Array.isArray(files)) fail(`the backup manifest in ${backup} is not a list`);
try {
for (const rel of files) copyFileSync(join(backup, 'files', rel), inTree(tree, rel));
rmSync(backup, { recursive: true, force: true });
} catch (e) {
fail(`cannot restore from ${backup}: ${e.code || e.message}`);
}
process.stdout.write(`red-mutate: restored ${files.length} file(s)\n`);
}
const [cmd, ...rest] = process.argv.slice(2);
if (cmd === 'list') await list(rest);
else if (cmd === 'apply') await apply(rest);
else if (cmd === 'restore') restore(rest);
else fail(cmd ? `unknown command ${cmd}` : 'no command');

View file

@ -0,0 +1,39 @@
// tests/helpers/red-reporter.mjs
// A node:test custom reporter for the red script of fix round 4-5a
// (tests/fix-round-4-5a-red.sh). It writes one JSON line per test:pass and
// test:fail event, for tests/helpers/red-judge.mjs to read:
// {name, file, nesting, ok, skip, todo, code, cname, failureType}
// `code` and `cname` come from the error's cause (the error node:test reports
// is its own ERR_TEST_FAILURE wrapper): ERR_ASSERTION / AssertionError for an
// assertion. A cause with no code (a thrown TypeError) gives the wrapper's
// code; a cause that is not an object (a file that failed to load, a suite
// whose subtests failed) gives a null `cname`. A pass gives null for both.
// `failureType` is node:test's own (testCodeFailure, subtestsFailed, ...).
// Use: node --test --test-reporter=./tests/helpers/red-reporter.mjs
// --test-reporter-destination=<file> <files>
/**
* @param {AsyncIterable<{type: string, data: any}>} source - node:test events
* @returns {AsyncGenerator<string>} one JSON line per pass or fail
*/
export default async function* redReporter(source) {
for await (const event of source) {
if (event.type !== 'test:pass' && event.type !== 'test:fail') continue;
const d = event.data || {};
const error = d.details && d.details.error;
const cause = error && error.cause;
const causeObj = cause !== null && typeof cause === 'object' ? cause : null;
const line = {
name: d.name,
file: d.file ?? null,
nesting: d.nesting,
ok: event.type === 'test:pass',
skip: Boolean(d.skip),
todo: Boolean(d.todo),
code: causeObj && causeObj.code != null ? String(causeObj.code) : (error && error.code != null ? String(error.code) : null),
cname: causeObj && causeObj.name != null ? String(causeObj.name) : null,
failureType: error && error.failureType !== undefined ? error.failureType : null,
};
yield `${JSON.stringify(line)}\n`;
}
}

212
tests/helpers/red-round.sh Normal file
View file

@ -0,0 +1,212 @@
#!/usr/bin/env bash
# tests/helpers/red-round.sh
# The shared red script of the fix rounds. A round's wrapper
# (tests/fix-round-<n>-red.sh) hands it the round's start point, ids, the
# other rounds' ids, a mutation module and the round's test files:
#
# red-round.sh --base <sha> --ids '<id> ...' --other-ids '<id> ...'
# --mutations <module.mjs> [--require-sha256] <test file>...
#
# Every id of the round has tests whose names start with "[id]"; each of them
# must fail on an assertion at the start point, or, for an id a mutation
# names, under that mutation.
# 1. Clone the repo into a temp directory outside it (git clone --shared
# --no-checkout, then checkout --detach of the base; nothing registers
# in .git/worktrees). With --require-sha256 the local git must be able to
# create a SHA-256 repository first.
# 2. Replace the clone's tests/ with the working tree's whole tests/ (tar),
# helpers and fixtures included, so a new helper is no crash.
# 3. Run every tagged test of the files once (--test-name-pattern=^\[)
# under the red reporter and let the red judge decide: --ids, --other-ids
# (another round's tag is never judged), --pass-ids (the ids the
# mutation module names: their defect is proven by the mutation).
# 4. For each mutation of the module (tests/helpers/red-mutate.mjs list):
# apply it to the clone (every span exactly once, else
# "broken: mutation-not-applied [id] key"), run only that id's tests
# (^\[id\]), judge with --mutation <id>, restore.
# The reporter, the judge, the mutate helper and the --import preload always
# run from the working tree's tests/helpers/ by absolute path, so a mutation
# of the clone's copy never edits the instrument that measures it.
# Exit 0 all red, 1 a rule broken (the "broken:" lines name it), 2 a setup
# failure ("red script: setup failure: <reason>"; a bad base, a missing file,
# no SHA-256 git under --require-sha256, an instrument that could not run);
# never 0 on a setup failure. The clone is removed on every exit.
# Needs Node >= 20.14 for --test-force-exit; an older Node rejects the option
# and the run fails closed (a setup failure).
# bash 3.2 compatible, ASCII only, no arrays.
set -u
set -f
setup_fail() {
echo "red script: setup failure: $1" >&2
exit 2
}
ROOT="$(cd "$(dirname "$0")/../.." && pwd -P)" || setup_fail "cannot find the repo root from $0"
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE NODE_TEST_CONTEXT CLAUDE_CODE_SESSION_ID
# ---- arguments ----
BASE=''
IDS=''
OTHER_IDS=''
MUTATIONS=''
REQUIRE_SHA256=0
HAVE_BASE=0
HAVE_IDS=0
HAVE_OTHER=0
HAVE_MUTATIONS=0
while [ "$#" -gt 0 ]; do
case "$1" in
--base) [ "$#" -ge 2 ] || setup_fail "--base needs a value"; BASE="$2"; HAVE_BASE=1; shift 2 ;;
--ids) [ "$#" -ge 2 ] || setup_fail "--ids needs a value"; IDS="$2"; HAVE_IDS=1; shift 2 ;;
--other-ids) [ "$#" -ge 2 ] || setup_fail "--other-ids needs a value"; OTHER_IDS="$2"; HAVE_OTHER=1; shift 2 ;;
--mutations) [ "$#" -ge 2 ] || setup_fail "--mutations needs a value"; MUTATIONS="$2"; HAVE_MUTATIONS=1; shift 2 ;;
--require-sha256) REQUIRE_SHA256=1; shift ;;
--) shift; break ;;
-*) setup_fail "unknown option $1" ;;
*) break ;;
esac
done
[ "$HAVE_BASE" -eq 1 ] && [ -n "$BASE" ] || setup_fail "--base <sha> is required"
[ "$HAVE_IDS" -eq 1 ] && [ -n "$IDS" ] || setup_fail "--ids '<id> ...' is required"
[ "$HAVE_OTHER" -eq 1 ] || setup_fail "--other-ids '<id> ...' is required"
[ "$HAVE_MUTATIONS" -eq 1 ] || setup_fail "--mutations <module.mjs> is required"
case "$MUTATIONS" in
/*) ;;
*) setup_fail "--mutations needs an absolute path, not '$MUTATIONS'" ;;
esac
[ -f "$MUTATIONS" ] || setup_fail "missing mutation module $MUTATIONS"
[ "$#" -gt 0 ] || setup_fail "no test file given"
command -v node >/dev/null 2>&1 || setup_fail "node is not on PATH"
command -v git >/dev/null 2>&1 || setup_fail "git is not on PATH"
command -v tar >/dev/null 2>&1 || setup_fail "tar is not on PATH"
for f in no-session-id.mjs red-reporter.mjs red-judge.mjs red-mutate.mjs; do
[ -f "$ROOT/tests/helpers/$f" ] || setup_fail "missing $ROOT/tests/helpers/$f"
done
# ---- the temp directory, outside the repo, removed on every exit ----
TMP="$(mktemp -d "${TMPDIR:-/tmp}/red-round.XXXXXX")" || setup_fail "mktemp -d failed"
case "$TMP" in
''|/) setup_fail "mktemp -d gave an unusable directory '$TMP'" ;;
esac
TMP_REAL="$(cd "$TMP" && pwd -P)" || setup_fail "cannot enter $TMP"
case "$TMP_REAL/" in
"$ROOT"/*) rmdir "$TMP" 2>/dev/null; setup_fail "the temp directory $TMP_REAL is inside the repo" ;;
esac
trap 'rm -rf "$TMP"' EXIT
trap 'rm -rf "$TMP"; exit 2' INT TERM HUP
TREE="$TMP/tree"
if [ "$REQUIRE_SHA256" -eq 1 ]; then
git init --quiet --object-format=sha256 "$TMP/sha256-probe" >/dev/null 2>&1 \
|| setup_fail "this git cannot create a SHA-256 repository (--require-sha256)"
rm -rf "$TMP/sha256-probe" || setup_fail "could not remove $TMP/sha256-probe"
fi
# ---- 1. the start point ----
git clone --quiet --shared --no-checkout "$ROOT" "$TREE" || setup_fail "could not clone $ROOT"
git -C "$TREE" checkout --quiet --detach "$BASE" || setup_fail "could not check out $BASE"
# ---- 2. the working tree's whole tests/ ----
(cd "$ROOT" && tar -cf "$TMP/tests.tar" tests) || setup_fail "could not tar tests/"
rm -rf "$TREE/tests" || setup_fail "could not remove the start point's tests/"
tar -xf "$TMP/tests.tar" -C "$TREE" || setup_fail "could not copy tests/ into the start point"
for f in "$@"; do
[ -f "$TREE/$f" ] || setup_fail "missing test file $f"
done
for id in $IDS; do
printf '%s\n' "$id"
done > "$TMP/ids" || setup_fail "could not write $TMP/ids"
: > "$TMP/other-ids" || setup_fail "could not write $TMP/other-ids"
for id in $OTHER_IDS; do
printf '%s\n' "$id"
done >> "$TMP/other-ids" || setup_fail "could not write $TMP/other-ids"
# ---- the mutations: "<key> <id>" per line; their ids are the pass-ids ----
node "$ROOT/tests/helpers/red-mutate.mjs" list "$MUTATIONS" > "$TMP/mutations" \
|| setup_fail "red-mutate could not list $MUTATIONS"
PASS_IDS=''
while read -r key id rest <&3; do
[ -n "$key" ] || continue
case " $IDS " in
*" $id "*) ;;
*) setup_fail "mutation $key names id $id, which is not on --ids" ;;
esac
case ",$PASS_IDS," in
*",$id,"*) ;;
*) PASS_IDS="${PASS_IDS:+$PASS_IDS,}$id" ;;
esac
done 3< "$TMP/mutations"
# run_tagged <results> <node test option>... <file>...: a test run in the
# start point under the red reporter. The positional parameters carry the
# options and files (no arrays in bash 3.2). Node exits 0 (all passed) or 1
# (a test failed); anything else (a bad option, a crash) is a setup failure.
run_tagged() {
out="$1"
shift
(cd "$TREE" && node --import "$ROOT/tests/helpers/no-session-id.mjs" --test --test-reporter="$ROOT/tests/helpers/red-reporter.mjs" --test-reporter-destination="$out" "$@") </dev/null >/dev/null 2>"$out.stderr"
rc=$?
if [ "$rc" -ne 0 ] && [ "$rc" -ne 1 ]; then
cat "$out.stderr" >&2
setup_fail "the test run exited $rc (needs Node >= 20.14)"
fi
[ -f "$out" ] || { cat "$out.stderr" >&2; setup_fail "the test run wrote no results to $out"; }
}
# judge <args...>: the red judge; 2 (usage or input) is a setup failure.
judge() {
node "$ROOT/tests/helpers/red-judge.mjs" "$@" </dev/null
rc=$?
[ "$rc" -eq 2 ] && setup_fail "the red judge could not run (exit 2)"
return "$rc"
}
STATUS=0
# ---- 3. every tagged test at the start point, one run ----
echo "red script: the tagged tests at $BASE"
run_tagged "$TMP/base.jsonl" --test-timeout=300000 --test-force-exit '--test-name-pattern=^\[' "$@"
judge --ids "$TMP/ids" --other-ids "$TMP/other-ids" --pass-ids "$PASS_IDS" --results "$TMP/base.jsonl" || STATUS=1
# ---- 4. each mutation: apply, run its id's tests, judge, restore ----
N=0
while read -r key id rest <&3; do
[ -n "$key" ] || continue
N=$((N + 1))
BK="$TMP/backup-$N"
echo "red script: mutation $key [$id]"
node "$ROOT/tests/helpers/red-mutate.mjs" apply "$MUTATIONS" "$key" "$TREE" "$BK" </dev/null >/dev/null
rc=$?
if [ "$rc" -eq 0 ]; then
run_tagged "$TMP/mutation-$N.jsonl" --test-timeout=300000 --test-force-exit "--test-name-pattern=^\\[$id\\]" "$@"
judge --ids "$TMP/ids" --other-ids "$TMP/other-ids" --results "$TMP/mutation-$N.jsonl" --mutation "$id" || STATUS=1
node "$ROOT/tests/helpers/red-mutate.mjs" restore "$BK" "$TREE" </dev/null >/dev/null \
|| setup_fail "could not restore the files of mutation $key"
elif [ "$rc" -eq 1 ]; then
echo "broken: mutation-not-applied [$id] $key"
STATUS=1
else
setup_fail "red-mutate could not apply $key (exit $rc)"
fi
done 3< "$TMP/mutations"
if [ "$STATUS" -eq 0 ]; then
echo "red script: ok - every id red at $BASE, $N mutation(s) red"
else
echo "red script: FAIL - a rule is broken (see the broken: lines above)" >&2
fi
exit "$STATUS"

View file

@ -0,0 +1,175 @@
// tests/hooks/bash-guard-opener-word.test.mjs
// [PQO] Fix round 4.5b, part 3 (decision 89).
// Defect: the args-heredoc guard in hooks/scripts/pre-bash-executor.mjs found
// heredoc openers with one regex that needs the whole word quoted, so an
// opener whose word is only partly quoted (<<"VOYAGE"_ARGS_EOF_7F3C9E), quoted
// as $'...' or $"...", or split by a backslash-newline was not recognised: the
// guard exited 0 and the shell ran the line after the closing line. The guard
// must read the opener's word as the shell does, and treat a word it cannot
// read as unquoted.
// The helpers ARGS_DELIM, ARGS_RULE, PARSER_LINE, bashInput,
// expectArgsBlocked and expectArgsAllowed are copied from
// tests/hooks/bash-guard.test.mjs:21-23,327-341, not imported, because
// importing a test file would run its tests. The guard is only spawned, so
// this file loads at ce98f62 and its red is an assertion.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { runHook } from '../helpers/hook-helper.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const PRE_BASH = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs');
function bashInput(command) {
return { tool_name: 'Bash', tool_input: { command } };
}
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
const ARGS_RULE = /args-heredoc-closed-early/;
const PARSER_LINE = 'node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin';
async function expectArgsBlocked(command) {
const { code, stderr } = await runHook(PRE_BASH, bashInput(command));
assert.match(stderr, ARGS_RULE, `the guard did not name args-heredoc-closed-early for: ${JSON.stringify(command)}`);
assert.strictEqual(code, 2, `the guard did not block: ${JSON.stringify(command)}`);
return stderr;
}
async function expectArgsAllowed(command) {
const { code, stderr } = await runHook(PRE_BASH, bashInput(command));
assert.doesNotMatch(stderr, ARGS_RULE, `the guard refused a well-formed heredoc: ${JSON.stringify(command)}`);
assert.strictEqual(code, 0, `the guard refused a well-formed heredoc: ${stderr}`);
}
// The unquoted reason: the guard blocks and says the opener is unquoted.
const UNQUOTED = /opens VOYAGE_ARGS_EOF_7F3C9E unquoted/;
async function expectUnquoted(command) {
const stderr = await expectArgsBlocked(command);
assert.match(stderr, UNQUOTED, `the guard did not give the unquoted reason for: ${JSON.stringify(command)}`);
}
const D = ARGS_DELIM;
const BODY = `\nbrief.md\n${D}`;
const EARLY = `${BODY}\ntouch PWNED`;
// The five backslash-newline openers (the brief's).
const CONTINUATION_OPENERS = [
`<<\\\n${D}`,
`<<VOYAGE_ARGS\\\n_EOF_7F3C9E`,
`<\\\n<${D}`,
`<< \\\n${D}`,
`<<-\\\n${D}`,
];
const X5F_OPENER = `<<$'VOYAGE\\x5fARGS_EOF_7F3C9E'`;
const DQ_CONTINUATION_OPENERS = [
`<<"VOYAGE_ARGS\\\n_EOF_7F3C9E"`,
`<<VOYAGE"_ARGS\\\n_EOF_7F3C9E"`,
];
const OCTAL_OPENER = `<<$'\\126\\117YAGE_ARGS_EOF_7F3C9E'`;
const DASH_AFTER_CONTINUATION_OPENER = `<<\\\n-"VOYAGE"_ARGS_EOF_7F3C9E`;
const UNTERMINATED_OPENER = `<<V"OYAGE_ARGS_EOF_7F3C9E`;
const LOCALE_BACKSLASH_OPENER = `<<$"VOYAGE\\_ARGS_EOF_7F3C9E"`;
// The brief's 11 spellings.
const BRIEF_SPELLINGS = [
`<<"VOYAGE"_ARGS_EOF_7F3C9E`,
`<<V'OYAGE_ARGS_EOF_7F3C9E'`,
`<<VOYAGE\\_ARGS_EOF_7F3C9E`,
`<<$'VOYAGE_ARGS_EOF_7F3C9E'`,
`<<- "VOYAGE"_ARGS_EOF_7F3C9E`,
X5F_OPENER,
...CONTINUATION_OPENERS,
];
// 8 from planning, each measured to close at the plain line or shell-dependent.
const PLANNING_SPELLINGS = [
...DQ_CONTINUATION_OPENERS,
OCTAL_OPENER,
`<<$"VOYAGE_ARGS_EOF_7F3C9E"`,
DASH_AFTER_CONTINUATION_OPENER,
`<<-\t"VOYAGE"_ARGS_EOF_7F3C9E`,
UNTERMINATED_OPENER,
LOCALE_BACKSLASH_OPENER,
];
test('[PQO] the guard reads a heredoc opener\'s word as the shell does and checks every spelling of the delimiter', async () => {
// Red: every spelling, followed by a body, the closing line and a command.
assert.equal(BRIEF_SPELLINGS.length + PLANNING_SPELLINGS.length, 19);
for (const opener of [...BRIEF_SPELLINGS, ...PLANNING_SPELLINGS]) {
await expectArgsBlocked(`${PARSER_LINE} ${opener}${EARLY}`);
}
// The unterminated quote the regex does not match also gets the unquoted reason.
await expectUnquoted(`${PARSER_LINE} ${UNTERMINATED_OPENER}${EARLY}`);
// Red: word ends. Unquoted whitespace or one of ; & | ( ) < > ends the word.
for (const end of [';echo x', '>/dev/null', '|cat', '&', '</dev/null', ')']) {
await expectArgsBlocked(`${PARSER_LINE} <<"VOYAGE"_ARGS_EOF_7F3C9E${end}${EARLY}`);
}
// Red: a fake accepted opener inside a string hides no real opener after it.
await expectUnquoted(`echo "<<'${D}' x"\n${PARSER_LINE} <<VOYAGE_ARGS\\\n_EOF_7F3C9E\nbrief.md $(touch PWNED)\n${D}`);
await expectUnquoted(`echo "<<'${D}' x"\n${PARSER_LINE} <<\\\n-VOYAGE_ARGS_EOF_7F3C9E\nbrief.md $(touch PWNED)\n${D}`);
// Red: a quoted word spanning lines hides nothing, in both quote kinds.
await expectUnquoted(`echo "<<'"\n${PARSER_LINE} <<VOYAGE_ARGS\\\n_EOF_7F3C9E\nbrief.md $(touch PWNED) don't\n${D}`);
await expectUnquoted(`echo '<<"'\n${PARSER_LINE} <<VOYAGE_ARGS\\\n_EOF_7F3C9E\nbrief.md $(touch PWNED) say "hi\n${D}`);
// Red: a typed argument with another unreadable word after << (Known issue 3b).
await expectUnquoted(`${PARSER_LINE} <<'${D}'\nx <<$'\\t' y\n${D}`);
// Red: lines stay raw. A body line ending in a backslash does not join the
// early closing line to it.
await expectArgsBlocked(`${PARSER_LINE} <<"VOYAGE"_ARGS_EOF_7F3C9E\nfoo\\\n${D}\ntouch PWNED\n${D}`);
// Red: unquoted reason, with EARLY and with BODY (closing line last).
const unquotedOpeners = [
...CONTINUATION_OPENERS,
X5F_OPENER,
...DQ_CONTINUATION_OPENERS,
OCTAL_OPENER,
DASH_AFTER_CONTINUATION_OPENER,
LOCALE_BACKSLASH_OPENER,
];
for (const opener of unquotedOpeners) {
await expectUnquoted(`${PARSER_LINE} ${opener}${EARLY}`);
await expectUnquoted(`${PARSER_LINE} ${opener}${BODY}`);
}
// Control: an unterminated "-quoted delimiter with a body is blocked as unquoted.
await expectUnquoted(`${PARSER_LINE} <<"${D}\nbrief.md`);
// Control: well-formed spellings with the closing line last pass.
for (const opener of [
`<<"VOYAGE"_ARGS_EOF_7F3C9E`,
`<<V'OYAGE_ARGS_EOF_7F3C9E'`,
`<<VOYAGE\\_ARGS_EOF_7F3C9E`,
`<<$'VOYAGE_ARGS_EOF_7F3C9E'`,
`<<- "VOYAGE"_ARGS_EOF_7F3C9E`,
`<<$"VOYAGE_ARGS_EOF_7F3C9E"`,
`<<"VOYAGE"_ARGS_EOF_7F3C9E;echo x`,
]) {
await expectArgsAllowed(`${PARSER_LINE} ${opener}${BODY}`);
}
// Control: the shipped form, and the backslash-quoted opener.
await expectArgsAllowed(`${PARSER_LINE} <<'${D}'\nbrief.md Session 2 of 3\n${D}`);
await expectArgsAllowed(`${PARSER_LINE} <<\\${D}${BODY}`);
// Control: typed arguments inside the shipped heredoc pass (the operator's decision).
await expectArgsAllowed(`${PARSER_LINE} <<'${D}'\nfix "<<" handling\n${D}`);
await expectArgsAllowed(`${PARSER_LINE} <<'${D}'\nFix <<don't>> bug\n${D}`);
await expectArgsAllowed(`${PARSER_LINE} <<'${D}'\nx <<"VOYAGE"_ARGS_EOF_7F3C9E y\n${D}`);
// Control: a continuation before the opener passes.
await expectArgsAllowed(`node /plugin/lib/parsers/arg-parser.mjs --command trekendsession \\\n --stdin <<'${D}'${BODY}`);
// Control: a one-line mention passes; with a line after it, it is blocked.
await expectArgsAllowed(`grep -n "<<'${D}'" f`);
await expectArgsBlocked(`grep -n "<<'${D}'" f\necho x`);
// Control: a delimiter line before a correct heredoc is blocked.
await expectArgsBlocked(`echo hi\n${D}\n${PARSER_LINE} <<'${D}'${BODY}`);
// Control: openers cut off at the end of the text never make the guard exit 1.
await expectArgsAllowed(`echo ${D}\nx <<\\`);
await expectArgsAllowed(`echo ${D}\nx <<"`);
await expectArgsAllowed(`echo ${D}\nx <<$'`);
await expectArgsAllowed(`echo ${D}\nx <\\`);
});

View file

@ -9,6 +9,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 { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { runHook } from '../helpers/hook-helper.mjs'; import { runHook } from '../helpers/hook-helper.mjs';
@ -314,3 +315,125 @@ test('pre-bash-executor fails open on malformed JSON', async () => {
const { code } = await runHook(PRE_BASH, 'not-json'); const { code } = await runHook(PRE_BASH, 'not-json');
assert.strictEqual(code, 0); assert.strictEqual(code, 0);
}); });
// -----------------------------------------------------------------------
// ARG / R6 (fix round 4.5b) -- the parser heredoc must not close early.
// The commands hand $ARGUMENTS to the arg parser through a quoted heredoc
// closed by VOYAGE_ARGS_EOF_7F3C9E. A typed line equal to that delimiter
// would end the heredoc there, and the shell would run every line after it
// as a command. Each blocking case asserts the rule's name on stderr, so it
// is red on its own assertion where the rule does not exist.
// -----------------------------------------------------------------------
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
const ARGS_RULE = /args-heredoc-closed-early/;
const PARSER_LINE = 'node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin';
async function expectArgsBlocked(command) {
const { code, stderr } = await runHook(PRE_BASH, bashInput(command));
assert.match(stderr, ARGS_RULE, `the guard did not name args-heredoc-closed-early for: ${JSON.stringify(command)}`);
assert.strictEqual(code, 2, `the guard did not block: ${JSON.stringify(command)}`);
}
async function expectArgsAllowed(command) {
const { code, stderr } = await runHook(PRE_BASH, bashInput(command));
assert.doesNotMatch(stderr, ARGS_RULE, `the guard refused a well-formed heredoc: ${JSON.stringify(command)}`);
assert.strictEqual(code, 0, `the guard refused a well-formed heredoc: ${stderr}`);
}
test('[ARG] the guard blocks a parser heredoc whose closing line is followed by a command', async () => {
await expectArgsBlocked(`${PARSER_LINE} <<'${ARGS_DELIM}'\nbrief.md\n${ARGS_DELIM}\ntouch PWNED`);
await expectArgsBlocked(`${PARSER_LINE} <<'${ARGS_DELIM}'\nbrief.md\n${ARGS_DELIM}\ntouch PWNED\n`);
});
test('[ARG] the guard blocks a parser heredoc with the closing line twice', async () => {
await expectArgsBlocked(`${PARSER_LINE} <<'${ARGS_DELIM}'\nbrief.md\n${ARGS_DELIM}\ntouch PWNED\n${ARGS_DELIM}`);
// A typed line with spaces around the delimiter still counts as a closing line.
await expectArgsBlocked(`${PARSER_LINE} <<'${ARGS_DELIM}'\nbrief.md\n ${ARGS_DELIM} \ntouch PWNED\n${ARGS_DELIM}\n`);
});
test('[ARG] the guard allows the parser heredoc as the commands write it', async () => {
await expectArgsAllowed(`${PARSER_LINE} <<'${ARGS_DELIM}'\nbrief.md Session 2 of 3\n${ARGS_DELIM}`);
await expectArgsAllowed(`${PARSER_LINE} <<'${ARGS_DELIM}'\nbrief.md Session 2 of 3\n${ARGS_DELIM}\n`);
await expectArgsAllowed(`${PARSER_LINE} <<'${ARGS_DELIM}'\n\n${ARGS_DELIM}\n\n`);
await expectArgsAllowed(`${PARSER_LINE} <<'${ARGS_DELIM}'\nbrief.md ; touch PWNED $(touch PWNED2) *\n${ARGS_DELIM}`);
});
test('[ARG] the guard blocks an unquoted or spaced opener whose body closes early', async () => {
await expectArgsBlocked(`${PARSER_LINE} << '${ARGS_DELIM}'\nbrief.md\n${ARGS_DELIM}\ntouch PWNED\n${ARGS_DELIM}`);
await expectArgsBlocked(`${PARSER_LINE} <<"${ARGS_DELIM}"\nbrief.md\n${ARGS_DELIM}\ntouch PWNED\n${ARGS_DELIM}`);
await expectArgsBlocked(`${PARSER_LINE} <<-'${ARGS_DELIM}'\nbrief.md\n${ARGS_DELIM}\ntouch PWNED\n${ARGS_DELIM}`);
await expectArgsBlocked(`${PARSER_LINE} <<${ARGS_DELIM}\nbrief.md\n${ARGS_DELIM}\ntouch PWNED\n${ARGS_DELIM}`);
// Unquoted, the body would expand $( ) even when it closes where it should.
await expectArgsBlocked(`${PARSER_LINE} <<${ARGS_DELIM}\nbrief.md $(touch PWNED)\n${ARGS_DELIM}`);
});
test('[ARG] the guard checks a backslash-quoted <<\\VOYAGE_ARGS_EOF_7F3C9E opener', async () => {
await expectArgsBlocked(`${PARSER_LINE} <<\\${ARGS_DELIM}\nbrief.md\n${ARGS_DELIM}\ntouch PWNED\n${ARGS_DELIM}`);
await expectArgsBlocked(`${PARSER_LINE} << \\${ARGS_DELIM}\nbrief.md\n${ARGS_DELIM}\ntouch PWNED`);
await expectArgsAllowed(`${PARSER_LINE} <<\\${ARGS_DELIM}\nbrief.md Session 2 of 3\n${ARGS_DELIM}`);
});
test("[ARG] the guard's added lines are ASCII", () => {
const src = readFileSync(PRE_BASH, 'utf8');
const from = src.indexOf('// BEGIN args-heredoc-closed-early');
const to = src.indexOf('// END args-heredoc-closed-early');
assert.ok(from >= 0 && to > from, 'the guard marks its args-heredoc-closed-early block with BEGIN/END comments');
const block = src.slice(from, to);
assert.match(block, /VOYAGE_ARGS_EOF_7F3C9E/);
assert.doesNotMatch(block, /[^\x00-\x7f]/, 'a hook script is ASCII (~/.claude/CLAUDE.md, Miljo)');
});
// The four parser heredoc blocks the commands ship, as Claude Code runs them
// after it substitutes $ARGUMENTS and ${CLAUDE_PLUGIN_ROOT}: a block the guard
// refuses would disable its command while every other test stays green.
// [file, the heading the section starts with]
const SHIPPED_ARGS_BLOCKS = [
['commands/trekendsession.md', '## Phase 0 '],
['commands/trekendsession.md', '## Phase 3 '],
['commands/trekcontinue.md', '## Phase 0 '],
['commands/trekreview.md', '## Phase 1 '],
];
/** The ```bash blocks of the `## ` section of `rel` that starts with `heading` (fence-aware). */
function sectionBashBlocks(rel, heading) {
const lines = readFileSync(join(ROOT, rel), 'utf8').split('\n');
const s = lines.findIndex((l) => l.startsWith(heading));
assert.ok(s !== -1, `${rel} must hold a heading starting "${heading}"`);
let e = lines.length;
let fenced = false;
for (let i = s + 1; i < lines.length; i++) {
if (/^\s{0,3}(`{3,}|~{3,})/.test(lines[i])) fenced = !fenced;
if (!fenced && lines[i].startsWith('## ')) { e = i; break; }
}
const section = lines.slice(s, e).join('\n');
return section.split('\n```bash\n').slice(1).map((b) => b.split('\n```')[0]);
}
test('[ARG] every shipped parser heredoc block passes the guard', async () => {
for (const [rel, heading] of SHIPPED_ARGS_BLOCKS) {
const blocks = sectionBashBlocks(rel, heading).filter((b) => b.includes(`<<'${ARGS_DELIM}'`));
assert.equal(blocks.length, 1, `${rel} ${heading.trim()} must hold one \`\`\`bash block with the parser heredoc <<'${ARGS_DELIM}'; found ${blocks.length}`);
const command = blocks[0].split('$ARGUMENTS').join('brief.md Session 2 of 3').split('${CLAUDE_PLUGIN_ROOT}').join(ROOT);
await expectArgsAllowed(command);
}
});
// HDG (fix round 4.5b, part 2) -- bash reads a heredoc body only from the
// lines after the opener's line. A command that only mentions the opener, with
// no non-blank line after it, has nothing that can close early and nothing to
// expand, so it passes. Every opener with a line after it is checked, so a
// quoted first mention cannot hide a later unquoted heredoc.
test('[HDG] a command that only mentions the opener on its last line passes, and every opener with a line after it is checked', async () => {
await expectArgsAllowed(`grep -n "<<'${ARGS_DELIM}'" commands/trekreview.md`);
await expectArgsAllowed(`grep -n "<<'${ARGS_DELIM}'" commands/trekreview.md\n`);
await expectArgsAllowed(`grep -n "<<${ARGS_DELIM}" commands/trekreview.md\n`);
await expectArgsAllowed(`echo hi\ngrep -n "<<'${ARGS_DELIM}'" f`);
// A quoted first mention must not hide a later unquoted heredoc.
await expectArgsBlocked(`echo "<<'${ARGS_DELIM}'"\n${PARSER_LINE} <<${ARGS_DELIM}\nbrief.md $(touch PWNED)\n${ARGS_DELIM}`);
// Control: a real heredoc of the delimiter is checked as before.
await expectArgsBlocked(`${PARSER_LINE} <<'${ARGS_DELIM}' 2>&1\nbrief.md\n${ARGS_DELIM}\ntouch PWNED`);
await expectArgsBlocked(`${PARSER_LINE} <<'${ARGS_DELIM}'\nbrief.md\n${ARGS_DELIM}\ntouch PWNED\n${ARGS_DELIM}`);
await expectArgsBlocked(`${PARSER_LINE} <<'${ARGS_DELIM}'\n\n${ARGS_DELIM}\ntouch PWNED`);
await expectArgsBlocked(`echo hi\n${PARSER_LINE} <<'${ARGS_DELIM}'\nbrief.md\n${ARGS_DELIM}\ntouch PWNED\n${ARGS_DELIM}`);
await expectArgsBlocked(`${PARSER_LINE} <<${ARGS_DELIM}\nbrief.md\n${ARGS_DELIM}`);
});

View file

@ -0,0 +1,29 @@
// [CQW] Fix round 4.5b, part 3 (decision 89).
// Defect: `tokenize` refused a closing quote that had whitespace or a line
// break before it, so `"multi line` + line break + `"` (the form the `--stdin`
// heredoc produces), `--project "my dir "`, `"a "` and `" "` never paired and
// their quotes stayed literal characters. A same-kind quote that ends a word
// must close the value, whatever stands before it.
// This file must load at ce98f62: it imports only `parseArgs` and `tokenize`,
// which exist there, so its red is an assertion, not a crash.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { parseArgs, tokenize } from '../../lib/parsers/arg-parser.mjs';
const values = (text) => tokenize(text).map((t) => t.value);
test('[CQW] a quoted value closes at a quote that ends a word, also after whitespace or a line break', () => {
// Red at the start point: the closing quote has whitespace before it.
assert.deepEqual(values('"multi line\n" --since HEAD'), ['multi line\n', '--since', 'HEAD']);
const r = parseArgs('--project "my dir "', 'trekreview');
assert.equal(r.flags['--project'], 'my dir ');
assert.deepEqual(r.positional, []);
assert.deepEqual(values('"a "'), ['a ']);
assert.deepEqual(values('" "'), [' ']);
// Control: a quote that starts a word stops the search; a quote inside a
// word is skipped; a quote that ends a word after a letter still closes.
assert.equal(parseArgs('--project "my dir"/x --since "HEAD~1"', 'trekreview').flags['--since'], 'HEAD~1');
assert.deepEqual(values("'Fix the user's login'"), ["Fix the user's login"]);
assert.deepEqual(values("'the '90s were great'"), ["'the", '90s were great']);
});

View file

@ -0,0 +1,19 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { parseArgs, tokenize } from '../../lib/parsers/arg-parser.mjs';
const values = (text) => tokenize(text).map((t) => t.value);
test('[TOK] a quoted value never swallows a later flag, and an apostrophe inside a word keeps its value', () => {
const text = '--project "my dir"/x --since "HEAD~1"';
assert.equal(parseArgs(text, 'trekreview').flags['--since'], 'HEAD~1',
'the quote after `dir` is followed by `/`, so it does not close; the search stops at the quote that starts `"HEAD~1"`');
assert.deepEqual(values(text), ['--project', '"my', 'dir"/x', '--since', 'HEAD~1']);
// Control: an apostrophe inside a word in single quotes keeps one value.
assert.deepEqual(values(`'Fix the user's login'`), ["Fix the user's login"]);
// Control: two adjacent quoted words stay two values.
assert.deepEqual(values(`"a" "b"`), ['a', 'b']);
// Control: the other kind of quote inside a quoted value does not split it.
assert.deepEqual(values(`"say 'hi' now"`), ["say 'hi' now"]);
});

View file

@ -1,6 +1,15 @@
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 { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { parseArgs } from '../../lib/parsers/arg-parser.mjs'; import { parseArgs } from '../../lib/parsers/arg-parser.mjs';
import { childEnv } from '../helpers/child-env.mjs';
const PARSER = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'lib', 'parsers', 'arg-parser.mjs');
const PARSER_URL = pathToFileURL(PARSER).href;
test('trekbrief — empty args', () => { test('trekbrief — empty args', () => {
const r = parseArgs('', 'trekbrief'); const r = parseArgs('', 'trekbrief');
@ -155,3 +164,128 @@ test('trekcontinue — .md positional accepted by parser (rejection is command-l
assert.deepEqual(r.positional, ['NEXT-SESSION-PROMPT.local.md']); assert.deepEqual(r.positional, ['NEXT-SESSION-PROMPT.local.md']);
assert.deepEqual(r.errors, []); assert.deepEqual(r.errors, []);
}); });
// --- #15 — /trekendsession reads its arguments with the parser ---
// A slug or label holding "-h" is an argument, not a help request: only the
// exact --help flag or a lone -h as the first positional asks for help.
test('[#15] trekendsession — a slug is one positional, with no errors', () => {
const r = parseArgs('2026-05-01-hotfix', 'trekendsession');
assert.deepEqual(r.errors, []);
assert.deepEqual(r.positional, ['2026-05-01-hotfix']);
assert.equal(r.flags['--help'], undefined);
});
test('[#15] trekendsession — a path and a quoted label are two positionals, with no --help', () => {
const r = parseArgs('fix-header "Session 2"', 'trekendsession');
assert.deepEqual(r.errors, []);
assert.deepEqual(r.positional, ['fix-header', 'Session 2']);
assert.equal(r.flags['--help'], undefined);
});
test('[#15] trekendsession — --help is the boolean help flag', () => {
const r = parseArgs('--help', 'trekendsession');
assert.deepEqual(r.errors, []);
assert.equal(r.flags['--help'], true);
assert.deepEqual(r.unknown, []);
});
test('[#15] trekendsession — the CLI with --command trekendsession -- fix-header exits 0', () => {
const r = spawnSync(process.execPath, [PARSER, '--command', 'trekendsession', '--', 'fix-header'], { encoding: 'utf8' });
assert.equal(r.status, 0, `the parser CLI refused trekendsession: ${r.stdout}${r.stderr}`);
const out = JSON.parse(r.stdout);
assert.deepEqual(out.positional, ['fix-header']);
assert.equal(out.flags['--help'], undefined);
});
test('trekendsession — -h stays a positional (no alias resolution)', () => {
const r = parseArgs('-h', 'trekendsession');
assert.deepEqual(r.positional, ['-h']);
assert.deepEqual(r.errors, []);
assert.equal(r.flags['--help'], undefined);
});
// --- ARG / M10 (fix round 4.5b) — the arguments reach the parser as stdin
// text, never as shell words, and the label is read whole from `tail` ---
function runStdin(command, input, cwd) {
return spawnSync(process.execPath, [PARSER, '--command', command, '--stdin'], {
input, cwd, encoding: 'utf8', env: childEnv(),
});
}
test('[ARG] --stdin keeps ; $( ) and * as literal characters and creates no file', async () => {
const mod = await import(PARSER_URL);
assert.equal(typeof mod.parseArgs, 'function', 'arg-parser.mjs exports parseArgs');
const cwd = mkdtempSync(join(tmpdir(), 'arg-parser-stdin-'));
try {
const r = runStdin('trekendsession', 'brief.md ; touch PWNED $(touch PWNED2) * ~/x $HOME\n', cwd);
assert.equal(r.status, 0, `the parser CLI refused --stdin: ${r.stdout}${r.stderr}`);
const out = JSON.parse(r.stdout);
assert.deepEqual(out.positional,
['brief.md', ';', 'touch', 'PWNED', '$(touch', 'PWNED2)', '*', '~/x', '$HOME']);
assert.equal(out.tail, '; touch PWNED $(touch PWNED2) * ~/x $HOME');
assert.equal(existsSync(join(cwd, 'PWNED')), false, 'no PWNED file was created');
assert.equal(existsSync(join(cwd, 'PWNED2')), false, 'no PWNED2 file was created');
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test('[ARG] a balanced quote groups words', async () => {
const mod = await import(PARSER_URL);
assert.equal(typeof mod.tokenize, 'function', 'arg-parser.mjs exports tokenize');
assert.deepEqual(mod.tokenize(`a "b c" 'd e' f`), [
{ value: 'a', start: 0, end: 1 },
{ value: 'b c', start: 2, end: 7 },
{ value: 'd e', start: 8, end: 13 },
{ value: 'f', start: 14, end: 15 },
]);
assert.deepEqual(mod.tokenize(`brief.md "Fix the user's login"`).map((t) => t.value),
['brief.md', "Fix the user's login"]);
});
test('[ARG] an unbalanced quote stays a literal character', async () => {
const mod = await import(PARSER_URL);
assert.equal(typeof mod.tokenize, 'function', 'arg-parser.mjs exports tokenize');
assert.deepEqual(mod.tokenize(`Fix the user's login`).map((t) => t.value),
['Fix', 'the', "user's", 'login']);
assert.deepEqual(mod.tokenize('say "hi there').map((t) => t.value),
['say', '"hi', 'there']);
assert.deepEqual(mod.parseArgs(`brief.md it's`, 'trekendsession').positional,
['brief.md', "it's"]);
});
test('[ARG] empty stdin gives no positional, an empty tail and exit 0', async () => {
const mod = await import(PARSER_URL);
assert.equal(typeof mod.parseArgs, 'function', 'arg-parser.mjs exports parseArgs');
for (const input of ['', '\n']) {
const r = runStdin('trekendsession', input);
assert.equal(r.status, 0, `the parser CLI refused empty stdin: ${r.stdout}${r.stderr}`);
const out = JSON.parse(r.stdout);
assert.deepEqual(out.positional, []);
assert.deepEqual(out.errors, []);
assert.equal(out.tail, '', `tail for ${JSON.stringify(input)}`);
}
});
test('[M10] tail keeps a multi-word label whole', async () => {
const mod = await import(PARSER_URL);
assert.equal(typeof mod.parseArgs, 'function', 'arg-parser.mjs exports parseArgs');
const r = mod.parseArgs('brief.md Session 2 of 3', 'trekendsession');
assert.equal(r.tail, 'Session 2 of 3');
assert.equal(r.positional[0], 'brief.md');
});
test('[M10] one outer quote pair around the label is removed', async () => {
const mod = await import(PARSER_URL);
assert.equal(typeof mod.parseArgs, 'function', 'arg-parser.mjs exports parseArgs');
assert.equal(mod.parseArgs(`brief.md 'Session 2 of 3'`, 'trekendsession').tail, 'Session 2 of 3');
assert.equal(mod.parseArgs(`brief.md "Session 2 of 3"`, 'trekendsession').tail, 'Session 2 of 3');
});
test('[M10] an inner apostrophe stays', async () => {
const mod = await import(PARSER_URL);
assert.equal(typeof mod.parseArgs, 'function', 'arg-parser.mjs exports parseArgs');
assert.equal(mod.parseArgs(`brief.md Fix the user's login`, 'trekendsession').tail, "Fix the user's login");
});

View file

@ -145,3 +145,64 @@ test('CLI shim missing required args returns usage error (exit 1)', () => {
assert.equal(parsed.ok, false); assert.equal(parsed.ok, false);
assert.match(parsed.error, /usage:/); assert.match(parsed.error, /usage:/);
}); });
// ---- --gates value rule (fix round 4.5a, #1) ---------------------------------
// The shim is tested only through its CLI; the exit code is asserted before
// any JSON.parse, so a crash or a usage error never reads as a wrong value.
function shimState(args) {
const r = runShim(args);
assert.equal(r.code, 0, `expected exit 0 for ${JSON.stringify(args)}, got ${r.code}: ${r.out}`);
const parsed = JSON.parse(r.out.trim());
assert.equal(parsed.ok, true);
return parsed.next_state;
}
test('[#1] bare --gates as the last argument → gates_on', () => {
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates']), 'gates_on');
});
test('[#1] bare --gates before other flags swallows none of them → exit 0, gates_on', () => {
assert.equal(shimState(['--gates', '--state', 'idle', '--event', 'start']), 'gates_on');
});
test('[#1] --gates closed → gates_on', () => {
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates', 'closed']), 'gates_on');
});
for (const bad of ['TRUE', 'yes', 'adaptive']) {
test(`[#1] --gates ${bad} → usage error (exit 1)`, () => {
const r = runShim(['--state', 'idle', '--event', 'start', '--gates', bad]);
assert.equal(r.code, 1, `expected exit 1 for --gates ${bad}, got ${r.code}: ${r.out}`);
const parsed = JSON.parse(r.out.trim());
assert.equal(parsed.ok, false);
assert.match(parsed.error, /usage/);
});
}
test('--gates true → gates_on', () => {
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates', 'true']), 'gates_on');
});
test('--gates false → auto_running', () => {
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates', 'false']), 'auto_running');
});
test('--gates open → auto_running', () => {
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates', 'open']), 'auto_running');
});
// The `=` form reaches the shim whole as one token; it is read as `--gates <value>`.
test('[#1] sibling: --gates=<value> is read as --gates <value>; an unknown or empty value is the usage error', () => {
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates=true']), 'gates_on');
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates=closed']), 'gates_on');
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates=false']), 'auto_running');
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates=open']), 'auto_running');
for (const bad of ['--gates=maybe', '--gates=']) {
const r = runShim(['--state', 'idle', '--event', 'start', bad]);
assert.equal(r.code, 1, `expected exit 1 for ${bad}, got ${r.code}: ${r.out}`);
const parsed = JSON.parse(r.out.trim());
assert.equal(parsed.ok, false);
assert.match(parsed.error, /usage: --gates takes true, false, closed or open/);
}
});

View file

@ -360,6 +360,22 @@ test('classifySuppression — the refuting reasons the LLM coordinator emits are
'unresolvable must never collapse into refuted'); 'unresolvable must never collapse into refuted');
}); });
test('classifySuppression — a line past the end of the file is unverified, never refuted', () => {
// Fail-closed already: an undeclared reason is unverified. Holds before and
// after the reason is declared (agents/review-coordinator.md Pass 2 Accuracy).
assert.equal(classifySuppression('accuracy:line-out-of-range'), 'unverified');
});
test('[CL] accuracy:line-out-of-range is a declared unverified reason, not a refuting one', () => {
// A line number alone never refutes a finding: the citation is wrong, the
// claim is unchecked. Declaring it here makes the vocabulary test below hold
// the word in agents/review-coordinator.md.
assert.ok(UNVERIFIED_REASONS.includes('accuracy:line-out-of-range'),
'accuracy:line-out-of-range is not in UNVERIFIED_REASONS');
assert.equal(REFUTING_REASONS.has('accuracy:line-out-of-range'), false,
'accuracy:line-out-of-range must never refute');
});
test('suppression vocabulary — the two sets are disjoint and every reason is documented in the prose', () => { test('suppression vocabulary — the two sets are disjoint and every reason is documented in the prose', () => {
const refuting = [...REFUTING_REASONS]; const refuting = [...REFUTING_REASONS];
const overlap = refuting.filter((r) => UNVERIFIED_REASONS.includes(r)); const overlap = refuting.filter((r) => UNVERIFIED_REASONS.includes(r));
@ -439,3 +455,141 @@ test('runContract — the #k id counts rows of one triplet in the final list, so
assert.equal(result.findings[0].id, computeFindingId('bin/slug.mjs', 46, 'MISSING_ERROR_HANDLING'), 'what fill-finding-ids computes for a review.md holding this row alone'); assert.equal(result.findings[0].id, computeFindingId('bin/slug.mjs', 46, 'MISSING_ERROR_HANDLING'), 'what fill-finding-ids computes for a review.md holding this row alone');
assert.equal(result.trimmed_count, 1, 'the trimmed row that Pass 2 set aside is not counted'); assert.equal(result.trimmed_count, 1, 'the trimmed row that Pass 2 set aside is not counted');
}); });
// ---- ORD — the rows a finding joins do not depend on arrival order ---------
/** Every arrival order of `xs`. */
function orders(xs) {
if (xs.length <= 1) return [xs];
return xs.flatMap((x, i) => orders([...xs.slice(0, i), ...xs.slice(i + 1)]).map((rest) => [x, ...rest]));
}
/**
* A row's members, rebuilt from its survivor plus each "Also flagged by" line,
* as sorted `reviewer|title` strings; an untitled member is named by its
* rule_key, as the line names it.
*/
function members(row) {
const out = [`${row.reviewer}|${row.title ?? row.rule_key}`];
for (const m of String(row.detail ?? '').matchAll(/^Also flagged by (.+?): (.+)\.$/gm)) out.push(`${m[1]}|${m[2]}`);
return out.sort();
}
const ORD_C = 'code-correctness-reviewer';
const ORD_F = 'brief-conformance-reviewer';
test('[ORD] correctness A, conformance B, correctness B give the rows {correctness A} and {conformance B, correctness B} in every arrival order', () => {
const at = { file: 'bin/slug.mjs', line: 46, rule_key: 'MISSING_TEST', detail: 'd' };
const cA = { ...at, severity: 'MAJOR', title: 'A', reviewer: ORD_C };
const fB = { ...at, severity: 'MAJOR', title: 'B', reviewer: ORD_F };
const cB = { ...at, severity: 'MAJOR', title: 'B', reviewer: ORD_C };
const expected = [[`${ORD_C}|A`], [`${ORD_F}|B`, `${ORD_C}|B`]];
const all = orders([['cA', cA], ['fB', fB], ['cB', cB]]);
assert.equal(all.length, 6);
for (const order of all) {
const name = order.map(([n]) => n).join(', ');
const rows = dedupByTriplet(order.map(([, f]) => structuredClone(f)));
assert.equal(rows.length, 2, `${name}: ${JSON.stringify(rows.map(members))}`);
const got = rows.map(members).sort((x, y) => x.length - y.length || x.join().localeCompare(y.join()));
assert.deepEqual(got, expected, `order ${name}`);
}
});
test('[ORD] with an off-catalogue rule_key and cA MINOR, fB MAJOR, cB MINOR, every arrival order gives {cA} MINOR, {fB, cB} MAJOR and one verdict', () => {
const at = { file: 'bin/slug.mjs', line: 46, rule_key: 'NOT_IN_THE_CATALOGUE', detail: 'd' };
const cA = { ...at, severity: 'MINOR', title: 'A', reviewer: ORD_C };
const fB = { ...at, severity: 'MAJOR', title: 'B', reviewer: ORD_F };
const cB = { ...at, severity: 'MINOR', title: 'B', reviewer: ORD_C };
const expected = { [`${ORD_C}|A`]: 'MINOR', [`${ORD_F}|B+${ORD_C}|B`]: 'MAJOR' };
const verdicts = new Set();
for (const order of orders([['cA', cA], ['fB', fB], ['cB', cB]])) {
const name = order.map(([n]) => n).join(', ');
const rows = dedupByTriplet(order.map(([, f]) => structuredClone(f)));
const bySet = Object.fromEntries(rows.map((r) => [members(r).join('+'), r.severity]));
assert.deepEqual(bySet, expected, `order ${name}`);
verdicts.add(JSON.stringify(computeVerdict(rows)));
}
assert.equal(verdicts.size, 1, [...verdicts].join(' | '));
assert.equal(JSON.parse([...verdicts][0]).verdict, 'WARN');
});
/** The kept and unverified rows of a run, as sorted `reviewer|title|severity` strings, so two runs can be compared. */
function split(result) {
const key = (f) => `${f.reviewer}|${String(f.title ?? '').trim().toLowerCase()}|${f.severity}`;
return { kept: result.findings.map(key).sort(), unverified: result.unverified.map(key).sort() };
}
test('[ORD] a full tie on one row: one reviewer\'s two same-title BLOCKERs, one with an empty recommended_action, give one verdict in both arrival orders', () => {
const at = { file: 'x.mjs', line: 3, rule_key: 'SECURITY_INJECTION', severity: 'BLOCKER' };
const empty = { ...at, title: 'Injection', recommended_action: '' };
const full = { ...at, title: 'injection ', recommended_action: 'Quote the argument.' };
const a = runContract([{ reviewer: ORD_C, findings: [structuredClone(empty), structuredClone(full)] }]);
const b = runContract([{ reviewer: ORD_C, findings: [structuredClone(full), structuredClone(empty)] }]);
assert.equal(a.verdict, b.verdict, `order (empty, full) gives ${a.verdict}, order (full, empty) gives ${b.verdict}`);
assert.deepEqual(split(a), split(b), 'the kept/unverified split');
});
test('[ORD] a full tie on one row: two /code-review MAJORs titled T, one marked as contradicting the brief, give one verdict in both arrival orders', () => {
const at = { file: 'x.mjs', line: 3, rule_key: 'CODE_REVIEW_DEFECT', severity: 'MAJOR', title: 'T' };
const marked = { ...at, contradicts_brief: 'Non-Goal: no new runtime dependency' };
const plain = { ...at };
const a = runContract([{ reviewer: 'claude-code-review', findings: [structuredClone(marked), structuredClone(plain)] }]);
const b = runContract([{ reviewer: 'claude-code-review', findings: [structuredClone(plain), structuredClone(marked)] }]);
assert.equal(a.verdict, b.verdict, `marked first gives ${a.verdict}, unmarked first gives ${b.verdict}`);
assert.deepEqual(split(a), split(b), 'the kept/unverified split');
});
test('[E20] untitled findings give one verdict and one kept/unverified split in every arrival order', () => {
// One reviewer's two untitled BLOCKERs on one triplet are one row and tie on
// every survivor rule; one has an empty recommended_action, which sets the
// row aside as unverified when it is kept. The canonical order of untitled
// findings decides which is kept, never the arrival order.
const at = { file: 'x.mjs', line: 3, rule_key: 'SECURITY_INJECTION', severity: 'BLOCKER' };
const empty = { ...at, recommended_action: '' };
const full = { ...at, recommended_action: 'Quote the argument.' };
const all = orders([['empty', empty], ['full', full]]);
assert.equal(all.length, 2);
const outcomes = new Set();
for (const order of all) {
const r = runContract([{ reviewer: ORD_C, findings: order.map(([, f]) => structuredClone(f)) }]);
outcomes.add(JSON.stringify({ verdict: r.verdict, ...split(r) }));
}
assert.equal(outcomes.size, 1, [...outcomes].join(' | '));
});
test('[ORD] the title classes are placed in alphabetical order: cA, fB, cB and an untitled correctness BLOCKER give {cA, cU} BLOCKER and {fB, cB} MAJOR and one verdict in all 24 orders', () => {
const K = 'NOT_IN_THE_CATALOGUE';
const at = { file: 'bin/slug.mjs', line: 46, rule_key: K, detail: 'd' };
// cA and cU tie on every survivor rule, so the row's first member in the
// group's order (class A before the untitled findings) is kept: cA, whose
// empty recommended_action sets the row aside as unverified.
const cA = { ...at, severity: 'BLOCKER', title: 'A', reviewer: ORD_C, recommended_action: '' };
const fB = { ...at, severity: 'MAJOR', title: 'B', reviewer: ORD_F };
const cB = { ...at, severity: 'MINOR', title: 'B', reviewer: ORD_C };
const cU = { ...at, severity: 'BLOCKER', reviewer: ORD_C };
const expected = {
[`${ORD_C}|A+${ORD_C}|${K}`]: 'BLOCKER',
[`${ORD_F}|B+${ORD_C}|B`]: 'MAJOR',
};
const all = orders([['cA', cA], ['fB', fB], ['cB', cB], ['cU', cU]]);
assert.equal(all.length, 24);
const verdicts = new Set();
for (const order of all) {
const name = order.map(([n]) => n).join(', ');
const rows = dedupByTriplet(order.map(([, f]) => structuredClone(f)));
const bySet = Object.fromEntries(rows.map((r) => [members(r).join('+'), r.severity]));
assert.deepEqual(bySet, expected, `order ${name}`);
const j = judgeFilter(rows);
verdicts.add(JSON.stringify({
kept: j.kept.map((r) => members(r).join('+')).sort(),
unverified: j.unverified.map((r) => members(r).join('+')).sort(),
verdict: computeVerdict(j.kept, { unverified: j.unverified }).verdict,
}));
}
assert.equal(verdicts.size, 1, [...verdicts].join(' | '));
assert.deepEqual(JSON.parse([...verdicts][0]), {
kept: [`${ORD_F}|B+${ORD_C}|B`],
unverified: [`${ORD_C}|A+${ORD_C}|${K}`],
verdict: 'WARN',
});
});

View file

@ -395,15 +395,16 @@ test('operations.md custom-profile prose matches findProfilePath resolution orde
// --- S24 — anti-false-claim: operations.md + command-modes.md autonomy-gate truth-pass --- // --- S24 — anti-false-claim: operations.md + command-modes.md autonomy-gate truth-pass ---
// //
// autonomy-gate.mjs is a BOOLEAN gate: --gates {true|false} → gates_on|auto_running. // autonomy-gate.mjs is a BOOLEAN gate: --gates [true|false|closed|open] → gates_on|auto_running.
// State machine: idle → gates_on|auto_running → paused_for_gate → completed. // State machine: idle → gates_on|auto_running → paused_for_gate → completed.
// The old prose (operations.md:15 / command-modes.md gates rows) (1) fabricated a // The old prose (operations.md:15 / command-modes.md gates rows) (1) fabricated a
// state machine `idle → approved → executing → merge-pending → main-merged` — only // state machine `idle → approved → executing → merge-pending → main-merged` — only
// `idle` exists in autonomy-gate.mjs STATES; (2) claimed event-emit.mjs "records // `idle` exists in autonomy-gate.mjs STATES; (2) claimed event-emit.mjs "records
// each transition" — it emits 3 named lifecycle events and is decoupled from the // each transition" — it emits 3 named lifecycle events and is decoupled from the
// (pure, no-I/O) autonomy-gate; (3) advertised `--gates {open|closed|adaptive}` — // (pure, no-I/O) autonomy-gate; (3) advertised `--gates {open|closed|adaptive}` —
// open/closed/adaptive is a DERIVED gates_mode policy in the command layer, not a // there is no strictness policy and no `adaptive` value: the flag is boolean, and
// flag value (the CLI shim + all 4 command docs take boolean `--gates {true|false}`). // the CLI shim + all 4 command docs take `--gates [true|false|closed|open]`
// (bare, true, closed = on; false, open = off; fix round 4.5a, #1).
test('operations.md + command-modes.md describe the real autonomy-gate, not a fabricated one [S24]', () => { test('operations.md + command-modes.md describe the real autonomy-gate, not a fabricated one [S24]', () => {
const ops = read('docs/operations.md'); const ops = read('docs/operations.md');
const modes = read('docs/command-modes.md'); const modes = read('docs/command-modes.md');
@ -430,13 +431,13 @@ test('operations.md + command-modes.md describe the real autonomy-gate, not a fa
'docs/operations.md: event-emit records 3 named lifecycle events, not "each transition" of the (decoupled) autonomy-gate; fix the prose', 'docs/operations.md: event-emit records 3 named lifecycle events, not "each transition" of the (decoupled) autonomy-gate; fix the prose',
); );
// F3 — --gates is boolean; open/closed/adaptive is a derived gates_mode policy, not a flag enum // F3 — --gates is boolean; there is no open/closed/adaptive strictness enum
const GATES_ENUM_FORMS = ['{open|closed|adaptive}', '{open\\|closed\\|adaptive}']; const GATES_ENUM_FORMS = ['{open|closed|adaptive}', '{open\\|closed\\|adaptive}'];
for (const [label, txt] of [['operations.md', ops], ['command-modes.md', modes]]) { for (const [label, txt] of [['operations.md', ops], ['command-modes.md', modes]]) {
for (const bad of GATES_ENUM_FORMS) { for (const bad of GATES_ENUM_FORMS) {
assert.ok( assert.ok(
!txt.includes(bad), !txt.includes(bad),
`docs/${label}: --gates flag enum "${bad}" is false — flag is boolean {true|false}; open/closed/adaptive is a derived gates_mode; fix the prose, not this test`, `docs/${label}: --gates flag enum "${bad}" is false — the flag is boolean: bare, true or closed = on, false or open = off, and there is no adaptive; fix the prose, not this test`,
); );
} }
} }
@ -848,7 +849,7 @@ test('v5.1.1 — commands/trekreview.md contains ### High-effort behavior (v5.1.
test('v5.1.1 — commands/trekexecute.md contains ### High-effort behavior (v5.1.1) sub-section', () => { test('v5.1.1 — commands/trekexecute.md contains ### High-effort behavior (v5.1.1) sub-section', () => {
const t = read('commands/trekexecute.md'); const t = read('commands/trekexecute.md');
assert.match(t, /^### High-effort behavior \(v5\.1\.1\)$/m, assert.match(t, /^### High-effort behavior \(v5\.1\.1\)$/m,
'trekexecute.md must contain ### High-effort behavior (v5.1.1) sub-section under Composition rule (gates_mode = closed)'); 'trekexecute.md must contain ### High-effort behavior (v5.1.1) sub-section under Composition rule (high effort changes nothing in /trekexecute)');
}); });
// --- v5.4 — brief.md formalized as PUBLIC CONTRACT (Handover 1) --- // --- v5.4 — brief.md formalized as PUBLIC CONTRACT (Handover 1) ---
@ -2083,8 +2084,11 @@ function schemaRow(id) {
// Each row is pinned to the span it names: its phase heading to the closing // Each row is pinned to the span it names: its phase heading to the closing
// fence of its stats-append stdin block. The trekreview anchor is the full // fence of its stats-append stdin block. The trekreview anchor is the full
// "## Phase 8 — Validate output" so the Phase 8.5 / 8.7 headings never match. // "## Phase 8 — Validate output" so the Phase 8.5 / 8.7 headings never match.
// The trekresearch anchor is "### Stats tracking", the one such heading in
// commands/trekresearch.md.
const STATS_ROWS = [ const STATS_ROWS = [
{ id: 'trekbrief-stats', cmd: 'trekbrief', heading: /^## Phase 7\b(?!\.)/ }, { id: 'trekbrief-stats', cmd: 'trekbrief', heading: /^## Phase 7\b(?!\.)/ },
{ id: 'trekresearch-stats', cmd: 'trekresearch', heading: /^### Stats tracking$/ },
{ id: 'trekplan-stats', cmd: 'trekplan', heading: /^## Phase 12\b(?!\.)/ }, { id: 'trekplan-stats', cmd: 'trekplan', heading: /^## Phase 12\b(?!\.)/ },
{ id: 'trekreview-stats', cmd: 'trekreview', heading: /^## Phase 8 — Validate output/ }, { id: 'trekreview-stats', cmd: 'trekreview', heading: /^## Phase 8 — Validate output/ },
{ id: 'trekexecute-stats (Phase 9 record)', cmd: 'trekexecute', heading: /^## Phase 9\b(?!\.)/ }, { id: 'trekexecute-stats (Phase 9 record)', cmd: 'trekexecute', heading: /^## Phase 9\b(?!\.)/ },

View file

@ -91,3 +91,32 @@ test('done line: --check finds the line for that step only', () => {
assert.equal(run(['--check', '--step', '4', '--report', report]).status, 1); assert.equal(run(['--check', '--step', '4', '--report', report]).status, 1);
} finally { rmSync(dir, { recursive: true, force: true }); } } finally { rmSync(dir, { recursive: true, force: true }); }
}); });
// ---- M11 sibling: only a full 40- or 64-hex commit id ----
test('[M11] sibling: the done line accepts a 64-hex commit id', () => {
// A SHA-256 repository writes a 64-hex HEAD into the line; --check must read it.
const dir = mkdtempSync(join(tmpdir(), 'done-line-sha256-'));
const report = join(dir, 'step-3.report.md');
const sha = '0123abcd'.padEnd(64, 'e');
try {
writeFileSync(report, `# Step 3 report\nDONE step 3: \`node --test tests/ok.test.mjs\` exit 0 at ${sha} 2026-10-06T12:00:00.000Z\n`);
const plain = run(['--check', '--step', '3', '--report', report]);
assert.equal(plain.status, 0, `a 64-hex line is a done line: ${plain.stdout}${plain.stderr}`);
const bound = run(['--check', '--step', '3', '--report', report, '--commit', sha]);
assert.equal(bound.status, 0, `the line at that 64-hex commit: ${bound.stdout}${bound.stderr}`);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('[M11] sibling: the done line refuses a 7-hex id given with --commit, naming 40 or 64 hex', () => {
// A short id can also be a branch name (DJ3); a line typed at a short id
// must not pass the check bound to that short id.
const dir = mkdtempSync(join(tmpdir(), 'done-line-short-'));
const report = join(dir, 'step-3.report.md');
try {
writeFileSync(report, '# Step 3 report\nDONE step 3: `node --test tests/ok.test.mjs` exit 0 at deadbee 2026-10-06T12:00:00.000Z\n');
const r = run(['--check', '--step', '3', '--report', report, '--commit', 'deadbee']);
assert.equal(r.status, 2, `a 7-hex --commit could not be checked (exit 2, never green): ${r.stdout}${r.stderr}`);
assert.match(r.stdout + r.stderr, /40 or 64 hex/, `the refusal names 40 or 64 hex: ${r.stdout}${r.stderr}`);
} finally { rmSync(dir, { recursive: true, force: true }); }
});

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more