@dougharness/cli 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/bin.js +8 -0
  2. package/dist/bin.js.map +1 -1
  3. package/dist/board-serve.js +9 -3
  4. package/dist/board-serve.js.map +1 -1
  5. package/dist/board.js +10 -2
  6. package/dist/board.js.map +1 -1
  7. package/dist/detect/repo.js +14 -1
  8. package/dist/detect/repo.js.map +1 -1
  9. package/dist/detect/types.d.ts +2 -0
  10. package/dist/doctor.d.ts +46 -0
  11. package/dist/doctor.js +491 -0
  12. package/dist/doctor.js.map +1 -0
  13. package/dist/generate/agents.d.ts +7 -0
  14. package/dist/generate/agents.js +62 -12
  15. package/dist/generate/agents.js.map +1 -1
  16. package/dist/generate/claude-md.js +4 -0
  17. package/dist/generate/claude-md.js.map +1 -1
  18. package/dist/generate/config.d.ts +8 -0
  19. package/dist/generate/config.js +4 -1
  20. package/dist/generate/config.js.map +1 -1
  21. package/dist/generate/proposal.js +15 -6
  22. package/dist/generate/proposal.js.map +1 -1
  23. package/dist/generate/settings.d.ts +2 -0
  24. package/dist/generate/settings.js +17 -2
  25. package/dist/generate/settings.js.map +1 -1
  26. package/dist/init.d.ts +14 -0
  27. package/dist/init.js +94 -8
  28. package/dist/init.js.map +1 -1
  29. package/dist/memory-setup.d.ts +28 -0
  30. package/dist/memory-setup.js +323 -0
  31. package/dist/memory-setup.js.map +1 -0
  32. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +16 -4
  33. package/node_modules/@dougharness/flow/agents/planner.md +2 -2
  34. package/node_modules/@dougharness/flow/agents/researcher.md +2 -2
  35. package/node_modules/@dougharness/flow/hooks/hooks.json +16 -0
  36. package/node_modules/@dougharness/flow/lib/board.mjs +154 -12
  37. package/node_modules/@dougharness/flow/lib/code-index.mjs +18 -3
  38. package/node_modules/@dougharness/flow/lib/context.mjs +57 -0
  39. package/node_modules/@dougharness/flow/lib/cost.mjs +129 -4
  40. package/node_modules/@dougharness/flow/lib/embeddings.mjs +185 -23
  41. package/node_modules/@dougharness/flow/lib/land.mjs +69 -10
  42. package/node_modules/@dougharness/flow/lib/learn.mjs +4 -4
  43. package/node_modules/@dougharness/flow/lib/memory.mjs +326 -35
  44. package/node_modules/@dougharness/flow/lib/plan.mjs +9 -0
  45. package/node_modules/@dougharness/flow/package.json +2 -1
  46. package/node_modules/@dougharness/flow/scripts/board.mjs +60 -20
  47. package/node_modules/@dougharness/flow/scripts/context.mjs +48 -0
  48. package/node_modules/@dougharness/flow/scripts/cost.mjs +15 -3
  49. package/node_modules/@dougharness/flow/scripts/memory.mjs +190 -34
  50. package/node_modules/@dougharness/flow/scripts/plan.mjs +32 -64
  51. package/node_modules/@dougharness/flow/scripts/session-recall.mjs +132 -0
  52. package/node_modules/@dougharness/flow/skills/{core-next → doug-hand}/SKILL.md +31 -18
  53. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
  54. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +44 -10
  55. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +5 -5
  56. package/node_modules/@dougharness/flow/skills/research/SKILL.md +4 -2
  57. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +8 -9
  58. package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +1 -1
  59. package/node_modules/@dougharness/flow/workflows/doug-implement.js +119 -72
  60. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  61. package/node_modules/@dougharness/gates/hooks/hooks.json +11 -0
  62. package/node_modules/@dougharness/gates/lib/baseline.mjs +166 -1
  63. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +58 -1
  64. package/node_modules/@dougharness/gates/lib/checkpoint.mjs +1 -1
  65. package/node_modules/@dougharness/gates/lib/config.mjs +20 -0
  66. package/node_modules/@dougharness/gates/lib/docs-gate.mjs +95 -0
  67. package/node_modules/@dougharness/gates/lib/evidence.mjs +2 -0
  68. package/node_modules/@dougharness/gates/lib/run.mjs +4 -0
  69. package/node_modules/@dougharness/gates/lib/test-totals.mjs +40 -7
  70. package/node_modules/@dougharness/gates/lib/trace.mjs +24 -7
  71. package/node_modules/@dougharness/gates/lib/verified-tree.mjs +225 -0
  72. package/node_modules/@dougharness/gates/package.json +1 -1
  73. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +133 -5
  74. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +551 -113
  75. package/node_modules/@dougharness/gates/scripts/trace.mjs +5 -1
  76. package/node_modules/@dougharness/gates/scripts/turn-start.mjs +17 -0
  77. package/node_modules/@dougharness/gates/scripts/verify.mjs +146 -0
  78. package/package.json +3 -3
  79. package/templates/board-app.js +2 -2
  80. package/node_modules/@dougharness/flow/lib/rehearse.mjs +0 -825
  81. package/node_modules/@dougharness/flow/scripts/rehearse.mjs +0 -129
  82. package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +0 -85
@@ -18,6 +18,29 @@ export const DONE = "done";
18
18
  export const SIZES = ["S", "M", "L"];
