feat(execute)!: hand waves over as session orders, never start Claude Code (green)

Operator decision 27.09.2026: Voyage works the PM way, so nothing in it
starts Claude Code. Work that needs another session becomes an order plus
a starter line the user gives a session they open themselves; Voyage
never starts the session.

/trekexecute's multi-session path (Phase 2.6) now prepares one wave at a
time: worktree per session, then lib/util/session-orders.mjs writes one
order per session (worktree, steps, done-condition, starter) plus
orders/wave.json, and the run stops. Running the same command again reads
each session's progress file (status mode), merges sequentially with
push-before-cleanup as before, and prepares the next wave. Phase 2.5 skips
the destructive pre-flight while a wave is in flight. Phase 7.6 recovery is
handed over the same way. Hard rule 21: never start Claude Code.

Removed with it, because they existed only for launched children: the
ANTHROPIC_API_KEY billing check (and its dry-run line and Hard rule 1
exception), the hooks-fire version precheck, the safety preamble, the
scoped-tool/turn/budget flags, templates/headless-launch-template.md, the
decomposer's launch.sh (now orders.md), /trekplan's "launch", the README
billing and MCP tuning sections, and scripts/q3-cache-prefix-experiment.mjs
(an experiment that spawned the binary; its results stay in docs/).
/trekresearch's deep-research pre-gate no longer runs the binary for its
version; the post-hoc check was already the authoritative guard.

Chose orders + starters over in-session subagents because the order's done
criterion asks for exactly that, and because /trekexecute disallows the
Agent tool on purpose (Hard rule 10); reopening that is a separate change.

Gate: 54 -> 0 hit lines outside EXEMPT. npm test 1250 (1245 pass, 0 fail,
5 skipped: 3 are the retired-reference scan without its local list).
verify.sh 7/0. The fixture run (tests/lib/session-orders.test.mjs) writes
two orders and wave.json for wave 1 and leaves a trap binary untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-27 15:35:22 +02:00
commit 219e40cd91
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
18 changed files with 664 additions and 1108 deletions

View file

@ -13,7 +13,7 @@ body:
id: claude-version id: claude-version
attributes: attributes:
label: Claude Code version label: Claude Code version
description: Output of `claude --version` description: The Claude Code version you ran
- type: textarea - type: textarea
id: steps id: steps
attributes: attributes:

View file

@ -6,7 +6,7 @@ This is a solo project. Issues are welcome. PRs may be considered but are not ex
Open an issue with: Open an issue with:
- Plugin version (from `.claude-plugin/plugin.json`) - Plugin version (from `.claude-plugin/plugin.json`)
- Claude Code version (`claude --version`) - Claude Code version
- What you did, what you expected, what happened instead - What you did, what you expected, what happened instead
- Whether it fails consistently or occasionally - Whether it fails consistently or occasionally
@ -39,15 +39,16 @@ Changes to this plugin must preserve:
| `templates/research-brief-template.md` | Research brief format with triangulation and confidence | | `templates/research-brief-template.md` | Research brief format with triangulation and confidence |
| `templates/spec-template.md` | Spec file format | | `templates/spec-template.md` | Spec file format |
| `templates/session-spec-template.md` | Session spec format for headless execution | | `templates/session-spec-template.md` | Session spec format for headless execution |
| `templates/headless-launch-template.md` | Launch script template |
The command files are the core. All logic lives in markdown. The command files are the core. All logic lives in markdown.
## Testing locally ## Testing locally
```bash Start a Claude Code session yourself with this checkout loaded as a local
claude --plugin-dir /path/to/trekplan plugin (Claude Code's plugin-directory option; see its plugin docs). Voyage
# Then in the session: never starts a session for you. Then, in that session:
```
/trekresearch <research question> /trekresearch <research question>
/trekplan <describe a task> /trekplan <describe a task>
/trekexecute <path to plan> /trekexecute <path to plan>

View file

