@ngockhoale/ukit 3.4.27 → 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.
Files changed (31) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/manifests/documentation.yaml +59 -0
  3. package/manifests/platform.full.yaml +24 -0
  4. package/package.json +6 -1
  5. package/scripts/audit/subagent-cost/metrics.mjs +14 -2
  6. package/scripts/audit/subagent-cost/reader.mjs +15 -3
  7. package/scripts/audit/subagent-cost/usage-fields.mjs +82 -0
  8. package/src/cli/commands/update.js +59 -2
  9. package/src/core/agentRuntime/context.js +78 -0
  10. package/src/core/diffPlan.js +100 -102
  11. package/src/core/observability/emit/sessionBoot.js +14 -0
  12. package/src/core/runtimeConfigMerge.js +125 -0
  13. package/src/core/updateSafety.js +174 -0
  14. package/src/index/contextPack.js +285 -0
  15. package/src/index/sensitivePath.js +21 -0
  16. package/src/index/taskRouting.js +3 -0
  17. package/template_project/.claude/commands/ukit/ci-check.md +53 -0
  18. package/template_project/.claude/hooks/handoff-model-guard.sh +9 -2
  19. package/template_project/.claude/hooks/handoff-resume.sh +13 -0
  20. package/template_project/.claude/hooks/record-execution.mjs +9 -0
  21. package/template_project/.claude/hooks/skill-router.sh +3 -0
  22. package/template_project/.claude/ukit/index/ci-check.mjs +665 -0
  23. package/template_project/.claude/ukit/index/route-task.mjs +3 -0
  24. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +19 -7
  25. package/template_project/.claude/ukit/runtime/handoff-intent.mjs +8 -0
  26. package/template_project/.claude/ukit/runtime/loop-state.mjs +131 -0
  27. package/template_project/.claude/ukit/runtime/observability-emit.mjs +14 -0
  28. package/template_project/.claude/ukit/runtime/read-advisory.mjs +92 -0
  29. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +33 -1
  30. package/template_project/.claude/ukit/runtime/usage-capture.mjs +338 -0
  31. 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'), ' ')
@@ -624,10 +624,13 @@ function gitOutput(args, cwd) {
624
624
  // so the session root's fingerprint never moves for work done there. Only a LEADING `cd <dir>`
625
625
  // that lands in a checkout sharing this repo's git common dir redirects the fingerprint; an
626
626
  // unrelated repo, a missing dir or any git failure keeps the session root (fail-closed).
627
- function bashWorkspaceScope(command, projectRoot) {
627
+ function bashWorkspaceScope(command, projectRoot, shellCwd) {
628
628
  const here = { root: projectRoot, foreign: false };
629
629
  const match = String(command || '').match(/^\s*cd\s+(?:"([^"]+)"|'([^']+)'|([^\s;&|<>$`~]+))\s*(?:&&|;)/);
630
- if (!match || !projectRoot) return here;
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;
631
634
  const real = (dir) => { try { return fsSync.realpathSync(dir); } catch { return null; } };
632
635
  const commonDir = (dir) => {
633
636
  const out = gitOutput(['rev-parse', '--git-common-dir'], dir);
@@ -637,7 +640,7 @@ function bashWorkspaceScope(command, projectRoot) {
637
640
  // A session outside any git repo has no checkout family to compare against: keep the
638
641
  // pre-existing command-text behaviour rather than inventing a foreign verdict.
639
642
  if (!sessionCommon) return here;
640
- const target = real(path.resolve(projectRoot, match[1] || match[2] || match[3]));
643
+ const target = real(match ? path.resolve(cwdDir || projectRoot, match[1] || match[2] || match[3]) : cwdDir);
641
644
  const top = target ? gitOutput(['rev-parse', '--show-toplevel'], target) : null;
642
645
  if (!top || commonDir(target) !== sessionCommon) return { root: projectRoot, foreign: true };
643
646
  const topReal = real(top);
@@ -1166,6 +1169,7 @@ function freshLedger(payload, routeState, harness) {
1166
1169
  // TASK-005 (FR-005): OutcomeRecord dedupe ledger ({verdict, runKey, at}).
1167
1170
  // Emitted-once for INTERRUPTED survives re-keys via carriedEvidenceLedger.
1168
1171
  outcomes: [],
1172
+ startedAt: Date.now(),
1169
1173
  updatedAt: Date.now(),
1170
1174
  };
1171
1175
  }
@@ -1310,7 +1314,7 @@ function newEventId() {
1310
1314
  return `${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`;
1311
1315
  }
1312
1316
 
1313
- const JOURNAL_RECEIPT_FIELDS = ['ts', 'kind', 'toolName', 'toolUseId', 'success', 'exitCode', 'file', 'command', 'scope', 'workspaceFp', 'workspaceRoot', 'bashReadOnly', 'exitStatusUnproven'];
1317
+ const JOURNAL_RECEIPT_FIELDS = ['ts', 'kind', 'toolName', 'toolUseId', 'success', 'exitCode', 'file', 'command', 'scope', 'workspaceFp', 'workspaceRoot', 'workspaceHeadTs', 'bashReadOnly', 'exitStatusUnproven'];
1314
1318
 
1315
1319
  // TASK-009 (BL-011): evidence attestations are whitelisted verbatim — class /
1316
1320
  // surface / result / claim, the ARCH §Evidence Schema fields a receipt may
@@ -1785,7 +1789,11 @@ function applyReceiptToLedger(ledger, receipt, options = {}) {
1785
1789
  const keys = Object.keys(base.workspaceFps);
1786
1790
  for (const key of keys.slice(0, Math.max(0, keys.length - 8))) delete base.workspaceFps[key];
1787
1791
  }
1788
- if (prior && prior !== fp && receipt.kind === 'workspace' && receipt.toolName === 'Bash' && receipt.success === true && receipt.bashReadOnly !== true) {
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) {
1789
1797
  const applied = applyReceiptCore(base, {
1790
1798
  ts: receipt.ts,
1791
1799
  kind: 'write',
@@ -2369,7 +2377,7 @@ export async function recordExecutionReceipt({
2369
2377
  const toolInput = payload.tool_input || {};
2370
2378
  const evidence = receiptEvidenceAttestation(payload, toolInput);
2371
2379
  const bashScope = toolName === 'Bash'
2372
- ? bashWorkspaceScope(toolInput.command, projectRoot)
2380
+ ? bashWorkspaceScope(toolInput.command, projectRoot, payload.cwd)
2373
2381
  : { root: projectRoot, foreign: false };
2374
2382
  const workspaceRoot = bashScope.root;
2375
2383
  const workspaceFp = workspaceFingerprint(workspaceRoot);
@@ -2427,7 +2435,11 @@ export async function recordExecutionReceipt({
2427
2435
  }
2428
2436
  if (workspaceFp) {
2429
2437
  receipt.workspaceFp = workspaceFp;
2430
- if (workspaceRoot !== projectRoot) receipt.workspaceRoot = workspaceRoot;
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
+ }
2431
2443
  if (toolName === 'Bash' && isReadOnlyBashCommand(toolInput.command)) receipt.bashReadOnly = true;
2432
2444
  }
2433
2445
  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
- const merged = coordinateStopDecisions({ completion, watchdog, handoff, failures });
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: