@ngockhoale/ukit 3.4.26 → 3.4.28
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 +30 -0
- package/manifests/documentation.yaml +61 -0
- package/manifests/platform.full.yaml +24 -0
- package/package.json +7 -1
- package/scripts/audit/subagent-cost/metrics.mjs +14 -2
- package/scripts/audit/subagent-cost/reader.mjs +15 -3
- package/scripts/audit/subagent-cost/usage-fields.mjs +82 -0
- package/scripts/dev/worktree-preflight.mjs +147 -0
- package/src/cli/commands/update.js +59 -2
- package/src/core/agentRuntime/context.js +78 -0
- package/src/core/diffPlan.js +100 -102
- package/src/core/docContracts.js +3 -0
- package/src/core/observability/emit/sessionBoot.js +14 -0
- package/src/core/runtimeConfigMerge.js +125 -0
- package/src/core/updateSafety.js +174 -0
- package/src/index/contextPack.js +285 -0
- package/src/index/sensitivePath.js +21 -0
- package/src/index/taskRouting.js +3 -0
- package/template_project/.claude/commands/ukit/ci-check.md +53 -0
- package/template_project/.claude/hooks/auto-allow-bash.sh +20 -2
- package/template_project/.claude/hooks/handoff-model-guard.sh +9 -2
- package/template_project/.claude/hooks/handoff-resume.sh +25 -1
- package/template_project/.claude/hooks/record-execution.mjs +9 -0
- package/template_project/.claude/hooks/skill-router.sh +3 -0
- package/template_project/.claude/ukit/index/ci-check.mjs +665 -0
- package/template_project/.claude/ukit/index/post-edit-verify.mjs +4 -4
- package/template_project/.claude/ukit/index/pre-edit-backup.mjs +3 -3
- package/template_project/.claude/ukit/index/route-task.mjs +3 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +154 -12
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +61 -0
- package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +70 -4
- package/template_project/.claude/ukit/runtime/loop-state.mjs +131 -0
- package/template_project/.claude/ukit/runtime/observability-emit.mjs +14 -0
- package/template_project/.claude/ukit/runtime/read-advisory.mjs +92 -0
- package/template_project/.claude/ukit/runtime/safe-patch-core.mjs +71 -0
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +53 -6
- package/template_project/.claude/ukit/runtime/usage-capture.mjs +338 -0
- package/template_project/.omp/hooks/pre/ukit-bridge.js +48 -4
|
@@ -3,7 +3,7 @@ import fsSync from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { analyzeTextBuffer, analyzeTextFile, publicTextProfile } from '../runtime/text-profile.mjs';
|
|
6
|
-
import { classifySafePatchRisk, isSafePatchAdvisoryOnly, parseJsonInput, pathExists, readRuntimeSafePatchConfig,
|
|
6
|
+
import { classifySafePatchRisk, isSafePatchAdvisoryOnly, parseJsonInput, pathExists, readRuntimeSafePatchConfig, resolveEditTarget } from '../runtime/safe-patch-core.mjs';
|
|
7
7
|
|
|
8
8
|
// Hook-context self-deadline (2.4.1 orphan-leak class): the post-edit-verify hook passes
|
|
9
9
|
// UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the hook
|
|
@@ -179,10 +179,10 @@ export async function verifyPostEdit({ projectRoot = process.cwd(), payload = {}
|
|
|
179
179
|
if (config.enabled === false || config.backupEnabled === false) return { status: 'disabled' };
|
|
180
180
|
const filePath = getFilePath(payload);
|
|
181
181
|
if (!filePath) return { status: 'skipped', reason: 'no-file' };
|
|
182
|
-
const resolved =
|
|
182
|
+
const resolved = resolveEditTarget(projectRoot, filePath);
|
|
183
183
|
if (!(await pathExists(resolved.absolute))) return { status: 'skipped', reason: 'missing-after', file: resolved.relative };
|
|
184
184
|
|
|
185
|
-
const latest = await findLatestBackup(projectRoot, resolved.
|
|
185
|
+
const latest = await findLatestBackup(projectRoot, resolved.key);
|
|
186
186
|
// Consumed (or absent) baseline → there is nothing truthful to diff against.
|
|
187
187
|
// Skipping is deliberate: a fabricated delta is strictly worse than no gate.
|
|
188
188
|
if (!latest || latest.consumed) return { status: 'skipped', reason: 'no-backup', file: resolved.relative };
|
|
@@ -209,7 +209,7 @@ export async function verifyPostEdit({ projectRoot = process.cwd(), payload = {}
|
|
|
209
209
|
const status = risk.strict && (overChangedLines || overHunks) ? (handoffAdvisory ? 'advisory' : 'blocked') : 'ok';
|
|
210
210
|
const postEntry = {
|
|
211
211
|
event: 'post-edit',
|
|
212
|
-
file: resolved.
|
|
212
|
+
file: resolved.key,
|
|
213
213
|
beforeHash: beforeProfile.sha256,
|
|
214
214
|
afterHash: afterProfile.sha256,
|
|
215
215
|
timestamp: new Date().toISOString(),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { analyzeTextFile, publicTextProfile } from '../runtime/text-profile.mjs';
|
|
4
|
-
import { classifySafePatchRisk, parseJsonInput, pathExists, readRuntimeSafePatchConfig,
|
|
4
|
+
import { classifySafePatchRisk, parseJsonInput, pathExists, readRuntimeSafePatchConfig, resolveEditTarget } from '../runtime/safe-patch-core.mjs';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
|
|
7
7
|
// Hook-context self-deadline (2.4.1 orphan-leak class): the pre-edit-backup hook passes
|
|
@@ -49,7 +49,7 @@ async function backupIfNeeded({ projectRoot, payload }) {
|
|
|
49
49
|
await pruneOldBackups(projectRoot, config);
|
|
50
50
|
const filePath = getFilePath(payload);
|
|
51
51
|
if (!filePath) return { status: 'skipped', reason: 'no-file' };
|
|
52
|
-
const resolved =
|
|
52
|
+
const resolved = resolveEditTarget(projectRoot, filePath);
|
|
53
53
|
if (!(await pathExists(resolved.absolute))) return { status: 'skipped', reason: 'new-file', file: resolved.relative };
|
|
54
54
|
const profile = await analyzeTextFile(resolved.absolute);
|
|
55
55
|
const risk = classifySafePatchRisk(resolved.relative, profile, config);
|
|
@@ -68,7 +68,7 @@ async function backupIfNeeded({ projectRoot, payload }) {
|
|
|
68
68
|
// manifest order, so readers must be able to tell a pre-edit backup row
|
|
69
69
|
// from a post-edit audit row without inferring it from field presence.
|
|
70
70
|
event: 'pre-edit',
|
|
71
|
-
file: resolved.
|
|
71
|
+
file: resolved.key,
|
|
72
72
|
beforeHash: profile.sha256,
|
|
73
73
|
profile: publicTextProfile(profile),
|
|
74
74
|
tool: getToolName(payload),
|
|
@@ -4220,6 +4220,9 @@ function hasMutationOrder({ promptText = '', commandText = '' } = {}) {
|
|
|
4220
4220
|
const WAIT_TAIL = '[^.,;\\n]*?(?=\\s+(?:va|and|nhung|roi|then|later|sau\\s+do)\\s+[^.,;\\n]*?\\b(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;]|\\s*$)';
|
|
4221
4221
|
const residue = trimmed
|
|
4222
4222
|
.replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
|
|
4223
|
+
// Descriptive state, not an order: "đã/vừa [được] + verb" ("hooks vừa được cài đặt") reports
|
|
4224
|
+
// something already done. Only the verb is stripped, so a later imperative still survives.
|
|
4225
|
+
.replace(/(?<![a-z0-9_])(?:da|vua)\s+(?:duoc\s+)?(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\s+the|cap\s+nhat|viet|chay|cai|chinh|trien\s+khai|cau\s+hinh)(?![a-z0-9_])/g, ' ')
|
|
4223
4226
|
.replace(/\bwrite-evidence\b|\bverification-evidence\b|\bimpact-evidence\b|\broot-cause\b/g, ' ')
|
|
4224
4227
|
.replace(new RegExp(`\\bgiu\\s+nguyen\\b${TAIL}`, 'g'), ' ')
|
|
4225
4228
|
.replace(new RegExp(`\\bmuon\\s+giu\\b${TAIL}`, 'g'), ' ')
|
|
@@ -505,20 +505,91 @@ function isVerificationCommand(command) {
|
|
|
505
505
|
const text = String(command || '').trim();
|
|
506
506
|
if (!text) return false;
|
|
507
507
|
return [
|
|
508
|
-
/(?:^|\s)(?:vitest|jest|mocha|ava|pytest|py\.test)(?:\s|$)/i,
|
|
508
|
+
/(?:^|\s|\.bin\/)(?:vitest|jest|mocha|ava|pytest|py\.test)(?:\s|$)/i,
|
|
509
509
|
/(?:^|\s)(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+(?:test|lint|typecheck|check|build)(?:\s|$)/i,
|
|
510
|
-
/(?:^|\s)(?:
|
|
510
|
+
/(?:^|\s)(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+release:verify(?:\s|$)/i,
|
|
511
|
+
/(?:^|\s|\.bin\/)(?:tsc|eslint|biome|ruff|mypy)(?:\s|$)/i,
|
|
511
512
|
/(?:^|\s)node\s+--check(?:\s|$)/i,
|
|
512
513
|
].some((pattern) => pattern.test(text));
|
|
513
514
|
}
|
|
514
515
|
|
|
516
|
+
// Split a shell command into pipelines of stages (quote/escape aware). `&&`, `||`, `;` and
|
|
517
|
+
// newlines separate pipelines; a lone `|` (or `|&`) separates stages. Redirections such as
|
|
518
|
+
// `2>&1` / `&>` are not separators.
|
|
519
|
+
function shellPipelineStages(command) {
|
|
520
|
+
const text = String(command || '');
|
|
521
|
+
const pipelines = [];
|
|
522
|
+
let stages = [];
|
|
523
|
+
let buffer = '';
|
|
524
|
+
let quote = null;
|
|
525
|
+
let escaped = false;
|
|
526
|
+
const closeStage = () => {
|
|
527
|
+
if (buffer.trim()) stages.push(buffer.trim());
|
|
528
|
+
buffer = '';
|
|
529
|
+
};
|
|
530
|
+
const closePipeline = () => {
|
|
531
|
+
closeStage();
|
|
532
|
+
if (stages.length) pipelines.push(stages);
|
|
533
|
+
stages = [];
|
|
534
|
+
};
|
|
535
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
536
|
+
const char = text[i];
|
|
537
|
+
if (escaped) { escaped = false; buffer += char; continue; }
|
|
538
|
+
if (char === '\\') { escaped = true; buffer += char; continue; }
|
|
539
|
+
if (quote) { if (char === quote) quote = null; buffer += char; continue; }
|
|
540
|
+
if (char === "'" || char === '"') { quote = char; buffer += char; continue; }
|
|
541
|
+
if (char === '|') {
|
|
542
|
+
if (text[i + 1] === '|') { closePipeline(); i += 1; continue; }
|
|
543
|
+
closeStage();
|
|
544
|
+
if (text[i + 1] === '&') i += 1;
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
if (char === '&') {
|
|
548
|
+
if (text[i + 1] === '&') { closePipeline(); i += 1; continue; }
|
|
549
|
+
buffer += char; // `2>&1`, `&>file`, trailing background `&`
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
if (char === ';' || char === '\n') { closePipeline(); continue; }
|
|
553
|
+
buffer += char;
|
|
554
|
+
}
|
|
555
|
+
closePipeline();
|
|
556
|
+
return pipelines;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// A shell pipeline exits with its LAST stage's status, so `npx vitest run | tail` is exit 0
|
|
560
|
+
// even when vitest failed. A verification command in a non-final stage therefore has an
|
|
561
|
+
// unproven exit status — unless the command enables `pipefail` or reads `PIPESTATUS`.
|
|
562
|
+
// Returns the masked verification stage (string) or null.
|
|
563
|
+
function maskedVerificationStage(command) {
|
|
564
|
+
const text = String(command || '');
|
|
565
|
+
if (/\bpipefail\b|\bPIPESTATUS\b|\bpipestatus\b/.test(text)) return null;
|
|
566
|
+
for (const stages of shellPipelineStages(text)) {
|
|
567
|
+
for (const stage of stages.slice(0, -1)) {
|
|
568
|
+
if (isVerificationCommand(stage)) return stage;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Announce through the in-proc hook emitter only (never fd 1 from library callers).
|
|
575
|
+
function noteUnprovenExitStatus(stage) {
|
|
576
|
+
if (!ledgerMessageEmitter) return;
|
|
577
|
+
const message =
|
|
578
|
+
`UKit execution-ledger: \`${stage}\` ran in a pipeline, so the exit status belongs to the last pipe stage `
|
|
579
|
+
+ '(e.g. tail/grep), not the test runner — it is NOT counted as passing verification. '
|
|
580
|
+
+ 'Re-run with `set -o pipefail; <cmd> | tail` (or without the pipe) to prove the result.';
|
|
581
|
+
try {
|
|
582
|
+
ledgerMessageEmitter(`${JSON.stringify({ systemMessage: message })}\n`);
|
|
583
|
+
} catch { /* an emitter failure never loses the ledger event */ }
|
|
584
|
+
}
|
|
585
|
+
|
|
515
586
|
function compactReceipt(receipt) {
|
|
516
587
|
const compact = {
|
|
517
588
|
ts: receipt.ts,
|
|
518
589
|
kind: receipt.kind,
|
|
519
590
|
success: receipt.success,
|
|
520
591
|
};
|
|
521
|
-
for (const key of ['toolName', 'toolUseId', 'file', 'command', 'exitCode', 'scope', 'error', 'verdict', 'evidence', 'evidenceSurface', 'class', 'status', 'detail']) {
|
|
592
|
+
for (const key of ['toolName', 'toolUseId', 'file', 'command', 'exitCode', 'scope', 'error', 'verdict', 'evidence', 'evidenceSurface', 'class', 'status', 'detail', 'exitStatusUnproven']) {
|
|
522
593
|
if (receipt[key] !== undefined && receipt[key] !== null && receipt[key] !== '') {
|
|
523
594
|
compact[key] = receipt[key];
|
|
524
595
|
}
|
|
@@ -549,6 +620,34 @@ function gitOutput(args, cwd) {
|
|
|
549
620
|
}
|
|
550
621
|
}
|
|
551
622
|
|
|
623
|
+
// A Bash command may run in a sibling worktree of the session's repo (`cd ../repo-wt && ...`),
|
|
624
|
+
// so the session root's fingerprint never moves for work done there. Only a LEADING `cd <dir>`
|
|
625
|
+
// that lands in a checkout sharing this repo's git common dir redirects the fingerprint; an
|
|
626
|
+
// unrelated repo, a missing dir or any git failure keeps the session root (fail-closed).
|
|
627
|
+
function bashWorkspaceScope(command, projectRoot, shellCwd) {
|
|
628
|
+
const here = { root: projectRoot, foreign: false };
|
|
629
|
+
const match = String(command || '').match(/^\s*cd\s+(?:"([^"]+)"|'([^']+)'|([^\s;&|<>$`~]+))\s*(?:&&|;)/);
|
|
630
|
+
// The host reports the shell's cwd: a session that already `cd`-ed into a linked worktree
|
|
631
|
+
// runs bare commands there, with no leading `cd` to parse.
|
|
632
|
+
const cwdDir = typeof shellCwd === 'string' && path.isAbsolute(shellCwd) ? shellCwd : null;
|
|
633
|
+
if ((!match && !cwdDir) || !projectRoot) return here;
|
|
634
|
+
const real = (dir) => { try { return fsSync.realpathSync(dir); } catch { return null; } };
|
|
635
|
+
const commonDir = (dir) => {
|
|
636
|
+
const out = gitOutput(['rev-parse', '--git-common-dir'], dir);
|
|
637
|
+
return out ? real(path.resolve(dir, out)) : null;
|
|
638
|
+
};
|
|
639
|
+
const sessionCommon = commonDir(projectRoot);
|
|
640
|
+
// A session outside any git repo has no checkout family to compare against: keep the
|
|
641
|
+
// pre-existing command-text behaviour rather than inventing a foreign verdict.
|
|
642
|
+
if (!sessionCommon) return here;
|
|
643
|
+
const target = real(match ? path.resolve(cwdDir || projectRoot, match[1] || match[2] || match[3]) : cwdDir);
|
|
644
|
+
const top = target ? gitOutput(['rev-parse', '--show-toplevel'], target) : null;
|
|
645
|
+
if (!top || commonDir(target) !== sessionCommon) return { root: projectRoot, foreign: true };
|
|
646
|
+
const topReal = real(top);
|
|
647
|
+
if (!topReal || topReal === real(projectRoot)) return here;
|
|
648
|
+
return { root: topReal, foreign: false };
|
|
649
|
+
}
|
|
650
|
+
|
|
552
651
|
// Verifiable Bash-mutation evidence: a Bash command is never a write by itself. It counts
|
|
553
652
|
// only when the TRACKED git workspace fingerprint (HEAD + content hash of `git diff HEAD`)
|
|
554
653
|
// differs from the one recorded at the previous receipt. Any git failure, timeout or
|
|
@@ -640,6 +739,14 @@ export function verdictCurrent(verdict, { cwd = process.cwd() } = {}) {
|
|
|
640
739
|
if (!verdict || typeof verdict !== 'object') {
|
|
641
740
|
return { status: 'untracked', current: false, headSha: null };
|
|
642
741
|
}
|
|
742
|
+
if (verdict.workspaceRoot) {
|
|
743
|
+
// Verified inside a sibling checkout: judge currency there. A checkout that has since been
|
|
744
|
+
// removed proves nothing — stale, never fail-open.
|
|
745
|
+
if (!fsSync.existsSync(verdict.workspaceRoot)) {
|
|
746
|
+
return { status: 'stale', current: false, headSha: null };
|
|
747
|
+
}
|
|
748
|
+
cwd = verdict.workspaceRoot;
|
|
749
|
+
}
|
|
643
750
|
const currentHead = gitOutput(['rev-parse', 'HEAD'], cwd);
|
|
644
751
|
if (!currentHead) {
|
|
645
752
|
// No git repo (or git unavailable): currency cannot be proven either way —
|
|
@@ -676,9 +783,9 @@ export function verdictCurrent(verdict, { cwd = process.cwd() } = {}) {
|
|
|
676
783
|
// (a hook or subagent can attest its own kind/verifier); otherwise the kind is
|
|
677
784
|
// derived from the command shape — test runners prove `test-verified`, a run that
|
|
678
785
|
// only type-checks/lints is honestly `check-only`.
|
|
679
|
-
const TEST_RUNNER_COMMAND = /(?:^|\s)(?:vitest|jest|mocha|ava|pytest|py\.test)(?:\s|$)|(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+test(?:\s|$)/i;
|
|
786
|
+
const TEST_RUNNER_COMMAND = /(?:^|\s|\.bin\/)(?:vitest|jest|mocha|ava|pytest|py\.test)(?:\s|$)|(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+test(?:\s|$)/i;
|
|
680
787
|
|
|
681
|
-
function mintVerificationVerdict(receipt, payload, projectRoot) {
|
|
788
|
+
function mintVerificationVerdict(receipt, payload, projectRoot, sessionRoot = projectRoot) {
|
|
682
789
|
const supplied = payload?.verdict && typeof payload.verdict === 'object' ? payload.verdict : {};
|
|
683
790
|
const cwd = projectRoot || payload?.cwd || process.cwd();
|
|
684
791
|
const headSha = supplied.headSha || gitOutput(['rev-parse', 'HEAD'], cwd);
|
|
@@ -696,6 +803,9 @@ function mintVerificationVerdict(receipt, payload, projectRoot) {
|
|
|
696
803
|
}
|
|
697
804
|
return {
|
|
698
805
|
kind,
|
|
806
|
+
// The sibling checkout the verification ran in (absent for the session root): currency is
|
|
807
|
+
// judged against THAT checkout's HEAD, never the session root's.
|
|
808
|
+
...(sessionRoot && projectRoot !== sessionRoot ? { workspaceRoot: projectRoot } : {}),
|
|
699
809
|
evidence: supplied.evidence || receipt.command || null,
|
|
700
810
|
verifier: supplied.verifier || 'self',
|
|
701
811
|
headSha: headSha || null,
|
|
@@ -948,6 +1058,7 @@ function carriedEvidenceLedger(fresh, current) {
|
|
|
948
1058
|
// Workspace baseline for Bash-mutation attribution: same-prompt re-keys keep it (the router
|
|
949
1059
|
// re-keys on every tool call); a different prompt returns `fresh` above and re-seeds.
|
|
950
1060
|
workspaceFp: current.workspaceFp || fresh.workspaceFp || null,
|
|
1061
|
+
workspaceFps: { ...(current.workspaceFps || {}), ...(fresh.workspaceFps || {}) },
|
|
951
1062
|
inferredWrite: fresh.inferredWrite === true || current.inferredWrite === true,
|
|
952
1063
|
directWrite: fresh.directWrite === true || current.directWrite === true,
|
|
953
1064
|
writeAttempted: fresh.writeAttempted || current.writeAttempted === true,
|
|
@@ -1058,6 +1169,7 @@ function freshLedger(payload, routeState, harness) {
|
|
|
1058
1169
|
// TASK-005 (FR-005): OutcomeRecord dedupe ledger ({verdict, runKey, at}).
|
|
1059
1170
|
// Emitted-once for INTERRUPTED survives re-keys via carriedEvidenceLedger.
|
|
1060
1171
|
outcomes: [],
|
|
1172
|
+
startedAt: Date.now(),
|
|
1061
1173
|
updatedAt: Date.now(),
|
|
1062
1174
|
};
|
|
1063
1175
|
}
|
|
@@ -1202,7 +1314,7 @@ function newEventId() {
|
|
|
1202
1314
|
return `${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`;
|
|
1203
1315
|
}
|
|
1204
1316
|
|
|
1205
|
-
const JOURNAL_RECEIPT_FIELDS = ['ts', 'kind', 'toolName', 'toolUseId', 'success', 'exitCode', 'file', 'command', 'scope', 'workspaceFp', 'bashReadOnly'];
|
|
1317
|
+
const JOURNAL_RECEIPT_FIELDS = ['ts', 'kind', 'toolName', 'toolUseId', 'success', 'exitCode', 'file', 'command', 'scope', 'workspaceFp', 'workspaceRoot', 'workspaceHeadTs', 'bashReadOnly', 'exitStatusUnproven'];
|
|
1206
1318
|
|
|
1207
1319
|
// TASK-009 (BL-011): evidence attestations are whitelisted verbatim — class /
|
|
1208
1320
|
// surface / result / claim, the ARCH §Evidence Schema fields a receipt may
|
|
@@ -1665,9 +1777,23 @@ function applyReceiptToLedger(ledger, receipt, options = {}) {
|
|
|
1665
1777
|
const fp = typeof receipt.workspaceFp === 'string' && receipt.workspaceFp ? receipt.workspaceFp : null;
|
|
1666
1778
|
let base = ledger;
|
|
1667
1779
|
if (fp) {
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1780
|
+
// One baseline per checkout: the session root keeps `workspaceFp`; a sibling worktree the
|
|
1781
|
+
// command ran in is keyed by its path, so work in one checkout is never compared against
|
|
1782
|
+
// another checkout's fingerprint.
|
|
1783
|
+
const rootKey = typeof receipt.workspaceRoot === 'string' && receipt.workspaceRoot ? receipt.workspaceRoot : null;
|
|
1784
|
+
const prior = rootKey ? ledger?.workspaceFps?.[rootKey] || null : ledger?.workspaceFp || null;
|
|
1785
|
+
base = rootKey
|
|
1786
|
+
? { ...ledger, workspaceFps: { ...(ledger?.workspaceFps || {}), [rootKey]: fp } }
|
|
1787
|
+
: { ...ledger, workspaceFp: fp };
|
|
1788
|
+
if (rootKey) {
|
|
1789
|
+
const keys = Object.keys(base.workspaceFps);
|
|
1790
|
+
for (const key of keys.slice(0, Math.max(0, keys.length - 8))) delete base.workspaceFps[key];
|
|
1791
|
+
}
|
|
1792
|
+
// First sight of a linked worktree: no baseline to diff, so credit only a HEAD commit newer
|
|
1793
|
+
// than this session's ledger (never a historical commit, never a read-only command).
|
|
1794
|
+
const firstSeenNewCommit = Boolean(rootKey && !prior && receipt.workspaceHeadTs > 0
|
|
1795
|
+
&& ledger?.startedAt > 0 && receipt.workspaceHeadTs >= Math.floor(ledger.startedAt / 1000) * 1000); // %ct has 1 s resolution
|
|
1796
|
+
if ((firstSeenNewCommit || (prior && prior !== fp)) && receipt.kind === 'workspace' && receipt.toolName === 'Bash' && receipt.success === true && receipt.bashReadOnly !== true) {
|
|
1671
1797
|
const applied = applyReceiptCore(base, {
|
|
1672
1798
|
ts: receipt.ts,
|
|
1673
1799
|
kind: 'write',
|
|
@@ -2250,7 +2376,11 @@ export async function recordExecutionReceipt({
|
|
|
2250
2376
|
const success = !failed && (exitCode === null || exitCode === 0);
|
|
2251
2377
|
const toolInput = payload.tool_input || {};
|
|
2252
2378
|
const evidence = receiptEvidenceAttestation(payload, toolInput);
|
|
2253
|
-
const
|
|
2379
|
+
const bashScope = toolName === 'Bash'
|
|
2380
|
+
? bashWorkspaceScope(toolInput.command, projectRoot, payload.cwd)
|
|
2381
|
+
: { root: projectRoot, foreign: false };
|
|
2382
|
+
const workspaceRoot = bashScope.root;
|
|
2383
|
+
const workspaceFp = workspaceFingerprint(workspaceRoot);
|
|
2254
2384
|
const receipt = {
|
|
2255
2385
|
ts: Date.now(),
|
|
2256
2386
|
toolName,
|
|
@@ -2265,7 +2395,7 @@ export async function recordExecutionReceipt({
|
|
|
2265
2395
|
} else if (toolName === 'Edit' || toolName === 'Write') {
|
|
2266
2396
|
receipt.kind = 'write';
|
|
2267
2397
|
receipt.file = toolInput.file_path || toolInput.path || toolInput.paths?.[0] || null;
|
|
2268
|
-
} else if (toolName === 'Bash' && (isVerificationCommand(toolInput.command) || evidence)) {
|
|
2398
|
+
} else if (toolName === 'Bash' && !bashScope.foreign && (isVerificationCommand(toolInput.command) || evidence)) {
|
|
2269
2399
|
// TASK-009 (BL-011): a Bash command carrying an evidence attestation
|
|
2270
2400
|
// records as a verification receipt even when the command is not a known
|
|
2271
2401
|
// test runner — a repro command, a trace, a one-off check. The typed
|
|
@@ -2273,6 +2403,13 @@ export async function recordExecutionReceipt({
|
|
|
2273
2403
|
// alone must never mint verification evidence.
|
|
2274
2404
|
receipt.kind = 'verification';
|
|
2275
2405
|
receipt.command = String(toolInput.command || '').trim();
|
|
2406
|
+
// A pipe-masked runner status (exit 0 from `| tail`) is not evidence of a pass.
|
|
2407
|
+
const maskedStage = isVerificationCommand(toolInput.command) ? maskedVerificationStage(toolInput.command) : null;
|
|
2408
|
+
if (maskedStage && receipt.success) {
|
|
2409
|
+
receipt.success = false;
|
|
2410
|
+
receipt.exitStatusUnproven = true;
|
|
2411
|
+
noteUnprovenExitStatus(maskedStage);
|
|
2412
|
+
}
|
|
2276
2413
|
// WS-C routed-verification receipt: a command counts as "targeted" when it matches the
|
|
2277
2414
|
// routed plan (preferredOrder / primaryCommands). Off-plan verification still records
|
|
2278
2415
|
// as broad — counted only when the route carried no commands at all. The resolved
|
|
@@ -2286,7 +2423,7 @@ export async function recordExecutionReceipt({
|
|
|
2286
2423
|
// (kind/evidence/verifier/headSha/baseSha/patchId/ts). Explicit payload.verdict
|
|
2287
2424
|
// fields win so a hook or subagent verifier can attest its own kind and identity.
|
|
2288
2425
|
if (isVerificationCommand(toolInput.command)) {
|
|
2289
|
-
receipt.verdict = mintVerificationVerdict(receipt, payload, projectRoot);
|
|
2426
|
+
receipt.verdict = mintVerificationVerdict(receipt, payload, workspaceRoot, projectRoot);
|
|
2290
2427
|
}
|
|
2291
2428
|
} else if (toolName === 'Bash' && workspaceFp) {
|
|
2292
2429
|
// Any other Bash: no evidence of its own. The receipt only carries the workspace
|
|
@@ -2298,6 +2435,11 @@ export async function recordExecutionReceipt({
|
|
|
2298
2435
|
}
|
|
2299
2436
|
if (workspaceFp) {
|
|
2300
2437
|
receipt.workspaceFp = workspaceFp;
|
|
2438
|
+
if (workspaceRoot !== projectRoot) {
|
|
2439
|
+
receipt.workspaceRoot = workspaceRoot;
|
|
2440
|
+
const ct = Number(gitOutput(['log', '-1', '--format=%ct'], workspaceRoot));
|
|
2441
|
+
if (Number.isFinite(ct) && ct > 0) receipt.workspaceHeadTs = ct * 1000;
|
|
2442
|
+
}
|
|
2301
2443
|
if (toolName === 'Bash' && isReadOnlyBashCommand(toolInput.command)) receipt.bashReadOnly = true;
|
|
2302
2444
|
}
|
|
2303
2445
|
if (evidence) {
|
|
@@ -34,6 +34,9 @@
|
|
|
34
34
|
// new enum every consumer would have to learn (TASK-C97-004).
|
|
35
35
|
|
|
36
36
|
import fs from 'node:fs/promises';
|
|
37
|
+
import fsSync from 'node:fs';
|
|
38
|
+
import path from 'node:path';
|
|
39
|
+
import { spawnSync } from 'node:child_process';
|
|
37
40
|
|
|
38
41
|
// The `(?![\w-])` closer is the exact token boundary: a command name followed by
|
|
39
42
|
// MORE name characters or a hyphen (`handoff-fullstack-custom`) is a different,
|
|
@@ -349,3 +352,61 @@ export async function transcriptShowsHandoffRun(transcriptPath, opts = {}) {
|
|
|
349
352
|
if (!known) return null;
|
|
350
353
|
return intent === 'fullstack';
|
|
351
354
|
}
|
|
355
|
+
|
|
356
|
+
// ─── Worktree scoping ─────────────────────────────────────────────────────
|
|
357
|
+
function gitOut(cwd, args) {
|
|
358
|
+
try {
|
|
359
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 800, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
360
|
+
return r.error || r.status !== 0 ? null : String(r.stdout).trim();
|
|
361
|
+
} catch {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function realOrNull(p) {
|
|
367
|
+
try { return fsSync.realpathSync(p); } catch { return null; }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Which checkout's RUN.md may this Stop be held to? The host pins CLAUDE_PROJECT_DIR
|
|
372
|
+
* to the primary checkout even when the session works in a linked worktree, so the
|
|
373
|
+
* primary's cursor used to be enforced on an unrelated worktree session. `payload.cwd`
|
|
374
|
+
* is only a CANDIDATE: it is honoured solely when git itself proves it is inside a
|
|
375
|
+
* linked worktree of `projectRoot`'s own repository (same common git dir AND listed by
|
|
376
|
+
* `git worktree list`). Anything else — an unrelated repo, a plain directory, a
|
|
377
|
+
* missing path, a non-string — keeps `projectRoot`, so cwd can never be used to dodge
|
|
378
|
+
* the gate. Returns `projectRoot` unchanged for the primary checkout.
|
|
379
|
+
*/
|
|
380
|
+
export function resolveHandoffRunRoot(projectRoot, cwd) {
|
|
381
|
+
try {
|
|
382
|
+
if (typeof projectRoot !== 'string' || !projectRoot || typeof cwd !== 'string' || !cwd || !path.isAbsolute(cwd)) {
|
|
383
|
+
return projectRoot;
|
|
384
|
+
}
|
|
385
|
+
const top = gitOut(cwd, ['rev-parse', '--show-toplevel']);
|
|
386
|
+
const topReal = top && realOrNull(top);
|
|
387
|
+
const projectReal = realOrNull(projectRoot);
|
|
388
|
+
if (!topReal || !projectReal || topReal === projectReal) return projectRoot;
|
|
389
|
+
const commonOf = (dir) => {
|
|
390
|
+
const c = gitOut(dir, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
391
|
+
return c && realOrNull(c);
|
|
392
|
+
};
|
|
393
|
+
const common = commonOf(projectReal);
|
|
394
|
+
if (!common || common !== commonOf(topReal)) return projectRoot;
|
|
395
|
+
const listing = gitOut(projectReal, ['worktree', 'list', '--porcelain']);
|
|
396
|
+
if (!listing) return projectRoot;
|
|
397
|
+
const listed = listing.split('\n')
|
|
398
|
+
.filter((l) => l.startsWith('worktree '))
|
|
399
|
+
.map((l) => realOrNull(l.slice('worktree '.length)));
|
|
400
|
+
return listed.includes(topReal) ? topReal : projectRoot;
|
|
401
|
+
} catch {
|
|
402
|
+
return projectRoot;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Paused: `Paused: yes`, `Phase: paused`, or token PAUSED in Cursor/Next. Never resume/spawn; RUN.md untouched.
|
|
407
|
+
export function runCursorPaused(text) {
|
|
408
|
+
const field = (name) => (String(text || '').match(new RegExp(`^${name}:\\s*(.+)$`, 'm'))?.[1] || '').trim();
|
|
409
|
+
if (/^(?:yes|true|1)\b/i.test(field('Paused'))) return true;
|
|
410
|
+
if (/^paused\b/i.test(field('Phase'))) return true;
|
|
411
|
+
return /\bPAUSED\b/.test(field('Cursor')) || /\bPAUSED\b/.test(field('Next'));
|
|
412
|
+
}
|
|
@@ -121,6 +121,74 @@ function chainFailureKind(processResult) {
|
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
// Micro-fix #6: Claude Code parses a hook's stdout as ONE JSON document. Several chain steps
|
|
125
|
+
// (post-edit-verify, record-execution, task-watchdog on PostToolUse:Edit) each emit their own
|
|
126
|
+
// `{"systemMessage":…}` object, and plain concatenation yields two documents back to back —
|
|
127
|
+
// "Hook output looks like a JSON object but is not valid JSON". When EVERY byte of the replayed
|
|
128
|
+
// stdout is a sequence of top-level JSON objects, merge them into one (serialised with
|
|
129
|
+
// JSON.stringify, never string-spliced). Anything else — plain-text context, a lone document,
|
|
130
|
+
// unparseable text — is returned untouched, so the block/allow policy and context replay are
|
|
131
|
+
// unchanged. Merge rules: systemMessage values join with "\n"; `continue:false` and
|
|
132
|
+
// `decision:"block"` (with its reason) are sticky; any other key: the later step wins.
|
|
133
|
+
function splitTopLevelJsonObjects(text) {
|
|
134
|
+
const docs = [];
|
|
135
|
+
let i = 0;
|
|
136
|
+
const n = text.length;
|
|
137
|
+
while (i < n) {
|
|
138
|
+
while (i < n && /\s/.test(text[i])) i += 1;
|
|
139
|
+
if (i >= n) break;
|
|
140
|
+
if (text[i] !== '{') return null;
|
|
141
|
+
let depth = 0;
|
|
142
|
+
let inString = false;
|
|
143
|
+
let escaped = false;
|
|
144
|
+
let end = -1;
|
|
145
|
+
for (let j = i; j < n; j += 1) {
|
|
146
|
+
const ch = text[j];
|
|
147
|
+
if (inString) {
|
|
148
|
+
if (escaped) escaped = false;
|
|
149
|
+
else if (ch === '\\') escaped = true;
|
|
150
|
+
else if (ch === '"') inString = false;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (ch === '"') inString = true;
|
|
154
|
+
else if (ch === '{') depth += 1;
|
|
155
|
+
else if (ch === '}') {
|
|
156
|
+
depth -= 1;
|
|
157
|
+
if (depth === 0) { end = j; break; }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (end < 0) return null;
|
|
161
|
+
let parsed;
|
|
162
|
+
try { parsed = JSON.parse(text.slice(i, end + 1)); } catch { return null; }
|
|
163
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
164
|
+
docs.push(parsed);
|
|
165
|
+
i = end + 1;
|
|
166
|
+
}
|
|
167
|
+
return docs;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function coalesceJsonStdout(text) {
|
|
171
|
+
if (typeof text !== 'string' || text.length === 0) return text;
|
|
172
|
+
const docs = splitTopLevelJsonObjects(text);
|
|
173
|
+
if (!docs || docs.length < 2) return text;
|
|
174
|
+
const merged = {};
|
|
175
|
+
const messages = [];
|
|
176
|
+
for (const d of docs) {
|
|
177
|
+
for (const [key, value] of Object.entries(d)) {
|
|
178
|
+
if (key === 'systemMessage') {
|
|
179
|
+
if (typeof value === 'string' && value) messages.push(value);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (key === 'continue' && merged.continue === false) continue;
|
|
183
|
+
if (key === 'decision' && merged.decision === 'block') continue;
|
|
184
|
+
if (key === 'reason' && merged.decision === 'block' && d.decision !== 'block') continue;
|
|
185
|
+
merged[key] = value;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (messages.length) merged.systemMessage = messages.join('\n');
|
|
189
|
+
return `${JSON.stringify(merged)}\n`;
|
|
190
|
+
}
|
|
191
|
+
|
|
124
192
|
// B1 (TASK-001, SPEC §FR-001): a child that emits a `hookSpecificOutput`
|
|
125
193
|
// permission decision owns the verdict. `hookSpecificOutput` alone is NOT
|
|
126
194
|
// enough — a child killed mid-write can carry the marker in a truncated
|
|
@@ -792,14 +860,12 @@ try {
|
|
|
792
860
|
process.stderr.write(gateMsg + breakerAdvice(gateMsg));
|
|
793
861
|
process.exitCode = 2;
|
|
794
862
|
} else if (last.code === 2) {
|
|
795
|
-
|
|
796
|
-
if (last.stdout) process.stdout.write(last.stdout);
|
|
863
|
+
process.stdout.write(coalesceJsonStdout(`${contextStdout}${last.stdout || ''}`));
|
|
797
864
|
if (last.stderr) process.stderr.write(last.stderr + breakerAdvice(last.stderr));
|
|
798
865
|
else process.stderr.write(breakerAdvice(`${last.scriptName} blocked (exit 2) with no reason`));
|
|
799
866
|
process.exitCode = 2;
|
|
800
867
|
} else {
|
|
801
|
-
|
|
802
|
-
if (last.stdout) process.stdout.write(last.stdout);
|
|
868
|
+
process.stdout.write(coalesceJsonStdout(`${contextStdout}${last.stdout || ''}`));
|
|
803
869
|
// A spawn-unavailable `last` carries only the warning already written above.
|
|
804
870
|
if (last.stderr && last.failureKind !== 'spawn-unavailable') process.stderr.write(last.stderr);
|
|
805
871
|
process.exitCode = last.code === 0 ? 0 : (lastIsFailClosed ? 2 : 0);
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Deterministic local loop detection for Stop blocks (no LLM/network). Release is not approval.
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { withAsyncLock } from './async-lock.mjs';
|
|
6
|
+
|
|
7
|
+
const DEFAULTS = { enabled: true, maxNoProgressBlocks: 3 };
|
|
8
|
+
const MAX_SESSIONS = 20;
|
|
9
|
+
const MAX_CAUSES = 20;
|
|
10
|
+
const cache = (root, name) => path.join(root, '.ukit', 'storage', 'cache', name);
|
|
11
|
+
const sha = (t) => crypto.createHash('sha1').update(String(t)).digest('hex').slice(0, 16);
|
|
12
|
+
const readJson = async (f, d) => { try { const v = JSON.parse(await fs.readFile(f, 'utf8')); return v && typeof v === 'object' ? v : d; } catch { return d; } };
|
|
13
|
+
async function writeJson(f, v) {
|
|
14
|
+
await fs.mkdir(path.dirname(f), { recursive: true });
|
|
15
|
+
const tmp = `${f}.${process.pid}.${Date.now()}.tmp`;
|
|
16
|
+
await fs.writeFile(tmp, `${JSON.stringify(v)}\n`, 'utf8');
|
|
17
|
+
await fs.rename(tmp, f);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function reasonCodeFor(owner, reason) {
|
|
21
|
+
const norm = String(reason || '').split('\n', 1)[0]
|
|
22
|
+
.replace(/'[^']*'|"[^"]*"/g, '<Q>').replace(/(?:[A-Za-z]:)?[\\/][\w.\-@\\/]+/g, '<P>')
|
|
23
|
+
.replace(/\d+/g, 'N').replace(/\s+/g, ' ').trim().slice(0, 160);
|
|
24
|
+
return `${owner || 'unknown'}:${sha(norm)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function loadLoopConfig(root) {
|
|
28
|
+
const c = (await readJson(path.join(root, '.ukit', 'storage', 'config.json'), {})).loopBreaker || {};
|
|
29
|
+
const num = (v, d) => (Number.isFinite(v) ? Math.max(1, v) : d);
|
|
30
|
+
return {
|
|
31
|
+
enabled: typeof c.enabled === 'boolean' ? c.enabled : DEFAULTS.enabled,
|
|
32
|
+
maxNoProgressBlocks: Math.floor(num(c.maxNoProgressBlocks, DEFAULTS.maxNoProgressBlocks)),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Task-relevant evidence only: RUN.md cursor, banked ledger evidence, trusted prompt.
|
|
37
|
+
export async function progressFingerprint(root, runRoot = root, payload = {}) {
|
|
38
|
+
let cur = 'no-run';
|
|
39
|
+
try {
|
|
40
|
+
const t = await fs.readFile(path.join(runRoot, 'docs', 'AI_HANDOFF', 'RUN.md'), 'utf8');
|
|
41
|
+
cur = ['Phase', 'Cursor', 'Next'].map((n) => t.match(new RegExp(`^${n}:\\s*(.+)$`, 'm'))?.[1] || '').join('|');
|
|
42
|
+
} catch {}
|
|
43
|
+
const id = payload.session_id ? String(payload.session_id).trim().replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96)
|
|
44
|
+
: payload.transcript_path ? `transcript-${crypto.createHash('sha256').update(String(payload.transcript_path)).digest('hex').slice(0, 20)}` : 'default';
|
|
45
|
+
const led = await readJson(cache(root, path.join('exec-ledger', `${id || 'default'}.json`)), {});
|
|
46
|
+
const bank = (f) => Object.keys(led[f] && typeof led[f] === 'object' ? led[f] : {}).sort();
|
|
47
|
+
const rc = (await readJson(path.join(root, '.claude', 'ukit', 'skill-router-state.json'), {})).routingContext || {};
|
|
48
|
+
return sha(JSON.stringify([cur, bank('bankedWrites'), bank('bankedVerifications'), rc.lastExplicitUserPromptText || '', rc.mutationOrder, rc.planOnly]));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Mode: router-stamped routingContext only.
|
|
52
|
+
export async function trustedTaskMode(root) {
|
|
53
|
+
const rc = (await readJson(path.join(root, '.claude', 'ukit', 'skill-router-state.json'), {})).routingContext || {};
|
|
54
|
+
if (rc.planOnly === true) return 'REPORT_ONLY';
|
|
55
|
+
if (rc.mutationOrder === false) return 'READ_ONLY';
|
|
56
|
+
return rc.mutationOrder === true ? 'IMPLEMENTATION' : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function recordBlock({ projectRoot, sessionId, hookEvent = 'Stop', reasonCode, cause = reasonCode, fingerprint, taskExecutionMode = null, stopHookActive = false, now = Date.now(), cfg, lockBudgetMs = 600 }) {
|
|
60
|
+
if (!projectRoot || !sessionId || !reasonCode) return null;
|
|
61
|
+
const config = cfg || await loadLoopConfig(projectRoot);
|
|
62
|
+
const stateFile = cache(projectRoot, 'loop-state.json');
|
|
63
|
+
const run = async () => {
|
|
64
|
+
const state = await readJson(stateFile, {});
|
|
65
|
+
const sessions = state.sessions && typeof state.sessions === 'object' ? state.sessions : {};
|
|
66
|
+
const sid = String(sessionId).slice(0, 80);
|
|
67
|
+
const causes = sessions[sid] || (sessions[sid] = {});
|
|
68
|
+
const key = `${hookEvent}|${cause}`;
|
|
69
|
+
const prev = causes[key];
|
|
70
|
+
const repeated = Boolean(prev);
|
|
71
|
+
const progressChanged = repeated && prev.progressFingerprint !== fingerprint;
|
|
72
|
+
const stale = repeated && !progressChanged;
|
|
73
|
+
// Terminal records persist (LRU-bounded) until task-relevant progress.
|
|
74
|
+
const term = stale && prev.terminal === true;
|
|
75
|
+
const rec = {
|
|
76
|
+
sessionId: sid, hookEvent, reasonCode, progressFingerprint: fingerprint,
|
|
77
|
+
attemptCount: stale ? prev.attemptCount + (term ? 0 : 1) : 1,
|
|
78
|
+
firstBlockedAt: stale ? prev.firstBlockedAt : now, lastBlockedAt: now,
|
|
79
|
+
progressChanged, taskExecutionMode: taskExecutionMode || 'UNKNOWN',
|
|
80
|
+
};
|
|
81
|
+
const quick = stale && (stopHookActive === true || taskExecutionMode === 'READ_ONLY' || taskExecutionMode === 'REPORT_ONLY');
|
|
82
|
+
const limitReached = term || rec.attemptCount > config.maxNoProgressBlocks || quick;
|
|
83
|
+
if (limitReached) rec.terminal = true;
|
|
84
|
+
causes[key] = rec;
|
|
85
|
+
// Retention: never evict the current record; terminal records go last. Beyond 20 terminal
|
|
86
|
+
// sessions/causes the oldest terminal one is dropped and gets one fresh bounded budget.
|
|
87
|
+
const keep = (o, cur, max) => Object.keys(o).sort((a, b) => (b === cur) - (a === cur) || rank(o[b]) - rank(o[a])).slice(max);
|
|
88
|
+
const rank = (v) => (v.terminal || Object.values(v).some((x) => x.terminal) ? 1e15 : 0) + (v.lastBlockedAt || Math.max(0, ...Object.values(v).map((x) => x.lastBlockedAt || 0)));
|
|
89
|
+
for (const id of keep(sessions, sid, MAX_SESSIONS)) delete sessions[id];
|
|
90
|
+
for (const k of keep(causes, key, MAX_CAUSES)) delete causes[k];
|
|
91
|
+
await writeJson(stateFile, { sessions });
|
|
92
|
+
const mf = cache(projectRoot, 'loop-metrics.json');
|
|
93
|
+
const m = await readJson(mf, {});
|
|
94
|
+
const inc = (k) => { m[k] = (m[k] || 0) + 1; };
|
|
95
|
+
inc('stop_block_count');
|
|
96
|
+
if (repeated) inc('repeated_block_count');
|
|
97
|
+
if (stale) inc('retry_without_progress');
|
|
98
|
+
if (limitReached && !term) {
|
|
99
|
+
inc('loop_break_count');
|
|
100
|
+
m.time_to_safe_stop_ms_last = now - rec.firstBlockedAt;
|
|
101
|
+
}
|
|
102
|
+
await writeJson(mf, m);
|
|
103
|
+
return { attemptCount: rec.attemptCount, limitReached, firstBlockedAt: rec.firstBlockedAt };
|
|
104
|
+
};
|
|
105
|
+
try {
|
|
106
|
+
const out = await withAsyncLock(stateFile, { deadlineMs: lockBudgetMs }, run);
|
|
107
|
+
if (out.ok) return out.value;
|
|
108
|
+
} catch {}
|
|
109
|
+
try { return await run(); } catch { return null; }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function applyLoopBreak({ merged, projectRoot, runRoot, payload, sessionKey, now, lockBudgetMs }) {
|
|
113
|
+
if (merged.decision !== 'block' || merged.policyOwner === 'fail-closed') return merged;
|
|
114
|
+
const cfg = await loadLoopConfig(projectRoot);
|
|
115
|
+
if (!cfg.enabled) return merged;
|
|
116
|
+
const mode = await trustedTaskMode(projectRoot);
|
|
117
|
+
const res = await recordBlock({
|
|
118
|
+
projectRoot, sessionId: sessionKey, reasonCode: reasonCodeFor(merged.policyOwner, merged.reason), cause: merged.policyOwner || 'unknown',
|
|
119
|
+
fingerprint: await progressFingerprint(projectRoot, runRoot, payload), taskExecutionMode: mode,
|
|
120
|
+
stopHookActive: payload?.stop_hook_active === true, now, cfg, lockBudgetMs,
|
|
121
|
+
});
|
|
122
|
+
if (!res?.limitReached) return merged;
|
|
123
|
+
const minutes = Math.max(0, Math.round((now - res.firstBlockedAt) / 60_000));
|
|
124
|
+
const note = [
|
|
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
|
+
`Unresolved blocker: ${String(merged.reason || '').split('\n', 1)[0].slice(0, 200)}`,
|
|
127
|
+
'Stop released. This is NOT approval: nothing was verified and no safety/permission denial is lifted.',
|
|
128
|
+
'Report it verbatim; do not retry, wake up, or resume any handoff.',
|
|
129
|
+
].join('\n');
|
|
130
|
+
return { ...merged, decision: null, reason: undefined, loopBreak: true, policyOwner: 'loop-breaker', systemMessage: merged.systemMessage ? `${note}\n${merged.systemMessage}` : note };
|
|
131
|
+
}
|