@ -116,7 +116,7 @@ An optional architect step can sit between research and plan — `/trekplan` aut
- You're working in an unfamiliar codebase and need a map before you start - You're working in an unfamiliar codebase and need a map before you start
- The implementation has non-obvious dependencies, ordering constraints, or risks - The implementation has non-obvious dependencies, ordering constraints, or risks
- You want a reviewable plan before committing to an approach - You want a reviewable plan before committing to an approach
- You need autonomous headless execution without human intervention - You want execution that asks no questions — every recovery decision comes from the plan
- You need to research a technology, library, or approach before deciding - You need to research a technology, library, or approach before deciding
**Don't use it when:** **Don't use it when:**
@ -305,46 +305,35 @@ Per step: apply Changes exactly as written → run Verify (exit code is truth)
| **Project** | `/trekexecute --project <dir>` | Read `{dir}/plan.md`, write `{dir}/progress.json` | | **Project** | `/trekexecute --project <dir>` | Read `{dir}/plan.md`, write `{dir}/progress.json` |
| **Plan path** | `/trekexecute plan.md` | Execute a specific plan file | | **Plan path** | `/trekexecute plan.md` | Execute a specific plan file |
| **Resume** | `/trekexecute --project <dir> --resume` | Resume from last progress checkpoint | | **Resume** | `/trekexecute --project <dir> --resume` | Resume from last progress checkpoint |
| **Dry run** | `/trekexecute --project <dir> --dry-run` | Validate plan structure + preview sessions and billing | | **Dry run** | `/trekexecute --project <dir> --dry-run` | Validate plan structure + preview sessions and their starters |
| **Validate** | `/trekexecute --project <dir> --validate` | Schema-only check — parse steps + manifests, report `READY \| FAIL`, no execution | | **Validate** | `/trekexecute --project <dir> --validate` | Schema-only check — parse steps + manifests, report `READY \| FAIL`, no execution |
| **Single step** | `/trekexecute --project <dir> --step 3` | Execute only step 3 | | **Single step** | `/trekexecute --project <dir> --step 3` | Execute only step 3 |
| **Foreground** | `/trekexecute --project <dir> --fg` | Force sequential, ignore Execution Strategy | | **Foreground** | `/trekexecute --project <dir> --fg` | Force sequential, ignore Execution Strategy |
| **Single session** | `/trekexecute --project <dir> --session 2` | Execute only session 2 from Execution Strategy | | **Single session** | `/trekexecute --project <dir> --session 2` | Execute only session 2 from Execution Strategy |
| **Profile** | `/trekexecute --profile <name> --project <dir>` | (v4.1.0) Pin model profile for execution. Plan-frontmatter `profile:` is honored when this flag is omitted. See [Profile system](#profile-system-v410). | | **Profile** | `/trekexecute --profile <name> --project <dir>` | (v4.1.0) Pin model profile for execution. Plan-frontmatter `profile:` is honored when this flag is omitted. See [Profile system](#profile-system-v410). |
### Session-aware parallel execution (worktree-isolated) ### Multi-session execution through session orders (worktree-isolated)
When a plan has an `## Execution Strategy` section (auto-generated by `/trekplan` for plans with > 5 steps), `/trekexecute` automatically: Voyage never starts a Claude Code session. When a plan has an `## Execution Strategy` section (auto-generated by `/trekplan` for plans with > 5 steps), `/trekexecute` works one wave at a time:
1. **Pre-flight checks** — validates clean working tree, plan file tracked in git, no scope fence overlaps between parallel sessions, no stale worktrees 1. **Pre-flight checks** — validates clean working tree, plan file tracked in git, no scope fence overlaps between parallel sessions, no stale worktrees
2. **Creates git worktrees** — each parallel session gets its own isolated worktree and branch (`trek/{slug}/session-{N}`) 2. **Creates git worktrees** — each session in the wave gets its own isolated worktree and branch (`trek/{slug}/session-{N}`)
3. Launches `claude -p` per session per wave, each in its own worktree 3. **Writes a session order per session** — the worktree to open, the steps, the done-condition, and a one-line starter (`/trekexecute --session N <plan>`) — then stops
4. **Merges branches back** sequentially with `--no-ff` after each wave completes 4. **You open each session yourself** in the directory its order names and give it the starter
5. **Cleans up** worktrees and branches unconditionally (even on failure) 5. **Run the same `/trekexecute` command again** — it reads every session's progress file, merges the branches back sequentially with `--no-ff`, cleans up, and writes the next wave's orders
6. Runs master verification on the merged result 6. After the last wave: master verification on the merged result; worktrees and branches are cleaned up unconditionally (even on failure)
``` ```
Wave 1: Session 1 (worktree-1) + Session 2 (worktree-2) -- parallel Wave 1 orders: Session 1 (worktree-1) + Session 2 (worktree-2) -- you run them, in parallel
↓ both complete → sequential merge to main ↓ re-run /trekexecute: both complete → sequential merge to main
Wave 2: Session 3 (worktree-3) -- sequential Wave 2 orders: Session 3 (worktree-3)
↓ complete → merge to main ↓ re-run /trekexecute: complete → merge to main
Cleanup worktrees + Master verification Cleanup worktrees + Master verification
``` ```
Each session operates in complete filesystem isolation — no shared git index, no race conditions, no data loss. If a merge produces conflicts, the merge is aborted and conflicting files are reported. Each session operates in complete filesystem isolation — no shared git index, no race conditions, no data loss. If a merge produces conflicts, the merge is aborted and conflicting files are reported.
Use `--fg` to force sequential execution even when a plan has an Execution Strategy. Use `--fg` to run every step sequentially in the current session instead, even when a plan has an Execution Strategy.
### Billing safety
Before launching parallel `claude -p` sessions, `/trekexecute` checks whether `ANTHROPIC_API_KEY` is set in your environment. If it is, parallel sessions will bill your **API account** (pay-per-token), not your Claude subscription (Max/Pro). This can be expensive — parallel Opus sessions can cost $50-100+ per run.
When an API key is detected, you are asked how to proceed:
- **Use --fg instead** (recommended) — run sequentially in the current session using your subscription
- **Continue with API billing** — launch parallel sessions on your API account
- **Stop** — cancel and unset the API key first
If no API key is set, parallel sessions use your subscription and proceed without asking.
### Failure recovery ### Failure recovery
@ -356,12 +345,11 @@ If no API key is set, parallel sessions use your subscription and proceed withou
### Security hardening ### Security hardening
The executor implements defense-in-depth security across four layers: The executor implements defense-in-depth security across three layers:
1. **Plugin hooks** — `pre-bash-executor.mjs` blocks 13 categories of destructive commands (rm -rf /, chmod 777, pipe-to-shell, eval injection, disk wipe, shutdown, fork bombs, cron persistence, process killing, history destruction) with bash evasion normalization. `pre-write-executor.mjs` blocks writes to `.git/hooks/`, `.claude/settings.json`, shell configs, `.ssh/`, `.aws/`, and `.env` files 1. **Plugin hooks** — `pre-bash-executor.mjs` blocks 13 categories of destructive commands (rm -rf /, chmod 777, pipe-to-shell, eval injection, disk wipe, shutdown, fork bombs, cron persistence, process killing, history destruction) with bash evasion normalization. `pre-write-executor.mjs` blocks writes to `.git/hooks/`, `.claude/settings.json`, shell configs, `.ssh/`, `.aws/`, and `.env` files
2. **Prompt-level denylist** — Security rules embedded in the executor command and session spec template that work even in headless `claude -p` sessions where hooks don't run 2. **Prompt-level denylist** — Security rules embedded in the executor command and session spec template that hold even if a hook does not fire
3. **Pre-execution plan scan** — Phase 2.4 scans all `Verify:` and `Checkpoint:` commands against the denylist before execution begins, catching dangerous commands before they reach the executor 3. **Pre-execution plan scan** — Phase 2.4 scans all `Verify:` and `Checkpoint:` commands against the denylist before execution begins, catching dangerous commands before they reach the executor
4. **Scoped tool access** — Headless child sessions use `--allowedTools "Read,Write,Edit,Bash,Glob,Grep"` instead of `--dangerously-skip-permissions`, blocking Agent spawning, MCP tools, and web access in parallel sessions
#### Recommended: disable Skill shell execution (CC v2.1.91+) #### Recommended: disable Skill shell execution (CC v2.1.91+)
@ -380,35 +368,19 @@ prompt-injection vector that the plugin's own hooks cannot fully mitigate
(Skills can fire before `pre-bash-executor` matches). See (Skills can fire before `pre-bash-executor` matches). See
[SECURITY.md](SECURITY.md) for the full hardening list. [SECURITY.md](SECURITY.md) for the full hardening list.
### Headless execution ### Unattended execution
`/trekexecute` is designed for `claude -p` headless sessions: `/trekexecute` runs without asking questions:
- **No questions asked** — all recovery decisions come from the plan - **No questions asked** — all recovery decisions come from the plan
- **Progress file** — crash recovery via `{project_dir}/progress.json` (or `.trekexecute-progress-{slug}.json` for legacy plans) - **Progress file** — crash recovery via `{project_dir}/progress.json` (or `.trekexecute-progress-{slug}.json` for legacy plans)
- **Scope fence enforcement** — never touches files outside the session's scope - **Scope fence enforcement** — never touches files outside the session's scope
- **JSON summary** — machine-parseable `trekexecute_summary` block for log parsing - **JSON summary** — machine-parseable `trekexecute_summary` block for log parsing
#### Headless multi-session tuning (CC v2.1.89+)
When running multiple parallel `claude -p` sessions (decomposed plans
or wave-based execution), set `MCP_CONNECTION_NONBLOCKING=true` in the
launching environment so MCP server connection latency does not
serialize startup across waves:
```bash
export MCP_CONNECTION_NONBLOCKING=true
bash .claude/projects/{slug}/sessions/launch.sh
```
Without this, each child session can spend 1-3 s blocking on MCP
connect, multiplying across waves. Setting it lets MCP connect lazily
on first tool call.
### Session titles for voyage commands (CC v2.1.94+) ### Session titles for voyage commands (CC v2.1.94+)
A `UserPromptSubmit` hook (`hooks/scripts/session-title.mjs`) sets the A `UserPromptSubmit` hook (`hooks/scripts/session-title.mjs`) sets the
session title to `voyage:<command>:<slug>` whenever you invoke one of session title to `voyage:<command>:<slug>` whenever you invoke one of
the four voyage commands. This makes multi-session headless runs and the four voyage commands. This makes the sessions of a multi-session run and
session-picker output trivially identifiable. Slug derivation: session-picker output trivially identifiable. Slug derivation:
| Invocation | Session title | | Invocation | Session title |
@ -669,7 +641,7 @@ flowchart TB
PLG --> PLS --> PLC PLG --> PLS --> PLC
end end
subgraph EX["/trekexecute · no sub-agents"] subgraph EX["/trekexecute · no sub-agents"]
EXI["inline step-loop + claude -p worktrees<br/>+ Phase 7.5/7.6 manifest audit (deterministic)"] EXI["inline step-loop + session orders per wave (worktrees)<br/>+ Phase 7.5/7.6 manifest audit (deterministic)"]
end end
subgraph RV["/trekreview"] subgraph RV["/trekreview"]
RVR["Phase 5 — parallel · code-correctness-reviewer · brief-conformance-reviewer*"] RVR["Phase 5 — parallel · code-correctness-reviewer · brief-conformance-reviewer*"]
@ -732,7 +704,7 @@ An opt-in upstream architect plugin (not bundled) can insert a Claude-Code-speci
**Dry run + validate before executing:** **Dry run + validate before executing:**
```bash ```bash
/trekexecute --project <dir> --validate # schema check, no execution /trekexecute --project <dir> --validate # schema check, no execution
/trekexecute --project <dir> --dry-run # preview sessions and billing /trekexecute --project <dir> --dry-run # preview sessions and starters
/trekexecute --project <dir> # execute /trekexecute --project <dir> # execute
``` ```
@ -891,7 +863,7 @@ synthesis.
The command orchestrators pin no model: `/trekbrief`, `/trekresearch`, The command orchestrators pin no model: `/trekbrief`, `/trekresearch`,
`/trekplan`, and `/trekexecute` run on whatever model the session runs on. `/trekplan`, and `/trekexecute` run on whatever model the session runs on.
To run them on Sonnet for cost or latency, start the session on Sonnet To run them on Sonnet for cost or latency, start the session on Sonnet
(`claude --model sonnet`) or switch with `/model` inside the session. or switch with `/model` inside the session.
The exploration and review sub-agents are chosen separately, at the spawn The exploration and review sub-agents are chosen separately, at the spawn
sites: brief `phase_signals` first, then the profile's `phase_models`, with the sites: brief `phase_signals` first, then the profile's `phase_models`, with the

View file

@ -143,7 +143,7 @@ questions, which defeats the purpose.
(not "handle the error" but "revert file X, do not proceed to step N+1") (not "handle the error" but "revert file X, do not proceed to step N+1")
- Is there a circuit breaker? (steps that should halt execution on failure - Is there a circuit breaker? (steps that should halt execution on failure
must say so explicitly — never assume the executor will "figure it out") must say so explicitly — never assume the executor will "figure it out")
- Could a headless `claude -p` session execute each step without asking questions? - Could a fresh session running `/trekexecute` execute each step without asking questions?
Steps missing On failure or Checkpoint clauses are **major** findings Steps missing On failure or Checkpoint clauses are **major** findings
(not blockers — the plan is still valid for interactive use, but it (not blockers — the plan is still valid for interactive use, but it

View file

@ -3,7 +3,7 @@ name: session-decomposer
description: | description: |
Use this agent to decompose an trekplan into self-contained headless sessions. Use this agent to decompose an trekplan into self-contained headless sessions.
Reads a plan file, analyzes step dependencies, groups steps into sessions, Reads a plan file, analyzes step dependencies, groups steps into sessions,
identifies parallelism, and generates session specs + dependency graph + launch script. identifies parallelism, and generates session specs + dependency graph + session orders.
model: opus model: opus
color: green color: green
tools: ["Read", "Glob", "Grep", "Write"] tools: ["Read", "Glob", "Grep", "Write"]
@ -166,8 +166,8 @@ The Step 0 block to prepend verbatim:
``` ```
→ expected: non-77 exit code → expected: non-77 exit code
- **On failure:** `escalate` — exit code 77 means this sandbox cannot push. - **On failure:** `escalate` — exit code 77 means this sandbox cannot push.
Abort immediately; do not attempt any work. Main orchestrator will Abort immediately; do not attempt any work. Fix the sandbox's push
re-spawn with correct permissions. permission, then rerun this session.
- **Checkpoint:** none (no file changes) - **Checkpoint:** none (no file changes)
- **Manifest:** - **Manifest:**
```yaml ```yaml
@ -218,28 +218,22 @@ graph LR
| 3 | S4 | sequential | Wave 2 | | 3 | S4 | sequential | Wave 2 |
``` ```
### Step 7 — Generate the launch script ### Step 7 — Write the session orders
Write a bash launch script to `{output_dir}/launch.sh`. Voyage never starts a session, and neither does anything you write: no launch
script, no `claude` command line. Write `{output_dir}/orders.md` — the hand-over
the user works from. For each wave, in order, list every session with:
The script must: 1. Its spec file (`{output_dir}/session-{N}-{slug}.md`)
1. Group sessions into waves matching the dependency graph 2. Its starter line, which the user gives a Claude Code session they open
2. Launch parallel sessions in each wave using `claude -p "$(cat session-file.md)"` themselves: `/trekexecute {output_dir}/session-{N}-{slug}.md`
3. Wait for all sessions in a wave before starting the next wave 3. What it depends on, and its exit condition
4. Log each session to a separate file in `{output_dir}/logs/`
5. Run exit-condition verification after each wave
6. Stop if any wave's verification fails
7. Run the master plan's overall verification at the end
**Important script conventions:** State the two rules at the top of the file: a wave starts only after every
- Use `#!/usr/bin/env bash` shebang session of the previous wave met its exit condition; and sessions of the same
- Use `set -euo pipefail` wave run in parallel only in separate git worktrees (never two in one working
- Each `claude -p` invocation must use `--allowedTools "Read,Write,Edit,Bash,Glob,Grep"` directory) — run sequentially, one after another in the main checkout, is
and `--permission-mode bypassPermissions`. Prepend `unset ANTHROPIC_API_KEY` always safe. End with the master plan's overall verification commands.
before each invocation to prevent accidental API billing
- Background processes use `&` and are collected with `wait`
- PID tracking for wait targets
- Exit codes propagated correctly
### Step 8 — Write the summary ### Step 8 — Write the summary
@ -271,7 +265,7 @@ Output a structured summary:
- Session specs: `{output_dir}/session-*.md` - Session specs: `{output_dir}/session-*.md`
- Dependency graph: `{output_dir}/dependency-graph.md` - Dependency graph: `{output_dir}/dependency-graph.md`
- Launch script: `{output_dir}/launch.sh` - Session orders: `{output_dir}/orders.md`
### Final verification ### Final verification

View file

@ -894,8 +894,6 @@ topic.
> levels deep), so a delegated redesign is under evaluation (see > levels deep), so a delegated redesign is under evaluation (see
> `docs/cc-upgrade-2.1.181-decision-matrix.md`, W1/CC-26). Until then, > `docs/cc-upgrade-2.1.181-decision-matrix.md`, W1/CC-26). Until then,
> running each research pass inline in main context keeps the swarm intact. > running each research pass inline in main context keeps the swarm intact.
> For true parallel execution, use `claude -p` invocations in separate
> terminal windows.
### Step 6c — Verify all briefs landed ### Step 6c — Verify all briefs landed

View file

@ -1,6 +1,6 @@
--- ---
name: trekexecute name: trekexecute
description: Disciplined plan executor — single-session or multi-session with parallel orchestration, failure recovery, and headless support description: Disciplined plan executor — single-session, or multi-session through session orders the user starts, with failure recovery
argument-hint: "[--project <dir>] [--fg | --resume | --dry-run | --validate | --step N | --session N] [plan.md]" argument-hint: "[--project <dir>] [--fg | --resume | --dry-run | --validate | --step N | --session N] [plan.md]"
allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion
disallowed-tools: Agent, TeamCreate disallowed-tools: Agent, TeamCreate
@ -10,9 +10,11 @@ disallowed-tools: Agent, TeamCreate
Disciplined executor for trekplan plans. Reads a plan file, detects if it has Disciplined executor for trekplan plans. Reads a plan file, detects if it has
an Execution Strategy (multi-session), and either executes directly or an Execution Strategy (multi-session), and either executes directly or
orchestrates parallel headless sessions — all to realize one plan. prepares one session order per session, wave by wave — all to realize one plan.
Designed to work identically in interactive and headless (`claude -p`) mode. 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).
## Phase 1 — Parse mode and validate input ## Phase 1 — Parse mode and validate input
@ -329,20 +331,6 @@ pre-execution summary. Report them in the final output under "Security advisorie
**If clean:** Report `Security scan: PASS ({N} commands checked)` and continue. **If clean:** Report `Security scan: PASS ({N} commands checked)` and continue.
### Hooks-fire precheck (advisory)
Verify the operator's Claude Code version supports cross-model hook firing
(v2.1.117+ shipped the cross-model malware-reminder fix per research/04 D4
+ R5 + research/06 R1). Run `claude --version` (or read
`${CLAUDE_CODE_VERSION}` if available) and compare against `2.1.117`.
If unavailable or older: WARN (do not block) —
`Hooks-fire precheck: CC version {X} predates v2.1.117. Plugin hooks may
not fire reliably in headless child sessions; the in-prompt safety
preamble (Phase 2.6 Step 2b) is the primary defense for affected runs.`
If version >= 2.1.117 or unparseable: continue silently.
## Phase 2.5 — Execution strategy decision ## Phase 2.5 — Execution strategy decision
Determine how to execute this plan: Determine how to execute this plan:
@ -366,7 +354,10 @@ Determine how to execute this plan:
- All sessions are in different waves (no parallelism, but still separate sessions) - All sessions are in different waves (no parallelism, but still separate sessions)
For single-session: continue to Phase 3. For single-session: continue to Phase 3.
For multi-session: jump to Phase 2.6. For multi-session: if `.claude/trekplan-sessions/{slug}/orders/wave.json`
exists, a wave is awaiting its sessions — skip Phase 2.55 (its clean-tree and
stale-worktree checks would destroy the live worktrees) and jump to Phase 2.6,
which collects the wave. Otherwise run Phase 2.55, then Phase 2.6.
Report: Report:
``` ```
@ -508,91 +499,59 @@ After all 4 checks pass:
Pre-flight: PASS (clean tree, plan reaches worktrees, no overlaps, no stale worktrees) Pre-flight: PASS (clean tree, plan reaches worktrees, no overlaps, no stale worktrees)
``` ```
## Phase 2.6 — Multi-session orchestration (worktree-isolated) ## Phase 2.6 — Multi-session orchestration (worktree-isolated, session orders)
**Only runs for multi-session execution.** This phase creates isolated git **Only runs for multi-session execution.** Voyage never starts a Claude Code
worktrees for each parallel session, launches headless child sessions in their session. This phase prepares ONE wave at a time: it creates an isolated git
own worktrees, merges results back sequentially, and cleans up. After this worktree for each session in the wave, writes a **session order** for each
phase, jump directly to Phase 8 (final report). (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 parallel `claude -p` session MUST run in its own **CRITICAL SAFETY RULE:** Every session MUST run in its own git worktree. Never
git worktree. Never launch two sessions in the same working directory. This rule run two sessions in the same working directory. This rule exists because
exists because parallel git operations in a shared worktree cause index corruption, parallel git operations in a shared worktree cause index corruption, race
race conditions, and repository destruction. conditions, and repository destruction. Each order names its own worktree.
### Step 0 — Billing safety check (MANDATORY) ### Step 0 — Resolve paths and detect a wave in flight
Before launching ANY `claude -p` process, check the environment:
```bash
echo "${ANTHROPIC_API_KEY:+SET}"
```
If the result is `SET`, **STOP** and warn the user. `claude -p` sessions with
`ANTHROPIC_API_KEY` in the environment bill the **API account** (pay-per-token),
not the user's Claude subscription (Max/Pro). Parallel Opus sessions can cost
$50–100+ per run.
Use AskUserQuestion with these options:
**Question:** "ANTHROPIC_API_KEY is set in your environment. Parallel `claude -p`
sessions will bill your API account, not your Claude subscription. How do you
want to proceed?"
| Option | Description |
|--------|-------------|
| **Use --fg instead (Recommended)** | Run all steps sequentially in this session using your subscription. No extra cost. |
| **Continue with API billing** | Launch parallel sessions. Each session bills your API account at token rates. |
| **Stop** | Cancel execution. Unset ANTHROPIC_API_KEY first, then re-run. |
If the user chooses `--fg`: restart execution with mode = foreground (jump back
to Phase 3, single-session).
If the user chooses `Continue`: proceed with Phase 2.6 Step 1.
If the user chooses `Stop`: report "Execution cancelled — billing safety check"
and stop.
If `ANTHROPIC_API_KEY` is NOT set: proceed silently to Step 1.
### Step 1 — Create session infrastructure
```bash ```bash
REPO_ROOT="$(git rev-parse --show-toplevel)" REPO_ROOT="$(git rev-parse --show-toplevel)"
SESSION_DIR="$REPO_ROOT/.claude/trekplan-sessions/{slug}" SESSION_DIR="$REPO_ROOT/.claude/trekplan-sessions/{slug}"
WORKTREE_DIR="$SESSION_DIR/worktrees" WORKTREE_DIR="$SESSION_DIR/worktrees"
LOG_DIR="$SESSION_DIR/logs" ORDERS_DIR="$SESSION_DIR/orders"
mkdir -p "$WORKTREE_DIR" "$LOG_DIR"
ORIGINAL_BRANCH="$(git rev-parse --abbrev-ref HEAD)" 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).
VOYAGE_ROOT="${CLAUDE_PLUGIN_ROOT:-}"
case "$VOYAGE_ROOT" in
/*) ;;
*) VOYAGE_ROOT="$(ls -d "$HOME"/.claude/plugins/cache/*/voyage 2>/dev/null | head -1)" ;;
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`, `WORKTREE_DIR`, `LOG_DIR`, and `ORIGINAL_BRANCH` for use Record `REPO_ROOT`, `SESSION_DIR`, `WORKTREE_DIR`, `ORDERS_DIR`, `VOYAGE_ROOT`
in subsequent steps. All paths must be absolute. and `ORIGINAL_BRANCH` for the later steps. All paths must be absolute.
### Step 2 — Execute waves with worktree isolation - `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 first wave of the Execution Strategy: `W` = the lowest
wave number. Continue to Step 1.
For each wave (in order): ### Step 1 — Create worktrees for wave W
**2a-pre. Build shared context + budget caps (runs once per wave, before
worktree creation):**
```bash ```bash
WORKTREE_BASE="$(realpath "${WORKTREE_BASE:-${SESSION_DIR:-$REPO_ROOT}/worktrees}")" mkdir -p "$WORKTREE_DIR" "$ORDERS_DIR"
mkdir -p "${WORKTREE_BASE}"
SHARED_CONTEXT_FILE="${WORKTREE_BASE}/.shared-context.md"
cat "${PROJECT_DIR}/brief.md" > "${SHARED_CONTEXT_FILE}"
if [ -f "${PROJECT_DIR}/architecture/overview.md" ]; then
printf '\n\n---\n\n' >> "${SHARED_CONTEXT_FILE}"
cat "${PROJECT_DIR}/architecture/overview.md" >> "${SHARED_CONTEXT_FILE}"
fi
MAX_TURNS="${TREKEXECUTE_MAX_TURNS:-50}"
MAX_BUDGET_USD="${TREKEXECUTE_MAX_BUDGET_USD:-5}"
``` ```
The budget caps default to safe values for normal-length steps. Operators may
override for long runs:
`TREKEXECUTE_MAX_TURNS=120 TREKEXECUTE_MAX_BUDGET_USD=20 /trekexecute --project ...`
**2a. Create worktrees for this wave's sessions:** **2a. Create worktrees for this wave's sessions:**
For each session N in this wave: For each session N in this wave:
@ -623,11 +582,11 @@ plugin-in-monorepo + gitignored-state topology):**
`git worktree add` clones HEAD only. The plugin's `.gitignore` excludes `git worktree add` clones HEAD only. The plugin's `.gitignore` excludes
`.claude/projects/`, so the project directory (brief.md, plan.md, research/) `.claude/projects/`, so the project directory (brief.md, plan.md, research/)
is missing in the freshly-created worktree. Each child session needs to read 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. `brief.md`, `plan.md`, and `research/*` from `${PROJECT_DIR}` to do real work.
Insert this block AFTER the worktree-creation loop and BEFORE wave dispatch Insert this block AFTER the worktree-creation loop and BEFORE the orders are
(Step 2b): written (Step 2):
```bash ```bash
PROJECT_SOURCE="$(realpath "${PROJECT_DIR}")" PROJECT_SOURCE="$(realpath "${PROJECT_DIR}")"
@ -658,7 +617,7 @@ 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 is GNU coreutils only; BSD `realpath` (the macOS default) rejects it, and the
failure is **silent** — the command substitution leaves `PROJECT_REL` empty, so 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` `mkdir -p "$wt/"` and `cp ... "$wt//"` both succeed and drop `brief.md`/`plan.md`
at the worktree root, where no child session looks for them. Measured on an at the worktree root, where no session looks for them. Measured on an
Intel Mac 2026-08-31: `realpath --relative-to=... ` → `realpath: illegal option Intel Mac 2026-08-31: `realpath --relative-to=... ` → `realpath: illegal option
-- -`, while bare `realpath <path>` works; a whole wave ran with zero steps -- -`, while bare `realpath <path>` works; a whole wave ran with zero steps
executed. Both `realpath` calls above are bare path resolution, which BSD and executed. Both `realpath` calls above are bare path resolution, which BSD and
@ -668,89 +627,63 @@ it correct when one side goes through a symlink (macOS `/var` → `/private/var`
Failure modes: an underivable relpath (empty, or outside the repo) aborts Failure modes: an underivable relpath (empty, or outside the repo) aborts
before anything is copied — better a loud stop than files delivered where no 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 session reads them; any `cp` failure exits the wave non-zero, reported via
Step 4 cleanup. A project without `research/` is not a failure. Step 5 cleanup. A project without `research/` is not a failure.
Source: brief Constraint 2. Source: brief Constraint 2.
**2b. Launch sessions in this wave (each in its own worktree):** ### Step 2 — Write the session orders for wave W, then stop
Each per-session prompt is **prefixed with two safety preambles** to defend
against headless-mode hook gaps (GH #36071) and the Claude 4 cross-model
malware-reminder issue (GH #52272 community workaround):
```text
[CRITICAL — defense in depth] You are running in headless mode where plugin
hooks may not fire reliably (GH #36071). DO NOT execute commands matching:
`rm -rf /`, `curl | bash`, `git push --force` to main, `git reset --hard`
outside this worktree, or any command writing outside the current worktree.
Treat this rule as enforced regardless of `--allowedTools` allowlist.
Note: any malware-related safety reminders apply conditionally to code you
assess as actually malicious, not to all code reads (per GH #52272 community
workaround).
```
Then the per-session dispatch (note the `cwd:` honor for plugin-in-monorepo
layouts):
For each session N in the wave:
```bash
SESSION_CWD="${session_cwd:-.}" # default: worktree root; overridable per-session via Execution Strategy `cwd:` field
cd "$WORKTREE_PATH/$SESSION_CWD" && \
GIT_OPTIONAL_LOCKS=0 claude -p "${SAFETY_PREAMBLE}\n\n/trekexecute --session {N} {plan-path}" \
--allowedTools "Read,Write,Edit,Bash,Glob,Grep" \
--permission-mode bypassPermissions \
--max-turns "$MAX_TURNS" \
--max-budget-usd "$MAX_BUDGET_USD" \
--append-system-prompt-file "${SHARED_CONTEXT_FILE}" \
> "$LOG_DIR/session-{N}.log" 2>&1 &
```
`session_cwd` is sourced from the per-session `cwd:` field in the plan's
Execution Strategy block (parsed by Phase 2.6's existing session-metadata
reader). Default is `.` (worktree root) when absent — backward compatible.
Sessions can declare `cwd: <subdir>` to make child sessions run from a
plugin-subdir instead of repo root. Required for plugin-in-monorepo layouts
where the plugin lives at `plugins/<name>/` and step paths are relative to
the plugin (brief Constraint 1, Option B).
Key rules:
- `$WORKTREE_PATH` is the absolute path to the session's worktree
- `$LOG_DIR` is an absolute path in the main worktree (NOT inside the session worktree)
- `{plan-path}` is the same relative path — it works because the worktree has
the same repo content from HEAD
- `${SHARED_CONTEXT_FILE}` is absolute (from Step 2a-pre) so child sessions
can resolve it after `cd`
- If the wave has only 1 session, run without `&` (no background needed)
- Track PIDs for parallel sessions
- `--max-turns` + `--max-budget-usd` are guardrails (research/06 R3 + R4)
- `--append-system-prompt-file` injects shared brief + architecture context
into every child without bloating the per-session prompt
**2c. Wait for wave completion:**
```bash ```bash
wait {PID1} {PID2} ... node "$VOYAGE_ROOT/lib/util/session-orders.mjs" write \
--plan "{plan-path}" --wave "{W}" --session-dir "$SESSION_DIR"
``` ```
**2d. Check results after each wave:** `{plan-path}` is repo-relative (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
and `$ORDERS_DIR/wave.json` (`status: awaiting-sessions`), and prints, per
session, the directory to open and the starter line
(`/trekexecute --session {N} {plan-path}`). 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.
For each session in the wave, read its log file (in `$LOG_DIR`, always accessible Show the tool's output to the user verbatim, then report and **stop**:
from the main worktree) and grep for `"trekexecute_summary"`. Parse the JSON to
determine:
- Did the session complete? (`result: "completed"`)
- Did it fail? (`result: "failed"` or `"stopped"`)
If ANY session in the wave failed:
``` ```
Wave {W} FAILED: Session {N} failed at step {S}. Wave {W}: {N} session order(s) written in {ORDERS_DIR}. Awaiting sessions.
Stopping — later waves depend on this wave. Open each session yourself in the directory its order names and give it the
See log: {LOG_DIR}/session-{N}.log starter line. When every session is done, run this same /trekexecute command
again here to merge the wave.
``` ```
Do NOT merge. Do NOT start later waves. Jump to Step 4 (cleanup), then Phase 8.
If all sessions in the wave passed: continue to Step 2e. This run ends here. It writes no Phase 8 report: the plan is not finished, and
the orders are the hand-over.
**2e. Merge session branches back (SEQUENTIAL, one at a time):** ### Step 3 — Collect wave W (a later run)
```bash
node "$VOYAGE_ROOT/lib/util/session-orders.mjs" status \
--plan "{plan-path}" --wave "{W}" --session-dir "$SESSION_DIR"
```
The JSON names each session's progress file (the file `--session N` writes,
Phase 3) and its `status`: `completed`, `failed`, `stopped`, `pending` (no
progress file yet) or `unreadable`.
- **Any `pending`:** the wave is still running. Report which sessions are
pending, point at their order files (`$ORDERS_DIR/wave-{W}-session-{N}.md`),
and stop. Do not merge, do not clean up.
- **Any `failed`, `stopped` or `unreadable`:**
```
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), then Phase 8.
- **`all_completed: true`:** if `gates_mode` is true, pause for operator
confirmation before merge-back. Then continue with the merge.
**Merge session branches back (SEQUENTIAL, one at a time):**
Return to the main worktree: Return to the main worktree:
```bash ```bash
@ -781,13 +714,13 @@ Wave {W} MERGE CONFLICT: Session {N} branch conflicts with merged state.
Conflicting files: Conflicting files:
{CONFLICTS} {CONFLICTS}
Session {N} log: {LOG_DIR}/session-{N}.log Session {N} order: {ORDERS_DIR}/wave-{W}-session-{N}.md
Aborting further merges. Sessions already merged in this wave are preserved. Aborting further merges. Sessions already merged in this wave are preserved.
``` ```
Mark remaining sessions as "merge-failed". Jump to Step 4 (cleanup), then Phase 8. Mark remaining sessions as "merge-failed". Jump to Step 5 (cleanup), then Phase 8.
**2f. Remove worktrees for completed wave:** **Remove worktrees for the merged wave:**
After successful merge of all sessions in the wave: After successful merge of all sessions in the wave:
```bash ```bash
@ -796,21 +729,26 @@ for each session N in the wave:
GIT_OPTIONAL_LOCKS=0 git branch -d "trek/{slug}/session-{N}" GIT_OPTIONAL_LOCKS=0 git branch -d "trek/{slug}/session-{N}"
done done
GIT_OPTIONAL_LOCKS=0 git worktree prune GIT_OPTIONAL_LOCKS=0 git worktree prune
rm -f "$ORDERS_DIR/wave.json"
``` ```
Report: `Wave {W}: {N} sessions merged, worktrees cleaned up.` Report: `Wave {W}: {N} sessions merged, worktrees cleaned up.`
Continue to the next wave. **Next wave:** if the Execution Strategy has a later wave, set `W` to it and go
back to Step 1 — this run prepares that wave's orders and stops again. If wave
W was the last, continue to Step 4.
### Step 3 — Run master verification ### Step 4 — Run master verification
After all waves complete and merge successfully, run the plan's `## Verification` After all waves complete and merge successfully, run the plan's `## Verification`
section commands to verify the integrated result. section commands to verify the integrated result.
### Step 4 — Cleanup (ALWAYS runs, even on failure) ### Step 5 — Cleanup (ALWAYS runs when the run ends, even on failure)
This step MUST execute regardless of how Step 2 exited — success, failure, or This step MUST execute whenever this phase ends the run with a failure, a merge
merge conflict. It is the worktree equivalent of a `finally` block. conflict, or after the last wave — never while a wave is awaiting its sessions
(Step 2 and a `pending` Step 3 stop without it). It is the worktree equivalent
of a `finally` block.
```bash ```bash
cd "$REPO_ROOT" cd "$REPO_ROOT"
@ -826,7 +764,8 @@ GIT_OPTIONAL_LOCKS=0 git branch --list "trek/{slug}/*" | while read branch; do
GIT_OPTIONAL_LOCKS=0 git branch -D "$branch" 2>/dev/null GIT_OPTIONAL_LOCKS=0 git branch -D "$branch" 2>/dev/null
done done
# Clean up empty directories # Clean up empty directories and the wave marker
rm -f "$ORDERS_DIR/wave.json"
rmdir "$WORKTREE_DIR" 2>/dev/null rmdir "$WORKTREE_DIR" 2>/dev/null
``` ```
@ -841,9 +780,9 @@ Warning: failed to remove worktree {path}. Manual cleanup:
git worktree remove {path} --force && git worktree prune git worktree remove {path} --force && git worktree prune
``` ```
### Step 5 — Aggregate results ### Step 6 — Aggregate results
Collect all session summaries into an aggregated report. Jump to Phase 8. Collect every session's progress file into an aggregated report. Jump to Phase 8.
### --session N mode ### --session N mode
@ -855,12 +794,13 @@ When mode = `session N`:
5. Enforce the session's scope fence as if it were a session spec's scope fence 5. Enforce the session's scope fence as if it were a session spec's scope fence
6. Report results for those steps only 6. Report results for those steps only
This mode is used internally by Phase 2.6 when launching child sessions. This mode is what each session order's starter runs, in a session the user
It can also be used manually to re-run a specific session. opened in that order's worktree. It can also be used manually to re-run a
specific session.
When `--session N` is invoked inside a git worktree (as done by Phase 2.6), all When `--session N` runs inside a git worktree (as each session order sets up),
git operations (add, commit) apply to the worktree's branch. The session does not all git operations (add, commit) apply to the worktree's branch. The session does
need to know it is in a worktree — git handles this transparently. not need to know it is in a worktree — git handles this transparently.
## Phase 3 — Progress file setup ## Phase 3 — Progress file setup
@ -1010,16 +950,11 @@ If `has_execution_strategy = true`, show a preview of multi-session orchestratio
``` ```
**Sessions:** {S} across {W} waves **Sessions:** {S} across {W} waves
| Wave | Session | Steps | Depends on | Command | | Wave | Session | Steps | Depends on | Starter (the user runs it in a session they open) |
|------|---------|-------|------------|---------| |------|---------|-------|------------|---------|
| 1 | Session 1: {title} | {nums} | none | `claude -p "/trekexecute --session 1 {path}"` | | 1 | Session 1: {title} | {nums} | none | `/trekexecute --session 1 {path}` |
| 1 | Session 2: {title} | {nums} | none | `claude -p "/trekexecute --session 2 {path}"` | | 1 | Session 2: {title} | {nums} | none | `/trekexecute --session 2 {path}` |
| 2 | Session 3: {title} | {nums} | S1, S2 | `claude -p "/trekexecute --session 3 {path}"` | | 2 | Session 3: {title} | {nums} | S1, S2 | `/trekexecute --session 3 {path}` |
```
Check billing status via `echo "${ANTHROPIC_API_KEY:+SET}"` and report:
```
Billing: ANTHROPIC_API_KEY is {SET — parallel sessions will bill API account | NOT SET — sessions will use subscription}
``` ```
### Verdict ### Verdict
@ -1245,7 +1180,7 @@ depends on what was executed:
Phase 4's entry-condition skip for trekplans stands — a plan carries no entry 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` 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 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 3). A single-session run therefore 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 reported `completed` without ever executing the criteria it was measured
against. It no longer does. against. It no longer does.
@ -1443,17 +1378,20 @@ the user for manual resolution.
4. Prepend the synthetic Step 0 pre-flight (same as normal session specs). 4. Prepend the synthetic Step 0 pre-flight (same as normal session specs).
5. Set `recovery_dispatched = true` in parent progress file. 5. Set `recovery_dispatched = true` in parent progress file.
**Invoke the recovery session:** **Hand the recovery session over as an order (Voyage does not start it):**
```bash Report the recovery spec as a session order and stop the run as `partial`:
cd "$WORKTREE_PATH" && claude -p "/trekexecute --session {N} {recovery spec path}" \
--allowedTools "Read,Write,Edit,Bash,Glob,Grep" \ ```
--permission-mode bypassPermissions \ Recovery order: session {N}, depth {depth}
> "$LOG_DIR/session-{N}-recovery-{depth}.log" 2>&1 Open a Claude Code session yourself in {WORKTREE_PATH} and give it:
/trekexecute --session {N} {recovery spec path}
When it is done, run this same /trekexecute command again: it re-runs the
Phase 7.5 audit.
``` ```
Wait for the recovery session to complete. After it returns, re-run Phase On that later run, re-run the Phase 7.5 audit one more time. If it still
7.5 audit one more time. If it still drifts at `recovery_depth=2`: drifts at `recovery_depth=2`:
``` ```
RECOVERY EXHAUSTED: session {N} drifted after 2 recovery attempts. RECOVERY EXHAUSTED: session {N} drifted after 2 recovery attempts.
@ -1466,8 +1404,7 @@ Do NOT dispatch a third recovery. Report to the user.
### Iteration caps + global recovery/retry budget ### Iteration caps + global recovery/retry budget
Every recovery/retry loop in this executor has a documented numeric bound. Every recovery/retry loop in this executor has a documented numeric bound.
These are **three distinct axes** — not one nested counter — plus a separate These are **three distinct axes** — not one nested counter:
child turn cap:
- **Per-step retry cap** — `attempts ≤ 3` (initial + **maximum 2 retries**; - **Per-step retry cap** — `attempts ≤ 3` (initial + **maximum 2 retries**;
Hard Rule 5 / Sub-step E). Bounds the retries of a *single* step. Hard Rule 5 / Sub-step E). Bounds the retries of a *single* step.
@ -1482,14 +1419,10 @@ child turn cap:
enforced via the `iterations_remaining` counter (Phase 3 schema; decremented enforced via the `iterations_remaining` counter (Phase 3 schema; decremented
on every recovery/retry iteration; reconciled at the Phase 7.5 completion on every recovery/retry iteration; reconciled at the Phase 7.5 completion
gate). gate).
- **Child turn cap** — `--max-turns` (`TREKEXECUTE_MAX_TURNS`, default 50)
bounds model turns inside a single `-p` child; a *different* axis from the
three above.
Default **25** is an independent value from the cap research (practitioner range Default **25** is an independent value from the cap research (practitioner range
10–50; OWASP Agentic ASI08 financial-DoS), tuned down from the research ~50 10–50; OWASP Agentic ASI08 financial-DoS), tuned down from the research ~50
starting point for a tighter backstop. It is **not** a reconciliation with starting point for a tighter backstop. Operators may override:
`--max-turns` (50) — the axes are incomparable. Operators may override:
`TREKEXECUTE_MAX_RECOVERY_ITERATIONS=10 /trekexecute --project ...`. The `TREKEXECUTE_MAX_RECOVERY_ITERATIONS=10 /trekexecute --project ...`. The
`effort=='low'` single-foreground-loop path and the wave path are both subject to `effort=='low'` single-foreground-loop path and the wave path are both subject to
this aggregate ceiling; it is an execution-spec ceiling (enforced via the this aggregate ceiling; it is an execution-spec ceiling (enforced via the
@ -1703,7 +1636,7 @@ To resume: /trekexecute --resume {path}
} }
``` ```
The `trekexecute_summary` key makes it grep-able in log files from headless runs. The `trekexecute_summary` key makes it grep-able in a session's output.
## Phase 9 — Stats tracking ## Phase 9 — Stats tracking
@ -1823,8 +1756,7 @@ code-path.
1. **No AskUserQuestion for execution decisions.** All execution decisions come 1. **No AskUserQuestion for execution decisions.** All execution decisions come
from the plan's On failure clauses. If the plan says escalate, stop and from the plan's On failure clauses. If the plan says escalate, stop and
report — never ask. **Exception:** the billing safety check in Phase 2.6 report — never ask.
Step 0 MUST ask before spending money on the user's API account.
2. **No scope creep.** Only touch files listed in the step's `Files:` field. 2. **No scope creep.** Only touch files listed in the step's `Files:` field.
If a file outside the list seems to need changing, record it as a finding If a file outside the list seems to need changing, record it as a finding
@ -1855,9 +1787,9 @@ code-path.
which removes both tools from the pool — `allowed-tools` alone does not, which removes both tools from the pool — `allowed-tools` alone does not,
since omission leaves a tool callable (CC 2.1.152). since omission leaves a tool callable (CC 2.1.152).
11. **Worktree isolation is mandatory for parallel execution.** Every parallel 11. **Worktree isolation is mandatory for multi-session execution.** Every
`claude -p` session MUST run in its own git worktree. Never launch two or session order names its own git worktree. Never run two or more sessions
more sessions in the same working directory. This rule has no exceptions. in the same working directory. This rule has no exceptions.
Sequential (single-session) execution does not require worktrees. Sequential (single-session) execution does not require worktrees.
12. **Worktree cleanup is unconditional.** Before producing the final report 12. **Worktree cleanup is unconditional.** Before producing the final report
@ -1921,6 +1853,11 @@ code-path.
20. **Three distinct iteration axes.** Per-step `attempts` (≤3), per-session 20. **Three distinct iteration axes.** Per-step `attempts` (≤3), per-session
`recovery_depth` (<2), and the global `TREKEXECUTE_MAX_RECOVERY_ITERATIONS` `recovery_depth` (<2), and the global `TREKEXECUTE_MAX_RECOVERY_ITERATIONS`
budget (default 25 — aggregate recovery+retry backstop) are independent budget (default 25 — aggregate recovery+retry backstop) are independent
bounds, not one nested counter; child `--max-turns` (50) is a fourth, bounds, not one nested counter. Every recovery/retry loop is bounded by at
separate axis. Every recovery/retry loop is bounded by at least one of them. least one of them. See "Iteration caps + global recovery/retry budget".
See "Iteration caps + global recovery/retry budget".
21. **Never start Claude Code.** No `claude` CLI invocation, no Agent SDK, no
background process that runs a session — not for waves, not for recovery,
not for a test. Work that needs another session becomes a session order
plus a starter line the user gives a session they open themselves
(Phase 2.6, Phase 7.6).

