@mmerterden/multi-agent-pipeline 17.6.0 → 18.0.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 (103) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/README.md +43 -1
  3. package/README.tr.md +41 -0
  4. package/docs/adr/0011-dormant-ci.md +25 -1
  5. package/docs/server-readiness.md +188 -0
  6. package/index.js +16 -1
  7. package/install/_common.mjs +42 -17
  8. package/install/_dev-only-files.mjs +8 -0
  9. package/install/_unattended-profile.mjs +113 -0
  10. package/install/index.mjs +48 -0
  11. package/manifest.json +1049 -0
  12. package/package.json +5 -2
  13. package/pipeline/commands/multi-agent/status/SKILL.md +52 -21
  14. package/pipeline/lib/_jira-auth.sh +8 -0
  15. package/pipeline/lib/analysis-jira-write.sh +32 -0
  16. package/pipeline/lib/ask-choice.sh +13 -2
  17. package/pipeline/lib/autopilot-state.sh +8 -0
  18. package/pipeline/lib/fatal.mjs +129 -0
  19. package/pipeline/lib/figma-mcp-refresh.sh +18 -0
  20. package/pipeline/lib/figma-screenshot.sh +18 -0
  21. package/pipeline/lib/invoked-directly.mjs +43 -0
  22. package/pipeline/lib/jira-publish.sh +42 -0
  23. package/pipeline/lib/md2confluence-v3.py +47 -0
  24. package/pipeline/lib/outbound-gate.mjs +175 -0
  25. package/pipeline/lib/plan-todos.sh +27 -6
  26. package/pipeline/lib/post-pr-review.sh +77 -8
  27. package/pipeline/lib/repo-hygiene.sh +8 -3
  28. package/pipeline/lib/require-jq.sh +40 -0
  29. package/pipeline/lib/run-paths.sh +335 -0
  30. package/pipeline/multi-agent-refs/features/autopilot-circuit-breaker.md +70 -0
  31. package/pipeline/multi-agent-refs/features/cost-analysis.md +93 -0
  32. package/pipeline/multi-agent-refs/features/doctor.md +45 -0
  33. package/pipeline/multi-agent-refs/features/verify.md +83 -0
  34. package/pipeline/multi-agent-refs/phases/operations.md +13 -2
  35. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
  36. package/pipeline/multi-agent-refs/unattended-contract.md +129 -0
  37. package/pipeline/scripts/_run-paths.mjs +372 -0
  38. package/pipeline/scripts/aggregate-metrics.mjs +64 -64
  39. package/pipeline/scripts/autopilot-arming.mjs +2 -1
  40. package/pipeline/scripts/autopilot-intake.mjs +2 -1
  41. package/pipeline/scripts/autopilot-runner.mjs +206 -2
  42. package/pipeline/scripts/build-references.mjs +2 -1
  43. package/pipeline/scripts/build-stack-plugins.mjs +10 -2
  44. package/pipeline/scripts/capture-evidence.sh +7 -2
  45. package/pipeline/scripts/classify-plan-safety.mjs +2 -1
  46. package/pipeline/scripts/cost-analyze.mjs +600 -0
  47. package/pipeline/scripts/cost-budget-check.mjs +4 -12
  48. package/pipeline/scripts/council-view.mjs +2 -1
  49. package/pipeline/scripts/crush-json.mjs +2 -1
  50. package/pipeline/scripts/diff-explain.mjs +6 -9
  51. package/pipeline/scripts/diff-risk-score.mjs +2 -1
  52. package/pipeline/scripts/doctor.mjs +138 -4
  53. package/pipeline/scripts/evidence-gate.mjs +9 -3
  54. package/pipeline/scripts/feedback-send.mjs +12 -2
  55. package/pipeline/scripts/gc-abandoned.sh +29 -13
  56. package/pipeline/scripts/gc-worktrees.sh +11 -4
  57. package/pipeline/scripts/github-ssh-setup.sh +64 -7
  58. package/pipeline/scripts/graph-mermaid.mjs +4 -2
  59. package/pipeline/scripts/keychain-save.sh +101 -30
  60. package/pipeline/scripts/learn-from-transcripts.mjs +2 -1
  61. package/pipeline/scripts/learning-curve.mjs +34 -29
  62. package/pipeline/scripts/make-manifest.mjs +199 -0
  63. package/pipeline/scripts/migrate-prefs.mjs +2 -1
  64. package/pipeline/scripts/migrate-state.mjs +94 -4
  65. package/pipeline/scripts/phase-banner.sh +6 -2
  66. package/pipeline/scripts/phase-tracker.sh +41 -3
  67. package/pipeline/scripts/plan-coverage-gate.mjs +6 -2
  68. package/pipeline/scripts/pre-commit-check.sh +7 -0
  69. package/pipeline/scripts/pre-push-check.sh +7 -0
  70. package/pipeline/scripts/purge.sh +23 -6
  71. package/pipeline/scripts/render-agent-log-cost.sh +9 -2
  72. package/pipeline/scripts/render-cost-summary.sh +9 -2
  73. package/pipeline/scripts/render-work-summary.sh +11 -4
  74. package/pipeline/scripts/review-file-filter.mjs +4 -2
  75. package/pipeline/scripts/review-scope.mjs +2 -1
  76. package/pipeline/scripts/routine-registry.mjs +2 -1
  77. package/pipeline/scripts/run-aggregator.mjs +13 -14
  78. package/pipeline/scripts/run-metrics.mjs +3 -1
  79. package/pipeline/scripts/runs-index.mjs +343 -0
  80. package/pipeline/scripts/scorecard-snapshot.mjs +178 -0
  81. package/pipeline/scripts/search-logs.sh +18 -0
  82. package/pipeline/scripts/test-gap-scan.mjs +2 -1
  83. package/pipeline/scripts/test-integrity-gate.mjs +2 -1
  84. package/pipeline/scripts/update-issue-progress.sh +56 -7
  85. package/pipeline/scripts/usage-report.mjs +12 -1
  86. package/pipeline/scripts/validate-analysis-doc.mjs +2 -1
  87. package/pipeline/scripts/validate-code-graph.mjs +6 -3
  88. package/pipeline/scripts/validate-complaint-doc.mjs +2 -1
  89. package/pipeline/scripts/validate-diff-risk.mjs +6 -3
  90. package/pipeline/scripts/validate-test-gap.mjs +6 -3
  91. package/pipeline/scripts/validate-triage.mjs +3 -1
  92. package/pipeline/scripts/verify-citations.mjs +4 -2
  93. package/pipeline/scripts/verify.mjs +327 -0
  94. package/pipeline/scripts/worktree-finalize.sh +13 -4
  95. package/pipeline/scripts/write-state.mjs +154 -15
  96. package/pipeline/skills/.skill-manifest.json +2 -2
  97. package/pipeline/skills/.skills-index.json +56 -1
  98. package/pipeline/skills/shared/README.md +8 -3
  99. package/pipeline/skills/shared/core/multi-agent-status/SKILL.md +33 -9
  100. package/pipeline/skills/shared/external/macos-spm-app-packaging/assets/templates/package_app.sh +4 -1
  101. package/pipeline/skills/shared/external/macos-spm-app-packaging/assets/templates/setup_dev_signing.sh +4 -1
  102. package/pipeline/skills/shared/external/macos-spm-app-packaging/assets/templates/sign-and-notarize.sh +2 -1
  103. package/pipeline/skills/skills-index.md +6 -1
