@ngockhoale/ukit 3.4.24 → 3.4.25

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 CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 3.4.25 - 2026-10-08
6
+
7
+ **P0 hotfix: Stop hook no longer force-continues a run the user paused or cancelled.**
8
+ - `stop-coordinator`: a genuine user pause/cancel turn ("pause", "cancel", "tạm dừng", "dừng lại", ...) now outranks every Stop lane (completion gate, task watchdog, handoff cursor). The stop is released with a visible note and run state (RUN.md, tasks, branches, worktrees) is left untouched. The signal is read only from real user turns in the host transcript, never from agent text, tool output, sub-agent reports or hook feedback; a later resume turn wins; an unreadable or over-budget scan means "no interruption", so enforcement is never relaxed by a lost read.
9
+ - Handoff cursor reentrancy valve: when `stop_hook_active` is set and the same cursor signature repeats, the stop is released once with an advisory instead of waiting for the stalled-block cap. A cursor that moved keeps blocking.
10
+ - Regression tests: `tests/hooks/stopHookInterruption.test.js` (15 cases).
11
+ - Documentation registry: `docs/analysis/SUBAGENT-COST-AUDIT.md` (C99) is now classified in `manifests/documentation.yaml`; 3.4.24's consistency suite flagged it as an unregistered/orphan doc.
12
+ - Not included: no change to evidence/ledger accounting or model routing.
13
+
5
14
  ## 3.4.24 - 2026-10-08
6
15
 
7
16
  **Honest per-category token usage record + report** (C98; capability only — no producer exists yet, so real stores report `no_data`).
@@ -405,6 +405,17 @@ entries:
405
405
  load_policy: never
406
406
  validation: [none]
407
407
  archive_policy: immutable
408
+ - id: docs-analysis-subagent-cost-audit
409
+ path: docs/analysis/SUBAGENT-COST-AUDIT.md
410
+ class: archive
411
+ audience: [maintainer]
412
+ owner: product
413
+ source_of_truth: docs/analysis/SUBAGENT-COST-AUDIT.md
414
+ merge_strategy: none
415
+ load_policy: never
416
+ validation: [none]
417
+ archive_policy: immutable
418
+ notes: C99 measurement-only sub-agent cost audit snapshot; registered in 3.4.25 (was unclassified on 3.4.24)
408
419
  - id: docs-skill-audits
409
420
  path: docs/skill-audits/
