merge(wave2): 6 kobling, executor coupled to review gate, plan approval and step checks
Order 6 of wave 2: /trekexecute checks the approved plan (K2), runs the step check (K9) and the step's Regression: (K10) at step end, the architecture rules (K5) and plan-file sweep (K6) after the last step, reviews by itself in the closing run (K7 lock on the main merge), and the guard refuses every asked-about call where no one can be asked. Branch suite 2102 (2100/0/2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
19ff772773
15 changed files with 1319 additions and 65 deletions
|
|
@ -32,7 +32,7 @@ Voyage — a contract-driven Claude Code pipeline: brief, research, plan, execut
|
|||
| `/trekbrief` | Brief — one question round sized by class (`ceremony: small\|bounded\|architectural`, heaviest in doubt, said aloud, only moved up) produces a task brief with explicit research plan; ends with the operator explicitly approving `## Intent` + `## Goal` («høres bra ut» is not approval), logged in the target repo's hash-chained approval log and committed (the brief too when `artifact_store: git`) (`--approve <dir>` does only the approval, then writes the trekbrief record); optionally orchestrates the pipeline | session |
|
||||
| `/trekresearch` | Research — deep local + external research, produces structured research brief. Opt-in `--engine {swarm\|deep-research}` delegates the external phase to Claude Code's built-in `/deep-research` workflow (swarm default). The external agents preload the `researching-sources` skill and are told it by name at spawn | session |
|
||||
| `/trekplan` | Plan — brief-reviewer, explore, plan, review. Requires `--brief` or `--project`; halts unless the brief's intent is approved (hash-bound, stale on edit). Auto-discovers `architecture/overview.md` if present. Plan 1.8: `Test first:` per step + one `End-to-end:` step; a design part for `ceremony: architectural` (absent = architectural); critic loop of max 3 rounds, recorded in the plan, 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: one fresh `step-implementer` helper per plan step, one controller that reads the diff (spec + quality verdicts), proof of red / floor guard / done line as commands, fix rounds that go up (max 5: helper → plan → operator), closed stop list + decision log in the final report | session |
|
||||
| `/trekexecute` | Execute — disciplined plan/session-spec executor: refuses a plan whose logged approval does not cover its current sha256 (K2); one fresh `step-implementer` helper per plan step, one controller that reads the diff (spec + quality verdicts), proof of red / floor guard / step check (K9) / `Regression:` (K10) / done line as commands, fix rounds that go up (max 5: helper → plan → operator), architecture rules (K5) + plan-file sweep (K6) after the last step, 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) | 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 |
|
||||
|
|
@ -84,6 +84,11 @@ Full flag reference for each command (modes, `--gates`, `--profile`, breaking ch
|
|||
> logged", never green. Voyage's guards are deterministic only when no
|
||||
> installed mod handles `tool.check`.
|
||||
|
||||
> **One question per merge (operator decision, 2026-10-08).** Where the guard
|
||||
> asks (merge into main, push), `/trekexecute` never asks the same thing: its
|
||||
> main-merge gate is a lock on the review verdict, and the guard's prompt is
|
||||
> the one question. Exit 2 from K5/K6/K7/K9 is "not measured", never green.
|
||||
|
||||
## Agents
|
||||
|
||||
| Agent | Model | Role |
|
||||
|
|
|
|||
28
README.md
28
README.md
|
|
@ -343,6 +343,8 @@ Reads a plan from `/trekplan` and implements it with strict discipline. No guess
|
|||
|
||||
Per step: record the start point → **one fresh helper** (`step-implementer`) writes the step's test first, proves it red, locks it, then applies Changes exactly as written → the executor proves it by command: **red at the start point, green at the result** (`red-proof.mjs`), Verify (exit code is truth), manifest audit, and the **floor guard** on the diff (`test-integrity.mjs`: removed asserts, skipped or deleted tests, muted checks, stubs, loosened thresholds) → **one controller** reads the diff — never the helper's summary — and gives two verdicts, *spec* and *quality* → a script writes the step's **done line** only when the test command exits 0 (`done-line.mjs`) → Checkpoint commit. After all steps: independent Phase 7.5 manifest audit from git log + filesystem (ignoring agent bookkeeping); drift triggers Phase 7.6 bounded recovery. A plan with no passing run-the-thing criterion ends **not verified end to end**, never green.
|
||||
|
||||
A run starts only on a plan you approved **as it is now**: the approval log holds the plan's sha256, so a plan edited after its approval is refused before step 1. At step end the repo's own tools check the step's diff (the step check) and the step's `Regression:` suite must stay green. After the last step come the architecture rules and the sweep for files the plan does not name, and then **the review starts by itself**: `/simplify` → `/trekreview` → the verdict gate. A BLOCK keeps the merge into main closed.
|
||||
|
||||
### The step loop
|
||||
|
||||
| Part | What it does | Why |
|
||||
|
|
@ -353,6 +355,8 @@ Per step: record the start point → **one fresh helper** (`step-implementer`) w
|
|||
| Test lock | the helper locks its test files after the red proof (`test-lock.mjs`, when installed; otherwise "not-locked" is reported) | The code moves to meet the test, never the reverse |
|
||||
| Floor guard | `test-integrity.mjs` reads the diff for weakened tests; exit 2 ("could not run") is never green | Cheating the tests is caught by a command |
|
||||
| Controller | one general helper, two verdicts (spec, quality), re-checks only what a fix round changed | An independent judge of the diff |
|
||||
| Step check | `step-check.mjs` runs the repo's own formatters, linters and type checker on the step's diff; findings go to the controller, a repo without tools reads "not measured" | The stop for P7 is at step end |
|
||||
| Regression | the step's optional `Regression:` command (one allowlisted test runner) runs through the criteria runner before the checkpoint; red goes into the fix rounds | Verify proves the step; the regression proves nothing else broke |
|
||||
| Done line | `done-line.mjs` appends `DONE step N` only when the test command exits 0 | "Done" is an exit code |
|
||||
|
||||
The helper works in your working tree, not in a worktree of its own: measured 2026-10-06, a worktree-isolated subagent branches from the default branch rather than your `HEAD`, so it would not see the earlier steps (only the operator setting `worktree.baseRef: "head"` changes that).
|
||||
|
|
@ -361,6 +365,20 @@ The helper works in your working tree, not in a worktree of its own: measured 20
|
|||
|
||||
A run stops only on a **closed list**: something that cannot be undone · something security-sensitive · something outside the working copy (merge to main, push, publish) · a plan so broken that everything after it is guesswork. Everything else the executor decides itself and logs in the final report's **Decisions taken** table, each with what it costs if it is wrong.
|
||||
|
||||
### The review at the end — a gate (P11)
|
||||
|
||||
After the last step, and with nobody typing anything, the executor runs Cherny's order: **run the thing** (the plan's `## Verification`, its end-to-end criterion included) → **`/simplify`** (Claude Code's cleanup skill; edits outside the run's files are restored, and a cleanup that breaks the verification is reverted) → **`/trekreview`** (Claude Code's `/code-review` and `/security-review`, then Voyage's check against the brief) → **`verdict-gate.mjs`**.
|
||||
|
||||
| Verdict gate | Merge into main |
|
||||
|---|---|
|
||||
| 0 — ALLOW / WARN | 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 |
|
||||
|
||||
**One question per merge.** The guard asks before every merge into main and every push, in auto mode too, so the executor no longer asks "ready to merge?" itself (operator decision 2026-10-08). On the wave path the push and the merge of each session go in one call, so you are asked once per session.
|
||||
|
||||
Borrowed: the order from Cherny; the gate on a verdict in a file from Kiro; the bounded fix-and-review loop from Spec Kit; nothing dropped silently from Superpowers. Ideas only.
|
||||
|
||||
### Modes
|
||||
|
||||
| Mode | Usage | Behavior |
|
||||
|
|
@ -383,8 +401,8 @@ Voyage never starts a Claude Code session. When a plan has an `## Execution Stra
|
|||
2. **Creates git worktrees** — each session in the wave gets its own isolated worktree and branch (`trek/{slug}/session-{N}`)
|
||||
3. **Writes a session order per session** — the worktree to open, the steps, the done-condition, and a one-line starter (`/trekexecute --session N <plan>`) — then stops
|
||||
4. **You open each session yourself** in the directory its order names and give it the starter
|
||||
5. **Run the same `/trekexecute` command again** — it reads every session's progress file, merges the branches back sequentially with `--no-ff`, cleans up, and writes the next wave's orders
|
||||
6. After the last wave: master verification on the merged result; worktrees and branches are cleaned up unconditionally (even on failure)
|
||||
5. **Run the same `/trekexecute` command again** — it reads every session's progress file, merges the branches back sequentially with `--no-ff` (the guard asks you once per session: push and merge go in one call), cleans up, and writes the next wave's orders
|
||||
6. After the last wave: master verification on the merged result, then the review at the end; worktrees and branches are cleaned up unconditionally (even on failure)
|
||||
|
||||
```
|
||||
Wave 1 orders: Session 1 (worktree-1) + Session 2 (worktree-2) -- you run them, in parallel
|
||||
|
|
@ -416,7 +434,7 @@ The executor implements defense-in-depth security across three layers:
|
|||
|
||||
**Guards during the build.** All are command hooks; none runs a model.
|
||||
|
||||
- **Approval guard (P12)** — `pre-bash-executor.mjs` answers `ask` before a merge into `main`, a push, a tag or package publish, and destructive git (hard reset, forced clean, force-deleted branch, dropped stash, discarded changes, history rewrite). Claude Code shows the prompt labelled `[plugin:voyage]`, also in auto mode, and the prompt names the way forward. `post-approval-log.mjs` writes an approved merge or push to `main` (`merge`) and a tag or publish (`release`) to the approval log with a one-hour expiry. It logs exactly what the person was asked, and nothing in a permission mode that shows no prompts, where no one is asked; other pushes and destructive git are asked about but not logged. Claude Code's own notification for a waiting permission prompt is the alert; Voyage adds none.
|
||||
- **Approval guard (P12)** — `pre-bash-executor.mjs` answers `ask` before a merge into `main`, a push, a tag or package publish, and destructive git (hard reset, forced clean, force-deleted branch, dropped stash, discarded changes, history rewrite). Claude Code shows the prompt labelled `[plugin:voyage]`, also in auto mode, and the prompt names the way forward. `post-approval-log.mjs` writes an approved merge or push to `main` (`merge`) and a tag or publish (`release`) to the approval log with a one-hour expiry. It logs exactly what the person was asked, and nothing in a permission mode that shows no prompts, where no one is asked; other pushes and destructive git are asked about but not logged. Claude Code's own notification for a waiting permission prompt is the alert; Voyage adds none. `/trekexecute` relies on it: where the guard asks, the executor never asks the same thing again. In the permission mode where Claude Code shows no prompts there is no one to ask, so the guard refuses every call it would ask about instead (merge into `main`, push, tag or publish, destructive git) and says to run it yourself (operator decisions 2026-10-09).
|
||||
- **Plan gate (P3)** — `pre-plan-gate.mjs` runs Voyage's plan gate on the plan Claude Code's plan mode presents and sends an invalid Voyage plan back to planning; when the gate cannot run, the prompt says "not measured". An approved Voyage plan is logged with the hash of its text. A plan without `plan_version` passes untouched.
|
||||
- **Test lock (P9)** — while a step's test is locked (`lib/util/test-lock.mjs`), `pre-write-executor.mjs` refuses Write, Edit and NotebookEdit on it, and `stop-test-lock.mjs` refuses "done" in the session that took the lock until the executor unlocks it.
|
||||
- **Post-edit check (P7)** — `post-edit-check.mjs` runs the target repo's own formatter and linter on each edited file (check-only; a tool is never downloaded) and logs the result. `lib/verification/step-check.mjs` re-runs them on the diff at step end, adds the type checker, and fails the step on findings. A repo without tools, or a configured tool that is missing or crashes, reads "not measured", never green. The check is on only while Voyage executes a plan in that repo, or when the repo switches it on with `.claude/voyage-guards.json` = `{ "post_edit_check": true }`.
|
||||
|
|
@ -445,7 +463,7 @@ prompt-injection vector that the plugin's own hooks cannot fully mitigate
|
|||
### Unattended execution
|
||||
|
||||
`/trekexecute` runs without asking questions:
|
||||
- **No questions asked** — all recovery decisions come from the plan
|
||||
- **No questions asked** — all recovery decisions come from the plan. The exceptions are approvals: the plan's approval again after the review added steps, and the guard's question before a merge or push
|
||||
- **Progress file** — crash recovery via `{project_dir}/progress.json` (or `.trekexecute-progress-{slug}.json` for legacy plans)
|
||||
- **Scope fence enforcement** — never touches files outside the session's scope
|
||||
- **JSON summary** — machine-parseable `trekexecute_summary` block at the end of every run
|
||||
|
|
@ -956,7 +974,7 @@ trekplan/
|
|||
├── agents/ 24 specialized agents (all `model: opus`-pinned; per-phase model set by `--profile`)
|
||||
│ └ 21 spawnable (1 dormant: synthesis-agent, Δ≈0) + 3 orchestrator reference docs (not spawned)
|
||||
├── commands/ 6 slash commands (trekbrief, trekresearch, trekplan, trekexecute, trekreview, trekcontinue) + trekendsession helper
|
||||
├── skills/ 2 skills (test-first, debugging), preloaded into the step helper
|
||||
├── skills/ 3 skills (test-first, debugging: preloaded into the step helper; researching-sources: preloaded into the five web-search agents)
|
||||
├── templates/ Frontmatter templates for brief, research, plan, session
|
||||
├── hooks/ 12 hooks (pre-bash, pre-write, pre-agent-cap, pre-plan-gate, session-title, post-bash-stats, post-approval-log, post-edit-check, pre-compact-flush, post-compact-flush, otel-export, stop-test-lock)
|
||||
├── lib/ Zero-dep parsers and validators (CLI shims under lib/validators/)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,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]"
|
||||
allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion, Agent
|
||||
allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion, Agent, Skill
|
||||
disallowed-tools: TeamCreate
|
||||
---
|
||||
|
||||
|
|
@ -16,8 +16,14 @@ Each plan step is built by **one fresh helper** (`voyage:step-implementer`)
|
|||
and judged by **one controller** that reads the step's diff. The executor
|
||||
itself writes no product code: it records the start point, launches the
|
||||
helper, runs the mechanical checks (proof of red, the floor guard, the
|
||||
manifest, the done line), and decides from exit codes and the controller's
|
||||
verdicts (Phase 6).
|
||||
manifest, the step check, the regression, the done line), and decides from
|
||||
exit codes and the controller's verdicts (Phase 6).
|
||||
|
||||
It starts only on a plan the operator approved as it is now (Phase 1, K2), and
|
||||
it ends with a review it starts by itself (Phase 7.2): `/simplify`, then
|
||||
`/trekreview`, then the verdict gate (K7). A BLOCK keeps the merge into main
|
||||
closed (Phase 8). The guard asks the one question before a merge; the executor
|
||||
never asks it again (operator decision 2026-10-08).
|
||||
|
||||
Voyage never starts a Claude Code session. Work that needs another session
|
||||
becomes an order plus a starter line the user gives a session they open
|
||||
|
|
@ -50,14 +56,17 @@ 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`. Pause for
|
||||
operator confirmation after each wave's worktrees finish but before
|
||||
merge-back, AND before the main-merge gate. (The MAIN_MERGE_GATE in
|
||||
Phase 8 ALWAYS pauses regardless of `gates_mode` — `--gates` re-enables
|
||||
the per-wave pauses that auto mode otherwise skips.) Default
|
||||
8. If arguments contain `--gates`: set `gates_mode = true`. Default
|
||||
`gates_mode = false`. The flag is consumed by the autonomy-gate state
|
||||
machine via the CLI shim:
|
||||
`node ${CLAUDE_PLUGIN_ROOT}/lib/util/autonomy-gate.mjs --state X --event Y --gates {true|false}`.
|
||||
It no longer adds a question of the executor's own before a merge. The
|
||||
wave merge-back (Phase 2.6 Step 3) and the merge into main (Phase 8) are
|
||||
asked by the guard, which asks before every merge into main and every push,
|
||||
in auto mode too, and the executor never asks the same thing again
|
||||
(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**.
|
||||
|
||||
If no path is provided (and `--project` was not used to derive one), output
|
||||
|
|
@ -108,6 +117,53 @@ Project: {project_dir or "-"}
|
|||
File: {path}
|
||||
```
|
||||
|
||||
### Plan approval gate (K2)
|
||||
|
||||
**Runs in every mode that executes** (execute, resume, step N, session N).
|
||||
Dry-run and validate execute nothing and skip it. Before anything is parsed
|
||||
for execution, check that the operator approved THIS plan as it is now.
|
||||
`/trekplan` logs the approval bound to the plan file's sha256 (its Phase 11:
|
||||
`approval-log.mjs append --kind plan --sha256-of`), so an edit after the
|
||||
approval makes it stale. Nothing reads the log file directly (K2); the log's
|
||||
own CLI answers:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/util/approval-log.mjs verify --json --kind plan --subject "{plan_path}" --sha256-of "{plan_path}"
|
||||
```
|
||||
|
||||
`{plan_path}` is the file resolved above. Exit 0 (`approved`) → continue and
|
||||
record `plan_approval = "approved"`; if `uncommitted` is above 0, say in one
|
||||
line that N approvals are in the log but not committed yet. Exit 1 → **halt**:
|
||||
no step, no helper, no progress file. Print `status` and `detail`:
|
||||
|
||||
| `status` | Meaning | What the operator does |
|
||||
|----------|---------|------------------------|
|
||||
| `not-logged` | The plan was never approved, or was approved before the log existed | `/trekplan --project {dir}` and answer "Approve" at its plan approval |
|
||||
| `mismatch` | The plan changed after its approval (the approval covers other content) | Same: approve the plan as it is now, or restore the approved text. A review loop interrupted between adding its steps and the approval left the approved text in `{report dir}/plan-approved-round-{R}.md` (Phase 7.2): copy it back |
|
||||
| `expired` | The approval carried an expiry that has passed | Same |
|
||||
| `broken` | A record in `.claude/voyage/approvals.jsonl` was edited, removed or reordered | Restore the log from git (`git checkout -- .claude/voyage/approvals.jsonl`), then re-run |
|
||||
| `diverged` | Committed approvals were rewritten | Same: restore from git |
|
||||
|
||||
Exit 2, or no JSON at all → **halt** and show the raw error: an approval check
|
||||
that could not run is never a pass.
|
||||
|
||||
**Not checked:** a session spec or a recovery spec (a file with
|
||||
`## Scope Fence`, Phase 2). Voyage writes those from an approved plan (the
|
||||
session decomposer, Phase 7.6), and they carry no approval of their own.
|
||||
Record `plan_approval = "not-checked"` and say "plan approval: not checked
|
||||
(session spec)" in the final report, never "approved". A `--session N` run of
|
||||
a trekplan's Execution Strategy executes the plan itself, so it IS checked:
|
||||
the log is committed, and Phase 2.55 Check 2 brings the plan into the worktree.
|
||||
|
||||
The remediation steps of the review loop (Phase 7.2) change the plan, so the
|
||||
loop asks for the plan's approval again before it builds them. Nothing else
|
||||
in this command edits the plan.
|
||||
|
||||
Borrowed: the form of `lib/validators/intent-approval.mjs` and the `/trekplan`
|
||||
intent gate (an approval bound to the approved content's hash, checked before
|
||||
the work starts); the log is ai-native-sdlc's gate ledger (MIT), with its two
|
||||
flaws corrected (header of `lib/util/approval-log.mjs`).
|
||||
|
||||
## Phase 2 — Detect file type and parse structure
|
||||
|
||||
Read the file. Determine whether it is an **trekplan** or a **session spec**:
|
||||
|
|
@ -135,7 +191,15 @@ Extract every `### Step N: {description}` heading (in order). For each step, ext
|
|||
span of the field. It is also the step's **test command**: proof of red
|
||||
(Sub-step B) and the done line (Sub-step G) run it, so it must be one
|
||||
test-runner invocation
|
||||
- **On failure** — recovery action (revert/retry/skip/escalate)
|
||||
- **On failure** — recovery action: the field starts with one of the words
|
||||
`revert`, `retry`, `skip`, `escalate` (K11; the plan validator warns
|
||||
otherwise), and the rest is the clause's detail
|
||||
- **Regression** — optional (K10): ONE command from the criteria runner's
|
||||
allowlist, without pipes or `&&`: the frozen regression suite the step must
|
||||
keep green. Sub-step D4 runs it before the checkpoint; a step without the
|
||||
field runs as before. The plan validator rejects a `Regression:` that cannot
|
||||
run once K10 is in it (wave 2, order 9); for a plan validated before that,
|
||||
Sub-step D4 is where an unrunnable one is caught, never read as green
|
||||
- **Checkpoint** — git commit command after success
|
||||
- **Manifest** — YAML block following Checkpoint (v1.7+)
|
||||
- **Interface in** / **Interface out** / **Review focus** — optional (K4):
|
||||
|
|
@ -666,6 +730,9 @@ Record `REPO_ROOT`, `PLAN_REL`, `SESSION_DIR`, `WORKTREE_DIR`, `ORDERS_DIR`,
|
|||
|
||||
```bash
|
||||
mkdir -p "$WORKTREE_DIR" "$ORDERS_DIR"
|
||||
# The run's start point, written once (wave 1): Step 6's whole-run checks and
|
||||
# the review (Phase 7.2) read it. Phase 8's clear removes it with the orders.
|
||||
[ -f "$ORDERS_DIR/run-base.sha" ] || git rev-parse HEAD > "$ORDERS_DIR/run-base.sha"
|
||||
```
|
||||
|
||||
**2a. Create worktrees for this wave's sessions:**
|
||||
|
|
@ -842,8 +909,13 @@ in this order:
|
|||
```
|
||||
Do NOT merge. Do NOT prepare later waves. Jump to Step 5 (cleanup; it keeps
|
||||
every unmerged branch and every worktree git refuses to remove), then Phase 8.
|
||||
- **`all_completed: true`:** if `gates_mode` is true, pause for operator
|
||||
confirmation before merge-back. Then continue with the merge.
|
||||
- **`all_completed: true`:** continue with the merge. The executor asks
|
||||
nothing here, `gates_mode` or not. Each session's push and merge below run
|
||||
in one Bash call, and the guard asks the operator before it (a push always,
|
||||
a merge when the branch is main), so that answer is the one question for the
|
||||
merge (operator decision 2026-10-08; it was two when `--gates` added a pause
|
||||
of its own). A declined guard question means the call did not run: stop and
|
||||
report it like `MERGE_REFUSED`, keeping `wave.json`.
|
||||
|
||||
**Merge session branches back (SEQUENTIAL, one at a time):**
|
||||
|
||||
|
|
@ -864,7 +936,9 @@ If it exits 1, stop and report its message: no merge, no cleanup, and keep
|
|||
For each session N in the wave (in order), push BEFORE merge so the wave
|
||||
branch survives even if cleanup races ahead (research/02 R3 — converts an
|
||||
unrecoverable failure to a recoverable one). Push failure is non-fatal
|
||||
(no remote, offline, etc.):
|
||||
(no remote, offline, etc.). Run the whole block as one Bash call, never split
|
||||
into a push call and a merge call: the guard reads the call and asks once for
|
||||
both.
|
||||
```bash
|
||||
GIT_OPTIONAL_LOCKS=0 git push origin "trek/{slug}/session-{N}" 2>/dev/null || true
|
||||
if GIT_OPTIONAL_LOCKS=0 git merge --no-ff "trek/{slug}/session-{N}" \
|
||||
|
|
@ -940,7 +1014,12 @@ back to Step 1 — this run prepares that wave's orders and stops again. `none`
|
|||
### Step 4 — Run master verification
|
||||
|
||||
After all waves complete and merge successfully, run the plan's `## Verification`
|
||||
section commands to verify the integrated result.
|
||||
section commands to verify the integrated result, through the same criteria
|
||||
runner block as Phase 7 (`### trekplans — run the plan's ## Verification`),
|
||||
from `$REPO_ROOT`. Record `plan_verification` and `end_to_end` exactly as
|
||||
Phase 7 does (the same exit table, the same `exitCode` → `exit_code`
|
||||
translation): the review (Phase 7.2) and the completion gate read them. Step 4
|
||||
passed = the runner exited 0.
|
||||
|
||||
### Step 5 — Cleanup (ALWAYS runs when the run ends, even on failure)
|
||||
|
||||
|
|
@ -1000,7 +1079,13 @@ Keep the orders directory here, whatever the outcome. Only Phase 8's
|
|||
main-merge gate clears it, on confirm, when `next-wave` said `none` (every wave
|
||||
merged) and Step 4 passed. Until then the next run resumes at the lowest wave
|
||||
not yet merged (Step 0), or re-runs Step 4 and returns to the gate when every
|
||||
wave is merged. Jump to Phase 8.
|
||||
wave is merged.
|
||||
|
||||
When every wave is merged and Step 4 passed, the integrated result is checked
|
||||
and reviewed like a single-session run, from the run's start point
|
||||
(`session_start_sha` = the contents of `$ORDERS_DIR/run-base.sha`, Step 1):
|
||||
Phase 7's architecture rules and plan-file sweep (K5, K6), then Phase 7.2
|
||||
(Step 4 is its "run the thing"). Otherwise skip both. Then jump to Phase 8.
|
||||
|
||||
### --session N mode
|
||||
|
||||
|
|
@ -1052,13 +1137,18 @@ progress file.
|
|||
"session_start_sha": null,
|
||||
"status": "{in_progress | completed | failed | stopped | partial}",
|
||||
"steps": {
|
||||
"1": { "status": "pending", "attempts": 0, "fix_rounds": 0, "start_sha": null, "report_path": null, "red_proof": null, "test_lock": null, "error": null, "completed_at": null, "commit": null }
|
||||
"1": { "status": "pending", "attempts": 0, "fix_rounds": 0, "start_sha": null, "report_path": null, "red_proof": null, "test_lock": null, "step_check": null, "regression": null, "error": null, "completed_at": null, "commit": null }
|
||||
},
|
||||
"iterations_remaining": 25,
|
||||
"plan_approval": null,
|
||||
"entry_condition_checked": false,
|
||||
"exit_condition_checked": false,
|
||||
"plan_verification": null,
|
||||
"end_to_end": null,
|
||||
"arch_rules": null,
|
||||
"scope_sweep": null,
|
||||
"simplify": null,
|
||||
"review": null,
|
||||
"decisions": [],
|
||||
"summary": null
|
||||
}
|
||||
|
|
@ -1082,6 +1172,24 @@ a progress file without them still validates and resumes:
|
|||
`progress-validator.mjs` rejects `fix_rounds` outside 0–5 (there is no sixth
|
||||
round) and a `decisions` entry without its `cost_if_wrong`.
|
||||
|
||||
**The coupling fields (Handover 5, wave 2, additive-optional).** Each is a
|
||||
contract CLI's exit said in words. A progress file without them still
|
||||
validates and resumes; when present, `progress-validator.mjs` rejects any
|
||||
other value:
|
||||
|
||||
| Field | Written | Values (exit 0 / 1 / 2) |
|
||||
|---|---|---|
|
||||
| `plan_approval` | Phase 1 (K2) | `"approved"`, or `"not-checked"` for a session spec |
|
||||
| `steps.{N}.step_check` | Sub-step D4 (K9) | `"clean"` / `"findings"` / `"not-measured"` |
|
||||
| `steps.{N}.regression` | Sub-step D4 (K10) | `"passed"` or `"absent"` (no field) / `"failed"` or `"unrunnable"` / `"not-run"` |
|
||||
| `arch_rules` | Phase 7 (K5) | `"pass"` / `"violation"` / `"not-measured"` |
|
||||
| `scope_sweep` | Phase 7 (K6) | `{status: "pass" \| "deviation" \| "not-measured", outside: [paths]}` |
|
||||
| `simplify` | Phase 7.2 | `"applied"` \| `"clean"` \| `"reverted"` \| `"not-run"` |
|
||||
| `review` | Phase 7.2 (K7) | `{round, path, gate_exit: 0 \| 1 \| 2, verdict}`; `round` counts reviews from 1 |
|
||||
|
||||
`"not-measured"` and `"not-run"` are never green: the final report says them
|
||||
in those words, never "pass".
|
||||
|
||||
`plan_version` is the value Phase 2 parsed from the plan header (required by
|
||||
`progress-validator.mjs`). The status words here are the validator's:
|
||||
`PROGRESS_VALID_STATUSES` for the run, `PROGRESS_STEP_STATUSES` for a step.
|
||||
|
|
@ -1242,7 +1350,10 @@ Every stop names its class in the final report (Phase 8 `### Stops`).
|
|||
guesswork), a step went up the fix ladder (Sub-step F), the global
|
||||
iteration budget reached 0 — and the plan's own preconditions failing: an
|
||||
unmet entry condition (Phase 4), or the sandbox pre-flight exiting 77
|
||||
(Sub-step D1: the plan cannot be carried out where it runs).
|
||||
(Sub-step D1: the plan cannot be carried out where it runs). The review
|
||||
loop's stops are this class too (Phase 7.2): a BLOCK on the wave path,
|
||||
remediation steps that are not executable, the plan not approved again,
|
||||
and a BLOCK at the review cap.
|
||||
|
||||
Every stop the executor can make maps to one of these four; `stop_class` in
|
||||
the summary is that number.
|
||||
|
|
@ -1329,6 +1440,11 @@ The step's test is written first, and a command proves it red — never the
|
|||
model's word that "it failed first". This sub-step blocks: a step whose test
|
||||
is not proven red at its start point and green at its result does not pass.
|
||||
|
||||
**How to write the test first is the `test-first` skill** (S2, Voyage plugin:
|
||||
`voyage:test-first`): the build helper is told it by name (Sub-step C). The
|
||||
skill is the advice; the gate below, `red-proof.mjs`, is the guarantee, and it
|
||||
stays a command whatever the skill says.
|
||||
|
||||
**The step's test files** are the `File:` paths under its `Test first:`. The
|
||||
plan template lists them apart from `Files:`, so everywhere below **the step's
|
||||
scope = its `Files:` + its test files**: both are built, both are committed at
|
||||
|
|
@ -1391,10 +1507,12 @@ Launch `subagent_type: "voyage:step-implementer"` with a short `description`
|
|||
summary: the plan path, `plan_version`, the step number, the step's text
|
||||
verbatim (every field, including `Interface in:`, `Interface out:` and
|
||||
`Review focus:` when present), `start_sha`, `report_path`, `mode: build`, the
|
||||
round label (`build`, or `fix round R`), and `voyage_root: ${CLAUDE_PLUGIN_ROOT}`.
|
||||
round label (`build`, or `fix round R`), `voyage_root: ${CLAUDE_PLUGIN_ROOT}`,
|
||||
and the skill to work by: `skill: test-first` for a build round,
|
||||
`skill: debugging` for a fix round (S2; Sub-steps B and F).
|
||||
|
||||
**A step at `fix_rounds` 5 is never built again** (Sub-step F): skip the
|
||||
launch and run only the checks (B, D1, D2, D3, E) on whatever the operator
|
||||
launch and run only the checks (B, D1, D2, D3, D4, E) on whatever the operator
|
||||
changed. A step at `fix_rounds` 4 on `--resume` was re-planned: launch a fresh
|
||||
`build` helper for the revised step.
|
||||
|
||||
|
|
@ -1561,10 +1679,62 @@ It reports a removed assert, a deleted or skipped test, a muted check
|
|||
A finding is never dropped silently: the step report and the final report list
|
||||
every one with its ruling.
|
||||
|
||||
#### Sub-step D4 — Step check and regression
|
||||
|
||||
Two more commands at step end, before the controller and before the
|
||||
checkpoint (Sub-step G). Run them in one Bash call. The plugin root is resolved
|
||||
first, as in Phase 7: an unresolved root would make `node` exit 1, which reads
|
||||
as a finding when it is in fact a check that never ran.
|
||||
|
||||
```bash
|
||||
# zsh (the Bash tool runs the user's shell): an unmatched glob stays literal, as in bash.
|
||||
[ -n "${ZSH_VERSION:-}" ] && setopt nonomatch
|
||||
VOYAGE_ROOT="${CLAUDE_PLUGIN_ROOT}"
|
||||
case "$VOYAGE_ROOT" in
|
||||
/*) ;;
|
||||
*) VOYAGE_ROOT="$(ls -d "$HOME"/.claude/plugins/cache/*/voyage/[0-9]*/ 2>/dev/null \
|
||||
| awk -F/ '{ split($(NF-1), v, "."); printf "%09d%09d%09d %s\n", v[1], v[2], v[3], $(0) }' \
|
||||
| sort | tail -1 | cut -d' ' -f2-)"
|
||||
VOYAGE_ROOT="${VOYAGE_ROOT%/}" ;;
|
||||
esac
|
||||
CHECK_CWD="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
||||
if [ ! -f "$VOYAGE_ROOT/lib/verification/step-check.mjs" ]; then
|
||||
echo "[voyage] step check not run - plugin root unresolved (exit 2)"
|
||||
else
|
||||
node "$VOYAGE_ROOT/lib/verification/step-check.mjs" --cwd "$CHECK_CWD"; echo "step-check exit $?"
|
||||
fi
|
||||
if [ ! -f "$VOYAGE_ROOT/lib/verification/regression-check.mjs" ]; then
|
||||
echo "[voyage] regression not run - plugin root unresolved (exit 2)"
|
||||
else
|
||||
node "$VOYAGE_ROOT/lib/verification/regression-check.mjs" --plan "{plan_path}" --step {N} --cwd "$CHECK_CWD" --json; echo "regression exit $?"
|
||||
fi
|
||||
```
|
||||
|
||||
**The step check (K9, P7)** runs the target repo's OWN formatters, linters and
|
||||
type checkers over the step's changes (the diff against `HEAD` plus untracked
|
||||
files: nothing of the step is committed yet) and reads the post-edit guard's
|
||||
log. It installs nothing.
|
||||
|
||||
| Exit | `steps.{N}.step_check` | Executor |
|
||||
|------|------------------------|----------|
|
||||
| 0 | `"clean"` | continue |
|
||||
| 1 | `"findings"` | every finding goes to the controller (Sub-step E) as a quality finding; the round does not pass |
|
||||
| 2 | `"not-measured"` | the repo has no tool for these files, or one could not run. Write "step check: not measured — {reason}" in the step report. Not a finding, and never green: the final report counts the step among those whose step check was not measured |
|
||||
|
||||
**The regression (K10)** runs the step's `Regression:` command through the
|
||||
criteria runner's two screens (the allowlist of test runners, then the
|
||||
executor denylist), before the checkpoint:
|
||||
|
||||
| Exit | `steps.{N}.regression` | Executor |
|
||||
|------|------------------------|----------|
|
||||
| 0 | `"passed"`, or `"absent"` when the step has no `Regression:` (nothing new happens) | continue |
|
||||
| 1 | `"failed"` (the suite went red) or `"unrunnable"` (not one allowlisted command; `"blocked"` by the denylist counts here too) | the step is not done: a finding for Sub-step F, exactly like a red Verify — it goes into the fix rounds, never to the checkpoint |
|
||||
| 2 | `"not-run"` | the CLI could not run (plan unreadable, no such step): a finding for Sub-step F, never green |
|
||||
|
||||
#### Sub-step E — Control
|
||||
|
||||
**One controller, two verdicts.** After the mechanical checks (B, D1, D2, D3)
|
||||
— whatever they said — launch one controller with the Agent tool:
|
||||
**One controller, two verdicts.** After the mechanical checks (B, D1, D2, D3,
|
||||
D4) — whatever they said — launch one controller with the Agent tool:
|
||||
`subagent_type: "general-purpose"`, `model: "opus"`, no `name` (Sub-step C),
|
||||
no `isolation`. It is a general helper with the prompt below, not a second
|
||||
Voyage agent. The Agent tool sets no reasoning effort, so the controller runs
|
||||
|
|
@ -1589,7 +1759,10 @@ The step, verbatim:
|
|||
Review focus from the plan (look hardest here): {Review focus, or "none"}
|
||||
|
||||
Mechanical results: proof of red {red-proof JSON}; Verify exit {code};
|
||||
manifest {pass | fail + drift}; floor guard {test-integrity JSON}.
|
||||
manifest {pass | fail + drift}; floor guard {test-integrity JSON};
|
||||
step check {step-check JSON}; regression {regression-check JSON}.
|
||||
Every step-check finding is a quality finding unless you show it is not in
|
||||
this step's diff.
|
||||
|
||||
Give two verdicts:
|
||||
- "spec": does the diff do what the step's Changes, Files and Interface say -
|
||||
|
|
@ -1613,7 +1786,8 @@ Append both verdicts, the findings and `not_judged` to the step report.
|
|||
**The round passes** only when ALL hold: `red_proof` is `"proven"` (or, below
|
||||
plan_version 1.8, the tolerated `"not-run"` of Sub-step B), Verify PASS,
|
||||
manifest pass, every floor-guard finding ruled `justified` (and the guard
|
||||
ran), `"spec": "pass"` and `"quality": "pass"`. Then go to Sub-step G.
|
||||
ran), `step_check` is `"clean"` or `"not-measured"` (Sub-step D4), `regression` is `"passed"` or `"absent"`,
|
||||
`"spec": "pass"` and `"quality": "pass"`. Then go to Sub-step G.
|
||||
Otherwise go to Sub-step F with every finding.
|
||||
|
||||
**Re-control after a fix** checks only what was fixed. A fresh controller gets
|
||||
|
|
@ -1635,6 +1809,11 @@ It does not re-judge the rest of the step.
|
|||
|
||||
#### Sub-step F — Fix rounds
|
||||
|
||||
**How a fix helper finds the cause is the `debugging` skill** (S2, Voyage
|
||||
plugin: `voyage:debugging`): it is told it by name (Sub-step C,
|
||||
`skill: debugging`). The skill is the advice; the ladder below and the checks
|
||||
that re-run after every round are the guarantee.
|
||||
|
||||
A failed round never loops sideways. **The fix rounds are capped at maximum 5 fix rounds per step, and they escalate upward** — to a fresh helper, then to
|
||||
the plan, then to the operator. The executor does not improvise a new design,
|
||||
and there is no ladder of cheaper models: every helper and every controller
|
||||
|
|
@ -1643,9 +1822,9 @@ session's effort, Sub-step E).
|
|||
|
||||
| `fix_rounds` | Goes to | What happens |
|
||||
|---|---|---|
|
||||
| 1, 2, 3 | a fresh helper | First `node ${CLAUDE_PLUGIN_ROOT}/lib/util/test-lock.mjs pending` (the step is no longer seen green; D1 marks it again). Launch `voyage:step-implementer` again (no `name`, no `isolation`), `mode: fix`, with the findings (file, line, problem, fix) and the mechanical failures. It fixes only those; the tests stay locked (except in a `not-red-at-base` round, Sub-step B). Then B (both ways), D1, D2, D3 run again and a fresh controller re-controls only what was fixed (Sub-step E). `attempts` +1; `iterations_remaining` −1 |
|
||||
| 1, 2, 3 | a fresh helper | First `node ${CLAUDE_PLUGIN_ROOT}/lib/util/test-lock.mjs pending` (the step is no longer seen green; D1 marks it again). Launch `voyage:step-implementer` again (no `name`, no `isolation`), `mode: fix`, with the findings (file, line, problem, fix) and the mechanical failures. It fixes only those; the tests stay locked (except in a `not-red-at-base` round, Sub-step B). Then B (both ways), D1, D2, D3, D4 run again and a fresh controller re-controls only what was fixed (Sub-step E). `attempts` +1; `iterations_remaining` −1 |
|
||||
| 4 | the plan | **After three failed fix rounds the question is the architecture, not a fourth fix.** The step goes back to planning (below). 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, E) on whatever the operator changed. If they fail again the step stays at 5 — there is no sixth round, `fix_rounds` never exceeds 5 — and the run stops again. Stop class 4 |
|
||||
| 5 | the operator | A step already sent to the plan (`fix_rounds` 4) that fails its control again after re-planning goes to the operator. The executor never builds this step again: on a later `--resume` it only runs the checks (B, D1, D2, D3, D4, E) on whatever the operator changed. If they fail again the step stays at 5 — there is no sixth round, `fix_rounds` never exceeds 5 — and the run stops again. Stop class 4 |
|
||||
|
||||
**Back to planning** (`fix_rounds = 4`): save `git diff {start_sha}` to
|
||||
`{report dir}/step-{N}-upward.diff` and write `{report dir}/step-{N}-upward.md`:
|
||||
|
|
@ -1801,6 +1980,8 @@ carrying a second copy of the denylist.)
|
|||
# indistinguishable from a criterion that failed.
|
||||
# Only the literal form is substituted (a `:-` default is never replaced); the
|
||||
# fallback takes the highest installed version under cache/<market>/voyage/.
|
||||
# zsh (the Bash tool runs the user's shell): an unmatched glob stays literal, as in bash.
|
||||
[ -n "${ZSH_VERSION:-}" ] && setopt nonomatch
|
||||
VOYAGE_ROOT="${CLAUDE_PLUGIN_ROOT}"
|
||||
case "$VOYAGE_ROOT" in
|
||||
/*) ;;
|
||||
|
|
@ -1854,7 +2035,12 @@ Record in the progress file (additive-optional; unknown keys are tolerated by
|
|||
|
||||
- `plan_verification.status` — `"passed" | "failed" | "not-run"`
|
||||
- `plan_verification.summary` — `{total, passed, failed, blocked, unrunnable}`
|
||||
- `plan_verification.failed_criteria` — `[{label, command, exit_code}]`
|
||||
- `plan_verification.failed_criteria` — `[{label, command, exit_code}]`, one
|
||||
per runner result whose `status` is not `passed`. The runner's JSON says
|
||||
`exitCode`; progress says `exit_code`, so translate the key explicitly,
|
||||
`exitCode` → `exit_code`, and copy `null` as `null` (a blocked or
|
||||
unrunnable criterion never ran). Never write the runner's key into progress:
|
||||
`progress-validator.mjs` rejects it (`PROGRESS_FAILED_CRITERIA_KEY`, C15).
|
||||
|
||||
**A failing criterion FELLS the run.** It is not "recorded and included in the
|
||||
final report": the executor MUST NOT emit `result: completed` while
|
||||
|
|
@ -1883,6 +2069,188 @@ 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`.)
|
||||
|
||||
### Architecture rules (K5) and the plan-file sweep (K6)
|
||||
|
||||
Two whole-run checks, for a trekplan and a session spec alike, in one Bash
|
||||
call. Shell state does not survive between Bash calls, so the block resolves
|
||||
the plugin root itself; an unresolved root prints exit 2, never a `node`
|
||||
exit 1 that would read as a violation.
|
||||
|
||||
```bash
|
||||
# zsh (the Bash tool runs the user's shell): an unmatched glob stays literal, as in bash.
|
||||
[ -n "${ZSH_VERSION:-}" ] && setopt nonomatch
|
||||
VOYAGE_ROOT="${CLAUDE_PLUGIN_ROOT}"
|
||||
case "$VOYAGE_ROOT" in
|
||||
/*) ;;
|
||||
*) VOYAGE_ROOT="$(ls -d "$HOME"/.claude/plugins/cache/*/voyage/[0-9]*/ 2>/dev/null \
|
||||
| awk -F/ '{ split($(NF-1), v, "."); printf "%09d%09d%09d %s\n", v[1], v[2], v[3], $(0) }' \
|
||||
| sort | tail -1 | cut -d' ' -f2-)"
|
||||
VOYAGE_ROOT="${VOYAGE_ROOT%/}" ;;
|
||||
esac
|
||||
CHECK_CWD="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
||||
if [ ! -f "$VOYAGE_ROOT/lib/verification/arch-rules.mjs" ]; then
|
||||
echo "[voyage] architecture rules not run - plugin root unresolved"; echo "arch-rules exit 2"
|
||||
else
|
||||
node "$VOYAGE_ROOT/lib/verification/arch-rules.mjs" --repo "$CHECK_CWD"; echo "arch-rules exit $?"
|
||||
fi
|
||||
if [ ! -f "$VOYAGE_ROOT/lib/verification/plan-scope-sweep.mjs" ]; then
|
||||
echo "[voyage] plan-file sweep not run - plugin root unresolved"; echo "scope-sweep exit 2"
|
||||
else
|
||||
node "$VOYAGE_ROOT/lib/verification/plan-scope-sweep.mjs" --plan "{plan_path}" --base "{session_start_sha}" --repo "$CHECK_CWD" \
|
||||
--process .claude/voyage/approvals.jsonl --process .claude/rules/voyage-review-lessons.md {--process CLAUDE.md, see below}; echo "scope-sweep exit $?"
|
||||
fi
|
||||
```
|
||||
|
||||
`{--process CLAUDE.md, see below}` is filled in, or left out, before the call:
|
||||
it is passed only when every commit since the start point that touches
|
||||
`CLAUDE.md` is a review-lessons commit (Phase 7.2), which
|
||||
`git log --format=%s {session_start_sha}..HEAD -- CLAUDE.md` shows. A plan
|
||||
step's own change to `CLAUDE.md` is never hidden by it.
|
||||
|
||||
**K5, the architecture rules (P3/P7)**, runs the target repo's OWN
|
||||
architecture tool (dependency-cruiser, import-linter) and installs nothing:
|
||||
|
||||
| Exit | `arch_rules` | Executor |
|
||||
|------|--------------|----------|
|
||||
| 0 | `"pass"` | continue |
|
||||
| 1 | `"violation"` | a finding in the final report with every violation; the completion gate turns `completed` into `partial` (clause 5) |
|
||||
| 2 | `"not-measured"` | the repo has no such tool, or it could not run. The report says "architecture rules: not measured — {reason}", never "pass". Not a finding |
|
||||
|
||||
**K6, the plan-file sweep (P3)**, from the run's start point
|
||||
(`session_start_sha`, Phase 3): every file the run changed must be named by
|
||||
the plan (a step's `Files:`, its `Test first:` file, its manifest) or be a
|
||||
process file. Voyage's own approval log and the review's project-memory file
|
||||
(and its one-line `CLAUDE.md` pointer, as above) are passed as process files:
|
||||
the run writes them, the plan does not name them.
|
||||
|
||||
| Exit | `scope_sweep` | Executor |
|
||||
|------|---------------|----------|
|
||||
| 0 | `{status: "pass", outside: []}` | continue |
|
||||
| 1 | `{status: "deviation", outside: [...]}` | a finding in the final report with the file list (`outside`), never a silent warning; the completion gate turns `completed` into `partial` (clause 5). A deviation is cleared only by the plan naming the file in the same commit |
|
||||
| 2 | `{status: "not-measured", outside: []}` | it could not run (not a git repo, an unknown start point). "plan-file sweep: not measured" in the report, never "pass" |
|
||||
|
||||
## Phase 7.2 — Simplify, review, verdict (P11)
|
||||
|
||||
**Runs only in the closing run, and only when Phase 7 ran** (every step
|
||||
passed). The closing run is the one that ends a trekplan: a single-session
|
||||
execute or resume of the whole plan, or the wave parent once every wave is
|
||||
merged (Phase 2.6 Step 6). It is never a `--step N` run, a `--session N`
|
||||
child or a session spec: those deliver a part, the review is of the whole
|
||||
delivery, and a wave session's work is reviewed once the wave is merged
|
||||
(Phase 8 leaves their result alone). It starts by itself after the last step,
|
||||
never typed by the operator, in this order (Cherny's):
|
||||
|
||||
1. **run the thing** — Phase 7's `## Verification` and its `End-to-end:`
|
||||
criterion already ran (on the wave path: Step 4, which runs the same
|
||||
criteria runner). `end_to_end` must be `"verified"`; otherwise skip the rest
|
||||
of this phase (`review = {round: 1, path: null, gate_exit: 2, verdict: null}`:
|
||||
there is nothing proven to review) and go to Phase 7.5.
|
||||
2. **`/simplify`** — Claude Code's bundled cleanup skill: four agents look at
|
||||
reuse, simplification, efficiency and altitude, and it applies the fixes.
|
||||
Measured 2026-10-08: the Skill tool starts it from the session. It reviews
|
||||
"the changed code": its upstream range, else `main...HEAD` or `HEAD~1`, plus
|
||||
uncommitted changes, or the PR, branch or path given as its argument. Give
|
||||
it the run as its argument:
|
||||
`Skill("simplify", args: "the changes of this run: git diff {session_start_sha}..HEAD — files: {git diff --name-only {session_start_sha} HEAD}")`.
|
||||
Wait for it to finish. Then, from the working tree:
|
||||
- **Outside the run's changed files:** every path it changed that is not
|
||||
in `git diff --name-only {session_start_sha} HEAD` is restored
|
||||
(`git checkout -- {path}`; a new file it made is removed from the index
|
||||
with `git rm --cached -q -- {path}` and left on disk), and listed in the
|
||||
final report. Nothing it did is dropped silently.
|
||||
- **Nothing changed:** `simplify = "clean"`.
|
||||
- **Changes:** run Phase 7's criteria runner again (same block, same exit
|
||||
table). Exit 0 → stage exactly the changed paths and commit them as
|
||||
`refactor: simplify pass (trekexecute)`; `simplify = "applied"`. Exit 1 or
|
||||
2 → restore every path it changed as above, `simplify = "reverted"`, and
|
||||
say why in the report: the cleanup broke the verification.
|
||||
- **It could not start** (the Skill tool refused, the skill is not there):
|
||||
`simplify = "not-run"`, with the reason. Not a stop: `/simplify` is a
|
||||
cleanup pass, not a gate. Continue.
|
||||
3. **`/trekreview`** — Voyage's review gate: Claude Code's `/code-review` and
|
||||
`/security-review` as the engine, then Voyage's control against the brief,
|
||||
which is what stops. Start it through the Skill tool, with the run's range:
|
||||
`Skill("voyage:trekreview", args: "--project {project_dir} --since {session_start_sha}")`.
|
||||
A plugin command is a skill the model may start unless it carries
|
||||
`disable-model-invocation`, which `/trekreview` does not (the Claude Code
|
||||
skills documentation, read 2026-10-08). Follow its phases to the end in
|
||||
this session; its own questions (project-memory lines, one by one) are its
|
||||
own. **Without `--project`** there is no brief to review against:
|
||||
`review = {round: 1, path: null, gate_exit: 2, verdict: null}`, "review not
|
||||
run — no project directory", and the merge stays closed (Phase 8).
|
||||
**Its writes are committed.** `/trekreview` may have written approved
|
||||
project-memory lines (`.claude/rules/voyage-review-lessons.md`) and a
|
||||
one-line pointer in `CLAUDE.md`. Commit exactly those paths, when they
|
||||
changed, as `docs(voyage): review lessons (round {R})`: a process commit
|
||||
(Phase 7.5 step 3). Nothing the review wrote is left uncommitted for the
|
||||
next round's checks or for the merge.
|
||||
4. **`verdict-gate.mjs`** — the review's verdict decides, as a command:
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/review/verdict-gate.mjs --json "{project_dir}/review.md"
|
||||
```
|
||||
Record `review = {round, path, gate_exit, verdict}`. Exit 0 (ALLOW/WARN) →
|
||||
Phase 7.5. Exit 2 (could not decide, or a reviewer or engine that did not
|
||||
run) → Phase 7.5 with the merge closed: never green. Exit 1 (BLOCK) → the
|
||||
loop below.
|
||||
|
||||
**A BLOCK is fixed by new plan steps, in a bounded loop.** The review writes
|
||||
every BLOCKER as a numbered `### Step R<n>` under `## Remediation Steps`
|
||||
(Handover 6: Files, Source finding, Test first, Verify, On failure). The fix is
|
||||
those steps, not improvised. Every stop below is stop class 4 (Phase 5.5):
|
||||
the run is `stopped`, and the merge stays closed.
|
||||
|
||||
0. **On the wave path there is no loop.** The plan's sha256 binds the
|
||||
merged-waves record (Phase 2.6), so new steps would strand the waves. Stop,
|
||||
and tell the operator to re-plan from the review: `/trekplan --brief
|
||||
{project_dir}/review.md` (Handover 6's remediation plan).
|
||||
1. Copy the approved plan aside: `{report dir}/plan-approved-round-{R}.md`. It
|
||||
is the text the log approved, and it is how every stop below leaves the plan
|
||||
matching its approval.
|
||||
2. Append each `### Step R<n>` to the plan's `## Implementation Plan` as the
|
||||
next numbered step (`### Step {total + 1}: …`, then +2, …), its fields
|
||||
copied as they are, never reworded. Add a `Checkpoint:`
|
||||
(`git commit -m "fix(review): {title} [{finding id, first 8}]"`), and on a
|
||||
plan_version ≥ 1.7 plan the manifest the legacy rule synthesizes
|
||||
(Phase 2: `expected_paths` ← its Files, …): a rule already in this file, not
|
||||
a new design. Earlier steps are never touched.
|
||||
3. Check that the new steps can run, before anyone is asked:
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/plan-validator.mjs --strict --json "{plan_path}"
|
||||
```
|
||||
Any exit but 0 (a `Verify:` that is not one test-runner call, a
|
||||
`Test first:` without its `File:`, …): restore the plan from the copy,
|
||||
save the appended text as `{report dir}/review-round-{R}-steps.md`, and
|
||||
stop. Say that the review's remediation steps are not executable as plan
|
||||
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:
|
||||
**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}"`
|
||||
and `artifact-commit.mjs --store {artifact_store} --artifact "{plan_path}" --message "docs(voyage): approve plan {slug}"`.
|
||||
This is an approval of new plan content, not an execution decision
|
||||
(Hard rule 1), and not a merge: no guard asks it.
|
||||
5. **Approve** → add the new steps to progress (`steps.{M}` pending,
|
||||
`total_steps` raised), run Phase 6 for them (the same loop: helper, checks,
|
||||
controller, fix rounds), then Phase 7, then this phase again with
|
||||
`review.round` + 1. **Not yet**, or an answer that is not an approval →
|
||||
restore the plan from the copy, save the appended text as
|
||||
`{report dir}/review-round-{R}-steps.md`, and stop. A later `--resume`
|
||||
finds the plan as approved and comes back to this review.
|
||||
|
||||
**The cap:** `TREKEXECUTE_MAX_REVIEW_ROUNDS` reviews per run, default **3**
|
||||
(the first review and two fix loops; env-overridable).
|
||||
A BLOCK at the cap goes to the operator: stop, and the report names every
|
||||
BLOCKER still open. Each loop also takes 1 from `iterations_remaining`, which
|
||||
bounds it a second time (the completion gate's clause 3 counts it).
|
||||
|
||||
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
|
||||
Kiro's `feature-pipeline` gate and Spec Kit's `/converge` loop (MIT); that no
|
||||
finding is dropped silently is Superpowers' (MIT). Ideas only.
|
||||
|
||||
## Phase 7.5 — Manifest audit (independent)
|
||||
|
||||
**Runs for all modes except dry-run.** This is the last-line-of-defense
|
||||
|
|
@ -1902,11 +2270,19 @@ produces an independent truth based on objective state, not self-narrative.
|
|||
|
||||
3. **Commit-count check:** count commits since session-start
|
||||
(`git rev-list --count {session_start_sha}..HEAD`). Expected count =
|
||||
number of steps with `status=completed` (excludes Step 0 if sandbox_preflight).
|
||||
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
|
||||
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
|
||||
Phase 8 merge comes after the audit.)
|
||||
|
||||
4. **Commit-message pattern sweep:** walk `git log {session_start_sha}..HEAD` and
|
||||
confirm each commit matches one of the declared
|
||||
`commit_message_patterns` (in any order — order is advisory).
|
||||
`commit_message_patterns` (in any order — order is advisory). The process
|
||||
commits of step 3 match their own fixed messages above.
|
||||
|
||||
5. **Bash syntax sweep:** for every `.sh` file in
|
||||
`git diff --name-only {session_start_sha} HEAD`, run `bash -n`. Collect failures.
|
||||
|
|
@ -1960,7 +2336,8 @@ Record in progress file:
|
|||
> 3. **Counter liveness (deterministic, defeats "never decremented")** — the gate
|
||||
> reconciles the budget counter against the ground-truth counters the Phase 7.5
|
||||
> audit already derives:
|
||||
> `iterations_remaining == TREKEXECUTE_MAX_RECOVERY_ITERATIONS − (recovery_depth + Σ steps.*.attempts-beyond-first)`.
|
||||
> `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,
|
||||
|
|
@ -1968,6 +2345,15 @@ Record in progress file:
|
|||
> 4. **End to end** — for a trekplan, Phase 7's `end_to_end == "verified"`.
|
||||
> `"not-verified"` OVERRIDES `completed → partial` with the reason
|
||||
> "not verified end to end": tests that pass are not the thing running.
|
||||
> 5. **Architecture and plan scope** — Phase 7's `arch_rules` is not
|
||||
> `"violation"` and `scope_sweep.status` is not `"deviation"`. Either one
|
||||
> OVERRIDES `completed → partial`, and the report lists the violations or
|
||||
> the files outside the plan. `"not-measured"` does not override, and it is
|
||||
> never reported as a pass.
|
||||
>
|
||||
> 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
|
||||
|
|
@ -2085,22 +2471,122 @@ Run each event call as its own command, never chained with `&&`. The writer
|
|||
prints `{"written": true, ...}`. On `"written": false` (exit 1), print its
|
||||
`reason` in one line and continue — an event failure never blocks the gate.
|
||||
|
||||
Pause for operator confirmation via `AskUserQuestion`:
|
||||
**The gate is a lock, not a question** (operator decision 2026-10-08). The
|
||||
guard (`pre-bash-executor`) asks the operator before every merge into main and
|
||||
every push, in auto mode too. When the executor first asked "ready to merge?"
|
||||
itself, one merge cost two questions. Now the executor decides whether the
|
||||
merge may run at all, and the guard's question is the one question.
|
||||
|
||||
**Question:** "All waves merged. Ready to proceed with the main-merge step
|
||||
and finalize the run?"
|
||||
**Only the closing run passes the lock** (Phase 7.2: a single-session run of
|
||||
the whole plan, or the wave parent after the last wave). A `--session N`
|
||||
child, a `--step N` run and a session spec deliver a part: they emit no gate
|
||||
event, merge nothing, and their result is left as Phase 7.5 decided it
|
||||
(`main_merge: "n/a"`). A wave session's `completed` must reach its parent as
|
||||
`completed`, or Step 3 would read the wave as failed.
|
||||
|
||||
| Option | Action |
|
||||
|--------|--------|
|
||||
| **Confirm — main-merge** | Emit `main-merge-approved`, proceed to write progress + state files. |
|
||||
| **Decline — hold for review** | Emit `main-merge-declined`. Nothing is finalized: the run is recorded as `partial`. The commits stay where they are for review — on the multi-session wave path the waves are already merged on the current branch, and the merged-waves record is kept. |
|
||||
**1. The verdict.** Decide with the review's verdict as a command, never from
|
||||
a summary:
|
||||
|
||||
This pause is **always on**, regardless of `gates_mode`. The `--gates` flag
|
||||
re-enables earlier per-wave pauses; this gate is the one boundary that
|
||||
ALWAYS pauses on every run — it is the safety stop between completed waves
|
||||
and the merge that publishes the integrated result.
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/review/verdict-gate.mjs --json "{review_path}"
|
||||
```
|
||||
|
||||
`{review_path}` is `review.path` from Phase 7.2. No review (Phase 7.2 did not
|
||||
run, ran without a project directory, or the run stopped before it) counts as
|
||||
exit 2 without the call.
|
||||
|
||||
| Exit | Verdict | Merge |
|
||||
|------|---------|-------|
|
||||
| 0 | ALLOW / WARN | 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 |
|
||||
|
||||
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
|
||||
`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.
|
||||
|
||||
**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:
|
||||
|
||||
```bash
|
||||
git rev-parse --abbrev-ref HEAD
|
||||
for b in main master; do git rev-parse -q --verify "refs/heads/$b" >/dev/null && echo "MAIN=$b" && break; done
|
||||
git worktree list --porcelain
|
||||
```
|
||||
|
||||
- **No `MAIN=` line** (the repo has neither branch): there is nothing to merge
|
||||
into. Emit `main-merge-declined` with the reason `no-main-branch`.
|
||||
- **The run's branch IS main (or master):** there is nothing to merge; the
|
||||
commits are already on main. Emit `main-merge-approved` and say in the
|
||||
report that the review ran after the commits landed on main and could not
|
||||
hold them back: run on a branch when the review should gate the merge.
|
||||
- **Another branch:** merge it into main in ONE Bash call, with the values
|
||||
written out as literals (the guard reads the command; it cannot follow a
|
||||
shell variable). When `git worktree list` shows main checked out in a
|
||||
worktree, merge there:
|
||||
```bash
|
||||
if git -C "{main worktree path}" merge --no-ff "{run branch}" -m "merge: trekexecute {slug}"; then
|
||||
echo "MERGED"
|
||||
elif git -C "{main worktree path}" rev-parse -q --verify MERGE_HEAD >/dev/null; then
|
||||
git -C "{main worktree path}" diff --name-only --diff-filter=U
|
||||
git -C "{main worktree path}" merge --abort
|
||||
echo "MERGE_CONFLICT"
|
||||
else
|
||||
echo "MERGE_REFUSED"
|
||||
fi
|
||||
```
|
||||
Otherwise switch this tree to main, and back to the run's branch on any
|
||||
failure:
|
||||
```bash
|
||||
git switch "{main}" || { echo "MERGE_REFUSED"; exit 1; }
|
||||
if git merge --no-ff "{run branch}" -m "merge: trekexecute {slug}"; then
|
||||
echo "MERGED"
|
||||
elif git rev-parse -q --verify MERGE_HEAD >/dev/null; then
|
||||
git diff --name-only --diff-filter=U
|
||||
git merge --abort
|
||||
git switch "{run branch}"
|
||||
echo "MERGE_CONFLICT"
|
||||
else
|
||||
git switch "{run branch}"
|
||||
echo "MERGE_REFUSED"
|
||||
fi
|
||||
```
|
||||
The guard answers "ask" before it runs (measured 2026-10-08/09 against
|
||||
both blocks: they ask, and a merge into another branch does not). The
|
||||
operator's answer there is the one question; the post-approval hook logs it
|
||||
as a `merge` approval (K2). The executor asks nothing before or after it. In
|
||||
the permission mode where Claude Code asks nothing, the guard refuses the
|
||||
call instead, because there is no one to ask (operator decision 2026-10-09).
|
||||
- `MERGED` → emit `main-merge-approved`.
|
||||
- The call did not run (the operator declined at the guard's question, or
|
||||
the guard refused it) → emit `main-merge-declined` with the reason
|
||||
`hold-for-review`; the run is `partial`, and the commits stay on the run's
|
||||
branch. When the guard refused, say so, and that the operator merges by
|
||||
hand.
|
||||
- `MERGE_CONFLICT` → list the conflicting files the block printed (the
|
||||
merge is already aborted), emit `main-merge-declined` with the reason
|
||||
`merge-conflict`; the run is `partial`. Never resolve a conflict with
|
||||
`-X ours/theirs`.
|
||||
- `MERGE_REFUSED` → git refused to start (local changes in the main tree, or
|
||||
untracked files in the way: its message says which). Emit
|
||||
`main-merge-declined` with the reason `merge-refused` and report git's
|
||||
message; the run is `partial`.
|
||||
|
||||
This gate is **always on**, regardless of `gates_mode`: a lock that every run
|
||||
passes through, the one boundary that ALWAYS pauses on every run, either at
|
||||
its lock or at the guard's question. It is the safety stop between completed
|
||||
work and the merge that publishes the integrated result. On the multi-session
|
||||
wave path the waves are already merged on the current branch, the branch the
|
||||
run started on (Phase 2.6 Step 3, one guard question per session); when that
|
||||
branch is not main, this gate merges it into main like a single-session run,
|
||||
and the merged-waves record is kept until the confirm below.
|
||||
|
||||
On confirm:
|
||||
(the merge ran, or there was nothing to merge)
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/stats/event-emit.mjs --event main-merge-approved --payload - <<'JSON'
|
||||
{"plan":"{plan-path}"}
|
||||
|
|
@ -2117,21 +2603,25 @@ cleared:
|
|||
```
|
||||
|
||||
On decline:
|
||||
(a closed lock, or the guard's question declined, or a conflict)
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/stats/event-emit.mjs --event main-merge-declined --payload - <<'JSON'
|
||||
{"plan":"{plan-path}","reason":"hold-for-review"}
|
||||
{"plan":"{plan-path}","reason":"{reason}"}
|
||||
JSON
|
||||
```
|
||||
|
||||
The reason is the literal label of the one decline option, never the
|
||||
operator's free text (free text breaks the JSON).
|
||||
The reason is one of the literal words above (`review-block`,
|
||||
`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.
|
||||
|
||||
**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 AskUserQuestion. On the multi-session wave path,
|
||||
to the main-merge gate: the lock reads `review.md` again (a review fixed or
|
||||
re-run in between counts), and when it opens, the guard asks again. On the
|
||||
multi-session wave path,
|
||||
re-run the same `/trekexecute` command instead, not `--resume` (Phase 2.5 runs
|
||||
a `--resume` as a single session): the waves are already merged on the current
|
||||
branch, the kept merged-waves record makes Step 0's `next-wave` say `none`,
|
||||
|
|
@ -2256,6 +2746,17 @@ copy · 4 plan so broken that everything after it is guesswork, and the file or
|
|||
- **Recovery depth:** {N}
|
||||
- **Legacy plan:** {true | false}
|
||||
|
||||
### Checks and review (wave 2)
|
||||
|
||||
- **Plan approval (K2):** {approved | not checked (session spec)}
|
||||
- **Step checks (K9):** {N clean · N with findings (fixed in rounds) · N not measured: steps {list}}
|
||||
- **Regressions (K10):** {N passed · N absent · failed: steps {list}}
|
||||
- **Architecture rules (K5):** {pass | violation: {each one} | not measured — {reason}}
|
||||
- **Plan-file sweep (K6):** {pass | deviation — files outside the plan: {outside, one per line} | not measured — {reason}}
|
||||
- **Simplify:** {applied (commit {hash}) | clean | reverted — {why} | not run — {reason}}{; edits outside the run restored: {paths}}
|
||||
- **Review (K7):** round {R} of {cap} · {review_path} · verdict {ALLOW | WARN | BLOCK | none} · gate exit {0 | 1 | 2}{; open BLOCKERs: {ids}}
|
||||
- **Merge into main:** {merged ({commit}) after the guard's question | nothing to merge (the run was on main) | closed — {reason} | declined at the guard's question | n/a — a part of the plan (`--session N`, `--step N`, a session spec)}
|
||||
|
||||
### Summary
|
||||
|
||||
- Passed: {N}/{total}
|
||||
|
|
@ -2292,7 +2793,9 @@ To continue (multi-session waves): re-run the same /trekexecute command (not --r
|
|||
(for trekplans) Phase 7 plan verification passed
|
||||
- `partial` — steps passed per executor but Phase 7.5 found drift, OR
|
||||
Phase 7.6 recovery incomplete, OR the run is not verified end to end
|
||||
(Phase 7, gate clause 4)
|
||||
(Phase 7, gate clause 4), OR an architecture violation or a file outside the
|
||||
plan (gate clause 5), OR — in the closing run — the main-merge lock stayed
|
||||
closed or the merge was declined (Phase 8)
|
||||
- `blocked` — Step 0 sandbox pre-flight exited 77; no real work attempted
|
||||
- `failed` — a step failed and went up the fix ladder (On failure revert/retry: round 4 to the plan, or 5 to the operator)
|
||||
- `stopped` — On failure: escalate triggered, or another stop on the closed stop list (Phase 5.5)
|
||||
|
|
@ -2325,7 +2828,15 @@ To continue (multi-session waves): re-run the same /trekexecute command (not --r
|
|||
"end_to_end": "{verified | not-verified | n/a}",
|
||||
"steps_without_red_proof": 0,
|
||||
"stop_class": "{none | 1 | 2 | 3 | 4}",
|
||||
"decisions": 0
|
||||
"decisions": 0,
|
||||
"plan_approval": "{approved | not-checked}",
|
||||
"steps_check_not_measured": 0,
|
||||
"arch_rules": "{pass | violation | not-measured | n/a}",
|
||||
"scope_sweep": "{pass | deviation | not-measured | n/a}",
|
||||
"simplify": "{applied | clean | reverted | not-run | n/a}",
|
||||
"review_verdict": "{ALLOW | WARN | BLOCK | none}",
|
||||
"review_gate_exit": "{0 | 1 | 2}",
|
||||
"main_merge": "{merged | nothing-to-merge | closed | declined | n/a}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -2450,7 +2961,7 @@ 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 pauses for operator confirm, and Phase 7.5
|
||||
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
|
||||
|
|
@ -2479,7 +2990,11 @@ code-path.
|
|||
from the plan's On failure clauses. If the plan says escalate, stop and
|
||||
report — never ask. What the plan leaves open, the executor decides and
|
||||
writes into the decision log with its `cost_if_wrong`; it stops only on the
|
||||
closed stop list (Phase 5.5).
|
||||
closed stop list (Phase 5.5). The one question the executor asks itself is
|
||||
an approval, not an execution decision: the plan's approval after the
|
||||
review loop added remediation steps (Phase 7.2). A merge or a push is asked
|
||||
by the guard, and the executor never asks again (operator decision
|
||||
2026-10-08).
|
||||
|
||||
2. **No scope creep.** Only touch files listed in the step's `Files:` field.
|
||||
If a file outside the list seems to need changing, record it as a finding
|
||||
|
|
@ -2586,6 +3101,8 @@ code-path.
|
|||
cleanup proceeds regardless. Rationale: this converts an unrecoverable
|
||||
failure (worktree removed, branch deleted, work lost) into a recoverable
|
||||
one (push succeeded, branch preserved on remote). Source: research/02 R3.
|
||||
The push and the merge go in one Bash call, so the guard asks once for
|
||||
both (Phase 2.6 Step 3).
|
||||
|
||||
20. **Three distinct iteration axes.** Per-step `fix_rounds` (≤5, upward),
|
||||
per-session `recovery_depth` (<2), and the global
|
||||
|
|
@ -2605,6 +3122,17 @@ code-path.
|
|||
`done-line.mjs` — never the helper's or the executor's statement. Exit 2
|
||||
from any of them ("could not run") is never green.
|
||||
|
||||
23. **The review is a gate, and the guard asks the one question.** A run
|
||||
starts only on a plan approved as it is now (K2, Phase 1). It ends with
|
||||
`/simplify`, `/trekreview` and the verdict gate, started by the executor
|
||||
itself (Phase 7.2). Exit 1 (BLOCK) or 2 (not measured) from the verdict
|
||||
gate keeps the merge into main closed (Phase 8). A BLOCKER is fixed by new
|
||||
numbered plan steps the operator approves, in a loop capped at
|
||||
`TREKEXECUTE_MAX_REVIEW_ROUNDS`, never by improvising. When the lock
|
||||
opens, the guard asks before the merge, and the executor never asks again.
|
||||
Exit 2 from the step check (K9), the architecture rules (K5) or the
|
||||
plan-file sweep (K6) is "not measured", never green.
|
||||
|
||||
## Credits
|
||||
|
||||
Every borrowing in this executor is named here, never silent. All sources are
|
||||
|
|
@ -2632,4 +3160,17 @@ pins and quotes in the local practice analysis.
|
|||
- **Cherny** (public primary sources): "run the thing" — a plan is verified
|
||||
end to end only by a criterion that runs what was built (P9 (6)).
|
||||
- **Claude Code** itself: subagents for the helper and the controller; the
|
||||
`PreToolUse` guard behind the test lock (K8).
|
||||
`PreToolUse` guard behind the test lock (K8); `/simplify`, `/code-review` and
|
||||
`/security-review` as the review's engine, started through the Skill tool
|
||||
(P11).
|
||||
- **The review at the end (P11, wave 2)** — Cherny: the order — run the
|
||||
thing, simplify, then review.
|
||||
Kiro (`feature-pipeline`, documentation only): the gate — a verdict in a file.
|
||||
Spec Kit (MIT, `/converge`): the bounded fix-and-review loop, its
|
||||
remediation steps appended and never reworded.
|
||||
Superpowers (MIT): nothing is dropped silently — what the controller did not
|
||||
judge, and every edit restored, is listed.
|
||||
- **The checks wired in (P7, P3, P12)** — agent-skills (MIT): the step check's
|
||||
tiers and "not measured"; ai-native-sdlc (MIT): the sweep for files outside
|
||||
the plan and the guard + log; Superpowers (MIT): the four classes the guard
|
||||
asks about. Ideas only.
|
||||
|
|
|
|||
|
|
@ -363,6 +363,24 @@ The wave-1 fields are **additive-optional on read**: a `progress.json` written
|
|||
before them validates and resumes unchanged (no `schema_version` bump). `null`
|
||||
means "not set yet". When present, `progress-validator.mjs` shape-checks them.
|
||||
|
||||
**Wave-2 coupling fields (order 6, additive-optional, same rule).** Each is a
|
||||
contract CLI's exit said in words; `not-measured` / `not-run` are never green.
|
||||
|
||||
| Field | Level | Values | Source |
|
||||
|---|---|---|---|
|
||||
| `step_check` | step | `clean \| findings \| not-measured` | K9 `lib/verification/step-check.mjs` exit 0 / 1 / 2 (Sub-step D4) |
|
||||
| `regression` | step | `passed \| failed \| unrunnable \| not-run \| absent` | K10 `lib/verification/regression-check.mjs` — the step's `Regression:` run through the criteria runner; `absent` = no field |
|
||||
| `plan_approval` | run | `approved \| not-checked` | K2 `approval-log.mjs verify --kind plan` (Phase 1); `not-checked` = a session spec |
|
||||
| `arch_rules` | run | `pass \| violation \| not-measured` | K5 `lib/verification/arch-rules.mjs` exit 0 / 1 / 2 (Phase 7) |
|
||||
| `scope_sweep` | run | `{status: pass \| deviation \| not-measured, outside: [paths]}` | K6 `lib/verification/plan-scope-sweep.mjs` exit 0 / 1 / 2 (Phase 7) |
|
||||
| `simplify` | run | `applied \| clean \| reverted \| not-run` | `/simplify` in Phase 7.2 |
|
||||
| `review` | run | `{round ≥ 1, path, gate_exit: 0 \| 1 \| 2, verdict}` | K7 `lib/review/verdict-gate.mjs` on `review.md` (Phase 7.2; Phase 8 locks on it) |
|
||||
|
||||
`plan_verification.failed_criteria[]` carries `exit_code` (integer, or `null`
|
||||
for a criterion that never ran). The criteria runner reports `exitCode`; the
|
||||
executor translates the key, and the validator rejects the runner's key or a
|
||||
missing `exit_code` (C15, `PROGRESS_FAILED_CRITERIA_KEY`).
|
||||
|
||||
**Validation strategy:** `progress-validator.mjs` runs at:
|
||||
1. `/trekexecute --validate` (alongside plan-validator)
|
||||
2. `/trekexecute --resume` entry (must pass `checkResumeReadiness`)
|
||||
|
|
@ -382,6 +400,8 @@ means "not set yet". When present, `progress-validator.mjs` shape-checks them.
|
|||
- `PROGRESS_FIX_ROUNDS_INVALID` → `fix_rounds` not an integer 0..5; resume halts
|
||||
- `PROGRESS_START_SHA_INVALID` / `PROGRESS_REPORT_PATH_INVALID` / `PROGRESS_RED_PROOF_INVALID` / `PROGRESS_TEST_LOCK_INVALID` → a wave-1 step field of the wrong shape; resume halts
|
||||
- `PROGRESS_DECISION_INVALID` → `decisions` is not an array of `{decision, cost_if_wrong}`; resume halts
|
||||
- `PROGRESS_STEP_CHECK_INVALID` / `PROGRESS_REGRESSION_INVALID` / `PROGRESS_PLAN_APPROVAL_INVALID` / `PROGRESS_ARCH_RULES_INVALID` / `PROGRESS_SCOPE_SWEEP_INVALID` / `PROGRESS_SIMPLIFY_INVALID` / `PROGRESS_REVIEW_INVALID` → a wave-2 coupling field of the wrong shape; resume halts
|
||||
- `PROGRESS_FAILED_CRITERIA_KEY` → a `failed_criteria` entry with the runner's `exitCode`, or without `exit_code`; resume halts. This one is a tightening, not an addition: a progress file written before it with the runner's key (the C15 defect itself) no longer resumes until the key is renamed to `exit_code`
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -80,9 +80,11 @@ 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`. |
|
||||
| `--gates {true\|false}` | (v3.4.0) Boolean autonomy-gate flag; present → gating on. Policy (`gates_mode`) detailed under `## Autonomy mode` in `docs/operations.md`. It adds no question of its own before a merge: the guard asks there (operator decision 2026-10-08). |
|
||||
| `--profile <name>` | (v4.1.0) Model profile for the execute phase. Inherited from plan.md frontmatter `profile:` if present. |
|
||||
|
||||
Every mode that executes refuses a plan whose approval in the approval log does not cover its current sha256 (K2). A run ends with the review it starts by itself (`/simplify` → `/trekreview` → verdict gate); a BLOCK or an undecided verdict keeps the merge into main closed. `TREKEXECUTE_MAX_REVIEW_ROUNDS` (default 3) caps the reviews per run.
|
||||
|
||||
## /trekreview modes
|
||||
|
||||
| Flag | Behavior |
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ All four pipeline commands accept a boolean `--gates {true|false}` flag. Presenc
|
|||
|
||||
Under the hood: `lib/util/autonomy-gate.mjs` runs the state machine `idle → gates_on | auto_running → paused_for_gate → completed`. `start` routes to `gates_on` when `--gates true`, else `auto_running`; a `phase_boundary` from `gates_on` pauses at `paused_for_gate` (awaiting `resume`); `finish` reaches the terminal `completed`. The module is pure data with no I/O. Separately, `lib/stats/event-emit.mjs` records named lifecycle events (`brief-approved`, `main-merge-gate`, `main-merge-approved`, `main-merge-declined`, `gates_mode_flag_override`, `user_input`) to `trekexecute-stats.jsonl` in the plugin data directory (`CLAUDE_PLUGIN_DATA`, else `~/.claude/plugins/data/voyage-ktg-plugin-marketplace/`, the directory `scripts/yardstick.mjs` reads); a write that fails is reported, never skipped. The main-merge gate is the final autonomy boundary before HEAD lands on `main`.
|
||||
|
||||
**The main-merge gate is a lock, and the guard asks the one question** (operator decision 2026-10-08). The gate opens only when the run is `completed` and the review's verdict gate (`lib/review/verdict-gate.mjs`, K7) exits 0; BLOCK (1) and "could not decide" (2) keep it closed. When it opens, `/trekexecute` runs the merge into main itself and `pre-bash-executor` answers "ask" before it runs, in auto mode too, so that question is the only one. In the permission mode where Claude Code shows no prompts, the guard refuses the merge instead, and every other call it would ask about (push, tag or publish, destructive git), since no one can be asked (operator decisions 2026-10-09). The executor no longer asks "ready to merge?" first, and `--gates` no longer adds a pause before the wave merge-back: the guard asks there as well, once per session, because the push and the merge go in one call. Before this, one merge cost two questions in three places: the `--gates` pause before the wave merge-back, the gate's own question after the waves were merged, and the gate's question on the single-session path followed by the guard's at the merge itself.
|
||||
|
||||
### Path A/B/C decision (v3.4.0; Path C closed 2026-05-05)
|
||||
|
||||
Three architectural options were considered for the speedup work:
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@
|
|||
// tag/release, destructive git (hooks/lib/git-approval.mjs). The prompt is
|
||||
// labelled [plugin:voyage] and also appears in auto mode (hooks reference
|
||||
// section PreToolUse decision control). Advisories are added to the prompt.
|
||||
// In the permission mode that shows no prompts, every call it would ask
|
||||
// about is BLOCKED instead: there is no one to ask (operator, 2026-10-09).
|
||||
// - WARN (exit 0): risky but recoverable operations — advisory to stderr
|
||||
// - Allow (exit 0): everything else
|
||||
//
|
||||
|
|
@ -271,6 +273,25 @@ try {
|
|||
approvals = [{ name: `a command the approval guard could not read (${e.message})`, kind: null, way: 'approve only if you have read the command yourself.' }];
|
||||
}
|
||||
|
||||
// In the permission mode where Claude Code shows no prompts, an "ask" reaches
|
||||
// no one and the call runs unattended. Everything this guard would ask about —
|
||||
// merge into main, push, tag/release, destructive git, and a command it could
|
||||
// not read — is refused there instead, with the way forward (operator
|
||||
// decisions 2026-10-09: first merge and push, then the other two classes).
|
||||
// /trekexecute relies on this: its main-merge gate is a lock, and this guard's
|
||||
// question is the one question.
|
||||
const NO_PROMPT_MODE = 'bypassPermissions';
|
||||
const unattended = input.permission_mode === NO_PROMPT_MODE ? approvals : [];
|
||||
if (unattended.length > 0) {
|
||||
process.stderr.write(
|
||||
`[voyage] BLOCKED: ${unattended.map((m) => m.name).join(', ')} with no one to ask\n` +
|
||||
` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n` +
|
||||
' This session runs in the permission mode where Claude Code asks nothing, so the approval guard ' +
|
||||
'cannot ask a person. Way forward: run the command yourself, or switch to a mode that asks, then retry.\n'
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (approvals.length > 0) {
|
||||
// What the person is asked is what post-approval-log.mjs logs once the call ran.
|
||||
const loggable = approvals.filter((m) => m.kind).map(({ kind, name, subject }) => ({ kind, name, subject }));
|
||||
|
|
|
|||
|
|
@ -8,9 +8,13 @@
|
|||
// cross-check in trekexecute, not by this per-validation shape check.
|
||||
// The same holds for the wave-1 step-loop fields (per step: start_sha, report_path,
|
||||
// red_proof, test_lock, fix_rounds 0..5; per run: decisions): shape-checked when
|
||||
// present, never required.
|
||||
// present, never required. So are the wave-2 coupling fields (per step: step_check
|
||||
// from K9, regression from K10; per run: plan_approval, arch_rules, scope_sweep,
|
||||
// simplify, review) and the key of plan_verification.failed_criteria: the runner
|
||||
// says `exitCode`, progress says `exit_code`, and only `exit_code` is accepted (C15).
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { readFileSync, existsSync, realpathSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { issue, fail } from '../util/result.mjs';
|
||||
|
||||
export const PROGRESS_REQUIRED_TOP = ['schema_version', 'plan', 'plan_version', 'mode', 'status', 'total_steps', 'current_step', 'steps'];
|
||||
|
|
@ -28,6 +32,17 @@ export const PROGRESS_MAX_FIX_ROUNDS = 5;
|
|||
export const PROGRESS_RED_PROOF_VALUES = ['proven', 'rejected', 'not-run'];
|
||||
export const PROGRESS_TEST_LOCK_VALUES = ['locked', 'released', 'not-locked'];
|
||||
|
||||
// Wave 2 (order 6) coupling fields. Each value is the exit of a contract CLI said
|
||||
// in words; "not-measured" / "not-run" are never green, and "absent" means the plan
|
||||
// declared nothing (no `Regression:` field), so nothing new happened.
|
||||
export const PROGRESS_STEP_CHECK_VALUES = ['clean', 'findings', 'not-measured']; // K9 exit 0 / 1 / 2
|
||||
export const PROGRESS_REGRESSION_VALUES = ['passed', 'failed', 'unrunnable', 'not-run', 'absent']; // K10
|
||||
export const PROGRESS_PLAN_APPROVAL_VALUES = ['approved', 'not-checked']; // K2; not-checked = a session spec
|
||||
export const PROGRESS_ARCH_RULES_VALUES = ['pass', 'violation', 'not-measured']; // K5 exit 0 / 1 / 2
|
||||
export const PROGRESS_SCOPE_SWEEP_VALUES = ['pass', 'deviation', 'not-measured']; // K6 exit 0 / 1 / 2
|
||||
export const PROGRESS_SIMPLIFY_VALUES = ['applied', 'clean', 'reverted', 'not-run'];
|
||||
export const PROGRESS_REVIEW_GATE_EXITS = [0, 1, 2]; // K7: ALLOW/WARN, BLOCK, could not decide
|
||||
|
||||
function checkStepFields(k, s, errors) {
|
||||
const set = (v) => v !== undefined && v !== null;
|
||||
if (s.fix_rounds !== undefined &&
|
||||
|
|
@ -47,6 +62,50 @@ function checkStepFields(k, s, errors) {
|
|||
if (set(s.test_lock) && !PROGRESS_TEST_LOCK_VALUES.includes(s.test_lock)) {
|
||||
errors.push(issue('PROGRESS_TEST_LOCK_INVALID', `steps["${k}"].test_lock "${s.test_lock}" not in [${PROGRESS_TEST_LOCK_VALUES.join(', ')}]`));
|
||||
}
|
||||
if (set(s.step_check) && !PROGRESS_STEP_CHECK_VALUES.includes(s.step_check)) {
|
||||
errors.push(issue('PROGRESS_STEP_CHECK_INVALID', `steps["${k}"].step_check ${JSON.stringify(s.step_check)} not in [${PROGRESS_STEP_CHECK_VALUES.join(', ')}]`));
|
||||
}
|
||||
if (set(s.regression) && !PROGRESS_REGRESSION_VALUES.includes(s.regression)) {
|
||||
errors.push(issue('PROGRESS_REGRESSION_INVALID', `steps["${k}"].regression ${JSON.stringify(s.regression)} not in [${PROGRESS_REGRESSION_VALUES.join(', ')}]`));
|
||||
}
|
||||
}
|
||||
|
||||
const isStr = (v) => typeof v === 'string' && v.trim() !== '';
|
||||
|
||||
// Wave 2 (order 6) run-level fields; all optional, shape-checked when set.
|
||||
function checkCouplingFields(p, errors) {
|
||||
const set = (v) => v !== undefined && v !== null;
|
||||
const oneOf = (field, values, code) => {
|
||||
if (set(p[field]) && !values.includes(p[field])) {
|
||||
errors.push(issue(code, `${field} ${JSON.stringify(p[field])} not in [${values.join(', ')}]`));
|
||||
}
|
||||
};
|
||||
oneOf('plan_approval', PROGRESS_PLAN_APPROVAL_VALUES, 'PROGRESS_PLAN_APPROVAL_INVALID');
|
||||
oneOf('arch_rules', PROGRESS_ARCH_RULES_VALUES, 'PROGRESS_ARCH_RULES_INVALID');
|
||||
oneOf('simplify', PROGRESS_SIMPLIFY_VALUES, 'PROGRESS_SIMPLIFY_INVALID');
|
||||
const sw = p.scope_sweep;
|
||||
if (set(sw) && !(typeof sw === 'object' && PROGRESS_SCOPE_SWEEP_VALUES.includes(sw.status) &&
|
||||
Array.isArray(sw.outside) && sw.outside.every(isStr))) {
|
||||
errors.push(issue('PROGRESS_SCOPE_SWEEP_INVALID',
|
||||
`scope_sweep must be {status: ${PROGRESS_SCOPE_SWEEP_VALUES.join('|')}, outside: [paths]}`));
|
||||
}
|
||||
const rv = p.review;
|
||||
if (set(rv) && !(typeof rv === 'object' && Number.isInteger(rv.round) && rv.round >= 1 &&
|
||||
PROGRESS_REVIEW_GATE_EXITS.includes(rv.gate_exit))) {
|
||||
errors.push(issue('PROGRESS_REVIEW_INVALID', 'review must be {round: integer >= 1, gate_exit: 0|1|2, ...}'));
|
||||
}
|
||||
// C15: the criteria runner reports `exitCode`; progress.json carries `exit_code`
|
||||
// (an integer, or null when the criterion never ran). The executor translates;
|
||||
// the runner's key here means the translation was skipped.
|
||||
const fc = p.plan_verification?.failed_criteria;
|
||||
if (fc !== undefined && fc !== null) {
|
||||
const ok = Array.isArray(fc) && fc.every((c) => c && typeof c === 'object' && !('exitCode' in c) &&
|
||||
'exit_code' in c && (c.exit_code === null || Number.isInteger(c.exit_code)));
|
||||
if (!ok) {
|
||||
errors.push(issue('PROGRESS_FAILED_CRITERIA_KEY',
|
||||
'plan_verification.failed_criteria entries carry exit_code (integer or null), never the runner key exitCode'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The run-level decision log (P4): every decision the executor took on its own,
|
||||
|
|
@ -129,6 +188,7 @@ export function validateProgressObject(parsed, opts = {}) {
|
|||
}
|
||||
|
||||
checkDecisions(parsed.decisions, errors);
|
||||
checkCouplingFields(parsed, errors);
|
||||
|
||||
return { valid: errors.length === 0, errors, warnings, parsed };
|
||||
}
|
||||
|
|
@ -152,7 +212,17 @@ export function validateProgress(filePath, opts = {}) {
|
|||
return validateProgressContent(text, opts);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
// The real paths on both sides: a `file://` comparison fails when the path holds a
|
||||
// space or runs through a symlink, and the CLI then exits 0 having checked nothing.
|
||||
function isMain() {
|
||||
try {
|
||||
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMain()) {
|
||||
const args = process.argv.slice(2);
|
||||
const filePath = args.find(a => !a.startsWith('--'));
|
||||
if (!filePath) {
|
||||
|
|
|
|||
108
lib/verification/regression-check.mjs
Normal file
108
lib/verification/regression-check.mjs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
#!/usr/bin/env node
|
||||
// lib/verification/regression-check.mjs
|
||||
// K10 at execution time: run ONE step's `Regression:` command through the
|
||||
// criteria runner's own screens, before the step's checkpoint.
|
||||
//
|
||||
// node lib/verification/regression-check.mjs --plan <plan.md> --step <N> [--cwd <dir>] [--json]
|
||||
// 0 = the step's `Regression:` passed, or the step has no `Regression:`
|
||||
// field (`status: "absent"` — nothing new happens, K10)
|
||||
// 1 = it failed, the executor denylist blocked it, or it is not runnable
|
||||
// (no command, or a command outside the test-runner allowlist): the
|
||||
// step is not done, and that is a finding for the fix rounds
|
||||
// 2 = could not run: bad usage, a plan that cannot be read, no step N
|
||||
// JSON on stdout: {step, status, command, exit_code, output}
|
||||
// status: passed | failed | blocked | unrunnable | absent | error
|
||||
//
|
||||
// The command is the field's first backticked span — the same reading as the
|
||||
// runner's `## Verification` bullets — and it goes through the same two
|
||||
// screens: the allowlist of test runners, then the plugin's PreToolUse
|
||||
// denylist. The plan validator rejects a `Regression:` that cannot run once K10
|
||||
// is in it (wave 2, order 9); this is the run-time check, so a plan validated
|
||||
// before that is never read as green.
|
||||
//
|
||||
// The runner reports `exitCode`; this CLI writes `exit_code`, the key
|
||||
// progress.json uses (C15). Nothing else is translated.
|
||||
//
|
||||
// The command runs the way the runner runs it (`/bin/sh -c`), but without
|
||||
// NODE_TEST_CONTEXT in its environment (red-proof.mjs `commandEnv`): measured
|
||||
// 2026-10-08, a red `node --test` regression read as passed when this CLI was
|
||||
// itself started under `node --test`, because the nested run reported to the
|
||||
// parent and exited 0.
|
||||
|
||||
function exec(command, { cwd, timeoutMs }) {
|
||||
const r = spawnSync('/bin/sh', ['-c', command], { cwd, encoding: 'utf8', timeout: timeoutMs, env: commandEnv() });
|
||||
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
|
||||
}
|
||||
|
||||
import { readFileSync, realpathSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { sliceSteps, parseStepFields } from '../parsers/plan-schema.mjs';
|
||||
import { firstCommand, runCriteria } from './criteria-runner.mjs';
|
||||
import { commandEnv } from './red-proof.mjs';
|
||||
|
||||
export const REGRESSION_FIELD = 'Regression';
|
||||
const EXIT = Object.freeze({ passed: 0, absent: 0, failed: 1, blocked: 1, unrunnable: 1, error: 2 });
|
||||
|
||||
/**
|
||||
* @param {{ planText: string, step: number, cwd?: string, run?: Function }} o
|
||||
* @returns {{ step: number, status: string, command: string|null, exit_code: number|null, output: string, exit: 0|1|2 }}
|
||||
*/
|
||||
export function checkRegression({ planText, step, cwd = process.cwd(), run = (c, o) => runCriteria(c, { ...o, exec }) }) {
|
||||
const base = { step, command: null, exit_code: null, output: '' };
|
||||
const found = sliceSteps(planText).find((s) => Number(s.n) === step);
|
||||
if (!found) return { ...base, status: 'error', output: `no "### Step ${step}:" in the plan`, exit: EXIT.error };
|
||||
const field = parseStepFields(found.body).get(REGRESSION_FIELD);
|
||||
if (!field) return { ...base, status: 'absent', output: 'no Regression: field — nothing to run', exit: EXIT.absent };
|
||||
const { command, reason } = firstCommand(field.text);
|
||||
const [r] = run([{ label: `R${step}`, text: field.text, command, reason }], { cwd });
|
||||
return {
|
||||
step,
|
||||
status: r.status,
|
||||
command: r.command ?? null,
|
||||
exit_code: r.exitCode ?? null,
|
||||
output: r.output ?? '',
|
||||
exit: EXIT[r.status] ?? EXIT.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function main(argv) {
|
||||
let plan; let step; let cwd = process.cwd(); let asJson = false;
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--plan' && argv[i + 1]) plan = argv[++i];
|
||||
else if (a === '--step' && argv[i + 1]) step = Number(argv[++i]);
|
||||
else if (a === '--cwd' && argv[i + 1]) cwd = resolve(argv[++i]);
|
||||
else if (a === '--json') asJson = true;
|
||||
else {
|
||||
process.stderr.write(`regression-check: unknown argument ${a}\n`);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
const say = (r) => {
|
||||
const { exit, ...rest } = r;
|
||||
process.stdout.write(asJson ? `${JSON.stringify(rest)}\n`
|
||||
: `regression step ${rest.step}: ${rest.status}${rest.command ? ` — \`${rest.command}\`` : ''}${rest.exit_code === null ? '' : ` (exit ${rest.exit_code})`}\n`);
|
||||
return exit;
|
||||
};
|
||||
if (!plan || !Number.isInteger(step) || step < 1) {
|
||||
process.stderr.write('Usage: regression-check.mjs --plan <plan.md> --step <N> [--cwd <dir>] [--json]\n');
|
||||
return 2;
|
||||
}
|
||||
let planText;
|
||||
try { planText = readFileSync(plan, 'utf8'); } catch (e) {
|
||||
return say({ step, status: 'error', command: null, exit_code: null, output: `cannot read ${plan}: ${e.message}`, exit: 2 });
|
||||
}
|
||||
return say(checkRegression({ planText, step, cwd }));
|
||||
}
|
||||
|
||||
function isMain() {
|
||||
try {
|
||||
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMain()) process.exitCode = main(process.argv.slice(2));
|
||||
356
tests/commands/trekexecute-coupling.test.mjs
Normal file
356
tests/commands/trekexecute-coupling.test.mjs
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
// tests/commands/trekexecute-coupling.test.mjs
|
||||
// Wave 2, order 6 — the executor's coupling to the contracts, run against the
|
||||
// real CLIs (the prose pins are in tests/lib/coupling-prose-pins.test.mjs):
|
||||
//
|
||||
// K7 a stub review.md with `verdict: BLOCK` → the verdict gate exits 1, and
|
||||
// Phase 8 keeps the main merge closed on 1 and on 2.
|
||||
// K2 a plan edited after approval → the exact Phase 1 command refuses it
|
||||
// (a temp repo, the real approval log).
|
||||
// K9 the step check's exit 2 → `step_check: "not-measured"`, never green.
|
||||
// K10 a failing `Regression:` → regression-check exits 1, so the step does
|
||||
// not reach its checkpoint.
|
||||
// C15 the runner's `exitCode` must be written as `exit_code`: the progress
|
||||
// validator rejects the runner's key in `plan_verification.failed_criteria`.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, 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 { validateProgressObject } from '../../lib/validators/progress-validator.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const CMD = readFileSync(join(ROOT, 'commands', 'trekexecute.md'), 'utf8');
|
||||
const GATE = join(ROOT, 'lib', 'review', 'verdict-gate.mjs');
|
||||
const LOG = join(ROOT, 'lib', 'util', 'approval-log.mjs');
|
||||
const STEP_CHECK = join(ROOT, 'lib', 'verification', 'step-check.mjs');
|
||||
const REGRESSION = join(ROOT, 'lib', 'verification', 'regression-check.mjs');
|
||||
|
||||
/** The text of a markdown section: from its heading to the next heading of the same or a higher level. */
|
||||
function section(text, heading) {
|
||||
const at = text.indexOf(`\n${heading}\n`);
|
||||
assert.ok(at >= 0, `trekexecute.md must have the heading "${heading}"`);
|
||||
const level = heading.match(/^#+/)[0].length;
|
||||
const lines = text.slice(at + heading.length + 2).split('\n');
|
||||
const stop = new RegExp(`^#{1,${level}} `);
|
||||
let fenced = false;
|
||||
const out = [];
|
||||
for (const l of lines) {
|
||||
if (/^\s*(```|~~~)/.test(l)) fenced = !fenced;
|
||||
else if (!fenced && stop.test(l)) break;
|
||||
out.push(l);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
function tmp(prefix) {
|
||||
return realpathSync(mkdtempSync(join(tmpdir(), prefix)));
|
||||
}
|
||||
|
||||
function git(cwd, ...args) {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
||||
assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
function repo() {
|
||||
const dir = tmp('coupling-repo-');
|
||||
git(dir, 'init', '-q', '-b', 'main');
|
||||
git(dir, 'config', 'user.email', 't@example.invalid');
|
||||
git(dir, 'config', 'user.name', 't');
|
||||
git(dir, 'config', 'commit.gpgsign', 'false');
|
||||
writeFileSync(join(dir, 'README.md'), 'x\n');
|
||||
git(dir, 'add', 'README.md');
|
||||
git(dir, 'commit', '-q', '-m', 'init');
|
||||
return dir;
|
||||
}
|
||||
|
||||
const node = (args, opts = {}) => spawnSync(process.execPath, args, { encoding: 'utf8', ...opts });
|
||||
const json = (r) => JSON.parse(r.stdout.trim().split('\n').pop());
|
||||
|
||||
// ---- K7 ---------------------------------------------------------------------
|
||||
|
||||
function reviewStub(verdict) {
|
||||
const id = 'b'.repeat(40);
|
||||
const severities = verdict === 'BLOCK' ? ['BLOCKER'] : [];
|
||||
const findings = severities.map((s) => ({
|
||||
id, severity: s, rule_key: 'MISSING_TEST', file: 'a.mjs', line: 1, title: 't', detail: 'd', recommended_action: 'Add a test.',
|
||||
}));
|
||||
const counts = { BLOCKER: severities.length, MAJOR: 0, MINOR: 0, SUGGESTION: 0 };
|
||||
const block = JSON.stringify({ verdict, counts, allow_blocked_by: [], findings }, null, 2);
|
||||
return [
|
||||
'---', 'type: trekreview', 'review_version: "1.1"', 'task: "t"', 'slug: s',
|
||||
'project_dir: .claude/projects/2026-10-08-s/', 'brief_path: .claude/projects/2026-10-08-s/brief.md',
|
||||
'scope_sha_end: abc', 'reviewed_files_count: 1', `verdict: ${verdict}`,
|
||||
severities.length ? `findings:\n - ${id}` : 'findings: []', '---', '', '# Review: t', '',
|
||||
'## Executive Summary', '', 'x', '', '## Coverage', '', '| File | Treatment | Reason |', '|---|---|---|',
|
||||
'| a.mjs | summary-only | default |', '', '## Remediation Summary', '', 'x', '', '```json', block, '```', '',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
test('K7: a stub review.md with verdict BLOCK -> the gate exits 1, and Phase 8 keeps the merge closed on 1 and 2', () => {
|
||||
const dir = tmp('coupling-review-');
|
||||
try {
|
||||
const p = join(dir, 'review.md');
|
||||
writeFileSync(p, reviewStub('BLOCK'));
|
||||
const r = node([GATE, '--json', p]);
|
||||
assert.equal(r.status, 1, `BLOCK must exit 1: ${r.stdout}${r.stderr}`);
|
||||
assert.equal(json(r).verdict, 'BLOCK');
|
||||
writeFileSync(p, reviewStub('ALLOW'));
|
||||
assert.equal(node([GATE, p]).status, 0, 'the control: ALLOW exits 0');
|
||||
rmSync(p);
|
||||
assert.equal(node([GATE, p]).status, 2, 'a review that was never written exits 2');
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
|
||||
const gate = section(CMD, '### Main-merge gate (MAIN_MERGE_GATE — always pauses)');
|
||||
assert.match(gate, /lib\/review\/verdict-gate\.mjs --json "\{review_path\}"/, 'Phase 8 runs the verdict gate on review.md');
|
||||
assert.match(gate, /\| 1 \| BLOCK \| closed/, 'exit 1 keeps the merge closed');
|
||||
assert.match(gate, /\| 2 \| not measured \| closed/, 'exit 2 keeps the merge closed - never green');
|
||||
assert.match(gate, /The merge runs only on exit 0/);
|
||||
});
|
||||
|
||||
// ---- K2 ---------------------------------------------------------------------
|
||||
|
||||
/** The Phase 1 plan-approval command, as the prose writes it, with the placeholders filled. */
|
||||
function approvalCommand(planPath) {
|
||||
const m = /node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/util\/approval-log\.mjs verify --json --kind plan --subject "\{plan_path\}" --sha256-of "\{plan_path\}"/.exec(
|
||||
section(CMD, '### Plan approval gate (K2)'),
|
||||
);
|
||||
assert.ok(m, 'Phase 1 must run approval-log.mjs verify --kind plan against the plan file');
|
||||
return [LOG, 'verify', '--json', '--kind', 'plan', '--subject', planPath, '--sha256-of', planPath];
|
||||
}
|
||||
|
||||
test('K2: a plan edited after approval -> the Phase 1 command refuses it', () => {
|
||||
const dir = repo();
|
||||
try {
|
||||
const plan = join(dir, '.claude', 'projects', '2026-10-08-x', 'plan.md');
|
||||
mkdirSync(dirname(plan), { recursive: true });
|
||||
writeFileSync(plan, '# Plan\n\n### Step 1: one\n');
|
||||
const never = node(approvalCommand(plan), { cwd: dir });
|
||||
assert.equal(never.status, 1, 'a plan never approved is refused');
|
||||
assert.equal(json(never).status, 'not-logged');
|
||||
|
||||
const add = node([LOG, 'append', '--kind', 'plan', '--subject', plan, '--sha256-of', plan], { cwd: dir });
|
||||
assert.equal(add.status, 0, add.stdout + add.stderr);
|
||||
assert.equal(node(approvalCommand(plan), { cwd: dir }).status, 0, 'the approved plan, unchanged, passes');
|
||||
|
||||
writeFileSync(plan, '# Plan\n\n### Step 1: one\n### Step 2: added after approval\n');
|
||||
const edited = node(approvalCommand(plan), { cwd: dir });
|
||||
assert.equal(edited.status, 1, 'the edited plan is refused');
|
||||
assert.equal(json(edited).status, 'mismatch');
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
|
||||
const gate = section(CMD, '### Plan approval gate (K2)');
|
||||
assert.match(gate, /Exit 1 → \*\*halt\*\*/, 'exit 1 halts');
|
||||
assert.match(gate, /Exit 2, or no JSON at all → \*\*halt\*\*/, 'exit 2 halts: a check that could not run is never a pass');
|
||||
});
|
||||
|
||||
// ---- K9 ---------------------------------------------------------------------
|
||||
|
||||
const stepWith = (extra) => ({
|
||||
schema_version: '1', plan: 'plan.md', plan_version: '1.8', mode: 'execute', status: 'in_progress',
|
||||
total_steps: 1, current_step: 1, steps: { 1: { status: 'in_progress', attempts: 1, ...extra } },
|
||||
});
|
||||
const codes = (r) => r.errors.map((e) => e.code);
|
||||
|
||||
test('K9: exit 2 from the step check -> step_check "not-measured", never green', () => {
|
||||
const dir = tmp('coupling-norepo-');
|
||||
try {
|
||||
const r = node([STEP_CHECK, '--cwd', dir]);
|
||||
assert.equal(r.status, 2, `not a repository must be "not measured" (exit 2): ${r.stdout}${r.stderr}`);
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
|
||||
const d4 = section(CMD, '#### Sub-step D4 — Step check and regression');
|
||||
assert.match(d4, /\| 2 \| `"not-measured"` \|/, 'exit 2 is written as not-measured');
|
||||
assert.match(d4, /never green/);
|
||||
for (const v of ['clean', 'findings', 'not-measured']) {
|
||||
assert.deepEqual(codes(validateProgressObject(stepWith({ step_check: v }))), [], `step_check ${v}`);
|
||||
}
|
||||
for (const bad of ['green', 'pass', 'measured', 2]) {
|
||||
assert.deepEqual(codes(validateProgressObject(stepWith({ step_check: bad }))), ['PROGRESS_STEP_CHECK_INVALID'], `step_check ${bad}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- K10 --------------------------------------------------------------------
|
||||
|
||||
function regressionRepo(regressionLine) {
|
||||
const dir = repo();
|
||||
mkdirSync(join(dir, 'tests'));
|
||||
writeFileSync(join(dir, 'tests', 'red.test.mjs'), "import { test } from 'node:test';\ntest('red', () => { throw new Error('red'); });\n");
|
||||
writeFileSync(join(dir, 'tests', 'green.test.mjs'), "import { test } from 'node:test';\ntest('green', () => {});\n");
|
||||
const plan = join(dir, 'plan.md');
|
||||
writeFileSync(plan, [
|
||||
'---', 'plan_version: "1.8"', '---', '', '## Implementation Plan', '',
|
||||
'### Step 1: the step', '- **Files:** `src/a.mjs`', '- **Verify:** `node --test tests/green.test.mjs`',
|
||||
...(regressionLine ? [regressionLine] : []), '- **On failure:** revert', '',
|
||||
'### Step 2: the next', '- **Files:** `src/b.mjs`', '- **Regression:** `node --test tests/green.test.mjs`', '',
|
||||
].join('\n'));
|
||||
return { dir, plan };
|
||||
}
|
||||
|
||||
const regression = (dir, plan, step = '1') => node([REGRESSION, '--plan', plan, '--step', step, '--cwd', dir, '--json']);
|
||||
|
||||
test('K10: a failing Regression: -> exit 1, the step does not reach its checkpoint', () => {
|
||||
const red = regressionRepo('- **Regression:** `node --test tests/red.test.mjs`');
|
||||
try {
|
||||
const r = regression(red.dir, red.plan);
|
||||
assert.equal(r.status, 1, `a red regression exits 1: ${r.stdout}${r.stderr}`);
|
||||
const out = json(r);
|
||||
assert.equal(out.status, 'failed');
|
||||
assert.equal(out.exit_code, 1, 'the exit code is written as exit_code (C15)');
|
||||
assert.ok(!('exitCode' in out), 'never the runner key');
|
||||
assert.equal(regression(red.dir, red.plan, '2').status, 0, 'step 2 has its own, green regression');
|
||||
} finally { rmSync(red.dir, { recursive: true, force: true }); }
|
||||
|
||||
const absent = regressionRepo(null);
|
||||
try {
|
||||
const r = regression(absent.dir, absent.plan);
|
||||
assert.equal(r.status, 0, 'no Regression: field -> nothing new happens');
|
||||
assert.equal(json(r).status, 'absent');
|
||||
} finally { rmSync(absent.dir, { recursive: true, force: true }); }
|
||||
|
||||
const piped = regressionRepo('- **Regression:** `npm test | tee x`');
|
||||
try {
|
||||
const r = regression(piped.dir, piped.plan);
|
||||
assert.equal(r.status, 1, 'a command outside the allowlist is never green');
|
||||
assert.equal(json(r).status, 'unrunnable');
|
||||
} finally { rmSync(piped.dir, { recursive: true, force: true }); }
|
||||
|
||||
const none = node([REGRESSION, '--plan', join(tmpdir(), 'no-such-plan-coupling.md'), '--step', '1', '--json']);
|
||||
assert.equal(none.status, 2, 'a plan that cannot be read is "could not run"');
|
||||
|
||||
const d4 = section(CMD, '#### Sub-step D4 — Step check and regression');
|
||||
assert.match(d4, /lib\/verification\/regression-check\.mjs"? --plan "\{plan_path\}" --step \{N\}/);
|
||||
assert.match(d4, /a finding for Sub-step F/, 'a red regression goes into the fix rounds');
|
||||
const e = section(CMD, '#### Sub-step E — Control');
|
||||
assert.match(e, /`regression` is `"passed"` or `"absent"`/, 'the round passes only on a green or absent regression');
|
||||
});
|
||||
|
||||
// ---- C15 --------------------------------------------------------------------
|
||||
|
||||
const withVerification = (failed) => ({
|
||||
...stepWith({}), plan_verification: { status: 'failed', summary: { total: 1, passed: 0, failed: 1, blocked: 0, unrunnable: 0 }, failed_criteria: failed },
|
||||
});
|
||||
|
||||
test('C15: the progress validator rejects exitCode in failed_criteria and accepts exit_code', () => {
|
||||
assert.deepEqual(codes(validateProgressObject(withVerification([{ label: 'V1', command: 'npm test', exit_code: 1 }]))), []);
|
||||
assert.deepEqual(codes(validateProgressObject(withVerification([{ label: 'V1', command: 'npm test', exit_code: null }]))), [],
|
||||
'null: the criterion never ran');
|
||||
assert.deepEqual(codes(validateProgressObject(withVerification([{ label: 'V1', command: 'npm test', exitCode: 1 }]))),
|
||||
['PROGRESS_FAILED_CRITERIA_KEY']);
|
||||
assert.deepEqual(codes(validateProgressObject(withVerification([{ label: 'V1', command: 'npm test' }]))),
|
||||
['PROGRESS_FAILED_CRITERIA_KEY'], 'exit_code is required in each entry');
|
||||
assert.match(section(CMD, '### trekplans — run the plan\'s `## Verification`'), /`exitCode` → `exit_code`/,
|
||||
'the executor translates the runner key explicitly');
|
||||
});
|
||||
|
||||
// ---- P11: a BLOCK's remediation steps are checked before anyone is asked -------
|
||||
|
||||
test('P11: a review step in the template form is caught by the plan validator, so the loop stops before it asks', async () => {
|
||||
const { buildPlan } = await import('../helpers/plan-v18-builder.mjs');
|
||||
const { validatePlanContent } = await import('../../lib/validators/plan-validator.mjs');
|
||||
const plan = buildPlan();
|
||||
assert.deepEqual(validatePlanContent(plan, { strict: true }).errors, [], 'the control: the plan before the append is valid');
|
||||
// The review template's own Step R1, appended the Phase 7.2 way: next number, fields as they are, a Checkpoint.
|
||||
const tpl = readFileSync(join(ROOT, 'templates', 'trekreview-template.md'), 'utf8');
|
||||
const r1 = /### Step R1:[^\n]*\n[\s\S]*?(?=\n> )/.exec(tpl);
|
||||
assert.ok(r1, 'templates/trekreview-template.md carries a ### Step R1');
|
||||
const appended = r1[0].replace(/^### Step R1:/, '### Step 3:') +
|
||||
'- **Checkpoint:** `git commit -m "fix(review): block-style YAML [01234567]"`\n';
|
||||
const edited = plan.replace('\n## Test Strategy', `\n${appended}\n## Test Strategy`);
|
||||
const errors = validatePlanContent(edited, { strict: true }).errors.map((e) => e.code);
|
||||
assert.ok(errors.length > 0, 'a remediation step the executor cannot run must not validate');
|
||||
assert.ok(errors.some((c) => /VERIFY_NOT_RUNNABLE|TEST_FIRST/.test(c)), errors.join(','));
|
||||
|
||||
const p = section(CMD, '## Phase 7.2 — Simplify, review, verdict (P11)');
|
||||
const check = p.indexOf('plan-validator.mjs --strict --json "{plan_path}"');
|
||||
const ask = p.indexOf('Approve the plan as it is now?');
|
||||
assert.ok(check > 0 && ask > check, 'the validator runs before the approval question');
|
||||
assert.match(p, /restore the plan from the copy/, 'a stop leaves the plan matching its approval');
|
||||
});
|
||||
|
||||
test('P11: the lock is passed only by the closing run - a wave session keeps its completed', () => {
|
||||
const gate = section(CMD, '### Main-merge gate (MAIN_MERGE_GATE — always pauses)');
|
||||
assert.match(gate, /Only the closing run passes the lock/);
|
||||
assert.match(gate, /`--session N`\s+child, a `--step N` run and a session spec/);
|
||||
assert.match(gate, /left as Phase 7\.5 decided it/);
|
||||
assert.match(section(CMD, '## Phase 7.2 — Simplify, review, verdict (P11)'), /never a `--step N` run, a `--session N`\s+child/);
|
||||
});
|
||||
|
||||
test('every bash block outside Phase 2.6 that calls "$VOYAGE_ROOT" resolves it itself', () => {
|
||||
const start = CMD.indexOf('\n## Phase 2.6 —');
|
||||
const end = CMD.indexOf('\n## Phase 3 —');
|
||||
const outside = CMD.slice(0, start) + CMD.slice(end);
|
||||
const fences = [...outside.matchAll(/```bash\n([\s\S]*?)```/g)].map((m) => m[1]);
|
||||
// A wave-path block (it runs from "$REPO_ROOT") uses the values Phase 2.6 Step 0 records.
|
||||
const using = fences.filter((f) => f.includes('"$VOYAGE_ROOT/') && !f.includes('"$REPO_ROOT"'));
|
||||
assert.ok(using.length >= 3, `expected the Phase 7, D4 and K5/K6 blocks, found ${using.length}`);
|
||||
for (const f of using) {
|
||||
assert.match(f, /^VOYAGE_ROOT="\$\{CLAUDE_PLUGIN_ROOT\}"$/m, `a block uses $VOYAGE_ROOT without resolving it:\n${f.slice(0, 300)}`);
|
||||
assert.match(f, /\[ ! -f "\$VOYAGE_ROOT\//, 'and guards the unresolved root with exit 2');
|
||||
}
|
||||
});
|
||||
|
||||
// ---- P12: the guard is the one question, and it refuses where no one is asked --
|
||||
|
||||
const GUARD = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs');
|
||||
const NO_PROMPT_MODE = 'bypassPermissions';
|
||||
|
||||
function guard(cwd, command, permission_mode) {
|
||||
return node([GUARD], {
|
||||
input: JSON.stringify({ tool_name: 'Bash', tool_input: { command }, cwd, permission_mode, session_id: 'coupling', tool_use_id: `t${Math.random()}` }),
|
||||
});
|
||||
}
|
||||
|
||||
test('P12: the guard asks before a merge into main; in the mode with no prompts it refuses (operator 2026-10-09)', () => {
|
||||
const dir = repo();
|
||||
try {
|
||||
git(dir, 'branch', 'feature');
|
||||
const merge = 'git merge --no-ff feature -m "merge: trekexecute x"';
|
||||
for (const mode of ['default', 'auto', 'acceptEdits']) {
|
||||
const r = guard(dir, merge, mode);
|
||||
assert.equal(r.status, 0, `${mode}: ${r.stderr}`);
|
||||
assert.match(r.stdout, /"permissionDecision":"ask"/, `${mode}: the merge into main is asked`);
|
||||
}
|
||||
const blocked = guard(dir, merge, NO_PROMPT_MODE);
|
||||
assert.equal(blocked.status, 2, 'no one to ask: the merge into main is refused');
|
||||
assert.match(blocked.stderr, /no one to ask/);
|
||||
assert.match(blocked.stderr, /Way forward/);
|
||||
assert.equal(guard(dir, 'git push origin feature', NO_PROMPT_MODE).status, 2, 'a push is refused too');
|
||||
// Operator 2026-10-09, second decision: the other two classes as well.
|
||||
for (const cmd of ['git tag v1.0', 'npm publish', 'git reset --hard HEAD~1', 'git push --force origin feature']) {
|
||||
assert.match(guard(dir, cmd, 'default').stdout, /"permissionDecision":"ask"/, `the control: ${cmd} is asked where someone can answer`);
|
||||
const r = guard(dir, cmd, NO_PROMPT_MODE);
|
||||
assert.equal(r.status, 2, `${cmd}: refused where no one is asked`);
|
||||
assert.match(r.stderr, /no one to ask/);
|
||||
}
|
||||
const plain = guard(dir, 'git status', NO_PROMPT_MODE);
|
||||
assert.equal(plain.status, 0, 'an ordinary command still runs');
|
||||
assert.equal(plain.stdout, '');
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
// ---- the other new progress fields (Handover 5, additive-optional) -----------
|
||||
|
||||
test('H5: the wave-2 fields are shape-checked when present and optional when absent', () => {
|
||||
assert.deepEqual(codes(validateProgressObject(stepWith({}))), [], 'an older progress file still validates');
|
||||
const ok = {
|
||||
...stepWith({ regression: 'passed' }),
|
||||
plan_approval: 'approved', arch_rules: 'not-measured', simplify: 'applied',
|
||||
scope_sweep: { status: 'deviation', outside: ['src/x.mjs'] },
|
||||
review: { round: 1, path: '.claude/projects/x/review.md', gate_exit: 1, verdict: 'BLOCK' },
|
||||
};
|
||||
assert.deepEqual(codes(validateProgressObject(ok)), []);
|
||||
const bad = [
|
||||
[{ ...stepWith({ regression: 'green' }) }, 'PROGRESS_REGRESSION_INVALID'],
|
||||
[{ ...stepWith({}), plan_approval: 'yes' }, 'PROGRESS_PLAN_APPROVAL_INVALID'],
|
||||
[{ ...stepWith({}), arch_rules: 'green' }, 'PROGRESS_ARCH_RULES_INVALID'],
|
||||
[{ ...stepWith({}), simplify: 'done' }, 'PROGRESS_SIMPLIFY_INVALID'],
|
||||
[{ ...stepWith({}), scope_sweep: { status: 'deviation' } }, 'PROGRESS_SCOPE_SWEEP_INVALID'],
|
||||
[{ ...stepWith({}), review: { round: 0, gate_exit: 0 } }, 'PROGRESS_REVIEW_INVALID'],
|
||||
[{ ...stepWith({}), review: { round: 1, gate_exit: 3 } }, 'PROGRESS_REVIEW_INVALID'],
|
||||
];
|
||||
for (const [p, code] of bad) assert.deepEqual(codes(validateProgressObject(p)), [code], JSON.stringify(p));
|
||||
});
|
||||
2
tests/fixtures/jsonl-schemas.md
vendored
2
tests/fixtures/jsonl-schemas.md
vendored
|
|
@ -22,7 +22,7 @@
|
|||
| trekbrief-stats | ts, task, slug, mode, interview_turns, review_iterations, brief_quality, research_topics, auto_research, auto_result, intent_approved, project_dir, order_id, order_claimed_at, order_reason | commands/trekbrief.md (orchestrator-emit Phase 7; order_* added by lib/stats/stats-append.mjs) | trekbrief.md:1130-1145 | profile, phase_models, profile_source | none |
|
||||
| trekresearch-stats | ts, question, mode, scope, engine, slug, project_dir, brief_path, dimensions, dimensions_baseline, dimensions_baseline_preserved, effort, conv_turns, empty_turns, unique_sources, agents_local, agents_external, gemini_used, confidence, contradictions, open_questions | lib/stats/stats-append.mjs (trekresearch.md Stats tracking) | trekresearch.md:782-808 | profile, phase_models, parallel_agents, external_research_enabled, profile_source | none |
|
||||
| trekplan-stats | ts, task, mode, slug, brief_path, project_dir, codebase_size, codebase_files, agents_deployed, deep_dives, research_briefs_used, research_scout_used, critic_verdict, guardian_verdict, outcome | lib/stats/stats-append.mjs (trekplan.md Phase 12) | trekplan.md:1115-1135 | profile, phase_models, parallel_agents, profile_source | none |
|
||||
| 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:2344-2366 | 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:2855-2877 | 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:628-632 | profile, phase_models, profile_source | none |
|
||||
|
|
|
|||
|
|
@ -103,15 +103,19 @@ test('review 8: a command nobody was asked about is not logged, even if it reads
|
|||
assert.equal(readStubLog(log).length, 0);
|
||||
});
|
||||
|
||||
test('uncertain (bypass mode): an approval that no person gave is not logged', async () => {
|
||||
// Operator decision 2026-10-09: where no person can be asked, the guard refuses
|
||||
// a merge into main or a push (exit 2) instead of asking, so the call never runs
|
||||
// and nothing reaches the approval log. post-approval-log's own check of the
|
||||
// recorded mode stays as a second line.
|
||||
test('uncertain (no-prompt mode): the push to main is refused, and no approval is logged', async () => {
|
||||
const plugin = pluginCopy({ 'lib/util/approval-log.mjs': 'stub' });
|
||||
const repo = makeRepo('main');
|
||||
const base = { session_id: 's-b', tool_use_id: 'toolu_b', cwd: repo, tool_name: 'Bash', tool_input: { command: 'git push origin main' }, permission_mode: 'bypassPermissions' };
|
||||
await runHook(join(plugin, 'hooks', 'scripts', 'pre-bash-executor.mjs'), { ...base, hook_event_name: 'PreToolUse' });
|
||||
const pre = await runHook(join(plugin, 'hooks', 'scripts', 'pre-bash-executor.mjs'), { ...base, hook_event_name: 'PreToolUse' });
|
||||
assert.equal(pre.code, 2, `refused, not asked: ${JSON.stringify(pre)}`);
|
||||
const log = join(tmp(), 'stub.jsonl');
|
||||
const r = await runHookWithEnv(join(plugin, 'hooks', 'scripts', 'post-approval-log.mjs'), { ...base, hook_event_name: 'PostToolUse' }, { STUB_LOG: log });
|
||||
await runHookWithEnv(join(plugin, 'hooks', 'scripts', 'post-approval-log.mjs'), { ...base, hook_event_name: 'PostToolUse' }, { STUB_LOG: log });
|
||||
assert.equal(readStubLog(log).length, 0);
|
||||
assert.match(parseJsonOut(r.stdout).hookSpecificOutput.additionalContext, /not logged/i);
|
||||
});
|
||||
|
||||
// 9. Classification
|
||||
|
|
|
|||
104
tests/lib/coupling-prose-pins.test.mjs
Normal file
104
tests/lib/coupling-prose-pins.test.mjs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// tests/lib/coupling-prose-pins.test.mjs
|
||||
// Wave 2, order 6 — prose pins for the coupling in commands/trekexecute.md:
|
||||
// the review at the end of a run (P11), the one question before a merge
|
||||
// (operator decision 2026-10-08: the guard stays, the executor's own question
|
||||
// goes where it doubled), the checks wired in at step end and in Phase 7
|
||||
// (K5, K6, K9, K10), and the pointer lines to the skills `test-first` and
|
||||
// `debugging` (S2). The behaviour behind each pin runs in
|
||||
// tests/commands/trekexecute-coupling.test.mjs.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const CMD = readFileSync(join(ROOT, 'commands', 'trekexecute.md'), 'utf8');
|
||||
|
||||
function section(text, heading) {
|
||||
const at = text.indexOf(`\n${heading}\n`);
|
||||
assert.ok(at >= 0, `trekexecute.md must have the heading "${heading}"`);
|
||||
const level = heading.match(/^#+/)[0].length;
|
||||
const lines = text.slice(at + heading.length + 2).split('\n');
|
||||
const stop = new RegExp(`^#{1,${level}} `);
|
||||
let fenced = false;
|
||||
const out = [];
|
||||
for (const l of lines) {
|
||||
if (/^\s*(```|~~~)/.test(l)) fenced = !fenced;
|
||||
else if (!fenced && stop.test(l)) break;
|
||||
out.push(l);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
test('S2: Sub-step B asks for test-first by name, Sub-step F for debugging', () => {
|
||||
assert.match(section(CMD, '#### Sub-step B — Test first and proof of red'), /the `test-first` skill/);
|
||||
assert.match(section(CMD, '#### Sub-step F — Fix rounds'), /the `debugging` skill/);
|
||||
const c = section(CMD, '#### Sub-step C — Implement through one fresh helper');
|
||||
assert.match(c, /`skill: test-first`/, 'the build helper is told the skill by name');
|
||||
assert.match(c, /`skill: debugging`/, 'the fix helper is told the skill by name');
|
||||
// The skill is the advice, the gate is the guarantee: proof of red stays a command.
|
||||
assert.match(section(CMD, '#### Sub-step B — Test first and proof of red'), /red-proof\.mjs --base \{start_sha\}/);
|
||||
});
|
||||
|
||||
test('P11: the review runs by itself after Phase 7, in Cherny\'s order, before Phase 8', () => {
|
||||
const p = section(CMD, '## Phase 7.2 — Simplify, review, verdict (P11)');
|
||||
const order = ['run the thing', 'Skill("simplify"', 'Skill("voyage:trekreview"', 'verdict-gate.mjs'];
|
||||
const at = order.map((s) => p.indexOf(s));
|
||||
at.forEach((i, k) => assert.ok(i >= 0, `Phase 7.2 must name ${order[k]}`));
|
||||
assert.deepEqual([...at].sort((a, b) => a - b), at, `order must be ${order.join(' -> ')}`);
|
||||
assert.ok(CMD.indexOf('## Phase 7.2 —') > CMD.indexOf('## Phase 7 —'), 'after Phase 7');
|
||||
assert.ok(CMD.indexOf('## Phase 7.2 —') < CMD.indexOf('## Phase 8 —'), 'before Phase 8');
|
||||
assert.match(CMD.split('\n---\n')[0], /\nallowed-tools:[^\n]*\bSkill\b/, 'the command may use the Skill tool');
|
||||
assert.match(p, /never typed by the operator/i);
|
||||
});
|
||||
|
||||
test('P11: a BLOCKER becomes new numbered plan steps, in a bounded loop that ends with the operator', () => {
|
||||
const p = section(CMD, '## Phase 7.2 — Simplify, review, verdict (P11)');
|
||||
assert.match(p, /TREKEXECUTE_MAX_REVIEW_ROUNDS/, 'the cap is named');
|
||||
assert.match(p, /default \*\*3\*\*/);
|
||||
assert.match(p, /### Step R<n>/, 'the review\'s remediation steps are the source');
|
||||
assert.match(p, /never reworded/);
|
||||
assert.match(p, /at the cap[^\n]*operator/i);
|
||||
assert.match(p, /not improvised/);
|
||||
});
|
||||
|
||||
test('P11: nothing is dropped silently - simplify edits outside the run are reverted and listed', () => {
|
||||
const p = section(CMD, '## Phase 7.2 — Simplify, review, verdict (P11)');
|
||||
assert.match(p, /outside the run's changed files/i);
|
||||
assert.match(p, /"reverted"/);
|
||||
assert.match(p, /"not-run"/, 'a simplify that could not start is said, not hidden');
|
||||
});
|
||||
|
||||
test('P12: one question before a merge - the guard asks, the executor does not ask again', () => {
|
||||
const gate = section(CMD, '### Main-merge gate (MAIN_MERGE_GATE — always pauses)');
|
||||
assert.doesNotMatch(gate, /AskUserQuestion/, 'the gate is a lock, not a question');
|
||||
assert.match(gate, /operator decision 2026-10-08/);
|
||||
assert.match(gate, /the guard's question is the one question/i);
|
||||
const step3 = section(CMD, '### Step 3 — Collect wave W (a later run)');
|
||||
assert.doesNotMatch(step3, /pause for operator\s+confirmation before merge-back/, 'no executor pause before the wave merge');
|
||||
const allDone = /- \*\*`all_completed: true`:\*\*([\s\S]*?)\n\n/.exec(step3);
|
||||
assert.ok(allDone, 'Step 3 has its all_completed rule');
|
||||
assert.match(allDone[1], /The executor asks\s+nothing here, `gates_mode` or not/);
|
||||
assert.doesNotMatch(allDone[1], /AskUserQuestion|pause for operator/i);
|
||||
assert.match(step3, /one Bash call/, 'push and merge go in one call, so the guard asks once');
|
||||
assert.match(section(CMD, '## Hard rules'), /never asks again/);
|
||||
});
|
||||
|
||||
test('P7/P3/P9: K9 at step end, K5 and K6 in Phase 7', () => {
|
||||
const d4 = section(CMD, '#### Sub-step D4 — Step check and regression');
|
||||
assert.match(d4, /lib\/verification\/step-check\.mjs"? --cwd "\$CHECK_CWD"/);
|
||||
const p7 = section(CMD, '### Architecture rules (K5) and the plan-file sweep (K6)');
|
||||
assert.match(p7, /lib\/verification\/arch-rules\.mjs"? --repo "\$CHECK_CWD"/);
|
||||
assert.match(p7, /lib\/verification\/plan-scope-sweep\.mjs"? --plan "\{plan_path\}" --base "\{session_start_sha\}"/);
|
||||
assert.match(p7, /a finding in the final report with the file list/, 'a K6 deviation is a finding, not a silent warning');
|
||||
assert.match(p7, /not measured/);
|
||||
});
|
||||
|
||||
test('Credits: every borrowing of the coupling is named', () => {
|
||||
const credits = section(CMD, '## Credits');
|
||||
for (const s of [/Cherny[^\n]*order/i, /Kiro[^\n]*gate/i, /Spec Kit[^\n]*loop/i, /Superpowers[^\n]*silent/i]) {
|
||||
assert.match(credits, s);
|
||||
}
|
||||
});
|
||||
|
|
@ -67,6 +67,8 @@ const EXEMPT = new Map([
|
|||
['.gitignore', ['operator-instrument', 1, 'ignores the output of that same operator-run eval']],
|
||||
['hooks/scripts/post-approval-log.mjs', ['mode-read', 3, "reads the session's permission_mode from hook input so an approval is never logged when no person was asked; starts nothing - operator decision 2026-10-06"]],
|
||||
['tests/hooks/guards-review-regressions.test.mjs', ['mode-read', 1, 'feeds that same permission_mode value to the hook under test; starts nothing']],
|
||||
['hooks/scripts/pre-bash-executor.mjs', ['mode-read', 1, "reads the session's permission_mode from hook input so every call the guard would ask about (merge into main, push, tag/release, destructive git) is refused when no person can be asked; starts nothing - operator decision 2026-10-09"]],
|
||||
['tests/commands/trekexecute-coupling.test.mjs', ['mode-read', 1, 'feeds that same permission_mode value to the guard under test; starts nothing - operator decision 2026-10-09']],
|
||||
]);
|
||||
|
||||
function trackedFiles(root) {
|
||||
|
|
|
|||
|
|
@ -47,9 +47,10 @@ function run(blockText, home) {
|
|||
return execFileSync('bash', ['-c', `${blockText}\nprintf '%s' "$VOYAGE_ROOT"`], { env, encoding: 'utf-8' });
|
||||
}
|
||||
|
||||
test('commands carry exactly the four known VOYAGE_ROOT blocks', () => {
|
||||
// trekexecute blocks 3 and 4 are Sub-step D4 (step check + regression) and Phase 7's K5/K6 checks (wave 2, order 6).
|
||||
test('commands carry exactly the six known VOYAGE_ROOT blocks', () => {
|
||||
assert.deepEqual(resolutionBlocks().map(b => b.where.split(':')[0]), [
|
||||
'commands/trekexecute.md', 'commands/trekexecute.md',
|
||||
'commands/trekexecute.md', 'commands/trekexecute.md', 'commands/trekexecute.md', 'commands/trekexecute.md',
|
||||
'commands/trekresearch.md', 'commands/trekreview.md',
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue