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:
parent
bc652ec7d3
commit
219e40cd91
18 changed files with 664 additions and 1108 deletions
|
|
@ -13,7 +13,7 @@ body:
|
|||
id: claude-version
|
||||
attributes:
|
||||
label: Claude Code version
|
||||
description: Output of `claude --version`
|
||||
description: The Claude Code version you ran
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ This is a solo project. Issues are welcome. PRs may be considered but are not ex
|
|||
|
||||
Open an issue with:
|
||||
- Plugin version (from `.claude-plugin/plugin.json`)
|
||||
- Claude Code version (`claude --version`)
|
||||
- Claude Code version
|
||||
- What you did, what you expected, what happened instead
|
||||
- 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/spec-template.md` | Spec file format |
|
||||
| `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.
|
||||
|
||||
## Testing locally
|
||||
|
||||
```bash
|
||||
claude --plugin-dir /path/to/trekplan
|
||||
# Then in the session:
|
||||
Start a Claude Code session yourself with this checkout loaded as a local
|
||||
plugin (Claude Code's plugin-directory option; see its plugin docs). Voyage
|
||||
never starts a session for you. Then, in that session:
|
||||
|
||||
```
|
||||
/trekresearch <research question>
|
||||
/trekplan <describe a task>
|
||||
/trekexecute <path to plan>
|
||||
|
|
|
|||
72
README.md
72
README.md
|
|
@ -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
|
||||
- The implementation has non-obvious dependencies, ordering constraints, or risks
|
||||
- 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
|
||||
|
||||
**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` |
|
||||
| **Plan path** | `/trekexecute plan.md` | Execute a specific plan file |
|
||||
| **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 |
|
||||
| **Single step** | `/trekexecute --project <dir> --step 3` | Execute only step 3 |
|
||||
| **Foreground** | `/trekexecute --project <dir> --fg` | Force sequential, ignore Execution Strategy |
|
||||
| **Single session** | `/trekexecute --project <dir> --session 2` | Execute only session 2 from Execution Strategy |
|
||||
| **Profile** | `/trekexecute --profile <name> --project <dir>` | (v4.1.0) Pin model profile for execution. Plan-frontmatter `profile:` is honored when this flag is omitted. See [Profile system](#profile-system-v410). |
|
||||
|
||||
### 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
|
||||
2. **Creates git worktrees** — each parallel session gets its own isolated worktree and branch (`trek/{slug}/session-{N}`)
|
||||
3. Launches `claude -p` per session per wave, each in its own worktree
|
||||
4. **Merges branches back** sequentially with `--no-ff` after each wave completes
|
||||
5. **Cleans up** worktrees and branches unconditionally (even on failure)
|
||||
6. Runs master verification on the merged result
|
||||
2. **Creates git worktrees** — each session in the wave gets its own isolated worktree and branch (`trek/{slug}/session-{N}`)
|
||||
3. **Writes a session order per session** — the worktree to open, the steps, the done-condition, and a one-line starter (`/trekexecute --session N <plan>`) — then stops
|
||||
4. **You open each session yourself** in the directory its order names and give it the starter
|
||||
5. **Run the same `/trekexecute` command again** — it reads every session's progress file, merges the branches back sequentially with `--no-ff`, cleans up, and writes the next wave's orders
|
||||
6. After the last wave: master verification on the merged result; worktrees and branches are cleaned up unconditionally (even on failure)
|
||||
|
||||
```
|
||||
Wave 1: Session 1 (worktree-1) + Session 2 (worktree-2) -- parallel
|
||||
↓ both complete → sequential merge to main
|
||||
Wave 2: Session 3 (worktree-3) -- sequential
|
||||
↓ complete → merge to main
|
||||
Wave 1 orders: Session 1 (worktree-1) + Session 2 (worktree-2) -- you run them, in parallel
|
||||
↓ re-run /trekexecute: both complete → sequential merge to main
|
||||
Wave 2 orders: Session 3 (worktree-3)
|
||||
↓ re-run /trekexecute: complete → merge to main
|
||||
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.
|
||||
|
||||
Use `--fg` to force sequential execution 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.
|
||||
Use `--fg` to run every step sequentially in the current session instead, even when a plan has an Execution Strategy.
|
||||
|
||||
### Failure recovery
|
||||
|
||||
|
|
@ -356,12 +345,11 @@ If no API key is set, parallel sessions use your subscription and proceed withou
|
|||
|
||||
### 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
|
||||
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
|
||||
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+)
|
||||
|
||||
|
|
@ -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
|
||||
[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
|
||||
- **Progress file** — crash recovery via `{project_dir}/progress.json` (or `.trekexecute-progress-{slug}.json` for legacy plans)
|
||||
- **Scope fence enforcement** — never touches files outside the session's scope
|
||||
- **JSON summary** — machine-parseable `trekexecute_summary` block 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+)
|
||||
|
||||
A `UserPromptSubmit` hook (`hooks/scripts/session-title.mjs`) sets the
|
||||
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:
|
||||
|
||||
| Invocation | Session title |
|
||||
|
|
@ -669,7 +641,7 @@ flowchart TB
|
|||
PLG --> PLS --> PLC
|
||||
end
|
||||
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
|
||||
subgraph RV["/trekreview"]
|
||||
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:**
|
||||
```bash
|
||||
/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
|
||||
```
|
||||
|
||||
|
|
@ -891,7 +863,7 @@ synthesis.
|
|||
The command orchestrators pin no model: `/trekbrief`, `/trekresearch`,
|
||||
`/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
|
||||
(`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
|
||||
sites: brief `phase_signals` first, then the profile's `phase_models`, with the
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ questions, which defeats the purpose.
|
|||
(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
|
||||
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
|
||||
(not blockers — the plan is still valid for interactive use, but it
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ name: session-decomposer
|
|||
description: |
|
||||
Use this agent to decompose an trekplan into self-contained headless 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
|
||||
color: green
|
||||
tools: ["Read", "Glob", "Grep", "Write"]
|
||||
|
|
@ -166,8 +166,8 @@ The Step 0 block to prepend verbatim:
|
|||
```
|
||||
→ expected: non-77 exit code
|
||||
- **On failure:** `escalate` — exit code 77 means this sandbox cannot push.
|
||||
Abort immediately; do not attempt any work. Main orchestrator will
|
||||
re-spawn with correct permissions.
|
||||
Abort immediately; do not attempt any work. Fix the sandbox's push
|
||||
permission, then rerun this session.
|
||||
- **Checkpoint:** none (no file changes)
|
||||
- **Manifest:**
|
||||
```yaml
|
||||
|
|
@ -218,28 +218,22 @@ graph LR
|
|||
| 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. Group sessions into waves matching the dependency graph
|
||||
2. Launch parallel sessions in each wave using `claude -p "$(cat session-file.md)"`
|
||||
3. Wait for all sessions in a wave before starting the next wave
|
||||
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
|
||||
1. Its spec file (`{output_dir}/session-{N}-{slug}.md`)
|
||||
2. Its starter line, which the user gives a Claude Code session they open
|
||||
themselves: `/trekexecute {output_dir}/session-{N}-{slug}.md`
|
||||
3. What it depends on, and its exit condition
|
||||
|
||||
**Important script conventions:**
|
||||
- Use `#!/usr/bin/env bash` shebang
|
||||
- Use `set -euo pipefail`
|
||||
- Each `claude -p` invocation must use `--allowedTools "Read,Write,Edit,Bash,Glob,Grep"`
|
||||
and `--permission-mode bypassPermissions`. Prepend `unset ANTHROPIC_API_KEY`
|
||||
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
|
||||
State the two rules at the top of the file: a wave starts only after every
|
||||
session of the previous wave met its exit condition; and sessions of the same
|
||||
wave run in parallel only in separate git worktrees (never two in one working
|
||||
directory) — run sequentially, one after another in the main checkout, is
|
||||
always safe. End with the master plan's overall verification commands.
|
||||
|
||||
### Step 8 — Write the summary
|
||||
|
||||
|
|
@ -271,7 +265,7 @@ Output a structured summary:
|
|||
|
||||
- Session specs: `{output_dir}/session-*.md`
|
||||
- Dependency graph: `{output_dir}/dependency-graph.md`
|
||||
- Launch script: `{output_dir}/launch.sh`
|
||||
- Session orders: `{output_dir}/orders.md`
|
||||
|
||||
### Final verification
|
||||
|
||||
|
|
|
|||
|
|
@ -894,8 +894,6 @@ topic.
|
|||
> levels deep), so a delegated redesign is under evaluation (see
|
||||
> `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.
|
||||
> For true parallel execution, use `claude -p` invocations in separate
|
||||
> terminal windows.
|
||||
|
||||
### Step 6c — Verify all briefs landed
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
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]"
|
||||
allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion
|
||||
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
|
||||
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
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
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)
|
||||
|
||||
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:
|
||||
```
|
||||
|
|
@ -508,91 +499,59 @@ After all 4 checks pass:
|
|||
Pre-flight: PASS (clean tree, plan reaches worktrees, no overlaps, no stale worktrees)
|
||||
```
|
||||
|
||||
## Phase 2.6 — Multi-session orchestration (worktree-isolated)
|
||||
## Phase 2.6 — Multi-session orchestration (worktree-isolated, session orders)
|
||||
|
||||
**Only runs for multi-session execution.** This phase creates isolated git
|
||||
worktrees for each parallel session, launches headless child sessions in their
|
||||
own worktrees, merges results back sequentially, and cleans up. After this
|
||||
phase, jump directly to Phase 8 (final report).
|
||||
**Only runs for multi-session execution.** Voyage never starts a Claude Code
|
||||
session. This phase prepares ONE wave at a time: it creates an isolated git
|
||||
worktree for each session in the wave, writes a **session order** for each
|
||||
(the worktree, the steps, the done-condition) plus a one-line **starter**, and
|
||||
stops. The user opens each session themselves, in the directory the order
|
||||
names, and gives it the starter. Running the same `/trekexecute` command again
|
||||
in the main checkout collects the wave: it reads every session's progress file,
|
||||
merges the session branches back sequentially, cleans up, and prepares the next
|
||||
wave. After the last wave, jump to Phase 8 (final report).
|
||||
|
||||
**CRITICAL SAFETY RULE:** Every parallel `claude -p` session MUST run in its own
|
||||
git worktree. Never launch two sessions in the same working directory. This rule
|
||||
exists because parallel git operations in a shared worktree cause index corruption,
|
||||
race conditions, and repository destruction.
|
||||
**CRITICAL SAFETY RULE:** Every session MUST run in its own git worktree. Never
|
||||
run two sessions in the same working directory. This rule exists because
|
||||
parallel git operations in a shared worktree cause index corruption, race
|
||||
conditions, and repository destruction. Each order names its own worktree.
|
||||
|
||||
### Step 0 — Billing safety check (MANDATORY)
|
||||
|
||||
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
|
||||
### Step 0 — Resolve paths and detect a wave in flight
|
||||
|
||||
```bash
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
SESSION_DIR="$REPO_ROOT/.claude/trekplan-sessions/{slug}"
|
||||
WORKTREE_DIR="$SESSION_DIR/worktrees"
|
||||
LOG_DIR="$SESSION_DIR/logs"
|
||||
mkdir -p "$WORKTREE_DIR" "$LOG_DIR"
|
||||
ORDERS_DIR="$SESSION_DIR/orders"
|
||||
ORIGINAL_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
|
||||
# Resolve the plugin root ONCE (same reason as Phase 7: the variable is
|
||||
# substituted in this command's text but EMPTY in the Bash tool's env).
|
||||
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
|
||||
in subsequent steps. All paths must be absolute.
|
||||
Record `REPO_ROOT`, `SESSION_DIR`, `WORKTREE_DIR`, `ORDERS_DIR`, `VOYAGE_ROOT`
|
||||
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):
|
||||
|
||||
**2a-pre. Build shared context + budget caps (runs once per wave, before
|
||||
worktree creation):**
|
||||
### Step 1 — Create worktrees for wave W
|
||||
|
||||
```bash
|
||||
WORKTREE_BASE="$(realpath "${WORKTREE_BASE:-${SESSION_DIR:-$REPO_ROOT}/worktrees}")"
|
||||
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}"
|
||||
mkdir -p "$WORKTREE_DIR" "$ORDERS_DIR"
|
||||
```
|
||||
|
||||
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:**
|
||||
|
||||
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
|
||||
`.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.
|
||||
|
||||
Insert this block AFTER the worktree-creation loop and BEFORE wave dispatch
|
||||
(Step 2b):
|
||||
Insert this block AFTER the worktree-creation loop and BEFORE the orders are
|
||||
written (Step 2):
|
||||
|
||||
```bash
|
||||
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
|
||||
failure is **silent** — the command substitution leaves `PROJECT_REL` empty, so
|
||||
`mkdir -p "$wt/"` and `cp ... "$wt//"` both succeed and drop `brief.md`/`plan.md`
|
||||
at the worktree root, where no 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
|
||||
-- -`, while bare `realpath <path>` works; a whole wave ran with zero steps
|
||||
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
|
||||
before anything is copied — better a loud stop than files delivered where no
|
||||
session reads them; any `cp` failure exits the wave non-zero, reported via
|
||||
Step 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.
|
||||
|
||||
**2b. Launch sessions in this wave (each in its own worktree):**
|
||||
|
||||
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:**
|
||||
### Step 2 — Write the session orders for wave W, then stop
|
||||
|
||||
```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
|
||||
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"`)
|
||||
Show the tool's output to the user verbatim, then report and **stop**:
|
||||
|
||||
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.
|
||||
Open each session yourself in the directory its order names and give it the
|
||||
starter line. When every session is done, run this same /trekexecute command
|
||||
again here to merge the wave.
|
||||
```
|
||||
|
||||
This run ends here. It writes no Phase 8 report: the plan is not finished, and
|
||||
the orders are the hand-over.
|
||||
|
||||
### Step 3 — Collect wave W (a later run)
|
||||
|
||||
```bash
|
||||
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 log: {LOG_DIR}/session-{N}.log
|
||||
See its order: {ORDERS_DIR}/wave-{W}-session-{N}.md
|
||||
```
|
||||
Do NOT merge. Do NOT start later waves. Jump to Step 4 (cleanup), then Phase 8.
|
||||
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.
|
||||
|
||||
If all sessions in the wave passed: continue to Step 2e.
|
||||
|
||||
**2e. Merge session branches back (SEQUENTIAL, one at a time):**
|
||||
**Merge session branches back (SEQUENTIAL, one at a time):**
|
||||
|
||||
Return to the main worktree:
|
||||
```bash
|
||||
|
|
@ -781,13 +714,13 @@ Wave {W} MERGE CONFLICT: Session {N} branch conflicts with merged state.
|
|||
Conflicting files:
|
||||
{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.
|
||||
```
|
||||
|
||||
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:
|
||||
```bash
|
||||
|
|
@ -796,21 +729,26 @@ for each session N in the wave:
|
|||
GIT_OPTIONAL_LOCKS=0 git branch -d "trek/{slug}/session-{N}"
|
||||
done
|
||||
GIT_OPTIONAL_LOCKS=0 git worktree prune
|
||||
rm -f "$ORDERS_DIR/wave.json"
|
||||
```
|
||||
|
||||
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`
|
||||
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
|
||||
merge conflict. It is the worktree equivalent of a `finally` block.
|
||||
This step MUST execute whenever this phase ends the run with a failure, a merge
|
||||
conflict, or after the last wave — never while a wave is awaiting its sessions
|
||||
(Step 2 and a `pending` Step 3 stop without it). It is the worktree equivalent
|
||||
of a `finally` block.
|
||||
|
||||
```bash
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
|
|
@ -841,9 +780,9 @@ Warning: failed to remove worktree {path}. Manual cleanup:
|
|||
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
|
||||
|
||||
|
|
@ -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
|
||||
6. Report results for those steps only
|
||||
|
||||
This mode is used internally by Phase 2.6 when launching child sessions.
|
||||
It can also be used manually to re-run a specific session.
|
||||
This mode is what each session order's starter runs, in a session the user
|
||||
opened in that order's worktree. It can also be used manually to re-run a
|
||||
specific session.
|
||||
|
||||
When `--session N` is invoked inside a git worktree (as done by Phase 2.6), all
|
||||
git operations (add, commit) apply to the worktree's branch. The session does not
|
||||
need to know it is in a worktree — git handles this transparently.
|
||||
When `--session N` runs inside a git worktree (as each session order sets up),
|
||||
all git operations (add, commit) apply to the worktree's branch. The session does
|
||||
not need to know it is in a worktree — git handles this transparently.
|
||||
|
||||
## Phase 3 — Progress file setup
|
||||
|
||||
|
|
@ -1010,16 +950,11 @@ If `has_execution_strategy = true`, show a preview of multi-session orchestratio
|
|||
```
|
||||
**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 2: {title} | {nums} | none | `claude -p "/trekexecute --session 2 {path}"` |
|
||||
| 2 | Session 3: {title} | {nums} | S1, S2 | `claude -p "/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}
|
||||
| 1 | Session 1: {title} | {nums} | none | `/trekexecute --session 1 {path}` |
|
||||
| 1 | Session 2: {title} | {nums} | none | `/trekexecute --session 2 {path}` |
|
||||
| 2 | Session 3: {title} | {nums} | S1, S2 | `/trekexecute --session 3 {path}` |
|
||||
```
|
||||
|
||||
### Verdict
|
||||
|
|
@ -1245,7 +1180,7 @@ depends on what was executed:
|
|||
Phase 4's entry-condition skip for trekplans stands — a plan carries no entry
|
||||
condition. The exit side is not symmetrical: a trekplan's `## Verification`
|
||||
section is where the brief's success criteria land, and it used to run only on
|
||||
the multi-session wave path (Phase 2.6 Step 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
|
||||
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).
|
||||
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
|
||||
cd "$WORKTREE_PATH" && claude -p "/trekexecute --session {N} {recovery spec path}" \
|
||||
--allowedTools "Read,Write,Edit,Bash,Glob,Grep" \
|
||||
--permission-mode bypassPermissions \
|
||||
> "$LOG_DIR/session-{N}-recovery-{depth}.log" 2>&1
|
||||
Report the recovery spec as a session order and stop the run as `partial`:
|
||||
|
||||
```
|
||||
Recovery order: session {N}, depth {depth}
|
||||
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
|
||||
7.5 audit one more time. If it still drifts at `recovery_depth=2`:
|
||||
On that later run, re-run the Phase 7.5 audit one more time. If it still
|
||||
drifts at `recovery_depth=2`:
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
Every recovery/retry loop in this executor has a documented numeric bound.
|
||||
These are **three distinct axes** — not one nested counter — plus a separate
|
||||
child turn cap:
|
||||
These are **three distinct axes** — not one nested counter:
|
||||
|
||||
- **Per-step retry cap** — `attempts ≤ 3` (initial + **maximum 2 retries**;
|
||||
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
|
||||
on every recovery/retry iteration; reconciled at the Phase 7.5 completion
|
||||
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
|
||||
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
|
||||
`--max-turns` (50) — the axes are incomparable. Operators may override:
|
||||
starting point for a tighter backstop. Operators may override:
|
||||
`TREKEXECUTE_MAX_RECOVERY_ITERATIONS=10 /trekexecute --project ...`. The
|
||||
`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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -1823,8 +1756,7 @@ code-path.
|
|||
|
||||
1. **No AskUserQuestion for execution decisions.** All execution decisions come
|
||||
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
|
||||
Step 0 MUST ask before spending money on the user's API account.
|
||||
report — never ask.
|
||||
|
||||
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
|
||||
|
|
@ -1855,9 +1787,9 @@ code-path.
|
|||
which removes both tools from the pool — `allowed-tools` alone does not,
|
||||
since omission leaves a tool callable (CC 2.1.152).
|
||||
|
||||
11. **Worktree isolation is mandatory for parallel execution.** Every parallel
|
||||
`claude -p` session MUST run in its own git worktree. Never launch two or
|
||||
more sessions in the same working directory. This rule has no exceptions.
|
||||
11. **Worktree isolation is mandatory for multi-session execution.** Every
|
||||
session order names its own git worktree. Never run two or more sessions
|
||||
in the same working directory. This rule has no exceptions.
|
||||
Sequential (single-session) execution does not require worktrees.
|
||||
|
||||
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
|
||||
`recovery_depth` (<2), and the global `TREKEXECUTE_MAX_RECOVERY_ITERATIONS`
|
||||
budget (default 25 — aggregate recovery+retry backstop) are independent
|
||||
bounds, not one nested counter; child `--max-turns` (50) is a fourth,
|
||||
separate axis. Every recovery/retry loop is bounded by at least one of them.
|
||||
See "Iteration caps + global recovery/retry budget".
|
||||
bounds, not one nested counter. Every recovery/retry loop is bounded by at
|
||||
least one of them. 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).
|
||||
|
|
|
|||
|
|
@ -297,7 +297,7 @@ The session-decomposer will:
|
|||
4. Identify which sessions can run in parallel (waves)
|
||||
5. Generate one session spec file per session
|
||||
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:
|
||||
|
||||
|
|
@ -318,16 +318,16 @@ When the session-decomposer completes, present the summary to the user:
|
|||
|
||||
- Session specs: .claude/trekplan-sessions/{slug}/session-*.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:
|
||||
- Review individual session specs before running
|
||||
- Run all sessions: `bash .claude/trekplan-sessions/{slug}/launch.sh`
|
||||
- Run a single session: `claude -p "$(cat .claude/trekplan-sessions/{slug}/session-1-*.md)"`
|
||||
- Say **"launch"** to start headless execution from here
|
||||
- Run the sessions yourself, wave by wave: open a Claude Code session per spec
|
||||
and give it the starter line from orders.md (`/trekexecute <spec>`). Run the
|
||||
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.
|
||||
|
||||
|
|
@ -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 —
|
||||
> a delegated redesign is under 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 intact. Use `claude -p`
|
||||
> in a separate terminal window for long-running headless work.
|
||||
> the phases inline in main context keeps the swarm intact.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -245,8 +245,7 @@ Then continue to the next phase inline.
|
|||
> 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).
|
||||
> Until then, running the phases inline in main context keeps the swarm
|
||||
> intact. Use `claude -p` in a separate terminal window for long-running
|
||||
> headless work.
|
||||
> intact.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -331,26 +330,21 @@ changes nothing (SC1). Keep the native-swarm anchors intact ("in parallel",
|
|||
|
||||
**engine = deep-research:**
|
||||
|
||||
1. **Coarse pre-gate (best-effort, NOT a trust signal).** `Bash: claude --version`;
|
||||
parse the leading `X.Y.Z` (e.g. from `2.1.196 (Claude Code)`) and compare
|
||||
numerically against **both ends** of the supported window
|
||||
`2.1.154 <= version < 2.1.218` — split each on `.` and compare major, then minor,
|
||||
then patch as integers (do NOT string-compare; lexical comparison mis-orders
|
||||
multi-digit patch numbers). If the version is `< 2.1.154` (below the
|
||||
dynamic-workflows floor), OR `>= 2.1.218` (the ceiling: `/deep-research` starts
|
||||
only when the operator invokes it, and the Skill tool refuses a model invocation
|
||||
with `disable-model-invocation` — see step 4's reason token), OR if
|
||||
`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
|
||||
1. **Coarse pre-gate (best-effort, NOT a trust signal).** The supported window is
|
||||
`2.1.154 <= version < 2.1.218`: below `2.1.154` there are no dynamic workflows,
|
||||
and from `2.1.218` (the ceiling) `/deep-research` starts only when the operator
|
||||
invokes it, and the Skill tool refuses a model invocation with
|
||||
`disable-model-invocation` — see step 4's reason token. Voyage does not run the
|
||||
Claude Code binary to read its version (it never calls Claude Code), so the
|
||||
version is *indeterminate* here. If `disableWorkflows: true` /
|
||||
`CLAUDE_CODE_DISABLE_WORKFLOWS=1` is set, skip to the fallback (step 4);
|
||||
otherwise 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
|
||||
workflow runs; the post-hoc check (step 3) is the authoritative guard.
|
||||
|
||||
2. **Run.** Instruct Claude (in prose, this turn) to run
|
||||
`/deep-research <research question>` and request per-claim citations. Note:
|
||||
interactive default/acceptEdits triggers a per-run approval prompt; `claude -p` /
|
||||
SDK / bypass runs immediately.
|
||||
interactive default/acceptEdits triggers a per-run approval prompt.
|
||||
|
||||
3. **Post-hoc presence + provenance check (the real guard).** Verify a real, cited
|
||||
`/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
|
||||
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
|
||||
current Claude Code), `version-below-floor`, `workflows-disabled`,
|
||||
`no-cited-report`. **NEVER fabricate
|
||||
current Claude Code), `workflows-disabled`, `no-cited-report` (also what a
|
||||
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
|
||||
passes the structure-only validator and silently poisons `/trekplan`; that is the
|
||||
worst outcome of this feature.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
**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`.
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
**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.
|
||||
|
||||
|
|
@ -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) |
|
||||
| **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) |
|
||||
| **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) |
|
||||
| **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 | — |
|
||||
|
|
|
|||
|
|
@ -80,14 +80,19 @@ Protocol:
|
|||
- Both arms append to the same `trekresearch-stats.jsonl`; `effort` is the
|
||||
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
|
||||
# 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
|
||||
node scripts/storm-measure.mjs --stats "${CLAUDE_PLUGIN_DATA}/trekresearch-stats.jsonl"
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
// 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
|
||||
// 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
|
||||
// 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
|
||||
|
|
@ -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
|
||||
// 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
|
||||
// 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
|
||||
// the marker. It was 6h; nothing needed six.
|
||||
const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000;
|
||||
|
|
|
|||
227
lib/util/session-orders.mjs
Normal file
227
lib/util/session-orders.mjs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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
50
tests/fixtures/session-orders/plan.md
vendored
Normal 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`
|
||||
|
|
@ -277,24 +277,24 @@ test('rule-catalogue has exactly 12 entries', async () => {
|
|||
);
|
||||
});
|
||||
|
||||
test('headless-launch-template.md mirrors Phase 2.6 hardenings', () => {
|
||||
const tpl = read('templates/headless-launch-template.md');
|
||||
test('Phase 2.6 hands waves over as session orders; no launch template ships', () => {
|
||||
// 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 [
|
||||
'lib/util/session-orders.mjs" write',
|
||||
'lib/util/session-orders.mjs" status',
|
||||
'GIT_OPTIONAL_LOCKS',
|
||||
'--max-turns',
|
||||
'--max-budget-usd',
|
||||
'--append-system-prompt-file',
|
||||
'SHARED_CONTEXT_FILE',
|
||||
'SAFETY_PREAMBLE',
|
||||
'git push origin',
|
||||
'GH #36071',
|
||||
'push-before-cleanup',
|
||||
'Voyage never starts a Claude Code',
|
||||
]) {
|
||||
assert.ok(
|
||||
tpl.includes(needle),
|
||||
`templates/headless-launch-template.md should include "${needle}" (Step 10 mirrors Phase 2.6)`,
|
||||
);
|
||||
assert.ok(p26.includes(needle), `trekexecute.md Phase 2.6 must include "${needle}"`);
|
||||
}
|
||||
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', () => {
|
||||
|
|
|
|||
142
tests/lib/session-orders.test.mjs
Normal file
142
tests/lib/session-orders.test.mjs
Normal 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/);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue