@dougharness/cli 1.4.0 → 1.6.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 (44) hide show
  1. package/dist/ablate.js +2 -2
  2. package/dist/ablate.js.map +1 -1
  3. package/dist/generate/agents.js +4 -0
  4. package/dist/generate/agents.js.map +1 -1
  5. package/dist/generate/claude-md.js +1 -1
  6. package/dist/generate/claude-md.js.map +1 -1
  7. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
  8. package/node_modules/@dougharness/flow/agents/coder.md +2 -0
  9. package/node_modules/@dougharness/flow/agents/implementer.md +2 -0
  10. package/node_modules/@dougharness/flow/agents/planner.md +2 -1
  11. package/node_modules/@dougharness/flow/agents/reviewer.md +2 -0
  12. package/node_modules/@dougharness/flow/agents/tester.md +2 -0
  13. package/node_modules/@dougharness/flow/lib/board.mjs +9 -9
  14. package/node_modules/@dougharness/flow/lib/card-loop.mjs +54 -5
  15. package/node_modules/@dougharness/flow/lib/code-index.mjs +2 -2
  16. package/node_modules/@dougharness/flow/lib/coordinator.mjs +4 -2
  17. package/node_modules/@dougharness/flow/lib/decisions.mjs +3 -5
  18. package/node_modules/@dougharness/flow/lib/learn.mjs +105 -57
  19. package/node_modules/@dougharness/flow/lib/memory.mjs +1418 -57
  20. package/node_modules/@dougharness/flow/lib/plan.mjs +40 -0
  21. package/node_modules/@dougharness/flow/package.json +1 -1
  22. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +178 -1
  23. package/node_modules/@dougharness/flow/scripts/board.mjs +43 -15
  24. package/node_modules/@dougharness/flow/scripts/learn.mjs +4 -2
  25. package/node_modules/@dougharness/flow/scripts/memory.mjs +870 -39
  26. package/node_modules/@dougharness/flow/scripts/plan.mjs +3 -3
  27. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +8 -2
  28. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
  29. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +4 -0
  30. package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +9 -8
  31. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +1 -1
  32. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +6 -3
  33. package/node_modules/@dougharness/flow/workflows/card-queen.js +54 -5
  34. package/node_modules/@dougharness/flow/workflows/doug-implement.js +34 -19
  35. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  36. package/node_modules/@dougharness/gates/lib/baseline.mjs +9 -0
  37. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +6 -3
  38. package/node_modules/@dougharness/gates/lib/gate-lock.mjs +175 -4
  39. package/node_modules/@dougharness/gates/lib/test-totals.mjs +1 -1
  40. package/node_modules/@dougharness/gates/lib/verified-tree.mjs +33 -0
  41. package/node_modules/@dougharness/gates/package.json +1 -1
  42. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +110 -125
  43. package/node_modules/@dougharness/gates/scripts/verify.mjs +47 -13
  44. package/package.json +3 -3
@@ -576,6 +576,46 @@ export function validatePlan(plan) {
576
576
  }
577
577
  });
578
578
  }