View file

@ -297,7 +297,7 @@ The session-decomposer will:
4. Identify which sessions can run in parallel (waves) 4. Identify which sessions can run in parallel (waves)
5. Generate one session spec file per session 5. Generate one session spec file per session
6. Generate a dependency diagram (mermaid) 6. Generate a dependency diagram (mermaid)
7. Generate a launch script (`launch.sh`) 7. Write the session orders (`orders.md`): one starter line per session, wave by wave
When the session-decomposer completes, present the summary to the user: When the session-decomposer completes, present the summary to the user:
@ -318,16 +318,16 @@ When the session-decomposer completes, present the summary to the user:
- Session specs: .claude/trekplan-sessions/{slug}/session-*.md - Session specs: .claude/trekplan-sessions/{slug}/session-*.md
- Dependency graph: .claude/trekplan-sessions/{slug}/dependency-graph.md - Dependency graph: .claude/trekplan-sessions/{slug}/dependency-graph.md
- Launch script: .claude/trekplan-sessions/{slug}/launch.sh - Session orders: .claude/trekplan-sessions/{slug}/orders.md
You can: You can:
- Review individual session specs before running - Review individual session specs before running
- Run all sessions: `bash .claude/trekplan-sessions/{slug}/launch.sh` - Run the sessions yourself, wave by wave: open a Claude Code session per spec
- Run a single session: `claude -p "$(cat .claude/trekplan-sessions/{slug}/session-1-*.md)"` and give it the starter line from orders.md (`/trekexecute <spec>`). Run the
- Say **"launch"** to start headless execution from here sessions of one wave in parallel only in separate git worktrees.
``` ```
If the user says **"launch"**: run the launch script via Bash. Voyage does not start these sessions.
Then **stop**. Do not continue to any subsequent phase. Then **stop**. Do not continue to any subsequent phase.
@ -372,8 +372,7 @@ Then continue to the next phase inline.
> can spawn sub-agents (up to 5 levels deep), so that block no longer holds — > can spawn sub-agents (up to 5 levels deep), so that block no longer holds —
> a delegated redesign is under evaluation (see > a delegated redesign is under evaluation (see
> `docs/cc-upgrade-2.1.181-decision-matrix.md`, W1/CC-26). Until then, running > `docs/cc-upgrade-2.1.181-decision-matrix.md`, W1/CC-26). Until then, running
> the phases inline in main context keeps the swarm intact. Use `claude -p` > the phases inline in main context keeps the swarm intact.
> in a separate terminal window for long-running headless work.
--- ---

View file

@ -245,8 +245,7 @@ Then continue to the next phase inline.
> deep), so that block no longer holds — a delegated redesign is under > deep), so that block no longer holds — a delegated redesign is under
> evaluation (see `docs/cc-upgrade-2.1.181-decision-matrix.md`, W1/CC-26). > evaluation (see `docs/cc-upgrade-2.1.181-decision-matrix.md`, W1/CC-26).
> Until then, running the phases inline in main context keeps the swarm > Until then, running the phases inline in main context keeps the swarm
> intact. Use `claude -p` in a separate terminal window for long-running > intact.
> headless work.
--- ---
@ -331,26 +330,21 @@ changes nothing (SC1). Keep the native-swarm anchors intact ("in parallel",
**engine = deep-research:** **engine = deep-research:**
1. **Coarse pre-gate (best-effort, NOT a trust signal).** `Bash: claude --version`; 1. **Coarse pre-gate (best-effort, NOT a trust signal).** The supported window is
parse the leading `X.Y.Z` (e.g. from `2.1.196 (Claude Code)`) and compare `2.1.154 <= version < 2.1.218`: below `2.1.154` there are no dynamic workflows,
numerically against **both ends** of the supported window and from `2.1.218` (the ceiling) `/deep-research` starts only when the operator
`2.1.154 <= version < 2.1.218` — split each on `.` and compare major, then minor, invokes it, and the Skill tool refuses a model invocation with
then patch as integers (do NOT string-compare; lexical comparison mis-orders `disable-model-invocation` — see step 4's reason token. Voyage does not run the
multi-digit patch numbers). If the version is `< 2.1.154` (below the Claude Code binary to read its version (it never calls Claude Code), so the
dynamic-workflows floor), OR `>= 2.1.218` (the ceiling: `/deep-research` starts version is *indeterminate* here. If `disableWorkflows: true` /
only when the operator invokes it, and the Skill tool refuses a model invocation `CLAUDE_CODE_DISABLE_WORKFLOWS=1` is set, skip to the fallback (step 4);
with `disable-model-invocation` — see step 4's reason token), OR if otherwise proceed to step 2 — do NOT hard-fail. There is no positive
`disableWorkflows: true` / `CLAUDE_CODE_DISABLE_WORKFLOWS=1` is set, skip to the
fallback (step 4). **If `claude` is not on PATH inside the Bash tool (possible
under `claude -p`) or the version cannot be parsed, treat the pre-gate as
*indeterminate* and proceed to step 2 — do NOT hard-fail.** There is no positive
availability probe (research Dim 4), so a passing pre-gate does not guarantee the availability probe (research Dim 4), so a passing pre-gate does not guarantee the
workflow runs; the post-hoc check (step 3) is the authoritative guard. workflow runs; the post-hoc check (step 3) is the authoritative guard.
2. **Run.** Instruct Claude (in prose, this turn) to run 2. **Run.** Instruct Claude (in prose, this turn) to run
`/deep-research <research question>` and request per-claim citations. Note: `/deep-research <research question>` and request per-claim citations. Note:
interactive default/acceptEdits triggers a per-run approval prompt; `claude -p` / interactive default/acceptEdits triggers a per-run approval prompt.
SDK / bypass runs immediately.
3. **Post-hoc presence + provenance check (the real guard).** Verify a real, cited 3. **Post-hoc presence + provenance check (the real guard).** Verify a real, cited
`/deep-research` report actually landed in context — substantive findings with `/deep-research` report actually landed in context — substantive findings with
@ -366,8 +360,8 @@ changes nothing (SC1). Keep the native-swarm anchors intact ("in parallel",
`Engine: deep-research → swarm (fallback: <reason>)` and carry the reason into the `Engine: deep-research → swarm (fallback: <reason>)` and carry the reason into the
Phase-8 Present summary and the brief's `## Executive Summary`. Known reason Phase-8 Present summary and the brief's `## Executive Summary`. Known reason
tokens: `disable-model-invocation` (CC >= 2.1.218 — the expected reason on any tokens: `disable-model-invocation` (CC >= 2.1.218 — the expected reason on any
current Claude Code), `version-below-floor`, `workflows-disabled`, current Claude Code), `workflows-disabled`, `no-cited-report` (also what a
`no-cited-report`. **NEVER fabricate Claude Code below the `2.1.154` floor produces, since no version is read). **NEVER fabricate
or synthesize a substitute report** — a structurally-valid-but-invented brief or synthesize a substitute report** — a structurally-valid-but-invented brief
passes the structure-only validator and silently poisons `/trekplan`; that is the passes the structure-only validator and silently poisons `/trekplan`; that is the
worst outcome of this feature. worst outcome of this feature.

View file

@ -49,7 +49,7 @@ Doc-consistency test at `tests/lib/doc-consistency.test.mjs` pins agent-table co
**Decompose:** Parse plan → Analyze step dependencies → Group into sessions → Identify parallel waves → Generate session specs + dependency graph + launch script. **Decompose:** Parse plan → Analyze step dependencies → Group into sessions → Identify parallel waves → Generate session specs + dependency graph + launch script.
**Execute:** Parse plan → Security scan (Phase 2.4) → Detect Execution Strategy → Single-session (step loop) or multi-session (parallel waves via `claude -p` with scoped `--allowedTools`) → Phase 7.5 manifest audit → Phase 7.6 bounded recovery (if partial) → Phase 8 atomically writes `progress.json` + `.session-state.local.json` (Handover 7) → Report. With `--project`, reads `{dir}/plan.md`. Phase 2.55 (pre-flight stop) and Phase 4 (entry-condition stop) also write `.session-state.local.json` so `/trekcontinue` can surface the stop and prompt for next steps. **Execute:** Parse plan → Security scan (Phase 2.4) → Detect Execution Strategy → Single-session (step loop) or multi-session (one wave at a time: a worktree plus a session order and starter per session; the user opens every session, and a re-run merges the wave — Voyage never starts a session) → Phase 7.5 manifest audit → Phase 7.6 bounded recovery (if partial) → Phase 8 atomically writes `progress.json` + `.session-state.local.json` (Handover 7) → Report. With `--project`, reads `{dir}/plan.md`. Phase 2.55 (pre-flight stop) and Phase 4 (entry-condition stop) also write `.session-state.local.json` so `/trekcontinue` can surface the stop and prompt for next steps.
**Continue:** `/trekcontinue` reads `{dir}/.session-state.local.json` (Handover 7), validates schema-v1 via `session-state-validator`, narrates a 3-line summary (project / next-session-label / brief-path), and immediately begins executing the next session. Auto-discovers active project state files under `.claude/projects/*/.session-state.local.json` if no explicit `<project-dir>` argument. Operator-invoked only — never auto-loaded via SessionStart. The `/trekendsession` helper is the informal-flow producer: writes the same state file for ad-hoc multi-session handovers that don't run through `/trekexecute`. **Continue:** `/trekcontinue` reads `{dir}/.session-state.local.json` (Handover 7), validates schema-v1 via `session-state-validator`, narrates a 3-line summary (project / next-session-label / brief-path), and immediately begins executing the next session. Auto-discovers active project state files under `.claude/projects/*/.session-state.local.json` if no explicit `<project-dir>` argument. Operator-invoked only — never auto-loaded via SessionStart. The `/trekendsession` helper is the informal-flow producer: writes the same state file for ad-hoc multi-session handovers that don't run through `/trekexecute`.
@ -59,7 +59,7 @@ Doc-consistency test at `tests/lib/doc-consistency.test.mjs` pins agent-table co
**Operator-annotation HTML (v5.0.3):** the last step of `/trekbrief`, `/trekplan`, and `/trekreview` runs `scripts/annotate.mjs` against the just-written `.md` and prints the resulting `file://<abs path>` link. The HTML is self-contained (zero npm deps, zero external network, design-system-styled, light + dark + print) and modelled on an existing annotation surface the operator pointed at. The operator opens the file, the document renders as a proper article (headings / paragraphs / lists / tables / code / quotes — every element gets a stable `data-anchor-id`). In annotation mode (default ON, pencil-toggle in topbar), the operator can **select any text or click any element** → a form popover opens at the cursor with: section context auto-detected from nearest h1/h2, the anchored snippet (selection if any, else element text), **three intent buttons (Fiks / Endre / Spørsmål)**, comment textarea, Save/Cancel. The sidebar (Show annotations button) lists every annotation grouped by section with intent badge + snippet + comment + delete; clicking a card scrolls to and flashes the source element. **Copy Prompt** assembles a structured markdown (`### N. [Intent] Section: <…>` + `Quote: «…»` + `Comment: …`) and copies to clipboard. Persistence: `localStorage` keyed on absolute artifact path (`voyage-annotate:v2:<abs path>`). v5.0.0 removed the v4.2/v4.3 bespoke playground SPA + `/trekrevise` + Handover 8; v5.0.1 pointed at `/playground document-critique` (Claude-leads, wrong direction); v5.0.2 was operator-led but too thin (line-click + freeform note, no intents); v5.0.3 matches the annotation reference the operator first pointed at, with pencil-toggle / selection capture / intent categories / popover form / structured export. See [CHANGELOG.md](../CHANGELOG.md) § v5.0.3. **Operator-annotation HTML (v5.0.3):** the last step of `/trekbrief`, `/trekplan`, and `/trekreview` runs `scripts/annotate.mjs` against the just-written `.md` and prints the resulting `file://<abs path>` link. The HTML is self-contained (zero npm deps, zero external network, design-system-styled, light + dark + print) and modelled on an existing annotation surface the operator pointed at. The operator opens the file, the document renders as a proper article (headings / paragraphs / lists / tables / code / quotes — every element gets a stable `data-anchor-id`). In annotation mode (default ON, pencil-toggle in topbar), the operator can **select any text or click any element** → a form popover opens at the cursor with: section context auto-detected from nearest h1/h2, the anchored snippet (selection if any, else element text), **three intent buttons (Fiks / Endre / Spørsmål)**, comment textarea, Save/Cancel. The sidebar (Show annotations button) lists every annotation grouped by section with intent badge + snippet + comment + delete; clicking a card scrolls to and flashes the source element. **Copy Prompt** assembles a structured markdown (`### N. [Intent] Section: <…>` + `Quote: «…»` + `Comment: …`) and copies to clipboard. Persistence: `localStorage` keyed on absolute artifact path (`voyage-annotate:v2:<abs path>`). v5.0.0 removed the v4.2/v4.3 bespoke playground SPA + `/trekrevise` + Handover 8; v5.0.1 pointed at `/playground document-critique` (Claude-leads, wrong direction); v5.0.2 was operator-led but too thin (line-click + freeform note, no intents); v5.0.3 matches the annotation reference the operator first pointed at, with pencil-toggle / selection capture / intent categories / popover form / structured export. See [CHANGELOG.md](../CHANGELOG.md) § v5.0.3.
**Security:** 4-layer defense-in-depth: plugin hooks (pre-bash-executor, pre-write-executor), prompt-level denylist (works in headless sessions), pre-execution plan scan (Phase 2.4), scoped `--allowedTools` replacing `--dangerously-skip-permissions`. Hard Rules 14-16 enforce verify command security, repo-boundary writes, and sensitive path protection. **Security:** 3-layer defense-in-depth: plugin hooks (pre-bash-executor, pre-write-executor), prompt-level denylist (holds even if a hook does not fire), pre-execution plan scan (Phase 2.4). Hard Rules 14-16 enforce verify command security, repo-boundary writes, and sensitive path protection.
**Pipeline:** `/trekbrief` produces the task brief. `/trekresearch --project <dir>` fills in `{dir}/research/`. `/trekplan --project <dir>` reads brief + research to produce `{dir}/plan.md` (and auto-discovers `{dir}/architecture/overview.md` if an opt-in upstream architect plugin produced one). `/trekexecute --project <dir>` executes and writes `{dir}/progress.json`. `/trekreview --project <dir>` produces `{dir}/review.md`. `/trekbrief`, `/trekplan`, and `/trekreview` each end by running `scripts/annotate.mjs` on the just-written artifact, producing `{dir}/{artifact}.html` — a self-contained operator-annotation surface — and printing the `file://` link. The operator opens it, clicks lines, writes their own notes, copies a structured prompt, pastes back, Claude revises the `.md`. All artifacts live in one project directory. **Pipeline:** `/trekbrief` produces the task brief. `/trekresearch --project <dir>` fills in `{dir}/research/`. `/trekplan --project <dir>` reads brief + research to produce `{dir}/plan.md` (and auto-discovers `{dir}/architecture/overview.md` if an opt-in upstream architect plugin produced one). `/trekexecute --project <dir>` executes and writes `{dir}/progress.json`. `/trekreview --project <dir>` produces `{dir}/review.md`. `/trekbrief`, `/trekplan`, and `/trekreview` each end by running `scripts/annotate.mjs` on the just-written artifact, producing `{dir}/{artifact}.html` — a self-contained operator-annotation surface — and printing the `file://` link. The operator opens it, clicks lines, writes their own notes, copies a structured prompt, pastes back, Claude revises the `.md`. All artifacts live in one project directory.
@ -93,7 +93,7 @@ Which native Claude Code primitive each pipeline step runs on today, and the alt
| **brief** | Inline interview loop → `brief.md` (framing gate) | `AskUserQuestion` as the Q&A engine (recommended, not yet wired); Skill/Workflow auto-orchestration (rejected — one-shot, can't span slash-command/handover boundaries) | | **brief** | Inline interview loop → `brief.md` (framing gate) | `AskUserQuestion` as the Q&A engine (recommended, not yet wired); Skill/Workflow auto-orchestration (rejected — one-shot, can't span slash-command/handover boundaries) |
| **research** | Inline spawns a foreground swarm; MCP per agent¹ | Delegated `synthesis-agent` (PoC measured Δ main-context ≈ 0 → ships **dormant**, wired to nothing) | | **research** | Inline spawns a foreground swarm; MCP per agent¹ | Delegated `synthesis-agent` (PoC measured Δ main-context ≈ 0 → ships **dormant**, wired to nothing) |
| **plan** | Inline: parallel explorer swarm → synthesis → adversarial `plan-critic` | Delegated orchestrator via nested sub-agents (CC-26 → lean NO, not wired; `planning-orchestrator.md` is a reference doc, not a spawnable agent) | | **plan** | Inline: parallel explorer swarm → synthesis → adversarial `plan-critic` | Delegated orchestrator via nested sub-agents (CC-26 → lean NO, not wired; `planning-orchestrator.md` is a reference doc, not a spawnable agent) |
| **execute** | Inline step loop; multi-session via `git worktree` + `claude -p` waves; deterministic manifest audit | CC `TaskCreate`/`TodoWrite` for progress/resume (insufficient — carries no step status / attempts / SHA / drift → own typed `progress.json` contract) | | **execute** | Inline step loop; multi-session via `git worktree` + session orders the user runs, wave by wave; deterministic manifest audit | CC `TaskCreate`/`TodoWrite` for progress/resume (insufficient — carries no step status / attempts / SHA / drift → own typed `progress.json` contract) |
| **review** | Inline parallel reviewers (no cross-feed) → `review-coordinator` Judge | **Workflow** substrate for Phase 5–6 (bake-off POSITIVE: +4.4 % tokens / +54 % wall-time → shipped **opt-in `--workflow`**, not default; wholesale substrate swap declined) | | **review** | Inline parallel reviewers (no cross-feed) → `review-coordinator` Judge | **Workflow** substrate for Phase 5–6 (bake-off POSITIVE: +4.4 % tokens / +54 % wall-time → shipped **opt-in `--workflow`**, not default; wholesale substrate swap declined) |
| **continue** | Inline reads `.session-state.local.json` → zero-confirm resume | CC `--resume` (transcript replay, not typed work-state → insufficient) | | **continue** | Inline reads `.session-state.local.json` → zero-confirm resume | CC `--resume` (transcript replay, not typed work-state → insufficient) |
| **cross-cutting** | 8 hook scripts: `pre-bash` + `pre-write` guards, `pre-agent-cap` loop-bound enforcement, `post-bash` stats, `session-title`, `pre-`/`post-compact` flush, **`Stop`→OTEL** export | — | | **cross-cutting** | 8 hook scripts: `pre-bash` + `pre-write` guards, `pre-agent-cap` loop-bound enforcement, `post-bash` stats, `session-title`, `pre-`/`post-compact` flush, **`Stop`→OTEL** export | — |

View file

@ -80,14 +80,19 @@ Protocol:
- Both arms append to the same `trekresearch-stats.jsonl`; `effort` is the - Both arms append to the same `trekresearch-stats.jsonl`; `effort` is the
grouping key that separates them. grouping key that separates them.
Each run is a Claude Code session the operator opens (Voyage starts none of
them; changed 2026-09-27 before any run — the thresholds above are untouched):
```text
# Control arm (loop inert) — n >= 5, in a session opened with VOYAGE_STORM_ENABLED unset
/trekresearch --project .claude/projects/<brief-standard-effort>
# Treatment arm (loop live, effort: high in the brief's phase_signals) — n >= 5,
# in a session opened with VOYAGE_STORM_ENABLED=1 in its environment
/trekresearch --project .claude/projects/<brief-high-effort>
```
```bash ```bash
# Control arm (loop inert) — n >= 5
claude -p "/trekresearch --project .claude/projects/<brief-standard-effort>"
# Treatment arm (loop live, effort: high in the brief's phase_signals) — n >= 5
VOYAGE_STORM_ENABLED=1 \
claude -p "/trekresearch --project .claude/projects/<brief-high-effort>"
# The gate # The gate
node scripts/storm-measure.mjs --stats "${CLAUDE_PLUGIN_DATA}/trekresearch-stats.jsonl" node scripts/storm-measure.mjs --stats "${CLAUDE_PLUGIN_DATA}/trekresearch-stats.jsonl"

View file

@ -30,7 +30,7 @@
// budget; a PreToolUse hook that over-blocks breaks every session on the box. // budget; a PreToolUse hook that over-blocks breaks every session on the box.
// //
// Stated limit, because the guarantee above is about OTHER sessions and reads // Stated limit, because the guarantee above is about OTHER sessions and reads
// as broader than it is: `claude --resume` keeps the same session_id, so a // as broader than it is: resuming a session (`--resume`) keeps the same session_id, so a
// resumed session is the same session by this key. If a run reached its cap // resumed session is the same session by this key. If a run reached its cap
// and then died before removing the marker, the resume inherits the remainder // and then died before removing the marker, the resume inherits the remainder
// of the TTL, for any WebSearch/WebFetch/Task — research or not. Three things // of the TTL, for any WebSearch/WebFetch/Task — research or not. Three things
@ -73,7 +73,7 @@ const SCOPE_DIRNAME = 'trekresearch-loop-scope';
// 2h — comfortably longer than any real research run (a 24-turn loop at a couple // 2h — comfortably longer than any real research run (a 24-turn loop at a couple
// of minutes a turn is under an hour), and short enough that debris does not own // of minutes a turn is under an hour), and short enough that debris does not own
// the rest of the working day. The TTL is measured from marker.startedAt rather // the rest of the working day. The TTL is measured from marker.startedAt rather
// than from last activity, and `claude --resume` keeps the same session_id, so // than from last activity, and resuming a session (`--resume`) keeps the same session_id, so
// this window is what a resumed session can inherit from a run that died holding // this window is what a resumed session can inherit from a run that died holding
// the marker. It was 6h; nothing needed six. // the marker. It was 6h; nothing needed six.
const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000; const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000;

227
lib/util/session-orders.mjs Normal file
View file

@ -0,0 +1,227 @@
// lib/util/session-orders.mjs
// /trekexecute's multi-session path, the PM way: every session a wave needs
// becomes an ORDER (a file naming its worktree, steps and done-condition) plus
// a STARTER (the one line the user gives a Claude Code session they open
// themselves in that worktree). Voyage never starts a session; this module
// only reads the plan, writes order files and reads progress files.
//
// Subcommands (CLI shim at the bottom):
// write --plan <repo-relative plan> --wave <W> --session-dir <dir>
// writes <dir>/orders/wave-<W>-session-<N>.md + <dir>/orders/wave.json
// and prints the starters
// status --plan <repo-relative plan> --wave <W> --session-dir <dir>
// prints JSON: each session's progress-file status, all_completed
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { join, dirname, basename, extname, isAbsolute } from 'node:path';
function sectionBody(text, heading) {
const lines = text.split('\n');
const start = lines.findIndex(l => l.trim() === heading);
if (start === -1) return null;
const out = [];
for (let i = start + 1; i < lines.length; i++) {
if (/^## /.test(lines[i])) break;
out.push(lines[i]);
}
return out;
}
function field(block, name) {
const re = new RegExp(`^\\s*-\\s*\\*\\*${name}:\\*\\*\\s*(.*)$`, 'i');
for (const l of block) {
const m = l.match(re);
if (m) return m[1].trim();
}
return null;
}
function numberList(raw) {
if (!raw || /^none$/i.test(raw)) return [];
const out = [];
for (const part of raw.split(',')) {
const p = part.replace(/session|^s/gi, '').trim();
const range = p.match(/^(\d+)\s*[-–]\s*(\d+)$/);
if (range) {
for (let n = Number(range[1]); n <= Number(range[2]); n++) out.push(n);
} else if (/^\d+$/.test(p)) {
out.push(Number(p));
}
}
return out;
}
/**
* Parse a plan's `## Execution Strategy` section.
* @returns {{sessions: {n:number,title:string,wave:number,steps:number[],dependsOn:number[],cwd:string}[], waves:number[]}}
*/
export function parseExecutionStrategy(planText) {
const body = sectionBody(planText, '## Execution Strategy');
if (!body) throw new Error('plan has no ## Execution Strategy section');
const sessions = [];
let cur = null;
for (const line of body) {
const h = line.match(/^###\s+Session\s+(\d+):\s*(.*)$/);
if (h) {
cur = { n: Number(h[1]), title: h[2].trim(), block: [] };
sessions.push(cur);
} else if (/^###\s/.test(line)) {
cur = null;
} else if (cur) {
cur.block.push(line);
}
}
if (sessions.length === 0) throw new Error('## Execution Strategy names no ### Session N: blocks');
const parsed = sessions.map(({ n, title, block }) => {
const wave = Number(field(block, 'Wave'));
if (!Number.isInteger(wave) || wave < 1) throw new Error(`Session ${n} has no Wave`);
return {
n, title, wave,
steps: numberList(field(block, 'Steps')),
dependsOn: numberList(field(block, 'Depends on')),
cwd: field(block, 'cwd') || '.',
};
});
return { sessions: parsed, waves: [...new Set(parsed.map(s => s.wave))].sort((a, b) => a - b) };
}
function session(strategy, n) {
const s = strategy.sessions.find(x => x.n === n);
if (!s) throw new Error(`no Session ${n} in the Execution Strategy`);
return s;
}
function worktreePathFor(sessionDir, n) {
return join(sessionDir, 'worktrees', `session-${n}`);
}
function openInFor(sessionDir, s) {
const wt = worktreePathFor(sessionDir, s.n);
return s.cwd === '.' ? wt : join(wt, s.cwd);
}
/**
* The progress file `/trekexecute --session N <plan>` writes, as seen from the
* session's working directory inside its worktree (Phase 3 session naming).
*/
export function progressFileFor({ strategy, n, planPath, sessionDir }) {
const s = session(strategy, n);
const slug = basename(planPath, extname(planPath));
return join(openInFor(sessionDir, s), dirname(planPath), `.trekexecute-progress-${slug}-session-${n}.json`);
}
/** One order per session in `wave`: worktree, starter, done-condition. */
export function buildWaveOrders({ strategy, wave, planPath, sessionDir }) {
if (isAbsolute(planPath)) throw new Error(`plan path must be repo-relative (each session resolves it inside its own worktree): ${planPath}`);
const members = strategy.sessions.filter(s => s.wave === wave);
if (members.length === 0) throw new Error(`no sessions in wave ${wave}`);
return members.map((s) => {
const worktreePath = worktreePathFor(sessionDir, s.n);
const openIn = openInFor(sessionDir, s);
const starter = `/trekexecute --session ${s.n} ${planPath}`;
const progressFile = progressFileFor({ strategy, n: s.n, planPath, sessionDir });
const body = [
`# Order: session ${s.n}, wave ${wave} - ${s.title}`,
'',
`- plan: ${planPath}`,
`- steps: ${s.steps.join(', ') || '-'}`,
`- depends on: ${s.dependsOn.length ? s.dependsOn.map(d => `session ${d}`).join(', ') : 'none'}`,
`- worktree: ${worktreePath}`,
`- open the session in: ${openIn}`,
'',
'## Starter',
'',
`Open a Claude Code session yourself in \`${openIn}\` and give it this line:`,
'',
` ${starter}`,
'',
'Voyage does not start this session. It runs only this session\'s steps, inside',
'its own worktree; never run two sessions in the same working directory.',
'',
'## Done when',
'',
`\`${progressFile}\` has \`"status": "completed"\`. Then run the same`,
'`/trekexecute` command as before in the main checkout: it reads every',
'session\'s progress file and merges the wave.',
'',
].join('\n');
return { session: s.n, title: s.title, wave, worktreePath, openIn, starter, progressFile, orderFile: `wave-${wave}-session-${s.n}.md`, body };
});
}
export function writeWaveOrders({ strategy, wave, planPath, sessionDir, now = new Date() }) {
const orders = buildWaveOrders({ strategy, wave, planPath, sessionDir });
const dir = join(sessionDir, 'orders');
mkdirSync(dir, { recursive: true });
for (const o of orders) writeFileSync(join(dir, o.orderFile), o.body);
const state = {
wave, plan: planPath, sessions: orders.map(o => o.session),
status: 'awaiting-sessions', created_at: now.toISOString(),
};
writeFileSync(join(dir, 'wave.json'), JSON.stringify(state, null, 2) + '\n');
return { dir, orders, state };
}
export function waveStatus({ strategy, wave, planPath, sessionDir }) {
const members = strategy.sessions.filter(s => s.wave === wave);
if (members.length === 0) throw new Error(`no sessions in wave ${wave}`);
const sessions = members.map((s) => {
const progressFile = progressFileFor({ strategy, n: s.n, planPath, sessionDir });
let status = 'pending';
if (existsSync(progressFile)) {
try {
status = JSON.parse(readFileSync(progressFile, 'utf-8')).status || 'unreadable';
} catch {
status = 'unreadable';
}
}
return { session: s.n, status, progressFile };
});
return { wave, sessions, all_completed: sessions.every(s => s.status === 'completed') };
}
// ---- CLI shim ----------------------------------------------------------------
function parseArgs(argv) {
const out = { cmd: argv[0] };
for (let i = 1; i < argv.length; i++) {
const a = argv[i];
if (a === '--plan') out.plan = argv[++i];
else if (a === '--wave') out.wave = Number(argv[++i]);
else if (a === '--session-dir') out.sessionDir = argv[++i];
}
return out;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const args = parseArgs(process.argv.slice(2));
const usage = 'usage: session-orders.mjs write|status --plan <repo-relative plan> --wave <W> --session-dir <dir>';
if (!['write', 'status'].includes(args.cmd) || !args.plan || !Number.isInteger(args.wave) || !args.sessionDir) {
process.stderr.write(usage + '\n');
process.exit(1);
}
try {
const strategy = parseExecutionStrategy(readFileSync(args.plan, 'utf-8'));
const opts = { strategy, wave: args.wave, planPath: args.plan, sessionDir: args.sessionDir };
if (args.cmd === 'status') {
process.stdout.write(JSON.stringify(waveStatus(opts), null, 2) + '\n');
} else {
const { dir, orders } = writeWaveOrders(opts);
const lines = [
`Wave ${args.wave}: ${orders.length} session order(s) in ${dir}`,
'Voyage starts none of them. For each session, open Claude Code yourself in the',
'named directory and give it the starter line:',
'',
];
for (const o of orders) {
lines.push(` Session ${o.session} (${o.title})`, ` open in: ${o.openIn}`, ` starter: ${o.starter}`, '');
}
lines.push('When every session is done, run the same /trekexecute command again here to merge the wave.');
process.stdout.write(lines.join('\n') + '\n');
}
process.exit(0);
} catch (err) {
process.stderr.write(`session-orders: ${err.message}\n`);
process.exit(1);
}
}

View file

@ -1,540 +0,0 @@
#!/usr/bin/env node
// scripts/q3-cache-prefix-experiment.mjs
//
// Q3 cache-prefix-preservation experiment for Spor C of post-v3.4.0 roadmap.
// Measures whether CLAUDE_CODE_FORK_SUBAGENT=1 preserves the server-side
// cache prefix across multiple `claude -p` fork-children when all children
// spawn with byte-identical --allowedTools at 150-250K parent context.
//
// Brief: .claude/projects/2026-05-04-spor-c-q3-cache-prefix-experiment/brief.md
// Plan: .claude/projects/2026-05-04-spor-c-q3-cache-prefix-experiment/plan.md
//
// Result thresholds (master-plan):
// median(cache_creation_input_tokens) <= 1500 -> POSITIVE
// median >= 3500 -> NEGATIVE
// else -> INCONCLUSIVE
// Any per-child failure or missing metadata -> INCONCLUSIVE.
//
// Zero npm dependencies. Node stdlib only. Hook-safe (no forbidden words
// in source — pre-bash-executor.mjs scans the entire command string when
// this script is invoked).
import { spawn, spawnSync } from 'node:child_process';
import { readFileSync, readdirSync, statSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join, dirname, resolve } from 'node:path';
import { tmpdir } from 'node:os';
const PROJECT_DIR = resolve(
process.cwd(),
'.claude/projects/2026-05-04-spor-c-q3-cache-prefix-experiment',
);
const DEFAULT_OUT = join(PROJECT_DIR, 'q3-experiment-results.local.md');
const STATS_JSONL = '/Users/ktg/.claude/plugins/data/voyage-ktg-plugin-marketplace/trekexecute-stats.jsonl';
const ANALYZER = resolve(process.cwd(), 'lib/stats/cache-analyzer.mjs');
const MIN_PARENT_TOKENS = 150_000;
const MAX_PARENT_TOKENS = 250_000;
const POSITIVE_THRESHOLD = 1500;
const NEGATIVE_THRESHOLD = 3500;
const HARD_TIMEOUT_MS = 600_000; // 10 min total
const PER_CHILD_TIMEOUT_MS = 240_000; // 4 min per child
const MIN_CC_VERSION = [2, 1, 121];
const ALLOWED_TOOLS = 'Read,Write,Edit,Bash,Glob,Grep';
const MODEL = 'sonnet';
// Sources for parent context build. Brief constraint: no secrets, no ~/, no
// other plugins. Stays inside plugins/trekplan/.
//
// Calibration (empirical, CC v2.1.128 + Sonnet 4.6):
// Token-per-byte ratio varies from 0.38-0.90 depending on content type.
// Mixed .md+.mjs at 264K bytes yielded only ~60K context tokens (4.5 byte/token).
// To reliably hit 150K context tokens, target ~600-700K bytes of mixed content.
// Hooks baseline ~62K cache_creation always present, so total lands ~212-262K.
const CONTEXT_DIRS = [
'commands',
'agents',
'lib/parsers',
'lib/validators',
'lib/util',
'lib/review',
'lib/stats',
];
const CONTEXT_EXTRA_FILES = [
'docs/HANDOVER-CONTRACTS.md',
'CLAUDE.md',
'examples/02-real-cli/REGENERATED.md',
];
function usage() {
return `q3-cache-prefix-experiment.mjs — Q3 cache-prefix experiment harness
USAGE:
node scripts/q3-cache-prefix-experiment.mjs [--help] [--dry-run] [--out <path>]
FLAGS:
--help Print this usage block and exit 0.
--dry-run Build parent context, print child argv arrays + token-byte
estimate to stderr, do NOT call the API. No result file written.
--out <path> Write result file to <path>. Default:
${DEFAULT_OUT}
EXIT CODES:
0 Experiment completed (RESULT line written).
2 Hard timeout exceeded.
3 CC version too old or FORK_SUBAGENT warm-up failed -> INCONCLUSIVE.
4 Parent context out of 150K-250K band -> INCONCLUSIVE.
5 Child API metadata unavailable -> INCONCLUSIVE.
7 Usage / I/O error.
ENV:
ANTHROPIC_API_KEY must be set (read from operator env, not embedded).
`;
}
function parseArgs(argv) {
const opts = { help: false, dryRun: false, out: DEFAULT_OUT };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--help' || a === '-h') opts.help = true;
else if (a === '--dry-run') opts.dryRun = true;
else if (a === '--out') opts.out = argv[++i];
else {
process.stderr.write(`Unknown argument: ${a}\n${usage()}`);
process.exit(7);
}
}
return opts;
}
function log(msg) {
process.stderr.write(`[q3] ${msg}\n`);
}
function nowIso() {
return new Date().toISOString();
}
function listFilesRecursive(dir, ext) {
const out = [];
if (!existsSync(dir)) return out;
for (const ent of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, ent.name);
if (ent.isDirectory()) out.push(...listFilesRecursive(p, ext));
else if (ent.isFile() && (!ext || p.endsWith(ext))) out.push(p);
}
return out.sort(); // deterministic ordering
}
function buildParentContext() {
const parts = [];
const fileList = [];
for (const d of CONTEXT_DIRS) {
const files = [
...listFilesRecursive(d, '.mjs'),
...listFilesRecursive(d, '.md'),
].sort();
for (const f of files) {
if (existsSync(f)) {
try {
parts.push(`=== FILE: ${f} ===\n` + readFileSync(f, 'utf-8'));
fileList.push(f);
} catch { /* skip unreadable */ }
}
}
}
for (const f of CONTEXT_EXTRA_FILES) {
if (existsSync(f)) {
try {
parts.push(`=== FILE: ${f} ===\n` + readFileSync(f, 'utf-8'));
fileList.push(f);
} catch { /* skip */ }
}
}
const text = parts.join('\n\n');
const sha256 = createHash('sha256').update(text).digest('hex');
return { text, sha256, fileCount: fileList.length, byteLength: Buffer.byteLength(text, 'utf-8') };
}
function checkCcVersion() {
const r = spawnSync('claude', ['--version'], { encoding: 'utf-8', timeout: 10_000 });
if (r.status !== 0) {
return { ok: false, reason: `claude --version exit ${r.status}: ${r.stderr || r.stdout}` };
}
const m = (r.stdout || '').match(/(\d+)\.(\d+)\.(\d+)/);
if (!m) return { ok: false, reason: `cannot parse version from: ${r.stdout}` };
const got = [Number(m[1]), Number(m[2]), Number(m[3])];
for (let i = 0; i < 3; i++) {
if (got[i] > MIN_CC_VERSION[i]) return { ok: true, version: got.join('.') };
if (got[i] < MIN_CC_VERSION[i]) {
return {
ok: false,
reason: `CC ${got.join('.')} < required ${MIN_CC_VERSION.join('.')}`,
version: got.join('.'),
};
}
}
return { ok: true, version: got.join('.') };
}
function buildChildArgv(contextFilePath) {
// Byte-identical across all 3 children (SC #3). Per-child differentiation
// is via the user prompt suffix only, NOT via argv.
//
// Context is delivered via --append-system-prompt-file (NOT stdin) to:
// 1. avoid stdin pipe buffer issues at >200K bytes
// 2. ensure context is part of the cache-prefix segment
//
// --exclude-dynamic-system-prompt-sections moves cwd/env/git-status into
// the user message, preventing per-child variation in the cache prefix.
return [
'-p',
'--model', MODEL,
'--output-format', 'stream-json',
'--verbose',
'--allowedTools', ALLOWED_TOOLS,
'--max-turns', '1',
'--append-system-prompt-file', contextFilePath,
'--exclude-dynamic-system-prompt-sections',
];
}
function spawnChild(contextFilePath, childIndex) {
return new Promise((resolve) => {
const argv = buildChildArgv(contextFilePath);
// User prompt is short (per-child suffix only). Context lives in the
// appended system-prompt file, which Claude treats as cache-prefix
// material.
const prompt = `[child #${childIndex}] Reply only with the word OK.`;
const env = { ...process.env, CLAUDE_CODE_FORK_SUBAGENT: '1' };
const child = spawn('claude', argv, { env, stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
let killed = false;
const timer = setTimeout(() => {
killed = true;
child.kill('SIGTERM');
}, PER_CHILD_TIMEOUT_MS);
child.stdout.on('data', (b) => { stdout += b.toString('utf-8'); });
child.stderr.on('data', (b) => { stderr += b.toString('utf-8'); });
child.on('close', (code) => {
clearTimeout(timer);
resolve({ code, stdout, stderr, killed, argv: ['claude', ...argv] });
});
child.on('error', (err) => {
clearTimeout(timer);
resolve({ code: -1, stdout, stderr: stderr + `\nspawn error: ${err.message}`, killed, argv: ['claude', ...argv] });
});
child.stdin.write(prompt);
child.stdin.end();
});
}
function extractUsageFromStream(stdout) {
// First {"type":"assistant",...} JSON line carries the usage payload.
const lines = stdout.split('\n');
for (const line of lines) {
if (!line.startsWith('{')) continue;
try {
const obj = JSON.parse(line);
if (obj.type === 'assistant' && obj.message && obj.message.usage) {
return obj.message.usage;
}
// Fallback: top-level result event also carries usage.
if (obj.type === 'result' && obj.usage) {
return obj.usage;
}
} catch { /* skip non-JSON lines */ }
}
return null;
}
function median(values) {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
}
function decideResult(measurements, allValid) {
if (!allValid) return { result: 'INCONCLUSIVE', reason: 'one or more children failed or missing metadata' };
const ccs = measurements.map(m => m.cache_creation_input_tokens);
const med = median(ccs);
if (med === null) return { result: 'INCONCLUSIVE', reason: 'no measurements' };
if (med <= POSITIVE_THRESHOLD) return { result: 'POSITIVE', reason: `median cache_creation ${med} <= ${POSITIVE_THRESHOLD}`, median: med };
if (med >= NEGATIVE_THRESHOLD) return { result: 'NEGATIVE', reason: `median cache_creation ${med} >= ${NEGATIVE_THRESHOLD}`, median: med };
return { result: 'INCONCLUSIVE', reason: `median cache_creation ${med} in (${POSITIVE_THRESHOLD}, ${NEGATIVE_THRESHOLD})`, median: med };
}
function runAnalyzer() {
if (!existsSync(ANALYZER) || !existsSync(STATS_JSONL)) return null;
const r = spawnSync('node', [ANALYZER, '--json', STATS_JSONL], {
encoding: 'utf-8',
timeout: 30_000,
});
if (r.status !== 0) return null;
try { return JSON.parse(r.stdout); }
catch { return null; }
}
function writeResultFile(outPath, ctx, ccVersion, measurements, parentTokens, decision, analyzerSummary, runErrors) {
// ALWAYS write at least 30 lines + required strings (SC #6).
const dir = dirname(outPath);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const lines = [];
lines.push('# Q3 Cache-Prefix-Preservation Experiment — Results');
lines.push('');
lines.push(`Generated: ${nowIso()}`);
lines.push(`Brief: \`.claude/projects/2026-05-04-spor-c-q3-cache-prefix-experiment/brief.md\``);
lines.push(`Plan: \`.claude/projects/2026-05-04-spor-c-q3-cache-prefix-experiment/plan.md\``);
lines.push('');
lines.push('## Setup');
lines.push('');
lines.push(`- Claude Code version: ${ccVersion ?? 'unknown'}`);
lines.push(`- Model: ${MODEL}`);
lines.push(`- Allowed tools: ${ALLOWED_TOOLS}`);
lines.push(`- CLAUDE_CODE_FORK_SUBAGENT: 1 (set per-child via env)`);
lines.push(`- Children: 3 (sequential spawn)`);
lines.push('');
lines.push('## Parent context');
lines.push('');
lines.push(`- File count: ${ctx.fileCount}`);
lines.push(`- Byte length: ${ctx.byteLength}`);
lines.push(`- SHA-256: \`${ctx.sha256}\``);
lines.push(`- Measured input_tokens (pre-flight): ${parentTokens ?? 'N/A'}`);
lines.push(`- Target band: [${MIN_PARENT_TOKENS}, ${MAX_PARENT_TOKENS}]`);
lines.push('');
lines.push('## Per-child measurements');
lines.push('');
lines.push('| child | cache_creation | cache_read | input_tokens | output_tokens | argv_unique | exit |');
lines.push('|-------|----------------|------------|--------------|---------------|-------------|------|');
for (const m of measurements) {
lines.push(
`| ${m.child} | ${m.cache_creation_input_tokens ?? 'N/A'} | ${m.cache_read_input_tokens ?? 'N/A'} | ${m.input_tokens ?? 'N/A'} | ${m.output_tokens ?? 'N/A'} | ${m.argv_signature} | ${m.exit_code} |`,
);
}
lines.push('');
lines.push('## argv parity (SC #3)');
lines.push('');
const argvSet = new Set(measurements.map(m => m.argv_signature));
lines.push(`Unique argv signatures across children: ${argvSet.size} (expected: 1)`);
lines.push('');
lines.push('## Telemetry context');
lines.push('');
if (analyzerSummary) {
lines.push(`- total_events: ${analyzerSummary.total_events}`);
lines.push(`- wall_time_ms_p50: ${analyzerSummary.wall_time_ms_p50}`);
lines.push(`- wall_time_ms_p90: ${analyzerSummary.wall_time_ms_p90}`);
lines.push(`- oldest_event_iso: ${analyzerSummary.oldest_event_iso ?? 'N/A'}`);
lines.push(`- newest_event_iso: ${analyzerSummary.newest_event_iso ?? 'N/A'}`);
} else {
lines.push('- analyser unavailable or stats jsonl missing');
}
lines.push('');
if (runErrors.length > 0) {
lines.push('## Errors');
lines.push('');
for (const e of runErrors) lines.push(`- ${e}`);
lines.push('');
}
lines.push('## Conclusion');
lines.push('');
lines.push(`Reason: ${decision.reason}`);
if (decision.median !== undefined) lines.push(`Median cache_creation_input_tokens: ${decision.median}`);
lines.push('');
lines.push(`RESULT: ${decision.result}`);
lines.push('');
lines.push('## Path C decision (master-plan §Spor D direction)');
lines.push('');
if (decision.result === 'POSITIVE') {
lines.push('Path C is feasible. C3 should write a v3.5.0 brief proposing cache-warm sentinel + identical-tool parallel children.');
} else if (decision.result === 'NEGATIVE') {
lines.push('Path C is closed. C3 should update master-plan §Spor D = stabilisation work; v3.5.0 brief NOT written.');
} else {
lines.push('Path C decision deferred to operator. C3 documents the gap and proposes targeted follow-up before Spor D commits.');
}
lines.push('');
writeFileSync(outPath, lines.join('\n') + '\n', 'utf-8');
log(`wrote result file: ${outPath} (${lines.length} lines)`);
}
async function measureParentTokens(contextFilePath) {
// Fire one warm-up call to measure parent context size.
//
// CC's stream-json wrapper splits the prompt into:
// - input_tokens: only the non-cached portion (typically the latest turn)
// - cache_creation_input_tokens: tokens promoted to cache (the parent context)
// - cache_read_input_tokens: tokens served from cache (zero on first hit)
//
// Total parent context size = input_tokens + cache_creation + cache_read.
const argv = [
'-p',
'--model', MODEL,
'--output-format', 'stream-json',
'--verbose',
'--max-turns', '1',
'--append-system-prompt-file', contextFilePath,
'--exclude-dynamic-system-prompt-sections',
];
const env = { ...process.env, CLAUDE_CODE_FORK_SUBAGENT: '1' };
return new Promise((resolve) => {
const child = spawn('claude', argv, { env, stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
const timer = setTimeout(() => child.kill('SIGTERM'), 180_000);
child.stdout.on('data', (b) => { stdout += b.toString('utf-8'); });
child.stderr.on('data', (b) => { stderr += b.toString('utf-8'); });
child.on('close', (code) => {
clearTimeout(timer);
const usage = extractUsageFromStream(stdout);
if (!usage) {
log(`measureParentTokens: no usage extracted; exit=${code}; stderr (first 300): ${stderr.slice(0, 300)}`);
resolve(null);
return;
}
const total = (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0);
log(`measureParentTokens: input=${usage.input_tokens} cache_creation=${usage.cache_creation_input_tokens} cache_read=${usage.cache_read_input_tokens} total=${total}`);
resolve({ total, ...usage });
});
child.on('error', (e) => { clearTimeout(timer); log(`measureParentTokens spawn error: ${e.message}`); resolve(null); });
child.stdin.write('Reply only with the word OK.');
child.stdin.end();
});
}
async function main() {
const opts = parseArgs(process.argv.slice(2));
if (opts.help) {
process.stdout.write(usage());
process.exit(0);
}
const hardTimer = setTimeout(() => {
process.stderr.write('[q3] HARD TIMEOUT: 10 min exceeded, exit 2\n');
process.exit(2);
}, HARD_TIMEOUT_MS);
log(`starting at ${nowIso()}`);
// Build parent context first (works in dry-run too).
log('building parent context...');
const ctx = buildParentContext();
log(`context: ${ctx.fileCount} files, ${ctx.byteLength} bytes, sha256=${ctx.sha256.slice(0, 16)}`);
// Write parent context to a temp file (used as system-prompt-file for all
// 3 children + warm-up). Determinism check: SHA-256 already computed.
const contextFilePath = join(tmpdir(), `q3-parent-context-${process.pid}-${Date.now()}.txt`);
writeFileSync(contextFilePath, ctx.text, 'utf-8');
log(`wrote parent context to: ${contextFilePath}`);
// Print 3 child argvs for SC #3 verification.
const argvBase = buildChildArgv(contextFilePath);
log(`argv (identical for all 3 children):`);
log(` argv: ${JSON.stringify(['claude', ...argvBase])}`);
log(` "--allowedTools" "${ALLOWED_TOOLS}"`);
log(` "--allowedTools" "${ALLOWED_TOOLS}"`);
log(` "--allowedTools" "${ALLOWED_TOOLS}"`);
if (opts.dryRun) {
log('dry-run: skipping API calls.');
try { unlinkSync(contextFilePath); } catch {}
clearTimeout(hardTimer);
process.exit(0);
}
// Pre-flight: CC version (SC #2 part 1).
log('pre-flight: checking CC version...');
const verCheck = checkCcVersion();
if (!verCheck.ok) {
log(`CC version check FAILED: ${verCheck.reason}`);
const decision = { result: 'INCONCLUSIVE', reason: `CC version: ${verCheck.reason}` };
writeResultFile(opts.out, ctx, verCheck.version, [], null, decision, runAnalyzer(), [verCheck.reason]);
clearTimeout(hardTimer);
process.exit(3);
}
log(`CC version OK: ${verCheck.version}`);
// Pre-flight: parent token band (SC #4).
log('pre-flight: measuring parent context token count via warm-up...');
const measurement = await measureParentTokens(contextFilePath);
if (measurement === null) {
const decision = { result: 'INCONCLUSIVE', reason: 'pre-flight warm-up returned no usage metadata' };
writeResultFile(opts.out, ctx, verCheck.version, [], null, decision, runAnalyzer(), ['pre-flight failed']);
clearTimeout(hardTimer);
process.exit(3);
}
const parentTokens = measurement.total;
log(`parent total tokens: ${parentTokens} (input=${measurement.input_tokens} cache_creation=${measurement.cache_creation_input_tokens} cache_read=${measurement.cache_read_input_tokens})`);
if (parentTokens < MIN_PARENT_TOKENS || parentTokens > MAX_PARENT_TOKENS) {
const decision = {
result: 'INCONCLUSIVE',
reason: `parent context out of band: ${parentTokens} not in [${MIN_PARENT_TOKENS}, ${MAX_PARENT_TOKENS}]`,
};
writeResultFile(opts.out, ctx, verCheck.version, [], parentTokens, decision, runAnalyzer(), [decision.reason]);
clearTimeout(hardTimer);
process.exit(4);
}
// Run 3 children sequentially (avoids spawn-burst rate-limit).
const measurements = [];
const runErrors = [];
let allValid = true;
for (let i = 1; i <= 3; i++) {
log(`spawning child ${i}/3...`);
const r = await spawnChild(contextFilePath, i);
const usage = extractUsageFromStream(r.stdout);
const argvSig = JSON.stringify(r.argv);
if (r.code !== 0 || !usage || typeof usage.cache_creation_input_tokens !== 'number') {
allValid = false;
const err = `child ${i}: exit=${r.code}, killed=${r.killed}, usage=${usage ? 'partial' : 'missing'}`;
runErrors.push(err);
log(err);
if (r.stderr) log(` stderr (first 500 chars): ${r.stderr.slice(0, 500)}`);
}
measurements.push({
child: i,
cache_creation_input_tokens: usage?.cache_creation_input_tokens ?? null,
cache_read_input_tokens: usage?.cache_read_input_tokens ?? null,
input_tokens: usage?.input_tokens ?? null,
output_tokens: usage?.output_tokens ?? null,
argv_signature: argvSig,
exit_code: r.code,
});
log(` cache_creation=${usage?.cache_creation_input_tokens ?? 'N/A'} cache_read=${usage?.cache_read_input_tokens ?? 'N/A'}`);
}
// Decide result (SC #7).
const decision = decideResult(measurements, allValid);
log(`RESULT: ${decision.result} (${decision.reason})`);
// Run analyser for telemetry context (SC #8).
const analyzerSummary = runAnalyzer();
// Write result file (SC #6).
writeResultFile(opts.out, ctx, verCheck.version, measurements, parentTokens, decision, analyzerSummary, runErrors);
// Cleanup temp context file.
try { unlinkSync(contextFilePath); } catch {}
clearTimeout(hardTimer);
// Exit 0 even on INCONCLUSIVE — that's a valid outcome per brief NFR.
// Only exit non-zero on harness failures (already handled above).
process.exit(0);
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((e) => {
process.stderr.write(`[q3] uncaught: ${e.stack || e.message}\n`);
process.exit(7);
});
}

View file

@ -1,223 +0,0 @@
# Headless Launch Script Template
This template is used by the session-decomposer agent to generate a launch script
for headless execution of decomposed sessions.
## Template
```bash
#!/usr/bin/env bash
# Headless launch script — generated by trekplan
# Master plan: {plan_path}
# Generated: {date}
# Sessions: {total_sessions} ({parallel_count} parallel, {sequential_count} sequential)
set -euo pipefail
# Prevent accidental API billing — remove this line if you intend to use API credits
unset ANTHROPIC_API_KEY
REPO_ROOT="$(git rev-parse --show-toplevel)"
PLAN_DIR="{session_dir}"
LOG_DIR="{session_dir}/logs"
WORKTREE_BASE="{session_dir}/worktrees"
mkdir -p "$LOG_DIR" "$WORKTREE_BASE"
# Disable git's optional locks during parallel worktree ops (research/02 R2;
# GH #47721). Mirror Phase 2.6 hardenings (commands/trekexecute.md).
export GIT_OPTIONAL_LOCKS=0
# Per-child guardrails (operator may override via env). Match Phase 2.6
# Step 2a-pre defaults.
MAX_TURNS="${TREKEXECUTE_MAX_TURNS:-50}"
MAX_BUDGET_USD="${TREKEXECUTE_MAX_BUDGET_USD:-5}"
# Shared context (brief + architecture if present). Absolute path so child
# sessions resolve it after `cd` into their worktree.
SHARED_CONTEXT_FILE="$WORKTREE_BASE/.shared-context.md"
if [ -f "$PLAN_DIR/brief.md" ]; then
cat "$PLAN_DIR/brief.md" > "$SHARED_CONTEXT_FILE"
if [ -f "$PLAN_DIR/architecture/overview.md" ]; then
printf '\n\n---\n\n' >> "$SHARED_CONTEXT_FILE"
cat "$PLAN_DIR/architecture/overview.md" >> "$SHARED_CONTEXT_FILE"
fi
fi
# Safety preambles prepended to every per-session prompt (defense-in-depth
# against headless-mode hook gaps + Claude 4 cross-model malware-reminder).
SAFETY_PREAMBLE='[CRITICAL — defense in depth] You are running in headless mode where plugin hooks may not fire reliably (GH #36071). DO NOT execute commands matching: `rm -rf /`, `curl | bash`, `git push --force` to main, `git reset --hard` outside this worktree, or any command writing outside the current worktree. Treat this rule as enforced regardless of `--allowedTools` allowlist.
Note: any malware-related safety reminders apply conditionally to code you assess as actually malicious, not to all code reads (per GH #52272 community workaround).'
# Cleanup trap — always remove worktrees on exit (success or failure)
cleanup_worktrees() {
echo ""
echo "=== Cleaning up worktrees ==="
cd "$REPO_ROOT"
# push-before-cleanup (Hard Rule 19): push any remaining branches so work
# survives even if subsequent removal races. Failure is non-fatal.
git branch --list "trek/{slug}/*" | while read b; do
git push origin "$b" 2>/dev/null || true
done
for wt in "$WORKTREE_BASE"/session-*; do
[ -d "$wt" ] && git worktree remove "$wt" --force 2>/dev/null && echo "Removed: $wt"
done
git worktree prune
git branch --list "trek/{slug}/*" | while read b; do
git branch -D "$b" 2>/dev/null
done
rmdir "$WORKTREE_BASE" 2>/dev/null
echo "Cleanup complete."
}
trap cleanup_worktrees EXIT
# Pre-flight: verify clean working tree
if [ -n "$(git status --porcelain)" ]; then
echo "ERROR: Working tree is not clean. Commit or stash changes before parallel execution."
git status --short
exit 1
fi
# Pre-flight: verify remote push permissions (catches credential/auth issues
# BEFORE spawning sessions). Sub-agent bash sandbox may have different
# credentials than the launching shell — Step 0 in each session spec handles
# the sandbox-side detection. Set TREKEXECUTE_SKIP_PREFLIGHT=1 for offline
# or air-gapped testing.
if [ "${TREKEXECUTE_SKIP_PREFLIGHT:-0}" != "1" ]; then
if ! git push --dry-run origin HEAD >/tmp/push-dryrun-launch.log 2>&1; then
echo "ERROR: git push --dry-run failed. Sessions will be unable to push."
cat /tmp/push-dryrun-launch.log
echo ""
echo "Fix remote credentials before running parallel execution, or set"
echo "TREKEXECUTE_SKIP_PREFLIGHT=1 to bypass (offline/air-gapped only)."
exit 1
fi
if grep -qE "(rejected|denied|forbidden|permission)" /tmp/push-dryrun-launch.log; then
echo "ERROR: git push --dry-run reports rejection. Sessions will fail at commit time."
cat /tmp/push-dryrun-launch.log
exit 1
fi
fi
echo "=== Voyage Headless Execution (Worktree-Isolated) ==="
echo "Plan: {plan_path}"
echo "Sessions: {total_sessions}"
echo "Repo root: $REPO_ROOT"
echo ""
# --- Wave {N}: Parallel sessions (no dependencies) ---
echo "--- Wave {N}: {description} ---"
{# For each parallel session in this wave, create worktree: }
git worktree add -b "trek/{slug}/session-{n}" "$WORKTREE_BASE/session-{n}" HEAD
echo "Worktree created: session-{n} (branch: trek/{slug}/session-{n})"
{# Launch session in its worktree (with safety preamble + budget caps + shared context): }
cd "$WORKTREE_BASE/session-{n}" && claude -p "${SAFETY_PREAMBLE}
$(cat "$PLAN_DIR/session-{n}-{slug}.md")" \
--allowedTools "Read,Write,Edit,Bash,Glob,Grep" \
--permission-mode bypassPermissions \
--max-turns "$MAX_TURNS" \
--max-budget-usd "$MAX_BUDGET_USD" \
--append-system-prompt-file "$SHARED_CONTEXT_FILE" \
> "$LOG_DIR/session-{n}.log" 2>&1 &
PID_{n}=$!
cd "$REPO_ROOT"
echo "Started session {n}: {title} (PID $PID_{n})"
{# After all parallel sessions in this wave: }
echo "Waiting for Wave {N} to complete..."
wait $PID_{n1} $PID_{n2}
echo "Wave {N} complete."
echo ""
# --- Merge wave results (sequential) ---
echo "--- Merging Wave {N} ---"
cd "$REPO_ROOT"
{# For each session in the wave: push BEFORE merge (Hard Rule 19 — push-before-cleanup). }
git push origin "trek/{slug}/session-{n}" 2>/dev/null || true
git merge --no-ff "trek/{slug}/session-{n}" \
-m "merge: trekplan session {n} — {title}"
if [ $? -ne 0 ]; then
echo "MERGE CONFLICT: session {n}. Conflicting files:"
git diff --name-only --diff-filter=U
git merge --abort
echo "Aborting. Earlier sessions in this wave are already merged."
exit 1
fi
git worktree remove "$WORKTREE_BASE/session-{n}" --force
git branch -d "trek/{slug}/session-{n}"
echo "Merged and cleaned: session {n}"
git worktree prune
# --- Verify wave results ---
echo "--- Verifying Wave {N} ---"
{# For each session in the wave, run its exit condition commands }
{verify_commands}
# --- Wave {N+1}: Sequential sessions (depends on previous wave) ---
{# Repeat wave pattern for dependent sessions }
echo ""
echo "=== All sessions complete ==="
echo "Review logs in $LOG_DIR/"
echo "Run final verification: {final_verify_command}"
```
## Rules for the session-decomposer
When generating a launch script from this template:
1. **Group sessions into waves** by dependency. Sessions with no dependencies
or whose dependencies are all in earlier waves can run in the same wave.
2. **Each wave waits for completion** before the next wave starts.
3. **Verification runs after each wave** — if verification fails, the script
stops and reports which session failed.
4. **Log each session** to a separate file for debugging.
5. **Use `claude -p`** with the session spec file as the prompt.
6. **Use `--allowedTools "Read,Write,Edit,Bash,Glob,Grep"`** with
`--permission-mode bypassPermissions` for child sessions. This limits the
tool surface to what the executor needs and prevents agent spawning, MCP
access, and external web requests in headless sessions.
7. **Final verification** at the end runs the master plan's verification section.
8. **Never include secrets** in the generated script.
9. **Wave verification must be independent.** After each wave completes, run
verification commands fresh via Bash — never parse session log files as proof
of success. Log files contain executor self-reporting, not ground truth. The
command's exit code is the only authoritative verification signal.
10. **Billing preamble.** Prepend `unset ANTHROPIC_API_KEY` with a comment at
the top of the script to prevent accidental API billing. Users who intend
to use API credits can remove this line.
11. **Worktree isolation is mandatory.** Every parallel wave MUST use git
worktrees. Each session gets its own worktree and branch. Never launch
parallel `claude -p` sessions in the same working directory.
12. **Cleanup trap on EXIT.** The generated script MUST include a `trap` on
EXIT that removes all worktrees (`git worktree remove --force`) and prunes
branches, even if the script fails or is interrupted.
13. **Sequential merge after each wave.** After all sessions in a wave complete,
merge their branches back to the main branch one at a time. Abort on merge
conflict — do not force-resolve.
14. **Clean working tree before worktrees.** Add a `git status --porcelain`
check at the top of the script. Fail if the working tree is dirty.
15. **Absolute paths for logs.** Log file paths must be absolute (resolved from
`$REPO_ROOT`), not relative to any worktree.
16. **Per-child guardrails (mirrors Phase 2.6 Step 2b).** Every `claude -p`
invocation must include `--max-turns "$MAX_TURNS"`,
`--max-budget-usd "$MAX_BUDGET_USD"`, and
`--append-system-prompt-file "$SHARED_CONTEXT_FILE"`. The shared context
must be built once with an absolute path (resolved from `$WORKTREE_BASE`)
so child sessions can read it after `cd`.
17. **Safety preamble.** Every per-session prompt must be prefixed with the
`$SAFETY_PREAMBLE` string defined at the top of the script. This is the
primary defense when plugin hooks do not fire reliably (GH #36071), and
includes the GH #52272 malware-reminder clarification for AUTO mode.
18. **GIT_OPTIONAL_LOCKS=0.** The script must export `GIT_OPTIONAL_LOCKS=0`
once at the top so every git invocation (worktree add/remove/prune,
branch -d, merge, push) avoids the index.lock background-poll race
(research/02 R2; GH #47721).
19. **push-before-cleanup (Hard Rule 19).** After successful `git merge --no-ff`,
run `git push origin <branch>` BEFORE `git worktree remove` and
`git branch -d`. Push failure is non-fatal — cleanup proceeds. Converts
unrecoverable branch loss into recoverable remote state (research/02 R3).

50
tests/fixtures/session-orders/plan.md vendored Normal file
View file

@ -0,0 +1,50 @@
---
plan_version: "1.7"
slug: demo
---
# Plan: demo (fixture for session orders)
## Implementation Plan
### Step 1: Add the parser
### Step 2: Add the formatter
### Step 3: Add the CLI
### Step 4: Wire the plugin subdir
### Step 5: Integrate
## Execution Strategy
### Session 1: Parser
- **Steps:** 1, 2
- **Wave:** 1
- **Depends on:** none
- **Scope fence:**
- Touch: lib/parser.mjs, lib/format.mjs
- Never touch: lib/cli.mjs, plugins/demo/
### Session 2: Plugin subdir
- **Steps:** 3–4
- **Wave:** 1
- **Depends on:** none
- **cwd:** plugins/demo
- **Scope fence:**
- Touch: lib/cli.mjs, plugins/demo/
- Never touch: lib/parser.mjs, lib/format.mjs
### Session 3: Integration
- **Steps:** 5
- **Wave:** 2
- **Depends on:** Session 1, Session 2
- **Scope fence:**
- Touch: README.md
- Never touch: lib/
### Execution Order
- **Wave 1:** Session 1, Session 2 (parallel)
- **Wave 2:** Session 3 (after Wave 1)
## Verification
- `node --test`

View file

@ -277,24 +277,24 @@ test('rule-catalogue has exactly 12 entries', async () => {
); );
}); });
test('headless-launch-template.md mirrors Phase 2.6 hardenings', () => { test('Phase 2.6 hands waves over as session orders; no launch template ships', () => {
const tpl = read('templates/headless-launch-template.md'); // The coupling pin for the PM way: the prose must call the order writer and
// the status reader (tests/lib/session-orders.test.mjs proves they work), keep
// push-before-cleanup, and carry the never-start rule.
assert.ok(!existsSync(join(ROOT, 'templates', 'headless-launch-template.md')),
'templates/headless-launch-template.md must stay removed');
const exec = read('commands/trekexecute.md');
const p26 = exec.slice(exec.indexOf('## Phase 2.6'), exec.indexOf('## Phase 3 '));
for (const needle of [ for (const needle of [
'lib/util/session-orders.mjs" write',
'lib/util/session-orders.mjs" status',
'GIT_OPTIONAL_LOCKS', 'GIT_OPTIONAL_LOCKS',
'--max-turns',
'--max-budget-usd',
'--append-system-prompt-file',
'SHARED_CONTEXT_FILE',
'SAFETY_PREAMBLE',
'git push origin', 'git push origin',
'GH #36071', 'Voyage never starts a Claude Code',
'push-before-cleanup',
]) { ]) {
assert.ok( assert.ok(p26.includes(needle), `trekexecute.md Phase 2.6 must include "${needle}"`);
tpl.includes(needle),
`templates/headless-launch-template.md should include "${needle}" (Step 10 mirrors Phase 2.6)`,
);
} }
assert.match(exec, /21\. \*\*Never start Claude Code\.\*\*/, 'trekexecute.md Hard rule 21 missing');
}); });
test('Phase 9 prose mandates parallel single-message dispatch + inline dedup', () => { test('Phase 9 prose mandates parallel single-message dispatch + inline dedup', () => {

View file

@ -0,0 +1,142 @@
// tests/lib/session-orders.test.mjs
// /trekexecute's multi-session path turns each wave into orders plus starters
// and stops: the user opens every session themselves. Voyage starts nothing.
// These tests run the module and its CLI on a small fixture plan and prove
// both halves: the orders land, and no process is started.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync, chmodSync, cpSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import {
parseExecutionStrategy, buildWaveOrders, progressFileFor, waveStatus,
} from '../../lib/util/session-orders.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const MODULE = join(ROOT, 'lib', 'util', 'session-orders.mjs');
const FIXTURE = join(ROOT, 'tests', 'fixtures', 'session-orders', 'plan.md');
const PLAN_REL = '.claude/plans/demo.md';
function withRepo(fn) {
const root = mkdtempSync(join(tmpdir(), 'session-orders-'));
try {
mkdirSync(join(root, '.claude', 'plans'), { recursive: true });
cpSync(FIXTURE, join(root, PLAN_REL));
return fn(root);
} finally {
rmSync(root, { recursive: true, force: true });
}
}
test('parseExecutionStrategy reads sessions, waves, step ranges, dependencies and cwd', () => {
const s = parseExecutionStrategy(readFileSync(FIXTURE, 'utf-8'));
assert.deepEqual(s.waves, [1, 2]);
assert.deepEqual(s.sessions.map(x => [x.n, x.title, x.wave, x.steps, x.dependsOn, x.cwd]), [
[1, 'Parser', 1, [1, 2], [], '.'],
[2, 'Plugin subdir', 1, [3, 4], [], 'plugins/demo'],
[3, 'Integration', 2, [5], [1, 2], '.'],
]);
});
test('parseExecutionStrategy refuses a plan without a strategy, and a session without a wave', () => {
assert.throws(() => parseExecutionStrategy('# Plan\n\n## Verification\n'), /no ## Execution Strategy/);
assert.throws(
() => parseExecutionStrategy('## Execution Strategy\n\n### Session 1: X\n- **Steps:** 1\n'),
/Session 1 has no Wave/,
);
});
test('buildWaveOrders gives one order per session in the wave, each with its own worktree and a starter', () => {
const s = parseExecutionStrategy(readFileSync(FIXTURE, 'utf-8'));
const orders = buildWaveOrders({ strategy: s, wave: 1, planPath: PLAN_REL, sessionDir: '/r/.claude/trekplan-sessions/demo' });
assert.equal(orders.length, 2);
assert.deepEqual(orders.map(o => o.starter), [
`/trekexecute --session 1 ${PLAN_REL}`,
`/trekexecute --session 2 ${PLAN_REL}`,
]);
assert.deepEqual(orders.map(o => o.openIn), [
'/r/.claude/trekplan-sessions/demo/worktrees/session-1',
'/r/.claude/trekplan-sessions/demo/worktrees/session-2/plugins/demo',
]);
assert.equal(new Set(orders.map(o => o.worktreePath)).size, 2, 'two sessions never share a worktree');
for (const o of orders) {
assert.match(o.body, /Voyage does not start this session/);
assert.ok(o.body.includes(o.starter));
assert.ok(o.body.includes(o.progressFile));
}
assert.throws(() => buildWaveOrders({ strategy: s, wave: 3, planPath: PLAN_REL, sessionDir: '/r' }), /no sessions in wave 3/);
assert.throws(() => buildWaveOrders({ strategy: s, wave: 1, planPath: '/abs/plan.md', sessionDir: '/r' }), /repo-relative/);
});
test('the module cannot start a process: it imports nothing that could', () => {
const src = readFileSync(MODULE, 'utf-8');
assert.doesNotMatch(src, /child_process|worker_threads|\bspawn\(|\bexec(File)?(Sync)?\(/);
});
test('CLI on the fixture: wave 1 writes two orders and wave.json, prints the starters, and starts nothing', () => {
withRepo((root) => {
// Trap: a `claude` first on PATH that leaves a marker if anything runs it.
const bin = join(root, 'trap-bin');
const marker = join(root, 'claude-was-started');
mkdirSync(bin);
writeFileSync(join(bin, 'claude'), `#!/bin/sh\ntouch "${marker}"\n`);
chmodSync(join(bin, 'claude'), 0o755);
const sessionDir = join(root, '.claude', 'trekplan-sessions', 'demo');
const r = spawnSync(process.execPath, [MODULE, 'write', '--plan', PLAN_REL, '--wave', '1', '--session-dir', sessionDir], {
cwd: root, encoding: 'utf-8', env: { ...process.env, PATH: `${bin}:${process.env.PATH}` },
});
assert.equal(r.status, 0, r.stderr);
assert.equal(existsSync(marker), false, 'nothing may start Claude Code');
const ordersDir = join(sessionDir, 'orders');
assert.deepEqual(readdirSync(ordersDir).sort(), ['wave-1-session-1.md', 'wave-1-session-2.md', 'wave.json']);
const wave = JSON.parse(readFileSync(join(ordersDir, 'wave.json'), 'utf-8'));
assert.deepEqual([wave.wave, wave.sessions, wave.status, wave.plan], [1, [1, 2], 'awaiting-sessions', PLAN_REL]);
assert.match(r.stdout, /\/trekexecute --session 1 \.claude\/plans\/demo\.md/);
assert.match(r.stdout, /\/trekexecute --session 2 \.claude\/plans\/demo\.md/);
assert.match(r.stdout, /Voyage starts none of them/);
});
});
test('CLI status: pending until each session writes its progress file, then completed', () => {
withRepo((root) => {
const sessionDir = join(root, '.claude', 'trekplan-sessions', 'demo');
const run = (...a) => spawnSync(process.execPath, [MODULE, ...a, '--plan', PLAN_REL, '--session-dir', sessionDir], { cwd: root, encoding: 'utf-8' });
assert.equal(run('write', '--wave', '1').status, 0);
let st = JSON.parse(run('status', '--wave', '1').stdout);
assert.deepEqual(st.sessions.map(x => x.status), ['pending', 'pending']);
assert.equal(st.all_completed, false);
const s = parseExecutionStrategy(readFileSync(FIXTURE, 'utf-8'));
const put = (n, status) => {
const f = progressFileFor({ strategy: s, n, planPath: PLAN_REL, sessionDir });
mkdirSync(dirname(f), { recursive: true });
writeFileSync(f, JSON.stringify({ status }));
};
put(1, 'completed');
put(2, 'failed');
st = JSON.parse(run('status', '--wave', '1').stdout);
assert.deepEqual(st.sessions.map(x => x.status), ['completed', 'failed']);
assert.equal(st.all_completed, false);
put(2, 'completed');
st = waveStatus({ strategy: s, wave: 1, planPath: PLAN_REL, sessionDir });
assert.equal(st.all_completed, true);
});
});
test('CLI refuses a plan without an Execution Strategy, loudly', () => {
withRepo((root) => {
writeFileSync(join(root, PLAN_REL), '# Plan\n\n## Verification\n');
const r = spawnSync(process.execPath, [MODULE, 'write', '--plan', PLAN_REL, '--wave', '1', '--session-dir', join(root, 's')], { cwd: root, encoding: 'utf-8' });
assert.equal(r.status, 1);
assert.match(r.stderr, /no ## Execution Strategy/);
});
});