@ngockhoale/ukit 3.4.27 → 3.4.29
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 +25 -0
- package/manifests/documentation.yaml +59 -0
- package/manifests/platform.full.yaml +24 -0
- package/package.json +6 -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/src/cli/commands/update.js +59 -2
- package/src/core/agentRuntime/context.js +78 -0
- package/src/core/buildPlan.js +11 -1
- package/src/core/diffPlan.js +100 -102
- package/src/core/observability/emit/sessionBoot.js +14 -0
- package/src/core/runtimeConfigMerge.js +125 -0
- package/src/core/status.js +3 -1
- 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/handoff-model-guard.sh +9 -2
- package/template_project/.claude/hooks/handoff-resume.sh +13 -0
- 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/route-task.mjs +3 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +26 -12
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +8 -0
- 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/stop-coordinator.mjs +33 -1
- package/template_project/.claude/ukit/runtime/usage-capture.mjs +338 -0
- package/template_project/.omp/hooks/pre/ukit-bridge.js +48 -4
|
@@ -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'), ' ')
|
|
@@ -501,15 +501,17 @@ function extractExitCode(payload = {}) {
|
|
|
501
501
|
return null;
|
|
502
502
|
}
|
|
503
503
|
|
|
504
|
+
// The trailing boundary accepts shell terminators (`;`, `&`, `|`, `)`) so
|
|
505
|
+
// `yarn release:verify; echo "exit=$?"` — the real-exit-code idiom — still mints a receipt.
|
|
504
506
|
function isVerificationCommand(command) {
|
|
505
507
|
const text = String(command || '').trim();
|
|
506
508
|
if (!text) return false;
|
|
507
509
|
return [
|
|
508
|
-
/(?:^|\s|\.bin\/)(?:vitest|jest|mocha|ava|pytest|py\.test)(?:\s
|
|
509
|
-
/(?:^|\s)(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+(?:test|lint|typecheck|check|build)(?:\s
|
|
510
|
-
/(?:^|\s)(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+release:verify(?:\s
|
|
511
|
-
/(?:^|\s|\.bin\/)(?:tsc|eslint|biome|ruff|mypy)(?:\s
|
|
512
|
-
/(?:^|\s)node\s+--check(?:\s
|
|
510
|
+
/(?:^|\s|\.bin\/)(?:vitest|jest|mocha|ava|pytest|py\.test)(?:\s|$|[;&|)])/i,
|
|
511
|
+
/(?:^|\s)(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+(?:test|lint|typecheck|check|build)(?:\s|$|[;&|)])/i,
|
|
512
|
+
/(?:^|\s)(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+release:verify(?:\s|$|[;&|)])/i,
|
|
513
|
+
/(?:^|\s|\.bin\/)(?:tsc|eslint|biome|ruff|mypy)(?:\s|$|[;&|)])/i,
|
|
514
|
+
/(?:^|\s)node\s+--check(?:\s|$|[;&|)])/i,
|
|
513
515
|
].some((pattern) => pattern.test(text));
|
|
514
516
|
}
|
|
515
517
|
|
|
@@ -624,10 +626,13 @@ function gitOutput(args, cwd) {
|
|
|
624
626
|
// so the session root's fingerprint never moves for work done there. Only a LEADING `cd <dir>`
|
|
625
627
|
// that lands in a checkout sharing this repo's git common dir redirects the fingerprint; an
|
|
626
628
|
// unrelated repo, a missing dir or any git failure keeps the session root (fail-closed).
|
|
627
|
-
function bashWorkspaceScope(command, projectRoot) {
|
|
629
|
+
function bashWorkspaceScope(command, projectRoot, shellCwd) {
|
|
628
630
|
const here = { root: projectRoot, foreign: false };
|
|
629
631
|
const match = String(command || '').match(/^\s*cd\s+(?:"([^"]+)"|'([^']+)'|([^\s;&|<>$`~]+))\s*(?:&&|;)/);
|
|
630
|
-
|
|
632
|
+
// The host reports the shell's cwd: a session that already `cd`-ed into a linked worktree
|
|
633
|
+
// runs bare commands there, with no leading `cd` to parse.
|
|
634
|
+
const cwdDir = typeof shellCwd === 'string' && path.isAbsolute(shellCwd) ? shellCwd : null;
|
|
635
|
+
if ((!match && !cwdDir) || !projectRoot) return here;
|
|
631
636
|
const real = (dir) => { try { return fsSync.realpathSync(dir); } catch { return null; } };
|
|
632
637
|
const commonDir = (dir) => {
|
|
633
638
|
const out = gitOutput(['rev-parse', '--git-common-dir'], dir);
|
|
@@ -637,7 +642,7 @@ function bashWorkspaceScope(command, projectRoot) {
|
|
|
637
642
|
// A session outside any git repo has no checkout family to compare against: keep the
|
|
638
643
|
// pre-existing command-text behaviour rather than inventing a foreign verdict.
|
|
639
644
|
if (!sessionCommon) return here;
|
|
640
|
-
const target = real(path.resolve(projectRoot, match[1] || match[2] || match[3]));
|
|
645
|
+
const target = real(match ? path.resolve(cwdDir || projectRoot, match[1] || match[2] || match[3]) : cwdDir);
|
|
641
646
|
const top = target ? gitOutput(['rev-parse', '--show-toplevel'], target) : null;
|
|
642
647
|
if (!top || commonDir(target) !== sessionCommon) return { root: projectRoot, foreign: true };
|
|
643
648
|
const topReal = real(top);
|
|
@@ -1166,6 +1171,7 @@ function freshLedger(payload, routeState, harness) {
|
|
|
1166
1171
|
// TASK-005 (FR-005): OutcomeRecord dedupe ledger ({verdict, runKey, at}).
|
|
1167
1172
|
// Emitted-once for INTERRUPTED survives re-keys via carriedEvidenceLedger.
|
|
1168
1173
|
outcomes: [],
|
|
1174
|
+
startedAt: Date.now(),
|
|
1169
1175
|
updatedAt: Date.now(),
|
|
1170
1176
|
};
|
|
1171
1177
|
}
|
|
@@ -1310,7 +1316,7 @@ function newEventId() {
|
|
|
1310
1316
|
return `${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`;
|
|
1311
1317
|
}
|
|
1312
1318
|
|
|
1313
|
-
const JOURNAL_RECEIPT_FIELDS = ['ts', 'kind', 'toolName', 'toolUseId', 'success', 'exitCode', 'file', 'command', 'scope', 'workspaceFp', 'workspaceRoot', 'bashReadOnly', 'exitStatusUnproven'];
|
|
1319
|
+
const JOURNAL_RECEIPT_FIELDS = ['ts', 'kind', 'toolName', 'toolUseId', 'success', 'exitCode', 'file', 'command', 'scope', 'workspaceFp', 'workspaceRoot', 'workspaceHeadTs', 'bashReadOnly', 'exitStatusUnproven'];
|
|
1314
1320
|
|
|
1315
1321
|
// TASK-009 (BL-011): evidence attestations are whitelisted verbatim — class /
|
|
1316
1322
|
// surface / result / claim, the ARCH §Evidence Schema fields a receipt may
|
|
@@ -1785,7 +1791,11 @@ function applyReceiptToLedger(ledger, receipt, options = {}) {
|
|
|
1785
1791
|
const keys = Object.keys(base.workspaceFps);
|
|
1786
1792
|
for (const key of keys.slice(0, Math.max(0, keys.length - 8))) delete base.workspaceFps[key];
|
|
1787
1793
|
}
|
|
1788
|
-
|
|
1794
|
+
// First sight of a linked worktree: no baseline to diff, so credit only a HEAD commit newer
|
|
1795
|
+
// than this session's ledger (never a historical commit, never a read-only command).
|
|
1796
|
+
const firstSeenNewCommit = Boolean(rootKey && !prior && receipt.workspaceHeadTs > 0
|
|
1797
|
+
&& ledger?.startedAt > 0 && receipt.workspaceHeadTs >= Math.floor(ledger.startedAt / 1000) * 1000); // %ct has 1 s resolution
|
|
1798
|
+
if ((firstSeenNewCommit || (prior && prior !== fp)) && receipt.kind === 'workspace' && receipt.toolName === 'Bash' && receipt.success === true && receipt.bashReadOnly !== true) {
|
|
1789
1799
|
const applied = applyReceiptCore(base, {
|
|
1790
1800
|
ts: receipt.ts,
|
|
1791
1801
|
kind: 'write',
|
|
@@ -2369,7 +2379,7 @@ export async function recordExecutionReceipt({
|
|
|
2369
2379
|
const toolInput = payload.tool_input || {};
|
|
2370
2380
|
const evidence = receiptEvidenceAttestation(payload, toolInput);
|
|
2371
2381
|
const bashScope = toolName === 'Bash'
|
|
2372
|
-
? bashWorkspaceScope(toolInput.command, projectRoot)
|
|
2382
|
+
? bashWorkspaceScope(toolInput.command, projectRoot, payload.cwd)
|
|
2373
2383
|
: { root: projectRoot, foreign: false };
|
|
2374
2384
|
const workspaceRoot = bashScope.root;
|
|
2375
2385
|
const workspaceFp = workspaceFingerprint(workspaceRoot);
|
|
@@ -2427,7 +2437,11 @@ export async function recordExecutionReceipt({
|
|
|
2427
2437
|
}
|
|
2428
2438
|
if (workspaceFp) {
|
|
2429
2439
|
receipt.workspaceFp = workspaceFp;
|
|
2430
|
-
if (workspaceRoot !== projectRoot)
|
|
2440
|
+
if (workspaceRoot !== projectRoot) {
|
|
2441
|
+
receipt.workspaceRoot = workspaceRoot;
|
|
2442
|
+
const ct = Number(gitOutput(['log', '-1', '--format=%ct'], workspaceRoot));
|
|
2443
|
+
if (Number.isFinite(ct) && ct > 0) receipt.workspaceHeadTs = ct * 1000;
|
|
2444
|
+
}
|
|
2431
2445
|
if (toolName === 'Bash' && isReadOnlyBashCommand(toolInput.command)) receipt.bashReadOnly = true;
|
|
2432
2446
|
}
|
|
2433
2447
|
if (evidence) {
|
|
@@ -402,3 +402,11 @@ export function resolveHandoffRunRoot(projectRoot, cwd) {
|
|
|
402
402
|
return projectRoot;
|
|
403
403
|
}
|
|
404
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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1088,6 +1088,15 @@ function outcomeCost(value) {
|
|
|
1088
1088
|
};
|
|
1089
1089
|
}
|
|
1090
1090
|
|
|
1091
|
+
// A3 (C100 O8): agent/model/provider linkage. Nullable and additive: a value the hook payload
|
|
1092
|
+
// or emitter did not supply stays null (UNKNOWN) — never a guess, never 0, never inferred from a model name.
|
|
1093
|
+
function linkageField(...candidates) {
|
|
1094
|
+
for (const value of candidates) {
|
|
1095
|
+
if (typeof value === 'string' && value.trim().length > 0) return value.trim().slice(0, 96);
|
|
1096
|
+
}
|
|
1097
|
+
return null;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1091
1100
|
/**
|
|
1092
1101
|
* buildOutcomeObservedRecord({ outcome, projectRoot, payload, sessionId,
|
|
1093
1102
|
* engine, env }) → semantic-record candidate for `outcome.observed`.
|
|
@@ -1135,6 +1144,11 @@ export function buildOutcomeObservedRecord({
|
|
|
1135
1144
|
implementer: isNonEmptyString(safe.implementer) ? safe.implementer.slice(0, 96) : null,
|
|
1136
1145
|
reviewer: isNonEmptyString(safe.reviewer) ? safe.reviewer.slice(0, 96) : null,
|
|
1137
1146
|
reviewVerdict: isNonEmptyString(safe.reviewVerdict) ? safe.reviewVerdict.slice(0, 96) : null,
|
|
1147
|
+
agentId: linkageField(safe.agentId, payload?.agent_id),
|
|
1148
|
+
parentAgentId: linkageField(safe.parentAgentId, payload?.parent_agent_id),
|
|
1149
|
+
agentType: linkageField(safe.agentType, payload?.agent_type),
|
|
1150
|
+
model: linkageField(safe.model, payload?.model),
|
|
1151
|
+
provider: linkageField(safe.provider),
|
|
1138
1152
|
enforcement: OUTCOME_ENFORCEMENTS.has(safe.enforcement)
|
|
1139
1153
|
? safe.enforcement
|
|
1140
1154
|
: deriveEnforcement({ engine: engineName, env }),
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// read-advisory.mjs — C100 O4: advisory note when the SAME unchanged file region is read again.
|
|
2
|
+
//
|
|
3
|
+
// Evidence (docs/analysis/c100): implementer sessions re-read one file up to 31 times; every
|
|
4
|
+
// repeat enlarges the context that is re-billed on all later calls. This module only REMINDS —
|
|
5
|
+
// it never blocks, never alters tool output, never touches the verdict.
|
|
6
|
+
//
|
|
7
|
+
// A read counts as a repeat only when path, mtimeMs, size AND offset/limit all match the previous
|
|
8
|
+
// read in the same (session, agent). Any Edit/Write by the agent changes mtime/size, a different
|
|
9
|
+
// region has a different offset/limit, so a legitimate re-read is never flagged.
|
|
10
|
+
//
|
|
11
|
+
// Contract: advisory end-to-end (every failure returns ''), bounded state, metadata only (no file
|
|
12
|
+
// content, no raw paths on disk: paths are sha1-hashed), kill switch UKIT_READ_ADVISORY=0.
|
|
13
|
+
//
|
|
14
|
+
// KNOWN LIMITS (A5 review): (1) emitted as `systemMessage`; on Claude Code that is documented as
|
|
15
|
+
// user-visible, model visibility is UNVERIFIED (the model-visible channel is
|
|
16
|
+
// hookSpecificOutput.additionalContext, but the chain runner treats any hookSpecificOutput as a
|
|
17
|
+
// permission-verdict owner, so it is not used here). omp surfaces hook output in the tool result;
|
|
18
|
+
// Codex is not wired. (2) One fingerprint per path: alternating regions A,B,A resets the count.
|
|
19
|
+
// (3) Per-session state files are not pruned.
|
|
20
|
+
|
|
21
|
+
import crypto from 'node:crypto';
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
|
|
25
|
+
const MAX_ENTRIES = 200;
|
|
26
|
+
const MAX_STATE_BYTES = 64 * 1024;
|
|
27
|
+
|
|
28
|
+
function safeName(value) {
|
|
29
|
+
return String(value || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96) || 'unknown';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function hash12(text) {
|
|
33
|
+
return crypto.createHash('sha1').update(text).digest('hex').slice(0, 12);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function intOrNull(value) {
|
|
37
|
+
return Number.isInteger(value) && value >= 0 ? value : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Emit on the 2nd identical read, then at 4, 8, 16, ... so a loop cannot flood the channel.
|
|
41
|
+
function shouldNotify(count) {
|
|
42
|
+
return count >= 2 && (count & (count - 1)) === 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function readAdvisoryMessage({ payload, projectRoot, env = process.env } = {}) {
|
|
46
|
+
try {
|
|
47
|
+
if (env.UKIT_READ_ADVISORY === '0') return '';
|
|
48
|
+
if (!payload || payload.tool_name !== 'Read') return '';
|
|
49
|
+
const input = payload.tool_input;
|
|
50
|
+
const rawPath = input && typeof input.file_path === 'string' ? input.file_path : '';
|
|
51
|
+
if (!rawPath || !projectRoot) return '';
|
|
52
|
+
const response = payload.tool_response;
|
|
53
|
+
if (response && (response.isError === true || response.is_error === true)) return '';
|
|
54
|
+
|
|
55
|
+
const abs = path.resolve(projectRoot, rawPath);
|
|
56
|
+
const st = fs.statSync(abs);
|
|
57
|
+
if (!st.isFile()) return '';
|
|
58
|
+
|
|
59
|
+
const fingerprint = [st.mtimeMs, st.size, intOrNull(input.offset), intOrNull(input.limit), input.pages ?? null].join(':');
|
|
60
|
+
const key = hash12(abs);
|
|
61
|
+
const scope = `${safeName(payload.session_id)}.${safeName(payload.agent_id || 'main')}`;
|
|
62
|
+
const dir = path.join(projectRoot, '.ukit', 'storage', 'cache', 'read-advisory');
|
|
63
|
+
const file = path.join(dir, `${scope}.json`);
|
|
64
|
+
|
|
65
|
+
let state = {};
|
|
66
|
+
try {
|
|
67
|
+
if (fs.statSync(file).size <= MAX_STATE_BYTES) state = JSON.parse(fs.readFileSync(file, 'utf8')) || {};
|
|
68
|
+
} catch { state = {}; }
|
|
69
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) state = {};
|
|
70
|
+
|
|
71
|
+
const prev = state[key];
|
|
72
|
+
const count = prev && prev.fp === fingerprint ? prev.count + 1 : 1;
|
|
73
|
+
state[key] = { fp: fingerprint, count, at: Date.now() };
|
|
74
|
+
|
|
75
|
+
const keys = Object.keys(state);
|
|
76
|
+
if (keys.length > MAX_ENTRIES) {
|
|
77
|
+
keys.sort((a, b) => (state[a].at || 0) - (state[b].at || 0));
|
|
78
|
+
for (const k of keys.slice(0, keys.length - MAX_ENTRIES)) delete state[k];
|
|
79
|
+
}
|
|
80
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
81
|
+
// temp + rename: parallel Reads must never leave a torn state file.
|
|
82
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
83
|
+
fs.writeFileSync(tmp, JSON.stringify(state));
|
|
84
|
+
fs.renameSync(tmp, file);
|
|
85
|
+
|
|
86
|
+
if (!shouldNotify(count)) return '';
|
|
87
|
+
const name = path.basename(abs);
|
|
88
|
+
return `UKit read-advisory: ${name} was read ${count}x unchanged (same region) in this session — reuse the earlier read instead of re-reading.`;
|
|
89
|
+
} catch {
|
|
90
|
+
return '';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -1026,6 +1026,17 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
|
|
|
1026
1026
|
return { kind: 'none' };
|
|
1027
1027
|
}
|
|
1028
1028
|
|
|
1029
|
+
// PAUSED run: never force continuation.
|
|
1030
|
+
const intentMod = await loadHandoffIntentModule();
|
|
1031
|
+
if (intentMod?.runCursorPaused?.(text) === true) {
|
|
1032
|
+
return {
|
|
1033
|
+
kind: 'advisory',
|
|
1034
|
+
waitForApproval: true,
|
|
1035
|
+
systemMessage: '[ukit-stop-coordinator] handoff run is PAUSED: stop allowed, nothing is resumed or forced. '
|
|
1036
|
+
+ 'Only an explicit user instruction (/ukit:handoff-fullstack or "continue") lifts the pause.',
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1029
1040
|
const signature = `${phase}|${cursor}|${next}`;
|
|
1030
1041
|
const streak = await bumpHandoffStallCount({ projectRoot: runRoot, signature, lockBudgetMs });
|
|
1031
1042
|
// Reentrancy valve (stop_hook_active): Claude Code sets it when this Stop fires
|
|
@@ -1301,7 +1312,20 @@ export async function runStopCoordinator({
|
|
|
1301
1312
|
process.stderr.write(`[ukit-stop-coordinator] handoff-cursor evaluator failed: ${failures.handoff}\n`);
|
|
1302
1313
|
}
|
|
1303
1314
|
|
|
1304
|
-
|
|
1315
|
+
let merged = coordinateStopDecisions({ completion, watchdog, handoff, failures });
|
|
1316
|
+
|
|
1317
|
+
// Bounded loop detection (fail-closed infra block keeps its own cap).
|
|
1318
|
+
try {
|
|
1319
|
+
const loop = await import('./loop-state.mjs');
|
|
1320
|
+
merged = await loop.applyLoopBreak({ merged, projectRoot, runRoot: await resolveRunRoot(projectRoot, payload?.cwd), payload, sessionKey, now, lockBudgetMs });
|
|
1321
|
+
} catch { /* detector failure keeps the original decision */ }
|
|
1322
|
+
|
|
1323
|
+
// Termination contract (label only; no new hook, no LLM): every coordinated stop ends in one
|
|
1324
|
+
// outcome. A loop-breaker release is BLOCKED_AND_STOP, never approval; a PAUSED run waits.
|
|
1325
|
+
// Advisory label only (nothing consumes it); a gate block approves nothing.
|
|
1326
|
+
merged.terminalOutcome = merged.decision === 'block' ? 'CONTINUE_REQUIRED'
|
|
1327
|
+
: merged.loopBreak === true ? 'BLOCKED_AND_STOP'
|
|
1328
|
+
: handoff?.waitForApproval === true ? 'WAIT_FOR_APPROVAL' : 'COMPLETE_AND_STOP';
|
|
1305
1329
|
|
|
1306
1330
|
// C85 TASK-015 (BL-015): advisory review-policy verdict. The evaluator runs
|
|
1307
1331
|
// only when a route record exists; its action + namedCheck are recorded on
|
|
@@ -1327,6 +1351,14 @@ export async function runStopCoordinator({
|
|
|
1327
1351
|
}
|
|
1328
1352
|
} catch { /* advisory verdicts never affect the stop decision */ }
|
|
1329
1353
|
|
|
1354
|
+
// C100 MB-1b: stage-gated (default off) shadow capture of per-call usage
|
|
1355
|
+
// metadata from the host transcript. Observation only; never affects the
|
|
1356
|
+
// stop decision and never throws.
|
|
1357
|
+
try {
|
|
1358
|
+
const { captureUsageShadow } = await import('./usage-capture.mjs');
|
|
1359
|
+
await captureUsageShadow({ projectRoot, payload });
|
|
1360
|
+
} catch { /* advisory */ }
|
|
1361
|
+
|
|
1330
1362
|
// TASK-005 (FR-005): the coordinator's terminal verdict lands ONE
|
|
1331
1363
|
// `outcome.observed` record per coordinated stop — the merged evaluator
|
|
1332
1364
|
// decision is the session's honest outcome at this stop. Fire-and-forget:
|