579
+ errors.push(...signatureErrors(plan));
580
+ return errors;
581
+ }
582
+
583
+ // Card planner-keeps-stated-signatures (2026-10-23, ts-orders swarm-skill): a signature the goal or an acceptance
584
+ // line quotes in backticks (`name(...)...`) must reach every task spec that quotes the same name, character for
585
+ // character after an optional `export `, `async `, `function ` prefix. Two different signatures for one name are
586
+ // rejected, never guessed between. Non-string goal, spec, and acceptance entries are skipped.
587
+ function signatureErrors(plan) {
588
+ const spans = (s) => (typeof s === "string" ? [...s.matchAll(/`([^`\n]+)`/g)].map((m) => m[1].trim()) : []);
589
+ const strip = (s) => s.replace(/^export\s+/, "").replace(/^async\s+/, "").replace(/^function\s+/, "").replace(/\s*;\s*$/, "").trim();
590
+ // A stated signature is a call form that carries a return type (its last `)` is followed by `:`); an untyped call
591
+ // is an example, never compared.
592
+ const nameOf = (s) => (/\)\s*:/.test(s.slice(s.lastIndexOf(")"))) ? /^([A-Za-z_$][\w$]*)\(/.exec(s)?.[1] : undefined);
593
+ const texts = [plan.goal, ...(Array.isArray(plan.acceptance) ? plan.acceptance.map((a) => (a && typeof a === "object" ? a.text : a)) : [])];
594
+ const stated = new Map(); // name -> signatures (distinct)
595
+ for (const text of texts) {
596
+ for (const span of spans(text)) {
597
+ const sig = strip(span);
598
+ const name = nameOf(sig);
599
+ if (!name) continue;
600
+ if (!stated.has(name)) stated.set(name, []);
601
+ if (!stated.get(name).includes(sig)) stated.get(name).push(sig);
602
+ }
603
+ }
604
+ const errors = [];
605
+ for (const [name, sigs] of stated) {
606
+ if (sigs.length > 1) errors.push(`the goal and acceptance state ${name} two ways, ${sigs.map((s) => `\`${s}\``).join(" and ")}: a stated signature reaches the plan verbatim, so state one`);
607
+ }
608
+ if (!Array.isArray(plan.tasks)) return errors;
609
+ for (const t of plan.tasks) {
610
+ if (!t || typeof t !== "object") continue;
611
+ for (const span of spans(t.spec)) {
612
+ const sig = strip(span);
613
+ const name = nameOf(sig);
614
+ const want = name && stated.get(name);
615
+ if (!want || want.length !== 1 || want[0] === sig) continue;
616
+ errors.push(`task ${t.id} spec states \`${sig}\` but the plan states \`${want[0]}\`: a stated signature reaches the task spec verbatim`);
617
+ }
618
+ }
579
619
  return errors;
580
620
  }
581
621
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dougharness/flow",
3
- "version": "1.4.0",
3
+ "version": "1.6.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",
@@ -23,10 +23,11 @@
23
23
  // autopilot.mjs end
24
24
  // --dir <d> names the project (default cwd). Exit 0 done; 1 no run or unknown card; 2 bad usage.
25
25
  import { execFileSync } from "node:child_process";
26
+ import { createHash } from "node:crypto";
26
27
  import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
27
28
  import { homedir } from "node:os";
28
29
  import { dirname, join, resolve } from "node:path";
29
- import { fileURLToPath } from "node:url";
30
+ import { fileURLToPath, pathToFileURL } from "node:url";
30
31
  import { loadModels } from "../lib/models.mjs";
31
32
  import { AUTOPILOT_FILE, STEPS, clearable, readAutopilot } from "../lib/autopilot-view.mjs";
32
33
  import { contextThreshold } from "../lib/context.mjs";
@@ -34,6 +35,21 @@ import { classifyNotification, pickCards, queenLabel, renameQueenMeta, workersMa
34
35
  import { nextReadyCards } from "../lib/board.mjs";
35
36
  import { SEATS } from "../lib/card-loop.mjs";
36
37
  import { planFor, reportPathFor } from "../lib/plan.mjs";
38
+ import { readMemoryConfig, readMemoryStaleDays } from "../lib/embeddings.mjs";
39
+
40
+ // Card memory-failures-wiring (user decision, 2026-10-28): a stopped card starts `memory.mjs reflect --failures
41
+ // <card> --max-usd 0.5 --detach` (job failures-<card>, cheap row), after its run history is written. The queue
42
+ // approval is the approval. A start that fails (a live job holds the name) warns and never fails the report.
43
+ function startFailuresReflect(dir, id) {
44
+ try {
45
+ execFileSync(process.execPath, [join(dirname(fileURLToPath(import.meta.url)), "memory.mjs"), "reflect", "--failures", id, dir, "--max-usd", "0.5", "--detach"], {
46
+ stdio: ["ignore", "ignore", "pipe"],
47
+ env: process.env,
48
+ });
49
+ } catch (e) {
50
+ process.stderr.write(`autopilot: warning: cannot start reflect --failures for ${id}: ${String(e.stderr || e.message || e).trim().split("\n")[0]}\n`);
51
+ }
52
+ }
37
53
 
38
54
  const argv = process.argv.slice(2);
39
55
  const opts = {};
@@ -484,6 +500,167 @@ switch (cmd) {
484
500
  }
485
501
  }
486
502
  }
