@ngockhoale/ukit 3.4.24 → 3.4.26

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,25 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 3.4.26 - 2026-10-08
6
+
7
+ **P1 fix: completion gate no longer reports `missing write-evidence` for tracked-file changes made through Bash.**
8
+ - `execution-ledger`: a Bash receipt records a git workspace fingerprint (HEAD + hash of `git diff HEAD`, tracked files only). When it differs from the previous receipt, a synthetic write is applied. First observation, git failure/timeout, non-git directories and untracked files record nothing (fail-closed).
9
+ - Strictly read-only Bash (`ls`, `cat`, `git status`, ...) and verification commands absorb the delta into the baseline instead of earning a write, so a background process or subagent edit is not credited to the main agent.
10
+ - Evidence tier: a Bash-derived write is `inferred` (`evidenceTier: 'inferred'`, supporting evidence); only Edit/Write is direct. Inferred writes never satisfy `verification-evidence`.
11
+ - Known limits: the first Bash of a session only seeds the baseline, so a mutation made by that very command is not credited; a delta that coincides with a non-read-only Bash is still attributed to it (inferred, not proof).
12
+ - Regression tests: `tests/hooks/bashMutationEvidence.test.js` (12 cases).
13
+ - Not included: AWAITING_USER stop handling (separate bug); `isVerificationCommand` coverage for commands such as `yarn release:verify`.
14
+
15
+ ## 3.4.25 - 2026-10-08
16
+
17
+ **P0 hotfix: Stop hook no longer force-continues a run the user paused or cancelled.**
18
+ - `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.
19
+ - 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.
20
+ - Regression tests: `tests/hooks/stopHookInterruption.test.js` (15 cases).
21
+ - 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.
22
+ - Not included: no change to evidence/ledger accounting or model routing.
23
+
5
24
  ## 3.4.24 - 2026-10-08
6
25
 
7
26
  **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.26",
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
+ }
@@ -549,6 +549,52 @@ function gitOutput(args, cwd) {
549
549
  }
550
550
  }
551
551
 
552
+ // Verifiable Bash-mutation evidence: a Bash command is never a write by itself. It counts
553
+ // only when the TRACKED git workspace fingerprint (HEAD + content hash of `git diff HEAD`)
554
+ // differs from the one recorded at the previous receipt. Any git failure, timeout or
555
+ // oversize diff yields null, and null never creates evidence (fail-closed). Untracked files
556
+ // are deliberately excluded: runtime state under .ukit/ would otherwise look like a change.
557
+ function workspaceFingerprint(cwd) {
558
+ if (!cwd) return null;
559
+ try {
560
+ const run = (args) => execFileSync('git', args, {
561
+ cwd,
562
+ stdio: ['ignore', 'pipe', 'ignore'],
563
+ timeout: 1500,
564
+ maxBuffer: 16 * 1024 * 1024,
565
+ });
566
+ const head = String(run(['rev-parse', 'HEAD'])).trim();
567
+ if (!head) return null;
568
+ const diffHash = crypto.createHash('sha256').update(run(['diff', 'HEAD', '--no-ext-diff'])).digest('hex');
569
+ return `${head.slice(0, 12)}:${diffHash.slice(0, 16)}`;
570
+ } catch {
571
+ return null;
572
+ }
573
+ }
574
+
575
+ // A workspace delta cannot be attributed to the Bash call that happened to observe it: a
576
+ // background process or a subagent may have changed the tree in between. The classifier below
577
+ // only ever REMOVES credit — a command it recognises as strictly read-only never receives a
578
+ // delta write (the delta is absorbed into the baseline). It never grants credit by itself;
579
+ // unrecognised commands are merely eligible, and their write stays tier "inferred".
580
+ const READ_ONLY_BASH = new Set([
581
+ 'ls', 'cat', 'head', 'tail', 'wc', 'pwd', 'echo', 'printf', 'grep', 'rg', 'cd', 'which',
582
+ 'stat', 'file', 'diff', 'sort', 'uniq', 'date', 'env', 'true', 'test', '[', 'basename', 'dirname',
583
+ ]);
584
+ const READ_ONLY_GIT = new Set(['status', 'diff', 'log', 'show', 'rev-parse', 'ls-files', 'blame', 'describe', 'merge-base']);
585
+
586
+ function isReadOnlyBashCommand(command) {
587
+ const text = String(command || '').trim();
588
+ if (!text || /[>`]|\$\(|<\(|\btee\b|\s-i\b|--in-place|-exec\b|-delete\b/.test(text)) return false;
589
+ return text.split(/&&|\|\||;|\||\n/).every((segment) => {
590
+ const words = segment.trim().split(/\s+/).filter(Boolean);
591
+ while (words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) words.shift();
592
+ if (words.length === 0) return false;
593
+ if (words[0] === 'git') return READ_ONLY_GIT.has(words.find((w, i) => i > 0 && !w.startsWith('-')) || '');
594
+ return READ_ONLY_BASH.has(words[0]);
595
+ });
596
+ }
597
+
552
598
  // A diff whose changed paths are all tests/docs/lint-format config is
553
599
  // stale-but-recoverable: one rebuild at current head restores currency.
554
600
  const RECOVERABLE_DIFF_PATH = /(^|\/)(docs\/|[^/]*\.md$)|\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)(tests?|specs?|__tests__)\/|(?:^|\/)\.?eslint|\.prettierrc|biome\.json|\.editorconfig|tsconfig.*\.json$/i;
@@ -899,6 +945,11 @@ function carriedEvidenceLedger(fresh, current) {
899
945
  || current.targetedVerificationSucceeded === true,
900
946
  sourceFiles: [...new Set([...(current.sourceFiles || []), ...(fresh.sourceFiles || [])])]
901
947
  .slice(-MAX_SOURCE_FILES),
948
+ // Workspace baseline for Bash-mutation attribution: same-prompt re-keys keep it (the router
949
+ // re-keys on every tool call); a different prompt returns `fresh` above and re-seeds.
950
+ workspaceFp: current.workspaceFp || fresh.workspaceFp || null,
951
+ inferredWrite: fresh.inferredWrite === true || current.inferredWrite === true,
952
+ directWrite: fresh.directWrite === true || current.directWrite === true,
902
953
  writeAttempted: fresh.writeAttempted || current.writeAttempted === true,
903
954
  writeSucceeded: fresh.writeSucceeded || current.writeSucceeded === true,
904
955
  verificationAttempted: fresh.verificationAttempted || current.verificationAttempted === true,
@@ -1032,7 +1083,7 @@ const MAX_JOURNAL_SEEN = 64;
1032
1083
  const MAX_JOURNAL_RECORDS = 128;
1033
1084
  const MAX_JOURNAL_QUARANTINE_LINES = 32;
1034
1085
  const LEDGER_EVENT_TYPES = new Set(['receipt', 'continuation', 'notified', 'stop-progress', 'resumable-run']);
1035
- const RECEIPT_KINDS = new Set(['source', 'write', 'verification', 'playbook-finding']);
1086
+ const RECEIPT_KINDS = new Set(['source', 'write', 'verification', 'playbook-finding', 'workspace']);
1036
1087
 
1037
1088
  function journalPathFor(target) {
1038
1089
  return `${target}.journal`;
@@ -1151,7 +1202,7 @@ function newEventId() {
1151
1202
  return `${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`;
1152
1203
  }
1153
1204
 
1154
- const JOURNAL_RECEIPT_FIELDS = ['ts', 'kind', 'toolName', 'toolUseId', 'success', 'exitCode', 'file', 'command', 'scope'];
1205
+ const JOURNAL_RECEIPT_FIELDS = ['ts', 'kind', 'toolName', 'toolUseId', 'success', 'exitCode', 'file', 'command', 'scope', 'workspaceFp', 'bashReadOnly'];
1155
1206
 
1156
1207
  // TASK-009 (BL-011): evidence attestations are whitelisted verbatim — class /
1157
1208
  // surface / result / claim, the ARCH §Evidence Schema fields a receipt may
@@ -1605,7 +1656,36 @@ function applyBugFixFloorReceipt(ledger, receipt) {
1605
1656
  return receipt;
1606
1657
  }
1607
1658
 
1608
- function applyReceiptToLedger(ledger, receipt, { vibecode = false } = {}) {
1659
+ // Folds the workspace fingerprint into the ledger, then applies the receipt itself. A
1660
+ // successful Bash receipt whose fingerprint moved since the last receipt is additionally
1661
+ // applied as a synthetic write (fingerprint equality is the only evidence — never the
1662
+ // command text). A first observation only seeds the baseline.
1663
+ function applyReceiptToLedger(ledger, receipt, options = {}) {
1664
+ if (!receipt || typeof receipt !== 'object' || !RECEIPT_KINDS.has(receipt.kind)) return null;
1665
+ const fp = typeof receipt.workspaceFp === 'string' && receipt.workspaceFp ? receipt.workspaceFp : null;
1666
+ let base = ledger;
1667
+ if (fp) {
1668
+ const prior = ledger?.workspaceFp || null;
1669
+ base = { ...ledger, workspaceFp: fp };
1670
+ if (prior && prior !== fp && receipt.kind === 'workspace' && receipt.toolName === 'Bash' && receipt.success === true && receipt.bashReadOnly !== true) {
1671
+ const applied = applyReceiptCore(base, {
1672
+ ts: receipt.ts,
1673
+ kind: 'write',
1674
+ toolName: 'Bash',
1675
+ toolUseId: receipt.toolUseId,
1676
+ success: true,
1677
+ exitCode: receipt.exitCode,
1678
+ file: `bash-workspace:${fp}`,
1679
+ detail: 'bash-workspace-delta',
1680
+ }, options);
1681
+ if (applied) base = applied.ledger;
1682
+ }
1683
+ }
1684
+ if (receipt.kind === 'workspace') return { ledger: base, value: null };
1685
+ return applyReceiptCore(base, receipt, options);
1686
+ }
1687
+
1688
+ function applyReceiptCore(ledger, receipt, { vibecode = false } = {}) {
1609
1689
  if (!receipt || typeof receipt !== 'object' || !RECEIPT_KINDS.has(receipt.kind)) return null;
1610
1690
  const next = { ...ledger };
1611
1691
  if (receipt.kind === 'source') {
@@ -1616,6 +1696,10 @@ function applyReceiptToLedger(ledger, receipt, { vibecode = false } = {}) {
1616
1696
  } else if (receipt.kind === 'write') {
1617
1697
  next.writeAttempted = true;
1618
1698
  next.writeSucceeded = next.writeSucceeded || receipt.success;
1699
+ // Evidence tier: a fingerprint-derived Bash delta is inferred (supporting) evidence; only a
1700
+ // tool-attributed Edit/Write is direct.
1701
+ if (receipt.detail === 'bash-workspace-delta') next.inferredWrite = true;
1702
+ else if (receipt.success) next.directWrite = true;
1619
1703
  // A mutation attempt between failures breaks the "no change in between" loop shape.
1620
1704
  if (next.failedVerificationStreak) next.failedVerificationStreak = null;
1621
1705
  // TASK-026 fix round 1: bank the FIRST successful write to this target. Later replays of
@@ -2166,6 +2250,7 @@ export async function recordExecutionReceipt({
2166
2250
  const success = !failed && (exitCode === null || exitCode === 0);
2167
2251
  const toolInput = payload.tool_input || {};
2168
2252
  const evidence = receiptEvidenceAttestation(payload, toolInput);
2253
+ const workspaceFp = workspaceFingerprint(projectRoot);
2169
2254
  const receipt = {
2170
2255
  ts: Date.now(),
2171
2256
  toolName,
@@ -2203,10 +2288,18 @@ export async function recordExecutionReceipt({
2203
2288
  if (isVerificationCommand(toolInput.command)) {
2204
2289
  receipt.verdict = mintVerificationVerdict(receipt, payload, projectRoot);
2205
2290
  }
2291
+ } else if (toolName === 'Bash' && workspaceFp) {
2292
+ // Any other Bash: no evidence of its own. The receipt only carries the workspace
2293
+ // fingerprint so a verifiable tracked-file change can be attributed at apply time.
2294
+ receipt.kind = 'workspace';
2206
2295
  } else {
2207
2296
  // Untracked tool: no event, no lock, no write — same as the old unlocked early return.
2208
2297
  return { rejected: true, eventId: null };
2209
2298
  }
2299
+ if (workspaceFp) {
2300
+ receipt.workspaceFp = workspaceFp;
2301
+ if (toolName === 'Bash' && isReadOnlyBashCommand(toolInput.command)) receipt.bashReadOnly = true;
2302
+ }
2210
2303
  if (evidence) {
2211
2304
  receipt.evidence = evidence;
2212
2305
  }
@@ -2474,6 +2567,12 @@ function recoveryInstruction(missingEvidence, ledger = {}, routeSummary = {}, {
2474
2567
  return instruction ?? 'Complete the current routed milestone before stopping.';
2475
2568
  }
2476
2569
 
2570
+ // Write evidence that exists only as a Bash workspace delta is supporting evidence, labelled so
2571
+ // consumers never mistake it for tool-attributed proof. It never satisfies verification.
2572
+ function writeEvidenceTier(ledger = {}) {
2573
+ return ledger.inferredWrite === true && ledger.directWrite !== true ? { evidenceTier: 'inferred' } : {};
2574
+ }
2575
+
2477
2576
  export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
2478
2577
  const routeSummary = state?.routeSummary || {};
2479
2578
  // Explicit vibecode autonomy: the user asked UKit to run one prompt to a finished result,
@@ -2581,6 +2680,7 @@ export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
2581
2680
  complete: true,
2582
2681
  outcome: 'verified',
2583
2682
  missingEvidence: [],
2683
+ ...writeEvidenceTier(effectiveLedger),
2584
2684
  };
2585
2685
  }
2586
2686
  }
@@ -2588,7 +2688,7 @@ export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
2588
2688
  // Silent success: the route is present and every required evidence is satisfied. Marked
2589
2689
  // `complete` so the CLI dispatch recognizes it BEFORE the loud final else — otherwise a
2590
2690
  // bare loud else would turn a clean stop into user-visible noise.
2591
- return { continue: false, notify: false, complete: true, missingEvidence: [] };
2691
+ return { continue: false, notify: false, complete: true, missingEvidence: [], ...writeEvidenceTier(effectiveLedger) };
2592
2692
  }
2593
2693
 
2594
2694
  // `find-cause` can validly end clean: an investigation may establish that no actionable
@@ -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);