@dougharness/cli 1.4.0 → 1.5.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 (32) hide show
  1. package/dist/ablate.js +2 -2
  2. package/dist/ablate.js.map +1 -1
  3. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
  4. package/node_modules/@dougharness/flow/agents/planner.md +1 -0
  5. package/node_modules/@dougharness/flow/lib/board.mjs +9 -9
  6. package/node_modules/@dougharness/flow/lib/card-loop.mjs +44 -5
  7. package/node_modules/@dougharness/flow/lib/decisions.mjs +2 -4
  8. package/node_modules/@dougharness/flow/lib/learn.mjs +95 -54
  9. package/node_modules/@dougharness/flow/lib/memory.mjs +1380 -44
  10. package/node_modules/@dougharness/flow/lib/plan.mjs +40 -0
  11. package/node_modules/@dougharness/flow/package.json +1 -1
  12. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +162 -1
  13. package/node_modules/@dougharness/flow/scripts/board.mjs +38 -14
  14. package/node_modules/@dougharness/flow/scripts/learn.mjs +1 -1
  15. package/node_modules/@dougharness/flow/scripts/memory.mjs +658 -36
  16. package/node_modules/@dougharness/flow/scripts/plan.mjs +3 -3
  17. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +6 -2
  18. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
  19. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +2 -0
  20. package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +9 -8
  21. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +1 -1
  22. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +1 -1
  23. package/node_modules/@dougharness/flow/workflows/card-queen.js +44 -5
  24. package/node_modules/@dougharness/flow/workflows/doug-implement.js +34 -19
  25. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  26. package/node_modules/@dougharness/gates/lib/baseline.mjs +9 -0
  27. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +5 -3
  28. package/node_modules/@dougharness/gates/lib/verified-tree.mjs +33 -0
  29. package/node_modules/@dougharness/gates/package.json +1 -1
  30. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +48 -33
  31. package/node_modules/@dougharness/gates/scripts/verify.mjs +20 -4
  32. 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.5.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,7 @@ 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";
37
39
 
38
40
  const argv = process.argv.slice(2);
39
41
  const opts = {};
@@ -484,6 +486,165 @@ switch (cmd) {
484
486
  }
485
487
  }
486
488
  }
