@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
@@ -14,6 +14,13 @@
14
14
  * Read-only guarantee: this module performs no writes, spawns, signals,
15
15
  * or enqueues. Journal + contract are the only inputs. It performs no
16
16
  * config reads (same convention as eventStore.js).
17
+ *
18
+ * DF2-FR07 (TASK-008): this module is ALSO the diagnostics-side emit
19
+ * surface — `emitModelAttempt` and `emitVerificationCompleted` turn a
20
+ * finished model attempt / verification gate outcome into canonical
21
+ * recorder records parented onto the caller's run span ctx. Emit helpers
22
+ * are synchronous, never throw, and never write to journals — the
23
+ * read-only contract above still holds for the two readers.
17
24
  */
18
25
 
19
26
  import { promises as fs } from 'node:fs';
@@ -26,6 +33,7 @@ import {
26
33
  validateTransition,
27
34
  } from './contract.js';
28
35
  import { readJournal, EventStoreError } from './eventStore.js';
36
+ import { makeSpanContext, emitSpanRecord, typedResource, registryCode } from './telemetry.js';
29
37
 
30
38
  export class DiagnosticsError extends Error {
31
39
  constructor(code, message) {
@@ -242,3 +250,99 @@ export async function replayOperation(dir, operationId, _opts = {}) {
242
250
  gaps: Object.freeze(gaps),
243
251
  });
244
252
  }
