@dougharness/cli 1.1.0 → 1.1.1

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 (37) hide show
  1. package/dist/bin.js +10 -3
  2. package/dist/bin.js.map +1 -1
  3. package/dist/board-serve.js +5 -4
  4. package/dist/board-serve.js.map +1 -1
  5. package/dist/board.d.ts +1 -1
  6. package/dist/board.js +33 -8
  7. package/dist/board.js.map +1 -1
  8. package/dist/memory-setup.js +5 -2
  9. package/dist/memory-setup.js.map +1 -1
  10. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
  11. package/node_modules/@dougharness/flow/agents/lead.md +1 -1
  12. package/node_modules/@dougharness/flow/agents/planner.md +2 -2
  13. package/node_modules/@dougharness/flow/lib/board.mjs +17 -1
  14. package/node_modules/@dougharness/flow/lib/cost.mjs +29 -6
  15. package/node_modules/@dougharness/flow/lib/embeddings.mjs +3 -1
  16. package/node_modules/@dougharness/flow/lib/land.mjs +6 -1
  17. package/node_modules/@dougharness/flow/lib/memory.mjs +49 -22
  18. package/node_modules/@dougharness/flow/lib/plan.mjs +2 -1
  19. package/node_modules/@dougharness/flow/package.json +1 -1
  20. package/node_modules/@dougharness/flow/scripts/board.mjs +35 -14
  21. package/node_modules/@dougharness/flow/scripts/context.mjs +4 -2
  22. package/node_modules/@dougharness/flow/scripts/memory.mjs +10 -6
  23. package/node_modules/@dougharness/flow/scripts/plan.mjs +22 -0
  24. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +4 -4
  25. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +8 -6
  26. package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +1 -1
  27. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +2 -2
  28. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +11 -9
  29. package/node_modules/@dougharness/flow/workflows/doug-implement.js +26 -14
  30. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  31. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +21 -14
  32. package/node_modules/@dougharness/gates/lib/gate-lock.mjs +94 -0
  33. package/node_modules/@dougharness/gates/package.json +1 -1
  34. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +10 -87
  35. package/node_modules/@dougharness/gates/scripts/verify.mjs +58 -3
  36. package/package.json +4 -4
  37. package/templates/board-app.js +2 -1
@@ -9,7 +9,10 @@ import { basename, join } from "node:path";
9
9
  // 0.05x on Opus 5.5); cache writes are 1.25x input for the 5-minute TTL and 2x for the 1-hour TTL. Opus 5.5:
10
10
  // input 4, output 20, cacheRead 0.2, cacheWrite5m 5, cacheWrite1h 8; source
11
11
  // https://platform.claude.com/docs/en/about-claude/pricing, fetched 2026-10-04. Update the date with the table.
12
- export const PRICES_DATED = "2026-10-04";
12
+ // Haiku 5.5 is priced per request by prompt length (the same page, fetched 2026-10-10): a row may carry `over`, the
13
+ // prices for a request whose prompt (input + cacheRead + cacheWrite) is strictly over `over.above` tokens; a request
14
+ // at or under it pays the row's own prices. Sonnet 5.5 cacheRead is 0.1 (corrected 2026-10-10).
15
+ export const PRICES_DATED = "2026-10-10";
13
16
  export const PRICES = {
14
17
  "claude-fable-5-1": { input: 10, output: 50, cacheRead: 0.25, cacheWrite5m: 12.5, cacheWrite1h: 20 },
15
18
  "claude-fable-5": { input: 10, output: 50, cacheRead: 1, cacheWrite5m: 12.5, cacheWrite1h: 20 },
@@ -18,9 +21,13 @@ export const PRICES = {
18
21
  "claude-opus-4-8": { input: 5, output: 25, cacheRead: 0.5, cacheWrite5m: 6.25, cacheWrite1h: 10 },
19
22
  "claude-opus-4-7": { input: 5, output: 25, cacheRead: 0.5, cacheWrite5m: 6.25, cacheWrite1h: 10 },
20
23
  "claude-opus-4-6": { input: 5, output: 25, cacheRead: 0.5, cacheWrite5m: 6.25, cacheWrite1h: 10 },
21
- "claude-sonnet-5-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite5m: 2.5, cacheWrite1h: 4 },
24
+ "claude-sonnet-5-5": { input: 2, output: 10, cacheRead: 0.1, cacheWrite5m: 2.5, cacheWrite1h: 4 },
22
25
  "claude-sonnet-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite5m: 2.5, cacheWrite1h: 4 },
23
26
  "claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite5m: 3.75, cacheWrite1h: 6 },
27
+ "claude-haiku-5-5": {
28
+ input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite5m: 0.125, cacheWrite1h: 0.2,
29
+ over: { above: 100_000, input: 0.5, output: 2.5, cacheRead: 0.05, cacheWrite5m: 0.625, cacheWrite1h: 1 },
30
+ },
24
31
  "claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite5m: 1.25, cacheWrite1h: 2 },
25
32
  };
26
33
 
@@ -61,8 +68,9 @@ function addUsage(a, b) {
61
68
 
62
69
  // Price one usage record for a model; null when the model is not in the table.
63
70
  export function priceUsage(usage, model) {
64
- const p = PRICES[normalizeModel(model)];
71
+ let p = PRICES[normalizeModel(model)];
65
72
  if (!p) return null;
73
+ if (p.over && usage.input + usage.cacheRead + usage.cacheWrite > p.over.above) p = p.over;
66
74
  const write5m = Math.max(0, usage.cacheWrite - usage.cacheWrite1h);
67
75
  return (usage.input * p.input + usage.output * p.output + usage.cacheRead * p.cacheRead + write5m * p.cacheWrite5m + usage.cacheWrite1h * p.cacheWrite1h) / 1e6;
68
76
  }
@@ -100,7 +108,22 @@ export function transcriptUsage(file) {
100
108
  }
101
109
  // The agent's model is the one most of its messages ran on (a transcript names one model in practice).
102
110
  const model = [...models.entries()].sort((a, b) => b[1] - a[1]).map(([m]) => m)[0] || null;
103
- return { usage, model, messages: perMessage.size };
111
+ // The transcript's USD: each message priced on its own model and tier (a tiered model is priced per request), null
112
+ // when any message's model is not in the table. A message without a model uses the transcript's.
113
+ let usd = model ? 0 : null;
114
+ if (model) {
115
+ for (const v of perMessage.values()) {
116
+ // An all-zero message (Claude Code's "<synthetic>" ones) costs nothing whatever its model.
117
+ if (!v.input && !v.output && !v.cacheRead && !v.cacheWrite && !v.cacheWrite1h) continue;
118
+ const price = priceUsage(v, v.model || model);
119
+ if (price === null) {
120
+ usd = null;
121
+ break;
122
+ }
123
+ usd += price;
124
+ }
125
+ }
126
+ return { usage, model, messages: perMessage.size, usd };
104
127
  }
105
128
 
106
129
  // The workflow's label for an agent: `stage:task[:pass]` (`integrate:level-0`, `fix:agents-generator:2`). A swarm's
@@ -254,7 +277,7 @@ export function costRun({ runId, claudeDir, projectDir, report, reportPath } = {
254
277
  agent.model = t.model;
255
278
  agent.messages = t.messages;
256
279
  agent.usage = t.usage;
257
- agent.usd = t.model ? priceUsage(t.usage, t.model) : null;
280
+ agent.usd = t.usd;
258
281
  if (t.model && agent.usd === null) notes.push(`${j.agentId} (${agent.label || agent.stage}): model ${t.model} is not in the price table; not priced`);
259
282
  if (!t.model && t.messages === 0) notes.push(`${j.agentId} (${agent.label || agent.stage}): transcript has no usage lines; not priced`);
260
283
  } else {
@@ -467,7 +490,7 @@ export function costSession({ session, claudeDir = join(homedir(), ".claude"), p
467
490
  const t = transcriptUsage(f);
468
491
  addUsage(usage, t.usage);
469
492
  if (!t.messages) continue;
470
- const price = t.model ? priceUsage(t.usage, t.model) : null;
493
+ const price = t.usd;
471
494
  if (price === null) {
472
495
  unpriced++;
473
496
  notes.push(`${f}: model ${t.model || "unknown"} is not in the price table; not priced`);
@@ -264,7 +264,9 @@ export async function checkEmbeddingSanity(provider, pairs) {
264
264
 
265
265
  // Task prefixes the provider layer must add; the server never adds them. Only the models below carry a
266
266
  // prefix; every other model gets none. mxbai and qwen3 prefix queries only, documents get "".
267
- function prefixFor(model, inputType) {
267
+ export const PREFIXED_MODELS = ["embeddinggemma", "nomic-embed-text", "mxbai-embed-large", "qwen3-embedding"];
268
+
269
+ export function prefixFor(model, inputType) {
268
270
  // Match on the lowercased basename after the last "/", so HF-style names (org/name) and Ollama names agree.
269
271
  let m = String(model || "").split(/[/\\]/).pop().toLowerCase();
270
272
  // LM Studio-style "text-embedding-<name>" ids: match on <name> when it is a prefixed family (not OpenAI's text-embedding-3-*).
@@ -90,10 +90,15 @@ function run(command, cwd, timeoutMs) {
90
90
  // the MAIN checkout (--record-dir). Returns the entries land records under `verify` (command, ok, status, timedOut,
91
91
  // durationMs, outputTail, plus credited: "verified-tree" when credited), stopping at the first failure like
92
92
  // today's loop, or null when the output cannot be read (the caller then runs the loop unchanged).
93
+ // verify.mjs may wait timeoutMs x n on a held gate.lock and then run n commands of timeoutMs each.
94
+ export function vendoredVerifySpawnTimeoutMs(timeoutMs, n) {
95
+ return 2 * timeoutMs * n + 60000;
96
+ }
97
+
93
98
  function verifyViaVendored(script, dir, work, commands, timeoutMs) {
94
99
  try {
95
100
  const args = [script, "--json", "--by", "land", "--cwd", work, "--record-dir", dir, "--timeout-ms", String(timeoutMs), ...commands.flatMap((c) => ["--command", c])];
96
- const r = spawnSync(process.execPath, args, { cwd: work, encoding: "utf8", timeout: timeoutMs * commands.length + 60000, maxBuffer: 64 * 1024 * 1024, env: commandEnv() });
101
+ const r = spawnSync(process.execPath, args, { cwd: work, encoding: "utf8", timeout: vendoredVerifySpawnTimeoutMs(timeoutMs, commands.length), maxBuffer: 64 * 1024 * 1024, env: commandEnv() });
97
102
  let out = null;
98
103
  try {
99
104
  out = JSON.parse(String(r.stdout || "").trim().split("\n").pop());
@@ -18,7 +18,7 @@ import { homedir } from "node:os";
18
18
  import { request as httpRequest } from "node:http";
19
19
  import { request as httpsRequest } from "node:https";
20
20
  import { classifyBlocks, adversaryBlocks } from "./board.mjs";
21
- import { transcriptUsage, priceUsage, projectSlug, CODEX_PRICES, CODEX_DEFAULT_MODEL } from "./cost.mjs";
21
+ import { transcriptUsage, projectSlug, CODEX_PRICES, CODEX_DEFAULT_MODEL } from "./cost.mjs";
22
22
  import { MODELS_HEADING } from "./models.mjs";
23
23
 
24
24
  let DatabaseSync;
@@ -958,18 +958,20 @@ export async function recallLessons(m, query, opts = {}) {
958
958
  // stale and drops it, backfilling from the next candidate so k are returned when enough candidates exist,
959
959
  // and a pass sets confirmed. Without one (checked: false), candidates are taken as-is — nothing is marked
960
960
  // stale or confirmed, since there is nothing trustworthy to check them against.
961
+ // Every lessons column except the blob, derived from the live schema so a later column cannot drop out.
962
+ const loadCols = m
963
+ .prepare("PRAGMA table_info(lessons)")
964
+ .all()
965
+ .map((c) => c.name)
966
+ .filter((n) => n !== "embedding")
967
+ .join(", ");
968
+ const loadById = m.prepare(`SELECT ${loadCols}, embedding IS NOT NULL AS has_vec FROM lessons WHERE id = ?`);
961
969
  const result = [];
962
970
  let idx = 0;
963
971
  while (result.length < k && idx < candidates.length) {
964
972
  const cand = candidates[idx++];
965
973
  // Every column except the blob (and the model/dims toRecallLesson strips anyway); hasVector comes from has_vec.
966
- const { has_vec, ...full } = m
967
- .prepare(
968
- `SELECT id, text, kind, scope, citation, source_agent, source_model, task, card, commit_sha, created, confirmed,
969
- helpful, harmful, superseded_by, embedding_model, embedding_dims, stale, embedding IS NOT NULL AS has_vec
970
- FROM lessons WHERE id = ?`
971
- )
972
- .get(cand.lesson.id);
974
+ const { has_vec, ...full } = loadById.get(cand.lesson.id);
973
975
  cand.lesson = lessonRowToObject({ ...full, embedding: has_vec ? true : null });
974
976
  if (!checked) {
975
977
  result.push(toRecallLesson(cand.lesson, { score: cand.score, sides: cand.sides }));
@@ -1502,14 +1504,25 @@ function reflectionRowToObject(row) {
1502
1504
  };
1503
1505
  }
1504
1506
 
1505
- // Deterministic, no LLM: for each task whose memoryUsed is non-empty, a real block bumps harmful on every used
1506
- // lesson that exists (real wins even when the task also verified), else a first-verified task bumps helpful;
1507
- // neither verified nor real does nothing. An id naming no lesson is reported in `unknown`, never an error.
1508
- // apply:false (a dry run's preview) computes the same result without writing anything.
1509
- function computeReflectCounters(m, blocks, report, { apply }) {
1507
+ // Deterministic, no LLM. harmful is an explicit mark (card reflect-harmful-irrelevant-lessons): only an id in
1508
+ // `harmfulIds` (the lead's judgement that the lesson misled the work) gets harmful +1, once however often it is
1509
+ // 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
1511
+ // lesson not named harmful; neither verified nor real does nothing. An id naming no lesson is reported in
1512
+ // `unknown`, never an error. apply:false (a dry run's preview) computes the same result without writing anything.
1513
+ function computeReflectCounters(m, blocks, report, { apply, harmfulIds = [] }) {
1510
1514
  const helpful = [];
1511
1515
  const harmful = [];
1512
1516
  const unknown = [];
1517
+ const named = new Set(harmfulIds);
1518
+ for (const id of named) {
1519
+ if (getLesson(m, id)) {
1520
+ if (apply) m.prepare("UPDATE lessons SET harmful = harmful + 1 WHERE id = ?").run(id);
1521
+ harmful.push(id);
1522
+ } else {
1523
+ unknown.push(id);
1524
+ }
1525
+ }
1513
1526
  for (const level of report.levels || []) {
1514
1527
  for (const task of level.tasks || []) {
1515
1528
  const used = Array.isArray(task.memoryUsed) ? task.memoryUsed : [];
@@ -1517,15 +1530,11 @@ function computeReflectCounters(m, blocks, report, { apply }) {
1517
1530
  const real = blocks.some((b) => b.task === task.id && b.class === "real");
1518
1531
  if (real) {
1519
1532
  for (const id of used) {
1520
- if (getLesson(m, id)) {
1521
- if (apply) m.prepare("UPDATE lessons SET harmful = harmful + 1 WHERE id = ?").run(id);
1522
- harmful.push(id);
1523
- } else {
1524
- unknown.push(id);
1525
- }
1533
+ if (!getLesson(m, id) && !named.has(id)) unknown.push(id);
1526
1534
  }
1527
1535
  } else if (task.verified === true) {
1528
1536
  for (const id of used) {
1537
+ if (named.has(id)) continue;
1529
1538
  if (getLesson(m, id)) {
1530
1539
  if (apply) m.prepare("UPDATE lessons SET helpful = helpful + 1 WHERE id = ?").run(id);
1531
1540
  helpful.push(id);
@@ -1756,7 +1765,7 @@ function computeReflectCost({ dir, claudeDir, sessionId, totalCostUsd }) {
1756
1765
  if (existsSync(file)) {
1757
1766
  try {
1758
1767
  const t = transcriptUsage(file);
1759
- const usd = t.model ? priceUsage(t.usage, t.model) : null;
1768
+ const usd = t.usd;
1760
1769
  if (usd !== null) return { usd, usdSource: "transcript" };
1761
1770
  } catch {
1762
1771
  // fall through to the envelope's own number
@@ -1785,6 +1794,7 @@ export async function runReflect(m, report, opts = {}) {
1785
1794
  model = null,
1786
1795
  claudeDir,
1787
1796
  dryRun = false,
1797
+ harmful = [],
1788
1798
  propose,
1789
1799
  now = nowIso(),
1790
1800
  } = opts;
@@ -1798,13 +1808,30 @@ export async function runReflect(m, report, opts = {}) {
1798
1808
  // happen before any write, so it is the very first thing after the idempotency check.
1799
1809
  const blocks = classifyBlocks(report, classes);
1800
1810
 
1811
+ // A harmful id must be one a task recalled; refused before any write, naming it and the recalled ids.
1812
+ const harmfulIds = [...new Set(harmful)];
1813
+ if (harmfulIds.length) {
1814
+ const recalled = new Set();
1815
+ for (const level of report.levels || []) {
1816
+ for (const task of level.tasks || []) {
1817
+ if (Array.isArray(task.memoryUsed)) for (const id of task.memoryUsed) recalled.add(id);
1818
+ }
1819
+ }
1820
+ const outside = harmfulIds.filter((id) => !recalled.has(id));
1821
+ if (outside.length) {
1822
+ throw new Error(
1823
+ `--harmful id(s) not in any task's memoryUsed: ${outside.join(", ")}. memoryUsed ids: ${recalled.size ? [...recalled].join(", ") : "(none)"}.`
1824
+ );
1825
+ }
1826
+ }
1827
+
1801
1828
  let counters;
1802
1829
  if (dryRun) {
1803
- counters = computeReflectCounters(m, blocks, report, { apply: false });
1830
+ counters = computeReflectCounters(m, blocks, report, { apply: false, harmfulIds });
1804
1831
  } else {
1805
1832
  m.exec("BEGIN");
1806
1833
  try {
1807
- counters = computeReflectCounters(m, blocks, report, { apply: true });
1834
+ counters = computeReflectCounters(m, blocks, report, { apply: true, harmfulIds });
1808
1835
  m.prepare(
1809
1836
  `INSERT INTO reflections (report_hash, recorded, run, card, commit_sha, model, counters, proposed, appended, bumped, rejected, usd, usd_source, session_id)
1810
1837
  VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL)`
@@ -42,7 +42,8 @@ export function crewOf(plan, task) {
42
42
  // feature, never more than eight. The validator warns; it does not refuse, since the planner may have a reason.
43
43
  export const FEATURE_TASKS = 5;
44
44
  export const TASK_CAP = 8;
45
- export const DEFAULT_BUDGET = { agents: 12, tokens: 400000, wallMinutes: 40 };
45
+ // agents 26 is the minimum that lets the default fixAttempts 5 run with one adversary seat when Codex is down and the Claude fallback runs on every pass (card flow-budget-after-parallel-adversary); a supervisor seat or a fix retry is not covered.
46
+ export const DEFAULT_BUDGET = { agents: 26, tokens: 400000, wallMinutes: 40 };
46
47
 
47
48
  export function loadPlan(dir, relpath = PLAN_RELPATH) {
48
49
  const file = join(dir, relpath);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dougharness/flow",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
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",
@@ -27,25 +27,25 @@
27
27
  // flow, or named by .doug/plan.json (each with --force removes it
28
28
  // and prints a warning to stderr); prints the removed card as JSON
29
29
  // to stdout so a mistaken removal can be re-added by hand
30
- // board.mjs record <id> <report.json> [dir] [--cost <usd>] [--codex-cost <usd>] [--shared-cost <usd>] [--wall <text>] [--commit <sha>] [--adversary "<id>=<class>[: <reason>]"]... [--note <text>] [--rehearsal <scenario>] [--run <run-id>] [--claude-dir <dir>]
30
+ // board.mjs record <id> <report.json> [dir] --summary "<one sentence>" [--cost <usd>] [--codex-cost <usd>] [--shared-cost <usd>] [--wall <text>] [--commit <sha>] [--adversary "<id>=<class>[: <reason>]"]... [--note <text>] [--rehearsal <scenario>] [--run <run-id>] [--claude-dir <dir>]
31
31
  // append a run entry for the card to docs/live-runs.md; every adversary block
32
32
  // in the report is classified real, marginal, or false (repeat the flag per block).
33
33
  // When .doug/plan.json lists the card among its `cards` (a batch), the entry
34
34
  // shows only that card's tasks, --cost is that card's tasks' cost, and
35
35
  // --shared-cost <usd> the shared integration agents', counted once
36
- // board.mjs record <id> --hand [dir] [--commit <sha>] [--wall <text>] [--gate <text>] [--note <text>] [--rehearsal <scenario>]
36
+ // board.mjs record <id> --hand [dir] --summary "<one sentence>" [--commit <sha>] [--wall <text>] [--gate <text>] [--note <text>] [--rehearsal <scenario>]
37
37
  // append a hand-track entry (no report) for the card
38
38
  // --rehearsal <scenario> (either form) marks the entry as a rehearse.mjs run on the ts-basic fixture: the first
39
39
  // prose line and the commit sha (abbreviated to 7 characters) read as the fixture's, not this repository's.
40
40
  // Either record form also promotes .doug/.state/research/<id>.md to docs/research/<id>.md when that note
41
41
  // exists and differs from the docs copy, copying it and printing one line saying so; a card with no note, or
42
42
  // whose docs copy is already byte-identical, is untouched, silently.
43
- // board.mjs summary <report.json> [--cost <usd>] [--codex-cost <usd>] [--wall <text>] [--commit <sha>] [--card <id>] [--run <run-id>] [--claude-dir <dir>]
43
+ // board.mjs summary <report.json> --summary "<one sentence>" [--cost <usd>] [--codex-cost <usd>] [--wall <text>] [--commit <sha>] [--card <id>] [--run <run-id>] [--claude-dir <dir>]
44
44
  // the three-line chat summary of a report (--card: that card's tasks only)
45
45
 
46
46
  import { readFileSync } from "node:fs";
47
47
  import { relative, resolve } from "node:path";
48
- import { loadBoard, saveBoard, findCard, nextReadyCard, nextReadyCards, moveCard, editCard, removeCard, reorderCard, readyCapWarning, wallCapMinutes, WALL_BUDGET_MIN, WALL_BUDGET_MIN_N, parseLiveRunWalls, wallBudgetTable, runEntry, handEntry, runSummary, boardPath, parseAdversaryClasses, filterReportForCard, recordLanding } from "../lib/board.mjs";
48
+ import { loadBoard, saveBoard, findCard, nextReadyCard, nextReadyCards, moveCard, editCard, removeCard, reorderCard, readyCapWarning, wallCapMinutes, WALL_BUDGET_MIN, WALL_BUDGET_MIN_N, parseLiveRunWalls, wallBudgetTable, runEntry, handEntry, handWallVerdict, runSummary, boardPath, parseAdversaryClasses, filterReportForCard, recordLanding } from "../lib/board.mjs";
49
49
  import { loadPlan, unwrapReport, DEFAULT_BUDGET } from "../lib/plan.mjs";
50
50
  import { wallForRun } from "../lib/cost.mjs";
51
51
 
@@ -66,10 +66,10 @@ const SUBCOMMAND_FLAGS = {
66
66
  remove: ["force"],
67
67
  reorder: ["before", "after", "top", "bottom"],
68
68
  budgets: ["from"],
69
- summary: ["cost", "codex-cost", "wall", "commit", "card", "run", "claude-dir"],
69
+ summary: ["summary", "cost", "codex-cost", "wall", "commit", "card", "run", "claude-dir"],
70
70
  };
71
- const RECORD_HAND_FLAGS = ["hand", "commit", "wall", "gate", "note", "rehearsal"];
72
- const RECORD_REPORT_FLAGS = ["cost", "codex-cost", "wall", "commit", "adversary", "shared-cost", "note", "rehearsal", "run", "claude-dir"];
71
+ const RECORD_HAND_FLAGS = ["summary", "hand", "commit", "wall", "gate", "note", "rehearsal"];
72
+ const RECORD_REPORT_FLAGS = ["summary", "cost", "codex-cost", "wall", "commit", "adversary", "shared-cost", "note", "rehearsal", "run", "claude-dir"];
73
73
  const opts = {};
74
74
  const positional = [];
75
75
  for (let i = 0; i < argv.length; i++) {
@@ -101,10 +101,10 @@ const USAGE_FOR = {
101
101
  remove: "usage: board.mjs remove <id> [dir] [--force]",
102
102
  reorder: "usage: board.mjs reorder <id> [dir] --before <other> | --after <other> | --top | --bottom",
103
103
  budgets: "usage: board.mjs budgets [dir] [--from <live-runs file>]",
104
- summary: "usage: board.mjs summary <report.json> [--cost <usd>] [--codex-cost <usd>] [--wall <text>] [--commit <sha>] [--card <id>] [--run <run-id>] [--claude-dir <dir>]",
104
+ summary: "usage: board.mjs summary <report.json> --summary \"<one sentence>\" [--cost <usd>] [--codex-cost <usd>] [--wall <text>] [--commit <sha>] [--card <id>] [--run <run-id>] [--claude-dir <dir>]",
105
105
  };
106
- const RECORD_HAND_USAGE = "usage: board.mjs record <id> --hand [dir] [--commit <sha>] [--wall <text>] [--gate <text>] [--note <text>] [--rehearsal <scenario>]";
107
- const RECORD_REPORT_USAGE = "usage: board.mjs record <id> <report.json> [dir] [--cost <usd>] [--codex-cost <usd>] [--shared-cost <usd>] [--wall <text>] [--commit <sha>] [--adversary \"<id>=<class>[: <reason>]\"]... [--note <text>] [--rehearsal <scenario>] [--run <run-id>] [--claude-dir <dir>]";
106
+ const RECORD_HAND_USAGE = "usage: board.mjs record <id> --hand [dir] --summary \"<one sentence>\" [--commit <sha>] [--wall <text>] [--gate <text>] [--note <text>] [--rehearsal <scenario>]";
107
+ const RECORD_REPORT_USAGE = "usage: board.mjs record <id> <report.json> [dir] --summary \"<one sentence>\" [--cost <usd>] [--codex-cost <usd>] [--shared-cost <usd>] [--wall <text>] [--commit <sha>] [--adversary \"<id>=<class>[: <reason>]\"]... [--note <text>] [--rehearsal <scenario>] [--run <run-id>] [--claude-dir <dir>]";
108
108
 
109
109
  // An unknown --flag for the given subcommand: exit 2, name the flag and the subcommand's usage.
110
110
  // Runs on flag names only (parse order doesn't matter), before any work or write.
@@ -128,6 +128,17 @@ if (cmd === "record" || Object.prototype.hasOwnProperty.call(SUBCOMMAND_FLAGS, c
128
128
  }
129
129
  }
130
130
 
131
+ // --summary: the lead's one sentence of what changed for the user, required by record and summary (card
132
+ // landing-one-sentence-summary). Trimmed, non-empty, one line, no sentence end followed by more text.
133
+ // Checked before anything is appended or promoted; printed as stdout's first line, never written to a file.
134
+ let summarySentence = null;
135
+ if (cmd === "record" || cmd === "summary") {
136
+ const text = typeof opts.summary === "string" ? opts.summary.trim() : "";
137
+ if (!text) fail(`--summary "<one sentence>" is required for board.mjs ${cmd}: one sentence, in plain words, of what changed for the user`, 2);
138
+ if (/[\r\n]/.test(text) || /[.!?]\s+\S/.test(text)) fail(`--summary must be one sentence for board.mjs ${cmd}: no newline, and no sentence end followed by more text`, 2);
139
+ summarySentence = text;
140
+ }
141
+
131
142
  try {
132
143
  switch (cmd) {
133
144
  case "next": {
@@ -180,8 +191,11 @@ try {
180
191
  const [id, column] = rest;
181
192
  if (!id || !column) fail("usage: board.mjs move <id> <column> [dir] [--source <text>]", 2);
182
193
  const dir = dirFrom(rest, 2);
183
- saveBoard(dir, moveCard(loadBoard(dir), id, column, { source: opts.source }));
194
+ const moved = moveCard(loadBoard(dir), id, column, { source: opts.source });
195
+ saveBoard(dir, moved);
184
196
  process.stdout.write(`Moved ${id} to ${column} in ${relative(dir, boardPath(dir))}.\n`);
197
+ const capLine = column === "ready" ? readyCapWarning(moved) : null;
198
+ if (capLine) process.stderr.write(capLine);
185
199
  break;
186
200
  }
187
201
  case "edit": {
@@ -252,17 +266,22 @@ try {
252
266
  case "record": {
253
267
  if (opts.hand) {
254
268
  const [id] = rest;
255
- if (!id) fail("usage: board.mjs record <id> --hand [dir] [--commit <sha>] [--wall <text>] [--gate <text>] [--note <text>] [--rehearsal <scenario>]", 2);
269
+ if (!id) fail("usage: board.mjs record <id> --hand [dir] --summary \"<one sentence>\" [--commit <sha>] [--wall <text>] [--gate <text>] [--note <text>] [--rehearsal <scenario>]", 2);
256
270
  const dir = dirFrom(rest, 1);
257
271
  const card = findCard(loadBoard(dir), id);
258
272
  const entry = handEntry({ card, commit: opts.commit ?? null, wallClock: opts.wall ?? null, gate: opts.gate ?? null, note: opts.note ?? null, record: relative(dir, boardPath(dir)), rehearsal: opts.rehearsal ?? null });
259
273
  const { file, promoted } = recordLanding(dir, id, entry);
274
+ process.stdout.write(`${summarySentence}\n`);
275
+ if (!opts.rehearsal) {
276
+ const verdict = handWallVerdict(card, opts.wall ?? null);
277
+ if (verdict.over) process.stdout.write(`Wall budget: ${verdict.text}\n`);
278
+ }
260
279
  if (promoted) process.stdout.write(`Promoted the research note for ${id} to ${promoted}.\n`);
261
280
  process.stdout.write(`Appended a hand-track entry for ${id} to ${file}.\n`);
262
281
  break;
263
282
  }
264
283
  const [id, reportFile] = rest;
265
- if (!id || !reportFile) fail("usage: board.mjs record <id> <report.json> [dir] [--cost <usd>] [--codex-cost <usd>] [--shared-cost <usd>] [--wall <text>] [--commit <sha>] [--adversary \"<id>=<class>[: <reason>]\"]... [--note <text>] [--rehearsal <scenario>] [--run <run-id>] [--claude-dir <dir>]", 2);
284
+ if (!id || !reportFile) fail("usage: board.mjs record <id> <report.json> [dir] --summary \"<one sentence>\" [--cost <usd>] [--codex-cost <usd>] [--shared-cost <usd>] [--wall <text>] [--commit <sha>] [--adversary \"<id>=<class>[: <reason>]\"]... [--note <text>] [--rehearsal <scenario>] [--run <run-id>] [--claude-dir <dir>]", 2);
266
285
  const dir = dirFrom(rest, 2);
267
286
  const card = findCard(loadBoard(dir), id);
268
287
  const report = unwrapReport(JSON.parse(readFileSync(resolve(reportFile), "utf8")));
@@ -273,13 +292,14 @@ try {
273
292
  const wall = opts.run ? wallForRun({ runId: opts.run, claudeDir: opts["claude-dir"], projectDir: dir, wallMinutes: report.budget?.wallMinutes ?? DEFAULT_BUDGET.wallMinutes }) : null;
274
293
  const entry = runEntry({ card, report, wall, cost: opts.cost !== undefined ? Number(opts.cost) : null, codexCost: opts["codex-cost"] !== undefined ? Number(opts["codex-cost"]) : null, wallClock: opts.wall ?? null, mergeCommit: opts.commit ?? null, record: relative(dir, boardPath(dir)), adversary: parseAdversaryClasses(opts.adversary || []), batch, taskIds, sharedCost: opts["shared-cost"] !== undefined ? Number(opts["shared-cost"]) : null, note: opts.note ?? null, rehearsal: opts.rehearsal ?? null });
275
294
  const { file, promoted } = recordLanding(dir, id, entry);
295
+ process.stdout.write(`${summarySentence}\n`);
276
296
  if (promoted) process.stdout.write(`Promoted the research note for ${id} to ${promoted}.\n`);
277
297
  process.stdout.write(`Appended a run entry for ${id} to ${file}.\n`);
278
298
  break;
279
299
  }
280
300
  case "summary": {
281
301
  const [reportFile] = rest;
282
- if (!reportFile) fail("usage: board.mjs summary <report.json> [--cost <usd>] [--codex-cost <usd>] [--wall <text>] [--commit <sha>] [--card <id>] [--run <run-id>] [--claude-dir <dir>]", 2);
302
+ if (!reportFile) fail("usage: board.mjs summary <report.json> --summary \"<one sentence>\" [--cost <usd>] [--codex-cost <usd>] [--wall <text>] [--commit <sha>] [--card <id>] [--run <run-id>] [--claude-dir <dir>]", 2);
283
303
  let report = unwrapReport(JSON.parse(readFileSync(resolve(reportFile), "utf8")));
284
304
  if (opts.card !== undefined) {
285
305
  // With the plan at hand the filter also covers a report whose tasks carry no card.
@@ -289,6 +309,7 @@ try {
289
309
  const wall = opts.run ? wallForRun({ runId: opts.run, claudeDir: opts["claude-dir"], projectDir: process.cwd(), wallMinutes: report.budget?.wallMinutes ?? DEFAULT_BUDGET.wallMinutes }) : null;
290
310
  const sumPlan = opts.card !== undefined ? loadPlan(dirFrom(rest, 1)) : null;
291
311
  const sumBatch = sumPlan && Array.isArray(sumPlan.cards) && sumPlan.cards.includes(opts.card) ? sumPlan.cards : null;
312
+ process.stdout.write(`${summarySentence}\n`);
292
313
  process.stdout.write(runSummary({ report, wall, batch: sumBatch, card: opts.card ?? null, cost: opts.cost !== undefined ? Number(opts.cost) : null, codexCost: opts["codex-cost"] !== undefined ? Number(opts["codex-cost"]) : null, wallClock: opts.wall ?? null, mergeCommit: opts.commit ?? null }));
293
314
  break;
294
315
  }
@@ -2,7 +2,9 @@
2
2
  // The lead's own context percentage (card autopilot-unlimited), read from the session transcript.
3
3
  // context.mjs [dir] [--session <id|latest>] [--claude-dir <path>]
4
4
  // dir the project directory (default cwd); <dir>/.doug/config.json supplies autopilot.contextThreshold
5
- // --session a session id or latest (default latest), as cost.mjs --session
5
+ // --session a session id or latest, as cost.mjs --session. Default: the CLAUDE_CODE_SESSION_ID environment
6
+ // variable (the lead's own session, so a newer non-lead transcript, such as a reflect pass, is not read);
7
+ // latest only when it is unset or empty. A variable naming a session with no transcript exits 1.
6
8
  // --claude-dir where ~/.claude lives (tests point it at a fixture)
7
9
  // Prints one line and exits 0; exits 1 with a stderr message when there is no transcript or no usage line.
8
10
  import { readFileSync } from "node:fs";
@@ -33,7 +35,7 @@ const threshold = contextThreshold(config, (m) => process.stderr.write(`[doug] $
33
35
 
34
36
  let file;
35
37
  try {
36
- file = sessionTranscriptPath({ session: opts.session || "latest", ...(opts["claude-dir"] ? { claudeDir: resolve(opts["claude-dir"]) } : {}), projectDir: dir });
38
+ file = sessionTranscriptPath({ session: opts.session || process.env.CLAUDE_CODE_SESSION_ID || "latest", ...(opts["claude-dir"] ? { claudeDir: resolve(opts["claude-dir"]) } : {}), projectDir: dir });
37
39
  } catch (err) {
38
40
  process.stderr.write(`context: ${err.message}\n`);
39
41
  process.exit(1);
@@ -79,11 +79,14 @@
79
79
  // lesson's citation is re-checked against [dir] only when
80
80
  // [dir] is a real project root (contains .doug)
81
81
  // memory.mjs reflect <report.json> [dir] [--run <wf-id>] [--card <id>] [--commit <sha>]
82
- // [--adversary "<id>=<class>[: <reason>]"]... [--claude-bin <path>] [--claude-dir <path>]
82
+ // [--adversary "<id>=<class>[: <reason>]"]... [--harmful <id>]... [--claude-bin <path>] [--claude-dir <path>]
83
83
  // [--model <name>] [--max-usd <n>] [--dry-run] [--json]
84
84
  // runs after record: applies deterministic helpful/harmful
85
85
  // counters to lessons named in each task's memoryUsed (committed
86
- // even if the LLM pass below fails), then a cheap-row `claude -p
86
+ // even if the LLM pass below fails; a real block alone bumps no
87
+ // harmful: each --harmful <id>, a recalled lesson that misled the
88
+ // work, gets harmful +1 and no helpful; an id outside every
89
+ // task's memoryUsed exits 1 before any write), then a cheap-row `claude -p
87
90
  // --json-schema` pass proposes at most 3 lessons from the report,
88
91
  // filtered in code (a pitfall needs a block classed real, a
89
92
  // pattern needs a first-pass verified task, the citation must be
@@ -96,7 +99,7 @@
96
99
  // transcripts cost.mjs reads, falling back to the CLI's own
97
100
  // cost estimate. Needs Node >=22.16 (node:sqlite).
98
101
  // memory.mjs reflect --hand <card-id> [dir] --commit <sha> [--rounds <n>] [--review "R1=<class>[: <reason>]"]...
99
- // [--note <text>] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]
102
+ // [--harmful <id>]... [--note <text>] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]
100
103
  // (card memory-every-session) the same pass for a hand landing:
101
104
  // assembles .doug/.state/reports/<card-id>/hand-report.json from
102
105
  // the saved briefs/, reviews/ and lessons.json (memoryUsed = its
@@ -204,7 +207,7 @@ import {
204
207
 
205
208
  const argv = process.argv.slice(2);
206
209
  const FLAGS = new Set(["json", "all", "hand", "dry-run", "check", "no-check"]);
207
- const REPEATABLE = new Set(["adversary", "review"]);
210
+ const REPEATABLE = new Set(["adversary", "review", "harmful"]);
208
211
  const opts = {};
209
212
  const positional = [];
210
213
  for (let i = 0; i < argv.length; i++) {
@@ -612,7 +615,7 @@ try {
612
615
  }
613
616
  case "reflect": {
614
617
  const usage =
615
- 'usage: memory.mjs reflect <report.json> [dir] [--run <wf-id>] [--card <id>] [--commit <sha>] [--adversary "<id>=<class>[: <reason>]"]... [--claude-bin <path>] [--claude-dir <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]';
618
+ 'usage: memory.mjs reflect <report.json> [dir] [--run <wf-id>] [--card <id>] [--commit <sha>] [--adversary "<id>=<class>[: <reason>]"]... [--harmful <id>]... [--claude-bin <path>] [--claude-dir <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]';
616
619
  let dir;
617
620
  let reportPath;
618
621
  let reportText;
@@ -621,7 +624,7 @@ try {
621
624
  let handCard = null;
622
625
  if (opts.hand) {
623
626
  const handUsage =
624
- 'usage: memory.mjs reflect --hand <card-id> [dir] --commit <sha> [--rounds <n>] [--review "R1=<real|marginal|false>[: <reason>]"]... [--note <text>] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]';
627
+ 'usage: memory.mjs reflect --hand <card-id> [dir] --commit <sha> [--rounds <n>] [--review "R1=<real|marginal|false>[: <reason>]"]... [--harmful <id>]... [--note <text>] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]';
625
628
  const [id] = rest;
626
629
  if (!id || !opts.commit) fail(handUsage, 2);
627
630
  handCard = id;
@@ -724,6 +727,7 @@ try {
724
727
  model,
725
728
  claudeDir,
726
729
  dryRun,
730
+ harmful: opts.harmful || [],
727
731
  propose,
728
732
  });
729
733
  if (!dryRun && result.ok && result.appended && result.appended.length) {
@@ -51,6 +51,7 @@ import { landPlan, INTEGRATION_WORKTREE } from "../lib/land.mjs";
51
51
  import { removeFinishedWorktrees, retireTaskWorktrees, retireIntegrationWorktree, branchHead, isGitRepo } from "../lib/worktrees.mjs";
52
52
  import { readMemoryConfig, readMemoryStaleDays, readIndexConfig, createProvider } from "../lib/embeddings.mjs";
53
53
  import { existsSync, readFileSync, statSync } from "node:fs";
54
+ import { execFileSync } from "node:child_process";
54
55
 
55
56
  // The project's install command from .doug/config.json, used when the plan does not name one.
56
57
  function configInstall(d) {
@@ -88,8 +89,29 @@ function configDeleteBranches(d) {
88
89
  return false;
89
90
  }
90
91
  }
92
+ // card plan-base-branch-default: the branch checked out in d, or on a detached HEAD its full sha (noted on
93
+ // stderr); null when d is not a git repository or git fails.
94
+ function currentBase(d) {
95
+ const git = (...args) => execFileSync("git", args, { cwd: d, encoding: "utf8", stdio: "pipe" }).trim();
96
+ try {
97
+ const b = git("symbolic-ref", "--short", "-q", "HEAD");
98
+ if (b) return b;
99
+ } catch {}
100
+ try {
101
+ const sha = git("rev-parse", "--verify", "-q", "HEAD");
102
+ if (!sha) return null;
103
+ process.stderr.write(`[doug] plan.mjs: detached HEAD; baseBranch set to ${sha}\n`);
104
+ return sha;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
91
109
  function withDefaults(p, d) {
92
110
  let out = p;
111
+ if (typeof out.baseBranch !== "string" || !out.baseBranch.trim()) {
112
+ const base = currentBase(d);
113
+ if (base) out = { ...out, baseBranch: base };
114
+ }
93
115
  if (!out.install && configInstall(d)) out = { ...out, install: configInstall(d) };
94
116
  const hasCommand = out.adversary && typeof out.adversary === "object" && typeof out.adversary.command === "string" && out.adversary.command.trim();
95
117
  if (out.adversary !== false && !hasCommand && configAdversary(d)) out = { ...out, adversary: { ...(out.adversary && typeof out.adversary === "object" ? out.adversary : {}), command: configAdversary(d) } };
@@ -44,9 +44,9 @@ Size S, or a card with no size at all: by hand, no question.
44
44
 
45
45
  Size M or L: by hand without asking when the goal names a file the running workflow or hooks execute: the workflow file (`workflows/doug-implement.js`), anything under `.doug/hooks/scripts/`, the plugin's agents, plus any list the project's CLAUDE.md, or a procedure it names, gives. The workflow cannot rewrite itself, its gate hooks, or its agents while it runs them, so those stay by hand.
46
46
 
47
- Batch: a batch runs only as a swarm, so for two or more cards this step does not ask swarm-or-by-hand; it says so and goes straight to spawning one planner per card in the same message so they run in parallel, on the `plan` row — the same `plan.mjs models` row and prompt shape below, each told to write its draft to `.doug/.state/drafts/<id>.json` (not `.doug/plan.json`) and validate it with `plan.mjs validate --file .doug/.state/drafts/<id>.json`, each also told this plan runs as a swarm: set `"swarm": true` and size every task M or L unless it is truly one bounded change. When every planner has returned, `node "${CLAUDE_PLUGIN_ROOT}/scripts/plan.mjs" merge <id> <id>...` joins the drafts into `.doug/plan.json` (its output in the chat verbatim), then `node "${CLAUDE_PLUGIN_ROOT}/scripts/plan.mjs" set swarm on`. Never `plan.mjs set card` for a batch: merge's `cards` replaces it. Then `plan.mjs show` (it prints `Cards:` and each task's card) and the approval gate below; Stop on a batch moves every card back to Ready with `board.mjs move <id> ready` (mirroring `/doug-next` section 3's Stop).
47
+ Batch: a batch runs only as a swarm, so for two or more cards this step does not ask swarm-or-by-hand; it says so and goes straight to spawning one planner per card in the same message so they run in parallel, on the `plan` row — the same `plan.mjs models` row and prompt shape below (including its `Scripts: ${CLAUDE_PLUGIN_ROOT}/scripts.` sentence), each told to write its draft to `.doug/.state/drafts/<id>.json` (not `.doug/plan.json`) and validate it with `plan.mjs validate --file .doug/.state/drafts/<id>.json`, each also told this plan runs as a swarm: set `"swarm": true` and size every task M or L unless it is truly one bounded change. When every planner has returned, `node "${CLAUDE_PLUGIN_ROOT}/scripts/plan.mjs" merge <id> <id>...` joins the drafts into `.doug/plan.json` (its output in the chat verbatim), then `node "${CLAUDE_PLUGIN_ROOT}/scripts/plan.mjs" set swarm on`. Never `plan.mjs set card` for a batch: merge's `cards` replaces it. Then `plan.mjs show` (it prints `Cards:` and each task's card) and the approval gate below; Stop on a batch moves every card back to Ready with `board.mjs move <id> ready` (mirroring `/doug-next` section 3's Stop).
48
48
 
49
- Otherwise, for one card, ask with `AskUserQuestion`: run it as a swarm, or by hand. Swarm: spawn the planner the way `doug-plan` does, on the `plan` row: read it with the `plan.mjs models` command, `node "${CLAUDE_PLUGIN_ROOT}/scripts/plan.mjs" models` (`roles.plan.model`, `roles.plan.effort`), and pass that row's model (omit `model` when the row says `inherit`), but tell it this plan runs as a swarm the way `/doug-swarm` does: `Agent({ subagent_type: "doug-flow:planner", model: "<the plan row's model>", prompt: "Board card <id>, title \"<title>\": <goal>. This plan runs as a swarm: set \"swarm\": true in the plan file, and size every task M or L unless it is truly one bounded change (a size-S task keeps a single implementer). Write .doug/plan.json and return the rendered plan." })`. Then `node "${CLAUDE_PLUGIN_ROOT}/scripts/plan.mjs" set card <id>` and `plan.mjs set swarm on` (`node "${CLAUDE_PLUGIN_ROOT}/scripts/plan.mjs" set swarm on`) guarantee the card and the opt-in even if the planner forgot, put `plan.mjs show` in the chat, and ask the approval gate with `AskUserQuestion`: approve as written, edit first, or stop. Approval is never automatic.
49
+ Otherwise, for one card, ask with `AskUserQuestion`: run it as a swarm, or by hand. Swarm: spawn the planner the way `doug-plan` does, on the `plan` row: read it with the `plan.mjs models` command, `node "${CLAUDE_PLUGIN_ROOT}/scripts/plan.mjs" models` (`roles.plan.model`, `roles.plan.effort`), and pass that row's model (omit `model` when the row says `inherit`), but tell it this plan runs as a swarm the way `/doug-swarm` does: `Agent({ subagent_type: "doug-flow:planner", model: "<the plan row's model>", prompt: "Board card <id>, title \"<title>\": <goal>. This plan runs as a swarm: set \"swarm\": true in the plan file, and size every task M or L unless it is truly one bounded change (a size-S task keeps a single implementer). Scripts: ${CLAUDE_PLUGIN_ROOT}/scripts. Write .doug/plan.json and return the rendered plan." })`. Then `node "${CLAUDE_PLUGIN_ROOT}/scripts/plan.mjs" set card <id>` and `plan.mjs set swarm on` (`node "${CLAUDE_PLUGIN_ROOT}/scripts/plan.mjs" set swarm on`) guarantee the card and the opt-in even if the planner forgot, put `plan.mjs show` in the chat, and ask the approval gate with `AskUserQuestion`: approve as written, edit first, or stop. Approval is never automatic.
50
50
 
51
51
  On approve: `plan.mjs approve`, `plan.mjs anchor`, invoke the `swarm-launch` skill. Note the wall clock when it starts and ends. When it returns, save the `result` object of the Workflow tool's output to `.doug/.state/last-report.json` with the Write tool and note the run id the Workflow tool printed (`wf_...`), the way `/doug-next` step 4 does: `plan.mjs replan`, `plan.mjs done`, `board.mjs record`, and `cost.mjs <run-id>` all read those two. If the report carries `paused`, follow `/doug-next` step 4's paused paragraph: name the tasks in `next` and ask with `AskUserQuestion` whether to open the gate; yes reopens with `plan.mjs gate open <level>` and a relaunch with `resumeFromRunId`, no leaves the card In flow and stops — `plan.mjs done` refuses while the last report is paused. A paused or failed batch changes nothing in this mechanism: the paused paragraph and `plan.mjs replan` both work from the plan file, which keeps `cards` through replan, and a second pass is detected the way `/doug-next` section 2 is, by the plan's `cards` matching this batch. Otherwise read the report as `swarm-launch` says, then `plan.mjs done` and `plan.mjs land`; a report that is not ok goes back through `plan.mjs replan` and the approval gate above, or by hand if the user says so — for a batch, "by hand" dissolves it: the first card continues by hand at step 3, and every other card of the batch goes back to Ready with `board.mjs move <id> ready`.
52
52
 
@@ -84,7 +84,7 @@ Stay within the card's goal. A defect found on the way that the goal does not co
84
84
 
85
85
  With the commit sha (the merge sha for a swarmed card) and the wall clock since step 1 (or since 2b's launch, for a swarmed card):
86
86
 
87
- 1. Invoke the `run-report` skill. By hand: its hand-track-landing form, `node "${CLAUDE_PLUGIN_ROOT}/scripts/board.mjs" record <id> --hand --commit <sha> --wall "<m> min" --gate "<gate line>"` (the gate line is run-report's own rule: one `<command> <exit>` clause per entry of `.doug/config.json` `stopGate.commands`, joined by `; `; for example `typecheck 0; test:unit 0 (<n> passed)`), and `--note "<one line>"` when something about the build is worth keeping (what was harder than the goal said, what was left out and why), Hand the form's step 4, `memory.mjs reflect --hand`, two more inputs; they go to that command only, never to `board.mjs record` or `memory.mjs record`, which reject them: `--rounds <n>` (coder rounds until the reviewer passed) and one `--review "R<n>=<real|marginal|false>: <reason>"` per reviewer blocker finding a later round fixed, classed by the lead. Swarmed: its workflow-run form, `node "${CLAUDE_PLUGIN_ROOT}/scripts/board.mjs" record <id> <report.json>` (the file 2b saved, `.doug/.state/last-report.json`) `--commit <merge sha> --wall "<m> min" --cost <usd> --codex-cost <usd>` (add --codex-cost <usd> when cost.mjs printed a Codex line, next to the run total) with one `--adversary "<id>=<real|marginal|false>: <reason>"` per adversary block the report carries (run-report requires every block classified), then `node "${CLAUDE_PLUGIN_ROOT}/scripts/board.mjs" summary <report.json> --wall "<m> min" --commit <merge sha>` and put its three lines in the chat verbatim. Batch: the `run-report` skill's batch form — `cost.mjs <run-id> --by-card` for each card's cost with the shared integration agents counted once under the first, then one `node "${CLAUDE_PLUGIN_ROOT}/scripts/board.mjs" record <id> <report.json> --commit <merge sha> --wall "<m> min" --cost <that card's usd> --shared-cost <shared usd> --adversary ...` per card in the plan's `cards` order, then `node "${CLAUDE_PLUGIN_ROOT}/scripts/board.mjs" summary <report.json> --card <id> --wall "<m> min" --commit <merge sha>` per card with each card's three lines in the chat verbatim.
87
+ 1. Invoke the `run-report` skill. By hand: its hand-track-landing form, `node "${CLAUDE_PLUGIN_ROOT}/scripts/board.mjs" record <id> --hand --commit <sha> --wall "<m> min" --gate "<gate line>" --summary "<one sentence>"` (the gate line is run-report's own rule: one `<command> <exit>` clause per entry of `.doug/config.json` `stopGate.commands`, joined by `; `; for example `typecheck 0; test:unit 0 (<n> passed)`), and `--note "<one line>"` when something about the build is worth keeping (what was harder than the goal said, what was left out and why), Hand the form's step 4, `memory.mjs reflect --hand`, three more inputs; they go to that command only, never to `board.mjs record` or `memory.mjs record`, which reject them: `--rounds <n>` (coder rounds until the reviewer passed) and one `--review "R<n>=<real|marginal|false>: <reason>"` per reviewer blocker finding a later round fixed, classed by the lead, and, third, `--harmful <id>` once per recalled lesson the lead judges actually misled the work (a real block no longer bumps harmful). Swarmed: its workflow-run form, `node "${CLAUDE_PLUGIN_ROOT}/scripts/board.mjs" record <id> <report.json>` (the file 2b saved, `.doug/.state/last-report.json`) `--commit <merge sha> --wall "<m> min" --cost <usd> --codex-cost <usd> --summary "<one sentence>"` (add --codex-cost <usd> when cost.mjs printed a Codex line, next to the run total) with one `--adversary "<id>=<real|marginal|false>: <reason>"` per adversary block the report carries (run-report requires every block classified), then `node "${CLAUDE_PLUGIN_ROOT}/scripts/board.mjs" summary <report.json> --wall "<m> min" --commit <merge sha> --summary "<one sentence>"` and put its sentence, then its three lines, in the chat verbatim. Batch: the `run-report` skill's batch form — `cost.mjs <run-id> --by-card` for each card's cost with the shared integration agents counted once under the first, then one `node "${CLAUDE_PLUGIN_ROOT}/scripts/board.mjs" record <id> <report.json> --commit <merge sha> --wall "<m> min" --cost <that card's usd> --shared-cost <shared usd> --summary "<one sentence>" --adversary ...` per card in the plan's `cards` order, then `node "${CLAUDE_PLUGIN_ROOT}/scripts/board.mjs" summary <report.json> --card <id> --wall "<m> min" --commit <merge sha> --summary "<one sentence>"` per card with each card's sentence and three lines in the chat verbatim. A batch ends with one overall sentence, in plain words, of what the batch completed, after each card's own summary line.
88
88
  2. `node "${CLAUDE_PLUGIN_ROOT}/scripts/board.mjs" move <id> done --source "commit <sha>"`. Batch: once per card of the batch, same merge sha.
89
89
  3. `git add <record> docs/live-runs.md && git commit -m "Board: <id> done as <sha>"` (swarmed: add `.doug/plan.json` too); when step 1's `run-report` invocation printed that it promoted a research note, add `docs/research/<id>.md` too. No trailers. Batch: one commit `"Board: <id>, <id> done as <sha>"` including `.doug/plan.json`.
90
90
 
@@ -98,7 +98,7 @@ The lead says in the chat what it filed and where: each new card's id, title, an
98
98
 
99
99
  ## Cards the user asks for
100
100
 
101
- A card the user asks for is a mini ADR, not a one-line goal. Before filing it, the lead asks the scoping questions in one `AskUserQuestion` of up to 4 questions, each with a Recommended option, and writes the answers into the goal. The goal carries the fixed headings Context / Decision / Options / Questions; `docs/decisions` stays for real ADRs. Only a user-requested card takes this form: a follow-up the lead files keeps step 5's lighter form, so autopilot follow-ups stay question-free.
101
+ A card the user asks for is a mini ADR, not a one-line goal. Before filing it, the lead asks the scoping questions in one `AskUserQuestion` of up to 4 questions, each with a Recommended option, and writes the answers into the goal. The goal carries the fixed headings Context / Decision / Options / Questions; `docs/decisions` stays for real ADRs. Only a user-requested card takes this form: a follow-up the lead files keeps step 5's lighter form, so autopilot follow-ups stay question-free. A card the user asks for while autopilot runs is scoped at once with the same questions, filed to Backlog, and the queue resumes at the card it was on: the request is input, not a gate, and the running queue is unchanged.
102
102
 
103
103
  ## 6. Human gate: continue
104
104