503
+ if (kind === "hand" && parsed !== null) {
504
+ const rep = parsed.report ?? parsed;
505
+ if (rep && typeof rep === "object" && rep.outcome === "stopped") {
506
+ try {
507
+ const mem = await import("../lib/memory.mjs");
508
+ const written = JSON.stringify(rep, null, 2) + "\n";
509
+ const readOr = (p) => {
510
+ try {
511
+ return readFileSync(p, "utf8");
512
+ } catch {
513
+ return "";
514
+ }
515
+ };
516
+ const ids = mem.runAgentIds((Array.isArray(c.journals) ? c.journals : []).map(readOr));
517
+ const blocks = mem.attributeBlocks(readOr(join(dir, ".doug/.state/blocks.jsonl")), ids);
518
+ let condition = null;
519
+ try {
520
+ const parsedCondition = JSON.parse(readFileSync(join(dir, ".doug/.state/reports", c.id, "condition.json"), "utf8"));
521
+ if (parsedCondition && typeof parsedCondition === "object" && !Array.isArray(parsedCondition)) condition = parsedCondition;
522
+ } catch {
523
+ condition = null;
524
+ }
525
+ const row = mem.stopOutcomeRow({
526
+ card: c.id,
527
+ report: rep,
528
+ reportHash: createHash("sha256").update(written).digest("hex"),
529
+ reportPath: join(dir, ".doug/.state/reports", c.id, "card-report.json"),
530
+ run,
531
+ blocks,
532
+ condition,
533
+ });
534
+ const m = mem.openMemory(dir);
535
+ try {
536
+ mem.recordOutcomes(m, [row]);
537
+ } finally {
538
+ m.close();
539
+ }
540
+ } catch (e) {
541
+ process.stderr.write(`autopilot: warning: cannot record the stop row for ${c.id}: ${e.message || e}\n`);
542
+ }
543
+ }
544
+ }
545
+ if (kind === "hand" && parsed !== null) {
546
+ try {
547
+ const mem = await import("../lib/memory.mjs");
548
+ const rep = parsed.report ?? parsed;
549
+ const readOr = (p) => {
550
+ try {
551
+ return readFileSync(p, "utf8");
552
+ } catch {
553
+ return "";
554
+ }
555
+ };
556
+ const sid = process.env.CLAUDE_CODE_SESSION_ID;
557
+ const jp = journalPath(run);
558
+ const journalText = jp ? readOr(jp) : "";
559
+ let queenArgs = null;
560
+ try {
561
+ queenArgs = JSON.parse(readFileSync(join(dir, ".doug/.state/reports", c.id, "queen-args.json"), "utf8"));
562
+ } catch {
563
+ // no saved args: no briefs
564
+ }
565
+ const seatRuns = mem.journalSeatRuns(journalText);
566
+ const briefs = mem.replayBriefs(queenArgs, seatRuns);
567
+ const ids = mem.runAgentIds([journalText]);
568
+ const blocks = mem.attributeBlocks(readOr(join(dir, ".doug/.state/blocks.jsonl")), ids);
569
+ const denials = mem.attributeDenials(sid ? readOr(join(dir, ".doug/.state/trace", `${sid.replace(/[^A-Za-z0-9_.-]/g, "_")}.jsonl`)) : "", ids);
570
+ const scan = await mem.historyScanner(dir);
571
+ const entries = [{ kind: "report", source: "card-report.json", value: rep }];
572
+ for (const b of blocks) entries.push({ kind: "block", source: "blocks.jsonl", value: b });
573
+ for (const d of denials) entries.push({ kind: "denial", source: "trace", value: d });
574
+ seatRuns.forEach((r, i) => {
575
+ if (briefs[i] !== null) entries.push({ kind: "brief", seat: r.seat, round: r.round, source: "replay:card-loop.mjs", value: briefs[i] });
576
+ entries.push({ kind: "hand-back", seat: r.seat, round: r.round, source: "journal.jsonl", value: r.result });
577
+ });
578
+ mem.writeRunHistory(dir, { card: c.id, run, entries, scan });
579
+ } catch (e) {
580
+ process.stderr.write(`autopilot: warning: cannot write the run history for ${c.id}: ${e.message || e}\n`);
581
+ }
582
+ }
583
+ if (kind === "hand" && parsed !== null && (parsed.report ?? parsed)?.outcome === "stopped") startFailuresReflect(dir, c.id);
584
+ if (kind === "flow" && parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) && parsed.ok === false && !parsed.paused) {
585
+ try {
586
+ const mem = await import("../lib/memory.mjs");
587
+ const readOr = (p) => {
588
+ try {
589
+ return readFileSync(p, "utf8");
590
+ } catch {
591
+ return "";
592
+ }
593
+ };
594
+ const written = JSON.stringify(parsed, null, 2) + "\n";
595
+ const ids = mem.runAgentIds((Array.isArray(c.journals) ? c.journals : []).map(readOr));
596
+ const blocks = mem.attributeBlocks(readOr(join(dir, ".doug/.state/blocks.jsonl")), ids);
597
+ const levels = Array.isArray(parsed.levels) ? parsed.levels : [];
598
+ let stopClass = null;
599
+ let stopReason = null;
600
+ for (const lv of levels) {
601
+ const t = (Array.isArray(lv?.tasks) ? lv.tasks : []).find((x) => x && typeof x.stopReason === "string" && x.stopReason.trim() !== "");
602
+ if (t) {
603
+ stopClass = t.stopClass ?? null;
604
+ stopReason = t.stopReason;
605
+ break;
606
+ }
607
+ }
608
+ if (stopReason === null) {
609
+ stopReason = "the run stopped";
610
+ if (parsed.stoppedAtLevel !== undefined && parsed.stoppedAtLevel !== null) {
611
+ stopReason += ` at level ${parsed.stoppedAtLevel}`;
612
+ const lv = levels.find((x) => x && x.index === parsed.stoppedAtLevel) ?? levels[parsed.stoppedAtLevel];
613
+ const why = lv?.integration?.reason;
614
+ if (typeof why === "string" && why.trim() !== "") stopReason += `: ${why}`;
615
+ }
616
+ }
617
+ const row = {
618
+ ...mem.stopOutcomeRow({
619
+ card: c.id,
620
+ report: { outcome: "stopped", reason: stopClass, detail: stopReason },
621
+ reportHash: createHash("sha256").update(written).digest("hex"),
622
+ reportPath: join(dir, reportPathFor(c.id)),
623
+ run,
624
+ blocks,
625
+ memoryConfig: mem.memoryConfig({ embeddings: readMemoryConfig(dir).embeddings, staleDays: readMemoryStaleDays(dir), track: "flow" }),
626
+ }),
627
+ track: "flow",
628
+ };
629
+ const m = mem.openMemory(dir);
630
+ try {
631
+ mem.recordOutcomes(m, [row]);
632
+ } finally {
633
+ m.close();
634
+ }
635
+ } catch (e) {
636
+ process.stderr.write(`autopilot: warning: cannot record the stop row for ${c.id}: ${e.message || e}\n`);
637
+ }
638
+ }
639
+ if (kind === "flow" && parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) && (parsed.ok === true || parsed.ok === false) && !parsed.paused) {
640
+ try {
641
+ const mem = await import("../lib/memory.mjs");
642
+ const readOr = (p) => {
643
+ try {
644
+ return readFileSync(p, "utf8");
645
+ } catch {
646
+ return "";
647
+ }
648
+ };
649
+ const sid = process.env.CLAUDE_CODE_SESSION_ID;
650
+ const jp = journalPath(run);
651
+ const ids = mem.runAgentIds([jp ? readOr(jp) : ""]);
652
+ const blocks = mem.attributeBlocks(readOr(join(dir, ".doug/.state/blocks.jsonl")), ids);
653
+ const denials = mem.attributeDenials(sid ? readOr(join(dir, ".doug/.state/trace", `${sid.replace(/[^A-Za-z0-9_.-]/g, "_")}.jsonl`)) : "", ids);
654
+ const scan = await mem.historyScanner(dir);
655
+ const entries = [{ kind: "report", source: "last-report.json", value: parsed }];
656
+ for (const b of blocks) entries.push({ kind: "block", source: "blocks.jsonl", value: b });
657
+ for (const d of denials) entries.push({ kind: "denial", source: "trace", value: d });
658
+ mem.writeRunHistory(dir, { card: c.id, run, entries, scan });
659
+ } catch (e) {
660
+ process.stderr.write(`autopilot: warning: cannot write the run history for ${c.id}: ${e.message || e}\n`);
661
+ }
662
+ }
663
+ if (kind === "flow" && parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) && parsed.ok === false && !parsed.paused) startFailuresReflect(dir, c.id);
487
664
  const v = classifyNotification(kind, { status: "completed", result: parsed === null ? text : parsed });
