@ngockhoale/ukit 3.4.46 → 3.4.48

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 +13 -5
  2. package/manifests/documentation.yaml +22 -0
  3. package/manifests/platform.full.yaml +27 -0
  4. package/package.json +4 -1
  5. package/scripts/dev/capture-omp-stall.mjs +246 -0
  6. package/scripts/dev/worktree-preflight.mjs +20 -1
  7. package/scripts/docs/render-instructions.mjs +29 -5
  8. package/src/core/agentRuntime/supervisor.js +15 -1
  9. package/src/core/diffPlan.js +38 -12
  10. package/src/core/executionContracts.js +67 -16
  11. package/src/core/metadata.js +4 -0
  12. package/src/core/runInstallPipeline.js +19 -2
  13. package/src/core/runtimeConfig.js +1 -1
  14. package/src/diagnostics/ompStallSnapshot.js +199 -0
  15. package/src/index/playbookRegistry.js +22 -3
  16. package/src/index/taskRouting.js +2 -2
  17. package/template_project/.claude/agents/bug-debugger.md +1 -0
  18. package/template_project/.claude/agents/code-reviewer.md +1 -0
  19. package/template_project/.claude/agents/feature-implementer.md +1 -0
  20. package/template_project/.claude/agents/handoff-planner.md +1 -0
  21. package/template_project/.claude/agents/ukit-small-task-maintainer.md +1 -0
  22. package/template_project/.claude/hooks/completion-gate.sh +1 -1
  23. package/template_project/.claude/hooks/delegation-gate.mjs +343 -0
  24. package/template_project/.claude/hooks/skill-router.sh +147 -6
  25. package/template_project/.claude/settings.json +8 -8
  26. package/template_project/.claude/ukit/index/playbook-registry.mjs +22 -3
  27. package/template_project/.claude/ukit/index/route-resolver.mjs +67 -15
  28. package/template_project/.claude/ukit/index/route-task.mjs +115 -37
  29. package/template_project/.claude/ukit/index/tier-lane.mjs +202 -0
  30. package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +3 -1
  31. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +306 -20
  32. package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +8 -0
  33. package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
  34. package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -0
  35. package/template_project/.claude/ukit/runtime/playbook-execution.mjs +704 -0
  36. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +80 -3
  37. package/template_project/.omp/agents/bug-debugger.md +1 -0
  38. package/template_project/.omp/agents/code-reviewer.md +1 -0
  39. package/template_project/.omp/agents/feature-implementer.md +1 -0
  40. package/template_project/.omp/agents/handoff-planner.md +1 -0
  41. package/template_project/.omp/agents/ukit-small-task-maintainer.md +1 -0
  42. package/template_project/.omp/hooks/pre/ukit-bridge.js +254 -7
  43. package/template_project/ukit/storage/config.json +1 -1
  44. package/scripts/bench/data-foundation.mjs +0 -929
  45. package/scripts/bench/decision-runtime-baseline.mjs +0 -427
  46. package/scripts/bench/decision-runtime-variant.mjs +0 -626
  47. package/scripts/bench/goldTasks.json +0 -38
  48. package/scripts/bench/memory-ablation.mjs +0 -495
  49. package/scripts/bench/memory-baseline.mjs +0 -596
  50. package/scripts/bench/memory-bench.mjs +0 -661
  51. package/scripts/bench/memory-canary.mjs +0 -331
  52. package/scripts/bench/memory-corpus.mjs +0 -354
  53. package/scripts/bench/memory-gate.mjs +0 -389
  54. package/scripts/bench/memory-metrics.mjs +0 -179
  55. package/scripts/bench/outline-savings.mjs +0 -107
  56. package/scripts/bench/parallel-agents.mjs +0 -224
  57. package/scripts/bench/recorder-overhead.mjs +0 -204
  58. package/scripts/bench/runGold.mjs +0 -238
  59. package/scripts/bench/sqlite-spike.mjs +0 -451
  60. package/scripts/bench/subagent-orchestrator-corpus.mjs +0 -275
  61. package/scripts/bench/subagent-orchestrator-eval.mjs +0 -565
  62. package/scripts/bench/v3-ceremony.mjs +0 -220
  63. package/scripts/perf/audit-perf.mjs +0 -1195
  64. package/scripts/perf/diff-perf-findings.mjs +0 -136
  65. package/scripts/perf/perf-findings.json +0 -474
  66. package/scripts/perf/perf-measure.md +0 -271
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { withAsyncLock, LOCK_MAX_SLICE_MS } from './async-lock.mjs';
10
10
  import { cronOperationReceipt, bashOperationReceipt, releaseOperationReceipt } from './cron-operation-adapter.mjs';
11
+ import * as playbookExecution from './playbook-execution.mjs';
11
12
  import { operationCompletionPolicy, foldOperationEvidence, compactOperation, conditionalAbsenceGoal } from './operation-evidence.mjs';
12
13
 
13
14
  // Hook-context self-deadline (2.4.1 orphan-leak class): armed ONLY on direct
@@ -659,7 +660,7 @@ function compactReceipt(receipt) {
659
660
  kind: receipt.kind,
660
661
  success: receipt.success,
661
662
  };
662
- for (const key of ['toolName', 'toolUseId', 'file', 'command', 'exitCode', 'scope', 'error', 'verdict', 'evidence', 'evidenceSurface', 'class', 'status', 'detail', 'operation', 'transportSuccess', 'taskInstanceId', 'sessionKey', 'sequence', 'exitStatusUnproven']) {
663
+ for (const key of ['toolName', 'toolUseId', 'file', 'command', 'exitCode', 'scope', 'error', 'verdict', 'evidence', 'evidenceSurface', 'class', 'status', 'detail', 'operation', 'transportSuccess', 'taskInstanceId', 'sessionKey', 'sequence', 'exitStatusUnproven', 'agentId', 'agentType', 'contentSha']) {
663
664
  if (receipt[key] !== undefined && receipt[key] !== null && receipt[key] !== '') {
664
665
  compact[key] = key === 'operation' ? compactOperation(receipt[key]) : receipt[key];
665
666
  }
@@ -774,6 +775,31 @@ function fingerprintExcluded(file) {
774
775
  || ['docs/WORKLOG.md', 'docs/STATUS.md', 'docs/TASKS.md', 'docs/MEMORY.md'].includes(p);
775
776
  }
776
777
 
778
+ // A write to UKit's own bookkeeping (living docs, runtime state) is housekeeping the project rules
779
+ // REQUIRE after every task; it is never the implementation an implementation contract asks for.
780
+ // `bash-workspace:` is the prefix of fingerprint-derived Bash write receipts.
781
+ function isBookkeepingWrite(file) {
782
+ const p = String(file ?? '').replace(/^bash-workspace:/, '').replace(/\\/g, '/');
783
+ return /(^|\/)docs\/(WORKLOG|STATUS|TASKS|MEMORY)\.md$/.test(p)
784
+ || /(^|\/)\.ukit\/storage\//.test(p)
785
+ || /(^|\/)\.claude\/ukit\/skill-router-state\.json$/.test(p);
786
+ }
787
+
788
+ // A bookkeeping write counts as the requested write only when the user's own request names that file.
789
+ function promptNamesBookkeeping(state) {
790
+ const prompt = String(state?.routingContext?.lastExplicitUserPromptText ?? state?.routingContext?.promptText ?? '');
791
+ return /\b(WORKLOG|STATUS|TASKS|MEMORY)\.md\b|\.ukit\/storage|skill-router-state/i.test(prompt);
792
+ }
793
+
794
+ // Per-file form: naming STATUS.md does not make a WORKLOG.md edit the requested write.
795
+ function promptNamesBookkeepingFile(state, file) {
796
+ const p = String(file ?? '').replace(/^bash-workspace:/, '').replace(/\\/g, '/');
797
+ const prompt = String(state?.routingContext?.lastExplicitUserPromptText ?? state?.routingContext?.promptText ?? '');
798
+ const doc = p.match(/(?:^|\/)docs\/(WORKLOG|STATUS|TASKS|MEMORY)\.md$/);
799
+ if (doc) return new RegExp(`\\b${doc[1]}\\.md\\b`, 'i').test(prompt);
800
+ return promptNamesBookkeeping(state);
801
+ }
802
+
777
803
  function contentFingerprint(cwd) {
778
804
  if (!cwd) return null;
779
805
  try {
@@ -1246,6 +1272,10 @@ function carriedEvidenceLedger(fresh, current) {
1246
1272
  directWrite: fresh.directWrite === true || current.directWrite === true,
1247
1273
  writeAttempted: fresh.writeAttempted || current.writeAttempted === true,
1248
1274
  writeSucceeded: fresh.writeSucceeded || current.writeSucceeded === true,
1275
+ // undefined (a ledger written before this field existed) keeps the legacy any-write behavior.
1276
+ substantiveWrite: fresh.substantiveWrite === true || current.substantiveWrite === true
1277
+ ? true
1278
+ : (current.substantiveWrite === undefined && current.writeSucceeded === true ? undefined : false),
1249
1279
  verificationAttempted: fresh.verificationAttempted || current.verificationAttempted === true,
1250
1280
  verificationSucceeded: fresh.verificationSucceeded || current.verificationSucceeded === true,
1251
1281
  verificationFailed: fresh.verificationFailed || current.verificationFailed === true,
@@ -1287,6 +1317,10 @@ function carriedEvidenceLedger(fresh, current) {
1287
1317
  // TASK-008: the playbook-finding bank is request-scoped like the floor —
1288
1318
  // a same-prompt re-key must keep the latest per-class statuses.
1289
1319
  playbookFindings: current.playbookFindings || fresh.playbookFindings || null,
1320
+ // Round 5: the delegated-run state is request-scoped like the floor (this carry is
1321
+ // reached only for the SAME taskInstanceId / promptKey — never across tasks).
1322
+ playbookRun: current.playbookRun || fresh.playbookRun || null,
1323
+ delegationViolations: current.delegationViolations || fresh.delegationViolations || null,
1290
1324
  verificationFailureCounts: current.verificationFailureCounts || {},
1291
1325
  blocker: current.blocker || null,
1292
1326
  // Journal bookkeeping belongs to the file, not the request: the dedupe memory must
@@ -1344,6 +1378,7 @@ function freshLedger(payload, routeState, harness) {
1344
1378
  sourceFiles: [],
1345
1379
  writeAttempted: false,
1346
1380
  writeSucceeded: false,
1381
+ substantiveWrite: false,
1347
1382
  verificationAttempted: false,
1348
1383
  verificationSucceeded: false,
1349
1384
  verificationFailed: false,
@@ -1390,7 +1425,7 @@ const MAX_JOURNAL_SEEN = 64;
1390
1425
  const MAX_JOURNAL_RECORDS = 128;
1391
1426
  const MAX_JOURNAL_QUARANTINE_LINES = 32;
1392
1427
  const LEDGER_EVENT_TYPES = new Set(['receipt', 'continuation', 'notified', 'stop-progress', 'resumable-run']);
1393
- const RECEIPT_KINDS = new Set(['source', 'write', 'verification', 'playbook-finding', 'workspace', 'operation']);
1428
+ const RECEIPT_KINDS = new Set(['source', 'write', 'verification', 'playbook-finding', 'workspace', 'operation', 'handback', 'review', 'delegation-violation']);
1394
1429
 
1395
1430
  function journalPathFor(target) {
1396
1431
  return `${target}.journal`;
@@ -1511,11 +1546,27 @@ async function bumpContinuationSidecar(target, event, { signal, deadlineMs } = {
1511
1546
  return outcome.ok === true ? outcome.value : { ok: false };
1512
1547
  }
1513
1548
 
1549
+ // Receipts that carry task-owned semantics: they must belong to the live task or be
1550
+ // rejected (operation proof; Round 5 worker handbacks and review decisions).
1551
+ const TASK_OWNED_RECEIPT_KINDS = new Set(['operation', 'handback', 'review', 'delegation-violation']);
1552
+
1553
+ // Bounded content digest of a file a worker wrote (change fingerprint input). Null on any
1554
+ // read failure or a file over the cap — a missing digest only weakens, never fabricates.
1555
+ function fileContentSha(file) {
1556
+ try {
1557
+ const stat = fsSync.statSync(file);
1558
+ if (!stat.isFile() || stat.size > 2 * 1024 * 1024) return null;
1559
+ return crypto.createHash('sha256').update(fsSync.readFileSync(file)).digest('hex').slice(0, 16);
1560
+ } catch {
1561
+ return null;
1562
+ }
1563
+ }
1564
+
1514
1565
  function newEventId() {
1515
1566
  return `${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`;
1516
1567
  }
1517
1568
 
1518
- const JOURNAL_RECEIPT_FIELDS = ['ts', 'kind', 'toolName', 'toolUseId', 'success', 'exitCode', 'file', 'command', 'scope', 'workspaceFp', 'workspaceRoot', 'workspaceHeadTs', 'bashReadOnly', 'exitStatusUnproven', 'detail', 'operation', 'transportSuccess', 'taskInstanceId', 'sessionKey', 'sequence'];
1569
+ const JOURNAL_RECEIPT_FIELDS = ['ts', 'kind', 'toolName', 'toolUseId', 'success', 'exitCode', 'file', 'command', 'scope', 'workspaceFp', 'workspaceRoot', 'workspaceHeadTs', 'bashReadOnly', 'exitStatusUnproven', 'detail', 'operation', 'transportSuccess', 'taskInstanceId', 'sessionKey', 'sequence', 'agentId', 'agentType', 'contentSha', 'handback', 'review', 'verdict'];
1519
1570
 
1520
1571
  // TASK-009 (BL-011): evidence attestations are whitelisted verbatim — class /
1521
1572
  // surface / result / claim, the ARCH §Evidence Schema fields a receipt may
@@ -1535,11 +1586,29 @@ function sanitizeEvidenceForJournal(evidence) {
1535
1586
  return Object.keys(clean).length > 0 ? clean : null;
1536
1587
  }
1537
1588
 
1589
+ // The verification verdict is minted from LIVE git/workspace state at record time and cannot be
1590
+ // re-minted at replay (the state may have moved on), so it travels with the journaled receipt.
1591
+ // Fixed keys, bounded strings; an unknown kind is dropped (replay then stays pending, fail-safe).
1592
+ // Currency is still judged by verdictCurrent at assessment time, so a replayed stale verdict is
1593
+ // rejected exactly like a live one.
1594
+ const JOURNAL_VERDICT_FIELDS = ['workspaceRoot', 'evidence', 'verifier', 'headSha', 'baseSha', 'patchId', 'dirtyFp', 'contentFp', 'ts'];
1595
+ function sanitizeVerdictForJournal(verdict) {
1596
+ if (!verdict || typeof verdict !== 'object' || Array.isArray(verdict) || !VERDICT_KINDS.has(verdict.kind)) return null;
1597
+ const clean = { kind: verdict.kind };
1598
+ for (const key of JOURNAL_VERDICT_FIELDS) {
1599
+ if (typeof verdict[key] === 'string' && verdict[key]) clean[key] = verdict[key].slice(0, 400);
1600
+ else if (verdict[key] === null && (key === 'headSha' || key === 'baseSha' || key === 'patchId')) clean[key] = null;
1601
+ }
1602
+ return clean;
1603
+ }
1604
+
1538
1605
  function sanitizeReceiptForJournal(receipt) {
1539
1606
  const clean = {};
1540
1607
  for (const key of JOURNAL_RECEIPT_FIELDS) {
1541
1608
  if (receipt && receipt[key] !== undefined && receipt[key] !== null && receipt[key] !== '') {
1542
- clean[key] = key === 'operation' ? compactOperation(receipt[key]) : receipt[key];
1609
+ clean[key] = key === 'operation' ? compactOperation(receipt[key])
1610
+ : key === 'verdict' ? sanitizeVerdictForJournal(receipt[key]) : receipt[key];
1611
+ if (clean[key] === null) delete clean[key];
1543
1612
  }
1544
1613
  }
1545
1614
  const evidence = sanitizeEvidenceForJournal(receipt && receipt.evidence);
@@ -1815,11 +1884,9 @@ const PLAYBOOK_STEP_MAPS = {
1815
1884
  { id: 'fix', derive: 'write', essential: true },
1816
1885
  { id: 'verify', derive: 'post-verify', essential: true },
1817
1886
  ],
1818
- 'feature-implementation': [
1819
- { id: 'analog-search', derive: 'pre-source', essential: false },
1820
- { id: 'implement', derive: 'write', essential: true },
1821
- { id: 'verify', derive: 'post-verify', essential: true },
1822
- ],
1887
+ // Round 5: the step contract lives once, in playbook-execution.mjs; conditional steps
1888
+ // (integrate-verify / review) are filtered out of the unconditional trace below.
1889
+ 'feature-implementation': playbookExecution.FEATURE_IMPLEMENTATION_STEPS,
1823
1890
  migration: [
1824
1891
  { id: 'schema-inspect', derive: 'pre-source', essential: false },
1825
1892
  { id: 'write', derive: 'write', essential: true },
@@ -1829,7 +1896,7 @@ const PLAYBOOK_STEP_MAPS = {
1829
1896
 
1830
1897
  function playbookStepsFor(playbookId) {
1831
1898
  const steps = playbookId ? PLAYBOOK_STEP_MAPS[String(playbookId)] : null;
1832
- return Array.isArray(steps) ? steps : [];
1899
+ return Array.isArray(steps) ? steps.filter((step) => !step.conditional) : [];
1833
1900
  }
1834
1901
 
1835
1902
  function boundedTraceText(value) {
@@ -1975,6 +2042,9 @@ export async function playbookStatus(projectRoot, payload = {}) {
1975
2042
  taskType: ledger?.taskType || state?.routeSummary?.taskType || null,
1976
2043
  trace,
1977
2044
  summary: formatPlaybookTrace(trace),
2045
+ ...(playbookId === playbookExecution.FEATURE_PLAYBOOK_ID
2046
+ ? { acceptance: evaluateFeatureAcceptance({ state: state || {}, ledger, cwd: projectRoot }) }
2047
+ : {}),
1978
2048
  };
1979
2049
  }
1980
2050
 
@@ -2210,6 +2280,7 @@ function applyReceiptToLedger(ledger, receipt, options = {}) {
2210
2280
  exitCode: receipt.exitCode,
2211
2281
  file: `bash-workspace:${fp}`,
2212
2282
  detail: 'bash-workspace-delta',
2283
+ ...(receipt.agentId ? { agentId: receipt.agentId, agentType: receipt.agentType } : {}),
2213
2284
  }, options);
2214
2285
  if (applied) base = applied.ledger;
2215
2286
  }
@@ -2221,6 +2292,25 @@ function applyReceiptToLedger(ledger, receipt, options = {}) {
2221
2292
  function applyReceiptCore(ledger, receipt, { vibecode = false } = {}) {
2222
2293
  if (!receipt || typeof receipt !== 'object' || !RECEIPT_KINDS.has(receipt.kind)) return null;
2223
2294
  const next = { ...ledger, receiptSequence: Number(ledger.receiptSequence || 0) + 1 };
2295
+ if (receipt.kind === 'delegation-violation') {
2296
+ // delegation-gate.mjs refused a main-thread source write while delegation is required and not
2297
+ // performed. Counted per task ledger (a new task starts from a fresh ledger); the limit-th one
2298
+ // mints the visible sticky blocker the Stop gate ends the run on. Never enters the receipt
2299
+ // window and never clears or replaces an existing blocker.
2300
+ const count = Number(ledger.delegationViolations?.count || 0) + 1;
2301
+ const blocked = count >= playbookExecution.DELEGATION_VIOLATION_LIMIT;
2302
+ next.delegationViolations = { count, blocked, lastTs: receipt.ts ?? Date.now(), lastTarget: receipt.target ?? null };
2303
+ if (blocked && !next.blocker) {
2304
+ next.blocker = {
2305
+ kind: 'delegation-required',
2306
+ visible: true,
2307
+ detail: `the feature-implementation Playbook requires the coding to be delegated to the Tier 2 worker, but the orchestrator tried to write source ${count} times without dispatching one (delegation-required-not-performed) — dispatch the worker via the Agent tool or report this blocker.`,
2308
+ ts: Date.now(),
2309
+ };
2310
+ }
2311
+ next.updatedAt = Date.now();
2312
+ return { ledger: next, value: null };
2313
+ }
2224
2314
  if (receipt.kind === 'operation') receipt = { ...receipt, sequence: next.receiptSequence };
2225
2315
  if (receipt.kind === 'operation') {
2226
2316
  next.operationBank = foldOperationEvidence(ledger.operationBank, receipt, ledger);
@@ -2235,6 +2325,8 @@ function applyReceiptCore(ledger, receipt, { vibecode = false } = {}) {
2235
2325
  } else if (receipt.kind === 'write') {
2236
2326
  next.writeAttempted = true;
2237
2327
  next.writeSucceeded = next.writeSucceeded || receipt.success;
2328
+ if (receipt.success && receipt.file && !isBookkeepingWrite(receipt.file)) next.substantiveWrite = true;
2329
+ else if (next.substantiveWrite === undefined) next.substantiveWrite = false;
2238
2330
  // Evidence tier: a fingerprint-derived Bash delta is inferred (supporting) evidence; only a
2239
2331
  // tool-attributed Edit/Write is direct.
2240
2332
  if (receipt.detail === 'bash-workspace-delta') next.inferredWrite = true;
@@ -2361,6 +2453,22 @@ function applyReceiptCore(ledger, receipt, { vibecode = false } = {}) {
2361
2453
  next.updatedAt = Date.now();
2362
2454
  return { ledger: next, value: null };
2363
2455
  }
2456
+ // Round 5: feature-implementation run state (worker attribution, change barrier,
2457
+ // integration verification, handbacks, review decision). Banked outside the evictable
2458
+ // receipt window; handback/review receipts live only here and never enter the window.
2459
+ if (ledger?.playbookId === playbookExecution.FEATURE_PLAYBOOK_ID) {
2460
+ next.playbookRun = playbookExecution.foldPlaybookRun(ledger.playbookRun, receipt, {
2461
+ seq: next.receiptSequence,
2462
+ bookkeeping: receipt.kind === 'write' && isBookkeepingWrite(receipt.file),
2463
+ });
2464
+ if (receipt.kind === 'handback' || receipt.kind === 'review') {
2465
+ next.updatedAt = Date.now();
2466
+ return { ledger: next, value: null };
2467
+ }
2468
+ } else if (receipt.kind === 'handback' || receipt.kind === 'review') {
2469
+ // Not a feature-implementation run: these kinds mean nothing here.
2470
+ return { ledger: next, value: null };
2471
+ }
2364
2472
  // TASK-009 (BL-011): fold the receipt into the bug-fix floor bank before it
2365
2473
  // enters the (evictable) receipt window. The fold runs on the pre-update
2366
2474
  // verificationFailureCounts: a pass over a surface whose failure is still
@@ -2558,7 +2666,7 @@ export async function recordLedgerEvent(event, {
2558
2666
  // Hoisted so the post-commit outcome emitter sees the same state snapshot
2559
2667
  // the lock callback evaluated (a fresh read could observe a re-route).
2560
2668
  let lockedRouteState = routeState || await readRouteState(projectRoot, payload);
2561
- if (event.type === 'receipt' && event.receipt?.kind === 'operation') {
2669
+ if (event.type === 'receipt' && TASK_OWNED_RECEIPT_KINDS.has(event.receipt?.kind)) {
2562
2670
  if (event.receipt.taskInstanceId && event.receipt.taskInstanceId !== lockedRouteState?.taskInstanceId) {
2563
2671
  return { rejected: true, eventId, reason: 'receipt-task-mismatch' };
2564
2672
  }
@@ -2606,7 +2714,7 @@ export async function recordLedgerEvent(event, {
2606
2714
  if (event.receipt.taskInstanceId && event.receipt.taskInstanceId !== state?.taskInstanceId) {
2607
2715
  return { committed: false, rejected: true, eventId, reason: 'receipt-task-mismatch' };
2608
2716
  }
2609
- const stampedReceipt = event.receipt.kind === 'operation'
2717
+ const stampedReceipt = TASK_OWNED_RECEIPT_KINDS.has(event.receipt.kind)
2610
2718
  ? { ...event.receipt, taskInstanceId: state?.taskInstanceId || null, sessionKey: sessionIdentity(payload) }
2611
2719
  : event.receipt;
2612
2720
  const applied = applyReceiptToLedger(ledger, stampedReceipt, { vibecode: event.vibecode === true });
@@ -2849,6 +2957,7 @@ export async function recordExecutionReceipt({
2849
2957
  success,
2850
2958
  exitCode,
2851
2959
  };
2960
+ const featureRun = routeState?.routeSummary?.playbookId === playbookExecution.FEATURE_PLAYBOOK_ID;
2852
2961
 
2853
2962
  const policy = routeState?.routeSummary?.executionContract?.releasePolicy;
2854
2963
  if (toolName === 'Bash' && !bashScope.foreign && policy?.kind === 'conditional-release'
@@ -2913,10 +3022,38 @@ export async function recordExecutionReceipt({
2913
3022
  // Any other Bash: no evidence of its own. The receipt only carries the workspace
2914
3023
  // fingerprint so a verifiable tracked-file change can be attributed at apply time.
2915
3024
  receipt.kind = 'workspace';
3025
+ } else if ((toolName === 'Agent' || toolName === 'Task') && featureRun) {
3026
+ // Round 5: a subagent dispatch result is a HANDBACK — a claim plus identity, never
3027
+ // evidence by itself. Only a feature-implementation route records it; every other
3028
+ // playbook keeps the old untracked rejection (their ledgers are unchanged).
3029
+ const handback = playbookExecution.buildHandbackRecord({ toolInput, toolResponse, projectRoot, success });
3030
+ if (!handback) return { rejected: true, eventId: null };
3031
+ receipt.kind = 'handback';
3032
+ // A prompt-carried task marker binds the result to ITS task: a mismatch with the live
3033
+ // task is rejected by the task-ownership check in recordLedgerEvent (receipt-task-mismatch).
3034
+ if (handback.markerTaskId) receipt.taskInstanceId = handback.markerTaskId;
3035
+ delete handback.markerTaskId;
3036
+ receipt.handback = handback;
3037
+ if (handback.agentId) { receipt.agentId = handback.agentId; receipt.agentType = handback.agentType; }
2916
3038
  } else {
2917
3039
  // Untracked tool: no event, no lock, no write — same as the old unlocked early return.
2918
3040
  return { rejected: true, eventId: null };
2919
3041
  }
3042
+ // Round 5: attribute subagent tool receipts to their native agent identity (Claude Code
3043
+ // stamps agent_id/agent_type on a subagent's hook payloads). Hosts without it leave the
3044
+ // receipt unattributed — the run then reports UNSUPPORTED instead of guessing.
3045
+ if (featureRun && receipt.kind !== 'handback') {
3046
+ const agentId = playbookExecution.sanitizeAgentId(payload.agent_id);
3047
+ if (agentId) {
3048
+ receipt.agentId = agentId;
3049
+ const agentType = typeof payload.agent_type === 'string' ? payload.agent_type.slice(0, 64) : null;
3050
+ if (agentType) receipt.agentType = agentType;
3051
+ if (receipt.kind === 'write' && receipt.success && typeof receipt.file === 'string') {
3052
+ const digest = fileContentSha(path.resolve(projectRoot, receipt.file));
3053
+ if (digest) receipt.contentSha = digest;
3054
+ }
3055
+ }
3056
+ }
2920
3057
  if (workspaceFp) {
2921
3058
  receipt.workspaceFp = workspaceFp;
2922
3059
  if (workspaceRoot !== projectRoot) {
@@ -3040,7 +3177,13 @@ function evidenceSatisfied(evidence, ledger = {}, state = {}, { cwd } = {}) {
3040
3177
  || (cwd && bank[evidenceBankKey(path.resolve(cwd, file))] === true)
3041
3178
  ));
3042
3179
  }
3043
- return ledger.writeSucceeded === true;
3180
+ // A bookkeeping-only write (WORKLOG/STATUS/TASKS/MEMORY, runtime state) is not the implementation:
3181
+ // it satisfies write-evidence only when the request itself names that file.
3182
+ if (ledger.writeSucceeded !== true) return false;
3183
+ if (ledger.substantiveWrite !== false) return true;
3184
+ // Only bookkeeping was written: each written file must itself be named by the request.
3185
+ const written = (ledger.receipts || []).filter((r) => r?.kind === 'write' && r.success && r.file).map((r) => r.file);
3186
+ return written.length ? written.some((file) => promptNamesBookkeepingFile(state, file)) : promptNamesBookkeeping(state);
3044
3187
  }
3045
3188
  if (evidence === 'verification-evidence') {
3046
3189
  // SPEC-typed-verdicts §2.3: when a verdict record exists it governs — only a
@@ -3154,6 +3297,9 @@ function recoveryInstruction(missingEvidence, ledger = {}, routeSummary = {}, {
3154
3297
  } else if (missingEvidence.includes('root-cause')) {
3155
3298
  instruction = 'Record the root cause: attest the named mechanism (UKIT_EVIDENCE root-cause/instrumentation/behavioral-check with claim) — the rejected hypotheses and the surviving mechanism, one line each.';
3156
3299
  }
3300
+ if (!instruction && missingEvidence.includes('integration-verification') && !missingEvidence.includes('write-evidence')) {
3301
+ instruction = 'Worker changes are not yet verified together: in this session (not inside a worker), run the routed targeted verification once on the integrated state, after the last worker handback, and inspect its result. A worker\'s own pass does not count.';
3302
+ }
3157
3303
  if (!instruction && missingEvidence.includes('write-evidence')) {
3158
3304
  if (!ledger.sourceSucceeded) {
3159
3305
  instruction = 'Pull one bounded indexed source slice, then make the requested Edit/Write in this continuation.';
@@ -3343,12 +3489,47 @@ function effectiveRequestLedger(state = {}, ledger = {}) {
3343
3489
  // since (publishedReleaseDrifted). A failed/missing publish with no registry proof
3344
3490
  // stays missing. A registry that already serves the version counts as published, so a finished release is
3345
3491
  // never re-published just to satisfy the gate.
3346
- function promptOrdersNpmPublish(state = {}) {
3492
+ const PUBLISH_ORDER_NEGATOR = /\b(?:do not|don't|dont|never|without|not yet|no|not|avoid|skip|exclud\w*|forbid\w*|prohibit\w*|khong|dung|chua)\b|--dry-run/;
3493
+ const PUBLISH_ORDER_IMPERATIVE = /^(?:please\s+|just\s+|now\s+|also\s+)*(?:run|do|execute|perform|go|ship|publish|release|push|tag|commit|verify|make|cut|chay)\b/;
3494
+ const PUBLISH_ORDER_HEADER = /prohibit|forbidden|out of scope|non-goal|scope control/;
3495
+ // Release intent comes ONLY from the user's own affirmative order. A prohibition list ("No release, npm publish or CI.",
3496
+ // "Do not: ... npm publish") keeps its negation across comma/and/or list items and under a prohibition header; only a
3497
+ // explicit command can end a negated non-release clause, but bare list verbs cannot. Explicit publication
3498
+ // prohibitions win over positive references elsewhere. Only the task's authoritative prompt is consulted.
3499
+ export function promptOrdersNpmPublish(state = {}) {
3347
3500
  const text = String(state?.routingContext?.lastExplicitUserPromptText || '')
3348
3501
  .normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd').toLowerCase();
3349
- if (!/\bnpm\s+publish\b/.test(text)) return false;
3350
- return text.split(/[\n;.,]|\bthen\b|\band\b/).some((clause) => /\bnpm\s+publish\b/.test(clause)
3351
- && !/\b(?:do not|don't|never|without|not yet|dry-run|khong|dung|chua)\b|--dry-run/.test(clause));
3502
+ // Quoted commands and historical/example blocks describe orders; they do not issue them.
3503
+ const active = text.replace(/```[^]*?```|~~~[^]*?~~~/g, '')
3504
+ .replace(/^[ \t]*>.*$/gm, '').replace(/[“"][^”"\n]*[”"]|‘[^’\n]*’/g, '');
3505
+ let prohibited = false;
3506
+ let ordered = false;
3507
+ let blockNegated = false;
3508
+ for (const line of active.split('\n')) {
3509
+ const bullet = /^\s*(?:[-*\u2022]|\d+[.)])\s+/.test(line);
3510
+ if (!line.trim()) { blockNegated = false; continue; }
3511
+ if (!bullet) blockNegated = line.trim().length <= 80 && (PUBLISH_ORDER_NEGATOR.test(line) || PUBLISH_ORDER_HEADER.test(line)) && /:\s*$|^[^.]{0,40}$/.test(line.trim()) && !/\bnpm\s+publish\b/.test(line);
3512
+ for (const sentence of line.split(/\s*;\s*|\.\s+|\.$/)) {
3513
+ for (const hard of sentence.split(/\bthen\b|\bbut\b|\binstead\b/)) {
3514
+ let negated = bullet && blockNegated;
3515
+ for (const raw of hard.split(/,|\band\b|\bor\b|\bnor\b/)) {
3516
+ const item = raw.replace(/^[\s\-*\u2022]+|^\d+[.)]\s*/g, '').trim();
3517
+ if (PUBLISH_ORDER_NEGATOR.test(item)) negated = true;
3518
+ // Bare list nouns (push, tag, commit) must not revoke an earlier "No".
3519
+ else if (/^(?:please\s+|now\s+|also\s+)*(?:run|execute|perform|chay)\s+\S/.test(item)) negated = false;
3520
+ const publication = /\bnpm\s+publish\b/.test(item);
3521
+ const releaseOrder = /^(?:please\s+|just\s+|now\s+|also\s+)*(?:release|ship|publish)\s+(?!readiness\b|verification\b|evidence\b|policy\b|intent\b|notes\b|terminology\b)\S/.test(item);
3522
+ const releaseReference = /\b(?:release|publish|publication)\b/.test(item);
3523
+ if (negated && releaseReference && !/--dry-run/.test(item)) prohibited = true;
3524
+ if (!negated && (releaseOrder || (publication && (
3525
+ /^npm\s+publish\b/.test(item) || PUBLISH_ORDER_IMPERATIVE.test(item)
3526
+ || /\b(?:chay|run)\s+`?npm\s+publish\b/.test(item)
3527
+ || /continue the release workflow:/.test(item))))) ordered = true;
3528
+ }
3529
+ }
3530
+ }
3531
+ }
3532
+ return ordered && !prohibited;
3352
3533
  }
3353
3534
 
3354
3535
  function releasePublicationMissing(state, ledger, cwd) {
@@ -3595,6 +3776,18 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3595
3776
  };
3596
3777
  }
3597
3778
  }
3779
+ // Round 5: the ONE new hard demand. A delegated feature-implementation run whose workers
3780
+ // produced attributed writes owes a passing verification of the INTEGRATED state after the
3781
+ // last worker change. Conflicts / missing files / claim-only workers are not fixable by
3782
+ // running a command, so they stay acceptance-level (advisory) findings, not Stop demands.
3783
+ // Bounded by MAX_CONTINUATIONS like every other missing-evidence item.
3784
+ if (playbookId === playbookExecution.FEATURE_PLAYBOOK_ID && !operationalTask) {
3785
+ const run = effectiveLedger?.playbookRun;
3786
+ if (run && playbookExecution.assessWorkers(run).workers.some((w) => w.state === 'evidenced')) {
3787
+ const integration = playbookExecution.assessIntegration(run, { cwd, verdictCurrent });
3788
+ if (['pending', 'stale', 'failed'].includes(integration.status)) missingEvidence.push('integration-verification');
3789
+ }
3790
+ }
3598
3791
  if (missingEvidence.length === 0) {
3599
3792
  // Silent success: the route is present and every required evidence is satisfied. Marked
3600
3793
  // `complete` so the CLI dispatch recognizes it BEFORE the loud final else — otherwise a
@@ -3790,6 +3983,92 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3790
3983
  };
3791
3984
  }
3792
3985
 
3986
+ // ─── Round 5: feature-implementation lifecycle (acceptance / review inputs / telemetry) ──────
3987
+ // All derived from the task-owned ledger snapshot (effectiveRequestLedger), so another task's
3988
+ // handbacks, writes or verification can never satisfy this task. Pure reads — no I/O besides the
3989
+ // existence/currency checks the integration assessment needs.
3990
+ function featureRunContext({ state = {}, ledger = {}, cwd } = {}) {
3991
+ const routeSummary = state?.routeSummary || {};
3992
+ const playbookId = routeSummary.playbookId || null;
3993
+ if (playbookId !== playbookExecution.FEATURE_PLAYBOOK_ID) return null;
3994
+ const owned = effectiveRequestLedger(state, ledger) || {};
3995
+ const trace = buildPlaybookTrace(owned, playbookId);
3996
+ const run = owned.playbookRun || null;
3997
+ return { routeSummary, playbookId, owned, trace, run, cwd, host: owned.harness || ledger?.harness || null };
3998
+ }
3999
+
4000
+ /** Final Playbook acceptance (see playbook-execution.mjs). NOT_APPLICABLE outside feature-implementation. */
4001
+ export function evaluateFeatureAcceptance({ state = {}, ledger = {}, cwd, reviewDecision = null } = {}) {
4002
+ const context = featureRunContext({ state, ledger, cwd });
4003
+ if (!context) return playbookExecution.evaluatePlaybookAcceptance({ playbookId: state?.routeSummary?.playbookId || null });
4004
+ return playbookExecution.evaluatePlaybookAcceptance({
4005
+ playbookId: context.playbookId,
4006
+ requiredSteps: context.trace.requiredSteps,
4007
+ missingSteps: playbookTraceMissingSteps(context.trace),
4008
+ run: context.run,
4009
+ cwd,
4010
+ verdictCurrent,
4011
+ reviewDecision: reviewDecision ?? context.run?.reviewDecision ?? null,
4012
+ delegationRequired: context.routeSummary.tierLane?.delegationRequired === true,
4013
+ delegationBlocked: context.owned?.delegationViolations?.blocked === true,
4014
+ host: context.host,
4015
+ contractEvidence: contractEvidenceStatus(state, context.owned, cwd),
4016
+ });
4017
+ }
4018
+
4019
+ // Which contract evidence the route demanded and which of it is still unproven (existing predicates).
4020
+ function contractEvidenceStatus(state, owned, cwd) {
4021
+ const required = requiredEvidence(state);
4022
+ if (!required.length) return null;
4023
+ return { required, missing: required.filter((item) => !evidenceSatisfied(item, owned, state, { cwd })) };
4024
+ }
4025
+
4026
+ /** Everything the Stop coordinator needs for a delegated feature run: review inputs, acceptance, telemetry. */
4027
+ export function featureRunReport({ state = {}, ledger = {}, cwd, reviewDecision = null } = {}) {
4028
+ const context = featureRunContext({ state, ledger, cwd });
4029
+ if (!context) return { applicable: false };
4030
+ const run = context.run || playbookExecution.emptyPlaybookRun();
4031
+ const integration = playbookExecution.assessIntegration(run, { cwd, verdictCurrent });
4032
+ const workers = playbookExecution.assessWorkers(run);
4033
+ const rs = context.routeSummary;
4034
+ const riskHigh = (rs.riskEscalation?.level === 'high' && rs.riskEscalation?.stage !== 'shadow') || rs.riskFloor?.floor === 'high-risk';
4035
+ const acceptance = evaluateFeatureAcceptance({ state, ledger, cwd, reviewDecision });
4036
+ return {
4037
+ applicable: true,
4038
+ delegated: integration.delegated,
4039
+ riskHigh,
4040
+ host: playbookExecution.normalizeHost(context.host),
4041
+ integration,
4042
+ workers,
4043
+ acceptance,
4044
+ reviewDispatch: reviewDecision
4045
+ ? playbookExecution.reviewDispatchHint({ decision: reviewDecision, workers, integration, host: context.host, riskHigh })
4046
+ : null,
4047
+ reviewInputs: playbookExecution.playbookReviewInputs({
4048
+ run, workers, integration, riskHigh,
4049
+ routeFixLoopCount: Number(rs.historySignals?.fixLoopCount) || 0,
4050
+ }),
4051
+ telemetry: playbookExecution.summarizePlaybookRun({
4052
+ playbookId: context.playbookId, taskInstanceId: state?.taskInstanceId, trace: context.trace,
4053
+ run, acceptance, reviewDecision: reviewDecision ?? run.reviewDecision, host: context.host,
4054
+ }),
4055
+ };
4056
+ }
4057
+
4058
+ /** Persist the review-policy decision on the ledger as a task-owned `review` receipt. */
4059
+ export async function recordReviewDecision({ projectRoot, payload = {}, decision, harness = 'unknown' } = {}) {
4060
+ if (!projectRoot || !decision || typeof decision.action !== 'string') return { rejected: true, eventId: null };
4061
+ const routeState = await readRouteState(projectRoot, payload);
4062
+ if (routeState?.routeSummary?.playbookId !== playbookExecution.FEATURE_PLAYBOOK_ID) return { rejected: true, eventId: null };
4063
+ return recordLedgerEvent({
4064
+ type: 'receipt',
4065
+ receipt: {
4066
+ ts: Date.now(), kind: 'review', toolName: 'review-policy', success: true,
4067
+ review: { action: decision.action, namedCheck: decision.namedCheck ?? null, signalsHit: decision.signalsHit || [], round: decision.reviewRounds ?? 0 },
4068
+ },
4069
+ }, { projectRoot, payload, harness, routeState });
4070
+ }
4071
+
3793
4072
  // Playbook trace advisory (observability only). A routed playbook other than
3794
4073
  // bug-fix — whose floor stays the one hard gate — whose required steps are
3795
4074
  // neither receipt-done nor proportionately skipped, on a request that already
@@ -3797,7 +4076,7 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3797
4076
  // changes the decision (continue/notify/missingEvidence untouched): no new
3798
4077
  // continuation loop, no hard block. A no-write release (answer/plan/review, or
3799
4078
  // a proportionate trivial route) stays advisory-free.
3800
- function playbookStopAdvisory({ state = {}, ledger = {} } = {}, result = {}) {
4079
+ function playbookStopAdvisory({ state = {}, ledger = {}, cwd } = {}, result = {}) {
3801
4080
  if (!result || result.continue === true) return null;
3802
4081
  const routeSummary = state?.routeSummary || {};
3803
4082
  const playbookId = routeSummary.playbookId || null;
@@ -3805,8 +4084,11 @@ function playbookStopAdvisory({ state = {}, ledger = {} } = {}, result = {}) {
3805
4084
  const effectiveLedger = effectiveRequestLedger(state, ledger);
3806
4085
  if (effectiveLedger.writeAttempted !== true) return null;
3807
4086
  const trace = buildPlaybookTrace(effectiveLedger, playbookId);
3808
- if (playbookTraceMissingSteps(trace).length === 0) return null;
3809
- return `UKit playbook note: ${formatPlaybookTrace(trace)} (advisory).`;
4087
+ const stepNote = playbookTraceMissingSteps(trace).length === 0 ? null
4088
+ : `UKit playbook note: ${formatPlaybookTrace(trace)} (advisory).`;
4089
+ // Round 5: a delegated feature run adds its acceptance verdict — advisory text only.
4090
+ const acceptanceNote = playbookExecution.formatAcceptance(evaluateFeatureAcceptance({ state, ledger, cwd }));
4091
+ return [stepNote, acceptanceNote && `UKit ${acceptanceNote} (advisory).`].filter(Boolean).join(' ') || null;
3810
4092
  }
3811
4093
 
3812
4094
  export function evaluateCompletion(args = {}) {
@@ -3825,6 +4107,10 @@ export function evaluateCompletion(args = {}) {
3825
4107
  };
3826
4108
  result.diagnostics = diagnostics;
3827
4109
  if (result.reason) result.reason += ` [task=${diagnostics.taskInstanceId || 'unknown'} contract=${diagnostics.contract || 'unknown'} accepted-count=${diagnostics.acceptedEvidence.length} rejected=${diagnostics.rejectedEvidence.join(',') || 'none'}]`;
4110
+ try {
4111
+ const acceptance = evaluateFeatureAcceptance({ state: args.state, ledger: args.ledger, cwd: args.cwd });
4112
+ if (acceptance.applicable) result.playbookAcceptance = acceptance;
4113
+ } catch { /* acceptance is advisory and never breaks the gate */ }
3828
4114
  try {
3829
4115
  const advisory = playbookStopAdvisory(args, result);
3830
4116
  if (advisory) return { ...result, playbookAdvisory: advisory };
@@ -325,6 +325,14 @@ async function run(payloadText, scriptArgs, { chainMarker = true, decisionShortC
325
325
  const parsedArgs = scriptArgs.map(parseScriptArg);
326
326
  const scriptPaths = parsedArgs.map((a) => a.scriptPath);
327
327
  const payload = JSON.parse(payloadText || '{}');
328
+ // The omp bridge stamps `ukit_harness` on every payload it builds. pi.exec drops its environment
329
+ // option and the host's spawn inherits the launch-time snapshot, so a runtime process.env write in
330
+ // the bridge never reaches hook children (live: omp ledger harness 'claude-code', main tier read
331
+ // from .claude/settings.json). Setting it here covers this runner, its in-proc .mjs steps and the
332
+ // .sh children (stepEnv spreads process.env).
333
+ if (typeof payload?.ukit_harness === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(payload.ukit_harness)) {
334
+ process.env.UKIT_HARNESS = payload.ukit_harness;
335
+ }
328
336
  const firstScript = scriptPaths[0] || '';
329
337
  const projectRoot = firstScript
330
338
  ? path.resolve(path.dirname(firstScript), '../..')
@@ -127,5 +127,7 @@ export async function applyLoopBreak({ merged, projectRoot, runRoot, payload, se
127
127
  'Stop released. This is NOT approval: completion evidence was not accepted by the blocking policy; this does not mean no verification ran; no safety/permission denial is lifted.',
128
128
  'Report it verbatim; do not retry, wake up, or resume any handoff.',
129
129
  ].join('\n');
130
- return { ...merged, decision: null, reason: undefined, loopBreak: true, policyOwner: 'loop-breaker', systemMessage: merged.systemMessage ? `${note}\n${merged.systemMessage}` : note };
130
+ // The release is a terminal diagnostic, not another carrier for prior hook
131
+ // output. Re-appending that output can recursively amplify escalation text.
132
+ return { ...merged, decision: null, reason: undefined, loopBreak: true, policyOwner: 'loop-breaker', systemMessage: note };
131
133
  }
@@ -1110,6 +1110,43 @@ function linkageField(...candidates) {
1110
1110
  * derivation, the session-boot request_key join, and the envelope session_id.
1111
1111
  * `enforcement` derives per FR-006 unless the emitter overrode it.
1112
1112
  */
1113
+ // Round 5: minimal, non-sensitive Playbook-execution telemetry riding the existing
1114
+ // outcome.observed record. Closed field set, bounded strings/numbers; ids and counts only
1115
+ // (no prompts, paths or reply text). `observed.*` stays null unless the host really reported it.
1116
+ export function outcomePlaybook(value) {
1117
+ if (!isPlainObject(value) || !isNonEmptyString(value.playbookId)) return null;
1118
+ const text = (v, max = 64) => (isNonEmptyString(v) ? v.slice(0, max) : null);
1119
+ const num = (v) => (Number.isFinite(v) && v >= 0 ? Math.trunc(v) : null);
1120
+ const obj = (v) => (isPlainObject(v) ? v : {});
1121
+ const tokens = isPlainObject(value.tokens) ? {
1122
+ input: num(value.tokens.input), output: num(value.tokens.output), cacheRead: num(value.tokens.cacheRead),
1123
+ cacheCreate: num(value.tokens.cacheCreate), total: num(value.tokens.total),
1124
+ provenance: ['PROVIDER', 'ESTIMATED', 'UNKNOWN'].includes(value.tokens.provenance) ? value.tokens.provenance : 'UNKNOWN',
1125
+ } : null;
1126
+ const outcomes = obj(value.workerOutcomes);
1127
+ return {
1128
+ playbookId: text(value.playbookId),
1129
+ taskInstanceId: text(value.taskInstanceId, 96),
1130
+ host: text(value.host, 16),
1131
+ stepsRequired: num(value.stepsRequired),
1132
+ stepsDone: num(value.stepsDone),
1133
+ workerCount: num(value.workerCount),
1134
+ executionMode: ['none', 'single', 'sequential', 'concurrent', 'unknown'].includes(value.executionMode) ? value.executionMode : 'unknown',
1135
+ requested: { tier: text(obj(value.requested).tier, 16), model: text(obj(value.requested).model), effort: text(obj(value.requested).effort, 16) },
1136
+ observed: { model: text(obj(value.observed).model), effort: text(obj(value.observed).effort, 16) },
1137
+ resolved: { tier: text(obj(value.resolved).tier, 16), effort: text(obj(value.resolved).effort, 16), effortBinding: text(obj(value.resolved).effortBinding, 24) },
1138
+ effective: { model: text(obj(value.effective).model) || 'UNKNOWN', effort: text(obj(value.effective).effort, 16) || 'UNKNOWN' },
1139
+ durationMs: num(value.durationMs),
1140
+ retries: num(value.retries),
1141
+ workerOutcomes: { evidenced: num(outcomes.evidenced), claimOnly: num(outcomes.claimOnly), unobservable: num(outcomes.unobservable), failed: num(outcomes.failed) },
1142
+ tokens,
1143
+ integration: text(value.integration, 24),
1144
+ review: text(value.review, 32),
1145
+ reviewState: text(value.reviewState, 32),
1146
+ acceptance: ['PASS', 'NOT_PASS', 'UNVERIFIED'].includes(value.acceptance) ? value.acceptance : null,
1147
+ };
1148
+ }
1149
+
1113
1150
  export function buildOutcomeObservedRecord({
1114
1151
  outcome = null,
1115
1152
  projectRoot = null,
@@ -1156,6 +1193,7 @@ export function buildOutcomeObservedRecord({
1156
1193
  source: isNonEmptyString(safe.source) ? safe.source.slice(0, 96) : null,
1157
1194
  request_key: sessionBootRequestKey({ projectRoot, sessionId: resolvedSessionId }),
1158
1195
  ts: isNonEmptyString(safe.ts) ? safe.ts : new Date().toISOString(),
1196
+ ...(outcomePlaybook(safe.playbook) ? { playbook: outcomePlaybook(safe.playbook) } : {}),
1159
1197
  };
1160
1198
  return {
1161
1199
  semantic_name: SEMANTIC_OUTCOME_OBSERVED,