410
421
  class: archive
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.24",
3
+ "version": "3.4.25",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,107 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { loadCorpus } from './reader.mjs';
5
+ import { computeMetrics } from './metrics.mjs';
6
+ import { computeDuplicates } from './duplicates.mjs';
7
+ import { renderMarkdown } from './render.mjs';
8
+
9
+ const MISSING = [
10
+ 'Provider/gateway id is not recorded in transcript usage.',
11
+ 'Native request latency is unavailable; timestamp gaps are only approximate.',
12
+ 'Retry counts are not recorded as a distinct telemetry field.',
13
+ 'Per-sub-agent request-shape cost is unavailable.',
14
+ 'Price table is absent; currency cost cannot be calculated.',
15
+ 'Codex and omp transcripts are not included by this Claude Code reader.',
16
+ 'Observability token categories are unavailable.',
17
+ ];
18
+
19
+ export async function buildReport(root, opts = {}) {
20
+ const corpus = await loadCorpus(root, { since: opts.since ?? null, until: opts.until ?? null });
21
+ // Wave-1 reader predates the normalized call.depth field; derive it without
22
+ // changing that shared module, while honoring depth when it is already present.
23
+ const depthByAgent = new Map(corpus.agents.map((agent) => [`${agent.sessionId}\u0000${agent.agentId}`, agent.depth]));
24
+ for (const call of corpus.calls) {
25
+ if (call.depth == null) call.depth = call.agentId == null ? 0 : (depthByAgent.get(`${call.sessionId}\u0000${call.agentId}`) ?? 1);
26
+ }
27
+ const metrics = computeMetrics(corpus);
28
+ const duplicates = computeDuplicates(corpus);
29
+ const headline = {
30
+ subShare: metrics.share.subOfInputSide,
31
+ subUncachedShare: metrics.share.subOfUncached,
32
+ contextAmplification: duplicates.amplification,
33
+ duplicateContextRatio: duplicates.duplicateRatio,
34
+ avgCallsPerTask: metrics.perTask.avgCalls,
35
+ };
36
+ return {
37
+ schema: 'subagent-cost/v1',
38
+ generatedAt: new Date().toISOString(),
39
+ root: path.basename(path.resolve(root)),
40
+ window: { since: opts.since ?? null, until: opts.until ?? null },
41
+ stats: corpus.stats,
42
+ headline,
43
+ metrics,
44
+ duplicates,
45
+ missing: [...MISSING],
46
+ };
47
+ }
48
+
49
+ function parseArgs(args) {
50
+ const opts = {};
51
+ const allowed = new Set(['--root', '--since', '--until', '--json', '--md']);
52
+ for (let i = 0; i < args.length; i += 1) {
53
+ const flag = args[i];
54
+ if (!allowed.has(flag)) throw new Error(`bad flag: ${flag}`);
55
+ const value = args[i + 1];
56
+ if (!value || value.startsWith('--')) throw new Error(`missing value for ${flag}`);
57
+ if (opts[flag]) throw new Error(`duplicate flag: ${flag}`);
58
+ opts[flag] = value;
59
+ i += 1;
60
+ }
61
+ if (!opts['--root']) throw new Error('missing required --root');
62
+ for (const flag of ['--since', '--until']) {
63
+ if (opts[flag] && !Number.isFinite(Date.parse(opts[flag]))) throw new Error(`invalid ISO date for ${flag}: ${opts[flag]}`);
64
+ }
65
+ if (opts['--since'] && opts['--until'] && Date.parse(opts['--since']) > Date.parse(opts['--until'])) throw new Error('--since must be <= --until');
66
+ return opts;
67
+ }
68
+
69
+ async function main(args) {
70
+ let opts;
71
+ try {
72
+ opts = parseArgs(args);
73
+ } catch (error) {
74
+ process.stderr.write(`${error.message}\n`);
75
+ return 2;
76
+ }
77
+ let report;
78
+ try {
79
+ report = await buildReport(opts['--root'], { since: opts['--since'] ?? null, until: opts['--until'] ?? null });
80
+ } catch (error) {
81
+ process.stderr.write(`${error.message}\n`);
82
+ return 2;
83
+ }
84
+ const markdown = renderMarkdown(report);
85
+ try {
86
+ if (opts['--json']) {
87
+ fs.mkdirSync(path.dirname(path.resolve(opts['--json'])), { recursive: true });
88
+ fs.writeFileSync(opts['--json'], `${JSON.stringify(report, null, 2)}\n`);
89
+ }
90
+ if (opts['--md']) {
91
+ fs.mkdirSync(path.dirname(path.resolve(opts['--md'])), { recursive: true });
92
+ fs.writeFileSync(opts['--md'], markdown);
93
+ }
94
+ } catch (error) {
95
+ process.stderr.write(`${error.message}\n`);
96
+ return 2;
97
+ }
98
+ process.stdout.write(`subagent-cost: ${report.metrics.totals.main.calls + report.metrics.totals.sub.calls} calls, ${report.stats.files} files, headline subShare=${report.headline.subShare}\n`);
99
+ if (opts['--json']) process.stdout.write(`json: ${path.resolve(opts['--json'])}\n`);
100
+ if (opts['--md']) process.stdout.write(`md: ${path.resolve(opts['--md'])}\n`);
101
+ return 0;
102
+ }
103
+
104
+ const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : null;
105
+ if (invokedPath === fileURLToPath(import.meta.url)) {
106
+ process.exitCode = await main(process.argv.slice(2));
107
+ }
@@ -0,0 +1,91 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { realpathSync } from 'node:fs';
3
+ import { pathToFileURL } from 'node:url';
4
+
5
+ const HEADLINE_KEYS = ['subShare', 'subUncachedShare', 'contextAmplification', 'duplicateContextRatio', 'avgCallsPerTask'];
6
+ const MARKER = /<!--\s*HEADLINE\s+(\{[^\n]*\})\s*-->/;
7
+
8
+ function parseArgs(args) {
9
+ const options = {};
10
+ for (let i = 0; i < args.length; i += 1) {
11
+ const flag = args[i];
12
+ if (!['--doc', '--report'].includes(flag) || !args[i + 1] || args[i + 1].startsWith('--')) {
13
+ throw new Error('usage: check-doc.mjs --doc <md> --report <json>');
14
+ }
15
+ if (options[flag]) throw new Error(`duplicate flag: ${flag}`);
16
+ options[flag] = args[++i];
17
+ }
18
+ if (!options['--doc'] || !options['--report']) throw new Error('usage: check-doc.mjs --doc <md> --report <json>');
19
+ return options;
20
+ }
21
+
22
+ async function checkDoc(docPath, reportPath) {
23
+ let doc;
24
+ let reportText;
25
+ try {
26
+ [doc, reportText] = await Promise.all([readFile(docPath, 'utf8'), readFile(reportPath, 'utf8')]);
27
+ } catch (error) {
28
+ console.error(`check-doc: ${error.message}`);
29
+ return 2;
30
+ }
31
+
32
+ let report;
33
+ try {
34
+ report = JSON.parse(reportText);
35
+ } catch {
36
+ console.error('check-doc: report is not valid JSON');
37
+ return 2;
38
+ }
39
+ const marker = doc.match(MARKER);
40
+ if (!marker) {
41
+ console.error('check-doc: missing HEADLINE marker');
42
+ return 2;
43
+ }
44
+ let embedded;
45
+ try {
46
+ embedded = JSON.parse(marker[1]);
47
+ } catch {
48
+ console.error('check-doc: invalid HEADLINE JSON');
49
+ return 2;
50
+ }
51
+
52
+ const mismatches = [];
53
+ const headline = report?.headline;
54
+ for (const key of HEADLINE_KEYS) {
55
+ const actual = embedded[key];
56
+ const expected = headline?.[key];
57
+ if (actual === null || expected === null) {
58
+ if (actual !== expected) mismatches.push(key);
59
+ } else if (typeof actual !== 'number' || !Number.isFinite(actual) || typeof expected !== 'number' || !Number.isFinite(expected) || Math.abs(actual - expected) > 0.001 * Math.max(1, Math.abs(actual), Math.abs(expected))) {
60
+ mismatches.push(key);
61
+ }
62
+ }
63
+
64
+ for (let section = 1; section <= 17; section += 1) {
65
+ if (!new RegExp(`^## ${section}\\.`, 'm').test(doc)) mismatches.push(`## ${section}.`);
66
+ }
67
+ for (let question = 1; question <= 16; question += 1) {
68
+ if (!new RegExp(`\\bQ${question}\\b`).test(doc)) mismatches.push(`Q${question}`);
69
+ }
70
+
71
+ if (mismatches.length) {
72
+ console.error(`check-doc: mismatch: ${[...new Set(mismatches)].join(', ')}`);
73
+ return 1;
74
+ }
75
+ console.log('check-doc: headline and required markers match');
76
+ return 0;
77
+ }
78
+
79
+ async function main() {
80
+ let options;
81
+ try {
82
+ options = parseArgs(process.argv.slice(2));
83
+ } catch (error) {
84
+ console.error(`check-doc: ${error.message}`);
85
+ process.exitCode = 2;
86
+ return;
87
+ }
88
+ process.exitCode = await checkDoc(options['--doc'], options['--report']);
89
+ }
90
+
91
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) await main();
@@ -0,0 +1,195 @@
1
+ const HEAVY_INPUT_SIDE_MIN = 250_000;
2
+ const HEAVY_OUTPUT_MAX = 2_000;
3
+ const MIN_HEAVY_CALLS_FOR_VERDICT = 10;
4
+ const TOP_LIMIT = 20;
5
+
6
+ const isFiniteNumber = Number.isFinite;
7
+ const inputSide = (call) => (isFiniteNumber(call.input) ? call.input : 0)
8
+ + (isFiniteNumber(call.cacheRead) ? call.cacheRead : 0)
9
+ + (isFiniteNumber(call.cacheWrite) ? call.cacheWrite : 0);
10
+ const ratio = (numerator, denominator) => denominator > 0 ? numerator / denominator : null;
11
+ const agentType = (agent) => agent.agentType ?? 'unknown';
12
+ const sortText = (a, b) => String(a) < String(b) ? -1 : (String(a) > String(b) ? 1 : 0);
13
+
14
+ function getAgents(corpus) {
15
+ return Array.isArray(corpus?.agents) ? corpus.agents.filter((agent) => agent && typeof agent === 'object') : [];
16
+ }
17
+
18
+ function getCalls(corpus) {
19
+ return Array.isArray(corpus?.calls) ? corpus.calls.filter((call) => call && typeof call === 'object') : [];
20
+ }
21
+
22
+ function collectSegments(agents) {
23
+ const byHash = new Map();
24
+ for (const agent of agents) {
25
+ if (!Array.isArray(agent.segments)) continue;
26
+ for (const segment of agent.segments) {
27
+ if (!segment || typeof segment !== 'object' || segment.h == null
28
+ || !isFiniteNumber(segment.t) || segment.t < 0) continue;
29
+ if (!byHash.has(segment.h)) byHash.set(segment.h, []);
30
+ byHash.get(segment.h).push({
31
+ h: segment.h,
32
+ t: segment.t,
33
+ turnId: agent.turnId,
34
+ agentId: agent.agentId,
35
+ agentType: agentType(agent),
36
+ });
37
+ }
38
+ }
39
+ return byHash;
40
+ }
41
+
42
+ function repeatedOccurrences(occurrences) {
43
+ // A segment can occur more than once inside an agent prompt. Repetition is
44
+ // measured between agents, so retain that agent's first occurrence only.
45
+ const firstByAgent = new Map();
46
+ for (const occurrence of occurrences) {
47
+ if (occurrence.agentId == null || firstByAgent.has(occurrence.agentId)) continue;
48
+ firstByAgent.set(occurrence.agentId, occurrence);
49
+ }
50
+ const distinctAgents = [...firstByAgent.values()];
51
+ if (distinctAgents.length < 2) return null;
52
+ return distinctAgents;
53
+ }
54
+
55
+ function duplicateBreakdown(occurrences) {
56
+ const agents = repeatedOccurrences(occurrences);
57
+ if (!agents) return null;
58
+
59
+ let withinTokens = 0;
60
+ let crossTokens = 0;
61
+ const first = agents[0];
62
+ for (const occurrence of agents.slice(1)) {
63
+ if (occurrence.turnId === first.turnId) withinTokens += occurrence.t;
64
+ else crossTokens += occurrence.t;
65
+ }
66
+ return {
67
+ withinTokens,
68
+ crossTokens,
69
+ count: agents.length,
70
+ displayTokens: first.t,
71
+ duplicateTokens: withinTokens + crossTokens,
72
+ };
73
+ }
74
+
75
+ function computeAmplification(corpus, agents, calls) {
76
+ const agentsById = new Map(agents.filter((agent) => agent.agentId != null)
77
+ .map((agent) => [agent.agentId, agent]));
78
+ const inputByType = new Map();
79
+ for (const call of calls) {
80
+ const agent = agentsById.get(call.agentId);
81
+ if (!agent || call.zero || (call.depth ?? agent.depth ?? 1) <= 0) continue;
82
+ const type = agentType(agent);
83
+ inputByType.set(type, (inputByType.get(type) ?? 0) + inputSide(call));
84
+ }
85
+
86
+ const promptsByType = new Map();
87
+ for (const agent of agents) {
88
+ if (agent.agentId == null || (agent.depth ?? 1) <= 0
89
+ || !isFiniteNumber(agent.promptTokensEst) || agent.promptTokensEst < 0) continue;
90
+ const type = agentType(agent);
91
+ promptsByType.set(type, (promptsByType.get(type) ?? 0) + agent.promptTokensEst);
92
+ }
93
+
94
+ const inputTotal = [...inputByType.values()].reduce((sum, value) => sum + value, 0);
95
+ const promptTotal = [...promptsByType.values()].reduce((sum, value) => sum + value, 0);
96
+ const types = [...new Set([...inputByType.keys(), ...promptsByType.keys()])].sort(sortText);
97
+ return {
98
+ overall: promptTotal === 0 ? null : inputTotal / promptTotal,
99
+ byType: Object.fromEntries(types.map((type) => [
100
+ type,
101
+ (promptsByType.get(type) ?? 0) === 0 ? null : (inputByType.get(type) ?? 0) / promptsByType.get(type),
102
+ ])),
103
+ };
104
+ }
105
+
106
+ function summarizeSegments(byHash) {
107
+ let totalTokens = 0;
108
+ let duplicateTokens = 0;
109
+ let withinTokens = 0;
110
+ let crossTokens = 0;
111
+ const repeated = [];
112
+
113
+ for (const [hash, occurrences] of byHash) {
114
+ totalTokens += occurrences.reduce((sum, item) => sum + item.t, 0);
115
+ const breakdown = duplicateBreakdown(occurrences);
116
+ if (!breakdown) continue;
117
+ duplicateTokens += breakdown.duplicateTokens;
118
+ withinTokens += breakdown.withinTokens;
119
+ crossTokens += breakdown.crossTokens;
120
+ repeated.push({
121
+ h: hash,
122
+ t: breakdown.displayTokens,
123
+ count: breakdown.count,
124
+ agentTypes: [...new Set(occurrences.filter((item) => item.agentId != null)
125
+ .map((item) => item.agentType))].sort(sortText),
126
+ duplicateTokens: breakdown.duplicateTokens,
127
+ });
128
+ }
129
+
130
+ repeated.sort((a, b) => b.duplicateTokens - a.duplicateTokens || sortText(a.h, b.h));
131
+ return {
132
+ totalTokens,
133
+ duplicateTokens,
134
+ withinTokens,
135
+ crossTokens,
136
+ topRepeatedSegments: repeated.slice(0, TOP_LIMIT).map(({ duplicateTokens: _tokens, ...segment }) => segment),
137
+ };
138
+ }
139
+
140
+ function computeReplayHypothesis(calls, agents, byHash) {
141
+ const agentsById = new Map(agents.filter((agent) => agent.agentId != null)
142
+ .map((agent) => [agent.agentId, agent]));
143
+ const heavyCalls = calls.filter((call) => {
144
+ const agent = agentsById.get(call.agentId);
145
+ if (!agent || call.zero || (call.depth ?? agent.depth ?? 1) <= 0) return false;
146
+ return inputSide(call) >= HEAVY_INPUT_SIDE_MIN
147
+ && isFiniteNumber(call.output) && call.output <= HEAVY_OUTPUT_MAX;
148
+ });
149
+ const heavyTurns = new Set(heavyCalls.map((call) => call.turnId).filter((turnId) => turnId != null));
150
+ const sharedHeavyTurns = new Set();
151
+
152
+ for (const occurrences of byHash.values()) {
153
+ const agentIdsByTurn = new Map();
154
+ for (const occurrence of repeatedOccurrences(occurrences) ?? []) {
155
+ if (occurrence.turnId == null || !heavyTurns.has(occurrence.turnId)) continue;
156
+ if (!agentIdsByTurn.has(occurrence.turnId)) agentIdsByTurn.set(occurrence.turnId, new Set());
157
+ agentIdsByTurn.get(occurrence.turnId).add(occurrence.agentId);
158
+ }
159
+ for (const [turnId, ids] of agentIdsByTurn) if (ids.size >= 2) sharedHeavyTurns.add(turnId);
160
+ }
161
+
162
+ return {
163
+ verdict: heavyCalls.length < MIN_HEAVY_CALLS_FOR_VERDICT
164
+ ? 'inconclusive'
165
+ : (sharedHeavyTurns.size > 0 ? 'supported' : 'not_supported'),
166
+ heavyCalls: heavyCalls.length,
167
+ heavyTurns: heavyTurns.size,
168
+ sharedHeavyTurns: sharedHeavyTurns.size,
169
+ share: ratio(sharedHeavyTurns.size, heavyTurns.size),
170
+ };
171
+ }
172
+
173
+ export function computeDuplicates(corpus) {
174
+ const agents = getAgents(corpus);
175
+ const calls = getCalls(corpus);
176
+ const byHash = collectSegments(agents);
177
+ const segments = summarizeSegments(byHash);
178
+ const amplification = computeAmplification(corpus, agents, calls);
179
+
180
+ return {
181
+ amplification: amplification.overall,
182
+ amplificationByType: amplification.byType,
183
+ duplicateRatio: ratio(segments.duplicateTokens, segments.totalTokens),
184
+ withinTurn: {
185
+ tokens: segments.withinTokens,
186
+ ratio: ratio(segments.withinTokens, segments.totalTokens),
187
+ },
188
+ crossTurn: {
189
+ tokens: segments.crossTokens,
190
+ ratio: ratio(segments.crossTokens, segments.totalTokens),
191
+ },
192
+ topRepeatedSegments: segments.topRepeatedSegments,
193
+ replayHypothesis: computeReplayHypothesis(calls, agents, byHash),
194
+ };
195
+ }
@@ -0,0 +1,181 @@
1
+ const N_PLUS_ONE_MIN_AGENTS = 3;
2
+ const N_PLUS_ONE_MAX_PROMPT_TOKENS = 2_000;
3
+ const RETRY_MIN_OCCURRENCES = 2;
4
+ const LOOP_MIN_OCCURRENCES = 5;
5
+ const HEAVY_INPUT_SIDE_MIN = 250_000;
6
+ const HEAVY_OUTPUT_MAX = 2_000;
7
+ const TOP_LIMIT = 20;
8
+
9
+ export function percentile(values, p) {
10
+ if (!Array.isArray(values) || values.length === 0) return null;
11
+ const sorted = values.filter(Number.isFinite).sort((a, b) => a - b);
12
+ if (sorted.length === 0) return null;
13
+ const rank = Math.max(1, Math.ceil(p * sorted.length));
14
+ return sorted[rank - 1];
15
+ }
16
+
17
+ const sum = (items, field) => items.reduce((total, item) => total + (Number.isFinite(item[field]) ? item[field] : 0), 0);
18
+ const ratio = (n, d) => d > 0 ? n / d : null;
19
+ const inputSide = (call) => (call.input ?? 0) + (call.cacheRead ?? 0) + (call.cacheWrite ?? 0);
20
+ const uncached = (call) => (call.input ?? 0) + (call.cacheWrite ?? 0);
21
+ const callTokens = (call) => inputSide(call) + (call.output ?? 0);
22
+ const validCalls = (corpus) => (corpus.calls ?? []).filter((call) => !call.zero);
23
+ const grouped = (items, keyFn) => {
24
+ const result = new Map();
25
+ for (const item of items) {
26
+ const key = keyFn(item);
27
+ if (key == null) continue;
28
+ if (!result.has(key)) result.set(key, []);
29
+ result.get(key).push(item);
30
+ }
31
+ return result;
32
+ };
33
+ const totalFor = (calls) => ({
34
+ calls: calls.length,
35
+ input: sum(calls, 'input'),
36
+ cacheRead: sum(calls, 'cacheRead'),
37
+ cacheWrite: sum(calls, 'cacheWrite'),
38
+ output: sum(calls, 'output'),
39
+ uncached: calls.reduce((n, c) => n + uncached(c), 0),
40
+ inputSide: calls.reduce((n, c) => n + inputSide(c), 0),
41
+ });
42
+ const cacheFor = (calls) => {
43
+ const totals = totalFor(calls);
44
+ return { hitRate: ratio(totals.cacheRead, totals.inputSide), cacheRead: totals.cacheRead, inputSide: totals.inputSide };
45
+ };
46
+ const sortedTop = (items, getValue, getKey) => [...items]
47
+ .map((item) => ({ ...item, uncached: getValue(item), inputSide: getKey(item) }))
48
+ .sort((a, b) => b.uncached - a.uncached || b.inputSide - a.inputSide || String(a.agentId ?? a.turnId).localeCompare(String(b.agentId ?? b.turnId)))
49
+ .slice(0, TOP_LIMIT);
50
+
51
+ function callPercentiles(calls, selector) {
52
+ return {
53
+ p50: percentile(calls.map(selector), 0.5),
54
+ p90: percentile(calls.map(selector), 0.9),
55
+ p99: percentile(calls.map(selector), 0.99),
56
+ };
57
+ }
58
+
59
+ function computeTasks(corpus, calls) {
60
+ const tasks = new Map();
61
+ for (const turn of corpus.turns ?? []) tasks.set(turn.turnId, { turnId: turn.turnId, calls: [] });
62
+ for (const call of calls) {
63
+ const key = call.turnId ?? `${call.sessionId}#unattributed`;
64
+ if (!tasks.has(key)) tasks.set(key, { turnId: key, calls: [] });
65
+ tasks.get(key).calls.push(call);
66
+ }
67
+ const rows = [...tasks.values()];
68
+ const subAgentCounts = rows.map((task) => new Set((corpus.agents ?? []).filter((a) => a.turnId === task.turnId).map((a) => a.agentId)).size);
69
+ return {
70
+ tasks: rows.length,
71
+ avgSubAgents: ratio(subAgentCounts.reduce((a, b) => a + b, 0), rows.length),
72
+ avgCalls: ratio(rows.reduce((n, task) => n + task.calls.length, 0), rows.length),
73
+ ...Object.fromEntries(['Calls', 'Tokens'].flatMap((label) => {
74
+ const values = rows.map((task) => label === 'Calls' ? task.calls.length : task.calls.reduce((n, c) => n + callTokens(c), 0));
75
+ return [[`p50${label}`, percentile(values, 0.5)], [`p90${label}`, percentile(values, 0.9)], [`p99${label}`, percentile(values, 0.99)]];
76
+ })),
77
+ };
78
+ }
79
+
80
+ export function computeMetrics(corpus) {
81
+ const calls = validCalls(corpus);
82
+ const mainCalls = calls.filter((call) => (call.depth ?? 0) === 0);
83
+ const subCalls = calls.filter((call) => (call.depth ?? 0) > 0);
84
+ const main = totalFor(mainCalls);
85
+ const sub = totalFor(subCalls);
86
+ const all = totalFor(calls);
87
+ const totalOutput = all.output;
88
+ const cacheByType = {};
89
+ const byType = {};
90
+ const typeCalls = grouped(subCalls, (call) => call.agentType ?? 'unknown');
91
+ for (const [type, typeGroup] of typeCalls) {
92
+ const agents = (corpus.agents ?? []).filter((agent) => (agent.agentType ?? 'unknown') === type);
93
+ const totals = totalFor(typeGroup);
94
+ const latencies = typeGroup.map((call) => call.latencyMs).filter(Number.isFinite);
95
+ const finalTokens = agents.map((agent) => agent.finalTokensEst).filter(Number.isFinite);
96
+ const refs = agents.map((agent) => agent.finalRefRatio).filter(Number.isFinite);
97
+ byType[type] = {
98
+ agents: new Set(agents.map((agent) => agent.agentId)).size,
99
+ calls: totals.calls,
100
+ inputSide: totals.inputSide,
101
+ uncached: totals.uncached,
102
+ output: totals.output,
103
+ avgCallsPerAgent: ratio(totals.calls, new Set(agents.map((agent) => agent.agentId)).size),
104
+ outputPerInput: ratio(totals.output, totals.inputSide),
105
+ avgFinalTokensEst: ratio(finalTokens.reduce((a, b) => a + b, 0), finalTokens.length),
106
+ avgFinalRefRatio: ratio(refs.reduce((a, b) => a + b, 0), refs.length),
107
+ latencyP50: percentile(latencies, 0.5),
108
+ latencyP90: percentile(latencies, 0.9),
109
+ valueProxy: { label: 'proxy; not a measure of true agent value', avgFinalTokensEst: ratio(finalTokens.reduce((a, b) => a + b, 0), finalTokens.length), avgFinalRefRatio: ratio(refs.reduce((a, b) => a + b, 0), refs.length), outputPerInput: ratio(totals.output, totals.inputSide) },
110
+ };
111
+ cacheByType[type] = cacheFor(typeGroup);
112
+ }
113
+ const depthCalls = grouped(calls, (call) => call.depth ?? 0);
114
+ const byDepth = Object.fromEntries([...depthCalls].map(([depth, group]) => [depth, totalFor(group)]));
115
+ const modelCalls = grouped(calls, (call) => call.model ?? 'unknown');
116
+ const byModel = Object.fromEntries([...modelCalls].map(([model, group]) => [model, totalFor(group)]));
117
+ const turnAgents = grouped(corpus.agents ?? [], (agent) => agent.turnId);
118
+ const fanoutCounts = [...turnAgents.values()].map((agents) => agents.length);
119
+ const fanoutHistogram = {};
120
+ for (const count of fanoutCounts) fanoutHistogram[count] = (fanoutHistogram[count] ?? 0) + 1;
121
+ const heavy = subCalls.filter((call) => inputSide(call) >= HEAVY_INPUT_SIDE_MIN && call.output <= HEAVY_OUTPUT_MAX);
122
+ const heavyByType = {};
123
+ for (const [type, group] of grouped(heavy, (call) => call.agentType ?? 'unknown')) heavyByType[type] = group.length;
124
+ const heavyBySession = {};
125
+ for (const [sessionId, group] of grouped(heavy, (call) => call.sessionId ?? 'unknown')) heavyBySession[sessionId] = group.length;
126
+ const turns = [...new Set([...calls.map((call) => call.turnId), ...(corpus.turns ?? []).map((turn) => turn.turnId)].filter(Boolean))];
127
+ const turnAgentsAll = grouped(corpus.agents ?? [], (agent) => agent.turnId);
128
+ const flagged = (predicate) => {
129
+ const ids = [];
130
+ for (const [turnId, agents] of turnAgentsAll) if (predicate(agents)) ids.push(turnId);
131
+ return { count: ids.length, turnIds: ids.slice(0, TOP_LIMIT) };
132
+ };
133
+ const nPlusOne = flagged((agents) => {
134
+ const sorted = [...agents].sort((a, b) => a.firstTs - b.firstTs);
135
+ let run = 0;
136
+ let previous = null;
137
+ for (const agent of sorted) {
138
+ const small = Number.isFinite(agent.promptTokensEst) && agent.promptTokensEst <= N_PLUS_ONE_MAX_PROMPT_TOKENS;
139
+ if (small && previous && previous.agentType === agent.agentType && agent.firstTs >= previous.lastTs) run += 1;
140
+ else run = small ? 1 : 0;
141
+ previous = agent;
142
+ if (run >= N_PLUS_ONE_MIN_AGENTS) return true;
143
+ }
144
+ return false;
145
+ });
146
+ const retryLoops = flagged((agents) => [...grouped(agents.filter((a) => a.promptHash != null), (a) => `${a.agentType}\0${a.promptHash}`).values()].some((group) => group.length >= RETRY_MIN_OCCURRENCES));
147
+ const loops = flagged((agents) => [...grouped(agents, (a) => a.agentType).values()].some((group) => group.length >= LOOP_MIN_OCCURRENCES));
148
+ const agentStats = (corpus.agents ?? []).map((agent) => {
149
+ const group = calls.filter((call) => call.agentId === agent.agentId);
150
+ return { agentId: agent.agentId, agentType: agent.agentType, turnId: agent.turnId, calls: group.length, uncached: group.reduce((n, c) => n + uncached(c), 0), inputSide: group.reduce((n, c) => n + inputSide(c), 0), output: sum(group, 'output') };
151
+ });
152
+ const turnStats = turns.map((turnId) => {
153
+ const group = calls.filter((call) => call.turnId === turnId);
154
+ return { turnId, calls: group.length, uncached: group.reduce((n, c) => n + uncached(c), 0), inputSide: group.reduce((n, c) => n + inputSide(c), 0), output: sum(group, 'output') };
155
+ });
156
+ const costliest = [...turnStats].sort((a, b) => b.uncached - a.uncached).slice(0, 5);
157
+ const tokenFlow = costliest.map(({ turnId }) => ({ turnId, calls: calls.filter((call) => call.turnId === turnId).sort((a, b) => a.ts - b.ts).map((call) => ({ ts: call.ts, agentType: call.agentType, depth: call.depth, input: call.input, cacheRead: call.cacheRead, cacheWrite: call.cacheWrite, output: call.output })) }));
158
+
159
+ return {
160
+ totals: { main, sub },
161
+ share: {
162
+ subOfInputSide: ratio(sub.inputSide, all.inputSide),
163
+ subOfUncached: ratio(sub.uncached, all.uncached),
164
+ subOfOutput: ratio(sub.output, totalOutput),
165
+ subOfCalls: ratio(sub.calls, all.calls),
166
+ },
167
+ cache: { main: cacheFor(mainCalls), sub: cacheFor(subCalls), byType: cacheByType },
168
+ perTask: computeTasks(corpus, calls),
169
+ byType,
170
+ byDepth,
171
+ byModel,
172
+ fanout: { histogram: fanoutHistogram, max: fanoutCounts.length ? Math.max(...fanoutCounts) : 0, p90: percentile(fanoutCounts, 0.9) },
173
+ heavyCalls: { count: heavy.length, share: ratio(heavy.length, subCalls.length), byType: heavyByType, bySession: heavyBySession },
174
+ nPlusOne,
175
+ retryLoops,
176
+ loops,
177
+ topAgents: sortedTop(agentStats, (agent) => agent.uncached, (agent) => agent.inputSide),
178
+ topTurns: sortedTop(turnStats, (turn) => turn.uncached, (turn) => turn.inputSide),
179
+ tokenFlow,
180
+ };
181
+ }
@@ -0,0 +1,320 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import readline from 'node:readline';
4
+ import { createHash } from 'node:crypto';
5
+
6
+ const sha1 = (value) => createHash('sha1').update(value).digest('hex').slice(0, 12);
7
+ const timestampMs = (value) => {
8
+ const result = typeof value === 'number' ? value : Date.parse(value);
9
+ return Number.isFinite(result) ? result : null;
10
+ };
11
+ const estimateTokens = (text) => (text ? Math.ceil(text.length / 4) : 0);
12
+ const contentText = (content) => {
13
+ if (typeof content === 'string') return content;
14
+ if (!Array.isArray(content)) return '';
15
+ return content.filter((part) => part?.type === 'text' && typeof part.text === 'string')
16
+ .map((part) => part.text).join('\n');
17
+ };
18
+
19
+ async function readJsonl(file, stats, consume) {
20
+ const stream = fs.createReadStream(file, { encoding: 'utf8' });
21
+ const lines = readline.createInterface({ input: stream, crlfDelay: Infinity });
22
+ try {
23
+ for await (const line of lines) {
24
+ stats.lines += 1;
25
+ if (!line.trim()) continue;
26
+ let record;
27
+ try {
28
+ record = JSON.parse(line);
29
+ } catch {
30
+ stats.malformed += 1;
31
+ continue;
32
+ }
33
+ consume(record);
34
+ }
35
+ } finally {
36
+ lines.close();
37
+ stream.destroy();
38
+ }
39
+ }
40
+
41
+ function transcriptFiles(root) {
42
+ const files = [];
43
+ const rootEntries = fs.readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
44
+ for (const entry of rootEntries) {
45
+ const entryPath = path.join(root, entry.name);
46
+ if (entry.isFile() && entry.name.endsWith('.jsonl')) {
47
+ files.push({ file: entryPath, sessionId: entry.name.slice(0, -'.jsonl'.length), isAgent: false });
48
+ }
49
+ }
50
+ for (const entry of rootEntries) {
51
+ if (!entry.isDirectory()) continue;
52
+ const sessionDir = path.join(root, entry.name);
53
+ const subagentsDir = path.join(sessionDir, 'subagents');
54
+ let subagentEntries;
55
+ try {
56
+ subagentEntries = fs.readdirSync(subagentsDir, { withFileTypes: true });
57
+ } catch (error) {
58
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') continue;
59
+ throw error;
60
+ }
61
+ for (const agentEntry of subagentEntries.sort((a, b) => a.name.localeCompare(b.name))) {
62
+ if (!agentEntry.isFile() || !agentEntry.name.endsWith('.jsonl')) continue;
63
+ const stem = agentEntry.name.slice(0, -'.jsonl'.length);
64
+ files.push({
65
+ file: path.join(subagentsDir, agentEntry.name),
66
+ metaFile: path.join(subagentsDir, `${stem}.meta.json`),
67
+ sessionId: entry.name,
68
+ agentId: stem.replace(/^agent-/, ''),
69
+ isAgent: true,
70
+ });
71
+ }
72
+ }
73
+ return files;
74
+ }
75
+
76
+ async function readMeta(file) {
77
+ try {
78
+ const value = JSON.parse(await fs.promises.readFile(file, 'utf8'));
79
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ function makeCall(record, sessionId, agentId, agentType, depth, ts, priorTs) {
86
+ const message = record.message ?? {};
87
+ const usage = message.usage ?? {};
88
+ const input = Number(usage.input_tokens) || 0;
89
+ const cacheRead = Number(usage.cache_read_input_tokens ?? usage.cache_read_tokens) || 0;
90
+ const cacheWrite = Number(usage.cache_creation_input_tokens ?? usage.cache_creation_tokens) || 0;
91
+ const output = Number(usage.output_tokens) || 0;
92
+ const key = message.id ?? record.requestId ?? record.uuid;
93
+ return {
94
+ id: key == null ? null : String(key),
95
+ sessionId,
96
+ turnId: null,
97
+ agentId: agentId ?? null,
98
+ agentType: agentType ?? null,
99
+ model: message.model ?? record.model ?? null,
100
+ ts,
101
+ latencyMs: ts != null && priorTs != null && ts >= priorTs ? ts - priorTs : null,
102
+ input,
103
+ cacheRead,
104
+ cacheWrite,
105
+ output,
106
+ zero: input === 0 && cacheRead === 0 && cacheWrite === 0 && output === 0,
107
+ depth,
108
+ _dedupeKey: key == null ? null : String(key),
109
+ };
110
+ }
111
+
112
+ function hashFinalReferences(finalText, parentText) {
113
+ if (!finalText || !parentText) return null;
114
+ const words = (text) => text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
115
+ const finalWords = words(finalText);
116
+ const parentWords = words(parentText);
117
+ if (finalWords.length < 5 || parentWords.length < 5) return null;
118
+ const grams = new Set();
119
+ for (let i = 0; i <= parentWords.length - 5; i += 1) {
120
+ grams.add(sha1(parentWords.slice(i, i + 5).join(' ')));
121
+ }
122
+ let matches = 0;
123
+ let total = 0;
124
+ for (let i = 0; i <= finalWords.length - 5; i += 1) {
125
+ total += 1;
126
+ if (grams.has(sha1(finalWords.slice(i, i + 5).join(' ')))) matches += 1;
127
+ }
128
+ return total ? matches / total : null;
129
+ }
130
+
131
+ export async function loadCorpus(root, { since = null, until = null } = {}) {
132
+ let rootStat;
133
+ try {
134
+ rootStat = fs.statSync(root);
135
+ } catch {
136
+ throw new Error(`root not found: ${root}`);
137
+ }
138
+ if (!rootStat.isDirectory()) throw new Error(`root not found: ${root}`);
139
+
140
+ const stats = {
141
+ files: 0, lines: 0, malformed: 0, zeroUsage: 0, dupIds: 0,
142
+ missingMeta: 0, noParent: 0, unattributed: 0,
143
+ };
144
+ const files = transcriptFiles(root);
145
+ const mainFilesBySession = new Map();
146
+ const agentFiles = [];
147
+ const promptRecordsBySession = new Map();
148
+ const toolUsesBySession = new Map();
149
+ const calls = [];
150
+ const agents = [];
151
+ const turns = [];
152
+ const sinceMs = since == null ? null : timestampMs(since);
153
+ const untilMs = until == null ? null : timestampMs(until);
154
+
155
+ for (const fileInfo of files) {
156
+ stats.files += 1;
157
+ const meta = fileInfo.isAgent ? await readMeta(fileInfo.metaFile) : null;
158
+ if (fileInfo.isAgent && !meta) stats.missingMeta += 1;
159
+ const agentType = fileInfo.isAgent ? (meta?.agentType ?? null) : null;
160
+ const depth = fileInfo.isAgent
161
+ ? (Number.isFinite(meta?.spawnDepth) ? meta.spawnDepth : 1)
162
+ : 0;
163
+ const deduped = new Map();
164
+ const userRecords = [];
165
+ let previousTs = null;
166
+ let finalAssistantText = null;
167
+
168
+ await readJsonl(fileInfo.file, stats, (record) => {
169
+ const ts = timestampMs(record.timestamp);
170
+ if (!fileInfo.isAgent && record.type === 'user' && !record.isSidechain) {
171
+ const body = contentText(record.message?.content ?? record.content);
172
+ if (body.trim()) userRecords.push({ ts, body });
173
+ }
174
+ if (record.type !== 'assistant') return;
175
+
176
+ const call = makeCall(
177
+ record,
178
+ fileInfo.sessionId,
179
+ fileInfo.isAgent ? fileInfo.agentId : null,
180
+ fileInfo.isAgent ? (agentType ?? record.attributionAgent ?? 'unknown') : null,
181
+ depth,
182
+ ts,
183
+ previousTs,
184
+ );
185
+ previousTs = ts;
186
+ const key = call._dedupeKey ?? `line:${stats.lines}`;
187
+ if (deduped.has(key) && call._dedupeKey != null) stats.dupIds += 1;
188
+ deduped.set(key, { call, record });
189
+
190
+ if (fileInfo.isAgent) {
191
+ const text = contentText(record.message?.content);
192
+ if (text) finalAssistantText = text;
193
+ } else {
194
+ for (const part of record.message?.content ?? []) {
195
+ if (part?.type !== 'tool_use' || typeof part.id !== 'string') continue;
196
+ if (!toolUsesBySession.has(fileInfo.sessionId)) toolUsesBySession.set(fileInfo.sessionId, new Map());
197
+ toolUsesBySession.get(fileInfo.sessionId).set(part.id, { ts, record, tool: part });
198
+ }
199
+ }
200
+ });
201
+
202
+ const records = [...deduped.values()];
203
+ if (fileInfo.isAgent) {
204
+ agentFiles.push({ fileInfo, meta, agentType, depth, records, finalAssistantText });
205
+ } else {
206
+ mainFilesBySession.set(fileInfo.sessionId, records);
207
+ promptRecordsBySession.set(fileInfo.sessionId, userRecords);
208
+ }
209
+ }
210
+
211
+ const turnsBySession = new Map();
212
+ for (const [sessionId, prompts] of promptRecordsBySession) {
213
+ prompts.sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0));
214
+ const sessionTurns = prompts.map((prompt, index) => ({
215
+ turnId: `${sessionId}#${index + 1}`,
216
+ sessionId,
217
+ ts: prompt.ts,
218
+ promptHash: sha1(prompt.body),
219
+ promptTokensEst: estimateTokens(prompt.body),
220
+ }));
221
+ turnsBySession.set(sessionId, sessionTurns);
222
+ turns.push(...sessionTurns);
223
+ }
224
+
225
+ function turnForTime(sessionId, ts) {
226
+ const sessionTurns = turnsBySession.get(sessionId) ?? [];
227
+ let selected = null;
228
+ for (const turn of sessionTurns) {
229
+ if (ts == null || turn.ts == null || turn.ts <= ts) selected = turn;
230
+ else break;
231
+ }
232
+ return selected;
233
+ }
234
+
235
+ const parentTextBySessionTurn = new Map();
236
+ for (const [sessionId, records] of mainFilesBySession) {
237
+ let latestTurn = null;
238
+ const assistantTextsByTurn = new Map();
239
+ for (const { call, record } of records) {
240
+ const turn = turnForTime(sessionId, call.ts);
241
+ if (turn) latestTurn = turn;
242
+ call.turnId = latestTurn?.turnId ?? `${sessionId}#unattributed`;
243
+ if (record.message?.content) {
244
+ const text = contentText(record.message.content);
245
+ if (text) assistantTextsByTurn.set(call.turnId, text);
246
+ }
247
+ calls.push(call);
248
+ }
249
+ for (const [turnId, text] of assistantTextsByTurn) {
250
+ parentTextBySessionTurn.set(`${sessionId}\u0000${turnId}`, text);
251
+ }
252
+ }
253
+
254
+ agentFiles.sort((a, b) => {
255
+ const aTs = a.records[0]?.call.ts ?? 0;
256
+ const bTs = b.records[0]?.call.ts ?? 0;
257
+ return aTs - bTs || a.fileInfo.agentId.localeCompare(b.fileInfo.agentId);
258
+ });
259
+ const agentsById = new Map();
260
+ for (const { fileInfo, meta, agentType, depth, records, finalAssistantText } of agentFiles) {
261
+ const toolUseId = meta?.toolUseId ?? null;
262
+ const parent = toolUseId == null ? null : toolUsesBySession.get(fileInfo.sessionId)?.get(toolUseId) ?? null;
263
+ if (!parent) stats.noParent += 1;
264
+ const turn = parent ? turnForTime(fileInfo.sessionId, parent.ts) : null;
265
+ const turnId = turn?.turnId ?? `${fileInfo.sessionId}#unattributed`;
266
+ if (!turn) stats.unattributed += 1;
267
+
268
+ const delegatedPrompt = typeof parent?.tool?.input?.prompt === 'string' ? parent.tool.input.prompt : '';
269
+ const description = typeof meta?.description === 'string' ? meta.description : '';
270
+ const segments = delegatedPrompt.split(/\n\s*\n/)
271
+ .map((segment) => segment.trim())
272
+ .filter((segment) => segment.length >= 200)
273
+ .map((segment) => ({ h: sha1(segment), t: estimateTokens(segment) }));
274
+ const firstTs = records.reduce((value, item) => item.call.ts == null ? value : value == null ? item.call.ts : Math.min(value, item.call.ts), null);
275
+ const lastTs = records.reduce((value, item) => item.call.ts == null ? value : value == null ? item.call.ts : Math.max(value, item.call.ts), null);
276
+ const parentText = parentTextBySessionTurn.get(`${fileInfo.sessionId}\u0000${turnId}`) ?? '';
277
+ const agent = {
278
+ agentId: fileInfo.agentId,
279
+ sessionId: fileInfo.sessionId,
280
+ turnId,
281
+ agentType: agentType ?? records.find((item) => item.record.attributionAgent)?.record.attributionAgent ?? 'unknown',
282
+ descriptionHash: description ? sha1(description) : null,
283
+ toolUseId,
284
+ depth,
285
+ parentAgentId: meta?.parentAgentId ?? null,
286
+ metaModel: meta?.model ?? null,
287
+ hasMeta: Boolean(meta),
288
+ promptHash: delegatedPrompt ? sha1(delegatedPrompt) : null,
289
+ promptTokensEst: delegatedPrompt ? estimateTokens(delegatedPrompt) : null,
290
+ segments,
291
+ finalTokensEst: finalAssistantText ? estimateTokens(finalAssistantText) : null,
292
+ finalRefRatio: hashFinalReferences(finalAssistantText, parentText),
293
+ firstTs,
294
+ lastTs,
295
+ };
296
+ agents.push(agent);
297
+ agentsById.set(`${fileInfo.sessionId}\u0000${fileInfo.agentId}`, agent);
298
+ for (const { call } of records) {
299
+ call.turnId = turnId;
300
+ calls.push(call);
301
+ }
302
+ }
303
+
304
+ // Resolve explicit parent-agent links when the parent transcript is present; retain null for unknown links.
305
+ for (const agent of agents) {
306
+ if (agent.parentAgentId != null) {
307
+ const parent = agentsById.get(`${agent.sessionId}\u0000${agent.parentAgentId}`);
308
+ if (parent && agent.turnId.endsWith('#unattributed')) agent.turnId = parent.turnId;
309
+ }
310
+ }
311
+
312
+ stats.zeroUsage = calls.reduce((count, call) => count + (call.zero ? 1 : 0), 0);
313
+ const filteredCalls = calls.filter((call) =>
314
+ (sinceMs == null || (call.ts != null && call.ts >= sinceMs)) &&
315
+ (untilMs == null || (call.ts != null && call.ts <= untilMs)));
316
+ for (const call of filteredCalls) {
317
+ delete call._dedupeKey;
318
+ }
319
+ return { calls: filteredCalls, turns, agents, stats };
320
+ }
@@ -0,0 +1,34 @@
1
+ const fmt = (value) => value == null || !Number.isFinite(value) ? 'N/A' : Number(value).toLocaleString('en-US', { maximumFractionDigits: 3 });
2
+ const table = (headers, rows) => [`| ${headers.join(' | ')} |`, `| ${headers.map(() => '---').join(' | ')} |`, ...rows.map((row) => `| ${row.map((cell) => String(cell ?? 'N/A').replaceAll('|', '\\|').replaceAll('\n', ' ')).join(' | ')} |`)].join('\n');
3
+
4
+ export function renderMarkdown(report) {
5
+ const m = report.metrics ?? {};
6
+ const d = report.duplicates ?? {};
7
+ const totals = m.totals ?? {};
8
+ const typeRows = Object.entries(m.byType ?? {}).sort(([a], [b]) => a.localeCompare(b))
9
+ .map(([type, value]) => [type, value.agents, value.calls, fmt(value.inputSide), fmt(value.uncached), fmt(value.output), fmt(value.latencyP50)]);
10
+ const topRows = (items, key) => (items ?? []).map((item) => [item[key], item.agentType ?? 'N/A', item.calls, fmt(item.uncached), fmt(item.inputSide), fmt(item.output)]);
11
+ const flowRows = (m.tokenFlow ?? []).flatMap((turn) => turn.calls.map((call) => [turn.turnId, new Date(call.ts).toISOString(), call.agentType ?? 'main', call.depth, fmt(call.input), fmt(call.cacheRead), fmt(call.cacheWrite), fmt(call.output)]));
12
+ const flags = (value) => (value?.turnIds ?? []).map((turnId) => [turnId]);
13
+ return [
14
+ '# Sub-agent cost audit report',
15
+ '',
16
+ `Generated: ${report.generatedAt}`, `Root: ${report.root}`, `Window: ${report.window?.since ?? 'all'} — ${report.window?.until ?? 'all'}`,
17
+ '', '## Totals', '',
18
+ table(['Group', 'Calls', 'Input', 'Cache read', 'Cache write', 'Output', 'Uncached', 'Input-side'], ['main', 'sub'].map((key) => { const t = totals[key] ?? {}; return [key, t.calls, fmt(t.input), fmt(t.cacheRead), fmt(t.cacheWrite), fmt(t.output), fmt(t.uncached), fmt(t.inputSide)]; })),
19
+ '', '## Main vs sub-agent', '', table(['Metric', 'Value'], Object.entries(m.share ?? {}).map(([key, value]) => [key, fmt(value)])),
20
+ '', '## By type', '', table(['Type', 'Agents', 'Calls', 'Input-side', 'Uncached', 'Output', 'Latency p50 ms'], typeRows),
21
+ '', '## Fan-out', '', table(['Metric', 'Value'], [['Maximum', m.fanout?.max], ['p90', fmt(m.fanout?.p90)], ...Object.entries(m.fanout?.histogram ?? {}).map(([count, turns]) => [`${count} agents`, turns])]),
22
+ '', '## Cache', '', table(['Group', 'Hit rate', 'Cache read', 'Input-side'], ['main', 'sub'].map((key) => { const c = m.cache?.[key] ?? {}; return [key, fmt(c.hitRate), fmt(c.cacheRead), fmt(c.inputSide)]; })),
23
+ '', '## Top agents (20)', '', table(['Agent', 'Type', 'Calls', 'Uncached', 'Input-side', 'Output'], topRows(m.topAgents, 'agentId')),
24
+ '', '## Top turns (20)', '', table(['Turn', 'Type', 'Calls', 'Uncached', 'Input-side', 'Output'], topRows(m.topTurns, 'turnId')),
25
+ '', '## Token flow (5 costliest turns)', '', table(['Turn', 'Timestamp', 'Type', 'Depth', 'Input', 'Cache read', 'Cache write', 'Output'], flowRows),
26
+ '', '## N+1 flags', '', table(['Turn'], flags(m.nPlusOne)),
27
+ '', '## Retry flags', '', table(['Turn'], flags(m.retryLoops)),
28
+ '', '## Repeated-loop flags', '', table(['Turn'], flags(m.loops)),
29
+ '', '## Heavy calls and replay hypothesis', '', table(['Metric', 'Value'], [['Heavy calls', m.heavyCalls?.count], ['Heavy share', fmt(m.heavyCalls?.share)], ['Replay verdict', d.replayHypothesis?.verdict], ['Replay share', fmt(d.replayHypothesis?.share)]]),
30
+ '', '## Context duplication', '', table(['Metric', 'Value'], [['Amplification', fmt(d.amplification)], ['Duplicate context ratio', fmt(d.duplicateRatio)], ['Within-turn ratio', fmt(d.withinTurn?.ratio)], ['Cross-turn ratio', fmt(d.crossTurn?.ratio)]]),
31
+ '', '## Missing telemetry', '', ...(report.missing ?? []).map((item) => `- ${item}`),
32
+ '',
33
+ ].join('\n');
34
+ }
@@ -956,7 +956,7 @@ async function bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs }) {
956
956
  *
957
957
  * Advisory lane: EVERY failure path returns { kind: 'none' }, never throws.
958
958
  */
959
- export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), lockBudgetMs = LOCK_BUDGET_MS, transcriptPath = null } = {}) {
959
+ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), lockBudgetMs = LOCK_BUDGET_MS, transcriptPath = null, stopHookActive = false } = {}) {
960
960
  const gate = await loadHandoffGateConfig(projectRoot);
961
961
  if (!gate.enabled) return { kind: 'none' };
962
962
 
@@ -1024,6 +1024,20 @@ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), loc
1024
1024
 