253
+
254
+ // --- DF2-FR07: model.* / verification.completed emit helpers ---------------
255
+ // The emit surface lives here (not on the read path): callers that own a
256
+ // model attempt or a verification gate call these with the run's span ctx
257
+ // (from supervisor.start().telemetry or a parent's ctx) so every record
258
+ // lands on the run's trace_id with a resolvable parent_span_id.
259
+ // DF-FR08 resource honesty is enforced by typedResource — absent usage
260
+ // is {value:null, source:'UNKNOWN'} + telemetry_complete:false, never 0.
261
+
262
+ function isPlainObject(value) {
263
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
264
+ }
265
+
266
+ function safeDurationMs(value) {
267
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0
268
+ ? Math.max(value, 1e-6)
269
+ : null;
270
+ }
271
+
272
+ /**
273
+ * Emit a `model.started` + `model.completed`/`model.failed` span pair for
274
+ * one model attempt. `ctx` is the parent (run) span ctx — the attempt
275
+ * span is a child of it. Returns the child ctx; null when ctx missing.
276
+ *
277
+ * @param {object} recorder live recorder (emit only — never flushed here)
278
+ * @param {object} args
279
+ * @param {object} args.ctx parent span ctx {trace_id, span_id, execution_id, agent_id?, nowNs?}
280
+ * @param {boolean} [args.ok=true] whether the attempt succeeded
281
+ * @param {number} [args.durationMs] caller-measured attempt duration (ms)
282
+ * @param {number} [args.attemptIndex] 1-based attempt index (retries >1)
283
+ * @param {{value:number|null, source:string}} [args.resource] DF-FR08 usage
284
+ * @param {string} [args.errorCode] registry reason code for failures
285
+ */
286
+ export function emitModelAttempt(recorder, {
287
+ ctx,
288
+ ok = true,
289
+ durationMs: duration,
290
+ attemptIndex,
291
+ resource,
292
+ errorCode,
293
+ } = {}) {
294
+ if (recorder == null || typeof recorder.emit !== 'function' || !isPlainObject(ctx)) {
295
+ return null;
296
+ }
297
+ const telemetry = { recorder };
298
+ const span = makeSpanContext(ctx);
299
+ const payload = { operation: 'model' };
300
+ if (Number.isInteger(attemptIndex)) payload.attempt_index = attemptIndex;
301
+ emitSpanRecord(telemetry, span, 'model.started', payload);
302
+ const end = { ...payload };
303
+ const measured = safeDurationMs(duration);
304
+ if (measured !== null) end.duration_ms = measured;
305
+ const typed = typedResource(resource);
306
+ end.resource = typed.resource;
307
+ end.telemetry_complete = typed.complete;
308
+ if (ok !== true) end.error_code = registryCode(errorCode);
309
+ emitSpanRecord(telemetry, span, ok === true ? 'model.completed' : 'model.failed', end);
310
+ return span;
311
+ }
312
+
313
+ /**
314
+ * Emit `verification.completed` for a verification gate that ran to a
315
+ * verdict — outcome is the gate's typed verdict string (never raw
316
+ * output), reason_code only from the frozen registry.
317
+ *
318
+ * @param {object} recorder
319
+ * @param {object} args
320
+ * @param {object} args.ctx parent span ctx
321
+ * @param {string} args.outcome verdict code ('passed'|'failed'|'indeterminate'|gate verdict)
322
+ * @param {number} [args.durationMs] gate duration in ms when measured
323
+ * @param {string} [args.reasonCode] registry code explaining the outcome
324
+ * @param {number} [args.attempt] verification attempt count
325
+ */
326
+ export function emitVerificationCompleted(recorder, {
327
+ ctx,
328
+ outcome,
329
+ durationMs: duration,
330
+ reasonCode,
331
+ attempt,
332
+ } = {}) {
333
+ if (recorder == null || typeof recorder.emit !== 'function' || !isPlainObject(ctx)) {
334
+ return null;
335
+ }
336
+ const telemetry = { recorder };
337
+ const span = makeSpanContext(ctx);
338
+ const payload = {
339
+ operation: 'verification',
340
+ outcome: typeof outcome === 'string' && outcome.length > 0 ? outcome : 'unknown',
341
+ };
342
+ const measured = safeDurationMs(duration);
343
+ if (measured !== null) payload.duration_ms = measured;
344
+ if (reasonCode != null) payload.reason_code = registryCode(reasonCode);
345
+ if (Number.isInteger(attempt)) payload.attempt = attempt;
346
+ emitSpanRecord(telemetry, span, 'verification.completed', payload);
347
+ return span;
348
+ }
@@ -34,6 +34,15 @@ import {
34
34
  import { classifyObservation } from './liveness.js';
35
35
  import { createArtifactWriter } from './artifacts.js';
36
36
  import { reconcileOwnedOperations } from './recovery.js';
37
+ import { beginToolSpan, endToolSpan } from './adapters.js';
38
+ import {
39
+ resolveTelemetry,
40
+ newRunContext,
41
+ emitSpanRecord,
42
+ durationMs,
43
+ reasonCodeForFailure,
44
+ projectRootFromRuntimeDir,
45
+ } from './telemetry.js';
37
46
 
38
47
  const SUPERVISOR_FLAG = 'decisionRuntime.supervisor.stage';
39
48
  // G6 (DR-09): `decisionRuntime.promotion.stage` — absent/malformed → 'off';
@@ -108,6 +117,66 @@ export function createSupervisor(opts = {}) {
108
117
  const resourcesFn = typeof opts.resourcesFn === 'function' ? opts.resourcesFn : () => ({});
109
118
  // Bounded receipt log — shadow-stage evidence only, never fs.
110
119
  const WAIT_RECEIPT_CAP = 128;
120
+
121
+ // DF2-FR07 telemetry lane — resolved once at creation; null = emit path
122
+ // never runs (zero per-event cost when no recorder exists). Stage gating
123
+ // stays inside recorder.emit per record (kill switch is live-read).
124
+ const telemetry = resolveTelemetry({
125
+ recorder: opts.telemetry?.recorder,
126
+ projectRoot: opts.telemetry?.projectRoot ?? projectRootFromRuntimeDir(runtimeDir),
127
+ config: opts.config ?? null,
128
+ env: opts.env,
129
+ clock: opts.telemetry?.clock,
130
+ });
131
+
132
+ /** Open the run's execution root span (emits execution.started). */
133
+ function beginExecutionSpan(spec) {
134
+ const ctx = newRunContext(telemetry, { traceId: spec?.traceId });
135
+ emitSpanRecord(telemetry, ctx, 'execution.started', {
136
+ operation: spec?.operationId ?? 'run',
137
+ duration_ms: null,
138
+ });
139
+ return ctx;
140
+ }
141
+
142
+ /** Close the run root span: completed | failed | blocked. */
143
+ function endExecutionSpan(ctx, status, payload = {}) {
144
+ if (!ctx) return;
145
+ emitSpanRecord(telemetry, ctx, `execution.${status}`, {
146
+ operation: payload.operation ?? ctx.operation ?? 'run',
147
+ duration_ms: durationMs(ctx.start_ns, telemetry.nowNs()),
148
+ ...payload,
149
+ });
150
+ }
151
+
152
+ /**
153
+ * Close tool + execution spans exactly once per op. `status` is the
154
+ * terminal outcome ('completed'|'failed'|'blocked'); `info` carries
155
+ * exit/cancel detail mapped onto the frozen reason vocabulary.
156
+ */
157
+ function closeOpSpans(op, status, { errorCode, reasonCode, exitCode } = {}) {
158
+ if (!telemetry || !op.telemetryCtx || op.spansClosed) return;
159
+ op.spansClosed = true;
160
+ // A blocked execution never spawned a tool — its tool span either
161
+ // does not exist or failed before the gate; 'blocked' only describes
162
+ // the execution end record.
163
+ const toolFailed = status === 'failed';
164
+ if (op.toolCtx) {
165
+ endToolSpan(telemetry, op.toolCtx, {
166
+ status: toolFailed ? 'failed' : 'completed',
167
+ errorCode: toolFailed ? errorCode : undefined,
168
+ toolName: op.spec?.toolName ?? op.spec?.sideEffectClass,
169
+ operationId: op.id,
170
+ exitCode,
171
+ });
172
+ }
173
+ endExecutionSpan(op.telemetryCtx, status, {
174
+ operation: op.id,
175
+ ...(toolFailed && errorCode != null ? { error_code: errorCode } : {}),
176
+ ...(reasonCode != null ? { reason_code: reasonCode } : {}),
177
+ ...(Number.isInteger(exitCode) ? { exit_code: exitCode } : {}),
178
+ });
179
+ }
111
180
  const waitReceipts = [];
112
181
 
113
182
  function recordWaitReceipt(entry) {
@@ -281,6 +350,10 @@ export function createSupervisor(opts = {}) {
281
350
  fencingEpoch,
282
351
  ownerEpoch,
283
352
  });
353
+ closeOpSpans(op, 'failed', {
354
+ errorCode: reasonCodeForFailure({ cancelReason: op.cancelReason ?? 'cancel_requested' }),
355
+ exitCode: op.exitInfo?.code,
356
+ });
284
357
  });
