@dougharness/cli 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/dist/bin.js +8 -0
  2. package/dist/bin.js.map +1 -1
  3. package/dist/board-serve.js +9 -3
  4. package/dist/board-serve.js.map +1 -1
  5. package/dist/board.js +10 -2
  6. package/dist/board.js.map +1 -1
  7. package/dist/detect/repo.js +14 -1
  8. package/dist/detect/repo.js.map +1 -1
  9. package/dist/detect/types.d.ts +2 -0
  10. package/dist/doctor.d.ts +46 -0
  11. package/dist/doctor.js +491 -0
  12. package/dist/doctor.js.map +1 -0
  13. package/dist/generate/agents.d.ts +7 -0
  14. package/dist/generate/agents.js +62 -12
  15. package/dist/generate/agents.js.map +1 -1
  16. package/dist/generate/claude-md.js +4 -0
  17. package/dist/generate/claude-md.js.map +1 -1
  18. package/dist/generate/config.d.ts +8 -1
  19. package/dist/generate/config.js +5 -12
  20. package/dist/generate/config.js.map +1 -1
  21. package/dist/generate/proposal.js +24 -6
  22. package/dist/generate/proposal.js.map +1 -1
  23. package/dist/generate/settings.d.ts +2 -0
  24. package/dist/generate/settings.js +17 -2
  25. package/dist/generate/settings.js.map +1 -1
  26. package/dist/init.d.ts +14 -0
  27. package/dist/init.js +94 -8
  28. package/dist/init.js.map +1 -1
  29. package/dist/memory-setup.d.ts +28 -0
  30. package/dist/memory-setup.js +323 -0
  31. package/dist/memory-setup.js.map +1 -0
  32. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +16 -4
  33. package/node_modules/@dougharness/flow/agents/planner.md +2 -2
  34. package/node_modules/@dougharness/flow/agents/researcher.md +2 -2
  35. package/node_modules/@dougharness/flow/hooks/hooks.json +16 -0
  36. package/node_modules/@dougharness/flow/lib/board.mjs +154 -12
  37. package/node_modules/@dougharness/flow/lib/code-index.mjs +18 -3
  38. package/node_modules/@dougharness/flow/lib/context.mjs +57 -0
  39. package/node_modules/@dougharness/flow/lib/cost.mjs +129 -4
  40. package/node_modules/@dougharness/flow/lib/data/pairs.json +103 -0
  41. package/node_modules/@dougharness/flow/lib/decisions.mjs +2 -2
  42. package/node_modules/@dougharness/flow/lib/embeddings.mjs +189 -27
  43. package/node_modules/@dougharness/flow/lib/land.mjs +69 -10
  44. package/node_modules/@dougharness/flow/lib/learn.mjs +61 -4
  45. package/node_modules/@dougharness/flow/lib/memory-import.mjs +22 -4
  46. package/node_modules/@dougharness/flow/lib/memory.mjs +326 -35
  47. package/node_modules/@dougharness/flow/lib/plan.mjs +9 -0
  48. package/node_modules/@dougharness/flow/package.json +2 -1
  49. package/node_modules/@dougharness/flow/scripts/board.mjs +60 -20
  50. package/node_modules/@dougharness/flow/scripts/context.mjs +48 -0
  51. package/node_modules/@dougharness/flow/scripts/cost.mjs +15 -3
  52. package/node_modules/@dougharness/flow/scripts/memory.mjs +196 -39
  53. package/node_modules/@dougharness/flow/scripts/plan.mjs +32 -64
  54. package/node_modules/@dougharness/flow/scripts/session-recall.mjs +132 -0
  55. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +105 -0
  56. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
  57. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +44 -10
  58. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +5 -5
  59. package/node_modules/@dougharness/flow/skills/research/SKILL.md +4 -2
  60. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +10 -11
  61. package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +1 -1
  62. package/node_modules/@dougharness/flow/workflows/doug-implement.js +119 -72
  63. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  64. package/node_modules/@dougharness/gates/hooks/hooks.json +11 -0
  65. package/node_modules/@dougharness/gates/lib/baseline.mjs +166 -1
  66. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +58 -1
  67. package/node_modules/@dougharness/gates/lib/checkpoint.mjs +1 -1
  68. package/node_modules/@dougharness/gates/lib/config.mjs +20 -0
  69. package/node_modules/@dougharness/gates/lib/docs-gate.mjs +95 -0
  70. package/node_modules/@dougharness/gates/lib/evidence.mjs +2 -0
  71. package/node_modules/@dougharness/gates/lib/run.mjs +4 -0
  72. package/node_modules/@dougharness/gates/lib/state.mjs +5 -0
  73. package/node_modules/@dougharness/gates/lib/test-totals.mjs +139 -0
  74. package/node_modules/@dougharness/gates/lib/trace.mjs +24 -7
  75. package/node_modules/@dougharness/gates/lib/verified-tree.mjs +225 -0
  76. package/node_modules/@dougharness/gates/package.json +1 -1
  77. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +133 -5
  78. package/node_modules/@dougharness/gates/scripts/session-start-baseline.mjs +14 -0
  79. package/node_modules/@dougharness/gates/scripts/statusline.mjs +33 -7
  80. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +794 -67
  81. package/node_modules/@dougharness/gates/scripts/trace.mjs +5 -1
  82. package/node_modules/@dougharness/gates/scripts/turn-start.mjs +17 -0
  83. package/node_modules/@dougharness/gates/scripts/verify.mjs +146 -0
  84. package/package.json +3 -3
  85. package/templates/board-app.js +2 -2
  86. package/node_modules/@dougharness/flow/lib/rehearse.mjs +0 -825
  87. package/node_modules/@dougharness/flow/scripts/rehearse.mjs +0 -129
  88. package/node_modules/@dougharness/flow/skills/core-next/SKILL.md +0 -92
  89. package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +0 -85