488
665
  if (v.action === "ask") {
489
666
  let answered = false;
@@ -38,13 +38,13 @@
38
38
  // flow, or named by .doug/plan.json (each with --force removes it
39
39
  // and prints a warning to stderr); prints the removed card as JSON
40
40
  // to stdout so a mistaken removal can be re-added by hand
41
- // 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> --rehearsal-outcome <passed|failed>] [--run <run-id>] [--claude-dir <dir>]
41
+ // 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> --rehearsal-outcome <passed|failed> [--rehearsal-fixture <name>]] [--run <run-id>] [--claude-dir <dir>]
42
42
  // append a run entry for the card to docs/live-runs.md; every adversary block
43
43
  // in the report is classified real, marginal, or false (repeat the flag per block).
44
44
  // When .doug/plan.json lists the card among its `cards` (a batch), the entry
45
45
  // shows only that card's tasks, --cost is that card's tasks' cost, and
46
46
  // --shared-cost <usd> the shared integration agents', counted once
47
- // board.mjs record <id> --hand [dir] --summary "<one sentence>" [--commit <sha>] [--wall <text>] [--gate <text>] [--note <text>] [--rehearsal <scenario> --rehearsal-outcome <passed|failed>] [--cost <usd>]
47
+ // board.mjs record <id> --hand [dir] --summary "<one sentence>" [--commit <sha>] [--wall <text>] [--gate <text>] [--note <text>] [--rehearsal <scenario> --rehearsal-outcome <passed|failed> [--rehearsal-fixture <name>]] [--cost <usd>]
48
48
  // append a hand-track entry (no report) for the card
49
49
  // --rehearsal <scenario> (either form) marks the entry as a rehearse.mjs run on the ts-basic fixture (the start scenario: an empty git repo): the first
50
50
  // prose line and the commit sha (abbreviated to 7 characters) read as the fixture's, not this repository's.
@@ -94,8 +94,8 @@ const planUsage = (kind) => [
94
94
  ` board.mjs ${kind} edit <id> [dir] [--title <t>] [--goal <g>] [--version <v>] [--date YYYY-MM-DD] [--status planned|active|done] [--force]`,
95
95
  ` board.mjs ${kind} list [dir]`,
96
96
  ].join("\n");
97
- const RECORD_HAND_FLAGS = ["summary", "hand", "commit", "wall", "gate", "note", "rehearsal", "rehearsal-outcome", "cost"];
98
- const RECORD_REPORT_FLAGS = ["summary", "cost", "codex-cost", "wall", "commit", "adversary", "shared-cost", "note", "rehearsal", "rehearsal-outcome", "run", "claude-dir"];
97
+ const RECORD_HAND_FLAGS = ["summary", "hand", "commit", "wall", "gate", "note", "rehearsal", "rehearsal-outcome", "rehearsal-fixture", "cost"];
98
+ const RECORD_REPORT_FLAGS = ["summary", "cost", "codex-cost", "wall", "commit", "adversary", "shared-cost", "note", "rehearsal", "rehearsal-outcome", "rehearsal-fixture", "run", "claude-dir"];
99
99
  const opts = {};
100
100
  const positional = [];
101
101
  for (let i = 0; i < argv.length; i++) {
@@ -131,8 +131,8 @@ const USAGE_FOR = {
131
131
  labels: "usage: board.mjs labels --check [dir] | labels init [dir]",
132
132
  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>] [--note <text>]",
133
133
  };
134
- const RECORD_HAND_USAGE = "usage: board.mjs record <id> --hand [dir] --summary \"<one sentence>\" [--commit <sha>] [--wall <text>] [--gate <text>] [--note <text>] [--rehearsal <scenario> --rehearsal-outcome <passed|failed>] [--cost <usd>]";
135
- 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> --rehearsal-outcome <passed|failed>] [--run <run-id>] [--claude-dir <dir>]";
134
+ const RECORD_HAND_USAGE = "usage: board.mjs record <id> --hand [dir] --summary \"<one sentence>\" [--commit <sha>] [--wall <text>] [--gate <text>] [--note <text>] [--rehearsal <scenario> --rehearsal-outcome <passed|failed> [--rehearsal-fixture <name>]] [--cost <usd>]";
135
+ 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> --rehearsal-outcome <passed|failed> [--rehearsal-fixture <name>]] [--run <run-id>] [--claude-dir <dir>]";
136
136
 
