@ngockhoale/ukit 3.4.23 → 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.
@@ -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
+ }
@@ -3,6 +3,7 @@
3
3
  //
4
4
  // collect ingestStoredTelemetry → flush → maybeRefreshSupport (canary+)
5
5
  // status [--json] stage, segments, counters, support lag, crashes
6
+ // usage [--json] read-only usage-readiness report over the segment store
6
7
  // digest [--json] rebuildIndex → anomalies + digest markdown
7
8
  // export-support projectSupport now (explicit user act — works below canary)
8
9
  // import <path> validateSupportBundle on a received bundle
@@ -29,6 +30,10 @@ import { ingestStoredTelemetry } from '../../core/observability/adapters/ingest.
29
30
  import { listSegments, totalSegmentBytes } from '../../core/observability/segments/internal.js';
30
31
  import { readSegments } from '../../core/observability/segments/readSegments.js';
31
32
  import { rebuildIndex } from '../../core/observability/analytics/rebuild.js';
33
+ import {
34
+ buildUsageReadiness,
35
+ renderUsageReadiness,
36
+ } from '../../core/observability/analytics/usageReadiness.js';
32
37
  import { renderStoreDigest } from '../../core/observability/analytics/storeDigest.js';
33
38
  import { renderTraceDigest } from '../../core/observability/analytics/digest.js';
34
39
  import {
@@ -43,8 +48,8 @@ import { isValidOperationId } from '../../core/agentRuntime/eventStore.js';
43
48
  import { runEvaluation } from '../../core/observability/evaluation/runner.js';
44
49
 
45
50
  const HELP_FLAGS = new Set(['--help', '-h', 'help']);
46
- const SUBCOMMANDS = new Set(['collect', 'status', 'digest', 'export-support', 'export-run', 'import', 'evaluate']);
47
- const JSON_SUBCOMMANDS = new Set(['status', 'digest', 'evaluate']);
51
+ const SUBCOMMANDS = new Set(['collect', 'status', 'usage', 'digest', 'export-support', 'export-run', 'import', 'evaluate']);
52
+ const JSON_SUBCOMMANDS = new Set(['status', 'usage', 'digest', 'evaluate']);
48
53
 
49
54
  // The automatic support refresh's canary gate lives in schedule.js via
50
55
  // resolveSeamStages (projector seam minimum) — the CLI does not pre-gate.
@@ -61,6 +66,7 @@ function printUsage() {
61
66
  console.log(' collect Ingest stored telemetry into the flight recorder,');
62
67
  console.log(' flush it, and refresh the support view (stage canary+)');
63
68
  console.log(' status Show stage, segments, counters, support lag, crashes');
69
+ console.log(' usage Read-only usage readiness report over the segment store');
64
70
  console.log(' digest Rebuild the trace index; print anomalies + digest markdown');
65
71
  console.log(' export-support Write the sanitized UKit Support bundle now');
66
72
  console.log(' export-run <op> Write a sanitized per-run support bundle (V-06)');
@@ -68,7 +74,7 @@ function printUsage() {
68
74
  console.log(' evaluate Run the AI evaluator lane (needs observability.evaluator config)');
69
75
  console.log('');
70
76
  console.log('Options:');
71
- console.log(' --json JSON output (status, digest, evaluate)');
77
+ console.log(' --json JSON output (status, usage, digest, evaluate)');
72
78
  console.log(' -h, --help Show this help');
73
79
  }
74
80
 
@@ -337,6 +343,59 @@ async function digestCommand({ projectRoot, json }) {
337
343
  console.log(out.join('\n'));
338
344
  }
339
345
 
346
+ // --- usage --------------------------------------------------------------------
347
+
348
+ // Scan cap (SPEC FR-07): at most 200,000 records feed the report; the reader
349
+ // limit is one higher so a 200001st record can mark the scan as truncated.
350
+ const USAGE_SCAN_LIMIT = 200_000;
351
+
352
+ // Read-only readiness scan (TASK-C98-006, SPEC §5 FR-07): never getRecorder,
353
+ // never a flush, never mkdir, never a quarantine — the store looks after
354
+ // `usage` exactly as it looked before. Dispatched before loadConfig so the
355
+ // report cannot create a runtime config either.
356
+ async function usageCommand({ projectRoot, json }) {
357
+ const root = segmentsRoot(projectRoot);
358
+ const stream = readSegments(root, { readOnly: true, limit: USAGE_SCAN_LIMIT + 1 });
359
+ const records = [];
360
+ let truncated = false;
361
+ for await (const record of stream) {
362
+ if (records.length === USAGE_SCAN_LIMIT) {
363
+ truncated = true;
364
+ break;
365
+ }
366
+ records.push(record);
367
+ }
368
+ const report = buildUsageReadiness(records, {
369
+ coverage: stream.coverage,
370
+ truncated,
371
+ scanLimit: USAGE_SCAN_LIMIT,
372
+ });
373
+ if (json) console.log(JSON.stringify(report));
374
+ else console.log(renderUsageReadiness(report));
375
+
376
+ // Segment-level degradation stays exit 0 — the report itself declares the
377
+ // gap (unquarantined segments, degraded entries). Only a root-level degrade
378
+ // (the segments root itself unreadable, e.g. io_enotdir on a file at that
379
+ // path) is this command's typed failure; the resolveRoot io_enoent on a
380
+ // not-yet-existing tree is the missing-store success, not a failure
381
+ // (SPEC FR-07). resolveRoot reports the realpath'd root (macOS /var →
382
+ // /private/var), so compare against both spellings.
383
+ const degraded = Array.isArray(stream.coverage.degraded) ? stream.coverage.degraded : [];
384
+ let realRoot = root;
385
+ try {
386
+ realRoot = await fs.realpath(root);
387
+ } catch {
388
+ // Root absent (empty store) — the literal spelling is the only one.
389
+ }
390
+ const rootDegrade = degraded.find(
391
+ (e) => e && (e.file === root || e.file === realRoot) && e.reason !== 'io_enoent',
392
+ );
393
+ if (rootDegrade) {
394
+ console.error(`[UKit] telemetry usage failed: ${rootDegrade.reason}`);
395
+ process.exitCode = 2;
396
+ }
397
+ }
398
+
340
399
  // --- export-support / import --------------------------------------------------
341
400
 
342
401
  async function exportSupportCommand({ projectRoot, config }) {
@@ -506,9 +565,13 @@ export async function runTelemetry({ projectRoot, packageRoot, argv = [] }) {
506
565
  return;
507
566
  }
508
567
 
509
- const config = await loadConfig(projectRoot);
510
568
  const json = flags.includes('--json');
511
569
 
570
+ // Read-only scan — config is not needed and must not be created (FR-07).
571
+ if (sub === 'usage') return usageCommand({ projectRoot, json });
572
+
573
+ const config = await loadConfig(projectRoot);
574
+
512
575
  if (sub === 'collect') return collectCommand({ projectRoot, config });
513
576
  if (sub === 'status') return statusCommand({ projectRoot, config, json });
514
577
  if (sub === 'digest') return digestCommand({ projectRoot, json });
package/src/cli/index.js CHANGED
@@ -141,7 +141,7 @@ export async function runCli({ argv, packageRoot, projectRoot, packageVersion })
141
141
  console.log(' memory Inspect shared UKit memory');
142
142
  console.log(' playbook List/show playbooks (project > user > builtin)');
143
143
  console.log(' metrics Telemetry roll-up (route outcomes, failure patterns, memory)');
144
- console.log(' telemetry Flight recorder (collect/status/digest/export-support/import/evaluate)');
144
+ console.log(' telemetry Flight recorder (collect/status/usage/digest/export-support/import/evaluate)');
145
145
  console.log(' feedback Record or list wrong-route feedback labels');
146
146
  console.log(' self-improve Run the automatic learn/tune/collect pass now (hooks run it)');
147
147
  console.log(' vm Agent VM driver (list/show/status/shadow-run/evidence)');
@@ -29,9 +29,22 @@ import {
29
29
  createAdapterContext,
30
30
  emitRecord,
31
31
  } from './common.js';
32
+ import { USAGE_CATEGORY_KEYS, USAGE_CATEGORIES_FIELD } from '../schema/constants.js';
32
33
 
33
34
  const RESOURCE_SOURCES = new Set(['PROVIDER', 'LOCAL_TOKENIZER', 'ESTIMATED', 'UNKNOWN']);
34
35
 
36
+ // C98 FR-05 producer contract (SPEC §5): `ledger.model.usage.categories`
37
+ // carries camelCase counts normalized by the producer (freshInput/cacheRead/
38
+ // cacheWrite/output/reasoning); the adapter maps them to the schema category
39
+ // keys without interpreting provider-native names.
40
+ const USAGE_CATEGORY_INPUT_KEYS = Object.freeze({
41
+ freshInput: 'fresh_input_tokens',
42
+ cacheRead: 'cache_read_tokens',
43
+ cacheWrite: 'cache_write_tokens',
44
+ output: 'output_tokens',
45
+ reasoning: 'reasoning_tokens',
46
+ });
47
+
35
48
  function normalizeRow(row) {
36
49
  if (!isPlainObject(row)) return null;
37
50
  const audit = isPlainObject(row.audit) ? row.audit : row;
@@ -79,6 +92,29 @@ function modelResource(model) {
79
92
  return { value, source };
80
93
  }
81
94
 
95
+ // C98 FR-05: categories are provider-reported or unknown. A numeric value is
96
+ // accepted only when usage.source === 'PROVIDER'; any other source (incl.
97
+ // missing/UNKNOWN/ESTIMATED/LOCAL_TOKENIZER), absent key, or non-integer
98
+ // value stays `{ value: null, source: 'UNKNOWN' }` — never 0, never derived
99
+ // from the aggregate, never estimated. Returns null when no categories
100
+ // object is supplied, so the payload field is omitted entirely.
101
+ function usageCategories(model) {
102
+ const usage = isPlainObject(model.usage) ? model.usage : {};
103
+ if (!isPlainObject(usage.categories)) return null;
104
+ const categories = {};
105
+ for (const key of USAGE_CATEGORY_KEYS) {
106
+ categories[key] = { value: null, source: 'UNKNOWN' };
107
+ }
108
+ if (usage.source !== 'PROVIDER') return categories;
109
+ for (const [inputKey, categoryKey] of Object.entries(USAGE_CATEGORY_INPUT_KEYS)) {
110
+ const value = usage.categories[inputKey];
111
+ if (typeof value === 'number' && Number.isInteger(value) && value >= 0) {
112
+ categories[categoryKey] = { value, source: 'PROVIDER' };
113
+ }
114
+ }
115
+ return categories;
116
+ }
117
+
82
118
  export function adaptRouteOutcomes(rows, options = {}) {
83
119
  if (!Array.isArray(rows)) return [];
84
120
  const ctx = createAdapterContext({ writerId: 'adapter-route', ...options });
@@ -113,19 +149,22 @@ export function adaptRouteOutcomes(rows, options = {}) {
113
149
  const model = isPlainObject(ledger.model) ? ledger.model : null;
114
150
  if (model) {
115
151
  const resource = modelResource(model);
152
+ const categories = usageCategories(model);
116
153
  const failed = model.failed === true || asString(model.status) === 'failed';
154
+ const modelPayload = {
155
+ request_key: requestKey,
156
+ duration_ms:
157
+ typeof model.durationMs === 'number' && Number.isFinite(model.durationMs)
158
+ ? model.durationMs
159
+ : null,
160
+ resource,
161
+ telemetry_complete: resource.source !== 'UNKNOWN',
162
+ };
163
+ if (categories) modelPayload[USAGE_CATEGORIES_FIELD] = categories;
117
164
  const modelRecord = emitRecord(ctx, {
118
165
  semantic_name: failed ? 'model.failed' : 'model.completed',
119
166
  wall_time_utc: asIsoTime(audit.ts) ?? asIsoTime(ledger.updatedAt),
120
- payload: {
121
- request_key: requestKey,
122
- duration_ms:
123
- typeof model.durationMs === 'number' && Number.isFinite(model.durationMs)
124
- ? model.durationMs
125
- : null,
126
- resource,
127
- telemetry_complete: resource.source !== 'UNKNOWN',
128
- },
167
+ payload: modelPayload,
129
168
  });
130
169
  if (modelRecord) records.push(modelRecord);
131
170
  }