@ngockhoale/ukit 3.0.12 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +1 -0
  3. package/manifests/documentation.yaml +12 -0
  4. package/manifests/platform.full.yaml +24 -0
  5. package/package.json +1 -1
  6. package/scripts/bench/data-foundation.mjs +368 -50
  7. package/src/cli/commands/doctor.js +232 -3
  8. package/src/cli/commands/feedback.js +64 -1
  9. package/src/cli/commands/install.js +18 -0
  10. package/src/cli/commands/memory.js +42 -37
  11. package/src/cli/commands/telemetry.js +460 -0
  12. package/src/cli/index.js +7 -0
  13. package/src/core/agentRuntime/adapters.js +83 -2
  14. package/src/core/agentRuntime/diagnostics.js +104 -0
  15. package/src/core/agentRuntime/supervisor.js +137 -0
  16. package/src/core/agentRuntime/telemetry.js +204 -0
  17. package/src/core/memory/memoryEmit.js +131 -0
  18. package/src/core/memory/memoryHit.js +1 -1
  19. package/src/core/memory/migrate.js +18 -11
  20. package/src/core/memory/migrateMapping.js +15 -7
  21. package/src/core/memory/mutateMemory.js +22 -4
  22. package/src/core/memory/recordIndex.js +10 -3
  23. package/src/core/memory/recordStore.js +28 -3
  24. package/src/core/memory/retrieval.js +79 -38
  25. package/src/core/memory/store.js +37 -37
  26. package/src/core/memory/storeV2.js +28 -25
  27. package/src/core/memory/storeV2Loader.js +2 -2
  28. package/src/core/observability/adapters/ingest.js +576 -0
  29. package/src/core/observability/analytics/anomalies.js +415 -0
  30. package/src/core/observability/analytics/summary.js +16 -1
  31. package/src/core/observability/emit/config.js +69 -1
  32. package/src/core/observability/emit/crash.js +434 -0
  33. package/src/core/observability/emit/lifecycle.js +349 -0
  34. package/src/core/observability/emit/recorder.js +135 -9
  35. package/src/core/observability/evaluation/aiPacket.js +52 -10
  36. package/src/core/observability/evaluation/outcomes.js +95 -0
  37. package/src/core/observability/evaluation/runner.js +225 -0
  38. package/src/core/observability/privacy/allowlist.js +23 -3
  39. package/src/core/observability/schema/compatibility.js +48 -3
  40. package/src/core/observability/schema/constants.js +5 -0
  41. package/src/core/observability/schema/registry.js +57 -0
  42. package/src/core/observability/schema/validate.js +68 -6
  43. package/src/core/observability/segments/internal.js +42 -8
  44. package/src/core/observability/segments/readSegments.js +35 -1
  45. package/src/core/observability/segments/recovery.js +3 -2
  46. package/src/core/observability/segments/retention.js +137 -33
  47. package/src/core/observability/support/projector.js +88 -18
  48. package/src/core/observability/support/provision.js +160 -0
  49. package/src/core/observability/support/renderer.js +2 -2
  50. package/src/core/observability/support/schedule.js +174 -0
  51. package/src/decision/registry.js +144 -0
  52. package/src/decision/reviewVerdict.js +309 -0
  53. package/template_project/.claude/agents/code-reviewer.md +25 -1
  54. package/template_project/.claude/agents/ukit-small-task-maintainer.md +16 -0
  55. package/template_project/.claude/commands/ukit/handoff-fullstack.md +2 -0
  56. package/template_project/.claude/commands/ukit/handoff-review.md +12 -0
  57. package/template_project/.claude/hooks/auto-allow-bash.sh +7 -1
  58. package/template_project/.claude/hooks/auto-prune-bash.sh +16 -7
  59. package/template_project/.claude/hooks/verification-guard.sh +13 -4
  60. package/template_project/.claude/ukit/index/review-verdict.mjs +592 -0
  61. package/template_project/.claude/ukit/index/sidecar-decision.mjs +595 -0
  62. package/template_project/.claude/ukit/index/unic-decision.mjs +10 -1
  63. package/template_project/.claude/ukit/runtime/async-lock.mjs +26 -0
  64. package/template_project/.codex/settings.json +3 -0
  65. package/template_project/.omp/agents/code-reviewer.md +25 -1
  66. package/template_project/.omp/agents/ukit-small-task-maintainer.md +16 -0
