@dougharness/cli 1.0.2 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/bin.js +18 -3
  2. package/dist/bin.js.map +1 -1
  3. package/dist/board-serve.js +14 -7
  4. package/dist/board-serve.js.map +1 -1
  5. package/dist/board.d.ts +1 -1
  6. package/dist/board.js +42 -9
  7. package/dist/board.js.map +1 -1
  8. package/dist/detect/repo.js +14 -1
  9. package/dist/detect/repo.js.map +1 -1
  10. package/dist/detect/types.d.ts +2 -0
  11. package/dist/doctor.d.ts +46 -0
  12. package/dist/doctor.js +491 -0
  13. package/dist/doctor.js.map +1 -0
  14. package/dist/generate/agents.d.ts +7 -0
  15. package/dist/generate/agents.js +62 -12
  16. package/dist/generate/agents.js.map +1 -1
  17. package/dist/generate/claude-md.js +4 -0
  18. package/dist/generate/claude-md.js.map +1 -1
  19. package/dist/generate/config.d.ts +8 -0
  20. package/dist/generate/config.js +4 -1
  21. package/dist/generate/config.js.map +1 -1
  22. package/dist/generate/proposal.js +15 -6
  23. package/dist/generate/proposal.js.map +1 -1
  24. package/dist/generate/settings.d.ts +2 -0
  25. package/dist/generate/settings.js +17 -2
  26. package/dist/generate/settings.js.map +1 -1
  27. package/dist/init.d.ts +14 -0
  28. package/dist/init.js +94 -8
  29. package/dist/init.js.map +1 -1
  30. package/dist/memory-setup.d.ts +28 -0
  31. package/dist/memory-setup.js +326 -0
  32. package/dist/memory-setup.js.map +1 -0
  33. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +16 -4
  34. package/node_modules/@dougharness/flow/agents/lead.md +1 -1
  35. package/node_modules/@dougharness/flow/agents/planner.md +3 -3
  36. package/node_modules/@dougharness/flow/agents/researcher.md +2 -2
  37. package/node_modules/@dougharness/flow/hooks/hooks.json +16 -0
  38. package/node_modules/@dougharness/flow/lib/board.mjs +171 -13
  39. package/node_modules/@dougharness/flow/lib/code-index.mjs +18 -3
  40. package/node_modules/@dougharness/flow/lib/context.mjs +57 -0
  41. package/node_modules/@dougharness/flow/lib/cost.mjs +155 -7
  42. package/node_modules/@dougharness/flow/lib/embeddings.mjs +188 -24
  43. package/node_modules/@dougharness/flow/lib/land.mjs +74 -10
  44. package/node_modules/@dougharness/flow/lib/learn.mjs +4 -4
  45. package/node_modules/@dougharness/flow/lib/memory.mjs +368 -50
  46. package/node_modules/@dougharness/flow/lib/plan.mjs +11 -1
  47. package/node_modules/@dougharness/flow/package.json +2 -1
  48. package/node_modules/@dougharness/flow/scripts/board.mjs +86 -25
  49. package/node_modules/@dougharness/flow/scripts/context.mjs +50 -0
  50. package/node_modules/@dougharness/flow/scripts/cost.mjs +15 -3
  51. package/node_modules/@dougharness/flow/scripts/memory.mjs +197 -37
  52. package/node_modules/@dougharness/flow/scripts/plan.mjs +54 -64
  53. package/node_modules/@dougharness/flow/scripts/session-recall.mjs +132 -0
  54. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +105 -0
  55. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
  56. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +50 -14
  57. package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +1 -1
  58. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +7 -7
  59. package/node_modules/@dougharness/flow/skills/research/SKILL.md +4 -2
  60. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +14 -13
  61. package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +1 -1
  62. package/node_modules/@dougharness/flow/workflows/doug-implement.js +145 -86
  63. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  64. package/node_modules/@dougharness/gates/hooks/hooks.json +11 -0
  65. package/node_modules/@dougharness/gates/lib/baseline.mjs +166 -1
  66. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +21 -14
  67. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +58 -1
  68. package/node_modules/@dougharness/gates/lib/checkpoint.mjs +1 -1
  69. package/node_modules/@dougharness/gates/lib/config.mjs +20 -0
  70. package/node_modules/@dougharness/gates/lib/docs-gate.mjs +95 -0
  71. package/node_modules/@dougharness/gates/lib/evidence.mjs +2 -0
  72. package/node_modules/@dougharness/gates/lib/gate-lock.mjs +94 -0
  73. package/node_modules/@dougharness/gates/lib/run.mjs +4 -0
  74. package/node_modules/@dougharness/gates/lib/test-totals.mjs +40 -7
  75. package/node_modules/@dougharness/gates/lib/trace.mjs +24 -7
  76. package/node_modules/@dougharness/gates/lib/verified-tree.mjs +225 -0
  77. package/node_modules/@dougharness/gates/package.json +1 -1
  78. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +133 -5
  79. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +558 -197
  80. package/node_modules/@dougharness/gates/scripts/trace.mjs +5 -1
  81. package/node_modules/@dougharness/gates/scripts/turn-start.mjs +17 -0
  82. package/node_modules/@dougharness/gates/scripts/verify.mjs +201 -0
  83. package/package.json +4 -4
  84. package/templates/board-app.js +4 -3
  85. package/node_modules/@dougharness/flow/lib/rehearse.mjs +0 -825
  86. package/node_modules/@dougharness/flow/scripts/rehearse.mjs +0 -129
  87. package/node_modules/@dougharness/flow/skills/core-next/SKILL.md +0 -92
  88. package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +0 -85
@@ -0,0 +1,16 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "matcher": "startup|clear",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-recall.mjs\"",
10
+ "timeout": 5
11
+ }
12
+ ]
13
+ }
14
+ ]
15
+ }
16
+ }
@@ -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):");
@@ -643,16 +777,31 @@ export function runEntry({ card, report, cost = null, codexCost = null, wallCloc
643
777
  return lines.join("\n") + "\n";
644
778
  }
645
779
 
780
+ // A hand landing's wall judged against the card's p80 budget (card hand-wall-flag). `over` is true only when the wall
781
+ // reads as whole minutes and exceeds the budget; `text` is the Wall budget row's value. Per-seat times are not
782
+ // recorded on the hand track, so the verdict always says so.
783
+ export function handWallVerdict(card, wallClock, table = WALL_BUDGET_MIN) {
784
+ const m = /^(\d+) min\b/.exec(typeof wallClock === "string" ? wallClock : "");
785
+ if (!m) return { over: false, text: "not checked (wall not in whole minutes)" };
786
+ const n = Number(m[1]);
787
+ const budget = cardBudgetMinutes(card, table);
788
+ const size = card && (card.size === "S" || card.size === "M" || card.size === "L") ? card.size : "M";
789
+ const cls = card && card.class;
790
+ const cell = typeof cls === "string" && Object.prototype.hasOwnProperty.call(table[size], cls) && cls !== "all" ? cls : "all";
791
+ const over = n > budget;
792
+ return { over, text: `${over ? "over" : "within"}: ${n} min vs ${budget} min (${size}/${cell}, p80); per-seat times unverified` };
793
+ }
794
+
646
795
  // 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.
648
- export function handEntry({ card, commit = null, wallClock = null, gate = null, note = null, record = null, date = new Date().toISOString().slice(0, 10), rehearsal = null }) {
796
+ // doug-hand can measure: the landing commit, the wall clock, and the gate result, plus an optional note.
797
+ export function handEntry({ card, commit = null, wallClock = null, gate = null, note = null, record = null, date = new Date().toISOString().slice(0, 10), rehearsal = null, table = WALL_BUDGET_MIN }) {
649
798
  const lines = [];
650
799
  lines.push(`## ${date}, ${card.id}: ${card.title}`);
651
800
  lines.push("");
652
801
  lines.push(
653
802
  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.`,
803
+ ? `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.`
804
+ : `Built by hand through doug-hand from card \`${card.id}\`${record ? ` in \`${record}\`` : ""} (hand track: a gated by-hand change); no workflow run.`,
656
805
  );
657
806
  lines.push("");
658
807
  lines.push("| Measure | Value |");
@@ -661,6 +810,7 @@ export function handEntry({ card, commit = null, wallClock = null, gate = null,
661
810
  const shownCommit = rehearsal && commit ? String(commit).slice(0, 7) : commit;
662
811
  lines.push(`| Commit | ${shownCommit ? `\`${shownCommit}\`` : "none"} |`);
663
812
  lines.push(`| Wall clock | ${wallClock === null ? "not measured" : wallClock} |`);
813
+ if (!rehearsal) lines.push(`| Wall budget | ${handWallVerdict(card, wallClock, table).text} |`);
664
814
  lines.push(`| Gate | ${gate === null ? "not recorded" : gate} |`);
665
815
  lines.push("");
666
816
  if (note) {
@@ -764,13 +914,21 @@ function acceptanceUnmet(report, tasks) {
764
914
  return { unmet, source: ` (acceptance read per task by [card] tag${asideNote})`, dirty: asideFailing > 0 };
765
915
  }
766
916
 
917
+ function wallNote(wall, flags) {
918
+ if (!wall) return "";
919
+ if (!wall.measured) return " (stage times unverified)";
920
+ const n = Array.isArray(wall.unverified) ? wall.unverified.length : 0;
921
+ const unv = n ? ` (stage times unverified for ${n} task${n === 1 ? "" : "s"})` : "";
922
+ return (flags.length ? ` (over budget: ${flags.map(wallFlagText).join("; ")})` : "") + unv;
923
+ }
924
+
767
925
  // Three lines for the chat after a run: the outcome, the tasks, and what stopped or was not met. Everything in it
768
926
  // is read from the report; nothing is estimated.
769
- export function runSummary({ report, cost = null, codexCost = null, wallClock = null, mergeCommit = null }) {
927
+ export function runSummary({ report, cost = null, codexCost = null, wallClock = null, wall = null, mergeCommit = null, batch = null, card = null }) {
770
928
  const tasks = (report.levels || []).flatMap((l) => (l.tasks || []).map((t) => ({ level: l, task: t })));
771
929
  const integrated = tasks.filter(({ level }) => level.integration && level.integration.ok).length;
772
930
  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}` : ""}.`;
931
+ 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
932
  const second = tasks.length
775
933
  ? tasks.map(({ task: t }) => {
776
934
  const passes = Array.isArray(t.attempts) && t.attempts.length ? t.attempts.length : 1;
@@ -780,7 +938,7 @@ export function runSummary({ report, cost = null, codexCost = null, wallClock =
780
938
  return `${t.id}: ${impl}, ${passes} pass${passes === 1 ? "" : "es"}, verify ${yesNo(t.verified)}, review ${yesNo(t.reviewed)}, adversary ${adv}`;
781
939
  }).join("; ")
782
940
  : "no tasks";
783
- const stops = tasks.filter(({ task: t }) => t.stopReason).map(({ task: t }) => `${t.id}: "${t.stopReason}"`);
941
+ const stops = tasks.filter(({ task: t }) => t.stopReason).map(({ task: t }) => stopText(t));
784
942
  const { unmet, source, dirty } = acceptanceUnmet(report, tasks);
785
943
  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
944
  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,18 @@
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
+ // Haiku 5.5 is priced per request by prompt length (the same page, fetched 2026-10-10): a row may carry `over`, the
13
+ // prices for a request whose prompt (input + cacheRead + cacheWrite) is strictly over `over.above` tokens; a request
14
+ // at or under it pays the row's own prices. Sonnet 5.5 cacheRead is 0.1 (corrected 2026-10-10).
15
+ export const PRICES_DATED = "2026-10-10";
13
16
  export const PRICES = {
14
17
  "claude-fable-5-1": { input: 10, output: 50, cacheRead: 0.25, cacheWrite5m: 12.5, cacheWrite1h: 20 },
15
18
  "claude-fable-5": { input: 10, output: 50, cacheRead: 1, cacheWrite5m: 12.5, cacheWrite1h: 20 },
@@ -18,8 +21,13 @@ export const PRICES = {
18
21
  "claude-opus-4-8": { input: 5, output: 25, cacheRead: 0.5, cacheWrite5m: 6.25, cacheWrite1h: 10 },
19
22
  "claude-opus-4-7": { input: 5, output: 25, cacheRead: 0.5, cacheWrite5m: 6.25, cacheWrite1h: 10 },
20
23
  "claude-opus-4-6": { input: 5, output: 25, cacheRead: 0.5, cacheWrite5m: 6.25, cacheWrite1h: 10 },
24
+ "claude-sonnet-5-5": { input: 2, output: 10, cacheRead: 0.1, cacheWrite5m: 2.5, cacheWrite1h: 4 },
21
25
  "claude-sonnet-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite5m: 2.5, cacheWrite1h: 4 },
22
26
  "claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite5m: 3.75, cacheWrite1h: 6 },
27
+ "claude-haiku-5-5": {
28
+ input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite5m: 0.125, cacheWrite1h: 0.2,
29
+ over: { above: 100_000, input: 0.5, output: 2.5, cacheRead: 0.05, cacheWrite5m: 0.625, cacheWrite1h: 1 },
30
+ },
23
31
  "claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite5m: 1.25, cacheWrite1h: 2 },
24
32
  };
25
33
 
@@ -60,8 +68,9 @@ function addUsage(a, b) {
60
68
 
61
69
  // Price one usage record for a model; null when the model is not in the table.
62
70
  export function priceUsage(usage, model) {
63
- const p = PRICES[normalizeModel(model)];
71
+ let p = PRICES[normalizeModel(model)];
64
72
  if (!p) return null;
73
+ if (p.over && usage.input + usage.cacheRead + usage.cacheWrite > p.over.above) p = p.over;
65
74
  const write5m = Math.max(0, usage.cacheWrite - usage.cacheWrite1h);
66
75
  return (usage.input * p.input + usage.output * p.output + usage.cacheRead * p.cacheRead + write5m * p.cacheWrite5m + usage.cacheWrite1h * p.cacheWrite1h) / 1e6;
67
76
  }
@@ -99,7 +108,22 @@ export function transcriptUsage(file) {
99
108
  }
100
109
  // The agent's model is the one most of its messages ran on (a transcript names one model in practice).
101
110
  const model = [...models.entries()].sort((a, b) => b[1] - a[1]).map(([m]) => m)[0] || null;
102
- return { usage, model, messages: perMessage.size };
111
+ // The transcript's USD: each message priced on its own model and tier (a tiered model is priced per request), null
112
+ // when any message's model is not in the table. A message without a model uses the transcript's.
113
+ let usd = model ? 0 : null;
114
+ if (model) {
115
+ for (const v of perMessage.values()) {
116
+ // An all-zero message (Claude Code's "<synthetic>" ones) costs nothing whatever its model.
117
+ if (!v.input && !v.output && !v.cacheRead && !v.cacheWrite && !v.cacheWrite1h) continue;
118
+ const price = priceUsage(v, v.model || model);
119
+ if (price === null) {
120
+ usd = null;
121
+ break;
122
+ }
123
+ usd += price;
124
+ }
125
+ }
126
+ return { usage, model, messages: perMessage.size, usd };
103
127
  }
104
128
 
105
129
  // The workflow's label for an agent: `stage:task[:pass]` (`integrate:level-0`, `fix:agents-generator:2`). A swarm's
@@ -253,7 +277,7 @@ export function costRun({ runId, claudeDir, projectDir, report, reportPath } = {
253
277
  agent.model = t.model;
254
278
  agent.messages = t.messages;
255
279
  agent.usage = t.usage;
256
- agent.usd = t.model ? priceUsage(t.usage, t.model) : null;
280
+ agent.usd = t.usd;
257
281
  if (t.model && agent.usd === null) notes.push(`${j.agentId} (${agent.label || agent.stage}): model ${t.model} is not in the price table; not priced`);
258
282
  if (!t.model && t.messages === 0) notes.push(`${j.agentId} (${agent.label || agent.stage}): transcript has no usage lines; not priced`);
259
283
  } else {
@@ -318,6 +342,64 @@ export function costRun({ runId, claudeDir, projectDir, report, reportPath } = {
318
342
  };
319
343
  }
320
344
 
345
+ // Card flow-wall-budget: slow tasks flagged after the run, from the runtime's run file (workflowProgress[] agents
346
+ // carry startedAt and durationMs). Detection only; the budget is the report's wallMinutes. A task's wall and a
347
+ // pass's wall are spans (latest end minus earliest start), never sums, since agents of a pass run alongside.
348
+ export const PASS_WALL_SHARE = 0.75;
349
+ export const STAGE_WALL_SHARE = 0.25;
350
+
351
+ export function runWall(run, { wallMinutes } = {}) {
352
+ const MIN = 60000;
353
+ const result = (extra) => ({ measured: false, reason: null, wallMinutes, unverified: [], tasks: [], flags: [], ...extra });
354
+ const agents = run && Array.isArray(run.workflowProgress) ? run.workflowProgress.filter((p) => p && p.type === "workflow_agent") : [];
355
+ if (!agents.length) return result({ reason: "no run file" });
356
+ const timed = (a) => typeof a.startedAt === "number" && typeof a.durationMs === "number";
357
+ if (!agents.some(timed)) return result({ reason: "no agent times" });
358
+ const taskLimit = wallMinutes * MIN;
359
+ const passLimit = PASS_WALL_SHARE * taskLimit;
360
+ const stageLimit = STAGE_WALL_SHARE * taskLimit;
361
+ const byTask = new Map();
362
+ const stageFlags = [];
363
+ for (const a of agents) {
364
+ const label = String(a.label || "");
365
+ const parsed = parseLabel(label.replace(/:retry$/, ""));
366
+ if (timed(a) && a.durationMs > stageLimit) stageFlags.push({ kind: "stage", label, ms: a.durationMs, limitMs: stageLimit });
367
+ if (!parsed.task || /^level-\d+$/.test(parsed.task)) continue;
368
+ const t = byTask.get(parsed.task) || { task: parsed.task, agents: [], missing: false };
369
+ if (timed(a)) t.agents.push({ pass: parsed.pass ?? 1, from: a.startedAt, to: a.startedAt + a.durationMs });
370
+ else t.missing = true;
371
+ byTask.set(parsed.task, t);
372
+ }
373
+ const span = (list) => Math.max(...list.map((x) => x.to)) - Math.min(...list.map((x) => x.from));
374
+ const unverified = [];
375
+ const tasks = [];
376
+ const flags = [];
377
+ for (const t of byTask.values()) {
378
+ if (t.missing || !t.agents.length) {
379
+ unverified.push(t.task);
380
+ continue;
381
+ }
382
+ const passNums = [...new Set(t.agents.map((x) => x.pass))].sort((x, y) => x - y);
383
+ const passes = passNums.map((n) => ({ pass: n, ms: span(t.agents.filter((x) => x.pass === n)) }));
384
+ const ms = span(t.agents);
385
+ tasks.push({ task: t.task, ms, passes, stages: [] });
386
+ if (ms > taskLimit) flags.push({ kind: "task", label: t.task, ms, limitMs: taskLimit });
387
+ for (const p of passes) if (p.ms > passLimit) flags.push({ kind: "pass", label: t.task, pass: p.pass, ms: p.ms, limitMs: passLimit });
388
+ }
389
+ return result({ measured: true, unverified, tasks, flags: [...flags, ...stageFlags] });
390
+ }
391
+
392
+ // runWall for a run id: finds the run file next to the journal; never throws, an unreadable run is not measured.
393
+ export function wallForRun({ runId, claudeDir, projectDir, wallMinutes } = {}) {
394
+ try {
395
+ const found = findRunDir({ runId, claudeDir, projectDir });
396
+ if (!found) return runWall(null, { wallMinutes });
397
+ return runWall(readJson(found.runFile), { wallMinutes });
398
+ } catch {
399
+ return runWall(null, { wallMinutes });
400
+ }
401
+ }
402
+
321
403
  // A batch run's cost split per card (card parallel-cards): every agent whose task is one of a card's plan tasks
322
404
  // counts for that card; the shared stages (integrate, the level adversary: task `level-<n>`) are listed once as
323
405
  // `shared`; agents on no card's task are `other`. Nothing estimated: unpriced agents are counted, not summed.
@@ -359,6 +441,72 @@ export function renderCostByCard(byCard) {
359
441
  const fmtInt = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
360
442
  const fmtUsd = (n) => (n === null ? "unpriced" : `$${n.toFixed(2)}`);
361
443
 
444
+ // The top-level transcript path for a session id or "latest" (the most recently modified one in the project's slug
445
+ // directory). Throws when none is found. Used by costSession and scripts/context.mjs.
446
+ export function sessionTranscriptPath({ session, claudeDir = join(homedir(), ".claude"), projectDir = process.cwd() } = {}) {
447
+ if (!session) throw new Error("a session id or latest is required");
448
+ const slugDir = join(claudeDir, "projects", projectSlug(projectDir));
449
+ let id = session;
450
+ if (session === "latest") {
451
+ let best = null;
452
+ if (existsSync(slugDir)) {
453
+ for (const e of readdirSync(slugDir, { withFileTypes: true })) {
454
+ if (!e.isFile() || !e.name.endsWith(".jsonl")) continue;
455
+ const mtime = statSync(join(slugDir, e.name)).mtimeMs;
456
+ if (!best || mtime > best.mtime) best = { id: e.name.slice(0, -".jsonl".length), mtime };
457
+ }
458
+ }
459
+ if (!best) throw new Error(`no session transcript under ${slugDir}`);
460
+ id = best.id;
461
+ }
462
+ const top = join(slugDir, `${id}.jsonl`);
463
+ if (!existsSync(top)) throw new Error(`no transcript for session ${id} under ${slugDir}`);
464
+ return top;
465
+ }
466
+
467
+ // The cost of one Claude Code session (card autopilot): its top-level transcript plus every *.jsonl under
468
+ // <slug>/<session>/subagents/, recursively (plain agents and workflows/wf_*/ agents). `session` is an id or "latest",
469
+ // the most recently modified top-level transcript in the project's slug directory. Throws when none is found.
470
+ // Workflow journal.jsonl files carry no usage lines and add nothing.
471
+ export function costSession({ session, claudeDir = join(homedir(), ".claude"), projectDir = process.cwd() } = {}) {
472
+ const top = sessionTranscriptPath({ session, claudeDir, projectDir });
473
+ const slugDir = join(claudeDir, "projects", projectSlug(projectDir));
474
+ const id = basename(top, ".jsonl");
475
+ const files = [top];
476
+ const walk = (dir) => {
477
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
478
+ const p = join(dir, e.name);
479
+ if (e.isDirectory()) walk(p);
480
+ else if (e.isFile() && e.name.endsWith(".jsonl")) files.push(p);
481
+ }
482
+ };
483
+ const subDir = join(slugDir, id, "subagents");
484
+ if (existsSync(subDir)) walk(subDir);
485
+ const usage = emptyUsage();
486
+ let usd = 0;
487
+ let unpriced = 0;
488
+ const notes = [];
489
+ for (const f of files) {
490
+ const t = transcriptUsage(f);
491
+ addUsage(usage, t.usage);
492
+ if (!t.messages) continue;
493
+ const price = t.usd;
494
+ if (price === null) {
495
+ unpriced++;
496
+ notes.push(`${f}: model ${t.model || "unknown"} is not in the price table; not priced`);
497
+ } else usd += price;
498
+ }
499
+ return { session: id, files: files.length, total: { usage, usd, unpriced, agents: files.length }, pricesDated: PRICES_DATED, notes };
500
+ }
501
+
502
+ export function renderSession(result) {
503
+ const t = result.total;
504
+ const lines = [`Session ${result.session}: ${result.files} transcript${result.files === 1 ? "" : "s"}.`];
505
+ 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}.`);
506
+ for (const n of result.notes) lines.push(`Note: ${n}.`);
507
+ return lines.join("\n") + "\n";
508
+ }
509
+
362
510
  export function renderCost(result) {
363
511
  const lines = [];
364
512
  const when = result.startedAt ? `, ${result.startedAt}` : "";