489
+ if (kind === "hand" && parsed !== null) {
490
+ const rep = parsed.report ?? parsed;
491
+ if (rep && typeof rep === "object" && rep.outcome === "stopped") {
492
+ try {
493
+ const mem = await import("../lib/memory.mjs");
494
+ const written = JSON.stringify(rep, null, 2) + "\n";
495
+ const readOr = (p) => {
496
+ try {
497
+ return readFileSync(p, "utf8");
498
+ } catch {
499
+ return "";
500
+ }
501
+ };
502
+ const ids = mem.runAgentIds((Array.isArray(c.journals) ? c.journals : []).map(readOr));
503
+ const blocks = mem.attributeBlocks(readOr(join(dir, ".doug/.state/blocks.jsonl")), ids);
504
+ let condition = null;
505
+ try {
506
+ const parsedCondition = JSON.parse(readFileSync(join(dir, ".doug/.state/reports", c.id, "condition.json"), "utf8"));
507
+ if (parsedCondition && typeof parsedCondition === "object" && !Array.isArray(parsedCondition)) condition = parsedCondition;
508
+ } catch {
509
+ condition = null;
510
+ }
511
+ const row = mem.stopOutcomeRow({
512
+ card: c.id,
513
+ report: rep,
514
+ reportHash: createHash("sha256").update(written).digest("hex"),
515
+ reportPath: join(dir, ".doug/.state/reports", c.id, "card-report.json"),
516
+ run,
517
+ blocks,
518
+ condition,
519
+ });
520
+ const m = mem.openMemory(dir);
521
+ try {
522
+ mem.recordOutcomes(m, [row]);
523
+ } finally {
524
+ m.close();
525
+ }
526
+ } catch (e) {
527
+ process.stderr.write(`autopilot: warning: cannot record the stop row for ${c.id}: ${e.message || e}\n`);
528
+ }
529
+ }
530
+ }
531
+ if (kind === "hand" && parsed !== null) {
532
+ try {
533
+ const mem = await import("../lib/memory.mjs");
534
+ const rep = parsed.report ?? parsed;
535
+ const readOr = (p) => {
536
+ try {
537
+ return readFileSync(p, "utf8");
538
+ } catch {
539
+ return "";
540
+ }
541
+ };
542
+ const sid = process.env.CLAUDE_CODE_SESSION_ID;
543
+ const jp = journalPath(run);
544
+ const journalText = jp ? readOr(jp) : "";
545
+ let queenArgs = null;
546
+ try {
547
+ queenArgs = JSON.parse(readFileSync(join(dir, ".doug/.state/reports", c.id, "queen-args.json"), "utf8"));
548
+ } catch {
549
+ // no saved args: no briefs
550
+ }
551
+ const seatRuns = mem.journalSeatRuns(journalText);
552
+ const briefs = mem.replayBriefs(queenArgs, seatRuns);
553
+ const ids = mem.runAgentIds([journalText]);
554
+ const blocks = mem.attributeBlocks(readOr(join(dir, ".doug/.state/blocks.jsonl")), ids);
555
+ const denials = mem.attributeDenials(sid ? readOr(join(dir, ".doug/.state/trace", `${sid.replace(/[^A-Za-z0-9_.-]/g, "_")}.jsonl`)) : "", ids);
556
+ const scan = await mem.historyScanner(dir);
557
+ const entries = [{ kind: "report", source: "card-report.json", value: rep }];
558
+ for (const b of blocks) entries.push({ kind: "block", source: "blocks.jsonl", value: b });
559
+ for (const d of denials) entries.push({ kind: "denial", source: "trace", value: d });
560
+ seatRuns.forEach((r, i) => {
561
+ if (briefs[i] !== null) entries.push({ kind: "brief", seat: r.seat, round: r.round, source: "replay:card-loop.mjs", value: briefs[i] });
562
+ entries.push({ kind: "hand-back", seat: r.seat, round: r.round, source: "journal.jsonl", value: r.result });
563
+ });
564
+ mem.writeRunHistory(dir, { card: c.id, run, entries, scan });
565
+ } catch (e) {
566
+ process.stderr.write(`autopilot: warning: cannot write the run history for ${c.id}: ${e.message || e}\n`);
567
+ }
568
+ }
569
+ if (kind === "flow" && parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) && parsed.ok === false && !parsed.paused) {
570
+ try {
571
+ const mem = await import("../lib/memory.mjs");
572
+ const readOr = (p) => {
573
+ try {
574
+ return readFileSync(p, "utf8");
575
+ } catch {
576
+ return "";
577
+ }
578
+ };
579
+ const written = JSON.stringify(parsed, null, 2) + "\n";
580
+ const ids = mem.runAgentIds((Array.isArray(c.journals) ? c.journals : []).map(readOr));
581
+ const blocks = mem.attributeBlocks(readOr(join(dir, ".doug/.state/blocks.jsonl")), ids);
582
+ const levels = Array.isArray(parsed.levels) ? parsed.levels : [];
583
+ let stopClass = null;
584
+ let stopReason = null;
585
+ for (const lv of levels) {
586
+ const t = (Array.isArray(lv?.tasks) ? lv.tasks : []).find((x) => x && typeof x.stopReason === "string" && x.stopReason.trim() !== "");
587
+ if (t) {
588
+ stopClass = t.stopClass ?? null;
589
+ stopReason = t.stopReason;
590
+ break;
591
+ }
592
+ }
593
+ if (stopReason === null) {
594
+ stopReason = "the run stopped";
595
+ if (parsed.stoppedAtLevel !== undefined && parsed.stoppedAtLevel !== null) {
596
+ stopReason += ` at level ${parsed.stoppedAtLevel}`;
597
+ const lv = levels.find((x) => x && x.index === parsed.stoppedAtLevel) ?? levels[parsed.stoppedAtLevel];
598
+ const why = lv?.integration?.reason;
599
+ if (typeof why === "string" && why.trim() !== "") stopReason += `: ${why}`;
600
+ }
601
+ }
602
+ const row = {
603
+ ...mem.stopOutcomeRow({
604
+ card: c.id,
605
+ report: { outcome: "stopped", reason: stopClass, detail: stopReason },
606
+ reportHash: createHash("sha256").update(written).digest("hex"),
607
+ reportPath: join(dir, reportPathFor(c.id)),
608
+ run,
609
+ blocks,
610
+ memoryConfig: mem.memoryConfig({ embeddings: readMemoryConfig(dir).embeddings, staleDays: readMemoryStaleDays(dir), track: "flow" }),
611
+ }),
612
+ track: "flow",
613
+ };
614
+ const m = mem.openMemory(dir);
615
+ try {
616
+ mem.recordOutcomes(m, [row]);
617
+ } finally {
618
+ m.close();
619
+ }
620
+ } catch (e) {
621
+ process.stderr.write(`autopilot: warning: cannot record the stop row for ${c.id}: ${e.message || e}\n`);
622
+ }
623
+ }
624
+ if (kind === "flow" && parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) && (parsed.ok === true || parsed.ok === false) && !parsed.paused) {
625
+ try {
626
+ const mem = await import("../lib/memory.mjs");
627
+ const readOr = (p) => {
628
+ try {
629
+ return readFileSync(p, "utf8");
630
+ } catch {
631
+ return "";
632
+ }
633
+ };
634
+ const sid = process.env.CLAUDE_CODE_SESSION_ID;
635
+ const jp = journalPath(run);
636
+ const ids = mem.runAgentIds([jp ? readOr(jp) : ""]);
637
+ const blocks = mem.attributeBlocks(readOr(join(dir, ".doug/.state/blocks.jsonl")), ids);
638
+ const denials = mem.attributeDenials(sid ? readOr(join(dir, ".doug/.state/trace", `${sid.replace(/[^A-Za-z0-9_.-]/g, "_")}.jsonl`)) : "", ids);
639
+ const scan = await mem.historyScanner(dir);
640
+ const entries = [{ kind: "report", source: "last-report.json", value: parsed }];
641
+ for (const b of blocks) entries.push({ kind: "block", source: "blocks.jsonl", value: b });
642
+ for (const d of denials) entries.push({ kind: "denial", source: "trace", value: d });
643
+ mem.writeRunHistory(dir, { card: c.id, run, entries, scan });
644
+ } catch (e) {
645
+ process.stderr.write(`autopilot: warning: cannot write the run history for ${c.id}: ${e.message || e}\n`);
646
+ }
647
+ }
487
648
  const v = classifyNotification(kind, { status: "completed", result: parsed === null ? text : parsed });