@@ -1,825 +0,0 @@
1
- // The pure and fixture-setup pieces behind scripts/rehearse.mjs (card workflow-rehearsal): estimates, argument
2
- // parsing, the ts-basic fixture the three scenarios run in, the stream assertions a real `claude -p` session's
3
- // stream-json output is checked against, and the recording that goes through the existing board.mjs run-report
4
- // CLI (never lib/board.mjs directly, so a batch or a research-note promotion is never bypassed). Spawning the
5
- // real `claude` binary (runStage) is the only side effect here that costs money; every other export is pure, or
6
- // touches only a fixture directory the caller made. Facts this module leans on are cited in
7
- // .doug/.state/research/workflow-rehearsal.md.
8
-
9
- import { spawnSync, execFileSync } from "node:child_process";
10
- import { cpSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, readdirSync } from "node:fs";
11
- import { tmpdir } from "node:os";
12
- import { dirname, join, relative } from "node:path";
13
- import { fileURLToPath } from "node:url";
14
- import { newBoard, addCard, boardPath, BOARD_RELPATH } from "./board.mjs";
15
- import { commandEnv } from "./land.mjs";
16
- import { unwrapReport } from "./plan.mjs";
17
-
18
- const here = dirname(fileURLToPath(import.meta.url));
19
- export const ROOT = join(here, "../../..");
20
- export const PLUGIN_DIR = join(ROOT, "plugins/doug-flow");
21
- export const PLAN_MJS = join(PLUGIN_DIR, "scripts/plan.mjs");
22
- export const BOARD_MJS = join(PLUGIN_DIR, "scripts/board.mjs");
23
- export const MEMORY_MJS = join(PLUGIN_DIR, "scripts/memory.mjs");
24
- export const CLI = join(ROOT, "packages/doug-cli/dist/bin.js");
25
- export const FIXTURE_DIR = join(ROOT, "evals/fixtures/ts-basic");
26
- export const TASKS_FILE = join(ROOT, "evals/tasks/ts-basic.json");
27
- export const HELDOUT_FILE = join(ROOT, "evals/heldout/fix-hours.test.ts");
28
- export const RESULTS_FILE = ".doug/.state/last-report.json";
29
- export const STREAM_DIR = ".doug/.state/rehearsal";
30
- export const SYNTHETIC_REPORT_RELPATH = `${STREAM_DIR}/synthetic-report.json`;
31
-
32
- // Every child process this module spawns clears CLAUDE_PROJECT_DIR and GIT_* (plugins/doug-flow/tests/seams.test.mjs's
33
- // precedent, itself following plugins/doug-gates/tests/git-hooks.test.mjs): scripts/plan.mjs, board.mjs, and
34
- // memory.mjs's dirFrom() all prefer CLAUDE_PROJECT_DIR over an explicit dir argument, so a value leaked from this
35
- // process's own environment (set by a real Claude Code session running the rehearsal skill) would silently
36
- // redirect every plan.mjs/board.mjs spawn at the real checkout instead of the fixture or the target repository
37
- // the caller named explicitly (card workflow-rehearsal-review, round 2: "no card fix-hours on the board"). Used
38
- // by every spawn this module makes: plan.mjs, board.mjs, doug init, git, pnpm, and the claude session itself
39
- // (which already needs CLAUDE_PROJECT_DIR cleared to "" for its own reasons).
40
- export function cleanEnv(extra = {}) {
41
- const env = {};
42
- for (const [k, v] of Object.entries(process.env)) {
43
- if (!k.startsWith("GIT_") && k !== "CLAUDE_PROJECT_DIR") env[k] = v;
44
- }
45
- return { ...env, ...extra };
46
- }
47
-
48
- // ---- Estimates (docs/live-runs.md measurements the estimate table cites), per stage ------------------------
49
- // Each scenario's total is the sum of its own stages; a session stage's --max-budget-usd is three times ITS OWN
50
- // stage figure, not the scenario total (item workflow-rehearsal-review #6: research gives no single number, so
51
- // the flow and hand totals are split across their sessions in proportion to what each is expected to do, and the
52
- // swarm total is all in its one implement session).
53
- export const SCENARIOS = ["flow", "swarm", "hand"];
54
- export const ESTIMATES = {
55
- flow: {
56
- // /doug-next's own first step moves the card (uncommitted working state, never a "Board: <id> moved to In
57
- // flow" commit) and spawns the planner before its approval gate (research §1, section 1), so there is no
58
- // separate /doug-plan stage to estimate: the gate stage now covers what the old $0.25 gate and $0.75 plan
59
- // stages did together (item rehearsal-first-live-findings #1).
60
- stages: { gate: 1.25, implement: 2.5 },
61
- source:
62
- "flow rehearsals on the ts-basic fixture measured $4.15/9 min (docs/live-runs.md, 2026-09-09), $3.93/10 min (2026-09-10), and $2.13/9 min (2026-09-20); the former gate ($0.25) and plan ($0.75) estimates merge into one gate stage since /doug-next runs the planner before its own approval gate, leaving implement as before",
63
- },
64
- swarm: {
65
- stages: { implement: 4.0 },
66
- source:
67
- "swarm rehearsal on the ts-basic fixture measured $4.32/12.7 min (docs/live-runs.md, 2026-09-10), predating the shape gate (3eb9485, 2026-09-12); the scenario fails at implement today, so this figure is not reproducible until rehearsal-swarm-splittable lands",
68
- },
69
- hand: {
70
- stages: { gate: 0.25, build: 3.75 },
71
- source: "hand rehearsal on the ts-basic fixture measured $2.16/3.8 min (docs/live-runs.md, 2026-09-09), measured before the tester seat and research step existed",
72
- },
73
- };
74
-
75
- export function scenarioTotal(scenario) {
76
- return Object.values(ESTIMATES[scenario].stages).reduce((a, b) => a + b, 0);
77
- }
78
-
79
- export function totalEstimate() {
80
- return SCENARIOS.reduce((sum, s) => sum + scenarioTotal(s), 0);
81
- }
82
-
83
- // Three times a session stage's own estimate (never the scenario total): the cap `runStage` passes as
84
- // --max-budget-usd.
85
- export function capFor(scenario, stageName) {
86
- const usd = ESTIMATES[scenario].stages[stageName];
87
- if (usd === undefined) throw new Error(`capFor: scenario "${scenario}" has no estimate for stage "${stageName}"`);
88
- return Math.round(usd * 3 * 100) / 100;
89
- }
90
-
91
- // With no scenario: one line per scenario (its total and source) plus the `all` total. With a scenario: its
92
- // per-stage breakdown (card workflow-rehearsal-review #6).
93
- export function estimateLines(scenario = null) {
94
- if (!scenario) {
95
- const lines = SCENARIOS.map((s) => ` ${s.padEnd(6)} $${scenarioTotal(s).toFixed(2)} (${ESTIMATES[s].source})`);
96
- lines.push(` ${"all".padEnd(6)} $${totalEstimate().toFixed(2)} (sum of the three)`);
97
- return lines;
98
- }
99
- const est = ESTIMATES[scenario];
100
- const lines = [` ${scenario} $${scenarioTotal(scenario).toFixed(2)} total (${est.source})`];
101
- for (const [stage, usd] of Object.entries(est.stages)) lines.push(` ${stage.padEnd(10)} $${usd.toFixed(2)} (cap $${(usd * 3).toFixed(2)})`);
102
- return lines;
103
- }
104
-
105
- // ---- CLI argument parsing ------------------------------------------------------------------------------
106
- // `rehearse.mjs [flow|swarm|hand|all] --card <id> --spend [dir]`. Pure: does not validate the scenario name
107
- // or check the card exists (the caller does, before anything is spawned).
108
- export function parseArgs(argv) {
109
- const positional = [];
110
- const opts = { spend: false, card: null };
111
- for (let i = 0; i < argv.length; i++) {
112
- const a = argv[i];
113
- if (a === "--spend") opts.spend = true;
114
- else if (a === "--card") opts.card = argv[++i] ?? null;
115
- else positional.push(a);
116
- }
117
- const [scenario, dir] = positional;
118
- return { scenario: scenario ?? null, dir: dir ?? null, spend: opts.spend, card: opts.card };
119
- }
120
-
121
- // ---- Stream assertions (research §2: the exact stream-json shapes on 2.1.266) -----------------------------
122
- function* jsonLines(stream) {
123
- for (const line of String(stream || "").split("\n")) {
124
- const t = line.trim();
125
- if (!t.startsWith("{")) continue;
126
- try {
127
- yield JSON.parse(t);
128
- } catch {
129
- // not a JSON line; skip
130
- }
131
- }
132
- }
133
-
134
- function initEvent(stream) {
135
- for (const j of jsonLines(stream)) if (j.type === "system" && j.subtype === "init") return j;
136
- return null;
137
- }
138
-
139
- export function pluginsLoaded(stream, names) {
140
- const init = initEvent(stream);
141
- const loaded = new Set((init && Array.isArray(init.plugins) ? init.plugins : []).map((p) => p && p.name));
142
- return names.every((n) => loaded.has(n));
143
- }
144
-
145
- export function slashCommandPresent(stream, command) {
146
- const init = initEvent(stream);
147
- return !!(init && Array.isArray(init.slash_commands) && init.slash_commands.includes(command));
148
- }
149
-
150
- // Every agent name from an "Agent type '<x>' not found" tool_result (research §2): a name Claude Code could not
151
- // resolve. [] when the stream carries no such error.
152
- export function unresolvedAgentNames(stream) {
153
- const names = [];
154
- const re = /Agent type '([^']+)' not found/;
155
- for (const j of jsonLines(stream)) {
156
- const content = j && j.type === "user" && j.message && Array.isArray(j.message.content) ? j.message.content : [];
157
- for (const c of content) {
158
- if (c && c.type === "tool_result" && c.is_error && typeof c.content === "string") {
159
- const m = re.exec(c.content);
160
- if (m) names.push(m[1]);
161
- }
162
- }
163
- }
164
- return names;
165
- }
166
-
167
- // `total_cost_usd` from the final result line; null when the stream carries none (the session never finished).
168
- export function sessionCost(stream) {
169
- let cost = null;
170
- for (const j of jsonLines(stream)) if (j.type === "result") cost = typeof j.total_cost_usd === "number" ? j.total_cost_usd : null;
171
- return cost;
172
- }
173
-
174
- // ---- The report a doug-implement session writes (evals/run.mjs REPORT_FILE, reportVerdict; reimplemented
175
- // here rather than imported, since evals/ must not gain a new export for this) --------------------------------
176
- export function readReport(dir) {
177
- const file = join(dir, RESULTS_FILE);
178
- if (!existsSync(file)) return { ok: false, reason: `no report at ${RESULTS_FILE}: the session ended before the workflow returned`, report: null };
179
- let report;
180
- try {
181
- report = unwrapReport(JSON.parse(readFileSync(file, "utf8")));
182
- } catch (err) {
183
- return { ok: false, reason: `unreadable report at ${RESULTS_FILE}: ${err.message}`, report: null };
184
- }
185
- return { ok: report && report.ok === true, reason: report && report.ok === true ? null : "report ok is not true", report };
186
- }
187
-
188
- function reportTasks(report) {
189
- return (report && Array.isArray(report.levels) ? report.levels : []).flatMap((l) => l.tasks || []);
190
- }
191
-
192
- // A minimal, honest report for a flow/swarm scenario that stopped before the workflow ever wrote one (a gate,
193
- // plan, or approve stage failed, or the implement session died before returning): no levels, no invented tasks,
194
- // `ok: false`, `stoppedAtLevel: 0` so runEntry's outcomeOf reads it as "stopped at level 0", and a `rehearsal`
195
- // field carrying the stage and the assertion message that stopped it (card workflow-rehearsal-review #4).
196
- // integrationBranch is a plain string, never null/undefined: runEntry renders it unconditionally inside backticks
197
- // (`integration branch \`${report.integrationBranch}\``, lib/board.mjs, no "missing branch" form exists there),
198
- // so a nullish value would render literally as "integration branch `null`" in the real docs/live-runs.md (card
199
- // workflow-rehearsal-review, round 2, #19).
200
- export function syntheticReport(scenario, stage, message) {
201
- return { ok: false, plan: `${scenario} rehearsal (no report)`, integrationBranch: "none (no report)", levels: [], stoppedAtLevel: 0, rehearsal: { stage, message } };
202
- }
203
-
204
- // ---- Assertions shared by a scenario's gate and implement stages -------------------------------------------
205
-
206
- function fail(message) {
207
- return { ok: false, message };
208
- }
209
- function pass(message, extra = {}) {
210
- return { ok: true, message, ...extra };
211
- }
212
-
213
- // `git status --porcelain`, tracked or untracked, with every `.doug/` path filtered out (lib/land.mjs's own
214
- // tracked-files filter, matched here): a plan file the runner's own act stages leave modified or untracked (a
215
- // draft plan.json, `plan.mjs done` flipping its status) and a board record are never themselves "dirty" for a
216
- // clean-tree check, since nothing in the fixture commits them mid-run. Shared by every clean-tree check the
217
- // runner makes, so a real land or build is never blocked by its own bookkeeping (card workflow-rehearsal-review,
218
- // round 2, #16).
219
- export function dirtyOutsideDoug(dir) {
220
- return execFileSync("git", ["status", "--porcelain"], { cwd: dir, encoding: "utf8", env: cleanEnv() })
221
- .split("\n")
222
- .filter(Boolean)
223
- .filter((l) => !l.slice(3).startsWith(".doug/"));
224
- }
225
-
226
- // A gate stage asserts that a skill reached its human gate (AskUserQuestion, denied in print mode) and stopped
227
- // there without doing anything: the runtime loaded the plugin and offered the slash command, but the fixture is
228
- // otherwise exactly as prepareFixture left it (research §1, "Starting is never automatic").
229
- export function assertGateHeld(dir, stream, { plugins, command, cardId, expectColumn = "ready", unchangedFiles = [], planAbsent = false }) {
230
- const missingPlugins = plugins.filter((p) => !pluginsLoaded(stream, [p]));
231
- if (missingPlugins.length) return fail(`the init event's plugins did not name ${missingPlugins.join(", ")}`);
232
- if (!slashCommandPresent(stream, command)) return fail(`the init event's slash_commands did not carry ${command}`);
233
- let board;
234
- try {
235
- board = JSON.parse(readFileSync(boardPath(dir), "utf8"));
236
- } catch (err) {
237
- return fail(`could not read the fixture board: ${err.message}`);
238
- }
239
- const card = (board.cards || []).find((c) => c.id === cardId);
240
- if (!card || card.column !== expectColumn) return fail(`the fixture board no longer has ${cardId} in ${expectColumn}`);
241
- let log;
242
- try {
243
- log = execFileSync("git", ["log", "--oneline"], { cwd: dir, encoding: "utf8", env: cleanEnv() }).trim().split("\n").filter(Boolean);
244
- } catch (err) {
245
- return fail(`git log failed: ${err.message}`);
246
- }
247
- if (log.length !== 1) return fail(`git log has ${log.length} commit(s); expected only the fixture commit`);
248
- if (planAbsent && existsSync(join(dir, ".doug/plan.json"))) return fail(".doug/plan.json exists; the gate must not have started planning");
249
- for (const f of unchangedFiles) {
250
- const status = execFileSync("git", ["status", "--porcelain", "--", f], { cwd: dir, encoding: "utf8", env: cleanEnv() }).trim();
251
- if (status) return fail(`${f} changed even though the gate should have stopped before any edit`);
252
- }
253
- const rest = dirtyOutsideDoug(dir);
254
- if (rest.length) return fail(`working tree is not clean apart from .doug/: ${rest.join(", ")}`);
255
- return pass("gate held: the skill did not proceed past the denied human gate");
256
- }
257
-
258
- // The flow scenario's gate stage (item rehearsal-first-live-findings #1): /doug-next has no gate before planning,
259
- // only its approval gate after planning, so a run that follows the skill exactly moves the card to In flow
260
- // (uncommitted working state), spawns the planner, and stops at AskUserQuestion with a draft plan on disk.
261
- // Asserting the card was still Ready (assertGateHeld's shape) fails a correct run; this asserts the real stopping
262
- // point instead: the card is in flow in the working-tree record, `git log` holds only the fixture commit (task
263
- // rehearse-uncommitted-move: /doug-next leaves the move uncommitted, landing it only in the card's landing
264
- // commit), a draft plan exists and validates, and nothing past the gate (a task worktree, an integration branch)
265
- // exists yet.
266
- export function assertFlowGateHeld(dir, stream, { plugins, command, cardId }) {
267
- const missingPlugins = plugins.filter((p) => !pluginsLoaded(stream, [p]));
268
- if (missingPlugins.length) return fail(`the init event's plugins did not name ${missingPlugins.join(", ")}`);
269
- if (!slashCommandPresent(stream, command)) return fail(`the init event's slash_commands did not carry ${command}`);
270
- let board;
271
- try {
272
- board = JSON.parse(readFileSync(boardPath(dir), "utf8"));
273
- } catch (err) {
274
- return fail(`could not read the fixture board: ${err.message}`);
275
- }
276
- const card = (board.cards || []).find((c) => c.id === cardId);
277
- if (!card || card.column !== "flow") return fail(`the fixture board does not have ${cardId} in flow`);
278
- let log;
279
- try {
280
- log = execFileSync("git", ["log", "--oneline"], { cwd: dir, encoding: "utf8", env: cleanEnv() }).trim().split("\n").filter(Boolean);
281
- } catch (err) {
282
- return fail(`git log failed: ${err.message}`);
283
- }
284
- if (log.some((l) => l.includes(`Board: ${cardId} moved to In flow`)))
285
- return fail(`git log carries a "Board: ${cardId} moved to In flow" commit; /doug-next leaves the move uncommitted, landing it only in the card's landing commit`);
286
- if (log.length !== 1) return fail(`git log has ${log.length} commit(s); expected only the fixture commit`);
287
- const planPath = join(dir, ".doug/plan.json");
288
- if (!existsSync(planPath)) return fail(".doug/plan.json does not exist; the planner did not run");
289
- const validate = act(PLAN_MJS, ["validate"], dir);
290
- if (!validate.ok) return fail(`plan.mjs validate failed: ${validate.stderr || validate.stdout}`);
291
- let plan;
292
- try {
293
- plan = JSON.parse(readFileSync(planPath, "utf8"));
294
- } catch (err) {
295
- return fail(`.doug/plan.json is not valid JSON: ${err.message}`);
296
- }
297
- if (plan.status === "approved") return fail("the plan is already approved; the planning skill must never approve on its own");
298
- let branches;
299
- try {
300
- branches = execFileSync("git", ["branch", "--list"], { cwd: dir, encoding: "utf8", env: cleanEnv() })
301
- .split("\n")
302
- .map((l) => l.replace(/^\*?\s*/, "").trim())
303
- .filter(Boolean);
304
- } catch (err) {
305
- return fail(`git branch failed: ${err.message}`);
306
- }
307
- const stray = branches.filter((b) => b !== "main");
308
- if (stray.length) return fail(`an integration branch already exists: ${stray.join(", ")}`);
309
- const worktreesDir = join(dir, ".claude/worktrees");
310
- if (existsSync(worktreesDir) && readdirSync(worktreesDir).length) return fail("a task worktree exists; the session proceeded past the approval gate");
311
- const rest = dirtyOutsideDoug(dir);
312
- if (rest.length) return fail(`working tree is not clean apart from .doug/: ${rest.join(", ")}`);
313
- return pass("gate held: the card moved to In flow, a draft plan exists, and the session stopped at the approval gate");
314
- }
315
-
316
- // The implement stage of the flow and swarm scenarios: the plugin loaded, no agent name went unresolved, and the
317
- // workflow's own report says every task was verified, reviewed, and carries an adversary field. `requireWorkers`
318
- // is the swarm scenario's extra check (a real lead/worker split happened, not just an implementer).
319
- export function assertImplemented(dir, stream, { plugins, requireWorkers = false } = {}) {
320
- const missingPlugins = plugins.filter((p) => !pluginsLoaded(stream, [p]));
321
- if (missingPlugins.length) return fail(`the init event's plugins did not name ${missingPlugins.join(", ")}`);
322
- const unresolved = unresolvedAgentNames(stream);
323
- if (unresolved.length) return fail(`unresolved agent name(s) in the stream: ${unresolved.join(", ")}`);
324
- const { ok, reason, report } = readReport(dir);
325
- if (!ok) return fail(reason);
326
- const tasks = reportTasks(report);
327
- if (!tasks.length) return fail("the report carries no tasks");
328
- for (const t of tasks) {
329
- if (!t.verified) return fail(`task ${t.id}: verified is not true`);
330
- if (!t.reviewed) return fail(`task ${t.id}: reviewed is not true`);
331
- if (!("adversary" in t)) return fail(`task ${t.id}: report carries no adversary field`);
332
- }
333
- if (requireWorkers) {
334
- const swarmed = tasks.filter((t) => Array.isArray(t.workers) && t.workers.length);
335
- if (!swarmed.length) {
336
- // Since 3eb9485, a deterministic shape gate (plugins/doug-flow/skills/swarm-launch/SKILL.md) keeps a task
337
- // shaped like this fixture's (one source file plus its tests) off the lead: doug-implement.js's swarmReport()
338
- // records that on the task as `swarm: { applies: false, reason, ... }`. When that is why no task swarmed, say
339
- // so by name instead of the old blanket message, and point at the card that would give this scenario a
340
- // splittable task.
341
- const gated = tasks.find((t) => t.swarm && t.swarm.applies === false);
342
- if (gated) {
343
- return fail(
344
- `task ${gated.id}: no workers (${gated.swarm.reason}); the shape gate kept it off the lead, so this scenario cannot prove a split until card rehearsal-swarm-splittable lands`,
345
- );
346
- }
347
- return fail("no task in the report carried workers: the swarm did not run");
348
- }
349
- }
350
- return pass(`implemented: ${tasks.map((t) => t.id).join(", ")}`);
351
- }
352
-
353
- // Every agent name a swarmed report's tasks actually ran: each attempt's own `stages` (lead, worker-n, lead-merge,
354
- // verify, review, adversary, integrate — not just the last pass's, doug-implement.js's per-task report around
355
- // line 1794-1811 keeps `attempts` as the full pass history and `stages` on the task itself as only the last one),
356
- // plus each worker's brief id from the `workers` list.
357
- export function agentNamesFromReport(report) {
358
- const names = new Set();
359
- for (const t of reportTasks(report)) {
360
- for (const a of Array.isArray(t.attempts) ? t.attempts : []) for (const s of a.stages || []) names.add(s);
361
- for (const w of Array.isArray(t.workers) ? t.workers : []) if (w && w.id) names.add(w.id);
362
- }
363
- return [...names];
364
- }
365
-
366
- // ---- Fixture (a fresh temp copy of evals/fixtures/ts-basic, prepared like evals/run.mjs prepare()) ------------
367
-
368
- function fixtureSuite() {
369
- return JSON.parse(readFileSync(TASKS_FILE, "utf8"));
370
- }
371
-
372
- function fixtureTask(suite) {
373
- const task = suite.tasks.find((t) => t.id === "fix-hours-planned");
374
- if (!task || !task.plan) throw new Error(`task "fix-hours-planned" with a plan was not found in ${TASKS_FILE}`);
375
- return task;
376
- }
377
-
378
- // A fresh fixture for one scenario: the suite's own setup commands (so a checkout that never ran evals still
379
- // gets node_modules; evals/fixtures/ts-basic/node_modules is gitignored), `doug init`, a board holding one card
380
- // `fix-hours` (title/goal from the fix-hours-planned task's plan), on the hand track only for the hand scenario,
381
- // and, for the swarm scenario, the same task's plan installed pre-approved with swarm on (the swarm opt-in and
382
- // the approval are the user's acts, standing in for /doug-swarm here since the swarm scenario starts at
383
- // implement, not at a gate). CLAUDE.md's Models table is left exactly as `doug init` generated it, worker row
384
- // included; the runner edits no row. Throws with a `pnpm build` message when the CLI is missing.
385
- // `tempPrefix` lets a caller (the test file) mark its own fixtures distinctly from a real run's, so a fixture
386
- // left behind by --spend on failure is recognisable as the runner's (card workflow-rehearsal-review #7).
387
- export function prepareFixture({ scenario, dir, tempPrefix } = {}) {
388
- if (!SCENARIOS.includes(scenario)) throw new Error(`prepareFixture: unknown scenario "${scenario}"; scenarios are ${SCENARIOS.join(", ")}`);
389
- if (!existsSync(CLI)) throw new Error(`the doug CLI is not built (${relative(ROOT, CLI)} is missing); run "pnpm build" first`);
390
- const suite = fixtureSuite();
391
- const task = fixtureTask(suite);
392
- const target = dir || mkdtempSync(join(tmpdir(), tempPrefix || `doug-rehearse-${scenario}-`));
393
- cpSync(FIXTURE_DIR, target, { recursive: true });
394
-
395
- for (const cmd of suite.setup || []) {
396
- const r = spawnSync(cmd, { cwd: target, shell: true, encoding: "utf8", timeout: 600000, env: commandEnv(cleanEnv()) });
397
- if (r.status !== 0) throw new Error(`fixture setup failed in ${target}: ${cmd}\n${(r.stdout || "") + (r.stderr || "")}`);
398
- }
399
-
400
- const init = spawnSync(process.execPath, [CLI, "init", target, "--yes", "--quiet"], { encoding: "utf8", env: cleanEnv() });
401
- if (init.status !== 0) throw new Error(`doug init failed in ${target}: ${init.stdout}${init.stderr}`);
402
-
403
- // A workflow run leaves worktrees under .claude/worktrees/; the fixture's own .gitignore does not cover it
404
- // (doug init only adds .doug/.state/), so every clean-tree check the runner makes would otherwise see them as
405
- // untracked (card workflow-rehearsal-review, round 2, #17). Appended after doug init so it is not overwritten.
406
- const gitignorePath = join(target, ".gitignore");
407
- const gitignore = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf8") : "";
408
- if (!gitignore.split("\n").some((l) => l.trim() === ".claude/worktrees/")) {
409
- writeFileSync(gitignorePath, `${gitignore}${gitignore && !gitignore.endsWith("\n") ? "\n" : ""}.claude/worktrees/\n`);
410
- }
411
-
412
- let board = { ...newBoard(), components: ["flow"] };
413
- board = addCard(board, {
414
- id: "fix-hours",
415
- title: task.plan.title,
416
- goal: task.plan.goal,
417
- component: "flow",
418
- size: "S",
419
- track: scenario === "hand" ? "hand" : undefined,
420
- column: "ready",
421
- // /core-next's Start step now refuses a classless card before it stamps the landing's condition
422
- // (scripts/memory.mjs's "condition open"); every fixture card carries one so a rehearsal never trips that
423
- // refusal (plugins/doug-flow/skills/core-next/SKILL.md step 2).
424
- class: "code",
425
- });
426
- mkdirSync(join(target, ".doug"), { recursive: true });
427
- writeFileSync(join(target, BOARD_RELPATH), JSON.stringify(board, null, 2) + "\n");
428
-
429
- if (scenario === "swarm") {
430
- const plan = { ...task.plan, status: "approved", card: "fix-hours", swarm: true };
431
- writeFileSync(join(target, ".doug/plan.json"), JSON.stringify(plan, null, 2) + "\n");
432
- }
433
-
434
- execFileSync("git", ["init", "-q", "-b", "main"], { cwd: target, env: cleanEnv() });
435
- execFileSync("git", ["config", "user.email", "rehearse@example.com"], { cwd: target, env: cleanEnv() });
436
- execFileSync("git", ["config", "user.name", "rehearse"], { cwd: target, env: cleanEnv() });
437
- execFileSync("git", ["add", "-A"], { cwd: target, env: cleanEnv() });
438
- execFileSync("git", ["commit", "-q", "-m", "fixture"], { cwd: target, env: cleanEnv() });
439
- const baseline = execFileSync("git", ["rev-parse", "HEAD"], { cwd: target, encoding: "utf8", env: cleanEnv() }).trim();
440
- return { dir: target, card: { id: "fix-hours", title: task.plan.title, goal: task.plan.goal }, task, baseline };
441
- }
442
-
443
- // ---- One `claude -p` session (evals/run.mjs runWorkflowSession's spawn shape exactly) --------------------------
444
- const SESSION_TIMEOUT_MS = 90 * 60 * 1000;
445
-
446
- // Spawns one stage's session, saving the full stream to <dir>/.doug/.state/rehearsal/<stage>.jsonl so a failed
447
- // assertion can be read afterwards. `claudeBin` defaults to $DOUG_REHEARSE_CLAUDE or "claude"; a real user never
448
- // sets that env var, the test file points it at a fake. `capUsd` becomes --max-budget-usd. `spawnSync` never
449
- // throws on a missing binary; it sets `.error` instead (ENOENT), surfaced here as `error`/`signal` rather than
450
- // left for the caller to notice only from empty output (card workflow-rehearsal-review #5).
451
- export function runStage({ dir, prompt, maxTurns, stage, capUsd = null, claudeBin = process.env.DOUG_REHEARSE_CLAUDE || "claude" }) {
452
- const streamDir = join(dir, STREAM_DIR);
453
- mkdirSync(streamDir, { recursive: true });
454
- const streamPath = join(streamDir, `${stage}.jsonl`);
455
- const env = cleanEnv({ CLAUDE_PROJECT_DIR: "", CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1", CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS: "0" });
456
- // --disallowedTools Artifact: a gate/build session that reaches the doug-board skill tries to publish, and the
457
- // first live flow rehearsal found the Artifact tool merely absent from the -p tool list, not denied — a runtime
458
- // where it happened to be present would let the session actually publish (item rehearsal-first-live-findings
459
- // #5). This lockout is the runner's own, not evals/run.mjs's: an eval session never invokes a board skill.
460
- const args = ["-p", prompt, "--output-format", "stream-json", "--verbose", "--max-turns", String(maxTurns), "--plugin-dir", PLUGIN_DIR, "--dangerously-skip-permissions", "--disallowedTools", "Artifact"];
461
- if (capUsd !== null) args.push("--max-budget-usd", String(capUsd));
462
- const r = spawnSync(claudeBin, args, { cwd: dir, encoding: "utf8", timeout: SESSION_TIMEOUT_MS, env, maxBuffer: 64 * 1024 * 1024 });
463
- writeFileSync(streamPath, r.stdout || "");
464
- return { stdout: r.stdout || "", stderr: r.stderr || "", status: r.status, signal: r.signal || null, error: r.error || null, streamPath, cost: sessionCost(r.stdout || "") };
465
- }
466
-
467
- // Runs one session stage end to end: spawn, then assert, with every failure mode (the binary could not run, it
468
- // was killed, or the assertion itself threw) turned into a normal `{ ok:false, message }` instead of a crash or
469
- // a misleading assertion failure (card workflow-rehearsal-review #5). Always carries `streamPath` and `cost`.
470
- export function runSessionStage(stage, { dir, card, baseline, capUsd = null, claudeBin } = {}) {
471
- const r = runStage({ dir, prompt: stage.prompt({ card }), maxTurns: stage.maxTurns, stage: stage.name, capUsd, ...(claudeBin ? { claudeBin } : {}) });
472
- if (r.error) return { ok: false, message: `claude could not run: ${r.error.message}`, streamPath: r.streamPath, cost: null };
473
- if (r.signal) return { ok: false, message: `claude was killed by signal ${r.signal} before finishing`, streamPath: r.streamPath, cost: r.cost };
474
- let result;
475
- try {
476
- result = stage.assert({ dir, card, baseline, stream: r.stdout });
477
- } catch (err) {
478
- result = fail(`assertion threw: ${err && err.message ? err.message : String(err)}`);
479
- }
480
- return { ...result, streamPath: r.streamPath, cost: r.cost };
481
- }
482
-
483
- // Runs one act stage (the runner performing a human gate's act, or a deterministic step): a throw becomes a
484
- // normal failed result instead of crashing the scenario (card workflow-rehearsal-review #5).
485
- export function runActStage(stage, { dir, card, baseline } = {}) {
486
- try {
487
- return { ...stage.run({ dir, card, baseline }), streamPath: null, cost: null };
488
- } catch (err) {
489
- return { ok: false, message: `threw: ${err && err.message ? err.message : String(err)}`, streamPath: null, cost: null };
490
- }
491
- }
492
-
493
- // A runner act: node <script> <args...> <dir>, the same shape plan.mjs and board.mjs subcommands take.
494
- function act(script, args, dir) {
495
- const r = spawnSync(process.execPath, [script, ...args, dir], { encoding: "utf8", env: cleanEnv() });
496
- return { ok: r.status === 0, stdout: r.stdout || "", stderr: r.stderr || "" };
497
- }
498
-
499
- // A clean-before, held-out-test-after helper shared by every scenario's last stage (card workflow-rehearsal-review
500
- // #1b): checks the tree is clean BEFORE the held-out test is copied in (a dirty tree afterwards used to be
501
- // unwinnable, since the copy itself is what made it dirty), then always removes the held-out file again, whether
502
- // `pnpm test` passed or not, so the fixture is left exactly as the build/land left it.
503
- function runHeldOutAndClean(dir) {
504
- const before = dirtyOutsideDoug(dir);
505
- if (before.length) return fail(`working tree is not clean before the held-out test is added: ${before.join(", ")}`);
506
- const heldOutPath = join(dir, "tests/heldout-fix-hours.test.ts");
507
- cpSync(HELDOUT_FILE, heldOutPath);
508
- let test;
509
- try {
510
- test = spawnSync("pnpm", ["test"], { cwd: dir, encoding: "utf8", shell: true, env: cleanEnv() });
511
- } finally {
512
- rmSync(heldOutPath, { force: true });
513
- }
514
- if (test.status !== 0) return fail(`pnpm test failed in the fixture after the held-out test was added:\n${((test.stdout || "") + (test.stderr || "")).slice(0, 2000)}`);
515
- return pass("held-out test passed");
516
- }
517
-
518
- // A passed run used to be removed, streams and report and all, so a passed scenario's evidence could only be read
519
- // afterwards from ~/.claude/projects (item rehearsal-first-live-findings #2). Before a fixture is removed, its
520
- // stream logs (<fixtureDir>/.doug/.state/rehearsal/*.jsonl, this module's own STREAM_DIR) and its last report
521
- // (RESULTS_FILE, when the workflow wrote one) are copied into the real repository at `dir`, under
522
- // .doug/.state/rehearsal/<scenario>-<ISO timestamp>/, so a passed run's evidence survives the fixture's removal
523
- // the same way a failed run's already does by being kept. Returns the absolute path copied into, so the caller
524
- // can print it.
525
- export function archiveEvidence({ fixtureDir, dir, scenario, now = new Date() }) {
526
- const dest = join(dir, STREAM_DIR, `${scenario}-${now.toISOString().replace(/[:.]/g, "-")}`);
527
- mkdirSync(dest, { recursive: true });
528
- const streamSrc = join(fixtureDir, STREAM_DIR);
529
- if (existsSync(streamSrc)) cpSync(streamSrc, dest, { recursive: true });
530
- const reportSrc = join(fixtureDir, RESULTS_FILE);
531
- if (existsSync(reportSrc)) cpSync(reportSrc, join(dest, "last-report.json"));
532
- return dest;
533
- }
534
-
535
- // ---- Recording, through the existing board.mjs CLI only (never lib/board.mjs's runEntry/handEntry/appendRun
536
- // directly: the CLI is what does research-note promotion and batch handling, card workflow-rehearsal-review #3) --
537
-
538
- function runBoardCli(args) {
539
- const r = spawnSync(process.execPath, [BOARD_MJS, ...args], { encoding: "utf8", env: cleanEnv() });
540
- return { ok: r.status === 0, stdout: r.stdout || "", stderr: r.stderr || "" };
541
- }
542
-
543
- // The one-line note a rehearsal's run entry carries (runEntry/handEntry's `note`, rendered where handEntry
544
- // already renders one). The swarm scenario's note also names the plugin and the agents its report resolved,
545
- // since that scenario is the one that proves plugin loading and agent-name resolution in the real runtime.
546
- // A stage's own message (for example the approve act's "the plan also owns ..." note) is folded into the stage
547
- // list so it reaches the record instead of being discarded (card workflow-rehearsal-review #10).
548
- export function rehearsalNote({ scenario, outcome, stages, cost = null, agentNames = null }) {
549
- const stageList = stages.map((s) => `${s.name} (${s.wallClock})${s.message ? ` — ${s.message}` : ""}`).join(", ");
550
- const base =
551
- scenario === "hand"
552
- ? `Rehearsal hand on the ts-basic fixture: ${outcome}; cost ${cost === null ? "not measured" : `$${Number(cost).toFixed(2)}`}; stages ${stageList}`
553
- : `Rehearsal ${scenario} on the ts-basic fixture: ${outcome}; stages ${stageList}`;
554
- if (scenario !== "swarm") return base;
555
- return `${base}; plugin loading and agent-name resolution asserted in the runtime (plugins: doug-flow; agents resolved: ${(agentNames || []).join(", ") || "none"})`;
556
- }
557
-
558
- // The fixture's own hand-track "| Gate |" line (the fixture's /core-next build session ran board.mjs record
559
- // --hand on itself): the last such line in the fixture's docs/live-runs.md, or null when there is none, so the
560
- // real repository's record can carry the same gate text the fixture build reported, or "not observed".
561
- export function extractGateLine(dir) {
562
- const file = join(dir, "docs/live-runs.md");
563
- if (!existsSync(file)) return null;
564
- const matches = [...readFileSync(file, "utf8").matchAll(/\|\s*Gate\s*\|\s*(.*?)\s*\|/g)];
565
- if (!matches.length) return null;
566
- const last = matches[matches.length - 1][1];
567
- return last && last !== "not recorded" ? last : null;
568
- }
569
-
570
- // Records a flow or swarm outcome under `cardId` on the board at `dir` (the real repository by default; a temp
571
- // copy in tests) by spawning `board.mjs record <id> <report> [dir] ...` exactly as a normal run does. Throws on a
572
- // non-zero exit (the caller decides how to report that; it is never swallowed).
573
- export function recordRunOutcome(dir, cardId, { reportFile, wallClock = null, cost = null, mergeCommit = null, note = null, rehearsal = null }) {
574
- const args = ["record", cardId, reportFile];
575
- if (cost !== null) args.push("--cost", String(cost));
576
- if (wallClock !== null) args.push("--wall", wallClock);
577
- if (mergeCommit) args.push("--commit", mergeCommit);
578
- if (note) args.push("--note", note);
579
- if (rehearsal) args.push("--rehearsal", rehearsal);
580
- args.push(dir);
581
- const r = runBoardCli(args);
582
- if (!r.ok) throw new Error(`board.mjs record failed: ${r.stderr || r.stdout}`);
583
- return r.stdout;
584
- }
585
-
586
- // Records a hand outcome under `cardId` on the board at `dir` by spawning `board.mjs record <id> --hand [dir]
587
- // ...` exactly as a normal hand-track landing does.
588
- export function recordHandOutcome(dir, cardId, { commit = null, wallClock = null, gate = null, note = null, rehearsal = null }) {
589
- const args = ["record", cardId, "--hand"];
590
- if (commit) args.push("--commit", commit);
591
- if (wallClock !== null) args.push("--wall", wallClock);
592
- if (gate !== null) args.push("--gate", gate);
593
- if (note) args.push("--note", note);
594
- if (rehearsal) args.push("--rehearsal", rehearsal);
595
- args.push(dir);
596
- const r = runBoardCli(args);
597
- if (!r.ok) throw new Error(`board.mjs record --hand failed: ${r.stderr || r.stdout}`);
598
- return r.stdout;
599
- }
600
-
601
- // ---- The three scenarios, stage by stage ------------------------------------------------------------------
602
- // Each entry is a session stage (spawns `claude -p`, then asserts on the stream and the fixture) or an act stage
603
- // (the runner performs the human gate's act, or a deterministic step like `plan.mjs land`, itself). A session
604
- // stage's `prompt`/`assert` take `{ dir, card, baseline, stream }`; an act stage's `run` takes `{ dir, card,
605
- // baseline }` and returns `{ ok, message, ...extra }` (`commit` when it lands or builds something).
606
- export const SCENARIO_STAGES = {
607
- hand: [
608
- {
609
- kind: "session",
610
- name: "gate",
611
- maxTurns: 12,
612
- prompt: () => "/core-next fix-hours",
613
- assert: ({ dir, stream }) => assertGateHeld(dir, stream, { plugins: ["doug-flow"], command: "doug-flow:core-next", cardId: "fix-hours", unchangedFiles: ["src/duration.ts"] }),
614
- },
615
- {
616
- kind: "act",
617
- name: "start-gate",
618
- run: ({ dir }) => {
619
- // task rehearse-uncommitted-move: the board move stays uncommitted working state (it lands only in the
620
- // card's landing commit), so this act no longer commits it.
621
- const move = act(BOARD_MJS, ["move", "fix-hours", "flow"], dir);
622
- if (!move.ok) return fail(`board.mjs move failed: ${move.stderr || move.stdout}`);
623
- // /core-next step 2 stamps the landing's condition before the brief (plugins/doug-flow/skills/core-next/SKILL.md):
624
- // `memory.mjs condition open <id>` writes .doug/.state/reports/<id>/condition.json, and refuses a classless
625
- // card or a dir with no git HEAD — the fixture already has a HEAD (its own baseline commit) regardless of
626
- // the uncommitted move above. `act()` passes `dir` positionally and spawns with cleanEnv(), so this writes
627
- // into the fixture, never this repository (dirFrom prefers CLAUDE_PROJECT_DIR, which cleanEnv scrubs, over
628
- // that positional argument).
629
- const condition = act(MEMORY_MJS, ["condition", "open", "fix-hours"], dir);
630
- if (!condition.ok) return fail(`memory.mjs condition open failed: ${condition.stderr || condition.stdout}`);
631
- return pass("moved fix-hours to In flow (uncommitted) and opened its condition, standing in for the start gate");
632
- },
633
- },
634
- {
635
- kind: "session",
636
- name: "build",
637
- maxTurns: 60,
638
- prompt: () =>
639
- [
640
- "/core-next fix-hours",
641
- "I answered Start at the start gate and moved the card to In flow myself (it is uncommitted), and I chose by hand. Do not invoke the doug-board skill at any step: this checkout publishes nothing. Do steps 3 and 4, then stop before step 5: do not suggest follow-up cards and do not ask the continue gate.",
642
- ].join("\n\n"),
643
- assert: (ctx) => assertHandBuilt(ctx),
644
- },
645
- ],
646
- flow: [
647
- {
648
- kind: "session",
649
- name: "gate",
650
- maxTurns: 30,
651
- prompt: () => "/doug-next fix-hours",
652
- assert: ({ dir, stream }) => assertFlowGateHeld(dir, stream, { plugins: ["doug-flow"], command: "doug-flow:doug-next", cardId: "fix-hours" }),
653
- },
654
- {
655
- kind: "act",
656
- name: "approve",
657
- run: ({ dir }) => {
658
- const setCard = act(PLAN_MJS, ["set", "card", "fix-hours"], dir);
659
- if (!setCard.ok) return fail(`plan.mjs set card failed: ${setCard.stderr || setCard.stdout}`);
660
- let plan = null;
661
- try {
662
- plan = JSON.parse(readFileSync(join(dir, ".doug/plan.json"), "utf8"));
663
- } catch {
664
- // approve below reports the same problem
665
- }
666
- const owned = new Set((plan && plan.tasks ? plan.tasks : []).flatMap((t) => t.files || []));
667
- const outOfScope = [...owned].filter((f) => f !== "src/duration.ts" && f !== "tests/duration.test.ts");
668
- const approve = act(PLAN_MJS, ["approve"], dir);
669
- if (!approve.ok) return fail(`plan.mjs approve failed: ${approve.stderr || approve.stdout}`);
670
- return pass(outOfScope.length ? `approved; the plan also owns ${outOfScope.join(", ")} (noted, not blocking)` : "approved");
671
- },
672
- },
673
- {
674
- kind: "session",
675
- name: "implement",
676
- maxTurns: 40,
677
- prompt: () => ["/doug-implement", "When the run completes, write the `result` object of the Workflow tool's output to .doug/.state/last-report.json with the Write tool, then reply done."].join("\n\n"),
678
- assert: ({ dir, stream }) => assertImplemented(dir, stream, { plugins: ["doug-flow"] }),
679
- },
680
- {
681
- kind: "act",
682
- name: "land",
683
- run: ({ dir }) => landAndVerify(dir),
684
- },
685
- ],
686
- swarm: [
687
- {
688
- kind: "session",
689
- name: "implement",
690
- maxTurns: 40,
691
- prompt: () => ["/doug-implement", "When the run completes, write the `result` object of the Workflow tool's output to .doug/.state/last-report.json with the Write tool, then reply done."].join("\n\n"),
692
- assert: ({ dir, stream }) => assertImplemented(dir, stream, { plugins: ["doug-flow"], requireWorkers: true }),
693
- },
694
- {
695
- kind: "act",
696
- name: "land",
697
- run: ({ dir }) => landAndVerify(dir),
698
- },
699
- ],
700
- };
701
-
702
- // The hand scenario's build assertion (card workflow-rehearsal-review #1): /core-next's own step 4.3 commits the
703
- // board and docs/live-runs.md AFTER the code commit ("Board: <id> done as <sha>"), so HEAD is that board commit,
704
- // not the code commit — take every commit since the fixture's recorded baseline instead of assuming HEAD is it,
705
- // require that one of them changes both owned files, and require the board's `source` to name THAT commit.
706
- export function assertHandBuilt({ dir, baseline }) {
707
- if (!baseline) return fail("no baseline commit was recorded for this fixture");
708
- let shas;
709
- try {
710
- shas = execFileSync("git", ["rev-list", `${baseline}..HEAD`], { cwd: dir, encoding: "utf8", env: cleanEnv() }).trim().split("\n").filter(Boolean);
711
- } catch (err) {
712
- return fail(`git rev-list failed: ${err.message}`);
713
- }
714
- if (!shas.length) return fail("no commits since the fixture baseline; the build never committed");
715
- let codeCommit = null;
716
- for (const sha of shas) {
717
- const files = execFileSync("git", ["show", "--name-only", "--pretty=format:", sha], { cwd: dir, encoding: "utf8", env: cleanEnv() }).split("\n").filter(Boolean);
718
- if (files.includes("src/duration.ts") && files.includes("tests/duration.test.ts")) {
719
- codeCommit = sha;
720
- break;
721
- }
722
- }
723
- if (!codeCommit) return fail(`no commit since the fixture baseline changes both src/duration.ts and tests/duration.test.ts (commits since baseline: ${shas.join(", ")})`);
724
- let board;
725
- try {
726
- board = JSON.parse(readFileSync(boardPath(dir), "utf8"));
727
- } catch (err) {
728
- return fail(`could not read the fixture board: ${err.message}`);
729
- }
730
- const card = (board.cards || []).find((c) => c.id === "fix-hours");
731
- if (!card || card.column !== "done") return fail("the fixture board does not have fix-hours in done");
732
- if (!card.source || !String(card.source).includes(codeCommit.slice(0, 7))) return fail(`fix-hours's source does not name the code commit ${codeCommit} (source: ${card.source || "none"})`);
733
- const runs = existsSync(join(dir, "docs/live-runs.md")) ? readFileSync(join(dir, "docs/live-runs.md"), "utf8") : "";
734
- if (!runs.includes("`fix-hours`") || !runs.includes("hand track, decision 0005")) return fail("docs/live-runs.md carries no hand-track entry for fix-hours (board.mjs record --hand did not run)");
735
- const held = runHeldOutAndClean(dir);
736
- if (!held.ok) return held;
737
- return pass(`built as ${codeCommit}`, { commit: codeCommit });
738
- }
739
-
740
- // HEAD is a merge commit (two parents), not a fast-forward or a no-op: `git rev-list --parents -n 1 HEAD` lists
741
- // the commit followed by each parent, so a plain commit has one token after it and a merge has two or more.
742
- export function assertTwoParents(dir) {
743
- const commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: dir, encoding: "utf8", env: cleanEnv() }).trim();
744
- const parents = execFileSync("git", ["rev-list", "--parents", "-n", "1", "HEAD"], { cwd: dir, encoding: "utf8", env: cleanEnv() }).trim().split(" ");
745
- if (parents.length < 3) return fail(`HEAD (${commit}) has ${parents.length - 1} parent(s); land should have produced a merge commit with two`);
746
- return pass(`HEAD is a merge commit: ${commit}`, { commit });
747
- }
748
-
749
- // The runner act shared by the flow and swarm scenarios' last stage: `plan.mjs done`, `plan.mjs land`, a check
750
- // that land actually produced a merge commit (two parents) rather than a fast-forward or a no-op, then the
751
- // held-out test on the landed fixture (as /doug-next step 5 and the eval scorer both do).
752
- function landAndVerify(dir) {
753
- const done = act(PLAN_MJS, ["done"], dir);
754
- if (!done.ok) return fail(`plan.mjs done failed: ${done.stderr || done.stdout}`);
755
- const land = act(PLAN_MJS, ["land"], dir);
756
- if (!land.ok) return fail(`plan.mjs land failed: ${land.stderr || land.stdout}`);
757
- const merged = assertTwoParents(dir);
758
- if (!merged.ok) return merged;
759
- const held = runHeldOutAndClean(dir);
760
- if (!held.ok) return held;
761
- return pass(`landed as ${merged.commit}`, { commit: merged.commit });
762
- }
763
-
764
- // ---- Running a whole scenario end to end (never called by the tests: it spawns the real claude) ---------------
765
-
766
- // Runs every stage of `scenario` in order, stopping at the first failed assertion. Every stage runs through
767
- // runSessionStage/runActStage, so a throw anywhere (a missing binary, a killed session, an assertion that threw)
768
- // becomes a normal failed stage instead of crashing the run (card workflow-rehearsal-review #5). Returns
769
- // `{ scenario, outcome, stages, totalCost, wallClock, fixtureDir, commit, reportFile, reportSynthesized,
770
- // failedStage, failedMessage, agentNames }`. For flow/swarm, `reportFile` always exists on return: the real one
771
- // when the workflow wrote it, otherwise a small synthetic one this function writes (never inventing task rows).
772
- export function runScenario(scenario, { tempPrefix } = {}) {
773
- const started = Date.now();
774
- const { dir, card, baseline } = prepareFixture({ scenario, tempPrefix });
775
- const stages = [];
776
- let outcome = "passed";
777
- let commit = null;
778
- let failedStage = null;
779
- let failedMessage = null;
780
- for (const stage of SCENARIO_STAGES[scenario]) {
781
- const stageStarted = Date.now();
782
- const result = stage.kind === "session" ? runSessionStage(stage, { dir, card, baseline, capUsd: capFor(scenario, stage.name) }) : runActStage(stage, { dir, card, baseline });
783
- const wallClock = `${Math.round((Date.now() - stageStarted) / 6000) / 10} min`;
784
- stages.push({ name: stage.name, wallClock, cost: result.cost, streamPath: result.streamPath, message: result.message });
785
- if (result.commit) commit = result.commit;
786
- if (!result.ok) {
787
- outcome = `failed at ${stage.name}: ${result.message}`;
788
- failedStage = stage.name;
789
- failedMessage = result.message;
790
- break;
791
- }
792
- }
793
- const totalCost = stages.reduce((sum, s) => sum + (s.cost || 0), 0);
794
-
795
- let reportFile = null;
796
- let reportSynthesized = false;
797
- let agentNames = [];
798
- if (scenario !== "hand") {
799
- const realReport = join(dir, RESULTS_FILE);
800
- if (existsSync(realReport)) {
801
- reportFile = realReport;
802
- if (scenario === "swarm") agentNames = agentNamesFromReport(readReport(dir).report || {});
803
- } else {
804
- reportFile = join(dir, SYNTHETIC_REPORT_RELPATH);
805
- mkdirSync(dirname(reportFile), { recursive: true });
806
- writeFileSync(reportFile, JSON.stringify(syntheticReport(scenario, failedStage || "prepare", failedMessage || "no stage ran"), null, 2) + "\n");
807
- reportSynthesized = true;
808
- }
809
- }
810
-
811
- return {
812
- scenario,
813
- outcome,
814
- stages,
815
- totalCost,
816
- wallClock: `${Math.round((Date.now() - started) / 6000) / 10} min`,
817
- fixtureDir: dir,
818
- commit,
819
- reportFile,
820
- reportSynthesized,
821
- failedStage,
822
- failedMessage,
823
- agentNames,
824
- };
825
- }