137
137
  // An unknown --flag for the given subcommand: exit 2, name the flag and the subcommand's usage.
138
138
  // Runs on flag names only (parse order doesn't matter), before any work or write.
@@ -145,7 +145,11 @@ function unknownFlagFor(cmd, opts) {
145
145
  }
146
146
 
147
147
  function dirFrom(args, n) {
148
- return resolve(process.env.CLAUDE_PROJECT_DIR || args[n] || process.cwd());
148
+ const d = resolve(process.env.CLAUDE_PROJECT_DIR || args[n] || process.cwd());
149
+ if (!existsSync(join(d, ".doug")) && !existsSync(join(d, "docs", "board.json"))) {
150
+ fail(`not a Doug project: ${d} has no .doug/`, 1);
151
+ }
152
+ return d;
149
153
  }
150
154
 
151
155
  if (cmd === "epic" || cmd === "milestone") {
@@ -177,6 +181,7 @@ if (cmd === "record" || cmd === "summary") {
177
181
  if (cmd === "record") {
178
182
  const ro = opts["rehearsal-outcome"];
179
183
  if (opts.rehearsal !== undefined && ro !== "passed" && ro !== "failed") fail(`--rehearsal needs --rehearsal-outcome passed|failed, got ${ro === undefined ? "none" : JSON.stringify(ro)}\n${opts.hand ? RECORD_HAND_USAGE : RECORD_REPORT_USAGE}`, 2);
184
+ if (opts.rehearsal === undefined && opts["rehearsal-fixture"] !== undefined) fail(`--rehearsal-fixture is only valid with --rehearsal\n${opts.hand ? RECORD_HAND_USAGE : RECORD_REPORT_USAGE}`, 2);
180
185
  if (opts.rehearsal === undefined && ro !== undefined) fail(`--rehearsal-outcome is only valid with --rehearsal\n${opts.hand ? RECORD_HAND_USAGE : RECORD_REPORT_USAGE}`, 2);
181
186
  }
182
187
 
@@ -192,7 +197,8 @@ function landingBlock({ id, ok, commit, wall, gate, cost, codexCost, note, rehea
192
197
  const learned = typeof note === "string" && note.trim() ? note.trim().replace(/\s+/g, " ") : "nothing new";
193
198
  const lines = [`Changed: ${summarySentence}`, `Outcome: ${outcome}`, `Learned: ${learned}`];
194
199
  const words = lines.join("\n").split(/\s+/).filter(Boolean).length;
195
- if (words > LANDING_BLOCK_WORDS) fail(`landing block is ${words} words; the limit is ${LANDING_BLOCK_WORDS}. Shorten --summary or --note.`);
200
+ // A rehearsal's block is print-only (no landing-block files, so the Stop gate checks nothing): no cap, so a failed stage's long note still records.
201
+ if (!rehearsal && words > LANDING_BLOCK_WORDS) fail(`landing block is ${words} words; the limit is ${LANDING_BLOCK_WORDS}. Shorten --summary or --note.`);
196
202
  return lines;
197
203
  }
198
204
  // The pending marker holds the session that landed, so only that session's Stop checks it (empty when unset).
@@ -212,6 +218,25 @@ function writeLandingBlock(dir, id, lines, keepFile = false, printOnly = false)
212
218
  }
213
219
  process.stdout.write(lines.join("\n") + "\n");
214
220
  }
221
+ // A plan landed after `plan.mjs done --force` (landed set, report not ok, --commit naming the land merge or absent,
222
+ // not a rehearsal) is a landed card whatever the report says; its gate is land's own acceptance. In a batch plan
223
+ // only the entries tagged `[<card>] ` count, or the whole list labelled as the plan's when none carries the tag.
224
+ function forcedLanding({ plan, report, opts, card }) {
225
+ const lc = plan && plan.landed && typeof plan.landed === "object" ? plan.landed : null;
226
+ const lm = lc && typeof lc.mergeCommit === "string" ? lc.mergeCommit : "";
227
+ const forced = report.ok !== true && !opts.rehearsal && lc !== null && (opts.commit === undefined || (lm !== "" && (lm.startsWith(String(opts.commit)) || String(opts.commit).startsWith(lm))));
228
+ if (!forced) return { forced: false, gate: null };
229
+ if (!Array.isArray(lc.acceptance)) return { forced, gate: "acceptance checked at land" };
230
+ const count = (list) => `${list.filter((a) => a && a.ok).length}/${list.length}`;
231
+ if (plan && Array.isArray(plan.cards) && plan.cards.includes(card)) {
232
+ const tag = `[${card}] `;
233
+ const own = lc.acceptance.filter((a) => a && (typeof a === "string" ? a : String(a.text ?? "")).startsWith(tag));
234
+ if (own.length) return { forced, gate: `acceptance ${count(own)} at land` };
235
+ return { forced, gate: `acceptance ${count(lc.acceptance)} at land (the plan's)` };
236
+ }
237
+ return { forced, gate: `acceptance ${count(lc.acceptance)} at land` };
238
+ }
239
+
215
240
  function acceptanceGate(report) {
216
241
  const levels = (report.levels || []).filter((l) => l && l.integration);
217
242
  const acc = levels.length && Array.isArray(levels[levels.length - 1].integration.acceptance) ? levels[levels.length - 1].integration.acceptance : [];
@@ -434,11 +459,11 @@ try {
434
459
  case "record": {
435
460
  if (opts.hand) {
436
461
  const [id] = rest;
437
- if (!id) fail("usage: board.mjs record <id> --hand [dir] --summary \"<one sentence>\" [--commit <sha>] [--wall <text>] [--gate <text>] [--note <text>] [--rehearsal <scenario> --rehearsal-outcome <passed|failed>] [--cost <usd>]", 2);
462
+ if (!id) fail("usage: board.mjs record <id> --hand [dir] --summary \"<one sentence>\" [--commit <sha>] [--wall <text>] [--gate <text>] [--note <text>] [--rehearsal <scenario> --rehearsal-outcome <passed|failed> [--rehearsal-fixture <name>]] [--cost <usd>]", 2);
438
463
  if (opts.cost !== undefined && !/^\d+(\.\d+)?$/.test(opts.cost)) fail(`--cost takes a non-negative number of dollars, got ${JSON.stringify(opts.cost)}`, 2);
439
464
  const dir = dirFrom(rest, 1);
440
465
  const card = findCard(loadBoard(dir), id);
441
- 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, rehearsalOutcome: opts["rehearsal-outcome"] ?? null, cost: opts.cost !== undefined ? Number(opts.cost) : null });
466
+ 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, rehearsalOutcome: opts["rehearsal-outcome"] ?? null, fixture: opts["rehearsal-fixture"] ?? null, cost: opts.cost !== undefined ? Number(opts.cost) : null });
442
467
  const block = landingBlock({ id, ok: true, commit: opts.commit, wall: opts.wall, gate: opts.gate, cost: opts.cost, note: opts.note, rehearsal: opts.rehearsal ?? null, rehearsalOutcome: opts["rehearsal-outcome"] ?? null });
443
468
  const { file, promoted } = recordLanding(dir, id, entry);
444
469
  process.stdout.write(`${summarySentence}\n`);
@@ -452,7 +477,7 @@ try {
452
477
  break;
453
478
  }
454
479
  const [id, reportFile] = rest;
455
- 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> --rehearsal-outcome <passed|failed>] [--run <run-id>] [--claude-dir <dir>]", 2);
480
+ 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> --rehearsal-outcome <passed|failed> [--rehearsal-fixture <name>]] [--run <run-id>] [--claude-dir <dir>]", 2);
456
481
  const dir = dirFrom(rest, 2);
