@ticket-gate/engine 0.0.0 → 0.1.0-rc.1

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 (92) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +82 -0
  3. package/bin/ticket-gate.mjs +261 -0
  4. package/checks/assertions-unguarded.mjs +31 -0
  5. package/checks/assertions.mjs +319 -0
  6. package/checks/cache-report.mjs +313 -0
  7. package/checks/docs-stale.mjs +69 -0
  8. package/checks/duplication.mjs +258 -0
  9. package/checks/ratchet-options.mjs +51 -0
  10. package/checks/surface.mjs +397 -0
  11. package/cli.mjs +379 -0
  12. package/commands-config.mjs +55 -0
  13. package/config.mjs +597 -0
  14. package/digest.mjs +195 -0
  15. package/env-name.mjs +24 -0
  16. package/failures.mjs +256 -0
  17. package/fu.mjs +458 -0
  18. package/graph/js.mjs +517 -0
  19. package/graph/load-time.mjs +309 -0
  20. package/graph/typescript.mjs +28 -0
  21. package/hooks/pre-tool-use.mjs +213 -0
  22. package/hooks/stop.mjs +495 -0
  23. package/is-main.mjs +15 -0
  24. package/kill-group.mjs +13 -0
  25. package/ledger.mjs +186 -0
  26. package/lock.d.mts +72 -0
  27. package/lock.mjs +329 -0
  28. package/move-proof.mjs +206 -0
  29. package/mutations/check.mjs +506 -0
  30. package/mutations/cli.mjs +361 -0
  31. package/mutations/exec.mjs +120 -0
  32. package/mutations/gate.mjs +148 -0
  33. package/mutations/harness.mjs +240 -0
  34. package/mutations/kinds.mjs +148 -0
  35. package/mutations/locate.mjs +71 -0
  36. package/mutations/project.mjs +23 -0
  37. package/mutations/registry.mjs +173 -0
  38. package/mutations/rotate.mjs +419 -0
  39. package/mutations/rotation-status.mjs +93 -0
  40. package/mutations/run.mjs +252 -0
  41. package/mutations/select.mjs +333 -0
  42. package/mutations/served.mjs +216 -0
  43. package/mutations/validate.mjs +86 -0
  44. package/mutations/verdict.mjs +159 -0
  45. package/nightly/cli.mjs +30 -0
  46. package/nightly/define.mjs +111 -0
  47. package/nightly/e2e-runs.mjs +73 -0
  48. package/nightly/gate.mjs +25 -0
  49. package/nightly/io.mjs +109 -0
  50. package/nightly/launchd.mjs +42 -0
  51. package/nightly/record.mjs +231 -0
  52. package/nightly/run.mjs +470 -0
  53. package/nightly/schedule.mjs +76 -0
  54. package/nightly/seed.mjs +78 -0
  55. package/nightly/status.mjs +189 -0
  56. package/nightly/suites.mjs +43 -0
  57. package/nightly-config.mjs +38 -0
  58. package/nightly-window.mjs +6 -0
  59. package/pack-hooks-all.mjs +15 -0
  60. package/pack-hooks.mjs +26 -0
  61. package/package.json +21 -1
  62. package/plan.mjs +272 -0
  63. package/precheck.mjs +90 -0
  64. package/project.mjs +21 -0
  65. package/ratchet.mjs +431 -0
  66. package/receipt.mjs +34 -0
  67. package/review-map.mjs +432 -0
  68. package/root.mjs +33 -0
  69. package/run.mjs +291 -0
  70. package/runners/playwright.mjs +132 -0
  71. package/score/adapters/stryker.mjs +111 -0
  72. package/score/base.mjs +82 -0
  73. package/score/cli.mjs +182 -0
  74. package/score/judge.mjs +107 -0
  75. package/score/lines.mjs +40 -0
  76. package/score/schema.mjs +68 -0
  77. package/score/survivors.mjs +103 -0
  78. package/score/targets.mjs +34 -0
  79. package/score-config.mjs +120 -0
  80. package/selection/check.mjs +45 -0
  81. package/selection/cli.mjs +267 -0
  82. package/selection/define.mjs +55 -0
  83. package/selection/for.mjs +235 -0
  84. package/selection/graphs.mjs +38 -0
  85. package/selection/project.mjs +11 -0
  86. package/selection/select.mjs +159 -0
  87. package/selection/units.mjs +646 -0
  88. package/selection/value-rules.mjs +142 -0
  89. package/selection-config.mjs +76 -0
  90. package/serve.mjs +142 -0
  91. package/summary.mjs +34 -0
  92. package/tree.mjs +211 -0