488
649
  if (v.action === "ask") {
489
650
  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.
@@ -177,6 +177,7 @@ if (cmd === "record" || cmd === "summary") {
177
177
  if (cmd === "record") {
178
178
  const ro = opts["rehearsal-outcome"];
179
179
  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);
180
+ 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
181
  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
182
  }
182
183
 
@@ -192,7 +193,8 @@ function landingBlock({ id, ok, commit, wall, gate, cost, codexCost, note, rehea
192
193
  const learned = typeof note === "string" && note.trim() ? note.trim().replace(/\s+/g, " ") : "nothing new";
193
194
  const lines = [`Changed: ${summarySentence}`, `Outcome: ${outcome}`, `Learned: ${learned}`];
194
195
  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.`);
196
+ // 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.
197
+ if (!rehearsal && words > LANDING_BLOCK_WORDS) fail(`landing block is ${words} words; the limit is ${LANDING_BLOCK_WORDS}. Shorten --summary or --note.`);
196
198
  return lines;
197
199
  }
198
200
  // The pending marker holds the session that landed, so only that session's Stop checks it (empty when unset).
@@ -212,6 +214,25 @@ function writeLandingBlock(dir, id, lines, keepFile = false, printOnly = false)
212
214
  }
213
215
  process.stdout.write(lines.join("\n") + "\n");
214
216
  }
217
+ // A plan landed after `plan.mjs done --force` (landed set, report not ok, --commit naming the land merge or absent,
218
+ // not a rehearsal) is a landed card whatever the report says; its gate is land's own acceptance. In a batch plan
219
+ // only the entries tagged `[<card>] ` count, or the whole list labelled as the plan's when none carries the tag.
220
+ function forcedLanding({ plan, report, opts, card }) {
221
+ const lc = plan && plan.landed && typeof plan.landed === "object" ? plan.landed : null;
222
+ const lm = lc && typeof lc.mergeCommit === "string" ? lc.mergeCommit : "";
223
+ const forced = report.ok !== true && !opts.rehearsal && lc !== null && (opts.commit === undefined || (lm !== "" && (lm.startsWith(String(opts.commit)) || String(opts.commit).startsWith(lm))));
224
+ if (!forced) return { forced: false, gate: null };
225
+ if (!Array.isArray(lc.acceptance)) return { forced, gate: "acceptance checked at land" };
226
+ const count = (list) => `${list.filter((a) => a && a.ok).length}/${list.length}`;
227
+ if (plan && Array.isArray(plan.cards) && plan.cards.includes(card)) {
228
+ const tag = `[${card}] `;
229
+ const own = lc.acceptance.filter((a) => a && (typeof a === "string" ? a : String(a.text ?? "")).startsWith(tag));
230
+ if (own.length) return { forced, gate: `acceptance ${count(own)} at land` };
231
+ return { forced, gate: `acceptance ${count(lc.acceptance)} at land (the plan's)` };
232
+ }
233
+ return { forced, gate: `acceptance ${count(lc.acceptance)} at land` };
234
+ }
235
+
215
236
  function acceptanceGate(report) {
216
237
  const levels = (report.levels || []).filter((l) => l && l.integration);
217
238
  const acc = levels.length && Array.isArray(levels[levels.length - 1].integration.acceptance) ? levels[levels.length - 1].integration.acceptance : [];
@@ -434,11 +455,11 @@ try {
434
455
  case "record": {
435
456
  if (opts.hand) {
436
457
  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);
458
+ 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
459
  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
460
  const dir = dirFrom(rest, 1);
440
461
  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 });
462
+ 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
463
  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
464
  const { file, promoted } = recordLanding(dir, id, entry);
444
465
  process.stdout.write(`${summarySentence}\n`);
@@ -452,7 +473,7 @@ try {
452
473
  break;
453
474
  }
454
475
  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);
476
+ 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
477
  const dir = dirFrom(rest, 2);
457
478
  const card = findCard(loadBoard(dir), id);
458
479
  const report = unwrapReport(JSON.parse(readFileSync(resolve(reportFile), "utf8")));
@@ -461,8 +482,10 @@ try {
461
482
  const batch = plan && Array.isArray(plan.cards) && plan.cards.includes(id) ? plan.cards : null;
462
483
  const taskIds = batch ? plan.tasks.filter((t) => t.card === id).map((t) => t.id) : [];
463
484
  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 });
485
+ // A plan landed after `plan.mjs done --force` is recorded as landed, with land's own acceptance.
486
+ const { forced, gate: forcedGate } = forcedLanding({ plan, report, opts, card: id });
487
+ 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 });
488
+ 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
489
  const { file, promoted } = recordLanding(dir, id, entry);
467
490
  process.stdout.write(`${summarySentence}\n`);
468
491
  if (promoted) process.stdout.write(`Promoted the research note for ${id} to ${promoted}.\n`);
@@ -491,9 +514,10 @@ try {
491
514
  if (saved.length === 3) sumSaved = saved;
492
515
  } catch {}
493
516
  }
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;
517
+ const sumForced = opts.card !== undefined ? forcedLanding({ plan: sumPlan, report: wholeReport, opts, card: opts.card }) : { forced: false, gate: null };
518
+ 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
519
  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 }));
520
+ 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
521
  if (sumBlock) writeLandingBlock(dirFrom(rest, 1), opts.card, sumBlock, sumSaved !== null);
498
522
  break;
499
523
  }
@@ -95,7 +95,7 @@ function readOwnSkills() {
95
95
  function signalsText(s) {
96
96
  return [
97
97
  `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`,
98
+ `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
99
  `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
100
  ].join("\n");
101
101
  }