457
482
  const card = findCard(loadBoard(dir), id);
458
483
  const report = unwrapReport(JSON.parse(readFileSync(resolve(reportFile), "utf8")));
@@ -461,8 +486,10 @@ try {
461
486
  const batch = plan && Array.isArray(plan.cards) && plan.cards.includes(id) ? plan.cards : null;
462
487
  const taskIds = batch ? plan.tasks.filter((t) => t.card === id).map((t) => t.id) : [];
463
488
  const wall = opts.run ? wallForRun({ runId: opts.run, claudeDir: opts["claude-dir"], projectDir: dir, wallMinutes: report.budget?.wallMinutes ?? DEFAULT_BUDGET.wallMinutes }) : null;
464
- 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, rehearsalOutcome: opts["rehearsal-outcome"] ?? null });
465
- const block = landingBlock({ id, ok: report.ok === true, commit: opts.commit, wall: opts.wall, gate: acceptanceGate(report), cost: opts.cost, codexCost: opts["codex-cost"], note: opts.note, rehearsal: opts.rehearsal ?? null, rehearsalOutcome: opts["rehearsal-outcome"] ?? null });
489
+ // A plan landed after `plan.mjs done --force` is recorded as landed, with land's own acceptance.
490
+ const { forced, gate: forcedGate } = forcedLanding({ plan, report, opts, card: id });
491
+ const entry = runEntry({ forcedLanding: forced, 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, rehearsalOutcome: opts["rehearsal-outcome"] ?? null, fixture: opts["rehearsal-fixture"] ?? null });
492
+ const block = landingBlock({ id, ok: report.ok === true || forced, commit: opts.commit, wall: opts.wall, gate: forced ? forcedGate : acceptanceGate(report), cost: opts.cost, codexCost: opts["codex-cost"], note: opts.note, rehearsal: opts.rehearsal ?? null, rehearsalOutcome: opts["rehearsal-outcome"] ?? null });
466
493
  const { file, promoted } = recordLanding(dir, id, entry);