@@ -0,0 +1,600 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cost-analyze.mjs - what the spend is doing, not just what it was.
4
+ *
5
+ * cost-budget-check.mjs answers one question, live, for one run: is THIS task
6
+ * about to cross its ceiling. It cannot see a second run, yesterday, or a
7
+ * trend, so the two failures that actually empty a budget go unreported by it:
8
+ * a slow drift that never trips a per-task ceiling, and one pathological
9
+ * session that burns a week's worth in an hour while every individual run
10
+ * stays under its cap.
11
+ *
12
+ * Four questions, one series:
13
+ * projection - at this rate, what does a week / a month / a quarter cost,
14
+ * and when does a stated budget run out
15
+ * anomaly - which day or session is out of family, by median absolute
16
+ * deviation rather than by mean and standard deviation (one
17
+ * huge day inflates both and then hides itself)
18
+ * burn - is the last 24h accelerating against the preceding week
19
+ * diff - two saved snapshots, metric by metric
20
+ *
21
+ * WHERE THE NUMBERS COME FROM, and what that makes them worth: the per-run
22
+ * token accumulators in tracker-state.json are the pipeline's own ledger, but
23
+ * they are written only when a phase reports them, and on this machine no
24
+ * tracker carries them at all. The dense series that DOES exist is the host's
25
+ * own transcripts (~/.claude/projects/<slug>/<session>.jsonl), one usage record
26
+ * per assistant turn. So this reads those. Consequences, stated rather than
27
+ * buried: the figures cover everything Claude Code did on this machine, not
28
+ * only pipeline runs; they are ESTIMATES priced from cost-table.json, not an
29
+ * invoice; and a host with no such transcripts (Copilot, Codex) reports
30
+ * UNMEASURED rather than zero, because zero here would read as "you spent
31
+ * nothing" when it means "I could not see".
32
+ *
33
+ * Usage:
34
+ * cost-analyze.mjs projection [--days N] [--monthly-usd N]
35
+ * cost-analyze.mjs anomaly [--days N] [--by day|session] [--threshold N]
36
+ * cost-analyze.mjs burn [--factor N]
37
+ * cost-analyze.mjs diff [a.json b.json]
38
+ * cost-analyze.mjs --save store a snapshot
39
+ * cost-analyze.mjs --list what is stored
40
+ * any subcommand plus --json
41
+ *
42
+ * Exit codes:
43
+ * 0 nothing to report, or UNMEASURED (no series to read)
44
+ * 10 a finding: an anomaly, an accelerating burn, or a budget that the
45
+ * current rate exhausts inside the projection window
46
+ * 2 usage error
47
+ */
48
+
49
+ import {
50
+ createReadStream,
51
+ existsSync,
52
+ mkdirSync,
53
+ readdirSync,
54
+ readFileSync,
55
+ statSync,
56
+ writeFileSync,
57
+ } from "node:fs";
58
+ import { createInterface } from "node:readline";
59
+ import { homedir } from "node:os";
60
+ import { dirname, join } from "node:path";
61
+ import { fileURLToPath } from "node:url";
62
+ import { costUsd } from "./_cost.mjs";
63
+ import { runMain } from "../lib/fatal.mjs";
64
+
65
+ const HERE = dirname(fileURLToPath(import.meta.url));
66
+ const STORE = process.env.COST_SNAPSHOT_STORE || join(homedir(), ".claude", "state", "cost");
67
+ const TRANSCRIPT_ROOT = process.env.COST_TRANSCRIPT_ROOT || join(homedir(), ".claude", "projects");
68
+
69
+ function die(msg) {
70
+ process.stderr.write(`cost-analyze: ${msg}\n`);
71
+ process.exit(2);
72
+ }
73
+
74
+ // --- argv --------------------------------------------------------------------
75
+
76
+ // Flags that never take a value. Without this set, `--json projection` made the
77
+ // sub-command the value of --json and left the parser with no sub-command at
78
+ // all - so the natural order of the two words was a usage error.
79
+ const BOOLEAN_FLAGS = new Set(["json", "save", "list"]);
80
+
81
+ const argv = process.argv.slice(2);
82
+ const positional = [];
83
+ const flags = {};
84
+ for (let i = 0; i < argv.length; i++) {
85
+ const a = argv[i];
86
+ if (!a.startsWith("--")) {
87
+ positional.push(a);
88
+ continue;
89
+ }
90
+ const [k, inline] = a.slice(2).split("=");
91
+ const next = argv[i + 1];
92
+ if (inline !== undefined) flags[k] = inline;
93
+ else if (BOOLEAN_FLAGS.has(k)) flags[k] = true;
94
+ else if (next !== undefined && !next.startsWith("--")) {
95
+ flags[k] = next;
96
+ i++;
97
+ } else flags[k] = true;
98
+ }
99
+
100
+ const asNumber = (v, fallback) => {
101
+ if (v === undefined || v === true) return fallback;
102
+ const n = Number(v);
103
+ return Number.isFinite(n) ? n : fallback;
104
+ };
105
+
106
+ // --- pricing -----------------------------------------------------------------
107
+
108
+ const TABLE = JSON.parse(readFileSync(join(HERE, "cost-table.json"), "utf-8"));
109
+
110
+ /**
111
+ * modelId -> rung. The transcripts carry the wire value ("claude-opus-5"); the
112
+ * price table is keyed by rung. Matching on modelId rather than on the rung
113
+ * name is what keeps a generation move a one-line data edit, which is the
114
+ * contract cost-table.json states for itself.
115
+ */
116
+ const RUNG_BY_MODEL_ID = new Map(
117
+ Object.entries(TABLE.prices || {}).map(([rung, p]) => [p.modelId, rung]),
118
+ );
119
+
120
+ function rateFor(modelId) {
121
+ if (!modelId) return null;
122
+ const rung = RUNG_BY_MODEL_ID.get(modelId);
123
+ if (rung) return TABLE.prices[rung];
124
+ // A dated snapshot id (an alias plus a release date suffix) prices as its
125
+ // alias - written as a shape rather than as a literal, because
126
+ // smoke-model-fallback.sh reads every model id in this tree and requires it
127
+ // to exist in cost-table.json, and an EXAMPLE id is exactly the kind of thing
128
+ // that would make that gate wrong about what ships. An
129
+ // id nobody knows prices as nothing and is COUNTED as unpriced rather than
130
+ // folded in at zero.
131
+ for (const [id, rung] of RUNG_BY_MODEL_ID) {
132
+ if (modelId.startsWith(id)) return TABLE.prices[rung];
133
+ }
134
+ return null;
135
+ }
136
+
137
+ /**
138
+ * Cache WRITES are not a discount, they are a surcharge: Anthropic prices a
139
+ * 1h cache write above fresh input. cost-table.json carries cacheReadPerMtok
140
+ * only, so the write rate is derived here at the published 1.25x of input and
141
+ * labelled as derived - an unpriced component silently dropped would make
142
+ * every long-context session look cheap, which is the direction that hurts.
143
+ */
144
+ const CACHE_WRITE_MULTIPLIER = 1.25;
145
+
146
+ function priceRecord(rate, u) {
147
+ const fresh = Number(u.input_tokens || 0);
148
+ const out = Number(u.output_tokens || 0);
149
+ const cacheRead = Number(u.cache_read_input_tokens || 0);
150
+ const cacheWrite = Number(u.cache_creation_input_tokens || 0);
151
+ const base = costUsd(rate, fresh, out, cacheRead);
152
+ if (base === null) return null;
153
+ return base + (cacheWrite / 1_000_000) * (rate.inPerMtok || 0) * CACHE_WRITE_MULTIPLIER;
154
+ }
155
+
156
+ // --- the series --------------------------------------------------------------
157
+
158
+ function transcriptFiles(sinceMs) {
159
+ if (!existsSync(TRANSCRIPT_ROOT)) return [];
160
+ const out = [];
161
+ for (const slug of readdirSync(TRANSCRIPT_ROOT)) {
162
+ const dir = join(TRANSCRIPT_ROOT, slug);
163
+ let entries;
164
+ try {
165
+ if (!statSync(dir).isDirectory()) continue;
166
+ entries = readdirSync(dir);
167
+ } catch {
168
+ continue;
169
+ }
170
+ for (const f of entries) {
171
+ if (!f.endsWith(".jsonl")) continue;
172
+ const p = join(dir, f);
173
+ try {
174
+ // mtime is a cheap pre-filter, not the truth: a file touched today can
175
+ // hold last month's turns, and every record is date-checked below. It
176
+ // only skips files that CANNOT contain anything in the window.
177
+ if (sinceMs && statSync(p).mtimeMs < sinceMs) continue;
178
+ } catch {
179
+ continue;
180
+ }
181
+ out.push({ path: p, project: slug, session: f.replace(/\.jsonl$/, "") });
182
+ }
183
+ }
184
+ return out;
185
+ }
186
+
187
+ async function readSeries({ days }) {
188
+ const sinceMs = days ? Date.now() - days * 86_400_000 : 0;
189
+ const files = transcriptFiles(sinceMs);
190
+ const byDay = new Map();
191
+ const bySession = new Map();
192
+ const seen = new Set();
193
+ let records = 0;
194
+ let unpriced = 0;
195
+ const unpricedModels = new Set();
196
+
197
+ for (const f of files) {
198
+ let rl;
199
+ try {
200
+ rl = createInterface({ input: createReadStream(f.path), crlfDelay: Infinity });
201
+ } catch {
202
+ continue;
203
+ }
204
+ for await (const line of rl) {
205
+ if (!line || line.charCodeAt(0) !== 123) continue;
206
+ let d;
207
+ try {
208
+ d = JSON.parse(line);
209
+ } catch {
210
+ continue;
211
+ }
212
+ const msg = d.message;
213
+ const u = msg?.usage;
214
+ if (!u) continue;
215
+ const ts = Date.parse(d.timestamp || "");
216
+ if (!Number.isFinite(ts)) continue;
217
+ if (sinceMs && ts < sinceMs) continue;
218
+ // A retried or resumed turn can appear twice across transcripts; the
219
+ // message id is the only stable identity, and double counting is the
220
+ // failure mode a cost report can least afford.
221
+ const id = msg?.id;
222
+ if (id) {
223
+ if (seen.has(id)) continue;
224
+ seen.add(id);
225
+ }
226
+ const rate = rateFor(msg?.model);
227
+ const usd = rate ? priceRecord(rate, u) : null;
228
+ if (usd === null) {
229
+ unpriced++;
230
+ if (msg?.model) unpricedModels.add(msg.model);
231
+ continue;
232
+ }
233
+ records++;
234
+ const day = new Date(ts).toISOString().slice(0, 10);
235
+ byDay.set(day, (byDay.get(day) || 0) + usd);
236
+ const key = `${f.project}/${f.session}`;
237
+ const s = bySession.get(key) || { usd: 0, first: ts, last: ts, project: f.project };
238
+ s.usd += usd;
239
+ s.first = Math.min(s.first, ts);
240
+ s.last = Math.max(s.last, ts);
241
+ bySession.set(key, s);
242
+ }
243
+ }
244
+
245
+ return {
246
+ files: files.length,
247
+ records,
248
+ unpriced,
249
+ unpricedModels: [...unpricedModels].sort(),
250
+ byDay: [...byDay.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1)),
251
+ bySession: [...bySession.entries()].sort((a, b) => b[1].usd - a[1].usd),
252
+ };
253
+ }
254
+
255
+ const usd = (n) => `$${n.toFixed(2)}`;
256
+
257
+ function unmeasured(reason, json) {
258
+ const payload = { kind: "UNMEASURED", reason };
259
+ if (json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
260
+ else process.stdout.write(`UNMEASURED: ${reason}\n`);
261
+ process.exitCode = 0;
262
+ }
263
+
264
+ // --- statistics --------------------------------------------------------------
265
+
266
+ function median(xs) {
267
+ if (!xs.length) return 0;
268
+ const s = [...xs].sort((a, b) => a - b);
269
+ const m = s.length >> 1;
270
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
271
+ }
272
+
273
+ /**
274
+ * Modified z-score (Iglewicz and Hoaglin): 0.6745 * (x - median) / MAD. Mean
275
+ * and standard deviation cannot be used here, because the single expensive
276
+ * session this is meant to find is also the observation that inflates both -
277
+ * it hides itself. The median and the MAD do not move for one outlier.
278
+ *
279
+ * MAD is zero whenever more than half the days are identical, which happens
280
+ * with very few days of data; the published fallback is the mean absolute
281
+ * deviation scaled by 1.253314. When that is zero too there is no dispersion
282
+ * at all, and the honest answer is that no anomaly CAN be called.
283
+ */
284
+ function modifiedZ(xs) {
285
+ const med = median(xs);
286
+ const mad = median(xs.map((x) => Math.abs(x - med)));
287
+ if (mad > 0) return { med, scale: mad / 0.6745, basis: "MAD" };
288
+ const meanAbs = xs.reduce((a, x) => a + Math.abs(x - med), 0) / (xs.length || 1);
289
+ if (meanAbs > 0) return { med, scale: meanAbs * 1.253314, basis: "mean absolute deviation" };
290
+ return { med, scale: 0, basis: "none" };
291
+ }
292
+
293
+ // --- subcommands -------------------------------------------------------------
294
+
295
+ async function cmdProjection(json) {
296
+ const days = asNumber(flags.days, 14);
297
+ const monthly = asNumber(flags["monthly-usd"], null);
298
+ const s = await readSeries({ days });
299
+ if (!s.byDay.length) return unmeasured(`no priced usage records in the last ${days} days`, json);
300
+
301
+ const total = s.byDay.reduce((a, [, v]) => a + v, 0);
302
+ // Divided by the CALENDAR window, not by the number of days that happen to
303
+ // have data: dividing by active days answers "what does a working day cost",
304
+ // which then projects a 30-day month as thirty working days and overstates
305
+ // the month by roughly a third.
306
+ const perDay = total / days;
307
+ const observedDays = s.byDay.length;
308
+ const proj = { 7: perDay * 7, 30: perDay * 30, 90: perDay * 90 };
309
+ let exhaustDays = null;
310
+ if (monthly !== null && perDay > 0) exhaustDays = monthly / perDay;
311
+
312
+ const finding = exhaustDays !== null && exhaustDays < 30;
313
+ if (json) {
314
+ process.stdout.write(
315
+ `${JSON.stringify(
316
+ {
317
+ kind: "projection",
318
+ windowDays: days,
319
+ observedDays,
320
+ totalUsd: Number(total.toFixed(4)),
321
+ perDayUsd: Number(perDay.toFixed(4)),
322
+ projectedUsd: {
323
+ d7: Number(proj[7].toFixed(2)),
324
+ d30: Number(proj[30].toFixed(2)),
325
+ d90: Number(proj[90].toFixed(2)),
326
+ },
327
+ monthlyBudgetUsd: monthly,
328
+ budgetExhaustedInDays: exhaustDays === null ? null : Number(exhaustDays.toFixed(1)),
329
+ unpricedRecords: s.unpriced,
330
+ unpricedModels: s.unpricedModels,
331
+ finding,
332
+ },
333
+ null,
334
+ 2,
335
+ )}\n`,
336
+ );
337
+ } else {
338
+ process.stdout.write(
339
+ `son ${days} gun: ${usd(total)} (${observedDays} gunde kayit var) - gunluk ${usd(perDay)}\n` +
340
+ ` 7 gun ${usd(proj[7])} - 30 gun ${usd(proj[30])} - 90 gun ${usd(proj[90])}\n`,
341
+ );
342
+ if (monthly === null)
343
+ process.stdout.write(
344
+ " butce tukenme tarihi yok: --monthly-usd verilmedi, bu makinede aylik tavan tanimli degil\n",
345
+ );
346
+ else if (exhaustDays === null) process.stdout.write(" harcama yok, tukenme hesaplanamaz\n");
347
+ else
348
+ process.stdout.write(
349
+ ` ${usd(monthly)} aylik tavan bu hizda ${exhaustDays.toFixed(1)} gunde biter\n`,
350
+ );
351
+ if (s.unpriced)
352
+ process.stdout.write(
353
+ ` ${s.unpriced} kayit fiyatlanamadi (${s.unpricedModels.join(", ") || "model alani yok"}) - toplamin disinda\n`,
354
+ );
355
+ // Said every time, not in a footnote somebody reads once: on a subscription
356
+ // this figure is what the same tokens would have COST at list price, not
357
+ // what was paid. It is the right number for comparing days to each other
358
+ // and the wrong number to put in an invoice.
359
+ process.stdout.write(" (liste fiyatindan tahmin - abonelikte odenen tutar degil)\n");
360
+ }
361
+ if (finding) process.exitCode = 10;
362
+ }
363
+
364
+ async function cmdAnomaly(json) {
365
+ const days = asNumber(flags.days, 30);
366
+ const threshold = asNumber(flags.threshold, 3.5);
367
+ const by = flags.by === "session" ? "session" : "day";
368
+ const s = await readSeries({ days });
369
+ const rows =
370
+ by === "day"
371
+ ? s.byDay.map(([k, v]) => ({ key: k, usd: v }))
372
+ : s.bySession.map(([k, v]) => ({ key: k, usd: v.usd }));
373
+ if (rows.length < 5)
374
+ return unmeasured(
375
+ `${rows.length} ${by} of data in the last ${days} days; at least 5 are needed before an outlier means anything`,
376
+ json,
377
+ );
378
+
379
+ const { med, scale, basis } = modifiedZ(rows.map((r) => r.usd));
380
+ if (scale === 0)
381
+ return unmeasured(`every ${by} costs the same (${usd(med)}); no dispersion, no outlier`, json);
382
+
383
+ const scored = rows.map((r) => ({ ...r, z: (r.usd - med) / scale })).sort((a, b) => b.z - a.z);
384
+ const flagged = scored.filter((r) => r.z >= threshold);
385
+
386
+ if (json) {
387
+ process.stdout.write(
388
+ `${JSON.stringify(
389
+ {
390
+ kind: "anomaly",
391
+ by,
392
+ windowDays: days,
393
+ threshold,
394
+ basis,
395
+ medianUsd: Number(med.toFixed(4)),
396
+ flagged: flagged.map((r) => ({
397
+ key: r.key,
398
+ usd: Number(r.usd.toFixed(2)),
399
+ z: Number(r.z.toFixed(2)),
400
+ })),
401
+ top: scored
402
+ .slice(0, 3)
403
+ .map((r) => ({ key: r.key, usd: Number(r.usd.toFixed(2)), z: Number(r.z.toFixed(2)) })),
404
+ },
405
+ null,
406
+ 2,
407
+ )}\n`,
408
+ );
409
+ } else {
410
+ process.stdout.write(
411
+ `${rows.length} ${by} - medyan ${usd(med)} - olcek ${basis} - esik z>=${threshold}\n`,
412
+ );
413
+ if (!flagged.length) process.stdout.write(" aile disinda kayit yok\n");
414
+ // Ten lines, then a count. Session spend is heavy-tailed - the median
415
+ // session is a few cents of question-answering and a real run is three
416
+ // orders of magnitude above it - so `--by session` legitimately flags
417
+ // dozens, and forty lines of flags is a list nobody finishes reading.
418
+ for (const r of flagged.slice(0, 10))
419
+ process.stdout.write(` ! ${r.key} ${usd(r.usd)} z=${r.z.toFixed(1)}\n`);
420
+ if (flagged.length > 10)
421
+ process.stdout.write(` ... ve ${flagged.length - 10} ${by} daha esigin uzerinde\n`);
422
+ }
423
+ if (flagged.length) process.exitCode = 10;
424
+ }
425
+
426
+ async function cmdBurn(json) {
427
+ const factor = asNumber(flags.factor, 3);
428
+ const s = await readSeries({ days: 8 });
429
+ // TODAY by the calendar, not "the last day that has data". Anchoring on the
430
+ // latest record meant a machine that spent nothing for five days kept
431
+ // reporting last Friday's spike as the current burn, with exit 10, every day,
432
+ // forever - an alarm that cannot turn itself off is one people turn off.
433
+ const todayKey = new Date().toISOString().slice(0, 10);
434
+ const todayRow = s.byDay.find(([d]) => d === todayKey);
435
+ if (!todayRow) return unmeasured(`no priced usage today (${todayKey}); nothing is burning`, json);
436
+ const priorRows = s.byDay.filter(([d]) => d !== todayKey);
437
+ if (priorRows.length < 2)
438
+ return unmeasured(
439
+ `only ${priorRows.length} prior day(s) of data; acceleration needs something to accelerate against`,
440
+ json,
441
+ );
442
+ // Divided by the prior days that EXIST rather than by a hard-coded 7: with
443
+ // three days of history, dividing by seven halves the baseline and doubles
444
+ // every ratio, which is the direction that invents findings.
445
+ const priorTotal = priorRows.reduce((a, [, v]) => a + v, 0);
446
+ const priorPerDay = priorTotal / priorRows.length;
447
+ const today = todayRow;
448
+ const ratio = priorPerDay > 0 ? today[1] / priorPerDay : null;
449
+ const finding = ratio !== null && ratio >= factor;
450
+
451
+ if (json) {
452
+ process.stdout.write(
453
+ `${JSON.stringify(
454
+ {
455
+ kind: "burn",
456
+ day: today[0],
457
+ dayUsd: Number(today[1].toFixed(2)),
458
+ priorPerDayUsd: Number(priorPerDay.toFixed(2)),
459
+ priorDays: priorRows.length,
460
+ ratio: ratio === null ? null : Number(ratio.toFixed(2)),
461
+ factor,
462
+ finding,
463
+ },
464
+ null,
465
+ 2,
466
+ )}\n`,
467
+ );
468
+ } else {
469
+ process.stdout.write(
470
+ `${today[0]}: ${usd(today[1])} - onceki ${priorRows.length} gun gunluk ortalama ${usd(priorPerDay)}\n`,
471
+ );
472
+ if (ratio === null) process.stdout.write(" onceki pencerede harcama yok, oran hesaplanamaz\n");
473
+ else
474
+ process.stdout.write(
475
+ ` ${ratio.toFixed(1)}x ${finding ? `- esik ${factor}x asildi` : "- normal aralik"}\n`,
476
+ );
477
+ }
478
+ if (finding) process.exitCode = 10;
479
+ }
480
+
481
+ // --- snapshots ---------------------------------------------------------------
482
+
483
+ function snapshots() {
484
+ if (!existsSync(STORE)) return [];
485
+ return readdirSync(STORE)
486
+ .filter((f) => f.endsWith(".json"))
487
+ .sort()
488
+ .map((f) => join(STORE, f));
489
+ }
490
+
491
+ async function cmdSave(json) {
492
+ const days = asNumber(flags.days, 30);
493
+ const s = await readSeries({ days });
494
+ const total = s.byDay.reduce((a, [, v]) => a + v, 0);
495
+ const snap = {
496
+ savedAt: new Date().toISOString(),
497
+ windowDays: days,
498
+ totalUsd: Number(total.toFixed(4)),
499
+ perDayUsd: Number((total / days).toFixed(4)),
500
+ observedDays: s.byDay.length,
501
+ records: s.records,
502
+ unpriced: s.unpriced,
503
+ byDay: Object.fromEntries(s.byDay.map(([k, v]) => [k, Number(v.toFixed(4))])),
504
+ };
505
+ mkdirSync(STORE, { recursive: true });
506
+ const path = join(STORE, `${snap.savedAt.replace(/[:.]/g, "-")}.json`);
507
+ writeFileSync(path, `${JSON.stringify(snap, null, 2)}\n`);
508
+ if (json) process.stdout.write(`${JSON.stringify({ saved: path, ...snap }, null, 2)}\n`);
509
+ else
510
+ process.stdout.write(
511
+ `kaydedildi: ${path}\n son ${days} gun ${usd(snap.totalUsd)} - gunluk ${usd(snap.perDayUsd)}\n`,
512
+ );
513
+ }
514
+
515
+ function cmdList(json) {
516
+ const files = snapshots();
517
+ if (json)
518
+ process.stdout.write(`${JSON.stringify({ store: STORE, snapshots: files }, null, 2)}\n`);
519
+ else {
520
+ process.stdout.write(`${STORE}\n`);
521
+ if (!files.length) process.stdout.write(" (bos)\n");
522
+ for (const f of files) {
523
+ const d = JSON.parse(readFileSync(f, "utf-8"));
524
+ process.stdout.write(` ${d.savedAt} ${usd(d.totalUsd)} / ${d.windowDays} gun\n`);
525
+ }
526
+ }
527
+ }
528
+
529
+ function cmdDiff(json) {
530
+ // positional[0] is the sub-command. Dropping it matters: with `diff a.json`
531
+ // the old form took the last two entries, which were "diff" and "a.json",
532
+ // and died with `snapshot not found: diff`.
533
+ const paths = positional.slice(1);
534
+ let a;
535
+ let b;
536
+ if (paths.length === 2) [a, b] = paths;
537
+ else if (paths.length === 0) {
538
+ const files = snapshots();
539
+ if (files.length < 2) die("need two snapshots; run --save at least twice");
540
+ [a, b] = files.slice(-2);
541
+ } else {
542
+ die(`diff takes two snapshot paths or none; got ${paths.length}`);
543
+ }
544
+ for (const p of [a, b]) if (!existsSync(p)) die(`snapshot not found: ${p}`);
545
+ const A = JSON.parse(readFileSync(a, "utf-8"));
546
+ const B = JSON.parse(readFileSync(b, "utf-8"));
547
+ const delta = B.perDayUsd - A.perDayUsd;
548
+ const pct = A.perDayUsd > 0 ? (delta / A.perDayUsd) * 100 : null;
549
+ if (json) {
550
+ process.stdout.write(
551
+ `${JSON.stringify(
552
+ {
553
+ kind: "diff",
554
+ from: { path: a, savedAt: A.savedAt, perDayUsd: A.perDayUsd, windowDays: A.windowDays },
555
+ to: { path: b, savedAt: B.savedAt, perDayUsd: B.perDayUsd, windowDays: B.windowDays },
556
+ deltaPerDayUsd: Number(delta.toFixed(4)),
557
+ deltaPct: pct === null ? null : Number(pct.toFixed(1)),
558
+ comparable: A.windowDays === B.windowDays,
559
+ },
560
+ null,
561
+ 2,
562
+ )}\n`,
563
+ );
564
+ } else {
565
+ process.stdout.write(`${A.savedAt} -> ${B.savedAt}\n`);
566
+ process.stdout.write(
567
+ ` gunluk ${usd(A.perDayUsd)} -> ${usd(B.perDayUsd)} (${delta >= 0 ? "+" : ""}${delta.toFixed(2)}${
568
+ pct === null ? "" : `, ${pct >= 0 ? "+" : ""}${pct.toFixed(0)}%`
569
+ })\n`,
570
+ );
571
+ if (A.windowDays !== B.windowDays)
572
+ process.stdout.write(
573
+ ` ! pencereler farkli (${A.windowDays} / ${B.windowDays} gun) - gunluk oran karsilastirilabilir, toplam degil\n`,
574
+ );
575
+ }
576
+ }
577
+
578
+ // --- main --------------------------------------------------------------------
579
+
580
+ const json = flags.json === true || flags.json === "true";
581
+ const sub = positional[0];
582
+
583
+ const run = async () => {
584
+ if (flags.save) return cmdSave(json);
585
+ if (flags.list) return cmdList(json);
586
+ switch (sub) {
587
+ case "projection":
588
+ return cmdProjection(json);
589
+ case "anomaly":
590
+ return cmdAnomaly(json);
591
+ case "burn":
592
+ return cmdBurn(json);
593
+ case "diff":
594
+ return cmdDiff(json);
595
+ default:
596
+ return die("usage: cost-analyze.mjs projection|anomaly|burn|diff [--json] | --save | --list");
597
+ }
598
+ };
599
+
600
+ runMain("cost-analyze", run, { code: 2 });
@@ -13,7 +13,8 @@
13
13
  * coding tool; this turns "will this burn my tokens?" into a visible number.
14
14
  *
15
15
  * Inputs (CLI overrides prefs):
16
- * --task-id <id> Locate $HOME/.claude/logs/multi-agent/<id>/tracker-state.json
16
+ * --task-id <id> Locate the run's tracker-state.json via _run-paths.mjs
17
+ * (both layouts, every id spelling)
17
18
  * (also tried: <id> without a leading #, task-<id>)
18
19
  * --tracker <path> Explicit tracker JSON (skips lookup)
19
20
  * --prefs <path> Preferences JSON; reads .global.costBudget
@@ -35,9 +36,9 @@
35
36
 
36
37
  import { readFileSync, existsSync } from "fs";
37
38
  import { dirname, join } from "path";
38
- import { homedir } from "os";
39
39
  import { fileURLToPath } from "url";
40
40
  import { costUsd } from "./_cost.mjs";
41
+ import { resolveRunFile } from "./_run-paths.mjs";
41
42
 
42
43
  const __dirname = dirname(fileURLToPath(import.meta.url));
43
44
 
@@ -105,16 +106,7 @@ function resolveTracker() {
105
106
  }
106
107
  const id = flags["task-id"];
107
108
  if (!id) die("either --tracker or --task-id is required");
108
- const bare = String(id).replace(/^#/, "");
109
- const logRoot = join(homedir(), ".claude", "logs", "multi-agent");
110
- for (const c of [
111
- join(logRoot, String(id), "tracker-state.json"),
112
- join(logRoot, bare, "tracker-state.json"),
113
- join(logRoot, `task-${bare}`, "tracker-state.json"),
114
- ]) {
115
- if (existsSync(c)) return c;
116
- }
117
- return null;
109
+ return resolveRunFile(id, "tracker-state.json", flags.project);
118
110
  }
119
111
 
120
112
  const trackerPath = resolveTracker();
@@ -21,6 +21,7 @@
21
21
  // Exit codes: 0 rendered, 2 nothing to render (no review iterations), 64 usage.
22
22
 
23
23
  import { readFileSync } from "node:fs";
24
+ import { runMain } from "../lib/fatal.mjs";
24
25
 
25
26
  const argv = process.argv.slice(2);
26
27
  const JSON_OUT = argv.includes("--json");
@@ -141,4 +142,4 @@ function main() {
141
142
  process.stdout.write(lines.join("\n"));
142
143
  }
143
144
 
144
- main();
145
+ runMain("council-view", main);
@@ -53,6 +53,7 @@
53
53
  */
54
54
 
55
55
  import { readFileSync } from "node:fs";
56
+ import { runMain } from "../lib/fatal.mjs";
56
57
 
57
58
  const argv = process.argv.slice(2);
58
59
  const STATS = argv.includes("--stats");
@@ -280,4 +281,4 @@ function main() {
280
281
  return emit(out, "crushed");
281
282
  }
282
283
 
283
- main();
284
+ runMain("crush-json", main);
@@ -31,9 +31,8 @@
31
31
  */
32
32
 
33
33
  import { readFileSync, existsSync } from "fs";
34
- import { join } from "path";
35
- import { homedir } from "os";
36
34
  import { execFileSync } from "child_process";
35
+ import { resolveRunFile, taskIdVariants, logsRoot } from "./_run-paths.mjs";
37
36
 
38
37
  const argv = process.argv.slice(2);
39
38
  const flags = {};
@@ -71,14 +70,12 @@ Output:
71
70
  }
72
71
 
73
72
  function resolveStateByTaskId(id) {
74
- const bare = String(id).replace(/^#/, "");
75
- const logRoot = join(homedir(), ".claude", "logs", "multi-agent");
76
- const candidates = [...new Set([String(id), bare, `task-${bare}`])].map((d) =>
77
- join(logRoot, d, "agent-state.json"),
78
- );
79
- const found = candidates.find((p) => existsSync(p));
73
+ const found = resolveRunFile(id, "agent-state.json", flags.project);
80
74
  if (!found)
81
- die(`no agent-state.json for task ${id} under ${logRoot} (tried ${candidates.join(", ")})`);
75
+ die(
76
+ `no agent-state.json for task ${id} under ${logsRoot()} ` +
77
+ `(tried ${taskIdVariants(id).join(", ")} in both layouts)`,
78
+ );
82
79
  return found;
83
80
  }
84
81