feat(trekexecute): every MAJOR answered before merge, fixed runs the fix loop

This commit is contained in:
Kjell Tore Guttormsen 2026-11-02 18:44:42 +01:00
commit 9e7e8d1516
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
15 changed files with 376 additions and 30 deletions

View file

@ -34,7 +34,7 @@ Voyage — a contract-driven Claude Code pipeline: brief, research, plan, execut
| `/trekresearch` | Research — deep local + external research, produces structured research brief. Opt-in `--engine {swarm\|deep-research}` delegates the external phase to Claude Code's built-in `/deep-research` workflow (swarm default). The external agents preload the `researching-sources` skill and are told it by name at spawn | session |
| `/trekplan` | Plan — brief-reviewer, explore, plan, review. Requires `--brief` or `--project`; halts unless the brief's intent is approved (hash-bound, stale on edit). Auto-discovers `architecture/overview.md` if present. Plan 1.9: the file first on every line — every `Files:` entry and `Test first:` `File:` line opens with its backticked path(s). From 1.8: `Test first:` per step + one `End-to-end:` step; every check written by `voyage:writing-criteria` (named where the plan is written and where its three reviewers start); a design part for `ceremony: architectural` (absent = architectural); critic loop of max 3 rounds, recorded in the plan, rounds 2+ in diff mode (rule A, counted by the gate; rule B in the reviewers), and `plan-gate.mjs` (0 go / 1 stop / 2 could not run) decides; open minors after it are listed for the operator to pick | session |
| `/trekexecute` | Execute — disciplined plan/session-spec executor: refuses a plan whose logged approval does not cover its current sha256 (K2); one fresh `step-implementer` helper per plan step, one controller that reads the diff (spec + quality verdicts), proof of red / floor guard / step check (K9) / `Regression:` (K10) / done line as commands, fix rounds that go up (max 5: helper → plan → operator), architecture rules (K5) + plan-file sweep (K6) after the last step, the app seen with Claude Code's built-in `run` while the `End-to-end:` command stays the verdict (`run` gives no exit code), then its own review (`/simplify` → `/trekreview` → verdict gate, K7; BLOCK keeps the merge into main closed), closed stop list + decision log in the final report | session |
| `/trekreview` | Review — a gate: Claude Code's `/code-review` + `/security-review` as engine, then Voyage's brief control (which stops); writes `review.md` with a required verdict that `lib/review/verdict-gate.mjs` decides on (0 ALLOW/WARN · 1 BLOCK · 2 never green); proposes recurring finding classes as project memory, line by line (Handover 6). The reviewers and coordinator preload `voyage:reviewing-adversarially` and are told it by name at spawn; each reviewer's Declined to Judge lines reach `review.md`; the operator's notes from the annotation page go through `voyage:annotating` | session |
| `/trekreview` | Review — a gate: Claude Code's `/code-review` + `/security-review` as engine, then Voyage's brief control (which stops); writes `review.md` with a required verdict that `lib/review/verdict-gate.mjs` decides on (0 ALLOW, or WARN with every counting MAJOR answered · 1 BLOCK · 2 never green); the operator answers each counting MAJOR (Phase 8.8; `--answers` runs only that, no new round); proposes recurring finding classes as project memory, line by line (Handover 6). The reviewers and coordinator preload `voyage:reviewing-adversarially` and are told it by name at spawn; each reviewer's Declined to Judge lines reach `review.md`; the operator's notes from the annotation page go through `voyage:annotating` | session |
| `/trekcontinue` | Continue — resumes the next session of a multi-session voyage project. Reads `.session-state.local.json` (Handover 7) and immediately begins executing | session |
| `/trekendsession` | End-session — mark the current session complete and write session-state pointing at the next session. Helper for informal multi-session flows | session |

View file

@ -370,7 +370,7 @@ After the last step, and with nobody typing anything, the executor runs Cherny's
| Verdict gate | Merge into main |
|---|---|
| 0 — ALLOW / WARN | open: the executor runs the merge, and the guard asks you before it runs |
| 0 — ALLOW, or WARN with every counting MAJOR answered | open: the executor runs the merge, and the guard asks you before it runs |
| 1 — BLOCK | closed. The review's BLOCKERs are added to the plan as new numbered steps, you approve the plan again, and the loop runs: build, check, review — at most 3 reviews, then it is yours |
| 2 — could not decide | closed: a review that did not run in full is never green |
@ -509,7 +509,7 @@ them and is the part that stops. The verdict is written in `review.md`
```bash
node lib/review/verdict-gate.mjs <dir>/review.md
# 0 = ALLOW or WARN · 1 = BLOCK · 2 = could not decide (never green)
# 0 = ALLOW, or WARN with every counting MAJOR answered · 1 = BLOCK · 2 = could not decide (never green)
```
A WARN that exists only because a check did not run is a 2, not a 0: a

View file