1025
1025
  const signature = `${phase}|${cursor}|${next}`;
1026
1026
  const streak = await bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs });
1027
+ // Reentrancy valve (stop_hook_active): Claude Code sets it when this Stop fires
1028
+ // inside the continuation turn WE forced. The same cursor signature seen again
1029
+ // (streak > 1) means that continuation made no cursor progress — blocking again
1030
+ // is a no-progress loop, so release once instead of waiting for the stalled cap.
1031
+ // A moved cursor resets the streak to 1 and keeps blocking (real progress).
1032
+ if (stopHookActive === true && streak !== null && streak > 1) {
1033
+ return {
1034
+ kind: 'advisory',
1035
+ systemMessage:
1036
+ `[ukit-stop-coordinator] handoff-cursor released this reentrant stop (stop_hook_active): ` +
1037
+ `the run cursor ("${signature}") did not advance during the forced continuation. ` +
1038
+ `RUN.md is untouched; resume with /ukit:handoff-fullstack or clear with /ukit:handoff-clear.`,
1039
+ };
1040
+ }
1027
1041
  if (streak !== null && streak > gate.maxStalledBlocks) {
1028
1042
  let predicateState = 'no ExitPredicate recorded';
1029
1043
  if (exitPredicate) {
@@ -1064,6 +1078,101 @@ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), loc
1064
1078
  return { kind: 'block', reason: lines.join('\n') };
1065
1079
  }
