@dougharness/cli 1.1.0 → 1.2.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 (59) hide show
  1. package/dist/bin.js +10 -3
  2. package/dist/bin.js.map +1 -1
  3. package/dist/board-serve.js +45 -10
  4. package/dist/board-serve.js.map +1 -1
  5. package/dist/board.d.ts +1 -1
  6. package/dist/board.js +33 -8
  7. package/dist/board.js.map +1 -1
  8. package/dist/init.js +1 -1
  9. package/dist/init.js.map +1 -1
  10. package/dist/memory-setup.d.ts +7 -0
  11. package/dist/memory-setup.js +75 -48
  12. package/dist/memory-setup.js.map +1 -1
  13. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +6 -2
  14. package/node_modules/@dougharness/flow/agents/coder.md +23 -0
  15. package/node_modules/@dougharness/flow/agents/lead.md +1 -1
  16. package/node_modules/@dougharness/flow/agents/planner.md +2 -2
  17. package/node_modules/@dougharness/flow/agents/spec-writer.md +23 -0
  18. package/node_modules/@dougharness/flow/agents/tester.md +21 -0
  19. package/node_modules/@dougharness/flow/hooks/hooks.json +4 -1
  20. package/node_modules/@dougharness/flow/hooks/register.tsx +75 -0
  21. package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +117 -0
  22. package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +17 -0
  23. package/node_modules/@dougharness/flow/lib/board.mjs +107 -10
  24. package/node_modules/@dougharness/flow/lib/card-land.mjs +115 -0
  25. package/node_modules/@dougharness/flow/lib/card-loop.mjs +746 -0
  26. package/node_modules/@dougharness/flow/lib/coordinator.mjs +368 -0
  27. package/node_modules/@dougharness/flow/lib/cost.mjs +44 -11
  28. package/node_modules/@dougharness/flow/lib/embeddings.mjs +147 -4
  29. package/node_modules/@dougharness/flow/lib/land.mjs +33 -9
  30. package/node_modules/@dougharness/flow/lib/memory.mjs +96 -33
  31. package/node_modules/@dougharness/flow/lib/plan.mjs +34 -5
  32. package/node_modules/@dougharness/flow/lib/worktrees.mjs +3 -3
  33. package/node_modules/@dougharness/flow/package.json +1 -1
  34. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +407 -0
  35. package/node_modules/@dougharness/flow/scripts/board.mjs +128 -32
  36. package/node_modules/@dougharness/flow/scripts/context.mjs +4 -2
  37. package/node_modules/@dougharness/flow/scripts/cost.mjs +16 -3
  38. package/node_modules/@dougharness/flow/scripts/memory.mjs +29 -14
  39. package/node_modules/@dougharness/flow/scripts/plan.mjs +122 -30
  40. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +10 -4
  41. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +34 -12
  42. package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +1 -1
  43. package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +80 -0
  44. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +2 -2
  45. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +12 -10
  46. package/node_modules/@dougharness/flow/workflows/card-queen.js +807 -0
  47. package/node_modules/@dougharness/flow/workflows/doug-implement.js +33 -21
  48. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  49. package/node_modules/@dougharness/gates/lib/anchor.mjs +29 -4
  50. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +217 -20
  51. package/node_modules/@dougharness/gates/lib/gate-lock.mjs +126 -0
  52. package/node_modules/@dougharness/gates/lib/proposals.mjs +41 -5
  53. package/node_modules/@dougharness/gates/lib/scope.mjs +43 -4
  54. package/node_modules/@dougharness/gates/package.json +1 -1
  55. package/node_modules/@dougharness/gates/scripts/reanchor.mjs +2 -1
  56. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +148 -97
  57. package/node_modules/@dougharness/gates/scripts/verify.mjs +64 -5
  58. package/package.json +4 -4
  59. package/templates/board-app.js +48 -1