@@ -0,0 +1,319 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The assertion ratchet in engine shape: counts the configured needles per spec,
4
+ * sums them per area (a spec counts toward every area in its line-1 header), and compares with the
5
+ * configured baseline file. Every project value (paths, counters, areas, commands, messages) comes
6
+ * from the config passed to `assertions(cfg)`; this module names none.
7
+ *
8
+ * - A drop in an area fails unless the mutation registry covers it: the receipt is green and
9
+ * complete for this tree, and every dropped spec is named by the `from` of a live entry of that
10
+ * ticket (`unguarded` included).
11
+ * - Any other change (a rise, a new or changed spec) is `stale`; the update command fixes it.
12
+ * Never edit the baseline by hand.
13
+ */
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
15
+ import { dirname, join } from 'node:path';
16
+
17
+ import { listFiles, readText } from '../graph/js.mjs';
18
+ import {
19
+ entriesDigest,
20
+ entriesFor,
21
+ loadRegistry,
22
+ ticketTreeHash,
23
+ } from '../mutations/registry.mjs';
24
+
25
+ const count = (text, needle) => text.split(needle).length - 1;
26
+
27
+ /** @param {object} cfg the assertions config @param {string} root repo root */
28
+ function countSpecs(cfg, root) {
29
+ const { dir, match, header } = cfg.specs;
30
+ const out = {};
31
+ const files = listFiles(root, dir, (p) => match.test(p)).map((p) =>
32
+ p.slice(dir.length + 1),
33
+ );
34
+ for (const name of files) {
35
+ const text = readText(root, `${dir}/${name}`) ?? '';
36
+ const m = header.exec(text.split('\n', 1)[0].replace(/\r$/, ''));
37
+ const entry = { areas: m ? m[1] : '' };
38
+ for (const k of Object.keys(cfg.counters))
39
+ entry[k] = count(text, cfg.counters[k]);
40
+ out[name] = entry;
41
+ }
42
+ return out;
43
+ }
44
+
45
+ function areaTotals(cfg, specs) {
46
+ const out = {};
47
+ for (const a of cfg.areas)
48
+ out[a] = Object.fromEntries(Object.keys(cfg.counters).map((k) => [k, 0]));
49
+ for (const s of Object.values(specs)) {
50
+ for (const a of s.areas.split(', ')) {
51
+ if (!out[a]) continue;
52
+ for (const k of Object.keys(cfg.counters)) out[a][k] += s[k];
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+
58
+ function compare(cfg, baseline, specs) {
59
+ const prev = baseline.areas ?? {};
60
+ const nowAreas = areaTotals(cfg, specs);
61
+ const drops = [];
62
+ const rises = [];
63
+ for (const area of cfg.areas) {
64
+ for (const counter of Object.keys(cfg.counters)) {
65
+ const was = prev[area]?.[counter] ?? 0;
66
+ const now = nowAreas[area][counter];
67
+ if (now < was) drops.push({ area, counter, was, now });
68
+ if (now > was) rises.push({ area, counter, was, now });
69
+ }
70
+ }
71
+ const specChanges = [];
72
+ const old = baseline.specs ?? {};
73
+ for (const name of Object.keys({ ...old, ...specs }).sort()) {
74
+ if (JSON.stringify(old[name]) !== JSON.stringify(specs[name]))
75
+ specChanges.push(name);
76
+ }
77
+ return { drops, rises, specChanges };
78
+ }
79
+
80
+ /** Baseline specs in a dropped area that are gone or have a lower counter. */
81
+ function culpritsOf(cfg, baseline, specs, drops) {
82
+ const areas = new Set(drops.map((d) => d.area));
83
+ const out = [];
84
+ for (const [name, b] of Object.entries(baseline.specs ?? {})) {
85
+ if (!b.areas.split(', ').some((a) => areas.has(a))) continue;
86
+ const now = specs[name];
87
+ const lostArea = b.areas
88
+ .split(', ')
89
+ .some((a) => areas.has(a) && !now?.areas.split(', ').includes(a));
90
+ if (
91
+ !now ||
92
+ lostArea ||
93
+ Object.keys(cfg.counters).some((k) => now[k] < b[k])
94
+ )
95
+ out.push(`${cfg.from}${name}`);
96
+ }
97
+ return out;
98
+ }
99
+
100
+ function receiptProblem(cfg, receipt, io) {
101
+ if (receipt === null) return `no mutation receipt (${cfg.receipt})`;
102
+ if (receipt.ok !== true) return 'receipt not green';
103
+ if (receipt.partial !== false) return 'receipt is partial';
104
+ if (receipt.source !== 'registry') return 'receipt is from a --file run';
105
+ if (receipt.treeHash !== io.treeHash()) return 'receipt is for another tree';
106
+ const own = entriesFor(loadRegistry(io.root, io.setup), receipt.ticket);
107
+ if (entriesDigest(own) !== receipt.entriesDigest) return 'mutations changed';
108
+ return { own };
109
+ }
110
+
111
+ function coverage(cfg, baseline, specs, receipt, io) {
112
+ const { drops } = compare(cfg, baseline, specs);
113
+ const culprits = culpritsOf(cfg, baseline, specs, drops);
114
+ const r = receiptProblem(cfg, receipt, io);
115
+ if (typeof r === 'string')
116
+ return { covered: false, why: r, missing: culprits };
117
+ const named = new Set();
118
+ for (const [, e] of r.own) if (!e.retired && e.from) named.add(e.from);
119
+ const missing = culprits.filter((spec) => !named.has(spec));
120
+ if (missing.length > 0)
121
+ return {
122
+ covered: false,
123
+ why: 'no mutation names the dropped spec',
124
+ missing,
125
+ };
126
+ return { covered: true, why: null, missing: [] };
127
+ }
128
+
129
+ function readBaseline(path) {
130
+ if (!existsSync(path)) return { baseline: null };
131
+ try {
132
+ const b = JSON.parse(readFileSync(path, 'utf8'));
133
+ if (!b || typeof b !== 'object' || !b.areas || !b.specs)
134
+ throw new Error('missing areas or specs');
135
+ return { baseline: b };
136
+ } catch (e) {
137
+ return { error: e.message };
138
+ }
139
+ }
140
+
141
+ function readReceipt(path) {
142
+ try {
143
+ return JSON.parse(readFileSync(path, 'utf8'));
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ const dropLine = (d) => ` ${d.area} ${d.counter}: was ${d.was}, now ${d.now}`;
150
+
151
+ /**
152
+ * @param {object} cfg
153
+ * @param {string} root
154
+ * @param {{ baselinePath?: string, receiptPath?: string, treeHash?: () => string, update?: boolean }} [opts]
155
+ * @returns {{ code: 0 | 1 | 2, lines: string[], wrote: boolean }}
156
+ */
157
+ function checkE2eAssertions(cfg, root, opts = {}) {
158
+ const { name, commands } = cfg;
159
+ const baselinePath = opts.baselinePath ?? join(root, cfg.baseline);
160
+ const receiptPath = opts.receiptPath ?? join(root, cfg.receipt);
161
+ const io = {
162
+ root,
163
+ treeHash: opts.treeHash ?? (() => ticketTreeHash(cfg.setup)),
164
+ setup: cfg.setup,
165
+ };
166
+ const specs = countSpecs(cfg, root);
167
+ const write = () => {
168
+ const doc = {
169
+ comment: cfg.comment,
170
+ areas: areaTotals(cfg, specs),
171
+ specs,
172
+ };
173
+ mkdirSync(dirname(baselinePath), { recursive: true });
174
+ writeFileSync(baselinePath, `${JSON.stringify(doc, null, 2)}\n`);
175
+ return true;
176
+ };
177
+ const refuseUpdate = (cov) => ({
178
+ code: 1,
179
+ wrote: false,
180
+ lines: [
181
+ `${name}: --update refused, the drop is not covered: ${cov.why}`,
182
+ ...cov.missing.map((m) => ` missing: ${m}`),
183
+ `Run ${commands.mutate} green on this tree with a mutation from field (or "unguarded") for each spec above, then retry.`,
184
+ ],
185
+ });
186
+
187
+ const { baseline, error } = readBaseline(baselinePath);
188
+ if (error)
189
+ return {
190
+ code: 2,
191
+ wrote: false,
192
+ lines: [
193
+ `${name}: unreadable baseline ${baselinePath}: ${error}`,
194
+ `Restore it with git checkout -- ${cfg.baseline}, or delete it and run ${commands.update}.`,
195
+ ],
196
+ };
197
+ if (baseline === null) {
198
+ if (opts.update) {
199
+ write();
200
+ return {
201
+ code: 0,
202
+ wrote: true,
203
+ lines: [`${name}: baseline written (none existed)`],
204
+ };
205
+ }
206
+ return {
207
+ code: 2,
208
+ wrote: false,
209
+ lines: [
210
+ `${name}: no baseline at ${baselinePath}. Run ${commands.update}.`,
211
+ ],
212
+ };
213
+ }
214
+
215
+ const { drops, rises, specChanges } = compare(cfg, baseline, specs);
216
+ const equal =
217
+ drops.length === 0 && rises.length === 0 && specChanges.length === 0;
218
+ if (equal)
219
+ return {
220
+ code: 0,
221
+ wrote: false,
222
+ lines: [
223
+ `${name}: ${Object.keys(specs).length} specs, ${cfg.areas.length} areas match the baseline`,
224
+ ],
225
+ };
226
+
227
+ if (drops.length > 0) {
228
+ const cov = coverage(cfg, baseline, specs, readReceipt(receiptPath), io);
229
+ if (opts.update) {
230
+ if (!cov.covered) return refuseUpdate(cov);
231
+ write();
232
+ return {
233
+ code: 0,
234
+ wrote: true,
235
+ lines: [`${name}: baseline updated (drop covered)`],
236
+ };
237
+ }
238
+ if (cov.covered)
239
+ return {
240
+ code: 0,
241
+ wrote: false,
242
+ lines: [
243
+ `${name}: assertion drop`,
244
+ ...drops.map(dropLine),
245
+ `covered by the mutation receipt; run ${commands.update}`,
246
+ ],
247
+ };
248
+ return {
249
+ code: 1,
250
+ wrote: false,
251
+ lines: [
252
+ `${name}: assertion drop not covered`,
253
+ ...drops.map(dropLine),
254
+ `why: ${cov.why}`,
255
+ ...cov.missing.map((m) => ` missing: ${m}`),
256
+ `Fix: add a mutation with a from field (or an "unguarded" entry) for each missing spec in the registry (${cfg.registry}), run ${commands.mutate} green on this tree, then ${commands.update}.`,
257
+ ],
258
+ };
259
+ }
260
+
261
+ if (opts.update) {
262
+ write();
263
+ return {
264
+ code: 0,
265
+ wrote: true,
266
+ lines: [`${name}: baseline updated`],
267
+ };
268
+ }
269
+ return {
270
+ code: 1,
271
+ wrote: false,
272
+ lines: [
273
+ `${name}: stale baseline (assertions or specs changed with no drop)`,
274
+ ...rises.map(dropLine),
275
+ ...specChanges.map((n) => ` spec changed: ${n}`),
276
+ `Fix: ${commands.update} (no receipt needed), then commit ${cfg.baseline}.`,
277
+ ],
278
+ };
279
+ }
280
+
281
+ /**
282
+ * @param {object} cfg
283
+ * @param {{ root: string, args: string[] }} opts
284
+ * @returns {{ code: 0 | 1 | 2, lines: string[], out: string[], err: string[] }}
285
+ */
286
+ function checkArgs(cfg, { root, args }) {
287
+ const opts = { update: false };
288
+ let bad = null;
289
+ for (const a of args) {
290
+ if (a === '--update') opts.update = true;
291
+ else if (a.startsWith('--baseline=')) opts.baselinePath = a.slice(11);
292
+ else if (a.startsWith('--receipt=')) opts.receiptPath = a.slice(10);
293
+ else bad = a;
294
+ }
295
+ if (bad) {
296
+ const err = [
297
+ `${cfg.name}: unknown argument ${bad}. Usage: ${cfg.commands.usage} [--update] [--baseline=<path>] [--receipt=<path>]`,
298
+ ];
299
+ return { code: 2, lines: err, out: [], err };
300
+ }
301
+ const r = checkE2eAssertions(cfg, root, opts);
302
+ const out = r.code === 0 ? [r.lines.join('\n')] : [];
303
+ const err = r.code === 0 ? [] : [r.lines.join('\n')];
304
+ return { code: r.code, lines: [...out, ...err], out, err };
305
+ }
306
+
307
+ /** @param {AssertionsConfig} cfg @returns {Assertions} */
308
+ export function assertions(cfg) {
309
+ return {
310
+ COUNTERS: cfg.counters,
311
+ countSpecs: (root) => countSpecs(cfg, root),
312
+ areaTotals: (specs) => areaTotals(cfg, specs),
313
+ compare: (baseline, specs) => compare(cfg, baseline, specs),
314
+ coverage: (baseline, specs, receipt, io) =>
315
+ coverage(cfg, baseline, specs, receipt, io),
316
+ checkE2eAssertions: (root, opts) => checkE2eAssertions(cfg, root, opts),
317
+ check: (args) => checkArgs(cfg, args),
318
+ };
319
+ }
@@ -0,0 +1,313 @@
1
+ #!/usr/bin/env node
2
+ // Prompt-cache usage for one Claude Code session and every subagent it spawned,
3
+ // read from the transcripts Claude Code writes under ~/.claude/projects/<slug>/.
4
+ //
5
+ // cache-report latest session of this repo
6
+ // cache-report <session-id> that session
7
+ // cache-report <path.jsonl> a transcript anywhere
8
+ // add --json for machine-readable output
9
+ //
10
+ // Every cache write is put in one of three buckets:
11
+ // spawn the first request of an agent (system prompt, agent definition, task);
12
+ // growth the turn's new messages appended to an already-cached prefix;
13
+ // rebuild a request that read back less than half of the previous request's
14
+ // context, so it rewrote it: the cache expired (subagents get a
15
+ // 5-minute TTL) or the prefix changed. That is the waste worth chasing.
16
+ // Cost is in input-token units, priced relative to an uncached input token:
17
+ // a cache read is 0.1, a 5-minute write 1.25, a 1-hour write 2.
18
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import { basename, join } from 'node:path';
21
+
22
+ export const WEIGHT = { uncached: 1, write5m: 1.25, write1h: 2, read: 0.1 };
23
+ const REBUILD_SHARE = 0.5;
24
+
25
+ /** ~/.claude/projects/<slug> for a working directory: every non-alphanumeric becomes `-`. */
26
+ export function projectDir(cwd = process.cwd(), home = homedir()) {
27
+ return join(home, '.claude', 'projects', cwd.replace(/[^a-zA-Z0-9]/g, '-'));
28
+ }
29
+
30
+ /** Path of the most recently modified top-level session transcript in a project dir. */
31
+ export function latestSession(dir) {
32
+ const files = readdirSync(dir)
33
+ .filter((f) => f.endsWith('.jsonl'))
34
+ .map((f) => join(dir, f));
35
+ if (!files.length) throw new Error(`no session transcripts in ${dir}`);
36
+ return files.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];
37
+ }
38
+
39
+ /**
40
+ * Collapse a transcript into one usage record per API request. Streaming logs
41
+ * the same message id several times with a growing output count; the last copy
42
+ * holds the final usage, the first copy the time the request was answered.
43
+ */
44
+ export function requests(text) {
45
+ const byId = new Map();
46
+ for (const line of text.split('\n')) {
47
+ if (!line) continue;
48
+ let rec;
49
+ try {
50
+ rec = JSON.parse(line);
51
+ } catch {
52
+ continue;
53
+ }
54
+ const msg = rec.message;
55
+ if (msg?.role !== 'assistant' || !msg.usage) continue;
56
+ const id = msg.id ?? rec.uuid;
57
+ const prev = byId.get(id);
58
+ byId.set(id, {
59
+ at: prev?.at ?? Date.parse(rec.timestamp),
60
+ model: msg.model,
61
+ usage: msg.usage,
62
+ });
63
+ }
64
+ return [...byId.values()];
65
+ }
66
+
67
+ /** Token totals, write buckets and rebuild events for one agent's requests. */
68
+ export function summarize(reqs) {
69
+ const s = {
70
+ requests: reqs.length,
71
+ uncached: 0,
72
+ write5m: 0,
73
+ write1h: 0,
74
+ read: 0,
75
+ output: 0,
76
+ spawn: 0,
77
+ growth: 0,
78
+ rebuild: 0,
79
+ rebuilds: [],
80
+ models: new Set(),
81
+ };
82
+ let prevContext = 0;
83
+ let prevAt = NaN;
84
+ reqs.forEach((r, i) => {
85
+ const u = r.usage;
86
+ const write = u.cache_creation_input_tokens ?? 0;
87
+ const w1h = u.cache_creation?.ephemeral_1h_input_tokens ?? 0;
88
+ s.uncached += u.input_tokens ?? 0;
89
+ s.write1h += w1h;
90
+ s.write5m += write - w1h;
91
+ s.read += u.cache_read_input_tokens ?? 0;
92
+ s.output += u.output_tokens ?? 0;
93
+ if (r.model && r.model !== '<synthetic>') s.models.add(r.model);
94
+ if (i === 0) s.spawn += write;
95
+ else if ((u.cache_read_input_tokens ?? 0) < REBUILD_SHARE * prevContext) {
96
+ s.rebuild += write;
97
+ s.rebuilds.push({
98
+ tokens: write,
99
+ gapSec: Math.round((r.at - prevAt) / 1000),
100
+ });
101
+ } else s.growth += write;
102
+ prevContext =
103
+ (u.input_tokens ?? 0) + write + (u.cache_read_input_tokens ?? 0);
104
+ prevAt = r.at;
105
+ });
106
+ return s;
107
+ }
108
+
109
+ /** Input cost of a summary, in uncached-input-token units. */
110
+ export function cost(s) {
111
+ return (
112
+ s.uncached * WEIGHT.uncached +
113
+ s.write5m * WEIGHT.write5m +
114
+ s.write1h * WEIGHT.write1h +
115
+ s.read * WEIGHT.read
116
+ );
117
+ }
118
+
119
+ /** Cost of the rebuild writes alone, priced at the TTL each agent wrote at. */
120
+ export function rebuildCost(s) {
121
+ const writes = s.write5m + s.write1h;
122
+ if (!writes) return 0;
123
+ const rate =
124
+ (s.write5m * WEIGHT.write5m + s.write1h * WEIGHT.write1h) / writes;
125
+ return s.rebuild * rate;
126
+ }
127
+
128
+ function label(file) {
129
+ const meta = file.replace(/\.jsonl$/, '.meta.json');
130
+ if (existsSync(meta)) {
131
+ try {
132
+ const m = JSON.parse(readFileSync(meta, 'utf8'));
133
+ return [m.agentType, m.description].filter(Boolean).join(': ');
134
+ } catch {
135
+ // fall through to the file name
136
+ }
137
+ }
138
+ return basename(file, '.jsonl');
139
+ }
140
+
141
+ /** Every `subagents/workflows/<wfId>/agent-*.jsonl` (a review-round run's agents). */
142
+ function workflowAgents(subDir) {
143
+ const root = join(subDir, 'workflows');
144
+ if (!existsSync(root)) return [];
145
+ const out = [];
146
+ for (const workflow of readdirSync(root).sort()) {
147
+ const dir = join(root, workflow);
148
+ if (!statSync(dir).isDirectory()) continue;
149
+ for (const f of readdirSync(dir).sort())
150
+ if (/^agent-.*\.jsonl$/.test(f))
151
+ out.push({ file: join(dir, f), workflow });
152
+ }
153
+ return out;
154
+ }
155
+
156
+ /**
157
+ * One row per transcript: the session itself, each subagent in start order,
158
+ * then each workflow run's agents grouped under its wf id (groups by earliest
159
+ * start, rows within a group by start).
160
+ */
161
+ export function report(sessionFile) {
162
+ const files = [sessionFile];
163
+ const sub = join(sessionFile.replace(/\.jsonl$/, ''), 'subagents');
164
+ if (existsSync(sub))
165
+ for (const f of readdirSync(sub))
166
+ if (f.endsWith('.jsonl')) files.push(join(sub, f));
167
+ const rows = [];
168
+ for (const file of files) {
169
+ const reqs = requests(readFileSync(file, 'utf8'));
170
+ if (!reqs.length) continue;
171
+ const s = summarize(reqs);
172
+ rows.push({
173
+ agent: file === sessionFile ? 'orchestrator (session)' : label(file),
174
+ start: reqs[0].at,
175
+ ...s,
176
+ });
177
+ }
178
+ const [head, ...rest] = rows;
179
+ const ordered = head
180
+ ? [head, ...rest.sort((a, b) => a.start - b.start)]
181
+ : rest;
182
+ const wfFiles = workflowAgents(sub);
183
+ const groups = new Map();
184
+ for (const { file, workflow } of wfFiles) {
185
+ const reqs = requests(readFileSync(file, 'utf8'));
186
+ if (!reqs.length) continue;
187
+ const row = {
188
+ agent: `${workflow}/${label(file)}`,
189
+ workflow,
190
+ start: reqs[0].at,
191
+ ...summarize(reqs),
192
+ };
193
+ groups.set(workflow, [...(groups.get(workflow) ?? []), row]);
194
+ }
195
+ const sorted = [...groups.values()]
196
+ .map((g) => g.sort((a, b) => a.start - b.start))
197
+ .sort((a, b) => a[0].start - b[0].start);
198
+ return [...ordered, ...sorted.flat()];
199
+ }
200
+
201
+ const KEYS = [
202
+ 'requests',
203
+ 'uncached',
204
+ 'write5m',
205
+ 'write1h',
206
+ 'read',
207
+ 'output',
208
+ 'spawn',
209
+ 'growth',
210
+ 'rebuild',
211
+ ];
212
+
213
+ export function total(rows) {
214
+ const t = Object.fromEntries(KEYS.map((k) => [k, 0]));
215
+ t.rebuilds = [];
216
+ t.costUnits = 0;
217
+ t.rebuildUnits = 0;
218
+ for (const r of rows) {
219
+ for (const k of KEYS) t[k] += r[k];
220
+ t.rebuilds.push(...r.rebuilds);
221
+ t.costUnits += cost(r);
222
+ t.rebuildUnits += rebuildCost(r);
223
+ }
224
+ return t;
225
+ }
226
+
227
+ const k = (n) =>
228
+ n >= 1e6
229
+ ? `${(n / 1e6).toFixed(1)}M`
230
+ : n >= 1e3
231
+ ? `${Math.round(n / 1e3)}k`
232
+ : String(n);
233
+ const pct = (a, b) => (b ? `${((100 * a) / b).toFixed(1)}%` : '-');
234
+
235
+ export function format(rows, sessionFile) {
236
+ const out = [`cache report: ${sessionFile}`, ''];
237
+ const head = [
238
+ 'agent',
239
+ 'reqs',
240
+ 'hit%',
241
+ 'read',
242
+ 'write',
243
+ 'rebuild',
244
+ 'n',
245
+ 'max gap',
246
+ 'model',
247
+ ];
248
+ const lines = rows.map((r) => {
249
+ const input = r.uncached + r.write5m + r.write1h + r.read;
250
+ const maxGap = r.rebuilds.reduce((m, e) => Math.max(m, e.gapSec), 0);
251
+ return [
252
+ r.agent.slice(0, 40),
253
+ String(r.requests),
254
+ pct(r.read, input),
255
+ k(r.read),
256
+ k(r.write5m + r.write1h),
257
+ k(r.rebuild),
258
+ String(r.rebuilds.length),
259
+ maxGap ? `${maxGap}s` : '-',
260
+ [...r.models].map((m) => m.replace(/^claude-/, '')).join(','),
261
+ ];
262
+ });
263
+ const width = head.map((h, i) =>
264
+ Math.max(h.length, ...lines.map((l) => l[i].length)),
265
+ );
266
+ const row = (cells) =>
267
+ cells
268
+ .map((c, i) =>
269
+ i === 0 || i === 8 ? c.padEnd(width[i]) : c.padStart(width[i]),
270
+ )
271
+ .join(' ');
272
+ out.push(row(head), ...lines.map(row), '');
273
+ const t = total(rows);
274
+ const writes = t.write5m + t.write1h;
275
+ out.push(
276
+ `agents ${rows.length}, requests ${t.requests}, hit rate ${pct(t.read, t.uncached + writes + t.read)}`,
277
+ `writes ${k(writes)}: spawn ${k(t.spawn)} (${pct(t.spawn, writes)}), growth ${k(t.growth)} (${pct(t.growth, writes)}), rebuild ${k(t.rebuild)} (${pct(t.rebuild, writes)}) in ${t.rebuilds.length} events`,
278
+ `input cost ${k(Math.round(t.costUnits))} units; rebuilds ${pct(t.rebuildUnits, t.costUnits)} of it`,
279
+ );
280
+ return out.join('\n');
281
+ }
282
+
283
+ /**
284
+ * The report for the CLI arguments: { code, lines }. code 0 prints lines to stdout, code 1 (no
285
+ * transcript) one line to stderr. The caller sets the exit code; this never exits.
286
+ */
287
+ export function runCacheReport({ root, args }) {
288
+ const json = args.includes('--json');
289
+ const target = args.find((a) => !a.startsWith('--'));
290
+ const dir = projectDir(root);
291
+ const file = !target
292
+ ? latestSession(dir)
293
+ : target.endsWith('.jsonl')
294
+ ? target
295
+ : join(dir, `${target}.jsonl`);
296
+ if (!existsSync(file))
297
+ return { code: 1, lines: [`cache-report: no transcript at ${file}`] };
298
+ const rows = report(file);
299
+ if (json) {
300
+ const plain = rows.map((r) => ({ ...r, models: [...r.models] }));
301
+ return {
302
+ code: 0,
303
+ lines: [
304
+ JSON.stringify(
305
+ { session: file, rows: plain, total: total(rows) },
306
+ null,
307
+ 2,
308
+ ),
309
+ ],
310
+ };
311
+ }
312
+ return { code: 0, lines: [format(rows, file)] };
313
+ }
@@ -0,0 +1,69 @@
1
+ // Which pages of a docs folder describe code that has changed since they were last checked. Each
2
+ // page starts with a comment:
3
+ //
4
+ // guide-sources: <path> <path> ...
5
+ // checked-at: <commit>
6
+ //
7
+ // docsStale() lists, per page, the files under its sources that changed between `checked-at` and
8
+ // HEAD. Pages with no changes are listed as current. Read-only; it never edits a page.
9
+ //
10
+ // docsStale({ root, dirs, ext, exclude }) -> { code: 0, lines }. Pages are the files of each dir
11
+ // ending ext and not in exclude, labelled relative to dirs[0] and sorted. git runs in root.
12
+ import { execFileSync } from 'node:child_process';
13
+ import { readdirSync, readFileSync } from 'node:fs';
14
+ import { join, posix, relative } from 'node:path';
15
+
16
+ export function docsStale({
17
+ root,
18
+ dirs,
19
+ ext = '.html',
20
+ exclude = ['index.html'],
21
+ }) {
22
+ const git = (args) =>
23
+ execFileSync('git', args, { encoding: 'utf8', cwd: root }).trim();
24
+
25
+ function readStamp(file) {
26
+ const head = readFileSync(join(root, dirs[0], file), 'utf8').slice(0, 2000);
27
+ const sources = /guide-sources:\s*([^\n]+)/
28
+ .exec(head)?.[1]
29
+ .trim()
30
+ .split(/\s+/);
31
+ const checkedAt = /checked-at:\s*(\S+)/.exec(head)?.[1];
32
+ return { sources, checkedAt };
33
+ }
34
+
35
+ // The top-level pages first, then each further dir's pages (labelled with their sub path).
36
+ const pages = dirs.flatMap((dir) => {
37
+ const sub = relative(dirs[0], dir).split('\\').join('/');
38
+ const prefix = sub === '' ? '' : `${sub}/`;
39
+ return readdirSync(join(root, dir))
40
+ .filter((f) => f.endsWith(ext) && !exclude.includes(f))
41
+ .map((f) => posix.join(prefix, f));
42
+ });
43
+ const lines = [];
44
+ let stale = 0;
45
+ for (const page of pages.sort()) {
46
+ const { sources, checkedAt } = readStamp(page);
47
+ if (!sources || !checkedAt) {
48
+ lines.push(`${page}: no guide-sources / checked-at stamp`);
49
+ stale += 1;
50
+ continue;
51
+ }
52
+ const changed = git([
53
+ 'diff',
54
+ '--stat=120',
55
+ `${checkedAt}..HEAD`,
56
+ '--',
57
+ ...sources,
58
+ ]);
59
+ if (changed === '') {
60
+ lines.push(`${page}: current (checked at ${checkedAt})`);
61
+ } else {
62
+ stale += 1;
63
+ lines.push(`${page}: STALE since ${checkedAt}`);
64
+ lines.push(changed.replace(/^/gm, ' '));
65
+ }
66
+ }
67
+ lines.push('', `${stale} of ${pages.length} pages to review`);
68
+ return { code: 0, lines };
69
+ }