@ngockhoale/ukit 3.4.47 → 3.4.49

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 (62) hide show
  1. package/CHANGELOG.md +15 -0
  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/worktree-preflight.mjs +20 -1
  6. package/src/core/diffPlan.js +38 -12
  7. package/src/core/executionContracts.js +79 -18
  8. package/src/core/metadata.js +4 -0
  9. package/src/core/runInstallPipeline.js +19 -2
  10. package/src/core/runtimeConfig.js +1 -1
  11. package/src/index/playbookRegistry.js +22 -3
  12. package/src/index/taskRouting.js +2 -2
  13. package/template_project/.claude/agents/bug-debugger.md +1 -0
  14. package/template_project/.claude/agents/code-reviewer.md +1 -0
  15. package/template_project/.claude/agents/feature-implementer.md +1 -0
  16. package/template_project/.claude/agents/handoff-planner.md +1 -0
  17. package/template_project/.claude/agents/ukit-small-task-maintainer.md +1 -0
  18. package/template_project/.claude/hooks/completion-gate.sh +1 -1
  19. package/template_project/.claude/hooks/delegation-gate.mjs +343 -0
  20. package/template_project/.claude/hooks/skill-router.sh +159 -8
  21. package/template_project/.claude/settings.json +8 -8
  22. package/template_project/.claude/ukit/index/playbook-registry.mjs +22 -3
  23. package/template_project/.claude/ukit/index/route-resolver.mjs +79 -17
  24. package/template_project/.claude/ukit/index/route-task.mjs +115 -37
  25. package/template_project/.claude/ukit/index/tier-lane.mjs +202 -0
  26. package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +3 -1
  27. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +331 -21
  28. package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +8 -0
  29. package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
  30. package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -0
  31. package/template_project/.claude/ukit/runtime/playbook-execution.mjs +704 -0
  32. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +79 -3
  33. package/template_project/.omp/agents/bug-debugger.md +1 -0
  34. package/template_project/.omp/agents/code-reviewer.md +1 -0
  35. package/template_project/.omp/agents/feature-implementer.md +1 -0
  36. package/template_project/.omp/agents/handoff-planner.md +1 -0
  37. package/template_project/.omp/agents/ukit-small-task-maintainer.md +1 -0
  38. package/template_project/.omp/hooks/pre/ukit-bridge.js +254 -7
  39. package/template_project/ukit/storage/config.json +1 -1
  40. package/scripts/bench/data-foundation.mjs +0 -929
  41. package/scripts/bench/decision-runtime-baseline.mjs +0 -427
  42. package/scripts/bench/decision-runtime-variant.mjs +0 -626
  43. package/scripts/bench/goldTasks.json +0 -38
  44. package/scripts/bench/memory-ablation.mjs +0 -495
  45. package/scripts/bench/memory-baseline.mjs +0 -596
  46. package/scripts/bench/memory-bench.mjs +0 -661
  47. package/scripts/bench/memory-canary.mjs +0 -331
  48. package/scripts/bench/memory-corpus.mjs +0 -354
  49. package/scripts/bench/memory-gate.mjs +0 -389
  50. package/scripts/bench/memory-metrics.mjs +0 -179
  51. package/scripts/bench/outline-savings.mjs +0 -107
  52. package/scripts/bench/parallel-agents.mjs +0 -224
  53. package/scripts/bench/recorder-overhead.mjs +0 -204
  54. package/scripts/bench/runGold.mjs +0 -238
  55. package/scripts/bench/sqlite-spike.mjs +0 -451
  56. package/scripts/bench/subagent-orchestrator-corpus.mjs +0 -275
  57. package/scripts/bench/subagent-orchestrator-eval.mjs +0 -565
  58. package/scripts/bench/v3-ceremony.mjs +0 -220
  59. package/scripts/perf/audit-perf.mjs +0 -1195
  60. package/scripts/perf/diff-perf-findings.mjs +0 -136
  61. package/scripts/perf/perf-findings.json +0 -474
  62. 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,16 +3489,75 @@ 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;
3533
+ }
3534
+
3535
+ // Conditional repair is not a publication order until owned project source actually changes.
3536
+ // Reuse the affirmative publication parser on the taken branch, including its negation rules.
3537
+ function conditionalRepairOrdersPublish(state, ledger, cwd) {
3538
+ if (ledger?.substantiveWrite !== true || !cwd) return false;
3539
+ const text = String(state?.routingContext?.lastExplicitUserPromptText || '')
3540
+ .normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[đĐ]/g, 'd').toLowerCase()
3541
+ .replace(/```[^]*?```|~~~[^]*?~~~/g, '').replace(/^[ \t]*>.*$/gm, '');
3542
+ if (!/(?:^|\n)[ \t*#_-]*(?:muc tieu|goal|purpose|objective)\s*:[^\n]*\b(?:xac nhan|verify|confirm|validate)\b/.test(text)) return false;
3543
+ const branch = text.match(/(?:^|\n|\.\s+)[ \t]*(?:\d+[.)]\s*)?(?:neu\s+co\s+source fix|if\s+(?:there is a\s+)?source fix)\s*:[^\n]*/)?.[0];
3544
+ if (!branch) return false;
3545
+ const writes = [...(ledger.receipts || []), ...Object.keys(ledger.bankedWrites || {}).map((file) => ({ kind: 'write', success: true, file }))];
3546
+ const projectWrite = writes.some((receipt) => {
3547
+ if (receipt.kind !== 'write' || receipt.success !== true || !receipt.file || isBookkeepingWrite(receipt.file)) return false;
3548
+ if (receipt.detail === 'bash-workspace-delta' && /^bash-workspace:/.test(receipt.file)) return true;
3549
+ const relative = path.relative(path.resolve(cwd), path.resolve(cwd, receipt.file));
3550
+ return relative && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)
3551
+ && !/^(?:\.ukit|\.cache|node_modules|tests?|fixtures?|tmp)(?:[\\/]|$)/.test(relative);
3552
+ });
3553
+ if (!projectWrite) return false;
3554
+ // Explicit task-wide publication prohibitions still dominate the conditional branch.
3555
+ if (/(?:do not|never|khong|chua)\s+(?:npm\s+)?publish\b|\bno\s+(?:release|publication)\b/.test(text)) return false;
3556
+ return promptOrdersNpmPublish({ routingContext: { lastExplicitUserPromptText: branch.replace(/^.*?:/, 'Run ') } });
3352
3557
  }
3353
3558
 
3354
3559
  function releasePublicationMissing(state, ledger, cwd) {
3355
- if (!promptOrdersNpmPublish(state)) return null;
3560
+ if (!promptOrdersNpmPublish(state) && !conditionalRepairOrdersPublish(state, ledger, cwd)) return null;
3356
3561
  let pkg = null;
3357
3562
  try { pkg = JSON.parse(fsSync.readFileSync(path.join(cwd || process.cwd(), 'package.json'), 'utf8')); } catch { return null; }
3358
3563
  if (!pkg?.version) return null;
@@ -3595,6 +3800,18 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3595
3800
  };
3596
3801
  }
3597
3802
  }
3803
+ // Round 5: the ONE new hard demand. A delegated feature-implementation run whose workers
3804
+ // produced attributed writes owes a passing verification of the INTEGRATED state after the
3805
+ // last worker change. Conflicts / missing files / claim-only workers are not fixable by
3806
+ // running a command, so they stay acceptance-level (advisory) findings, not Stop demands.
3807
+ // Bounded by MAX_CONTINUATIONS like every other missing-evidence item.
3808
+ if (playbookId === playbookExecution.FEATURE_PLAYBOOK_ID && !operationalTask) {
3809
+ const run = effectiveLedger?.playbookRun;
3810
+ if (run && playbookExecution.assessWorkers(run).workers.some((w) => w.state === 'evidenced')) {
3811
+ const integration = playbookExecution.assessIntegration(run, { cwd, verdictCurrent });
3812
+ if (['pending', 'stale', 'failed'].includes(integration.status)) missingEvidence.push('integration-verification');
3813
+ }
3814
+ }
3598
3815
  if (missingEvidence.length === 0) {
3599
3816
  // Silent success: the route is present and every required evidence is satisfied. Marked
3600
3817
  // `complete` so the CLI dispatch recognizes it BEFORE the loud final else — otherwise a
@@ -3790,6 +4007,92 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3790
4007
  };
3791
4008
  }
3792
4009
 
4010
+ // ─── Round 5: feature-implementation lifecycle (acceptance / review inputs / telemetry) ──────
4011
+ // All derived from the task-owned ledger snapshot (effectiveRequestLedger), so another task's
4012
+ // handbacks, writes or verification can never satisfy this task. Pure reads — no I/O besides the
4013
+ // existence/currency checks the integration assessment needs.
4014
+ function featureRunContext({ state = {}, ledger = {}, cwd } = {}) {
4015
+ const routeSummary = state?.routeSummary || {};
4016
+ const playbookId = routeSummary.playbookId || null;
4017
+ if (playbookId !== playbookExecution.FEATURE_PLAYBOOK_ID) return null;
4018
+ const owned = effectiveRequestLedger(state, ledger) || {};
4019
+ const trace = buildPlaybookTrace(owned, playbookId);
4020
+ const run = owned.playbookRun || null;
4021
+ return { routeSummary, playbookId, owned, trace, run, cwd, host: owned.harness || ledger?.harness || null };
4022
+ }
4023
+
4024
+ /** Final Playbook acceptance (see playbook-execution.mjs). NOT_APPLICABLE outside feature-implementation. */
4025
+ export function evaluateFeatureAcceptance({ state = {}, ledger = {}, cwd, reviewDecision = null } = {}) {
4026
+ const context = featureRunContext({ state, ledger, cwd });
4027
+ if (!context) return playbookExecution.evaluatePlaybookAcceptance({ playbookId: state?.routeSummary?.playbookId || null });
4028
+ return playbookExecution.evaluatePlaybookAcceptance({
4029
+ playbookId: context.playbookId,
4030
+ requiredSteps: context.trace.requiredSteps,
4031
+ missingSteps: playbookTraceMissingSteps(context.trace),
4032
+ run: context.run,
4033
+ cwd,
4034
+ verdictCurrent,
4035
+ reviewDecision: reviewDecision ?? context.run?.reviewDecision ?? null,
4036
+ delegationRequired: context.routeSummary.tierLane?.delegationRequired === true,
4037
+ delegationBlocked: context.owned?.delegationViolations?.blocked === true,
4038
+ host: context.host,
4039
+ contractEvidence: contractEvidenceStatus(state, context.owned, cwd),
4040
+ });
4041
+ }
4042
+
4043
+ // Which contract evidence the route demanded and which of it is still unproven (existing predicates).
4044
+ function contractEvidenceStatus(state, owned, cwd) {
4045
+ const required = requiredEvidence(state);
4046
+ if (!required.length) return null;
4047
+ return { required, missing: required.filter((item) => !evidenceSatisfied(item, owned, state, { cwd })) };
4048
+ }
4049
+
4050
+ /** Everything the Stop coordinator needs for a delegated feature run: review inputs, acceptance, telemetry. */
4051
+ export function featureRunReport({ state = {}, ledger = {}, cwd, reviewDecision = null } = {}) {
4052
+ const context = featureRunContext({ state, ledger, cwd });
4053
+ if (!context) return { applicable: false };
4054
+ const run = context.run || playbookExecution.emptyPlaybookRun();
4055
+ const integration = playbookExecution.assessIntegration(run, { cwd, verdictCurrent });
4056
+ const workers = playbookExecution.assessWorkers(run);
4057
+ const rs = context.routeSummary;
4058
+ const riskHigh = (rs.riskEscalation?.level === 'high' && rs.riskEscalation?.stage !== 'shadow') || rs.riskFloor?.floor === 'high-risk';
4059
+ const acceptance = evaluateFeatureAcceptance({ state, ledger, cwd, reviewDecision });
4060
+ return {
4061
+ applicable: true,
4062
+ delegated: integration.delegated,
4063
+ riskHigh,
4064
+ host: playbookExecution.normalizeHost(context.host),
4065
+ integration,
4066
+ workers,
4067
+ acceptance,
4068
+ reviewDispatch: reviewDecision
4069
+ ? playbookExecution.reviewDispatchHint({ decision: reviewDecision, workers, integration, host: context.host, riskHigh })
4070
+ : null,
4071
+ reviewInputs: playbookExecution.playbookReviewInputs({
4072
+ run, workers, integration, riskHigh,
4073
+ routeFixLoopCount: Number(rs.historySignals?.fixLoopCount) || 0,
4074
+ }),
4075
+ telemetry: playbookExecution.summarizePlaybookRun({
4076
+ playbookId: context.playbookId, taskInstanceId: state?.taskInstanceId, trace: context.trace,
4077
+ run, acceptance, reviewDecision: reviewDecision ?? run.reviewDecision, host: context.host,
4078
+ }),
4079
+ };
4080
+ }
4081
+
4082
+ /** Persist the review-policy decision on the ledger as a task-owned `review` receipt. */
4083
+ export async function recordReviewDecision({ projectRoot, payload = {}, decision, harness = 'unknown' } = {}) {
4084
+ if (!projectRoot || !decision || typeof decision.action !== 'string') return { rejected: true, eventId: null };
4085
+ const routeState = await readRouteState(projectRoot, payload);
4086
+ if (routeState?.routeSummary?.playbookId !== playbookExecution.FEATURE_PLAYBOOK_ID) return { rejected: true, eventId: null };
4087
+ return recordLedgerEvent({
4088
+ type: 'receipt',
4089
+ receipt: {
4090
+ ts: Date.now(), kind: 'review', toolName: 'review-policy', success: true,
4091
+ review: { action: decision.action, namedCheck: decision.namedCheck ?? null, signalsHit: decision.signalsHit || [], round: decision.reviewRounds ?? 0 },
4092
+ },
4093
+ }, { projectRoot, payload, harness, routeState });
4094
+ }
4095
+
3793
4096
  // Playbook trace advisory (observability only). A routed playbook other than