@@ -0,0 +1,368 @@
1
+ // The parallel-card coordinator (card parallel-card-queens, ADR 0015): the pure decisions main makes while
2
+ // several card queens run in one session. Which cards may run together (D1 disjointness), how many slots are
3
+ // free, what a queen's finished run means, which done card lands next, which autopilot caps are reached.
4
+ // Pure and import-free: the decisions are testable with fakes, and a host that is not Claude Code can reuse them.
5
+
6
+ export const WORKERS_MAX_DEFAULT = 3;
7
+ export const WORKERS_MAX_LIMIT = 16;
8
+
9
+ // The configured workers.max when it is an integer from 1 to 16; the default silently when the key (or
10
+ // workers) is absent; the default plus one warn() call otherwise.
11
+ export function workersMax(config, warn = () => {}) {
12
+ const v = config && config.workers ? config.workers.max : undefined;
13
+ if (v === undefined) return WORKERS_MAX_DEFAULT;
14
+ if (Number.isInteger(v) && v >= 1 && v <= WORKERS_MAX_LIMIT) return v;
15
+ warn(`workers.max must be an integer from 1 to ${WORKERS_MAX_LIMIT}; using ${WORKERS_MAX_DEFAULT}`);
16
+ return WORKERS_MAX_DEFAULT;
17
+ }
18
+
19
+ // Paths every card records into; they never make two cards conflict.
20
+ const IGNORED_PATHS = [".doug/board.json", "docs/live-runs.md", "board.json", "live-runs.md"];
21
+ // A goal naming one of these runs alone: parallel cards would collide on the file (by basename), or on the
22
+ // config (by full path).
23
+ const SOLO_BASENAMES = ["package.json", "pnpm-lock.yaml", "package-lock.json", "yarn.lock", "CLAUDE.md"];
24
+ const SOLO_PATHS = [".doug/config.json"];
25
+
26
+ function norm(p) {
27
+ return String(p)
28
+ .replace(/\\/g, "/")
29
+ .replace(/\/{2,}/g, "/")
30
+ .replace(/^(\.\/)+/, "")
31
+ .replace(/\/+$/, "");
32
+ }
33
+
34
+ function baseName(p) {
35
+ const parts = norm(p).split("/");
36
+ return parts[parts.length - 1];
37
+ }
38
+
39
+ // Extensions seen in this repository; a token whose last segment has another one (Date.now, claude.ai, v1.2)
40
+ // is prose, not a path.
41
+ const FILE_EXTS = ["mjs", "js", "ts", "tsx", "json", "md", "yaml", "yml", "sh", "toml", "lock", "txt", "jsonl", "py", "db", "html", "css"];
42
+ const PATH_CHARS = /^[A-Za-z0-9_.\-/*?@]+$/;
43
+ // Prose pairs that look like a/b when no roots list is given.
44
+ const PROSE_PAIRS = ["and/or", "either/or", "yes/no", "input/output", "read/write", "true/false", "he/she"];
45
+
46
+ // A name that is only a dot and a word (.state, .git): a dot-directory, not a file with an extension.
47
+ function isDotName(p) {
48
+ return /^\.[^.]+$/.test(baseName(p));
49
+ }
50
+
51
+ function hasFileExt(t) {
52
+ if (isDotName(t)) return false;
53
+ const m = /\.([A-Za-z0-9]+)$/.exec(baseName(t));
54
+ return !!m && FILE_EXTS.includes(m[1].toLowerCase());
55
+ }
56
+
57
+ // a/{b,c} -> a/b, a/c (first group, recursively). An unclosed brace is left as it is.
58
+ function expandBraces(t) {
59
+ const open = t.indexOf("{");
60
+ const close = open < 0 ? -1 : t.indexOf("}", open);
61
+ if (open < 0 || close < 0) return [t];
62
+ const out = [];
63
+ for (const alt of t.slice(open + 1, close).split(",")) out.push(...expandBraces(t.slice(0, open) + alt + t.slice(close + 1)));
64
+ return out;
65
+ }
66
+
67
+ function cleanToken(raw) {
68
+ let t = raw;
69
+ for (let i = 0; i < 4; i++) {
70
+ const before = t;
71
+ t = t.replace(/^[([<"'`]+/, "").replace(/['\u2019]s$/, "").replace(/:\d+(-\d+)?$/, "");
72
+ t = t.replace(/[)\]>"'`,;:!?.]+$/, "");
73
+ if (t.endsWith("}") && !t.includes("{")) t = t.slice(0, -1);
74
+ if (t === before) break;
75
+ }
76
+ return t;
77
+ }
78
+
79
+ // The file and directory paths a goal names. Sentence punctuation, a trailing :line or :line-line and a
80
+ // possessive 's are dropped; a/{b,c} expands to a/b and a/c; a trailing slash (a directory) is kept. Slash
81
+ // commands, @scopes and prose (and/or, Date.now) are not paths. A slash token with no extension and no
82
+ // trailing slash counts only when its first segment is in opts.roots; with no roots given it is kept.
83
+ export function goalPaths(goal, opts) {
84
+ if (typeof goal !== "string") return [];
85
+ const roots = opts && Array.isArray(opts.roots) ? opts.roots : null;
86
+ const out = [];
87
+ for (const raw of goal.split(/\s+/)) {
88
+ const cleaned = cleanToken(raw);
89
+ if (!cleaned || cleaned.startsWith("/") || cleaned.startsWith("@") || cleaned.includes("://")) continue;
90
+ for (const t of expandBraces(cleaned)) {
91
+ if (!t || !PATH_CHARS.test(t) || !/[A-Za-z]/.test(t)) continue;
92
+ let ok;
93
+ if (!t.includes("/")) ok = hasFileExt(t);
94
+ else if (t.endsWith("/") || hasFileExt(t)) ok = true;
95
+ else if (roots) ok = roots.includes(t.split("/")[0]);
96
+ else ok = !PROSE_PAIRS.includes(t.toLowerCase()) && (isDotName(t) || !baseName(t).includes("."));
97
+ if (ok && !out.includes(t)) out.push(t);
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+
103
+ function handPaths(card, opts) {
104
+ return goalPaths(card.goal, opts).filter((p) => !IGNORED_PATHS.includes(norm(p)));
105
+ }
106
+
107
+ function soloFile(card, opts) {
108
+ for (const p of goalPaths(card.goal, opts)) {
109
+ const n = norm(p);
110
+ if (SOLO_PATHS.includes(n) || SOLO_BASENAMES.includes(baseName(n))) return n;
111
+ }
112
+ return null;
113
+ }
114
+
115
+ function isDirEntry(p) {
116
+ return /\/$/.test(String(p)) || isDotName(p) || !baseName(p).includes(".");
117
+ }
118
+
119
+ function covers(dir, file) {
120
+ const d = norm(dir);
121
+ const f = norm(file);
122
+ return d !== "" && (f === d || f.startsWith(d + "/"));
123
+ }
124
+
125
+ // Two goal paths overlap: the same path, one inside the other, or the same file name.
126
+ function pathsOverlap(p, q) {
127
+ if (covers(p, q) || covers(q, p)) return true;
128
+ return !isDirEntry(p) && !isDirEntry(q) && baseName(p) === baseName(q);
129
+ }
130
+
131
+ const GLOB_CHARS = /[*?{]/;
132
+
133
+ function globToRegExp(glob) {
134
+ let re = "";
135
+ const g = norm(glob);
136
+ let inBrace = false;
137
+ for (let i = 0; i < g.length; i++) {
138
+ const c = g[i];
139
+ if (c === "*") {
140
+ if (g[i + 1] === "*") {
141
+ i++;
142
+ if (g[i + 1] === "/") {
143
+ i++;
144
+ re += "(?:.*/)?";
145
+ } else re += ".*";
146
+ } else re += "[^/]*";
147
+ } else if (c === "?") re += "[^/]";
148
+ else if (c === "{" && g.indexOf("}", i) >= 0) {
149
+ inBrace = true;
150
+ re += "(?:";
151
+ } else if (c === "}" && inBrace) {
152
+ inBrace = false;
153
+ re += ")";
154
+ } else if (c === "," && inBrace) re += "|";
155
+ else re += c.replace(/[.+^$()|[\]\\]/g, "\\$&");
156
+ }
157
+ return new RegExp("^" + re + "$");
158
+ }
159
+
160
+ // The directory a glob is rooted in: its literal prefix up to the last slash before the first glob character.
161
+ function globRoot(glob) {
162
+ const g = norm(glob);
163
+ const at = g.search(GLOB_CHARS);
164
+ const lead = at < 0 ? g : g.slice(0, at);
165
+ const cut = lead.lastIndexOf("/");
166
+ return cut < 0 ? "" : lead.slice(0, cut);
167
+ }
168
+
169
+ // Do two plan ownership entries (exact file, directory or glob) overlap? Same semantics as scope.mjs's
170
+ // ownsPath, tried both ways; a glob against anything also counts when their directories nest.
171
+ function entriesOverlap(a, b) {
172
+ const x = norm(a);
173
+ const y = norm(b);
174
+ if (!x || !y) return false;
175
+ const gx = GLOB_CHARS.test(x);
176
+ const gy = GLOB_CHARS.test(y);
177
+ if (!gx && !gy) return covers(x, y) || covers(y, x);
178
+ if (gx && !gy && globToRegExp(x).test(y)) return true;
179
+ if (gy && !gx && globToRegExp(y).test(x)) return true;
180
+ const rx = gx ? globRoot(x) : x;
181
+ const ry = gy ? globRoot(y) : y;
182
+ if (rx === "" || ry === "") return true;
183
+ return covers(rx, ry) || covers(ry, rx);
184
+ }
185
+
186
+ function ownedFiles(card) {
187
+ const out = [];
188
+ const tasks = card.plan && Array.isArray(card.plan.tasks) ? card.plan.tasks : [];
189
+ for (const t of tasks) for (const f of (t && t.files) || []) if (typeof f === "string") out.push(f);
190
+ return out;
191
+ }
192
+
193
+ function taskIds(card) {
194
+ const tasks = card.plan && Array.isArray(card.plan.tasks) ? card.plan.tasks : [];
195
+ return tasks.map((t) => t && t.id).filter((id) => typeof id === "string");
196
+ }
197
+
198
+ function conflict(b, why) {
199
+ return { with: b.id, why };
200
+ }
201
+
202
+ function flowWithFlow(a, b) {
203
+ if (!a.plan || !b.plan) return conflict(b, `the plan of ${a.plan ? b.id : a.id} is not known yet`);
204
+ const ids = taskIds(b);
205
+ const shared = taskIds(a).find((id) => ids.includes(id));
206
+ if (shared) return conflict(b, `both plans have a task id "${shared}"`);
207
+ const mine = ownedFiles(a);
208
+ const theirs = ownedFiles(b);
209
+ for (const f of mine) for (const g of theirs) if (entriesOverlap(f, g)) return conflict(b, `both plans own ${norm(f) === norm(g) ? norm(f) : `${norm(f)} and ${norm(g)}`}`);
210
+ return null;
211
+ }
212
+
213
+ function flowWithHand(f, h, opts) {
214
+ if (!f.plan) return conflict(h, `the plan of ${f.id} is not known yet`);
215
+ if (soloFile(h, opts)) return conflict(h, `${h.id} names ${soloFile(h, opts)}, so it runs alone`);
216
+ for (const p of handPaths(h, opts)) {
217
+ for (const o of ownedFiles(f)) {
218
+ const bare = !norm(p).includes("/");
219
+ if (entriesOverlap(o, p) || (bare && !GLOB_CHARS.test(o) && baseName(o) === norm(p))) {
220
+ return conflict(h, `the plan owns ${norm(o)}, which the goal names (${norm(p)})`);
221
+ }
222
+ }
223
+ }
224
+ if (!f.component || !h.component) return conflict(h, "a card with no component conflicts with everything");
225
+ if (f.component === h.component) return conflict(h, `same component ${f.component}`);
226
+ return null;
227
+ }
228
+
229
+ function handWithHand(a, b, opts) {
230
+ const sa = soloFile(a, opts);
231
+ if (sa) return conflict(b, `${a.id} names ${sa}, so it runs alone`);
232
+ const sb = soloFile(b, opts);
233
+ if (sb) return conflict(b, `${b.id} names ${sb}, so it runs alone`);
234
+ const pa = handPaths(a, opts);
235
+ const pb = handPaths(b, opts);
236
+ for (const p of pa) for (const q of pb) if (pathsOverlap(p, q)) return conflict(b, `both goals name ${norm(p) === norm(q) ? norm(p) : `${norm(p)} and ${norm(q)}`}`);
237
+ if (!a.component || !b.component) return conflict(b, "a card with no component conflicts with everything");
238
+ if (a.component === b.component) return conflict(b, `same component ${a.component}`);
239
+ return null;
240
+ }
241
+
242
+ // null when the two cards may run together, else { with: b.id, why }. A flow card carries its plan as `.plan`.
243
+ export function cardConflict(a, b, opts) {
244
+ if (!a || !b || typeof a !== "object" || typeof b !== "object") return { with: b && b.id !== undefined ? b.id : null, why: "a card is missing" };
245
+ if (a.track === "flow" && b.track === "flow") return flowWithFlow(a, b);
246
+ if (a.track === "flow") return flowWithHand(a, b, opts);
247
+ if (b.track === "flow") {
248
+ const c = flowWithHand(b, a, opts);
249
+ return c && conflict(b, c.why);
250
+ }
251
+ return handWithHand(a, b, opts);
252
+ }
253
+
254
+ // "hand" or "flow" for a card a queen can run; "alone" for a swarm or a decision card, which runs by itself.
255
+ export function queenKind(card) {
256
+ if (!card) return "alone";
257
+ if (card.swarm || card.class === "decision") return "alone";
258
+ return card.track === "flow" ? "flow" : "hand";
259
+ }
260
+
261
+ // Which ready cards to start now. A flow card without a plan comes back as needsPlan (its owned files are
262
+ // unknown until a planner runs); with a plan it is checked like any card. A card is launched when it conflicts
263
+ // with nothing in flight and nothing launched before it; otherwise it is held against what it met first.
264
+ // needsPlan and launch together never exceed `slots`; a held card takes no slot.
265
+ export function pickCards(arg) {
266
+ const { ready = [], inFlight = [], slots = 0, plans = {}, roots } = arg && typeof arg === "object" ? arg : {};
267
+ const out = { launch: [], needsPlan: [], held: [], alone: [] };
268
+ const opts = { roots };
269
+ const planOf = (c) => (plans && typeof plans === "object" && Object.hasOwn(plans, c.id) ? plans[c.id] : null);
270
+ const withPlan = (c) => (c.track === "flow" && planOf(c) ? { ...c, plan: planOf(c) } : c);
271
+ const running = (inFlight || []).filter((c) => c && typeof c === "object").map(withPlan);
272
+ const launched = [];
273
+ let blocked = false;
274
+ for (const raw of ready || []) {
275
+ if (!raw || typeof raw !== "object") continue;
276
+ if (queenKind(raw) === "alone") {
277
+ // An alone card does not starve: the first one runnable in board order stops later launches. It is returned
278
+ // only when nothing is in flight, launched in this pick, or waiting for a plan.
279
+ if (running.length === 0 && out.launch.length === 0 && out.needsPlan.length === 0) out.alone.push(raw.id);
280
+ blocked = true;
281
+ continue;
282
+ }
283
+ if (blocked) continue;
284
+ if (out.launch.length + out.needsPlan.length >= slots) continue;
285
+ if (raw.track === "flow" && !planOf(raw)) {
286
+ out.needsPlan.push(raw.id);
287
+ continue;
288
+ }
289
+ const card = withPlan(raw);
290
+ let met = null;
291
+ for (const other of [...running, ...launched]) {
292
+ met = cardConflict(card, other, opts);
293
+ if (met) break;
294
+ }
295
+ if (met) out.held.push({ id: raw.id, with: met.with, why: met.why });
296
+ else {
297
+ out.launch.push(raw.id);
298
+ launched.push(card);
299
+ }
300
+ }
301
+ return out;
302
+ }
303
+
304
+ function parseResult(result) {
305
+ if (typeof result === "string") {
306
+ try {
307
+ return JSON.parse(result);
308
+ } catch {
309
+ return null;
310
+ }
311
+ }
312
+ return result && typeof result === "object" ? result : null;
313
+ }
314
+
315
+ // What a queen's finished run means for main: land it, resume it (the run died, or its result is unreadable, or
316
+ // a seat's agent returned null), or ask the user (the other queens keep running meanwhile).
317
+ export function classifyNotification(kind, arg) {
318
+ const { status, result } = arg || {};
319
+ if (status !== "completed") return { action: "resume", reason: `run status ${status}` };
320
+ const r = parseResult(result);
321
+ if (!r) return { action: "resume", reason: "result unreadable" };
322
+ if (kind === "flow") {
323
+ if (r.ok === true) return { action: "land" };
324
+ if (r.paused) {
325
+ const p = r.paused;
326
+ return { action: "ask", reason: `paused at a ${p.gate || "human"} gate (level ${p.level})` };
327
+ }
328
+ if (r.ok === false) return { action: "ask", reason: "the run stopped" + (r.stoppedAtLevel !== undefined ? ` at level ${r.stoppedAtLevel}` : "") };
329
+ return { action: "ask", reason: "unrecognised result" };
330
+ }
331
+ // The queen's real result (card-queen.js) carries outcome; the wrapper shape carries kind. Both are read.
332
+ const verdict = r.kind ?? r.outcome;
333
+ if (verdict === "done") return { action: "land" };
334
+ if (verdict === "stopped") {
335
+ if (r.reason === "agent-null") return { action: "resume", reason: "agent-null" };
336
+ return { action: "ask", reason: r.reason };
337
+ }
338
+ return { action: "ask", reason: "unrecognised result" };
339
+ }
340
+
341
+ // The done cards waiting to land, first in first out by doneAt. One lands at a time: while a card is landing
342
+ // nothing else is next.
343
+ export function landQueue(state) {
344
+ const cards = state && Array.isArray(state.cards) ? state.cards : [];
345
+ const done = cards
346
+ .filter((c) => c && typeof c === "object")
347
+ .map((c, i) => ({ c, i }))
348
+ .filter(({ c }) => c.state === "done")
349
+ .sort((x, y) => {
350
+ const a = x.c.doneAt || "￿";
351
+ const b = y.c.doneAt || "￿";
352
+ return a < b ? -1 : a > b ? 1 : x.i - y.i;
353
+ })
354
+ .map(({ c }) => c.id);
355
+ const busy = cards.some((c) => c && c.state === "landing");
356
+ return { next: busy || done.length === 0 ? null : done[0], queue: done };
357
+ }
358
+
359
+ // The names of the caps whose used value is at or over the cap. An unset cap is ignored.
360
+ export function capsReached(caps, used) {
361
+ const out = [];
362
+ for (const name of ["usd", "wallMin", "contextPct"]) {
363
+ const cap = caps ? caps[name] : undefined;
364
+ const v = used ? used[name] : undefined;
365
+ if (typeof cap === "number" && Number.isFinite(cap) && typeof v === "number" && v >= cap) out.push(name);
366
+ }
367
+ return out;
368
+ }
@@ -9,7 +9,10 @@ import { basename, join } from "node:path";
9
9
  // 0.05x on Opus 5.5); cache writes are 1.25x input for the 5-minute TTL and 2x for the 1-hour TTL. Opus 5.5:
10
10
  // input 4, output 20, cacheRead 0.2, cacheWrite5m 5, cacheWrite1h 8; source
11
11
  // https://platform.claude.com/docs/en/about-claude/pricing, fetched 2026-10-04. Update the date with the table.
12
- export const PRICES_DATED = "2026-10-04";
12
+ // Haiku 5.5 is priced per request by prompt length (the same page, fetched 2026-10-10): a row may carry `over`, the
13
+ // prices for a request whose prompt (input + cacheRead + cacheWrite) is strictly over `over.above` tokens; a request
14
+ // at or under it pays the row's own prices. Sonnet 5.5 cacheRead is 0.1 (corrected 2026-10-10).
15
+ export const PRICES_DATED = "2026-10-10";
13
16
  export const PRICES = {
14
17
  "claude-fable-5-1": { input: 10, output: 50, cacheRead: 0.25, cacheWrite5m: 12.5, cacheWrite1h: 20 },
15
18
  "claude-fable-5": { input: 10, output: 50, cacheRead: 1, cacheWrite5m: 12.5, cacheWrite1h: 20 },
@@ -18,9 +21,13 @@ export const PRICES = {
18
21
  "claude-opus-4-8": { input: 5, output: 25, cacheRead: 0.5, cacheWrite5m: 6.25, cacheWrite1h: 10 },
19
22
  "claude-opus-4-7": { input: 5, output: 25, cacheRead: 0.5, cacheWrite5m: 6.25, cacheWrite1h: 10 },
20
23
  "claude-opus-4-6": { input: 5, output: 25, cacheRead: 0.5, cacheWrite5m: 6.25, cacheWrite1h: 10 },
21
- "claude-sonnet-5-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite5m: 2.5, cacheWrite1h: 4 },
24
+ "claude-sonnet-5-5": { input: 2, output: 10, cacheRead: 0.1, cacheWrite5m: 2.5, cacheWrite1h: 4 },
22
25
  "claude-sonnet-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite5m: 2.5, cacheWrite1h: 4 },
23
26
  "claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite5m: 3.75, cacheWrite1h: 6 },
27
+ "claude-haiku-5-5": {
28
+ input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite5m: 0.125, cacheWrite1h: 0.2,
29
+ over: { above: 100_000, input: 0.5, output: 2.5, cacheRead: 0.05, cacheWrite5m: 0.625, cacheWrite1h: 1 },
30
+ },
24
31
  "claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite5m: 1.25, cacheWrite1h: 2 },
25
32
  };
26
33
 
@@ -61,15 +68,16 @@ function addUsage(a, b) {
61
68
 
62
69
  // Price one usage record for a model; null when the model is not in the table.
63
70
  export function priceUsage(usage, model) {
64
- const p = PRICES[normalizeModel(model)];
71
+ let p = PRICES[normalizeModel(model)];
65
72
  if (!p) return null;
73
+ if (p.over && usage.input + usage.cacheRead + usage.cacheWrite > p.over.above) p = p.over;
66
74
  const write5m = Math.max(0, usage.cacheWrite - usage.cacheWrite1h);
67
75
  return (usage.input * p.input + usage.output * p.output + usage.cacheRead * p.cacheRead + write5m * p.cacheWrite5m + usage.cacheWrite1h * p.cacheWrite1h) / 1e6;
68
76
  }
69
77
 
70
78
  // The transcript writes one line per content block of an API response, each carrying that response's usage (and
71
79
  // the early blocks a partial output count), so usage is taken once per message id, the largest value seen per field.
72
- export function transcriptUsage(file) {
80
+ export function transcriptUsage(file, { since = null, until = null } = {}) {
73
81
  const perMessage = new Map();
74
82
  const models = new Map();
75
83
  for (const line of readFileSync(file, "utf8").split("\n")) {
@@ -82,6 +90,13 @@ export function transcriptUsage(file) {
82
90
  }
83
91
  const m = o && o.message;
84
92
  if (o.type !== "assistant" || !m || !m.usage) continue;
93
+ if (since !== null || until !== null) {
94
+ // A window (card usd-budget-size-class) keeps only the lines whose timestamp falls in it, inclusive at both
95
+ // ends. A line with no readable timestamp cannot be placed and is kept: counting it is the conservative
96
+ // choice for a budget.
97
+ const at = Date.parse(o.timestamp);
98
+ if (!Number.isNaN(at) && ((since !== null && at < since) || (until !== null && at > until))) continue;
99
+ }
85
100
  const id = m.id || o.requestId || o.uuid;
86
101
  const u = m.usage;
87
102
  const cur = perMessage.get(id) || { ...emptyUsage(), model: m.model || null };
@@ -100,7 +115,22 @@ export function transcriptUsage(file) {
100
115
  }
101
116
  // The agent's model is the one most of its messages ran on (a transcript names one model in practice).
102
117
  const model = [...models.entries()].sort((a, b) => b[1] - a[1]).map(([m]) => m)[0] || null;
103
- return { usage, model, messages: perMessage.size };
118
+ // The transcript's USD: each message priced on its own model and tier (a tiered model is priced per request), null
119
+ // when any message's model is not in the table. A message without a model uses the transcript's.
120
+ let usd = model ? 0 : null;
121
+ if (model) {
122
+ for (const v of perMessage.values()) {
123
+ // An all-zero message (Claude Code's "<synthetic>" ones) costs nothing whatever its model.
124
+ if (!v.input && !v.output && !v.cacheRead && !v.cacheWrite && !v.cacheWrite1h) continue;
125
+ const price = priceUsage(v, v.model || model);
126
+ if (price === null) {
127
+ usd = null;
128
+ break;
129
+ }
130
+ usd += price;
131
+ }
132
+ }
133
+ return { usage, model, messages: perMessage.size, usd };
104
134
  }
105
135
 
106
136
  // The workflow's label for an agent: `stage:task[:pass]` (`integrate:level-0`, `fix:agents-generator:2`). A swarm's
@@ -254,7 +284,7 @@ export function costRun({ runId, claudeDir, projectDir, report, reportPath } = {
254
284
  agent.model = t.model;
255
285
  agent.messages = t.messages;
256
286
  agent.usage = t.usage;
257
- agent.usd = t.model ? priceUsage(t.usage, t.model) : null;
287
+ agent.usd = t.usd;
258
288
  if (t.model && agent.usd === null) notes.push(`${j.agentId} (${agent.label || agent.stage}): model ${t.model} is not in the price table; not priced`);
259
289
  if (!t.model && t.messages === 0) notes.push(`${j.agentId} (${agent.label || agent.stage}): transcript has no usage lines; not priced`);
260
290
  } else {
@@ -445,7 +475,7 @@ export function sessionTranscriptPath({ session, claudeDir = join(homedir(), ".c
445
475
  // <slug>/<session>/subagents/, recursively (plain agents and workflows/wf_*/ agents). `session` is an id or "latest",
446
476
  // the most recently modified top-level transcript in the project's slug directory. Throws when none is found.
447
477
  // Workflow journal.jsonl files carry no usage lines and add nothing.
448
- export function costSession({ session, claudeDir = join(homedir(), ".claude"), projectDir = process.cwd() } = {}) {
478
+ export function costSession({ session, claudeDir = join(homedir(), ".claude"), projectDir = process.cwd(), since = null, until = null } = {}) {
449
479
  const top = sessionTranscriptPath({ session, claudeDir, projectDir });
450
480
  const slugDir = join(claudeDir, "projects", projectSlug(projectDir));
451
481
  const id = basename(top, ".jsonl");
@@ -462,23 +492,26 @@ export function costSession({ session, claudeDir = join(homedir(), ".claude"), p
462
492
  const usage = emptyUsage();
463
493
  let usd = 0;
464
494
  let unpriced = 0;
495
+ let counted = 0;
496
+ const windowed = since !== null || until !== null;
465
497
  const notes = [];
466
498
  for (const f of files) {
467
- const t = transcriptUsage(f);
499
+ const t = transcriptUsage(f, { since, until });
468
500
  addUsage(usage, t.usage);
469
501
  if (!t.messages) continue;
470
- const price = t.model ? priceUsage(t.usage, t.model) : null;
502
+ counted++;
503
+ const price = t.usd;
471
504
  if (price === null) {
472
505
  unpriced++;
473
506
  notes.push(`${f}: model ${t.model || "unknown"} is not in the price table; not priced`);
474
507
  } else usd += price;
475
508
  }
476
- return { session: id, files: files.length, total: { usage, usd, unpriced, agents: files.length }, pricesDated: PRICES_DATED, notes };
509
+ return { session: id, files: windowed ? counted : files.length, windowed, total: { usage, usd, unpriced, agents: windowed ? counted : files.length }, pricesDated: PRICES_DATED, notes };
477
510
  }
478
511
 
479
512
  export function renderSession(result) {
480
513
  const t = result.total;
481
- const lines = [`Session ${result.session}: ${result.files} transcript${result.files === 1 ? "" : "s"}.`];
514
+ const lines = [`Session ${result.session}: ${result.files} transcript${result.files === 1 ? "" : "s"}${result.windowed ? " in window" : ""}.`];
482
515
  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
516
  for (const n of result.notes) lines.push(`Note: ${n}.`);
484
517
  return lines.join("\n") + "\n";