@ngockhoale/ukit 3.3.3 → 3.4.0

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 (89) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/manifests/engineConformance.yaml +17 -1
  3. package/manifests/hostCapabilities.yaml +68 -1
  4. package/manifests/platform.full.yaml +138 -0
  5. package/manifests/platform.user.yaml +255 -3
  6. package/package.json +1 -1
  7. package/scripts/bench/subagent-orchestrator-corpus.mjs +275 -0
  8. package/scripts/bench/subagent-orchestrator-eval.mjs +565 -0
  9. package/scripts/probe/codex-capability-probe.mjs +169 -0
  10. package/src/cli/commands/doctor.js +168 -0
  11. package/src/cli/commands/indexTools.js +7 -0
  12. package/src/cli/commands/metrics.js +66 -2
  13. package/src/cli/commands/playbook.js +4 -4
  14. package/src/cli/commands/vm.js +49 -8
  15. package/src/core/agentRuntime/adapters.js +328 -27
  16. package/src/core/agentRuntime/artifacts.js +89 -0
  17. package/src/core/agentRuntime/context.js +345 -1
  18. package/src/core/agentRuntime/contract.js +296 -0
  19. package/src/core/agentRuntime/eventStore.js +176 -0
  20. package/src/core/agentRuntime/shadowRun.js +481 -5
  21. package/src/core/agentRuntime/telemetry.js +121 -0
  22. package/src/core/observability/emit/lifecycle.js +68 -1
  23. package/src/core/observability/emit/sessionBoot.js +393 -0
  24. package/src/core/observability/privacy/allowlist.js +10 -1
  25. package/src/core/observability/schema/registry.js +10 -0
  26. package/src/core/runtimeConfig.js +133 -0
  27. package/src/core/userPlaybooks.js +18 -3
  28. package/src/decision/registry.js +19 -0
  29. package/src/diagnostics/feedbackEvents.js +7 -4
  30. package/src/diagnostics/routeOutcomes.js +51 -6
  31. package/src/diagnostics/skillAccuracy.js +43 -3
  32. package/src/index/crossCheckMatrix.js +412 -0
  33. package/src/index/fixLoopEscalation.js +453 -0
  34. package/src/index/playbookRegistry.js +691 -0
  35. package/src/index/reviewPolicy.js +368 -0
  36. package/src/index/routeResolver.js +915 -0
  37. package/src/index/sessionHistoryExtractor.js +359 -0
  38. package/src/index/taskRouting.js +764 -581
  39. package/src/index/tierSelection.js +308 -0
  40. package/src/index/verificationMap.js +404 -0
  41. package/template_project/.claude/hooks/observability-emit.mjs +14 -0
  42. package/template_project/.claude/hooks/record-execution.mjs +19 -1
  43. package/template_project/.claude/hooks/skill-router.sh +691 -25
  44. package/template_project/.claude/hooks/verification-guard.sh +230 -1
  45. package/template_project/.claude/settings.json +2 -2
  46. package/template_project/.claude/ukit/index/cross-check-matrix.mjs +415 -0
  47. package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +456 -0
  48. package/template_project/.claude/ukit/index/playbook-registry.mjs +690 -0
  49. package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +20 -2
  50. package/template_project/.claude/ukit/index/review-policy.mjs +376 -0
  51. package/template_project/.claude/ukit/index/route-resolver.mjs +1059 -0
  52. package/template_project/.claude/ukit/index/route-task.mjs +1253 -846
  53. package/template_project/.claude/ukit/index/session-history-extractor.mjs +362 -0
  54. package/template_project/.claude/ukit/index/tier-selection.mjs +309 -0
  55. package/template_project/.claude/ukit/index/verification-map.mjs +403 -0
  56. package/template_project/.claude/ukit/index/worktree-sweep.mjs +195 -0
  57. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +789 -11
  58. package/template_project/.claude/ukit/runtime/observability-emit.mjs +1102 -0
  59. package/template_project/.claude/ukit/runtime/reinject-context.mjs +9 -1
  60. package/template_project/.claude/ukit/runtime/resumable-run.mjs +149 -5
  61. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +323 -6
  62. package/template_project/.codex/README.md +8 -0
  63. package/template_project/.omp/hooks/pre/ukit-bridge.js +8 -1
  64. package/template_project/ukit/README.md +1 -1
  65. package/template_project/ukit/storage/config.json +20 -0
  66. package/template_user/playbooks/architecture-decision.md +28 -0
  67. package/template_user/playbooks/autonomous-run.md +43 -0
  68. package/template_user/playbooks/autopilot-full.md +59 -0
  69. package/template_user/playbooks/autopilot-stack.md +54 -0
  70. package/template_user/playbooks/babysit.md +39 -0
  71. package/template_user/playbooks/bug-fix.md +3 -1
  72. package/template_user/playbooks/{issue-implementation.md → feature-implementation.md} +4 -2
  73. package/template_user/playbooks/hillclimb.md +44 -0
  74. package/template_user/playbooks/investigation.md +21 -0
  75. package/template_user/playbooks/migration.md +21 -0
  76. package/template_user/playbooks/open-pr.md +48 -0
  77. package/template_user/playbooks/orchestrate.md +45 -0
  78. package/template_user/playbooks/performance.md +33 -0
  79. package/template_user/playbooks/prototype.md +28 -0
  80. package/template_user/playbooks/refactor.md +19 -0
  81. package/template_user/playbooks/release.md +28 -0
  82. package/template_user/playbooks/runtime-forensics.md +23 -0
  83. package/template_user/playbooks/session-pickup.md +31 -0
  84. package/template_user/playbooks/shipping.md +53 -0
  85. package/template_user/playbooks/skill-evaluation.md +48 -0
  86. package/template_user/playbooks/small-feature.md +20 -0
  87. package/template_user/playbooks/verification-map.json +153 -0
  88. package/template_user/playbooks/verification.md +22 -0
  89. package/template_user/playbooks/worktree-cleanup.md +37 -0
@@ -60,8 +60,8 @@
60
60
  * No LLM, no daemon, no sync fs on the hot path. The module never throws.
61
61
  */
62
62
 
63
+ import crypto from 'node:crypto';
63
64
  import path from 'node:path';
64
-
65
65
  import { createRecorder } from './recorder.js';
66
66
  import { registerCrashCapture } from './crash.js';
67
67
  import { resolveStage } from './config.js';
@@ -101,6 +101,73 @@ function isNonEmptyString(value) {
101
101
  return typeof value === 'string' && value.length > 0;
102
102
  }
103
103
 
104
+ // --- FR-003 (BL-005): shared semantic names + record builder --------------
105
+ //
106
+ // The parity-locked pair src/core/observability/emit/sessionBoot.js ↔
107
+ // template_project/.claude/ukit/runtime/observability-emit.mjs emits the
108
+ // same record shape the recorder's startSpan() produces (recorder.js) —
109
+ // these names are the single canonical vocabulary for both emit paths.
110
+
111
+ export const SEMANTIC_EXECUTION_STARTED = 'execution.started';
112
+ export const SEMANTIC_EXECUTION_COMPLETED = 'execution.completed';
113
+ export const SEMANTIC_EXECUTION_FAILED = 'execution.failed';
114
+ export const SEMANTIC_EXECUTION_BLOCKED = 'execution.blocked';
115
+ export const SEMANTIC_OUTCOME_OBSERVED = 'outcome.observed';
116
+
117
+ /**
118
+ * Deterministic session-boot join key. `payload.request_key` on the boot
119
+ * record is derived (not random) so terminal emitters can recompute the
120
+ * same key for the session: sha256('<projectRoot>\n<sessionId>'), 24 hex.
121
+ */
122
+ export function sessionBootRequestKey({ projectRoot, sessionId } = {}) {
123
+ const digest = crypto
124
+ .createHash('sha256')
125
+ .update(`${String(projectRoot ?? '')}\n${String(sessionId ?? '')}`)
126
+ .digest('hex')
127
+ .slice(0, 24);
128
+ return `session-boot-${digest}`;
129
+ }
130
+
131
+ /**
132
+ * The ONE session-boot `execution.started` record shape. Same field
133
+ * ordering contract as the recorder's own span-root emit: fresh
134
+ * trace/span/execution ids, caller's session/engine stamped in both the
135
+ * envelope and the payload, wall-clock `ts` echoed inside payload for the
136
+ * join tooling that never parses envelope timestamps.
137
+ * Returns null for a missing projectRoot (callers must never fabricate
138
+ * a storage root) — the emit layer maps that to a typed drop.
139
+ */
140
+ export function buildSessionBootRecord({
141
+ projectRoot,
142
+ engine,
143
+ sessionId,
144
+ nowMs = Date.now(),
145
+ } = {}) {
146
+ if (!isNonEmptyString(projectRoot)) return null;
147
+ const trace_id = `trace-${crypto.randomUUID()}`;
148
+ return {
149
+ semantic_name: SEMANTIC_EXECUTION_STARTED,
150
+ trace_id,
151
+ span_id: `span-${crypto.randomUUID()}`,
152
+ parent_span_id: null,
153
+ execution_id: `exec-${crypto.randomUUID()}`,
154
+ session_id: isNonEmptyString(sessionId) ? sessionId : undefined,
155
+ project_ref: `proj-${crypto
156
+ .createHash('sha256')
157
+ .update(String(path.resolve(projectRoot)))
158
+ .digest('hex')
159
+ .slice(0, 12)}`,
160
+ payload: {
161
+ operation: 'session-boot',
162
+ duration_ms: null,
163
+ engine: isNonEmptyString(engine) ? engine : 'unknown',
164
+ sessionId: isNonEmptyString(sessionId) ? sessionId : 'unknown',
165
+ ts: new Date(nowMs).toISOString(),
166
+ request_key: sessionBootRequestKey({ projectRoot, sessionId }),
167
+ },
168
+ };
169
+ }
170
+
104
171
  // --- disabled recorder (SPEC §8: missing root → emit→dropped/recorder-disabled)
