@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.
- package/CHANGELOG.md +127 -0
- package/README.md +43 -1
- package/README.tr.md +41 -0
- package/docs/adr/0011-dormant-ci.md +25 -1
- package/docs/server-readiness.md +188 -0
- package/index.js +16 -1
- package/install/_common.mjs +42 -17
- package/install/_dev-only-files.mjs +8 -0
- package/install/_unattended-profile.mjs +113 -0
- package/install/index.mjs +48 -0
- package/manifest.json +1049 -0
- package/package.json +5 -2
- package/pipeline/commands/multi-agent/status/SKILL.md +52 -21
- package/pipeline/lib/_jira-auth.sh +8 -0
- package/pipeline/lib/analysis-jira-write.sh +32 -0
- package/pipeline/lib/ask-choice.sh +13 -2
- package/pipeline/lib/autopilot-state.sh +8 -0
- package/pipeline/lib/fatal.mjs +129 -0
- package/pipeline/lib/figma-mcp-refresh.sh +18 -0
- package/pipeline/lib/figma-screenshot.sh +18 -0
- package/pipeline/lib/invoked-directly.mjs +43 -0
- package/pipeline/lib/jira-publish.sh +42 -0
- package/pipeline/lib/md2confluence-v3.py +47 -0
- package/pipeline/lib/outbound-gate.mjs +175 -0
- package/pipeline/lib/plan-todos.sh +27 -6
- package/pipeline/lib/post-pr-review.sh +77 -8
- package/pipeline/lib/repo-hygiene.sh +8 -3
- package/pipeline/lib/require-jq.sh +40 -0
- package/pipeline/lib/run-paths.sh +335 -0
- package/pipeline/multi-agent-refs/features/autopilot-circuit-breaker.md +70 -0
- package/pipeline/multi-agent-refs/features/cost-analysis.md +93 -0
- package/pipeline/multi-agent-refs/features/doctor.md +45 -0
- package/pipeline/multi-agent-refs/features/verify.md +83 -0
- package/pipeline/multi-agent-refs/phases/operations.md +13 -2
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
- package/pipeline/multi-agent-refs/unattended-contract.md +129 -0
- package/pipeline/scripts/_run-paths.mjs +372 -0
- package/pipeline/scripts/aggregate-metrics.mjs +64 -64
- package/pipeline/scripts/autopilot-arming.mjs +2 -1
- package/pipeline/scripts/autopilot-intake.mjs +2 -1
- package/pipeline/scripts/autopilot-runner.mjs +206 -2
- package/pipeline/scripts/build-references.mjs +2 -1
- package/pipeline/scripts/build-stack-plugins.mjs +10 -2
- package/pipeline/scripts/capture-evidence.sh +7 -2
- package/pipeline/scripts/classify-plan-safety.mjs +2 -1
- package/pipeline/scripts/cost-analyze.mjs +600 -0
- package/pipeline/scripts/cost-budget-check.mjs +4 -12
- package/pipeline/scripts/council-view.mjs +2 -1
- package/pipeline/scripts/crush-json.mjs +2 -1
- package/pipeline/scripts/diff-explain.mjs +6 -9
- package/pipeline/scripts/diff-risk-score.mjs +2 -1
- package/pipeline/scripts/doctor.mjs +138 -4
- package/pipeline/scripts/evidence-gate.mjs +9 -3
- package/pipeline/scripts/feedback-send.mjs +12 -2
- package/pipeline/scripts/gc-abandoned.sh +29 -13
- package/pipeline/scripts/gc-worktrees.sh +11 -4
- package/pipeline/scripts/github-ssh-setup.sh +64 -7
- package/pipeline/scripts/graph-mermaid.mjs +4 -2
- package/pipeline/scripts/keychain-save.sh +101 -30
- package/pipeline/scripts/learn-from-transcripts.mjs +2 -1
- package/pipeline/scripts/learning-curve.mjs +34 -29
- package/pipeline/scripts/make-manifest.mjs +199 -0
- package/pipeline/scripts/migrate-prefs.mjs +2 -1
- package/pipeline/scripts/migrate-state.mjs +94 -4
- package/pipeline/scripts/phase-banner.sh +6 -2
- package/pipeline/scripts/phase-tracker.sh +41 -3
- package/pipeline/scripts/plan-coverage-gate.mjs +6 -2
- package/pipeline/scripts/pre-commit-check.sh +7 -0
- package/pipeline/scripts/pre-push-check.sh +7 -0
- package/pipeline/scripts/purge.sh +23 -6
- package/pipeline/scripts/render-agent-log-cost.sh +9 -2
- package/pipeline/scripts/render-cost-summary.sh +9 -2
- package/pipeline/scripts/render-work-summary.sh +11 -4
- package/pipeline/scripts/review-file-filter.mjs +4 -2
- package/pipeline/scripts/review-scope.mjs +2 -1
- package/pipeline/scripts/routine-registry.mjs +2 -1
- package/pipeline/scripts/run-aggregator.mjs +13 -14
- package/pipeline/scripts/run-metrics.mjs +3 -1
- package/pipeline/scripts/runs-index.mjs +343 -0
- package/pipeline/scripts/scorecard-snapshot.mjs +178 -0
- package/pipeline/scripts/search-logs.sh +18 -0
- package/pipeline/scripts/test-gap-scan.mjs +2 -1
- package/pipeline/scripts/test-integrity-gate.mjs +2 -1
- package/pipeline/scripts/update-issue-progress.sh +56 -7
- package/pipeline/scripts/usage-report.mjs +12 -1
- package/pipeline/scripts/validate-analysis-doc.mjs +2 -1
- package/pipeline/scripts/validate-code-graph.mjs +6 -3
- package/pipeline/scripts/validate-complaint-doc.mjs +2 -1
- package/pipeline/scripts/validate-diff-risk.mjs +6 -3
- package/pipeline/scripts/validate-test-gap.mjs +6 -3
- package/pipeline/scripts/validate-triage.mjs +3 -1
- package/pipeline/scripts/verify-citations.mjs +4 -2
- package/pipeline/scripts/verify.mjs +327 -0
- package/pipeline/scripts/worktree-finalize.sh +13 -4
- package/pipeline/scripts/write-state.mjs +154 -15
- package/pipeline/skills/.skill-manifest.json +2 -2
- package/pipeline/skills/.skills-index.json +56 -1
- package/pipeline/skills/shared/README.md +8 -3
- package/pipeline/skills/shared/core/multi-agent-status/SKILL.md +33 -9
- package/pipeline/skills/shared/external/macos-spm-app-packaging/assets/templates/package_app.sh +4 -1
- package/pipeline/skills/shared/external/macos-spm-app-packaging/assets/templates/setup_dev_signing.sh +4 -1
- package/pipeline/skills/shared/external/macos-spm-app-packaging/assets/templates/sign-and-notarize.sh +2 -1
- 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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|