@ngockhoale/ukit 3.4.30 → 3.4.32

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 (27) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/manifests/documentation.yaml +12 -0
  3. package/package.json +1 -1
  4. package/src/core/executionContracts.js +32 -0
  5. package/src/decision/client.js +15 -0
  6. package/src/decision/protocol.js +12 -14
  7. package/src/decision/reviewVerdict.js +16 -2
  8. package/src/index/fixLoopEscalation.js +10 -4
  9. package/src/index/routeResolver.js +2 -2
  10. package/src/index/taskRouting.js +3 -1
  11. package/template_project/.claude/hooks/record-execution.mjs +1 -3
  12. package/template_project/.claude/hooks/skill-router.sh +82 -9
  13. package/template_project/.claude/settings.json +22 -0
  14. package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +10 -4
  15. package/template_project/.claude/ukit/index/review-verdict.mjs +48 -9
  16. package/template_project/.claude/ukit/index/route-resolver.mjs +34 -2
  17. package/template_project/.claude/ukit/index/route-task.mjs +88 -9
  18. package/template_project/.claude/ukit/index/sidecar-decision.mjs +6 -117
  19. package/template_project/.claude/ukit/index/unic-decision.mjs +27 -14
  20. package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +43 -0
  21. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +160 -33
  22. package/template_project/.claude/ukit/runtime/loop-state.mjs +1 -1
  23. package/template_project/.claude/ukit/runtime/operation-evidence.mjs +87 -0
  24. package/template_project/.omp/hooks/pre/ukit-bridge.js +8 -1
  25. package/template_project/AGENTS.md +1 -1
  26. package/template_project/CLAUDE.md +1 -1
  27. package/template_project/instructions/core.md +1 -1
@@ -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
@@ -345,7 +347,11 @@ function explicitPromptKey(routeState = {}) {
345
347
  return promptKeyFromText(routeState?.routingContext?.lastExplicitUserPromptText);
346
348
  }
347
349
 
348
- 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
+ }
349
355
  if (ledger?.blocker) return false;
350
356
  const mode = state?.routeSummary?.executionMode || state?.routeSummary?.approachSelector?.executionMode;
351
357
  if (!IMPLEMENT_MODES.has(mode)) return false;
