merge: fix round 4.5a — the serious findings, decision 67 and the too-lax 6.5.0 Known issues (decisions 57, 67, 68, 78)

Review WARN (0 BLOCKER, 13 MAJOR, 4 MINOR), verdict gate exit 0; the 13 MAJOR go first in hardening part 4.5b (operator 2026-10-25).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-25 16:21:02 +01:00
commit be0b631fed
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
56 changed files with 4924 additions and 461 deletions

View file

@ -29,6 +29,80 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
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.
### Fixed
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, a failed git call included, exits 2 with a reason that starts `review-stale` (the new `lib/review/reviewed-commit.mjs`). `/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 gates shim reads `--gates=true` as on.
### 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.
### Doors
@ -36,6 +110,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- 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
## v6.5.0 — 2026-10-18 — Three skills for brief, criteria and review, English labels, and three trial-5 fixes

View file

@ -140,7 +140,7 @@ Full flag reference for each command (modes, `--gates`, `--profile`, breaking ch
| `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>`); 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.
> **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`.

View file

@ -72,13 +72,7 @@ No cloud dependency. No GitHub requirement. Works on **Mac, Linux, and Windows**
### Autonomy mode (`--gates`, v3.4.0)
All four pipeline commands accept `--gates {open|closed|adaptive}` to control how many autonomy checkpoints surface to the operator on the path from brief approval to main-merge.
| 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. |
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.
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.
@ -396,7 +390,7 @@ Borrowed: the order from Cherny; the gate on a verdict in a file from Kiro; the
| **Single step** | `/trekexecute --project <dir> --step 3` | Execute only step 3 |
| **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) Pin model profile for execution. Plan-frontmatter `profile:` is honored when this flag is omitted. See [Profile system](#profile-system-v410). |
| **Profile** | `/trekexecute --profile <name> --project <dir>` | (v4.1.0) Profile for this run's resolver; the step helper and the controller stay on opus. Plan-frontmatter `profile:` is honored when this flag is omitted. See [Profile system](#profile-system-v410). |
### Multi-session execution through session orders (worktree-isolated)
@ -571,7 +565,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 patterns: `auth/**`, `crypto/**`, `**/security/**`. Hard refuse-with-suggestion above 100 files / 100K diff tokens.
A path-pattern classifier produces `{file → deep-review|summary-only|skip}` before any LLM runs. Hardcoded skip patterns: `*.lock`, `*.svg`, `dist/**`, `build/**`, `node_modules/**`, generated markers. `deep-review`: default for every changed file not in the other two rows — code, tests, `commands/`, `agents/`, `skills/` Markdown, and the instruction files `CLAUDE.md`, `AGENTS.md` and `.claude/rules/**`. `summary-only`: `docs/**`, Markdown files at the repo root other than `CLAUDE.md` and `AGENTS.md` (README, CHANGELOG), `tests/fixtures/**`. Hard refuse-with-suggestion above 100 files / 100K diff tokens.
### Feedback loop (Handover 6)
@ -810,7 +804,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)"]
@ -829,7 +823,7 @@ flowchart TB
BR --> RES --> PL --> EX --> RV
```
`* = conditional`: convention-scanner / test-strategist on medium+ codebases (50+ files); research-scout for unknown external tech; security-/contrarian-researcher when a leading recommendation forms (or always at `effort=high`); brief-conformance-reviewer skipped under `--quick`.
`* = conditional`: convention-scanner / test-strategist on medium+ codebases (50+ files); research-scout for unknown external tech; contrarian-researcher, when selected, runs in wave 2 after wave 1 returns, with the leading answer of wave 1's external findings to attack (with `--engine deep-research`, after the engine returns, from the leading answer of its report), always at `effort=high`; brief-conformance-reviewer skipped under `--quick`.
> **Which Claude Code primitive each phase uses — and the alternatives considered (Workflow substrate, delegated orchestrator, dormant synthesis-agent)** → see [docs/architecture.md §Primitives per step](docs/architecture.md#primitives-per-step-decision-matrix).
@ -950,7 +944,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>` and emitted to JSONL stats for cost-attribution.
Four built-in model profiles (`lib/profiles/*.yaml`) plus your own: a `<name>.yaml` in `voyage-profiles/` at the repo root, or in `~/.claude/voyage-profiles/`. A custom file with a built-in's name is ignored. Each profile pins `phase_models` for the six pipeline phases. The active profile is recorded in plan.md frontmatter as `profile: <name>`.
| Profile | Brief | Research | Plan | Execute | Review | Continue | Use case |
|---------|-------|----------|------|---------|--------|----------|----------|

View file

@ -171,8 +171,9 @@ 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. Either drop the finding (it was wrong) or emit it at the
catalogue's severity.
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.
## Output format
@ -254,8 +255,9 @@ parsers find it by reading the last `json` code fence.
`(file, line, rule_key)` with `lib/review/fill-finding-ids.mjs`).
- `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. Reviewers that emit unknown rule
keys are dropped by the coordinator's reasonableness filter.
- `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).
## Rules

View file

@ -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 `planning-orchestrator` Phase 1b).
1. A prose report (for humans and for /trekplan Phase 4b).
2. A final fenced `json` block with per-dimension numeric scores (for callers
that gate on machine-readable output, such as `/trekbrief` Phase 4).

View file

@ -93,8 +93,9 @@ Cite both the changed file:line AND the regressed file:line.
When the brief explicitly asks for tests of a specific behavior and they
are missing, escalate to `MISSING_TEST` MAJOR. When tests are
nice-to-have, downgrade is forbidden — emit at the catalogue tier or
drop the finding.
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.
### 5. Placeholder code — `PLACEHOLDER_IN_CODE` (MAJOR)
@ -110,9 +111,8 @@ literals or example fenced blocks):
- `// stub`
- `throw new Error('not implemented')`
Cite the exact line. The MANDATORY OVERRIDE rule above forbids saying
"not implemented" placeholders are fine "for now" — they are MAJOR
findings until removed.
Cite the exact line. Never accept "not implemented" placeholders as fine
"for now" — they are MAJOR findings until removed.
### 6. Security surface — `SECURITY_INJECTION` (BLOCKER)
@ -154,8 +154,10 @@ Severity is fixed by `rule_key`. Do NOT override the catalogue:
| `UNDECLARED_DEPENDENCY` | MAJOR |
| `COVERAGE_SILENT_SKIP` | MAJOR |
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.
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.
## Output format
@ -239,8 +241,9 @@ 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. Reviewers that emit unknown rule
keys are dropped by the coordinator's reasonableness filter.
- `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).
## Rules
@ -255,8 +258,10 @@ parsers find it by reading the last `json` code fence.
injection sink. Never flag a regression without naming both files.
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, either
drop it or emit it as the catalogue says.
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.
- **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.

View file

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

View file

@ -3,7 +3,7 @@ name: research-orchestrator
description: Reference document, not a spawnable capability — documents the /trekresearch workflow that runs inline in main context (full rationale, CC-2.1.172 history, and phase map in body).
model: opus
color: cyan
tools: ["Read", "Glob", "Grep", "Write", "Edit", "Bash"]
tools: ["Read", "Glob", "Grep"]
---
<!-- Phase mapping: orchestrator → command
@ -88,12 +88,22 @@ Based on the mode, determine which agent groups to launch:
| `docs-researcher` | Official documentation, RFCs, vendor docs |
| `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 |
| `contrarian-researcher` | Counter-evidence, overlooked alternatives, reasons to reconsider; runs in wave 2, against wave 1's leading answer |
### Phase 2 — Parallel research
Launch ALL selected agents **in parallel** using the Agent tool — one message,
multiple tool calls. This maximizes concurrency.
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.
**Prompting local agents for research (not planning):**

View file

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

View file

@ -65,7 +65,8 @@ You will receive a prompt containing:
(reasonableness filter) except the brief-conflict rule, which applies in every mode;
Passes 1, 2, 4 still run.
- **Rule catalogue** — `lib/review/rule-catalogue.mjs`. Findings whose
`rule_key` is not in this set are dropped by Pass 3.
`rule_key` is not in this set are routed to `unverified` by Pass 3
(`unknown-rule_key`).
## Your 4-pass process
@ -78,9 +79,14 @@ 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,
in input order. A finding from another reviewer joins the row whose title it
shares, else the first one. When findings collide:
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:
- 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
@ -92,7 +98,10 @@ shares, else the first one. When findings 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.
5. then the first seen in the row's order: its title groups
alphabetically, each title's findings in canonical order (reviewer,
detail, the whole finding), findings without a title last in that
order, never the order the findings arrived in.
- Write the kept finding with the highest severity any finding in its
group asserted, so dropping a duplicate never lowers the group and an
engine's brief-conflict mark never lowers what a reviewer agent asserted.
@ -113,7 +122,7 @@ is load-bearing** — see *Suppression is two-valued* below:
| Filter | Test | Fails if | Removed as |
|--------|------|----------|------------|
| Succinctness | `title.length ≤ 100` and `detail.length ≤ 800` chars | Title is a paragraph or detail is a wall of text | **never removed.** MINOR/SUGGESTION (catalogue tier): the text is trimmed (ending `[…trimmed]`), the finding is kept and counted. BLOCKER/MAJOR: kept whole, every character. |
| Accuracy | `file` resolves under the repo root AND `line` is plausible (≥ 0; ≤ file line count when known) | Path traversal escape, negative line, or impossibly large line number | **dropped** (`accuracy:refuted`) |
| Accuracy | `file` resolves under the repo root AND `line` ≥ 0 | Path traversal escape or negative line | **dropped** (`accuracy:refuted`). A line past the end of the file (when its line count is known) is not refuted: the citation is wrong, the claim is unchecked → `unverified` (`accuracy:line-out-of-range`) |
| Actionability | `recommended_action` is non-empty AND begins with an imperative verb | Empty action, "consider …" hedges, or restating the title | `unverified` (`actionability:empty`) |
A finding that carries `engine` (`code-review` / `security-review`) came
@ -125,8 +134,10 @@ 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 refutes the finding as a claim
about this codebase.
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. A line number alone never
refutes a finding, so it goes to `unverified` (`accuracy:line-out-of-range`).
**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

View file

@ -3,7 +3,7 @@ name: review-orchestrator
description: Reference document, not a spawnable capability — documents the /trekreview workflow that runs inline in main context (full rationale, CC-2.1.172 history, and phase map in body).
model: opus
color: red
tools: ["Read", "Glob", "Grep", "Write", "Edit", "Bash"]
tools: ["Read", "Glob", "Grep"]
---
<!-- Phase mapping: orchestrator → command
@ -74,7 +74,7 @@ Execute these phases in order. Do not skip phases.
Run the arg-parser via Bash:
```
node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekreview "$@"
node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekreview -- "$@"
```
Pull the structured flags from its JSON output. Reject unknown flags. If
@ -123,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` | Matches `auth/**`, `crypto/**`, `**/security/**`, `hooks/**` |
| `summary-only` | Default treatment for everything else |
| `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 gates (use AskUserQuestion):
- > 100 files in the diff
@ -185,9 +185,16 @@ 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 speculative findings without a
`file:line` citation; drop findings whose `rule_key` is not in
`RULE_CATALOGUE`.
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.
Skipped at high effort, as in `quick` mode (below); at high effort an
unknown `rule_key` is normalized to `PLAN_EXECUTE_DRIFT` instead
(`agents/review-coordinator.md`, "High-effort normalization"); the
brief-conflict rule still applies in every mode.
4. **Compute verdict** — `BLOCK` if `BLOCKER ≥ 1`, `WARN` if `MAJOR ≥ 1`,
else `ALLOW`.
@ -196,8 +203,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). Passes 1, 2, 4
still run.
In `quick` mode, skip pass 3 (reasonableness filter), except the
brief-conflict rule, which applies in every mode. Passes 1, 2, 4 still run.
### Phase 7 — Write review.md

View file

@ -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
planning-orchestrator." Do not attempt to synthesize.
STOP with error "Plan claims v1.7 but step N lacks Manifest.
Re-run /trekplan." 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

View file

@ -40,15 +40,25 @@ 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): 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}`.
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.
4. `--approve <project-dir | brief-file>`: set **mode = approve**. No
interview, no drafting. Set `APPROVE_ARG` to the argument and resolve it:
@ -965,7 +975,10 @@ printed `stamped: true`, then stop and print the next step:
## Phase 5 — Auto-orchestration opt-in (if research_topics > 0)
**Skip this phase if research_topics = 0.** Proceed directly to Phase 6.
**With research_topics = 0**, print the manual-path output below without the
`/trekresearch` lines (Brief, then `/trekplan --project {PROJECT_DIR}`, then
`/trekexecute --project {PROJECT_DIR}`), then run Phase 7 and stop. Phase 6 is
never entered without the operator's opt-in.
Ask the user via `AskUserQuestion`:
@ -999,13 +1012,13 @@ Then:
/trekexecute --project {PROJECT_DIR}
```
Stop. Do not continue to Phase 6.
Run Phase 7 (`auto_result: manual`), then stop. Do not continue to Phase 6.
### Auto path
**Auto requires an approved intent.** If Phase 4h ended "Leave unapproved",
the auto chain would halt at `/trekplan` anyway — print the manual-path
output instead and stop.
output instead, run Phase 7 (`auto_result: manual`) and stop.
Set `auto_research: true` in the brief's frontmatter (edit the file).
@ -1014,18 +1027,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 and stop. Otherwise
proceed to Phase 6.
answers no, fall back to the manual path output, run Phase 7
(`auto_result: cancelled`) and stop. Otherwise proceed to Phase 6.
If `gates_mode == false` (default in auto): proceed directly to Phase 6.
The chain stops only at the main-merge gate (see `commands/trekexecute.md`
Phase 8).
The chain ends when `/trekplan` has written the plan (Step 6e); it never
starts `/trekexecute`.
Proceed to Phase 6.
## Phase 6 — Auto research dispatch (auto path only)
**Runs only when user opted into auto mode.**
**Runs only when user opted into auto mode, and only after Phase 4h approved the intent.**
### Step 6a — Confirm proceed
@ -1041,7 +1054,8 @@ Continue?"
| **Continue — auto** | Proceed. |
| **Cancel — do manual** | Revert to manual path (print commands, stop). |
If cancelled → fall back to manual path output and stop.
If cancelled → fall back to manual path output, run Phase 7
(`auto_result: cancelled`) and stop.
### Step 6b — Run research topics sequentially (inline)
@ -1078,7 +1092,8 @@ ls -1 {PROJECT_DIR}/research/*.md | wc -l
```
Expected count: N. If any are missing, report and ask the user how to
proceed (retry, skip missing topic, cancel).
proceed (retry, skip missing topic, cancel). On cancel, run Phase 7
(`auto_result: cancelled`) and stop.
Update brief frontmatter: `research_status: complete`.
@ -1092,11 +1107,12 @@ 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.
before continuing. If `/trekplan` stops before it writes the plan, report
where it stopped, run Phase 7 (`auto_result: failed`) and stop.
### Step 6e — Report completion
When the planning-orchestrator finishes, present:
When /trekplan finishes, present:
```
## Ultrabrief + Ultraresearch + Voyage Complete (auto mode)
@ -1184,9 +1200,6 @@ 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

View file

@ -29,9 +29,15 @@ See **Handover 7** in `docs/HANDOVER-CONTRACTS.md` for the full schema.
## Phase 0 — `--help` handling
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):
Parse `$ARGUMENTS` with the shared arg-parser. Run first, exactly like this:
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekcontinue -- $ARGUMENTS
```
Read `flags` and `positional` from the JSON it prints. Dispatch the usage
block ONLY when one of these two conditions equals exactly true (no substring
search, no "contains" check):
- `flags['--help'] === true`, OR
- `positional[0] === '-h'` (single-dash short form — the parser keeps it as
@ -75,7 +81,7 @@ Typical flow:
## Phase 0.5 — Cleanup mode dispatch
After `parseArgs` has resolved `$ARGUMENTS`, check the parsed `flags`
After the parser has printed its parse of `$ARGUMENTS`, check the parsed `flags`
object directly (NOT a string contains-check on raw `$ARGUMENTS` — that
substring pattern was the root cause of Bug 1).
@ -174,23 +180,28 @@ 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. `<plugin-root>/NEXT-SESSION-PROMPT.local.md` — operator-managed master file
a. `NEXT-SESSION-PROMPT.local.md` at the repo root (`git rev-parse --show-toplevel`) —
operator-managed master file, written by the operator or an external producer, never by Voyage
b. `<project-dir>/NEXT-SESSION-PROMPT.local.md` — producer-written sibling
**If both exist:**
- Read both via the **Read tool** (NOT Bash — same anti-substitution rule
as Phase 2).
- Invoke the consistency validator with both paths emitted as concrete
literal tokens (no template substitution at the Bash boundary):
- 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):
```
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/next-session-prompt-validator.mjs --json --consistency <RESOLVED-PATH-A> <RESOLVED-PATH-B>
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/next-session-prompt-validator.mjs --json --consistency <RESOLVED-PATH-A> <RESOLVED-PATH-B> --state-file <RESOLVED-PATH-STATE>
```
Replace `<RESOLVED-PATH-A>` and `<RESOLVED-PATH-B>` with the two concrete
filesystem paths you have in your working context. The validator emits
`{valid, errors, warnings}` JSON on stdout.
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.
- **If `valid: false`** (typically `NEXT_SESSION_PROMPT_PRODUCER_MISMATCH`):
print the structured `errors[]` (each `[code] message` on its own line),
@ -328,8 +339,6 @@ 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

View file

@ -2,6 +2,7 @@
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
@ -29,8 +30,25 @@ See **Handover 7** in `docs/HANDOVER-CONTRACTS.md` for the schema.
## Phase 0 — `--help` handling
If `$ARGUMENTS` contains `--help` or `-h`, print the usage block below and exit
cleanly. Do NOT proceed to any further phase.
Parse `$ARGUMENTS` with the arg parser. Run this via the Bash tool at runtime
(NOT eager-exec):
```bash
setopt nonomatch 2>/dev/null || true
node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekendsession -- $ARGUMENTS
```
It prints the parse result as JSON. On exit 1, print its `errors` and stop.
Print the usage block below and exit cleanly ONLY when one of these two
conditions equals exactly true (no substring search, no "contains" check):
- `flags['--help'] === true`, OR
- `positional[0] === '-h'` (single-dash short form — the parser keeps it as
positional because the schema declares no alias).
Then do NOT proceed to any further phase. In every other case — a slug or a
label that holds `-h` or `help` too — go on to Phase 1.
```
/trekendsession — Mark current session done; point at next session.
@ -76,8 +94,9 @@ Decision tree:
## Phase 2 — Required args check (headless-safe)
Read `$ARGUMENTS`. Both `<next-brief-path>` and `<next-label>` are required.
If either is missing or empty:
Read the parse result from Phase 0: `<next-brief-path>` is `positional[0]`
and `<next-label>` is `positional[1]`. Both are required. If either is
missing or empty:
```
Error: missing required args.
@ -176,6 +195,7 @@ 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.
- **NEVER auto-invoke this command.** It is operator-typed only at session-end
(the frontmatter field keeps the model from starting it; the operator types it).
- **Idempotent within a session.** Running twice with the same args
overwrites cleanly (atomic rename); does not double-advance.

View file

@ -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>] [--fg | --resume | --dry-run | --validate | --step N | --session N] [plan.md]"
argument-hint: "[--project <dir>] [--profile <name>] [--gates [true|false|closed|open]] [--fg | --resume | --dry-run | --validate | --step N | --session N] [plan.md]"
allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion, Agent, Skill
disallowed-tools: TeamCreate
---
@ -48,6 +48,22 @@ Parse `$ARGUMENTS` for mode flags:
use that path but keep `project_dir` set (user may be pointing at a
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**.
@ -56,10 +72,22 @@ 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`: 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}`.
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).
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,
@ -67,7 +95,9 @@ 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. Otherwise: the entire argument string is the file path. Set **mode = execute**.
9. If arguments contain `--profile <name>`: set `profile = {name}`; strip both tokens.
10. Otherwise: the arguments left after every flag above is stripped are the file path.
Set **mode = execute**.
If no path is provided (and `--project` was not used to derive one), output
usage and stop:
@ -81,6 +111,7 @@ 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
@ -94,6 +125,8 @@ 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
@ -236,7 +269,7 @@ mode; absence or <1.7 as legacy mode.
- If any step is missing its Manifest, or YAML is malformed, STOP with:
```
Error: plan_version=1.7 but step {N} has invalid/missing Manifest.
Re-run planning-orchestrator — plan is not executable.
Re-run /trekplan — the plan is not executable.
```
**Legacy mode (no plan_version, or < 1.7):**
@ -334,11 +367,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 — planning-orchestrator must emit v1.7 format
Fix: re-run /trekplan — the plan must use v1.7 format
{if missing manifest}:
Step {N} has no Manifest block (plan_version=1.7 requires one per step)
Fix: re-run /trekplan — planning-orchestrator must include manifest YAML
Fix: re-run /trekplan — every step needs a Manifest YAML block
{if malformed YAML or invalid regex}:
Step {N}: {specific YAML/regex error}
@ -432,6 +465,7 @@ 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
@ -582,9 +616,8 @@ These sessions cannot safely run in parallel.
Fix the Execution Strategy in the plan, or use --fg for sequential execution.
```
Stop execution. This is a defense-in-depth check — the planning-orchestrator
should have prevented this, but verifying at execution time catches plans
that were manually edited or have bugs.
Stop execution. This check runs at execution time, so it also catches a plan
that was edited by hand after planning.
### Check 4 — Stale worktree cleanup
@ -1275,7 +1308,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 plugin-root candidates.
of `/trekcontinue` can compare project-dir and repo-root candidates.
If the entry condition **passes**:
```
@ -1852,7 +1885,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 diff --no-ext-diff --no-renames --src-prefix=a/ --dst-prefix=b/ {start_sha}` (one form whatever the user's git config) to
**Back to planning** (`fix_rounds = 4`): save `git -c core.quotepath=off diff --no-ext-diff --no-color --no-renames --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
@ -2106,6 +2139,11 @@ 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). 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
@ -2231,12 +2269,16 @@ never typed by the operator, in this order (Cherny's):
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"
node ${CLAUDE_PLUGIN_ROOT}/lib/review/verdict-gate.mjs --json "{project_dir}/review.md" --repo .
```
Record `review = {round, path, gate_exit, verdict}`. Exit 0 (ALLOW/WARN) →
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.
`--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.
**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`
@ -2376,7 +2418,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, `:1221`).
> a drifting audit is OVERRIDDEN to `partial` (Phase 7.5).
> 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
@ -2387,14 +2429,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 (deterministic, defeats "never decremented")** — the gate
> reconciles the budget counter against the ground-truth counters the Phase 7.5
> audit already derives:
> 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:
> `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 (`:1221`). So the counter's liveness is auditable at the gate,
> already does (Phase 7.5). So the counter's liveness is auditable at the gate,
> not merely asserted in prose.
> 4. **End to end** — for a trekplan, Phase 7's `end_to_end == "verified"`.
> `"not-verified"` OVERRIDES `completed → partial` with the reason
@ -2405,12 +2447,20 @@ 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 deterministic audit + an
> objective stop-signal, never on the model's narrative. Native `/goal` (when
> Termination authority therefore rests on the audit and 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.
@ -2542,7 +2592,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}"
node ${CLAUDE_PLUGIN_ROOT}/lib/review/verdict-gate.mjs --json "{review_path}" --repo .
```
`{review_path}` is `review.path` from Phase 7.2. No review (Phase 7.2 did not
@ -2555,13 +2605,20 @@ 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`.
The merge runs only on exit 0 and a `completed` result. **Closed:** emit
`main-merge-declined` with the reason `review-block`, `review-not-measured` or
`run-not-completed`. Nothing is finalized: a `completed` result becomes
`main-merge-declined` with the reason `review-block`, `review-stale`,
`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, or a run
that completes.
BLOCKERs fixed through Phase 7.2's loop, a review that runs in full, a review
of this commit, 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:
@ -2665,7 +2722,7 @@ JSON
```
The reason is one of the literal words above (`review-block`,
`review-not-measured`, `run-not-completed`, `no-main-branch`, `hold-for-review`,
`review-stale`, `review-not-measured`, `run-not-completed`, `no-main-branch`, `hold-for-review`,
`merge-conflict`, `merge-refused`), never the operator's free text (free text breaks the JSON).
On decline, nothing is cleared: the merged-waves record is kept.
@ -2673,8 +2730,9 @@ 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 `review.md` again (a review fixed or
re-run in between counts), and when it opens, the guard asks again. On the
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
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
@ -2715,7 +2773,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 plugin-root candidates. Use a single
producer drift between project-dir and repo-root candidates. Use a single
ESM inline block so state-file + prompt-file writes succeed or fail together:
```bash
@ -2755,6 +2813,7 @@ Phase 2.3 (validate exit) and Phase 5 (dry-run) intentionally do not write
**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
@ -2880,6 +2939,7 @@ 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,
@ -2947,8 +3007,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 drives `phase_models.execute` (model for execution
orchestrator) and `parallel_agents` for wave-parallel sessions.
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`.
Examples:
```
@ -2956,9 +3016,6 @@ 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
@ -2972,7 +3029,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 2.4 alongside the sequencing-gate brief-validator call:
invoked in Phase 1 alongside the sequencing-gate brief-validator call:
```bash
# v5.9 — composed phase-model resolution (brief > profile > default) for the
@ -2984,21 +3041,18 @@ node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model --pha
```
`phase_signal_result.effort` (absent = `standard`) is consumed when picking the execution
strategy (gates auto-escalation, parallel-wave choice — see High-effort
behavior below). The resolver does NOT control the orchestrator's own
strategy (the parallel-wave choice, Phase 2.5). 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 and stats.
spawn site here; it is returned for cross-command uniformity; no stats record carries it.
For `/trekexecute` specifically: `effort == 'low'` activates `--gates open`
+ 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.
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.
### Sequencing gate surface
@ -3012,32 +3066,9 @@ commands surface, don't re-enforce.
### High-effort behavior (v5.1.1)
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.
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.
## Hard rules

View file

@ -124,12 +124,23 @@ Parse `$ARGUMENTS` for mode flags. Order of precedence:
7. **`--quick`** — set **mode = quick**. Skip agent swarm; use lightweight
Glob/Grep scan and go directly to planning + adversarial review.
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}`.
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.
9. **`--min-brief-version <ver>`** — (optional) version floor. Set
**min_brief_version = {ver}** (e.g. `2.2`). Forwarded to the brief-validator
@ -314,6 +325,10 @@ Determine the output directory from the plan slug:
Launch the **session-decomposer** agent:
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}
@ -412,7 +427,7 @@ Then continue to the next phase inline.
## Phase 4 — Codebase sizing
Determine codebase scale to calibrate agent turns (not agent count).
Determine codebase scale to decide whether convention-scanner runs.
Run via Bash:
```
@ -454,6 +469,10 @@ Follow the writing-criteria skill (each success criterion is one command the
criteria runner accepts, and it can go red) and the reviewing-adversarially skill.
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.
@ -494,8 +513,7 @@ message). Use the specialized agents from the `agents/` directory.
> 2026-08-17: named 0/5 returned, unnamed 3/3. Mechanism, denominators, and the
> on-disk recovery path: `docs/agent-return-channel-defect.md`.
**All agents run for all codebase sizes.** Scale `maxTurns` by size (small: halved,
medium: default, large: default) instead of dropping agents.
**All agents run for all codebase sizes.**
| Agent | Small | Medium | Large | Purpose |
|-------|-------|--------|-------|---------|
@ -512,6 +530,10 @@ research-scout's web search is not a local read; the line above bounds its local
### Always launch (all codebase sizes):
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.
@ -549,7 +571,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: "opus")
**Convention Scanner** — use the `convention-scanner` plugin agent (model: phase_signal_result.model, the agent's frontmatter as the fallback)
for medium+ codebases only.
Prompt: "Discover the coding conventions relevant to this task: {task}. Brief file: {brief_path} — read it first. Read local files only inside the repo under exploration, plus paths this prompt gives you (the brief, the plan, and templates it names). Provide concrete examples from the codebase, not generic advice."
@ -567,6 +589,9 @@ topics have corresponding research files, skip research-scout entirely.
If yes (and not covered by attached briefs): launch **research-scout** in
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.
@ -587,7 +612,8 @@ 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: "opus",
For each significant gap, spawn a targeted deep-dive agent (model: phase_signal_result.model, else opus —
Explore is Claude Code's built-in agent and has no Voyage frontmatter to fall back to,
subagent_type: "Explore") with a narrow, specific brief. Each deep-dive prompt
carries these two lines:
Brief file: {brief_path} — read it first.
@ -962,6 +988,10 @@ Both reviewers are asked for `voyage:writing-criteria` and
writing-criteria rule in one sentence in its prompt, for the brief's criteria
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,
@ -1126,13 +1156,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"** to start implementing ← only when the gate exited 0
- Say **"execute"** for the `/trekexecute` line to run ← only when the gate exited 0
- Say **"save"** to keep the plan for later
```
When the gate exited 1, replace the "execute" line with
`Execution is blocked: {the gate's reasons}` and do not start implementing,
whatever is said next, until the gate exits 0.
`Execution is blocked: {the gate's reasons}` and do not print the `/trekexecute`
line, 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
@ -1171,8 +1201,11 @@ 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
continue with the operator's choice (Phase 11).
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.
4. **"No, keep planning":** revise per the operator's words, run one Phase 9
round, and present again from step 1.
@ -1260,15 +1293,16 @@ Confirm the plan and brief file locations and exit.
### "execute" / "go" / "start"
Begin implementing the plan step by step in this session. Follow the plan exactly.
Mark each step complete as you go.
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 — and stop.
`/trekexecute` is what checks the plan's approval (K2), locks the tests, runs
one step helper per step and the review.
### "execute with team" / "team"
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.
There is no team path. Print the same `/trekexecute` line and stop; parallel
work is `--decompose` followed by `/trekexecute`'s session orders.
## Phase 12 — Session tracking
@ -1329,9 +1363,6 @@ 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

View file

@ -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 to inject
`phase_signal_result` and used at Agent-spawn sites in Phase 4 and Phase 5 to inject
the resolved model:
```bash
@ -72,12 +72,23 @@ Supported flags:
node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model --phase research --brief-path "{dir}/brief.md" [--profile {profile}] --json
```
6. `--gates` — autonomy control. When present, set `gates_mode = true`. The
6. `--gates` — autonomy control. `--gates` takes the next token as its
value only when it is `true`, `false`, `closed` or `open` (strip both);
any other next token is not its value (strip only the flag). Run the shim
once to map the value, bare when there is no value:
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/util/autonomy-gate.mjs --state idle --event start --gates [value]
```
`next_state: gates_on` sets `gates_mode = true`, `auto_running` sets it
false, and nothing else reads the state. On exit 1 or no JSON (a broken
shim; the four values cannot cause it), print the error in one line and
map in prose instead: bare, `true`, `closed` → true; `false`, `open` →
false. The run goes on either way. With `gates_mode = true`, the
research command will pause after each topic completes ("Topic N
complete. Proceed to topic N+1? (yes/no)"). Default `gates_mode = false`
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}`.
means topics run continuously.
7. `--min-brief-version <ver>` — (optional) version floor for an attached
`--project` brief. Set **min_brief_version = {ver}** (e.g. `2.2`). Forwarded
@ -373,7 +384,8 @@ changes nothing (SC1). Keep the native-swarm anchors intact ("in parallel",
5. **On a real report:** keep `effective_engine = deep-research`, log
`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).
adapter below); then run wave 2 (`contrarian-researcher`, see Launch rules)
from the report's leading answer.
### External agents (scope = both or external)
@ -402,7 +414,22 @@ ticket IDs) already removed: the agents search with the words they are given.
### Launch rules
- Launch ALL selected agents **in parallel** in a single message
- Launch in two waves, both in Phase 4 (before Phase 4.5 and Phase 5, so outside
the Phase 5 loop scope marker that `hooks/scripts/pre-agent-cap.mjs` reads).
- **Wave 1:** ALL selected agents except `contrarian-researcher` **in parallel**
in a single message.
- **Wave 2:** `contrarian-researcher`, when selected, after wave 1 returns, in a
message of its own. Give it a conclusion to attack: the leading answer (one or
two sentences), the claims for it and the alternatives wave 1 dismissed,
taken from wave 1's external findings only, never from the local agents'
findings (Hard rules → Independence), and stripped of local parts like the
research question above (absolute paths, user names, internal project or
repository names, ticket IDs). It searches with the words it is given, so no
local file name or project term may be in them. When wave 1's external
findings hold no leading answer, say so and give it the research question
alone, stripped the same way. With `--engine deep-research`
(`effective_engine = deep-research`), wave 2 runs after the engine returns,
from the leading answer of its report.
- Inject the resolved model at every spawn: `phase_signal_result.model` (brief
signal > profile), with the agent's frontmatter `model:` as the fallback — see
Hard rules → Cost. The orchestrator itself follows the session model.
@ -418,8 +445,6 @@ ticket IDs) already removed: the agents search with the words they are given.
Measured 2026-08-17: named 0/5 returned, unnamed 3/3. Mechanism,
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
@ -493,7 +518,9 @@ 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: "opus") with a narrow, specific
launch a targeted follow-up agent (model: phase_signal_result.model, else opus — a
follow-up may be one of Claude Code's built-in agents, which have no Voyage
frontmatter to fall back to; see Launch rules) with a narrow, specific
brief; a follow-up that searches the web names the `researching-sources` skill
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.
@ -867,9 +894,7 @@ Examples:
VOYAGE_PROFILE=balanced /trekresearch
```
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.
Stats records emit `engine` so operators can audit which engine drove which session.
## Composition rule (v5.1)
@ -914,12 +939,11 @@ 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 triggering rules: 5 local
run the FULL swarm regardless of normal selection rules: 5 local
agents + 4 external agents, AND force `contrarian-researcher` to
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.
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.
The former third-party second-opinion bridge pass is **unavailable,
skipped** (dropped in v5.10.1 by operator directive). High effort no
@ -934,7 +958,9 @@ 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): use the existing conditional triggers.
Standard effort (or absent): the selected agents run as in Phase 4, and
`contrarian-researcher`, when selected, runs as wave 2 after the other
agents return (see Launch rules).
Low effort: inline research only, no agent swarm (existing
`--quick`-equivalent code-path).

View file

@ -184,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` | Matches `auth/**`, `crypto/**`, `**/security/**`, `hooks/**`. |
| `summary-only` | Default treatment for everything else. |
| `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 gates — use `AskUserQuestion`:
@ -510,8 +510,15 @@ 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** — remove speculative or catalogue-violating
findings (skipped in `quick` mode).
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.
4. **Verdict** — BLOCK / WARN / ALLOW per the threshold table.
**Fail-closed.** Every removal in Pass 2 and Pass 3 is either
@ -559,11 +566,8 @@ Workflow({
A reviewer that returns nothing, an engine listed in `engineNotRun`, or a coordinator that returns no verdict and findings forbids `ALLOW` on this path too (`missing_reviewers` in the result), and the result's `allow_blocked_by` names each one (`missing-reviewer:<name>`, the coordinator as `missing-reviewer:review-coordinator`);
Phase 7 writes that list as the trailing JSON block's `allow_blocked_by`, so the verdict gate reads such a WARN review as not performed in full (exit 2; a BLOCK stays exit 1).
On this path dedup ranks as Pass 1 does (both paths), every engine finding the
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.
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.
Contract (verified in S10 part B — follow exactly):
- **Pass `args` as a JSON object.** The script defensively re-parses a JSON *string*, but
@ -856,8 +860,6 @@ 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
@ -901,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`. Passes 1, 2, and 4 still run.
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.
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

View file

@ -148,7 +148,7 @@ with `BRIEF_INVALID_MODEL`.
**Producer:** `/trekresearch` Phase 7 (synthesis + brief writer).
**Consumer:** `/trekplan` Phase 1 (project-dir auto-discovery) + `planning-orchestrator` (consumes findings as context).
**Consumer:** `/trekplan` Phase 1 (project-dir auto-discovery) + `/trekplan` Phase 3 and Phase 7 (consumes findings as context).
**Path conventions:**
- 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) + `planning-orchestrator` Phase 7 (cross-reference architecture-note as priors during synthesis).
**Consumer:** `/trekplan` Phase 1 (architecture-discovery) + `/trekplan` Phase 7 (cross-reference architecture-note as priors during synthesis).
**Path conventions:**
- 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:** `planning-orchestrator` Phase 5 (plan synthesis) + Phase 5.5 (schema self-check via `plan-validator --strict`).
**Producer:** `/trekplan` Phase 8 (plan synthesis, then the schema self-check via `plan-validator --strict`).
**Consumer:** `/trekexecute` Phase 2 (plan parsing) + `--validate` mode.
@ -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. Phase 5.5 (planning-orchestrator) **must** run `plan-validator --strict` before handing the plan to plan-critic. `--validate` mode of `/trekexecute` runs the same check + `progress-validator`.
The strongest validation in the entire pipeline. `/trekplan` Phase 8 **must** run `plan-validator --strict` before handing the plan to plan-critic. `--validate` mode of `/trekexecute` runs the same check + `progress-validator`.
| Code | Meaning | Recovery |
|---|---|---|
@ -431,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. Whatever stops a merge or a release on a review reads this exit code, never a summary. Borrowed: the verdict goes to a file and a command gates on it (Kiro `feature-pipeline`); the executor reads the verdict but never writes it (Spec Kit).
**Second consumer — the verdict gate (K7, 1.1):** `lib/review/verdict-gate.mjs <review.md>` reads the frontmatter `verdict` and exits 0 = ALLOW/WARN, 1 = BLOCK, 2 = could not decide. Exit 2 is never green: a missing file, a missing or unknown verdict, two verdict lines, a trailing JSON block that is not the last fence or lacks `verdict`/`counts`/`findings`, and a file whose JSON block, `## Findings (BLOCKER)` section or finding-id list contradicts a greener verdict all land there — and so does a WARN that exists because a reviewer or engine did not run (`missing-reviewer:` / `unattributable-payload` in the JSON block's `allow_blocked_by`, which a 1.1 review must carry): a review not performed in full stops a merge (operator decision 2026-10-06). Exactly one file per call. With `--repo <dir>` (`verdict-gate.mjs --json <review.md> --repo <dir>`, R6) an ALLOW/WARN review must also be of the commit being merged in `<dir>`: its `scope_sha_end` 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`. A `--repo` with no value is exit 2 as well; BLOCK stays exit 1. Whatever stops a merge or a release on a review reads this exit code, never a summary. Borrowed: the verdict goes to a file and a command gates on it (Kiro `feature-pipeline`); the executor reads the verdict but never writes it (Spec Kit).
**Path conventions:**
- Project-dir mode (recommended): `{project_dir}/review.md` (one per review iteration; subsequent runs overwrite atomically).
@ -484,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), every engine finding the coordinator did not return at its exact `(file, line, rule_key)` is put back at its own severity (operator decision 23), and a returned finding is never below the lower of its reviewer counterpart's severity and its catalogue tier (floor (b)), so the workflow path is stricter than the prose path; a coordinator that returns no verdict and findings counts as a missing reviewer there (`missing-reviewer:review-coordinator` in `allow_blocked_by`), and its findings are read at their catalogue tier or higher, so the review is at least WARN and the verdict gate exits 2 on such a WARN (a BLOCK stays exit 1).
On the workflow path dedup ranks as Pass 1 does (both paths), and after the coordinator each finding is matched to its own row, its `(file, line, rule_key)` triplet plus its title, trimmed and in lower case: of the engine findings in a row no returned finding is on, the one with the highest severity (on a tie, the first seen) is put back at its own severity (operator decision 23), and a returned finding is never below the lower of its reviewer counterpart's severity and its catalogue tier (floor (b)); its counterpart is its own row, else the strictest row on its triplet, so the workflow path is stricter than the prose path; a coordinator that returns no verdict and findings counts as a missing reviewer there (`missing-reviewer:review-coordinator` in `allow_blocked_by`), and its findings are read at their catalogue tier or higher, so the review is at least WARN and the verdict gate exits 2 on such a WARN (a BLOCK stays exit 1).
A plan made from the review skips a lowered finding: it is MINOR, and `/trekplan` takes BLOCKER and MAJOR findings only.
**Validation strategy:**

View file

@ -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`
- `planning-orchestrator` Phase 5.5 → `plan-validator --strict` (replaces 3 `grep -cE` calls)
- `/trekplan` Phase 8 → `plan-validator --strict` (replaces 3 `grep -cE` calls)
- `/trekexecute --validate` → `plan-validator --strict` + `progress-validator`
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 = `--gates open` + sequential-only). High-effort behavior is deferred to v5.1.1 per brief Non-Goal.
**Phase 3.5 (v5.1) — adaptive-depth signals:** Between Phase 3 completeness exit and Phase 4 draft, the operator commits an effort level (`low | standard | high`) and an optional `model` (`sonnet | opus | fable`) per downstream phase (`research`, `plan`, `execute`, `review`) via 4 tier-coupled `AskUserQuestion` calls. The choices land in `brief.md` frontmatter as `phase_signals:` (a list of `{phase, effort?, model?}` entries) when committed, or `phase_signals_partial: true` when the operator force-stops. `brief_version: 2.1` activates the **sequencing gate**: validator emits `BRIEF_V51_MISSING_SIGNALS` if a 2.1-versioned brief lacks both fields. Downstream commands surface a friendly hint pointing back to `/trekbrief` — enforcement is validator-only. Composition is documented prose in each downstream command's `## Composition rule (v5.1)` section: `brief.phase_signals[phase] > profile.phase_models[phase]`. The brief signal wins per-phase when present; the profile fills gaps. `effort == low` activates each command's existing `--quick`-equivalent code-path (`/trekexecute` low effort = one session at a time). High-effort behavior is deferred to v5.1.1 per brief Non-Goal.
**Research:** Foreground workflow (v2.4.0): Parse mode → Interview → Parallel research swarm (5 local + 4 external + 1 bridge, spawned from main context) → Follow-ups → Triangulation → Synthesis + brief → Stats. With `--project`, writes to `{dir}/research/NN-slug.md`.

View file

@ -8,7 +8,7 @@ Per-command flag tables, imported from `CLAUDE.md` via pointer.
|------|----------|
| _(default)_ | Question round sized by class (`small` / `bounded` / `architectural`), brief-review gate, approval → brief.md with research plan |
| `--quick` | Proposes `small` as the class (still said aloud, overridable, raised when the task proves bigger) → brief.md with research plan |
| `--gates {true\|false}` | (v3.4.0) Boolean autonomy-gate flag; present → gating on. Policy (`gates_mode`) detailed under `## Autonomy mode` in `docs/operations.md`. |
| `--gates [true\|false\|closed\|open]` | (v3.4.0) Autonomy flag: bare, `true` or `closed` = on; `false` or `open` = off; any other next token is not its value. See `## Autonomy mode` in `docs/operations.md`. |
| `--profile <name>` | (v4.1.0) Model profile: `economy` / `balanced` / `premium` / `fable` / `<custom>`. Sets `phase_models` for the brief phase. See `## Profile system` in `docs/operations.md`. |
| `--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}` | (v3.4.0) Boolean autonomy-gate flag; present → gating on. Policy (`gates_mode`) detailed under `## Autonomy mode` in `docs/operations.md`. |
| `--gates [true\|false\|closed\|open]` | (v3.4.0) Autonomy flag: bare, `true` or `closed` = on; `false` or `open` = off; any other next token is not its value. See `## Autonomy mode` in `docs/operations.md`. |
| `--min-brief-version <ver>` | (S18) Warn — never block — if an attached `--project` brief declares a version below `<ver>` (e.g. `2.2`), i.e. sidesteps framing enforcement |
| `--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}` | (v3.4.0) Boolean autonomy-gate flag; present → gating on. Policy (`gates_mode`) detailed under `## Autonomy mode` in `docs/operations.md`. |
| `--gates [true\|false\|closed\|open]` | (v3.4.0) Autonomy flag: bare, `true` or `closed` = on; `false` or `open` = off; any other next token is not its value. See `## Autonomy mode` in `docs/operations.md`. |
| `--profile <name>` | (v4.1.0) Model profile for the plan phase (and others, since plan emits `profile:` to plan.md frontmatter). |
**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}` | (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. |
| `--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. |
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.

View file

@ -4,17 +4,9 @@ Imported from `CLAUDE.md` via pointer.
## Autonomy mode (`--gates`, v3.4.0)
All four pipeline commands accept a boolean `--gates {true|false}` flag. Presence (`--gates true`, or bare `--gates`) turns gating **on** — the run pauses at autonomy boundaries for operator confirmation. Absence (or `--gates false`, the default) runs phases continuously without pausing.
All four pipeline commands accept `--gates [true|false|closed|open]`. A bare `--gates`, `--gates true` or `--gates closed` turns gating **on**; `--gates false`, `--gates open` or no flag (the default) leaves it off. The next token is the flag's value only when it is one of those four; any other token stays the command's own argument, so `/trekplan --gates --project <dir>` and `/trekexecute --gates plan.md` are a bare `--gates`. Each command maps the value once through `lib/util/autonomy-gate.mjs`; a shim that fails falls back to the same mapping in prose, and the run goes on. With gating on, `/trekbrief` pauses before its auto chain, `/trekresearch` after each topic, and `/trekplan` after exploration, synthesis and adversarial review. `/trekexecute` accepts the flag and pauses on nothing: its one stop is the merge into main, which always asks (below).
`/trekexecute` additionally refines *how strict* the gating is via a `gates_mode` policy derived from the brief's effort signal (an explicit operator `--gates` flag takes precedence over the brief signal — see `commands/trekexecute.md` § High-effort behavior (v5.1.1)):
| `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`.
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`.
**The main-merge gate is a lock, and the guard asks the one question** (operator decision 2026-10-08). The gate opens only when the run is `completed` and the review's verdict gate (`lib/review/verdict-gate.mjs`, K7) exits 0; BLOCK (1) and "could not decide" (2) keep it closed. When it opens, `/trekexecute` runs the merge into main itself and `pre-bash-executor` answers "ask" before it runs, in auto mode too, so that question is the only one. In the permission mode where Claude Code shows no prompts, the guard refuses the merge instead, and every other call it would ask about (push, tag or publish, destructive git), since no one can be asked (operator decisions 2026-10-09). The executor no longer asks "ready to merge?" first, and `--gates` no longer adds a pause before the wave merge-back: the guard asks there as well, once per session, because the push and the merge go in one call. Before this, one merge cost two questions in three places: the `--gates` pause before the wave merge-back, the gate's own question after the waves were merged, and the gate's question on the single-session path followed by the guard's at the merge itself.
@ -32,7 +24,7 @@ A revived Path C (post-v2.2.xxx) would require: (1) re-architecting tool-list to
## Profile system (`--profile`, v4.1.0)
Four built-in model profiles plus operator-defined `<custom>.yaml`. Each profile pins `phase_models` for the six pipeline phases (`brief`, `research`, `plan`, `execute`, `review`, `continue`). Profile is recorded in plan.md frontmatter as `profile: <name>` and emitted to `${CLAUDE_PLUGIN_DATA}/trek*-stats.jsonl` for cost-attribution.
Four built-in model profiles plus operator-defined `<custom>.yaml`. Each profile pins `phase_models` for the six pipeline phases (`brief`, `research`, `plan`, `execute`, `review`, `continue`). Profile is recorded in plan.md frontmatter as `profile: <name>`.
| Profile | Brief | Research | Plan | Execute | Review | Continue | Use case |
|---------|-------|----------|------|---------|--------|----------|----------|

View file

@ -118,16 +118,9 @@ Voyage resolves the profile in this priority order:
3. **`VOYAGE_PROFILE` environment variable** — useful for headless CI
4. **Default `premium`** — final fallback
The resolved value is recorded in two places:
The plan-file fields for the resolved value:
- Plan-file frontmatter `profile: <name>` and `phase_models: [...]`
- 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
@ -206,9 +199,8 @@ execute + review) on a moderate-complexity task. Numbers scale roughly
linearly with the size of the resulting plan (10 steps ≈ baseline; 30
steps ≈ 3× the execute column).
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
The stats records carry no profile field, so they cannot split cost by
profile. Replace the table above with your own measured
numbers after ≥ 3 runs of each profile.
## Deferred to v4.2

View file

@ -1,5 +1,5 @@
// lib/parsers/arg-parser.mjs
// Parse $ARGUMENTS strings for the four voyage commands.
// Parse $ARGUMENTS strings for the seven voyage commands.
//
// Each command has its own valid-flag set; passing flags from another command
// produces an `unknown_flags` array but does not error — the caller decides.
@ -38,6 +38,11 @@ const FLAG_SCHEMA = {
valued: ['--profile'],
aliases: {},
},
trekendsession: {
boolean: ['--help'],
valued: [],
aliases: {},
},
};
/**

View file

@ -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 seen; the survivor carries its group's highest asserted severity
// first in the group's order; the survivor carries its group's highest asserted severity
// (review bc7f4e23) + detail concat + raised_by provenance; when that raises
// 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,7 +61,8 @@ 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) and
* `accuracy:refuted` (Pass 2 Accuracy — a citation escaping the repo root, or
* a negative line; a line past the end of the file does not refute) and
* `file-existence:refuted` (Pass 3 — absent from both working tree and diff)
* are emitted by the LLM coordinator, whose fs/judgement branches this module
* excludes. They are declared here anyway: the vocabulary is owned in one
@ -79,14 +80,17 @@ export const REFUTING_REASONS = Object.freeze(new Set([
* finding, so `succinctness:*` is no longer emitted (a stray one still fails
* closed through `classifySuppression`). `file-existence:indeterminate`
* is emitted by the LLM coordinator's Pass 3 (which runs the fs Glob this
* module deliberately excludes); the vocabulary is owned here so prose and lib
* cannot drift.
* 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.
*/
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
@ -250,9 +254,10 @@ 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) 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,
* (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,
* 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.
@ -263,23 +268,30 @@ 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). Another reviewer's finding joins the group whose title it
// shares, else the first one it can join.
// 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.
const tripletGroups = new Map();
for (const f of findings) {
findings.forEach((f, i) => {
const key = tripletKey(f);
if (!tripletGroups.has(key)) tripletGroups.set(key, []);
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();
tripletGroups.get(key).push({ f, i });
});
const groups = [...tripletGroups.values()].flatMap((entries) => groupTriplet(entries));
const out = [];
for (const group of groups) {
// Ranked as the JSDoc above says. A brief-conflict mark is not carried over
@ -337,9 +349,59 @@ export function dedupByTriplet(findings) {
return out.map((f, i) => ({ ...f, id: ids[i] }));
}
/** 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 String(a.title).trim().toLowerCase() === String(b.title).trim().toLowerCase();
return normTitle(a) === normTitle(b);
}
/** Code-unit order of two strings (never the locale's). */
function cmpStr(a, b) {
return a < b ? -1 : a > b ? 1 : 0;
}
/**
* The groups of one triplet's findings, in the two passes and the order
* `dedupByTriplet` describes. `entries` are `{ f, i }` (the finding and its
* input index). Returns arrays of findings.
* @param {Array<{f: object, i: number}>} entries
* @returns {object[][]}
*/
function groupTriplet(entries) {
const fits = (m, f) => m.reviewer !== f.reviewer || sameTitle(m, f);
const canon = (x, y) => cmpStr(String(x.f.reviewer ?? ''), String(y.f.reviewer ?? ''))
|| cmpStr(String(x.f.detail ?? ''), String(y.f.detail ?? ''))
|| cmpStr(JSON.stringify(x.f), JSON.stringify(y.f));
const classes = new Map();
const untitled = [];
for (const e of entries) {
if (e.f.title === undefined) {
untitled.push(e);
continue;
}
const t = normTitle(e.f);
if (!classes.has(t)) classes.set(t, []);
classes.get(t).push(e);
}
const groups = [];
const place = (members) => {
let g = groups.find((grp) => grp.every((m) => members.every((e) => fits(m.f, e.f))));
if (!g) {
g = [];
groups.push(g);
}
g.push(...members);
};
for (const t of [...classes.keys()].sort(cmpStr)) place(classes.get(t).sort(canon));
for (const e of untitled.sort(canon)) place([e]);
return groups
.map((g) => ({ first: Math.min(...g.map((e) => e.i)), members: g.map((e) => e.f) }))
.sort((a, b) => a.first - b.first)
.map((g) => g.members);
}
/**
@ -434,8 +496,9 @@ export function judgeFilter(findings) {
/**
* Pass 3 — Cloudflare reasonableness (deterministic subset): drop findings
* 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).
* 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).
* The fs file-existence glob is excluded (I/O) — its indeterminate branch is
* prose-side, tokenised as `file-existence:indeterminate`.
*

View file

@ -286,7 +286,7 @@ function atomicWriteText(path, text) {
renameSync(tmp, path);
}
function withPointer(claudeText) {
export 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`;

View file

@ -0,0 +1,166 @@
// 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, 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 `review-stale: …` when
// `checkReviewedCommit` is not ok. Every git call is an argv array with no
// shell; a git call that fails is a stale review (fails closed), never ok.
import { spawnSync } from 'node:child_process';
import { LESSONS_FILE_NAME, MARK_START, MARK_END, withPointer } from './recurrence.mjs';
const SHA_RE = /^[0-9a-f]{7,40}$/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).
return m && 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}` };
}
/** 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 as a stale reason: fails closed. */
function gitFailed(what, r) {
return stale(`git ${what} failed (${gitNote(r) || `exit ${r.status}`})`);
}
const short = (c) => c.slice(0, 12);
/** The lessons text with its managed block (start marker to end marker, plus its newline) taken out. */
function outsideBlock(text) {
const s = text.indexOf(MARK_START);
if (s === -1) return text;
const e = text.indexOf(MARK_END, s + MARK_START.length);
if (e === -1) return text;
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);
}
/**
* 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:`
*/
export function checkReviewedCommit({ repo, sha } = {}) {
if (typeof repo !== 'string' || repo === '') return stale('no repository given');
const given = String(sha ?? '');
const notCommit = `reviewed commit ${given} is not a commit in this repository`;
// 1. Hex only, so a value like `--output=/x` never reaches git as an option.
if (!SHA_RE.test(given)) return stale(notCommit);
const resolved = git(repo, ['rev-parse', '--verify', '--quiet', `${given}^{commit}`]);
if (resolved.status !== 0) {
const note = gitNote(resolved);
return stale(note ? `${notCommit} (${note})` : notCommit);
}
const full = resolved.stdout.trim();
// 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)})`);
}
} else if (after.text !== before.text && after.text !== withPointer(before.text)) {
// 6. CLAUDE.md: unchanged, or exactly the pointer block added.
return stale(`${path} changed other than the review-lessons pointer (commit ${short(c)})`);
}
}
}
return { ok: true };
}