@ -274,6 +274,9 @@ Executive Summary's first sentence — the same value in all three.
never green) a file whose JSON block or BLOCKER findings contradict it, and
a WARN whose `allow_blocked_by` names a reviewer or engine that did not run.
Always write `allow_blocked_by` (an empty list when nothing withheld ALLOW).
A WARN still means "a MAJOR is open": write it whenever a MAJOR survives, because
the verdict gate, not the coordinator, decides whether it was answered (the
operator's answers come after this review, in `/trekreview` Phase 8.8).
## Output: review.md content

View file

@ -240,7 +240,7 @@ Then decide on the verdict with the gate (K7):
```
node ${CLAUDE_PLUGIN_ROOT}/lib/review/verdict-gate.mjs {project_dir}/review.md
```
0 = ALLOW/WARN, 1 = BLOCK, 2 = could not decide (never green).
0 = ALLOW, or WARN with every counting MAJOR answered, 1 = BLOCK, 2 = could not decide (never green).
Append a stats line to `${CLAUDE_PLUGIN_DATA}/trekreview-stats.jsonl`:
```json

View file

@ -2286,11 +2286,16 @@ never typed by the operator, in this order (Cherny's):
`--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
commit), or only append the review answer records (K2 `review` records,
`/trekreview` Phase 8.8) to the top-level approval log. 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.
Exit 2 with a reason that starts `review-unanswered: fixed-pending` enters
the loop below, as a BLOCK does. The loop's R-steps are then the BLOCKER
steps plus each MAJOR answered "fixed". Any other `review-unanswered:` goes
to Phase 7.5 with the merge closed.
**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`
@ -2332,8 +2337,9 @@ the run is `stopped`, and the merge stays closed.
steps ({first error code}) and that the way on is a re-plan from the review,
`/trekplan --brief {project_dir}/review.md`.
4. The plan changed, so its approval is stale (Phase 1). Ask for it again in
one question, `AskUserQuestion`: **"The review blocked: {N} BLOCKER(s),
added as steps {list}. Approve the plan as it is now?"** Options:
one question, `AskUserQuestion`: **"The review asked for fixes: {N}
BLOCKER(s) and {M} MAJOR(s) answered "fixed", added as steps {list}.
Approve the plan as it is now?"** Options:
**Approve** / **Not yet**. Check the answer with
`approval-log.mjs answer` (stdin) exactly as `/trekplan` Phase 11 does;
exit 0 → `approval-log.mjs append --kind plan --subject "{plan_path}" --sha256-of "{plan_path}"`
@ -2353,8 +2359,10 @@ reviews per run (the first and two fix loops), read with
`node ${CLAUDE_PLUGIN_ROOT}/lib/review/review-rounds.mjs --cap`; `/trekreview`
reads the same constant and refuses a fourth round.
A review at the cap with BLOCKERs or MAJORs answered "fixed" still open goes to the operator: stop, and the report names every one.
Each loop also takes 1 from `iterations_remaining`, which
bounds it a second time (the completion gate's clause 3 counts it).
Each loop, WARN or BLOCK, takes 1 from `iterations_remaining`, which bounds
it a second time (the completion gate's clause 3 counts it), and the next
`/trekreview` is a new round counted against the cap, not a new remediation
round (decision 101).
Borrowed: the order (run the thing, simplify, review) is Cherny's; a verdict in
a file that a command gates on, and the bounded fix-and-review loop, are
@ -2383,7 +2391,8 @@ produces an independent truth based on objective state, not self-narrative.
number of steps with `status=completed` (excludes Step 0 if sandbox_preflight)
+ the run's own process commits, and only these: the simplify commit
(`refactor: simplify pass (trekexecute)`, when `simplify = "applied"`), each
review-lessons commit (`docs(voyage): review lessons (round R)`) and each
review-lessons commit (`docs(voyage): review lessons (round R)`), each
review-answers commit (`docs(voyage): review answers (round {R})`) and each
plan approval the review loop committed (`docs(voyage): approve plan …`),
all from Phase 7.2. Each is named in the audit, never folded in silently.
(A remediation step's commit is a completed step like any other; the
@ -2615,7 +2624,7 @@ exit 2 without the call.
| Exit | Verdict | Merge |
|------|---------|-------|
| 0 | ALLOW / WARN | open, when the run's result is `completed` (Phase 7.5) |
| 0 | ALLOW / WARN (every counting MAJOR answered) | open, when the run's result is `completed` (Phase 7.5) |
| 1 | BLOCK | closed |
| 2 | not measured | closed: a review that could not decide is never green |
@ -2623,19 +2632,23 @@ exit 2 without the call.
Exit 2 with a reason that starts `review-stale` is closed with the decline
reason `review-stale` and the line "re-run /trekreview on this commit": the
review is of another commit than this one, or something other than the
review lessons and the `CLAUDE.md` pointer changed after it. Any other exit 2
is `review-not-measured`. Exit 2 with a reason that starts `review-not-measured:`
review lessons and the `CLAUDE.md` pointer changed after it. Exit 2 with a
reason that starts `review-unanswered:` is closed with the decline reason
`review-unanswered`: a MAJOR has no answer, or an answer "fixed" no later round
has confirmed. Any other exit 2 that does not start `review-stale` or
`review-unanswered:` is `review-not-measured`. Exit 2 with a reason that starts `review-not-measured:`
(the `--repo` path is missing, it is no git repository, or a git call failed)
is reported as "not measured", never green, and the merge stays locked with
the decline reason `review-not-measured`.
The merge runs only on exit 0 and a `completed` result. **Closed:** emit
`main-merge-declined` with the reason `review-block`, `review-stale`,
`review-not-measured` or `run-not-completed`. Nothing is finalized: a `completed` result becomes
`review-unanswered`, `review-not-measured` or `run-not-completed`. Nothing is finalized: a `completed` result becomes
`partial` (`failed` and `stopped` stay), and the commits stay where they are
for review. Say in one line why it is closed and what opens it: the
BLOCKERs fixed through Phase 7.2's loop, a review that runs in full, a review
of this commit, or a run that completes.
of this commit, an answer per MAJOR (`/trekreview --answers --project {project_dir}`,
no new round) or a review round that finds it fixed, 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:
@ -2739,7 +2752,7 @@ JSON
```
The reason is one of the literal words above (`review-block`,
`review-stale`, `review-not-measured`, `run-not-completed`, `no-main-branch`, `hold-for-review`,
`review-stale`, `review-unanswered`, `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.

View file

@ -4,7 +4,7 @@ description: |
Independent post-hoc review of delivered code against the brief. Produces
review.md with severity-tagged findings (BLOCKER/MAJOR/MINOR/SUGGESTION)
per Handover 6 (review → plan).
argument-hint: "--project <dir> [--since <ref>] [--quick] [--validate] [--dry-run]"
argument-hint: "--project <dir> [--since <ref>] [--quick] [--validate] [--dry-run] [--answers]"
allowed-tools: Agent, Read, Glob, Grep, Write, Edit, Bash, AskUserQuestion, Skill
---
@ -33,8 +33,9 @@ inference across files is forbidden in v1.0 (Judge Agent pattern).
Claude Code's own `/code-review` and `/security-review` are the engine
(Phase 4.7); Voyage's control against the brief runs after them and is the
part that stops (Phase 5–6); the verdict is written in `review.md` and one
command decides on it (`lib/review/verdict-gate.mjs`, Phase 8: 0 = ALLOW/WARN,
1 = BLOCK, 2 = could not decide, never green). No finding is struck for
command decides on it (`lib/review/verdict-gate.mjs`, Phase 8: 0 = ALLOW, or
WARN with every counting MAJOR answered, 1 = BLOCK, 2 = could not decide,
never green). No finding is struck for
length. A finding class seen in two reviews becomes one proposal for the
project's memory, approved line by line (Phase 8.7). Order credited to Boris
Cherny's public workflow (engine → brief control); the gate to Kiro and Spec
@ -69,6 +70,7 @@ FLAG_SCHEMA `trekreview` entry):
| `--validate` | boolean | Schema-only check on existing `{project_dir}/review.md`. No LLM calls. |
| `--dry-run` | boolean | Print the discovered scope and triage map. Skip writes. |
| `--fg` | boolean | No-op alias (foreground is default). |
| `--answers` | boolean | Run only Phase 8.8 (the operator's answers to the counting MAJORs) on the existing `{project_dir}/review.md`, then the Phase 8 gate line. No `prepare`, no reviewer, no write of findings: it opens no new round and counts nothing against the cap (decision 93). |
| `--workflow` | boolean | **(opt-in, NW2)** Run Phase 5–6 on the bake-off-validated Workflow substrate (`scripts/trekreview-armB.workflow.mjs`) instead of the default prose Agent-tool path. Requires **Claude Code 2.1.154+**. Combines with `--quick`. See *§ Phase 5–6 via the Workflow substrate*. |
Resolution:
@ -87,6 +89,7 @@ Resolution:
Set `mode`:
- `validate` if `--validate` is set (overrides everything else; skip to Phase 8.5).
- `answers` if `--answers` is set: skip to Phase 8.8.
- `dry-run` if `--dry-run` is set.
- `quick` if `--quick` is set.
- `default` otherwise.
@ -733,14 +736,17 @@ If validation fails:
node ${CLAUDE_PLUGIN_ROOT}/lib/review/verdict-gate.mjs "{review_path}"
```
Exit 0 = ALLOW or WARN; exit 1 = BLOCK; exit 2 = the gate could not decide
Exit 0 = ALLOW, or WARN with every counting MAJOR answered; exit 1 = BLOCK;
exit 2 = the gate could not decide
(missing verdict, a verdict the file contradicts — in its JSON block, its
`## Findings (BLOCKER)` section or its finding-id list — a JSON block that is
not the last fence or has the wrong shape, two verdict lines, unreadable
file) — **never green**. A WARN that exists because a reviewer or engine
did not run (`missing-reviewer:` / `unattributable-payload` in
`allow_blocked_by`) is also exit 2: a review not performed in full stops a
merge (operator decision 2026-10-06). It takes exactly one file. On exit 0
merge (operator decision 2026-10-06). An exit 2 with a reason that starts
`review-unanswered:` before Phase 8.8 is expected: the operator's answers come
there. It takes exactly one file. On exit 0
with WARN, `--json` also returns `warn_reasons` (the JSON block's
`allow_blocked_by`); print them. Capture the exit code as `gate_exit` and print it in Phase 9 exactly
as returned. This is the command that later stops a merge or a release on
@ -849,6 +855,67 @@ the managed block (Spec Kit `agent-context`), evidence per proposal and
schema-check before write (Kiro), review → memory and the size budget
(Cherny). Credits in `lib/review/recurrence.mjs`.
## Phase 8.8 — Answers
**Skipped in `dry-run` and `validate`.** Every counting MAJOR needs the
operator's answer before a WARN opens the merge or the release; the verdict
gate decides that, not this command. List the open ones:
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/review/review-answers.mjs pending --review {project_dir}/review.md --json
```
- Exit 0: nothing to ask.
- Exit 2: say "answers not measured" in one line; the merge stays closed.
- Exit 1: it lists the open MAJORs (`id`, `title`, `detail`, `security`,
`fixed_pending`). Ask about each one with `AskUserQuestion`, at most four per
call and one finding per question, in plain words with no file or function
names. Each question gives the consequence of each option:
- **Fix it**: a plan step is written, and the merge stays closed until a
later review round no longer finds it.
- **Accept: <a reason in plain words>**: the merge can open with this
finding left as it is. Never offered for a finding `pending` marks
`security: true`. That finding gets one option, Fix it, and its question says a security finding cannot be accepted.
The recommendation is the session's honest judgement per finding. It is
never "accept" by default. The operator may type their own reason; a hollow
answer ("sounds good", "do as you like") is not a reason.
For each answer, a "fixed" first gets its `### Step R<n>` under
`## Remediation Steps`, in the form Phase 7 writes for a BLOCKER (Files,
Source finding, Test first, Verify, On failure). Then record it, with the
reason on stdin:
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/review/review-answers.mjs record --review "{project_dir}/review.md" --finding {id} --answer {accepted|fixed} --repo . --round {R} --store {artifact_store} --trailer "Co-Authored-By: Claude {model} <noreply@anthropic.com>"
```
`{R}` is this review's round (Phase 3's `{round}`), `{artifact_store}` the
brief's `artifact_store` (absent = `local`), and `{model}` this session's model
name. It appends a K2 `review` record, writes the `## Answers` row and commits
the log as `docs(voyage): review answers (round {R})`; show the operator the
stored reason it returns. On `"committed": false`, print its reason in one
line and continue: the answer is logged but not committed. On exit 1 (a
hollow or empty reason, a "fixed" without its R-step, an accept on a security
finding), say the refusal in one line and ask that finding again once. A
second refusal leaves it unanswered. On exit 2, say "answer not recorded" with
its reason in one line.
Then run the Phase 8 gate line again and report its result as `gate_exit`.
Run on its own, outside `/trekexecute`, "fixed" leaves the finding open until a
later `/trekreview` round marks it resolved.
**`--answers` mode.** `/trekreview --answers --project <dir>` runs only this
phase on the existing `{project_dir}/review.md`, and then the Phase 8 gate
line. It runs no `prepare`, no reviewer and no write of findings, so it opens
no new round and counts nothing against the cap (decision 93: accepting needs
no new review round). When `{project_dir}/review.md` is missing it stops with
one line: "no review to answer: {project_dir}/review.md is missing". In this
mode `--round` is the review's own `round` field (1 when absent), and
`--store` is the brief's `artifact_store` (absent = `local`), read from the
brief as Phase 1 finds it.
## Phase 9 — Present summary
After the write succeeds, print:
@ -864,7 +931,7 @@ After the write succeeds, print:
**Annotation HTML:** file://{$ANNOT_HTML}
**Scope:** {before_sha}..{after_sha} ({reviewed_files_count} files)
**Verdict:** {BLOCK | WARN | ALLOW}
**Gate:** verdict-gate exit {gate_exit} ({0 = ALLOW/WARN | 1 = BLOCK | 2 = could not decide, or a control did not run — not green})
**Gate:** verdict-gate exit {gate_exit} ({0 = ALLOW, or WARN with every counting MAJOR answered | 1 = BLOCK | 2 = could not decide, a control did not run, or a MAJOR without an answer or answered "fixed" and not yet confirmed — not green})
**Engine:** /code-review {delivered N | not run: reason} · /security-review {delivered N | not run: reason}
### Counts

View file

@ -9,7 +9,7 @@ Imported from `CLAUDE.md` via pointer.
- `lib/util/{frontmatter,result,atomic-write,autonomy-gate}.mjs` — shared YAML-frontmatter parser + Result helpers + `atomicWriteJson(path, obj)` for tmp+rename writes + autonomy-gate state machine (v3.4.0)
- `lib/parsers/{plan-schema,manifest-yaml,project-discovery,arg-parser,bash-normalize,jaccard,finding-id}.mjs` — pure parsers (no I/O), unit-tested. `manifest-yaml` extended in v3.4.0 with additive `skip_commit_check` + `memory_write` flags (forward-compat: unknown keys ignored)
- `lib/review/{rule-catalogue,plan-review-dedup}.mjs` — version-pinned rule catalogue (17 keys) + Phase 9 inline dedup helpers (v3.4.0)
- `lib/review/{verdict-gate,engine-findings,recurrence}.mjs` — the review gate (K7: 0 ALLOW/WARN · 1 BLOCK · 2 never green), Claude Code `/code-review` + `/security-review` findings → reviewer payload, recurring finding classes → operator-approved project-memory lines (review_version 1.2); `lib/review/fill-finding-ids.mjs` computes the coordinator's `@@ID_<n>@@` placeholders over `(file, line, rule_key)`; `lib/validators/brief-review-stamp.mjs` lets `/trekplan` skip a brief review `/trekbrief` already ran on the same text
- `lib/review/{verdict-gate,engine-findings,recurrence}.mjs` — the review gate (K7: 0 ALLOW, or WARN with every counting MAJOR answered · 1 BLOCK · 2 never green), Claude Code `/code-review` + `/security-review` findings → reviewer payload, recurring finding classes → operator-approved project-memory lines (review_version 1.2); `lib/review/fill-finding-ids.mjs` computes the coordinator's `@@ID_<n>@@` placeholders over `(file, line, rule_key)`; `lib/validators/brief-review-stamp.mjs` lets `/trekplan` skip a brief review `/trekbrief` already ran on the same text
- `lib/stats/event-emit.mjs` — single-source stats event emitter for autonomy-gate transitions and main-merge-gate (v3.4.0)
- `lib/validators/{brief,research,plan,progress,session-state}-validator.mjs` — schema validators with CLI shims (`node lib/validators/X.mjs --json <path>`)
- `lib/validators/architecture-discovery.mjs` — drift-WARN external-contract discovery for `architecture/overview.md`

View file

@ -89,10 +89,11 @@ Every mode that executes refuses a plan whose approval in the approval log does
| Flag | Behavior |
|------|----------|
| _(default)_ | Claude Code `/code-review` + `/security-review` (engine, via the Skill tool, never `--fix`), then brief-conformance + code-correctness reviewers in parallel, coordinator dedup + verdict, write `{project_dir}/review.md` with the required `verdict:`, decide with `lib/review/verdict-gate.mjs` (0 ALLOW/WARN · 1 BLOCK · 2 never green), propose recurring finding classes as project memory (operator approves each line) |
| _(default)_ | Claude Code `/code-review` + `/security-review` (engine, via the Skill tool, never `--fix`), then brief-conformance + code-correctness reviewers in parallel, coordinator dedup + verdict, write `{project_dir}/review.md` with the required `verdict:`, decide with `lib/review/verdict-gate.mjs` (0 ALLOW, or WARN with every counting MAJOR answered · 1 BLOCK · 2 never green), the operator answers each counting MAJOR (Phase 8.8), propose recurring finding classes as project memory (operator approves each line) |
| `--project <dir>` | **Required.** Path to trekplan project folder containing `brief.md`. Review is written to `{dir}/review.md` |
| `--since <ref>` | Override "before" SHA for the diff range. Validated via `git rev-parse --verify` |
| `--quick` | `/code-review` + code-correctness reviewer only; skip brief-conformance reviewer, `/security-review` and the coordinator's reasonableness filter except the brief-conflict rule — fast correctness-only pass |
| `--answers` | Only the operator's answers to the counting MAJORs (Phase 8.8) on the existing `{dir}/review.md`, then the verdict gate. No reviewer and no write of findings: it opens no new round and counts nothing against the cap |
| `--validate` | Schema-only check on existing `{dir}/review.md` plus the verdict gate. No LLM calls. A 1.0 review without `verdict:` fails here (it still seeds a plan in soft mode) |
| `--dry-run` | Print discovered scope + triage map; skip writes |
| `--fg` | No-op alias (foreground is default) |

View file

@ -66,7 +66,8 @@ do. `scripts/release-check.mjs` refuses a release whose section has no
node lib/review/verdict-gate.mjs <review.md>
```
0 = ALLOW or WARN, 1 = BLOCK, 2 = could not decide. Only 0 goes on.
0 = ALLOW, or WARN with every counting MAJOR answered, 1 = BLOCK, 2 = could
not decide. Only 0 goes on.
4. **The release commit.** It changes only the version and the release notes:
`.claude-plugin/plugin.json`, `package.json`, `package-lock.json` (both
`version` fields), the README version badge, and a new CHANGELOG section
@ -90,6 +91,17 @@ do. `scripts/release-check.mjs` refuses a release whose section has no
Voyage's own artifacts under `.claude/projects/` and `.claude/voyage/`, such
as the review itself and the approval log), and the door type is set. 0 = ready, 1 = not ready (each failure listed), 2 = could
not run — never green.
A review with an unanswered MAJOR reads NOT_READY (exit 1), also one
written before `review_version` 1.2, such as a review written by the
installed 6.5.0 plugin (this delivery's own review included). Answer each
counting MAJOR from this repo before the release check, the `/trekreview`
Phase 8.8 path: list them with
`node lib/review/review-answers.mjs pending --review <review.md> --repo .`,
then record each answer with `node lib/review/review-answers.mjs record`
(the reason on stdin; see its header for the flags). An answer "fixed"
needs its `### Step R<n>` in the review first, and stays open until a later
review round no longer finds the finding.
6. **The operator's yes, logged.** The operator reads the release section and
says yes (an answer like "sounds good" is not a yes). The check prints the
command that logs it (K2):

View file

@ -36,7 +36,7 @@ const FLAG_SCHEMA = {
aliases: {},
},
trekreview: {
boolean: ['--quick', '--fg', '--dry-run', '--validate', '--workflow'],
boolean: ['--quick', '--fg', '--dry-run', '--validate', '--workflow', '--answers'],
valued: ['--project', '--since', '--profile'],
aliases: {},
},
@ -203,7 +203,7 @@ export { FLAG_SCHEMA };
const REQUIRED = {
trekreview: {
flags: [['--project', '<dir>']],
usage: 'Usage: /trekreview --project <dir> [--since <ref>] [--quick] [--validate] [--dry-run]',
usage: 'Usage: /trekreview --project <dir> [--since <ref>] [--quick] [--validate] [--dry-run] [--answers]',
},
};

View file

@ -61,7 +61,8 @@
// exit 0 = recorded (a failed commit is reported as `committed: false`
// with its reason), 1 = refused (bad trailer, empty or hollow reason, an id
// that is not a counting MAJOR, a BLOCKER, `accepted` on a security
// finding), 2 = could not run
// finding, `fixed` while no `### Step R<n>` under `## Remediation Steps`
// names the finding as its `Source finding:`), 2 = could not run
// node lib/review/review-answers.mjs carry --review <p> --previous <archive>
// --repo <d>
// after finalize, from round 2: for each carried MAJOR whose previous
@ -239,6 +240,22 @@ function bodyOf(text) {
return m ? String(text).slice(m[0].length) : String(text ?? '');
}
/** Finding IDs named by `Source finding:` lines under `## Remediation Steps` step headings. */
// Copied from lib/validators/review-validator.mjs (remediationStepSources; not exported there).
function remediationStepSources(body) {
const start = body.search(/^##\s+Remediation Steps\b/m);
if (start === -1) return new Set();
const rest = body.slice(start).replace(/^##\s+Remediation Steps[^\n]*\n/, '');
const next = rest.search(/^##\s+/m);
const section = next === -1 ? rest : rest.slice(0, next);
const ids = new Set();
for (const step of section.split(/^###\s+Step\s+R\d+\b/m).slice(1)) {
const m = step.match(/Source finding:\**\s*`?([0-9a-f]{40})`?/);
if (m) ids.add(m[1]);
}
return ids;
}
/** The latest K2 record of kind `review` for `subject`, or null. */
function latestReviewRecord(records, subject) {
for (let i = records.length - 1; i >= 0; i--) {
@ -508,6 +525,9 @@ export function recordAnswer({ reviewPath, finding, answer, repo, round, store,
if (answer === 'accepted' && isSecurityFinding(f)) {
return refuse(`${id} is a security finding: it is never accepted, only fixed`);
}
if (answer === 'fixed' && !remediationStepSources(bodyOf(text)).has(id)) {
return refuse(`${id} has no "### Step R<n>" with "Source finding: ${id}" under ## Remediation Steps: write its R-step first`);
}
const blockSha = findingsBlockSha(text);
const scopeShaEnd = reviewedSha(text);
if (!blockSha) return cannot(`${reviewPath}: the last fenced block is not a closed json block`);

View file

@ -22,13 +22,17 @@
| trekbrief-stats | ts, task, slug, mode, interview_turns, review_iterations, brief_quality, research_topics, auto_research, auto_result, intent_approved, project_dir, order_id, order_claimed_at, order_reason | commands/trekbrief.md (orchestrator-emit Phase 7; order_* added by lib/stats/stats-append.mjs) | trekbrief.md:1141-1165 | profile, phase_models, profile_source | none |
| trekresearch-stats | ts, question, mode, scope, engine, slug, project_dir, brief_path, dimensions, dimensions_baseline, dimensions_baseline_preserved, effort, conv_turns, empty_turns, unique_sources, agents_local, agents_external, gemini_used, confidence, contradictions, open_questions | lib/stats/stats-append.mjs (trekresearch.md Stats tracking) | trekresearch.md:829-862 | profile, phase_models, parallel_agents, external_research_enabled, profile_source | none |
| trekplan-stats | ts, task, mode, slug, brief_path, project_dir, codebase_size, codebase_files, agents_deployed, deep_dives, research_briefs_used, research_scout_used, critic_verdict, guardian_verdict, outcome | lib/stats/stats-append.mjs (trekplan.md Phase 12) | trekplan.md:1307-1335 | profile, phase_models, parallel_agents, profile_source | none |
| trekexecute-stats (Phase 9 record) | ts, plan, plan_type, mode, result, steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step | lib/stats/stats-append.mjs (trekexecute.md Phase 9) | trekexecute.md:2992-3014 | profile, phase_models, profile_source | none |
| trekexecute-stats (Phase 9 record) | ts, plan, plan_type, mode, result, steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step | lib/stats/stats-append.mjs (trekexecute.md Phase 9) | trekexecute.md:3005-3027 | 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:708-758 | profile, phase_models, profile_source | none |
| trekreview-stats | ts, slug, verdict, counts (BLOCKER/MAJOR/MINOR/SUGGESTION), reviewed_files_count, mode, duration_ms | lib/stats/stats-append.mjs (trekreview.md Phase 8) | trekreview.md:711-764 | 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) |
The `main-merge-declined` event's `payload.reason` (the autonomy-events row) is one of the literal words of
`commands/trekexecute.md` Phase 8: `review-block`, `review-stale`, `review-unanswered`, `review-not-measured`,
`run-not-completed`, `no-main-branch`, `hold-for-review`, `merge-conflict`, `merge-refused`.
## Field-allowlist input for Step 11
The OTel exporter (Step 11 `lib/exporters/field-allowlist.mjs`) MUST inline the

View file

@ -13,6 +13,12 @@
// to mark in Phase 5, and the round fields passed to finalize in Phase 7.
// CAP one cap of three rounds: /trekreview and /trekexecute name
// `review-rounds.mjs` and `MAX_REVIEW_ROUNDS`, and no env name.
// FXL the operator answers each counting MAJOR (/trekreview Phase 8.8 and
// its `--answers` mode); /trekexecute enters the fix loop on
// `review-unanswered: fixed-pending`, closes the merge with the decline
// reason `review-unanswered` otherwise, and counts the answers commit;
// every WARN statement says exit 0 is "ALLOW, or WARN with every
// counting MAJOR answered".
// The helpers below are copied, never imported from an earlier prose test.
// Fix the SOURCE a pin guards, not the pin.
@ -21,6 +27,9 @@ import { strict as assert } from 'node:assert';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { parseArgs } from '../../lib/parsers/arg-parser.mjs';
import { childEnv } from '../helpers/child-env.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
@ -146,3 +155,143 @@ test('[CAP] /trekreview and /trekexecute both name review-rounds.mjs and MAX_REV
assert.ok(!text.includes('TREKEXECUTE_MAX_REVIEW_ROUNDS'), `${rel} no longer names TREKEXECUTE_MAX_REVIEW_ROUNDS`);
}
});
// --- FXL: every counting MAJOR answered (Step 7) -------------------------------------
const REVIEW_CMD = 'commands/trekreview.md';
const EXEC_CMD = 'commands/trekexecute.md';
const P88 = '## Phase 8.8 — Answers';
const MERGE_GATE = '### Main-merge gate (MAIN_MERGE_GATE — always pauses)';
const PHASE_72 = '## Phase 7.2 — Simplify, review, verdict (P11)';
/** The sentences of `text` that contain `needle`. */
const sentencesWith = (text, needle) => sentences(text).filter((s) => s.includes(needle));
/** The literal decline reasons of the main-merge gate, in backticks. */
function literalReasons() {
const gate = flat(section(read(EXEC_CMD), MERGE_GATE));
const literal = gate.match(/The reason is one of the literal words above \(([^)]*)\)/);
assert.ok(literal, 'the gate lists the literal decline reasons');
return [...literal[1].matchAll(/`([a-z-]+)`/g)].map((m) => m[1]);
}
test('[FXL] /trekreview Phase 8.8 runs review-answers.mjs pending and record, asks at most four per call, one finding per question, with the two options and their consequences', () => {
const cmd = read(REVIEW_CMD);
const p88 = section(cmd, P88);
assert.ok(cmd.indexOf(P88) > cmd.indexOf('## Phase 8.7 — The project\'s memory'), 'Phase 8.8 comes after Phase 8.7');
assert.ok(cmd.indexOf(P88) < cmd.indexOf('## Phase 9 — Present summary'), 'Phase 8.8 comes before Phase 9');
assert.ok(p88.includes('node ${CLAUDE_PLUGIN_ROOT}/lib/review/review-answers.mjs pending --review {project_dir}/review.md --json'),
'Phase 8.8 runs review-answers.mjs pending');
assert.ok(p88.includes('node ${CLAUDE_PLUGIN_ROOT}/lib/review/review-answers.mjs record --review "{project_dir}/review.md" --finding {id} --answer {accepted|fixed} --repo . --round {R} --store {artifact_store} --trailer "Co-Authored-By: Claude {model} <noreply@anthropic.com>"'),
'Phase 8.8 runs review-answers.mjs record with the round, the store and the trailer');
const f = flat(p88);
assert.match(f, /`AskUserQuestion`/, 'Phase 8.8 asks with AskUserQuestion');
assert.match(f, /at most four per call/, 'Phase 8.8 asks at most four per call');
assert.match(f, /one finding per question/, 'Phase 8.8 asks one finding per question');
assert.match(f, /no file or function names/, 'Phase 8.8 asks in plain words with no file or function names');
assert.match(f, /\*\*Fix it\*\*[^.]*plan step is written[^.]*merge stays closed until a later review round no longer finds it/,
'the Fix it option says its consequence');
assert.match(f, /\*\*Accept: <a reason in plain words>\*\*[^.]*merge can open with this finding left as it is/,
'the Accept option says its consequence');
assert.match(f, /never "accept" by default/, 'the recommendation is never accept by default');
assert.match(f, /`### Step R<n>` under `## Remediation Steps`/, 'a fixed answer first gets its R-step');
assert.match(f, /`"committed": false`/, 'a failed commit is said in one line');
assert.match(f, /ask that finding again once/, 'a refusal asks that finding again once');
// Phase 8 says the review-unanswered exit 2 is expected before Phase 8.8.
const p8 = section(cmd, '## Phase 8 — Validate output + stats');
assert.ok(sentencesWith(p8, '`review-unanswered:`').some((s) => /Phase 8\.8/.test(s) && /expected/.test(s)),
'Phase 8 says an exit 2 with review-unanswered: before Phase 8.8 is expected');
});
test('[FXL] /trekexecute: Phase 7.2 enters the loop on review-unanswered: fixed-pending, both decline lists name review-unanswered, and the audit counts the answers commit', () => {
const cmd = read(EXEC_CMD);
const p72 = section(cmd, PHASE_72);
// Control: the pinned gate line of step 4 is unchanged.
assert.ok(p72.includes('node ${CLAUDE_PLUGIN_ROOT}/lib/review/verdict-gate.mjs --json "{project_dir}/review.md" --repo .'),
'Control: Phase 7.2 step 4 keeps its gate line');
assert.ok(sentencesWith(p72, '`review-unanswered: fixed-pending`').some((s) => /enters the loop below/.test(s) && /as a BLOCK does/.test(s)),
'Phase 7.2 enters the loop on review-unanswered: fixed-pending, as a BLOCK does');
assert.ok(sentencesWith(p72, 'Any other `review-unanswered:`').some((s) => /Phase 7\.5/.test(s) && /merge closed/.test(s)),
'any other review-unanswered: goes to Phase 7.5 with the merge closed');
assert.ok(flat(p72).includes('"The review asked for fixes: {N} BLOCKER(s) and {M} MAJOR(s) answered "fixed", added as steps {list}. Approve the plan as it is now?"'),
'the loop\'s step-4 question names the MAJORs answered fixed');
assert.ok(sentencesWith(p72, 'iterations_remaining').some((s) => /WARN or BLOCK/.test(s) && /new round counted against the cap/.test(s)),
'each loop, WARN or BLOCK, takes 1 from iterations_remaining and the next review is a new round against the cap');
const gate = flat(section(cmd, MERGE_GATE));
const closed = gate.match(/\*\*Closed:\*\* emit `main-merge-declined` with the reason ([^.]*)\./);
assert.ok(closed, 'the gate says which reasons a closed lock emits');
assert.ok(closed[1].includes('`review-unanswered`'), `the closed-lock reasons name review-unanswered: ${closed[1]}`);
assert.ok(literalReasons().includes('review-unanswered'), 'the literal decline reasons name review-unanswered');
assert.match(gate, /Exit 2 with a reason that starts `review-unanswered:` is closed with the decline reason `review-unanswered`/,
'the exit table maps a review-unanswered: reason to its decline reason');
assert.match(gate, /Any other exit 2 that does not start `review-stale` or `review-unanswered:` is `review-not-measured`/,
'only the other exit 2 reasons are review-not-measured');
assert.match(gate, /\| 0 \| ALLOW \/ WARN \(every counting MAJOR answered\) \|/, 'the exit-0 row names the answered MAJORs');
// Control: the gate is a lock, not a question.
assert.doesNotMatch(section(cmd, MERGE_GATE), /AskUserQuestion/, 'Control: the main-merge gate asks nothing');
const audit = flat(section(cmd, '## Phase 7.5 — Manifest audit (independent)'));
assert.ok(audit.includes('`docs(voyage): review answers (round {R})`'), 'the audit\'s process commits name the review answers commit');
});
test('[FXL] tests/fixtures/jsonl-schemas.md lists the main-merge-declined reasons, review-unanswered included, after the field table', () => {
const text = read('tests/fixtures/jsonl-schemas.md');
const lines = text.split('\n');
const table = lines.filter((l) => l.startsWith('|'));
assert.ok(!table.some((l) => l.includes('review-unanswered')), 'the field table itself does not hold the reason list');
const hits = sentences(text).filter((s) => s.includes('main-merge-declined') && s.includes('payload.reason'));
assert.equal(hits.length, 1, `one sentence lists the main-merge-declined payload.reason words: ${hits.join(' | ')}`);
for (const word of literalReasons()) {
assert.ok(hits[0].includes(`\`${word}\``), `the reason list names ${word}: ${hits[0]}`);
}
assert.ok(hits[0].includes('`review-unanswered`'), `the reason list names review-unanswered: ${hits[0]}`);
const tableEnd = lines.findLastIndex((l, i) => l.startsWith('|') && i < lines.indexOf('## Field-allowlist input for Step 11'));
const at = lines.findIndex((l) => l.includes('main-merge-declined') && l.includes('payload.reason'));
assert.ok(at > tableEnd, 'the reason list stands after the field table');
});
// The WARN statements the step names, each by its file.
const WARN_FILES = ['CLAUDE.md', 'README.md', 'docs/command-modes.md', 'docs/architecture.md',
'agents/review-orchestrator.md', 'docs/release-runbook.md', REVIEW_CMD];
// "0 = ALLOW/WARN", "0 ALLOW/WARN", "0 = ALLOW or WARN", "0 — ALLOW / WARN": WARN alone exits 0.
const WARN_ALONE = /\b0\s*(?:=|—|-)?\s*ALLOW\s*(?:\/|or)\s*WARN\b/;
test('[FXL] no WARN statement says WARN alone exits 0; each names the answered MAJORs, and the runbook names review-answers.mjs pending', () => {
for (const rel of WARN_FILES) {
const text = read(rel);
const alone = text.split('\n').filter((l) => WARN_ALONE.test(l));
assert.deepEqual(alone, [], `${rel} still says WARN alone exits 0`);
assert.ok(text.includes('WARN with every counting MAJOR answered'), `${rel} says exit 0 is ALLOW, or WARN with every counting MAJOR answered`);
}
const runbook = flat(read('docs/release-runbook.md'));
assert.ok(runbook.includes('node lib/review/review-answers.mjs pending --review <review.md> --repo .'),
'the release runbook names review-answers.mjs pending');
assert.match(runbook, /NOT_READY/, 'the runbook says an unanswered MAJOR reads NOT_READY');
const coord = flat(section(read('agents/review-coordinator.md'), '### Pass 4 — Compute verdict'));
assert.match(coord, /the verdict gate, not the coordinator, decides whether it was answered/,
'the coordinator\'s Pass 4 says the gate decides whether a MAJOR was answered');
});
test('[FXL] the --answers mode: the parser accepts it, /trekreview names it and says it opens no new round, /trekexecute\'s closed lock names it', () => {
const r = parseArgs('--answers --project x', 'trekreview');
assert.equal(r.flags['--answers'], true, `--answers is a trekreview boolean: ${JSON.stringify(r)}`);
assert.deepEqual(r.unknown, [], `--answers is not unknown: ${JSON.stringify(r)}`);
const usage = spawnSync(process.execPath, [join(ROOT, 'lib', 'parsers', 'arg-parser.mjs'), '--command', 'trekreview', '--stdin'],
{ input: '--answers\n', encoding: 'utf8', env: childEnv() });
assert.equal(usage.status, 1, usage.stdout + usage.stderr);
assert.match(usage.stderr, /Usage: \/trekreview --project <dir> [^\n]*\[--answers\]/, `the usage line names --answers: ${usage.stderr}`);
const cmd = read(REVIEW_CMD);
assert.match(cmd.match(/^argument-hint: (.*)$/m)?.[1] ?? '', /\[--answers\]/, 'the argument-hint names --answers');
const p1 = section(cmd, '## Phase 1 — Parse mode and validate input');
const row = p1.split('\n').find((l) => l.startsWith('| `--answers` |'));
assert.ok(row, 'the Phase 1 mode table has an --answers row');
assert.match(row, /Phase 8\.8/, `the --answers row runs Phase 8.8: ${row}`);
assert.match(row, /no new round/, `the --answers row says it opens no new round: ${row}`);
const modes = section(read('docs/command-modes.md'), '## /trekreview modes');
assert.ok(modes.split('\n').some((l) => l.startsWith('| `--answers` |')), 'docs/command-modes.md lists --answers in the /trekreview modes table');
const claudeRow = read('CLAUDE.md').split('\n').find((l) => l.startsWith('| `/trekreview` |'));
assert.ok(claudeRow && claudeRow.includes('--answers'), 'the /trekreview row of CLAUDE.md names --answers');
const gate = flat(section(read(EXEC_CMD), MERGE_GATE));
assert.ok(gate.includes('/trekreview --answers --project {project_dir}'), 'the closed-lock line names /trekreview --answers');
});

View file

@ -504,6 +504,7 @@ test('[ANS] an accepted answer with a reason, bound to this review, exits 0', ()
test('[ANS] a fixed answer keeps exit 2 with fixed-pending', () => {
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
const fx = ansFixture();
fx.write(fx.read().replace('## Remediation Steps\n\nNone.\n', `## Remediation Steps\n\n### Step R1: fix t1\n\nSource finding: ${fid(1)}\n`));
const rec = fx.record({ answer: 'fixed', reason: 'fixed in step R1' });
assert.equal(rec.status, 0, rec.stdout + rec.stderr);
const r = fx.gate();

View file

@ -11,6 +11,9 @@
// CAP one cap of three rounds (`MAX_REVIEW_ROUNDS`).
// CRY an accepted answer is carried to the next round only by a link to the
// same run's earlier archive, checked by the gate.
// FXL (Step 7) a MAJOR answered "fixed" needs its `### Step R<n>` first:
// `record --answer fixed` refuses without it; /trekreview Phase 8.8
// offers only "Fix it" for a finding `pending` marks `security: true`.
// Tests run the CLIs (prepare, finalize, review-answers, validator, gate) in
// throwaway git repos with no hooks; `carry` is also called directly. Each
// tagged test's main assertion is red at the step's start; a case that
@ -908,3 +911,76 @@ test('[CRY] an answer to one carried occurrence, linked from another carried occ
assert.equal(r3.status, 2, r3.stdout);
assert.deepEqual(parse(r3).unanswered, [h2.id], r3.stdout);
});
// ---- [FXL] answers that ask for a fix (Step 7) ----------------------------------------
/** The review with one `### Step R1` for `id` under `## Remediation Steps`, in place of its "None.". */
function withRStep(text, id, title = 'fix the parser') {
const step = `### Step R1: ${title}\n\n- **Files:** \`a.mjs\`\n- **Source finding:** \`${id}\`\n- **Test first:** a test that fails today\n- **Verify:** re-run /trekreview\n- **On failure:** escalate\n`;
assert.ok(text.includes('## Remediation Steps\n\nNone.\n'), 'the review has no R-step yet');
return text.replace('## Remediation Steps\n\nNone.\n', `## Remediation Steps\n\n${step}`);
}
test('[FXL] a MAJOR answered fixed gives a ### Step R<n>: record --answer fixed refuses without the R-step and records with it, and the validator then passes', () => {
const fx = rndRepo();
// A review of HEAD (c2), so the gate decides on the answers, not on staleness.
fx.write(rndReview({ start: fx.start, end: fx.c2, findings: [MAJ_A3] }).text);
fx.out(CCR);
const f1 = fx.finalize(1);
assert.equal(f1.status, 0, f1.stdout + f1.stderr);
const id = jsonOf(fx.read()).findings[0].id;
const logFile = join(fx.repo, '.claude', 'voyage', 'approvals.jsonl');
const record = () => run([ANSWERS, 'record', '--review', fx.reviewPath, '--finding', id, '--answer', 'fixed', '--repo', fx.repo,
'--round', '1', '--store', 'local', '--trailer', TRAILER], { input: 'the parser gets the missing token back' });
const before = fx.read();
const refused = record();
assert.equal(refused.status, 1, `a fixed answer without its R-step is refused: ${refused.stdout}${refused.stderr}`);
assert.match(String(parse(refused).reason), /write its R-step first/, refused.stdout);
assert.equal(fx.read(), before, 'a refused answer writes no row');
assert.ok(!existsSync(logFile), 'a refused answer appends no K2 record');
// An R-step for another finding is not this finding's R-step.
fx.write(withRStep(before, 'e'.repeat(40)));
const other = record();
assert.equal(other.status, 1, `an R-step for another finding does not count: ${other.stdout}${other.stderr}`);
assert.ok(!existsSync(logFile), 'a refused answer appends no K2 record');
// With its R-step: recorded.
fx.write(withRStep(before, id));
const ok = record();
assert.equal(ok.status, 0, `with its R-step the answer is recorded: ${ok.stdout}${ok.stderr}`);
const text = fx.read();
assert.ok(text.includes(`| ${id} | fixed | the parser gets the missing token back | ${parse(ok).record} | - |`), text);
const v = fx.validate();
assert.equal(v.status, 0, `review-validator --strict: ${v.stdout}${v.stderr}`);
// Control: a fixed answer keeps the merge closed as fixed-pending.
const gate = fx.gate();
assert.equal(gate.status, 2, `Control: ${gate.stdout}${gate.stderr}`);
assert.match(String(parse(gate).reason), /^review-unanswered: fixed-pending 1/, `Control: ${gate.stdout}`);
});
test('[FXL] Phase 8.8 offers only Fix it for a finding pending marks security: true; pending returns that flag for a MAJOR-tier key from the security-review engine', () => {
const p88 = (() => {
const cmd = readFileSync(join(ROOT, 'commands', 'trekreview.md'), 'utf8');
const at = cmd.indexOf('## Phase 8.8 — Answers');
assert.ok(at >= 0, '/trekreview has a Phase 8.8 — Answers');
const next = cmd.indexOf('\n## ', at + 4);
return cmd.slice(at, next === -1 ? undefined : next).replace(/\s+/g, ' ');
})();
assert.match(p88, /Never offered for a finding `pending` marks `security: true`/, 'Accept is never offered for a security finding');
assert.match(p88, /That finding gets one option, Fix it, and its question says a security finding cannot be accepted/,
'a security finding gets one option, Fix it, and the question says why');
// Control: pending marks a MAJOR-tier key from the security-review engine security: true, and record refuses to accept it.
const fx = rndRepo();
const SEC = { severity: 'MAJOR', file: 'a.mjs', line: 3, rule_key: 'MISSING_TEST', engine: 'security-review', title: 'Token logged in clear' };
fx.write(rndReview({ start: fx.start, end: fx.c1, findings: [SEC, { ...MAJ_A3, line: 5 }] }).text);
const p = run([ANSWERS, 'pending', '--review', fx.reviewPath, '--repo', fx.repo, '--json']);
assert.equal(p.status, 1, `Control: ${p.stdout}${p.stderr}`);
const pending = parse(p).pending ?? [];
const sec = pending.find((x) => x.line === 3);
const plain = pending.find((x) => x.line === 5);
assert.ok(sec && plain, `Control: both MAJORs are pending: ${p.stdout}`);
assert.equal(sec.security, true, `Control: the security-review MAJOR is marked security: ${p.stdout}`);
assert.equal(plain.security, false, `Control: the other MAJOR is not: ${p.stdout}`);
const acc = run([ANSWERS, 'record', '--review', fx.reviewPath, '--finding', sec.id, '--answer', 'accepted', '--repo', fx.repo,
'--round', '1', '--store', 'local', '--trailer', TRAILER], { input: 'the token is a test token' });
assert.equal(acc.status, 1, `Control: a security finding is never accepted: ${acc.stdout}${acc.stderr}`);
});