105
172
 
106
173
  function makeDisabledRecorder() {
@@ -0,0 +1,393 @@
1
+ /**
2
+ * sessionBoot.js (TASK-003, SPEC §5 FR-003, §6, §14) — the live session-boot
3
+ * emit surface: the production caller the recorder layer never had.
4
+ *
5
+ * emitSessionBoot({ projectRoot, engine, sessionId, config?, deps? })
6
+ * → { status: 'written', written, elapsed_ms }
7
+ * | { status: 'dropped'|'partial'|'error', reason }
8
+ * emitOutcomeObserved({ projectRoot, record, config?, deps? }) → same result
9
+ * resolveEmitStage(config) → 'off' | 'shadow' | 'canary' | 'default'
10
+ * loadEmitConfig(projectRoot) → merged emit config (async, never throws)
11
+ *
12
+ * A hook child process lives for milliseconds — `startLifecycle`'s interval
13
+ * timers and beforeExit flush cannot survive it (SPEC §14 chose the bounded
14
+ * one-shot emit). One call = one recorder = one record = one flush, each
15
+ * step racing a deadline, so the emit can never delay session start: it is
16
+ * fire-and-forget, bounded, and never throws.
17
+ *
18
+ * Stage contract: this surface emits by default — the shipped
19
+ * `.ukit/storage/config.json` sets observability.stage='default' and the
20
+ * runtime default config agrees — so 'off' is an explicit kill switch the
21
+ * owner sets; a missing config file or missing stage emits (the same
22
+ * "default" the rest of the runtime resolves to). A caller-supplied
23
+ * `config` object is used as the complete merged config, so callers that
24
+ * want the live stage pass the runtime config themselves; absent config
25
+ * resolves the 'default' effective stage.
26
+ *
27
+ * The parity-locked mirror
28
+ * template_project/.claude/ukit/runtime/observability-emit.mjs cannot
29
+ * import package source — it replicates this pipeline (envelope fill →
30
+ * validate → sanitize → bounded append) with identical semantics. Keep the
31
+ * record shape changes coordinated with `tests/consistency/
32
+ * sessionBootEmitParity.test.js`.
33
+ */
34
+
35
+ import crypto from 'node:crypto';
36
+ import fs from 'node:fs';
37
+ import path from 'node:path';
38
+
39
+ import { createRecorder } from './recorder.js';
40
+ import {
41
+ buildSessionBootRecord,
42
+ segmentsRoot,
43
+ sessionBootRequestKey,
44
+ SEMANTIC_OUTCOME_OBSERVED,
45
+ } from './lifecycle.js';
46
+ import { resolveStage } from './config.js';
47
+
48
+ /** Hard upper bound for the whole one-shot emit (ms). */
49
+ export const SESSION_BOOT_EMIT_DEADLINE_MS = 250;
50
+
51
+ const VALID_STAGES = new Set(['off', 'shadow', 'canary', 'default']);
52
+
53
+ function isPlainObject(value) {
54
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
55
+ }
56
+
57
+ function isNonEmptyString(value) {
58
+ return typeof value === 'string' && value.length > 0;
59
+ }
60
+
61
+ /**
62
+ * The emit surface's effective stage: the explicit literal 'off' is the
63
+ * kill switch; any other explicit stage value resolves through
64
+ * resolveStage verbatim; absent/malformed resolves 'default' (the shipped
65
+ * + code default), never the recorder's fail-safe 'off'.
66
+ */
67
+ export function resolveEmitStage(config = null) {
68
+ const node = isPlainObject(config) ? config.observability : undefined;
69
+ if (isPlainObject(node) && node.stage === 'off') return 'off';
70
+ const stage = resolveStage(config);
71
+ return stage === 'off' ? 'default' : stage;
72
+ }
73
+
74
+ /**
75
+ * `.ukit/storage/config.json` on disk → emit config. A missing/corrupt
76
+ * file means a pre-install or bare project: effective stage 'default'
77
+ * (the shipped value), mirroring the runtime's own code default.
78
+ */
79
+ export async function loadEmitConfig(projectRoot) {
80
+ if (!isNonEmptyString(projectRoot)) return null;
81
+ try {
82
+ const raw = await fs.promises.readFile(
83
+ path.join(projectRoot, '.ukit', 'storage', 'config.json'),
84
+ 'utf8',
85
+ );
86
+ const parsed = JSON.parse(raw);
87
+ return isPlainObject(parsed) ? parsed : null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Race a promise against a deadline; the loser keeps running detached
95
+ * (bounded telemetry accepts a late write over blocking the caller).
96
+ * Returns { value } | { timedOut: true }. The timer is unref'd so it can
97
+ * never pin a dying hook process.
98
+ */
99
+ async function bounded(promise, deadlineMs) {
100
+ if (!Number.isFinite(deadlineMs) || deadlineMs <= 0) {
101
+ return { timedOut: true };
102
+ }
103
+ let timer;
104
+ try {
105
+ const result = await Promise.race([
106
+ Promise.resolve(promise).then((value) => ({ value })),
107
+ new Promise((resolve) => {
108
+ timer = setTimeout(() => resolve({ timedOut: true }), deadlineMs);
109
+ if (timer.unref) timer.unref();
110
+ }),
111
+ ]);
112
+ return result;
113
+ } finally {
114
+ clearTimeout(timer);
115
+ }
116
+ }
117
+
118
+ function toEmitResult(result, elapsed_ms) {
119
+ const out = { ...result };
120
+ out.elapsed_ms = Math.max(0, elapsed_ms);
121
+ return out;
122
+ }
123
+
124
+ /**
125
+ * The shared one-shot emit pipeline. `record` is a semantic-record
126
+ * candidate; the recorder fills envelope fields, validates, sanitizes,
127
+ * applies the sampling decider, and flush() drains through the segment
128
+ * writer — the twin never reimplements any of those stages.
129
+ */
130
+ async function emitOne({ projectRoot, config, record, deps, deadlineMs }) {
131
+ const started = Date.now();
132
+ const fail = (reason) => toEmitResult({ status: 'dropped', reason }, Date.now() - started);
133
+
134
+ if (!isNonEmptyString(projectRoot)) return fail('missing-project-root');
135
+ if (!isPlainObject(record)) return fail('invalid-record');
136
+ if (resolveEmitStage(config) === 'off') return fail('stage-off');
137
+
138
+ const injected = isPlainObject(deps) ? deps : {};
139
+ const clock = {
140
+ nowMs: typeof injected.nowMs === 'function' ? injected.nowMs : () => injected.nowMs ?? Date.now(),
141
+ nowNs:
142
+ typeof injected.nowNs === 'function'
143
+ ? injected.nowNs
144
+ : () => injected.nowNs ?? process.hrtime.bigint(),
145
+ iso:
146
+ typeof injected.iso === 'function'
147
+ ? injected.iso
148
+ : () => injected.iso ?? new Date().toISOString(),
149
+ };
150
+ const writer =
151
+ typeof injected.writer === 'function' ? injected.writer : undefined;
152
+ const samplingSeed = Number.isFinite(injected.samplingSeed) ? injected.samplingSeed : undefined;
153
+
154
+ // resolveEmitStage owns the 'default'-absent asymmetry: the effective
155
+ // stage written back here is 'off' only when the owner set the literal
156
+ // 'off' (that case already returned above); an absent or malformed stage
157
+ // resolves 'default' — the shipped + code default — while a supplied
158
+ // 'shadow'/'canary' passes through verbatim. Other observability keys
159
+ // (sampling, seams) ride along untouched for the recorder's own gates.
160
+ const effectiveConfig = isPlainObject(config)
161
+ ? {
162
+ ...config,
163
+ observability: {
164
+ ...(isPlainObject(config.observability) ? config.observability : {}),
165
+ stage: resolveEmitStage(config),
166
+ },
167
+ }
168
+ : { observability: { stage: 'default' } };
169
+
170
+ const budget = Number.isFinite(deadlineMs) && deadlineMs > 0
171
+ ? Math.min(deadlineMs, SESSION_BOOT_EMIT_DEADLINE_MS)
172
+ : SESSION_BOOT_EMIT_DEADLINE_MS;
173
+
174
+ // appendRecord's resolveRoot requires the segments root's PARENT to
175
+ // already exist (realpath containment), so a fresh project needs the
176
+ // .ukit/storage/observability tree created here — bounded by the same
177
+ // deadline so a stalled mount can never pin the hook.
178
+ const targetRoot = segmentsRoot(path.resolve(projectRoot));
179
+ const seeded = await bounded(
180
+ fs.promises.mkdir(targetRoot, { recursive: true }).catch((err) => err),
181
+ budget,
182
+ );
183
+ if (seeded.timedOut) {
184
+ return toEmitResult({ status: 'partial', reason: 'deadline' }, Date.now() - started);
185
+ }
186
+ try {
187
+ if (seeded.value instanceof Error) throw seeded.value;
188
+ } catch {
189
+ // mkdir raced with another process or failed — fall through and let
190
+ // the write path report the typed io reason.
191
+ }
192
+ try {
193
+ const recorder = createRecorder({
194
+ root: segmentsRoot(path.resolve(projectRoot)),
195
+ config: effectiveConfig,
196
+ clock,
197
+ ...(writer ? { writer } : {}),
198
+ ...(samplingSeed !== undefined ? { samplingSeed } : {}),
199
+ });
200
+ const emitted = recorder.emit(record);
201
+ if (emitted.status !== 'accepted') {
202
+ return fail(emitted.reason || 'dropped');
203
+ }
204
+ const flushed = await bounded(recorder.flush({ deadlineMs: budget }), budget);
205
+ if (flushed.timedOut) {
206
+ return toEmitResult({ status: 'partial', reason: 'deadline' }, Date.now() - started);
207
+ }
208
+ const result = flushed.value;
209
+ if (result.status === 'ok' && result.written > 0) {
210
+ return toEmitResult(
211
+ { status: 'written', written: result.written },
212
+ Date.now() - started,
213
+ );
214
+ }
215
+ return toEmitResult(
216
+ { status: result.status === 'partial' ? 'partial' : 'dropped', reason: result.reason || 'write-failed' },
217
+ Date.now() - started,
218
+ );
219
+ } catch {
220
+ return fail('emit-error');
221
+ }
222
+ }
223
+
224
+ /**
225
+ * `emitSessionBoot({ projectRoot, engine, sessionId, config?, deps? })` —
226
+ * append ONE `execution.started` record to the project's active segment.
227
+ * `deps` is the test-injection seam: { nowMs?, nowNs?, iso?, writer?,
228
+ * samplingSeed? } (values or zero-arg functions; the recorder's clock
229
+ * contract). Never throws, never exceeds the deadline.
230
+ */
231
+ export async function emitSessionBoot({
232
+ projectRoot,
233
+ engine,
234
+ sessionId,
235
+ config,
236
+ deps,
237
+ deadlineMs,
238
+ } = {}) {
239
+ const injected = isPlainObject(deps) ? deps : {};
240
+ const nowMs = typeof injected.nowMs === 'function' ? injected.nowMs() : injected.nowMs;
241
+ const record = buildSessionBootRecord({
242
+ projectRoot,
243
+ engine: isNonEmptyString(engine) ? engine : 'unknown',
244
+ sessionId,
245
+ nowMs: Number.isFinite(nowMs) ? nowMs : Date.now(),
246
+ });
247
+ return emitOne({ projectRoot, config, record, deps: injected, deadlineMs });
248
+ }
249
+
250
+ /**
251
+ * `emitOutcomeObserved({ projectRoot, record, config?, deps? })` — the
252
+ * generic terminal-record seam TASK-005's emitters consume. `record` is a
253
+ * full semantic-record candidate; a missing `semantic_name` defaults to
254
+ * 'outcome.observed' (the seam's own event).
255
+ */
256
+ export async function emitOutcomeObserved({
257
+ projectRoot,
258
+ record,
259
+ config,
260
+ deps,
261
+ deadlineMs,
262
+ } = {}) {
263
+ const candidate = isPlainObject(record) ? { ...record } : record;
264
+ if (isPlainObject(candidate) && candidate.semantic_name === undefined) {
265
+ candidate.semantic_name = SEMANTIC_OUTCOME_OBSERVED;
266
+ }
267
+ return emitOne({
268
+ projectRoot,
269
+ config,
270
+ record: candidate,
271
+ deps: isPlainObject(deps) ? deps : {},
272
+ deadlineMs,
273
+ });
274
+ }
275
+
276
+
277
+ // --- TASK-005 (FR-005, BL-007): terminal OutcomeRecord construction ---------
278
+ // The three terminal emitters (record-execution.mjs receipts,
279
+ // stop-coordinator.mjs verdicts, execution-ledger.mjs finalizers) share ONE
280
+ // record builder so every `outcome.observed` record carries the ARCH
281
+ // §Data Contracts OutcomeRecord fields plus the TASK-003 session-boot
282
+ // `request_key` join id — the missing 1-of-3934 emitter surface (TM §2).
283
+
284
+ export const OUTCOME_VERDICTS = Object.freeze([
285
+ 'VERIFIED', 'FAILED', 'BLOCKED', 'INCONCLUSIVE', 'INTERRUPTED',
286
+ ]);
287
+
288
+ const OUTCOME_ENFORCEMENTS = new Set(['enforced', 'advisory']);
289
+
290
+ // FR-005/FR-006: `advisory` is the Codex marker — the engine stamp may arrive
291
+ // as an explicit `engine` argument or as `env.UKIT_HARNESS` (the stamp the
292
+ // .sh wrappers and the omp bridge export). Anything else is `enforced`.
293
+ export function deriveEnforcement({ engine = null, env = null } = {}) {
294
+ const fromArg = isNonEmptyString(engine) ? engine : null;
295
+ const fromEnv = isPlainObject(env) && isNonEmptyString(env.UKIT_HARNESS) ? env.UKIT_HARNESS : null;
296
+ return (fromArg === 'codex' || fromEnv === 'codex') ? 'advisory' : 'enforced';
297
+ }
298
+
299
+ // The run id is the ledger's session identity: `run-<safe session id>` when a
300
+ // session is known, `run-transcript-<sha256[:12]>` for transcript-only
301
+ // payloads (never the raw path — it would be path-redacted anyway), else null.
302
+ export function outcomeRunId(payload = {}) {
303
+ const sessionId = isNonEmptyString(payload.session_id)
304
+ ? payload.session_id
305
+ : (isNonEmptyString(payload.sessionId) ? payload.sessionId : null);
306
+ if (sessionId) {
307
+ const safe = sessionId.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96);
308
+ return `run-${safe}`;
309
+ }
310
+ const transcript = isNonEmptyString(payload.transcript_path)
311
+ ? payload.transcript_path
312
+ : (isNonEmptyString(payload.transcriptPath) ? payload.transcriptPath : null);
313
+ if (transcript) {
314
+ const digest = crypto.createHash('sha256').update(String(transcript)).digest('hex').slice(0, 12);
315
+ return `run-transcript-${digest}`;
316
+ }
317
+ return null;
318
+ }
319
+
320
+ function outcomeStringList(value, cap) {
321
+ if (!Array.isArray(value)) return [];
322
+ return value
323
+ .filter((item) => typeof item === 'string' && item.length > 0)
324
+ .map((item) => item.slice(0, 160))
325
+ .slice(0, cap);
326
+ }
327
+
328
+ function outcomeCost(value) {
329
+ if (!isPlainObject(value)) return { latencyMs: null, rounds: null };
330
+ const cost = value;
331
+ return {
332
+ latencyMs: Number.isFinite(cost.latencyMs) && cost.latencyMs >= 0 ? cost.latencyMs : null,
333
+ rounds: Number.isFinite(cost.rounds) && cost.rounds >= 0 ? cost.rounds : null,
334
+ };
335
+ }
336
+
337
+ /**
338
+ * buildOutcomeObservedRecord({ outcome, projectRoot, payload, sessionId,
339
+ * engine, env }) → semantic-record candidate for `outcome.observed`.
340
+ *
341
+ * `outcome` is the ARCH §Data Contracts OutcomeRecord subset the emitter knows:
342
+ * { outcomeId?, runId?, routeId?, routeFingerprint?, ledgerKey?, verdict,
343
+ * evidenceIds?, cost?, reviewActions?, enforcement?, engine?, source?, ts? }
344
+ * Every field defaults sanely — a terminal event without runId/route data
345
+ * still emits with nulls (task case 4) and never blocks the caller's path.
346
+ * `payload` is the hook payload: it supplies session identity for runId
347
+ * derivation, the session-boot request_key join, and the envelope session_id.
348
+ * `enforcement` derives per FR-006 unless the emitter overrode it.
349
+ */
350
+ export function buildOutcomeObservedRecord({
351
+ outcome = null,
352
+ projectRoot = null,
353
+ payload = {},
354
+ sessionId = null,
355
+ engine = null,
356
+ env = null,
357
+ } = {}) {
358
+ const safe = isPlainObject(outcome) ? outcome : {};
359
+ const resolvedSessionId = isNonEmptyString(sessionId)
360
+ ? sessionId
361
+ : (isNonEmptyString(payload?.session_id)
362
+ ? payload.session_id
363
+ : (isNonEmptyString(payload?.sessionId) ? payload.sessionId : null));
364
+ const engineName = isNonEmptyString(engine)
365
+ ? engine
366
+ : (isNonEmptyString(safe.engine) ? safe.engine : null);
367
+ const verdict = OUTCOME_VERDICTS.includes(safe.verdict) ? safe.verdict : 'INCONCLUSIVE';
368
+ const recordPayload = {
369
+ outcomeId: isNonEmptyString(safe.outcomeId) ? safe.outcomeId : `oc-${crypto.randomUUID()}`,
370
+ runId: safe.runId !== undefined ? safe.runId : outcomeRunId(payload),
371
+ routeId: safe.routeId !== undefined ? safe.routeId : null,
372
+ routeFingerprint: safe.routeFingerprint !== undefined ? safe.routeFingerprint : null,
373
+ ledgerKey: safe.ledgerKey !== undefined ? safe.ledgerKey : null,
374
+ verdict,
375
+ evidenceIds: outcomeStringList(safe.evidenceIds, 32),
376
+ cost: outcomeCost(safe.cost),
377
+ reviewActions: outcomeStringList(safe.reviewActions, 32),
378
+ enforcement: OUTCOME_ENFORCEMENTS.has(safe.enforcement)
379
+ ? safe.enforcement
380
+ : deriveEnforcement({ engine: engineName, env }),
381
+ engine: engineName,
382
+ source: isNonEmptyString(safe.source) ? safe.source.slice(0, 96) : null,
383
+ request_key: sessionBootRequestKey({ projectRoot, sessionId: resolvedSessionId }),
384
+ ts: isNonEmptyString(safe.ts) ? safe.ts : new Date().toISOString(),
385
+ };
386
+ return {
387
+ semantic_name: SEMANTIC_OUTCOME_OBSERVED,
388
+ session_id: resolvedSessionId ?? undefined,
389
+ payload: recordPayload,
390
+ };
391
+ }
392
+
393
+ export { sessionBootRequestKey };
@@ -11,7 +11,7 @@
11
11
  * redaction rules change so downstream readers can detect stale records.
12
12
  */
13
13
 
14
- export const REDACTION_VERSION = 'df-redact-5';
14
+ export const REDACTION_VERSION = 'df-redact-6';
15
15
 
16
16
  /**
17
17
  * Envelope fields permitted on a persisted record (DF-FR01) plus the
@@ -179,6 +179,15 @@ export const ALLOWED_SUPPORT_PAYLOAD_FIELDS = new Set([
179
179
  'digest',
180
180
  'policy_version',
181
181
  'rate',
182
+ // TASK-C89-002 (SPEC §3 FR-01): subagent.usage provenance payload — the
183
+ // fingerprint-deduped component rows, measured byte counts, typed token
184
+ // estimates, the observed parent run ref and the telemetry-completeness
185
+ // marker. Metadata only; component text itself is never emitted.
186
+ 'components',
187
+ 'bytes',
188
+ 'tokens',
189
+ 'parent_run_ref',
190
+ 'telemetry_complete',
182
191
  ]);
183
192
 
184
193
  // --- size caps (prototype defaults per SPEC §14; freeze after measurement) ---
@@ -15,6 +15,7 @@ import { PRIVACY_CLASSES } from './constants.js';
15
15
 
16
16
  const V1 = '1';
17
17
  const V1_1 = '1.1';
18
+ const V1_2 = '1.2';
18
19
 
19
20
  export const SEMANTIC_REGISTRY = Object.freeze({
20
21
  'execution.started': {
@@ -210,6 +211,15 @@ export const SEMANTIC_REGISTRY = Object.freeze({
210
211
  introduced_version: V1_1,
211
212
  deprecated: null,
212
213
  },
214
+ // --- schema v1.2 additions (TASK-C89-002, SPEC §3 FR-01, append-only) ---
215
+ 'subagent.usage': {
216
+ definition: 'One child/subagent run emitted typed usage provenance. Payload carries fingerprint-deduped prompt components with measured bytes and ESTIMATED tokens, provider usage as a typed {value,source} resource, and the observed parent run ref — unknown stays UNKNOWN/null, never 0, never prompt text.',
217
+ unit: 'event',
218
+ privacy_class: 'internal',
219
+ retention_hint: 'retain',
220
+ introduced_version: V1_2,
221
+ deprecated: null,
222
+ },
213
223
  });
214
224
 
215
225
  // Error/wait/reason vocabulary: codes + definitions, no free-text grouping.