3794
4097
  // bug-fix — whose floor stays the one hard gate — whose required steps are
3795
4098
  // neither receipt-done nor proportionately skipped, on a request that already
@@ -3797,7 +4100,7 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3797
4100
  // changes the decision (continue/notify/missingEvidence untouched): no new
3798
4101
  // continuation loop, no hard block. A no-write release (answer/plan/review, or
3799
4102
  // a proportionate trivial route) stays advisory-free.
3800
- function playbookStopAdvisory({ state = {}, ledger = {} } = {}, result = {}) {
4103
+ function playbookStopAdvisory({ state = {}, ledger = {}, cwd } = {}, result = {}) {
3801
4104
  if (!result || result.continue === true) return null;
3802
4105
  const routeSummary = state?.routeSummary || {};
3803
4106
  const playbookId = routeSummary.playbookId || null;
@@ -3805,8 +4108,11 @@ function playbookStopAdvisory({ state = {}, ledger = {} } = {}, result = {}) {
3805
4108
  const effectiveLedger = effectiveRequestLedger(state, ledger);
3806
4109
  if (effectiveLedger.writeAttempted !== true) return null;
3807
4110
  const trace = buildPlaybookTrace(effectiveLedger, playbookId);
3808
- if (playbookTraceMissingSteps(trace).length === 0) return null;
3809
- return `UKit playbook note: ${formatPlaybookTrace(trace)} (advisory).`;
4111
+ const stepNote = playbookTraceMissingSteps(trace).length === 0 ? null
4112
+ : `UKit playbook note: ${formatPlaybookTrace(trace)} (advisory).`;
4113
+ // Round 5: a delegated feature run adds its acceptance verdict — advisory text only.
4114
+ const acceptanceNote = playbookExecution.formatAcceptance(evaluateFeatureAcceptance({ state, ledger, cwd }));
4115
+ return [stepNote, acceptanceNote && `UKit ${acceptanceNote} (advisory).`].filter(Boolean).join(' ') || null;
3810
4116
  }
3811
4117
 
3812
4118
  export function evaluateCompletion(args = {}) {
@@ -3825,6 +4131,10 @@ export function evaluateCompletion(args = {}) {
3825
4131
  };
3826
4132
  result.diagnostics = diagnostics;
3827
4133
  if (result.reason) result.reason += ` [task=${diagnostics.taskInstanceId || 'unknown'} contract=${diagnostics.contract || 'unknown'} accepted-count=${diagnostics.acceptedEvidence.length} rejected=${diagnostics.rejectedEvidence.join(',') || 'none'}]`;
4134
+ try {
4135
+ const acceptance = evaluateFeatureAcceptance({ state: args.state, ledger: args.ledger, cwd: args.cwd });
4136
+ if (acceptance.applicable) result.playbookAcceptance = acceptance;
4137
+ } catch { /* acceptance is advisory and never breaks the gate */ }
3828
4138
  try {
3829
4139
  const advisory = playbookStopAdvisory(args, result);
3830
4140
  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,