@ngockhoale/ukit 3.4.26 → 3.4.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/manifests/documentation.yaml +61 -0
- package/manifests/platform.full.yaml +24 -0
- package/package.json +7 -1
- package/scripts/audit/subagent-cost/metrics.mjs +14 -2
- package/scripts/audit/subagent-cost/reader.mjs +15 -3
- package/scripts/audit/subagent-cost/usage-fields.mjs +82 -0
- package/scripts/dev/worktree-preflight.mjs +147 -0
- package/src/cli/commands/update.js +59 -2
- package/src/core/agentRuntime/context.js +78 -0
- package/src/core/diffPlan.js +100 -102
- package/src/core/docContracts.js +3 -0
- package/src/core/observability/emit/sessionBoot.js +14 -0
- package/src/core/runtimeConfigMerge.js +125 -0
- package/src/core/updateSafety.js +174 -0
- package/src/index/contextPack.js +285 -0
- package/src/index/sensitivePath.js +21 -0
- package/src/index/taskRouting.js +3 -0
- package/template_project/.claude/commands/ukit/ci-check.md +53 -0
- package/template_project/.claude/hooks/auto-allow-bash.sh +20 -2
- package/template_project/.claude/hooks/handoff-model-guard.sh +9 -2
- package/template_project/.claude/hooks/handoff-resume.sh +25 -1
- package/template_project/.claude/hooks/record-execution.mjs +9 -0
- package/template_project/.claude/hooks/skill-router.sh +3 -0
- package/template_project/.claude/ukit/index/ci-check.mjs +665 -0
- package/template_project/.claude/ukit/index/post-edit-verify.mjs +4 -4
- package/template_project/.claude/ukit/index/pre-edit-backup.mjs +3 -3
- package/template_project/.claude/ukit/index/route-task.mjs +3 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +154 -12
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +61 -0
- package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +70 -4
- package/template_project/.claude/ukit/runtime/loop-state.mjs +131 -0
- package/template_project/.claude/ukit/runtime/observability-emit.mjs +14 -0
- package/template_project/.claude/ukit/runtime/read-advisory.mjs +92 -0
- package/template_project/.claude/ukit/runtime/safe-patch-core.mjs +71 -0
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +53 -6
- package/template_project/.claude/ukit/runtime/usage-capture.mjs +338 -0
- package/template_project/.omp/hooks/pre/ukit-bridge.js +48 -4
|
@@ -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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|