467
494
  process.stdout.write(`${summarySentence}\n`);
468
495
  if (promoted) process.stdout.write(`Promoted the research note for ${id} to ${promoted}.\n`);
@@ -491,9 +518,10 @@ try {
491
518
  if (saved.length === 3) sumSaved = saved;
492
519
  } catch {}
493
520
  }
494
- const sumBlock = sumSaved ? sumSaved : opts.card !== undefined ? landingBlock({ id: opts.card, ok: report.ok === true, commit: opts.commit, wall: opts.wall, gate: acceptanceGate(wholeReport), cost: opts.cost, codexCost: opts["codex-cost"], note: opts.note }) : null;
521
+ const sumForced = opts.card !== undefined ? forcedLanding({ plan: sumPlan, report: wholeReport, opts, card: opts.card }) : { forced: false, gate: null };
522
+ const sumBlock = sumSaved ? sumSaved : opts.card !== undefined ? landingBlock({ id: opts.card, ok: report.ok === true || sumForced.forced, commit: opts.commit, wall: opts.wall, gate: sumForced.forced ? sumForced.gate : acceptanceGate(wholeReport), cost: opts.cost, codexCost: opts["codex-cost"], note: opts.note }) : null;
495
523
  process.stdout.write(`${summarySentence}\n`);