285
358
  }
286
359
  }, limits.killGraceMs));
@@ -389,6 +462,23 @@ export function createSupervisor(opts = {}) {
389
462
  const event = makeEvent(op, 'operation.transition', { from, to, ...extra }, op.artifactRefs);
390
463
  await append(runtimeDir, event);
391
464
  await writeOperationState(runtimeDir, op.id, { state: to, fencingEpoch, ownerEpoch });
465
+ // DF2-FR07: terminal transition committed — close tool + execution
466
+ // spans with the same outcome. Cancelled is a failed run with a
467
+ // USER_CANCELLED/TOOL_TIMEOUT code, never a silent open span.
468
+ if (to === 'completed') {
469
+ closeOpSpans(op, 'completed', { exitCode: op.exitInfo?.code });
470
+ } else {
471
+ closeOpSpans(op, 'failed', {
472
+ errorCode: reasonCodeForFailure({
473
+ cancelReason: to === 'cancelled' ? (op.cancelReason ?? 'cancel_requested') : undefined,
474
+ exitCode: op.exitInfo?.code,
475
+ signal: op.exitInfo?.signal,
476
+ errno: artErr,
477
+ code: artErr != null ? undefined : (op.cancelRequested ? 'cancelled' : 'exit_nonzero'),
478
+ }),
479
+ exitCode: op.exitInfo?.code,
480
+ });
481
+ }
392
482
  }
393
483
 
