@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.
- package/dist/bin.js +10 -3
- package/dist/bin.js.map +1 -1
- package/dist/board-serve.js +45 -10
- package/dist/board-serve.js.map +1 -1
- package/dist/board.d.ts +1 -1
- package/dist/board.js +33 -8
- package/dist/board.js.map +1 -1
- package/dist/init.js +1 -1
- package/dist/init.js.map +1 -1
- package/dist/memory-setup.d.ts +7 -0
- package/dist/memory-setup.js +75 -48
- package/dist/memory-setup.js.map +1 -1
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +6 -2
- package/node_modules/@dougharness/flow/agents/coder.md +23 -0
- package/node_modules/@dougharness/flow/agents/lead.md +1 -1
- package/node_modules/@dougharness/flow/agents/planner.md +2 -2
- package/node_modules/@dougharness/flow/agents/spec-writer.md +23 -0
- package/node_modules/@dougharness/flow/agents/tester.md +21 -0
- package/node_modules/@dougharness/flow/hooks/hooks.json +4 -1
- package/node_modules/@dougharness/flow/hooks/register.tsx +75 -0
- package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +117 -0
- package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +17 -0
- package/node_modules/@dougharness/flow/lib/board.mjs +107 -10
- package/node_modules/@dougharness/flow/lib/card-land.mjs +115 -0
- package/node_modules/@dougharness/flow/lib/card-loop.mjs +746 -0
- package/node_modules/@dougharness/flow/lib/coordinator.mjs +368 -0
- package/node_modules/@dougharness/flow/lib/cost.mjs +44 -11
- package/node_modules/@dougharness/flow/lib/embeddings.mjs +147 -4
- package/node_modules/@dougharness/flow/lib/land.mjs +33 -9
- package/node_modules/@dougharness/flow/lib/memory.mjs +96 -33
- package/node_modules/@dougharness/flow/lib/plan.mjs +34 -5
- package/node_modules/@dougharness/flow/lib/worktrees.mjs +3 -3
- package/node_modules/@dougharness/flow/package.json +1 -1
- package/node_modules/@dougharness/flow/scripts/autopilot.mjs +407 -0
- package/node_modules/@dougharness/flow/scripts/board.mjs +128 -32
- package/node_modules/@dougharness/flow/scripts/context.mjs +4 -2
- package/node_modules/@dougharness/flow/scripts/cost.mjs +16 -3
- package/node_modules/@dougharness/flow/scripts/memory.mjs +29 -14
- package/node_modules/@dougharness/flow/scripts/plan.mjs +122 -30
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +10 -4
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +34 -12
- package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +80 -0
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +2 -2
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +12 -10
- package/node_modules/@dougharness/flow/workflows/card-queen.js +807 -0
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +33 -21
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/lib/anchor.mjs +29 -4
- package/node_modules/@dougharness/gates/lib/bash-rules.mjs +217 -20
- package/node_modules/@dougharness/gates/lib/gate-lock.mjs +126 -0
- package/node_modules/@dougharness/gates/lib/proposals.mjs +41 -5
- package/node_modules/@dougharness/gates/lib/scope.mjs +43 -4
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/reanchor.mjs +2 -1
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +148 -97
- package/node_modules/@dougharness/gates/scripts/verify.mjs +64 -5
- package/package.json +4 -4
- 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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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";
|