View file

@ -4,7 +4,7 @@
// One command decides on the verdict written in review.md, so whatever stops a
// merge or a release reads a file and an exit code, never a model's summary:
//
// node lib/review/verdict-gate.mjs [--json] <review.md>
// node lib/review/verdict-gate.mjs [--json] <review.md> [--repo <dir>]
// 0 = ALLOW or WARN
// 1 = BLOCK
// 2 = could not decide: missing/unreadable file, not a review, no verdict,
@ -13,7 +13,12 @@
// 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).
// 2026-10-06: a review not performed in full stops a merge), a
// `--repo` with no value, 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).
// Exit 2 is NEVER green.
//
// Fail-closed reading: a missing `verdict` is exit 2, never ALLOW. A verdict
@ -32,6 +37,7 @@ 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 });
@ -159,10 +165,12 @@ function blockerSectionEntries(body) {
}
/**
* Decide on a review.md path.
* Decide on a review.md path. With `repo`, an ALLOW/WARN review must also be
* of the commit being merged there (R6); BLOCK and exit 2 are returned as they are.
* @param {string} filePath
* @param {{ repo?: string }} [opts]
*/
export function decideVerdictFile(filePath) {
export function decideVerdictFile(filePath, { repo } = {}) {
if (!filePath || !existsSync(filePath)) return undecided(`file not found: ${filePath}`);
let text;
try {
@ -170,21 +178,44 @@ export function decideVerdictFile(filePath) {
} catch (e) {
return undecided(`cannot read ${filePath}: ${e.message}`);
}
return decideVerdict(text);
const decided = decideVerdict(text);
if (repo === undefined || decided.exit !== EXIT.PASS) return decided;
const sha = reviewedSha(text);
if (!sha) return undecided('review-stale: review.md has no scope_sha_end that is a commit sha', decided.verdict);
const commit = checkReviewedCommit({ repo, sha });
if (!commit.ok) return undecided(commit.reason, decided.verdict);
return decided;
}
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const files = args.filter((a) => !a.startsWith('--'));
const usage = 'Usage: verdict-gate.mjs [--json] <review.md> [--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.
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('--')) {
files.push(a);
}
}
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: verdict-gate.mjs [--json] <review.md> (exactly one file)\n');
process.stderr.write(usage);
process.exit(EXIT.UNDECIDED);
}
let result;
try {
result = decideVerdictFile(filePath);
result = decideVerdictFile(filePath, { repo });
} catch (e) {
result = undecided(`gate crashed: ${e.message}`);
}

View file

