@dougharness/cli 1.1.1 → 1.3.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 (57) hide show
  1. package/dist/board-serve.js +40 -6
  2. package/dist/board-serve.js.map +1 -1
  3. package/dist/init.js +1 -1
  4. package/dist/init.js.map +1 -1
  5. package/dist/memory-setup.d.ts +7 -0
  6. package/dist/memory-setup.js +70 -46
  7. package/dist/memory-setup.js.map +1 -1
  8. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +6 -2
  9. package/node_modules/@dougharness/flow/agents/coder.md +23 -0
  10. package/node_modules/@dougharness/flow/agents/implementer.md +1 -1
  11. package/node_modules/@dougharness/flow/agents/planner.md +1 -1
  12. package/node_modules/@dougharness/flow/agents/researcher.md +2 -1
  13. package/node_modules/@dougharness/flow/agents/reviewer.md +3 -1
  14. package/node_modules/@dougharness/flow/agents/spec-writer.md +23 -0
  15. package/node_modules/@dougharness/flow/agents/tester.md +21 -0
  16. package/node_modules/@dougharness/flow/hooks/hooks.json +4 -1
  17. package/node_modules/@dougharness/flow/hooks/register.tsx +94 -0
  18. package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +117 -0
  19. package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +17 -0
  20. package/node_modules/@dougharness/flow/lib/board.mjs +91 -10
  21. package/node_modules/@dougharness/flow/lib/card-land.mjs +115 -0
  22. package/node_modules/@dougharness/flow/lib/card-loop.mjs +778 -0
  23. package/node_modules/@dougharness/flow/lib/coordinator.mjs +393 -0
  24. package/node_modules/@dougharness/flow/lib/cost.mjs +15 -5
  25. package/node_modules/@dougharness/flow/lib/decisions.mjs +4 -2
  26. package/node_modules/@dougharness/flow/lib/embeddings.mjs +144 -3
  27. package/node_modules/@dougharness/flow/lib/land.mjs +27 -8
  28. package/node_modules/@dougharness/flow/lib/memory.mjs +406 -76
  29. package/node_modules/@dougharness/flow/lib/plan.mjs +33 -5
  30. package/node_modules/@dougharness/flow/lib/worktrees.mjs +3 -3
  31. package/node_modules/@dougharness/flow/package.json +1 -1
  32. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +440 -0
  33. package/node_modules/@dougharness/flow/scripts/board.mjs +107 -32
  34. package/node_modules/@dougharness/flow/scripts/cost.mjs +16 -3
  35. package/node_modules/@dougharness/flow/scripts/memory.mjs +33 -10
  36. package/node_modules/@dougharness/flow/scripts/plan.mjs +104 -30
  37. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +8 -2
  38. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +30 -10
  39. package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +80 -0
  40. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +1 -1
  41. package/node_modules/@dougharness/flow/skills/research/SKILL.md +1 -1
  42. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +6 -6
  43. package/node_modules/@dougharness/flow/workflows/card-queen.js +846 -0
  44. package/node_modules/@dougharness/flow/workflows/doug-implement.js +63 -23
  45. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  46. package/node_modules/@dougharness/gates/lib/anchor.mjs +29 -4
  47. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +196 -6
  48. package/node_modules/@dougharness/gates/lib/gate-lock.mjs +40 -8
  49. package/node_modules/@dougharness/gates/lib/proposals.mjs +41 -5
  50. package/node_modules/@dougharness/gates/lib/scope.mjs +43 -4
  51. package/node_modules/@dougharness/gates/package.json +1 -1
  52. package/node_modules/@dougharness/gates/scripts/reanchor.mjs +2 -1
  53. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +119 -15
  54. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +156 -14
  55. package/node_modules/@dougharness/gates/scripts/verify.mjs +8 -4
  56. package/package.json +3 -3
  57. package/templates/board-app.js +46 -0