19
19
  export const TRACKS = ["flow", "hand"];
20
20
  export const CLASSES = ["tests-only", "prose", "gate-script", "code", "docs", "eval", "decision"];
21
+ // Why a task stopped, as a closed set: every halt site in the doug-implement workflow sets one beside its prose
22
+ // stopReason. The workflow carries its own literal copy (it may not import); a template test keeps them equal.
23
+ export const STOP_CLASSES = [
24
+ "implementer-blocked",
25
+ "partial",
26
+ "stage-missing",
27
+ "spec-contradiction",
28
+ "environment",
29
+ "adversary-not-run",
30
+ "outside-owned",
31
+ "new-blockers-twice",
32
+ "fix-attempts-exhausted",
33
+ "stalled",
34
+ "budget",
35
+ "no-new-commit",
36
+ "dependency-skipped",
37
+ "stage-threw",
38
+ "level-adversary",
39
+ ];
40
+ // "<id> [<class>]: "<reason>"" for a stopped task; an old report has no class and prints "<id>: "<reason>"".
41
+ function stopText(t) {
42
+ return `${t.id}${t.stopClass ? ` [${t.stopClass}]` : ""}: "${t.stopReason}"`;
43
+ }
21
44
  export const DEFAULT_TAGS = ["bug", "feature", "chore", "docs", "refactor", "spike"];
22
45
 