@ -16,7 +16,10 @@
// finish — pipeline reached its end
//
// CLI shim:
// node lib/util/autonomy-gate.mjs --state X --event Y [--gates true|false]
// node lib/util/autonomy-gate.mjs --state X --event Y [--gates [true|false|closed|open]]
// (bare --gates = on; true|closed = on, false|open = off; any other value
// is a usage error, exit 1; case-sensitive. One token --gates=<value> is
// read as --gates <value>; an empty value is the same usage error)
// → JSON: { ok: true, next_state: "..." } (success)
// → JSON: { ok: false, error: "..." } (invalid transition; exit 1)
//
@ -102,15 +105,40 @@ 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 === '--gates') {
const v = argv[++i];
out.gates = v === 'true';
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++;
}
}
}
return out;
@ -118,10 +146,11 @@ function parseArgs(argv) {
if (isMain(import.meta.url)) {
const args = parseArgs(process.argv.slice(2));
if (!args.state || !args.event) {
if (args.error || !args.state || !args.event) {
process.stdout.write(JSON.stringify({
ok: false,
error: 'usage: autonomy-gate.mjs --state <state> --event <event> [--gates true|false]',
error: args.error
|| 'usage: autonomy-gate.mjs --state <state> --event <event> [--gates [true|false|closed|open]]',
}) + '\n');
process.exit(1);
}

View file

@ -13,31 +13,45 @@
// 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. 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 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".
// (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, an
// index entry that could not be removed; nothing is moved). 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).
* files whose header is not that form (another prefix, a quoted path). Colour
* codes are stripped first; a non-empty diff with no `diff --git` header at
* all is one `unread` entry, so it can never read as "nothing to park".
*/
export function newFilesInDiff(diffText) {
const paths = [];
const unread = [];
let header = null;
for (const line of String(diffText).split('\n')) {
let sawHeader = false;
const lines = String(diffText).split('\n').map((l) => l.replace(ANSI_RE, ''));
for (const line of lines) {
if (line.startsWith('diff --git ')) {
sawHeader = true;
header = line;
continue;
}
@ -49,9 +63,26 @@ 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 `path` (root-relative) in HEAD? A repository with no commit has no HEAD: not in it. */
function inHead(root, path) {
const r = spawnSync('git', ['-C', root, 'cat-file', '-e', `HEAD:${path}`], { encoding: 'utf8' });
if (r.error) throw new Error(`git cat-file -e HEAD:${path}: ${r.error.message}`);
return r.status === 0;
}
function lexists(abs) {
try {
lstatSync(abs);
@ -66,15 +97,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 = resolve(cwd);
const gitDir = spawnSync('git', ['rev-parse', '--absolute-git-dir'], { cwd: root, encoding: 'utf8' }).stdout.trim();
const root = git(resolve(cwd), ['rev-parse', '--show-toplevel']).trim();
const gitDir = git(root, ['rev-parse', '--absolute-git-dir']).trim();
const to = join(gitDir, 'voyage', 'parked', `step-${step}-attempt-${attempt}`);
const trackedPaths = new Set(
spawnSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8' }).stdout.split('\0').filter(Boolean),
);
const trackedPaths = new Set(git(root, ['ls-files', '-z']).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 = [];
for (const path of paths) {
const abs = resolve(root, path);
const rel = relative(root, abs);
@ -86,7 +117,8 @@ export function parkAttemptFiles({ diffText, step, attempt, cwd = process.cwd()
kept.push({ path, reason: 'not on disk: nothing to park', blocking: false });
continue;
}
if (trackedPaths.has(rel.split('\\').join('/'))) {
const posix = rel.split('\\').join('/');
if (trackedPaths.has(posix) && inHead(root, posix)) {
kept.push({ path, reason: 'tracked since the attempt: not the executor\'s to move', blocking: false });
continue;
}
@ -95,6 +127,20 @@ 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);
}
// 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);
@ -131,7 +177,13 @@ if (isMain(import.meta.url)) {
process.stderr.write(`park-attempt-files: ${cwd} is not a git repository\n`);
process.exit(2);
}
const result = parkAttemptFiles({ diffText, step, attempt, cwd });
let result;
try {
result = parkAttemptFiles({ diffText, step, attempt, cwd });
} catch (e) {
process.stderr.write(`park-attempt-files: could not run: ${e.message}\n`);
process.exit(2);
}
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
process.exit(result.kept.some((k) => k.blocking) ? 1 : 0);
}

View file

@ -153,11 +153,25 @@ export function validateNextSessionPromptConsistency(a, b, opts = {}) {
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
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;
// 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);
}
function emit(r) {
if (wantJson) {
@ -194,7 +208,8 @@ if (isMain(import.meta.url)) {
{ state: stateObj },
);
emit({
valid: r.valid && ra.valid !== false,
// Valid only when the pair is consistent AND A and B are each valid.
valid: r.valid && ra.valid !== false && rb.valid !== false,
errors: [...(ra.errors || []), ...(rb.errors || []), ...r.errors],
warnings: [...(ra.warnings || []), ...(rb.warnings || []), ...r.warnings],
});

View file

@ -40,6 +40,7 @@ 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';
@ -213,18 +214,10 @@ function checkReview(reviewPath) {
return { check: { id: 'review', status: 'pass', detail: `review verdict ${v.verdict}` }, text };
}
/**
* 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] : '';
}
// `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 };
function checkCoverage(repo, reviewText) {
if (reviewText === null) return { id: 'coverage', status: 'skip', detail: 'not checked: no readable review' };

View file

@ -54,8 +54,15 @@ 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)}`;
// Findings schema — mirrors lib/review/findings-schema.mjs (NW1). The enum on
// rule_key enforces the catalogue at the StructuredOutput layer (stronger than
@ -123,40 +130,79 @@ const VERDICT_SCHEMA = {
// 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;
// the workflow has no conformance rule, so otherwise the first-seen one stays.
// the workflow has no conformance rule, so otherwise the first in the group's
// order stays (the order the two passes below place the members in).
// The survivor carries the highest severity any finding in its group asserted,
// 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 ||
String(a.title).trim().toLowerCase() === String(b.title).trim().toLowerCase();
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 sourceOf = new Map();
function dedupByTriplet(findings, { perSource = true } = {}) {
const cmpStr = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
function groupTriplet(entries) {
const srcOf = (f) => sourceOf.get(f) ?? null;
const fits = (m, f) => srcOf(m) !== srcOf(f) || sameTitle(m, f);
const canon = (x, y) => cmpStr(String(srcOf(x.f) ?? ''), String(srcOf(y.f) ?? ''))
|| cmpStr(String(x.f.detail ?? ''), String(y.f.detail ?? ''))
|| cmpStr(JSON.stringify(x.f), JSON.stringify(y.f));
const classes = new Map();
const untitled = [];
for (const e of entries) {
if (e.f.title === undefined) {
untitled.push(e);
continue;
}
const t = normTitle(e.f);
if (!classes.has(t)) classes.set(t, []);
classes.get(t).push(e);
}
const groups = [];
const place = (members) => {
let g = groups.find((grp) => grp.every((m) => members.every((e) => fits(m.f, e.f))));
if (!g) {
g = [];
groups.push(g);
}
g.push(...members);
};
for (const t of [...classes.keys()].sort(cmpStr)) place(classes.get(t).sort(canon));
for (const e of untitled.sort(canon)) place([e]);
return groups
.map((g) => ({ first: Math.min(...g.map((e) => e.i)), members: g.map((e) => e.f) }))
.sort((a, b) => a.first - b.first)
.map((g) => g.members);
}
function dedupByTriplet(findings, { perSource = true, rows = null } = {}) {
const byTriplet = new Map();
for (const f of findings) {
findings.forEach((f, i) => {
const key = tripletOf(f);
if (!byTriplet.has(key)) byTriplet.set(key, []);
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);
byTriplet.get(key).push({ f, i });
});
const groups = [...byTriplet.values()].flatMap((entries) => (perSource
? groupTriplet(entries)
: [entries.map((e) => e.f)]));
const out = [];
for (const group of groups) {
let survivor = group[0];
for (const f of group.slice(1)) {
const tf = tier(f);
const ts = tier(survivor);
const tf = tierRank(f);
const ts = tierRank(survivor);
if (tf !== ts) {
if (tf < ts) survivor = f;
continue;
@ -173,7 +219,9 @@ function dedupByTriplet(findings, { perSource = true } = {}) {
for (const f of group) {
if (sevRank(f.severity) < sevRank(severity)) severity = f.severity;
}
out.push(severity === survivor.severity ? survivor : { ...survivor, severity });
const row = severity === survivor.severity ? survivor : { ...survivor, severity };
if (rows) rows.set(row, group);
out.push(row);
}
return out;
}
@ -187,7 +235,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 — treat all changed files as summary-only)',
a.triage || '(no triage map supplied — deep-review every changed file except docs/**, root Markdown other than CLAUDE.md and AGENTS.md, and tests/fixtures/**)',
].join('\n');
}
@ -198,7 +246,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 speculative or catalogue-violating findings), then compute',
'reasonableness (drop a finding with no file:line citation or whose file is in neither the tree nor the diff; keep, never drop, a finding whose rule_key is not in the catalogue or whose file cannot be checked), then compute',
'the verdict (BLOCK if any BLOCKER; WARN if any MAJOR; else ALLOW).',
'Return {verdict, findings} via StructuredOutput. Synthesis across files is forbidden.',
`The brief (the contract) is at: ${a.briefPath} — Read it if needed for context.`,
@ -275,7 +323,8 @@ const allFindings = [
...enginePayloads.flatMap((p) => (Array.isArray(p?.findings) ? p.findings : [])
.map((f) => tagged(withEngine(f, engineOf(p?.reviewer)), `engine:${p?.reviewer}`))),
];
const merged = dedupByTriplet(allFindings);
const rowMembers = new Map();
const merged = dedupByTriplet(allFindings, { rows: rowMembers });
log(`Arm B: ${reviewerResults.length} reviewer(s); ${allFindings.length} raw → ${merged.length} after triplet-dedup`);
phase('Coordinate');
@ -297,28 +346,50 @@ 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, same file/line/rule_key) for each
// returned finding's counterpart: (a) an engine counterpart raises it to the
// - `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
// 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.
// - `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 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 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).
// 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 counterparts = new Map(merged
.filter((m) => m && typeof m === 'object')
.map((m) => [tripletOf(m), m]));
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 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);
@ -331,7 +402,7 @@ if (coordinatorMissing) {
}
for (const f of returned) {
if (!f || typeof f !== 'object') continue;
const c = counterparts.get(tripletOf(f));
const c = counterpartOf(f);
const labelled = present(f, 'engine') || present(f, 'contradicts_brief') || present(f, 'brief_lowered_from');
if (c && c.engine) {
// (a) an engine counterpart: raised to its severity, unless the one legal lowering.
@ -355,11 +426,14 @@ for (const f of returned) {
}
}
// (c) the put-back (decision 23).
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 });
const returnedRows = new Set(returned.filter((f) => f && typeof f === 'object').map(rowOf));
// 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' && !returnedRows.has(rowOf(m)))
.flatMap((m) => dedupByTriplet(rowMembers.get(m).filter((f) =>
f && typeof f === 'object' && f.engine), { perSource: false }));
for (const f of putBack) {
const { brief_lowered_from: _lowered, ...copy } = f;
returned.push(copy);

View file

@ -93,7 +93,8 @@ 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; never soften it in between.
tier, or drop the finding as wrong with its reason on a Declined to Judge
line; never soften it in between.
### 6. Declined to Judge: nothing disappears silently

View file

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

View file

@ -1,6 +1,7 @@
// tests/commands/trekexecute.test.mjs
// v5.1 prose-pin tests + v5.1.1 runtime SC4 + SC7 tests for /trekexecute.
// Plan Assumption 2 locks low-effort to --gates open + sequential-only.
// Low effort runs the plan as one session, step after step; it sets no --gates
// (fix round 4-5a, #33).
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
@ -33,13 +34,14 @@ test('trekexecute — sequencing-gate surface mentions BRIEF_V51_MISSING_SIGNALS
'/trekexecute must reference phase_signals (v5.1 composition rule)');
});
test('trekexecute — low-effort path references --gates open + sequential', () => {
test('trekexecute — low-effort path runs one session, step after step, and sets no --gates open', () => {
const text = read();
const 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.match(section, /--gates open/, 'Low-effort path must mention --gates open');
assert.match(section, /sequential/, 'Low-effort path must mention sequential-only execution');
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');
});
// --- v5.1.1 runtime SC4 + SC7 ---

View file

@ -206,11 +206,16 @@ test('trekresearch — Phase 5 pins survive only while the prose does (mutation
);
});
test('trekresearch — High-effort behavior keeps the standard/low effort sentences verbatim', () => {
test('trekresearch — High-effort behavior gives standard effort the wave-2 sentence and keeps the low effort sentence verbatim', () => {
const text = read();
const flatText = text.replace(/\s+/g, ' ');
assert.ok(
text.includes('Standard effort (or absent): use the existing conditional triggers.'),
'the standard-effort sentence must survive the Phase 5 rewrite verbatim',
!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',
);
assert.ok(
text.includes('Low effort: inline research only, no agent swarm'),

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

@ -0,0 +1,199 @@
#!/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 --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"
printf '<!-- voyage:review-lessons:start -->\nEach line: a finding class.\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

167
tests/fix-round-4-5a-red.sh Normal file
View file

@ -0,0 +1,167 @@
#!/usr/bin/env bash
# tests/fix-round-4-5a-red.sh
# The red proof of fix round 4-5a (brief: the last Success Criterion). 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.
# 1. Clone the repo into a temp directory outside it (git clone --shared
# --no-checkout, then checkout --detach BASE, as
# lib/verification/red-proof.mjs does; nothing registers in
# .git/worktrees).
# 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 the round's eight test files there, only the tagged tests
# (--test-name-pattern per id), under tests/helpers/red-reporter.mjs, and
# let tests/helpers/red-judge.mjs decide (--pass-ids DJ: DJ's defect is a
# missing test, its sentences exist at ab6b32c).
# 4. DJ by mutation: A deletes the copy-through span in
# commands/trekreview.md, B deletes both spans in
# agents/review-coordinator.md; each span must match exactly once. Each
# run is judged with --mutation DJ; the file is restored with cp after.
# Exit 0 all red, 1 a rule broken (the judge names it), 2 a setup failure
# (clone, checkout, node or git missing); 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 FAIL - 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'
JUDGE="$ROOT/tests/helpers/red-judge.mjs"
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE NODE_TEST_CONTEXT CLAUDE_CODE_SESSION_ID
setup_fail() {
echo "red script: SETUP FAIL - $1" >&2
exit 2
}
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"
[ -f "$JUDGE" ] || setup_fail "missing $JUDGE"
TMP="$(mktemp -d "${TMPDIR:-/tmp}/fix-round-4-5a-red.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"
# ---- 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/ ----
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 $FILES; 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"
# run_tagged <results> <file...>: the tagged tests of the files, run in the
# start point under the red reporter. The positional parameters carry the
# name patterns (no arrays in bash 3.2).
run_tagged() {
out="$1"
shift
(cd "$TREE" && node --import ./tests/helpers/no-session-id.mjs --test \
--test-reporter=./tests/helpers/red-reporter.mjs \
--test-reporter-destination="$out" "$@") >/dev/null 2>"$out.stderr"
[ -s "$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 "$JUDGE" "$@"
rc=$?
[ "$rc" -eq 2 ] && setup_fail "the red judge could not run (exit 2)"
return "$rc"
}
STATUS=0
# ---- 3. every id at the start point ----
set --
for id in $IDS; do
set -- "$@" "--test-name-pattern=\\[$id\\]"
done
for f in $FILES; do
set -- "$@" "$f"
done
echo "red script: the tagged tests at $BASE"
run_tagged "$TMP/base.jsonl" "$@"
judge --ids "$TMP/ids" --results "$TMP/base.jsonl" --pass-ids DJ || STATUS=1
# ---- 4. DJ under its two mutations ----
cat > "$TMP/mutate.mjs" <<'EOF'
// mutate.mjs <file> <A|B>: delete DJ's copy-through span(s); each span must
// match exactly once, else exit 1 and leave the file as it was.
import { readFileSync, writeFileSync } from 'node:fs';
const [file, which] = process.argv.slice(2);
const SPANS = {
A: [
[/, 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, '.'],
],
B: [
[/^- \*\*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, ''],
[/, 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, ''],
],
};
if (!SPANS[which]) { console.error(`mutate: unknown mutation ${which}`); process.exit(1); }
let text = readFileSync(file, 'utf8');
for (const [re, by] of SPANS[which]) {
const n = (text.match(re) || []).length;
if (n !== 1) { console.error(`mutate ${which}: ${re.source.slice(0, 60)}... matched ${n} times in ${file}, not once`); process.exit(1); }
text = text.replace(re, by);
}
writeFileSync(file, text);
console.log(`mutate ${which}: ${SPANS[which].length} span(s) deleted in ${file}`);
EOF
[ -s "$TMP/mutate.mjs" ] || setup_fail "could not write $TMP/mutate.mjs"
# mutation <A|B> <repo-relative file> <what>: apply, run the DJ tests, judge, restore.
mutation() {
m="$1"
rel="$2"
echo "red script: DJ mutation $m ($3)"
cp "$TREE/$rel" "$TMP/mutation-$m.orig" || setup_fail "could not save $rel"
if node "$TMP/mutate.mjs" "$TREE/$rel" "$m"; then
run_tagged "$TMP/mutation-$m.jsonl" "--test-name-pattern=\\[DJ\\]" tests/lib/fix-round-4-5a-prose.test.mjs
judge --ids "$TMP/ids" --results "$TMP/mutation-$m.jsonl" --mutation DJ || STATUS=1
else
echo "broken: mutation-not-applied [DJ] $m on $rel"
STATUS=1
fi
cp "$TMP/mutation-$m.orig" "$TREE/$rel" || setup_fail "could not restore $rel"
}
mutation A commands/trekreview.md "the copy-through span in commands/trekreview.md deleted"
mutation B agents/review-coordinator.md "both spans in agents/review-coordinator.md deleted"
if [ "$STATUS" -eq 0 ]; then
echo "red script: ok - every id red at $BASE, DJ under both mutations"
else
echo "red script: FAIL - a rule is broken (see the broken: lines above)" >&2
fi
exit "$STATUS"

View file

@ -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:1125-1149 | 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:1273-1301 | 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:2909-2931 | profile, phase_models, profile_source | none |
| trekbrief-stats | ts, task, slug, mode, interview_turns, review_iterations, brief_quality, research_topics, auto_research, auto_result, intent_approved, project_dir, order_id, order_claimed_at, order_reason | commands/trekbrief.md (orchestrator-emit Phase 7; order_* added by lib/stats/stats-append.mjs) | trekbrief.md:1141-1165 | profile, phase_models, profile_source | none |
| trekresearch-stats | ts, question, mode, scope, engine, slug, project_dir, brief_path, dimensions, dimensions_baseline, dimensions_baseline_preserved, effort, conv_turns, empty_turns, unique_sources, agents_local, agents_external, gemini_used, confidence, contradictions, open_questions | lib/stats/stats-append.mjs (trekresearch.md Stats tracking) | trekresearch.md:822-855 | 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:2969-2991 | 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: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 |
| 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:640-690 | 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 |
| 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

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

@ -0,0 +1,145 @@
#!/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>
// [--pass-ids <id>[,<id>...]] [--mutation <id>]
//
// 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
// 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
// and unlisted 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).
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> [--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', '--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 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) {
if (e.ok === false) broken.push(`broken: untagged-failure ${name} (${e.file ?? 'no file'})`);
continue;
}
if (!listed.has(tag)) {
broken.push(`broken: unlisted-tag [${tag}] ${name}`);
continue;
}
if (mutation && tag !== mutation) continue;
bump(seen, tag);
if (e.skip) {
broken.push(`broken: skipped [${tag}] ${name}`);
} else if (e.todo) {
broken.push(`broken: todo [${tag}] ${name}`);
} else if (e.ok === true) {
if (mutation || passIds.has(tag)) bump(passed, tag);
else broken.push(`broken: passed [${tag}] ${name}`);
} else if (e.code === 'ERR_ASSERTION' && e.cname === 'AssertionError') {
bump(red, tag);
} else {
broken.push(`broken: not-assertion [${tag}] ${name} (${e.code ?? 'no code'} ${e.cname ?? 'no name'})`);
}
}
const judged = mutation ? [mutation] : ids;
for (const id of judged) {
if (mutation) {
if (!red.get(id)) broken.push(`broken: mutation-not-red [${id}]`);
} else if (!seen.get(id)) {
broken.push(`broken: no-test [${id}]`);
}
}
if (broken.length > 0) {
process.stdout.write(`${broken.join('\n')}\n`);
process.exit(1);
}
const out = [];
for (const id of judged) {
if (red.get(id)) out.push(`red: [${id}] ${red.get(id)} test(s)`);
if (!mutation && passed.get(id)) out.push(`pass: [${id}] ${passed.get(id)} test(s) (--pass-ids)`);
}
process.stdout.write(`${out.join('\n')}\n`);
process.exit(0);

View file

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

View file

@ -1,7 +1,12 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseArgs } from '../../lib/parsers/arg-parser.mjs';
const PARSER = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'lib', 'parsers', 'arg-parser.mjs');
test('trekbrief — empty args', () => {
const r = parseArgs('', 'trekbrief');
assert.equal(r.command, 'trekbrief');
@ -155,3 +160,43 @@ 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);
});

View file

@ -145,3 +145,64 @@ 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/);
}
});

View file

@ -360,6 +360,22 @@ 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));
@ -439,3 +455,123 @@ 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('[ORD] the title classes are placed in alphabetical order: cA, fB, cB and an untitled correctness BLOCKER give {cA, cU} BLOCKER and {fB, cB} MAJOR and one verdict in all 24 orders', () => {
const K = 'NOT_IN_THE_CATALOGUE';
const at = { file: 'bin/slug.mjs', line: 46, rule_key: K, detail: 'd' };
// cA and cU tie on every survivor rule, so the row's first member in the
// group's order (class A before the untitled findings) is kept: cA, whose
// empty recommended_action sets the row aside as unverified.
const cA = { ...at, severity: 'BLOCKER', title: 'A', reviewer: ORD_C, recommended_action: '' };
const fB = { ...at, severity: 'MAJOR', title: 'B', reviewer: ORD_F };
const cB = { ...at, severity: 'MINOR', title: 'B', reviewer: ORD_C };
const cU = { ...at, severity: 'BLOCKER', reviewer: ORD_C };
const expected = {
[`${ORD_C}|A+${ORD_C}|${K}`]: 'BLOCKER',
[`${ORD_F}|B+${ORD_C}|B`]: 'MAJOR',
};
const all = orders([['cA', cA], ['fB', fB], ['cB', cB], ['cU', cU]]);
assert.equal(all.length, 24);
const verdicts = new Set();
for (const order of all) {
const name = order.map(([n]) => n).join(', ');
const rows = dedupByTriplet(order.map(([, f]) => structuredClone(f)));
const bySet = Object.fromEntries(rows.map((r) => [members(r).join('+'), r.severity]));
assert.deepEqual(bySet, expected, `order ${name}`);
const j = judgeFilter(rows);
verdicts.add(JSON.stringify({
kept: j.kept.map((r) => members(r).join('+')).sort(),
unverified: j.unverified.map((r) => members(r).join('+')).sort(),
verdict: computeVerdict(j.kept, { unverified: j.unverified }).verdict,
}));
}
assert.equal(verdicts.size, 1, [...verdicts].join(' | '));
assert.deepEqual(JSON.parse([...verdicts][0]), {
kept: [`${ORD_F}|B+${ORD_C}|B`],
unverified: [`${ORD_C}|A+${ORD_C}|${K}`],
verdict: 'WARN',
});
});

View file

@ -395,15 +395,16 @@ test('operations.md custom-profile prose matches findProfilePath resolution orde
// --- S24 — anti-false-claim: operations.md + command-modes.md autonomy-gate truth-pass ---
//
// autonomy-gate.mjs is a BOOLEAN gate: --gates {true|false} → gates_on|auto_running.
// autonomy-gate.mjs is a BOOLEAN gate: --gates [true|false|closed|open] → gates_on|auto_running.
// State machine: idle → gates_on|auto_running → paused_for_gate → completed.
// 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}` —
// 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}`).
// 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).
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');
@ -430,13 +431,13 @@ test('operations.md + command-modes.md describe the real autonomy-gate, not a fa
'docs/operations.md: event-emit records 3 named lifecycle events, not "each transition" of the (decoupled) autonomy-gate; fix the prose',
);
// F3 — --gates is boolean; open/closed/adaptive is a derived gates_mode policy, not a flag enum
// F3 — --gates is boolean; there is no open/closed/adaptive strictness enum
const GATES_ENUM_FORMS = ['{open|closed|adaptive}', '{open\\|closed\\|adaptive}'];
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 — flag is boolean {true|false}; open/closed/adaptive is a derived gates_mode; fix the prose, not this test`,
`docs/${label}: --gates flag enum "${bad}" is false — the flag is boolean: bare, true or closed = on, false or open = off, and there is no adaptive; fix the prose, not this test`,
);
}
}
@ -848,7 +849,7 @@ test('v5.1.1 — commands/trekreview.md contains ### High-effort behavior (v5.1.
test('v5.1.1 — commands/trekexecute.md contains ### High-effort behavior (v5.1.1) sub-section', () => {
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 (gates_mode = closed)');
'trekexecute.md must contain ### High-effort behavior (v5.1.1) sub-section under Composition rule (high effort changes nothing in /trekexecute)');
});
// --- v5.4 — brief.md formalized as PUBLIC CONTRACT (Handover 1) ---
@ -2083,8 +2084,11 @@ 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(?!\.)/ },

File diff suppressed because it is too large Load diff

View file

@ -3,9 +3,9 @@
// build of the re-planned step they are moved into the report dir, never
// deleted (voyage-lekerepo trial 5, finding 4).
import { test } from 'node:test';
import { after, test } from 'node:test';
import { strict as assert } from 'node:assert';
import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, symlinkSync } from 'node:fs';
import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, symlinkSync, cpSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
@ -20,15 +20,25 @@ function git(cwd, ...args) {
return r.stdout;
}
// The base repo (bin/slug.mjs committed), built once; repo() gives each test its own copy.
let seed = null;
after(() => {
if (seed) rmSync(seed, { recursive: true, force: true });
});
function repo() {
if (!seed) {
seed = mkdtempSync(join(tmpdir(), 'voyage-park-seed-'));
git(seed, 'init', '-q');
git(seed, 'config', 'user.email', 't@example.invalid');
git(seed, 'config', 'user.name', 't');
mkdirSync(join(seed, 'bin'));
writeFileSync(join(seed, 'bin/slug.mjs'), 'old\n');
git(seed, 'add', 'bin/slug.mjs');
git(seed, 'commit', '-q', '-m', 'base');
}
const dir = mkdtempSync(join(tmpdir(), 'voyage-park-'));
git(dir, 'init', '-q');
git(dir, 'config', 'user.email', 't@example.invalid');
git(dir, 'config', 'user.name', 't');
mkdirSync(join(dir, 'bin'));
writeFileSync(join(dir, 'bin/slug.mjs'), 'old\n');
git(dir, 'add', 'bin/slug.mjs');
git(dir, 'commit', '-q', '-m', 'base');
cpSync(seed, dir, { recursive: true });
return dir;
}
@ -57,9 +67,10 @@ const DIFF = [
'',
].join('\n');
function run(dir, diff = DIFF) {
/** The CLI run with `--cwd <cwd>` (the repo by default) on `diff` written into the repo's git dir. */
function run(dir, diff = DIFF, cwd = dir) {
writeFileSync(join(dir, '.git', 'upward.diff'), diff);
return spawnSync(process.execPath, [CLI, '--diff', join(dir, '.git', 'upward.diff'), '--step', '1', '--attempt', '1', '--cwd', dir], { encoding: 'utf8' });
return spawnSync(process.execPath, [CLI, '--diff', join(dir, '.git', 'upward.diff'), '--step', '1', '--attempt', '1', '--cwd', cwd], { encoding: 'utf8' });
}
// Parked outside the work tree, in the git dir, so a later `git ls-files --others` never lists them.
@ -100,6 +111,9 @@ test('park: a new file that is tracked now, or already gone, is kept and named,
assert.match(out.kept[0].reason, /tracked/);
assert.match(out.kept[1].reason, /not on disk/);
assert.equal(existsSync(join(dir, 'tests/batch.sh')), true);
const inIndex = spawnSync('git', ['ls-files', '--error-unmatch', '--', 'tests/batch.sh'], { cwd: dir, encoding: 'utf8' });
assert.equal(inIndex.status, 0, `the committed file left the index: ${inIndex.stderr}`);
assert.equal(git(dir, 'status', '--porcelain', '--', 'tests/batch.sh'), '', 'the committed file is no longer clean in the index');
} finally {
rmSync(dir, { recursive: true, force: true });
}
@ -194,3 +208,164 @@ test('trekexecute: a re-planned step parks the earlier attempt\'s new files befo
assert.match(para, /never deleted/);
assert.match(para, /Exit 1 means a file the attempt made is still in the tree/);
});
// --- fix round 4-5a: F4a, F4b, F4c ---------------------------------------------
// [F4a] `git diff {start_sha}` names the attempt's new files because the helper
// registered them --intent-to-add, so they are in the index: the tool called
// them "tracked since the attempt" and left them in the tree with exit 0.
// [F4b] A coloured diff (color.diff=always) hid every header; a diff with no
// readable header at all parked nothing with exit 0.
// [F4c] The root was the --cwd as given, so a subdirectory read every
// root-relative path as absent; a git call that failed read as "nothing tracked".
/** The attempt's two new files, written on disk. */
function attemptFiles(dir) {
mkdirSync(join(dir, 'tests'));
writeFileSync(join(dir, 'tests/batch.sh'), 'stale | cat\n');
writeFileSync(join(dir, 'tests/cli-stdin.test.mjs'), 'stale test\n');
}
test('[F4a] a new file still registered --intent-to-add is parked: gone from the tree and the index, kept under the parked directory', () => {
const dir = repo();
try {
attemptFiles(dir);
git(dir, 'add', '--intent-to-add', '--', 'tests/batch.sh', 'tests/cli-stdin.test.mjs');
assert.match(git(dir, 'ls-files'), /tests\/batch\.sh/, 'control: the file is in the index before the park');
const r = run(dir);
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.equal(existsSync(join(dir, 'tests/batch.sh')), false, 'the intent-to-add file is still in the work tree');
assert.equal(existsSync(join(dir, 'tests/cli-stdin.test.mjs')), false, 'the intent-to-add test file is still in the work tree');
assert.doesNotMatch(git(dir, 'ls-files'), /tests\//, 'the parked files are still in the index');
assert.equal(readFileSync(parkedPath(dir, 'tests/batch.sh'), 'utf8'), 'stale | cat\n', 'the file is not under the parked directory');
assert.equal(readFileSync(parkedPath(dir, 'tests/cli-stdin.test.mjs'), 'utf8'), 'stale test\n');
assert.deepEqual(JSON.parse(r.stdout).parked, ['tests/batch.sh', 'tests/cli-stdin.test.mjs']);
assert.equal(readFileSync(join(dir, 'bin/slug.mjs'), 'utf8'), 'old\n', 'a committed file is never touched');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('[F4b] a diff with \\x1b[1m...\\x1b[m around its headers parks the file', () => {
const dir = repo();
try {
attemptFiles(dir);
const coloured = DIFF.split('\n')
.map((l) => (/^(diff --git |new file mode |index |--- |\+\+\+ )/.test(l) ? `\x1b[1m${l}\x1b[m` : l))
.join('\n');
assert.ok(coloured.includes('\x1b[1mdiff --git a/tests/batch.sh b/tests/batch.sh\x1b[m'), 'control: the headers are coloured');
const r = run(dir, coloured);
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.equal(existsSync(join(dir, 'tests/batch.sh')), false, 'a coloured header left the file in the tree');
assert.equal(readFileSync(parkedPath(dir, 'tests/batch.sh'), 'utf8'), 'stale | cat\n', 'the file is not under the parked directory');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('[F4b] a non-empty diff with no readable diff --git header exits 1 with a blocking entry', () => {
const dir = repo();
try {
attemptFiles(dir);
const headerless = ['--- /dev/null', '+++ b/tests/batch.sh', '@@ -0,0 +1 @@', '+cat', ''].join('\n');
const r = run(dir, headerless);
assert.equal(r.status, 1, `a diff it could not read must stop the run: ${r.stdout}${r.stderr}`);
const out = JSON.parse(r.stdout);
assert.equal(out.kept.filter((k) => k.blocking).length, 1, `expected one blocking entry: ${r.stdout}`);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('[F4c] --cwd <repo>/bin parks the root-relative path', () => {
const dir = repo();
try {
attemptFiles(dir);
const r = run(dir, DIFF, join(dir, 'bin'));
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.ok(JSON.parse(r.stdout).parked.includes('tests/batch.sh'), `tests/batch.sh was not parked from a subdirectory: ${r.stdout}`);
assert.equal(existsSync(join(dir, 'tests/batch.sh')), false, 'the file is still in the tree');
assert.equal(readFileSync(parkedPath(dir, 'tests/batch.sh'), 'utf8'), 'stale | cat\n');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('[F4c] a corrupt .git/index (ls-files exits 128) exits 2 and moves nothing', () => {
const dir = repo();
try {
attemptFiles(dir);
writeFileSync(join(dir, '.git', 'index'), 'not an index\n');
const ls = spawnSync('git', ['ls-files', '-z'], { cwd: dir, encoding: 'utf8' });
assert.equal(ls.status, 128, `control: ls-files must fail on the corrupt index: ${ls.stderr}`);
const r = run(dir);
assert.equal(r.status, 2, `a failed git call must be exit 2, never a park: ${r.stdout}${r.stderr}`);
assert.equal(existsSync(join(dir, 'tests/batch.sh')), true, 'a file was moved although git failed');
assert.equal(existsSync(parkedPath(dir, 'tests/batch.sh')), false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('park: an empty diff exits 0 and parks nothing', () => {
const dir = repo();
try {
const r = run(dir, '');
assert.equal(r.status, 0, r.stdout + r.stderr);
const out = JSON.parse(r.stdout);
assert.deepEqual(out.parked, []);
assert.deepEqual(out.kept, []);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('[F4a] an intent-to-add file named like a glob is parked, and the committed file it would match stays in the index', () => {
const dir = repo();
try {
mkdirSync(join(dir, 'tests'));
mkdirSync(join(dir, 'app/s'), { recursive: true });
writeFileSync(join(dir, 'tests/real.sh'), 'committed\n');
writeFileSync(join(dir, 'app/s/page.tsx'), 'committed\n');
git(dir, 'add', '--', 'tests/real.sh', 'app/s/page.tsx');
git(dir, 'commit', '-q', '-m', 'operator');
writeFileSync(join(dir, 'tests/*.sh'), 'stale glob\n');
mkdirSync(join(dir, 'app/[s]'));
writeFileSync(join(dir, 'app/[s]/page.tsx'), 'stale bracket\n');
git(dir, '--literal-pathspecs', 'add', '--intent-to-add', '--', 'tests/*.sh', 'app/[s]/page.tsx');
const diff = DIFF.replaceAll('tests/batch.sh', 'tests/*.sh').replaceAll('tests/cli-stdin.test.mjs', 'app/[s]/page.tsx');
const r = run(dir, diff);
assert.equal(r.status, 0, r.stdout + r.stderr);
for (const committed of ['tests/real.sh', 'app/s/page.tsx']) {
const inIndex = spawnSync('git', ['ls-files', '--error-unmatch', '--', committed], { cwd: dir, encoding: 'utf8' });
assert.equal(inIndex.status, 0, `${committed} left the index: ${inIndex.stderr}`);
assert.equal(git(dir, 'status', '--porcelain', '--', committed), '', `${committed} is no longer clean in the index`);
}
assert.equal(existsSync(join(dir, 'tests/*.sh')), false);
assert.equal(existsSync(parkedPath(dir, 'tests/*.sh')), true, 'the glob-named file is not under the parked directory');
assert.equal(existsSync(parkedPath(dir, 'app/[s]/page.tsx')), true, 'the bracket-named file is not under the parked directory');
assert.equal(readFileSync(parkedPath(dir, 'tests/*.sh'), 'utf8'), 'stale glob\n', 'the glob-named file is not parked');
assert.equal(readFileSync(parkedPath(dir, 'app/[s]/page.tsx'), 'utf8'), 'stale bracket\n', 'the bracket-named file is not parked');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('[F4a] one index entry that cannot be removed exits 2 with every entry still in the index and nothing parked', () => {
const dir = repo();
try {
attemptFiles(dir);
git(dir, 'add', '--intent-to-add', '--', 'tests/batch.sh');
git(dir, 'add', '--', 'tests/cli-stdin.test.mjs');
writeFileSync(join(dir, 'tests/cli-stdin.test.mjs'), 'edited after staging\n');
const r = run(dir);
assert.equal(r.status, 2, `a removal git refuses must be exit 2: ${r.stdout}${r.stderr}`);
const listed = git(dir, 'ls-files').split('\n');
assert.ok(listed.includes('tests/batch.sh'), 'the intent-to-add entry was taken out of the index although the run could not finish');
assert.ok(listed.includes('tests/cli-stdin.test.mjs'), 'the staged entry was taken out of the index');
assert.equal(existsSync(parkedPath(dir, 'tests/batch.sh')), false, 'a file was parked although the run could not finish');
assert.equal(existsSync(parkedPath(dir, 'tests/cli-stdin.test.mjs')), false);
assert.equal(existsSync(join(dir, 'tests/batch.sh')), true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

View file

@ -1,9 +1,11 @@
// tests/lib/profile-stats-fields.test.mjs
// SC #11 contract-test per brief design — kombinasjonen av:
// (a) fixture-records valideres som JSONL-contracts AND
// (b) command-prose contains field-names
// (b) command prose promises no profile key its stats record does not write
// er den brief-designede gating-mekanismen. Faktisk runtime-emission av
// feltene er LLM-prose-driven og ikke testbart i node:test alene.
// (b) was turned around in fix round 4-5a (#46): it used to require the prose
// to name profile_source and friends, which no stats heredoc writes.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
@ -20,6 +22,8 @@ const PROFILE_FIELDS = [
'parallel_agents',
'external_research_enabled',
'profile_source',
'model_used',
'phase_models_resolved',
];
const VALID_PROFILE_SOURCES = new Set(['flag', 'env', 'default', 'inheritance']);
@ -74,24 +78,68 @@ test('SC #11(a): fixture coverage — all 4 profile_source values represented',
}
});
// (b) Command prose contains field-names (false-confidence kompensasjon per plan-critic Major 4)
// (b) Command prose promises no profile key its stats record does not write
// (false-confidence kompensasjon per plan-critic Major 4). A paragraph outside
// a code fence that mentions stats may name a PROFILE_FIELDS key in backticks
// only when the command's own `stats-append.mjs <kind> <<'JSON'` heredoc
// writes it.
/** The keys of every `stats-append.mjs <kind> <<'JSON'` … `JSON` heredoc in `text`. */
function statsHeredocs(text) {
const out = [];
for (const m of text.matchAll(/stats-append\.mjs (\S+) <<'JSON'\n([\s\S]*?)\nJSON\s*$/gm)) {
out.push({ kind: m[1], keys: new Set([...m[2].matchAll(/"([A-Za-z_][A-Za-z0-9_]*)"\s*:/g)].map((k) => k[1])) });
}
return out;
}
/** Backticked PROFILE_FIELDS keys in stats paragraphs (outside fences) that `written` lacks; sorted. */
function unwrittenStatsKeys(text, written) {
const paras = [];
let cur = [];
let fenced = false;
for (const l of text.split('\n')) {
if (/^\s{0,3}(`{3,}|~{3,})/.test(l)) { fenced = !fenced; paras.push(cur); cur = []; continue; }
if (fenced) continue;
if (l.trim() === '') { paras.push(cur); cur = []; } else cur.push(l);
}
paras.push(cur);
const bad = new Set();
for (const p of paras.map((x) => x.join('\n')).filter((p) => /stats/i.test(p))) {
for (const m of p.matchAll(/`([^`\n]+)`/g)) {
if (PROFILE_FIELDS.includes(m[1]) && !written.has(m[1])) bad.add(m[1]);
}
}
return [...bad].sort();
}
for (const filename of COMMAND_FILES) {
test(`SC #11(b): commands/${filename} prose mentions profile + profile_source`, () => {
test(`SC #11(b): commands/${filename} stats prose promises no profile key its stats record does not write`, () => {
const content = readFileSync(join(REPO_ROOT, 'commands', filename), 'utf-8');
assert.match(content, /profile_source/,
`${filename} prose missing profile_source — Step 8 stats schema additive must be documented`);
assert.match(content, /profile/,
`${filename} prose missing profile — Step 8 stats schema additive must be documented`);
const docs = statsHeredocs(content);
assert.equal(docs.length, 1, `${filename}: expected exactly one stats-append.mjs heredoc; found ${docs.length}`);
assert.ok(docs[0].keys.has('ts'), `${filename}: the stats heredoc's keys were not read`);
assert.deepEqual(unwrittenStatsKeys(content, docs[0].keys), [],
`${filename} stats prose promises keys its stats record does not write`);
assert.doesNotMatch(content, /profile_source/,
`${filename} names profile_source, a key no stats record writes`);
});
}
test('SC #11(b): commands/trekplan.md prose mentions phase_models + parallel_agents', () => {
test('SC #11(b): commands/trekplan.md stats prose names neither phase_models nor parallel_agents, which its record does not write', () => {
const content = readFileSync(join(REPO_ROOT, 'commands', 'trekplan.md'), 'utf-8');
assert.match(content, /phase_models/,
'trekplan.md prose must mention phase_models (additive stats field)');
assert.match(content, /parallel_agents/,
'trekplan.md prose must mention parallel_agents (additive stats field)');
const docs = statsHeredocs(content);
assert.equal(docs.length, 1, `trekplan.md: expected exactly one stats-append.mjs heredoc; found ${docs.length}`);
assert.equal(docs[0].keys.has('phase_models'), false, 'the trekplan stats record now writes phase_models: update this test');
assert.equal(docs[0].keys.has('parallel_agents'), false, 'the trekplan stats record now writes parallel_agents: update this test');
const bad = unwrittenStatsKeys(content, docs[0].keys);
assert.ok(!bad.includes('phase_models'), 'trekplan.md stats prose promises phase_models');
assert.ok(!bad.includes('parallel_agents'), 'trekplan.md stats prose promises parallel_agents');
});
test('SC #11(b): the stats-key checker reports a planted paragraph that promises `profile`', () => {
const written = statsHeredocs(readFileSync(join(REPO_ROOT, 'commands', 'trekplan.md'), 'utf-8'))[0].keys;
assert.deepEqual(unwrittenStatsKeys('Stats rows carry `profile`.', written), ['profile']);
});
// --- Step 9: the five STORM measurement fields -----------------------------

View file

@ -0,0 +1,228 @@
// tests/lib/red-judge.test.mjs
// The red judge of fix round 4-5a (tests/helpers/red-judge.mjs) and its
// reporter (tests/helpers/red-reporter.mjs). The round's red script
// (tests/fix-round-4-5a-red.sh) runs every tagged test at ab6b32c and lets the
// judge decide; a judge that always exits 0 would prove nothing. So the judge
// is fed planted JSON lines with the tags [X1] and [X2] (never a real id) and
// must exit 1 on each broken rule, naming it on one line.
//
// The judge runs only as a CLI through spawnSync and is never imported: each
// test asserts the exit code and the line the judge prints, so every test
// fails on an assertion while the judge does not exist.
//
// Rule lines (one per broken rule, exit 1):
// broken: no-test [id] a listed id has no test
// broken: passed [id] {name} a tagged test passed
// broken: skipped [id] {name} a tagged test was skipped
// broken: todo [id] {name} a tagged test is a todo
// broken: not-assertion [id] {name} (...) it failed, but not on an assertion
// broken: untagged-failure {name} (...) a failing event with no tag
// broken: unlisted-tag [tag] {name} a tag that is not in the list
// broken: mutation-not-red [id] --mutation: no assertion failure
// All red: exit 0 and "red: [id] {n} test(s)" per id.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { childEnv } from '../helpers/child-env.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const JUDGE = join(ROOT, 'tests', 'helpers', 'red-judge.mjs');
const REPORTER = join(ROOT, 'tests', 'helpers', 'red-reporter.mjs');
/** A temp directory outside tests/, removed after `fn` returns. */
function inTmp(fn) {
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'red-judge-test-')));
try {
return fn(dir);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
/** One planted reporter line. Defaults: a top-level test that failed on an assertion. */
function ev(name, over = {}) {
return {
name, file: '/planted/x.test.mjs', nesting: 0, ok: false, skip: false, todo: false,
code: 'ERR_ASSERTION', cname: 'AssertionError', failureType: 'testCodeFailure', ...over,
};
}
const passing = (name) => ev(name, { ok: true, code: null, cname: null, failureType: null });
/** The judge on a results file, the id list written into `dir`, with extra flags. */
function judgeFile(dir, ids, results, extra = []) {
writeFileSync(join(dir, 'ids'), ids.join('\n') + '\n');
const r = spawnSync(process.execPath, [JUDGE, '--ids', join(dir, 'ids'), '--results', results, ...extra],
{ encoding: 'utf8', env: childEnv() });
return { status: r.status, out: `${r.stdout}${r.stderr}` };
}
/** Run the judge on an id list and planted events, with extra flags. */
function judge(ids, events, extra = []) {
return inTmp((dir) => {
const results = join(dir, 'results.jsonl');
writeFileSync(results, events.map((e) => JSON.stringify(e)).join('\n') + (events.length ? '\n' : ''));
return judgeFile(dir, ids, results, extra);
});
}
test('red judge: a tagged test that passes -> exit 1, "broken: passed [X1]"', () => {
const r = judge(['X1'], [passing('[X1] holds')]);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^broken: passed \[X1\] \[X1\] holds$/m, r.out);
});
test('red judge: a tagged test that fails with ERR_MODULE_NOT_FOUND -> exit 1, "broken: not-assertion [X1]" (a crash is not red)', () => {
const r = judge(['X1'], [ev('[X1] loads', { code: 'ERR_MODULE_NOT_FOUND', cname: 'Error' })]);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^broken: not-assertion \[X1\] \[X1\] loads \(ERR_MODULE_NOT_FOUND Error\)$/m, r.out);
});
test('red judge: a listed id with no test -> exit 1, "broken: no-test [X2]"', () => {
const r = judge(['X1', 'X2'], [ev('[X1] red')]);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^broken: no-test \[X2\]$/m, r.out);
assert.doesNotMatch(r.out, /^broken: .*\[X1\]/m, 'X1 is red and must not be named');
});
test('red judge: a tag that is not in the list -> exit 1, "broken: unlisted-tag [X2]"', () => {
const r = judge(['X1'], [ev('[X1] red'), ev('[X2] stray')]);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^broken: unlisted-tag \[X2\] \[X2\] stray$/m, r.out);
});
test('red judge: an untagged failing event (a file that failed to load) -> exit 1, "broken: untagged-failure"', () => {
const r = judge(['X1'], [ev('[X1] red'), ev('x.test.mjs', { code: null, cname: null })]);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^broken: untagged-failure x\.test\.mjs \(\/planted\/x\.test\.mjs\)$/m, r.out);
});
test('red judge: an untagged aggregate (failureType subtestsFailed) next to a tagged assertion failure is not counted -> exit 0', () => {
const r = judge(['X1'], [
ev('[X1] red', { nesting: 1 }),
ev('group', { code: 'ERR_TEST_FAILURE', cname: null, failureType: 'subtestsFailed' }),
]);
assert.equal(r.status, 0, r.out);
assert.match(r.out, /^red: \[X1\] 1 test\(s\)$/m, r.out);
assert.doesNotMatch(r.out, /^broken:/m, r.out);
});
test('red judge: a skipped tagged test -> exit 1, "broken: skipped [X1]"', () => {
const r = judge(['X1'], [ev('[X1] red'), ev('[X1] later', { ok: true, skip: true, code: null, cname: null, failureType: null })]);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^broken: skipped \[X1\] \[X1\] later$/m, r.out);
});
test('red judge: every id covered by assertion failures -> exit 0, one "red: [id] {n} test(s)" line per id', () => {
const r = judge(['X1', 'X2'], [ev('[X1] one'), ev('[X1] two'), ev('[X2] three'), passing('untagged keep')]);
assert.equal(r.status, 0, r.out);
assert.match(r.out, /^red: \[X1\] 2 test\(s\)$/m, r.out);
assert.match(r.out, /^red: \[X2\] 1 test\(s\)$/m, r.out);
assert.doesNotMatch(r.out, /^broken:/m, r.out);
});
test('red judge: --pass-ids X1 with [X1] passing -> exit 0, the other ids still red', () => {
const r = judge(['X1', 'X2'], [passing('[X1] holds'), ev('[X2] red')], ['--pass-ids', 'X1']);
assert.equal(r.status, 0, r.out);
assert.match(r.out, /^red: \[X2\] 1 test\(s\)$/m, r.out);
assert.doesNotMatch(r.out, /^broken:/m, r.out);
});
test('red judge: --pass-ids X1 exempts only X1: a passing [X2] next to a passing [X1] -> exit 1, "broken: passed [X2]"', () => {
const r = judge(['X1', 'X2'], [passing('[X1] holds'), passing('[X2] holds too')], ['--pass-ids', 'X1']);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^broken: passed \[X2\] \[X2\] .+$/m, r.out);
assert.doesNotMatch(r.out, /^broken: .*\[X1\]/m, 'X1 is in --pass-ids and must not be named');
});
test('red judge: a todo tagged test next to a red one -> exit 1, "broken: todo [X1]"', () => {
const r = judge(['X1'], [ev('[X1] red'), ev('[X1] someday', { todo: true })]);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^broken: todo \[X1\] \[X1\] someday$/m, r.out);
});
test('red judge: --mutation X1 with no [X1] assertion failure -> exit 1, "broken: mutation-not-red [X1]"', () => {
const r = judge(['X1'], [passing('[X1] holds')], ['--mutation', 'X1']);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^broken: mutation-not-red \[X1\]$/m, r.out);
});
test('red judge: --mutation X1 with one [X1] assertion failure and one [X1] pass -> exit 0, "red: [X1] 1 test(s)"', () => {
const r = judge(['X1'], [passing('[X1] holds'), ev('[X1] red')], ['--mutation', 'X1']);
assert.equal(r.status, 0, r.out);
assert.match(r.out, /^red: \[X1\] 1 test\(s\)$/m, r.out);
assert.doesNotMatch(r.out, /^broken:/m, r.out);
});
test('red judge: --mutation X1 with one [X1] assertion failure and one [X1] TypeError crash -> exit 1, "broken: not-assertion [X1]"', () => {
const r = judge(['X1'], [ev('[X1] red'), ev('[X1] crash', { code: 'ERR_TEST_FAILURE', cname: 'TypeError' })], ['--mutation', 'X1']);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^broken: not-assertion \[X1\] \[X1\] crash \(ERR_TEST_FAILURE TypeError\)$/m, r.out);
});
/** Write fixture test files into `dir`, run them under the red reporter, return the JSONL path. */
function report(dir, files) {
for (const [name, body] of Object.entries(files)) writeFileSync(join(dir, name), body);
const out = join(dir, 'out.jsonl');
spawnSync(process.execPath, ['--test', `--test-reporter=${REPORTER}`, `--test-reporter-destination=${out}`,
...Object.keys(files)], { cwd: dir, encoding: 'utf8', env: childEnv({ NODE_TEST_CONTEXT: undefined }) });
assert.ok(existsSync(out), 'the red reporter wrote no results file');
return out;
}
const HEAD = "import { test } from 'node:test';\nimport { strict as assert } from 'node:assert';\n";
test('red reporter + judge: a passing [X1] test and an [X2] test that throws a TypeError, run by node --test -> exit 1', () => {
inTmp((dir) => {
const out = report(dir, {
'x1.test.mjs': `${HEAD}test('[X1] holds', () => { assert.equal(1, 1); });\n`,
'x2.test.mjs': `${HEAD}test('[X2] throws', () => { throw new TypeError('boom'); });\n`,
});
const r = judgeFile(dir, ['X1', 'X2'], out);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^broken: passed \[X1\] \[X1\] holds$/m, r.out);
assert.match(r.out, /^broken: not-assertion \[X2\] \[X2\] throws \(ERR_TEST_FAILURE TypeError\)$/m, r.out);
});
});
test('red reporter + judge: an [X1] test that fails on assert.equal(1, 2) -> exit 0, and the line carries the reporter fields', () => {
inTmp((dir) => {
const out = report(dir, { 'x1.test.mjs': `${HEAD}test('[X1] red', () => { assert.equal(1, 2); });\n` });
const lines = readFileSync(out, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
const x1 = lines.filter((l) => l.name === '[X1] red');
assert.equal(x1.length, 1, readFileSync(out, 'utf8'));
assert.deepEqual(Object.keys(x1[0]).sort(),
['cname', 'code', 'failureType', 'file', 'name', 'nesting', 'ok', 'skip', 'todo']);
assert.deepEqual([x1[0].ok, x1[0].code, x1[0].cname, x1[0].failureType, x1[0].nesting],
[false, 'ERR_ASSERTION', 'AssertionError', 'testCodeFailure', 0]);
assert.equal(x1[0].file, join(dir, 'x1.test.mjs'));
const r = judgeFile(dir, ['X1'], out);
assert.equal(r.status, 0, r.out);
assert.match(r.out, /^red: \[X1\] 1 test\(s\)$/m, r.out);
});
});
test('red reporter + judge: a { skip: true } and a { todo: true } [X1] test carry skip and todo in their lines -> exit 1', () => {
inTmp((dir) => {
const out = report(dir, {
'x1.test.mjs': `${HEAD}test('[X1] red', () => { assert.equal(1, 2); });\n`
+ `test('[X1] skipped', { skip: true }, () => {});\n`
+ `test('[X1] todo', { todo: true }, () => { assert.equal(1, 2); });\n`,
});
const lines = readFileSync(out, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
const byName = (n) => lines.filter((l) => l.name === n);
assert.equal(byName('[X1] skipped').length, 1, readFileSync(out, 'utf8'));
assert.equal(byName('[X1] todo').length, 1, readFileSync(out, 'utf8'));
assert.deepEqual([byName('[X1] skipped')[0].skip, byName('[X1] skipped')[0].todo], [true, false]);
assert.deepEqual([byName('[X1] todo')[0].skip, byName('[X1] todo')[0].todo], [false, true]);
assert.deepEqual([byName('[X1] red')[0].skip, byName('[X1] red')[0].todo], [false, false]);
const r = judgeFile(dir, ['X1'], out);
assert.equal(r.status, 1, r.out);
assert.match(r.out, /^broken: skipped \[X1\] \[X1\] skipped$/m, r.out);
assert.match(r.out, /^broken: todo \[X1\] \[X1\] todo$/m, r.out);
});
});

View file

@ -153,9 +153,10 @@ test('resolveStatsPath honors CLAUDE_PLUGIN_DATA, else the stats-append director
assert.equal(resolveStatsPath({ HOME: '/h', CLAUDE_PLUGIN_DATA: '' }), join('/h', DATA_REL, 'trekexecute-stats.jsonl'));
});
// gates_mode_flag_override stays a known event; no command emits it since fix round 4.5a
test('the Phase 8 and override events are known events', () => {
for (const e of ['main-merge-approved', 'main-merge-declined', 'gates_mode_flag_override']) {
assert.equal(buildRecord(e).known_event, true, `${e} is emitted by /trekexecute by design`);
assert.equal(buildRecord(e).known_event, true, `${e} is a known event`);
}
});

View file

@ -339,9 +339,9 @@ test('CLI: stats-append writes when its path contains a space', () => {
});
// ---- /trekexecute: every telemetry write lands through a writer ----------------
// Phase 8's three event-emit calls, Phase 9's stats record and the
// gates_mode_flag_override record. Phase 9 said "skip silently"; the override was
// prose with no writer; Phase 8's payloads spliced ${PLAN_PATH} (set in an earlier
// Phase 8's three event-emit calls and Phase 9's stats record; the
// gates_mode_flag_override record is gone, since no effort sets gates_mode (#33).
// Phase 9 said "skip silently"; Phase 8's payloads spliced ${PLAN_PATH} (set in an earlier
// Bash call that does not survive) and ${WAVE_COUNT} (never assigned) into JSON,
// so a literal run printed payload-not-json. Every block runs here in a fresh
// shell with NOTHING exported, CLAUDE_PLUGIN_DATA empty and HOME pinned.
@ -431,9 +431,10 @@ test('Phase 8: a plan path with a quote character lands literally and the shell
}
});
test('gates_mode_flag_override: a written:false is printed in one line and the run continues', () => {
test('gates_mode_flag_override: the Composition rule holds no override record to report on', () => {
const flat = section(TE, 'Composition rule (v5.1)').replace(/\s+/g, ' ');
assert.match(flat, /On `"written": false` \(exit 1\), print its `reason` in one line and continue/);
assert.doesNotMatch(flat, /--event gates_mode_flag_override/, 'no effort sets gates_mode, so there is no override to log');
assert.doesNotMatch(flat, /override record never blocks the run/);
});
test('Phase 8: a written:false is printed in one line and the run continues', () => {
@ -443,18 +444,10 @@ test('Phase 8: a written:false is printed in one line and the run continues', ()
assert.match(flat, /never chained with `&&`/);
});
test('gates_mode_flag_override is written through event-emit with the documented shape', () => {
test('gates_mode_flag_override: /trekexecute holds no --event gates_mode_flag_override block', () => {
const sec = section(TE, 'Composition rule (v5.1)');
const blocks = fencedBlocksWith(sec, '--event gates_mode_flag_override');
assert.equal(blocks.length, 1, 'the override needs a writer, not a prose target');
assert.match(sec, /\{ts, event, known_event, payload:\{flag, signal_effort\}\}/);
const home = mkdtempSync(join(tmpdir(), 'stats-landing-'));
try {
const r = runBlock(blocks[0], home);
assert.equal(r.status, 0, r.stdout + r.stderr);
const [rec] = records(join(home, DATA_REL, 'trekexecute-stats.jsonl'));
assert.deepEqual(Object.keys(rec).sort(), ['event', 'known_event', 'payload', 'ts']);
assert.equal(rec.known_event, true);
assert.deepEqual(Object.keys(rec.payload).sort(), ['flag', 'signal_effort']);
} finally { rmSync(home, { recursive: true, force: true }); }
assert.deepEqual(fencedBlocksWith(sec, '--event gates_mode_flag_override'), [],
'high effort sets no gates_mode, so no override is written');
assert.deepEqual(fencedBlocksWith(read(TE), 'gates_mode_flag_override'), []);
assert.doesNotMatch(sec, /\{ts, event, known_event, payload:\{flag, signal_effort\}\}/);
});

View file

@ -3,20 +3,27 @@
// verdict written in review.md: 0 = ALLOW/WARN, 1 = BLOCK, 2 = could not read.
// Exit 2 is never green: a missing file, a missing verdict, or a verdict the
// file contradicts all land there.
// [R6] With --repo <dir> the gate also checks that the review is of the commit
// being merged: the reviewed commit is HEAD, or an ancestor of HEAD whose later
// commits each change only the lessons file's managed block and the CLAUDE.md
// pointer block. Anything else is exit 2 `review-stale: ...`. Those tests run
// the CLI only, in throwaway git repos with no hooks.
import { test } from 'node:test';
import { after, test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, rmSync, existsSync, symlinkSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, symlinkSync, appendFileSync, cpSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { MARK_START, MARK_END, POINTER_START, POINTER_LINE, POINTER_END } from '../../lib/review/recurrence.mjs';
import { childEnv } from '../helpers/child-env.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const GATE = join(ROOT, 'lib', 'review', 'verdict-gate.mjs');
const { decideVerdict } = existsSync(GATE) ? await import('../../lib/review/verdict-gate.mjs') : {};
function review({ verdict, jsonVerdict, severities = [], allowBlockedBy = [] }) {
function review({ verdict, jsonVerdict, severities = [], allowBlockedBy = [], scopeShaEnd = 'abc' }) {
const ids = severities.map((_, i) => String(i + 1).padStart(40, 'a'));
const fmVerdict = verdict === undefined ? '' : `verdict: ${verdict}\n`;
const findings = severities.map((s, i) => ({
@ -32,7 +39,7 @@ task: "t"
slug: s
project_dir: .claude/projects/2026-10-06-s/
brief_path: .claude/projects/2026-10-06-s/brief.md
scope_sha_end: abc
scope_sha_end: ${scopeShaEnd}
reviewed_files_count: 1
${fmVerdict}findings:
${ids.map((id) => ` - ${id}`).join('\n')}
@ -236,3 +243,338 @@ test('verdict-gate — run through a symlink and a path with a space it still de
rmSync(dir, { recursive: true, force: true });
}
});
// ---- R6: --repo — the review must be of the commit being merged ----
const LESSONS = '.claude/rules/voyage-review-lessons.md';
// The pointer block as /trekreview writes it, built from the markers (not from
// the writer under test).
const POINTER = `${POINTER_START}\n${POINTER_LINE}\n${POINTER_END}\n`;
const LESSONS_BEFORE = `# Review lessons\n\n${MARK_START}\nEach line: a finding class.\n${MARK_END}\n\nKept by hand.\n`;
const LESSON = '- [MISSING_TEST] (check) Mistake: m. · Fix: f. · How to check: c.';
const LESSONS_AFTER = `# Review lessons\n\n${MARK_START}\nEach line: a finding class.\n${LESSON}\n${MARK_END}\n\nKept by hand.\n`;
function g(cwd, ...args) {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: childEnv() });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
}
/** Write `text` to `rel` under `dir`, making its directories. */
function putIn(dir, rel, text) {
mkdirSync(dirname(join(dir, rel)), { recursive: true });
writeFileSync(join(dir, rel), text);
}
/** A fixture variant's seed: `{ base, reviewed }`, built once (see gateRepo) and removed after the file's tests. */
const seeds = new Map();
after(() => {
for (const seed of seeds.values()) rmSync(seed.base, { recursive: true, force: true });
});
function seedRepo({ sub, claude, claudeLink }) {
const base = mkdtempSync(join(tmpdir(), 'verdict-gate-r6-seed-'));
const repo = join(base, 'repo');
const dir = sub ? join(repo, sub) : repo;
mkdirSync(join(dir, '.claude', 'rules'), { recursive: true });
mkdirSync(join(base, 'hooks-none'));
g(repo, 'init', '-q', '-b', 'main');
appendFileSync(join(repo, '.git', 'config'), '[user]\n\tname = Test\n\temail = test@example.invalid\n'
+ `[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "${join(base, 'hooks-none')}"\n`);
putIn(dir, 'a.mjs', 'export const a = 1;\n');
putIn(dir, LESSONS, LESSONS_BEFORE);
if (claudeLink) {
putIn(dir, 'AGENTS.md', claude);
symlinkSync('AGENTS.md', join(dir, 'CLAUDE.md'));
} else {
putIn(dir, 'CLAUDE.md', claude);
}
g(repo, 'add', '-A');
g(repo, 'commit', '-q', '-m', 'feat: the work');
return { base, reviewed: g(repo, 'rev-parse', 'HEAD') };
}
/**
* A throwaway repo with one reviewed commit (a.mjs, CLAUDE.md, the lessons
* file), no git hooks. `sub` puts the project in a subdirectory of the repo;
* `claudeLink` makes CLAUDE.md a symlink to AGENTS.md. Each variant is built
* once; every call gets its own copy of it.
*/
function gateRepo({ sub = '', claude = '# Project\n', claudeLink = false } = {}) {
const key = JSON.stringify([sub, claude, claudeLink]);
if (!seeds.has(key)) seeds.set(key, seedRepo({ sub, claude, claudeLink }));
const seed = seeds.get(key);
const base = mkdtempSync(join(tmpdir(), 'verdict-gate-r6-'));
cpSync(seed.base, base, { recursive: true, verbatimSymlinks: true });
const repo = join(base, 'repo');
const dir = sub ? join(repo, sub) : repo;
// The copy's hooks path is its own empty hooks-none, not the seed's.
const config = join(repo, '.git', 'config');
writeFileSync(config, readFileSync(config, 'utf8').split(seed.base).join(base));
const { reviewed } = seed;
const put = (rel, text) => putIn(dir, rel, text);
const commit = (msg, files) => {
for (const [rel, text] of Object.entries(files)) put(rel, text);
g(repo, 'add', '-A');
g(repo, 'commit', '-q', '-m', msg);
};
const gate = ({ verdict = 'ALLOW', sha = reviewed } = {}) => {
const p = join(base, 'review.md');
writeFileSync(p, review({ verdict, severities: [verdict === 'BLOCK' ? 'BLOCKER' : 'MINOR'], scopeShaEnd: sha }));
return spawnSync(process.execPath, [GATE, '--json', p, '--repo', dir], { encoding: 'utf-8', env: childEnv() });
};
return {
base, repo, dir, reviewed, put, commit, gate,
git: (...args) => g(repo, ...args),
cleanup: () => rmSync(base, { recursive: true, force: true }),
};
}
/** The lessons commit /trekexecute Phase 7.2 makes after a review. */
function lessonsCommit(fx, claudeAfter = `# Project\n\n${POINTER}`) {
fx.commit('docs(voyage): review lessons (round 1)', { [LESSONS]: LESSONS_AFTER, 'CLAUDE.md': claudeAfter });
}
function assertStale(r, re = /review-stale/) {
assert.equal(r.status, 2, r.stdout + r.stderr);
assert.match(r.stdout, /review-stale/);
assert.match(r.stdout, re);
}
test('[R6] --repo: a review of HEAD itself → exit 0', () => {
const fx = gateRepo();
try {
const r = fx.gate();
assert.equal(r.status, 0, r.stdout + r.stderr);
} finally { fx.cleanup(); }
});
test('[R6] --repo: a later commit that changes a source file → exit 2 review-stale', () => {
const fx = gateRepo();
try {
fx.commit('fix: more work', { 'a.mjs': 'export const a = 2;\n' });
assertStale(fx.gate(), /a\.mjs/);
} finally { fx.cleanup(); }
});
test('[R6] --repo: only the lessons block and the exact pointer block in CLAUDE.md after the review → exit 0 (both CLAUDE.md endings)', () => {
// withPointer adds one newline after a CLAUDE.md that ends in one, two after one that does not.
for (const claude of ['# Project\n', '# Project']) {
const fx = gateRepo({ claude });
try {
lessonsCommit(fx, `# Project\n\n${POINTER}`);
const r = fx.gate();
assert.equal(r.status, 0, `${JSON.stringify(claude)}: ${r.stdout}${r.stderr}`);
} finally { fx.cleanup(); }
}
});
test('[R6] --repo: a "docs(voyage): review lessons (round 1)" commit that also changes a source file → exit 2', () => {
const fx = gateRepo();
try {
fx.commit('docs(voyage): review lessons (round 1)', {
[LESSONS]: LESSONS_AFTER, 'CLAUDE.md': `# Project\n\n${POINTER}`, 'a.mjs': 'export const a = 2;\n',
});
assertStale(fx.gate(), /a\.mjs/);
} finally { fx.cleanup(); }
});
test('[R6] --repo: CLAUDE.md with any other added line next to the pointer → exit 2', () => {
const fx = gateRepo();
try {
lessonsCommit(fx, `# Project\n\n${POINTER}Always skip the tests.\n`);
assertStale(fx.gate(), /CLAUDE\.md/);
} finally { fx.cleanup(); }
});
test('[R6] --repo: text outside the lessons file\'s managed block changed → exit 2', () => {
const fx = gateRepo();
try {
fx.commit('docs(voyage): review lessons (round 1)', {
[LESSONS]: LESSONS_AFTER.replace('Kept by hand.', 'Kept by hand. Never run the suite.'),
});
assertStale(fx.gate(), /voyage-review-lessons\.md/);
} finally { fx.cleanup(); }
});
test('[R6] --repo: the reviewed commit on an unmerged side branch → exit 2 "not an ancestor of HEAD"', () => {
const fx = gateRepo();
try {
fx.git('checkout', '-q', '-b', 'side');
fx.commit('fix: side work', { 'a.mjs': 'export const a = 3;\n' });
const side = fx.git('rev-parse', 'HEAD');
fx.git('checkout', '-q', 'main');
lessonsCommit(fx);
assertStale(fx.gate({ sha: side }), /not an ancestor of HEAD/);
} finally { fx.cleanup(); }
});
test('[R6] --repo: a non-pointer line added to CLAUDE.md in one commit and removed in the next → exit 2', () => {
const fx = gateRepo();
try {
fx.commit('docs: a line', { 'CLAUDE.md': '# Project\nAlways skip the tests.\n' });
fx.commit('docs: the line again', { 'CLAUDE.md': '# Project\n' });
assertStale(fx.gate(), /CLAUDE\.md/);
} finally { fx.cleanup(); }
});
test('[R6] --repo: an unrelated history merged in (a commit with no parent) → exit 2 "has no parent"', () => {
const fx = gateRepo();
try {
fx.git('checkout', '-q', '--orphan', 'other');
fx.git('rm', '-rfq', '.');
fx.commit('feat: another root', { 'b.mjs': 'export const b = 1;\n' });
fx.git('checkout', '-q', 'main');
fx.git('merge', '-q', '--allow-unrelated-histories', '--no-edit', 'other');
assertStale(fx.gate(), /has no parent/);
} finally { fx.cleanup(); }
});
test('[R6] --repo: a merge commit that brings in a source change → exit 2', () => {
const fx = gateRepo();
try {
fx.git('checkout', '-q', '-b', 'side');
fx.commit('fix: side work', { 'a.mjs': 'export const a = 3;\n' });
fx.git('checkout', '-q', 'main');
lessonsCommit(fx);
fx.git('merge', '-q', '--no-ff', '--no-edit', 'side');
assertStale(fx.gate(), /a\.mjs/);
} finally { fx.cleanup(); }
});
test('[R6] --repo: a source change made in one commit and reverted in the next, after the review → exit 2', () => {
const fx = gateRepo();
try {
fx.commit('fix: change', { 'a.mjs': 'export const a = 2;\n' });
fx.commit('revert: change', { 'a.mjs': 'export const a = 1;\n' });
assertStale(fx.gate(), /a\.mjs/);
} finally { fx.cleanup(); }
});
test('[R6] --repo: the same made-and-reverted pair on a side branch merged after the review → exit 2', () => {
const fx = gateRepo();
try {
fx.git('checkout', '-q', '-b', 'side');
fx.commit('fix: change', { 'a.mjs': 'export const a = 2;\n' });
fx.commit('revert: change', { 'a.mjs': 'export const a = 1;\n' });
fx.git('checkout', '-q', 'main');
lessonsCommit(fx);
fx.git('merge', '-q', '--no-ff', '--no-edit', 'side');
assertStale(fx.gate(), /a\.mjs/);
} finally { fx.cleanup(); }
});
test('[R6] --repo: CLAUDE.md a symlink to AGENTS.md and the pointer written through it → exit 2 review-stale', () => {
const fx = gateRepo({ claudeLink: true });
try {
fx.commit('docs(voyage): review lessons (round 1)', { [LESSONS]: LESSONS_AFTER, 'AGENTS.md': `# Project\n\n${POINTER}` });
assertStale(fx.gate(), /AGENTS\.md/);
} finally { fx.cleanup(); }
});
test('[R6] --repo: a project in a subdirectory of the repo → exit 0 for its prefixed lessons path, a change outside it is still seen', () => {
const fx = gateRepo({ sub: 'plugin' });
try {
lessonsCommit(fx);
const r = fx.gate();
assert.equal(r.status, 0, r.stdout + r.stderr);
writeFileSync(join(fx.repo, 'tool.mjs'), 'export const t = 1;\n');
fx.git('add', '-A');
fx.git('commit', '-q', '-m', 'fix: outside the project');
assertStale(fx.gate(), /tool\.mjs/);
} finally { fx.cleanup(); }
});
test('[R6] --repo: a submodule with `ignore = all` in the reviewed .gitmodules whose pointer moves after the review → exit 2 review-stale', () => {
const fx = gateRepo();
try {
// The reviewed commit holds the .gitmodules that hides the submodule from a plain `git diff`.
fx.put('.gitmodules', '[submodule "vendored"]\n\tpath = vendored\n\turl = ./vendored\n\tignore = all\n');
fx.git('add', '--', '.gitmodules');
fx.git('update-index', '--add', '--cacheinfo', `160000,${fx.reviewed},vendored`);
fx.git('commit', '-q', '-m', 'chore: vendored submodule');
const reviewed = fx.git('rev-parse', 'HEAD');
// A later commit only moves the submodule pointer.
fx.git('update-index', '--cacheinfo', `160000,${reviewed},vendored`);
fx.git('commit', '-q', '-m', 'chore: move the submodule');
assertStale(fx.gate({ sha: reviewed }), /vendored changed after the review/);
} finally { fx.cleanup(); }
});
test('[R6] --repo: `diff.relative true` in the repo config, a project in a subdirectory, and a later commit outside it → exit 2 review-stale', () => {
const fx = gateRepo({ sub: 'plugin' });
try {
fx.git('config', 'diff.relative', 'true');
writeFileSync(join(fx.repo, 'tool.mjs'), 'export const t = 1;\n');
fx.git('add', '-A');
fx.git('commit', '-q', '-m', 'fix: outside the project');
assertStale(fx.gate(), /tool\.mjs changed after the review/);
} finally { fx.cleanup(); }
});
test('[R6] --repo: a CLAUDE.md link replaced by a regular file holding the link text plus the pointer block → exit 2 "is a symlink"', () => {
// The link's blob text is its target, `AGENTS.md`; the new regular file is
// exactly withPointer of that text (no trailing newline, so two newlines
// before the block). Only the symlink check can close this.
const fx = gateRepo({ claudeLink: true });
try {
rmSync(join(fx.dir, 'CLAUDE.md'));
fx.commit('docs(voyage): review lessons (round 1)', { 'CLAUDE.md': `AGENTS.md\n\n${POINTER}` });
assertStale(fx.gate(), /CLAUDE\.md is a symlink/);
} finally { fx.cleanup(); }
});
test('verdict-gate --repo — a BLOCK review of another commit stays exit 1', () => {
const fx = gateRepo();
try {
fx.commit('fix: more work', { 'a.mjs': 'export const a = 2;\n' });
const r = fx.gate({ verdict: 'BLOCK' });
assert.equal(r.status, 1, r.stdout + r.stderr);
} finally { fx.cleanup(); }
});
test('verdict-gate — without --repo a review of another commit is decided on its text alone (unchanged)', () => {
const fx = gateRepo();
try {
fx.commit('fix: more work', { 'a.mjs': 'export const a = 2;\n' });
const p = join(fx.base, 'review.md');
writeFileSync(p, review({ verdict: 'ALLOW', severities: ['MINOR'], scopeShaEnd: fx.reviewed }));
const r = spawnSync(process.execPath, [GATE, '--json', p], { encoding: 'utf-8', env: childEnv() });
assert.equal(r.status, 0, r.stdout + r.stderr);
} finally { fx.cleanup(); }
});
test('verdict-gate — --repo with no value → exit 2', () => {
const dir = mkdtempSync(join(tmpdir(), 'verdict-gate-'));
try {
const p = join(dir, 'review.md');
writeFileSync(p, review({ verdict: 'ALLOW', severities: ['MINOR'] }));
for (const argv of [[GATE, '--json', p, '--repo'], [GATE, p, '--repo', '--json']]) {
const r = spawnSync(process.execPath, argv, { encoding: 'utf-8', env: childEnv() });
assert.equal(r.status, 2, `${argv.slice(1).join(' ')}: ${r.stdout}${r.stderr}`);
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('verdict-gate --repo — an all-digit scope_sha_end is kept as text, never read as a number', () => {
const fx = gateRepo();
try {
const digits = '0123456789012345678901234567890123456789';
const r = fx.gate({ sha: digits });
assert.equal(r.status, 2, r.stdout + r.stderr);
assert.ok(r.stdout.includes(digits), `the reason names the sha as written: ${r.stdout}`);
} finally { fx.cleanup(); }
});
test('verdict-gate --repo — a scope_sha_end like --output=<file> never reaches git', () => {
const fx = gateRepo();
try {
const leak = join(fx.base, 'leak');
const r = fx.gate({ sha: `--output=${leak}` });
assert.equal(r.status, 2, r.stdout + r.stderr);
assert.match(r.stdout, /no scope_sha_end that is a commit sha/);
assert.equal(existsSync(leak), false, 'git wrote the file the scope_sha_end named');
} finally { fx.cleanup(); }
});

View file

@ -9,7 +9,7 @@ import { strict as assert } from 'node:assert';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { illegalBriefLowering, severityRank as rank } from '../../lib/review/coordinator-contract.mjs';
import { dedupByTriplet, illegalBriefLowering, severityRank as rank } from '../../lib/review/coordinator-contract.mjs';
import { ENGINES } from '../../lib/review/engine-findings.mjs';
import { RULE_CATALOGUE } from '../../lib/review/rule-catalogue.mjs';
@ -75,6 +75,21 @@ test('armB — a coordinator ALLOW over a BLOCKER finding is recomputed to BLOCK
assert.equal(r.verdict, 'BLOCK');
});
test('[#111] sibling: the Arm B coordinator prompt keeps, never drops, a finding whose rule_key is not in the catalogue', async () => {
const f = { severity: 'MAJOR', rule_key: 'MISSING_TEST', file: 'a.mjs', line: 1, title: 't', detail: 'd' };
const s = stubs({
reviewers: [{ reviewer: 'c', findings: [f] }, { reviewer: 'k', findings: [] }],
coordinator: { verdict: 'WARN', findings: [f] },
});
await exec({ ...BASE, enginePayloads: [engine('claude-code-review'), engine('claude-security-review')] }, s);
const prompt = (s.calls.coordinatorPrompt ?? '').replace(/\s+/g, ' ');
assert.ok(prompt.includes('keep, never drop, a finding whose rule_key is not in the catalogue'),
'the coordinator prompt does not keep a finding whose rule_key is not in the catalogue');
assert.ok(prompt.includes('drop a finding with no file:line citation or whose file is in neither the tree nor the diff'),
'the coordinator prompt does not name what Pass 3 drops');
assert.ok(!prompt.includes('catalogue-violating'), 'the coordinator prompt still drops catalogue-violating findings');
});
test('armB — quick mode expects only the /code-review engine', async () => {
const s = stubs({ reviewers: [{ reviewer: 'k', findings: [] }] });
const r = await exec({ ...BASE, quick: true, enginePayloads: [engine('claude-code-review')] }, s);
@ -483,10 +498,11 @@ test('armB brief rule (15) — a tie between a reviewer MAJOR and a /code-review
});
// ---- Decision 23 (operator 13.10, review ab1b5d9f): no engine finding leaves ----
// the workflow review unreturned. Every engine finding of the input (before
// dedup) with no returned finding at its exact (file, line, rule_key) is put
// back at its own severity; a returned finding is never below the lower of its
// reviewer counterpart's severity and its catalogue tier.
// the workflow review unreturned. Each engine finding in a row (its triplet
// (file, line, rule_key) plus its normalized title) that no returned finding
// carries is put back at its own severity, one copy per row; a returned finding
// is never below the lower of its reviewer counterpart's severity and its
// catalogue tier.
const at = (findings, file, line, rule_key) =>
findings.filter((f) => f && f.file === file && f.line === line && f.rule_key === rule_key);
@ -559,7 +575,7 @@ test('decision 23 — a /code-review MAJOR dropped → WARN', async () => {
});
for (const order of ['MAJOR first', 'BLOCKER first']) {
test(`decision 23 — two /code-review findings on one triplet (${order}), returned empty → BLOCK, one copy, at BLOCKER`, async () => {
test(`decision 23 — two /code-review findings on one triplet (${order}), returned empty → BLOCK, both rows put back, the BLOCKER row present`, async () => {
const pair = [F('CODE_REVIEW_DEFECT', 'MAJOR', { title: 'major' }), F('CODE_REVIEW_DEFECT', 'BLOCKER', { title: 'blocker' })];
const c = await floorCase({
codeFindings: order === 'MAJOR first' ? pair : [pair[1], pair[0]],
@ -568,10 +584,14 @@ for (const order of ['MAJOR first', 'BLOCKER first']) {
});
assert.equal(c.r.verdict, 'BLOCK');
const back = at(c.r.findings, FILE, LINE, 'CODE_REVIEW_DEFECT');
assert.equal(back.length, 1, 'exactly one finding at the triplet');
assert.equal(back[0].severity, 'BLOCKER', 'the higher-severity copy is put back');
assert.equal(back[0].title, 'blocker');
assert.equal(back[0].engine, 'code-review');
assert.equal(back.length, 2, 'one source under two titles is two rows, and each is put back');
const got = back
.map((f) => ({ title: f.title, severity: f.severity, engine: f.engine }))
.sort((x, y) => x.title.localeCompare(y.title));
assert.deepEqual(got, [
{ title: 'blocker', severity: 'BLOCKER', engine: 'code-review' },
{ title: 'major', severity: 'MAJOR', engine: 'code-review' },
], 'each row is put back at its own severity');
});
}
@ -637,3 +657,273 @@ test('armB — two different /code-review findings on one triplet stay two rows
});
assert.deepEqual(c.merged.map((f) => f.title).sort(), ['A lone CR does not split lines', 'EAGAIN on stdin']);
});
// ---- ORD sibling: the workflow's inlined dedup does not depend on arrival order ----
test('[ORD] sibling: workflow — conformance B with /code-review A and B, in both engine orders, gives one row of A alone and one row of both B findings', async () => {
const K = 'CODE_REVIEW_DEFECT';
const engineA = F(K, 'MINOR', { title: 'A' });
const engineB = F(K, 'MAJOR', { title: 'B' });
for (const codeFindings of [[engineA, engineB], [engineB, engineA]]) {
const name = codeFindings.map((f) => f.title).join(', ');
const c = await floorCase({
reviewerFindings: [F(K, 'MINOR', { title: 'B' })],
codeFindings,
returned: [],
verdict: 'WARN',
});
const rows = c.merged.filter((m) => triplet(m) === triplet(F(K, 'MINOR')));
assert.equal(rows.length, 2, `engine order ${name}: ${JSON.stringify(rows)}`);
const got = rows
.map((m) => ({ title: m.title, engine: m.engine ?? null, severity: m.severity }))
.sort((x, y) => x.title.localeCompare(y.title));
// A alone: the /code-review A finding survives with its own MINOR. Both B
// findings: the conformance B finding survives (a reviewer over an engine)
// at the MAJOR the /code-review B finding asserted.
assert.deepEqual(got, [
{ title: 'A', engine: 'code-review', severity: 'MINOR' },
{ title: 'B', engine: null, severity: 'MAJOR' },
], `engine order ${name}`);
}
});
test('[ORD] sibling: workflow — the title classes are placed in alphabetical order: conformance B with /code-review A, B and an untitled BLOCKER gives {A, untitled} BLOCKER kept as A and {B, B} MAJOR in all 6 engine orders', async () => {
const K = 'CODE_REVIEW_DEFECT';
const engineA = F(K, 'BLOCKER', { title: 'A' });
const engineB = F(K, 'MINOR', { title: 'B' });
const { title: _t, ...engineU } = F(K, 'BLOCKER');
// The workflow reads the reviewer findings first, so class B always arrives
// first: only the alphabetical order puts the untitled finding in A's row.
// A and the untitled finding tie on every survivor rule, so the first in the
// group's order (class A before the untitled findings) is kept.
const engineOrders = [
[engineA, engineB, engineU], [engineA, engineU, engineB], [engineB, engineA, engineU],
[engineB, engineU, engineA], [engineU, engineA, engineB], [engineU, engineB, engineA],
];
for (const codeFindings of engineOrders) {
const name = codeFindings.map((f) => f.title ?? 'U').join(', ');
const c = await floorCase({
reviewerFindings: [F(K, 'MAJOR', { title: 'B' })],
codeFindings,
returned: [],
verdict: 'WARN',
});
const rows = c.merged.filter((m) => triplet(m) === triplet(F(K, 'MINOR')));
const got = rows
.map((m) => ({ title: m.title ?? null, engine: m.engine ?? null, severity: m.severity }))
.sort((x, y) => String(x.title).localeCompare(String(y.title)));
assert.deepEqual(got, [
{ title: 'A', engine: 'code-review', severity: 'BLOCKER' },
{ title: 'B', engine: null, severity: 'MAJOR' },
], `engine order ${name}`);
}
});
// ---- Parity: the workflow's inlined dedup groups as coordinator-contract Pass 1 does ----
//
// The script cannot import, so its groupTriplet is a hand-kept copy of
// lib/review/coordinator-contract.mjs's. Both run on the ORD fixtures of this
// file and of tests/lib/coordinator-contract.test.mjs, plus the WF floor
// fixture below (the one where two titles from two sources share a row), in
// every arrival order within a payload, and must give the same rows in the
// same order. Each finding
// carries its workflow source as `reviewer` (what the contract groups and
// orders by) and, from /code-review, `engine` last (where the workflow puts
// it), so both read the same objects. A row is compared by its survivor and its
// group's severity.
/** Every order of `xs`. */
function permutations(xs) {
if (xs.length <= 1) return [xs];
return xs.flatMap((x, i) => permutations([...xs.slice(0, i), ...xs.slice(i + 1)]).map((rest) => [x, ...rest]));
}
const SRC_F = 'reviewer:brief-conformance';
const SRC_C = 'reviewer:code-correctness';
const SRC_E = 'engine:claude-code-review';
const from = (src, f) => ({ ...f, reviewer: src, ...(src === SRC_E ? { engine: 'code-review' } : {}) });
const rowSig = (r) => JSON.stringify([r.title ?? null, r.severity, r.engine ?? null, r.contradicts_brief ?? null, r.recommended_action ?? null]);
function parityFixtures() {
const out = [];
const add = (name, { f = [], c = [], e = [] }) => out.push({
name, f: f.map((x) => from(SRC_F, x)), c: c.map((x) => from(SRC_C, x)), e: e.map((x) => from(SRC_E, x)),
});
// tests/lib/coordinator-contract.test.mjs
const slug = { file: 'bin/slug.mjs', line: 46, detail: 'd' };
for (const [rule_key, a, b, cb] of [['MISSING_TEST', 'MAJOR', 'MAJOR', 'MAJOR'], ['NOT_IN_THE_CATALOGUE', 'MINOR', 'MAJOR', 'MINOR']]) {
add(`cA ${a}, fB ${b}, cB ${cb} (${rule_key})`, {
f: [{ ...slug, rule_key, severity: b, title: 'B' }],
c: [{ ...slug, rule_key, severity: a, title: 'A' }, { ...slug, rule_key, severity: cb, title: 'B' }],
});
}
const off = { ...slug, rule_key: 'NOT_IN_THE_CATALOGUE' };
add('cA, fB, cB and an untitled correctness BLOCKER', {
f: [{ ...off, severity: 'MAJOR', title: 'B' }],
c: [{ ...off, severity: 'BLOCKER', title: 'A', recommended_action: '' }, { ...off, severity: 'MINOR', title: 'B' }, { ...off, severity: 'BLOCKER' }],
});
const inj = { file: 'x.mjs', line: 3, rule_key: 'SECURITY_INJECTION', severity: 'BLOCKER' };
add('one reviewer\'s two same-title BLOCKERs', {
c: [{ ...inj, title: 'Injection', recommended_action: '' }, { ...inj, title: 'injection ', recommended_action: 'Quote the argument.' }],
});
const t = { file: 'x.mjs', line: 3, rule_key: 'CODE_REVIEW_DEFECT', severity: 'MAJOR', title: 'T' };
add('two /code-review MAJORs titled T, one marked', { e: [{ ...t, contradicts_brief: MARK }, { ...t }] });
// this file
const K = 'CODE_REVIEW_DEFECT';
add('conformance B with /code-review A and B', {
f: [F(K, 'MINOR', { title: 'B' })], e: [F(K, 'MINOR', { title: 'A' }), F(K, 'MAJOR', { title: 'B' })],
});
add('a conformance R next to /code-review E1 and E2 (WF)', {
f: [F(K, 'MAJOR', { title: 'R' })], e: [F(K, 'MINOR', { title: 'E1' }), F(K, 'MINOR', { title: 'E2' })],
});
const { title: _t, ...untitled } = F(K, 'BLOCKER');
add('conformance B with /code-review A, B and an untitled BLOCKER', {
f: [F(K, 'MAJOR', { title: 'B' })], e: [F(K, 'BLOCKER', { title: 'A' }), F(K, 'MINOR', { title: 'B' }), untitled],
});
return out;
}
test('parity: the workflow dedup and coordinator-contract dedupByTriplet give the same rows on the ORD fixtures, in every arrival order within a payload', async () => {
let runs = 0;
for (const fx of parityFixtures()) {
for (const f of permutations(fx.f)) for (const c of permutations(fx.c)) for (const e of permutations(fx.e)) {
const name = `${fx.name}, order ${[...f, ...c, ...e].map((x) => `${x.reviewer}/${x.title ?? 'U'}`).join(', ')}`;
const s = stubs({
reviewers: [{ reviewer: 'brief-conformance', findings: structuredClone(f) }, { reviewer: 'code-correctness', findings: structuredClone(c) }],
coordinator: { verdict: 'WARN', findings: [] },
});
await exec({ ...BASE, enginePayloads: BOTH_ENGINES(structuredClone(e)) }, s);
const prompt = s.calls.coordinatorPrompt;
assert.ok(typeof prompt === 'string', `${name}: the coordinator ran`);
const merged = JSON.parse(prompt.slice(prompt.indexOf(PROMPT_JSON_MARK) + PROMPT_JSON_MARK.length));
assert.deepEqual(merged.map(rowSig), dedupByTriplet(structuredClone([...f, ...c, ...e])).map(rowSig), name);
runs++;
}
}
assert.equal(runs, 24, 'control: every fixture ran in each of its arrival orders');
});
// ---- WF: the floor and the put-back match each returned finding to its own row ----
//
// A row is a merged finding; its key is its triplet plus its normalized title.
// A returned finding is floored against the row with its own key; with no title
// match, against the only row on its triplet, else the strictest one there,
// never a lower one. Every row whose key was not returned has its engine
// findings put back at their own severity.
test('[WF] two /code-review MAJORs A and B on one triplet: A returned legally lowered with ALLOW → WARN, B put back at MAJOR', async () => {
const K = 'CODE_REVIEW_DEFECT';
const c = await floorCase({
codeFindings: [F(K, 'MAJOR', { title: 'A' }), F(K, 'MAJOR', { title: 'B' })],
returned: [F(K, 'MINOR', { title: 'A', contradicts_brief: MARK, brief_lowered_from: 'MAJOR' })],
verdict: 'ALLOW',
});
assert.equal(c.merged.length, 2, 'one source under two titles is two rows');
assert.equal(c.r.verdict, 'WARN');
assert.equal(c.r.findings[0].severity, 'MINOR', 'the legal lowering of A stands');
assert.equal(c.r.findings[0].engine, 'code-review');
const b = at(c.r.findings, FILE, LINE, K).filter((f) => f.title === 'B');
assert.equal(b.length, 1, 'B is put back');
assert.equal(b[0].severity, 'MAJOR');
assert.equal(b[0].engine, 'code-review');
});
test('[WF] two /code-review MAJORs A and B on one triplet: A returned at MAJOR with WARN → B present at MAJOR', async () => {
const K = 'CODE_REVIEW_DEFECT';
const c = await floorCase({
codeFindings: [F(K, 'MAJOR', { title: 'A' }), F(K, 'MAJOR', { title: 'B' })],
returned: [F(K, 'MAJOR', { title: 'A' })],
verdict: 'WARN',
});
assert.equal(c.r.verdict, 'WARN');
const titles = at(c.r.findings, FILE, LINE, K).map((f) => `${f.title}:${f.severity}`).sort();
assert.deepEqual(titles, ['A:MAJOR', 'B:MAJOR'], 'B is present beside the returned A');
});
test('[WF] a reviewer MAJOR returned as MINOR next to engine rows is floored against its own row and keeps engine unset', async () => {
const K = 'CODE_REVIEW_DEFECT';
const c = await floorCase({
reviewerFindings: [F(K, 'MAJOR', { title: 'R' })],
codeFindings: [F(K, 'MINOR', { title: 'E1' }), F(K, 'MINOR', { title: 'E2' })],
returned: [F(K, 'MINOR', { title: 'R' })],
verdict: 'ALLOW',
});
// Rows: {E1, R} kept as the reviewer's R at MAJOR, and {E2}, an engine row at MINOR.
const rows = c.merged.map((m) => `${m.title}:${m.severity}:${m.engine ?? 'none'}`).sort();
assert.deepEqual(rows, ['E2:MINOR:code-review', 'R:MAJOR:none']);
assert.equal(c.r.findings[0].title, 'R');
assert.equal(c.r.findings[0].severity, 'MAJOR', 'floored against its own row, not the engine row E2');
assert.equal(c.r.findings[0].engine, undefined, 'engine comes from its own row only');
assert.equal(c.r.verdict, 'WARN');
const e2 = at(c.r.findings, FILE, LINE, K).filter((f) => f.title === 'E2');
assert.equal(e2.length, 1, 'the engine row E2 was not returned and is put back');
assert.equal(e2[0].severity, 'MINOR');
});
test('[WF] a returned finding whose title matches no row is floored against the strictest row on its triplet, never a lower one', async () => {
const K = 'CODE_REVIEW_DEFECT';
const blocker = () => F(K, 'BLOCKER', { title: 'B' });
const minor = () => F(K, 'MINOR', { title: 'A' });
// Both input orders: the strictest row is first in one and last in the other.
for (const [name, codeFindings] of [['BLOCKER first', [blocker(), minor()]], ['BLOCKER last', [minor(), blocker()]]]) {
const c = await floorCase({
codeFindings,
returned: [F(K, 'MINOR', { title: 'renamed' })],
verdict: 'ALLOW',
});
assert.equal(c.r.findings[0].title, 'renamed', name);
assert.equal(c.r.findings[0].severity, 'BLOCKER', `the strictest row on the triplet, not the first or last one (${name})`);
assert.equal(c.r.verdict, 'BLOCK', name);
}
});
// Sample titles, each pair with whether the title rule calls them the same.
const TITLE_PAIRS = [
['Foo', 'Foo', true],
['Foo', 'foo', true],
[' Foo bar ', 'foo BAR', true],
['Foo\t', 'FOO', true],
['Foo', 'Bar', false],
['foo bar', 'foo bar', false],
['foo-bar', 'foo bar', false],
];
test('[WF] the row key reads titles as sameTitle does: on sample titles a returned finding matches a row exactly where the grouping calls the titles the same', async () => {
const K = 'CODE_REVIEW_DEFECT';
for (const [a, b, same] of TITLE_PAIRS) {
const name = `${JSON.stringify(a)} / ${JSON.stringify(b)}`;
// sameTitle in the grouping: one source under titles a and b is one row exactly when they are the same.
const g = await floorCase({
codeFindings: [F(K, 'MAJOR', { title: a }), F(K, 'MAJOR', { title: b })],
returned: [F(K, 'MAJOR', { title: a })],
verdict: 'WARN',
});
assert.equal(g.merged.length, same ? 1 : 2, `grouping, ${name}`);
// The row key: a row titled a, returned titled b, is matched (nothing put back) exactly when they are the same.
const k = await floorCase({
codeFindings: [F(K, 'MAJOR', { title: a })],
returned: [F(K, 'MAJOR', { title: b })],
verdict: 'WARN',
});
assert.equal(at(k.r.findings, FILE, LINE, K).length, same ? 1 : 2, `row key, ${name}`);
}
});
// ---- Architecture rule: the workflow script imports nothing (it runs inside the Workflow tool) ----
// Every module specifier a source text imports, statically or dynamically.
function importSpecifiers(text) {
const found = [];
for (const m of text.matchAll(/^\s*import\s*(?:[\w*{][^;]*?\sfrom\s*)?['"]([^'"]+)['"]/gm)) found.push(m[1]);
for (const m of text.matchAll(/\bimport\s*\(\s*['"`]([^'"`]+)['"`]/g)) found.push(m[1]);
return found;
}
test('arch: scripts/trekreview-armB.workflow.mjs holds no import statement, and the scan reports a planted import (control)', () => {
const raw = readFileSync(join(ROOT, 'scripts', 'trekreview-armB.workflow.mjs'), 'utf-8');
assert.deepEqual(importSpecifiers(raw), []);
const planted = importSpecifiers(`${raw}\nimport { sameTitle } from '../lib/review/coordinator-contract.mjs';\n`);
assert.deepEqual(planted, ['../lib/review/coordinator-contract.mjs'], 'control: the scan does not report a planted import');
const dynamic = importSpecifiers(`${raw}\nconst m = await import('node:fs');\n`);
assert.deepEqual(dynamic, ['node:fs'], 'control: the scan does not report a planted dynamic import');
});

View file

@ -8,7 +8,8 @@ import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { execFileSync, spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import {
validateNextSessionPromptContent,
@ -133,3 +134,73 @@ test('CLI shim — consistency mode flags producer mismatch', () => {
rmSync(dir, { recursive: true, force: true });
}
});
// --- #102: the consistency call reads the state file -----------------------
// The CLI counted the --state-file value as a third positional and exited 2,
// so the state-anchored staleness check never ran from the command line; and
// an invalid B still gave valid: true. These tests use spawnSync so they reach
// an assertion on exit 2 too.
const CLI = fileURLToPath(new URL('../../lib/validators/next-session-prompt-validator.mjs', import.meta.url));
function runCli(args) {
return spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf-8' });
}
/** A stale master (older than the state's updated_at) and a fresh sibling by another producer. */
function stalePair(dir) {
const a = join(dir, 'a.md');
const b = join(dir, 'b.md');
const s = join(dir, '.session-state.local.json');
writeFileSync(a, frontmatter('graceful-handoff', '2026-05-03T10:00:00.000Z'));
writeFileSync(b, frontmatter('trekexecute', '2026-05-04T16:05:00.000Z'));
writeFileSync(s, JSON.stringify({ schema_version: 1, updated_at: '2026-05-04T16:00:00.000Z' }));
return { a, b, s };
}
test('[#102] CLI --json --consistency a b --state-file s: the state-anchored case exits 0 with a STALE_IGNORED warning', () => {
const dir = mkdtempSync(join(tmpdir(), 'nspv-cli-'));
try {
const { a, b, s } = stalePair(dir);
const r = runCli(['--json', '--consistency', a, b, '--state-file', s]);
assert.equal(r.status, 0, `exit ${r.status}: ${r.stdout}${r.stderr}`);
const parsed = JSON.parse(r.stdout);
assert.equal(parsed.valid, true, JSON.stringify(parsed.errors));
assert.ok(parsed.warnings.find((w) => w.code === 'NEXT_SESSION_PROMPT_STALE_IGNORED'),
'the stale candidate was not ignored');
assert.equal(parsed.errors.find((e) => e.code === 'NEXT_SESSION_PROMPT_PRODUCER_MISMATCH'), undefined);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('control: the same pair without --state-file is a producer mismatch (exit 1)', () => {
const dir = mkdtempSync(join(tmpdir(), 'nspv-cli-'));
try {
const { a, b } = stalePair(dir);
const r = runCli(['--json', '--consistency', a, b]);
assert.equal(r.status, 1, `exit ${r.status}: ${r.stdout}${r.stderr}`);
const parsed = JSON.parse(r.stdout);
assert.equal(parsed.valid, false);
assert.ok(parsed.errors.find((e) => e.code === 'NEXT_SESSION_PROMPT_PRODUCER_MISMATCH'));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('[#102] sibling: rb.valid counts: an invalid B makes the consistency result invalid (exit 1)', () => {
const dir = mkdtempSync(join(tmpdir(), 'nspv-cli-'));
try {
const a = join(dir, 'a.md');
const b = join(dir, 'b.md');
writeFileSync(a, frontmatter('trekexecute', '2026-05-04T16:00:00.000Z'));
writeFileSync(b, '---\nproduced_by: trekexecute\n---\n\n# B, no produced_at\n');
const r = runCli(['--json', '--consistency', a, b]);
assert.equal(r.status, 1, `an invalid B still passed: exit ${r.status}: ${r.stdout}${r.stderr}`);
const parsed = JSON.parse(r.stdout);
assert.equal(parsed.valid, false);
assert.ok(parsed.errors.find((e) => e.code === 'NEXT_SESSION_PROMPT_MISSING_FIELD' && /produced_at/.test(e.message)));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});