394
484
  function onExit(op, code, signal) {
@@ -411,10 +501,23 @@ export function createSupervisor(opts = {}) {
411
501
  * whole tree via `-pgid`.
412
502
  */
413
503
  async start(spec, policy = {}) {
504
+ // DF2-FR07: the run's execution root span opens at supervisor entry —
505
+ // BEFORE the preflight gates so a refused launch still lands an
506
+ // execution.blocked record on a real trace (flight recorder keeps
507
+ // refusals, not just runs).
508
+ const ctx = telemetry ? beginExecutionSpan(spec) : null;
414
509
  if (closed || launchesDisabled || !enabledByConfig()) {
510
+ endExecutionSpan(ctx, 'blocked', {
511
+ operation: spec?.operationId ?? 'run',
512
+ reason_code: 'GATE_BLOCKED',
513
+ });
415
514
  return unsupported('launch_disabled');
416
515
  }
417
516
  if (!specIsValid(spec)) {
517
+ endExecutionSpan(ctx, 'blocked', {
518
+ operation: 'run',
519
+ reason_code: 'SCHEMA_MISMATCH',
520
+ });
418
521
  return unsupported('invalid_spec');
419
522
  }
420
523
  // G6 promotion consult — shadow evaluates+records a receipt; served
@@ -447,6 +550,7 @@ export function createSupervisor(opts = {}) {
447
550
  artifacts: null,
448
551
  timers: new Set(),
449
552
  };
553
+ op.telemetryCtx = ctx;
450
554
  operations.set(op.id, op);
451
555
 
452
556
  await transition(op, 'starting', {
@@ -455,6 +559,16 @@ export function createSupervisor(opts = {}) {
455
559
  });
456
560
 
457
561
  let proc;
562
+ // DF2-FR07: the owned child process IS this run's tool invocation —
563
+ // tool.started opens right before spawn so a spawn throw still
564
+ // closes it as tool.failed (never an orphaned open span).
565
+ if (telemetry) {
566
+ op.toolCtx = beginToolSpan(telemetry, ctx, {
567
+ toolName: spec.toolName ?? spec.sideEffectClass,
568
+ operationId: spec.operationId,
569
+ attempt: spec.attempt,
570
+ });
571
+ }
458
572
  try {
459
573
  const argv = Array.isArray(spec.argv) && spec.argv.length > 0
460
574
  ? spec.argv
@@ -469,6 +583,19 @@ export function createSupervisor(opts = {}) {
469
583
  await transition(op, 'failed', {
470
584
  error: { code: 'spawn_failed', message: String(err?.message ?? err) },
471
585
  });
586
+ if (telemetry) {
587
+ const code = reasonCodeForFailure({ code: 'spawn_failed', errno: err?.code });
588
+ endToolSpan(telemetry, op.toolCtx, {
589
+ status: 'failed',
590
+ errorCode: code,
591
+ toolName: spec.toolName ?? spec.sideEffectClass,
592
+ operationId: spec.operationId,
593
+ });
594
+ endExecutionSpan(ctx, 'failed', {
595
+ operation: spec.operationId,
596
+ error_code: code,
597
+ });
598
+ }
472
599
  return { ok: false, code: 'spawn_failed' };
473
600
  }
474
601
 
@@ -511,6 +638,16 @@ export function createSupervisor(opts = {}) {
511
638
  get currentState() {
512
639
  return op.state;
513
640
  },
641
+ // DF2-FR07: the run's span ctx propagates to callers so tool/model/
642
+ // verification emits parent onto this span — the span-context
643
+ // convention the task's Interfaces contract specifies.
644
+ telemetry: ctx ? {
645
+ trace_id: ctx.trace_id,
646
+ span_id: ctx.span_id,
647
+ execution_id: ctx.execution_id,
648
+ ...(ctx.agent_id ? { agent_id: ctx.agent_id } : {}),
649
+ nowNs: ctx.nowNs,
650
+ } : null,
514
651
  };
515
652
  },
516
653
 
@@ -0,0 +1,204 @@
1
+ /**
2
+ * agentRuntime/telemetry.js (TASK-008, SPEC §5 DF2-FR07, DF-FR08) —
3
+ * span-context plumbing for live agentRuntime telemetry.
4
+ *
5
+ * resolveTelemetry({ recorder, config, env }) → ctx | null
6
+ * makeSpanContext(ctx, parentSpan) → child ctx
7
+ * emitSpanRecord(telemetry, ctx, semanticName, payload) → EmitResult
8
+ * durationMs(startNs, nowNs) → positive ms | null
9
+ * projectRootFromRuntimeDir(runtimeDir) → projectRoot | null
10
+ *
11
+ * Contracts:
12
+ * - Emission is fire-and-forget: every helper is synchronous, wraps
13
+ * recorder.emit in try/catch, and NEVER throws into task execution —
14
+ * a throwing recorder is a telemetry loss, not a runtime failure.
15
+ * - Span context is the run's wiring convention: {trace_id, span_id,
16
+ * parent_span_id, execution_id, agent_id?, nowNs}. A child ctx is
17
+ * made only from a real parent span — parent_span_id always resolves
18
+ * to a span emitted in the same trace (validator causality rule).
19
+ * - DF-FR08: model/tool end records carry typed resource provenance
20
+ * {value, source}; absent usage stays {value:null, source:'UNKNOWN'}
21
+ * with telemetry_complete:false — never 0, never fabricated.
22
+ * - agent_id (SPEC §14): UKIT_AGENT_ID env, else config.agent.id, else
23
+ * the field is ABSENT — never an empty string.
24
+ * - Stage 'off' is enforced inside recorder.emit per record; helpers
25
+ * never pre-judge the stage.
26
+ */
27
+
28
+ import crypto from 'node:crypto';
29
+ import path from 'node:path';
30
+
31
+ import { getRecorder } from '../observability/emit/lifecycle.js';
32
+ import { RESOURCE_SOURCES } from '../observability/schema/constants.js';
33
+ import { REASON_CODES } from '../observability/schema/registry.js';
34
+
35
+ /** Monotonic nanoseconds — BigInt where available (process.hrtime). */
36
+ export function nowNs() {
37
+ return typeof process.hrtime?.bigint === 'function'
38
+ ? process.hrtime.bigint()
39
+ : BigInt(Math.round(performance.now() * 1e6));
40
+ }
41
+
42
+ /**
43
+ * Measured span duration in ms. Sub-millisecond spans still report a
44
+ * positive duration — a closed span always took real time, so 0 would
45
+ * read as "unmeasured" downstream (summary.js treats duration>0 as real).
46
+ */
47
+ export function durationMs(startNs, endNs) {
48
+ if (typeof startNs !== 'bigint' || typeof endNs !== 'bigint') return null;
49
+ const delta = Number(endNs - startNs) / 1e6;
50
+ if (!Number.isFinite(delta) || delta < 0) return null;
51
+ return Math.max(delta, 1e-6);
52
+ }
53
+
54
+ function isNonEmptyString(value) {
55
+ return typeof value === 'string' && value.length > 0;
56
+ }
57
+
58
+ /**
59
+ * agent_id source (SPEC §14): UKIT_AGENT_ID env first, config.agent.id
60
+ * second, absent otherwise. The field is omitted entirely — never ''.
61
+ */
62
+ export function resolveAgentId({ config, env } = {}) {
63
+ const source = env && typeof env === 'object' ? env : process.env;
64
+ if (isNonEmptyString(source?.UKIT_AGENT_ID)) return source.UKIT_AGENT_ID;
65
+ const fromConfig = config?.agent?.id;
66
+ return isNonEmptyString(fromConfig) ? fromConfig : null;
67
+ }
68
+
69
+ /**
70
+ * `.ukit/storage/agent-runtime[/...]` → project root. Returns null when
71
+ * the layout does not match (tests, foreign dirs) — the caller then
72
+ * relies on an explicit recorder instead of guessing a root.
73
+ */
74
+ export function projectRootFromRuntimeDir(runtimeDir) {
75
+ if (typeof runtimeDir !== 'string' || runtimeDir === '') return null;
76
+ const resolved = path.resolve(runtimeDir);
77
+ let dir = resolved;
78
+ for (let i = 0; i < 8; i += 1) {
79
+ if (path.basename(dir) === '.ukit') return path.dirname(dir);
80
+ const parent = path.dirname(dir);
81
+ if (parent === dir) break;
82
+ dir = parent;
83
+ }
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * Build the telemetry lane for one supervisor/adapter site. Returns null
89
+ * when there is no recorder — the lane is then a guaranteed no-op with
90
+ * zero per-event cost (DF2-FR07 stage-off = zero records AND no emit
91
+ * path work). `recorder` wins; otherwise getRecorder memoizes per root.
92
+ */
93
+ export function resolveTelemetry({ recorder, projectRoot, config, env, clock } = {}) {
94
+ const rec = recorder ?? (projectRoot ? getRecorder({ projectRoot, config }) : null);
95
+ if (rec == null || typeof rec.emit !== 'function' || rec.disabled === true) return null;
96
+ const agent_id = resolveAgentId({ config, env });
97
+ return {
98
+ recorder: rec,
99
+ agent_id,
100
+ nowNs: clock && typeof clock.nowNs === 'function' ? clock.nowNs.bind(clock) : nowNs,
101
+ };
102
+ }
103
+
104
+ /** Root span context for one run — the trace_id owner. */
105
+ export function newRunContext(telemetry, { traceId } = {}) {
106
+ const trace_id = isNonEmptyString(traceId)
107
+ ? traceId
108
+ : `trace-${crypto.randomUUID()}`;
109
+ return {
110
+ trace_id,
111
+ span_id: `span-${crypto.randomUUID()}`,
112
+ parent_span_id: null,
113
+ execution_id: `exec-${crypto.randomUUID()}`,
114
+ ...(telemetry.agent_id ? { agent_id: telemetry.agent_id } : {}),
115
+ nowNs: telemetry.nowNs,
116
+ start_ns: telemetry.nowNs(),
117
+ };
118
+ }
119
+
120
+ /** Child ctx under a real parent span (tool/model/verification). */
121
+ export function makeSpanContext(parent) {
122
+ return {
123
+ trace_id: parent.trace_id,
124
+ span_id: `span-${crypto.randomUUID()}`,
125
+ parent_span_id: parent.span_id,
126
+ execution_id: parent.execution_id,
127
+ ...(parent.agent_id ? { agent_id: parent.agent_id } : {}),
128
+ nowNs: parent.nowNs,
129
+ start_ns: parent.nowNs ? parent.nowNs() : undefined,
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Emit one span/event record. Envelope ids come from ctx verbatim; the
135
+ * recorder fills record_id/sequence/timestamps and the registry privacy
136
+ * floor. Never throws — a throwing recorder returns a typed drop.
137
+ */
138
+ export function emitSpanRecord(telemetry, ctx, semanticName, payload) {
139
+ try {
140
+ return telemetry.recorder.emit({
141
+ semantic_name: semanticName,
142
+ trace_id: ctx.trace_id,
143
+ span_id: ctx.span_id,
144
+ parent_span_id: ctx.parent_span_id ?? null,
145
+ execution_id: ctx.execution_id,
146
+ ...(ctx.agent_id ? { agent_id: ctx.agent_id } : {}),
147
+ payload: payload ?? {},
148
+ });
149
+ } catch {
150
+ return { status: 'dropped', reason: 'emit-threw' };
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Map a supervisor failure/cancel code onto the frozen REASON_CODES
156
+ * vocabulary (task test 3: error_code is UPPER_SNAKE from the registry,
157
+ * never free text). Unclassifiable causes stay HOST_BLIND — the honest
158
+ * "we do not have a code" code — rather than a fabricated reason.
159
+ */
160
+ export function reasonCodeForFailure({ exitCode, signal, code, errno, cancelReason } = {}) {
161
+ if (errno === 'EACCES' || errno === 'EPERM') return 'PERMISSION_DENIED';
162
+ if (errno === 'ENOSPC') return 'DISK_FULL';
163
+ if (code === 'spawn_failed') return 'HOST_BLIND';
164
+ if (code === 'launch_disabled') return 'GATE_BLOCKED';
165
+ if (code === 'invalid_spec') return 'SCHEMA_MISMATCH';
166
+ if (cancelReason === 'wall_timeout' || cancelReason === 'stalled' || code === 'wall_timeout') {
167
+ return 'TOOL_TIMEOUT';
168
+ }
169
+ if (cancelReason != null || signal === 'SIGTERM' || signal === 'SIGKILL' || code === 'cancelled') {
170
+ return 'USER_CANCELLED';
171
+ }
172
+ if (code === 'ENOSPC') return 'DISK_FULL';
173
+ if (code === 'EACCES' || code === 'EPERM') return 'PERMISSION_DENIED';
174
+ if (typeof exitCode === 'number' && exitCode !== 0) return 'HOST_BLIND';
175
+ return 'HOST_BLIND';
176
+ }
177
+
178
+ /** Only registry-known codes may reach payload.error_code/reason_code. */
179
+ export function registryCode(code) {
180
+ return isNonEmptyString(code) && REASON_CODES[code] ? code : 'HOST_BLIND';
181
+ }
182
+
183
+ /**
184
+ * DF-FR08 typed resource provenance (routeAdapter parity):
185
+ * - caller-typed {value, source} with a real source → kept verbatim;
186
+ * - numeric value without a trustworthy source → ESTIMATED (a number
187
+ * exists, so UNKNOWN would be a lie; PROVIDER would claim an
188
+ * unobserved measurement);
189
+ * - no value → {value:null, source:'UNKNOWN'} — host-blind stays
190
+ * explicitly unknown, never 0.
191
+ * Returns { resource, complete } — complete=false marks the record as
192
+ * telemetry-blind for summarizeTrace.
193
+ */
194
+ export function typedResource(resource) {
195
+ const value = resource && typeof resource.value === 'number' && Number.isFinite(resource.value)
196
+ ? resource.value
197
+ : null;
198
+ const source = resource && RESOURCE_SOURCES.includes(resource.source)
199
+ ? resource.source
200
+ : null;
201
+ if (value === null) return { resource: { value: null, source: 'UNKNOWN' }, complete: false };
202
+ if (source && source !== 'UNKNOWN') return { resource: { value, source }, complete: true };
203
+ return { resource: { value, source: 'ESTIMATED' }, complete: true };
204
+ }
@@ -0,0 +1,131 @@
1
+ // memoryEmit.js (TASK-007, SPEC §5 DF2-FR08) — the single telemetry seam for
2
+ // memory/retrieval call sites. Every record here is metadata-only: counts,
3
+ // kind buckets, opaque sha256 refs, durations. Record text, query text,
4
+ // paths and ids never enter a payload — the allowlist is the backstop, this
5
+ // module is the discipline.
6
+ //
7
+ // Contract: O(1) hot path and never-throw. resolveStage(config) !== 'off'
8
+ // short-circuits BEFORE any hashing/payload work so an off stage costs one
9
+ // property walk; emit() itself already never throws and the wrapper swallows
10
+ // anyway (a telemetry fault can never break the write/read path).
11
+
12
+ import crypto from 'node:crypto';
13
+
14
+ import { getRecorder } from '../observability/emit/lifecycle.js';
15
+ import { resolveStage } from '../observability/emit/config.js';
16
+
17
+ const REFS_MAX = 32;
18
+
19
+ function sha256(text) {
20
+ return crypto.createHash('sha256').update(String(text)).digest('hex');
21
+ }
22
+
23
+ function kindBuckets(records) {
24
+ const kinds = {};
25
+ for (const record of records) {
26
+ const kind = typeof record?.type === 'string' ? record.type : 'unknown';
27
+ kinds[kind] = (kinds[kind] ?? 0) + 1;
28
+ }
29
+ return kinds;
30
+ }
31
+
32
+ function safeEmit(recorder, record) {
33
+ try {
34
+ recorder.emit(record);
35
+ } catch {
36
+ // Never-throw: a recorder fault must not break the memory path.
37
+ }
38
+ }
39
+
40
+ /**
41
+ * emitMemoryWrite({projectRoot, config, op, subject, recordCount, tombstoneCount})
42
+ * One `memory.write` record per persisted doc write. `subject` is the
43
+ * record(s) this write materially changed (or a raw id string) — hashed into
44
+ * opaque refs; `recordCount`/`tombstoneCount` describe the persisted doc.
45
+ */
46
+ export function emitMemoryWrite({
47
+ projectRoot,
48
+ config,
49
+ op,
50
+ subject,
51
+ recordCount,
52
+ tombstoneCount,
53
+ } = {}) {
54
+ if (resolveStage(config) === 'off') return;
55
+ const recorder = getRecorder({ projectRoot, config });
56
+ const list = (Array.isArray(subject) ? subject : subject == null ? [] : [subject])
57
+ .slice(0, REFS_MAX);
58
+ const refs = [];
59
+ for (const entry of list) {
60
+ const id = typeof entry === 'string' ? entry : entry?.id;
61
+ if (typeof id === 'string' && id.length > 0) refs.push(sha256(id));
62
+ }
63
+ safeEmit(recorder, {
64
+ semantic_name: 'memory.write',
65
+ payload: {
66
+ op: typeof op === 'string' ? op : 'write',
67
+ record_count: Number.isInteger(recordCount) ? recordCount : 0,
68
+ changed_count: refs.length,
69
+ kinds: kindBuckets(list.filter((e) => e != null && typeof e === 'object')),
70
+ record_refs: refs,
71
+ tombstone_count: Number.isInteger(tombstoneCount) ? tombstoneCount : 0,
72
+ },
73
+ });
74
+ }
75
+
76
+ /**
77
+ * emitRetrievalQuery({projectRoot, config, lane, scope, indexed,
78
+ * candidateCount, returnedCount, durationMs, freshness})
79
+ * One `retrieval.query` record per search() call — lanes 'v2'|'legacy',
80
+ * never the query text.
81
+ */
82
+ export function emitRetrievalQuery({
83
+ projectRoot,
84
+ config,
85
+ lane,
86
+ scope,
87
+ indexed,
88
+ candidateCount,
89
+ returnedCount,
90
+ durationMs,
91
+ freshness,
92
+ } = {}) {
93
+ if (resolveStage(config) === 'off') return;
94
+ const recorder = getRecorder({ projectRoot, config });
95
+ const buckets = freshness && typeof freshness === 'object' ? freshness : {};
96
+ safeEmit(recorder, {
97
+ semantic_name: 'retrieval.query',
98
+ payload: {
99
+ lane: lane === 'legacy' ? 'legacy' : 'v2',
100
+ scope: typeof scope === 'string' ? scope : 'all',
101
+ indexed: indexed === true,
102
+ candidate_count: Number.isInteger(candidateCount) ? candidateCount : 0,
103
+ returned_count: Number.isInteger(returnedCount) ? returnedCount : 0,
104
+ duration_ms: Number.isFinite(durationMs) && durationMs >= 0 ? durationMs : 0,
105
+ freshness: buckets,
106
+ },
107
+ });
108
+ }
109
+
110
+ /**
111
+ * emitMemoryRetrieved({projectRoot, config, hits}) — one `memory.retrieved`
112
+ * record per returned hit: kind + opaque ref only.
113
+ */
114
+ export function emitMemoryRetrieved({ projectRoot, config, hits } = {}) {
115
+ if (!Array.isArray(hits) || hits.length === 0) return;
116
+ if (resolveStage(config) === 'off') return;
117
+ const recorder = getRecorder({ projectRoot, config });
118
+ for (const hit of hits) {
119
+ const id = hit?.id;
120
+ if (typeof id !== 'string' || id.length === 0) continue;
121
+ safeEmit(recorder, {
122
+ semantic_name: 'memory.retrieved',
123
+ payload: {
124
+ kind: typeof hit.type === 'string' ? hit.type
125
+ : typeof hit.source === 'string' ? hit.source
126
+ : 'unknown',
127
+ record_ref: sha256(id),
128
+ },
129
+ });
130
+ }
131
+ }
@@ -111,7 +111,7 @@ export function rankHits(records, {
111
111
  if (!eligible(record, ctx, policy).ok) continue;
112
112
  if (!matchesRequestedScope(record, scope)) continue;
113
113
 
114
- const lexical = scoreRecordTokens(record, queryTokens);
114
+ const lexical = scoreRecordTokens(record, queryTokens, nowMs);
115
115
  const typeWeight = RECORD_TYPE_WEIGHTS[record.type] ?? 1;
116
116
  const typeBonus = typeWeight / TYPE_WEIGHT_SCALE;
117
117
  const evidenceBonus = hasLocatorEvidence(record) ? 1 : 0;