@dougharness/cli 1.1.1 → 1.2.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 (50) hide show
  1. package/dist/board-serve.js +40 -6
  2. package/dist/board-serve.js.map +1 -1
  3. package/dist/init.js +1 -1
  4. package/dist/init.js.map +1 -1
  5. package/dist/memory-setup.d.ts +7 -0
  6. package/dist/memory-setup.js +70 -46
  7. package/dist/memory-setup.js.map +1 -1
  8. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +6 -2
  9. package/node_modules/@dougharness/flow/agents/coder.md +23 -0
  10. package/node_modules/@dougharness/flow/agents/spec-writer.md +23 -0
  11. package/node_modules/@dougharness/flow/agents/tester.md +21 -0
  12. package/node_modules/@dougharness/flow/hooks/hooks.json +4 -1
  13. package/node_modules/@dougharness/flow/hooks/register.tsx +75 -0
  14. package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +117 -0
  15. package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +17 -0
  16. package/node_modules/@dougharness/flow/lib/board.mjs +91 -10
  17. package/node_modules/@dougharness/flow/lib/card-land.mjs +115 -0
  18. package/node_modules/@dougharness/flow/lib/card-loop.mjs +746 -0
  19. package/node_modules/@dougharness/flow/lib/coordinator.mjs +368 -0
  20. package/node_modules/@dougharness/flow/lib/cost.mjs +15 -5
  21. package/node_modules/@dougharness/flow/lib/embeddings.mjs +144 -3
  22. package/node_modules/@dougharness/flow/lib/land.mjs +27 -8
  23. package/node_modules/@dougharness/flow/lib/memory.mjs +48 -12
  24. package/node_modules/@dougharness/flow/lib/plan.mjs +32 -4
  25. package/node_modules/@dougharness/flow/lib/worktrees.mjs +3 -3
  26. package/node_modules/@dougharness/flow/package.json +1 -1
  27. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +407 -0
  28. package/node_modules/@dougharness/flow/scripts/board.mjs +107 -32
  29. package/node_modules/@dougharness/flow/scripts/cost.mjs +16 -3
  30. package/node_modules/@dougharness/flow/scripts/memory.mjs +20 -9
  31. package/node_modules/@dougharness/flow/scripts/plan.mjs +100 -30
  32. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +8 -2
  33. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +30 -10
  34. package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +80 -0
  35. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +1 -1
  36. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +6 -6
  37. package/node_modules/@dougharness/flow/workflows/card-queen.js +807 -0
  38. package/node_modules/@dougharness/flow/workflows/doug-implement.js +7 -7
  39. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  40. package/node_modules/@dougharness/gates/lib/anchor.mjs +29 -4
  41. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +196 -6
  42. package/node_modules/@dougharness/gates/lib/gate-lock.mjs +40 -8
  43. package/node_modules/@dougharness/gates/lib/proposals.mjs +41 -5
  44. package/node_modules/@dougharness/gates/lib/scope.mjs +43 -4
  45. package/node_modules/@dougharness/gates/package.json +1 -1
  46. package/node_modules/@dougharness/gates/scripts/reanchor.mjs +2 -1
  47. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +138 -10
  48. package/node_modules/@dougharness/gates/scripts/verify.mjs +8 -4
  49. package/package.json +3 -3
  50. package/templates/board-app.js +46 -0
