cohorte 1.3.4 → 1.4.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 +43 -0
- package/README.md +4 -7
- package/bin/cli.js +29 -2
- package/core/agents/profile-reader.md +22 -0
- package/core/commands/doctor.md +5 -3
- package/core/workflows/audit.js +68 -4
- package/core/workflows/refactor.js +69 -4
- package/core/workflows/review.js +73 -4
- package/dashboard/server/doctor.js +10 -3
- package/dashboard/server/metrics.js +5 -4
- package/package.json +1 -1
- package/profile/SCHEMA.md +12 -34
- package/scripts/metrics/collect.mjs +495 -0
- package/scripts/metrics/prices.json +39 -0
- package/scripts/test-dashboard.mjs +7 -1
- package/scripts/test-metrics.mjs +135 -0
- package/scripts/test-workflows.mjs +56 -178
- package/scripts/validate-core.mjs +5 -4
- package/core/commands/cycle.md +0 -61
- package/core/workflows/cycle.js +0 -513
package/profile/SCHEMA.md
CHANGED
|
@@ -162,9 +162,9 @@ Coarse first, specialize on evidence: start with one `frontend` / `backend` surf
|
|
|
162
162
|
surface that's proven slow and cleanly separable. The evidence lives in
|
|
163
163
|
the **main checkout's** `.claude/pipeline-metrics.jsonl` (gitignored) — one JSONL line per phase batch
|
|
164
164
|
(`ts`/`feature`/`phase`/`seconds`/`surfaces:{key: result}`), appended by `/build`, `/review`, `/fix`
|
|
165
|
-
and `/smoke
|
|
165
|
+
and `/smoke`.
|
|
166
166
|
**`surfaces` keys are surface keys, nothing else** — run-level facts go in their own top-level
|
|
167
|
-
fields
|
|
167
|
+
fields. Anything put inside `surfaces` is read
|
|
168
168
|
as a surface: the dashboard renders it as a row in the per-surface table and scores a non-`ok`
|
|
169
169
|
value as that surface failing. Always the main checkout, never the feature worktree (which dies at teardown while
|
|
170
170
|
metrics must accumulate across features) — resolve from anywhere with
|
|
@@ -319,13 +319,12 @@ itself changes in ways `/build` §1.5 can't auto-grow (e.g. package manager or c
|
|
|
319
319
|
|
|
320
320
|
## Workflows — deterministic multi-agent runs (opt-in)
|
|
321
321
|
|
|
322
|
-
|
|
322
|
+
Three phases have a **workflow variant** — a deterministic orchestration script the Claude Code
|
|
323
323
|
Workflow runtime executes instead of the lead reasoning out the fan-out turn by turn:
|
|
324
|
-
`<core>/workflows/review.js`, `audit.js`, `refactor.js
|
|
324
|
+
`<core>/workflows/review.js`, `audit.js`, `refactor.js` (installed to `.claude/workflows/` bundled or
|
|
325
325
|
`~/.claude/workflows/` global). The conversational commands (`/review`, `/audit`, `/refactor`)
|
|
326
326
|
**remain the default path and the fallback** — a workflow runs only when the human explicitly asks
|
|
327
|
-
for it ("run the review workflow",
|
|
328
|
-
`cycle.js` and invokes the runtime for them), and requires Claude Code ≥ **2.1.154** with workflows
|
|
327
|
+
for it ("run the review workflow"), and requires Claude Code ≥ **2.1.154** with workflows
|
|
329
328
|
enabled.
|
|
330
329
|
`/doctor` reports which path a session will take. The interactive commands (`/init-pipeline`,
|
|
331
330
|
`/brainstorm`, `/spec`) and the dispatch-only ones (`/build`, `/ship`) have **no** workflow variant on
|
|
@@ -345,8 +344,8 @@ Shared design, all four scripts:
|
|
|
345
344
|
- **A dead agent is never a clean result.** `agent()` resolves to `null` when a subagent dies, and a
|
|
346
345
|
dead *reviewer* returns zero findings — byte-identical to a surface that is genuinely clean. Any
|
|
347
346
|
script that derives a verdict from "how many findings came back" must first subtract the agents
|
|
348
|
-
that never answered: `review.js`
|
|
349
|
-
`SHIP
|
|
347
|
+
that never answered: `review.js` names them in `unreviewedSurfaces` and refuses to score
|
|
348
|
+
`SHIP`. `scripts/test-workflows.mjs`
|
|
350
349
|
pins this — it is the one invariant the structural checks in `validate-core.mjs` cannot see.
|
|
351
350
|
- **`review.js`** — preflight gate (aborts red, zero agents), one `git diff --stat` staged per
|
|
352
351
|
touched surface, one reviewer per surface in parallel, then an **adversarial cross-check** phase
|
|
@@ -356,28 +355,9 @@ Shared design, all four scripts:
|
|
|
356
355
|
- **`refactor.js`** — big domains only (it skips domains with a handful of open items — the
|
|
357
356
|
conversational `/refactor` is cheaper there): `shared` first and alone, then the other domains'
|
|
358
357
|
implementers in parallel, each verified per-domain.
|
|
359
|
-
- **`cycle.js`** — the **full dev cycle** on a frozen spec: contract → parallel build → rounds of
|
|
360
|
-
[preflight → review(+cross-check) (∥ smoke if opted in) → fix on the surfaces with findings], looping until
|
|
361
|
-
**zero open findings (+ a PASS smoke when opted in)** (`maxRounds`, default 5, and the token budget are runaway
|
|
362
|
-
protection, not targets). Since a workflow can't ask anything mid-run, the decisions move to the
|
|
363
|
-
edges: a **readiness gate** aborts up front if the spec isn't frozen (other gaps ride along as
|
|
364
|
-
deferred questions), and everything genuinely human comes back at the END in the result's
|
|
365
|
-
`questions` array — empty when `/brainstorm` + `/spec` did their job. Even a finding that implies
|
|
366
|
-
a **contract change stays inside the loop**: a lead-equivalent agent re-authors spec §5 + the
|
|
367
|
-
contract file (exactly what conversational `/fix` §1 does — implementers still never touch it),
|
|
368
|
-
the consuming surfaces re-dispatch, and the loop continues; the re-authorings are reported in the
|
|
369
|
-
result's `contractChanges` for the human to review in the diff. A clean exit ticks the DoD and
|
|
370
|
-
stamps the freshness gate (when smoke was skipped — the default — the runtime-flows DoD box stays
|
|
371
|
-
unticked and `/ship` flags it); a stopped run appends its open
|
|
372
|
-
findings to the spec's `## Remediation` so a rerun of the cycle — or a conversational `/fix` —
|
|
373
|
-
continues seamlessly. `/ship` itself stays outside on purpose — outward-facing and irreversible,
|
|
374
|
-
it keeps its human confirmation.
|
|
375
|
-
**Corollary — harden the spec:** the more `/brainstorm` + `/spec` pre-answer (edge cases, error
|
|
376
|
-
envelopes, role matrix, design links), the further the cycle runs and the emptier `questions`
|
|
377
|
-
comes back; a vague spec just converts into deferred questions.
|
|
378
358
|
- **No input mid-run.** A workflow runs to completion without questions; anything interactive
|
|
379
|
-
(contract changes, human decisions) belongs to the conversational path
|
|
380
|
-
|
|
359
|
+
(contract changes, human decisions) belongs to the conversational path. The gate hook still
|
|
360
|
+
fires on workflow subagents (see
|
|
381
361
|
§Preflight) — in unattended runs its asks become denies.
|
|
382
362
|
- **Permissions:** `/init-pipeline` and `/update-pipeline` extend the generated `settings.json`
|
|
383
363
|
`allow` list with what workflow agents need (the quiet commands, the shipped
|
|
@@ -470,11 +450,9 @@ see where features stall, so every stage of `idea → PR` reports and nothing el
|
|
|
470
450
|
| `fix` | after the batch metrics line | wall-clock | `<fixed>/<found>` |
|
|
471
451
|
| `ship` | the release agent succeeded | `0` | `pr` / `compare` |
|
|
472
452
|
|
|
473
|
-
> Workflow-variant runs (`review.js
|
|
474
|
-
> conversational commands measure wall-clock. `
|
|
475
|
-
>
|
|
476
|
-
> conversational `/fix` does. `results` is a free-text summary field, so both forms are valid — but
|
|
477
|
-
> read the `fix` column knowing which path produced it.
|
|
453
|
+
> Workflow-variant runs (`review.js`) report `seconds: 0` for their phases — only the
|
|
454
|
+
> conversational commands measure wall-clock. `results` is a free-text summary field, so both
|
|
455
|
+
> forms are valid — but read the `fix` column knowing which path produced it.
|
|
478
456
|
|
|
479
457
|
`seconds: 0` marks a phase whose duration is human thinking time, not pipeline wall-clock — the
|
|
480
458
|
funnel signal there is the event, not how long it took. `/doctor`, `/audit`, `/refactor`,
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// collect.mjs — reconstruct per-command cost and runtime from Claude Code's own transcripts.
|
|
3
|
+
//
|
|
4
|
+
// node scripts/metrics/collect.mjs [projectRoot] [--json] [--runs] [--since=ISO] [--days=N]
|
|
5
|
+
//
|
|
6
|
+
// WHY THIS EXISTS
|
|
7
|
+
// `.claude/pipeline-metrics.jsonl` is written by the model itself (each command file tells
|
|
8
|
+
// it to append a line). That makes it unreliable — a command that ends early, errors, or
|
|
9
|
+
// simply forgets writes nothing, and it can never report tokens because the model does not
|
|
10
|
+
// know its own usage. Claude Code, meanwhile, already logs every API response it makes to
|
|
11
|
+
// ~/.claude/projects/<slug>/<sessionId>.jsonl with exact `usage` and timestamps, and every
|
|
12
|
+
// subagent to <sessionId>/subagents/agent-<id>.jsonl. That is ground truth, it needs no
|
|
13
|
+
// cooperation from the model, and it is retroactive: this script works on runs that already
|
|
14
|
+
// happened. Nothing here writes to the pipeline — it is a pure reader.
|
|
15
|
+
//
|
|
16
|
+
// THREE THINGS THAT ARE EASY TO GET WRONG, HANDLED HERE
|
|
17
|
+
// 1. One API response is written as SEVERAL transcript lines (one per content block:
|
|
18
|
+
// thinking, text, tool_use, tool_use), and EACH line repeats the full `usage` object.
|
|
19
|
+
// Summing lines inflates tokens ~1.8x. We dedupe by message.id.
|
|
20
|
+
// 2. Feature work happens in git worktrees, whose cwd hashes to a DIFFERENT project slug.
|
|
21
|
+
// Scanning only the main checkout's slug silently drops most of a multi-surface run. We match
|
|
22
|
+
// sessions by their recorded `cwd` against `git worktree list`.
|
|
23
|
+
// 3. Subagent spend lives in a separate file tree and is invisible in the parent
|
|
24
|
+
// transcript. For cohorte that is the majority of the cost, so we walk subagents/ and
|
|
25
|
+
// attribute each agent to the command segment that spawned it (via meta.toolUseId,
|
|
26
|
+
// falling back to timestamp containment for agents spawned by the Workflow runner).
|
|
27
|
+
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import os from 'node:os';
|
|
31
|
+
import { execFileSync } from 'node:child_process';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
33
|
+
|
|
34
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
const PRICES = JSON.parse(fs.readFileSync(path.join(HERE, 'prices.json'), 'utf8'));
|
|
36
|
+
|
|
37
|
+
// A gap longer than this between two API responses is the human thinking, reading, or away
|
|
38
|
+
// — not the command working. `wall` keeps it, `active` drops it. Without the split, a
|
|
39
|
+
// command left open over lunch reports a two-hour runtime and poisons every median.
|
|
40
|
+
const IDLE_GAP_S = 120;
|
|
41
|
+
|
|
42
|
+
// A prompt this short with no command in it ("continue", "go", "ok next") is the human
|
|
43
|
+
// steering a run that is already going, not starting a new one. Without this, a single
|
|
44
|
+
// /review driven by three "continue"s reports as one /review plus three anonymous chat
|
|
45
|
+
// runs, and three quarters of its cost lands under (chat).
|
|
46
|
+
const CONTINUATION_MAX_CHARS = 40;
|
|
47
|
+
|
|
48
|
+
// Commands are recognised two ways. `<command-name>` is emitted only when the whole prompt
|
|
49
|
+
// IS the slash command; in practice people write "move on branding-ramp and /review", which
|
|
50
|
+
// the harness records as ordinary prose. So we also look for an inline mention, checked
|
|
51
|
+
// against the real command list rather than any /token — otherwise a file path like
|
|
52
|
+
// /usr/bin or a URL fragment would invent commands that were never run.
|
|
53
|
+
function knownCommands() {
|
|
54
|
+
const names = new Set();
|
|
55
|
+
for (const dir of [path.join(HERE, '..', '..', 'core', 'commands'),
|
|
56
|
+
path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'pipeline', 'commands')]) {
|
|
57
|
+
try {
|
|
58
|
+
for (const f of fs.readdirSync(dir)) if (f.endsWith('.md')) names.add(f.slice(0, -3));
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
// Fallback for a collector run outside the package (e.g. copied into a repo on its own).
|
|
62
|
+
if (!names.size) {
|
|
63
|
+
for (const n of ['brainstorm', 'spec', 'build', 'smoke', 'review', 'fix', 'ship',
|
|
64
|
+
'audit', 'refactor', 'align-ds', 'doctor', 'init-pipeline', 'update-pipeline']) names.add(n);
|
|
65
|
+
}
|
|
66
|
+
// Retired commands. The list above is read from the shipped core, so a command that is
|
|
67
|
+
// removed stops being recognised — and every run of it already in the transcripts silently
|
|
68
|
+
// reclassifies as (chat), rewriting history and inflating the catch-all bucket. Keep the
|
|
69
|
+
// names here so past runs stay attributed to what actually ran.
|
|
70
|
+
for (const n of ['cycle']) names.add(n);
|
|
71
|
+
return names;
|
|
72
|
+
}
|
|
73
|
+
const COMMANDS = knownCommands();
|
|
74
|
+
|
|
75
|
+
function commandIn(text) {
|
|
76
|
+
const explicit = /<command-name>\s*(\/?[\w:-]+)\s*<\/command-name>/.exec(text);
|
|
77
|
+
if (explicit) return explicit[1].replace(/^\//, '');
|
|
78
|
+
// Last mention wins: "finish /build then /review" ends on the one being asked for.
|
|
79
|
+
let found = null;
|
|
80
|
+
for (const m of text.matchAll(/(?:^|\s)\/([a-z][a-z0-9-]{2,})\b/g)) {
|
|
81
|
+
if (COMMANDS.has(m[1])) found = m[1];
|
|
82
|
+
}
|
|
83
|
+
return found;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── paths ────────────────────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
const norm = (p) => String(p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
89
|
+
|
|
90
|
+
function git(args, cwd) {
|
|
91
|
+
try {
|
|
92
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
93
|
+
} catch { return ''; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Every checkout that belongs to this repo: the main one plus every live feature worktree.
|
|
97
|
+
// A cohorte run spreads its surfaces across worktrees, so this set is what makes a feature
|
|
98
|
+
// add up instead of reporting only whatever the human happened to type in the main window.
|
|
99
|
+
function repoCheckouts(root) {
|
|
100
|
+
const out = new Set([norm(root)]);
|
|
101
|
+
const common = git(['rev-parse', '--git-common-dir'], root).trim();
|
|
102
|
+
if (common) out.add(norm(path.resolve(root, common, '..')));
|
|
103
|
+
for (const line of git(['worktree', 'list', '--porcelain'], root).split(/\r?\n/)) {
|
|
104
|
+
if (line.startsWith('worktree ')) out.add(norm(line.slice(9)));
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const projectsDir = () =>
|
|
110
|
+
path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'projects');
|
|
111
|
+
|
|
112
|
+
// Read only the head of a transcript to decide whether it belongs to this repo. Transcripts
|
|
113
|
+
// run to tens of MB and most of them belong to other projects; fully parsing every file to
|
|
114
|
+
// find the handful that match turns a 2-second command into a 2-minute one.
|
|
115
|
+
function sessionCwd(file) {
|
|
116
|
+
let fd;
|
|
117
|
+
try {
|
|
118
|
+
fd = fs.openSync(file, 'r');
|
|
119
|
+
const buf = Buffer.alloc(65536);
|
|
120
|
+
const n = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
121
|
+
const m = /"cwd"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(buf.subarray(0, n).toString('utf8'));
|
|
122
|
+
return m ? JSON.parse(`"${m[1]}"`) : null;
|
|
123
|
+
} catch { return null; }
|
|
124
|
+
finally { if (fd !== undefined) try { fs.closeSync(fd); } catch {} }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function findSessions(checkouts) {
|
|
128
|
+
const root = projectsDir();
|
|
129
|
+
let dirs;
|
|
130
|
+
try { dirs = fs.readdirSync(root, { withFileTypes: true }); }
|
|
131
|
+
catch { return []; }
|
|
132
|
+
|
|
133
|
+
const found = [];
|
|
134
|
+
for (const d of dirs) {
|
|
135
|
+
if (!d.isDirectory()) continue;
|
|
136
|
+
const dir = path.join(root, d.name);
|
|
137
|
+
let files;
|
|
138
|
+
try { files = fs.readdirSync(dir); } catch { continue; }
|
|
139
|
+
for (const f of files) {
|
|
140
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
141
|
+
const file = path.join(dir, f);
|
|
142
|
+
const cwd = sessionCwd(file);
|
|
143
|
+
if (!cwd) continue;
|
|
144
|
+
// A worktree's cwd can be a SUBDIRECTORY of the checkout (an agent cd'd into a
|
|
145
|
+
// package), so prefix-match rather than compare for equality.
|
|
146
|
+
const c = norm(cwd);
|
|
147
|
+
if (![...checkouts].some((k) => c === k || c.startsWith(k + '/'))) continue;
|
|
148
|
+
found.push({ file, dir: path.join(dir, path.basename(f, '.jsonl')), cwd });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return found;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── pricing ──────────────────────────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
function rates(model, speed) {
|
|
157
|
+
let best = null;
|
|
158
|
+
for (const key of Object.keys(PRICES.models)) {
|
|
159
|
+
if (String(model || '').startsWith(key) && (!best || key.length > best.length)) best = key;
|
|
160
|
+
}
|
|
161
|
+
if (!best) return null;
|
|
162
|
+
const entry = PRICES.models[best];
|
|
163
|
+
return (speed === 'fast' && entry.fast) ? entry.fast : entry;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const emptyTokens = () => ({ input: 0, output: 0, cacheWrite5m: 0, cacheWrite1h: 0, cacheRead: 0 });
|
|
167
|
+
|
|
168
|
+
function addUsage(acc, usage, model, speed) {
|
|
169
|
+
// `<synthetic>` messages are harness-authored (API error notices, interrupt markers).
|
|
170
|
+
// They carry a usage block but cost nothing — billing them invents spend.
|
|
171
|
+
if (model === '<synthetic>') return;
|
|
172
|
+
const cc = usage.cache_creation || {};
|
|
173
|
+
let w5 = cc.ephemeral_5m_input_tokens || 0;
|
|
174
|
+
const w1 = cc.ephemeral_1h_input_tokens || 0;
|
|
175
|
+
// Older transcripts carry only the flat total with no TTL breakdown. Bill it as 5m —
|
|
176
|
+
// the cheaper of the two, so an unknown-TTL run under-reports rather than inflates.
|
|
177
|
+
if (!w5 && !w1) w5 = usage.cache_creation_input_tokens || 0;
|
|
178
|
+
|
|
179
|
+
const t = { input: usage.input_tokens || 0, output: usage.output_tokens || 0,
|
|
180
|
+
cacheWrite5m: w5, cacheWrite1h: w1, cacheRead: usage.cache_read_input_tokens || 0 };
|
|
181
|
+
for (const k of Object.keys(t)) acc.tokens[k] += t[k];
|
|
182
|
+
|
|
183
|
+
const r = rates(model, speed);
|
|
184
|
+
if (!r) { acc.unpriced.add(model || 'unknown'); return; }
|
|
185
|
+
const m = PRICES.multipliers;
|
|
186
|
+
acc.cost += (
|
|
187
|
+
t.input * r.input +
|
|
188
|
+
t.output * r.output +
|
|
189
|
+
t.cacheWrite5m * r.input * m.cacheWrite5m +
|
|
190
|
+
t.cacheWrite1h * r.input * m.cacheWrite1h +
|
|
191
|
+
t.cacheRead * r.input * m.cacheRead
|
|
192
|
+
) / 1e6;
|
|
193
|
+
|
|
194
|
+
const byModel = acc.models[model] || (acc.models[model] = emptyTokens());
|
|
195
|
+
for (const k of Object.keys(t)) byModel[k] += t[k];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── transcript parsing ───────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
const userText = (msg) => {
|
|
201
|
+
const c = msg && msg.content;
|
|
202
|
+
if (typeof c === 'string') return c;
|
|
203
|
+
if (!Array.isArray(c)) return '';
|
|
204
|
+
return c.filter((b) => b && b.type === 'text').map((b) => b.text || '').join('\n');
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const isToolResultTurn = (msg) =>
|
|
208
|
+
Array.isArray(msg && msg.content) && msg.content.some((b) => b && b.type === 'tool_result');
|
|
209
|
+
|
|
210
|
+
function newSegment(label, ts) {
|
|
211
|
+
return {
|
|
212
|
+
label, startTs: ts, endTs: ts,
|
|
213
|
+
tokens: emptyTokens(), cost: 0, models: {}, unpriced: new Set(),
|
|
214
|
+
activeS: 0, lastTs: ts, turns: 0, continuations: 0,
|
|
215
|
+
toolUseIds: new Set(), agents: [],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// One segment = one command invocation (or one free-form chat turn). Boundaries are user
|
|
220
|
+
// turns that are NOT tool results — a tool result is the harness feeding the loop, not the
|
|
221
|
+
// human starting something new.
|
|
222
|
+
function parseSession(file) {
|
|
223
|
+
const segments = [];
|
|
224
|
+
let seg = null;
|
|
225
|
+
const seenMessageIds = new Set();
|
|
226
|
+
|
|
227
|
+
let raw;
|
|
228
|
+
try { raw = fs.readFileSync(file, 'utf8'); } catch { return segments; }
|
|
229
|
+
|
|
230
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
231
|
+
if (!line) continue;
|
|
232
|
+
let e;
|
|
233
|
+
try { e = JSON.parse(line); } catch { continue; }
|
|
234
|
+
const ts = e.timestamp ? Date.parse(e.timestamp) : NaN;
|
|
235
|
+
|
|
236
|
+
if (e.type === 'user') {
|
|
237
|
+
const msg = e.message || {};
|
|
238
|
+
if (isToolResultTurn(msg) || e.isMeta || e.isSidechain) continue;
|
|
239
|
+
const text = userText(msg);
|
|
240
|
+
const cmd = commandIn(text);
|
|
241
|
+
// Not every user-role turn is the human starting something. The harness injects
|
|
242
|
+
// turns mid-run — a local-command echo, and (critically for cohorte) a
|
|
243
|
+
// <task-notification> when a background agent finishes. Those arrive DURING a
|
|
244
|
+
// a /build; treating them as boundaries chops one command into several
|
|
245
|
+
// cheap-looking fragments and strands the agent spend in the wrong segment.
|
|
246
|
+
if (!cmd && /<(local-command-(stdout|stderr)|task-notification|system-reminder)>/.test(text)) continue;
|
|
247
|
+
// A short steer with no command keeps the current run open rather than opening a new
|
|
248
|
+
// one — see CONTINUATION_MAX_CHARS.
|
|
249
|
+
if (!cmd && seg && text.trim().length <= CONTINUATION_MAX_CHARS) { seg.continuations += 1; continue; }
|
|
250
|
+
seg = newSegment(cmd ? '/' + cmd : '(chat)', ts);
|
|
251
|
+
segments.push(seg);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (e.type !== 'assistant' || !seg) continue;
|
|
256
|
+
const msg = e.message || {};
|
|
257
|
+
|
|
258
|
+
// Dedupe: the same API response is written once per content block, each copy carrying
|
|
259
|
+
// the full usage. See header note 1 — this is the single biggest correctness trap.
|
|
260
|
+
const id = msg.id;
|
|
261
|
+
if (id && seenMessageIds.has(id)) {
|
|
262
|
+
for (const b of msg.content || []) if (b && b.type === 'tool_use' && b.id) seg.toolUseIds.add(b.id);
|
|
263
|
+
if (!Number.isNaN(ts)) seg.endTs = Math.max(seg.endTs, ts);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (id) seenMessageIds.add(id);
|
|
267
|
+
|
|
268
|
+
if (msg.usage) addUsage(seg, msg.usage, msg.model, msg.usage.speed);
|
|
269
|
+
for (const b of msg.content || []) if (b && b.type === 'tool_use' && b.id) seg.toolUseIds.add(b.id);
|
|
270
|
+
|
|
271
|
+
seg.turns += 1;
|
|
272
|
+
if (!Number.isNaN(ts)) {
|
|
273
|
+
const gap = (ts - seg.lastTs) / 1000;
|
|
274
|
+
if (gap > 0 && gap <= IDLE_GAP_S) seg.activeS += gap;
|
|
275
|
+
seg.lastTs = ts;
|
|
276
|
+
seg.endTs = Math.max(seg.endTs, ts);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return segments;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── subagents ────────────────────────────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
function readSubagents(sessionDir) {
|
|
285
|
+
const dir = path.join(sessionDir, 'subagents');
|
|
286
|
+
let files;
|
|
287
|
+
try { files = fs.readdirSync(dir); } catch { return []; }
|
|
288
|
+
|
|
289
|
+
const agents = [];
|
|
290
|
+
for (const f of files) {
|
|
291
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
292
|
+
const base = f.slice(0, -'.jsonl'.length);
|
|
293
|
+
let meta = {};
|
|
294
|
+
try { meta = JSON.parse(fs.readFileSync(path.join(dir, base + '.meta.json'), 'utf8')); } catch {}
|
|
295
|
+
|
|
296
|
+
const acc = { tokens: emptyTokens(), cost: 0, models: {}, unpriced: new Set() };
|
|
297
|
+
let startTs = Infinity, endTs = -Infinity, turns = 0;
|
|
298
|
+
const seen = new Set();
|
|
299
|
+
|
|
300
|
+
let raw;
|
|
301
|
+
try { raw = fs.readFileSync(path.join(dir, f), 'utf8'); } catch { continue; }
|
|
302
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
303
|
+
if (!line) continue;
|
|
304
|
+
let e;
|
|
305
|
+
try { e = JSON.parse(line); } catch { continue; }
|
|
306
|
+
if (e.type !== 'assistant') continue;
|
|
307
|
+
const msg = e.message || {};
|
|
308
|
+
if (msg.id && seen.has(msg.id)) continue;
|
|
309
|
+
if (msg.id) seen.add(msg.id);
|
|
310
|
+
if (msg.usage) addUsage(acc, msg.usage, msg.model, msg.usage.speed);
|
|
311
|
+
turns += 1;
|
|
312
|
+
const ts = e.timestamp ? Date.parse(e.timestamp) : NaN;
|
|
313
|
+
if (!Number.isNaN(ts)) { startTs = Math.min(startTs, ts); endTs = Math.max(endTs, ts); }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
agents.push({
|
|
317
|
+
id: base.replace(/^agent-/, ''),
|
|
318
|
+
agentType: meta.agentType || 'unknown',
|
|
319
|
+
description: meta.description || '',
|
|
320
|
+
toolUseId: meta.toolUseId || null,
|
|
321
|
+
spawnDepth: meta.spawnDepth ?? null,
|
|
322
|
+
turns, startTs, endTs, ...acc,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
return agents;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function attachAgents(segments, agents) {
|
|
329
|
+
for (const a of agents) {
|
|
330
|
+
// Preferred link: the Task/Agent tool_use that spawned it. Workflow-spawned agents can
|
|
331
|
+
// carry a toolUseId the parent transcript never recorded, so fall back to which segment
|
|
332
|
+
// was running when the agent started.
|
|
333
|
+
let seg = a.toolUseId ? segments.find((s) => s.toolUseIds.has(a.toolUseId)) : null;
|
|
334
|
+
if (!seg && Number.isFinite(a.startTs)) {
|
|
335
|
+
seg = segments.find((s) => a.startTs >= s.startTs && a.startTs <= s.endTs + 5 * 60_000);
|
|
336
|
+
}
|
|
337
|
+
if (!seg) continue;
|
|
338
|
+
seg.agents.push(a);
|
|
339
|
+
for (const k of Object.keys(seg.tokens)) seg.tokens[k] += a.tokens[k];
|
|
340
|
+
seg.cost += a.cost;
|
|
341
|
+
for (const [m, t] of Object.entries(a.models)) {
|
|
342
|
+
const dst = seg.models[m] || (seg.models[m] = emptyTokens());
|
|
343
|
+
for (const k of Object.keys(t)) dst[k] += t[k];
|
|
344
|
+
}
|
|
345
|
+
for (const m of a.unpriced) seg.unpriced.add(m);
|
|
346
|
+
if (Number.isFinite(a.endTs)) seg.endTs = Math.max(seg.endTs, a.endTs);
|
|
347
|
+
// Agents run in parallel, so their wall time is not additive and cannot be folded into
|
|
348
|
+
// the parent's `active`. Segment `wall` already covers them via endTs; agent runtime is
|
|
349
|
+
// reported separately per command as agentWallS.
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ── rollup ───────────────────────────────────────────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
const pct = (arr, p) => {
|
|
356
|
+
if (!arr.length) return 0;
|
|
357
|
+
const s = [...arr].sort((a, b) => a - b);
|
|
358
|
+
return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
function rollup(segments) {
|
|
362
|
+
const by = new Map();
|
|
363
|
+
for (const s of segments) {
|
|
364
|
+
let g = by.get(s.label);
|
|
365
|
+
if (!g) {
|
|
366
|
+
g = { command: s.label, runs: 0, continuations: 0, tokens: emptyTokens(), cost: 0, models: {},
|
|
367
|
+
wall: [], active: [], agentCounts: [], agentWall: [], turns: [], unpriced: new Set() };
|
|
368
|
+
by.set(s.label, g);
|
|
369
|
+
}
|
|
370
|
+
g.runs += 1;
|
|
371
|
+
g.continuations += s.continuations;
|
|
372
|
+
for (const k of Object.keys(s.tokens)) g.tokens[k] += s.tokens[k];
|
|
373
|
+
g.cost += s.cost;
|
|
374
|
+
for (const [m, t] of Object.entries(s.models)) {
|
|
375
|
+
const dst = g.models[m] || (g.models[m] = emptyTokens());
|
|
376
|
+
for (const k of Object.keys(t)) dst[k] += t[k];
|
|
377
|
+
}
|
|
378
|
+
for (const m of s.unpriced) g.unpriced.add(m);
|
|
379
|
+
g.wall.push(Math.max(0, (s.endTs - s.startTs) / 1000) || 0);
|
|
380
|
+
g.active.push(s.activeS);
|
|
381
|
+
g.turns.push(s.turns);
|
|
382
|
+
g.agentCounts.push(s.agents.length);
|
|
383
|
+
g.agentWall.push(s.agents.reduce((n, a) => n + (Number.isFinite(a.endTs) ? (a.endTs - a.startTs) / 1000 : 0), 0));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return [...by.values()]
|
|
387
|
+
.map((g) => ({
|
|
388
|
+
command: g.command,
|
|
389
|
+
runs: g.runs,
|
|
390
|
+
continuations: g.continuations,
|
|
391
|
+
cost: { total: g.cost, perRun: g.cost / g.runs },
|
|
392
|
+
tokens: g.tokens,
|
|
393
|
+
tokensPerRun: Object.fromEntries(Object.entries(g.tokens).map(([k, v]) => [k, Math.round(v / g.runs)])),
|
|
394
|
+
wallS: { p50: pct(g.wall, 50), p90: pct(g.wall, 90), total: g.wall.reduce((a, b) => a + b, 0) },
|
|
395
|
+
activeS: { p50: pct(g.active, 50), p90: pct(g.active, 90) },
|
|
396
|
+
agents: { perRunP50: pct(g.agentCounts, 50), total: g.agentCounts.reduce((a, b) => a + b, 0),
|
|
397
|
+
serialWallP50S: pct(g.agentWall, 50) },
|
|
398
|
+
turnsP50: pct(g.turns, 50),
|
|
399
|
+
models: g.models,
|
|
400
|
+
unpriced: [...g.unpriced],
|
|
401
|
+
}))
|
|
402
|
+
.sort((a, b) => b.cost.total - a.cost.total);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ── output ───────────────────────────────────────────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
const fmtUsd = (n) => (n < 0.01 && n > 0 ? '<$0.01' : '$' + n.toFixed(2));
|
|
408
|
+
const fmtTok = (n) => (n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? Math.round(n / 1e3) + 'k' : String(n));
|
|
409
|
+
const fmtDur = (s) => (s >= 3600 ? (s / 3600).toFixed(1) + 'h' : s >= 60 ? Math.round(s / 60) + 'm' : Math.round(s) + 's');
|
|
410
|
+
|
|
411
|
+
function table(rows) {
|
|
412
|
+
const head = ['COMMAND', 'RUNS', '$/RUN', '$ TOTAL', 'TOK/RUN', 'OUT/RUN', 'WALL p50', 'ACTIVE p50', 'AGENTS p50'];
|
|
413
|
+
const body = rows.map((r) => [
|
|
414
|
+
r.command,
|
|
415
|
+
String(r.runs),
|
|
416
|
+
fmtUsd(r.cost.perRun),
|
|
417
|
+
fmtUsd(r.cost.total),
|
|
418
|
+
fmtTok(Object.values(r.tokensPerRun).reduce((a, b) => a + b, 0)),
|
|
419
|
+
fmtTok(r.tokensPerRun.output),
|
|
420
|
+
fmtDur(r.wallS.p50),
|
|
421
|
+
fmtDur(r.activeS.p50),
|
|
422
|
+
String(r.agents.perRunP50),
|
|
423
|
+
]);
|
|
424
|
+
const w = head.map((h, i) => Math.max(h.length, ...body.map((b) => b[i].length)));
|
|
425
|
+
const line = (cells) => cells.map((c, i) => (i === 0 ? c.padEnd(w[i]) : c.padStart(w[i]))).join(' ');
|
|
426
|
+
return [line(head), w.map((n) => '-'.repeat(n)).join(' '), ...body.map(line)].join('\n');
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ── main ─────────────────────────────────────────────────────────────────────────────────
|
|
430
|
+
|
|
431
|
+
function main(argv) {
|
|
432
|
+
const args = argv.slice(2);
|
|
433
|
+
const flag = (name) => args.includes('--' + name);
|
|
434
|
+
const opt = (name) => {
|
|
435
|
+
const hit = args.find((a) => a.startsWith(`--${name}=`));
|
|
436
|
+
return hit ? hit.slice(name.length + 3) : null;
|
|
437
|
+
};
|
|
438
|
+
const root = path.resolve(args.find((a) => !a.startsWith('--')) || process.cwd());
|
|
439
|
+
|
|
440
|
+
let since = opt('since') ? Date.parse(opt('since')) : null;
|
|
441
|
+
if (opt('days')) since = Date.now() - Number(opt('days')) * 86400_000;
|
|
442
|
+
|
|
443
|
+
const checkouts = repoCheckouts(root);
|
|
444
|
+
const sessions = findSessions(checkouts);
|
|
445
|
+
|
|
446
|
+
let segments = [];
|
|
447
|
+
for (const s of sessions) {
|
|
448
|
+
const segs = parseSession(s.file);
|
|
449
|
+
attachAgents(segs, readSubagents(s.dir));
|
|
450
|
+
segments.push(...segs);
|
|
451
|
+
}
|
|
452
|
+
if (since) segments = segments.filter((s) => s.startTs >= since);
|
|
453
|
+
// A segment with no API call is a typo or an interrupted prompt, not a run.
|
|
454
|
+
segments = segments.filter((s) => s.turns > 0);
|
|
455
|
+
|
|
456
|
+
const rows = rollup(segments);
|
|
457
|
+
const totals = {
|
|
458
|
+
sessions: sessions.length,
|
|
459
|
+
runs: segments.length,
|
|
460
|
+
cost: rows.reduce((n, r) => n + r.cost.total, 0),
|
|
461
|
+
agents: rows.reduce((n, r) => n + r.agents.total, 0),
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
if (flag('json')) {
|
|
465
|
+
const out = { generatedAt: new Date().toISOString(), projectRoot: root,
|
|
466
|
+
checkouts: [...checkouts], pricesUpdated: PRICES.updated, totals, commands: rows };
|
|
467
|
+
if (flag('runs')) {
|
|
468
|
+
out.runs = segments.map((s) => ({
|
|
469
|
+
command: s.label, startedAt: new Date(s.startTs).toISOString(),
|
|
470
|
+
wallS: Math.max(0, (s.endTs - s.startTs) / 1000), activeS: s.activeS,
|
|
471
|
+
turns: s.turns, continuations: s.continuations, cost: s.cost, tokens: s.tokens,
|
|
472
|
+
agents: s.agents.map((a) => ({ type: a.agentType, description: a.description,
|
|
473
|
+
cost: a.cost, tokens: a.tokens, turns: a.turns })),
|
|
474
|
+
}));
|
|
475
|
+
}
|
|
476
|
+
process.stdout.write(JSON.stringify(out, null, 2) + '\n');
|
|
477
|
+
return 0;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (!sessions.length) {
|
|
481
|
+
console.log(`No Claude Code transcripts found for ${root} under ${projectsDir()}.`);
|
|
482
|
+
return 0;
|
|
483
|
+
}
|
|
484
|
+
console.log(`cohorte metrics — ${root}`);
|
|
485
|
+
console.log(`${totals.runs} runs across ${totals.sessions} sessions, ${totals.agents} subagents, ${fmtUsd(totals.cost)} total`
|
|
486
|
+
+ (since ? ` (since ${new Date(since).toISOString().slice(0, 10)})` : ''));
|
|
487
|
+
console.log(`prices as of ${PRICES.updated}; wall excludes nothing, active drops gaps > ${IDLE_GAP_S}s\n`);
|
|
488
|
+
console.log(table(rows));
|
|
489
|
+
|
|
490
|
+
const unpriced = [...new Set(rows.flatMap((r) => r.unpriced))];
|
|
491
|
+
if (unpriced.length) console.log(`\nnot in prices.json (counted, not costed): ${unpriced.join(', ')}`);
|
|
492
|
+
return 0;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
process.exit(main(process.argv));
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": [
|
|
3
|
+
"USD per million tokens, Anthropic first-party API rates. Bedrock/Vertex are",
|
|
4
|
+
"partner-priced and are NOT covered here — a run on those platforms will be",
|
|
5
|
+
"costed at first-party rates and slightly misreported.",
|
|
6
|
+
"Cache rates are derived from `input` via `multipliers` (write 1.25x for the 5m",
|
|
7
|
+
"TTL, 2x for 1h, read 0.1x) rather than restated per model, so a price change",
|
|
8
|
+
"is a one-line edit. `fast` is the fast-mode premium (usage.speed === 'fast').",
|
|
9
|
+
"Model lookup is longest-prefix, so dated ids (claude-haiku-4-5-20251001) hit",
|
|
10
|
+
"their base entry without needing a row of their own."
|
|
11
|
+
],
|
|
12
|
+
"updated": "2026-07-31",
|
|
13
|
+
"multipliers": {
|
|
14
|
+
"cacheWrite5m": 1.25,
|
|
15
|
+
"cacheWrite1h": 2.0,
|
|
16
|
+
"cacheRead": 0.1
|
|
17
|
+
},
|
|
18
|
+
"models": {
|
|
19
|
+
"claude-fable-5": { "input": 10, "output": 50 },
|
|
20
|
+
"claude-mythos-5": { "input": 10, "output": 50 },
|
|
21
|
+
"claude-opus-5": {
|
|
22
|
+
"input": 5,
|
|
23
|
+
"output": 25,
|
|
24
|
+
"fast": { "input": 10, "output": 50 }
|
|
25
|
+
},
|
|
26
|
+
"claude-opus-4-8": {
|
|
27
|
+
"input": 5,
|
|
28
|
+
"output": 25,
|
|
29
|
+
"fast": { "input": 10, "output": 50 }
|
|
30
|
+
},
|
|
31
|
+
"claude-opus-4-7": { "input": 5, "output": 25 },
|
|
32
|
+
"claude-opus-4-6": { "input": 5, "output": 25 },
|
|
33
|
+
"claude-opus-4-5": { "input": 5, "output": 25 },
|
|
34
|
+
"claude-sonnet-5": { "input": 3, "output": 15 },
|
|
35
|
+
"claude-sonnet-4-6": { "input": 3, "output": 15 },
|
|
36
|
+
"claude-sonnet-4-5": { "input": 3, "output": 15 },
|
|
37
|
+
"claude-haiku-4-5": { "input": 1, "output": 5 }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -116,8 +116,14 @@ console.log("doctor.js — the /doctor port");
|
|
|
116
116
|
writeFileSync(join(d, "specs", "b.md"), spec("feature_id: b\nstatus: shipped # done"));
|
|
117
117
|
writeFileSync(join(d, "specs", "c.md"), "no front-matter at all");
|
|
118
118
|
writeFileSync(join(d, "specs", "_template.md"), spec("status: draft"));
|
|
119
|
+
// /audit writes this file by design and it has no front-matter. Scanning it as a
|
|
120
|
+
// spec made /doctor warn about a file cohorte itself had just created — it fired in
|
|
121
|
+
// every project that had ever run /audit.
|
|
122
|
+
writeFileSync(join(d, "specs", "refactor-backlog.md"), "# Refactor Backlog\n\n## backend\n- [ ] x\n");
|
|
119
123
|
const specs = scanSpecs(d);
|
|
120
124
|
eq("_template.md is excluded", specs.length, 3);
|
|
125
|
+
eq("the /audit backlog is not scanned as a spec",
|
|
126
|
+
specs.some(s => s.file === "refactor-backlog.md"), false);
|
|
121
127
|
eq("front-matter fields are read", specs.find(s => s.id === "a").title, "A");
|
|
122
128
|
eq("a trailing comment is stripped from status", specs.find(s => s.id === "b").status, "shipped");
|
|
123
129
|
eq("no front-matter ⇒ id falls back to the filename", specs.find(s => s.file === "c.md").id, "c");
|
|
@@ -158,7 +164,7 @@ console.log("doctor.js — the /doctor port");
|
|
|
158
164
|
writeFileSync(join(d, ".claude", "gate-config.json"), JSON.stringify(gate));
|
|
159
165
|
writeFileSync(join(d, ".mcp.json"), JSON.stringify({ mcpServers: { serena: {} } }));
|
|
160
166
|
mkdirSync(join(d, ".claude", "workflows"), { recursive: true });
|
|
161
|
-
for (const w of ["review.js", "audit.js", "refactor.js"
|
|
167
|
+
for (const w of ["review.js", "audit.js", "refactor.js"]) {
|
|
162
168
|
writeFileSync(join(d, ".claude", "workflows", w), "x");
|
|
163
169
|
}
|
|
164
170
|
writeFileSync(join(d, ".claude", "agents", "profile-reader.md"), "x");
|