---
name: trekexecute
description: Disciplined plan executor — single-session, or multi-session through session orders the user starts, with failure recovery
argument-hint: "[--project
] [--fg | --resume | --dry-run | --validate | --step N | --session N] [plan.md]"
allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion, Agent, Skill
disallowed-tools: TeamCreate
---
# Ultraexecute Local
Disciplined executor for trekplan plans. Reads a plan file, detects if it has
an Execution Strategy (multi-session), and either executes directly or
prepares one session order per session, wave by wave — all to realize one plan.
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 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
themselves (Phase 2.6). Helpers inside this session are subagents, and helpers
never start helpers.
## Phase 1 — Parse mode and validate input
Parse `$ARGUMENTS` for mode flags:
1. If arguments contain `--project `: extract the directory path.
- Resolve `{dir}` (trim trailing slash).
- Set **project_dir = {dir}**.
- Derive implicit file path: `{dir}/plan.md`.
- If `{dir}` does not exist or `{dir}/plan.md` is missing:
```
Error: project directory missing plan.md: {dir}
Run /trekplan --project {dir} to produce it.
```
- If no explicit `` argument is provided after flag parsing,
use the derived `{dir}/plan.md`. If a `` argument IS provided,
use that path but keep `project_dir` set (user may be pointing at a
recovery session spec inside the project).
- Continue parsing remaining flags (they combine with `--project`).
2. If arguments contain `--fg`: extract the file path. Set **mode = foreground**.
3. If arguments contain `--resume`: extract the file path. Set **mode = resume**.
4. If arguments contain `--dry-run`: extract the file path. Set **mode = dry-run**.
5. If arguments contain `--validate`: extract the file path. Set **mode = validate**.
6. If arguments contain `--step N` (N is a positive integer): extract N and the file path.
Set **mode = step**, **target-step = N**.
7. If arguments contain `--session N` (N is a positive integer): extract N and the file path.
Set **mode = session**, **target-session = N**.
8. If arguments contain `--gates`: set `gates_mode = true`. Default
`gates_mode = false`. The flag is consumed by the autonomy-gate state
machine via the CLI shim:
`node ${CLAUDE_PLUGIN_ROOT}/lib/util/autonomy-gate.mjs --state X --event Y --gates {true|false}`.
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
usage and stop:
```
Usage: /trekexecute
/trekexecute --project
/trekexecute --fg
/trekexecute --resume
/trekexecute --dry-run
/trekexecute --validate
/trekexecute --step N
/trekexecute --session N
/trekexecute --project --resume
/trekexecute --project --session N
Modes:
(default) Auto — multi-session if plan has Execution Strategy, else foreground
--project Resolve plan.md inside an trekbrief project folder; progress.json
is written there too
--fg Force foreground — all steps sequentially, ignore Execution Strategy
--resume Resume from last progress checkpoint
--dry-run Validate plan and show execution strategy without running
--validate Schema-only check — parse steps + manifests, no security scan, no execution
--step N Execute only step N (foreground)
--session N Execute only session N from the plan's Execution Strategy
Examples:
/trekexecute .claude/plans/trekplan-2026-04-06-auth-refactor.md
/trekexecute --project .claude/projects/2026-04-18-jwt-auth
/trekexecute --project .claude/projects/2026-04-18-jwt-auth --resume
/trekexecute --fg .claude/plans/trekplan-2026-04-06-auth-refactor.md
/trekexecute --session 2 .claude/plans/trekplan-2026-04-06-auth-refactor.md
/trekexecute --dry-run .claude/plans/trekplan-2026-04-06-auth-refactor.md
/trekexecute --validate .claude/plans/trekplan-2026-04-06-auth-refactor.md
```
If the file does not exist, report and stop:
```
Error: file not found: {path}
```
Report detected mode:
```
Mode: {execute | resume | dry-run | step N | session N}
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**:
- **Session spec**: contains `## Dependencies` with `Entry condition:` AND `## Scope Fence`
AND `## Exit Condition` sections.
- **Voyage**: contains `## Implementation Plan` with numbered `### Step N:` headings
but no `## Scope Fence`.
If neither structure is detected, report and stop:
```
Error: unrecognized file format. Expected an trekplan or session spec.
```
### Parse steps
Extract every `### Step N: {description}` heading (in order). For each step, extract:
- **Files** — file paths to create or modify
- **Changes** — what to modify
- **Reuses** — existing code to leverage (informational)
- **Test first** — the test the step writes before it builds: its `File:`
(existing | new), what it `Verifies:`, an optional `Pattern:` (required from
plan_version ≥ 1.8, see below)
- **Verify** — command to run after implementation: the first backticked
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: 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):
what the step consumes and produces, and what its controller must look at
hardest. Read when present and passed to the helper and the controller;
never required, and the executor tolerates their absence in every plan
version.
If a step is missing `On failure`, default to `escalate` and record a parse warning.
If a step is missing `Verify`, record a parse warning.
**`Test first:` is required from plan_version ≥ 1.8.** A ≥ 1.8 step without a
`Test first:` field (or whose `File:` is missing) is not executable: STOP with
```
Error: plan_version={v} but step {N} has no Test first: field.
Re-run /trekplan - from plan_version 1.8 every step proves red before it builds.
```
For plans below 1.8 the field stays optional: a step without it runs, its
`red_proof` is recorded as `"not-run"`, and the final report counts it among
the steps without proof of red (never hidden, never called proven).
### Parse the Manifest block (v1.7+)
Read the plan header for a `plan_version` marker. Treat ≥ `1.7` as strict
mode; absence or <1.7 as legacy mode.
**Strict mode (plan_version ≥ 1.7):**
- Every step MUST have a `Manifest:` block with a YAML fenced payload.
- Parse the YAML. Required keys: `expected_paths` (list), `min_file_count`
(integer), `commit_message_pattern` (string), `bash_syntax_check` (list),
`forbidden_paths` (list), `must_contain` (list of `{path, pattern}` dicts).
- Validate each `commit_message_pattern` compiles as a regex (use a tolerant
parser — do not execute untrusted input against shell).
- If any step is missing its Manifest, or YAML is malformed, STOP with:
```
Error: plan_version=1.7 but step {N} has invalid/missing Manifest.
Re-run planning-orchestrator — plan is not executable.
```
**Legacy mode (no plan_version, or < 1.7):**
- Synthesize a minimal manifest per step:
- `expected_paths` ← step's `Files:` list
- `min_file_count` ← `len(expected_paths)`
- `commit_message_pattern` ← regex-escape first 3 words of Checkpoint msg
- `bash_syntax_check` ← auto-detect `.sh` in Files
- `forbidden_paths` ← []
- `must_contain` ← []
- Record `legacy_plan: true` in the progress file.
- Emit warning: `Legacy plan (v1.6 or earlier) — manifests synthesized with
reduced audit precision.`
### Parse Session Manifest (session specs only)
If the file is a session spec (v1.7+), parse the `## Session Manifest` block
into a YAML dict. Preserve `session_manifest.*` fields for Phase 7.5 audit.
If missing and `plan_version ≥ 1.7`, record a parse warning but continue —
the per-step manifests are still available for audit.
### Parse session spec fields (if applicable)
- **Entry condition** from `## Dependencies`
- **Touch list** and **Never-touch list** from `## Scope Fence`
- **Exit condition** checklist from `## Exit Condition`
### Parse Execution Strategy (if present)
If the plan contains an `## Execution Strategy` section, extract:
- Each `### Session N: {title}` with its Steps, Wave, Depends on, and Scope fence
- The `### Execution Order` with wave definitions
Set **has_execution_strategy = true**.
Report:
```
Type: {plan | session-spec}
Steps: {N}
{if has_execution_strategy}: Execution Strategy: {S} sessions across {W} waves
{if session spec}: Entry condition: {text}
{if session spec}: Scope fence: {N} touch, {N} never-touch
{if warnings}: Warnings: {list}
```
## Phase 2.3 — Validate-only mode exit (if mode = validate)
**If mode = validate, stop after Phase 2 parsing** and emit a schema-only
report. Do NOT run security scan, do NOT touch progress files, do NOT
execute any steps. This gives the user a fast sanity-check of plan
schema compliance without side effects.
**Preferred path (since v3.1.0):** invoke the plan validator directly. It
returns the same diagnostic info Phase 2 derives in prose, with stable
error codes for downstream tooling:
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/plan-validator.mjs --strict --json "{path}"
# When --project is in scope and progress.json exists, also validate it:
[ -f "{project_dir}/progress.json" ] && \
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/progress-validator.mjs --json "{project_dir}/progress.json"
```
Map the validator's `code` field to the error templates below (e.g.
`PLAN_FORBIDDEN_HEADING` → "Detected heading format" branch). When both
calls exit 0, render the READY report. Otherwise render FAIL with the
validator's first error code + message.
If Phase 2 parsing succeeded (no fatal errors, every step has a valid
Manifest block in strict mode, or synthesized manifests in legacy mode):
```
=== Schema Validation: READY ===
File: {path}
Type: {plan | session-spec}
plan_version: {1.7 | legacy}
Steps: {N}
Manifests: {N valid | N synthesized (legacy)}
Warnings: {count}
{if warnings}: - {each warning on own line}
Plan is schema-compliant. Safe to run:
/trekexecute {path}
```
If Phase 2 parsing failed (unrecognized format, missing Manifest in strict
mode, malformed YAML, invalid regex):
```
=== Schema Validation: FAIL ===
File: {path}
Reason: {specific error from Phase 2}
{if format not recognized}:
Detected heading format: {e.g. "### Fase 1:", "## Phase 1"}
Expected: "### Step N: "
Fix: re-run /trekplan — planning-orchestrator must emit v1.7 format
{if missing manifest}:
Step {N} has no Manifest block (plan_version=1.7 requires one per step)
Fix: re-run /trekplan — planning-orchestrator must include manifest YAML
{if malformed YAML or invalid regex}:
Step {N}: {specific YAML/regex error}
Fix: edit the plan manually or re-run /trekplan
```
Exit after emitting the report. Do not continue to Phase 2.4 or later.
## Phase 2.4 — Pre-execution security scan
**Runs for all modes except dry-run and validate** (those modes exit earlier or have their own report format).
Scan every `Verify:` and `Checkpoint:` command in the parsed plan against the
executor security denylist. This catches dangerous commands before execution begins.
### Extract commands
For each step in the plan, extract the command string from:
- `Verify:` field (the shell command after the backtick-quoted command)
- `Checkpoint:` field (the git commit command)
Also extract Exit Condition commands if present.
### Check against denylist
For each extracted command, check against these patterns:
**BLOCK patterns (stop execution immediately):**
| Pattern | Threat |
|---------|--------|
| `rm` with both `-r` and `-f` flags (any order) | Recursive force delete |
| `chmod 777` or `chmod -R 777` | World-writable permissions |
| `curl`/`wget` piped to `bash`/`sh`/`zsh` | Remote code execution |
| `eval` with `$`, backtick, or `$(` | Code injection via eval |
| `mkfs` or `dd` writing to `/dev/sd*`, `/dev/nvme*`, `/dev/hd*` | Disk destruction |
| `shutdown`, `reboot`, `halt`, `poweroff` | System shutdown |
| `:(){ :\|:& };:` pattern | Fork bomb |
| `base64` piped to `bash`/`sh` | Obfuscated code execution |
| `crontab -e` or writing to `/etc/cron*` | Persistence via cron |
| `kill -9 -1` or `pkill -9 -1` | Kill all user processes |
| `history -c` or truncating `~/.bash_history` | Evidence destruction |
**WARN patterns (report but continue):**
| Pattern | Concern |
|---------|---------|
| `npm install --save`, `pip install`, `cargo add` | Dependency changes during execution |
| `git push --force` | History rewrite |
| `git reset --hard` | Discard uncommitted changes |
### Scan output
For each match:
```
Security scan: Step {N} — {description}
Command: {command}
{BLOCKED | WARNING}: {pattern name}
```
**If ANY BLOCK pattern is found:**
```
SECURITY SCAN FAILED: {count} dangerous command(s) found in plan.
Blocked commands:
Step {N}: {command} → {reason}
This plan contains commands blocked by the executor security policy.
The plan may have been tampered with or contain hallucinated dangerous commands.
Options:
1. Review and fix the plan file: {path}
2. Use --dry-run to inspect all commands without executing
3. Use --fg for interactive execution (hooks provide additional protection)
```
Stop execution. Do NOT continue to Phase 2.5.
**If only WARN patterns found:** Continue execution but include warnings in the
pre-execution summary. Report them in the final output under "Security advisories."
**If clean:** Report `Security scan: PASS ({N} commands checked)` and continue.
## Phase 2.5 — Execution strategy decision
Determine how to execute this plan:
**Run as single session (foreground)** when ANY of these are true:
- `--fg` flag is set
- `--step N` mode
- `--resume` mode
- `--session N` mode (runs only that session's steps, foreground)
- Plan has no `## Execution Strategy` section
- Plan has Execution Strategy with only 1 session
**Run as multi-session (parallel orchestration)** when ALL of these are true:
- mode = `execute` (default, no --fg)
- Plan has `## Execution Strategy` with 2+ sessions
- At least one wave has 2+ sessions (parallelism possible)
**Run as multi-session (sequential orchestration)** when:
- mode = `execute` (default, no --fg)
- Plan has `## Execution Strategy` with 2+ sessions
- All sessions are in different waves (no parallelism, but still separate sessions)
For single-session (including every `--session N` run inside a session's
worktree): continue to Phase 3.
For multi-session, first locate the main checkout and the wave marker:
```bash
GIT_DIR_ABS="$(cd "$(git rev-parse --git-dir)" && pwd -P)"
GIT_COMMON_ABS="$(cd "$(git rev-parse --git-common-dir)" && pwd -P)"
MAIN_ROOT="$(git worktree list --porcelain | sed -n '1s/^worktree //p')"
if [ "$GIT_DIR_ABS" != "$GIT_COMMON_ABS" ]; then
echo "Error: this is a linked worktree. Run this from the main checkout $MAIN_ROOT." >&2
exit 1
fi
if [ -f "$MAIN_ROOT/.claude/trekplan-sessions/{slug}/orders/wave.json" ]; then echo "WAVE_IN_FLIGHT"; else echo "NO_WAVE"; fi
```
Exit 1 means this is a session's (or another) linked worktree: stop and report
the message — the multi-session path runs only from the main checkout, whose
root the first entry of `git worktree list` names (`--show-toplevel` would name
the linked worktree and miss the wave). `WAVE_IN_FLIGHT`: a wave is awaiting
its sessions — skip Phase 2.55 (its clean-tree and stale-worktree checks would
touch the live worktrees) and jump to Phase 2.6, which collects the wave.
`NO_WAVE`: run Phase 2.55, then Phase 2.6.
Report:
```
Strategy: {single session | N sessions (M parallel, K sequential)}
```
## Phase 2.55 — Pre-flight safety checks
**Only runs for multi-session parallel execution.** These checks prevent the
catastrophic data loss that occurs when parallel sessions share a working directory.
### Check 1 — Clean working tree
Run `git status --porcelain -- ':(top,exclude).claude/trekplan-sessions/'`
(the whole repo, from any subdirectory, minus Voyage's own session state: orders,
archived progress and kept session worktrees are never merged, and counting them
would stop every re-run after a failed wave). If there are ANY uncommitted or
untracked changes:
```
Error: working tree is not clean. Parallel execution requires a clean git state.
Uncommitted changes are invisible to worktrees and will be lost during merge.
Untracked/modified files:
{output of git status --porcelain}
Commit or stash your changes, then re-run.
To run sequentially instead: /trekexecute --fg {plan-path}
```
Stop execution. Update progress with `status: "stopped"`.
**Also write `.session-state.local.json`** (Handover 7) — this surfaces the
stopped state to `/trekcontinue` so the next session can prompt the user to
clean the working tree before resuming. Write atomically alongside `progress.json`:
```json
{
"schema_version": 1,
"project": "{project_dir}",
"next_session_brief_path": "{project_dir}/brief.md",
"next_session_label": "{current session label, or 'Continue'}",
"status": "stopped",
"updated_at": "{ISO-8601 now}"
}
```
Verify with `node ${CLAUDE_PLUGIN_ROOT}/lib/validators/session-state-validator.mjs --json
{project_dir}/.session-state.local.json`. On validator failure, emit a warning
to stderr but do NOT block the stop; `progress.json` is still authoritative.
**Also write sibling `NEXT-SESSION-PROMPT.local.md`** with frontmatter
(`produced_by: trekexecute`, `produced_at: `,
`status: stopped`) so the next-session producer-mismatch check has both
candidates available. Use the same combined ESM block pattern as Phase 8.
### Check 2 — Plan file reaches every worktree
Worktrees are created from HEAD, so tracking the plan file is the cheapest way
to make it visible in each one. But the project directory may be **gitignored**
— `.claude/projects/` is tool-managed and local-only, and a repo that ignores it
is normal, not exotic. `git add -f` is **not** the answer there: it would push
operator-local artifacts into history, and into whatever remote the repo
publishes to. When the plan file is ignored, Phase 2.6 Step 2a' (which copies
brief/plan/research into each worktree) is the delivery path, and this check
must step aside instead of failing.
```bash
PLAN_PATH="{plan-path}"
if git ls-files --error-unmatch "$PLAN_PATH" >/dev/null 2>&1; then
PLAN_TRACKING="tracked"
else
git check-ignore -q "$PLAN_PATH"
case "$?" in
0) PLAN_TRACKING="ignored" ;;
1) PLAN_TRACKING="untracked" ;;
*) echo "Error: git check-ignore failed on $PLAN_PATH - a fatal probe is not an answer about ignore status." >&2
exit 1 ;;
esac
fi
if [ "$PLAN_TRACKING" = "untracked" ]; then
git add "$PLAN_PATH"
git commit -m "chore: track plan file for parallel execution"
fi
```
Report by outcome:
| `PLAN_TRACKING` | Report |
|---|---|
| `tracked` | `Plan file already tracked.` |
| `untracked` | `Plan file committed for worktree visibility.` |
| `ignored` | `Plan file is gitignored — not forced into history. Step 2a' copies it into each worktree.` |
Any other `git check-ignore` exit code is fatal and stops execution: a probe
that failed is not a probe that answered "not ignored".
### Check 3 — Scope fence overlap validation
For each wave that has 2+ sessions, validate that no file appears in the Touch
list of two different sessions in the same wave:
1. For each session in the wave, extract the "Touch" list from the Execution Strategy.
2. For each pair of sessions (A, B) in the same wave, compute the intersection
of their Touch lists.
3. If any intersection is non-empty:
```
Error: scope fence overlap detected in Wave {W}.
Sessions {A} and {B} both touch: {overlapping files}
These sessions cannot safely run in parallel.
Fix the Execution Strategy in the plan, or use --fg for sequential execution.
```
Stop execution. This is a defense-in-depth check — the planning-orchestrator
should have prevented this, but verifying at execution time catches plans
that were manually edited or have bugs.
### Check 4 — Stale worktree cleanup
Clean up what an earlier run of this plan left under
`trekplan-sessions/{slug}/` — never with force. It refuses from a linked
worktree and while a wave is awaiting its sessions (those worktrees and branches
are live or finished session work, not stale). `git worktree remove` without
force refuses a worktree with modified or untracked files, and `git branch -d`
refuses an unmerged branch: both are kept, reported, and never retried with
force.
```bash
# zsh (the Bash tool runs the user's shell): an unmatched glob stays literal, as in bash.
[ -n "${ZSH_VERSION:-}" ] && setopt nonomatch
GIT_DIR_ABS="$(cd "$(git rev-parse --git-dir)" && pwd -P)"
GIT_COMMON_ABS="$(cd "$(git rev-parse --git-common-dir)" && pwd -P)"
MAIN_ROOT="$(git worktree list --porcelain | sed -n '1s/^worktree //p')"
if [ "$GIT_DIR_ABS" != "$GIT_COMMON_ABS" ]; then
echo "Error: this is a linked worktree. Run this from the main checkout $MAIN_ROOT." >&2
exit 1
fi
SESSIONS="$MAIN_ROOT/.claude/trekplan-sessions/{slug}"
if [ -f "$SESSIONS/orders/wave.json" ]; then
echo "Error: $SESSIONS/orders/wave.json exists - a wave is awaiting its sessions. Stale-worktree cleanup refused." >&2
exit 1
fi
cd "$MAIN_ROOT" || exit 1
for wt in "$SESSIONS/worktrees"/session-*; do
[ -d "$wt" ] || continue
GIT_OPTIONAL_LOCKS=0 git worktree remove "$wt" 2>/dev/null || echo "KEPT worktree $wt"
done
GIT_OPTIONAL_LOCKS=0 git worktree prune
git for-each-ref --format='%(refname:short)' "refs/heads/trek/{slug}/" | while read -r b; do
GIT_OPTIONAL_LOCKS=0 git branch -d "$b" >/dev/null 2>&1 || echo "KEPT branch $b"
done
KEPT="$(ls -d "$SESSIONS/worktrees"/session-* 2>/dev/null; git for-each-ref --format='%(refname:short)' "refs/heads/trek/{slug}/")"
if [ -n "$KEPT" ]; then
echo "Earlier session work kept (changes or unmerged commits):" >&2
echo "$KEPT" >&2
exit 1
fi
```
Exit 0: report `Cleaned stale worktrees and merged branches from a previous run.`
Exit 1: stop and report its message. For every kept item say what the user can
do: a worktree — inspect it (`git -C status --short`), commit or move
what matters, then `git worktree remove `; a branch — inspect it
(`git log HEAD..`), merge it by hand, or delete it themselves once its
commits are not needed. Then re-run.
After all 4 checks pass:
```
Pre-flight: PASS (clean tree, plan reaches worktrees, no overlaps, no stale worktrees)
```
## Phase 2.6 — Multi-session orchestration (worktree-isolated, session orders)
**Only runs for multi-session execution.** Voyage never starts a Claude Code
session. This phase prepares ONE wave at a time: it creates an isolated git
worktree for each session in the wave, writes a **session order** for each
(the worktree, the steps, the done-condition) plus a one-line **starter**, and
stops. The user opens each session themselves, in the directory the order
names, and gives it the starter. Running the same `/trekexecute` command again
in the main checkout collects the wave: it reads every session's progress file,
merges the session branches back sequentially, cleans up, and prepares the next
wave. After the last wave, jump to Phase 8 (final report).
**CRITICAL SAFETY RULE:** Every session MUST run in its own git worktree. Never
run two sessions in the same working directory. This rule exists because
parallel git operations in a shared worktree cause index corruption, race
conditions, and repository destruction. Each order names its own worktree.
### Step 0 — Resolve paths and detect a wave in flight
```bash
# The MAIN checkout, never a linked worktree (Phase 2.5): in a session's
# worktree --show-toplevel names that worktree.
GIT_DIR_ABS="$(cd "$(git rev-parse --git-dir)" && pwd -P)"
GIT_COMMON_ABS="$(cd "$(git rev-parse --git-common-dir)" && pwd -P)"
REPO_ROOT="$(git worktree list --porcelain | sed -n '1s/^worktree //p')"
if [ "$GIT_DIR_ABS" != "$GIT_COMMON_ABS" ]; then
echo "Error: this is a linked worktree. Run this from the main checkout $REPO_ROOT." >&2
exit 1
fi
# The plan path relative to the main checkout: every session-orders.mjs call
# runs from $REPO_ROOT with it, and each session finds the plan there in its
# own worktree.
PLAN_REL="$(python3 -c 'import os.path,sys; print(os.path.relpath(os.path.realpath(sys.argv[1]), os.path.realpath(sys.argv[2])))' "{plan-path}" "$REPO_ROOT")"
case "$PLAN_REL" in
""|..*)
echo "Error: the plan {plan-path} is not inside the main checkout $REPO_ROOT." >&2
exit 1 ;;
esac
SESSION_DIR="$REPO_ROOT/.claude/trekplan-sessions/{slug}"
WORKTREE_DIR="$SESSION_DIR/worktrees"
ORDERS_DIR="$SESSION_DIR/orders"
ORIGINAL_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
# Resolve the plugin root ONCE (same reason as Phase 7: the variable is
# substituted in this command's text but EMPTY in the Bash tool's env).
# Only the literal form is substituted (a `:-` default is never replaced); the
# fallback takes the highest installed version under cache//voyage/.
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
if [ ! -f "$VOYAGE_ROOT/lib/util/session-orders.mjs" ]; then
echo "[voyage] session orders cannot be written - plugin root unresolved." >&2
exit 2
fi
if [ -f "$ORDERS_DIR/wave.json" ]; then echo "WAVE_IN_FLIGHT"; else echo "NO_WAVE"; fi
```
Record `REPO_ROOT`, `PLAN_REL`, `SESSION_DIR`, `WORKTREE_DIR`, `ORDERS_DIR`,
`VOYAGE_ROOT` and `ORIGINAL_BRANCH` for the later steps. All paths except
`PLAN_REL` must be absolute.
- `WAVE_IN_FLIGHT` → read the wave number `W` from `$ORDERS_DIR/wave.json` and
jump to **Step 3** (collect). Phase 2.55 was skipped for this run (Phase 2.5).
- `NO_WAVE` → prepare the lowest wave not yet merged:
```bash
(cd "$REPO_ROOT" && node "$VOYAGE_ROOT/lib/util/session-orders.mjs" next-wave --plan "$PLAN_REL" --session-dir "$SESSION_DIR")
```
A number is `W`: continue to Step 1. `none` means earlier runs merged every
wave: go to Step 4. The record it reads, `$ORDERS_DIR/merged-waves.json`
(plan path + sha256 of the plan's content), is written by Step 3 after each
merged wave, survives Step 5, and is cleared only on confirm at Phase 8's
main-merge gate, when every wave is merged and Step 4 passed, so a re-run
after a later wave failed (or after a declined gate) never prepares an
already-merged wave again.
If `next-wave` exits 1, the record belongs to another plan or another version
of this one (`{slug}` is the plan's file name, so plans can share
`$SESSION_DIR`). Stop, touch nothing, and tell the user exactly what its
message says: an unfinished multi-session run of that plan holds
`$ORDERS_DIR`. Finish that run (re-run it with its own plan), or clear it and
re-run this one:
`(cd "$REPO_ROOT" && node "$VOYAGE_ROOT/lib/util/session-orders.mjs" clear --session-dir "$SESSION_DIR")`.
### Step 1 — Create worktrees for wave W
```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:**
First make sure no session of this wave left a branch or a worktree behind in
an earlier attempt — it may hold that session's work, so it is neither reused
nor deleted:
```bash
EXISTING=""
for N in {wave W session numbers}; do
if git rev-parse -q --verify "refs/heads/trek/{slug}/session-$N" >/dev/null || [ -e "$WORKTREE_DIR/session-$N" ]; then
EXISTING="$EXISTING trek/{slug}/session-$N"
fi
done
if [ -n "$EXISTING" ]; then
echo "Error: left by an earlier attempt:$EXISTING (branch or $WORKTREE_DIR/session-N)." >&2
exit 1
fi
```
If it exits 1, stop and report (no Step 5, no Phase 8; nothing was created or
deleted): name each branch and worktree, and what the user can do — inspect it
(`git log HEAD..`, `git -C status --short`), merge it by
hand, or delete it themselves once its work is not needed — then re-run.
Then, for each session N in this wave:
```bash
BRANCH_NAME="trek/{slug}/session-{N}"
WORKTREE_PATH="$WORKTREE_DIR/session-{N}"
GIT_OPTIONAL_LOCKS=0 git worktree add -b "$BRANCH_NAME" "$WORKTREE_PATH" HEAD
```
`GIT_OPTIONAL_LOCKS=0` disables `.git/index.lock` background polling races
during parallel worktree operations (research/02 R2; GH #47721).
If `git worktree add` fails, report its error and stop the wave: never delete a
branch to make room. Jump to Step 5 (it removes the worktrees this wave just
created, without force), then Phase 8.
Report:
```
Worktree created: session-{N} → {WORKTREE_PATH} (branch: {BRANCH_NAME})
```
**2a'. Copy gitignored project artifacts into each worktree (addresses
plugin-in-monorepo + gitignored-state topology):**
`git worktree add` clones HEAD only. The plugin's `.gitignore` excludes
`.claude/projects/`, so the project directory (brief.md, plan.md, research/)
is missing in the freshly-created worktree. Each session needs to read
`brief.md`, `plan.md`, and `research/*` from `${PROJECT_DIR}` to do real work.
Insert this block AFTER the worktree-creation loop and BEFORE the orders are
written (Step 2):
```bash
# zsh (the Bash tool runs the user's shell): an unmatched glob stays literal, as in bash.
[ -n "${ZSH_VERSION:-}" ] && setopt nonomatch
PROJECT_SOURCE="$(realpath "${PROJECT_DIR}")"
REPO_ROOT_REAL="$(realpath "${REPO_ROOT}")"
# Compute destination relpath: PROJECT_DIR relative to REPO_ROOT.
# This makes $wt/$PROJECT_REL valid regardless of whether the operator
# passed --project as relative (.claude/projects/...) or absolute.
# python3 + os.path.relpath is stdlib and portable - see the note below the
# block for why no realpath flag may be used here.
PROJECT_REL="$(python3 -c 'import os.path,sys; print(os.path.relpath(sys.argv[1], sys.argv[2]))' "$PROJECT_SOURCE" "$REPO_ROOT_REAL")"
case "$PROJECT_REL" in
""|..*)
echo "Error: cannot derive a project relpath inside the repo ($PROJECT_SOURCE vs $REPO_ROOT_REAL)." >&2
exit 1 ;;
esac
for wt in "$WORKTREE_DIR"/session-*; do
[ -d "$wt" ] || continue
mkdir -p "$wt/$PROJECT_REL"
cp "$PROJECT_SOURCE"/brief.md "$wt/$PROJECT_REL/"
cp "$PROJECT_SOURCE"/plan.md "$wt/$PROJECT_REL/"
if [ -d "$PROJECT_SOURCE/research" ]; then
cp -r "$PROJECT_SOURCE/research" "$wt/$PROJECT_REL/"
fi
done
```
Do not "improve" the relpath line into `realpath --relative-to=...`. That flag
is GNU coreutils only; BSD `realpath` (the macOS default) rejects it, and the
failure is **silent** — the command substitution leaves `PROJECT_REL` empty, so
`mkdir -p "$wt/"` and `cp ... "$wt//"` both succeed and drop `brief.md`/`plan.md`
at the worktree root, where no session looks for them. Measured on an
Intel Mac 2026-08-31: `realpath --relative-to=... ` → `realpath: illegal option
-- -`, while bare `realpath ` works; a whole wave ran with zero steps
executed. Both `realpath` calls above are bare path resolution, which BSD and
GNU handle identically; resolving both operands before the relpath is what keeps
it correct when one side goes through a symlink (macOS `/var` → `/private/var`).
Failure modes: an underivable relpath (empty, or outside the repo) aborts
before anything is copied — better a loud stop than files delivered where no
session reads them; any `cp` failure exits the wave non-zero, reported via
Step 5 cleanup. A project without `research/` is not a failure.
Source: brief Constraint 2.
### Step 2 — Write the session orders for wave W, then stop
```bash
(cd "$REPO_ROOT" && node "$VOYAGE_ROOT/lib/util/session-orders.mjs" write --plan "$PLAN_REL" --wave "{W}" --session-dir "$SESSION_DIR" --branch "$ORIGINAL_BRANCH")
```
`$PLAN_REL` is relative to the main checkout (Step 0; the tool refuses an
absolute path): each session resolves it inside its own worktree, which has the
same repo content from HEAD. The tool writes `$ORDERS_DIR/wave-{W}-session-{N}.md`
per session (naming its worktree, directory and progress file) and
`$ORDERS_DIR/wave.json` (`status: awaiting-sessions`, the plan path, the
sha256 of its content and the branch the wave is prepared on), and prints, per
session, the directory to open and the starter line
(`/trekexecute --session {N} $PLAN_REL`). A session's per-session `cwd:`
field in the Execution Strategy moves the directory it opens in to that
subdirectory of its worktree (plugin-in-monorepo layouts, brief Constraint 1,
Option B); default `.` is the worktree root.
Show the tool's output to the user verbatim, then report and **stop**:
```
Wave {W}: {N} session order(s) written in {ORDERS_DIR}. Awaiting sessions.
Open each session yourself in the directory its order names and give it the
starter line. When every session is done, run this same /trekexecute command
again here to merge the wave.
```
This run ends here. It writes no Phase 8 report: the plan is not finished, and
the orders are the hand-over.
### Step 3 — Collect wave W (a later run)
```bash
(cd "$REPO_ROOT" && node "$VOYAGE_ROOT/lib/util/session-orders.mjs" status --plan "$PLAN_REL" --wave "{W}" --session-dir "$SESSION_DIR" --branch "$ORIGINAL_BRANCH")
```
If it exits 1, stop and report its message, touching nothing (`wave.json`
stays). Either `wave.json` was written for another plan or another version of
this one — re-run with the plan (version) that wrote the wave, or abandon the
wave (the way out below) — or the main checkout is on another branch than the
wave was prepared on (Step 0's `ORIGINAL_BRANCH` of that run): the user
switches back to that branch and re-runs, so the wave never merges elsewhere.
The JSON names each session's progress file (the file `--session N` writes,
Phase 3), its raw `status`, and its `state`, exactly one of:
`completed`; `running` — no progress file yet, `pending`, `in-progress`,
`in_progress`, or any status not named here (an unknown status is never treated
as ended); `ended-bad` — `failed`, `stopped`, `partial`, `blocked` or
`unreadable`. `running` and `ended_bad` list the sessions in each state. Check
in this order:
- **Any `running` (checked FIRST):** a session is still open. Report which
sessions are still running, with their raw status, point at their order files
(`$ORDERS_DIR/wave-{W}-session-{N}.md`), and **stop** — even when another
session in the wave already ended badly. Touch no worktree, no branch and not
`wave.json`: no merge, no archive, no cleanup.
**Way out of a stuck wave** (a session the user decided not to run): the user
deletes `$ORDERS_DIR/wave.json` and re-runs. That run takes the fresh path,
and Phase 2.55 Check 4 removes the wave's clean worktrees and merged branches
without force and stops on everything that holds work, listing it to merge by
hand or delete.
- No session is running. Before any worktree is removed (the failure branch's
Step 5 and the merge below both remove them), archive every session's
progress file:
```bash
(cd "$REPO_ROOT" && node "$VOYAGE_ROOT/lib/util/session-orders.mjs" archive --plan "$PLAN_REL" --wave "{W}" --session-dir "$SESSION_DIR")
```
It copies each one to `$ORDERS_DIR/wave-{W}-session-{N}.progress.json`. Step 5
never deletes these copies; Step 6 and Phase 8 read them.
- **Any `ended-bad`:**
```
Wave {W} FAILED: Session {N} ended {status}.
Stopping — later waves depend on this wave.
See its order: {ORDERS_DIR}/wave-{W}-session-{N}.md
```
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`:** 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`. In the permission mode
that shows no prompts the guard does not ask: it refuses the push and the
merge (exit 2, "no one to ask"). Then stop, keep `wave.json`, and report that
the guard refused because no one can be asked, not that local changes are in
the way: the operator runs this session's push-and-merge block by hand, or
switches to a mode that asks and re-runs. A re-run in the same mode is
refused again.
**Merge session branches back (SEQUENTIAL, one at a time):**
Return to the main worktree and make sure it is clean. While the sessions ran
the user may have kept working here, and Phase 2.55 (its Check 1) did not run
for this collect:
```bash
cd "$REPO_ROOT" || exit 1
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "Error: uncommitted changes in $REPO_ROOT. Commit or stash your changes in $REPO_ROOT, then re-run." >&2
exit 1
fi
```
If it exits 1, stop and report its message: no merge, no cleanup, and keep
`wave.json`, so the re-run collects this wave again.
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.). 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}" \
-m "merge: trekplan session {N} — {session-title}"; then
echo "MERGED session {N}"
elif git rev-parse -q --verify MERGE_HEAD >/dev/null; then
git diff --name-only --diff-filter=U
git merge --abort
echo "MERGE_CONFLICT session {N}"
exit 1
else
echo "MERGE_REFUSED session {N}"
exit 1
fi
```
- `MERGED`: continue to the next session.
- `MERGE_REFUSED`: git refused to start the merge (no `MERGE_HEAD`; its message
names the local changes or untracked files in the way). Stop and report
`commit, stash or move your changes in {REPO_ROOT}, then re-run`: no Step 5, and
keep `wave.json`. Sessions merged before it in this wave stay merged; the
re-run merges them again as no-ops.
- `MERGE_CONFLICT`: a real conflict, already aborted. Report:
```
Wave {W} MERGE CONFLICT: Session {N} branch conflicts with merged state.
Conflicting files:
{the files the block printed}
Session {N} order: {ORDERS_DIR}/wave-{W}-session-{N}.md
Aborting further merges. Sessions already merged in this wave are preserved.
```
Mark remaining sessions as "merge-failed". Jump to Step 5 (it keeps every
unmerged branch), then Phase 8.
**Record the merged wave, then remove its worktrees:**
After successful merge of all sessions in the wave, record it (Step 0 reads the
record, so a later run never prepares this wave again):
```bash
(cd "$REPO_ROOT" && node "$VOYAGE_ROOT/lib/util/session-orders.mjs" record-merged --plan "$PLAN_REL" --wave "{W}" --session-dir "$SESSION_DIR")
```
Then drop the wave marker and remove each session's worktree and merged branch,
never with force. A progress file still in a worktree is deleted only when it is
untracked and byte-identical to its archived copy (the archive is the record);
anything else in a worktree makes git refuse, and that worktree — and the branch
checked out in it — is kept and reported:
```bash
cd "$REPO_ROOT" || exit 1
rm -f "$ORDERS_DIR/wave.json"
for N in {wave W session numbers}; do
WT="$WORKTREE_DIR/session-$N"
ARCHIVED="$ORDERS_DIR/wave-{W}-session-$N.progress.json"
P="$(sed -n 's/^- progress file: //p' "$ORDERS_DIR/wave-{W}-session-$N.md")"
if [ -n "$P" ] && [ -f "$P" ] && cmp -s "$ARCHIVED" "$P" \
&& ! git -C "$WT" ls-files --error-unmatch "$P" >/dev/null 2>&1; then
rm -f "$P"
fi
GIT_OPTIONAL_LOCKS=0 git worktree remove "$WT" 2>/dev/null || echo "KEPT worktree $WT"
GIT_OPTIONAL_LOCKS=0 git branch -d "trek/{slug}/session-$N" >/dev/null 2>&1 || echo "KEPT branch trek/{slug}/session-$N"
done
GIT_OPTIONAL_LOCKS=0 git worktree prune
```
Report: `Wave {W}: {N} sessions merged.` plus every `KEPT` line, each with what
the user can do (inspect it with `git -C status --short`, commit or move
what matters, then `git worktree remove ` and `git branch -d `).
**Next wave:** run Step 0's `next-wave` command. A number is the next `W`: go
back to Step 1 — this run prepares that wave's orders and stops again. `none`
(every wave merged): continue to Step 4.
### Step 4 — Run master verification
After all waves complete and merge successfully, run the plan's `## Verification`
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)
This step MUST execute whenever this phase ends the run with a failure, a merge
conflict, or after the last wave — never while a wave is awaiting its sessions
(Step 2, and a Step 3 that finds a session `running` or a dirty or refused
merge, stop without it). It is the worktree equivalent of a `finally` block, and
it never forces: `git worktree remove` without force refuses a worktree with
modified or untracked files, `git branch -d` refuses an unmerged branch, and
whatever git refuses is kept and reported, never retried with force. From
`$ORDERS_DIR` it deletes only `wave.json`: the orders, the archived progress
copies and the merged-waves record stay until the main-merge gate's confirm
(Phase 8) clears them.
```bash
# zsh (the Bash tool runs the user's shell): an unmatched glob stays literal, as in bash.
[ -n "${ZSH_VERSION:-}" ] && setopt nonomatch
cd "$REPO_ROOT" || exit 1
# Remove worktrees git agrees are clean (GIT_OPTIONAL_LOCKS=0 to avoid lock-file races)
for wt in "$WORKTREE_DIR"/session-*; do
[ -d "$wt" ] || continue
GIT_OPTIONAL_LOCKS=0 git worktree remove "$wt" 2>/dev/null || echo "KEPT worktree $wt"
done
GIT_OPTIONAL_LOCKS=0 git worktree prune
# Delete merged session branches; every unmerged one is kept
git for-each-ref --format='%(refname:short)' "refs/heads/trek/{slug}/" | while read -r b; do
GIT_OPTIONAL_LOCKS=0 git branch -d "$b" >/dev/null 2>&1 || echo "KEPT branch $b"
done
# Clean up the wave marker and an empty worktree directory
rm -f "$ORDERS_DIR/wave.json"
rmdir "$WORKTREE_DIR" 2>/dev/null || true
```
Report:
```
Cleanup: worktrees removed and merged branches deleted.
Kept (they hold work): {every KEPT line}
```
For every kept item, the report says what the user can do: a worktree —
inspect it (`git -C status --short`), commit or move what matters, then
`git worktree remove `; a branch — inspect it (`git log HEAD..`),
merge it by hand (`git merge --no-ff `), or delete it themselves once
its commits are not needed. A re-run stops at Phase 2.55 Check 4 until they are
dealt with.
### Step 6 — Aggregate results
Read every archived copy, `$ORDERS_DIR/wave-*-session-*.progress.json`, into an
aggregated report — never the worktree copies, which Steps 3 and 5 remove with
their worktrees (earlier waves' in earlier runs).
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.
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
When mode = `session N`:
1. Find session N in the Execution Strategy
2. Extract its step numbers (e.g., Steps: 4, 5, 6)
3. Extract its scope fence (Touch / Never touch lists)
4. Execute ONLY those steps, in order, using the single-session protocol (Phase 3→7)
5. Enforce the session's scope fence as if it were a session spec's scope fence
6. Report results for those steps only
This mode is what each session order's starter runs, in a session the user
opened in that order's worktree. It can also be used manually to re-run a
specific session.
When `--session N` runs inside a git worktree (as each session order sets up),
all git operations (add, commit) apply to the worktree's branch. The session does
not need to know it is in a worktree — git handles this transparently.
## Phase 3 — Progress file setup
The progress file location depends on whether `--project` is in use:
- **With `--project `:** progress lives at `{project_dir}/progress.json`.
- **Without `--project`:** progress lives at
`{plan-dir}/.trekexecute-progress-{slug}.json`, where `{slug}` is the plan
filename without extension.
**Session-scoped naming:** When `mode = session N`:
- With `--project`: `{project_dir}/progress-session-{N}.json`.
- Without `--project`: `{plan-dir}/.trekexecute-progress-{slug}-session-{N}.json`.
This prevents merge conflicts when parallel sessions each write their own
progress file.
### Progress file schema
```json
{
"schema_version": "1",
"plan": "{path}",
"plan_type": "{plan | session-spec}",
"plan_version": "{1.7 | 1.6 | legacy}",
"started_at": "{ISO-8601}",
"updated_at": "{ISO-8601}",
"mode": "{execute | resume | step}",
"total_steps": 0,
"current_step": 0,
"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, "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
}
```
**The step-loop fields (Handover 5, additive-optional).** Written by Phase 6;
a progress file without them still validates and resumes:
| Field | Written | Meaning |
|---|---|---|
| `session_start_sha` | Phase 3 | the commit the run started from; Phase 7.5 audits the whole run from it |
| `steps.{N}.start_sha` | Sub-step 0 | the commit the step started from; every check of the step reads `git diff {start_sha}` |
| `steps.{N}.report_path` | Sub-step 0 | the step's report file (helper rounds + the done line) |
| `steps.{N}.red_proof` | Sub-step B | `"proven" \| "rejected" \| "not-run"` — the exit of `red-proof.mjs` |
| `steps.{N}.test_lock` | Sub-steps B, G | `"locked" \| "released" \| "not-locked"` (K8) |
| `steps.{N}.fix_rounds` | Sub-step F | 0–5: 0 = passed its first control, 1–3 = fix rounds, 4 = sent up to the plan, 5 = sent up to the operator |
| `steps.{N}.attempts` | Sub-steps C, F | helpers launched for the step: 1 + the fix rounds that launched one |
| `end_to_end` | Phase 7 | `"verified" \| "not-verified"` (P9 (6)) |
| `decisions` | any phase | `[{step, decision, cost_if_wrong}]` — every call the executor made on its own (Phase 5.5) |
`progress-validator.mjs` rejects `fix_rounds` outside 0–5 (there is no sixth
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.
**`plan_verification` (additive-optional):** written by Phase 7 when the input
is a trekplan — `{status, summary, failed_criteria}`, where `status` is
`"passed" | "failed" | "not-run"`. `null` for session specs (they have an exit
condition instead) and on early stop. Unknown keys are tolerated by
`progress-validator.mjs`, so a legacy progress file without it still validates.
**`iterations_remaining` (v1.7+, additive-optional):** the remaining
recovery/retry budget against the global hard cap (unit = **iterations**, per the
brief — token/cost budgeting is out of scope). Seeded to
`TREKEXECUTE_MAX_RECOVERY_ITERATIONS` (default 25) at start. **Single writer:** the
executor decrements it by 1 on **every recovery/retry iteration** — each fix
round of a step (Sub-step F), and each Phase 7.6 recovery dispatch. **Backfill-on-absent:**
when resuming a legacy `progress.json` that lacks the field, seed it to the cap
rather than hard-failing (forward/backward-compatible; shape-validated by
`progress-validator.mjs` — non-negative integer, never required-top). The field is
present in the spec at minimum — there is **no `(if wired)` conditional**.
### Mode-specific behavior
**Run start point (every mode that executes):** a fresh progress file records
`session_start_sha` = `git rev-parse HEAD` before step 1; a resumed one keeps
the value it has (a legacy file without it is backfilled with the parent of
the first completed step's `commit`, or `HEAD` when none is completed). Phase
7.5 audits the whole run from it — never from a step's own `start_sha`.
**mode = execute (fresh):**
- If a progress file exists with status `in_progress`, `failed`, `stopped` or `partial`: warn that
`--resume` is available, then wait 3 seconds (`sleep 3`) and start fresh.
This allows headless runs to proceed without blocking.
- Otherwise: create the progress file with all steps in `pending` status.
**mode = resume:**
- If no progress file exists: start from step 1 (same as fresh execute).
- If progress file exists: find the first step with status != `completed`.
A legacy progress file may say `passed` for a finished step; treat it as `completed`.
```
Resuming from step {N}. {M}/{total} steps already completed.
```
**mode = dry-run:**
- Do NOT create or modify the progress file.
**mode = step N:**
- Create the progress file if it does not exist.
- Only step N will be executed.
## Phase 4 — Entry condition check (session specs only)
**Skip for trekplans.** Skip in dry-run mode (report what would be checked instead).
Read the entry condition. Evaluate it:
- `"none"` or similar → pass immediately
- References git state (e.g., "git status clean") → run `git status --porcelain`
- References passing tests → run the specified command
- References a previous session → check `git log --oneline` for commit pattern
If the entry condition **fails**:
```
Entry condition FAILED: {condition text}
Reason: {what was checked, what was found}
Complete the prerequisite first, then re-run.
```
Update progress file with `status: "stopped"`. Stop execution.
**Also write `.session-state.local.json`** (Handover 7) with the same
`status: "stopped"` and `next_session_brief_path` pointing at the brief that
the failed entry-condition session was supposed to consume. This lets
`/trekcontinue` surface the stop in the next session. Use the same atomic
write pattern + validator check as Phase 2.55. On validator failure, warn
but do not block.
**Also write sibling `NEXT-SESSION-PROMPT.local.md`** with frontmatter
(`produced_by: trekexecute`, `produced_at: `,
`status: stopped`) — same combined ESM pattern as Phase 8 — so Phase 1.5
of `/trekcontinue` can compare project-dir and plugin-root candidates.
If the entry condition **passes**:
```
Entry condition: PASS
```
Update `entry_condition_checked: true` in the progress file.
## Phase 5 — Dry-run report (dry-run mode only)
**Only runs when mode = dry-run.** Produces a validation report, then stops.
```
## Dry Run Report: {filename}
**Type:** {plan | session-spec}
**Steps:** {N}
### Step Validation
| Step | Description | Verify | On failure | Checkpoint | Issues |
|------|-------------|--------|------------|------------|--------|
| 1 | {desc} | {cmd} | {action} | {msg} | {none / missing X} |
### File References
{For each file in Files: fields, check existence with Glob}
- {path}: EXISTS | NOT FOUND {(marked as new file) | (unexpected — may be missing)}
### Entry / Exit Conditions (session specs)
{What would be checked}
### Execution Preview (only when plan has Execution Strategy)
If `has_execution_strategy = true`, show a preview of multi-session orchestration:
```
**Sessions:** {S} across {W} waves
| Wave | Session | Steps | Depends on | Starter (the user runs it in a session they open) |
|------|---------|-------|------------|---------|
| 1 | Session 1: {title} | {nums} | none | `/trekexecute --session 1 {path}` |
| 1 | Session 2: {title} | {nums} | none | `/trekexecute --session 2 {path}` |
| 2 | Session 3: {title} | {nums} | S1, S2 | `/trekexecute --session 3 {path}` |
```
### Verdict
{READY | NEEDS ATTENTION — N issues found}
```
Stop after the dry-run report. Do not execute anything.
## Phase 5.5 — Autonomy: what stops a run, and the decision log
A run is long and mostly unattended. It does not stop to ask: what it decides
on its own it writes down, and it stops only for the four reasons below.
### Closed stop list
The list is **closed**: a run stops on these four classes and on nothing else.
Every stop names its class in the final report (Phase 8 `### Stops`).
1. **Something that cannot be undone.** A step or command whose effect cannot
be reverted from git: deleting data outside the repo's tracked files, a
migration against a live store, anything the plan marks irreversible — and
a dirty main checkout before a multi-session wave (Phase 2.55 Check 1:
merging would lose the uncommitted work).
2. **Something security-sensitive.** A command the executor denylist blocks
(Phase 2.4, Sub-step D1), a write to a security-sensitive path (Hard
rule 16), credentials or secrets in a diff.
3. **Something outside the working copy.** Merge to the main branch, push,
tag, publish, a write outside the repository (Hard rule 15). The main-merge
gate (Phase 8) is this class: it always pauses.
4. **A plan so broken that everything after it is guesswork.** The plan fails
to parse, a ≥ 1.8 step has no `Test first:`, a step's own `On failure:
escalate` fired (the plan's author declared that past this point is
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). 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.
Not on the list, and therefore never a stop: a failing test (that is a fix
round), an ambiguity the plan leaves open (decide, and log it), a style
choice, a missing optional field.
### Decision log
Every decision the executor (or a helper, through its report) takes that the
plan did not dictate goes into progress `decisions` as one entry:
`{"step": N, "decision": "...", "cost_if_wrong": "..."}`. `cost_if_wrong` is
concrete: what has to be redone, by whom, how big — "one rename commit",
"step 4's API shape; steps 5–7 build on it". The final report prints the log
(Phase 8 `### Decisions taken`), so the operator reviews the calls instead of
being asked them one by one.
Borrowed: Superpowers subagent-driven-development (MIT) — the stop classes and
the log of choices made while unattended.
## Phase 6 — Step execution loop
The core execution phase. Runs for modes: `execute`, `resume`, `step`.
### Determine starting step
- **execute**: step 1
- **resume**: first step where status != `completed`.
A legacy progress file may say `passed` for a finished step; treat it as `completed`.
- **step N**: step N only
### For each step
Update progress: `steps.{N}.status = "in_progress"`, `current_step = N`, `updated_at = now`.
```
--- Step {N}/{total}: {description} ---
```
One step at a time. Inside one session the steps run in order in one working
tree; parallel steps are the multi-session wave path (Phase 2.6), never two
helpers in one tree.
#### Sub-step 0 — Record the start point
Before anything touches the tree, record where the step begins:
```bash
git rev-parse HEAD
git status --porcelain
```
- `steps.{N}.start_sha` = the `HEAD` sha. Every later check of this step reads
`git diff {start_sha}`: the review package is the diff from this recorded
start point, not anything the helper says. On `--resume` of a step that
already has a `start_sha`, keep it.
- `steps.{N}.report_path` = `{progress-dir}/steps/step-{N}.report.md` (create
the directory). Every helper round of the step appends to it, and the done
line lands in it (Sub-step G). Hand-over between the executor, the helper
and the controller is in files: the plan, this report, the diff.
- A non-empty `git status --porcelain` (beyond Voyage's own progress and report
files) means work the plan does not know about would mix into this step's
diff. Not a stop (Phase 5.5): log it as a decision
(`cost_if_wrong`: "the controller judges unrelated changes as part of step N")
and name the paths in the report.
#### Sub-step A — Scope fence check (session specs only)
Before touching any file, verify that every file in the step's `Files:` field is
in the session spec's Touch list (or is a new file to create). If ANY file is in
the Never-touch list:
```
SCOPE VIOLATION: Step {N} requires {file} which is in the never-touch list.
Escalating — this step cannot be executed within this session's scope.
```
Treat this as an automatic `escalate`. Jump to the stop-and-report logic.
#### Sub-step B — Test first and proof of red
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
the checkpoint, and neither is a scope finding.
**The test command** is the step's `Verify:` command: the FIRST backticked
span of the field. Anything after it (expected output, notes) is description
and is not run. It must be ONE test-runner invocation without shell operators
(the allowlist of `lib/verification/criteria-runner.mjs`); the tools below
refuse anything else with exit 2, and from plan_version 1.8 the plan validator
already rejects such a step (`PLAN_STEP_VERIFY_NOT_RUNNABLE`).
1. **The helper proves red before it builds** (Sub-step C): it writes the test
from `Test first:`, runs the gate
`red-proof.mjs --red-only --base {start_sha} --cmd "{Verify}" --tests {test files, ONE comma-separated argument} --plan "{plan_path}" --step {N}`
(the test must FAIL at the start point), locks the test files, and only then
implements. A `tests_only: true` answer stops the helper before it builds
(it does not rewrite the test); the executor's own run below then sends the
step up to the plan.
2. **The executor proves it both ways after the helper returns:**
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/verification/red-proof.mjs --base {start_sha} --cmd "{Verify command}" --tests {test files, ONE comma-separated argument} --plan "{plan_path}" --step {N} --json
```
The test must FAIL against the start point and PASS against the result
(the working tree), and a **control run** must PASS: the start-point copy
with the step's whole change laid on top. The start point is a temp
`git clone --shared` checked out at `start_sha` (a real git checkout), with
the step's test files copied in and every gitignored top-level entry of the
working tree (`node_modules`, `.venv`, …) linked in; `--link a,b` adds
deeper dependency paths. A start point that fails for an environment reason
fails the control too, so it is never read as red.
| Exit | `steps.{N}.red_proof` | Executor |
|------|-----------------------|----------|
| 0 | `"proven"` | continue |
| 1, `not-red-at-base`, `tests_only: true` | `"rejected"` | every file in the step's `Files:` is a test (the plan validator's `PLAN_STEP_TESTS_ONLY`, the same reading): the behaviour its tests cover already exists at the start point (an earlier step built it, or it was there before the plan), so the step can never be red and no rewrite of the test can fix that. The plan is what failed: the step goes straight up to the plan (Sub-step F, `fix_rounds = 4`, stop class 4), with no fix round — the planner moves the test into the step that builds the behaviour, or makes a test meant to pass from the start the `Regression:` of the step that changes the code |
| 1, `not-red-at-base`, otherwise | `"rejected"` | the test passes without the change, so the TEST is wrong: a finding for Sub-step F, and that fix round is the one exception to the lock — the executor releases the step's test lock for it, the fix helper rewrites the test, passes the `--red-only` gate and locks again. `tests_only: null` (the plan or the step could not be read) counts here too |
| 1, `not-green-at-result` | `"rejected"` | the change does not make the test pass: a finding for Sub-step F |
| 2 | `"not-run"` | never a pass and never proven. **plan_version ≥ 1.8:** when the reason is the command (the `Verify:` is not one test-runner invocation), the plan is what failed: the step goes straight up to the plan (Sub-step F, `fix_rounds = 4`, stop class 4); any other reason (a missing test file, an unresolvable start point, a control run that stays red) is a finding for Sub-step F. **Below 1.8:** a step with no `Test first:`, or whose `Verify:` is not a test-runner command, is tolerated as `"not-run"` — counted in the final report among the steps without proof of red, never called proven; any other reason is a finding |
The exit code is the verdict, the output text is not. `base.tap` (when the
runner prints TAP) says what the red was: `fail 1 of 1` on a node test file is
usually a load error, not a named failing test — say so in the step report.
**The test lock (K8).** After its red gate the helper runs
`node ${CLAUDE_PLUGIN_ROOT}/lib/util/test-lock.mjs lock --files {test files} --step {N} --cmd "{Verify command}"`;
from then on the guard refuses Write/Edit/NotebookEdit on those files, so the
code moves to meet the test and never the reverse. The executor reads
`node ${CLAUDE_PLUGIN_ROOT}/lib/util/test-lock.mjs status` and records
`steps.{N}.test_lock = "locked"` when it exits 0 and names the step's files.
If `lib/util/test-lock.mjs` is not in this plugin, record `"not-locked"` and
write "not locked: test-lock.mjs missing" in the step report — never silent.
The floor guard (Sub-step D3) still reads every test change in the diff. The
lock is released in Sub-step G, or when the run stops.
#### Sub-step C — Implement through one fresh helper
The executor does not write the step's code. **Launch** one fresh helper for
the step with the Agent tool:
Launch `subagent_type: "voyage:step-implementer"` with a short `description`
("step {N}: {title}") and a prompt that carries, as files and values, not as a
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`), `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, 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.
- **Never pass the Agent tool's `name` parameter.** A named spawn becomes an
`in_process_teammate`: it does the work, but its result never reaches this
orchestrator (measured: named 0/5 returned, unnamed 3/3 —
`docs/agent-return-channel-defect.md`). The same holds for the controller
(Sub-step E).
- **Never pass `isolation`.** Measured 2026-10-06 (Claude Code 2.1.291): a
worktree-isolated helper branched from the repository's default branch
(`02f320f`), not from the executor's `HEAD` (`2c89a6b`), so it did not see
the earlier steps. Only the operator's `worktree.baseRef: "head"` setting
changes that, and a plugin cannot set it. The helper works in this tree,
from the recorded `start_sha`.
- **Wait for the helper to finish** before anything else; when it runs in the
background, its completion notification is the signal. Never start the next
sub-step or step while a helper runs.
- `steps.{N}.attempts` += 1 for every helper launched for the step — this
one, each fix round's, and the `build` helper after a re-plan. It is never
reset, and every launch after a step's first also takes 1 from
`iterations_remaining`, so the completion gate's reconciliation
(`Σ (attempts − 1)`) holds across re-plans.
The helper's one-line answer is not evidence. After it returns:
1. The report at `report_path` must carry a NEW section for this round
(`## Step {N} - {round label}`) with its `### Ran`, `### Saw` and
`### Did NOT check`; earlier rounds' sections and verdicts stay. A missing
section is a finding for Sub-step F (stale sections do not count).
2. Register EVERY new file so the diff and every check see it — not only the
listed ones: for each path `git ls-files --others --exclude-standard`
prints, run `git add --intent-to-add -- {path}` (stages nothing). A new file
the step did not list is otherwise invisible to `git diff`, to the floor
guard and to the controller. Voyage's own progress file and step reports
are the one exception: never registered.
3. A changed or new path outside the step's scope (`Files:` + test files) is a
scope finding (Hard rule 2) for the controller. The executor does not
revert it on its own.
**Rules the helper is held to** (its agent file carries them; the controller
checks them in the diff): follow `Changes:` exactly — no improvising, no added
scope; touch only the step's scope (`Files:` + test files); read `Reuses:`
first; never weaken a test; never write a stub; never commit.
#### Sub-step D1 — Command verification
**Security check (mandatory):** Before running the Verify command, check it against
the executor security denylist. If the command matches ANY of these patterns,
**refuse to execute** — treat as `On failure: escalate` regardless of the plan's
On failure setting:
- `rm -rf` or `rm -fr` with any path
- `chmod 777` or `chmod -R 777`
- Pipe-to-shell: `curl ... | bash`, `wget ... | sh`, `base64 ... | bash`
- `eval` with variable expansion: `eval $VAR`, `eval $(cmd)`, `` eval `cmd` ``
- `mkfs`, `dd` writing to block devices (`/dev/sd*`, `/dev/nvme*`)
- `shutdown`, `reboot`, `halt`, `poweroff`
- Fork bomb patterns
- `crontab` writes, `/etc/cron*` modifications
- `kill -9 -1` or `pkill -9 -1` (kill all processes)
- `history -c` or truncating `~/.bash_history`
If matched:
1. Do NOT execute the command
2. Set step status = "failed"
3. Log: `SECURITY: Verify command blocked — matches executor denylist: {pattern name}`
4. Apply `On failure: escalate` regardless of the plan's On failure setting
5. Include in final report under a "Security blocks" section
If the command passes the security check, run it:
Run the `Verify:` command exactly as written, via Bash.
**Rules:**
- Always a fresh run — never trust prior results
- Exit code is the authoritative truth:
- Exit 0 + expected output (if specified) = **PASS**
- Exit non-zero = **FAIL** regardless of output text
- Exit 0 but wrong output = **FAIL**
```
Verify: {command}
Result: {PASS | FAIL} (exit code {N})
{if FAIL}: Output (first 10 lines): {output}
```
**Step 0 sentinel:** if the Verify command exits `77` AND the step's manifest
has `sandbox_preflight: true`, do NOT treat as a normal failure. Set step
status = `blocked` and apply `On failure: escalate`. Skip D2 manifest
verification (nothing to verify for a read-only sandbox test). Commit none —
jump straight to Phase 7 with a structured "sandbox-blocked" reason.
If **PASS**: mark the step's test lock green, then proceed to Sub-step D2
(manifest verification). The mark is what lets the session wait for its
controller (Sub-step E) without the stop guard refusing every turn — the
test files stay locked against edits (operator decision 2026-10-08):
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/util/test-lock.mjs green
```
It runs the test command the helper stored in the lock (`lock --cmd`), never
one given now, and marks only on exit 0; a lock without a stored command is
never marked (exit 2), so the stop guard keeps holding as before; exit 1 (the test
did not pass this time) is a finding for Sub-step F like a FAIL, exit 2 (could
not run) is never green. A step that is `"not-locked"` skips it.
If **FAIL**: the failure output is a finding for Sub-step F; still run D2 and
D3, so the controller and the next fix round see every failure at once.
#### Sub-step D2 — Manifest verification
After the Verify command passes, verify the step's Manifest block. This is
the objective completion predicate: a step is not passed until its manifest
holds, regardless of Verify exit code.
**Checks to run (in order):**
1. **Expected paths exist:** for each `expected_paths` entry, verify the
file exists in the repo. Count how many exist.
2. **min_file_count satisfied:** count from (1) must be ≥ `min_file_count`.
3. **Forbidden paths untouched:** for each `forbidden_paths` entry, run
`git diff --name-only {start_sha} -- {path}` (since this step
began). Any modified forbidden path = fail.
4. **Bash syntax check:** for each entry in `bash_syntax_check` AND any
`.sh` file that appears in `git diff --name-only {start_sha}` (safety
net for unlisted scripts), run `bash -n {path}`. Non-zero exit = fail.
5. **must_contain patterns:** for each `{path, pattern}` pair, run
`grep -E -e "{pattern}" -- {path}` (`-e` and `--`: a pattern such as `--sep`
is never read as an option). No match = fail.
**On manifest failure:**
```
Manifest verification FAIL for step {N}:
- {which check failed with detail}
```
A manifest failure is a finding for Sub-step F, exactly like a command
failure: it counts against the same fix rounds.
Either way, proceed to Sub-step D3 (floor guard).
Record per-step manifest audit result in progress file:
`steps.{N}.manifest_audit = "pass" | "fail"`,
`steps.{N}.manifest_drift = [{check: reason, ...}]` on fail.
#### Sub-step D3 — Floor guard
Read the step's diff for weakened tests — by a command:
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/verification/test-integrity.mjs --base {start_sha} --json
```
It reports a removed assert, a deleted or skipped test, a muted check
(`|| true`, continue-on-error, an inline lint/type suppression), a stub body
("not implemented") and a threshold loosened in the permissive direction.
| Exit | Meaning | Executor |
|------|---------|----------|
| 0 | clean | continue |
| 1 | findings | each finding goes to the controller (Sub-step E), which rules it `justified` only by quoting the plan line that asks for the change; every other finding is a `violation` |
| 2 | could not run | exit 2 is never green: "floor guard not run: {reason}" is a finding for Sub-step F, and the step cannot pass this round |
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:
`steps.{N}.regression` is the JSON's `progress_status`, copied as it is: the
CLI already speaks progress.json's words (`"blocked"` → `"unrunnable"`,
`"error"` → `"not-run"`).
| 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,
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
at the session's effort: run `/trekexecute` at effort `high` or above (the
standard). A session below `high` is a decision-log entry ("controller ran at
effort {level}"), never silent. It reads the diff, not the helper's summary: it is never given
the step report, and it judges what changed, not what the helper says changed.
The prompt (fill in every `{…}`; pass the mechanical results verbatim as JSON):
```text
You are the controller for step {N} of the plan {plan path}. You judge; you
change nothing: no Write, no Edit, no git command that writes.
Read the step's diff yourself: git diff {start_sha} --stat, then
git diff {start_sha}. The diff is the evidence. You are not given the
helper's report and must not look for it.
The step, verbatim:
{every field of the step}
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};
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 -
no more, no less? Name any changed path outside Files.
- "quality": does the code hold - correctness, error handling, edge cases the
step names, no stub, no weakened test, the repo's own patterns?
Rule every floor-guard finding: "justified: " or
"violation". List what you did not judge.
Answer with exactly one JSON block:
{"spec": "pass|fail", "quality": "pass|fail",
"findings": [{"verdict": "spec|quality", "file": "...", "line": 0, "problem": "...", "fix": "..."}],
"floor_guard": [{"kind": "...", "file": "...", "ruling": "justified: ... | violation"}],
"not_judged": ["..."]}
```
Wait for the controller to finish. Parse its JSON block; an answer without a
parseable block is a failed control (a finding for Sub-step F), never a pass.
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), `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
the previous round's verdicts, findings and floor-guard rulings, and the
round's own change — before the fix round the executor saves
`git diff {start_sha}` to `{report dir}/step-{N}-round-{R}.diff`, and afterwards
hands over `diff -u` of that file against the new `git diff {start_sha}`. It
answers in the SAME JSON block as above:
- per previous finding, fixed or not — a finding not fixed stays in
`findings`; whether the round changed anything the findings did not name
(that is a new finding);
- `"spec"` and `"quality"` for the step as a whole: the previous verdict
carries over for every part the round did not touch, re-judged only where
the round changed something;
- `floor_guard`: a finding identical (same kind, file and text) to one the
previous round already ruled keeps that ruling; only new floor-guard
findings are ruled.
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
runs `opus` (the step helper pins effort `high`; the controller follows the
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, 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). Sub-step B sends two reds here directly, with no fix round: a `"not-run"` caused by the `Verify:` command, and a `not-red-at-base` with `tests_only: true` (a step of only tests). Stop class 4 |
| 5 | the operator | A step already sent to the plan (`fix_rounds` 4) that fails its control again after re-planning goes to the operator. The executor never builds this step again: on a later `--resume` it only runs the checks (B, D1, D2, D3, D4, E) on whatever the operator changed. If they fail again the step stays at 5 — there is no sixth round, `fix_rounds` never exceeds 5 — and the run stops again. Stop class 4 |
**Back to planning** (`fix_rounds = 4`): save `git diff {start_sha}` to
`{report dir}/step-{N}-upward.diff` and write `{report dir}/step-{N}-upward.md`:
the step verbatim, every round's findings and verdicts, and the question for
the planner — which assumption in the step (its Changes, its Interface, its
test) makes it fail. Then stop: step `failed`, run `failed`, and tell the
operator to re-plan the step (`/trekplan` revises it) and run
`/trekexecute --resume`, which launches a fresh `build` helper for the revised
step (`attempts` +1, `iterations_remaining` −1) and keeps `fix_rounds` at 4. A step whose proof of red cannot run on a
≥ 1.8 plan because its `Verify:` is not one test-runner invocation goes here
directly (Sub-step B).
**The plan's `On failure:` clause** decides what happens around the ladder:
**`On failure: revert`** and **`On failure: retry`**
- Fix rounds 1–3 run as above; for `retry`, the clause's alternative approach
is handed to the round-1 fix helper.
- When the step goes up (round 4 or 5), first save the upward files, then
revert this step's changes (nothing of the step is committed yet):
```bash
git checkout -- {the step's tracked files changed since start_sha}
git rm --cached -q -- {the step's new files: registered with --intent-to-add since start_sha}
```
The new files stay on disk, untracked, and `step-{N}-upward.md` names them —
nothing the step made is deleted, and none of it is left registered.
Record failure. **Do NOT proceed to next step.** Jump to Phase 7.5 (Phase 7
runs only when every step passed).
**`On failure: skip`**
- Mark step as skipped at its first failed round, without fix rounds. Continue
to next step.
```
Step {N}: SKIPPED (non-critical per plan)
```
Update `steps.{N}.status = "skipped"`.
**`On failure: escalate`**
- Stop at the first failed round, without fix rounds (stop class 4: the plan's
author declared that past this point is guesswork).
```
Step {N}: ESCALATED — requires human judgment
```
Commit all completed work before stopping. Stage ONLY files from steps with
`status: "completed"` in the progress file — collect their `Files:` fields. Never
use `git add -A` (risks staging secrets, binaries, or unrelated work).
```bash
git add {files from completed steps' Files: fields} && git commit -m "wip: trekexecute stopped at step {N} — escalation needed"
```
Jump to Phase 7.5 (Phase 7 runs only when every step passed).
The same wip commit of completed work runs whenever a step goes up (round 4
or 5). Release the step's test lock before stopping
(`node ${CLAUDE_PLUGIN_ROOT}/lib/util/test-lock.mjs unlock`, `test_lock` =
`"released"`), so the operator and the planner can edit the test.
#### Sub-step G — Checkpoint, done line, unlock
1. **Checkpoint.** Stage the step's scope — its `Files:` and its test files,
and only those, never `git add -A` — then run the `Checkpoint:` git commit
command exactly as written in the plan. (The test files are listed apart
from `Files:` in the plan; staging only `Files:` would leave the step's
test uncommitted and charge it to the next step's diff.)
If the commit fails (nothing to commit, etc.): warn but do NOT fail the
step. The step's verification already passed — the commit is bookkeeping.
2. **The done line**, at the step's own commit. A step is done only when a
script says so, and the script writes its line only when the test command
exits 0:
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/verification/done-line.mjs --step {N} --cmd "{Verify command}" --report {report_path}
```
Exit 0 = `DONE step {N}: … exit 0 at {the checkpoint sha} …` appended to the
step report. Confirm with
`node ${CLAUDE_PLUGIN_ROOT}/lib/verification/done-line.mjs --check --step {N} --report {report_path} --commit {hash}`
(exit 0 = the line names this step's commit). Neither the helper nor the
executor ever types a DONE line.
- Exit 1 = the test went red after the checkpoint (it passed in Sub-step E
moments before: a flaky test or an order dependency). A finding for
Sub-step F; the fix lands as a second commit with the same message, and
Phase 7.5's commit count reports that extra commit as drift — said, not
hidden.
- Exit 2 = it could not run: never green — on a ≥ 1.8 plan that is the
plan's `Verify:` (up to the plan, as in Sub-step B); below 1.8, a step
whose `Verify:` is not a test-runner command records "no done line
(Verify is not a test-runner command)" in its report and rests on
Sub-step D1's exit code.
3. **Release the test lock**:
`node ${CLAUDE_PLUGIN_ROOT}/lib/util/test-lock.mjs unlock` → `test_lock` =
`"released"` (a step that was `"not-locked"` stays so).
```
Step {N}: PASS (committed: {hash})
```
**Commit-message-pattern check:** after the commit succeeds, read the HEAD
commit message (`git log -1 --pretty=%s`) and match it against the step's
`commit_message_pattern`. Mismatch does NOT fail the step (verification
already passed) but is recorded as `checkpoint_drift: {expected_pattern,
actual_message}` in the progress file. Phase 7.5 audit reports drift as
an advisory.
Update progress: `steps.{N}.status = "completed"`, `steps.{N}.commit = {hash}`,
`steps.{N}.completed_at = now`, `steps.{N}.manifest_audit = "pass"`.
### Step mode exit
If mode = `step N`: after completing step N (pass or fail), skip remaining steps
and jump to Phase 8 (final report).
## Phase 7 — Exit / verification check
**Runs only when all steps passed** (not on early stop). Which check runs
depends on what was executed:
| Input | Check |
|-------|-------|
| session spec | the `## Exit Condition` checklist |
| trekplan | the plan's `## Verification` criteria |
Phase 4's entry-condition skip for trekplans stands — a plan carries no entry
condition. The exit side is not symmetrical: a trekplan's `## Verification`
section is where the brief's success criteria land, and it used to run only on
the multi-session wave path (Phase 2.6 Step 4). A single-session run therefore
reported `completed` without ever executing the criteria it was measured
against. It no longer does.
### Session specs — exit condition checklist
Run each exit condition command from the `## Exit Condition` checklist:
```
Exit condition check:
- [ ] {command} → {PASS | FAIL}
- [ ] {command} → {PASS | FAIL}
```
If all pass: `exit_condition_checked: true` in progress file.
If any fail: record which failed. Include in final report.
### trekplans — run the plan's `## Verification`
Run the criteria runner over the plan file parsed in Phase 1
(`{plan_path}`). It parses the `## Verification` section, screens every command
through the plugin's own PreToolUse denylist before it reaches a shell, and
runs what survives — in the FOREGROUND, never backgrounded. (A command spawned
from node does not pass through the Bash tool, so that hook cannot fire by
itself; the runner invokes it over its documented stdin protocol instead of
carrying a second copy of the denylist.)
```bash
# Resolve the plugin root ONCE. ${CLAUDE_PLUGIN_ROOT} is substituted in this
# command's text but is EMPTY in the Bash tool's process env, and a bare
# `node ${CLAUDE_PLUGIN_ROOT}/lib/…` then runs `node /lib/…`, which exits 1 —
# 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//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
/*) ;;
*) 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
if [ ! -f "$VOYAGE_ROOT/lib/verification/criteria-runner.mjs" ]; then
echo "[voyage] plan verification could not run - plugin root unresolved (exit 2)."
echo " NOT a pass: report it and never emit result: completed."
exit 2
fi
# The working tree the criteria run in. It is where every allowlisted command
# is executed and where a `bash tests/