@@ -0,0 +1,460 @@
1
+ // `ukit telemetry` (TASK-009, SPEC §5 DF2-FR09, §8, §9) — the maintainer
2
+ // surface over the local flight recorder.
3
+ //
4
+ // collect ingestStoredTelemetry → flush → maybeRefreshSupport (canary+)
5
+ // status [--json] stage, segments, counters, support lag, crashes
6
+ // digest [--json] rebuildIndex → anomalies + digest markdown
7
+ // export-support projectSupport now (explicit user act — works below canary)
8
+ // import <path> validateSupportBundle on a received bundle
9
+ // evaluate [--json] run the AI evaluator lane (provider-configured only)
10
+ //
11
+ // Exit codes: 0 success, 2 typed failure, 1 usage error. Read-only surfaces
12
+ // degrade to `unknown`, never to a fabricated zero; written output carries
13
+ // pseudonymous refs only (project dir is proj-<sha256>), no raw paths/secrets.
14
+
15
+ import fs from 'node:fs/promises';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+
19
+ import { inspectRuntimeConfig } from '../../core/runtimeConfig.js';
20
+ import { resolveStage } from '../../core/observability/emit/config.js';
21
+ import {
22
+ getRecorder,
23
+ recorderHealth,
24
+ observabilityRoot,
25
+ segmentsRoot,
26
+ } from '../../core/observability/emit/lifecycle.js';
27
+ import { readCrashReports } from '../../core/observability/emit/crash.js';
28
+ import { ingestStoredTelemetry } from '../../core/observability/adapters/ingest.js';
29
+ import { listSegments, totalSegmentBytes } from '../../core/observability/segments/internal.js';
30
+ import { readSegments } from '../../core/observability/segments/readSegments.js';
31
+ import { rebuildIndex } from '../../core/observability/analytics/rebuild.js';
32
+ import { renderStoreDigest } from '../../core/observability/analytics/storeDigest.js';
33
+ import { renderTraceDigest } from '../../core/observability/analytics/digest.js';
34
+ import {
35
+ projectSupport,
36
+ projectDirName,
37
+ SUPPORT_FORMAT,
38
+ } from '../../core/observability/support/projector.js';
39
+ import { maybeRefreshSupport } from '../../core/observability/support/schedule.js';
40
+ import { resolveSupportDir, SUPPORT_DIR_NAME } from '../../core/observability/support/paths.js';
41
+ import { validateSupportBundle } from '../../core/observability/support/import.js';
42
+ import { runEvaluation } from '../../core/observability/evaluation/runner.js';
43
+
44
+ const HELP_FLAGS = new Set(['--help', '-h', 'help']);
45
+ const SUBCOMMANDS = new Set(['collect', 'status', 'digest', 'export-support', 'import', 'evaluate']);
46
+ const JSON_SUBCOMMANDS = new Set(['status', 'digest', 'evaluate']);
47
+
48
+ // The automatic support refresh's canary gate lives in schedule.js via
49
+ // resolveSeamStages (projector seam minimum) — the CLI does not pre-gate.
50
+
51
+ const COLLECT_DEADLINE_MS = 60_000;
52
+ const FLUSH_DEADLINE_MS = 15_000;
53
+ const MAX_TRACE_DIGESTS = 8;
54
+ const MAX_ANOMALY_LINES = 20;
55
+
56
+ function printUsage() {
57
+ console.log('Usage: ukit telemetry <command> [options]');
58
+ console.log('');
59
+ console.log('Commands:');
60
+ console.log(' collect Ingest stored telemetry into the flight recorder,');
61
+ console.log(' flush it, and refresh the support view (stage canary+)');
62
+ console.log(' status Show stage, segments, counters, support lag, crashes');
63
+ console.log(' digest Rebuild the trace index; print anomalies + digest markdown');
64
+ console.log(' export-support Write the sanitized UKit Support bundle now');
65
+ console.log(' import <path> Validate a received support bundle (zip or directory)');
66
+ console.log(' evaluate Run the AI evaluator lane (needs observability.evaluator config)');
67
+ console.log('');
68
+ console.log('Options:');
69
+ console.log(' --json JSON output (status, digest, evaluate)');
70
+ console.log(' -h, --help Show this help');
71
+ }
72
+
73
+ // Invalid/missing config must not break the read path — stage resolves 'off'
74
+ // on an empty object, which is the fail-safe posture everywhere else.
75
+ async function loadConfig(projectRoot) {
76
+ try {
77
+ const inspection = await inspectRuntimeConfig(projectRoot, { homeDir: os.homedir() });
78
+ return inspection && inspection.config && typeof inspection.config === 'object'
79
+ ? inspection.config
80
+ : {};
81
+ } catch {
82
+ return {};
83
+ }
84
+ }
85
+
86
+ // Bundle-local pseudonym convention shared with the projector: the support
87
+ // view is addressed by hashed key, never by the project path — the
88
+ // projectDirName helper lives in support/projector.js.
89
+
90
+
91
+ function fmt(value) {
92
+ return value === null || value === undefined ? 'unknown' : String(value);
93
+ }
94
+
95
+ // --- status ------------------------------------------------------------------
96
+
97
+ async function supportLagMs(projectRoot) {
98
+ const resolved = await resolveSupportDir({});
99
+ if (!resolved.ok) return null;
100
+ const manifestPath = path.join(resolved.dir, projectDirName(projectRoot), 'manifest.json');
101
+ try {
102
+ const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
103
+ if (!manifest || manifest.format !== SUPPORT_FORMAT) return null;
104
+ const generated = Date.parse(manifest.generated_at);
105
+ return Number.isFinite(generated) ? Math.max(0, Date.now() - generated) : null;
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ async function statusCommand({ projectRoot, config, json }) {
112
+ const stage = resolveStage(config);
113
+ // getRecorder memoizes per root; in a one-shot CLI its counters read zero —
114
+ // that is honest per-process state, not a store claim.
115
+ getRecorder({ projectRoot, config });
116
+ const health = recorderHealth({ projectRoot });
117
+
118
+ const root = segmentsRoot(projectRoot);
119
+ const listing = await listSegments(root);
120
+ let active = null;
121
+ let sealed = null;
122
+ let bytes = null;
123
+ let records = null;
124
+ let readGaps = null;
125
+ if (listing.ok) {
126
+ active = listing.active ? 1 : 0;
127
+ sealed = listing.sealed.length;
128
+ bytes = totalSegmentBytes(listing);
129
+ const stream = readSegments(root);
130
+ let n = 0;
131
+ for await (const _record of stream) n += 1;
132
+ records = n;
133
+ const cov = stream.coverage;
134
+ readGaps =
135
+ (cov.corrupt_lines || 0) +
136
+ (cov.quarantined_segments || 0) +
137
+ (cov.expired_segments || 0) +
138
+ (Array.isArray(cov.degraded) ? cov.degraded.length : 0);
139
+ }
140
+
141
+ const lag = await supportLagMs(projectRoot);
142
+ const crashes = readCrashReports(observabilityRoot(projectRoot));
143
+ const crashCount = crashes.ok ? crashes.crashes.length : null;
144
+ const crashDetail = crashes.ok
145
+ ? { parsed: crashes.coverage.parsed, corrupt: crashes.coverage.corrupt, expired: crashes.coverage.expired }
146
+ : { error: crashes.reason };
147
+
148
+ if (json) {
149
+ console.log(
150
+ JSON.stringify(
151
+ {
152
+ stage,
153
+ segments: { active, sealed, bytes, records, read_gaps: readGaps },
154
+ counters: {
155
+ emitted: health.emitted ?? 0,
156
+ dropped: health.dropped ?? 0,
157
+ flushed: health.flushed ?? 0,
158
+ queue_depth: health.queue_depth ?? 0,
159
+ queue_bound: health.queue_bound ?? 0,
160
+ flushes: health.flushes ?? 0,
161
+ write_failures: health.write_failures ?? 0,
162
+ },
163
+ last_flush: health.last_flush ?? null,
164
+ support_lag_ms: lag,
165
+ crashes: { count: crashCount, ...crashDetail },
166
+ },
167
+ null,
168
+ 2,
169
+ ),
170
+ );
171
+ return;
172
+ }
173
+
174
+ console.log('telemetry status');
175
+ console.log(` stage: ${stage}`);
176
+ console.log(` segments: active=${fmt(active)} sealed=${fmt(sealed)} bytes=${fmt(bytes)}`);
177
+ console.log(
178
+ records === 0 && active === 0 && sealed === 0
179
+ ? ' records: 0 (no records retained)'
180
+ : ` records: ${fmt(records)}`,
181
+ );
182
+ console.log(
183
+ ` counters: emitted=${health.emitted ?? 0} dropped=${health.dropped ?? 0} ` +
184
+ `flushed=${health.flushed ?? 0} queue=${health.queue_depth ?? 0}/${health.queue_bound ?? 0}`,
185
+ );
186
+ const lastFlush = health.last_flush;
187
+ console.log(
188
+ lastFlush
189
+ ? ` last_flush: ${lastFlush.status} written=${lastFlush.written} dropped=${lastFlush.dropped}`
190
+ : ' last_flush: none',
191
+ );
192
+ console.log(` support_lag: ${fmt(lag === null ? null : `${lag}ms`)}`);
193
+ console.log(
194
+ crashCount === null
195
+ ? ` crashes: unknown (${crashDetail.error})`
196
+ : ` crashes: ${crashCount}`,
197
+ );
198
+ }
199
+
200
+ // --- collect -----------------------------------------------------------------
201
+ async function collectCommand({ projectRoot, config }) {
202
+ const stage = resolveStage(config);
203
+ const recorder = getRecorder({ projectRoot, config });
204
+ console.log(`telemetry collect: stage=${stage}`);
205
+
206
+ // Provision the recorder root: appendRecord's resolveRoot requires the
207
+ // parent to already exist, so a project whose .ukit was created by an
208
+ // older install has no observability dir yet. UKit-owned path, mkdir -p.
209
+ try {
210
+ await fs.mkdir(segmentsRoot(projectRoot), { recursive: true });
211
+ } catch {
212
+ // Unwritable roots surface as typed degrade on the flush/ingest lanes.
213
+ }
214
+
215
+ const ingest = await ingestStoredTelemetry({
216
+ projectRoot,
217
+ recorder,
218
+ deadlineMs: COLLECT_DEADLINE_MS,
219
+ });
220
+ if (!ingest || ingest.ok !== true) {
221
+ console.log(`collect: failed reason=${ingest && ingest.reason ? ingest.reason : 'unknown'}`);
222
+ process.exitCode = 2;
223
+ return;
224
+ }
225
+ for (const [name, cov] of Object.entries(ingest.coverage ?? {})) {
226
+ const reason = cov.reason ? ` reason=${cov.reason}` : '';
227
+ console.log(
228
+ ` ${name}: read=${cov.read ?? 0} emitted=${cov.emitted ?? 0} ` +
229
+ `rejected=${cov.rejected ?? 0} skipped=${cov.skipped ?? 0}${reason}`,
230
+ );
231
+ }
232
+
233
+ const flushRes = await recorder.flush({ deadlineMs: FLUSH_DEADLINE_MS });
234
+ const flushReason = flushRes.reason ? ` reason=${flushRes.reason}` : '';
235
+ console.log(
236
+ ` flush: ${flushRes.status} written=${flushRes.written ?? 0} ` +
237
+ `dropped=${flushRes.dropped ?? 0} remaining=${flushRes.remaining ?? 0}${flushReason}`,
238
+ );
239
+
240
+ // DF2-FR12: automatic support refresh goes through the schedule seam —
241
+ // projector stage gate + ≥15min interval gate + stamp. A manual
242
+ // `export-support` is an explicit user act and bypasses both gates.
243
+ const support = await maybeRefreshSupport({ projectRoot, config, recorder });
244
+ console.log(
245
+ ` support: ${support.status}` +
246
+ (support.status === 'refreshed' ? ` bytes=${support.bytes ?? 0}` : '') +
247
+ (support.reason ? ` reason=${support.reason}` : ''),
248
+ );
249
+
250
+ // Typed failure is about the collect lane itself: a degraded ingest or a
251
+ // failed flush lost/queued records. Support refresh is the optional
252
+ // add-on (DF2-FR12) — its degrade is reported, not promoted to exit 2;
253
+ // `export-support` is the command whose own mission is the projection.
254
+ const failed =
255
+ ingest.status === 'degraded' ||
256
+ ingest.status === 'partial' ||
257
+ flushRes.status === 'error' ||
258
+ flushRes.status === 'partial';
259
+ const cursorNote = ingest.cursor_error ? ` cursor_error=${ingest.cursor_error}` : '';
260
+ console.log(`status: ${failed ? 'degraded' : ingest.status}${cursorNote}`);
261
+ if (failed) process.exitCode = 2;
262
+ }
263
+
264
+ // --- digest ------------------------------------------------------------------
265
+
266
+ // Coarse per-trace anomaly roll-up mirroring digest.js anomalyLines: the
267
+ // detector set is the summary fields, not re-scanned records.
268
+ function traceAnomalies(summary) {
269
+ const s = summary && typeof summary === 'object' ? summary : {};
270
+ const kinds = [];
271
+ if (s.telemetry_complete === false) kinds.push('telemetry_incomplete');
272
+ const drops = s.drops && typeof s.drops === 'object' ? s.drops : {};
273
+ if ((drops.dropped_count ?? 0) > 0) kinds.push(`drops=${drops.dropped_count}`);
274
+ const retries = s.retries && typeof s.retries === 'object' ? s.retries : {};
275
+ if ((retries.failed_spans ?? 0) > 0) kinds.push(`failed_spans=${retries.failed_spans}`);
276
+ if ((retries.retries ?? 0) > 0) kinds.push(`retries=${retries.retries}`);
277
+ const cov = s.coverage && typeof s.coverage === 'object' ? s.coverage : {};
278
+ if ((cov.open_spans ?? 0) > 0) kinds.push(`open_spans=${cov.open_spans}`);
279
+ if ((cov.orphan_spans ?? 0) > 0) kinds.push(`orphan_spans=${cov.orphan_spans}`);
280
+ if ((cov.duplicate_ends ?? 0) > 0) kinds.push(`duplicate_ends=${cov.duplicate_ends}`);
281
+ const spans = Array.isArray(s.spans) ? s.spans : [];
282
+ const failed = spans.filter(
283
+ (sp) => sp && (sp.status === 'failed' || sp.status === 'blocked'),
284
+ ).length;
285
+ if (failed > 0) kinds.push(`span_status_failed=${failed}`);
286
+ return kinds;
287
+ }
288
+ async function digestCommand({ projectRoot, json }) {
289
+ const result = await rebuildIndex(segmentsRoot(projectRoot));
290
+ const summaries = Array.isArray(result.summaries) ? result.summaries : [];
291
+ const anomalies = summaries
292
+ .map((s) => ({ trace_id: s && s.trace_id ? s.trace_id : 'unknown', kinds: traceAnomalies(s) }))
293
+ .filter((a) => a.kinds.length > 0);
294
+
295
+ if (json) {
296
+ console.log(
297
+ JSON.stringify(
298
+ {
299
+ ok: result.ok === true,
300
+ coverage: result.coverage ?? {},
301
+ anomalies,
302
+ traces_total: summaries.length,
303
+ },
304
+ null,
305
+ 2,
306
+ ),
307
+ );
308
+ return;
309
+ }
310
+
311
+ const out = ['# telemetry digest', '', '## anomalies'];
312
+ const shown = anomalies.slice(0, MAX_ANOMALY_LINES);
313
+ if (shown.length === 0) {
314
+ out.push('- none');
315
+ } else {
316
+ for (const a of shown) out.push(`- ${a.trace_id} · ${a.kinds.join(', ')}`);
317
+ if (anomalies.length > shown.length) {
318
+ out.push(`- … truncated: ${anomalies.length - shown.length} anomalous trace(s) omitted`);
319
+ }
320
+ }
321
+ out.push('', renderStoreDigest(result));
322
+
323
+ // Per-trace digests for anomalous traces only — the store digest already
324
+ // carries every summary line; full per-trace dumps belong in the bundle.
325
+ const anomalousSummaries = anomalies.slice(0, MAX_TRACE_DIGESTS).map(
326
+ (a) => summaries.find((s) => (s && s.trace_id ? s.trace_id : 'unknown') === a.trace_id),
327
+ );
328
+ for (const s of anomalousSummaries) {
329
+ if (!s) continue;
330
+ out.push('', renderTraceDigest(s, { coverage: result.coverage }));
331
+ }
332
+ if (anomalies.length > MAX_TRACE_DIGESTS) {
333
+ out.push('', `_${anomalies.length - MAX_TRACE_DIGESTS} more anomalous trace digest(s) omitted_`);
334
+ }
335
+ console.log(out.join('\n'));
336
+ }
337
+
338
+ // --- export-support / import --------------------------------------------------
339
+
340
+ async function exportSupportCommand({ projectRoot, config }) {
341
+ // Manual export is an explicit user act: the stage gate governs automatic
342
+ // projection, so a stage-off config is lifted to 'default' for this call.
343
+ // Everything else (sanitizer, pseudonyms, caps) runs unchanged.
344
+ const effective =
345
+ resolveStage(config) === 'off'
346
+ ? { ...config, observability: { ...(config?.observability ?? {}), stage: 'default' } }
347
+ : config;
348
+ let res;
349
+ try {
350
+ res = await projectSupport(
351
+ { root: segmentsRoot(projectRoot) },
352
+ { config: effective, projectRoot },
353
+ );
354
+ } catch {
355
+ res = { status: 'degraded', reason: 'projector_error', bytes: 0 };
356
+ }
357
+ if (res.status === 'written') {
358
+ // Pseudonymous location only — the absolute path is user-private.
359
+ console.log(
360
+ `export-support: written bytes=${res.bytes ?? 0} ` +
361
+ `dir=${SUPPORT_DIR_NAME}/${projectDirName(projectRoot)}`,
362
+ );
363
+ return;
364
+ }
365
+ console.log(
366
+ `export-support: ${res.status}` +
367
+ (res.reason ? ` reason=${res.reason}` : '') +
368
+ ` bytes=${res.bytes ?? 0}`,
369
+ );
370
+ process.exitCode = 2;
371
+ }
372
+
373
+ async function importCommand({ bundlePath }) {
374
+ const res = await validateSupportBundle({ path: bundlePath });
375
+ if (!res.ok) {
376
+ // Typed reason verbatim — the maintainer keys remediation off it.
377
+ console.log(`import: ${res.reason}`);
378
+ process.exitCode = 2;
379
+ return;
380
+ }
381
+ const report = res.bundle.report;
382
+ console.log(
383
+ `import: ok files=${report.files} records=${report.records} ` +
384
+ `untrusted_prose=${report.untrusted_prose_present} ` +
385
+ `injection_signals=${report.injection_signals.length}`,
386
+ );
387
+ }
388
+
389
+ // --- evaluate -----------------------------------------------------------------
390
+
391
+ // DF2-FR14: the evaluator lane runs only when the operator configured
392
+ // observability.evaluator — skipped/no-provider is the honest default,
393
+ // never a failure. Degraded is a typed failure (exit 2), matching collect.
394
+ async function evaluateCommand({ projectRoot, config, json }) {
395
+ const result = await runEvaluation({ root: observabilityRoot(projectRoot), config });
396
+ if (json) {
397
+ console.log(JSON.stringify(result, null, 2));
398
+ } else {
399
+ const reason = result.reason ? ` reason=${result.reason}` : '';
400
+ console.log(
401
+ `telemetry evaluate: ${result.status}${reason} ` +
402
+ `packet_bytes=${result.packet_bytes ?? 0} ` +
403
+ `findings_accepted=${result.findings_accepted ?? 0} ` +
404
+ `findings_rejected=${result.findings_rejected ?? 0} ` +
405
+ `kb_records=${(result.kb_ids ?? []).length}`,
406
+ );
407
+ }
408
+ if (result.status === 'degraded') process.exitCode = 2;
409
+ }
410
+
411
+ // --- dispatch ----------------------------------------------------------------
412
+
413
+ export async function runTelemetry({ projectRoot, packageRoot, argv = [] }) {
414
+ const args = Array.isArray(argv) ? argv : [];
415
+ const sub = (args[0] ?? '').toLowerCase();
416
+
417
+ if (args.length === 0 || HELP_FLAGS.has(sub)) {
418
+ printUsage();
419
+ return;
420
+ }
421
+ if (!SUBCOMMANDS.has(sub)) {
422
+ console.error(`[UKit] Unknown telemetry subcommand: ${sub}`);
423
+ printUsage();
424
+ process.exitCode = 1;
425
+ return;
426
+ }
427
+
428
+ const rest = args.slice(1);
429
+ if (rest.some((a) => HELP_FLAGS.has(a))) {
430
+ printUsage();
431
+ return;
432
+ }
433
+ const positional = rest.filter((a) => !a.startsWith('-'));
434
+ const flags = rest.filter((a) => a.startsWith('-'));
435
+ const allowed = JSON_SUBCOMMANDS.has(sub) ? new Set(['--json']) : new Set();
436
+ const unknownFlags = flags.filter((f) => !allowed.has(f));
437
+ const maxPositional = sub === 'import' ? 1 : 0;
438
+
439
+ if (unknownFlags.length > 0) {
440
+ console.error(`[UKit] Unknown telemetry flag(s): ${unknownFlags.join(', ')}`);
441
+ printUsage();
442
+ process.exitCode = 1;
443
+ return;
444
+ }
445
+ if (positional.length > maxPositional || (sub === 'import' && positional.length === 0)) {
446
+ printUsage();
447
+ process.exitCode = 1;
448
+ return;
449
+ }
450
+
451
+ const config = await loadConfig(projectRoot);
452
+ const json = flags.includes('--json');
453
+
454
+ if (sub === 'collect') return collectCommand({ projectRoot, config });
455
+ if (sub === 'status') return statusCommand({ projectRoot, config, json });
456
+ if (sub === 'digest') return digestCommand({ projectRoot, json });
457
+ if (sub === 'export-support') return exportSupportCommand({ projectRoot, config });
458
+ if (sub === 'evaluate') return evaluateCommand({ projectRoot, config, json });
459
+ return importCommand({ bundlePath: positional[0] });
460
+ }
package/src/cli/index.js CHANGED
@@ -8,6 +8,7 @@ import { runMemory } from './commands/memory.js';
8
8
  import { runUpdate } from './commands/update.js';
9
9
  import { runCode } from './commands/code.js';
10
10
  import { runMetrics } from './commands/metrics.js';
11
+ import { runTelemetry } from './commands/telemetry.js';
11
12
  import { runFeedback } from './commands/feedback.js';
12
13
  import { runPlaybook } from './commands/playbook.js';
13
14
 
@@ -78,6 +79,11 @@ export async function runCli({ argv, packageRoot, projectRoot, packageVersion })
78
79
  return;
79
80
  }
80
81
 
82
+ if (command === 'telemetry') {
83
+ await runTelemetry({ projectRoot, packageRoot, argv: commandArgv });
84
+ return;
85
+ }
86
+
81
87
  if (command === 'feedback') {
82
88
  await runFeedback({ projectRoot, argv: commandArgv });
83
89
  return;
@@ -118,6 +124,7 @@ export async function runCli({ argv, packageRoot, projectRoot, packageVersion })
118
124
  console.log(' memory Inspect shared UKit memory');
119
125
  console.log(' playbook List/show playbooks (project > user > builtin)');
120
126
  console.log(' metrics Telemetry roll-up (route outcomes, failure patterns, memory)');
127
+ console.log(' telemetry Flight recorder (collect/status/digest/export-support/import/evaluate)');
121
128
  console.log(' feedback Record or list wrong-route feedback labels');
122
129
  console.log(' update Upgrade the global UKit CLI to the latest version');
123
130
  console.log(' version Show UKit version');
@@ -22,8 +22,10 @@
22
22
  * `PASSWORD=...`, `AUTHORIZATION: ...`) are redacted before any payload
23
23
  * or digest is built.
24
24
  *
25
- * Pure module: no imports of context/completionGate/qualityComparison.
26
- * `now` and `artifactStore` are injectable for tests.
25
+ * Pure module for summarization; the DF2-FR07 tool-span emit helpers
26
+ * (beginToolSpan/endToolSpan) own `tool.*` records and emit through the
27
+ * recorder via telemetry.js — synchronous, never-throw, stage-gated
28
+ * inside emit(). `now` and `artifactStore` are injectable for tests.
27
29
  *
28
30
  * `artifactStore` protocol (injected): `put(buffer)` →
29
31
  * `Promise<string | { path: string }>`; the returned ref is recorded on
@@ -37,6 +39,12 @@ import {
37
39
  CONTRACT_VERSION,
38
40
  MAX_SAFE_PAYLOAD_BYTES,
39
41
  } from './contract.js';
42
+ import {
43
+ makeSpanContext,
44
+ emitSpanRecord,
45
+ durationMs,
46
+ registryCode,
47
+ } from './telemetry.js';
40
48
 
41
49
  export const TOOL_PROFILES = Object.freeze(['generic', 'vitest']);
42
50
 
@@ -176,6 +184,59 @@ async function storeArtifact(artifactStore, buf) {
176
184
  }
177
185
  }