@@ -384,7 +390,7 @@ export async function readResumableExecution(projectRoot, payload = {}) {
384
390
  if (!state || !ledger || !ledger.sessionId || ledger.sessionId !== sessionId
385
391
  || ledger.sessionKey !== safeSegment(sessionId) || !ledger.requestKey
386
392
  || !ledger.promptKey || !promptKey || ledger.promptKey !== promptKey
387
- || !hasUnfinishedCompletion(state, ledger)) {
393
+ || !hasUnfinishedCompletion(state, ledger, { cwd: projectRoot })) {
388
394
  return null;
389
395
  }
390
396
  return { state, ledger, sessionId };
@@ -410,9 +416,11 @@ export async function readResumeIntent(projectRoot, payload = {}, { consume = fa
410
416
  // resumes; a genuinely different prompt produces a different promptKey and stays dead.
411
417
  if (!state || !ledger || ledger.sessionId !== sessionId
412
418
  || ledger.sessionKey !== safeSegment(sessionId)
419
+ || ((state.taskInstanceId || intent.taskInstanceId)
420
+ && (!state.taskInstanceId || state.taskInstanceId !== intent.taskInstanceId || ledger.taskInstanceId !== intent.taskInstanceId))
413
421
  || promptKey !== intent.promptKey
414
422
  || ledger.promptKey !== intent.promptKey
415
- || !hasUnfinishedCompletion(state, ledger)) {
423
+ || !hasUnfinishedCompletion(state, ledger, { cwd: projectRoot })) {
416
424
  return null;
417
425
  }
418
426
  if (consume) {
@@ -428,7 +436,7 @@ export async function writeResumeIntent(projectRoot, payload = {}, { now = Date.
428
436
  const ledger = await readExecutionLedger(projectRoot, payload);
429
437
  const promptKey = explicitPromptKey(state || {});
430
438
  if (!state || !ledger || ledger.sessionId !== sessionId || !promptKey
431
- || ledger.promptKey !== promptKey || !ledger.requestKey || !hasUnfinishedCompletion(state, ledger)) {
439
+ || ledger.promptKey !== promptKey || !ledger.requestKey || !hasUnfinishedCompletion(state, ledger, { cwd: projectRoot })) {
432
440
  return null;
433
441
  }
434
442
  const intent = {
@@ -436,6 +444,7 @@ export async function writeResumeIntent(projectRoot, payload = {}, { now = Date.
436
444
  sessionKey: safeSegment(sessionId),
437
445
  sessionHash: resumeSessionHash(sessionId),
438
446
  promptKey,
447
+ taskInstanceId: state.taskInstanceId || null,
439
448
  requestKey: ledger.requestKey,
440
449
  routeFingerprint: state.fingerprint || null,
441
450
  createdAt: now,
@@ -649,9 +658,9 @@ function compactReceipt(receipt) {
649
658
  kind: receipt.kind,
650
659
  success: receipt.success,
651
660
  };
652
- 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']) {
653
662
  if (receipt[key] !== undefined && receipt[key] !== null && receipt[key] !== '') {
654
- compact[key] = receipt[key];
663
+ compact[key] = key === 'operation' ? compactOperation(receipt[key]) : receipt[key];
655
664
  }
656
665
  }
657
666
  return compact;
@@ -731,6 +740,27 @@ function workspaceFingerprint(cwd) {
731
740
  }
732
741
  }
733
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
+
734
764
  // A workspace delta cannot be attributed to the Bash call that happened to observe it: a
735
765
  // background process or a subagent may have changed the tree in between. The classifier below
736
766
  // only ever REMOVES credit — a command it recognises as strictly read-only never receives a
@@ -794,7 +824,8 @@ function gitPatchId(diffText) {
794
824
  // SPEC §2.2 — is this verdict still valid at the current head?
795
825
  // Returns { status, current, headSha } where status is one of:
796
826
  // 'current' | 'stale' | 'stale-recoverable' | 'untracked' (no git / no sha data).
797
- // 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.
798
829
  export function verdictCurrent(verdict, { cwd = process.cwd() } = {}) {
799
830
  if (!verdict || typeof verdict !== 'object') {
800
831
  return { status: 'untracked', current: false, headSha: null };
@@ -808,6 +839,9 @@ export function verdictCurrent(verdict, { cwd = process.cwd() } = {}) {
808
839
  cwd = verdict.workspaceRoot;
809
840
  }
810
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
+ }
811
845
  if (!currentHead) {
812
846
  // No git repo (or git unavailable): currency cannot be proven either way —
813
847
  // null-safe per spec non-goals; do not invent staleness on lanes without git.
@@ -885,6 +919,7 @@ function mintVerificationVerdict(receipt, payload, projectRoot, sessionRoot = pr
885
919
  headSha: headSha || null,
886
920
  baseSha: baseSha || null,
887
921
  patchId,
922
+ ...(headSha ? { dirtyFp: verificationDirtyFingerprint(cwd) } : {}),
888
923
  ts: supplied.ts || new Date().toISOString(),
889
924
  };
890
925
  }
@@ -1116,11 +1151,15 @@ function progressDigest(ledger = {}) {
1116
1151
  // A genuinely different prompt keeps a clean slate so old work never satisfies a new
1117
1152
  // request's completion gate.
1118
1153
  function carriedEvidenceLedger(fresh, current) {
1119
- 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)) {
1120
1157
  return fresh;
1121
1158
  }
1122
1159
  return {
1123
1160
  ...fresh,
1161
+ operationBank: current.operationBank || {},
1162
+ receiptSequence: current.receiptSequence || 0,
1124
1163
  sourceSucceeded: fresh.sourceSucceeded || current.sourceSucceeded === true,
1125
1164
  // targeted-verification evidence (2.3.0) must survive the same re-key carry as the
1126
1165
  // coarse flags, or the stricter gates re-demand evidence mid-request — the exact
@@ -1213,6 +1252,7 @@ function freshLedger(payload, routeState, harness) {
1213
1252
  harness,
1214
1253
  requestKey: routeState?.requestKey || null,
1215
1254
  promptKey: evidencePromptKey(routeState),
1255
+ taskInstanceId: routeState?.taskInstanceId || null,
1216
1256
  routeFingerprint: routeState?.fingerprint || null,
1217
1257
  // TASK-009 (BL-011): the routed playbook rides the ledger so the bug-fix
1218
1258
  // completion floor can classify repro receipts at apply time — including
@@ -1238,6 +1278,8 @@ function freshLedger(payload, routeState, harness) {
1238
1278
  verificationSucceeded: false,
1239
1279
  verificationFailed: false,
1240
1280
  receipts: [],
1281
+ receiptSequence: 0,
1282
+ operationBank: {},
1241
1283
  blocker: null,
1242
1284
  verdict: null,
1243
1285
  verdictHistory: [],
@@ -1278,7 +1320,7 @@ const MAX_JOURNAL_SEEN = 64;
1278
1320
  const MAX_JOURNAL_RECORDS = 128;
1279
1321
  const MAX_JOURNAL_QUARANTINE_LINES = 32;
1280
1322
  const LEDGER_EVENT_TYPES = new Set(['receipt', 'continuation', 'notified', 'stop-progress', 'resumable-run']);
1281
- const RECEIPT_KINDS = new Set(['source', 'write', 'verification', 'playbook-finding', 'workspace']);
1323
+ const RECEIPT_KINDS = new Set(['source', 'write', 'verification', 'playbook-finding', 'workspace', 'operation']);
1282
1324
 
1283
1325
  function journalPathFor(target) {
1284
1326
  return `${target}.journal`;
@@ -1313,6 +1355,8 @@ async function mergeContinuationSidecar(ledger, target) {
1313
1355
  return ledger;
1314
1356
  }
1315
1357
  if (!sidecar || typeof sidecar !== 'object') return ledger;
1358
+ if (ledger && (ledger.taskInstanceId || sidecar.taskInstanceId)
1359
+ && (!ledger.taskInstanceId || ledger.taskInstanceId !== sidecar.taskInstanceId)) return ledger;
1316
1360
  const sidecarAt = Number(sidecar.lastContinuationAt) || 0;
1317
1361
  if (!ledger) {
1318
1362
  const hasState = sidecarAt > 0
@@ -1321,6 +1365,7 @@ async function mergeContinuationSidecar(ledger, target) {
1321
1365
  if (!hasState) return null;
1322
1366
  return {
1323
1367
  requestKey: sidecar.requestKey ?? null,
1368
+ taskInstanceId: sidecar.taskInstanceId || null,
1324
1369
  promptKey: sidecar.promptKey ?? null,
1325
1370
  continuationRequestKey: sidecar.requestKey ?? null,
1326
1371
  continuationCount: Number(sidecar.continuationCount) || 0,
@@ -1363,7 +1408,9 @@ async function bumpContinuationSidecar(target, event, { signal, deadlineMs } = {
1363
1408
  { signal, deadlineMs: deadlineMs ?? LOCK_MAX_SLICE_MS },
1364
1409
  async () => {
1365
1410
  try {
1366
- 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;
1367
1414
  const applied = event.type === 'notified'
1368
1415
  ? { ...base, notified: true, updatedAt: Date.now() }
1369
1416
  : applyContinuationToLedger(base, event);
@@ -1377,6 +1424,7 @@ async function bumpContinuationSidecar(target, event, { signal, deadlineMs } = {
1377
1424
  await writeJsonAtomic(sidecarPath, {
1378
1425
  requestKey: event?.requestKey ?? applied.requestKey ?? applied.continuationRequestKey ?? null,
1379
1426
  promptKey: event?.promptKey ?? applied.promptKey ?? null,
1427
+ taskInstanceId: event?.taskInstanceId || applied.taskInstanceId || null,
1380
1428
  continuationCount: Number(applied.continuationCount) || 0,
1381
1429
  noProgressCount: Number(applied.noProgressCount) || 0,
1382
1430
  lastProgressDigest: applied.lastProgressDigest ?? null,
@@ -1397,7 +1445,7 @@ function newEventId() {
1397
1445
  return `${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`;
1398
1446
  }
1399
1447
 
1400
- 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'];
1401
1449
 
1402
1450
  // TASK-009 (BL-011): evidence attestations are whitelisted verbatim — class /
1403
1451
  // surface / result / claim, the ARCH §Evidence Schema fields a receipt may
@@ -1421,7 +1469,7 @@ function sanitizeReceiptForJournal(receipt) {
1421
1469
  const clean = {};
1422
1470
  for (const key of JOURNAL_RECEIPT_FIELDS) {
1423
1471
  if (receipt && receipt[key] !== undefined && receipt[key] !== null && receipt[key] !== '') {
1424
- clean[key] = receipt[key];
1472
+ clean[key] = key === 'operation' ? compactOperation(receipt[key]) : receipt[key];
1425
1473
  }
1426
1474
  }
1427
1475
  const evidence = sanitizeEvidenceForJournal(receipt && receipt.evidence);
@@ -1459,6 +1507,7 @@ function buildJournalRecord({ event, eventId, payload, routeState }) {
1459
1507
  sessionKey: sessionIdentity(payload),
1460
1508
  sessionId: explicitSessionId(payload),
1461
1509
  requestKey: routeState?.requestKey ?? null,
1510
+ taskInstanceId: routeState?.taskInstanceId || null,
1462
1511
  promptKey: evidencePromptKey(routeState),
1463
1512
  type: event.type,
1464
1513
  event: event.type === 'receipt'
@@ -1555,6 +1604,10 @@ async function quarantineJournalLines(target, lines) {
1555
1604
  // Uniform applier: returns { ledger, value } or null when the record is unappliable
1556
1605
  // (the caller quarantines it instead of applying it).
1557
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;
1558
1611
  if (record.type === 'receipt') {
1559
1612
  return applyReceiptToLedger(ledger, record.event?.receipt, { vibecode: record.event?.vibecode === true });
1560
1613
  }
@@ -2097,7 +2150,11 @@ function applyReceiptToLedger(ledger, receipt, options = {}) {
2097
2150
 
2098
2151
  function applyReceiptCore(ledger, receipt, { vibecode = false } = {}) {
2099
2152
  if (!receipt || typeof receipt !== 'object' || !RECEIPT_KINDS.has(receipt.kind)) return null;
2100
- 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
+ }
2101
2158
  if (receipt.kind === 'source') {
2102
2159
  next.sourceSucceeded = next.sourceSucceeded || receipt.success;
2103
2160
  if (receipt.success && receipt.file) {
@@ -2329,12 +2386,16 @@ function liveBaseLedger(event, current, payload, routeState, harness, fallbackLe
2329
2386
  // live requestKey the only safe move is to keep appending to the current ledger.
2330
2387
  const nextRequestKey = routeState?.requestKey || null;
2331
2388
  if (current && !nextRequestKey) return { ...current, harness: current.harness || harness };
2332
- if (!current || current.requestKey !== nextRequestKey) {
2389
+ if (!current || current.requestKey !== nextRequestKey
2390
+ || (current.taskInstanceId || null) !== (routeState?.taskInstanceId || null)) {
2333
2391
  return carriedEvidenceLedger(freshLedger(payload, routeState, harness), current);
2334
2392
  }
2335
2393
  return { ...current, harness: current.harness || harness };
2336
2394
  }
2337
- 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);
2338
2399
  }
2339
2400
 
2340
2401
  // --- TASK-005 (FR-005, BL-007): outcome.observed terminal emissions ---------
@@ -2424,12 +2485,24 @@ export async function recordLedgerEvent(event, {
2424
2485
  const target = ledgerPath(projectRoot, payload);
2425
2486
  // Hoisted so the post-commit outcome emitter sees the same state snapshot
2426
2487
  // the lock callback evaluated (a fresh read could observe a re-route).
2427
- 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
+ }
2428
2496
  // The descriptor the post-commit emitter must fire (lock-local `value` is
2429
2497
  // nested two levels deep in the lock result — a hoisted ref is clearer).
2430
2498
  let emittedOutcome = null;
2431
2499
  const outcome = await withLedgerLock(target, { signal, deadlineMs }, async () => {
2432
- 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;
2433
2506
  lockedRouteState = state || null;
2434
2507
  const current = await readExecutionLedger(projectRoot, payload);
2435
2508
  let ledger = liveBaseLedger(event, current, payload, state, harness, fallbackLedger);
@@ -2458,7 +2531,13 @@ export async function recordLedgerEvent(event, {
2458
2531
  if (state && routeTaskType && ledger.taskType !== routeTaskType) {
2459
2532
  ledger = { ...ledger, taskType: routeTaskType };
2460
2533
  }
2461
- const applied = applyReceiptToLedger(ledger, event.receipt, { vibecode: event.vibecode === true });
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 });
2462
2541
  if (!applied) return { committed: false, eventId, reason: 'invalid-receipt' };
2463
2542
  ledger = applied.ledger;
2464
2543
  // TASK-005 (FR-005): completion receipts (write/verification terminal
@@ -2597,7 +2676,7 @@ export async function recordLedgerEvent(event, {
2597
2676
 
2598
2677
  // Fail-closed: the acquisition budget expired or the wait aborted. The main ledger is
2599
2678
  // NEVER mutated unlocked — journal the event once; the next acquired lock reconciles it.
2600
- const record = buildJournalRecord({ event, eventId, payload, routeState: routeState || null });
2679
+ const record = buildJournalRecord({ event, eventId, payload, routeState: lockedRouteState || null });
2601
2680
  const journalResult = await appendJournalRecord(target, record, { signal, deadlineMs });
2602
2681
  if (journalResult.ok) return { journaled: true, eventId };
2603
2682
  // F-4: continuation/notified are counter state, not journal rows — a journal-full
@@ -2605,7 +2684,7 @@ export async function recordLedgerEvent(event, {
2605
2684
  // MAX_CONTINUATIONS fires late/never. Land the tick in the sidecar the evaluator
2606
2685
  // merges; only a sidecar write failure is a real rejection.
2607
2686
  if (event.type === 'continuation' || event.type === 'notified') {
2608
- const sidecar = await bumpContinuationSidecar(target, event);
2687
+ const sidecar = await bumpContinuationSidecar(target, { ...event, taskInstanceId: lockedRouteState?.taskInstanceId || null });
2609
2688
  if (sidecar.ok) return { counted: true, eventId, reason: journalResult.reason };
2610
2689
  }
2611
2690
  return { rejected: true, eventId, reason: journalResult.reason };
@@ -2681,7 +2760,7 @@ export async function recordExecutionReceipt({
2681
2760
  } = {}) {
2682
2761
  if (!projectRoot || !toolName) return { rejected: true, eventId: null };
2683
2762
  const routeState = await readRouteState(projectRoot, payload);
2684
- const failed = explicitError(payload);
2763
+ const failed = payload.hook_event_name === 'PostToolUseFailure' || explicitError(payload);
2685
2764
  const exitCode = extractExitCode(payload);
2686
2765
  const success = !failed && (exitCode === null || exitCode === 0);
2687
2766
  const toolInput = payload.tool_input || {};
@@ -2699,7 +2778,10 @@ export async function recordExecutionReceipt({
2699
2778
  exitCode,
2700
2779
  };
2701
2780
 
2702
- 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') {
2703
2785
  receipt.kind = 'source';
2704
2786
  receipt.file = toolInput.file_path || toolInput.path || null;
2705
2787
  } else if (toolName === 'Edit' || toolName === 'Write') {
@@ -2818,6 +2900,9 @@ function requiredEvidence(state = {}) {
2818
2900
  const required = Array.isArray(contractEvidence) && contractEvidence.length > 0
2819
2901
  ? [...contractEvidence]
2820
2902
  : [...(routeSummary.completionState?.missingEvidence || [])];
2903
+ const contract = routeSummary.executionContract;
2904
+ const operationOnly = contract?.taskKind === 'OPERATIONAL'
2905
+ || (contract?.taskKind === 'CONFIGURATION' && contract.configurationScope === 'external');
2821
2906
  // FR-006 (M01.4' tail): a route persisted with a high riskEscalation must re-verify at
2822
2907
  // Stop even when its own contract only demands write evidence. The route state is the
2823
2908
  // carrier — no config read here; absent/malformed/non-high fields are a no-op.
@@ -2825,7 +2910,7 @@ function requiredEvidence(state = {}) {
2825
2910
  // verification-evidence; a missing or malformed stage fails closed (enforcing) so
2826
2911
  // pre-C48 persisted records keep re-verifying until re-stamped.
2827
2912
  if (routeSummary.riskEscalation?.level === 'high' && routeSummary.riskEscalation?.stage !== 'shadow') {
2828
- required.push('verification-evidence');
2913
+ required.push(operationOnly ? 'operation-verification' : 'verification-evidence');
2829
2914
  }
2830
2915
  // TASK-C85-014 (BL-014): a resolved verification-map recipe rides the route
2831
2916
  // summary (stamped by stop-coordinator's recipe consult). Its
@@ -2843,7 +2928,20 @@ function requiredEvidence(state = {}) {
2843
2928
 
2844
2929
  function evidenceSatisfied(evidence, ledger = {}, state = {}, { cwd } = {}) {
2845
2930
  const routeSummary = state?.routeSummary || {};
2846
- 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
+ }
2847
2945
  if (evidence === 'verification-evidence') {
2848
2946
  // SPEC-typed-verdicts §2.3: when a verdict record exists it governs — only a
2849
2947
  // CURRENT test-verified/live-verified verdict satisfies the gate. A stale or
@@ -3088,7 +3186,19 @@ function phaseRelease(phase, ledger, missingEvidence) {
3088
3186
  // continuation budget — the request already accumulated. A different prompt keeps a clean slate.
3089
3187
  // Shared by the completion contract and the playbook advisory so both judge the same
3090
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
+
3091
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
+ }
3092
3202
  const sameRequest = !ledger?.requestKey || !state?.requestKey
3093
3203
  || ledger.requestKey === state.requestKey
3094
3204
  || (ledger.promptKey && evidencePromptKey(state) === ledger.promptKey);
@@ -3130,7 +3240,13 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3130
3240
  // success — there is nothing to report. The CLI's lost-route path handles the case where
3131
3241
  // THIS session has ledger activity but the state vanished; callers that pass no state
3132
3242
  // (e.g. the omp bridge for a session it does not own) must not be force-notified.
3133
- 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: [] };
3134
3250
  }
3135
3251
  // An informational turn carries no completion contract of its own. Two paths land here:
3136
3252
  // a fully-routed informational prompt (routeSummary.executionMode === 'informational',
@@ -3143,8 +3259,8 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3143
3259
  // evidence the old contract demands. Release silently — the carried routeSummary stays
3144
3260
  // on disk, so the previous contract still survives until a real route replaces it.
3145
3261
  if (
3146
- mode === 'informational'
3147
- || state?.routingContext?.executionMode === 'informational'
3262
+ !routeSummary.executionContract?.taskKind && (mode === 'informational'
3263
+ || state?.routingContext?.executionMode === 'informational')
3148
3264
  ) {
3149
3265
  return { continue: false, notify: false, complete: true, missingEvidence: [] };
3150
3266
  }
@@ -3163,7 +3279,11 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3163
3279
  // every Edit/Write, so a requestKey mismatch alone must not discard the evidence — and the
3164
3280
  // continuation budget — the request already accumulated. A different prompt keeps a clean slate.
3165
3281
  const effectiveLedger = effectiveRequestLedger(state, ledger);
3282
+ const operationStatus = operationCompletionPolicy(scopedContract(state, effectiveLedger),
3283
+ null, effectiveLedger);
3284
+ const operationalTask = operationStatus !== null;
3166
3285
  const missingEvidence = evidence.filter((item) => !evidenceSatisfied(item, effectiveLedger, state, { cwd }));
3286
+ if (operationalTask && !operationStatus.complete && !missingEvidence.includes('operation-verification')) missingEvidence.push('operation-verification');
3167
3287
  // TASK-009 (BL-011): the bug-fix floor. On a `playbookId: bug-fix` route a
3168
3288
  // green verdict requires verbatim repro-before (fail) + repro-after (pass)
3169
3289
  // receipts on the same surface plus a named root cause in the exec-ledger —
@@ -3207,10 +3327,11 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3207
3327
  // Silent success: the route is present and every required evidence is satisfied. Marked
3208
3328
  // `complete` so the CLI dispatch recognizes it BEFORE the loud final else — otherwise a
3209
3329
  // bare loud else would turn a clean stop into user-visible noise.
3210
- 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)) };
3211
3331
  }
3212
3332
 
3213
- const released = phaseRelease(phase, effectiveLedger, missingEvidence);
3333
+ const released = routeSummary.executionContract?.taskKind
3334
+ ? null : phaseRelease(phase, effectiveLedger, missingEvidence);
3214
3335
  if (released) return released;
3215
3336
 
3216
3337
  // `find-cause` can validly end clean: an investigation may establish that no actionable
@@ -3233,6 +3354,7 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3233
3354
  || routeSummary.executionContract.completionRule === 'never-claim-fixed-without-write-and-verification');
3234
3355
  if (
3235
3356
  findCauseNoMutationContract
3357
+ && !routeSummary.executionContract?.taskKind
3236
3358
  && playbookId !== BUGFIX_PLAYBOOK_ID
3237
3359
  && !effectiveLedger.writeAttempted
3238
3360
  && !effectiveLedger.verificationFailed
@@ -3255,7 +3377,8 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3255
3377
  // satisfied), no mutation was attempted, and no verification failed; anything less has
3256
3378
  // concrete unfinished work and keeps recovering.
3257
3379
  if (
3258
- mode === 'map-impact'
3380
+ !routeSummary.executionContract?.taskKind
3381
+ && mode === 'map-impact'
3259
3382
  && !missingEvidence.includes('impact-evidence')
3260
3383
  && effectiveLedger.writeAttempted !== true
3261
3384
  && effectiveLedger.verificationFailed !== true
@@ -3282,7 +3405,8 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3282
3405
  const readOnlyPrompt = state?.routingContext?.mutationOrder === false
3283
3406
  || state?.routingContext?.planOnly === true;
3284
3407
  if (
3285
- readOnlyPrompt
3408
+ !routeSummary.executionContract?.taskKind
3409
+ && readOnlyPrompt
3286
3410
  && !effectiveLedger.writeAttempted
3287
3411
  && missingEvidence.some((item) => item === 'write-evidence' || item === 'verification-evidence')
3288
3412
  ) {
@@ -3295,7 +3419,7 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3295
3419
  };
3296
3420
  }
3297
3421
 
3298
- const gated = IMPLEMENT_MODES.has(mode);
3422
+ const gated = Boolean(routeSummary.executionContract?.taskKind) || IMPLEMENT_MODES.has(mode);
3299
3423
  if (!gated) {
3300
3424
  // Advisory, not a gate message: nothing was written, so verification was never owed.
3301
3425
  const noWriteAdvisory = !effectiveLedger.writeAttempted && missingEvidence.every((item) => item === 'verification-evidence');
@@ -3375,7 +3499,9 @@ function evaluateCompletionContract({ state = {}, ledger = {}, cwd, phase = null
3375
3499
  }
3376
3500
 
3377
3501
  const finalAttempt = !vibecode && continuationCount === MAX_CONTINUATIONS - 1;
3378
- 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 });
3379
3505
  return {
3380
3506
  continue: true,
3381
3507
  missingEvidence,
@@ -3646,6 +3772,7 @@ async function runEvaluateStop() {
3646
3772
  }
3647
3773
  return; // silent success: route present, all evidence satisfied
3648
3774
  }
3775
+ if (result.gateRelease === 'no-routable-contract') return; // Advisory release, never VERIFIED.
3649
3776
  // Defensive final else: no evaluation result may ever end a routed stop silently.
3650
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.`;
3651
3778
  process.stderr.write(`[ukit-completion] ${fallback}\n`);
@@ -124,7 +124,7 @@ export async function applyLoopBreak({ merged, projectRoot, runRoot, payload, se
124
124
  const note = [
125
125
  `[ukit-loop-breaker] BLOCKED/ESCALATED: ${merged.policyOwner} refused this stop ${res.attemptCount - 1} consecutive times with no change in the work state (~${minutes} min, mode ${mode || 'UNKNOWN'}).`,
126
126
  `Unresolved blocker: ${String(merged.reason || '').split('\n', 1)[0].slice(0, 200)}`,
127
- 'Stop released. This is NOT approval: nothing was verified and no safety/permission denial is lifted.',
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
130
  return { ...merged, decision: null, reason: undefined, loopBreak: true, policyOwner: 'loop-breaker', systemMessage: merged.systemMessage ? `${note}\n${merged.systemMessage}` : note };
@@ -0,0 +1,87 @@
1
+ // Adapter-normalized proof only; neither native output nor agent prose is evidence here.
2
+ const MAX_OPERATIONS = 16;
3
+ export function compactOperation(operation) {
4
+ if (!operation || typeof operation !== 'object' || Array.isArray(operation)) return { invalid: true };
5
+ const bounded = (value) => typeof value === 'string' && value.length > 0 && value.length <= 256;
6
+ if (!bounded(operation.domain) || !bounded(operation.scope)
7
+ || !['mutation', 'state'].includes(operation.role)
8
+ || (operation.target !== null && !bounded(operation.target))
9
+ || (operation.state !== undefined && !bounded(operation.state))
10
+ || (operation.presentTargets != null && (!Array.isArray(operation.presentTargets)
11
+ || operation.presentTargets.length > 256 || !operation.presentTargets.every(bounded)))) return { invalid: true };
12
+ return { domain: operation.domain, scope: operation.scope, role: operation.role, target: operation.target,
13
+ ...(operation.state !== undefined ? { state: operation.state } : {}),
14
+ ...(operation.presentTargets !== undefined ? { presentTargets: operation.presentTargets === null ? null : [...operation.presentTargets] } : {}),
15
+ complete: operation.complete === true };
16
+ }
17
+ export function foldOperationEvidence(bank = {}, receipt, ledger) {
18
+ const raw = receipt?.operation;
19
+ if (raw?.invalid === true) return { ...bank, overflow: true };
20
+ const op = raw?.presentTargets === null && receipt.success !== true
21
+ ? { ...raw, presentTargets: undefined } : raw;
22
+ if (!op || !ledger.taskInstanceId || receipt.taskInstanceId !== ledger.taskInstanceId
23
+ || receipt.sessionKey !== ledger.sessionKey || typeof op.domain !== 'string'
24
+ || typeof op.scope !== 'string' || !['mutation', 'state'].includes(op.role)) return bank;
25
+ const bounded = (value) => typeof value === 'string' && value.length > 0 && value.length <= 256;
26
+ if (!bounded(op.domain) || !bounded(op.scope)
27
+ || (op.target !== null && !bounded(op.target))
28
+ || (op.state !== undefined && !bounded(op.state))
29
+ || (op.presentTargets !== undefined && (!Array.isArray(op.presentTargets)
30
+ || op.presentTargets.length > 256 || !op.presentTargets.every(bounded)))) {
31
+ return { ...bank, overflow: true };
32
+ }
33
+ const key = JSON.stringify([op.domain, op.scope, op.role === 'state' ? null : op.target]);
34
+ if (!Object.hasOwn(bank, key) && Object.keys(bank).filter((entry) => !['overflow', 'seenToolUseIds'].includes(entry)).length >= MAX_OPERATIONS) {
35
+ return { ...bank, overflow: true }; // Never evict a known failure to make room for success.
36
+ }
37
+ const prior = bank[key] || {};
38
+ const seen = Array.isArray(bank.seenToolUseIds) ? bank.seenToolUseIds : [];
39
+ if (receipt.toolUseId && (seen.includes(receipt.toolUseId) || Object.values(bank).some((entry) =>
40
+ [entry?.mutation, entry?.state].some((proof) => proof?.toolUseId === receipt.toolUseId)))) return bank;
41
+ if (receipt.toolUseId && (!bounded(receipt.toolUseId) || seen.length >= 256)) return { ...bank, overflow: true };
42
+ const proof = { success: receipt.success === true, sequence: receipt.sequence,
43
+ toolUseId: receipt.toolUseId, taskInstanceId: receipt.taskInstanceId,
44
+ sessionKey: receipt.sessionKey, operation: {
45
+ domain: op.domain, scope: op.scope, role: op.role, target: op.target,
46
+ ...(op.state !== undefined ? { state: op.state } : {}),
47
+ ...(op.presentTargets !== undefined ? { presentTargets: [...op.presentTargets] } : {}),
48
+ complete: op.complete === true,
49
+ } };
50
+ return { ...bank, seenToolUseIds: receipt.toolUseId ? [...seen, receipt.toolUseId] : seen,
51
+ [key]: { ...prior, [op.role]: proof } };
52
+ }
53
+
54
+ export function operationEvidenceStatus(goal, ledger = {}) {
55
+ if (!goal || typeof goal.domain !== 'string' || !goal.domain || typeof goal.desiredState !== 'string'
56
+ || !goal.desiredState || (goal.target !== null && (typeof goal.target !== 'string' || !goal.target))
57
+ || !['ensure-state', 'execute-and-verify'].includes(goal.completionSemantics)) return null;
58
+ const incomplete = { complete: false, operationProof: null };
59
+ if (!ledger.taskInstanceId || !ledger.sessionKey || !goal.scope || ledger.operationBank?.overflow) return incomplete;
60
+ const entries = Object.values(ledger.operationBank || {});
61
+ const matches = (r) => r?.taskInstanceId === ledger.taskInstanceId && r.sessionKey === ledger.sessionKey
62
+ && r.operation?.domain === goal.domain && r.operation.scope === goal.scope;
63
+ const attempts = entries.map((e) => e.mutation).filter(matches);
64
+ const attempt = attempts.filter((r) => r.operation.target === goal.target).sort((a, b) => b.sequence - a.sequence)[0];
65
+ const observation = entries.map((e) => e.state).filter(matches).sort((a, b) => b.sequence - a.sequence)[0];
66
+ const op = observation?.operation;
67
+ const absent = goal.desiredState === 'absent' && Array.isArray(op?.presentTargets)
68
+ && op.presentTargets.every((id) => typeof id === 'string')
69
+ && (goal.target === null ? op.presentTargets.length === 0 : !op.presentTargets.includes(goal.target));
70
+ const stateMatches = op?.state === goal.desiredState
71
+ && (absent || (!Object.hasOwn(op || {}, 'presentTargets') && op?.target === goal.target));
72
+ if (!observation?.success || !observation.toolUseId || !Number.isSafeInteger(observation.sequence)
73
+ || op.complete !== true || !stateMatches) return incomplete;
74
+ if (goal.completionSemantics === 'ensure-state' && attempts.length === 0) {
75
+ return { complete: true, operationProof: 'desired-state-only' };
76
+ }
77
+ if (!attempt?.success || !attempt.toolUseId || !observation.toolUseId
78
+ || !(observation.sequence > attempt.sequence)) return incomplete;
79
+ return { complete: true, operationProof: 'executed-and-verified' };
80
+ }
81
+
82
+ export function operationCompletionPolicy(contract, _adapterGoal, ledger) {
83
+ const kind = String(contract?.taskKind || '').toUpperCase();
84
+ if (!['OPERATIONAL', 'MIXED', 'CONFIGURATION'].includes(kind)
85
+ || (kind === 'CONFIGURATION' && contract.configurationScope !== 'external')) return null;
86
+ return operationEvidenceStatus(contract.operationGoal, ledger) || { complete: false, operationProof: null };
87
+ }