@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/manifests/documentation.yaml +61 -0
  3. package/manifests/platform.full.yaml +24 -0
  4. package/package.json +7 -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/scripts/dev/worktree-preflight.mjs +147 -0
  9. package/src/cli/commands/update.js +59 -2
  10. package/src/core/agentRuntime/context.js +78 -0
  11. package/src/core/diffPlan.js +100 -102
  12. package/src/core/docContracts.js +3 -0
  13. package/src/core/observability/emit/sessionBoot.js +14 -0
  14. package/src/core/runtimeConfigMerge.js +125 -0
  15. package/src/core/updateSafety.js +174 -0
  16. package/src/index/contextPack.js +285 -0
  17. package/src/index/sensitivePath.js +21 -0
  18. package/src/index/taskRouting.js +3 -0
  19. package/template_project/.claude/commands/ukit/ci-check.md +53 -0
  20. package/template_project/.claude/hooks/auto-allow-bash.sh +20 -2
  21. package/template_project/.claude/hooks/handoff-model-guard.sh +9 -2
  22. package/template_project/.claude/hooks/handoff-resume.sh +25 -1
  23. package/template_project/.claude/hooks/record-execution.mjs +9 -0
  24. package/template_project/.claude/hooks/skill-router.sh +3 -0
  25. package/template_project/.claude/ukit/index/ci-check.mjs +665 -0
  26. package/template_project/.claude/ukit/index/post-edit-verify.mjs +4 -4
  27. package/template_project/.claude/ukit/index/pre-edit-backup.mjs +3 -3
  28. package/template_project/.claude/ukit/index/route-task.mjs +3 -0
  29. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +154 -12
  30. package/template_project/.claude/ukit/runtime/handoff-intent.mjs +61 -0
  31. package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +70 -4
  32. package/template_project/.claude/ukit/runtime/loop-state.mjs +131 -0
  33. package/template_project/.claude/ukit/runtime/observability-emit.mjs +14 -0
  34. package/template_project/.claude/ukit/runtime/read-advisory.mjs +92 -0
  35. package/template_project/.claude/ukit/runtime/safe-patch-core.mjs +71 -0
  36. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +53 -6
  37. package/template_project/.claude/ukit/runtime/usage-capture.mjs +338 -0
  38. package/template_project/.omp/hooks/pre/ukit-bridge.js +48 -4
@@ -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
+ }
@@ -1,5 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
+ import fsSync from 'node:fs';
2
3
  import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
3
5
 
4
6
  export const DEFAULT_SAFE_PATCH_CONFIG = {
5
7
  enabled: true,
@@ -78,6 +80,75 @@ export function resolveProjectFile(projectRoot, filePath) {
78
80
  };
79
81
  }
80
82
 
83
+ function realpathNearest(absolute) {
84
+ // Real path of the deepest existing ancestor + the not-yet-existing tail, so a Write
85
+ // that creates a new file is judged by where it would actually land (symlinks resolved).
86
+ let probe = absolute;
87
+ const tail = [];
88
+ for (;;) {
89
+ try {
90
+ return path.join(fsSync.realpathSync(probe), ...tail.reverse());
91
+ } catch {
92
+ const parent = path.dirname(probe);
93
+ if (parent === probe) return null;
94
+ tail.push(path.basename(probe));
95
+ probe = parent;
96
+ }
97
+ }
98
+ }
99
+
100
+ function listedLinkedWorktrees(projectRoot) {
101
+ // `git worktree list` run from the project root only names worktrees of THIS repository.
102
+ try {
103
+ const r = spawnSync('git', ['worktree', 'list', '--porcelain'], {
104
+ cwd: projectRoot, encoding: 'utf8', timeout: 800, stdio: ['ignore', 'pipe', 'ignore'],
105
+ });
106
+ if (r.error || r.status !== 0) return [];
107
+ const projectReal = fsSync.realpathSync(projectRoot);
108
+ return String(r.stdout).split('\n')
109
+ .filter((line) => line.startsWith('worktree '))
110
+ .map((line) => { try { return fsSync.realpathSync(line.slice('worktree '.length)); } catch { return null; } })
111
+ .filter((dir) => dir && dir !== projectReal);
112
+ } catch {
113
+ return [];
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Edit/Write target resolver for the Safe Patch hooks. CLAUDE_PROJECT_DIR stays pinned to the
119
+ * primary checkout, so a file in a linked worktree is lexically "outside" it. The strict
120
+ * resolveProjectFile contract is tried first and is unchanged; only when it rejects does the
121
+ * target get a second chance, and only if its REAL path (symlinks resolved) lies inside a
122
+ * worktree that git lists for this same repository and not inside that worktree's `.git`.
123
+ * Anything else rethrows the original "Path escapes project root" error.
124
+ * `relative` is worktree-relative (risk classification); `key` is the project-root-relative
125
+ * identity used for backup manifests so primary and worktree copies of one path never collide.
126
+ */
127
+ export function resolveEditTarget(projectRoot, filePath) {
128
+ try {
129
+ const resolved = resolveProjectFile(projectRoot, filePath);
130
+ return resolved && { ...resolved, key: resolved.relative };
131
+ } catch (error) {
132
+ const root = path.resolve(projectRoot || process.cwd());
133
+ const absolute = path.resolve(String(filePath).trim());
134
+ const real = path.isAbsolute(String(filePath).trim()) ? realpathNearest(absolute) : null;
135
+ if (real) {
136
+ for (const worktree of listedLinkedWorktrees(root)) {
137
+ const inside = path.relative(worktree, real);
138
+ if (!inside || inside.startsWith('..') || path.isAbsolute(inside)) continue;
139
+ if (inside.split(path.sep)[0] === '.git') break;
140
+ return {
141
+ absolute: real,
142
+ relative: normalizeRelativePath(inside),
143
+ key: normalizeRelativePath(path.relative(root, real)),
144
+ worktreeRoot: worktree,
145
+ };
146
+ }
147
+ }
148
+ throw error;
149
+ }
150
+ }
151
+
81
152
  export async function pathExists(filePath) {
82
153
  try {
83
154
  await fs.access(filePath);
@@ -956,13 +956,17 @@ async function bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs }) {
956
956
  *
957
957
  * Advisory lane: EVERY failure path returns { kind: 'none' }, never throws.
958
958
  */
959
- export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), lockBudgetMs = LOCK_BUDGET_MS, transcriptPath = null, stopHookActive = false } = {}) {
959
+ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot, now = Date.now(), lockBudgetMs = LOCK_BUDGET_MS, transcriptPath = null, stopHookActive = false } = {}) {
960
+ // `runRoot` is the checkout whose RUN.md cursor this session may be held to: the
961
+ // git-verified linked worktree when the session runs in one, else `projectRoot`.
962
+ // Policy (gate config) stays with `projectRoot`; the cursor, its exit predicate
963
+ // and its stall state are per-checkout.
960
964
  const gate = await loadHandoffGateConfig(projectRoot);
961
965
  if (!gate.enabled) return { kind: 'none' };
962
966
 
963
967
  let text;
964
968
  try {
965
- text = await fs.readFile(path.join(projectRoot, 'docs', 'AI_HANDOFF', 'RUN.md'), 'utf8');
969
+ text = await fs.readFile(path.join(runRoot, 'docs', 'AI_HANDOFF', 'RUN.md'), 'utf8');
966
970
  } catch {
967
971
  return { kind: 'none' };
968
972
  }
@@ -988,7 +992,7 @@ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), loc
988
992
  if (terminal === 'done') {
989
993
  let predicate;
990
994
  try {
991
- predicate = await evaluateExitPredicate(projectRoot, exitPredicate);
995
+ predicate = await evaluateExitPredicate(runRoot, exitPredicate);
992
996
  } catch {
993
997
  return { kind: 'none' }; // advisory lane: evaluator failure never wedges a stop
994
998
  }
@@ -1022,8 +1026,19 @@ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), loc
1022
1026
  return { kind: 'none' };
1023
1027
  }
1024
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
+
1025
1040
  const signature = `${phase}|${cursor}|${next}`;
1026
- const streak = await bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs });
1041
+ const streak = await bumpHandoffStallCount({ projectRoot: runRoot, signature, lockBudgetMs });
1027
1042
  // Reentrancy valve (stop_hook_active): Claude Code sets it when this Stop fires
1028
1043
  // inside the continuation turn WE forced. The same cursor signature seen again
1029
1044
  // (streak > 1) means that continuation made no cursor progress — blocking again
@@ -1042,7 +1057,7 @@ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), loc
1042
1057
  let predicateState = 'no ExitPredicate recorded';
1043
1058
  if (exitPredicate) {
1044
1059
  try {
1045
- predicateState = formatPredicateState(await evaluateExitPredicate(projectRoot, exitPredicate));
1060
+ predicateState = formatPredicateState(await evaluateExitPredicate(runRoot, exitPredicate));
1046
1061
  } catch {
1047
1062
  predicateState = 'predicate evaluation failed';
1048
1063
  }
@@ -1206,6 +1221,16 @@ export async function alreadyCoordinatedThisStop({ projectRoot, sessionKey, now,
1206
1221
  }
1207
1222
  }
1208
1223
 
1224
+ // ─── Worktree scoping ─────────────────────────────────────────────────────
1225
+ // The resolver lives in the shared handoff-intent module (also used by the
1226
+ // SessionStart resume hook). Unavailable module → the primary `projectRoot`, i.e.
1227
+ // the pre-existing, stricter behaviour.
1228
+ async function resolveRunRoot(projectRoot, cwd) {
1229
+ const mod = await loadHandoffIntentModule();
1230
+ if (!mod || typeof mod.resolveHandoffRunRoot !== 'function') return projectRoot;
1231
+ return mod.resolveHandoffRunRoot(projectRoot, cwd);
1232
+ }
1233
+
1209
1234
  // ─── Orchestration ────────────────────────────────────────────────────────
1210
1235
  /**
1211
1236
  * One coordinator invocation: event guard → once-per-stop dedupe → completion
@@ -1276,6 +1301,7 @@ export async function runStopCoordinator({
1276
1301
  try {
1277
1302
  handoff = await evaluateHandoffCursor({
1278
1303
  projectRoot,
1304
+ runRoot: await resolveRunRoot(projectRoot, payload?.cwd),
1279
1305
  now,
1280
1306
  lockBudgetMs,
1281
1307
  transcriptPath: typeof payload?.transcript_path === 'string' ? payload.transcript_path : null,
@@ -1286,7 +1312,20 @@ export async function runStopCoordinator({
1286
1312
  process.stderr.write(`[ukit-stop-coordinator] handoff-cursor evaluator failed: ${failures.handoff}\n`);
1287
1313
  }
1288
1314
 
1289
- 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';
1290
1329
 
1291
1330
  // C85 TASK-015 (BL-015): advisory review-policy verdict. The evaluator runs
1292
1331
  // only when a route record exists; its action + namedCheck are recorded on
@@ -1312,6 +1351,14 @@ export async function runStopCoordinator({
1312
1351
  }
1313
1352
  } catch { /* advisory verdicts never affect the stop decision */ }
1314
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
+
1315
1362
  // TASK-005 (FR-005): the coordinator's terminal verdict lands ONE
1316
1363
  // `outcome.observed` record per coordinated stop — the merged evaluator
1317
1364
  // decision is the session's honest outcome at this stop. Fire-and-forget:
@@ -0,0 +1,338 @@
1
+ #!/usr/bin/env node
2
+ // usage-capture.mjs — C100 MB-1b: SHADOW capture of per-call usage metadata.
3
+ //
4
+ // Attached to the EXISTING Stop event (stop-coordinator.mjs) — no extra hook
5
+ // invocation. Stage-gated by `.ukit/storage/config.json` -> usageCapture.stage;
6
+ // absent / 'off' / anything but 'shadow' emits nothing. Observation only: it
7
+ // never changes a routing, stop or tool decision, makes no network or LLM call.
8
+ //
9
+ // Reads the host transcript INCREMENTALLY: a per-session byte cursor means each
10
+ // Stop reads only bytes appended since the previous Stop, never more than
11
+ // MAX_READ_BYTES. Rows are metadata only — no prompt, response, tool input,
12
+ // credential or credentialId is ever copied.
13
+ //
14
+ // Provenance: every row read from a host transcript is HOST_TRANSCRIPT.
15
+ // providerId / gatewayId are identity metadata copied from explicit record keys;
16
+ // they are NOT evidence of who measured the usage. PROVIDER / GATEWAY would need
17
+ // an authenticated source (none exists yet), so they are never emitted here.
18
+ //
19
+ // Completeness: the capture is bounded, so it can be incomplete. Every gap
20
+ // (skipped history, backlog, truncation/rotation, unparsable usage lines, row
21
+ // cap, log rotation, lock timeout, deadline) is written as a `kind:'coverage'`
22
+ // record and a sticky per-session flag. Consumers must treat a session with any
23
+ // partial coverage as partial and never report its cache hit-rate as complete
24
+ // (see summarizeUsageRows).
25
+ //
26
+ // Delivery is at-least-once with dedupe: a crash between the log append and the
27
+ // cursor write replays the window; rows already present in the bounded log tail
28
+ // are skipped by dedupeKey.
29
+ //
30
+ // Engines: Claude Code transcripts (snake_case usage) are supported; omp's
31
+ // camelCase shape is parsed when handed in but the omp bridge does not call this
32
+ // module yet; Codex is explicitly unsupported.
33
+
34
+ import fs from 'node:fs/promises';
35
+ import path from 'node:path';
36
+ import crypto from 'node:crypto';
37
+ import { withAsyncLock } from './async-lock.mjs';
38
+
39
+ export const USAGE_ROW_VERSION = 1;
40
+ export const MAX_READ_BYTES = 1024 * 1024;
41
+ export const MAX_ROWS_PER_STOP = 5000;
42
+ export const MAX_LOG_BYTES = 256 * 1024;
43
+ export const DEDUPE_TAIL_BYTES = 64 * 1024;
44
+ export const CAPTURE_DEADLINE_MS = 250;
45
+ const LOCK_DEADLINE_MS = 150;
46
+
47
+ const CACHE_STATUS = Object.freeze({ ZERO: 'present_zero', POSITIVE: 'present_positive', ABSENT: 'absent', INVALID: 'invalid' });
48
+ const READ_KEYS = ['cache_read_input_tokens', 'cache_read_tokens', 'cacheRead'];
49
+ const WRITE_KEYS = ['cache_creation_input_tokens', 'cache_creation_tokens', 'cacheWrite'];
50
+ const SAFE_ID = /^[A-Za-z0-9._:/@-]{1,128}$/;
51
+ const SECRET_LIKE = /sk-[A-Za-z0-9]{8,}|bearer|secret|passw|token=|key=|api[_-]?key/i;
52
+ const PROVIDER_KEYS = ['providerId', 'provider_id', 'provider', 'apiProvider'];
53
+ const GATEWAY_KEYS = ['gatewayId', 'gateway_id', 'gatewayName', 'gateway'];
54
+
55
+ export function resolveUsageCaptureStage(config) {
56
+ return config?.usageCapture?.stage === 'shadow' ? 'shadow' : 'off';
57
+ }
58
+
59
+ export function classifyCache(usage, keys) {
60
+ if (!usage || typeof usage !== 'object') return { status: CACHE_STATUS.ABSENT, value: null };
61
+ for (const key of keys) {
62
+ const raw = usage[key];
63
+ if (raw === undefined || raw === null) continue;
64
+ if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0) return { status: CACHE_STATUS.INVALID, value: null };
65
+ return { status: raw === 0 ? CACHE_STATUS.ZERO : CACHE_STATUS.POSITIVE, value: raw };
66
+ }
67
+ return { status: CACHE_STATUS.ABSENT, value: null };
68
+ }
69
+
70
+ export function cacheMetadataStatus(read, write) {
71
+ if (read.status === CACHE_STATUS.INVALID || write.status === CACHE_STATUS.INVALID) return CACHE_STATUS.INVALID;
72
+ if (read.status === CACHE_STATUS.ABSENT) return CACHE_STATUS.ABSENT;
73
+ return read.status;
74
+ }
75
+
76
+ function safeId(record, message, keys) {
77
+ for (const holder of [message, record]) {
78
+ for (const key of keys) {
79
+ const value = holder?.[key];
80
+ if (typeof value !== 'string') continue;
81
+ return SAFE_ID.test(value) && !SECRET_LIKE.test(value) ? value : null;
82
+ }
83
+ }
84
+ return null;
85
+ }
86
+
87
+ const count = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null);
88
+ const hash = (s) => crypto.createHash('sha1').update(s).digest('hex').slice(0, 16);
89
+
90
+ export function buildUsageRow(record, { harness = 'claude-code', now = Date.now(), line = null } = {}) {
91
+ if (!record || (record.type !== 'assistant' && record.role !== 'assistant')) return null;
92
+ const message = record.message && typeof record.message === 'object' ? record.message : record;
93
+ const usage = message.usage && typeof message.usage === 'object' && !Array.isArray(message.usage) ? message.usage : null;
94
+ if (!usage) return null;
95
+ const read = classifyCache(usage, READ_KEYS);
96
+ const write = classifyCache(usage, WRITE_KEYS);
97
+ const id = typeof message.id === 'string' && SAFE_ID.test(message.id) ? message.id : null;
98
+ return {
99
+ v: USAGE_ROW_VERSION,
100
+ kind: 'usage',
101
+ ts: new Date(now).toISOString(),
102
+ harness,
103
+ provenance: 'HOST_TRANSCRIPT',
104
+ dedupeKey: id ? `id:${id}` : `h:${hash(line ?? JSON.stringify(usage))}`,
105
+ messageId: id,
106
+ model: typeof message.model === 'string' && SAFE_ID.test(message.model) ? message.model : null,
107
+ providerId: safeId(record, message, PROVIDER_KEYS),
108
+ gatewayId: safeId(record, message, GATEWAY_KEYS),
109
+ inputTokens: count(usage.input_tokens ?? usage.input),
110
+ outputTokens: count(usage.output_tokens ?? usage.output),
111
+ cacheReadTokens: read.value,
112
+ cacheWriteTokens: write.value,
113
+ cacheReadStatus: read.status,
114
+ cacheWriteStatus: write.status,
115
+ cacheMetadataStatus: cacheMetadataStatus(read, write),
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Consumer helper: hit-rate over rows with KNOWN cache metadata only. A session
121
+ * with any partial coverage record reports coverage:'partial' and withholds the
122
+ * headline hitRate (partialHitRate is explicitly labelled).
123
+ */
124
+ export function summarizeUsageRows(rows) {
125
+ let partial = false;
126
+ let known = 0; let unknown = 0; let cacheRead = 0; let inputSide = 0;
127
+ for (const row of rows || []) {
128
+ if (row?.kind === 'coverage') { if (row.status === 'partial') partial = true; continue; }
129
+ if (row?.kind !== 'usage') continue;
130
+ if (row.cacheMetadataStatus === 'absent' || row.cacheMetadataStatus === 'invalid') { unknown += 1; continue; }
131
+ known += 1;
132
+ cacheRead += row.cacheReadTokens ?? 0;
133
+ inputSide += (row.inputTokens ?? 0) + (row.cacheReadTokens ?? 0) + (row.cacheWriteTokens ?? 0);
134
+ }
135
+ const rate = inputSide > 0 ? cacheRead / inputSide : null;
136
+ return {
137
+ coverage: partial ? 'partial' : 'complete',
138
+ knownCalls: known,
139
+ unknownCalls: unknown,
140
+ hitRate: partial ? null : rate,
141
+ partialHitRate: partial ? rate : null,
142
+ };
143
+ }
144
+
145
+ async function readConfig(projectRoot) {
146
+ try {
147
+ return JSON.parse(await fs.readFile(path.join(projectRoot, '.ukit', 'storage', 'config.json'), 'utf8'));
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
152
+
153
+ const sessionFile = (value) => crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, 16);
154
+
155
+ async function readLogTailKeys(file) {
156
+ const keys = new Set();
157
+ let handle;
158
+ try {
159
+ handle = await fs.open(file, 'r');
160
+ const { size } = await handle.stat();
161
+ const start = Math.max(0, size - DEDUPE_TAIL_BYTES);
162
+ const buf = Buffer.alloc(size - start);
163
+ await handle.read(buf, 0, buf.length, start);
164
+ let text = buf.toString('utf8');
165
+ if (start > 0) text = text.slice(text.indexOf('\n') + 1);
166
+ for (const line of text.split('\n')) {
167
+ if (!line.includes('"dedupeKey"')) continue;
168
+ try { const k = JSON.parse(line)?.dedupeKey; if (typeof k === 'string') keys.add(k); } catch { /* torn line */ }
169
+ }
170
+ } catch { /* no log yet */ } finally {
171
+ await handle?.close().catch(() => {});
172
+ }
173
+ return keys;
174
+ }
175
+
176
+ async function rotateIfNeeded(file, incoming) {
177
+ try {
178
+ const { size } = await fs.stat(file);
179
+ if (size + incoming > MAX_LOG_BYTES) { await fs.rename(file, `${file}.1`); return true; }
180
+ } catch { /* ENOENT or race: append recreates */ }
181
+ return false;
182
+ }
183
+
184
+ async function readCursor(file) {
185
+ try {
186
+ const c = JSON.parse(await fs.readFile(file, 'utf8'));
187
+ if (Number.isInteger(c?.offset) && c.offset >= 0) return c;
188
+ } catch { /* none/corrupt */ }
189
+ return null;
190
+ }
191
+
192
+ async function startsAtLineBoundary(handle, offset) {
193
+ if (offset === 0) return true;
194
+ const b = Buffer.alloc(1);
195
+ const { bytesRead } = await handle.read(b, 0, 1, offset - 1);
196
+ return bytesRead === 1 && b[0] === 0x0a;
197
+ }
198
+
199
+ async function captureInner({ projectRoot, payload = {}, harness, now = Date.now() }) {
200
+ const result = { stage: 'off', rows: 0, bytesRead: 0 };
201
+ const config = await readConfig(projectRoot);
202
+ result.stage = resolveUsageCaptureStage(config);
203
+ if (result.stage !== 'shadow') return result;
204
+ const engine = harness || process.env.UKIT_HARNESS || 'claude-code';
205
+ if (engine === 'codex') return { ...result, skipped: 'unsupported-engine' };
206
+ const transcript = payload?.transcript_path;
207
+ if (typeof transcript !== 'string' || !transcript) return { ...result, skipped: 'no-transcript-path' };
208
+
209
+ const dir = path.join(projectRoot, '.ukit', 'storage', 'cache', 'usage-capture');
210
+ const key = sessionFile(payload.session_id || transcript);
211
+ const cursorFile = path.join(dir, `${key}.cursor.json`);
212
+ const logFile = path.join(dir, `${key}.jsonl`);
213
+ await fs.mkdir(dir, { recursive: true });
214
+
215
+ // The cursor is read, advanced and written under ONE lock: two concurrent
216
+ // Stops cannot both consume the same window. A lock timeout leaves the cursor
217
+ // untouched, so the bytes are picked up by the next Stop (delayed, not lost).
218
+ const lock = await withAsyncLock(logFile, { deadlineMs: LOCK_DEADLINE_MS }, async () => {
219
+ const handle = await fs.open(transcript, 'r');
220
+ try {
221
+ const st = await handle.stat();
222
+ const size = st.size;
223
+ const cursor = await readCursor(cursorFile);
224
+ const reasons = [];
225
+ let skippedBytes = 0;
226
+ let offset = 0;
227
+ if (cursor) {
228
+ if ((cursor.ino != null && cursor.ino !== st.ino) || cursor.offset > size) {
229
+ reasons.push('transcript-rotated-or-truncated');
230
+ } else {
231
+ offset = cursor.offset;
232
+ }
233
+ } else {
234
+ reasons.push('initial-window');
235
+ }
236
+ if (size - offset > MAX_READ_BYTES && (!cursor || reasons.length)) {
237
+ // No trustworthy cursor: only the newest window is read; older bytes are a declared gap.
238
+ skippedBytes = size - MAX_READ_BYTES - offset;
239
+ offset = size - MAX_READ_BYTES;
240
+ if (!reasons.length) reasons.push('initial-window');
241
+ } else if (!cursor && size === 0) {
242
+ reasons.length = 0;
243
+ } else if (!reasons.includes('transcript-rotated-or-truncated') && reasons[0] === 'initial-window' && skippedBytes === 0) {
244
+ reasons.length = 0; // whole small transcript read from byte 0: nothing was skipped
245
+ }
246
+ const length = Math.min(size - offset, MAX_READ_BYTES);
247
+ let pendingBytes = size - offset - length;
248
+ const next = { v: 1, offset, ino: st.ino, partial: Boolean(cursor?.partial) };
249
+ let lines = [];
250
+ let consumed = 0;
251
+ if (length > 0) {
252
+ const buf = Buffer.alloc(length);
253
+ const { bytesRead } = await handle.read(buf, 0, length, offset);
254
+ result.bytesRead = bytesRead;
255
+ const text = buf.subarray(0, bytesRead).toString('utf8');
256
+ const lastNl = text.lastIndexOf('\n');
257
+ if (lastNl >= 0) {
258
+ consumed = Buffer.byteLength(text.slice(0, lastNl + 1), 'utf8');
259
+ lines = text.slice(0, lastNl + 1).split('\n');
260
+ if (!(await startsAtLineBoundary(handle, offset))) lines = lines.slice(1); // window began mid-line
261
+ }
262
+ }
263
+ if (pendingBytes > 0) reasons.push('backlog');
264
+ next.offset = offset + consumed;
265
+
266
+ let unparsedUsageLines = 0;
267
+ const rows = [];
268
+ const seen = new Set();
269
+ for (const line of lines) {
270
+ if (!line.includes('"usage"')) continue;
271
+ let record;
272
+ try { record = JSON.parse(line); } catch { unparsedUsageLines += 1; continue; }
273
+ const row = buildUsageRow(record, { harness: engine, now, line });
274
+ if (!row) continue;
275
+ if (seen.has(row.dedupeKey)) { // streaming chunks of one message: last chunk wins
276
+ rows[rows.findIndex((r) => r.dedupeKey === row.dedupeKey)] = row;
277
+ continue;
278
+ }
279
+ seen.add(row.dedupeKey);
280
+ rows.push(row);
281
+ }
282
+ let droppedRows = 0;
283
+ if (rows.length > MAX_ROWS_PER_STOP) { droppedRows = rows.length - MAX_ROWS_PER_STOP; rows.splice(0, droppedRows); reasons.push('row-cap'); }
284
+ if (unparsedUsageLines > 0) reasons.push('unparsed-usage-lines');
285
+
286
+ const known = rows.length ? await readLogTailKeys(logFile) : new Set();
287
+ const fresh = rows.filter((r) => !known.has(r.dedupeKey));
288
+ const records = [...fresh];
289
+ let rotated = false;
290
+ const partialNow = reasons.length > 0;
291
+ if (partialNow) {
292
+ records.push({
293
+ v: USAGE_ROW_VERSION, kind: 'coverage', ts: new Date(now).toISOString(), harness: engine,
294
+ status: 'partial', reasons: [...new Set(reasons)], skippedBytes, pendingBytes, droppedRows, unparsedUsageLines,
295
+ });
296
+ next.partial = true;
297
+ }
298
+ if (records.length) {
299
+ const body = records.map((r) => JSON.stringify(r)).join('\n') + '\n';
300
+ rotated = await rotateIfNeeded(logFile, Buffer.byteLength(body));
301
+ if (rotated) {
302
+ // Older rows beyond the retention window are gone: say so, and let consumers know.
303
+ records.unshift({ v: USAGE_ROW_VERSION, kind: 'coverage', ts: new Date(now).toISOString(), harness: engine, status: 'partial', reasons: ['log-rotated'], skippedBytes: 0, pendingBytes: 0, droppedRows: 0, unparsedUsageLines: 0 });
304
+ next.partial = true;
305
+ }
306
+ await fs.appendFile(logFile, records.map((r) => JSON.stringify(r)).join('\n') + '\n', 'utf8');
307
+ }
308
+ const tmp = `${cursorFile}.${process.pid}.tmp`;
309
+ await fs.writeFile(tmp, JSON.stringify(next));
310
+ await fs.rename(tmp, cursorFile);
311
+ result.rows = fresh.length;
312
+ result.coverage = next.partial ? 'partial' : 'complete';
313
+ if (pendingBytes > 0) result.pendingBytes = pendingBytes;
314
+ } finally {
315
+ await handle.close().catch(() => {});
316
+ }
317
+ });
318
+ if (lock?.ok === false) return { ...result, skipped: 'lock-timeout' };
319
+ return result;
320
+ }
321
+
322
+ /**
323
+ * Capture usage rows appended to the transcript since the last Stop.
324
+ * Never throws and never takes longer than CAPTURE_DEADLINE_MS to return; a
325
+ * deadline hit returns skipped:'deadline' (the next Stop resumes from the cursor).
326
+ */
327
+ export async function captureUsageShadow(opts = {}) {
328
+ let timer;
329
+ const deadline = new Promise((resolve) => {
330
+ timer = setTimeout(() => resolve({ stage: 'shadow', rows: 0, bytesRead: 0, skipped: 'deadline' }), opts.deadlineMs ?? CAPTURE_DEADLINE_MS);
331
+ timer.unref?.();
332
+ });
333
+ try {
334
+ return await Promise.race([captureInner(opts).catch(() => ({ stage: 'off', rows: 0, bytesRead: 0, skipped: 'error' })), deadline]);
335
+ } finally {
336
+ clearTimeout(timer);
337
+ }
338
+ }