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
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