@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.
- package/CHANGELOG.md +29 -0
- package/manifests/documentation.yaml +100 -0
- package/package.json +1 -1
- package/src/core/executionContracts.js +32 -0
- package/src/core/permissionPolicy.js +1 -1
- package/src/decision/client.js +15 -0
- package/src/decision/protocol.js +12 -14
- package/src/index/buildIndex.js +244 -4
- package/src/index/impactContext.js +91 -11
- package/src/index/languageTools.js +126 -0
- package/src/index/paths.js +5 -1
- package/src/index/playbookRegistry.js +108 -9
- package/src/index/queryIndex.js +415 -1
- package/src/index/routeCatalog.js +1 -1
- package/src/index/routeResolver.js +2 -2
- package/src/index/taskRouting.js +28 -4
- package/template_project/.claude/commands/ukit/handoff-create.md +5 -0
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +43 -4
- package/template_project/.claude/hooks/block-dangerous.mjs +7 -1
- package/template_project/.claude/hooks/handoff-model-guard.sh +235 -21
- package/template_project/.claude/hooks/record-execution.mjs +21 -0
- package/template_project/.claude/hooks/skill-router.sh +187 -16
- package/template_project/.claude/settings.json +43 -1
- package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
- package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +10 -4
- package/template_project/.claude/ukit/index/impact-context.mjs +70 -15
- package/template_project/.claude/ukit/index/land-worktree.mjs +2 -2
- package/template_project/.claude/ukit/index/lib/index-core.mjs +883 -14
- package/template_project/.claude/ukit/index/playbook-registry.mjs +108 -9
- package/template_project/.claude/ukit/index/query-index.mjs +100 -7
- package/template_project/.claude/ukit/index/review-verdict.mjs +14 -4
- package/template_project/.claude/ukit/index/route-catalog.mjs +1 -1
- package/template_project/.claude/ukit/index/route-resolver.mjs +34 -2
- package/template_project/.claude/ukit/index/route-task.mjs +243 -38
- package/template_project/.claude/ukit/index/sidecar-decision.mjs +3 -59
- 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 +612 -56
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +122 -6
- 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/.claude/ukit/runtime/resumable-run.mjs +417 -0
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +635 -28
- package/template_project/.omp/hooks/pre/ukit-bridge.js +40 -5
- package/template_project/AGENTS.md +1 -1
- package/template_project/CLAUDE.md +1 -1
- package/template_project/instructions/core.md +1 -1
- package/template_user/playbooks/feature-implementation.md +9 -0
- package/template_user/playbooks/migration.md +4 -0
|
@@ -28,7 +28,9 @@
|
|
|
28
28
|
// could not complete (missing/unreadable path, budget overrun) — callers keep
|
|
29
29
|
// their fail-closed posture on `known=false`.
|
|
30
30
|
//
|
|
31
|
-
// Intent values
|
|
31
|
+
// Intent values: 'create' | 'fullstack' | 'review' | 'neutral' (status/clear); every
|
|
32
|
+
// consumer compares by equality with 'create' / 'fullstack', so the added values are
|
|
33
|
+
// non-matching by construction. An exact
|
|
32
34
|
// `handoff-implement` invocation CLAIMS the run (the fullstack command says so
|
|
33
35
|
// itself), so it maps onto the existing 'fullstack' intent instead of adding a
|
|
34
36
|
// new enum every consumer would have to learn (TASK-C97-004).
|
|
@@ -48,7 +50,7 @@ export const HANDOFF_RESUME_BANNER = 'UKIT HANDOFF RESUME — an unfinished hand
|
|
|
48
50
|
|
|
49
51
|
// Sweeper for "which command tag came LAST in this entry": finds every exact
|
|
50
52
|
// command-name tag occurrence with its position so the newest one wins.
|
|
51
|
-
const COMMAND_TAG_SWEEP = /<command-name>\/?(?:ukit:)?handoff-(create|fullstack|implement)(?![\w-])/g;
|
|
53
|
+
const COMMAND_TAG_SWEEP = /<command-name>\/?(?:ukit:)?handoff-(create|fullstack|implement|review|status|clear)(?![\w-])/g;
|
|
52
54
|
|
|
53
55
|
// omp and Codex do NOT wrap a slash command in `<command-name>` tags the way
|
|
54
56
|
// Claude Code's user turn does. Their native evidence is the command *expansion*:
|
|
@@ -58,7 +60,7 @@ const COMMAND_TAG_SWEEP = /<command-name>\/?(?:ukit:)?handoff-(create|fullstack|
|
|
|
58
60
|
// the FIRST line only (not any heading in the body) keeps prose references to the
|
|
59
61
|
// command inside the document from being mistaken for an invocation. The same
|
|
60
62
|
// exact token boundary applies: `-custom` suffixed headings are not commands.
|
|
61
|
-
const NATIVE_COMMAND_HEADING = /^\s{0,3}#{1,3}\s+\/?(?:ukit:)?(handoff-create|handoff-fullstack|handoff-implement)(?![\w-])/;
|
|
63
|
+
const NATIVE_COMMAND_HEADING = /^\s{0,3}#{1,3}\s+\/?(?:ukit:)?(handoff-create|handoff-fullstack|handoff-implement|handoff-review|handoff-status|handoff-clear)(?![\w-])/;
|
|
62
64
|
|
|
63
65
|
// The unique customType the omp bridge stamps on host-injected UKit hook context
|
|
64
66
|
// (`sendContext` → `hookContextMessage`). A resume banner only counts as intent
|
|
@@ -89,6 +91,10 @@ function commandTokenIntent(token) {
|
|
|
89
91
|
const t = String(token).toLowerCase().replace(/^handoff-/, '');
|
|
90
92
|
if (t === 'create') return 'create';
|
|
91
93
|
if (t === 'fullstack' || t === 'implement') return 'fullstack';
|
|
94
|
+
// review is its own phase (scoped fix + re-review); status/clear are neutral. They
|
|
95
|
+
// must SUPERSEDE an older create, otherwise the create-scope guard leaks forward.
|
|
96
|
+
if (t === 'review') return 'review';
|
|
97
|
+
if (t === 'status' || t === 'clear') return 'neutral';
|
|
92
98
|
return null;
|
|
93
99
|
}
|
|
94
100
|
|
|
@@ -182,14 +188,14 @@ function classifyAssistantBlocks(blocks) {
|
|
|
182
188
|
if (!b || typeof b !== 'object') continue;
|
|
183
189
|
if (b.type === 'tool_use' && b.name === 'Skill') {
|
|
184
190
|
const skill = String(b.input?.skill ?? '');
|
|
185
|
-
const m = /^(?:ukit:)?handoff-(create|fullstack|implement)$/.exec(skill);
|
|
191
|
+
const m = /^(?:ukit:)?handoff-(create|fullstack|implement|review|status|clear)$/.exec(skill);
|
|
186
192
|
if (m) latest = commandTokenIntent(m[1]);
|
|
187
193
|
continue;
|
|
188
194
|
}
|
|
189
195
|
if (b.type === 'toolCall') {
|
|
190
196
|
const name = String(b.name ?? '');
|
|
191
197
|
const skill = String(b.arguments?.skill ?? '');
|
|
192
|
-
const m = /^(?:ukit:)?handoff-(create|fullstack|implement)$/i.exec(name) || /^(?:ukit:)?handoff-(create|fullstack|implement)$/.exec(skill);
|
|
198
|
+
const m = /^(?:ukit:)?handoff-(create|fullstack|implement|review|status|clear)$/i.exec(name) || /^(?:ukit:)?handoff-(create|fullstack|implement|review|status|clear)$/.exec(skill);
|
|
193
199
|
if (m) latest = commandTokenIntent(m[1]);
|
|
194
200
|
}
|
|
195
201
|
}
|
|
@@ -307,7 +313,7 @@ export async function transcriptHandoffIntent(transcriptPath, {
|
|
|
307
313
|
// Cheap prefilter — skip JSON.parse for lines that cannot be evidence.
|
|
308
314
|
// `handoff-implement` is included: an implement invocation after a create
|
|
309
315
|
// must be seen as the NEWEST intent, not lost to the older create line.
|
|
310
|
-
if (!line.includes('handoff-create') && !line.includes('handoff-fullstack') && !line.includes('handoff-implement') && !line.includes('HANDOFF RESUME')) {
|
|
316
|
+
if (!line.includes('handoff-create') && !line.includes('handoff-fullstack') && !line.includes('handoff-implement') && !line.includes('handoff-review') && !line.includes('handoff-status') && !line.includes('handoff-clear') && !line.includes('HANDOFF RESUME')) {
|
|
311
317
|
continue;
|
|
312
318
|
}
|
|
313
319
|
// This is the NEWEST line carrying intent evidence. A host append can be
|
|
@@ -410,3 +416,113 @@ export function runCursorPaused(text) {
|
|
|
410
416
|
if (/^paused\b/i.test(field('Phase'))) return true;
|
|
411
417
|
return /\bPAUSED\b/.test(field('Cursor')) || /\bPAUSED\b/.test(field('Next'));
|
|
412
418
|
}
|
|
419
|
+
|
|
420
|
+
// ─── Run-all intent (goal-level loop phrase) ──────────────────────────────
|
|
421
|
+
// A run-all instruction ("run all remaining tasks", "finish everything",
|
|
422
|
+
// "chạy hết các task còn lại") binds the GOAL, not one cycle: the executor
|
|
423
|
+
// keeps cycling until no in-scope task remains. This is TEXT evidence for the
|
|
424
|
+
// goal loop ONLY — it never feeds classifyHandoffEntry, whose structured-only
|
|
425
|
+
// rule (TASK-007) keeps raw phrases from minting command ownership, so
|
|
426
|
+
// "handoff-create + run-all" stays planning. Every alternative below demands a
|
|
427
|
+
// totality quantifier (all/every/everything/hết/toàn bộ/…) next to the task
|
|
428
|
+
// word, so a single-task prompt ("run task 3", "TASK-003") never matches.
|
|
429
|
+
export const RUN_ALL_RE = new RegExp(
|
|
430
|
+
[
|
|
431
|
+
// EN verb … all/every … tasks ("run all remaining tasks", "execute every open task").
|
|
432
|
+
String.raw`\b(?:run|execute|do|process|implement|finish|complete|handle|work\s+through)\b[^.\n]{0,40}?\b(?:all|every)\b[^.\n]{0,40}?\btasks?\b`,
|
|
433
|
+
// EN bare quantifier over tasks ("all remaining tasks", "every queued task", "all of the tasks").
|
|
434
|
+
String.raw`\b(?:all|every)\s+(?:the\s+)?(?:(?:remaining|pending|open|unfinished|authorized|queued|outstanding|in-scope)\s+)?tasks?\b|\b(?:all|every)\b[^.\n]{0,20}?\bof\s+the\b[^.\n]{0,20}?\btasks?\b`,
|
|
435
|
+
// EN totality idioms ("run all", "finish everything", "complete the whole goal",
|
|
436
|
+
// "continue until no tasks remain", "until all tasks are done").
|
|
437
|
+
String.raw`\brun\s+(?:all|everything)\b(?!\s+(?:of\s+)?(?:the\s+)?(?:(?:unit|e2e|integration|smoke)\s+)?(?:tests?|specs?|checks?|lint|builds?|suites?|migrations?)\b)|\b(?:finish|complete|do)\s+(?:everything|all\s+of\s+it|the\s+(?:whole|entire)\s+(?:goal|backlog|list|plan))\b|\b(?:continue|keep\s+going|work)\s+until\s+(?:all\s+tasks?\s+(?:are\s+)?done|no\s+tasks?\s+(?:remain|are\s+left))\b|\buntil\s+(?:all\s+)?tasks?\s+(?:are\s+)?(?:done|complete[d]?|finished)\b|\bcontinue\s+until\s+nothing\s+(?:is\s+)?left\b`,
|
|
438
|
+
// VN (diacritic + ascii fallbacks): chạy hết / chạy tất cả task / làm hết các task còn lại /
|
|
439
|
+
// hoàn thành toàn bộ / cho đến khi không còn task.
|
|
440
|
+
String.raw`ch(?:ạy|ay)\s+h(?:ế|e)t|ch(?:ạy|ay)\s+t(?:ấ|a)t\s+c(?:ả|a)|l(?:à|a)m\s+h(?:ế|e)t|ho(?:à|a)n\s+th(?:à|a)nh\s+(?:to(?:à|a)n\s+b(?:ộ|o)|h(?:ế|e)t)|t(?:ấ|a)t\s+c(?:ả|a)\s+(?:c(?:á|a)c\s+)?tasks?|to(?:à|a)n\s+b(?:ộ|o)\s+(?:c(?:á|a)c\s+)?tasks?|cho\s+đ(?:ế|e)n\s+khi\s+kh(?:ô|o)ng\s+c(?:ò|o)n\s+tasks?`,
|
|
441
|
+
].join('|'),
|
|
442
|
+
'iu',
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Goal-level loop phrase detector. Pure text matching — deliberately separate
|
|
447
|
+
* from the structured intent classification above.
|
|
448
|
+
* @returns {boolean}
|
|
449
|
+
*/
|
|
450
|
+
export function isRunAllRequest(text) {
|
|
451
|
+
const t = String(text ?? '');
|
|
452
|
+
if (!t.trim()) return false;
|
|
453
|
+
// Read/list requests and plan-only / negated asks never authorize a backlog run.
|
|
454
|
+
if (RUN_ALL_READ_RE.test(t) || RUN_ALL_VETO_RE.test(t)) return false;
|
|
455
|
+
if (SINGLE_TASK_REF_RE.test(t)) return false;
|
|
456
|
+
return RUN_ALL_RE.test(t);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const RUN_ALL_READ_RE = /^\s*(?:please\s+)?(?:show|list|view|display|print|count|xem|liệt\s+kê|hiển\s+thị)\b/iu;
|
|
460
|
+
const RUN_ALL_VETO_RE = new RegExp(
|
|
461
|
+
[
|
|
462
|
+
String.raw`\b(?:do\s+not|don'?t|dont|never|no\s+need\s+to|without|not)\b[^.\n]{0,30}?\b(?:run|execute|do|process|implement|finish|complete|handle)\b`,
|
|
463
|
+
String.raw`\b(?:plan|planning)[\s-]+only\b|\bonly\s+(?:create|write|make|draft)\s+(?:a\s+|the\s+)?plan`,
|
|
464
|
+
String.raw`(?:kh(?:ô|o)ng|đ(?:ừ|u)ng|dung|ch(?:ư|u)a)\s+(?:được\s+|duoc\s+|cần\s+|can\s+)?(?:ch(?:ạ|a)y|l(?:à|a)m|th(?:ự|u)c\s+hi(?:ệ|e)n|implement)`,
|
|
465
|
+
String.raw`ch(?:ỉ|i)\s+(?:t(?:ạ|a)o|vi(?:ế|e)t|l(?:ậ|a)p)\s+(?:plan|k(?:ế|e)\s+ho(?:ạ|a)ch)`,
|
|
466
|
+
].join('|'),
|
|
467
|
+
'iu',
|
|
468
|
+
);
|
|
469
|
+
const SINGLE_TASK_REF_RE = /\b(?:task|việc|viec)[\s_-]*#?\d+\b|\bTASK-\d+\b/i;
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Shared phase lookup for BOTH Stop paths (Claude `execution-ledger --evaluate-stop` and the
|
|
473
|
+
* omp bridge's in-process `runSessionStop`). Returns the current intent only when the scan
|
|
474
|
+
* is conclusive and it is one of create/review/neutral — the phases that change what the
|
|
475
|
+
* completion gate may demand. fullstack/implement, unknown and unreadable all return null,
|
|
476
|
+
* which keeps the normal (strict) gate: a lost read never relaxes enforcement.
|
|
477
|
+
*/
|
|
478
|
+
export async function resolveStopPhase(transcriptPath, opts = {}) {
|
|
479
|
+
try {
|
|
480
|
+
const { intent, known } = await transcriptHandoffIntent(transcriptPath, opts);
|
|
481
|
+
if (!known) return null;
|
|
482
|
+
return intent === 'create' || intent === 'review' || intent === 'neutral' ? intent : null;
|
|
483
|
+
} catch {
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Subagent inheritance. A delegated omp session is its own transcript (own session id, no command
|
|
489
|
+
// tag) whose first record is `{type:"session", parentSession:<parent transcript path>}`; the
|
|
490
|
+
// parent's command authority must follow the delegation. Walks at most `maxDepth` parents, each
|
|
491
|
+
// scan bounded by the normal budget; a missing/unreadable link is `known:false` (never relaxed).
|
|
492
|
+
export async function transcriptHandoffIntentInherited(transcriptPath, { maxDepth = 3, ...opts } = {}) {
|
|
493
|
+
let current = transcriptPath;
|
|
494
|
+
for (let depth = 0; depth <= maxDepth; depth += 1) {
|
|
495
|
+
const scan = await transcriptHandoffIntent(current, opts);
|
|
496
|
+
// A parent link that cannot be read is reported (never relaxed): the caller fails closed
|
|
497
|
+
// against the parent's session marker instead of treating the child as unrestricted.
|
|
498
|
+
if (!scan.known) return depth > 0 ? { ...scan, unresolvedParent: current } : scan;
|
|
499
|
+
if (scan.intent) return scan;
|
|
500
|
+
const parent = await readParentSession(current);
|
|
501
|
+
if (!parent) return scan;
|
|
502
|
+
current = parent;
|
|
503
|
+
}
|
|
504
|
+
return { intent: null, known: false, unresolvedParent: current };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function readParentSession(transcriptPath) {
|
|
508
|
+
let handle;
|
|
509
|
+
try {
|
|
510
|
+
handle = await fs.open(transcriptPath, 'r');
|
|
511
|
+
const buf = Buffer.allocUnsafe(8192);
|
|
512
|
+
const { bytesRead } = await handle.read(buf, 0, buf.length, 0);
|
|
513
|
+
for (const line of buf.toString('utf8', 0, bytesRead).split('\n').slice(0, 4)) {
|
|
514
|
+
if (!line.includes('"parentSession"')) continue;
|
|
515
|
+
try {
|
|
516
|
+
const rec = JSON.parse(line);
|
|
517
|
+
if (rec?.type === 'session' && typeof rec.parentSession === 'string' && rec.parentSession.trim()) {
|
|
518
|
+
return rec.parentSession;
|
|
519
|
+
}
|
|
520
|
+
} catch { /* header larger than the probe window: treat as no link */ }
|
|
521
|
+
}
|
|
522
|
+
return null;
|
|
523
|
+
} catch {
|
|
524
|
+
return null;
|
|
525
|
+
} finally {
|
|
526
|
+
await handle?.close().catch(() => {});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
@@ -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
|
+
}
|