Compare commits
No commits in common. "main" and "v6.5.0" have entirely different histories.
124 changed files with 647 additions and 11805 deletions
181
CHANGELOG.md
181
CHANGELOG.md
|
|
@ -4,187 +4,6 @@ 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/).
|
||||
|
||||
## 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
|
||||
|
||||
Wave 3 adds three skills, each one source for a discipline that was copied
|
||||
|
|
|
|||
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -30,11 +30,11 @@ Voyage — a contract-driven Claude Code pipeline: brief, research, plan, execut
|
|||
|
||||
| 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`); 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 |
|
||||
| `/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 |
|
||||
| `/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 |
|
||||
| `/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`; the operator's notes from the annotation page go through `voyage:annotating` | 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 |
|
||||
| `/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 |
|
||||
| `/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 |
|
||||
|
||||
|
|
@ -137,12 +137,9 @@ 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: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: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 |
|
||||
|
||||
> **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`.
|
||||
> **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`.
|
||||
|
||||
## Reference docs (read on demand)
|
||||
|
||||
|
|
|
|||
63
README.md
63
README.md
|
|
@ -72,7 +72,13 @@ No cloud dependency. No GitHub requirement. Works on **Mac, Linux, and Windows**
|
|||
|
||||
### Autonomy mode (`--gates`, v3.4.0)
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
| 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.
|
||||
|
||||
|
|
@ -322,7 +328,7 @@ Every implementation step includes:
|
|||
- **Checkpoint:** — git commit after success
|
||||
- **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. 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.
|
||||
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.
|
||||
|
||||
**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).
|
||||
|
||||
|
|
@ -340,7 +346,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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -390,7 +396,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 |
|
||||
| **Foreground** | `/trekexecute --project <dir> --fg` | Force sequential, ignore 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) 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). |
|
||||
| **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). |
|
||||
|
||||
### Multi-session execution through session orders (worktree-isolated)
|
||||
|
||||
|
|
@ -536,7 +542,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 |
|
||||
| **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 except the brief-conflict rule — 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 — fast correctness-only pass |
|
||||
| **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 |
|
||||
| **Profile** | `/trekreview --profile <name> --project <dir>` | (v4.1.0) Pin model profile for the review phase. See [Profile system](#profile-system-v410). |
|
||||
|
|
@ -565,7 +571,7 @@ The history is the `review.md` files your repo already has; no new store.
|
|||
|
||||
### 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`: 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.
|
||||
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.
|
||||
|
||||
### Feedback loop (Handover 6)
|
||||
|
||||
|
|
@ -661,16 +667,13 @@ 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: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: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 |
|
||||
|
||||
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)
|
||||
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
|
||||
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
|
||||
in `evals/skill-*` for the operator's own eval run. How to write one, starting with a baseline the
|
||||
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
|
||||
|
|
@ -739,30 +742,11 @@ annotation surface the operator pointed at:
|
|||
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
|
||||
done, Copy Prompt, paste back into this chat. **The operator drives every
|
||||
annotation.** Claude never pre-generates suggestions in this flow. The surface
|
||||
has worked this way since v5.0.3; how it got here is in
|
||||
[CHANGELOG.md](CHANGELOG.md) § 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.
|
||||
done, Copy Prompt, paste back into this chat. Claude revises the `.md`
|
||||
freehand from your notes. **The operator drives every annotation.**
|
||||
Claude never pre-generates suggestions in this flow. The surface has worked
|
||||
this way since v5.0.3; how it got here is in [CHANGELOG.md](CHANGELOG.md)
|
||||
§ v5.0.0 → § v5.0.3.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -804,7 +788,7 @@ flowchart TB
|
|||
end
|
||||
subgraph RES["/trekresearch · Phase 4 — parallel"]
|
||||
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
|
||||
subgraph PL["/trekplan"]
|
||||
PLG["Phase 4b · brief-reviewer (gate)"]
|
||||
|
|
@ -823,7 +807,7 @@ flowchart TB
|
|||
BR --> RES --> PL --> EX --> RV
|
||||
```
|
||||
|
||||
`* = 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`.
|
||||
`* = 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`.
|
||||
|
||||
> **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).
|
||||
|
||||
|
|
@ -944,7 +928,7 @@ The `pre-compact-flush.mjs` hook directly fixes the documented P0 in `docs/treke
|
|||
|
||||
## 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>`.
|
||||
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.
|
||||
|
||||
| Profile | Brief | Research | Plan | Execute | Review | Continue | Use case |
|
||||
|---------|-------|----------|------|---------|--------|----------|----------|
|
||||
|
|
@ -999,9 +983,8 @@ voyage/
|
|||
├── 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)
|
||||
├── commands/ 6 slash commands (trekbrief, trekresearch, trekplan, trekexecute, trekreview, trekcontinue) + trekendsession helper
|
||||
├── 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;
|
||||
│ └ annotating: named by /trekbrief, /trekplan and /trekreview)
|
||||
├── skills/ 6 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)
|
||||
├── 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)
|
||||
├── lib/ Zero-dep parsers and validators (CLI shims under lib/validators/)
|
||||
|
|
|
|||
|
|
@ -171,9 +171,8 @@ Severity is fixed by `rule_key`. Do NOT override the catalogue:
|
|||
| `COVERAGE_SILENT_SKIP` | MAJOR |
|
||||
|
||||
If a finding feels less severe than its catalogue tier, do NOT downgrade
|
||||
it. Emit it at the catalogue's severity. Drop 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.
|
||||
it. Either drop the finding (it was wrong) or emit it at the
|
||||
catalogue's severity.
|
||||
|
||||
## Output format
|
||||
|
||||
|
|
@ -255,9 +254,8 @@ parsers find it by reading the last `json` code fence.
|
|||
`(file, line, rule_key)` with `lib/review/fill-finding-ids.mjs`).
|
||||
- `line` is an integer; use `0` when the finding is file-scoped without
|
||||
a specific line (e.g., MISSING_TEST for an entire file).
|
||||
- `rule_key` MUST be in the catalogue. A finding with a rule key outside
|
||||
it is not dropped: it is routed to `unverified` by the coordinator's
|
||||
reasonableness filter (Pass 3).
|
||||
- `rule_key` MUST be in the catalogue. Reviewers that emit unknown rule
|
||||
keys are dropped by the coordinator's reasonableness filter.
|
||||
|
||||
## Rules
|
||||
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ review as a quality gate and need per-dimension granularity.
|
|||
|
||||
Produce **two artifacts in this order**:
|
||||
|
||||
1. A prose report (for humans and for /trekplan Phase 4b).
|
||||
1. A prose report (for humans and for `planning-orchestrator` Phase 1b).
|
||||
2. A final fenced `json` block with per-dimension numeric scores (for callers
|
||||
that gate on machine-readable output, such as `/trekbrief` Phase 4).
|
||||
|
||||
|
|
|
|||
|
|
@ -93,9 +93,8 @@ Cite both the changed file:line AND the regressed file:line.
|
|||
|
||||
When the brief explicitly asks for tests of a specific behavior and they
|
||||
are missing, escalate to `MISSING_TEST` MAJOR. When tests are
|
||||
nice-to-have, downgrade is forbidden — emit at the catalogue tier. Drop
|
||||
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.
|
||||
nice-to-have, downgrade is forbidden — emit at the catalogue tier or
|
||||
drop the finding.
|
||||
|
||||
### 5. Placeholder code — `PLACEHOLDER_IN_CODE` (MAJOR)
|
||||
|
||||
|
|
@ -111,8 +110,9 @@ literals or example fenced blocks):
|
|||
- `// stub`
|
||||
- `throw new Error('not implemented')`
|
||||
|
||||
Cite the exact line. Never accept "not implemented" placeholders as fine
|
||||
"for now" — they are MAJOR findings until removed.
|
||||
Cite the exact line. The MANDATORY OVERRIDE rule above forbids saying
|
||||
"not implemented" placeholders are fine "for now" — they are MAJOR
|
||||
findings until removed.
|
||||
|
||||
### 6. Security surface — `SECURITY_INJECTION` (BLOCKER)
|
||||
|
||||
|
|
@ -154,10 +154,8 @@ Severity is fixed by `rule_key`. Do NOT override the catalogue:
|
|||
| `UNDECLARED_DEPENDENCY` | MAJOR |
|
||||
| `COVERAGE_SILENT_SKIP` | MAJOR |
|
||||
|
||||
If a finding feels off-tier, emit it at the catalogue's severity. 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. Do not invent severity
|
||||
overrides.
|
||||
If a finding feels off-tier, either drop it (it was wrong) or emit it
|
||||
at the catalogue's severity. Do not invent severity overrides.
|
||||
|
||||
## Output format
|
||||
|
||||
|
|
@ -241,9 +239,8 @@ parsers find it by reading the last `json` code fence.
|
|||
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;
|
||||
`0` for file-scoped findings.
|
||||
- `rule_key` MUST be in the catalogue. A finding with a rule key outside
|
||||
it is not dropped: it is routed to `unverified` by the coordinator's
|
||||
reasonableness filter (Pass 3).
|
||||
- `rule_key` MUST be in the catalogue. Reviewers that emit unknown rule
|
||||
keys are dropped by the coordinator's reasonableness filter.
|
||||
|
||||
## Rules
|
||||
|
||||
|
|
@ -258,10 +255,8 @@ parsers find it by reading the last `json` code fence.
|
|||
injection sink. Never flag a regression without naming both files.
|
||||
Speculative findings are dropped by the coordinator.
|
||||
- **No silent severity downgrades.** The catalogue tier is the floor.
|
||||
If a finding feels less serious than its catalogue severity, emit it
|
||||
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.
|
||||
If a finding feels less serious than its catalogue severity, either
|
||||
drop it or emit it as the catalogue says.
|
||||
- **Token budget honesty.** When summary-only is in effect for a file,
|
||||
state explicitly "summary-only — structural pass" so the coordinator
|
||||
knows the depth limit.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
model: opus
|
||||
color: cyan
|
||||
tools: ["Read", "Glob", "Grep"]
|
||||
tools: ["Read", "Glob", "Grep", "Write", "Edit", "Bash"]
|
||||
---
|
||||
|
||||
<!-- Phase mapping: orchestrator → command
|
||||
|
|
@ -87,7 +87,7 @@ Classify:
|
|||
- **Medium** (50–500 files)
|
||||
- **Large** (> 500 files)
|
||||
|
||||
Codebase size decides only whether convention-scanner runs.
|
||||
Codebase size controls `maxTurns` per agent, NOT which agents run.
|
||||
|
||||
### Phase 1b — Brief review
|
||||
|
||||
|
|
@ -120,7 +120,8 @@ scan results only.
|
|||
**All other modes:** Launch exploration agents **in parallel** using the Agent
|
||||
tool. Use specialized agents from the plugin.
|
||||
|
||||
**All agents run for all codebase sizes.**
|
||||
**All agents run for all codebase sizes.** Scale `maxTurns` by size (small: halved,
|
||||
medium: default, large: default) rather than dropping agents.
|
||||
|
||||
| Agent | Small | Medium | Large | Purpose |
|
||||
|-------|-------|--------|-------|---------|
|
||||
|
|
@ -133,7 +134,7 @@ tool. Use specialized agents from the plugin.
|
|||
| `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** — use the `convention-scanner` plugin agent (model: phase_signal_result.model, the agent's frontmatter as the fallback)
|
||||
**Convention Scanner** — use the `convention-scanner` plugin agent (model: "opus")
|
||||
for medium+ codebases only. Pass the task description as context.
|
||||
|
||||
**research-scout** — launch conditionally if the task involves technologies, APIs,
|
||||
|
|
@ -486,7 +487,7 @@ When done, your output message should contain:
|
|||
You can:
|
||||
- Review the full plan at {plan path}
|
||||
- Ask questions or request changes
|
||||
- Say "execute" for the /trekexecute line
|
||||
- Say "execute" to implement
|
||||
- Say "save" to keep for later
|
||||
```
|
||||
|
||||
|
|
@ -511,4 +512,5 @@ You can:
|
|||
If you don't know the exact change, mark it as `[ASSUMPTION]` and explain what
|
||||
information is missing.
|
||||
- **Honesty:** If the task is trivial, say so. Don't inflate the plan.
|
||||
- **Adaptive:** All agents run for all sizes.
|
||||
- **Adaptive:** All agents run for all sizes. Scale turns down for small codebases,
|
||||
not agent count.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
model: opus
|
||||
color: cyan
|
||||
tools: ["Read", "Glob", "Grep"]
|
||||
tools: ["Read", "Glob", "Grep", "Write", "Edit", "Bash"]
|
||||
---
|
||||
|
||||
<!-- Phase mapping: orchestrator → command
|
||||
|
|
@ -88,22 +88,12 @@ Based on the mode, determine which agent groups to launch:
|
|||
| `docs-researcher` | Official documentation, RFCs, vendor docs |
|
||||
| `community-researcher` | Real-world experience, issues, blog posts, discussions |
|
||||
| `security-researcher` | CVEs, audit history, supply chain risks |
|
||||
| `contrarian-researcher` | Counter-evidence, overlooked alternatives, reasons to reconsider; runs in wave 2, against wave 1's leading answer |
|
||||
| `contrarian-researcher` | Counter-evidence, overlooked alternatives, reasons to reconsider |
|
||||
|
||||
### Phase 2 — Parallel research
|
||||
|
||||
Launch the selected agents in two waves.
|
||||
|
||||
**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.
|
||||
Launch ALL selected agents **in parallel** using the Agent tool — one message,
|
||||
multiple tool calls. This maximizes concurrency.
|
||||
|
||||
**Prompting local agents for research (not planning):**
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ description: |
|
|||
APIs, or libraries where official documentation and known issues should be checked.
|
||||
model: opus
|
||||
color: blue
|
||||
tools: ["WebSearch", "WebFetch", "Read", "mcp__tavily__tavily_search", "mcp__tavily__tavily_research"]
|
||||
tools: ["WebSearch", "WebFetch", "Read"]
|
||||
skills: ["voyage:researching-sources"]
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -65,8 +65,7 @@ You will receive a prompt containing:
|
|||
(reasonableness filter) except the brief-conflict rule, which applies in every mode;
|
||||
Passes 1, 2, 4 still run.
|
||||
- **Rule catalogue** — `lib/review/rule-catalogue.mjs`. Findings whose
|
||||
`rule_key` is not in this set are routed to `unverified` by Pass 3
|
||||
(`unknown-rule_key`).
|
||||
`rule_key` is not in this set are dropped by Pass 3.
|
||||
|
||||
## Your 4-pass process
|
||||
|
||||
|
|
@ -79,14 +78,9 @@ re-reading, and fresh finding generation are all forbidden.
|
|||
Two findings collide when their `(file, line, rule_key)` triplets are
|
||||
identical, with one exception: **one reviewer never duplicates itself.** 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.
|
||||
Group in two passes, so the order the findings arrive in never changes a row:
|
||||
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:
|
||||
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
|
||||
shares, else the first one. When findings collide:
|
||||
- Rank the colliding findings and keep the first:
|
||||
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
|
||||
|
|
@ -98,10 +92,7 @@ collide:
|
|||
3. among findings of one kind, the higher asserted severity;
|
||||
4. among reviewer-agent findings, prefer the finding from
|
||||
`brief-conformance-reviewer` (its findings are anchored to the brief);
|
||||
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.
|
||||
5. then the first seen.
|
||||
- Write the kept finding with the highest severity any finding in its
|
||||
group asserted, so dropping a duplicate never lowers the group and an
|
||||
engine's brief-conflict mark never lowers what a reviewer agent asserted.
|
||||
|
|
@ -122,7 +113,7 @@ is load-bearing** — see *Suppression is two-valued* below:
|
|||
| 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. |
|
||||
| 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`) |
|
||||
| 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`) |
|
||||
| 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
|
||||
|
|
@ -134,11 +125,8 @@ voyage-lekerepo trial, 2026-10-06.)
|
|||
|
||||
Succinctness and Actionability read the finding's *packaging*; neither
|
||||
examines the claim, so neither can establish the finding is unreal. Accuracy
|
||||
does: a citation that escapes the repo root, or a negative line, refutes the
|
||||
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`).
|
||||
does: a citation that escapes the repo root refutes the finding as a claim
|
||||
about this codebase.
|
||||
|
||||
**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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
model: opus
|
||||
color: red
|
||||
tools: ["Read", "Glob", "Grep"]
|
||||
tools: ["Read", "Glob", "Grep", "Write", "Edit", "Bash"]
|
||||
---
|
||||
|
||||
<!-- Phase mapping: orchestrator → command
|
||||
|
|
@ -54,8 +54,8 @@ You will receive a prompt containing:
|
|||
contract that bounds review scope.
|
||||
- **Mode** — `default`, `quick`, `validate`, or `dry-run`.
|
||||
- `default` — run the full pipeline.
|
||||
- `quick` — skip the coordinator's reasonableness filter except the
|
||||
brief-conflict rule; use single reviewer (code-correctness only) for faster turnaround.
|
||||
- `quick` — skip the coordinator's reasonableness filter; use single
|
||||
reviewer (code-correctness only) for faster turnaround.
|
||||
- `validate` — schema-only check on existing review.md, no LLM calls.
|
||||
- `dry-run` — print the discovered scope and triage map; skip writes.
|
||||
- **Since-ref** (optional) — explicit `--since <ref>` override for the SHA
|
||||
|
|
@ -72,12 +72,9 @@ Execute these phases in order. Do not skip phases.
|
|||
|
||||
### Phase 1 — Parse mode and validate input
|
||||
|
||||
Run the arg-parser via Bash, with the arguments as the one line inside the
|
||||
quoted heredoc (on stdin, never as shell words):
|
||||
Run the arg-parser via Bash:
|
||||
```
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekreview --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'
|
||||
$ARGUMENTS
|
||||
VOYAGE_ARGS_EOF_7F3C9E
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekreview "$@"
|
||||
```
|
||||
|
||||
Pull the structured flags from its JSON output. Reject unknown flags. If
|
||||
|
|
@ -126,8 +123,8 @@ produces a treatment map:
|
|||
| Treatment | When |
|
||||
|-----------|------|
|
||||
| `skip` | Matches `*.lock`, `*.svg`, `dist/**`, `build/**`, `node_modules/**`, generated-file marker present in first 3 lines |
|
||||
| `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/**` |
|
||||
| `deep-review` | Matches `auth/**`, `crypto/**`, `**/security/**`, `hooks/**` |
|
||||
| `summary-only` | Default treatment for everything else |
|
||||
|
||||
Hard refuse-with-suggestion gates (use AskUserQuestion):
|
||||
- > 100 files in the diff
|
||||
|
|
@ -188,19 +185,9 @@ The coordinator runs a 4-pass process:
|
|||
2. **HubSpot Judge filters** — Accuracy refutes; Actionability sets aside
|
||||
as unverified; Succinctness never removes a finding (only MINOR/SUGGESTION
|
||||
text is trimmed, and counted).
|
||||
3. **Cloudflare reasonableness** — drop a finding 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.
|
||||
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.
|
||||
3. **Cloudflare reasonableness** — drop speculative findings without a
|
||||
`file:line` citation; drop findings whose `rule_key` is not in
|
||||
`RULE_CATALOGUE`.
|
||||
4. **Compute verdict** — `BLOCK` if `BLOCKER ≥ 1`, `WARN` if `MAJOR ≥ 1`,
|
||||
else `ALLOW`.
|
||||
|
||||
|
|
@ -209,8 +196,8 @@ The coordinator's output is the full review.md content — frontmatter
|
|||
and `## Remediation Steps` with one numbered step per BLOCKER) + trailing
|
||||
JSON block — ready to write.
|
||||
|
||||
In `quick` mode, skip pass 3 (reasonableness filter), except the
|
||||
brief-conflict rule, which applies in every mode. Passes 1, 2, 4 still run.
|
||||
In `quick` mode, skip pass 3 (reasonableness filter). Passes 1, 2, 4
|
||||
still run.
|
||||
|
||||
### Phase 7 — Write review.md
|
||||
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ Extract from the plan:
|
|||
|
||||
**Manifest handling:**
|
||||
- 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 /trekplan." Do not attempt to synthesize.
|
||||
STOP with error "Plan claims v1.7 but step N lacks Manifest. Re-run
|
||||
planning-orchestrator." Do not attempt to synthesize.
|
||||
- If no `plan_version` marker is present: treat as legacy v1.6. Synthesize
|
||||
minimal manifests from `Files:` (expected_paths) and the Checkpoint commit
|
||||
message (commit_message_pattern escaped). Mark output session specs with
|
||||
|
|
|
|||
|
|
@ -40,25 +40,15 @@ Parse `$ARGUMENTS`:
|
|||
2. Otherwise: **mode = default**. Interview probes each section until the
|
||||
completeness gate (Phase 3) and brief-review gate (Phase 4) both pass.
|
||||
|
||||
3. `--gates` flag (autonomy control, may combine with any mode): `--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. `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.
|
||||
3. `--gates` flag (autonomy control, may combine with any mode): when
|
||||
present, set `gates_mode = true`. This re-enables approval pauses at
|
||||
every phase boundary in the downstream pipeline (research, plan,
|
||||
execute) and at every wave in the executor. Default `gates_mode = false`
|
||||
means auto mode runs continuously until the main-merge gate (which is
|
||||
the one boundary that ALWAYS pauses, regardless of `gates_mode`). Strip
|
||||
the flag from `$ARGUMENTS` before further parsing. The flag is consumed
|
||||
by the autonomy-gate state machine via the CLI shim:
|
||||
`node ${CLAUDE_PLUGIN_ROOT}/lib/util/autonomy-gate.mjs --state X --event Y --gates {true|false}`.
|
||||
|
||||
4. `--approve <project-dir | brief-file>`: set **mode = approve**. No
|
||||
interview, no drafting. Set `APPROVE_ARG` to the argument and resolve it:
|
||||
|
|
@ -841,11 +831,14 @@ sub-point) and `BRIEF_SC_BASH_NOT_PINNED` (the brief requires bash 3.2 but a
|
|||
criterion runs plain `bash`: offer `/bin/bash`).
|
||||
|
||||
**Build the operator-annotation HTML, then print the report.** After the
|
||||
brief is validated, run `scripts/annotate.mjs` to build the page where the
|
||||
operator writes their own notes. The loop that follows is the skill
|
||||
`voyage:annotating`: call it when the operator pastes the notes back (a
|
||||
Question is answered, not applied; after Phase 4h, a note on `## Intent` or
|
||||
`## Goal` makes the approval stale, and that is said before the edit).
|
||||
brief is validated, run `scripts/annotate.mjs` to produce a self-contained
|
||||
HTML file the operator opens in their browser. The HTML renders the brief
|
||||
with line numbers, lets the operator click any line to attach their own
|
||||
note (not Claude-generated suggestions — the operator drives every
|
||||
annotation), keeps a sidebar of all notes, persists state in 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 `brief.md` freehand from the notes.
|
||||
|
||||
```bash
|
||||
ANNOT_HTML=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/annotate.mjs "{PROJECT_DIR}/brief.md" 2>&1)
|
||||
|
|
@ -874,9 +867,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,
|
||||
the "Copy Prompt" button gathers them into one structured prompt.
|
||||
Paste that prompt back into this chat: Claude answers each Question
|
||||
and revises brief.md from your Fix and Change notes. Annotations
|
||||
persist in your browser if you close the tab and reopen the same file.
|
||||
Paste that prompt back into this chat and Claude revises brief.md
|
||||
from your notes. Annotations persist in your browser if you close
|
||||
the tab and reopen the same file.
|
||||
────────────────────────────────────────────────────────────────────
|
||||
```
|
||||
|
||||
|
|
@ -975,10 +968,7 @@ printed `stamped: true`, then stop and print the next step:
|
|||
|
||||
## Phase 5 — Auto-orchestration opt-in (if research_topics > 0)
|
||||
|
||||
**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.
|
||||
**Skip this phase if research_topics = 0.** Proceed directly to Phase 6.
|
||||
|
||||
Ask the user via `AskUserQuestion`:
|
||||
|
||||
|
|
@ -1012,13 +1002,13 @@ Then:
|
|||
/trekexecute --project {PROJECT_DIR}
|
||||
```
|
||||
|
||||
Run Phase 7 (`auto_result: manual`), then stop. Do not continue to Phase 6.
|
||||
Stop. Do not continue to Phase 6.
|
||||
|
||||
### Auto path
|
||||
|
||||
**Auto requires an approved intent.** If Phase 4h ended "Leave unapproved",
|
||||
the auto chain would halt at `/trekplan` anyway — print the manual-path
|
||||
output instead, run Phase 7 (`auto_result: manual`) and stop.
|
||||
output instead and stop.
|
||||
|
||||
Set `auto_research: true` in the brief's frontmatter (edit the file).
|
||||
|
||||
|
|
@ -1027,18 +1017,18 @@ stamp — on both paths — so it is not emitted again here.
|
|||
|
||||
If `gates_mode == true`: pause here via `AskUserQuestion` —
|
||||
"Auto-mode confirmed. Proceed to research now? (yes/no)". If the user
|
||||
answers no, fall back to the manual path output, run Phase 7
|
||||
(`auto_result: cancelled`) and stop. Otherwise proceed to Phase 6.
|
||||
answers no, fall back to the manual path output and stop. Otherwise
|
||||
proceed to Phase 6.
|
||||
|
||||
If `gates_mode == false` (default in auto): proceed directly to Phase 6.
|
||||
The chain ends when `/trekplan` has written the plan (Step 6e); it never
|
||||
starts `/trekexecute`.
|
||||
The chain stops only at the main-merge gate (see `commands/trekexecute.md`
|
||||
Phase 8).
|
||||
|
||||
Proceed to Phase 6.
|
||||
|
||||
## Phase 6 — Auto research dispatch (auto path only)
|
||||
|
||||
**Runs only when user opted into auto mode, and only after Phase 4h approved the intent.**
|
||||
**Runs only when user opted into auto mode.**
|
||||
|
||||
### Step 6a — Confirm proceed
|
||||
|
||||
|
|
@ -1052,10 +1042,9 @@ Continue?"
|
|||
| Option | Action |
|
||||
|--------|--------|
|
||||
| **Continue — auto** | Proceed. |
|
||||
| **Cancel — do manual** | Revert to manual path (run Phase 7 with `auto_result: cancelled`, print commands, stop). |
|
||||
| **Cancel — do manual** | Revert to manual path (print commands, stop). |
|
||||
|
||||
If cancelled → fall back to manual path output, run Phase 7
|
||||
(`auto_result: cancelled`) and stop.
|
||||
If cancelled → fall back to manual path output and stop.
|
||||
|
||||
### Step 6b — Run research topics sequentially (inline)
|
||||
|
||||
|
|
@ -1092,8 +1081,7 @@ ls -1 {PROJECT_DIR}/research/*.md | wc -l
|
|||
```
|
||||
|
||||
Expected count: N. If any are missing, report and ask the user how to
|
||||
proceed (retry, skip missing topic, cancel). On cancel, run Phase 7
|
||||
(`auto_result: cancelled`) and stop.
|
||||
proceed (retry, skip missing topic, cancel).
|
||||
|
||||
Update brief frontmatter: `research_status: complete`.
|
||||
|
||||
|
|
@ -1107,12 +1095,11 @@ Invoke the planning command inline in this session:
|
|||
|
||||
The planning pipeline runs all phases (exploration, synthesis, review) in
|
||||
main context. Wait for the plan to be written to `{PROJECT_DIR}/plan.md`
|
||||
before continuing. If `/trekplan` stops before it writes the plan, report
|
||||
where it stopped, run Phase 7 (`auto_result: failed`) and stop.
|
||||
before continuing.
|
||||
|
||||
### Step 6e — Report completion
|
||||
|
||||
When /trekplan finishes, present:
|
||||
When the planning-orchestrator finishes, present:
|
||||
|
||||
```
|
||||
## Ultrabrief + Ultraresearch + Voyage Complete (auto mode)
|
||||
|
|
@ -1200,6 +1187,9 @@ Examples:
|
|||
VOYAGE_PROFILE=balanced /trekbrief
|
||||
```
|
||||
|
||||
Stats records emit `profile`, `phase_models`, and `profile_source` so operators
|
||||
can audit which profile drove which session.
|
||||
|
||||
## Hard rules
|
||||
|
||||
1. **Interactive only.** This command requires user input. There is no
|
||||
|
|
|
|||
|
|
@ -29,22 +29,9 @@ See **Handover 7** in `docs/HANDOVER-CONTRACTS.md` for the full schema.
|
|||
|
||||
## Phase 0 — `--help` handling
|
||||
|
||||
Parse `$ARGUMENTS` with the shared arg-parser. Run first, exactly like this:
|
||||
|
||||
```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):
|
||||
Parse `$ARGUMENTS` with `parseArgs($ARGUMENTS, 'trekcontinue')` from
|
||||
`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):
|
||||
|
||||
- `flags['--help'] === true`, OR
|
||||
- `positional[0] === '-h'` (single-dash short form — the parser keeps it as
|
||||
|
|
@ -88,7 +75,7 @@ Typical flow:
|
|||
|
||||
## Phase 0.5 — Cleanup mode dispatch
|
||||
|
||||
After the parser has printed its parse of `$ARGUMENTS`, check the parsed `flags`
|
||||
After `parseArgs` has resolved `$ARGUMENTS`, check the parsed `flags`
|
||||
object directly (NOT a string contains-check on raw `$ARGUMENTS` — that
|
||||
substring pattern was the root cause of Bug 1).
|
||||
|
||||
|
|
@ -187,28 +174,23 @@ locations; this phase refuses ambiguity before validating the state file.
|
|||
After resolving the project directory and state-file path, look for two
|
||||
`NEXT-SESSION-PROMPT.local.md` candidates:
|
||||
|
||||
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
|
||||
a. `<plugin-root>/NEXT-SESSION-PROMPT.local.md` — operator-managed master file
|
||||
b. `<project-dir>/NEXT-SESSION-PROMPT.local.md` — producer-written sibling
|
||||
|
||||
**If both exist:**
|
||||
|
||||
- Read both via the **Read tool** (NOT Bash — same anti-substitution rule
|
||||
as Phase 2).
|
||||
- Invoke the consistency validator with the paths emitted as concrete
|
||||
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):
|
||||
- Invoke the consistency validator with both paths emitted as concrete
|
||||
literal tokens (no template substitution at the Bash boundary):
|
||||
|
||||
```
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/next-session-prompt-validator.mjs --json --consistency <RESOLVED-PATH-A> <RESOLVED-PATH-B> --state-file <RESOLVED-PATH-STATE>
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/next-session-prompt-validator.mjs --json --consistency <RESOLVED-PATH-A> <RESOLVED-PATH-B>
|
||||
```
|
||||
|
||||
Replace `<RESOLVED-PATH-A>` and `<RESOLVED-PATH-B>` with the two concrete
|
||||
filesystem paths you have in your working context, and
|
||||
`<RESOLVED-PATH-STATE>` with the concrete path of the project's
|
||||
`.session-state.local.json`. The validator emits `{valid, errors, warnings}`
|
||||
JSON on stdout.
|
||||
filesystem paths you have in your working context. The validator emits
|
||||
`{valid, errors, warnings}` JSON on stdout.
|
||||
|
||||
- **If `valid: false`** (typically `NEXT_SESSION_PROMPT_PRODUCER_MISMATCH`):
|
||||
print the structured `errors[]` (each `[code] message` on its own line),
|
||||
|
|
@ -346,6 +328,8 @@ Examples:
|
|||
VOYAGE_PROFILE=economy /trekcontinue # env-var override
|
||||
```
|
||||
|
||||
Stats records emit `profile` and `profile_source` per Phase 5 record.
|
||||
|
||||
## Hard rules
|
||||
|
||||
- **Idempotent.** Running `/trekcontinue` twice in the same Claude session
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
name: trekendsession
|
||||
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"
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Voyage End-Session Local v1.0
|
||||
|
|
@ -30,32 +29,8 @@ See **Handover 7** in `docs/HANDOVER-CONTRACTS.md` for the schema.
|
|||
|
||||
## Phase 0 — `--help` handling
|
||||
|
||||
Parse `$ARGUMENTS` with the arg parser. Run this via the Bash tool at runtime
|
||||
(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.
|
||||
If `$ARGUMENTS` contains `--help` or `-h`, print the usage block below and exit
|
||||
cleanly. Do NOT proceed to any further phase.
|
||||
|
||||
```
|
||||
/trekendsession — Mark current session done; point at next session.
|
||||
|
|
@ -101,10 +76,8 @@ Decision tree:
|
|||
|
||||
## Phase 2 — Required args check (headless-safe)
|
||||
|
||||
Read the parse result from Phase 0: `<next-brief-path>` is `positional[0]`
|
||||
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:
|
||||
Read `$ARGUMENTS`. Both `<next-brief-path>` and `<next-label>` are required.
|
||||
If either is missing or empty:
|
||||
|
||||
```
|
||||
Error: missing required args.
|
||||
|
|
@ -135,41 +108,29 @@ 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
|
||||
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
|
||||
project directory with YAML frontmatter (`produced_by: trekendsession`,
|
||||
`produced_at: {ISO-8601}`, `project: {project_dir}`). Both files are written
|
||||
in a single ESM block so the writes succeed or fail together.
|
||||
|
||||
Run the block below via the Bash tool at runtime, exactly as written: put no
|
||||
value from Phase 1 or Phase 2 into it. This is NOT an eager-exec block — the
|
||||
files do not exist at command-load time. The block takes nothing on its shell
|
||||
line: it re-reads the arguments as the one line inside the quoted heredoc, on
|
||||
stdin, never as shell words (the heredoc is the last thing in the command),
|
||||
parses them with the same `parseArgs` as Phase 0 (the brief path is
|
||||
`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`:
|
||||
Run the block below via the Bash tool at runtime, substituting the resolved
|
||||
values for the `{curly}` placeholders (Phase 1 gives `{project_dir}`, Phase 2
|
||||
gives `{next_brief_path}` and `{next_label}`). This is NOT an eager-exec
|
||||
block — the values do not exist at command-load time. The import path 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
|
||||
node --input-type=module -e "
|
||||
import path from 'node:path';
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { atomicWriteJson } from '${CLAUDE_PLUGIN_ROOT}/lib/util/atomic-write.mjs';
|
||||
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 [, dir, brief, label] = process.argv;
|
||||
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 stateFile = path.join(dir, '.session-state.local.json');
|
||||
|
|
@ -179,9 +140,7 @@ const promptBody = '---\\nproduced_by: trekendsession\\nproduced_at: ' + now + '
|
|||
writeFileSync(promptFile, promptBody);
|
||||
console.log(stateFile);
|
||||
console.log(promptFile);
|
||||
" <<'VOYAGE_ARGS_EOF_7F3C9E'
|
||||
$ARGUMENTS
|
||||
VOYAGE_ARGS_EOF_7F3C9E
|
||||
" '{project_dir}' '{next_brief_path}' '{next_label}'
|
||||
```
|
||||
|
||||
## Phase 4 — Validate + narrate
|
||||
|
|
@ -217,7 +176,6 @@ before retrying — usually means a bad path or label argument.
|
|||
- **Atomic write only.** Tmp + rename — no partial state files on disk.
|
||||
- **Zero secrets.** Status, paths, labels — never API keys, never user content
|
||||
beyond filenames.
|
||||
- **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).
|
||||
- **NEVER auto-invoke this command.** It is operator-typed only at session-end.
|
||||
- **Idempotent within a session.** Running twice with the same args
|
||||
overwrites cleanly (atomic rename); does not double-advance.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
name: trekexecute
|
||||
description: Disciplined plan executor — single-session, or multi-session through session orders the user starts, with failure recovery
|
||||
argument-hint: "[--project <dir>] [--profile <name>] [--gates [true|false|closed|open]] [--fg | --resume | --dry-run | --validate | --step N | --session N] [plan.md]"
|
||||
argument-hint: "[--project <dir>] [--fg | --resume | --dry-run | --validate | --step N | --session N] [plan.md]"
|
||||
allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion, Agent, Skill
|
||||
disallowed-tools: TeamCreate
|
||||
---
|
||||
|
|
@ -48,22 +48,6 @@ Parse `$ARGUMENTS` for mode flags:
|
|||
use that path but keep `project_dir` set (user may be pointing at a
|
||||
recovery session spec inside the 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**.
|
||||
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**.
|
||||
|
|
@ -72,22 +56,10 @@ Parse `$ARGUMENTS` for mode flags:
|
|||
Set **mode = step**, **target-step = N**.
|
||||
7. If arguments contain `--session N` (N is a positive integer): extract N and the file path.
|
||||
Set **mode = session**, **target-session = N**.
|
||||
8. If arguments contain `--gates`: `--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. 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).
|
||||
8. If arguments contain `--gates`: set `gates_mode = true`. Default
|
||||
`gates_mode = false`. The flag is consumed by the autonomy-gate state
|
||||
machine via the CLI shim:
|
||||
`node ${CLAUDE_PLUGIN_ROOT}/lib/util/autonomy-gate.mjs --state X --event Y --gates {true|false}`.
|
||||
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
|
||||
asked by the guard, which asks before every merge into main and every push,
|
||||
|
|
@ -95,9 +67,7 @@ Parse `$ARGUMENTS` for mode flags:
|
|||
(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
|
||||
a lock, and the guard's question is the one question.
|
||||
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**.
|
||||
9. Otherwise: the entire argument string is the file path. Set **mode = execute**.
|
||||
|
||||
If no path is provided (and `--project` was not used to derive one), output
|
||||
usage and stop:
|
||||
|
|
@ -111,7 +81,6 @@ Usage: /trekexecute <plan.md>
|
|||
/trekexecute --validate <plan.md>
|
||||
/trekexecute --step N <plan.md>
|
||||
/trekexecute --session N <plan.md>
|
||||
/trekexecute --profile <name> <plan.md>
|
||||
/trekexecute --project <dir> --resume
|
||||
/trekexecute --project <dir> --session N
|
||||
|
||||
|
|
@ -125,8 +94,6 @@ Modes:
|
|||
--validate Schema-only check — parse steps + manifests, no security scan, no execution
|
||||
--step N Execute only step N (foreground)
|
||||
--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:
|
||||
/trekexecute .claude/plans/trekplan-2026-04-06-auth-refactor.md
|
||||
|
|
@ -269,7 +236,7 @@ mode; absence or <1.7 as legacy mode.
|
|||
- 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.
|
||||
Re-run /trekplan — the plan is not executable.
|
||||
Re-run planning-orchestrator — plan is not executable.
|
||||
```
|
||||
|
||||
**Legacy mode (no plan_version, or < 1.7):**
|
||||
|
|
@ -367,11 +334,11 @@ Reason: {specific error from Phase 2}
|
|||
{if format not recognized}:
|
||||
Detected heading format: {e.g. "### Fase 1:", "## Phase 1"}
|
||||
Expected: "### Step N: <description>"
|
||||
Fix: re-run /trekplan — the plan must use v1.7 format
|
||||
Fix: re-run /trekplan — planning-orchestrator must emit v1.7 format
|
||||
|
||||
{if missing manifest}:
|
||||
Step {N} has no Manifest block (plan_version=1.7 requires one per step)
|
||||
Fix: re-run /trekplan — every step needs a Manifest YAML block
|
||||
Fix: re-run /trekplan — planning-orchestrator must include manifest YAML
|
||||
|
||||
{if malformed YAML or invalid regex}:
|
||||
Step {N}: {specific YAML/regex error}
|
||||
|
|
@ -465,7 +432,6 @@ Determine how to execute this plan:
|
|||
- `--step N` mode
|
||||
- `--resume` mode
|
||||
- `--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 Execution Strategy with only 1 session
|
||||
|
||||
|
|
@ -616,8 +582,9 @@ These sessions cannot safely run in parallel.
|
|||
Fix the Execution Strategy in the plan, or use --fg for sequential execution.
|
||||
```
|
||||
|
||||
Stop execution. This check runs at execution time, so it also catches a plan
|
||||
that was edited by hand after planning.
|
||||
Stop execution. This is a defense-in-depth check — the planning-orchestrator
|
||||
should have prevented this, but verifying at execution time catches plans
|
||||
that were manually edited or have bugs.
|
||||
|
||||
### Check 4 — Stale worktree cleanup
|
||||
|
||||
|
|
@ -1184,7 +1151,6 @@ progress file.
|
|||
"exit_condition_checked": false,
|
||||
"plan_verification": null,
|
||||
"end_to_end": null,
|
||||
"run_seen": null,
|
||||
"arch_rules": null,
|
||||
"scope_sweep": null,
|
||||
"simplify": null,
|
||||
|
|
@ -1207,7 +1173,6 @@ 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}.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)) |
|
||||
| `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) |
|
||||
|
||||
`progress-validator.mjs` rejects `fix_rounds` outside 0–5 (there is no sixth
|
||||
|
|
@ -1310,7 +1275,7 @@ but do not block.
|
|||
**Also write sibling `NEXT-SESSION-PROMPT.local.md`** with frontmatter
|
||||
(`produced_by: trekexecute`, `produced_at: <ISO-8601>`,
|
||||
`status: stopped`) — same combined ESM pattern as Phase 8 — so Phase 1.5
|
||||
of `/trekcontinue` can compare project-dir and repo-root candidates.
|
||||
of `/trekcontinue` can compare project-dir and plugin-root candidates.
|
||||
|
||||
If the entry condition **passes**:
|
||||
```
|
||||
|
|
@ -1564,15 +1529,14 @@ finding 4):
|
|||
```bash
|
||||
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 new in the attempt's diff that is untracked, or staged but not in HEAD, to the git
|
||||
It moves every file the attempt created that is still untracked to the git
|
||||
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
|
||||
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
|
||||
(a header it could not read, a move that failed): stop and name it; exit 2 is
|
||||
a stop too, nothing unindexed or moved (HEAD could not be read, a path in HEAD
|
||||
is missing from the index, or another git call failed). Then launch a fresh
|
||||
`build` helper for the revised step.
|
||||
a stop too (the tree was not checked). Then launch a fresh `build` helper for
|
||||
the revised step.
|
||||
|
||||
- **Never pass the Agent tool's `name` parameter.** A named spawn becomes an
|
||||
`in_process_teammate`: it does the work, but its result never reaches this
|
||||
|
|
@ -1888,7 +1852,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 |
|
||||
| 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 -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
|
||||
**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
|
||||
`{report dir}/step-{N}-upward.diff` and write `{report dir}/step-{N}-upward.md`:
|
||||
the step verbatim, every round's findings and verdicts, and the question for
|
||||
the planner — which assumption in the step (its Changes, its Interface, its
|
||||
|
|
@ -1957,10 +1921,6 @@ or 5). Release the step's test lock before stopping
|
|||
command exactly as written in the plan. (The test files are listed apart
|
||||
from `Files:` in the plan; staging only `Files:` would leave the step's
|
||||
test uncommitted and charge it to the next step's diff.)
|
||||
`{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
|
||||
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
|
||||
|
|
@ -1999,7 +1959,7 @@ already passed) but is recorded as `checkpoint_drift: {expected_pattern,
|
|||
actual_message}` in the progress file. Phase 7.5 audit reports drift as
|
||||
an advisory.
|
||||
|
||||
Update progress: `steps.{N}.status = "completed"`, `steps.{N}.commit = {hash}` (the full commit id),
|
||||
Update progress: `steps.{N}.status = "completed"`, `steps.{N}.commit = {hash}`,
|
||||
`steps.{N}.completed_at = now`, `steps.{N}.manifest_audit = "pass"`.
|
||||
|
||||
### Step mode exit
|
||||
|
|
@ -2127,9 +2087,7 @@ verification failure must not hide a manifest drift.
|
|||
|
||||
### End to end — "run the thing" (P9 (6))
|
||||
|
||||
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
|
||||
Tests passing is not the product working. Copy `end_to_end.status` from the
|
||||
runner's JSON into `end_to_end` — the runner decides it, not this prose:
|
||||
|
||||
- `"verified"` — the verification passed and at least one passed criterion
|
||||
|
|
@ -2146,12 +2104,6 @@ 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
|
||||
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)
|
||||
|
||||
Two whole-run checks, for a trekplan and a session spec alike, in one Bash
|
||||
|
|
@ -2271,26 +2223,18 @@ never typed by the operator, in this order (Cherny's):
|
|||
run — no project directory", and the merge stays closed (Phase 8).
|
||||
**Its writes are committed.** `/trekreview` may have written approved
|
||||
project-memory lines (`.claude/rules/voyage-review-lessons.md`) and a
|
||||
one-line pointer in `CLAUDE.md`. Commit only those of them that changed
|
||||
and git does not ignore (`git check-ignore -q -- {path}` exits 1; exit 0
|
||||
means ignored), as `docs(voyage): review lessons (round {R})`: a process
|
||||
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.
|
||||
one-line pointer in `CLAUDE.md`. Commit exactly those paths, when they
|
||||
changed, as `docs(voyage): review lessons (round {R})`: a process commit
|
||||
(Phase 7.5 step 3). Nothing the review wrote 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:
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/review/verdict-gate.mjs --json "{project_dir}/review.md" --repo .
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/review/verdict-gate.mjs --json "{project_dir}/review.md"
|
||||
```
|
||||
`--repo .` checks that an ALLOW/WARN review is of the commit being merged:
|
||||
its `scope_sha_end` is HEAD, or an ancestor of HEAD whose later commits
|
||||
change only the review lessons and the `CLAUDE.md` pointer (step 3's
|
||||
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.
|
||||
Record `review = {round, path, gate_exit, verdict}`. Exit 0 (ALLOW/WARN) →
|
||||
Phase 7.5. Exit 2 (could not decide, or a reviewer or engine that did not
|
||||
run) → 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
|
||||
every BLOCKER as a numbered `### Step R<n>` under `## Remediation Steps`
|
||||
|
|
@ -2430,7 +2374,7 @@ Record in progress file:
|
|||
> 1. **Objective predicate** — the **Phase 7.5** manifest audit (above) PASSED,
|
||||
> re-verified directly from the filesystem + git log, ignoring the executor's
|
||||
> own per-step bookkeeping. "The audit wins"; a self-reported `completed` over
|
||||
> a drifting audit is OVERRIDDEN to `partial` (Phase 7.5).
|
||||
> a drifting audit is OVERRIDDEN to `partial` (Phase 7.5, `:1221`).
|
||||
> 2. **Explicit stop-signal** — a signal *distinct from the executor's
|
||||
> 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
|
||||
|
|
@ -2441,14 +2385,14 @@ Record in progress file:
|
|||
> from `lib/verification/criteria-runner.mjs`, on the single-session path as
|
||||
> well as the multi-session one. `"failed"` or `"not-run"` OVERRIDES
|
||||
> `completed` to `failed`, exactly as the audit-override does.
|
||||
> 3. **Counter liveness (checked by the executor against the audit's counts,
|
||||
> defeats "never decremented")** — the gate reconciles the budget counter
|
||||
> against the ground-truth counters the Phase 7.5 audit already derives:
|
||||
> 3. **Counter liveness (deterministic, defeats "never decremented")** — the gate
|
||||
> reconciles the budget counter 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))`
|
||||
> (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
|
||||
> result is OVERRIDDEN `completed → partial`, exactly as the audit-override
|
||||
> already does (Phase 7.5). So the counter's liveness is auditable at the gate,
|
||||
> already does (`:1221`). So the counter's liveness is auditable at the gate,
|
||||
> not merely asserted in prose.
|
||||
> 4. **End to end** — for a trekplan, Phase 7's `end_to_end == "verified"`.
|
||||
> `"not-verified"` OVERRIDES `completed → partial` with the reason
|
||||
|
|
@ -2459,20 +2403,12 @@ Record in progress file:
|
|||
> the files outside the plan. `"not-measured"` does not override, and it is
|
||||
> 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
|
||||
> Phase 8: in the closing run, a closed lock or a declined merge turns
|
||||
> `completed` into `partial`, as a declined gate always has.
|
||||
>
|
||||
> Termination authority therefore rests on the audit and an objective
|
||||
> stop-signal, never on the model's narrative. Native `/goal` (when
|
||||
> Termination authority therefore rests on the deterministic audit + an
|
||||
> objective stop-signal, never on the model's narrative. Native `/goal` (when
|
||||
> 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.
|
||||
|
||||
|
|
@ -2604,7 +2540,7 @@ event, merge nothing, and their result is left as Phase 7.5 decided it
|
|||
a summary:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/review/verdict-gate.mjs --json "{review_path}" --repo .
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/review/verdict-gate.mjs --json "{review_path}"
|
||||
```
|
||||
|
||||
`{review_path}` is `review.path` from Phase 7.2. No review (Phase 7.2 did not
|
||||
|
|
@ -2617,23 +2553,13 @@ exit 2 without the call.
|
|||
| 1 | BLOCK | closed |
|
||||
| 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
|
||||
`main-merge-declined` with the reason `review-block`, `review-stale`,
|
||||
`review-not-measured` or `run-not-completed`. Nothing is finalized: a `completed` result becomes
|
||||
`main-merge-declined` with the reason `review-block`, `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
|
||||
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, a review
|
||||
of this commit, or a run that completes.
|
||||
BLOCKERs fixed through Phase 7.2's loop, a review that runs in full, or a run
|
||||
that completes.
|
||||
|
||||
**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:
|
||||
|
|
@ -2737,7 +2663,7 @@ JSON
|
|||
```
|
||||
|
||||
The reason is one of the literal words above (`review-block`,
|
||||
`review-stale`, `review-not-measured`, `run-not-completed`, `no-main-branch`, `hold-for-review`,
|
||||
`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).
|
||||
|
||||
On decline, nothing is cleared: the merged-waves record is kept.
|
||||
|
|
@ -2745,9 +2671,8 @@ On decline, nothing is cleared: the merged-waves record is kept.
|
|||
**Recovery surface:** on the single-session path, if declined,
|
||||
`--resume re-enters at the gate` — re-running
|
||||
`/trekexecute --resume --project <dir>` skips the completed steps and returns
|
||||
to the main-merge gate: the lock reads the review again (a review fixed or re-run
|
||||
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
|
||||
to the main-merge gate: the lock reads `review.md` again (a review fixed or
|
||||
re-run in between counts), and when it opens, the guard asks again. On the
|
||||
multi-session wave path,
|
||||
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
|
||||
|
|
@ -2788,7 +2713,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
|
||||
project directory. The frontmatter MUST contain `produced_by: trekexecute`
|
||||
and `produced_at: <ISO-8601>` so `/trekcontinue` Phase 1.5 can detect
|
||||
producer drift between project-dir and repo-root candidates. Use a single
|
||||
producer drift between project-dir and plugin-root candidates. Use a single
|
||||
ESM inline block so state-file + prompt-file writes succeed or fail together:
|
||||
|
||||
```bash
|
||||
|
|
@ -2820,12 +2745,6 @@ 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
|
||||
— 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
|
||||
|
||||
|
|
@ -2834,7 +2753,6 @@ archived progress copy (Step 6). A `null` there is reported as `no — not run`.
|
|||
**Mode:** {execute | resume | step N}
|
||||
**Result:** {COMPLETED | FAILED at step N | STOPPED (escalation) | PARTIAL (N/total passed)}
|
||||
**End to end:** {verified | not verified end to end}
|
||||
**Seen with `run`:** {yes | no — {reason}}
|
||||
|
||||
### Step Results
|
||||
|
||||
|
|
@ -2960,7 +2878,6 @@ To continue (multi-session waves): re-run the same /trekexecute command (not --r
|
|||
"legacy_plan": false,
|
||||
"progress_file": "{path}",
|
||||
"end_to_end": "{verified | not-verified | n/a}",
|
||||
"run_seen": "{yes | no — reason}",
|
||||
"steps_without_red_proof": 0,
|
||||
"stop_class": "{none | 1 | 2 | 3 | 4}",
|
||||
"decisions": 0,
|
||||
|
|
@ -3028,8 +2945,8 @@ Resolution order (per `lib/profiles/resolver.mjs`):
|
|||
3. Inherited from `plan.md` frontmatter `profile:` field (source: `inheritance`)
|
||||
4. `premium` default for v4.0-style plans without `profile:` (source: `default`)
|
||||
|
||||
The selected profile goes to the resolver. It changes no model here: the step helper and the
|
||||
controller run opus (Composition rule below), and nothing in this command reads `parallel_agents`.
|
||||
The selected profile drives `phase_models.execute` (model for execution
|
||||
orchestrator) and `parallel_agents` for wave-parallel sessions.
|
||||
|
||||
Examples:
|
||||
```
|
||||
|
|
@ -3037,6 +2954,9 @@ Examples:
|
|||
VOYAGE_PROFILE=balanced /trekexecute --project ...
|
||||
```
|
||||
|
||||
Stats records emit `profile`, `phase_models`, and `profile_source` per
|
||||
Phase 9 record.
|
||||
|
||||
## Composition rule (v5.1)
|
||||
|
||||
Independent of the profile system. When `brief.md` carries
|
||||
|
|
@ -3050,7 +2970,7 @@ model_for_phase = brief.phase_signals[<phase>]?.model ?? profile.phase_models[
|
|||
|
||||
The brief signal wins per-phase when present; the profile fills any
|
||||
gaps. Both fields are mechanically resolved by the single composed CLI,
|
||||
invoked in Phase 1 alongside the sequencing-gate brief-validator call:
|
||||
invoked in Phase 2.4 alongside the sequencing-gate brief-validator call:
|
||||
|
||||
```bash
|
||||
# v5.9 — composed phase-model resolution (brief > profile > default) for the
|
||||
|
|
@ -3062,18 +2982,21 @@ node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model --pha
|
|||
```
|
||||
|
||||
`phase_signal_result.effort` (absent = `standard`) is consumed when picking the execution
|
||||
strategy (the parallel-wave choice, Phase 2.5). The resolver does NOT control the orchestrator's own
|
||||
strategy (gates auto-escalation, parallel-wave choice — see High-effort
|
||||
behavior below). The resolver does NOT control the orchestrator's own
|
||||
model — that is fixed at invocation time (command frontmatter omits
|
||||
`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
|
||||
(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
|
||||
spawn site here; it is returned for cross-command uniformity; no stats record carries it.
|
||||
spawn site here; it is returned for cross-command uniformity and stats.
|
||||
|
||||
For `/trekexecute` specifically: `effort == 'low'` runs the plan as one
|
||||
session, step after step (Phase 2.5). `effort == 'standard'` (or absent) →
|
||||
no change (default execution strategy applies). `effort == 'high'`: see
|
||||
`### High-effort behavior (v5.1.1)` below.
|
||||
For `/trekexecute` specifically: `effort == 'low'` activates `--gates open`
|
||||
+ sequential-only execution (no worktree-isolated parallel waves — runs
|
||||
all sessions in a single foreground loop). `effort == 'standard'` (or
|
||||
absent) → no change (default execution strategy applies). `effort == 'high'`
|
||||
activates the high-effort behavior documented under `### High-effort
|
||||
behavior (v5.1.1)` below.
|
||||
|
||||
### Sequencing gate surface
|
||||
|
||||
|
|
@ -3087,9 +3010,32 @@ commands surface, don't re-enforce.
|
|||
|
||||
### High-effort behavior (v5.1.1)
|
||||
|
||||
High effort changes nothing in `/trekexecute`: the step helper and the
|
||||
controller already run opus, and every gate already runs. No `gates_mode` is
|
||||
set from the effort.
|
||||
When `phase_signal_result.effort == 'high'` for the `execute` phase,
|
||||
set `gates_mode = 'closed'` automatically (equivalent to passing
|
||||
`--gates closed` on the command line). All autonomy boundaries become
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -124,23 +124,12 @@ Parse `$ARGUMENTS` for mode flags. Order of precedence:
|
|||
7. **`--quick`** — set **mode = quick**. Skip agent swarm; use lightweight
|
||||
Glob/Grep scan and go directly to planning + adversarial review.
|
||||
|
||||
8. **`--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`, pause for
|
||||
operator confirmation after Phase 5 (exploration), Phase 7 (synthesis),
|
||||
and Phase 9 (adversarial review). Default `gates_mode = false` lets
|
||||
phases flow continuously.
|
||||
8. **`--gates`** — autonomy control. When present, set `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. The flag is consumed by the
|
||||
autonomy-gate state machine via the CLI shim:
|
||||
`node ${CLAUDE_PLUGIN_ROOT}/lib/util/autonomy-gate.mjs --state X --event Y --gates {true|false}`.
|
||||
|
||||
9. **`--min-brief-version <ver>`** — (optional) version floor. Set
|
||||
**min_brief_version = {ver}** (e.g. `2.2`). Forwarded to the brief-validator
|
||||
|
|
@ -325,10 +314,6 @@ Determine the output directory from the plan slug:
|
|||
|
||||
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}
|
||||
Plugin root: ${CLAUDE_PLUGIN_ROOT}
|
||||
|
|
@ -427,7 +412,7 @@ Then continue to the next phase inline.
|
|||
|
||||
## Phase 4 — Codebase sizing
|
||||
|
||||
Determine codebase scale to decide whether convention-scanner runs.
|
||||
Determine codebase scale to calibrate agent turns (not agent count).
|
||||
|
||||
Run via Bash:
|
||||
```
|
||||
|
|
@ -469,10 +454,6 @@ 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.
|
||||
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:
|
||||
- **PROCEED** — continue to Phase 5.
|
||||
- **PROCEED_WITH_RISKS** — continue, carry flagged risks as `[ASSUMPTION]` in the plan.
|
||||
|
|
@ -513,7 +494,8 @@ message). Use the specialized agents from the `agents/` directory.
|
|||
> 2026-08-17: named 0/5 returned, unnamed 3/3. Mechanism, denominators, and the
|
||||
> on-disk recovery path: `docs/agent-return-channel-defect.md`.
|
||||
|
||||
**All agents run for all codebase sizes.**
|
||||
**All agents run for all codebase sizes.** Scale `maxTurns` by size (small: halved,
|
||||
medium: default, large: default) instead of dropping agents.
|
||||
|
||||
| Agent | Small | Medium | Large | Purpose |
|
||||
|-------|-------|--------|-------|---------|
|
||||
|
|
@ -530,10 +512,6 @@ research-scout's web search is not a local read; the line above bounds its local
|
|||
|
||||
### 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.
|
||||
Prompt: "Analyze the architecture of this codebase. The task being planned is: {task}.
|
||||
Brief file: {brief_path} — read it first.
|
||||
|
|
@ -571,7 +549,7 @@ Read local files only inside the repo under exploration, plus paths this prompt
|
|||
|
||||
### Launch for medium+ codebases (50+ files):
|
||||
|
||||
**Convention Scanner** — use the `convention-scanner` plugin agent (model: phase_signal_result.model, the agent's frontmatter as the fallback)
|
||||
**Convention Scanner** — use the `convention-scanner` plugin agent (model: "opus")
|
||||
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."
|
||||
|
||||
|
|
@ -589,9 +567,6 @@ topics have corresponding research files, skip research-scout entirely.
|
|||
|
||||
If yes (and not covered by attached briefs): launch **research-scout** in
|
||||
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}.
|
||||
Specific questions: {list specific questions about the technology}.
|
||||
Technologies to research: {list}. Brief file: {brief_path} — read it first.
|
||||
|
|
@ -612,8 +587,7 @@ Common reasons for deep-dives:
|
|||
- A test pattern was identified but the test infrastructure needs more detail
|
||||
- A risk was flagged but the actual impact needs verification
|
||||
|
||||
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,
|
||||
For each significant gap, spawn a targeted deep-dive agent (model: "opus",
|
||||
subagent_type: "Explore") with a narrow, specific brief. Each deep-dive prompt
|
||||
carries these two lines:
|
||||
Brief file: {brief_path} — read it first.
|
||||
|
|
@ -818,17 +792,17 @@ Write the plan following the template structure. The plan MUST include:
|
|||
(`PLAN_STEP_TESTS_ONLY`).
|
||||
|
||||
**The "run the thing" step.** At least one step carries
|
||||
`End-to-end: \`<command>\` → expected: <what the user sees>`: the product run the way a
|
||||
user runs it (the CLI, the server and a request, the app; for iOS, the simulator). Without
|
||||
it the result is "not verified end to end", never green. 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. 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: \`<command>\` → expected: <what the user sees>`: the product run
|
||||
the way a user runs it (the CLI, the server and a request, the app; for iOS,
|
||||
the simulator). Without it the result is "not verified end to end", never
|
||||
green. 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
|
||||
`## Verification`: Phase 7 of `/trekexecute` runs that section, and a passed criterion that
|
||||
runs the step's `End-to-end:` command is what verifies the run end to end. `End-to-end:`
|
||||
rides on the last step that builds behaviour, with `tests/e2e.sh` in that step's `Files:`:
|
||||
a step of its own that only adds the script is a step of only tests, and it can never be proven red.
|
||||
`## Verification`: Phase 7 of `/trekexecute` runs that section, and a passed
|
||||
criterion that runs the step's `End-to-end:` command is what verifies the
|
||||
run end to end. `End-to-end:` rides on the last step that builds behaviour,
|
||||
with `tests/e2e.sh` in that step's `Files:`: a step of its own that only adds
|
||||
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
|
||||
(3–5 steps each), organize sessions into waves (parallel where independent),
|
||||
specify scope fences per session. Omit for plans with ≤ 5 steps.
|
||||
|
|
@ -988,10 +962,6 @@ Both reviewers are asked for `voyage:writing-criteria` and
|
|||
writing-criteria rule in one sentence in its prompt, for the brief's criteria
|
||||
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.
|
||||
Prompt: "Review this implementation plan for the task: {task}.
|
||||
Plan file: {plan path}. Read it and find every problem — missing steps,
|
||||
|
|
@ -1156,13 +1126,13 @@ Present a summary to the user:
|
|||
You can:
|
||||
- Say which open minors to fix ("fix 1 and 3")
|
||||
- Ask questions or request changes to refine the plan
|
||||
- Say **"execute"** for the `/trekexecute` line to run ← only when the gate exited 0
|
||||
- Say **"execute"** to start implementing ← only when the gate exited 0
|
||||
- Say **"save"** to keep the plan for later
|
||||
```
|
||||
|
||||
When the gate exited 1, replace the "execute" line with
|
||||
`Execution is blocked: {the gate's reasons}` and do not print the `/trekexecute`
|
||||
line, whatever is said next, until the gate exits 0.
|
||||
`Execution is blocked: {the gate's reasons}` and do not start implementing,
|
||||
whatever is said next, until the gate exits 0.
|
||||
|
||||
**What counts as approval.** "Execute", "go" or "start" (or the plan-mode
|
||||
approval below) approves the plan. A hedged reply — "sounds good", "looks
|
||||
|
|
@ -1201,11 +1171,8 @@ plan-approval prompt:
|
|||
plan back.
|
||||
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
|
||||
exit 0 again, which also proves the copy is the reviewed text. Then go
|
||||
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.
|
||||
exit 0 again, which also proves the copy is the reviewed text. Then
|
||||
continue with the operator's choice (Phase 11).
|
||||
4. **"No, keep planning":** revise per the operator's words, run one Phase 9
|
||||
round, and present again from step 1.
|
||||
|
||||
|
|
@ -1217,11 +1184,15 @@ plan mode's restrictions). Until an operator has run it, both paths stay.
|
|||
|
||||
### Build the operator-annotation HTML and print the link
|
||||
|
||||
After the plan summary, run `scripts/annotate.mjs` to build the page
|
||||
where the operator writes their own notes on `plan.md`. The loop that
|
||||
follows is the skill `voyage:annotating`: call it when the operator pastes
|
||||
the notes back (a Question is answered, not applied; an approved plan that
|
||||
changes needs a new approval before `/trekexecute`, said before the edit).
|
||||
After the plan summary, run `scripts/annotate.mjs` to produce a
|
||||
self-contained HTML the operator opens in their browser. The HTML renders
|
||||
`plan.md` with line numbers, lets the operator click any line to attach
|
||||
their own note (not Claude-generated suggestions — the operator drives
|
||||
every annotation), keeps a sidebar of all notes, persists state in
|
||||
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
|
||||
ANNOT_HTML=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/annotate.mjs "{plan_path}" 2>&1)
|
||||
|
|
@ -1245,9 +1216,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,
|
||||
the "Copy Prompt" button gathers them into one structured prompt.
|
||||
Paste that prompt back into this chat: Claude answers each Question
|
||||
and revises plan.md from your Fix and Change notes. Annotations
|
||||
persist in your browser if you close the tab and reopen the same file.
|
||||
Paste that prompt back into this chat and Claude revises plan.md
|
||||
from your notes. Annotations persist in your browser if you close
|
||||
the tab and reopen the same file.
|
||||
────────────────────────────────────────────────────────────────────
|
||||
```
|
||||
|
||||
|
|
@ -1293,16 +1264,15 @@ Confirm the plan and brief file locations and exit.
|
|||
|
||||
### "execute" / "go" / "start"
|
||||
|
||||
Do not implement in this session. Print the line the operator gives
|
||||
`/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.
|
||||
Begin implementing the plan step by step in this session. Follow the plan exactly.
|
||||
Mark each step complete as you go.
|
||||
|
||||
### "execute with team" / "team"
|
||||
|
||||
There is no team path. Print the same `/trekexecute` line, then run Phase 12
|
||||
(`outcome: execute_team`); parallel work is `--decompose` followed by `/trekexecute`'s session orders.
|
||||
There is no team path. Tell the user that parallel team execution is not available
|
||||
from `/trekplan`, then proceed exactly as for "execute" (sequential, in this
|
||||
session). Parallel implementation is `--decompose` followed by `/trekexecute`'s
|
||||
multi-session waves.
|
||||
|
||||
## Phase 12 — Session tracking
|
||||
|
||||
|
|
@ -1363,6 +1333,9 @@ Examples:
|
|||
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)
|
||||
|
||||
Independent of the profile system. When `brief.md` carries
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ Supported flags:
|
|||
is `{model, source}`, plus
|
||||
`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_signal_result` and used at Agent-spawn sites in Phase 4 and Phase 5 to inject
|
||||
`phase_signal_result` and used at Agent-spawn sites in Phase 4 to inject
|
||||
the resolved model:
|
||||
|
||||
```bash
|
||||
|
|
@ -72,23 +72,12 @@ Supported flags:
|
|||
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. `--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
|
||||
6. `--gates` — autonomy control. When present, set `gates_mode = true`. The
|
||||
research command will pause after each topic completes ("Topic N
|
||||
complete. Proceed to topic N+1? (yes/no)"). Default `gates_mode = false`
|
||||
means topics run continuously.
|
||||
means topics run continuously. The flag is consumed by the autonomy-gate
|
||||
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
|
||||
`--project` brief. Set **min_brief_version = {ver}** (e.g. `2.2`). Forwarded
|
||||
|
|
@ -384,8 +373,7 @@ changes nothing (SC1). Keep the native-swarm anchors intact ("in parallel",
|
|||
5. **On a real report:** keep `effective_engine = deep-research`, log
|
||||
`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
|
||||
adapter below); then run wave 2 (`contrarian-researcher`, see Launch rules)
|
||||
from the report's leading answer.
|
||||
adapter below).
|
||||
|
||||
### External agents (scope = both or external)
|
||||
|
||||
|
|
@ -414,22 +402,7 @@ ticket IDs) already removed: the agents search with the words they are given.
|
|||
|
||||
### Launch rules
|
||||
|
||||
- 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.
|
||||
- Launch ALL selected agents **in parallel** in a single message
|
||||
- Inject the resolved model at every spawn: `phase_signal_result.model` (brief
|
||||
signal > profile), with the agent's frontmatter `model:` as the fallback — see
|
||||
Hard rules → Cost. The orchestrator itself follows the session model.
|
||||
|
|
@ -445,6 +418,8 @@ 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,
|
||||
denominators, and the on-disk recovery path:
|
||||
`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)
|
||||
|
||||
## Phase 4.5 — Dimension discovery
|
||||
|
|
@ -518,9 +493,7 @@ Review all agent results. Identify knowledge gaps — dimensions where findings
|
|||
are thin, contradictory, or missing (**under-illuminated dimensions**).
|
||||
|
||||
**Standard and low effort — unchanged single pass.** For each significant gap,
|
||||
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
|
||||
launch a targeted follow-up agent (model: "opus") with a narrow, specific
|
||||
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
|
||||
sufficient — no follow-ups needed." Then go to Phase 6.
|
||||
|
|
@ -648,10 +621,6 @@ 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
|
||||
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
|
||||
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
|
||||
|
|
@ -762,10 +731,7 @@ raw report. Specifically:
|
|||
- Reduce the report to ≥ 1 `### {Dimension} -- Confidence: {high|medium|low|contradictory}`
|
||||
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
|
||||
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.
|
||||
dimension as usual.
|
||||
- Emit a numeric `confidence ∈ [0,1]` in frontmatter and a 3-sentence
|
||||
`## Executive Summary` (answer, confidence, key caveat).
|
||||
- Populate `## Sources` from the report's citations.
|
||||
|
|
@ -901,7 +867,9 @@ Examples:
|
|||
VOYAGE_PROFILE=balanced /trekresearch
|
||||
```
|
||||
|
||||
Stats records emit `engine` so operators can audit which engine drove which session.
|
||||
Stats records emit `profile`, `phase_models`, `parallel_agents`,
|
||||
`external_research_enabled`, `profile_source`, and `engine` so operators can
|
||||
audit which profile and engine drove which session.
|
||||
|
||||
## Composition rule (v5.1)
|
||||
|
||||
|
|
@ -946,11 +914,12 @@ commands surface, don't re-enforce.
|
|||
### High-effort behavior (v5.1.1)
|
||||
|
||||
When `phase_signal_result.effort == 'high'` for the `research` phase,
|
||||
run the FULL swarm regardless of normal selection rules: 5 local
|
||||
run the FULL swarm regardless of normal triggering rules: 5 local
|
||||
agents + 4 external agents, AND force `contrarian-researcher` to
|
||||
always-on: in high-effort mode `contrarian-researcher` runs
|
||||
unconditionally, as wave 2 (see Launch rules), so the final brief
|
||||
always carries an adversarial counter-evidence pass.
|
||||
always-on. Normally `contrarian-researcher` triggers conditionally when
|
||||
a leading recommendation is emerging from initial agents; in
|
||||
high-effort mode it runs unconditionally so the final brief always
|
||||
carries an adversarial counter-evidence pass.
|
||||
|
||||
The former third-party second-opinion bridge pass is **unavailable,
|
||||
skipped** (dropped in v5.10.1 by operator directive). High effort no
|
||||
|
|
@ -965,9 +934,7 @@ turns per under-illuminated dimension, budgeted by
|
|||
`--local`, `external_research_enabled: false`) overrides the effort
|
||||
signal whenever the external phase does not run at all.
|
||||
|
||||
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).
|
||||
Standard effort (or absent): use the existing conditional triggers.
|
||||
Low effort: inline research only, no agent swarm (existing
|
||||
`--quick`-equivalent code-path).
|
||||
|
||||
|
|
|
|||
|
|
@ -46,13 +46,10 @@ command executes inline.
|
|||
## Phase 1 — Parse mode and validate input
|
||||
|
||||
Parse the arguments with the shared arg-parser. Run it first, exactly like
|
||||
this, before anything else (the arguments reach the parser as the one line
|
||||
inside the quoted heredoc, on stdin, never as shell words):
|
||||
this, before anything else:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekreview --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'
|
||||
$ARGUMENTS
|
||||
VOYAGE_ARGS_EOF_7F3C9E
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekreview -- $ARGUMENTS
|
||||
```
|
||||
|
||||
It prints the parse as JSON on stdout. It also checks the required flag
|
||||
|
|
@ -65,7 +62,7 @@ FLAG_SCHEMA `trekreview` entry):
|
|||
|------|------|---------|
|
||||
| `--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`. |
|
||||
| `--quick` | boolean | Skip the brief-conformance pass; run only the code-correctness reviewer; skip the coordinator's reasonableness filter except the brief-conflict rule. |
|
||||
| `--quick` | boolean | Skip the brief-conformance pass; run only the code-correctness reviewer; skip the coordinator's reasonableness filter. |
|
||||
| `--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. |
|
||||
| `--fg` | boolean | No-op alias (foreground is default). |
|
||||
|
|
@ -187,8 +184,8 @@ every file from Phase 3 into a treatment bucket:
|
|||
| 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`). |
|
||||
| `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/**`. |
|
||||
| `deep-review` | Matches `auth/**`, `crypto/**`, `**/security/**`, `hooks/**`. |
|
||||
| `summary-only` | Default treatment for everything else. |
|
||||
|
||||
Hard refuse-with-suggestion gates — use `AskUserQuestion`:
|
||||
|
||||
|
|
@ -513,15 +510,8 @@ The coordinator runs the 4-pass process documented in
|
|||
Succinctness never removes a finding: only MINOR/SUGGESTION text is
|
||||
trimmed, and every trim is counted. A long BLOCKER or MAJOR keeps every
|
||||
character.
|
||||
3. **Cloudflare reasonableness** (skipped in `quick` mode) — drop a finding
|
||||
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.
|
||||
3. **Cloudflare reasonableness** — remove speculative or catalogue-violating
|
||||
findings (skipped in `quick` mode).
|
||||
4. **Verdict** — BLOCK / WARN / ALLOW per the threshold table.
|
||||
|
||||
**Fail-closed.** Every removal in Pass 2 and Pass 3 is either
|
||||
|
|
@ -569,8 +559,11 @@ 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`);
|
||||
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), 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.
|
||||
On this 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.
|
||||
|
||||
Contract (verified in S10 part B — follow exactly):
|
||||
- **Pass `args` as a JSON object.** The script defensively re-parses a JSON *string*, but
|
||||
|
|
@ -702,11 +695,13 @@ the main workflow, but it is reported, not skipped silently.
|
|||
ANNOT_HTML=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/annotate.mjs "{review_path}" 2>&1)
|
||||
```
|
||||
|
||||
`stdout` is the absolute path to the `.html` on success: the page where the
|
||||
operator writes their own notes on `review.md`. The loop that follows is the
|
||||
skill `voyage:annotating`: call it when the operator pastes the notes back.
|
||||
If `annotate.mjs` exits non-zero, surface a one-line warning and continue —
|
||||
the annotation HTML is a convenience, not a gate.
|
||||
`stdout` is the absolute path to the `.html` on success. The HTML renders
|
||||
`review.md` with line numbers, lets the operator click any line to attach
|
||||
their own note (not Claude-generated suggestions — the operator drives
|
||||
every annotation), keeps a sidebar of all notes, persists state in
|
||||
localStorage, and exposes a "Copy Prompt" button. If `annotate.mjs`
|
||||
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`)
|
||||
|
||||
|
|
@ -826,9 +821,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,
|
||||
the "Copy Prompt" button gathers them into one structured prompt.
|
||||
Paste that prompt back into this chat: Claude answers each Question
|
||||
and revises review.md from your Fix and Change notes. Annotations
|
||||
persist in your browser if you close the tab and reopen the same file.
|
||||
Paste that prompt back into this chat and Claude revises review.md
|
||||
from your notes. Annotations persist in your browser if you close
|
||||
the tab and reopen the same file.
|
||||
────────────────────────────────────────────────────────────────────
|
||||
|
||||
You can also:
|
||||
|
|
@ -863,6 +858,8 @@ Examples:
|
|||
VOYAGE_PROFILE=premium /trekreview --project ...
|
||||
```
|
||||
|
||||
Stats records emit `profile` and `profile_source`.
|
||||
|
||||
## Composition rule (v5.1)
|
||||
|
||||
Independent of the profile system. When `brief.md` carries
|
||||
|
|
@ -906,8 +903,8 @@ readable.
|
|||
### High-effort behavior (v5.1.1)
|
||||
|
||||
When `phase_signal_result.effort == 'high'` for the `review` phase,
|
||||
skip Pass 3 (Cloudflare reasonableness filter) in `agents/review-coordinator.md`, except the brief-conflict rule, which applies in every mode.
|
||||
Passes 1, 2, and 4 still run.
|
||||
skip Pass 3 (Cloudflare reasonableness filter) in
|
||||
`agents/review-coordinator.md`. Passes 1, 2, and 4 still run.
|
||||
Rationale: high-effort review trusts the operator to weigh borderline
|
||||
findings rather than have the coordinator drop them. To prevent
|
||||
unknown `rule_key` values from polluting downstream remediation plans
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ with `BRIEF_INVALID_MODEL`.
|
|||
|
||||
**Producer:** `/trekresearch` Phase 7 (synthesis + brief writer).
|
||||
|
||||
**Consumer:** `/trekplan` Phase 1 (project-dir auto-discovery) + `/trekplan` Phase 3 and Phase 7 (consumes findings as context).
|
||||
**Consumer:** `/trekplan` Phase 1 (project-dir auto-discovery) + `planning-orchestrator` (consumes findings as context).
|
||||
|
||||
**Path conventions:**
|
||||
- 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).
|
||||
|
||||
**Consumer:** `/trekplan` Phase 1 (architecture-discovery) + `/trekplan` Phase 7 (cross-reference architecture-note as priors during synthesis).
|
||||
**Consumer:** `/trekplan` Phase 1 (architecture-discovery) + `planning-orchestrator` Phase 7 (cross-reference architecture-note as priors during synthesis).
|
||||
|
||||
**Path conventions:**
|
||||
- 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
|
||||
|
||||
**Producer:** `/trekplan` Phase 8 (plan synthesis, then the schema self-check via `plan-validator --strict`).
|
||||
**Producer:** `planning-orchestrator` Phase 5 (plan synthesis) + Phase 5.5 (schema self-check via `plan-validator --strict`).
|
||||
|
||||
**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:**
|
||||
|
||||
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`.
|
||||
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`.
|
||||
|
||||
| Code | Meaning | Recovery |
|
||||
|---|---|---|
|
||||
|
|
@ -358,7 +358,6 @@ 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 |
|
||||
| `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)) |
|
||||
| `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) |
|
||||
|
||||
**Per-step record:**
|
||||
|
|
@ -369,7 +368,7 @@ Rule B is stated by the reviewers and recorded by the gate from round 1 (`lowere
|
|||
| `attempts` | number | 1..N |
|
||||
| `error` | string \| null | |
|
||||
| `completed_at` | ISO string \| null | |
|
||||
| `commit` | string \| null | full commit id (`git rev-parse HEAD`) after Checkpoint |
|
||||
| `commit` | string \| null | git sha after Checkpoint |
|
||||
| `manifest_audit` | string | `pass \| fail \| pass-with-note \| n/a` |
|
||||
| `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}` |
|
||||
|
|
@ -432,7 +431,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.
|
||||
|
||||
**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).
|
||||
**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).
|
||||
|
||||
**Path conventions:**
|
||||
- Project-dir mode (recommended): `{project_dir}/review.md` (one per review iteration; subsequent runs overwrite atomically).
|
||||
|
|
@ -443,12 +442,12 @@ missing `exit_code` (C15, `PROGRESS_FAILED_CRITERIA_KEY`).
|
|||
| Field | Type | Required | Allowed values | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `type` | string | yes | `trekreview` | Hard-coded discriminator |
|
||||
| `review_version` | string | yes | `"1.1"` (current) | Bump on schema change |
|
||||
| `review_version` | string | yes | `"1.0"` (current) | Bump on schema change |
|
||||
| `task` | string | yes | one-line description | Mirrors brief task |
|
||||
| `slug` | string | yes | URL-safe slug | Used in project_dir |
|
||||
| `project_dir` | string | yes | `.claude/projects/{date}-{slug}/` | |
|
||||
| `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, 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 |
|
||||
| `scope_sha_end` | string | yes | git sha of HEAD at review time | Defines "after" boundary |
|
||||
| `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 |
|
||||
| `verdict` | string | yes (1.1; strict at write, warning at read) | `BLOCK \| WARN \| ALLOW` | Coordinator output; equals the trailing JSON block's `verdict` (K7) |
|
||||
|
|
@ -485,7 +484,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.
|
||||
`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.
|
||||
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).
|
||||
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).
|
||||
A plan made from the review skips a lowered finding: it is MINOR, and `/trekplan` takes BLOCKER and MAJOR findings only.
|
||||
|
||||
**Validation strategy:**
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ Imported from `CLAUDE.md` via pointer.
|
|||
Wiring points (replaces previous prose-grep instructions):
|
||||
- `/trekbrief` Phase 4g → `brief-validator` (post-write sanity check)
|
||||
- `/trekplan` Phase 1 → `brief-validator --soft`, `research-validator --dir`, `architecture-discovery`
|
||||
- `/trekplan` Phase 8 → `plan-validator --strict` (replaces 3 `grep -cE` calls)
|
||||
- `planning-orchestrator` Phase 5.5 → `plan-validator --strict` (replaces 3 `grep -cE` calls)
|
||||
- `/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).
|
||||
|
|
@ -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).
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
| `--quick` | Proposes `small` as the class (still said aloud, overridable, raised when the task proves bigger) → brief.md with research plan |
|
||||
| `--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`. |
|
||||
| `--gates {true\|false}` | (v3.4.0) Boolean autonomy-gate flag; present → gating on. Policy (`gates_mode`) detailed under `## 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`. |
|
||||
| `--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) |
|
||||
| `--external` | Only external research agents (skip codebase analysis) |
|
||||
| `--fg` | No-op alias (foreground is default since v2.4.0) |
|
||||
| `--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`. |
|
||||
| `--gates {true\|false}` | (v3.4.0) Boolean autonomy-gate flag; present → gating on. Policy (`gates_mode`) detailed under `## 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 |
|
||||
| `--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`. |
|
||||
|
|
@ -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 |
|
||||
| `--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) |
|
||||
| `--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`. |
|
||||
| `--gates {true\|false}` | (v3.4.0) Boolean autonomy-gate flag; present → gating on. Policy (`gates_mode`) detailed under `## 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). |
|
||||
|
||||
**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 |
|
||||
| `--fg` | Force foreground — run all steps sequentially, ignore Execution Strategy |
|
||||
| `--session N` | Execute only session N from plan's Execution Strategy |
|
||||
| `--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) Profile for this run's resolver; the step helper and the controller stay on opus. Inherited from plan.md frontmatter `profile:` if present. |
|
||||
| `--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). |
|
||||
| `--profile <name>` | (v4.1.0) Model profile for the execute phase. 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.
|
||||
|
||||
|
|
@ -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) |
|
||||
| `--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` |
|
||||
| `--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 |
|
||||
| `--quick` | `/code-review` + code-correctness reviewer only; skip brief-conformance reviewer, `/security-review` and the coordinator's reasonableness filter — 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) |
|
||||
| `--dry-run` | Print discovered scope + triage map; skip writes |
|
||||
| `--fg` | No-op alias (foreground is default) |
|
||||
|
|
|
|||
|
|
@ -4,9 +4,17 @@ Imported from `CLAUDE.md` via pointer.
|
|||
|
||||
## Autonomy mode (`--gates`, v3.4.0)
|
||||
|
||||
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).
|
||||
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.
|
||||
|
||||
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`.
|
||||
`/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)):
|
||||
|
||||
| `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.
|
||||
|
||||
|
|
@ -24,7 +32,7 @@ A revived Path C (post-v2.2.xxx) would require: (1) re-architecting tool-list to
|
|||
|
||||
## 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>`.
|
||||
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.
|
||||
|
||||
| Profile | Brief | Research | Plan | Execute | Review | Continue | Use case |
|
||||
|---------|-------|----------|------|---------|--------|----------|----------|
|
||||
|
|
|
|||
|
|
@ -118,9 +118,16 @@ Voyage resolves the profile in this priority order:
|
|||
3. **`VOYAGE_PROFILE` environment variable** — useful for headless CI
|
||||
4. **Default `premium`** — final fallback
|
||||
|
||||
The plan-file fields for the resolved value:
|
||||
The resolved value is recorded in two places:
|
||||
|
||||
- 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
|
||||
|
||||
|
|
@ -199,8 +206,9 @@ execute + review) on a moderate-complexity task. Numbers scale roughly
|
|||
linearly with the size of the resulting plan (10 steps ≈ baseline; 30
|
||||
steps ≈ 3× the execute column).
|
||||
|
||||
The stats records carry no profile field, so they cannot split cost by
|
||||
profile. Replace the table above with your own measured
|
||||
Per-profile actuals are emitted to JSONL stats — pipe them through the
|
||||
OTel export (`docs/observability.md`) to get real cost-attribution
|
||||
graphs in Grafana. Replace the table above with your own measured
|
||||
numbers after ≥ 3 runs of each profile.
|
||||
|
||||
## Deferred to v4.2
|
||||
|
|
|
|||
|
|
@ -225,18 +225,14 @@ changes introduced by commits that are not ancestors of the previously
|
|||
reverted merge." Revert the revert commit instead, and release it as a new
|
||||
version.
|
||||
|
||||
## Exempted: the eval gate (P10)
|
||||
## Not built yet: the eval gate (P10)
|
||||
|
||||
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
|
||||
configuration paths (commands, agents, skills, hooks, and a changed model alias
|
||||
counts as a change). It is exempted from the release, with a reason: it waits
|
||||
for the fixed test set to run again, and a gate that can never fire is not
|
||||
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.
|
||||
counts as a change). It waits for the test set to run again; a gate that can
|
||||
never fire is not built. When it comes, it is one more check in
|
||||
`scripts/release-check.mjs`, before the door check, and one more step in § 1.
|
||||
|
||||
## Credits
|
||||
|
||||
|
|
|
|||
|
|
@ -51,10 +51,7 @@ 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
|
||||
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 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.
|
||||
that owns it, and that owner must rank higher.
|
||||
- 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
|
||||
description to make room for yours.
|
||||
|
|
@ -75,10 +72,6 @@ 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.
|
||||
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`
|
||||
that accepts `voyage:<name>` and the bare name.
|
||||
|
|
@ -127,15 +120,12 @@ The pressure itself did not break the control in any run. The skills target what
|
|||
|
||||
## Adding a skill: checklist
|
||||
|
||||
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.
|
||||
1. Run the red baseline; keep the transcripts' findings.
|
||||
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`.
|
||||
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.
|
||||
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).
|
||||
6. Add one row to `## Skills` in `README.md` and `CLAUDE.md`.
|
||||
|
||||
## Credits
|
||||
|
||||
|
|
|
|||
|
|
@ -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)."
|
||||
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, from=proevesett-intent-gate]
|
||||
tags: [proevesett]
|
||||
runs: 1
|
||||
max_turns: 15
|
||||
timeout_seconds: 300
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
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."
|
||||
tags: [proevesett, from=proevesett-eval-suite]
|
||||
tags: [proevesett]
|
||||
runs: 1
|
||||
max_turns: 15
|
||||
timeout_seconds: 300
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
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."
|
||||
tags: [proevesett, from=proevesett-eval-suite]
|
||||
tags: [proevesett]
|
||||
runs: 1
|
||||
max_turns: 8
|
||||
timeout_seconds: 300
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
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."
|
||||
tags: [proevesett, from=proevesett-eval-suite]
|
||||
tags: [proevesett]
|
||||
runs: 1
|
||||
max_turns: 8
|
||||
timeout_seconds: 300
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
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."
|
||||
tags: [proevesett, from=proevesett-eval-suite]
|
||||
tags: [proevesett]
|
||||
runs: 1
|
||||
max_turns: 8
|
||||
timeout_seconds: 300
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
description: "/trekreview with no arguments demands --project and stops."
|
||||
expected_outcome: "'Error: --project <dir> is required.' plus usage; no Agent call, no file written."
|
||||
tags: [proevesett, from=proevesett-eval-suite]
|
||||
tags: [proevesett]
|
||||
runs: 1
|
||||
max_turns: 8
|
||||
timeout_seconds: 300
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
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, from=proevesett-eval-suite]
|
||||
tags: [proevesett]
|
||||
runs: 1
|
||||
max_turns: 10
|
||||
timeout_seconds: 300
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
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."
|
||||
tags: [proevesett, from=proevesett-eval-suite]
|
||||
tags: [proevesett]
|
||||
runs: 1
|
||||
max_turns: 10
|
||||
timeout_seconds: 300
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
schema_version: "1.1"
|
||||
name: skill-annotating-effect
|
||||
context:
|
||||
scaffold_script: scaffold.sh
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
---
|
||||
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?
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
type: tool_used
|
||||
tool: Skill
|
||||
input_match: '"skill"\s*:\s*"(?:voyage:)?annotating"'
|
||||
---
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
type: tool_used
|
||||
tool: Skill
|
||||
input_match: '"skill"\s*:\s*"(?:voyage:)?annotating"'
|
||||
---
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
---
|
||||
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?
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
type: tool_used
|
||||
tool: Skill
|
||||
input_match: '"skill"\s*:\s*"(?:voyage:)?annotating"'
|
||||
---
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
---
|
||||
type: tool_used
|
||||
tool: Skill
|
||||
input_match: '"skill"\s*:\s*"(?:voyage:)?annotating"'
|
||||
min: 0
|
||||
max: 0
|
||||
arm: both
|
||||
---
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
---
|
||||
type: tool_used
|
||||
tool: Skill
|
||||
input_match: '"skill"\s*:\s*"(?:voyage:)?annotating"'
|
||||
min: 0
|
||||
max: 0
|
||||
arm: both
|
||||
---
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -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. 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."
|
||||
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."
|
||||
expected_outcome: "Claude never calls the Skill tool for voyage:writing-criteria."
|
||||
tags: [skill-no-trigger, skill=writing-criteria, owner=run, from=order-7c-negatives]
|
||||
tags: [skill-no-trigger, skill=writing-criteria, owner=trekexecute, from=order-7c-negatives]
|
||||
runs: 3
|
||||
max_turns: 4
|
||||
allowed_tools: [Read, Glob, Grep, Skill]
|
||||
|
|
|
|||
|
|
@ -258,201 +258,6 @@ const deobfuscated = normalizeBashExpansion(command);
|
|||
const normalized = normalizeCommand(deobfuscated);
|
||||
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
|
||||
for (const rule of BLOCK_RULES) {
|
||||
if (rule.pattern.test(rule.commandView ? commandView : normalized)) {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,9 @@
|
|||
// lib/parsers/arg-parser.mjs
|
||||
// Parse $ARGUMENTS strings for the seven voyage commands.
|
||||
// Parse $ARGUMENTS strings for the four voyage commands.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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';
|
||||
|
||||
const FLAG_SCHEMA = {
|
||||
|
|
@ -45,11 +38,6 @@ const FLAG_SCHEMA = {
|
|||
valued: ['--profile'],
|
||||
aliases: {},
|
||||
},
|
||||
trekendsession: {
|
||||
boolean: ['--help'],
|
||||
valued: [],
|
||||
aliases: {},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -61,14 +49,9 @@ const FLAG_SCHEMA = {
|
|||
* positional: string[],
|
||||
* unknown: 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) {
|
||||
const tail = tailOf(argString);
|
||||
const schema = FLAG_SCHEMA[command];
|
||||
if (!schema) {
|
||||
return {
|
||||
|
|
@ -77,11 +60,10 @@ export function parseArgs(argString, command) {
|
|||
positional: [],
|
||||
unknown: [],
|
||||
errors: [{ code: 'ARG_UNKNOWN_COMMAND', message: `Unknown command: ${command}` }],
|
||||
tail,
|
||||
};
|
||||
}
|
||||
|
||||
const tokens = tokenize(argString).map((t) => t.value);
|
||||
const tokens = tokenize(argString);
|
||||
const flags = {};
|
||||
const positional = [];
|
||||
const unknown = [];
|
||||
|
|
@ -128,72 +110,22 @@ export function parseArgs(argString, command) {
|
|||
unknown.push(tok);
|
||||
}
|
||||
|
||||
return { command, flags, positional, unknown, errors, tail };
|
||||
return { command, flags, positional, unknown, errors };
|
||||
}
|
||||
|
||||
const isSpace = (c) => /\s/.test(c);
|
||||
|
||||
/**
|
||||
* 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 [];
|
||||
function tokenize(s) {
|
||||
if (typeof s !== 'string') return [];
|
||||
const trimmed = s.trim();
|
||||
if (trimmed === '') return [];
|
||||
const out = [];
|
||||
const n = text.length;
|
||||
let i = 0;
|
||||
while (i < n) {
|
||||
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 });
|
||||
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
||||
let m;
|
||||
while ((m = re.exec(trimmed)) !== null) {
|
||||
out.push(m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2] : m[3]);
|
||||
}
|
||||
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 };
|
||||
|
||||
// Flags a command cannot run without, and its usage line. The CLI below emits
|
||||
|
|
@ -207,41 +139,16 @@ const REQUIRED = {
|
|||
},
|
||||
};
|
||||
|
||||
// CLI: node lib/parsers/arg-parser.mjs --command <command> --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'
|
||||
// $ARGUMENTS
|
||||
// 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.
|
||||
// CLI: node lib/parsers/arg-parser.mjs --command <command> -- $ARGUMENTS
|
||||
// stdout: the parse result as JSON. exit 0, or exit 1 with each error as
|
||||
// "Error: <message>" plus the usage line on stderr.
|
||||
if (isMain(import.meta.url)) {
|
||||
const argv = process.argv.slice(2);
|
||||
const ci = argv.indexOf('--command');
|
||||
const command = ci >= 0 ? argv[ci + 1] : undefined;
|
||||
const dd = argv.indexOf('--');
|
||||
// `--stdin` counts only before `--`: after it, it is an argument word.
|
||||
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 rest = dd >= 0 ? argv.slice(dd + 1) : [];
|
||||
const r = parseArgs(rest.map((t) => (/\s/.test(t) ? `"${t}"` : t)).join(' '), command);
|
||||
const req = REQUIRED[command];
|
||||
if (req) {
|
||||
for (const [flag, metavar] of req.flags) {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
//
|
||||
// What IS implemented, purely: Pass 1 (triplet dedup → survivor ranked by
|
||||
// catalogue tier, then reviewer over engine, asserted severity, conformance,
|
||||
// first in the group's order; the survivor carries its group's highest asserted severity
|
||||
// first seen; the survivor carries its group's highest asserted severity
|
||||
// (review bc7f4e23) + detail concat + raised_by provenance; when that raises
|
||||
// it, `pass1_raised_from` keeps what the survivor itself asserted, read by
|
||||
// Pass 3 and never returned: `original_severity` is what the kept finding
|
||||
|
|
@ -61,8 +61,7 @@ export const JUDGE_DETAIL_MAX = 800;
|
|||
* (agents/review-coordinator.md Pass 3 — "Speculative 'code might break
|
||||
* somewhere' findings have no anchor").
|
||||
*
|
||||
* `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
|
||||
* `accuracy:refuted` (Pass 2 Accuracy — a citation escaping the repo root) and
|
||||
* `file-existence:refuted` (Pass 3 — absent from both working tree and diff)
|
||||
* are emitted by the LLM coordinator, whose fs/judgement branches this module
|
||||
* excludes. They are declared here anyway: the vocabulary is owned in one
|
||||
|
|
@ -80,17 +79,14 @@ export const REFUTING_REASONS = Object.freeze(new Set([
|
|||
* finding, so `succinctness:*` is no longer emitted (a stray one still fails
|
||||
* closed through `classifySuppression`). `file-existence:indeterminate`
|
||||
* is emitted by the LLM coordinator's Pass 3 (which runs the fs Glob this
|
||||
* module deliberately excludes), and `accuracy:line-out-of-range` by its
|
||||
* 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.
|
||||
* module deliberately excludes); the vocabulary is owned here so prose and lib
|
||||
* cannot drift.
|
||||
*/
|
||||
export const UNVERIFIED_REASONS = Object.freeze([
|
||||
'actionability:empty',
|
||||
'engine:out-of-scope',
|
||||
'unknown-rule_key',
|
||||
'file-existence:indeterminate',
|
||||
'accuracy:line-out-of-range',
|
||||
]);
|
||||
// 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
|
||||
|
|
@ -254,10 +250,9 @@ export function ingest(reviewerPayloads) {
|
|||
* (1) the catalogue tier (`getRule(rule_key)?.severity`, else the finding's own
|
||||
* severity; lower `severityRank` wins), (2) a reviewer-agent finding over an
|
||||
* engine finding, (3) among findings of one kind the higher asserted severity,
|
||||
* (4) the conformance reviewer, (5) the first in the group's order (the order
|
||||
* the two passes below place the members in, never the arrival order). It is
|
||||
* 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,
|
||||
* (4) the conformance reviewer, (5) first seen. It is 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
|
||||
* provenance, concatenates other reviewers' attribution into detail, and
|
||||
* recomputes the id over the triplet.
|
||||
|
|
@ -268,30 +263,23 @@ export function dedupByTriplet(findings) {
|
|||
// 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
|
||||
// its own row and the id over `rule_key#2` (voyage-lekerepo trial 5,
|
||||
// finding 7). Inside one triplet the findings are grouped in two passes, so
|
||||
// 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.
|
||||
// finding 7). Another reviewer's finding joins the group whose title it
|
||||
// shares, else the first one it can join.
|
||||
const tripletGroups = new Map();
|
||||
findings.forEach((f, i) => {
|
||||
for (const f of findings) {
|
||||
const key = tripletKey(f);
|
||||
if (!tripletGroups.has(key)) tripletGroups.set(key, []);
|
||||
tripletGroups.get(key).push({ f, i });
|
||||
});
|
||||
const groups = [...tripletGroups.values()].flatMap((entries) => groupTriplet(entries));
|
||||
const subs = tripletGroups.get(key);
|
||||
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))
|
||||
?? subs.find(fits);
|
||||
if (!sub) {
|
||||
sub = [];
|
||||
subs.push(sub);
|
||||
}
|
||||
sub.push(f);
|
||||
}
|
||||
const groups = [...tripletGroups.values()].flat();
|
||||
const out = [];
|
||||
for (const group of groups) {
|
||||
// Ranked as the JSDoc above says. A brief-conflict mark is not carried over
|
||||
|
|
@ -349,59 +337,9 @@ export function dedupByTriplet(findings) {
|
|||
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) {
|
||||
if (a.title === undefined || b.title === undefined) return true;
|
||||
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);
|
||||
return String(a.title).trim().toLowerCase() === String(b.title).trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -496,9 +434,8 @@ export function judgeFilter(findings) {
|
|||
|
||||
/**
|
||||
* Pass 3 — Cloudflare reasonableness (deterministic subset): drop findings
|
||||
* with no citation (empty file / line < 0) and
|
||||
* route an unknown rule_key to `unverified`; CORRECT a severity that does not
|
||||
* match the catalogue tier (a correction, not a drop).
|
||||
* with no citation (empty file / line < 0) or an unknown rule_key; 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
|
||||
* prose-side, tokenised as `file-existence:indeterminate`.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -34,14 +34,13 @@
|
|||
// node lib/review/recurrence.mjs propose --repo <dir>
|
||||
// → {"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>"]
|
||||
// → {"written":true|false, "reason":…, "pointer":…} on stdout (the pointer result,
|
||||
// see applyLesson); exit 0 written, 1 refused, 2 could not run
|
||||
// → {"written":true|false, "reason":…} on stdout; exit 0 written, 1 refused, 2 could not run
|
||||
|
||||
import {
|
||||
readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, readdirSync, statSync, realpathSync, lstatSync,
|
||||
} from 'node:fs';
|
||||
import { join, relative, isAbsolute, sep, delimiter, resolve, dirname } from 'node:path';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { join, relative, dirname } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { validateReviewContent } from '../validators/review-validator.mjs';
|
||||
import { extractFindingsBlock } from './findings-schema.mjs';
|
||||
import { getRule } from './rule-catalogue.mjs';
|
||||
|
|
@ -62,7 +61,7 @@ export const DEFAULT_BUDGET_CHARS = 6000;
|
|||
export const LINE_MAX = 700;
|
||||
export const RECURRENCE_THRESHOLD = 2;
|
||||
|
||||
export const BLOCK_HEADER = 'Each line: a finding class /trekreview saw in two reviews, approved by the operator. Format: mistake · fix · how to check.';
|
||||
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. */
|
||||
const CHECK_HINTS = Object.freeze({
|
||||
|
|
@ -277,133 +276,17 @@ export function proposeLessons(repo) {
|
|||
|
||||
// ---- writing an approved lesson ---------------------------------------------
|
||||
|
||||
/** lstat that answers null for a path that does not exist. */
|
||||
function lstatOrNull(path) {
|
||||
try { return lstatSync(path); } catch (e) { if (e && e.code === 'ENOENT') return null; throw e; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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` };
|
||||
}
|
||||
function atomicWriteText(path, text) {
|
||||
// Write THROUGH a symlink (CLAUDE.md -> AGENTS.md is common): rename onto the
|
||||
// real target, so the link survives and the target gets the change.
|
||||
if (existsSync(path) && lstatSync(path).isSymbolicLink()) path = realpathSync(path);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.tmp-${process.pid}`;
|
||||
writeFileSync(tmp, text);
|
||||
renameSync(tmp, path);
|
||||
return { written: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
function withPointer(claudeText) {
|
||||
if (claudeText.includes(POINTER_START) || claudeText.includes(POINTER_END)) return null;
|
||||
const sep = claudeText.endsWith('\n') ? '\n' : '\n\n';
|
||||
return `${claudeText}${sep}${POINTER_START}\n${POINTER_LINE}\n${POINTER_END}\n`;
|
||||
|
|
@ -415,16 +298,7 @@ export function withPointer(claudeText) {
|
|||
* @param {string} repo
|
||||
* @param {string} ruleKey
|
||||
* @param {{ line?: string, budgetEntries?: number, budgetChars?: number }} [opts]
|
||||
* 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 }}
|
||||
* @returns {{ written: boolean, reason: string, path?: string, pointer?: string }}
|
||||
*/
|
||||
export function applyLesson(repo, ruleKey, opts = {}) {
|
||||
const budgetEntries = opts.budgetEntries ?? DEFAULT_BUDGET_ENTRIES;
|
||||
|
|
@ -459,36 +333,16 @@ 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)' };
|
||||
}
|
||||
const next = base.slice(0, block.start + MARK_START.length) + innerAfter + base.slice(block.end);
|
||||
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 };
|
||||
atomicWriteText(path, next);
|
||||
|
||||
// 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 pointerNote;
|
||||
const claudePath = join(repo, 'CLAUDE.md');
|
||||
const claudeStat = lstatOrNull(claudePath);
|
||||
if (claudeStat && claudeStat.isSymbolicLink()) {
|
||||
pointer = 'symlink-skipped';
|
||||
} 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';
|
||||
}
|
||||
}
|
||||
if (existsSync(claudePath)) {
|
||||
const updated = withPointer(readFileSync(claudePath, 'utf-8'));
|
||||
if (updated === null) pointer = 'present';
|
||||
else { atomicWriteText(claudePath, updated); pointer = 'written'; }
|
||||
}
|
||||
const result = { written: true, reason: 'written', path: relative(repo, path), pointer };
|
||||
if (pointerNote !== undefined) result.pointer_note = pointerNote;
|
||||
return result;
|
||||
return { written: true, reason: 'written', path: relative(repo, path), pointer };
|
||||
}
|
||||
|
||||
// ---- CLI --------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,237 +0,0 @@
|
|||
// 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 };
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
// 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:
|
||||
//
|
||||
// node lib/review/verdict-gate.mjs [--json] <review.md> [--repo <dir> | --repo=<dir>]
|
||||
// node lib/review/verdict-gate.mjs [--json] <review.md>
|
||||
// 0 = ALLOW or WARN
|
||||
// 1 = BLOCK
|
||||
// 2 = could not decide: missing/unreadable file, not a review, no verdict,
|
||||
|
|
@ -13,17 +13,7 @@
|
|||
// shape (coordinator-contract `illegalBriefLowering`), or a
|
||||
// WARN because a reviewer or engine did not run (`missing-reviewer:` /
|
||||
// `unattributable-payload` in allow_blocked_by — operator decision
|
||||
// 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`.
|
||||
// 2026-10-06: a review not performed in full stops a merge).
|
||||
// Exit 2 is NEVER green.
|
||||
//
|
||||
// Fail-closed reading: a missing `verdict` is exit 2, never ALLOW. A verdict
|
||||
|
|
@ -42,7 +32,6 @@ import { readFileSync, existsSync } from 'node:fs';
|
|||
import { validateReviewContent, VERDICT_VALUES } from '../validators/review-validator.mjs';
|
||||
import { fencedBlocks, isJsonBlock } from './findings-schema.mjs';
|
||||
import { illegalBriefLowering } from './coordinator-contract.mjs';
|
||||
import { reviewedSha, checkReviewedCommit } from './reviewed-commit.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
export const EXIT = Object.freeze({ PASS: 0, BLOCK: 1, UNDECIDED: 2 });
|
||||
|
|
@ -170,12 +159,10 @@ function blockerSectionEntries(body) {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Decide on a review.md path.
|
||||
* @param {string} filePath
|
||||
* @param {{ repo?: string }} [opts]
|
||||
*/
|
||||
export function decideVerdictFile(filePath, { repo } = {}) {
|
||||
export function decideVerdictFile(filePath) {
|
||||
if (!filePath || !existsSync(filePath)) return undecided(`file not found: ${filePath}`);
|
||||
let text;
|
||||
try {
|
||||
|
|
@ -183,58 +170,21 @@ export function decideVerdictFile(filePath, { repo } = {}) {
|
|||
} catch (e) {
|
||||
return undecided(`cannot read ${filePath}: ${e.message}`);
|
||||
}
|
||||
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;
|
||||
return decideVerdict(text);
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) {
|
||||
const args = process.argv.slice(2);
|
||||
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 files = args.filter((a) => !a.startsWith('--'));
|
||||
const filePath = files[0];
|
||||
if (files.length !== 1) {
|
||||
// Exactly one file: a glob would otherwise gate on its first match only.
|
||||
process.stderr.write(usage);
|
||||
process.stderr.write('Usage: verdict-gate.mjs [--json] <review.md> (exactly one file)\n');
|
||||
process.exit(EXIT.UNDECIDED);
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = decideVerdictFile(filePath, { repo });
|
||||
result = decideVerdictFile(filePath);
|
||||
} catch (e) {
|
||||
result = undecided(`gate crashed: ${e.message}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,11 +20,7 @@
|
|||
// A case is tagged in its prompt.md frontmatter:
|
||||
// tags: [skill-trigger | skill-no-trigger | skill-effect, skill=<name>,
|
||||
// owner=<command|agent|skill name> (negatives), from=<event>]
|
||||
// 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=`.
|
||||
// `from=` names the event the case came from (P10: every case is marked).
|
||||
//
|
||||
// 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.
|
||||
|
|
@ -184,12 +180,8 @@ export function loadEntries(root) {
|
|||
|
||||
const KIND_TAGS = { 'skill-trigger': 'trigger', 'skill-no-trigger': 'no-trigger', 'skill-effect': 'effect' };
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
/** The routing-tagged eval cases of a plugin root (evals/<case>/prompt.md). */
|
||||
export function loadCases(root) {
|
||||
const d = join(root, 'evals');
|
||||
if (!existsSync(d)) return [];
|
||||
const out = [];
|
||||
|
|
@ -200,30 +192,16 @@ export function loadAllCases(root) {
|
|||
const tagsField = readFrontmatterFields(text).fields.tags;
|
||||
const tags = tagsField && Array.isArray(tagsField.value) ? tagsField.value.map(String) : [];
|
||||
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 body = text.replace(/^---\n[\s\S]*?\n---\n?/, '').trim();
|
||||
out.push({ case: name, kind: kindTag ? KIND_TAGS[kindTag] : null, skill: val('skill'), owner: val('owner'), from: val('from'), prompt: body });
|
||||
out.push({ case: name, kind: KIND_TAGS[kindTag], skill: val('skill'), owner: val('owner'), from: val('from'), prompt: body });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The routing-tagged eval cases of a plugin root. */
|
||||
export function loadCases(root) {
|
||||
return loadAllCases(root).filter((c) => c.kind);
|
||||
}
|
||||
|
||||
// ---- 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.
|
||||
* @returns {{ errors: object[], warnings: object[], stats: object }}
|
||||
|
|
@ -236,11 +214,9 @@ export function checkRouting(root, t = THRESHOLDS) {
|
|||
// 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 byName = new Map([...entries.filter((e) => e.kind !== 'skill'), ...skills].map((e) => [e.name, e]));
|
||||
const all = loadAllCases(root);
|
||||
const cases = all.filter((c) => c.kind);
|
||||
const cases = loadCases(root);
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const notes = [];
|
||||
let positives = 0; let rank1 = 0; let negatives = 0;
|
||||
|
||||
for (const c of collisions(corpus, t)) {
|
||||
|
|
@ -249,11 +225,8 @@ export function checkRouting(root, t = THRESHOLDS) {
|
|||
(c.level === 'error' ? errors : warnings).push(entry);
|
||||
}
|
||||
|
||||
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=...)` });
|
||||
}
|
||||
|
||||
for (const c of cases) {
|
||||
if (!c.from) errors.push({ code: 'ROUTING_CASE_NO_SOURCE', case: c.case, message: `${c.case}: tag the event the case came from (from=...)` });
|
||||
const self = c.skill && skillByName.get(c.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/` });
|
||||
|
|
@ -278,9 +251,6 @@ export function checkRouting(root, t = THRESHOLDS) {
|
|||
}
|
||||
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=...)` });
|
||||
} 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)) {
|
||||
errors.push({ code: 'ROUTING_UNKNOWN_OWNER', case: c.case, message: `${c.case}: owner=${c.owner} is no skill, command or spawnable agent` });
|
||||
} else {
|
||||
|
|
@ -306,7 +276,7 @@ export function checkRouting(root, t = THRESHOLDS) {
|
|||
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)}%` });
|
||||
}
|
||||
return { errors, warnings, notes, stats: { entries: entries.length, skills: skills.length, sourced: all.length, cases: cases.length, positives, negatives, rank1 } };
|
||||
return { errors, warnings, stats: { entries: entries.length, skills: skills.length, cases: cases.length, positives, negatives, rank1 } };
|
||||
}
|
||||
|
||||
// ---- CLI ----
|
||||
|
|
@ -324,8 +294,7 @@ if (isMain(import.meta.url)) {
|
|||
else {
|
||||
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 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.stdout.write(`${r.stats.entries} descriptions, ${r.stats.cases} cases, ${r.stats.rank1}/${r.stats.positives} positives first; ${r.errors.length} finding(s)\n`);
|
||||
}
|
||||
process.exit(r.errors.length ? 1 : 0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,10 +16,7 @@
|
|||
// finish — pipeline reached its end
|
||||
//
|
||||
// CLI shim:
|
||||
// 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)
|
||||
// node lib/util/autonomy-gate.mjs --state X --event Y [--gates true|false]
|
||||
// → JSON: { ok: true, next_state: "..." } (success)
|
||||
// → JSON: { ok: false, error: "..." } (invalid transition; exit 1)
|
||||
//
|
||||
|
|
@ -105,40 +102,15 @@ export function isTerminal(state) {
|
|||
|
||||
// ---- 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) {
|
||||
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++) {
|
||||
const a = argv[i];
|
||||
if (a === '--state') out.state = argv[++i];
|
||||
else if (a === '--event') out.event = argv[++i];
|
||||
else if (a.startsWith('--gates=')) {
|
||||
// The one-token form, read as `--gates <value>`; an empty value is no value.
|
||||
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++;
|
||||
}
|
||||
else if (a === '--gates') {
|
||||
const v = argv[++i];
|
||||
out.gates = v === 'true';
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
|
@ -146,11 +118,10 @@ function parseArgs(argv) {
|
|||
|
||||
if (isMain(import.meta.url)) {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.error || !args.state || !args.event) {
|
||||
if (!args.state || !args.event) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
ok: false,
|
||||
error: args.error
|
||||
|| 'usage: autonomy-gate.mjs --state <state> --event <event> [--gates [true|false|closed|open]]',
|
||||
error: 'usage: autonomy-gate.mjs --state <state> --event <event> [--gates true|false]',
|
||||
}) + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,59 +7,37 @@
|
|||
// rejected line would have passed Verify, the manifest and the success
|
||||
// criterion. Only the plan critic caught it.
|
||||
//
|
||||
// Before the fresh build the executor parks them: it parks every file new in
|
||||
// the attempt's diff (a `new file mode` entry in step-{N}-upward.diff) that is
|
||||
// untracked, or staged but not in HEAD; it moves to
|
||||
// <git dir>/voyage/parked/step-<N>-attempt-<k>/ under its own path. The git dir, like the test lock's, is outside the work
|
||||
// Before the fresh build the executor parks them: every file the attempt
|
||||
// created (a `new file mode` entry in step-{N}-upward.diff) that is still
|
||||
// untracked on disk moves to <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.
|
||||
// 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:
|
||||
// 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}.
|
||||
// Exit: 0 = no file the attempt made is left in the tree; 1 = one still is
|
||||
// (a header it could not read, a non-empty diff with no readable header, a
|
||||
// move that failed, a target already taken): the run stops; 2 = could not run
|
||||
// (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".
|
||||
// (a header it could not read, a move that failed, a target already taken):
|
||||
// the run stops; 2 = could not run (bad usage, unreadable diff, not a
|
||||
// repository). Neither 1 nor 2 is ever "the tree is clean".
|
||||
|
||||
import { readFileSync, mkdirSync, renameSync, lstatSync } from 'node:fs';
|
||||
import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
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`
|
||||
* 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). 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".
|
||||
* files whose header is not that form (another prefix, a quoted path).
|
||||
*/
|
||||
export function newFilesInDiff(diffText) {
|
||||
const paths = [];
|
||||
const unread = [];
|
||||
let header = null;
|
||||
let sawHeader = false;
|
||||
const lines = String(diffText).split(/\r?\n/).map((l) => l.replace(ANSI_RE, ''));
|
||||
for (const line of lines) {
|
||||
for (const line of String(diffText).split('\n')) {
|
||||
if (line.startsWith('diff --git ')) {
|
||||
sawHeader = true;
|
||||
header = line;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -71,64 +49,9 @@ export function newFilesInDiff(diffText) {
|
|||
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 };
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
try {
|
||||
lstatSync(abs);
|
||||
|
|
@ -143,17 +66,15 @@ function lexists(abs) {
|
|||
* @returns {{ parked: string[], kept: Array<{path: string, reason: string, blocking: boolean}>, to: string }}
|
||||
*/
|
||||
export function parkAttemptFiles({ diffText, step, attempt, cwd = process.cwd() }) {
|
||||
const root = git(resolve(cwd), ['rev-parse', '--show-toplevel']).trim();
|
||||
const gitDir = git(root, ['rev-parse', '--absolute-git-dir']).trim();
|
||||
const root = resolve(cwd);
|
||||
const gitDir = spawnSync('git', ['rev-parse', '--absolute-git-dir'], { cwd: root, encoding: 'utf8' }).stdout.trim();
|
||||
const to = join(gitDir, 'voyage', 'parked', `step-${step}-attempt-${attempt}`);
|
||||
const trackedPaths = new Set(git(root, ['ls-files', '-z']).split('\0').filter(Boolean));
|
||||
const inHead = headPaths(root);
|
||||
const trackedPaths = new Set(
|
||||
spawnSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8' }).stdout.split('\0').filter(Boolean),
|
||||
);
|
||||
const { paths, unread } = newFilesInDiff(diffText);
|
||||
const parked = [];
|
||||
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) {
|
||||
const abs = resolve(root, path);
|
||||
const rel = relative(root, abs);
|
||||
|
|
@ -165,14 +86,8 @@ export function parkAttemptFiles({ diffText, step, attempt, cwd = process.cwd()
|
|||
kept.push({ path, reason: 'not on disk: nothing to park', blocking: false });
|
||||
continue;
|
||||
}
|
||||
const posix = rel.split('\\').join('/');
|
||||
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);
|
||||
}
|
||||
if (trackedPaths.has(rel.split('\\').join('/'))) {
|
||||
kept.push({ path, reason: 'tracked since the attempt: not the executor\'s to move', blocking: false });
|
||||
continue;
|
||||
}
|
||||
const target = join(to, rel);
|
||||
|
|
@ -180,24 +95,6 @@ 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 });
|
||||
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 {
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
renameSync(abs, target);
|
||||
|
|
@ -234,13 +131,7 @@ if (isMain(import.meta.url)) {
|
|||
process.stderr.write(`park-attempt-files: ${cwd} is not a git repository\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
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);
|
||||
}
|
||||
const result = parkAttemptFiles({ diffText, step, attempt, cwd });
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
process.exit(result.kept.some((k) => k.blocking) ? 1 : 0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,25 +153,11 @@ export function validateNextSessionPromptConsistency(a, b, opts = {}) {
|
|||
|
||||
if (isMain(import.meta.url)) {
|
||||
const args = process.argv.slice(2);
|
||||
// The valued flag (--state-file) consumes its value before positionals are
|
||||
// collected, so the state file is never counted as a third path.
|
||||
const positionals = [];
|
||||
let wantJson = false;
|
||||
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);
|
||||
}
|
||||
const positionals = args.filter(a => !a.startsWith('--'));
|
||||
const wantJson = args.includes('--json');
|
||||
const consistency = args.includes('--consistency');
|
||||
const stateIdx = args.indexOf('--state-file');
|
||||
const stateFile = stateIdx >= 0 ? args[stateIdx + 1] : null;
|
||||
|
||||
function emit(r) {
|
||||
if (wantJson) {
|
||||
|
|
@ -208,8 +194,7 @@ if (isMain(import.meta.url)) {
|
|||
{ state: stateObj },
|
||||
);
|
||||
emit({
|
||||
// Valid only when the pair is consistent AND A and B are each valid.
|
||||
valid: r.valid && ra.valid !== false && rb.valid !== false,
|
||||
valid: r.valid && ra.valid !== false,
|
||||
errors: [...(ra.errors || []), ...(rb.errors || []), ...r.errors],
|
||||
warnings: [...(ra.warnings || []), ...(rb.warnings || []), ...r.warnings],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,8 +12,7 @@
|
|||
//
|
||||
// `--check --step <N> --report <file> [--commit <sha>]`: exit 0 when the report
|
||||
// carries a well-formed line for step N (at exactly that commit, with
|
||||
// --commit), 1 when it does not, 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
|
||||
// --commit), 1 when it does not. /trekexecute writes the line right after the
|
||||
// step's checkpoint and checks it with --commit, so the line is bound to the
|
||||
// commit the test passed on.
|
||||
//
|
||||
|
|
@ -27,26 +26,17 @@ import { resolve } from 'node:path';
|
|||
import { DEFAULT_TIMEOUT_MS, allowedCommand, runCommand, screenCommand, stoppedBy } from './criteria-runner.mjs';
|
||||
import { repoRoot } from './red-proof.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) {
|
||||
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() : '(?!)';
|
||||
const sha = commit ? String(commit).replace(/[^0-9a-f]/g, '') : '[0-9a-f]{40}';
|
||||
return new RegExp(`^DONE step ${step}: \`.+\` exit 0 at ${sha} \\d{4}-\\d{2}-\\d{2}T\\S+$`, 'm');
|
||||
}
|
||||
|
||||
/**
|
||||
* With `commit` (the step's checkpoint sha, a full 40- or 64-hex id), the line
|
||||
* must name exactly that commit. A regex cannot tell a typed line from a
|
||||
* written one; the commit binds the line to the state the test ran on
|
||||
* (/trekexecute writes the line right after the checkpoint, so HEAD is the
|
||||
* step's own commit).
|
||||
* With `commit` (the step's checkpoint sha, full 40 hex), the line must name
|
||||
* exactly that commit. A regex cannot tell a typed line from a written one; the
|
||||
* commit binds the line to the state the test ran on (/trekexecute writes the
|
||||
* line right after the checkpoint, so HEAD is the step's own commit).
|
||||
*/
|
||||
export function hasDoneLine(reportPath, step, commit) {
|
||||
if (!existsSync(reportPath)) return false;
|
||||
|
|
@ -99,11 +89,6 @@ export function main(argv) {
|
|||
}
|
||||
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; }
|
||||
// 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);
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ import { spawnSync } from 'node:child_process';
|
|||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
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 { DEFAULT_TIMEOUT_MS, stoppedBy } from '../lib/verification/criteria-runner.mjs';
|
||||
import { isMain } from '../lib/util/is-main.mjs';
|
||||
|
|
@ -214,21 +213,26 @@ function checkReview(reviewPath) {
|
|||
return { check: { id: 'review', status: 'pass', detail: `review verdict ${v.verdict}` }, text };
|
||||
}
|
||||
|
||||
// `reviewedSha` lives in lib/review/reviewed-commit.mjs (the verdict gate's
|
||||
// --repo check reads it too; lib/ never imports from scripts/). Re-exported
|
||||
// here under the same name for the callers of this module.
|
||||
export { reviewedSha };
|
||||
/**
|
||||
* 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).
|
||||
return m && /^[0-9a-f]{7,40}$/i.test(m[2]) ? m[2] : '';
|
||||
}
|
||||
|
||||
function checkCoverage(repo, reviewText) {
|
||||
if (reviewText === null) return { id: 'coverage', status: 'skip', detail: 'not checked: no readable review' };
|
||||
const sha = reviewedSha(reviewText);
|
||||
// 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' };
|
||||
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' };
|
||||
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)` };
|
||||
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']);
|
||||
if (anc.status !== 0) return { id: 'coverage', status: 'fail', detail: `the reviewed commit ${full.slice(0, 12)} is not in the history of HEAD` };
|
||||
|
||||
|
|
|
|||
|
|
@ -54,23 +54,8 @@ const RULE_KEYS = Object.keys(RULE_TIERS);
|
|||
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.
|
||||
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.
|
||||
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
|
||||
// rule_key enforces the catalogue at the StructuredOutput layer (stronger than
|
||||
|
|
@ -137,82 +122,41 @@ const VERDICT_SCHEMA = {
|
|||
// 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
|
||||
// rule_key is shared), then a reviewer finding (no `engine`) over an engine
|
||||
// finding (decision 13, operator 12.10), then the higher asserted severity,
|
||||
// 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).
|
||||
// finding (decision 13, operator 12.10), then the higher asserted severity;
|
||||
// the workflow has no conformance rule, so otherwise the first-seen one stays.
|
||||
// The survivor carries the highest severity any finding in its group 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
|
||||
// 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.
|
||||
const sameTitle = (a, b) => a.title === undefined || b.title === undefined || normTitle(a) === normTitle(b);
|
||||
// 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 sameTitle = (a, b) => a.title === undefined || b.title === undefined ||
|
||||
String(a.title).trim().toLowerCase() === String(b.title).trim().toLowerCase();
|
||||
const sourceOf = new Map();
|
||||
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 } = {}) {
|
||||
function dedupByTriplet(findings, { perSource = true } = {}) {
|
||||
const byTriplet = new Map();
|
||||
findings.forEach((f, i) => {
|
||||
for (const f of findings) {
|
||||
const key = tripletOf(f);
|
||||
if (!byTriplet.has(key)) byTriplet.set(key, []);
|
||||
byTriplet.get(key).push({ f, i });
|
||||
});
|
||||
const groups = [...byTriplet.values()].flatMap((entries) => (perSource
|
||||
? groupTriplet(entries)
|
||||
: [entries.map((e) => e.f)]));
|
||||
const subs = byTriplet.get(key);
|
||||
const src = sourceOf.get(f) ?? null;
|
||||
const fits = (g) => g.every((m) => (sourceOf.get(m) ?? null) !== src || sameTitle(m, f));
|
||||
let sub = !perSource ? subs[0]
|
||||
: subs.find((g) => fits(g) && g.some((m) => m.title !== undefined && f.title !== undefined && sameTitle(m, 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 = [];
|
||||
for (const group of groups) {
|
||||
let survivor = group[0];
|
||||
for (const f of group.slice(1)) {
|
||||
const tf = tierRank(f);
|
||||
const ts = tierRank(survivor);
|
||||
const tf = tier(f);
|
||||
const ts = tier(survivor);
|
||||
if (tf !== ts) {
|
||||
if (tf < ts) survivor = f;
|
||||
continue;
|
||||
|
|
@ -223,21 +167,13 @@ function dedupByTriplet(findings, { perSource = true, rows = null } = {}) {
|
|||
if (!ef) survivor = f;
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
if (sevRank(f.severity) < sevRank(survivor.severity)) survivor = f;
|
||||
}
|
||||
let severity = survivor.severity;
|
||||
for (const f of group) {
|
||||
if (sevRank(f.severity) < sevRank(severity)) severity = f.severity;
|
||||
}
|
||||
const row = severity === survivor.severity ? survivor : { ...survivor, severity };
|
||||
if (rows) rows.set(row, group);
|
||||
out.push(row);
|
||||
out.push(severity === survivor.severity ? survivor : { ...survivor, severity });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -251,7 +187,7 @@ function reviewerPrompt(kind, a) {
|
|||
'Only emit findings whose rule_key is in the catalogue. Cite file:line from the diff.',
|
||||
'',
|
||||
'=== TRIAGE MAP (respect skip decisions) ===',
|
||||
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/**)',
|
||||
a.triage || '(no triage map supplied — treat all changed files as summary-only)',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
|
|
@ -262,7 +198,7 @@ function coordinatorPrompt(merged, a) {
|
|||
'(file,line,rule_key) in JS. Do NOT re-dedup. Apply ONLY bounded operations:',
|
||||
'HubSpot Judge filters (accuracy / actionability; succinctness NEVER removes a',
|
||||
'finding — trim only MINOR/SUGGESTION text, keep BLOCKER/MAJOR whole), Cloudflare',
|
||||
'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',
|
||||
'reasonableness (drop speculative or catalogue-violating findings), then compute',
|
||||
'the verdict (BLOCK if any BLOCKER; WARN if any MAJOR; else ALLOW).',
|
||||
'Return {verdict, findings} via StructuredOutput. Synthesis across files is forbidden.',
|
||||
`The brief (the contract) is at: ${a.briefPath} — Read it if needed for context.`,
|
||||
|
|
@ -339,8 +275,7 @@ const allFindings = [
|
|||
...enginePayloads.flatMap((p) => (Array.isArray(p?.findings) ? p.findings : [])
|
||||
.map((f) => tagged(withEngine(f, engineOf(p?.reviewer)), `engine:${p?.reviewer}`))),
|
||||
];
|
||||
const rowMembers = new Map();
|
||||
const merged = dedupByTriplet(allFindings, { rows: rowMembers });
|
||||
const merged = dedupByTriplet(allFindings);
|
||||
log(`Arm B: ${reviewerResults.length} reviewer(s); ${allFindings.length} raw → ${merged.length} after triplet-dedup`);
|
||||
|
||||
phase('Coordinate');
|
||||
|
|
@ -362,51 +297,28 @@ if (coordinatorMissing) missingReviewers += 1;
|
|||
// coordinator returned, so an LLM-written ALLOW over a BLOCKER cannot pass.
|
||||
const returned = coordinatorMissing ? merged : coord.findings;
|
||||
// The floor reads two inputs, never the labels the coordinator wrote:
|
||||
// - `merged` (the coordinator's input, one finding per row) for each returned
|
||||
// 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
|
||||
// - `merged` (the coordinator's input, same file/line/rule_key) for each
|
||||
// returned finding's counterpart: (a) an engine counterpart raises it to the
|
||||
// engine's severity, unless the one legal lowering; (b) a reviewer
|
||||
// counterpart, labelled or not, raises it to the lower of the counterpart's
|
||||
// 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
|
||||
// counterpart's severity), and a labelled finding is raised as before.
|
||||
// - the rows' members (before dedup, so an engine finding a reviewer finding
|
||||
// shadowed in its row counts too) for the put-back: (c) each engine finding
|
||||
// in a row whose `rowOf` key no returned finding carries (neither as it
|
||||
// stands nor with the title trimmed as the coordinator trims it) is
|
||||
// put back at its own severity, one copy per row (the higher severity, else
|
||||
// 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).
|
||||
// - `allFindings` (before dedup, so an engine finding a reviewer finding
|
||||
// shadowed in its triplet counts too) for the put-back: (c) every engine
|
||||
// finding with no returned finding at its exact triplet is appended at its
|
||||
// own severity, one copy per triplet (the higher severity, else the first
|
||||
// seen) (operator decision 23, review ab1b5d9f). So the coordinator's drop,
|
||||
// move or relabel of an engine finding no longer lowers the verdict.
|
||||
// The raises and put-backs are written into `returned`, the marks kept, so the
|
||||
// rendered review.md carries them.
|
||||
// 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
|
||||
// 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.
|
||||
const rowsOn = (map, key, m) => {
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
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 counterparts = new Map(merged
|
||||
.filter((m) => m && typeof m === 'object')
|
||||
.map((m) => [tripletOf(m), m]));
|
||||
const present = (f, field) => f[field] !== undefined && f[field] !== null;
|
||||
const raiseTo = (f, sev) => { if (sevRank(sev) < sevRank(f.severity)) f.severity = sev; };
|
||||
const lowerOf = (s1, s2) => (sevRank(s1) >= sevRank(s2) ? s1 : s2);
|
||||
|
|
@ -419,7 +331,7 @@ if (coordinatorMissing) {
|
|||
}
|
||||
for (const f of returned) {
|
||||
if (!f || typeof f !== 'object') continue;
|
||||
const c = counterpartOf(f);
|
||||
const c = counterparts.get(tripletOf(f));
|
||||
const labelled = present(f, 'engine') || present(f, 'contradicts_brief') || present(f, 'brief_lowered_from');
|
||||
if (c && c.engine) {
|
||||
// (a) an engine counterpart: raised to its severity, unless the one legal lowering.
|
||||
|
|
@ -443,18 +355,11 @@ for (const f of returned) {
|
|||
}
|
||||
}
|
||||
// (c) the put-back (decision 23).
|
||||
const returnedRows = new Set(returned.filter((f) => f && typeof f === 'object').map(rowOf));
|
||||
// A row is returned when a returned finding carries its key, or the key of its
|
||||
// title as the coordinator trims it, so a trimmed title puts back no copy.
|
||||
const rowReturned = (m) => returnedRows.has(rowOf(m)) ||
|
||||
(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 }));
|
||||
const returnedTriplets = new Set(returned.filter((f) => f && typeof f === 'object').map(tripletOf));
|
||||
// Engine findings only, so dedupByTriplet keeps the higher severity, else the
|
||||
// first seen: one copy per triplet (decision 23), so not split per source here.
|
||||
const putBack = dedupByTriplet(allFindings.filter((f) =>
|
||||
f && typeof f === 'object' && f.engine && !returnedTriplets.has(tripletOf(f))), { perSource: false });
|
||||
for (const f of putBack) {
|
||||
const { brief_lowered_from: _lowered, ...copy } = f;
|
||||
returned.push(copy);
|
||||
|
|
|
|||
|
|
@ -1,98 +0,0 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -93,8 +93,7 @@ code alone.
|
|||
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
|
||||
defect instead (`lowered_from`, `caught_by`). With no named rule, keep the
|
||||
tier, or drop the finding as wrong with its reason on a Declined to Judge
|
||||
line; never soften it in between.
|
||||
tier or drop the finding as wrong; never soften it in between.
|
||||
|
||||
### 6. Declined to Judge: nothing disappears silently
|
||||
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ Several test files share one `File:` line the same way:
|
|||
- File: `path/to/test.ts` *(existing | new)*
|
||||
- Verifies: {what the test checks}
|
||||
- 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; 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)*
|
||||
- **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)*
|
||||
- **Verify:** `{exact command}` → expected: `{output}`
|
||||
- **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}"`
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@ should look at first.
|
|||
|
||||
| File | Treatment | Reason |
|
||||
|------|-----------|--------|
|
||||
| lib/foo.mjs | deep-review | default for changed code |
|
||||
| docs/guide.md | summary-only | docs/** is summary-only |
|
||||
| lib/foo.mjs | deep-review | matched deep-review pattern |
|
||||
| lib/bar.mjs | summary-only | low-risk, no test patterns matched |
|
||||
| dist/bundle.js | skip | matches generated-file pattern |
|
||||
| commands/baz.md `[uncommitted]` | deep-review | working-tree change since session_start_sha |
|
||||
|
||||
|
|
|
|||
|
|
@ -11,12 +11,9 @@
|
|||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { childEnv } from '../helpers/child-env.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
|
|
@ -128,108 +125,3 @@ 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',
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
// tests/commands/trekexecute.test.mjs
|
||||
// v5.1 prose-pin tests + v5.1.1 runtime SC4 + SC7 tests for /trekexecute.
|
||||
// Low effort runs the plan as one session, step after step; it sets no --gates
|
||||
// (fix round 4-5a, #33).
|
||||
// Plan Assumption 2 locks low-effort to --gates open + sequential-only.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
|
|
@ -34,14 +33,13 @@ test('trekexecute — sequencing-gate surface mentions BRIEF_V51_MISSING_SIGNALS
|
|||
'/trekexecute must reference phase_signals (v5.1 composition rule)');
|
||||
});
|
||||
|
||||
test('trekexecute — low-effort path runs one session, step after step, and sets no --gates open', () => {
|
||||
test('trekexecute — low-effort path references --gates open + sequential', () => {
|
||||
const text = read();
|
||||
const compIdx = text.indexOf('## Composition rule (v5.1)');
|
||||
assert.ok(compIdx >= 0, 'Composition rule (v5.1) section missing');
|
||||
const section = text.slice(compIdx, compIdx + 2000);
|
||||
assert.doesNotMatch(section, /--gates open/, 'Low effort must not set --gates open');
|
||||
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');
|
||||
assert.match(section, /--gates open/, 'Low-effort path must mention --gates open');
|
||||
assert.match(section, /sequential/, 'Low-effort path must mention sequential-only execution');
|
||||
});
|
||||
|
||||
// --- v5.1.1 runtime SC4 + SC7 ---
|
||||
|
|
|
|||
|
|
@ -206,16 +206,11 @@ test('trekresearch — Phase 5 pins survive only while the prose does (mutation
|
|||
);
|
||||
});
|
||||
|
||||
test('trekresearch — High-effort behavior gives standard effort the wave-2 sentence and keeps the low effort sentence verbatim', () => {
|
||||
test('trekresearch — High-effort behavior keeps the standard/low effort sentences verbatim', () => {
|
||||
const text = read();
|
||||
const flatText = text.replace(/\s+/g, ' ');
|
||||
assert.ok(
|
||||
!flatText.includes('use the existing conditional triggers'),
|
||||
'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',
|
||||
text.includes('Standard effort (or absent): use the existing conditional triggers.'),
|
||||
'the standard-effort sentence must survive the Phase 5 rewrite verbatim',
|
||||
);
|
||||
assert.ok(
|
||||
text.includes('Low effort: inline research only, no agent swarm'),
|
||||
|
|
|
|||
|
|
@ -1,201 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,330 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,495 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
// 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: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,261 +0,0 @@
|
|||
// 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;',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
// 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 = [];
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
#!/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"
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
// 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 = [];
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
#!/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"
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
#!/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"
|
||||
12
tests/fixtures/jsonl-schemas.md
vendored
12
tests/fixtures/jsonl-schemas.md
vendored
|
|
@ -19,14 +19,14 @@
|
|||
|
||||
| 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: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: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: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:2990-3012 | 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:1128-1152 | profile, phase_models, profile_source | none |
|
||||
| trekresearch-stats | ts, question, mode, scope, engine, slug, project_dir, brief_path, dimensions, dimensions_baseline, dimensions_baseline_preserved, effort, conv_turns, empty_turns, unique_sources, agents_local, agents_external, gemini_used, confidence, contradictions, open_questions | lib/stats/stats-append.mjs (trekresearch.md Stats tracking) | trekresearch.md:782-808 | profile, phase_models, parallel_agents, external_research_enabled, profile_source | none |
|
||||
| trekplan-stats | ts, task, mode, slug, brief_path, project_dir, codebase_size, codebase_files, agents_deployed, deep_dives, research_briefs_used, research_scout_used, critic_verdict, guardian_verdict, outcome | lib/stats/stats-append.mjs (trekplan.md Phase 12) | trekplan.md:1277-1305 | 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 (autonomy events) | ts, event, known_event, payload | lib/stats/event-emit.mjs `emit()` | event-emit.mjs:78-99 | payload.profile, payload.phase_models, payload.profile_source | none |
|
||||
| trekexecute-stats (PostToolUse Bash) | ts, session_id, command_excerpt, duration_ms, success | hooks/scripts/post-bash-stats.mjs (Bash PostToolUse) | post-bash-stats.mjs:42-54 | none (hook is plugin-level, not profile-aware) | command_excerpt (CWE-212) |
|
||||
| trekreview-stats | ts, slug, verdict, counts (BLOCKER/MAJOR/MINOR/SUGGESTION), reviewed_files_count, mode, duration_ms | lib/stats/stats-append.mjs (trekreview.md Phase 8) | trekreview.md: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:304-308 | profile, 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:636-686 | 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 |
|
||||
| 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
|
||||
|
|
|
|||
|
|
@ -1,165 +0,0 @@
|
|||
#!/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);
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
#!/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');
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
// 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`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,212 +0,0 @@
|
|||
#!/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"
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
// 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 <\\`);
|
||||
});
|
||||
|
|
@ -9,7 +9,6 @@
|
|||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { runHook } from '../helpers/hook-helper.mjs';
|
||||
|
|
@ -315,125 +314,3 @@ test('pre-bash-executor fails open on malformed JSON', async () => {
|
|||
const { code } = await runHook(PRE_BASH, 'not-json');
|
||||
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}`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
// [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']);
|
||||
});
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
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"]);
|
||||
});
|
||||
|
|
@ -1,15 +1,6 @@
|
|||
import { test } from 'node:test';
|
||||
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 { 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', () => {
|
||||
const r = parseArgs('', 'trekbrief');
|
||||
|
|
@ -164,128 +155,3 @@ test('trekcontinue — .md positional accepted by parser (rejection is command-l
|
|||
assert.deepEqual(r.positional, ['NEXT-SESSION-PROMPT.local.md']);
|
||||
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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -145,64 +145,3 @@ test('CLI shim missing required args returns usage error (exit 1)', () => {
|
|||
assert.equal(parsed.ok, false);
|
||||
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/);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -360,22 +360,6 @@ test('classifySuppression — the refuting reasons the LLM coordinator emits are
|
|||
'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', () => {
|
||||
const refuting = [...REFUTING_REASONS];
|
||||
const overlap = refuting.filter((r) => UNVERIFIED_REASONS.includes(r));
|
||||
|
|
@ -455,141 +439,3 @@ 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.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',
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -395,16 +395,15 @@ test('operations.md custom-profile prose matches findProfilePath resolution orde
|
|||
|
||||
// --- S24 — anti-false-claim: operations.md + command-modes.md autonomy-gate truth-pass ---
|
||||
//
|
||||
// autonomy-gate.mjs is a BOOLEAN gate: --gates [true|false|closed|open] → gates_on|auto_running.
|
||||
// autonomy-gate.mjs is a BOOLEAN gate: --gates {true|false} → gates_on|auto_running.
|
||||
// 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
|
||||
// state machine `idle → approved → executing → merge-pending → main-merged` — only
|
||||
// `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
|
||||
// (pure, no-I/O) autonomy-gate; (3) advertised `--gates {open|closed|adaptive}` —
|
||||
// there is no strictness policy and no `adaptive` value: the flag is boolean, and
|
||||
// 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).
|
||||
// open/closed/adaptive is a DERIVED gates_mode policy in the command layer, not a
|
||||
// flag value (the CLI shim + all 4 command docs take boolean `--gates {true|false}`).
|
||||
test('operations.md + command-modes.md describe the real autonomy-gate, not a fabricated one [S24]', () => {
|
||||
const ops = read('docs/operations.md');
|
||||
const modes = read('docs/command-modes.md');
|
||||
|
|
@ -431,13 +430,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',
|
||||
);
|
||||
|
||||
// F3 — --gates is boolean; there is no open/closed/adaptive strictness enum
|
||||
// F3 — --gates is boolean; open/closed/adaptive is a derived gates_mode policy, not a flag enum
|
||||
const GATES_ENUM_FORMS = ['{open|closed|adaptive}', '{open\\|closed\\|adaptive}'];
|
||||
for (const [label, txt] of [['operations.md', ops], ['command-modes.md', modes]]) {
|
||||
for (const bad of GATES_ENUM_FORMS) {
|
||||
assert.ok(
|
||||
!txt.includes(bad),
|
||||
`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`,
|
||||
`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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -849,7 +848,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', () => {
|
||||
const t = read('commands/trekexecute.md');
|
||||
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 (high effort changes nothing in /trekexecute)');
|
||||
'trekexecute.md must contain ### High-effort behavior (v5.1.1) sub-section under Composition rule (gates_mode = closed)');
|
||||
});
|
||||
|
||||
// --- v5.4 — brief.md formalized as PUBLIC CONTRACT (Handover 1) ---
|
||||
|
|
@ -2084,11 +2083,8 @@ function schemaRow(id) {
|
|||
// 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
|
||||
// "## 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 = [
|
||||
{ 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: 'trekreview-stats', cmd: 'trekreview', heading: /^## Phase 8 — Validate output/ },
|
||||
{ id: 'trekexecute-stats (Phase 9 record)', cmd: 'trekexecute', heading: /^## Phase 9\b(?!\.)/ },
|
||||
|
|
|
|||
|
|
@ -91,32 +91,3 @@ test('done line: --check finds the line for that step only', () => {
|
|||
assert.equal(run(['--check', '--step', '4', '--report', report]).status, 1);
|
||||
} 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
Loading…
Add table
Add a link
Reference in a new issue