@ngockhoale/ukit 3.4.29 → 3.4.31

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 (49) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/manifests/documentation.yaml +100 -0
  3. package/package.json +1 -1
  4. package/src/core/executionContracts.js +32 -0
  5. package/src/core/permissionPolicy.js +1 -1
  6. package/src/decision/client.js +15 -0
  7. package/src/decision/protocol.js +12 -14
  8. package/src/index/buildIndex.js +244 -4
  9. package/src/index/impactContext.js +91 -11
  10. package/src/index/languageTools.js +126 -0
  11. package/src/index/paths.js +5 -1
  12. package/src/index/playbookRegistry.js +108 -9
  13. package/src/index/queryIndex.js +415 -1
  14. package/src/index/routeCatalog.js +1 -1
  15. package/src/index/routeResolver.js +2 -2
  16. package/src/index/taskRouting.js +28 -4
  17. package/template_project/.claude/commands/ukit/handoff-create.md +5 -0
  18. package/template_project/.claude/commands/ukit/handoff-fullstack.md +43 -4
  19. package/template_project/.claude/hooks/block-dangerous.mjs +7 -1
  20. package/template_project/.claude/hooks/handoff-model-guard.sh +235 -21
  21. package/template_project/.claude/hooks/record-execution.mjs +21 -0
  22. package/template_project/.claude/hooks/skill-router.sh +187 -16
  23. package/template_project/.claude/settings.json +43 -1
  24. package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
  25. package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +10 -4
  26. package/template_project/.claude/ukit/index/impact-context.mjs +70 -15
  27. package/template_project/.claude/ukit/index/land-worktree.mjs +2 -2
  28. package/template_project/.claude/ukit/index/lib/index-core.mjs +883 -14
  29. package/template_project/.claude/ukit/index/playbook-registry.mjs +108 -9
  30. package/template_project/.claude/ukit/index/query-index.mjs +100 -7
  31. package/template_project/.claude/ukit/index/review-verdict.mjs +14 -4
  32. package/template_project/.claude/ukit/index/route-catalog.mjs +1 -1
  33. package/template_project/.claude/ukit/index/route-resolver.mjs +34 -2
  34. package/template_project/.claude/ukit/index/route-task.mjs +243 -38
  35. package/template_project/.claude/ukit/index/sidecar-decision.mjs +3 -59
  36. package/template_project/.claude/ukit/index/unic-decision.mjs +27 -14
  37. package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +43 -0
  38. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +612 -56
  39. package/template_project/.claude/ukit/runtime/handoff-intent.mjs +122 -6
  40. package/template_project/.claude/ukit/runtime/loop-state.mjs +1 -1
  41. package/template_project/.claude/ukit/runtime/operation-evidence.mjs +87 -0
  42. package/template_project/.claude/ukit/runtime/resumable-run.mjs +417 -0
  43. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +635 -28
  44. package/template_project/.omp/hooks/pre/ukit-bridge.js +40 -5
  45. package/template_project/AGENTS.md +1 -1
  46. package/template_project/CLAUDE.md +1 -1
  47. package/template_project/instructions/core.md +1 -1
  48. package/template_user/playbooks/feature-implementation.md +9 -0
  49. package/template_user/playbooks/migration.md +4 -0
@@ -7,6 +7,8 @@ import fsSync from 'node:fs';
7
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
+ import { cronOperationReceipt } from './cron-operation-adapter.mjs';
11
+ import { operationCompletionPolicy, foldOperationEvidence, compactOperation } from './operation-evidence.mjs';
10
12
 
11
13
  // Hook-context self-deadline (2.4.1 orphan-leak class): armed ONLY on direct
12
14
  // CLI invocation (see isDirectRun below) — never at import time. An import-time
@@ -307,9 +309,27 @@ export async function readRouteState(projectRoot, payload = {}) {
307
309
  const state = await readJson(path.join(projectRoot, '.claude', 'ukit', 'skill-router-state.json'), null);
308
310
  const sessionId = explicitSessionId(payload);
309
311
  const stateSessionId = explicitSessionId(state || {});
310
- if (!state || !sessionId) return state;
311
- if (!stateSessionId) return null;
312
- return stateSessionId === sessionId ? state : null;
312
+ if (!state || !sessionId) {
313
+ // The shared file is single-slot: with no shared file there is still a per-session record.
314
+ return sessionId ? readSessionRouteState(projectRoot, sessionId) : state;
315
+ }
316
+ if (stateSessionId === sessionId) return state;
317
+ // Shared file missing an owner or owned by another session (a concurrent session routed
318
+ // after this one): recover this session's own record instead of declaring the route lost.
319
+ return readSessionRouteState(projectRoot, sessionId);
320
+ }
321
+
322
+ // Per-session route-state sidecar (written by skill-router.sh and route-task.mjs next to the
323
+ // shared single-slot file). Owner is re-verified from the embedded sessionId, so a misfiled or
324
+ // tampered record can never be adopted by another session.
325
+ export async function readSessionRouteState(projectRoot, sessionId) {
326
+ const id = String(sessionId || '').trim();
327
+ if (!id) return null;
328
+ const state = await readJson(
329
+ path.join(projectRoot, '.ukit', 'storage', 'cache', 'route-state', `${safeSegment(id)}.json`),
330
+ null,
331
+ );
332
+ return state && explicitSessionId(state) === id ? state : null;
313
333
  }
314
334
 
315
335
  export async function readExecutionLedger(projectRoot, payload = {}) {
@@ -327,7 +347,11 @@ function explicitPromptKey(routeState = {}) {
327
347
  return promptKeyFromText(routeState?.routingContext?.lastExplicitUserPromptText);
328
348
  }
329
349
 
330
- function hasUnfinishedCompletion(state = {}, ledger = {}) {
350
+ export function hasUnfinishedCompletion(state = {}, ledger = {}, { cwd } = {}) {
351
+ if (state?.routeSummary?.executionContract?.taskKind) {
352
+ const result = evaluateCompletion({ state, ledger, cwd });
353
+ return result.complete !== true && (result.missingEvidence || []).length > 0 && !ledger.blocker;
354
+ }
331
355
  if (ledger?.blocker) return false;
332
356
  const mode = state?.routeSummary?.executionMode || state?.routeSummary?.approachSelector?.executionMode;
333
357
  if (!IMPLEMENT_MODES.has(mode)) return false;
@@ -366,7 +390,7 @@ export async function readResumableExecution(projectRoot, payload = {}) {
366
390
  if (!state || !ledger || !ledger.sessionId || ledger.sessionId !== sessionId
367
391
  || ledger.sessionKey !== safeSegment(sessionId) || !ledger.requestKey
368
392
  || !ledger.promptKey || !promptKey || ledger.promptKey !== promptKey
369
- || !hasUnfinishedCompletion(state, ledger)) {
393
+ || !hasUnfinishedCompletion(state, ledger, { cwd: projectRoot })) {
370
394
  return null;
371
395
  }
372
396
  return { state, ledger, sessionId };
@@ -392,9 +416,11 @@ export async function readResumeIntent(projectRoot, payload = {}, { consume = fa
392
416
  // resumes; a genuinely different prompt produces a different promptKey and stays dead.
393
417
  if (!state || !ledger || ledger.sessionId !== sessionId
394
418
  || ledger.sessionKey !== safeSegment(sessionId)
419
+ || ((state.taskInstanceId || intent.taskInstanceId)
420
+ && (!state.taskInstanceId || state.taskInstanceId !== intent.taskInstanceId || ledger.taskInstanceId !== intent.taskInstanceId))
395
421
  || promptKey !== intent.promptKey
396
422
  || ledger.promptKey !== intent.promptKey
397
- || !hasUnfinishedCompletion(state, ledger)) {
423
+ || !hasUnfinishedCompletion(state, ledger, { cwd: projectRoot })) {
398
424
  return null;
399
425
  }
400
426
  if (consume) {
@@ -410,7 +436,7 @@ export async function writeResumeIntent(projectRoot, payload = {}, { now = Date.
410
436
  const ledger = await readExecutionLedger(projectRoot, payload);
411
437
  const promptKey = explicitPromptKey(state || {});
412
438
  if (!state || !ledger || ledger.sessionId !== sessionId || !promptKey
413
- || ledger.promptKey !== promptKey || !ledger.requestKey || !hasUnfinishedCompletion(state, ledger)) {
439
+ || ledger.promptKey !== promptKey || !ledger.requestKey || !hasUnfinishedCompletion(state, ledger, { cwd: projectRoot })) {
414
440
  return null;
415
441
  }
416
442
  const intent = {
@@ -418,6 +444,7 @@ export async function writeResumeIntent(projectRoot, payload = {}, { now = Date.
418
444
  sessionKey: safeSegment(sessionId),
419
445
  sessionHash: resumeSessionHash(sessionId),
420
446
  promptKey,
447
+ taskInstanceId: state.taskInstanceId || null,
421
448
  requestKey: ledger.requestKey,
422
449
  routeFingerprint: state.fingerprint || null,
423
450
  createdAt: now,
@@ -503,15 +530,55 @@ function extractExitCode(payload = {}) {
503
530
 
504
531
  // The trailing boundary accepts shell terminators (`;`, `&`, `|`, `)`) so
505
532
  // `yarn release:verify; echo "exit=$?"` — the real-exit-code idiom — still mints a receipt.
506
- function isVerificationCommand(command) {
507
- const text = String(command || '').trim();
508
- if (!text) return false;
533
+ // Blank out quoted literals so `echo '(jest passed)'` / `git commit -m "run yarn test"` do not
534
+ // look like a runner invocation. A `bash|sh|zsh -c '<script>'` wrapper really executes its
535
+ // quoted script, so that script is tested on its own (bounded recursion).
536
+ function maskQuotedLiterals(text) {
537
+ let out = '';
538
+ let quote = null;
539
+ let escaped = false;
540
+ for (const char of text) {
541
+ if (escaped) { escaped = false; if (!quote) out += char; continue; }
542
+ if (char === '\\' && quote !== "'") { escaped = true; if (!quote) out += char; continue; }
543
+ if (quote) { if (char === quote) { quote = null; out += char; } continue; }
544
+ if (char === "'" || char === '"') { quote = char; out += char; continue; }
545
+ out += char;
546
+ }
547
+ return out;
548
+ }
549
+
550
+ function insideQuote(text, index) {
551
+ let quote = null;
552
+ let escaped = false;
553
+ for (let i = 0; i < index; i += 1) {
554
+ const char = text[i];
555
+ if (escaped) { escaped = false; continue; }
556
+ if (char === '\\' && quote !== "'") { escaped = true; continue; }
557
+ if (quote) { if (char === quote) quote = null; continue; }
558
+ if (char === "'" || char === '"') quote = char;
559
+ }
560
+ return quote !== null;
561
+ }
562
+
563
+ const SHELL_C_WRAPPER = /(?:^|[\s;&|({])(?:ba|z|da|k)?sh\s+(?:-o\s+\w+\s+|-[a-z]+\s+)*-[a-z]*c\s+(?:'([^']*)'|"((?:[^"\\]|\\.)*)")/gi;
564
+
565
+ function isVerificationCommand(command, depth = 0) {
566
+ const raw = String(command || '').trim();
567
+ if (!raw) return false;
568
+ if (depth < 2) {
569
+ for (const match of raw.matchAll(SHELL_C_WRAPPER)) {
570
+ // A wrapper that is itself inside a quoted literal (`git commit -m "sh -c 'yarn test'"`) ran nothing.
571
+ if (insideQuote(raw, match.index)) continue;
572
+ if (isVerificationCommand(match[1] ?? match[2] ?? '', depth + 1)) return true;
573
+ }
574
+ }
575
+ const text = maskQuotedLiterals(raw);
509
576
  return [
510
- /(?:^|\s|\.bin\/)(?:vitest|jest|mocha|ava|pytest|py\.test)(?:\s|$|[;&|)])/i,
511
- /(?:^|\s)(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+(?:test|lint|typecheck|check|build)(?:\s|$|[;&|)])/i,
512
- /(?:^|\s)(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+release:verify(?:\s|$|[;&|)])/i,
513
- /(?:^|\s|\.bin\/)(?:tsc|eslint|biome|ruff|mypy)(?:\s|$|[;&|)])/i,
514
- /(?:^|\s)node\s+--check(?:\s|$|[;&|)])/i,
577
+ /(?:^|[\s({]|\.bin\/)(?:vitest|jest|mocha|ava|pytest|py\.test)(?:\s|$|[;&|)])/i,
578
+ /(?:^|[\s({])(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+(?:test(?::[\w:.-]+)?|lint|typecheck|check|build)(?:\s|$|[;&|)])/i,
579
+ /(?:^|[\s({])(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+release:verify(?:\s|$|[;&|)])/i,
580
+ /(?:^|[\s({]|\.bin\/)(?:tsc|eslint|biome|ruff|mypy)(?:\s|$|[;&|)])/i,
581
+ /(?:^|[\s({])node\s+--check(?:\s|$|[;&|)])/i,
515
582
  ].some((pattern) => pattern.test(text));
516
583
  }
517
584
 
@@ -591,9 +658,9 @@ function compactReceipt(receipt) {
591
658
  kind: receipt.kind,
592
659
  success: receipt.success,
593
660
  };
594
- for (const key of ['toolName', 'toolUseId', 'file', 'command', 'exitCode', 'scope', 'error', 'verdict', 'evidence', 'evidenceSurface', 'class', 'status', 'detail', 'exitStatusUnproven']) {
661
+ for (const key of ['toolName', 'toolUseId', 'file', 'command', 'exitCode', 'scope', 'error', 'verdict', 'evidence', 'evidenceSurface', 'class', 'status', 'detail', 'operation', 'transportSuccess', 'taskInstanceId', 'sessionKey', 'sequence', 'exitStatusUnproven']) {
595
662
  if (receipt[key] !== undefined && receipt[key] !== null && receipt[key] !== '') {
596
- compact[key] = receipt[key];
663
+ compact[key] = key === 'operation' ? compactOperation(receipt[key]) : receipt[key];
597
664
  }
598
665
  }
599
666
  return compact;
@@ -673,6 +740,27 @@ function workspaceFingerprint(cwd) {
673
740
  }
674
741
  }
675
742
 
743
+ // Verification currency excludes UKit's mutable receipts/router state, not source files.
744
+ function verificationDirtyFingerprint(cwd) {
745
+ try {
746
+ const paths = ['.', ':(exclude).ukit/storage/**', ':(exclude).claude/ukit/skill-router-state.json'];
747
+ const options = { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: 1500, maxBuffer: 16 * 1024 * 1024 };
748
+ const diff = execFileSync('git', ['diff', 'HEAD', '--no-ext-diff', '--', ...paths], options);
749
+ const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard', '-z', '--', ...paths], options);
750
+ const hash = crypto.createHash('sha256').update(diff);
751
+ let bytes = diff.length + untracked.length;
752
+ for (const file of untracked.toString('utf8').split('\0').filter(Boolean).sort()) {
753
+ const absolute = path.resolve(cwd, file);
754
+ const stat = fsSync.lstatSync(absolute);
755
+ bytes += stat.size;
756
+ if (bytes > options.maxBuffer || (!stat.isFile() && !stat.isSymbolicLink())) return null;
757
+ const content = stat.isSymbolicLink() ? fsSync.readlinkSync(absolute) : fsSync.readFileSync(absolute);
758
+ hash.update(`\0${file}\0`).update(content);
759
+ }
760
+ return hash.digest('hex');
761
+ } catch { return null; }
762
+ }
763
+
676
764
  // A workspace delta cannot be attributed to the Bash call that happened to observe it: a
677
765
  // background process or a subagent may have changed the tree in between. The classifier below
678
766
  // only ever REMOVES credit — a command it recognises as strictly read-only never receives a
@@ -736,7 +824,8 @@ function gitPatchId(diffText) {
736
824
  // SPEC §2.2 — is this verdict still valid at the current head?
737
825
  // Returns { status, current, headSha } where status is one of:
738
826
  // 'current' | 'stale' | 'stale-recoverable' | 'untracked' (no git / no sha data).
739
- // Cost: one `rev-parse` fast path; when head moved, one `git diff` + one `patch-id`.
827
+ // Cost: rev-parse plus a bounded diff/untracked snapshot for new verdicts;
828
+ // when head moved, the existing diff/patch-id comparison also applies.
740
829
  export function verdictCurrent(verdict, { cwd = process.cwd() } = {}) {
741
830
  if (!verdict || typeof verdict !== 'object') {
742
831
  return { status: 'untracked', current: false, headSha: null };
@@ -750,6 +839,9 @@ export function verdictCurrent(verdict, { cwd = process.cwd() } = {}) {
750
839
  cwd = verdict.workspaceRoot;
751
840
  }
752
841
  const currentHead = gitOutput(['rev-parse', 'HEAD'], cwd);
842
+ if (verdict.headSha && Object.hasOwn(verdict, 'dirtyFp') && (!verdict.dirtyFp || verificationDirtyFingerprint(cwd) !== verdict.dirtyFp)) {
843
+ return { status: 'stale', current: false, headSha: currentHead };
844
+ }
753
845
  if (!currentHead) {
754
846
  // No git repo (or git unavailable): currency cannot be proven either way —
755
847
  // null-safe per spec non-goals; do not invent staleness on lanes without git.
@@ -785,7 +877,21 @@ export function verdictCurrent(verdict, { cwd = process.cwd() } = {}) {
785
877
  // (a hook or subagent can attest its own kind/verifier); otherwise the kind is
786
878
  // derived from the command shape — test runners prove `test-verified`, a run that
787
879
  // only type-checks/lints is honestly `check-only`.
788
- const TEST_RUNNER_COMMAND = /(?:^|\s|\.bin\/)(?:vitest|jest|mocha|ava|pytest|py\.test)(?:\s|$)|(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+test(?:\s|$)/i;
880
+ const TEST_RUNNER_COMMAND = /(?:^|[\s({]|\.bin\/)(?:vitest|jest|mocha|ava|pytest|py\.test)(?:\s|$)|(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+test(?:\s|$)/i;
881
+ // Package-script gates that actually execute tests: `test`, `test:<suite>` and the
882
+ // project's `release:verify` gate. Static-check / fixture-mutating variants
883
+ // (`test:types`, `test:lint`, `test:update-snapshots`, `test:watch`, ...) stay check-only.
884
+ const TEST_SCRIPT_COMMAND = /(?:^|[\s;&|(])(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+(test(?::[\w:.-]+)?|release:verify)(?=$|[\s;&|)])/gi;
885
+ const NON_TEST_SCRIPT_SUFFIX = /(?:^|:)(?:types?|typecheck|tsc|lint|format|fmt|snapshots?|update[\w-]*|watch|coverage-?report|clean|setup|fixtures?)(?::|$)/i;
886
+
887
+ function isTestGateCommand(command) {
888
+ const text = String(command || '');
889
+ if (TEST_RUNNER_COMMAND.test(text)) return true;
890
+ for (const m of text.matchAll(TEST_SCRIPT_COMMAND)) {
891
+ if (!NON_TEST_SCRIPT_SUFFIX.test(m[1])) return true;
892
+ }
893
+ return false;
894
+ }
789
895
 
790
896
  function mintVerificationVerdict(receipt, payload, projectRoot, sessionRoot = projectRoot) {
791
897
  const supplied = payload?.verdict && typeof payload.verdict === 'object' ? payload.verdict : {};
@@ -801,7 +907,7 @@ function mintVerificationVerdict(receipt, payload, projectRoot, sessionRoot = pr
801
907
  let kind = supplied.kind;
802
908
  if (!VERDICT_KINDS.has(kind)) {
803
909
  if (receipt.success !== true) kind = 'failed';
804
- else kind = TEST_RUNNER_COMMAND.test(receipt.command || '') ? 'test-verified' : 'check-only';
910
+ else kind = isTestGateCommand(receipt.command) ? 'test-verified' : 'check-only';
805
911
  }
806
912
  return {
807
913
  kind,
@@ -813,6 +919,7 @@ function mintVerificationVerdict(receipt, payload, projectRoot, sessionRoot = pr
813
919
  headSha: headSha || null,
814
920
  baseSha: baseSha || null,
815
921
  patchId,
922
+ ...(headSha ? { dirtyFp: verificationDirtyFingerprint(cwd) } : {}),
816
923
  ts: supplied.ts || new Date().toISOString(),
817
924
  };
818
925
  }
@@ -1044,11 +1151,15 @@ function progressDigest(ledger = {}) {
1044
1151
  // A genuinely different prompt keeps a clean slate so old work never satisfies a new
1045
1152
  // request's completion gate.
1046
1153
  function carriedEvidenceLedger(fresh, current) {
1047
- if (!current || !fresh.promptKey || !current.promptKey || current.promptKey !== fresh.promptKey) {
1154
+ if (!current || (fresh.taskInstanceId || current.taskInstanceId
1155
+ ? !fresh.taskInstanceId || fresh.taskInstanceId !== current.taskInstanceId
1156
+ : !fresh.promptKey || !current.promptKey || current.promptKey !== fresh.promptKey)) {
1048
1157
  return fresh;
1049
1158
  }
1050
1159
  return {
1051
1160
  ...fresh,
1161
+ operationBank: current.operationBank || {},
1162
+ receiptSequence: current.receiptSequence || 0,
1052
1163
  sourceSucceeded: fresh.sourceSucceeded || current.sourceSucceeded === true,
1053
1164
  // targeted-verification evidence (2.3.0) must survive the same re-key carry as the
1054
1165
  // coarse flags, or the stricter gates re-demand evidence mid-request — the exact
@@ -1097,6 +1208,9 @@ function carriedEvidenceLedger(fresh, current) {
1097
1208
  // too — dropping a blocker on re-key would resume the exact loop it recorded.
1098
1209
  failedVerificationStreak: current.failedVerificationStreak || null,
1099
1210
  playbookId: fresh.playbookId || current.playbookId || null,
1211
+ playbookReason: fresh.playbookReason || current.playbookReason || null,
1212
+ taskType: fresh.taskType || current.taskType || null,
1213
+ playbookTrace: current.playbookTrace || fresh.playbookTrace || null,
1100
1214
  // The bug-fix floor bank is monotone request-scoped evidence — a same-prompt
1101
1215
  // re-key must keep it or the gate would re-demand repro receipts mid-request.
1102
1216
  bugFixFloor: current.bugFixFloor || fresh.bugFixFloor || null,
@@ -1138,6 +1252,7 @@ function freshLedger(payload, routeState, harness) {
1138
1252
  harness,
1139
1253
  requestKey: routeState?.requestKey || null,
1140
1254
  promptKey: evidencePromptKey(routeState),
1255
+ taskInstanceId: routeState?.taskInstanceId || null,
1141
1256
  routeFingerprint: routeState?.fingerprint || null,
1142
1257
  // TASK-009 (BL-011): the routed playbook rides the ledger so the bug-fix
1143
1258
  // completion floor can classify repro receipts at apply time — including
@@ -1149,6 +1264,12 @@ function freshLedger(payload, routeState, harness) {
1149
1264
  // TASK-008 (BL-010): latest-per-class playbook-finding statuses, banked
1150
1265
  // outside the evictable receipt window.
1151
1266
  playbookFindings: null,
1267
+ // Playbook trace bookkeeping: the routed selection reason, the route's
1268
+ // proportionality signal, and the derived bounded step trace (persisted at
1269
+ // receipt-apply time). null until a playbook is routed.
1270
+ playbookReason: null,
1271
+ taskType: null,
1272
+ playbookTrace: null,
1152
1273
  sourceSucceeded: false,
1153
1274
  sourceFiles: [],
1154
1275
  writeAttempted: false,
@@ -1157,6 +1278,8 @@ function freshLedger(payload, routeState, harness) {
1157
1278
  verificationSucceeded: false,
1158
1279
  verificationFailed: false,
1159
1280
  receipts: [],
1281
+ receiptSequence: 0,
1282
+ operationBank: {},
1160
1283
  blocker: null,
1161
1284
  verdict: null,
1162
1285
  verdictHistory: [],
@@ -1197,7 +1320,7 @@ const MAX_JOURNAL_SEEN = 64;
1197
1320
  const MAX_JOURNAL_RECORDS = 128;
1198
1321
  const MAX_JOURNAL_QUARANTINE_LINES = 32;
1199
1322
  const LEDGER_EVENT_TYPES = new Set(['receipt', 'continuation', 'notified', 'stop-progress', 'resumable-run']);
1200
- const RECEIPT_KINDS = new Set(['source', 'write', 'verification', 'playbook-finding', 'workspace']);
1323
+ const RECEIPT_KINDS = new Set(['source', 'write', 'verification', 'playbook-finding', 'workspace', 'operation']);
1201
1324
 
1202
1325
  function journalPathFor(target) {
1203
1326
  return `${target}.journal`;
@@ -1232,6 +1355,8 @@ async function mergeContinuationSidecar(ledger, target) {
1232
1355
  return ledger;
1233
1356
  }
1234
1357
  if (!sidecar || typeof sidecar !== 'object') return ledger;
1358
+ if (ledger && (ledger.taskInstanceId || sidecar.taskInstanceId)
1359
+ && (!ledger.taskInstanceId || ledger.taskInstanceId !== sidecar.taskInstanceId)) return ledger;
1235
1360
  const sidecarAt = Number(sidecar.lastContinuationAt) || 0;
1236
1361
  if (!ledger) {
1237
1362
  const hasState = sidecarAt > 0
@@ -1240,6 +1365,7 @@ async function mergeContinuationSidecar(ledger, target) {
1240
1365
  if (!hasState) return null;
1241
1366
  return {
1242
1367
  requestKey: sidecar.requestKey ?? null,
1368
+ taskInstanceId: sidecar.taskInstanceId || null,
1243
1369
  promptKey: sidecar.promptKey ?? null,
1244
1370
  continuationRequestKey: sidecar.requestKey ?? null,
1245
1371
  continuationCount: Number(sidecar.continuationCount) || 0,
@@ -1282,7 +1408,9 @@ async function bumpContinuationSidecar(target, event, { signal, deadlineMs } = {
1282
1408
  { signal, deadlineMs: deadlineMs ?? LOCK_MAX_SLICE_MS },
1283
1409
  async () => {
1284
1410
  try {
1285
- const base = (await mergeContinuationSidecar(await readJson(target, null), target)) || {};
1411
+ const stored = (await mergeContinuationSidecar(await readJson(target, null), target)) || {};
1412
+ const base = event.taskInstanceId && stored.taskInstanceId !== event.taskInstanceId
1413
+ ? { taskInstanceId: event.taskInstanceId } : stored;
1286
1414
  const applied = event.type === 'notified'
1287
1415
  ? { ...base, notified: true, updatedAt: Date.now() }
1288
1416
  : applyContinuationToLedger(base, event);
@@ -1296,6 +1424,7 @@ async function bumpContinuationSidecar(target, event, { signal, deadlineMs } = {
1296
1424
  await writeJsonAtomic(sidecarPath, {
1297
1425
  requestKey: event?.requestKey ?? applied.requestKey ?? applied.continuationRequestKey ?? null,
1298
1426
  promptKey: event?.promptKey ?? applied.promptKey ?? null,
1427
+ taskInstanceId: event?.taskInstanceId || applied.taskInstanceId || null,
1299
1428
  continuationCount: Number(applied.continuationCount) || 0,
1300
1429
  noProgressCount: Number(applied.noProgressCount) || 0,
1301
1430
  lastProgressDigest: applied.lastProgressDigest ?? null,
@@ -1316,7 +1445,7 @@ function newEventId() {
1316
1445
  return `${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`;
1317
1446
  }
1318
1447
 
1319
- const JOURNAL_RECEIPT_FIELDS = ['ts', 'kind', 'toolName', 'toolUseId', 'success', 'exitCode', 'file', 'command', 'scope', 'workspaceFp', 'workspaceRoot', 'workspaceHeadTs', 'bashReadOnly', 'exitStatusUnproven'];
1448
+ const JOURNAL_RECEIPT_FIELDS = ['ts', 'kind', 'toolName', 'toolUseId', 'success', 'exitCode', 'file', 'command', 'scope', 'workspaceFp', 'workspaceRoot', 'workspaceHeadTs', 'bashReadOnly', 'exitStatusUnproven', 'detail', 'operation', 'transportSuccess', 'taskInstanceId', 'sessionKey', 'sequence'];
1320
1449
 
1321
1450
  // TASK-009 (BL-011): evidence attestations are whitelisted verbatim — class /
1322
1451
  // surface / result / claim, the ARCH §Evidence Schema fields a receipt may
@@ -1340,7 +1469,7 @@ function sanitizeReceiptForJournal(receipt) {
1340
1469
  const clean = {};
1341
1470
  for (const key of JOURNAL_RECEIPT_FIELDS) {
1342
1471
  if (receipt && receipt[key] !== undefined && receipt[key] !== null && receipt[key] !== '') {
1343
- clean[key] = receipt[key];
1472
+ clean[key] = key === 'operation' ? compactOperation(receipt[key]) : receipt[key];
1344
1473
  }
1345
1474
  }
1346
1475
  const evidence = sanitizeEvidenceForJournal(receipt && receipt.evidence);
@@ -1378,6 +1507,7 @@ function buildJournalRecord({ event, eventId, payload, routeState }) {
1378
1507
  sessionKey: sessionIdentity(payload),
1379
1508
  sessionId: explicitSessionId(payload),
1380
1509
  requestKey: routeState?.requestKey ?? null,
1510
+ taskInstanceId: routeState?.taskInstanceId || null,
1381
1511
  promptKey: evidencePromptKey(routeState),
1382
1512
  type: event.type,
1383
1513
  event: event.type === 'receipt'
@@ -1474,6 +1604,10 @@ async function quarantineJournalLines(target, lines) {
1474
1604
  // Uniform applier: returns { ledger, value } or null when the record is unappliable
1475
1605
  // (the caller quarantines it instead of applying it).
1476
1606
  function applyJournalRecord(ledger, record) {
1607
+ const sameTask = ledger.taskInstanceId || record.taskInstanceId
1608
+ ? Boolean(ledger.taskInstanceId && ledger.taskInstanceId === record.taskInstanceId)
1609
+ : Boolean(ledger.requestKey && ledger.requestKey === record.requestKey && (ledger.promptKey || null) === (record.promptKey || null));
1610
+ if (!sameTask) return null;
1477
1611
  if (record.type === 'receipt') {
1478
1612
  return applyReceiptToLedger(ledger, record.event?.receipt, { vibecode: record.event?.vibecode === true });
1479
1613
  }
@@ -1573,6 +1707,207 @@ async function drainJournal(target, { ledger, payload, signal, deadlineMs }) {
1573
1707
  return { ledger: next, applied, quarantined: malformed.length, commit };
1574
1708
  }
1575
1709
 
1710
+ // --- Playbook execution trace (observable progress, no LLM) ---------------------
1711
+ // A playbook is a text cue; the only durable signal of "was it followed" is the
1712
+ // receipt stream the hooks already record. This section folds that stream into
1713
+ // a bounded, deterministic `playbookTrace` record:
1714
+ // loaded — a Read receipt hit playbooks/<id>.md
1715
+ // stepsDone — receipt-derived evidence per the playbook's step map
1716
+ // stepsSkipped — non-essential steps on a simple/trivial route, justified
1717
+ // outputs — bounded list of written targets
1718
+ // verification — 'pending' | 'passed' | 'failed' | 'n/a'
1719
+ // No LLM call, no new store: everything derives from ledger.receipts plus the
1720
+ // route signals stamped beside them (playbookReason, taskType). The record is
1721
+ // persisted on the ledger at receipt-apply time so `ukit status` and the stop
1722
+ // message read it without re-deriving an evictable receipt window. Session and
1723
+ // request scoping are inherited from the ledger itself: a ledger only ever
1724
+ // holds its own session's receipts, and evaluateCompletion derives from the
1725
+ // same-request effective ledger, so another session's or patch's evidence can
1726
+ // never satisfy a step.
1727
+
1728
+ const PLAYBOOK_TRACE_EVIDENCE_MAX = 200;
1729
+ const MAX_PLAYBOOK_OUTPUTS = 8;
1730
+ const PROPORTIONATE_JUSTIFICATION = 'proportionate: simple task';
1731
+ const PROPORTIONATE_TASK_TYPES = new Set(['simple', 'trivial']);
1732
+
1733
+ // Per-playbook step maps. `derive` names the receipt shape that evidences the
1734
+ // step: 'pre-fail' (a failed verification before the first write), 'pre-source'
1735
+ // (a Read/Grep/Glob of a non-playbook file before the first write), 'write'
1736
+ // (the first write receipt), 'post-verify' (a passing verification receipt
1737
+ // after the first write). `essential: false` marks the pre-work a proportionate
1738
+ // (simple/trivial) route may skip — the fix/implement and verify core never
1739
+ // auto-skips. Playbooks outside this map keep the trace bookkeeping (loaded,
1740
+ // outputs, reason) but invent no requirements.
1741
+ const PLAYBOOK_STEP_MAPS = {
1742
+ 'bug-fix': [
1743
+ { id: 'reproduce', derive: 'pre-fail', essential: true },
1744
+ { id: 'locate', derive: 'pre-source', essential: false },
1745
+ { id: 'fix', derive: 'write', essential: true },
1746
+ { id: 'verify', derive: 'post-verify', essential: true },
1747
+ ],
1748
+ 'feature-implementation': [
1749
+ { id: 'analog-search', derive: 'pre-source', essential: false },
1750
+ { id: 'implement', derive: 'write', essential: true },
1751
+ { id: 'verify', derive: 'post-verify', essential: true },
1752
+ ],
1753
+ migration: [
1754
+ { id: 'schema-inspect', derive: 'pre-source', essential: false },
1755
+ { id: 'write', derive: 'write', essential: true },
1756
+ { id: 'verify', derive: 'post-verify', essential: true },
1757
+ ],
1758
+ };
1759
+
1760
+ function playbookStepsFor(playbookId) {
1761
+ const steps = playbookId ? PLAYBOOK_STEP_MAPS[String(playbookId)] : null;
1762
+ return Array.isArray(steps) ? steps : [];
1763
+ }
1764
+
1765
+ function boundedTraceText(value) {
1766
+ const text = String(value ?? '').trim();
1767
+ return text ? text.slice(0, PLAYBOOK_TRACE_EVIDENCE_MAX) : null;
1768
+ }
1769
+
1770
+ function isPlaybookRead(file, playbookId) {
1771
+ if (!file || !playbookId) return false;
1772
+ const normalized = String(file).replace(/\\/g, '/');
1773
+ return normalized === `playbooks/${playbookId}.md`
1774
+ || normalized.endsWith(`/playbooks/${playbookId}.md`);
1775
+ }
1776
+
1777
+ /**
1778
+ * Derive the playbook trace for `playbookId` from the ledger's own evidence.
1779
+ * Pure and deterministic: identical ledger in, identical trace out. A null
1780
+ * playbookId yields the not-applicable trace (no requirements) — the greeting /
1781
+ * informational shape.
1782
+ */
1783
+ export function buildPlaybookTrace(ledger = {}, playbookId = null) {
1784
+ const id = playbookId || null;
1785
+ if (!id) {
1786
+ return {
1787
+ playbookId: null,
1788
+ reason: 'not-applicable',
1789
+ selectedAt: null,
1790
+ requiredSteps: [],
1791
+ stepsDone: [],
1792
+ stepsSkipped: [],
1793
+ outputs: [],
1794
+ verification: 'n/a',
1795
+ loaded: false,
1796
+ };
1797
+ }
1798
+ const receipts = Array.isArray(ledger.receipts) ? ledger.receipts : [];
1799
+ const steps = playbookStepsFor(id);
1800
+ const firstWriteIndex = receipts.findIndex((receipt) => receipt?.kind === 'write');
1801
+ const stepEvidence = new Map();
1802
+ let loaded = false;
1803
+ let anyVerificationFailed = false;
1804
+ const outputs = [];
1805
+
1806
+ receipts.forEach((receipt, index) => {
1807
+ if (!receipt || typeof receipt !== 'object') return;
1808
+ if (receipt.kind === 'source') {
1809
+ const playbookRead = isPlaybookRead(receipt.file, id);
1810
+ if (playbookRead) loaded = true;
1811
+ const beforeWrite = firstWriteIndex === -1 || index < firstWriteIndex;
1812
+ // The playbook file itself is process, not target code — it loads the
1813
+ // trace but never satisfies a locate/analog-search step.
1814
+ if (beforeWrite && !playbookRead && !stepEvidence.has('pre-source')) {
1815
+ stepEvidence.set('pre-source', { at: receipt.ts, evidence: boundedTraceText(receipt.file) });
1816
+ }
1817
+ return;
1818
+ }
1819
+ if (receipt.kind === 'write') {
1820
+ const file = boundedTraceText(receipt.file);
1821
+ if (file && !outputs.includes(file) && outputs.length < MAX_PLAYBOOK_OUTPUTS) {
1822
+ outputs.push(file);
1823
+ }
1824
+ if (!stepEvidence.has('write')) {
1825
+ stepEvidence.set('write', { at: receipt.ts, evidence: boundedTraceText(receipt.file) });
1826
+ }
1827
+ return;
1828
+ }
1829
+ if (receipt.kind === 'verification') {
1830
+ const evidence = { at: receipt.ts, evidence: boundedTraceText(receipt.command) };
1831
+ if (receipt.success !== true) {
1832
+ anyVerificationFailed = true;
1833
+ if ((firstWriteIndex === -1 || index < firstWriteIndex) && !stepEvidence.has('pre-fail')) {
1834
+ stepEvidence.set('pre-fail', evidence);
1835
+ }
1836
+ return;
1837
+ }
1838
+ if (firstWriteIndex !== -1 && index > firstWriteIndex && !stepEvidence.has('post-verify')) {
1839
+ stepEvidence.set('post-verify', evidence);
1840
+ }
1841
+ }
1842
+ });
1843
+
1844
+ const stepsDone = [];
1845
+ const stepsSkipped = [];
1846
+ for (const step of steps) {
1847
+ const hit = stepEvidence.get(step.derive);
1848
+ if (hit) {
1849
+ stepsDone.push({
1850
+ id: step.id,
1851
+ at: hit.at ?? null,
1852
+ ...(hit.evidence ? { evidence: hit.evidence } : {}),
1853
+ });
1854
+ } else if (step.essential === false && PROPORTIONATE_TASK_TYPES.has(ledger.taskType)) {
1855
+ stepsSkipped.push({ id: step.id, justification: PROPORTIONATE_JUSTIFICATION });
1856
+ }
1857
+ }
1858
+ const verification = steps.length === 0
1859
+ ? 'n/a'
1860
+ : stepEvidence.has('post-verify') ? 'passed'
1861
+ : anyVerificationFailed ? 'failed'
1862
+ : 'pending';
1863
+ return {
1864
+ playbookId: id,
1865
+ reason: boundedTraceText(ledger.playbookReason) || 'routed',
1866
+ selectedAt: Number.isFinite(ledger.startedAt) ? ledger.startedAt : receipts[0]?.ts ?? null,
1867
+ requiredSteps: steps.map((step) => step.id),
1868
+ stepsDone,
1869
+ stepsSkipped,
1870
+ outputs,
1871
+ verification,
1872
+ loaded,
1873
+ };
1874
+ }
1875
+
1876
+ /** Required steps neither done nor proportionately skipped — the advisory list. */
1877
+ export function playbookTraceMissingSteps(trace = {}) {
1878
+ const done = new Set((Array.isArray(trace.stepsDone) ? trace.stepsDone : []).map((step) => step?.id));
1879
+ const skipped = new Set((Array.isArray(trace.stepsSkipped) ? trace.stepsSkipped : []).map((step) => step?.id));
1880
+ return (Array.isArray(trace.requiredSteps) ? trace.requiredSteps : [])
1881
+ .filter((id) => !done.has(id) && !skipped.has(id));
1882
+ }
1883
+
1884
+ /** 'playbook bug-fix: 3/4 steps, missing: verify' — null for no-playbook traces. */
1885
+ export function formatPlaybookTrace(trace = {}) {
1886
+ if (!trace || !trace.playbookId) return null;
1887
+ const required = Array.isArray(trace.requiredSteps) ? trace.requiredSteps : [];
1888
+ const missing = playbookTraceMissingSteps(trace);
1889
+ const base = `playbook ${trace.playbookId}: ${(Array.isArray(trace.stepsDone) ? trace.stepsDone : []).length}/${required.length} steps`;
1890
+ return missing.length > 0 ? `${base}, missing: ${missing.join(', ')}` : base;
1891
+ }
1892
+
1893
+ /**
1894
+ * Status/reporting view for `ukit status` and the `--playbook-status` CLI:
1895
+ * routed playbook id, the persisted trace, and its one-line summary.
1896
+ */
1897
+ export async function playbookStatus(projectRoot, payload = {}) {
1898
+ const state = await readRouteState(projectRoot, payload);
1899
+ const ledger = (await readExecutionLedger(projectRoot, payload)) || {};
1900
+ const playbookId = state?.routeSummary?.playbookId || ledger?.playbookId || null;
1901
+ const trace = buildPlaybookTrace(ledger, playbookId);
1902
+ return {
1903
+ playbookId,
1904
+ playbookReason: ledger?.playbookReason || state?.routeSummary?.playbookReason || null,
1905
+ taskType: ledger?.taskType || state?.routeSummary?.taskType || null,
1906
+ trace,
1907
+ summary: formatPlaybookTrace(trace),
1908
+ };
1909
+ }
1910
+
1576
1911
  // --- TASK-009 (BL-011): bug-fix completion floor --------------------------------
1577
1912
  // A green verdict on a `playbookId: bug-fix` route requires verbatim
1578
1913
  // `repro-before` (failing) + `repro-after` (passing) evidence receipts on the
@@ -1815,7 +2150,11 @@ function applyReceiptToLedger(ledger, receipt, options = {}) {
1815
2150
 
1816
2151
  function applyReceiptCore(ledger, receipt, { vibecode = false } = {}) {
1817
2152
  if (!receipt || typeof receipt !== 'object' || !RECEIPT_KINDS.has(receipt.kind)) return null;
1818
- const next = { ...ledger };
2153
+ const next = { ...ledger, receiptSequence: Number(ledger.receiptSequence || 0) + 1 };
2154
+ if (receipt.kind === 'operation') receipt = { ...receipt, sequence: next.receiptSequence };
2155
+ if (receipt.kind === 'operation') {
2156
+ next.operationBank = foldOperationEvidence(ledger.operationBank, receipt, ledger);
2157
+ }
1819
2158
  if (receipt.kind === 'source') {
1820
2159
  next.sourceSucceeded = next.sourceSucceeded || receipt.success;
1821
2160
  if (receipt.success && receipt.file) {
@@ -1861,7 +2200,16 @@ function applyReceiptCore(ledger, receipt, { vibecode = false } = {}) {
1861
2200
  // ones are kept in a bounded history so "one rebuild restores currency" has data.
1862
2201
  if (receipt.verdict && typeof receipt.verdict === 'object') {
1863
2202
  const history = Array.isArray(next.verdictHistory) ? next.verdictHistory : [];
1864
- next.verdictHistory = [...history, ...(next.verdict ? [next.verdict] : [])].slice(-MAX_VERDICT_HISTORY);
2203
+ const merged = [...history, ...(next.verdict ? [next.verdict] : [])];
2204
+ let kept = merged.slice(-MAX_VERDICT_HISTORY);
2205
+ // A later failed/unproven/check-only run must never push the last satisfying (real PASS)
2206
+ // verdict out of the bounded window — currency, not recency of failures, decides it.
2207
+ if (!kept.some((v) => VERDICT_SATISFYING_KINDS.has(v?.kind))
2208
+ && !VERDICT_SATISFYING_KINDS.has(receipt.verdict.kind)) {
2209
+ const lastPass = [...merged].reverse().find((v) => VERDICT_SATISFYING_KINDS.has(v?.kind));
2210
+ if (lastPass) kept = [lastPass, ...kept.slice(1)];
2211
+ }
2212
+ next.verdictHistory = kept;
1865
2213
  next.verdict = receipt.verdict;
1866
2214
  }
1867
2215
 
@@ -1957,6 +2305,12 @@ function applyReceiptCore(ledger, receipt, { vibecode = false } = {}) {
1957
2305
  }
1958
2306
 
1959
2307
  next.receipts = appendReceipt(next.receipts, receipt);
2308
+ // Playbook trace: re-derived from the ledger's own receipts on every applied
2309
+ // receipt (live and journaled replay alike) so the persisted record never
2310
+ // shows an evictable window. Only playbook-routed ledgers grow the field.
2311
+ if (next.playbookId) {
2312
+ next.playbookTrace = buildPlaybookTrace(next, next.playbookId);
2313
+ }
1960
2314
  next.updatedAt = Date.now();
1961
2315
  return { ledger: next, value: null };
1962
2316
  }
@@ -2032,12 +2386,16 @@ function liveBaseLedger(event, current, payload, routeState, harness, fallbackLe
2032
2386
  // live requestKey the only safe move is to keep appending to the current ledger.
2033
2387
  const nextRequestKey = routeState?.requestKey || null;
2034
2388
  if (current && !nextRequestKey) return { ...current, harness: current.harness || harness };
2035
- if (!current || current.requestKey !== nextRequestKey) {
2389
+ if (!current || current.requestKey !== nextRequestKey
2390
+ || (current.taskInstanceId || null) !== (routeState?.taskInstanceId || null)) {
2036
2391
  return carriedEvidenceLedger(freshLedger(payload, routeState, harness), current);
2037
2392
  }
2038
2393
  return { ...current, harness: current.harness || harness };
2039
2394
  }
2040
- return current || fallbackLedger || freshLedger(payload, null, 'unknown');
2395
+ if (current && routeState?.taskInstanceId && current.taskInstanceId !== routeState.taskInstanceId) {
2396
+ return freshLedger(payload, routeState, harness);
2397
+ }
2398
+ return current || (fallbackLedger?.sessionKey ? fallbackLedger : null) || freshLedger(payload, routeState, harness);
2041
2399
  }
2042
2400
 
2043
2401
  // --- TASK-005 (FR-005, BL-007): outcome.observed terminal emissions ---------
@@ -2127,12 +2485,24 @@ export async function recordLedgerEvent(event, {
2127
2485
  const target = ledgerPath(projectRoot, payload);
2128
2486
  // Hoisted so the post-commit outcome emitter sees the same state snapshot
2129
2487
  // the lock callback evaluated (a fresh read could observe a re-route).
2130
- let lockedRouteState = routeState || null;
2488
+ let lockedRouteState = routeState || await readRouteState(projectRoot, payload);
2489
+ if (event.type === 'receipt' && event.receipt?.kind === 'operation') {
2490
+ if (event.receipt.taskInstanceId && event.receipt.taskInstanceId !== lockedRouteState?.taskInstanceId) {
2491
+ return { rejected: true, eventId, reason: 'receipt-task-mismatch' };
2492
+ }
2493
+ event = { ...event, receipt: { ...event.receipt,
2494
+ taskInstanceId: lockedRouteState?.taskInstanceId || null, sessionKey: sessionIdentity(payload) } };
2495
+ }
2131
2496
  // The descriptor the post-commit emitter must fire (lock-local `value` is
2132
2497
  // nested two levels deep in the lock result — a hoisted ref is clearer).
2133
2498
  let emittedOutcome = null;
2134
2499
  const outcome = await withLedgerLock(target, { signal, deadlineMs }, async () => {
2135
- const state = routeState || await readRouteState(projectRoot, payload);
2500
+ const liveState = await readRouteState(projectRoot, payload);
2501
+ if (lockedRouteState?.taskInstanceId && liveState?.taskInstanceId
2502
+ && lockedRouteState.taskInstanceId !== liveState.taskInstanceId) {
2503
+ return { committed: false, rejected: true, eventId, reason: 'active-task-changed' };
2504
+ }
2505
+ const state = liveState || lockedRouteState;
2136
2506
  lockedRouteState = state || null;
2137
2507
  const current = await readExecutionLedger(projectRoot, payload);
2138
2508
  let ledger = liveBaseLedger(event, current, payload, state, harness, fallbackLedger);
@@ -2150,7 +2520,24 @@ export async function recordLedgerEvent(event, {
2150
2520
  if (state && ledger.playbookId !== playbookId) {
2151
2521
  ledger = { ...ledger, playbookId };
2152
2522
  }
2153
- const applied = applyReceiptToLedger(ledger, event.receipt, { vibecode: event.vibecode === true });
2523
+ // Playbook trace signals: the selection reason and the route's taskType
2524
+ // (proportionality) ride the same route summary. Persisted so the trace
2525
+ // derives identically on journaled replays, where no route state is passed.
2526
+ const playbookReason = state?.routeSummary?.playbookReason || null;
2527
+ if (state && playbookReason && ledger.playbookReason !== playbookReason) {
2528
+ ledger = { ...ledger, playbookReason };
2529
+ }
2530
+ const routeTaskType = state?.routeSummary?.taskType || null;
2531
+ if (state && routeTaskType && ledger.taskType !== routeTaskType) {
2532
+ ledger = { ...ledger, taskType: routeTaskType };
2533
+ }
2534
+ if (event.receipt.taskInstanceId && event.receipt.taskInstanceId !== state?.taskInstanceId) {
2535
+ return { committed: false, rejected: true, eventId, reason: 'receipt-task-mismatch' };
2536
+ }
2537
+ const stampedReceipt = event.receipt.kind === 'operation'
2538
+ ? { ...event.receipt, taskInstanceId: state?.taskInstanceId || null, sessionKey: sessionIdentity(payload) }
2539
+ : event.receipt;
2540
+ const applied = applyReceiptToLedger(ledger, stampedReceipt, { vibecode: event.vibecode === true });
2154
2541
  if (!applied) return { committed: false, eventId, reason: 'invalid-receipt' };
2155
2542
  ledger = applied.ledger;
2156
2543
  // TASK-005 (FR-005): completion receipts (write/verification terminal
@@ -2289,7 +2676,7 @@ export async function recordLedgerEvent(event, {
2289
2676
 
2290
2677
  // Fail-closed: the acquisition budget expired or the wait aborted. The main ledger is
2291
2678
  // NEVER mutated unlocked — journal the event once; the next acquired lock reconciles it.
2292
- const record = buildJournalRecord({ event, eventId, payload, routeState: routeState || null });
2679
+ const record = buildJournalRecord({ event, eventId, payload, routeState: lockedRouteState || null });
2293
2680
  const journalResult = await appendJournalRecord(target, record, { signal, deadlineMs });
2294
2681
  if (journalResult.ok) return { journaled: true, eventId };
2295
2682
  // F-4: continuation/notified are counter state, not journal rows — a journal-full
@@ -2297,7 +2684,7 @@ export async function recordLedgerEvent(event, {
2297
2684
  // MAX_CONTINUATIONS fires late/never. Land the tick in the sidecar the evaluator
2298
2685
  // merges; only a sidecar write failure is a real rejection.
2299
2686
  if (event.type === 'continuation' || event.type === 'notified') {
2300
- const sidecar = await bumpContinuationSidecar(target, event);
2687
+ const sidecar = await bumpContinuationSidecar(target, { ...event, taskInstanceId: lockedRouteState?.taskInstanceId || null });
2301
2688
  if (sidecar.ok) return { counted: true, eventId, reason: journalResult.reason };
2302
2689
  }
2303
2690
  return { rejected: true, eventId, reason: journalResult.reason };
@@ -2373,7 +2760,7 @@ export async function recordExecutionReceipt({
2373
2760
  } = {}) {
2374
2761
  if (!projectRoot || !toolName) return { rejected: true, eventId: null };
2375
2762
  const routeState = await readRouteState(projectRoot, payload);
2376
- const failed = explicitError(payload);
2763
+ const failed = payload.hook_event_name === 'PostToolUseFailure' || explicitError(payload);
2377
2764
  const exitCode = extractExitCode(payload);
2378
2765
  const success = !failed && (exitCode === null || exitCode === 0);
2379
2766
  const toolInput = payload.tool_input || {};
@@ -2391,7 +2778,10 @@ export async function recordExecutionReceipt({
2391
2778
  exitCode,
2392
2779
  };
2393
2780
 
2394
- if (toolName === 'Read' || toolName === 'Grep' || toolName === 'Glob') {
2781
+ const operationReceipt = cronOperationReceipt(toolName, toolInput, payload.tool_response ?? payload.tool_output ?? payload.tool_result, success, { sessionId: explicitSessionId(payload), harness });
2782
+ if (operationReceipt) {
2783
+ Object.assign(receipt, operationReceipt);
2784
+ } else if (toolName === 'Read' || toolName === 'Grep' || toolName === 'Glob') {
2395
2785
  receipt.kind = 'source';
2396
2786
  receipt.file = toolInput.file_path || toolInput.path || null;
2397
2787
  } else if (toolName === 'Edit' || toolName === 'Write') {
@@ -2510,6 +2900,9 @@ function requiredEvidence(state = {}) {
2510
2900
  const required = Array.isArray(contractEvidence) && contractEvidence.length > 0
2511
2901
  ? [...contractEvidence]
2512
2902
  : [...(routeSummary.completionState?.missingEvidence || [])];
2903
+ const contract = routeSummary.executionContract;
2904
+ const operationOnly = contract?.taskKind === 'OPERATIONAL'
2905
+ || (contract?.taskKind === 'CONFIGURATION' && contract.configurationScope === 'external');
2513
2906
  // FR-006 (M01.4' tail): a route persisted with a high riskEscalation must re-verify at
2514
2907
  // Stop even when its own contract only demands write evidence. The route state is the
2515
2908
  // carrier — no config read here; absent/malformed/non-high fields are a no-op.
@@ -2517,7 +2910,7 @@ function requiredEvidence(state = {}) {
2517
2910
  // verification-evidence; a missing or malformed stage fails closed (enforcing) so
2518
2911
  // pre-C48 persisted records keep re-verifying until re-stamped.
2519
2912
  if (routeSummary.riskEscalation?.level === 'high' && routeSummary.riskEscalation?.stage !== 'shadow') {
2520
- required.push('verification-evidence');
2913
+ required.push(operationOnly ? 'operation-verification' : 'verification-evidence');
2521
2914
  }
2522
2915
  // TASK-C85-014 (BL-014): a resolved verification-map recipe rides the route
2523
2916
  // summary (stamped by stop-coordinator's recipe consult). Its
@@ -2535,7 +2928,20 @@ function requiredEvidence(state = {}) {
2535
2928
 
2536
2929
  function evidenceSatisfied(evidence, ledger = {}, state = {}, { cwd } = {}) {
2537
2930
  const routeSummary = state?.routeSummary || {};
2538
- if (evidence === 'write-evidence') return ledger.writeSucceeded === true;
2931
+ if (evidence === 'operation-verification') {
2932
+ return operationCompletionPolicy(scopedContract(state, ledger), null, ledger)?.complete === true;
2933
+ }
2934
+ if (evidence === 'write-evidence') {
2935
+ const expected = routeSummary.expectedSourceFiles || [];
2936
+ if (routeSummary.executionContract?.taskKind && expected.length) {
2937
+ const bank = evidenceBank(ledger, 'bankedWrites');
2938
+ return expected.some((file) => typeof file === 'string' && (
2939
+ bank[evidenceBankKey(file)] === true
2940
+ || (cwd && bank[evidenceBankKey(path.resolve(cwd, file))] === true)
2941
+ ));
2942
+ }
2943
+ return ledger.writeSucceeded === true;
2944
+ }
2539
2945
  if (evidence === 'verification-evidence') {
2540
2946
  // SPEC-typed-verdicts §2.3: when a verdict record exists it governs — only a
2541
2947
  // CURRENT test-verified/live-verified verdict satisfies the gate. A stale or
@@ -2671,6 +3077,12 @@ function recoveryInstruction(missingEvidence, ledger = {}, routeSummary = {}, {
2671
3077
  }
2672
3078
  }
2673
3079
  }
3080
+ // A pipe-masked run is UNPROVEN, not failed: name the real gap (no provable exit status)
3081
+ // instead of telling the model to "fix the failure" of a run that may have passed.
3082
+ const lastVerification = [...(ledger.receipts || [])].reverse().find((r) => r?.kind === 'verification');
3083
+ if (!instruction && ledger.verificationFailed && lastVerification?.exitStatusUnproven === true) {
3084
+ instruction = `The last verification (\`${String(lastVerification.command || '').slice(0, 80)}\`) ran in a pipeline, so its exit status is unproven. Re-run it without the pipe, or with \`set -o pipefail\`, and inspect the exit code.`;
3085
+ }
2674
3086
  if (!instruction && ledger.verificationFailed) {
2675
3087
  instruction = 'Use the latest failed verification output, fix the failure, and rerun targeted verification.';
2676
3088
  } else if (!instruction) {
@@ -2717,7 +3129,83 @@ function writeEvidenceTier(ledger = {}) {
2717
3129
  return ledger.inferredWrite === true && ledger.directWrite !== true ? { evidenceTier: 'inferred' } : {};
2718
3130
  }
2719
3131
 
2720
- export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
3132
+ // Lazy: a missing/unloadable intent module must degrade to the strict gate, never crash Stop.
3133
+ async function resolveStopPhase(transcriptPath) {
3134
+ try {
3135
+ const mod = await import(new URL('./handoff-intent.mjs', import.meta.url).href);
3136
+ return await mod.resolveStopPhase(transcriptPath);
3137
+ } catch {
3138
+ return null;
3139
+ }
3140
+ }
3141
+ export { resolveStopPhase };
3142
+
3143
+ // Planning artifacts (docs/, tests/) are the only writes a handoff-create session may make.
3144
+ const PLANNING_WRITE_PATH = /(?:^|\/)(?:docs|tests?)\//;
3145
+ function wroteOutsidePlanningScope(ledger) {
3146
+ return (ledger?.receipts || []).some((r) => r?.kind === 'write'
3147
+ && typeof r.file === 'string' && r.file
3148
+ && !r.file.startsWith('bash-workspace:')
3149
+ && !PLANNING_WRITE_PATH.test(r.file.replace(/\\/g, '/')));
3150
+ }
3151
+
3152
+ /**
3153
+ * Command-authority valve (RC-A). `phase` is the session's CURRENT handoff intent from
3154
+ * handoff-intent.mjs: 'create' is planning-only, 'review'/'neutral' (review, status, clear)
3155
+ * carry no implementation debt unless a write actually happened. The gate must never turn
3156
+ * a planning-only session into an "Edit/Write now" instruction. implement/fullstack keep
3157
+ * the normal gate. Returns a release decision or null.
3158
+ */
3159
+ function phaseRelease(phase, ledger, missingEvidence) {
3160
+ if (!missingEvidence.some((item) => item === 'write-evidence' || item === 'verification-evidence')) return null;
3161
+ if (phase === 'create' && !wroteOutsidePlanningScope(ledger)) {
3162
+ return {
3163
+ continue: false,
3164
+ notify: true,
3165
+ gateRelease: 'planning-only',
3166
+ phase,
3167
+ missingEvidence,
3168
+ reason: 'UKit completion gate: handoff-create is planning-only, so write/verification evidence is not expected. Stop once the plan, spec and tasks are written; implementation needs /ukit:handoff-implement or /ukit:handoff-fullstack.',
3169
+ };
3170
+ }
3171
+ if ((phase === 'review' || phase === 'neutral') && ledger?.writeAttempted !== true) {
3172
+ return {
3173
+ continue: false,
3174
+ notify: true,
3175
+ gateRelease: 'phase-no-mutation',
3176
+ phase,
3177
+ missingEvidence,
3178
+ reason: `UKit completion gate: this ${phase === 'review' ? 'handoff-review' : 'handoff status/clear'} session made no change, so write/verification evidence is not expected. A review fix needs an actual edit plus a passing verification.`,
3179
+ };
3180
+ }
3181
+ return null;
3182
+ }
3183
+
3184
+ // Request identity is the prompt (see evidencePromptKey): the router re-keys requestKey on
3185
+ // every Edit/Write, so a requestKey mismatch alone must not discard the evidence — and the
3186
+ // continuation budget — the request already accumulated. A different prompt keeps a clean slate.
3187
+ // Shared by the completion contract and the playbook advisory so both judge the same
3188
+ // request-scoped ledger (another prompt's evidence never counts).
3189
+ function scopedContract(state, ledger) {
3190
+ const contract = state?.routeSummary?.executionContract;
3191
+ if (!contract?.operationGoal) return contract;
3192
+ const session = state?.sessionId || ledger?.sessionId;
3193
+ return { ...contract, operationGoal: { ...contract.operationGoal,
3194
+ scope: contract.operationGoal.scope || (session ? `session:${session}` : null) } };
3195
+ }
3196
+
3197
+ function effectiveRequestLedger(state = {}, ledger = {}) {
3198
+ if (state?.sessionId && ledger?.sessionId && state.sessionId !== ledger.sessionId) return {};
3199
+ if (state?.taskInstanceId || ledger?.taskInstanceId) {
3200
+ return state?.taskInstanceId && state.taskInstanceId === ledger?.taskInstanceId ? ledger : {};
3201
+ }
3202
+ const sameRequest = !ledger?.requestKey || !state?.requestKey
3203
+ || ledger.requestKey === state.requestKey
3204
+ || (ledger.promptKey && evidencePromptKey(state) === ledger.promptKey);
3205
+ return sameRequest ? ledger : {};
3206
+ }
3207
+
3208
+ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null } = {}) {
2721
3209
  const routeSummary = state?.routeSummary || {};
2722
3210
  // Explicit vibecode autonomy: the user asked UKit to run one prompt to a finished result,
2723
3211
  // so the completion gate keeps pushing instead of releasing at the continuation cap.
@@ -2752,7 +3240,13 @@ export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
2752
3240
  // success — there is nothing to report. The CLI's lost-route path handles the case where
2753
3241
  // THIS session has ledger activity but the state vanished; callers that pass no state
2754
3242
  // (e.g. the omp bridge for a session it does not own) must not be force-notified.
2755
- return { continue: false, notify: false, missingEvidence: [] };
3243
+ // A prompt that ORDERED a mutation but routed no contract (no skill matched) was never
3244
+ // verified — release, but say so; read-only/informational prompts stay silent.
3245
+ if (state?.routingContext?.mutationOrder === true) {
3246
+ return { continue: false, notify: true, complete: false, gateRelease: 'no-routable-contract', missingEvidence: [],
3247
+ reason: 'UKit completion gate: this request ordered a change but no completion contract was routed, so nothing was verified. Treat the work as unverified; if it is unfinished, re-send the task in a new message.' };
3248
+ }
3249
+ return { continue: false, notify: false, complete: false, gateRelease: 'no-routable-contract', missingEvidence: [] };
2756
3250
  }
2757
3251
  // An informational turn carries no completion contract of its own. Two paths land here:
2758
3252
  // a fully-routed informational prompt (routeSummary.executionMode === 'informational',
@@ -2765,8 +3259,8 @@ export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
2765
3259
  // evidence the old contract demands. Release silently — the carried routeSummary stays
2766
3260
  // on disk, so the previous contract still survives until a real route replaces it.
2767
3261
  if (
2768
- mode === 'informational'
2769
- || state?.routingContext?.executionMode === 'informational'
3262
+ !routeSummary.executionContract?.taskKind && (mode === 'informational'
3263
+ || state?.routingContext?.executionMode === 'informational')
2770
3264
  ) {
2771
3265
  return { continue: false, notify: false, complete: true, missingEvidence: [] };
2772
3266
  }
@@ -2784,11 +3278,12 @@ export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
2784
3278
  // Request identity is the prompt (see evidencePromptKey): the router re-keys requestKey on
2785
3279
  // every Edit/Write, so a requestKey mismatch alone must not discard the evidence — and the
2786
3280
  // continuation budget — the request already accumulated. A different prompt keeps a clean slate.
2787
- const sameRequest = !ledger?.requestKey || !state?.requestKey
2788
- || ledger.requestKey === state.requestKey
2789
- || (ledger.promptKey && evidencePromptKey(state) === ledger.promptKey);
2790
- const effectiveLedger = sameRequest ? ledger : {};
3281
+ const effectiveLedger = effectiveRequestLedger(state, ledger);
3282
+ const operationStatus = operationCompletionPolicy(scopedContract(state, effectiveLedger),
3283
+ null, effectiveLedger);
3284
+ const operationalTask = operationStatus !== null;
2791
3285
  const missingEvidence = evidence.filter((item) => !evidenceSatisfied(item, effectiveLedger, state, { cwd }));
3286
+ if (operationalTask && !operationStatus.complete && !missingEvidence.includes('operation-verification')) missingEvidence.push('operation-verification');
2792
3287
  // TASK-009 (BL-011): the bug-fix floor. On a `playbookId: bug-fix` route a
2793
3288
  // green verdict requires verbatim repro-before (fail) + repro-after (pass)
2794
3289
  // receipts on the same surface plus a named root cause in the exec-ledger —
@@ -2832,9 +3327,13 @@ export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
2832
3327
  // Silent success: the route is present and every required evidence is satisfied. Marked
2833
3328
  // `complete` so the CLI dispatch recognizes it BEFORE the loud final else — otherwise a
2834
3329
  // bare loud else would turn a clean stop into user-visible noise.
2835
- return { continue: false, notify: false, complete: true, missingEvidence: [], ...writeEvidenceTier(effectiveLedger) };
3330
+ return { continue: false, notify: false, complete: true, missingEvidence: [], ...(operationalTask ? { gateRelease: 'native-operation-verified', operationProof: operationStatus.operationProof } : writeEvidenceTier(effectiveLedger)) };
2836
3331
  }
2837
3332
 
3333
+ const released = routeSummary.executionContract?.taskKind
3334
+ ? null : phaseRelease(phase, effectiveLedger, missingEvidence);
3335
+ if (released) return released;
3336
+
2838
3337
  // `find-cause` can validly end clean: an investigation may establish that no actionable
2839
3338
  // defect exists. Its contract says a FIX cannot be claimed without write + verification;
2840
3339
  // it does not make a mutation mandatory. Treating missing evidence as an unconditional
@@ -2855,6 +3354,7 @@ export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
2855
3354
  || routeSummary.executionContract.completionRule === 'never-claim-fixed-without-write-and-verification');
2856
3355
  if (
2857
3356
  findCauseNoMutationContract
3357
+ && !routeSummary.executionContract?.taskKind
2858
3358
  && playbookId !== BUGFIX_PLAYBOOK_ID
2859
3359
  && !effectiveLedger.writeAttempted
2860
3360
  && !effectiveLedger.verificationFailed
@@ -2877,7 +3377,8 @@ export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
2877
3377
  // satisfied), no mutation was attempted, and no verification failed; anything less has
2878
3378
  // concrete unfinished work and keeps recovering.
2879
3379
  if (
2880
- mode === 'map-impact'
3380
+ !routeSummary.executionContract?.taskKind
3381
+ && mode === 'map-impact'
2881
3382
  && !missingEvidence.includes('impact-evidence')
2882
3383
  && effectiveLedger.writeAttempted !== true
2883
3384
  && effectiveLedger.verificationFailed !== true
@@ -2904,7 +3405,8 @@ export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
2904
3405
  const readOnlyPrompt = state?.routingContext?.mutationOrder === false
2905
3406
  || state?.routingContext?.planOnly === true;
2906
3407
  if (
2907
- readOnlyPrompt
3408
+ !routeSummary.executionContract?.taskKind
3409
+ && readOnlyPrompt
2908
3410
  && !effectiveLedger.writeAttempted
2909
3411
  && missingEvidence.some((item) => item === 'write-evidence' || item === 'verification-evidence')
2910
3412
  ) {
@@ -2917,14 +3419,19 @@ export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
2917
3419
  };
2918
3420
  }
2919
3421
 
2920
- const gated = IMPLEMENT_MODES.has(mode);
3422
+ const gated = Boolean(routeSummary.executionContract?.taskKind) || IMPLEMENT_MODES.has(mode);
2921
3423
  if (!gated) {
3424
+ // Advisory, not a gate message: nothing was written, so verification was never owed.
3425
+ const noWriteAdvisory = !effectiveLedger.writeAttempted && missingEvidence.every((item) => item === 'verification-evidence');
2922
3426
  return {
2923
3427
  continue: false,
2924
3428
  notify: true,
2925
3429
  missingEvidence,
3430
+ ...(noWriteAdvisory ? { gateRelease: 'advisory-no-write' } : {}),
2926
3431
  ...(playbookId === BUGFIX_PLAYBOOK_ID ? { outcome: 'inconclusive' } : {}),
2927
- reason: `UKit completion gate: missing ${missingEvidence.join(', ')}. This mode does not auto-continue; tell the user what is unfinished.`,
3432
+ reason: noWriteAdvisory
3433
+ ? `UKit note: no files were changed in this session, so ${missingEvidence.join(', ')} is not expected (advisory only; nothing to verify).`
3434
+ : `UKit completion gate: missing ${missingEvidence.join(', ')}. This mode does not auto-continue; tell the user what is unfinished.`,
2928
3435
  };
2929
3436
  }
2930
3437
 
@@ -2992,7 +3499,9 @@ export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
2992
3499
  }
2993
3500
 
2994
3501
  const finalAttempt = !vibecode && continuationCount === MAX_CONTINUATIONS - 1;
2995
- const instruction = recoveryInstruction(missingEvidence, effectiveLedger, routeSummary, { cwd });
3502
+ const instruction = operationalTask
3503
+ ? 'Inspect native mutation receipts and verify the requested operational state; do not fabricate source edits or source-test evidence.'
3504
+ : recoveryInstruction(missingEvidence, effectiveLedger, routeSummary, { cwd });
2996
3505
  return {
2997
3506
  continue: true,
2998
3507
  missingEvidence,
@@ -3007,6 +3516,34 @@ export function evaluateCompletion({ state = {}, ledger = {}, cwd } = {}) {
3007
3516
  };
3008
3517
  }
3009
3518
 
3519
+ // Playbook trace advisory (observability only). A routed playbook other than
3520
+ // bug-fix — whose floor stays the one hard gate — whose required steps are
3521
+ // neither receipt-done nor proportionately skipped, on a request that already
3522
+ // attempted a write, gets one ADVISORY line at the released stop. It never
3523
+ // changes the decision (continue/notify/missingEvidence untouched): no new
3524
+ // continuation loop, no hard block. A no-write release (answer/plan/review, or
3525
+ // a proportionate trivial route) stays advisory-free.
3526
+ function playbookStopAdvisory({ state = {}, ledger = {} } = {}, result = {}) {
3527
+ if (!result || result.continue === true) return null;
3528
+ const routeSummary = state?.routeSummary || {};
3529
+ const playbookId = routeSummary.playbookId || null;
3530
+ if (!playbookId || playbookId === BUGFIX_PLAYBOOK_ID) return null;
3531
+ const effectiveLedger = effectiveRequestLedger(state, ledger);
3532
+ if (effectiveLedger.writeAttempted !== true) return null;
3533
+ const trace = buildPlaybookTrace(effectiveLedger, playbookId);
3534
+ if (playbookTraceMissingSteps(trace).length === 0) return null;
3535
+ return `UKit playbook note: ${formatPlaybookTrace(trace)} (advisory).`;
3536
+ }
3537
+
3538
+ export function evaluateCompletion(args = {}) {
3539
+ const result = evaluateCompletionContract(args);
3540
+ try {
3541
+ const advisory = playbookStopAdvisory(args, result);
3542
+ if (advisory) return { ...result, playbookAdvisory: advisory };
3543
+ } catch { /* the advisory never breaks the gate */ }
3544
+ return result;
3545
+ }
3546
+
3010
3547
  export async function incrementContinuation(projectRoot, payload = {}, ledger = null, requestKey = null, promptKey = null, { signal, deadlineMs } = {}) {
3011
3548
  // Locked (and fail-closed) via recordLedgerEvent: parallel subagents firing Stop hooks
3012
3549
  // share one ledger file, and a stale-snapshot rewrite would reset each other's
@@ -3137,7 +3674,8 @@ async function runEvaluateStop() {
3137
3674
  return;
3138
3675
  }
3139
3676
 
3140
- const result = evaluateCompletion({ state, ledger, cwd: projectRoot });
3677
+ const phase = await resolveStopPhase(payload?.transcript_path);
3678
+ const result = evaluateCompletion({ state, ledger, cwd: projectRoot, phase });
3141
3679
 
3142
3680
  // TASK-C85-019 fix C: a mutating-mode route released by a no-mutation valve
3143
3681
  // while evidence is missing is the anomaly signal for misrouted advisory
@@ -3219,12 +3757,22 @@ async function runEvaluateStop() {
3219
3757
  if (result.capped || result.notify) {
3220
3758
  // Non-blocking endings (non-gated modes, an invisible blocker, an evidence-less route, or
3221
3759
  // cap reached after the final notice) must still tell the user what is unfinished — a
3222
- // silent end is indistinguishable from a stall.
3760
+ // silent end is indistinguishable from a stall. A playbook advisory rides the SAME
3761
+ // single line (evaluator stdout is parsed first-line-wins by the stop coordinator).
3762
+ const message = result.playbookAdvisory ? `${result.reason} ${result.playbookAdvisory}` : result.reason;
3223
3763
  process.stderr.write(`[ukit-completion] ${result.reason}\n`);
3224
- process.stdout.write(`${JSON.stringify({ systemMessage: result.reason })}\n`);
3764
+ process.stdout.write(`${JSON.stringify({ systemMessage: message })}\n`);
3225
3765
  return;
3226
3766
  }
3227
- if (result.complete) return; // silent success: route present, all evidence satisfied
3767
+ if (result.complete) {
3768
+ // A satisfied route stays a silent success — unless the routed playbook has
3769
+ // steps missing, where the trace advisory IS the one-line release message.
3770
+ if (result.playbookAdvisory) {
3771
+ process.stdout.write(`${JSON.stringify({ systemMessage: result.playbookAdvisory })}\n`);
3772
+ }
3773
+ return; // silent success: route present, all evidence satisfied
3774
+ }
3775
+ if (result.gateRelease === 'no-routable-contract') return; // Advisory release, never VERIFIED.
3228
3776
  // Defensive final else: no evaluation result may ever end a routed stop silently.
3229
3777
  const fallback = `UKit completion gate: the stop was evaluated without a decision (missing evidence: ${(result.missingEvidence || []).join(', ') || 'none'}). If work is unfinished, re-send the task in a new message.`;
3230
3778
  process.stderr.write(`[ukit-completion] ${fallback}\n`);
@@ -3240,6 +3788,14 @@ async function main() {
3240
3788
  }
3241
3789
  const payload = JSON.parse((await readStdin()) || '{}');
3242
3790
  const projectRoot = process.env.CLAUDE_PROJECT_DIR || payload.cwd || process.cwd();
3791
+ if (process.argv.includes('--playbook-status')) {
3792
+ // Reporting path for `ukit status`: { playbookId, playbookReason, taskType,
3793
+ // trace, summary } for this session's routed playbook (not-applicable when
3794
+ // no playbook is routed).
3795
+ const status = await playbookStatus(projectRoot, payload);
3796
+ process.stdout.write(`${JSON.stringify(status)}\n`);
3797
+ return;
3798
+ }
3243
3799
  if (process.argv.includes('--record')) {
3244
3800
  const receiptResult = await recordExecutionReceipt({
3245
3801
  projectRoot,