@@ -22,7 +22,7 @@ import { execFileSync, spawnSync } from "node:child_process";
22
22
  import { existsSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
23
23
  import { tmpdir } from "node:os";
24
24
  import { join } from "node:path";
25
- import { acceptanceEntries, integrationBranchFor, savePlan } from "./plan.mjs";
25
+ import { acceptanceEntries, integrationBranchFor, integrationWorktreeFor, savePlan, PLAN_RELPATH } from "./plan.mjs";
26
26
  import { removeFinishedWorktrees } from "./worktrees.mjs";
27
27
 
28
28
  export const INTEGRATION_WORKTREE = ".claude/worktrees/doug-integration";
@@ -109,7 +109,7 @@ function verifyViaVendored(script, dir, work, commands, timeoutMs) {
109
109
  const why = r.error && r.error.code === "ETIMEDOUT" ? "timed out" : "gave no readable result";
110
110
  return { error: `the vendored verify.mjs ${why}; land does not re-run the commands` };
111
111
  }
112
- return out.results.map((x) => ({
112
+ const rows = out.results.map((x) => ({
113
113
  command: x.command,
114
114
  ok: x.ok === true,
115
115
  status: x.status,
@@ -118,13 +118,20 @@ function verifyViaVendored(script, dir, work, commands, timeoutMs) {
118
118
  outputTail: x.outputTail || "",
119
119
  ...(out.credited ? { credited: out.credited } : {}),
120
120
  }));
121
+ if (typeof out.lockWaitMs === "number") rows.lockWaitMs = out.lockWaitMs;
122
+ return rows;
121
123
  } catch (err) {
122
124
  return { error: `the vendored verify.mjs could not run: ${err && err.message}` };
123
125
  }
124
126
  }
125
127
 
126
128
  // Returns { ok: true, base, branch, mergeCommit, verify } or { ok: false, reason, verify? }. Never throws on a refusal.
127
- export function landPlan(dir, plan, { timeoutMs = 600000, now = () => new Date(), deleteBranches = false } = {}) {
129
+ // Card parallel-card-queens B5: rebase (set by plan.mjs land --card) rebases the integration branch onto the base
130
+ // when the base has moved, in the worktree that has the branch checked out, and verifies the rebased tree; a
131
+ // conflict is aborted and refused as "rebase-conflict: <files>". ownOnly removes only this plan's own task
132
+ // branches' worktrees and its own integration worktree (no sweep of worktrees merged into base). planRelpath is
133
+ // where plan.landed is saved (the per-card plan under --card). The result carries lockWaitMs from verify.mjs.
134
+ export function landPlan(dir, plan, { timeoutMs = 600000, now = () => new Date(), deleteBranches = false, rebase = false, ownOnly = false, planRelpath = PLAN_RELPATH } = {}) {
128
135
  if (!plan) return { ok: false, reason: "no plan" };
129
136
  if (plan.status !== "done") return { ok: false, reason: `plan status is "${plan.status}", not "done"; run plan.mjs done after a green doug-implement run` };
130
137
  if (plan.landed) return { ok: false, reason: `plan was already landed as ${plan.landed.mergeCommit} on ${plan.landed.at}` };
@@ -152,15 +159,17 @@ export function landPlan(dir, plan, { timeoutMs = 600000, now = () => new Date()
152
159
  if (dirty.length) return { ok: false, reason: `tracked files are modified: ${dirty.join(", ")}; commit or stash them first` };
153
160
 
154
161
  // Verify on the branch itself, in the integration worktree if the workflow left it, else a temporary one.
155
- const existing = join(dir, INTEGRATION_WORKTREE);
162
+ const existing = join(dir, integrationWorktreeFor(plan));
156
163
  const reuse = existsSync(existing) && tryGit(existing, ["symbolic-ref", "--short", "-q", "HEAD"]) === branch;
157
164
  let work = reuse ? existing : null;
158
165
  let temp = null;
166
+ const needsRebase = rebase && tryGit(dir, ["merge-base", "--is-ancestor", base, branch]) === null;
159
167
  if (!work) {
160
168
  temp = mkdtempSync(join(tmpdir(), "doug-land-"));
161
169
  work = join(temp, "wt");
162
170
  try {
163
- git(dir, ["worktree", "add", "--detach", work, branch]);
171
+ // A rebase moves the branch ref, so the temporary worktree then has the branch itself checked out.
172
+ git(dir, needsRebase ? ["worktree", "add", work, branch] : ["worktree", "add", "--detach", work, branch]);
164
173
  } catch (err) {
165
174
  rmSync(temp, { recursive: true, force: true });
166
175
  return { ok: false, reason: `could not create a worktree for ${branch}: ${tail(err.stderr || err.message, 3)}` };
@@ -168,7 +177,16 @@ export function landPlan(dir, plan, { timeoutMs = 600000, now = () => new Date()
168
177
  }
169
178
  const verify = [];
170
179
  const acceptance = [];
180
+ let lockWaitMs;
171
181
  try {
182
+ if (needsRebase) {
183
+ const rb = tryGitCapture(work, ["rebase", base]);
184
+ if (!rb.ok) {
185
+ const files = (tryGit(work, ["diff", "--name-only", "--diff-filter=U"]) || "").split("\n").filter(Boolean);
186
+ tryGit(work, ["rebase", "--abort"]);
187
+ return { ok: false, reason: `rebase-conflict: ${files.length ? files.join(", ") : tail(rb.error.stderr || rb.error.message, 3)}` };
188
+ }
189
+ }
172
190
  // card verified-tree-record: install always runs through run(). plan.verify goes through the vendored
173
191
  // verify.mjs when the project has one (it credits a verified-tree record of the branch's tree and writes one
174
192
  // for the main checkout after a green run); otherwise, or when it cannot be read, today's loop runs.
@@ -189,6 +207,7 @@ export function landPlan(dir, plan, { timeoutMs = 600000, now = () => new Date()
189
207
  return { ok: false, reason: `verify failed on ${branch}: ${viaVerify.error}`, verify, acceptance: [] };
190
208
  }
191
209
  if (viaVerify) {
210
+ if (typeof viaVerify.lockWaitMs === "number") lockWaitMs = viaVerify.lockWaitMs;
192
211
  for (const r of viaVerify) {
193
212
  verify.push(r);
194
213
  if (!r.ok) return failVerify(r);
@@ -249,7 +268,7 @@ export function landPlan(dir, plan, { timeoutMs = 600000, now = () => new Date()
249
268
  removedWorktrees.push(existingReal);
250
269
  }
251
270
  const taskBranches = (plan.tasks || []).flatMap((t) => [`doug/task-${t.id}`, ...(typeof t.reuse === "string" ? [t.reuse] : [])]);
252
- const wtResult = removeFinishedWorktrees(dir, { branches: taskBranches, mergedInto: base });
271
+ const wtResult = removeFinishedWorktrees(dir, { branches: taskBranches, mergedInto: ownOnly ? null : base });
253
272
  const worktrees = { removed: [...removedWorktrees, ...wtResult.removed], kept: wtResult.kept };
254
273
 
255
274
  const branchesResult = { deleted: [], kept: [] };
@@ -278,6 +297,6 @@ export function landPlan(dir, plan, { timeoutMs = 600000, now = () => new Date()
278
297
  verify: verify.map((v) => ({ command: v.command, ok: v.ok, durationMs: v.durationMs, ...(v.credited ? { credited: v.credited } : {}) })),
279
298
  acceptance: acceptance.map((a) => ({ text: a.text, command: a.command, ok: a.ok, durationMs: a.durationMs, ...(a.reused ? { reused: a.reused } : {}) })),
280
299
  };
281
- savePlan(dir, { ...plan, landed });
282
- return { ok: true, base, branch, mergeCommit, verify, acceptance, worktrees, branches: branchesResult };
300
+ savePlan(dir, { ...plan, landed }, planRelpath);
301
+ return { ok: true, base, branch, mergeCommit, verify, acceptance, worktrees, branches: branchesResult, ...(lockWaitMs !== undefined ? { lockWaitMs } : {}) };
283
302
  }
@@ -55,7 +55,8 @@ const LESSON_KINDS = ["feedback", "project", "pitfall", "pattern"];
55
55
  // row. On a hand row the following columns are NULL because they do not exist on that track — never because
56
56
  // they measured zero: run_id, spec_hash, files, size, shape, models, implemented, verified, reviewed,
57
57
  // review_issues, review_issue_count, adversary_verdict, adversary_blocked, adversary_source, blocks, passes,
58
- // fix_passes, stop_reason, in_scope, budget_spent, tokens, usd, codex_usd, report_hash, report_path, partial. In particular
58
+ // fix_passes, stop_reason, stop_class, in_scope, budget_spent, tokens, codex_usd, report_hash, report_path, partial (usd too,
59
+ // except that a hand landing recorded with --cost carries its measured session cost there; card usd-budget-size-class). In particular
59
60
  // verified is null on a hand row even though the gate passed: no independent verifier ran on that track, and
60
61
  // the gate's own text lives in the `gate` column instead. A consumer that averages fix_passes or counts
61
62
  // blocks across both tracks must filter on track. wall_clock, gate, and note only exist on a hand row; they
@@ -108,6 +109,7 @@ const OUTCOMES_COLUMNS_SQL = `
108
109
  tokens TEXT,
109
110
  usd REAL,
110
111
  codex_usd REAL,
112
+ stop_class TEXT,
111
113
  commit_sha TEXT,
112
114
  track TEXT NOT NULL DEFAULT 'flow',
113
115
  wall_clock TEXT,
@@ -399,6 +401,27 @@ function migrateV7ToV8(db) {
399
401
  }
400
402
  }
401
403
 
404
+ // v8 had no stop_class column on outcomes (card memory-stop-class): the typed cause of a stop, from
405
+ // task.stopClass, so stops can be counted by cause across runs. Additive like v7->v8: one guarded ALTER, then
406
+ // the version bump; old rows stay null. OUTCOMES_COLUMNS_SQL carries stop_class for a v1 store's rebuild, so
407
+ // this step is a no-op there instead of a duplicate-column error.
408
+ const V9_STOP_COLUMNS = [["stop_class", "TEXT"]];
409
+
410
+ function migrateV8ToV9(db) {
411
+ db.exec("BEGIN");
412
+ try {
413
+ const cols = db.prepare("PRAGMA table_info(outcomes)").all().map((c) => c.name);
414
+ for (const [name, type] of V9_STOP_COLUMNS) {
415
+ if (!cols.includes(name)) db.exec(`ALTER TABLE outcomes ADD COLUMN ${name} ${type};`);
416
+ }
417
+ db.exec("PRAGMA user_version = 9");
418
+ db.exec("COMMIT");
419
+ } catch (err) {
420
+ db.exec("ROLLBACK");
421
+ throw err;
422
+ }
423
+ }
424
+
402
425
  export function openMemory(dir) {
403
426
  const file = memoryPath(dir);
404
427
  mkdirSync(dirname(file), { recursive: true });
@@ -407,7 +430,7 @@ export function openMemory(dir) {
407
430
  const version = db.prepare("PRAGMA user_version").get().user_version;
408
431
  if (version === 0) {
409
432
  db.exec(SCHEMA_SQL);
410
- db.exec("PRAGMA user_version = 8");
433
+ db.exec("PRAGMA user_version = 9");
411
434
  } else if (version === 1) {
412
435
  migrateV1ToV2(db);
413
436
  migrateV2ToV3(db);
@@ -416,6 +439,7 @@ export function openMemory(dir) {
416
439
  migrateV5ToV6(db);
417
440
  migrateV6ToV7(db);
418
441
  migrateV7ToV8(db);
442
+ migrateV8ToV9(db);
419
443
  } else if (version === 2) {
420
444
  migrateV2ToV3(db);
421
445
  migrateV3ToV4(db);
@@ -423,29 +447,37 @@ export function openMemory(dir) {
423
447
  migrateV5ToV6(db);
424
448
  migrateV6ToV7(db);
425
449
  migrateV7ToV8(db);
450
+ migrateV8ToV9(db);
426
451
  } else if (version === 3) {
427
452
  migrateV3ToV4(db);
428
453
  migrateV4ToV5(db);
429
454
  migrateV5ToV6(db);
430
455
  migrateV6ToV7(db);
431
456
  migrateV7ToV8(db);
457
+ migrateV8ToV9(db);
432
458
  } else if (version === 4) {
433
459
  migrateV4ToV5(db);
434
460
  migrateV5ToV6(db);
435
461
  migrateV6ToV7(db);
436
462
  migrateV7ToV8(db);
463
+ migrateV8ToV9(db);
437
464
  } else if (version === 5) {
438
465
  migrateV5ToV6(db);
439
466
  migrateV6ToV7(db);
440
467
  migrateV7ToV8(db);
468
+ migrateV8ToV9(db);
441
469
  } else if (version === 6) {
442
470
  migrateV6ToV7(db);
443
471
  migrateV7ToV8(db);
472
+ migrateV8ToV9(db);
444
473
  } else if (version === 7) {
445
474
  migrateV7ToV8(db);
446
- } else if (version !== 8) {
475
+ migrateV8ToV9(db);
476
+ } else if (version === 8) {
477
+ migrateV8ToV9(db);
478
+ } else if (version !== 9) {
447
479
  db.close();
448
- throw new Error(`memory store ${file} is user_version ${version}; this code understands up to version 8`);
480
+ throw new Error(`memory store ${file} is user_version ${version}; this code understands up to version 9`);
449
481
  }
450
482
  return {
451
483
  file,
@@ -479,7 +511,7 @@ export function probeSqliteFts5() {
479
511
  // Read-only look at the store for `doug doctor`: creates nothing, migrates nothing, never throws.
480
512
  export function inspectMemory(dir) {
481
513
  const file = memoryPath(dir);
482
- const out = { file, exists: false, userVersion: null, maxUserVersion: 8, quickCheck: null, error: null };
514
+ const out = { file, exists: false, userVersion: null, maxUserVersion: 9, quickCheck: null, error: null };
483
515
  if (!existsSync(file)) return out;
484
516
  out.exists = true;
485
517
  let db;
@@ -719,7 +751,7 @@ export function checkCitation(lesson, checkoutDir, { report } = {}) {
719
751
  }
720
752
  const taskId = rest.slice(0, slash);
721
753
  const blockId = rest.slice(slash + 1);
722
- const found = adversaryBlocks(report).some((b) => b.task === taskId && b.id === blockId);
754
+ const found = adversaryBlocks(report).some((b) => b.task === taskId && (b.id === blockId || b.id === `${taskId}/${blockId}`));
723
755
  return found ? { ok: true, reason: "block holds" } : { ok: false, reason: `no block "${blockId}" of task "${taskId}" in this report` };
724
756
  }
725
757
  const match = citation.match(CITATION_RE);
@@ -1259,6 +1291,7 @@ export function outcomeRows(report, opts) {
1259
1291
  passes,
1260
1292
  fix_passes: fixPasses,
1261
1293
  stop_reason: task.stopReason ?? null,
1294
+ stop_class: task.stopClass ?? null,
1262
1295
  in_scope: toIntOrNull(task.inScope),
1263
1296
  budget_spent: (task.budget && task.budget.spent) || null,
1264
1297
  tokens,
@@ -1329,7 +1362,7 @@ export function conditionConfigHash(dir) {
1329
1362
  // null when none was found: null fills every one of the seven condition columns with null, exactly like a row
1330
1363
  // that has never been stamped. `backfilled` is always 0 here — only `memory.mjs condition backfill`, which never
1331
1364
  // goes through this function, ever sets it to 1.
1332
- export function handOutcomeRow({ card, commit, wallClock = null, gate = null, note = null, now = nowIso(), condition = null } = {}) {
1365
+ export function handOutcomeRow({ card, commit, wallClock = null, gate = null, note = null, now = nowIso(), condition = null, cost = null } = {}) {
1333
1366
  if (!card) throw new Error("handOutcomeRow requires card");
1334
1367
  if (!commit) throw new Error("handOutcomeRow requires commit");
1335
1368
  return {
@@ -1357,10 +1390,11 @@ export function handOutcomeRow({ card, commit, wallClock = null, gate = null, no
1357
1390
  passes: null,
1358
1391
  fix_passes: null,
1359
1392
  stop_reason: null,
1393
+ stop_class: null,
1360
1394
  in_scope: null,
1361
1395
  budget_spent: null,
1362
1396
  tokens: null,
1363
- usd: null,
1397
+ usd: typeof cost === "number" && Number.isFinite(cost) ? cost : null,
1364
1398
  codex_usd: null,
1365
1399
  commit_sha: commit,
1366
1400
  track: "hand",
@@ -1381,7 +1415,7 @@ export function handOutcomeRow({ card, commit, wallClock = null, gate = null, no
1381
1415
  const OUTCOME_COLUMNS = [
1382
1416
  "recorded", "run", "run_id", "report_hash", "report_path", "card", "task", "spec_hash", "files", "size", "shape",
1383
1417
  "models", "implemented", "verified", "reviewed", "review_issues", "review_issue_count", "adversary_verdict",
1384
- "adversary_blocked", "adversary_source", "blocks", "passes", "fix_passes", "stop_reason", "in_scope",
1418
+ "adversary_blocked", "adversary_source", "blocks", "passes", "fix_passes", "stop_reason", "stop_class", "in_scope",
1385
1419
  "budget_spent", "tokens", "usd", "codex_usd", "commit_sha", "track", "wall_clock", "gate", "note", "partial",
1386
1420
  "harness_commit", "config_hash", "class", "arm", "assigned_by", "explore_probability", "backfilled",
1387
1421
  ];
@@ -1507,7 +1541,7 @@ function reflectionRowToObject(row) {
1507
1541
  // Deterministic, no LLM. harmful is an explicit mark (card reflect-harmful-irrelevant-lessons): only an id in
1508
1542
  // `harmfulIds` (the lead's judgement that the lesson misled the work) gets harmful +1, once however often it is
1509
1543
  // named or used; a real block alone bumps nothing. For each task whose memoryUsed is non-empty, a real block earns
1510
- // no helpful (real wins even when the task also verified), else a first-verified task bumps helpful on every used
1544
+ // no helpful (real wins even when the task also verified; except on the hand track, whose reviewer blocks never withhold it), else a first-verified task bumps helpful on every used
1511
1545
  // lesson not named harmful; neither verified nor real does nothing. An id naming no lesson is reported in
1512
1546
  // `unknown`, never an error. apply:false (a dry run's preview) computes the same result without writing anything.
1513
1547
  function computeReflectCounters(m, blocks, report, { apply, harmfulIds = [] }) {
@@ -1527,7 +1561,9 @@ function computeReflectCounters(m, blocks, report, { apply, harmfulIds = [] }) {
1527
1561
  for (const task of level.tasks || []) {
1528
1562
  const used = Array.isArray(task.memoryUsed) ? task.memoryUsed : [];
1529
1563
  if (!used.length) continue;
1530
- const real = blocks.some((b) => b.task === task.id && b.class === "real");
1564
+ // A hand report's blocks are reviewer blockers a later round fixed (card reflect-review-key-text): they are no
1565
+ // evidence the lesson misled, so they never withhold helpful; only --harmful marks that.
1566
+ const real = report.track !== "hand" && blocks.some((b) => b.task === task.id && b.class === "real");
1531
1567
  if (real) {
1532
1568
  for (const id of used) {
1533
1569
  if (!getLesson(m, id) && !named.has(id)) unknown.push(id);
@@ -1595,7 +1631,7 @@ export function buildReflectPrompt(report, { blocks = [], plan = null, existingL
1595
1631
  if (Array.isArray(task.verifierFindings) && task.verifierFindings.length) lines.push(` verifier findings: ${JSON.stringify(task.verifierFindings)}`);
1596
1632
  if (taskBlocks.length) {
1597
1633
  lines.push(" blocks:");
1598
- for (const b of taskBlocks) lines.push(` - report:${task.id}/${b.id} class=${b.class || "unclassified"} reason=${b.reason || "none"}`);
1634
+ for (const b of taskBlocks) lines.push(` - report:${b.id.startsWith(`${task.id}/`) ? b.id : `${task.id}/${b.id}`} class=${b.class || "unclassified"} reason=${b.reason || "none"}`);
1599
1635
  }
1600
1636
  lines.push(` memoryUsed: ${used.length ? used.join(", ") : "none"}`);
1601
1637
  }
@@ -9,6 +9,13 @@ import { isModelName, EFFORTS } from "./models.mjs";
9
9
 
10
10
  export const PLAN_RELPATH = ".doug/plan.json";
11
11
  export const REPORT_RELPATH = ".doug/.state/last-report.json";
12
+ // Per-card plans (card parallel-card-queens): a card run as its own queen keeps its plan at the tracked
13
+ // .doug/plans/<id>.json and its report at .doug/.state/reports/<id>/last-report.json.
14
+ export const PLANS_RELPATH = ".doug/plans";
15
+ export const REPORTS_RELPATH = ".doug/.state/reports";
16
+ export const DEFAULT_INTEGRATION_WORKTREE = ".claude/worktrees/doug-integration";
17
+ export const planPathFor = (card) => `${PLANS_RELPATH}/${card}.json`;
18
+ export const reportPathFor = (card) => `${REPORTS_RELPATH}/${card}/last-report.json`;
12
19
  // One draft per card of a batch (card parallel-cards): the planner for card <id> writes .doug/.state/drafts/<id>.json
13
20
  // and `plan.mjs merge` joins them into the plan file.
14
21
  export const DRAFTS_RELPATH = ".doug/.state/drafts";
@@ -55,8 +62,24 @@ export function draftPath(card) {
55
62
  return `${DRAFTS_RELPATH}/${card}.json`;
56
63
  }
57
64
 
58
- export function savePlan(dir, plan) {
59
- const file = join(dir, PLAN_RELPATH);
65
+ // The plan a card's commands read: its per-card plan when one exists; else the single .doug/plan.json, but only
66
+ // when no card is given or that plan is this card's (plan.card equals it, or plan.cards includes it), so a stale
67
+ // plan for another card is never read. Null otherwise, and when neither exists.
68
+ export function planFor(dir, card) {
69
+ const hasCard = typeof card === "string" && card !== "";
70
+ if (hasCard && existsSync(join(dir, planPathFor(card)))) return loadPlan(dir, planPathFor(card));
71
+ const plan = loadPlan(dir);
72
+ if (!plan || !hasCard) return plan;
73
+ return plan.card === card || (Array.isArray(plan.cards) && plan.cards.includes(card)) ? plan : null;
74
+ }
75
+
76
+ // The plan's integration worktree: its own when set (a per-card run), else the fixed shared path.
77
+ export function integrationWorktreeFor(plan) {
78
+ return (plan && plan.integrationWorktree) || DEFAULT_INTEGRATION_WORKTREE;
79
+ }
80
+
81
+ export function savePlan(dir, plan, relpath = PLAN_RELPATH) {
82
+ const file = join(dir, relpath);
60
83
  mkdirSync(dirname(file), { recursive: true });
61
84
  const tmp = file + "." + process.pid + ".tmp";
62
85
  writeFileSync(tmp, JSON.stringify(plan, null, 2) + "\n");
@@ -76,8 +99,8 @@ export function withSpecHashes(plan) {
76
99
  return { ...plan, tasks: plan.tasks.map((t) => ({ ...t, specHash: specHash(t) })) };
77
100
  }
78
101
 
79
- export function loadReport(dir) {
80
- const file = join(dir, REPORT_RELPATH);
102
+ export function loadReport(dir, relpath = REPORT_RELPATH) {
103
+ const file = join(dir, relpath);
81
104
  if (!existsSync(file)) return null;
82
105
  try {
83
106
  return unwrapReport(JSON.parse(readFileSync(file, "utf8")));
@@ -396,6 +419,11 @@ export function validatePlan(plan) {
396
419
  });
397
420
  }
398
421
  if (!Array.isArray(plan.verify) || !plan.verify.every((v) => typeof v === "string" && v.trim())) errors.push("verify must be an array of command strings");
422
+ if (plan.integrationWorktree !== undefined) {
423
+ const w = plan.integrationWorktree;
424
+ const under = typeof w === "string" && w.startsWith(".claude/worktrees/") && w.length > ".claude/worktrees/".length && !w.split("/").includes("..");
425
+ if (!under) errors.push("integrationWorktree must be a path under .claude/worktrees/ (the gate only recognises worktrees there)");
426
+ }
399
427
  if (plan.card !== undefined && (typeof plan.card !== "string" || !plan.card.trim())) errors.push("card must be a non-empty string when present");
400
428
  // A batch (several cards in one plan) lists its cards; every task then names the card it belongs to.
401
429
  let cards = null;
@@ -198,15 +198,15 @@ export function retireTaskWorktrees(dir, { tasks = [], saveDir }) {
198
198
  return result;
199
199
  }
200
200
 
201
- // Retires the integration worktree at .claude/worktrees/doug-integration, if one is registered (card
201
+ // Retires the integration worktree at `path` (default .claude/worktrees/doug-integration; a per-card run passes its own), if one is registered (card
202
202
  // integration-worktree-stale, 2026-09-08): a leftover from an abandoned run must not survive into the next one,
203
203
  // since the workflow's own leftover-vs-this-run's-own check (integratePrompt's firstIntegration) only runs inside
204
204
  // a run already underway. Removed with --force; the branch is never renamed or deleted (the workflow's
205
205
  // `git worktree add -B` on the next run's first integration resets it from the base branch). A worktree that is
206
206
  // not there is not an error.
207
207
  // Returns { removed: string | null } - the path removed, or null when none was registered.
208
- export function retireIntegrationWorktree(dir) {
209
- const target = join(realpathOrSelf(dir), ".claude", "worktrees", "doug-integration");
208
+ export function retireIntegrationWorktree(dir, path = ".claude/worktrees/doug-integration") {
209
+ const target = resolve(realpathOrSelf(dir), path);
210
210
  const match = taskWorktrees(dir).find((wt) => realpathOrSelf(wt.path) === realpathOrSelf(target));
211
211
  if (!match) return { removed: null };
212
212
  const removal = tryGit(dir, ["worktree", "remove", "--force", match.path]);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dougharness/flow",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "private": true,
5
5
  "description": "Plan, approve, implement one task per worktree, verify, review: a Workflow-tool template and subagents for Claude Code.",
6
6
  "type": "module",