1066
1080
 
1081
+ // ─── Explicit user interruption (pause / cancel) ──────────────────────────
1082
+ // A Stop must never be forced while the USER has paused or cancelled the run:
1083
+ // every lane (completion, watchdog, handoff-cursor) would otherwise bounce the
1084
+ // agent's "PAUSED" reply back as "CONTINUE IMMEDIATELY". The signal is read ONLY
1085
+ // from genuine user turns in the host-authored transcript — never from the agent's
1086
+ // own words, tool output, sub-agent reports or hook feedback (all skipped). The
1087
+ // latest pause/cancel-vs-resume turn wins; neutral turns (questions, health
1088
+ // checks) keep the earlier state; an unreadable/over-budget scan is "no
1089
+ // interruption" so enforcement is never relaxed by a lost read.
1090
+ export const INTERRUPT_SCAN_BUDGET_MS = 800;
1091
+ export const INTERRUPT_SCAN_MAX_BYTES = 8 * 1024 * 1024;
1092
+ const INTERRUPT_MAX_TEXT = 240;
1093
+ const WORD_END = '(?![\\p{L}\\p{N}])';
1094
+ const CANCEL_RE = new RegExp(`^(?:please\\s+)?(?:cancel|abort)${WORD_END}|^(?:hủy|huỷ|huy)${WORD_END}`, 'iu');
1095
+ const PAUSE_RE = new RegExp(
1096
+ `^(?:please\\s+)?(?:pause|halt|stop|hold\\s+(?:on|off))${WORD_END}`
1097
+ + `|^(?:tạm\\s+dừng|tạm\\s+ngưng|tạm\\s+ngừng|tam\\s+dung|dừng(?:\\s+lại)?|dung\\s+lai|ngừng)${WORD_END}`,
1098
+ 'iu',
1099
+ );
1100
+ const RESUME_RE = new RegExp(
1101
+ `^(?:please\\s+)?(?:continue|resume|proceed|unpause|go\\s+on|carry\\s+on)${WORD_END}`
1102
+ + `|^(?:tiếp\\s+tục|tiep\\s+tuc)${WORD_END}`,
1103
+ 'iu',
1104
+ );
1105
+ const SYNTHETIC_USER_PREFIX = /^(?:<system-reminder|<task-notification|<local-command|<user-prompt-submit-hook|Stop hook feedback|\[ukit|Caveat:|\[Request interrupted)/i;
1106
+ const RESUME_COMMAND_TAG = /^<command-name>\/?(?:ukit:)?handoff-(?:fullstack|implement)(?![\w-])/;
1107
+
1108
+ function genuineUserText(entry) {
1109
+ if (!entry || entry.type !== 'user' || entry.isSidechain === true || entry.isMeta === true) return null;
1110
+ const msg = entry.message;
1111
+ if (!msg || msg.role !== 'user') return null;
1112
+ let text = '';
1113
+ if (typeof msg.content === 'string') {
1114
+ text = msg.content;
1115
+ } else if (Array.isArray(msg.content)) {
1116
+ for (const b of msg.content) {
1117
+ if (!b || typeof b !== 'object') continue;
1118
+ if (b.type === 'tool_result') return null;
1119
+ if (b.type === 'text' && typeof b.text === 'string') text += (text ? '\n' : '') + b.text;
1120
+ }
1121
+ } else {
1122
+ return null;
1123
+ }
1124
+ text = text.trim();
1125
+ if (!text || SYNTHETIC_USER_PREFIX.test(text)) return null;
1126
+ return text;
1127
+ }
1128
+
1129
+ /**
1130
+ * @returns {Promise<{ kind: 'pause'|'cancel'|null }>} Newest decisive genuine user
1131
+ * turn: pause/cancel → interrupted; resume → not interrupted; none → not interrupted.
1132
+ */
1133
+ export async function detectUserInterruption(transcriptPath, {
1134
+ budgetMs = INTERRUPT_SCAN_BUDGET_MS,
1135
+ maxBytes = INTERRUPT_SCAN_MAX_BYTES,
1136
+ } = {}) {
1137
+ if (typeof transcriptPath !== 'string' || !transcriptPath.trim()) return { kind: null };
1138
+ let handle;
1139
+ try {
1140
+ handle = await fs.open(transcriptPath, 'r');
1141
+ const size = Number((await handle.stat()).size) || 0;
1142
+ const start = Math.max(0, size - maxBytes);
1143
+ const deadline = Date.now() + budgetMs;
1144
+ const buf = Buffer.allocUnsafe(size - start);
1145
+ let got = 0;
1146
+ while (got < buf.length) {
1147
+ if (Date.now() > deadline) return { kind: null };
1148
+ const { bytesRead } = await handle.read(buf, got, buf.length - got, start + got);
1149
+ if (!bytesRead) break;
1150
+ got += bytesRead;
1151
+ }
1152
+ let lines = buf.toString('utf8', 0, got).split('\n');
1153
+ if (start > 0) lines = lines.slice(1);
1154
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
1155
+ if (Date.now() > deadline) return { kind: null };
1156
+ const line = lines[i];
1157
+ if (!line || !line.includes('"user"') || line.includes('"tool_result"')) continue;
1158
+ let entry;
1159
+ try { entry = JSON.parse(line); } catch { continue; }
1160
+ const text = genuineUserText(entry);
1161
+ if (text === null) continue;
1162
+ if (RESUME_COMMAND_TAG.test(text)) return { kind: null };
1163
+ if (text.length > INTERRUPT_MAX_TEXT) continue;
1164
+ if (CANCEL_RE.test(text)) return { kind: 'cancel' };
1165
+ if (PAUSE_RE.test(text)) return { kind: 'pause' };
1166
+ if (RESUME_RE.test(text)) return { kind: null };
1167
+ }
1168
+ return { kind: null };
1169
+ } catch {
1170
+ return { kind: null };
1171
+ } finally {
1172
+ try { await handle?.close(); } catch {}
1173
+ }
1174
+ }
1175
+
1067
1176
  // ─── Once-per-stop guard ──────────────────────────────────────────────────
1068
1177
  /**
1069
1178
  * Lock-scoped dedupe: persist `{ lastStop: { key, ts } }` and report whether this
@@ -1124,6 +1233,27 @@ export async function runStopCoordinator({
1124
1233
  return { skip: 'duplicate Stop invocation for this session inside the dedupe window', merged: null };
1125
1234
  }
1126
1235
 
1236
+ // User pause/cancel outranks every lane: release, change nothing (RUN.md, tasks,
1237
+ // branches, worktrees stay as they are so the run can be resumed).
1238
+ const interruption = await detectUserInterruption(
1239
+ typeof payload?.transcript_path === 'string' ? payload.transcript_path : null,
1240
+ );
1241
+ if (interruption.kind) {
1242
+ return {
1243
+ skip: null,
1244
+ merged: {
1245
+ decision: null,
1246
+ reason: undefined,
1247
+ systemMessage:
1248
+ `[ukit-stop-coordinator] stop allowed: the user requested ${interruption.kind}. ` +
1249
+ `No verification or continuation is forced; run state is preserved. ` +
1250
+ `Resume by asking to continue (or /ukit:handoff-fullstack).`,
1251
+ policyOwner: 'user-interruption',
1252
+ policies: ['user-interruption'],
1253
+ },
1254
+ };
1255
+ }
1256
+
1127
1257
  let completion = null;
1128
1258
  const failures = {};
1129
1259
  try {
@@ -1149,6 +1279,7 @@ export async function runStopCoordinator({
1149
1279
  now,
1150
1280
  lockBudgetMs,
1151
1281
  transcriptPath: typeof payload?.transcript_path === 'string' ? payload.transcript_path : null,
1282
+ stopHookActive: payload?.stop_hook_active === true,
1152
1283
  });
1153
1284
  } catch (error) {
1154
1285
  failures.handoff = error?.message || String(error);