496
- 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 }));
524
+ process.stdout.write(runSummary({ report, forcedLanding: sumForced.forced, 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 }));
497
525
  if (sumBlock) writeLandingBlock(dirFrom(rest, 1), opts.card, sumBlock, sumSaved !== null);
498
526
  break;
499
527
  }
@@ -43,7 +43,9 @@ for (const a of argv) {
43
43
  const [cmd, ...rest] = positional;
44
44
 
45
45
  function dirFrom(args, n) {
46
- return resolve(process.env.CLAUDE_PROJECT_DIR || args[n] || process.cwd());
46
+ const d = resolve(process.env.CLAUDE_PROJECT_DIR || args[n] || process.cwd());
47
+ if (!existsSync(join(d, ".doug"))) fail(`not a Doug project: ${d} has no .doug/`, 1);
48
+ return d;
47
49
  }
48
50
 
49
51
  function readClaudeMd(dir) {
@@ -95,7 +97,7 @@ function readOwnSkills() {
95
97
  function signalsText(s) {
96
98
  return [
97
99
  `outcomes: ${s.outcomes.rows.length} rows, ${s.outcomes.gateFailures} gate failures, blocks real=${s.outcomes.blocksByClass.real} marginal=${s.outcomes.blocksByClass.marginal} false=${s.outcomes.blocksByClass.false}, fixPasses sum=${s.outcomes.fixPasses.sum} max=${s.outcomes.fixPasses.max}, usd sum=${s.outcomes.usd.sum.toFixed(2)} n=${s.outcomes.usd.n}, cards=${Object.keys(s.outcomes.byCard).length}`,
98
- `lessons: ${s.lessons.total} total, ${s.lessons.live} live, ${s.lessons.helpful} helpful, ${s.lessons.harmful} harmful, ${s.lessons.stale} stale, ${s.lessons.repeated.length} repeated`,
100
+ `lessons: ${s.lessons.total} total, ${s.lessons.live} live, ${s.lessons.helpful} helpful, ${s.lessons.harmful} harmful, ${s.lessons.stale} stale, ${s.lessons.pitfalls.length} pitfalls with occurrences`,
99
101
  `trace: ${s.trace.files.length} files, ${s.trace.denials.length} denials, ${s.trace.unmatched.length} unmatched (reported only, never a proposal signal), ${s.trace.skills.length} skills invoked, ${s.trace.instructionsLoaded.length} instruction files loaded`,
100
102
  ].join("\n");
101
103
  }