178
186
 
187
+ // --- DF2-FR07: tool.* span records (TASK-008) ------------------------------
188
+ // A tool span is a CHILD of the run's execution span — beginToolSpan is
189
+ // only ever called with a parent ctx emitted in the same trace, so
190
+ // parent_span_id always resolves (validator causality rule). Tool name is
191
+ // an opaque code string (`spec.toolName`, profile, or operationId) — raw
192
+ // command/argv/output are DENIED_PAYLOAD_FIELDS and never enter payloads.
193
+
194
+ /**
195
+ * Open a `tool.started` span under a run ctx. Returns the child span ctx
196
+ * (with start_ns) or null when telemetry is off — callers branch once on
197
+ * null, the emit path itself is never reached.
198
+ * @param {object|null} telemetry resolved lane (telemetry.js)
199
+ * @param {object} parent run span ctx {trace_id, span_id, execution_id, agent_id?}
200
+ * @param {object} [opts] {toolName?, operationId?, attempt?}
201
+ */
202
+ export function beginToolSpan(telemetry, parent, { toolName, operationId, attempt } = {}) {
203
+ if (telemetry == null || parent == null) return null;
204
+ const ctx = makeSpanContext(parent);
205
+ const name = typeof toolName === 'string' && toolName.length > 0
206
+ ? toolName
207
+ : String(operationId ?? 'tool');
208
+ const payload = { operation: name, tool_name: name };
209
+ if (Number.isInteger(attempt)) payload.attempt = attempt;
210
+ emitSpanRecord(telemetry, ctx, 'tool.started', payload);
211
+ return ctx;
212
+ }
213
+
214
+ /**
215
+ * Close a tool span. `status` is 'completed' | 'failed' | 'blocked'; a
216
+ * failure carries a registry error_code (UPPER_SNAKE only). Duration is
217
+ * measured from the begin() ctx's start_ns on the same injected clock.
218
+ * @param {object|null} telemetry
219
+ * @param {object} ctx span ctx returned by beginToolSpan
220
+ * @param {object} [opts] {status?, errorCode?, toolName?, operationId?, exitCode?}
221
+ */
222
+ export function endToolSpan(telemetry, ctx, { status = 'completed', errorCode, toolName, operationId, exitCode } = {}) {
223
+ if (telemetry == null || ctx == null) return;
224
+ const semantic = `tool.${status === 'completed' ? 'completed' : status === 'blocked' ? 'blocked' : 'failed'}`;
225
+ const name = typeof toolName === 'string' && toolName.length > 0
226
+ ? toolName
227
+ : String(operationId ?? 'tool');
228
+ const payload = {
229
+ operation: name,
230
+ tool_name: name,
231
+ duration_ms: durationMs(ctx.start_ns, ctx.nowNs ? ctx.nowNs() : null),
232
+ };
233
+ if (semantic === 'tool.failed') {
234
+ payload.error_code = registryCode(errorCode);
235
+ }
236
+ if (Number.isInteger(exitCode)) payload.exit_code = exitCode;
237
+ emitSpanRecord(telemetry, ctx, semantic, payload);
238
+ }
239
+
179
240
  /**
180
241
  * @param {object} args
181
242
  * @param {AsyncIterable<Buffer|string>} [args.stream] async-iterable output.
@@ -185,6 +246,11 @@ async function storeArtifact(artifactStore, buf) {
185
246
  * @param {number} [args.exitCode] child exit code when known.
186
247
  * @param {() => string} [args.now] injectable clock (ISO string).
187
248
  * @param {{put: (buf: Buffer) => Promise<string|{path:string}>}} [args.artifactStore]
249
+ * @param {object} [args.telemetry] resolved telemetry lane (DF2-FR07).
250
+ * @param {object} [args.toolSpan] open span ctx from beginToolSpan — when
251
+ * supplied, adaptOutput closes it as tool.completed/tool.failed using
252
+ * the summarized status (the adapter boundary IS the tool's outcome:
253
+ * 'failed' status or a throw inside adaptation → tool.failed).
188
254
  * @returns {Promise<{events: object[], artifactRefs: string[],
189
255
  * parseStatus: 'parsed'|'generic'|'parse_fallback'}>}
190
256
  */
@@ -196,6 +262,8 @@ export async function adaptOutput({
196
262
  exitCode,
197
263
  now = () => new Date().toISOString(),
198
264
  artifactStore,
265
+ telemetry,
266
+ toolSpan,
199
267
  } = {}) {
200
268
  const raw = await collectInput({ stream, chunks });
201
269
  const redacted = redactSecrets(raw.toString('utf8'));
@@ -250,5 +318,18 @@ export async function adaptOutput({
250
318
  };
251
319
  }
252
320
 
321
+ // DF2-FR07: the summarizer owns the tool invocation's terminal record.
322
+ // status 'failed' (or an unparseable/indeterminate verdict never fakes
323
+ // a completion — indeterminate still closes 'completed' because the
324
+ // invocation itself finished; the payload carries the real status).
325
+ if (toolSpan) {
326
+ endToolSpan(telemetry, toolSpan, {
327
+ status: safePayload.status === 'failed' ? 'failed' : 'completed',
328
+ errorCode: safePayload.status === 'failed' ? 'HOST_BLIND' : undefined,
329
+ toolName: safePayload.profile,
330
+ operationId,
331
+ exitCode,
332
+ });
333
+ }
253
334
  return { events: [event], artifactRefs, parseStatus };
254
335
  }