@@ -0,0 +1,393 @@
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
+ }
369
+
370
+ // The queen's short run label (card queen-run-name): the first two hyphen segments of the card id (the id itself
371
+ // with fewer), or the full id when another id in `others` (the card's own id excluded) shortens to the same form.
372
+ const shortForm = (id) => String(id).split("-").slice(0, 2).join("-");
373
+ export function queenLabel(id, others = []) {
374
+ const short = shortForm(id);
375
+ return others.some((o) => o !== id && shortForm(o) === short) ? String(id) : short;
376
+ }
377
+
378
+ // The queen script's source with only its meta literal renamed: name becomes "<label> card-queen" (hand) or
379
+ // "<label> flow-queen" (flow), description gets "<label>: " in front. Throws when either is missing from the meta.
380
+ export function renameQueenMeta(source, { kind, label }) {
381
+ const start = source.indexOf("export const meta = {");
382
+ if (start < 0) throw new Error("no meta literal in the queen script");
383
+ const e = source.indexOf("\n}", start);
384
+ const end = e < 0 ? source.length : e;
385
+ let meta = source.slice(start, end);
386
+ const nameRe = /^(\s*name: ')[^']*(',)/m;
387
+ const descRe = /^(\s*description: ')/m;
388
+ if (!nameRe.test(meta)) throw new Error("no meta name in the queen script");
389
+ if (!descRe.test(meta)) throw new Error("no meta description in the queen script");
390
+ const suffix = kind === "flow" ? "flow-queen" : "card-queen";
391
+ meta = meta.replace(nameRe, (_m, a, b) => `${a}${label} ${suffix}${b}`).replace(descRe, (_m, a) => `${a}${label}: `);
392
+ return source.slice(0, start) + meta + source.slice(end);
393
+ }
@@ -77,7 +77,7 @@ export function priceUsage(usage, model) {
77
77
 
78
78
  // The transcript writes one line per content block of an API response, each carrying that response's usage (and
79
79
  // the early blocks a partial output count), so usage is taken once per message id, the largest value seen per field.
80
- export function transcriptUsage(file) {
80
+ export function transcriptUsage(file, { since = null, until = null } = {}) {
81
81
  const perMessage = new Map();
82
82
  const models = new Map();
83
83
  for (const line of readFileSync(file, "utf8").split("\n")) {
@@ -90,6 +90,13 @@ export function transcriptUsage(file) {
90
90
  }
91
91
  const m = o && o.message;
92
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
+ }
93
100
  const id = m.id || o.requestId || o.uuid;
94
101
  const u = m.usage;
95
102
  const cur = perMessage.get(id) || { ...emptyUsage(), model: m.model || null };
@@ -468,7 +475,7 @@ export function sessionTranscriptPath({ session, claudeDir = join(homedir(), ".c
468
475
  // <slug>/<session>/subagents/, recursively (plain agents and workflows/wf_*/ agents). `session` is an id or "latest",
469
476
  // the most recently modified top-level transcript in the project's slug directory. Throws when none is found.
470
477
  // Workflow journal.jsonl files carry no usage lines and add nothing.
471
- 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 } = {}) {
472
479
  const top = sessionTranscriptPath({ session, claudeDir, projectDir });
473
480
  const slugDir = join(claudeDir, "projects", projectSlug(projectDir));
474
481
  const id = basename(top, ".jsonl");
@@ -485,23 +492,26 @@ export function costSession({ session, claudeDir = join(homedir(), ".claude"), p
485
492
  const usage = emptyUsage();
486
493
  let usd = 0;
487
494
  let unpriced = 0;
495
+ let counted = 0;
496
+ const windowed = since !== null || until !== null;
488
497
  const notes = [];
489
498
  for (const f of files) {
490
- const t = transcriptUsage(f);
499
+ const t = transcriptUsage(f, { since, until });
491
500
  addUsage(usage, t.usage);
492
501
  if (!t.messages) continue;
502
+ counted++;
493
503
  const price = t.usd;
494
504
  if (price === null) {
495
505
  unpriced++;
496
506
  notes.push(`${f}: model ${t.model || "unknown"} is not in the price table; not priced`);
497
507
  } else usd += price;
498
508
  }
499
- 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 };
500
510
  }
501
511
 
502
512
  export function renderSession(result) {
503
513
  const t = result.total;
504
- 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" : ""}.`];
505
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}.`);
506
516
  for (const n of result.notes) lines.push(`Note: ${n}.`);
507
517
  return lines.join("\n") + "\n";
@@ -250,8 +250,9 @@ function commandsBlockText(text) {
250
250
  // writes it - only ever calls writeOne/writeProposals (learn.mjs), which writes under .doug/.state/learn/. Every
251
251
  // refusal returns { ok:false, reason } with nothing written; `edits` can be ANY value JSON.parse can produce
252
252
  // (null, an array, a string, a number, or an object whose fields are the wrong shape) and this never throws -
253
- // every field is type-checked before use (lesson 1ee7d426432aca82).
254
- export function proposeClaudeMd({ dir, edits, now = new Date() } = {}) {
253
+ // every field is type-checked before use (lesson 1ee7d426432aca82). With `dryRun: true` every check still runs
254
+ // and the result is { ok:true, reason, diff } with nothing written (card reflect-offers-claude-md).
255
+ export function proposeClaudeMd({ dir, edits, now = new Date(), dryRun = false } = {}) {
255
256
  const target = join(dir, CLAUDE_MD_RELPATH);
256
257
  if (!existsSync(target)) return { ok: false, reason: `${CLAUDE_MD_RELPATH} does not exist under ${dir}` };
257
258
 
@@ -310,5 +311,6 @@ export function proposeClaudeMd({ dir, edits, now = new Date() } = {}) {
310
311
  lines: { before, after },
311
312
  };
312
313
  const proposal = { id: "01", kind: "claude-md", target: CLAUDE_MD_RELPATH, reason, rationale, diff: diffResult.diff };
314
+ if (dryRun) return { ok: true, reason, diff: diffResult.diff };
313
315
  return writeOne(proposal, { dir, now });
314
316
  }