@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.
- package/CHANGELOG.md +23 -0
- package/manifests/documentation.yaml +12 -0
- package/package.json +1 -1
- package/src/core/executionContracts.js +32 -0
- package/src/decision/client.js +15 -0
- package/src/decision/protocol.js +12 -14
- package/src/decision/reviewVerdict.js +16 -2
- package/src/index/fixLoopEscalation.js +10 -4
- package/src/index/routeResolver.js +2 -2
- package/src/index/taskRouting.js +3 -1
- package/template_project/.claude/hooks/record-execution.mjs +1 -3
- package/template_project/.claude/hooks/skill-router.sh +82 -9
- package/template_project/.claude/settings.json +22 -0
- package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +10 -4
- package/template_project/.claude/ukit/index/review-verdict.mjs +48 -9
- package/template_project/.claude/ukit/index/route-resolver.mjs +34 -2
- package/template_project/.claude/ukit/index/route-task.mjs +88 -9
- package/template_project/.claude/ukit/index/sidecar-decision.mjs +6 -117
- package/template_project/.claude/ukit/index/unic-decision.mjs +27 -14
- package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +43 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +160 -33
- package/template_project/.claude/ukit/runtime/loop-state.mjs +1 -1
- package/template_project/.claude/ukit/runtime/operation-evidence.mjs +87 -0
- package/template_project/.omp/hooks/pre/ukit-bridge.js +8 -1
- package/template_project/AGENTS.md +1 -1
- package/template_project/CLAUDE.md +1 -1
- 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:
|
|
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 ||
|
|
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
|
|
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
|
-
|
|
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 ||
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 === '
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
+
}
|