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