23
46
  export const DEFAULT_COLUMNS = [
@@ -28,6 +51,81 @@ export const DEFAULT_COLUMNS = [
28
51
  { id: "done", title: "Done", hint: "merged, gate green, documented" },
29
52
  ];
30
53
 
54
+ // Ready is capped at 7; only the user promotes cards to Ready. A line for stderr when it holds more, else null.
55
+ export const READY_CAP = 7;
56
+ export function readyCapWarning(board) {
57
+ const n = board.cards.filter((c) => c.column === "ready").length;
58
+ return n <= READY_CAP ? null : `warning: Ready holds ${n} cards, above the cap of ${READY_CAP}; only the user promotes cards to Ready\n`;
59
+ }
60
+
61
+ // Wall-clock budget per card in minutes, a size x class table at p80 of measured landings (cards
62
+ // autopilot-wall-cap-by-size, wall-budget-size-class). Each size has `all`; a class key is present only when its
63
+ // cell had WALL_BUDGET_MIN_N or more landings. `board.mjs budgets --from docs/live-runs.md` recomputes it.
64
+ export const WALL_BUDGET_MIN_N = 5;
65
+ export const WALL_BUDGET_MIN = {"S":{"all":57,"code":53,"docs":29,"gate-script":116,"prose":50,"tests-only":66},"M":{"all":102,"code":112,"gate-script":289},"L":{"all":274}};
66
+
67
+ // A card's budget: its size's class cell when it has one, else the size's `all`. No or an unknown size counts as M.
68
+ export function cardBudgetMinutes(card, table = WALL_BUDGET_MIN) {
69
+ const size = card && (card.size === "S" || card.size === "M" || card.size === "L") ? card.size : "M";
70
+ const row = table[size];
71
+ const cls = card && card.class;
72
+ return typeof cls === "string" && Object.prototype.hasOwnProperty.call(row, cls) && cls !== "all" ? row[cls] : row.all;
73
+ }
74
+
75
+ export function wallCapMinutes(cards, table = WALL_BUDGET_MIN) {
76
+ return cards.reduce((sum, c) => sum + cardBudgetMinutes(c, table), 0);
77
+ }
78
+
79
+ // The landings in a docs/live-runs.md text: one { id, minutes } per `## <date>, <id>: <title>` section that has a
80
+ // `| Wall clock | <n> min |` row (text after min is allowed; a decimal n is skipped) (the first such row). Older multi-column tables have no such row and are skipped.
81
+ export function parseLiveRunWalls(text) {
82
+ const out = [];
83
+ let id = null;
84
+ let seen = false;
85
+ for (const line of String(text).split("\n")) {
86
+ if (line.startsWith("## ")) {
87
+ const m = /^## \d{4}-\d{2}-\d{2}, ([^:\s]+): /.exec(line);
88
+ id = m ? m[1] : null;
89
+ seen = false;
90
+ continue;
91
+ }
92
+ if (id === null || seen) continue;
93
+ const w = /^\| Wall clock \| (\d+) min(?:\s[^|]*)? \|\s*$/.exec(line);
94
+ if (w) {
95
+ out.push({ id, minutes: Number(w[1]) });
96
+ seen = true;
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+
102
+ // The table recomputed from samples joined to their cards: p80 (sorted ascending, index min(floor(0.8 n), n - 1)) per size and per size/class cell. A
103
+ // class cell under minN samples is left out (it falls back to its size's `all`); a size with no samples keeps the
104
+ // fallback's `all`. `cells` lists every cell with its n and p80 (cls "all" is the size row; p80 null when n is 0).
105
+ export function wallBudgetTable(samples, board, { minN = WALL_BUDGET_MIN_N, fallback = WALL_BUDGET_MIN } = {}) {
106
+ const byId = new Map(board.cards.map((c) => [c.id, c]));
107
+ const groups = { S: { all: [] }, M: { all: [] }, L: { all: [] } };
108
+ for (const s of samples) {
109
+ const card = byId.get(s.id);
110
+ if (!card || !(card.size === "S" || card.size === "M" || card.size === "L")) continue;
111
+ groups[card.size].all.push(s.minutes);
112
+ if (typeof card.class === "string" && card.class && card.class !== "all") (groups[card.size][card.class] ||= []).push(s.minutes);
113
+ }
114
+ const p80 = (xs) => [...xs].sort((a, b) => a - b)[Math.min(Math.floor(0.8 * xs.length), xs.length - 1)];
115
+ const table = {};
116
+ const cells = [];
117
+ for (const size of ["S", "M", "L"]) {
118
+ const g = groups[size];
119
+ table[size] = { all: g.all.length ? p80(g.all) : fallback[size].all };
120
+ cells.push({ size, cls: "all", n: g.all.length, p80: g.all.length ? p80(g.all) : null });
121
+ for (const cls of Object.keys(g).filter((k) => k !== "all").sort()) {
122
+ cells.push({ size, cls, n: g[cls].length, p80: p80(g[cls]) });
123
+ if (g[cls].length >= minN) table[size][cls] = p80(g[cls]);
124
+ }
125
+ }
126
+ return { table, cells };
127
+ }
128
+
31
129
  function today() {
32
130
  return new Date().toISOString().slice(0, 10);
33
131
  }
@@ -197,8 +295,9 @@ export function findCard(board, id) {
197
295
  // The first Ready card, in board order, whose dependencies are all Done. Ready cards that are waiting
198
296
  // on something are reported so the user can see why they were skipped.
199
297
  // The first Ready card on the given track whose deps are Done. A card on the hand track is built directly in the
200
- // checkout (tests, gate, commit) by /core-next, never by /doug-next; a flow card the other way round. A Ready card on
201
- // the other track is skipped with hand: true or flow: true so the skill can say so. Decision 0005.
298
+ // checkout (tests, gate, commit) through doug-hand, which /doug-next hands it to by id; a flow card the other way round. A Ready card on
299
+ // the other track is skipped with hand: true or flow: true so the skill can say so. Decision 0005. `track: "any"`
300
+ // takes a Ready card of either track (card autopilot): no track skip entries, the deps rule unchanged.
202
301
  // `tag`, when given, filters to Ready cards carrying it: a card without it is skipped SILENTLY (not pushed onto
203
302
  // `skipped`, since a caller-requested filter should not print a "skipping" line per card it filters out); a `tag`
204
303
  // outside boardTags(board) throws.
@@ -210,7 +309,7 @@ export function nextReadyCard(board, { track = "flow", tag } = {}) {
210
309
  if (c.column !== READY) continue;
211
310
  if (tag !== undefined && !(c.tags || []).includes(tag)) continue;
212
311
  const cardTrack = c.track === "hand" ? "hand" : "flow";
213
- if (cardTrack !== track) {
312
+ if (track !== "any" && cardTrack !== track) {
214
313
  skipped.push(cardTrack === "hand" ? { id: c.id, hand: true } : { id: c.id, flow: true });
215
314
  continue;
216
315
  }
@@ -237,7 +336,7 @@ export function nextReadyCards(board, { track = "flow", batch, tag } = {}) {
237
336
  if (c.column !== READY || cards.length >= batch) continue;
238
337
  if (tag !== undefined && !(c.tags || []).includes(tag)) continue;
239
338
  const cardTrack = c.track === "hand" ? "hand" : "flow";
240
- if (cardTrack !== track) {
339
+ if (track !== "any" && cardTrack !== track) {
241
340
  skipped.push(cardTrack === "hand" ? { id: c.id, hand: true } : { id: c.id, flow: true });
242
341
  continue;
243
342
  }
@@ -579,7 +678,36 @@ function outcomeOf(report) {
579
678
  return "not green";
580
679
  }
581
680
 
582
- export function runEntry({ card, report, cost = null, codexCost = null, wallClock = null, mergeCommit = null, record = null, adversary = [], date = new Date().toISOString().slice(0, 10), batch = null, sharedCost = null, taskIds = [], note = null, rehearsal = null }) {
681
+ // Card flow-wall-budget: the Stage wall row and the summary note from cost.mjs's runWall result (passed in; this
682
+ // library does not import cost.mjs). `keep` narrows the flags to one card's entry of a batch run.
683
+ const wallMin = (ms) => {
684
+ const m = ms / 60000;
685
+ return Number.isInteger(m) ? String(m) : m.toFixed(1);
686
+ };
687
+ function wallFlagText(f) {
688
+ const what = f.kind === "pass" ? `${f.label} pass ${f.pass ?? "?"}` : `${f.kind} ${f.label}`;
689
+ return `${what} ${wallMin(f.ms)} min > ${wallMin(f.limitMs)}`;
690
+ }
691
+ function wallFlagsFor(wall, report, batch, cardId) {
692
+ const flags = Array.isArray(wall.flags) ? wall.flags : [];
693
+ if (!Array.isArray(batch) || batch.length < 2) return flags;
694
+ const ids = (report.levels || []).flatMap((l) => (l.tasks || []).map((t) => t.id));
695
+ return flags.filter((f) => {
696
+ const label = String(f.label).replace(/:retry$/, "");
697
+ if (/(^|:)level-\d+$/.test(label)) return batch[0] === cardId;
698
+ return ids.some((id) => label === id || label.startsWith(`${id}:`) || label.includes(`:${id}:`) || label.endsWith(`:${id}`));
699
+ });
700
+ }
701
+ function wallRow(wall, flags) {
702
+ if (!wall) return "not measured";
703
+ if (!wall.measured) return `unverified: ${wall.reason || "no agent times"}`;
704
+ const unverified = Array.isArray(wall.unverified) && wall.unverified.length ? `; unverified tasks: ${wall.unverified.join(", ")}` : "";
705
+ if (flags.length) return `over: ${flags.map(wallFlagText).join("; ")}${unverified}`;
706
+ const longest = Math.max(0, ...(wall.tasks || []).map((t) => t.ms));
707
+ return `within budget (${wall.wallMinutes ?? wallMin(longest)} min a task)${unverified}`;
708
+ }
709
+
710
+ export function runEntry({ card, report, cost = null, codexCost = null, wallClock = null, wall = null, mergeCommit = null, record = null, adversary = [], date = new Date().toISOString().slice(0, 10), batch = null, sharedCost = null, taskIds = [], note = null, rehearsal = null }) {
583
711
  const inBatch = Array.isArray(batch) && batch.length > 1;
584
712
  if (inBatch) report = filterReportForCard(report, card.id, taskIds);
585
713
  const lines = [];
@@ -588,7 +716,7 @@ export function runEntry({ card, report, cost = null, codexCost = null, wallCloc
588
716
  const batchNote = inBatch ? `, in one plan with ${listOthers(batch, card.id)} (one approval, one run, one landing)` : "";
589
717
  lines.push(
590
718
  rehearsal
591
- ? `Rehearsal ${rehearsal} for card \`${card.id}\` on the ts-basic fixture (plugins/doug-flow/scripts/rehearse.mjs); the commit and gate below are the fixture's, not this repository's.`
719
+ ? `Rehearsal ${rehearsal} for card \`${card.id}\` on the ts-basic fixture (scripts/rehearse.mjs); the commit and gate below are the fixture's, not this repository's.`
592
720
  : `Ran through /doug-next on this repo from card \`${card.id}\`${record ? ` in \`${record}\`` : ""}${batchNote}. Plan "${report.plan}", integration branch \`${report.integrationBranch}\`${report.modelsSource ? `, models from ${report.modelsSource}` : ""}.`,
593
721
  );
594
722
  lines.push("");
@@ -609,6 +737,7 @@ export function runEntry({ card, report, cost = null, codexCost = null, wallCloc
609
737
  }
610
738
  if (inBatch) lines.push(`| Shared integration agents | ${sharedCost === null ? "not measured" : `$${Number(sharedCost).toFixed(2)}, counted once for the batch ${batch[0] === card.id ? "here" : `under ${batch[0]}`}`} |`);
611
739
  lines.push(`| Wall clock | ${wallClock === null ? "not measured" : wallClock} |`);
740
+ lines.push(`| Stage wall | ${wallRow(wall, wall ? wallFlagsFor(wall, report, inBatch ? batch : null, card.id) : [])} |`);
612
741
  const blocks = classifyBlocks(report, adversary);
613
742
  const shownCommit = rehearsal && mergeCommit ? String(mergeCommit).slice(0, 7) : mergeCommit;
614
743
  lines.push(`| Landed as | ${shownCommit ? `\`${shownCommit}\`` : "not landed"} |`);
@@ -634,6 +763,11 @@ export function runEntry({ card, report, cost = null, codexCost = null, wallCloc
634
763
  }
635
764
  lines.push(`| ${level.index} | integration | ${level.integration ? (level.integration.ok ? "ok" : "failed") : "not run"} | | | | ${describeTierCell({ implement: level.integrationModel })} | ${levelAdversaryCell(level.levelAdversary)} |`);
636
765
  }
766
+ const stopped = (report.levels || []).flatMap((l) => (l.tasks || []).filter((t) => t.stopReason));
767
+ if (stopped.length) {
768
+ lines.push("");
769
+ lines.push(`Stopped: ${stopped.map(stopText).join("; ")}`);
770
+ }
637
771
  if (blocks.length) {
638
772
  lines.push("");
639
773
  lines.push("Adversary blocks, classified (real: a defect a user would hit; marginal: true to the spec, no user impact; false: wrong):");
@@ -644,15 +778,15 @@ export function runEntry({ card, report, cost = null, codexCost = null, wallCloc
644
778
  }
645
779
 
646
780
  // The live-runs entry for a hand-track card (decision 0005): no workflow report and no levels, so it records what
647
- // /core-next can measure: the landing commit, the wall clock, and the gate result, plus an optional note.
781
+ // doug-hand can measure: the landing commit, the wall clock, and the gate result, plus an optional note.
648
782
  export function handEntry({ card, commit = null, wallClock = null, gate = null, note = null, record = null, date = new Date().toISOString().slice(0, 10), rehearsal = null }) {
649
783
  const lines = [];
650
784
  lines.push(`## ${date}, ${card.id}: ${card.title}`);
651
785
  lines.push("");
652
786
  lines.push(
653
787
  rehearsal
654
- ? `Rehearsal ${rehearsal} for card \`${card.id}\` on the ts-basic fixture (plugins/doug-flow/scripts/rehearse.mjs); the commit and gate below are the fixture's, not this repository's.`
655
- : `Built by hand through /core-next from card \`${card.id}\`${record ? ` in \`${record}\`` : ""} (hand track, decision 0005); no workflow run.`,
788
+ ? `Rehearsal ${rehearsal} for card \`${card.id}\` on the ts-basic fixture (scripts/rehearse.mjs); the commit and gate below are the fixture's, not this repository's.`
789
+ : `Built by hand through doug-hand from card \`${card.id}\`${record ? ` in \`${record}\`` : ""} (hand track: a gated by-hand change); no workflow run.`,
656
790
  );
657
791
  lines.push("");
658
792
  lines.push("| Measure | Value |");
@@ -764,13 +898,21 @@ function acceptanceUnmet(report, tasks) {
764
898
  return { unmet, source: ` (acceptance read per task by [card] tag${asideNote})`, dirty: asideFailing > 0 };
765
899
  }
766
900
 
901
+ function wallNote(wall, flags) {
902
+ if (!wall) return "";
903
+ if (!wall.measured) return " (stage times unverified)";
904
+ const n = Array.isArray(wall.unverified) ? wall.unverified.length : 0;
905
+ const unv = n ? ` (stage times unverified for ${n} task${n === 1 ? "" : "s"})` : "";
906
+ return (flags.length ? ` (over budget: ${flags.map(wallFlagText).join("; ")})` : "") + unv;
907
+ }
908
+
767
909
  // Three lines for the chat after a run: the outcome, the tasks, and what stopped or was not met. Everything in it
768
910
  // is read from the report; nothing is estimated.
769
- export function runSummary({ report, cost = null, codexCost = null, wallClock = null, mergeCommit = null }) {
911
+ export function runSummary({ report, cost = null, codexCost = null, wallClock = null, wall = null, mergeCommit = null, batch = null, card = null }) {
770
912
  const tasks = (report.levels || []).flatMap((l) => (l.tasks || []).map((t) => ({ level: l, task: t })));
771
913
  const integrated = tasks.filter(({ level }) => level.integration && level.integration.ok).length;
772
914
  const outcome = outcomeOf(report);
773
- const first = `${report.plan}: ${outcome}; ${tasks.length} task${tasks.length === 1 ? "" : "s"}, ${integrated} integrated; wall ${wallClock === null ? "not measured" : wallClock}; cost ${cost === null ? "not measured" : `$${Number(cost).toFixed(2)}`}${codexCost === null ? "" : `; Codex $${Number(codexCost).toFixed(2)}`}${mergeCommit ? `; landed as ${mergeCommit}` : ""}.`;
915
+ const first = `${report.plan}: ${outcome}; ${tasks.length} task${tasks.length === 1 ? "" : "s"}, ${integrated} integrated; wall ${wallClock === null ? "not measured" : wallClock}; cost ${cost === null ? "not measured" : `$${Number(cost).toFixed(2)}`}${codexCost === null ? "" : `; Codex $${Number(codexCost).toFixed(2)}`}${mergeCommit ? `; landed as ${mergeCommit}` : ""}${wallNote(wall, wall ? wallFlagsFor(wall, report, batch, card) : [])}.`;
774
916
  const second = tasks.length
775
917
  ? tasks.map(({ task: t }) => {
776
918
  const passes = Array.isArray(t.attempts) && t.attempts.length ? t.attempts.length : 1;
@@ -780,7 +922,7 @@ export function runSummary({ report, cost = null, codexCost = null, wallClock =
780
922
  return `${t.id}: ${impl}, ${passes} pass${passes === 1 ? "" : "es"}, verify ${yesNo(t.verified)}, review ${yesNo(t.reviewed)}, adversary ${adv}`;
781
923
  }).join("; ")
782
924
  : "no tasks";
783
- const stops = tasks.filter(({ task: t }) => t.stopReason).map(({ task: t }) => `${t.id}: "${t.stopReason}"`);
925
+ const stops = tasks.filter(({ task: t }) => t.stopReason).map(({ task: t }) => stopText(t));
784
926
  const { unmet, source, dirty } = acceptanceUnmet(report, tasks);
785
927
  const pause = pausedAt(report) ? `Paused at the human gate after level ${pausedAt(report).level}; next: ${listNext(report)}. Open it with plan.mjs gate open ${pausedAt(report).level} and resume the run.` : "";
786
928
  const third = pause || stops.length || unmet.length
@@ -545,7 +545,7 @@ function rowsByIdMap(m, ids) {
545
545
  // the BM25 side (a precomputed allow-list of matching paths, so the ranking itself runs restricted), in memory
546
546
  // for the vector side. Never throws for a provider problem; a query with no FTS tokens simply has no BM25 side.
547
547
  export async function searchIndex(m, query, opts = {}) {
548
- const { k = 8, files = [], provider = null } = opts;
548
+ const { k = 8, files = [], provider = null, detectedProvider = null } = opts;
549
549
 
550
550
  const allowedPaths = files.length
551
551
  ? new Set(
@@ -576,14 +576,16 @@ export async function searchIndex(m, query, opts = {}) {
576
576
  let reason = "no provider configured";
577
577
  let providerName = null;
578
578
  let vectorRanked = [];
579
+ let vectorsForProvider = 0;
579
580
  if (provider) {
580
581
  providerName = provider.name;
581
582
  let vectorRows = m
582
583
  .prepare("SELECT id, path, embedding FROM code_chunks WHERE embedding_model = ? AND embedding_dims = ?")
583
584
  .all(provider.model, provider.dims);
585
+ vectorsForProvider = vectorRows.length;
584
586
  if (allowedPaths) vectorRows = vectorRows.filter((r) => allowedPaths.has(r.path));
585
587
  if (!vectorRows.length) {
586
- reason = `no vectors stored for ${provider.model}/${provider.dims}`;
588
+ reason = vectorsForProvider > 0 ? "no vectors in the filtered paths" : `no vectors stored for ${provider.model}/${provider.dims}`;
587
589
  } else {
588
590
  const embedResult = await provider.embed([String(query || "")], { inputType: "query" });
589
591
  if (!embedResult.ok) {
@@ -604,6 +606,19 @@ export async function searchIndex(m, query, opts = {}) {
604
606
  }
605
607
  }
606
608
 
609
+ // card recall-fallback-warning: a filter that excludes every vector while vectors exist for the provider is
610
+ // not degraded; hybrid counts chunks without a vector for the provider over the whole index.
611
+ const anyVectors = () => m.prepare("SELECT 1 FROM code_chunks WHERE embedding IS NOT NULL LIMIT 1").get() !== undefined;
612
+ const filteredOut = mode === "keyword-only" && !!provider && vectorsForProvider > 0 && reason === "no vectors in the filtered paths";
613
+ const degraded = mode === "keyword-only" && !filteredOut && (!!provider || anyVectors() || !!detectedProvider);
614
+ const coverage = {};
615
+ if (mode === "hybrid") {
616
+ coverage.total = m.prepare("SELECT COUNT(*) AS n FROM code_chunks").get().n;
617
+ coverage.unvectored = coverage.total - m
618
+ .prepare("SELECT COUNT(*) AS n FROM code_chunks WHERE embedding_model = ? AND embedding_dims = ?")
619
+ .get(provider.model, provider.dims).n;
620
+ }
621
+
607
622
  const fused = new Map();
608
623
  bm25Ranked.forEach((id, idx) => fused.set(id, (fused.get(id) || 0) + 1 / (60 + idx + 1)));
609
624
  vectorRanked.forEach((id, idx) => fused.set(id, (fused.get(id) || 0) + 1 / (60 + idx + 1)));
@@ -625,5 +640,5 @@ export async function searchIndex(m, query, opts = {}) {
625
640
  })
626
641
  .filter(Boolean);
627
642
 
628
- return { mode, reason: mode === "hybrid" ? null : reason, provider: providerName, chunks };
643
+ return { mode, reason: mode === "hybrid" ? null : reason, provider: providerName, degraded, ...coverage, chunks };
629
644
  }
@@ -0,0 +1,57 @@
1
+ // The lead's own context reading (card autopilot-unlimited): scripts/context.mjs prints the last assistant line's
2
+ // usage in the session transcript against its model's window, and whether it is at autopilot.contextThreshold.
3
+ // windowFor and contextReading are copied from plugins/doug-gates/lib/agent-context.mjs: the plugins install
4
+ // separately, so doug-flow cannot import across the boundary (as learn.mjs keeps its own local glob copy).
5
+
6
+ import { readFileSync, existsSync } from "node:fs";
7
+
8
+ // 1M for a Fable/Opus/Sonnet 5 model, 200k otherwise (an unknown or missing model errs early, on the smaller window).
9
+ export function windowFor(model) {
10
+ if (typeof model !== "string") return 200000;
11
+ return /fable-5|opus-5|sonnet-5/.test(model) ? 1000000 : 200000;
12
+ }
13
+
14
+ // The last assistant line with usage in a transcript file, against its model's window. null when the file is
15
+ // missing, unreadable, or has no assistant line carrying usage.
16
+ export function contextReading(file, { windows = windowFor } = {}) {
17
+ if (!file || !existsSync(file)) return null;
18
+ let text;
19
+ try {
20
+ text = readFileSync(file, "utf8");
21
+ } catch {
22
+ return null;
23
+ }
24
+ let lastMessage = null;
25
+ for (const line of text.split("\n")) {
26
+ if (!line.trim()) continue;
27
+ let o;
28
+ try {
29
+ o = JSON.parse(line);
30
+ } catch {
31
+ continue;
32
+ }
33
+ if (o && o.type === "assistant" && o.message && o.message.usage) lastMessage = o.message;
34
+ }
35
+ if (!lastMessage) return null;
36
+ const u = lastMessage.usage;
37
+ const tokens = (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0);
38
+ const model = lastMessage.model || null;
39
+ const window = windows(model);
40
+ return { tokens, model, window, pct: Math.round((100 * tokens) / window) };
41
+ }
42
+
43
+ export const CONTEXT_THRESHOLD_DEFAULT = 60;
44
+
45
+ // The configured autopilot.contextThreshold when it is a finite number in 1..100; the default silently when the key
46
+ // (or autopilot) is absent or null; the default plus one warn() call otherwise.
47
+ export function contextThreshold(config, warn = () => {}) {
48
+ const v = config && config.autopilot ? config.autopilot.contextThreshold : undefined;
49
+ if (v === undefined || v === null) return CONTEXT_THRESHOLD_DEFAULT;
50
+ if (typeof v === "number" && Number.isFinite(v) && v >= 1 && v <= 100) return v;
51
+ warn(`autopilot.contextThreshold must be a number from 1 to 100; using ${CONTEXT_THRESHOLD_DEFAULT}`);
52
+ return CONTEXT_THRESHOLD_DEFAULT;
53
+ }
54
+
55
+ export function atThreshold(pct, threshold) {
56
+ return pct >= threshold;
57
+ }
@@ -1,15 +1,15 @@
1
1
  // Real cost of one doug-implement run, from the local Claude Code transcripts (decision 0005, card cost-per-run).
2
2
  // No network: the run's agent ids come from its journal under ~/.claude/projects/<slug>/<session>/subagents/workflows/<run>/,
3
3
  // each agent's tokens are summed from its transcript there, and the price comes from the table below.
4
- import { existsSync, readdirSync, readFileSync } from "node:fs";
4
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
5
5
  import { homedir } from "node:os";
6
- import { join } from "node:path";
6
+ import { basename, join } from "node:path";
7
7
 
8
8
  // USD per million tokens, first-party Claude API list prices. Cache reads are 0.1x input (0.025x on Fable 5.1,
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
- // https://platform.claude.com/docs/en/about-claude/pricing, fetched 2026-09-23. Update the date with the table.
12
- export const PRICES_DATED = "2026-09-23";
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";
13
13
  export const PRICES = {
14
14
  "claude-fable-5-1": { input: 10, output: 50, cacheRead: 0.25, cacheWrite5m: 12.5, cacheWrite1h: 20 },
15
15
  "claude-fable-5": { input: 10, output: 50, cacheRead: 1, cacheWrite5m: 12.5, cacheWrite1h: 20 },
@@ -18,6 +18,7 @@ export const PRICES = {
18
18
  "claude-opus-4-8": { input: 5, output: 25, cacheRead: 0.5, cacheWrite5m: 6.25, cacheWrite1h: 10 },
19
19
  "claude-opus-4-7": { input: 5, output: 25, cacheRead: 0.5, cacheWrite5m: 6.25, cacheWrite1h: 10 },
20
20
  "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 },
21
22
  "claude-sonnet-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite5m: 2.5, cacheWrite1h: 4 },
22
23
  "claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite5m: 3.75, cacheWrite1h: 6 },
23
24
  "claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite5m: 1.25, cacheWrite1h: 2 },
@@ -318,6 +319,64 @@ export function costRun({ runId, claudeDir, projectDir, report, reportPath } = {
318
319
  };
319
320
  }
320
321
 
322
+ // Card flow-wall-budget: slow tasks flagged after the run, from the runtime's run file (workflowProgress[] agents
323
+ // carry startedAt and durationMs). Detection only; the budget is the report's wallMinutes. A task's wall and a
324
+ // pass's wall are spans (latest end minus earliest start), never sums, since agents of a pass run alongside.
325
+ export const PASS_WALL_SHARE = 0.75;
326
+ export const STAGE_WALL_SHARE = 0.25;
327
+
328
+ export function runWall(run, { wallMinutes } = {}) {
329
+ const MIN = 60000;
330
+ const result = (extra) => ({ measured: false, reason: null, wallMinutes, unverified: [], tasks: [], flags: [], ...extra });
331
+ const agents = run && Array.isArray(run.workflowProgress) ? run.workflowProgress.filter((p) => p && p.type === "workflow_agent") : [];
332
+ if (!agents.length) return result({ reason: "no run file" });
333
+ const timed = (a) => typeof a.startedAt === "number" && typeof a.durationMs === "number";
334
+ if (!agents.some(timed)) return result({ reason: "no agent times" });
335
+ const taskLimit = wallMinutes * MIN;
336
+ const passLimit = PASS_WALL_SHARE * taskLimit;
337
+ const stageLimit = STAGE_WALL_SHARE * taskLimit;
338
+ const byTask = new Map();
339
+ const stageFlags = [];
340
+ for (const a of agents) {
341
+ const label = String(a.label || "");
342
+ const parsed = parseLabel(label.replace(/:retry$/, ""));
343
+ if (timed(a) && a.durationMs > stageLimit) stageFlags.push({ kind: "stage", label, ms: a.durationMs, limitMs: stageLimit });
344
+ if (!parsed.task || /^level-\d+$/.test(parsed.task)) continue;
345
+ const t = byTask.get(parsed.task) || { task: parsed.task, agents: [], missing: false };
346
+ if (timed(a)) t.agents.push({ pass: parsed.pass ?? 1, from: a.startedAt, to: a.startedAt + a.durationMs });
347
+ else t.missing = true;
348
+ byTask.set(parsed.task, t);
349
+ }
350
+ const span = (list) => Math.max(...list.map((x) => x.to)) - Math.min(...list.map((x) => x.from));
351
+ const unverified = [];
352
+ const tasks = [];
353
+ const flags = [];
354
+ for (const t of byTask.values()) {
355
+ if (t.missing || !t.agents.length) {
356
+ unverified.push(t.task);
357
+ continue;
358
+ }
359
+ const passNums = [...new Set(t.agents.map((x) => x.pass))].sort((x, y) => x - y);
360
+ const passes = passNums.map((n) => ({ pass: n, ms: span(t.agents.filter((x) => x.pass === n)) }));
361
+ const ms = span(t.agents);
362
+ tasks.push({ task: t.task, ms, passes, stages: [] });
363
+ if (ms > taskLimit) flags.push({ kind: "task", label: t.task, ms, limitMs: taskLimit });
364
+ for (const p of passes) if (p.ms > passLimit) flags.push({ kind: "pass", label: t.task, pass: p.pass, ms: p.ms, limitMs: passLimit });
365
+ }
366
+ return result({ measured: true, unverified, tasks, flags: [...flags, ...stageFlags] });
367
+ }
368
+
369
+ // runWall for a run id: finds the run file next to the journal; never throws, an unreadable run is not measured.
370
+ export function wallForRun({ runId, claudeDir, projectDir, wallMinutes } = {}) {
371
+ try {
372
+ const found = findRunDir({ runId, claudeDir, projectDir });
373
+ if (!found) return runWall(null, { wallMinutes });
374
+ return runWall(readJson(found.runFile), { wallMinutes });
375
+ } catch {
376
+ return runWall(null, { wallMinutes });
377
+ }
378
+ }
379
+
321
380
  // A batch run's cost split per card (card parallel-cards): every agent whose task is one of a card's plan tasks
322
381
  // counts for that card; the shared stages (integrate, the level adversary: task `level-<n>`) are listed once as
323
382
  // `shared`; agents on no card's task are `other`. Nothing estimated: unpriced agents are counted, not summed.
@@ -359,6 +418,72 @@ export function renderCostByCard(byCard) {
359
418
  const fmtInt = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
360
419
  const fmtUsd = (n) => (n === null ? "unpriced" : `$${n.toFixed(2)}`);
361
420
 
421
+ // The top-level transcript path for a session id or "latest" (the most recently modified one in the project's slug
422
+ // directory). Throws when none is found. Used by costSession and scripts/context.mjs.
423
+ export function sessionTranscriptPath({ session, claudeDir = join(homedir(), ".claude"), projectDir = process.cwd() } = {}) {
424
+ if (!session) throw new Error("a session id or latest is required");
425
+ const slugDir = join(claudeDir, "projects", projectSlug(projectDir));
426
+ let id = session;
427
+ if (session === "latest") {
428
+ let best = null;
429
+ if (existsSync(slugDir)) {
430
+ for (const e of readdirSync(slugDir, { withFileTypes: true })) {
431
+ if (!e.isFile() || !e.name.endsWith(".jsonl")) continue;
432
+ const mtime = statSync(join(slugDir, e.name)).mtimeMs;
433
+ if (!best || mtime > best.mtime) best = { id: e.name.slice(0, -".jsonl".length), mtime };
434
+ }
435
+ }
436
+ if (!best) throw new Error(`no session transcript under ${slugDir}`);
437
+ id = best.id;
438
+ }
439
+ const top = join(slugDir, `${id}.jsonl`);
440
+ if (!existsSync(top)) throw new Error(`no transcript for session ${id} under ${slugDir}`);
441
+ return top;
442
+ }
443
+
444
+ // The cost of one Claude Code session (card autopilot): its top-level transcript plus every *.jsonl under
445
+ // <slug>/<session>/subagents/, recursively (plain agents and workflows/wf_*/ agents). `session` is an id or "latest",
446
+ // the most recently modified top-level transcript in the project's slug directory. Throws when none is found.
447
+ // Workflow journal.jsonl files carry no usage lines and add nothing.
448
+ export function costSession({ session, claudeDir = join(homedir(), ".claude"), projectDir = process.cwd() } = {}) {
449
+ const top = sessionTranscriptPath({ session, claudeDir, projectDir });
450
+ const slugDir = join(claudeDir, "projects", projectSlug(projectDir));
451
+ const id = basename(top, ".jsonl");
452
+ const files = [top];
453
+ const walk = (dir) => {
454
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
455
+ const p = join(dir, e.name);
456
+ if (e.isDirectory()) walk(p);
457
+ else if (e.isFile() && e.name.endsWith(".jsonl")) files.push(p);
458
+ }
459
+ };
460
+ const subDir = join(slugDir, id, "subagents");
461
+ if (existsSync(subDir)) walk(subDir);
462
+ const usage = emptyUsage();
463
+ let usd = 0;
464
+ let unpriced = 0;
465
+ const notes = [];
466
+ for (const f of files) {
467
+ const t = transcriptUsage(f);
468
+ addUsage(usage, t.usage);
469
+ if (!t.messages) continue;
470
+ const price = t.model ? priceUsage(t.usage, t.model) : null;
471
+ if (price === null) {
472
+ unpriced++;
473
+ notes.push(`${f}: model ${t.model || "unknown"} is not in the price table; not priced`);
474
+ } else usd += price;
475
+ }
476
+ return { session: id, files: files.length, total: { usage, usd, unpriced, agents: files.length }, pricesDated: PRICES_DATED, notes };
477
+ }
478
+
479
+ export function renderSession(result) {
480
+ const t = result.total;
481
+ const lines = [`Session ${result.session}: ${result.files} transcript${result.files === 1 ? "" : "s"}.`];
482
+ lines.push(`Session total: ${fmtInt(t.usage.input)} input, ${fmtInt(t.usage.output)} output, ${fmtInt(t.usage.cacheRead)} cache read, ${fmtInt(t.usage.cacheWrite)} cache write tokens; ${fmtUsd(t.usd)}${t.unpriced ? ` (${t.unpriced} transcript(s) unpriced)` : ""} at list prices dated ${result.pricesDated}.`);
483
+ for (const n of result.notes) lines.push(`Note: ${n}.`);
484
+ return lines.join("\n") + "\n";
485
+ }
486
+
362
487
  export function renderCost(result) {
363
488
  const lines = [];
364
489
  const when = result.startedAt ? `, ${result.startedAt}` : "";