@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
@@ -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
+ }
@@ -751,6 +751,12 @@ function normalizeToolInput(toolName, input) {
751
751
  const paths = Array.isArray(normalized.paths) ? normalized.paths.filter(Boolean) : [];
752
752
  normalized.file_path = normalized.path || paths[0] || undefined;
753
753
  }
754
+ // omp's eval tool carries `{language, code}` (no `command`), but block-dangerous and the
755
+ // secret guard only read tool_input.command. Surface the code as the command so the same
756
+ // Bash chain scans it; otherwise an eval payload would reach the gate as an empty command.
757
+ if (toolName === 'Bash' && typeof normalized.command !== 'string' && typeof normalized.code === 'string') {
758
+ normalized.command = normalized.code;
759
+ }
754
760
  return normalized;
755
761
  }
756
762
 
@@ -789,8 +795,39 @@ function sendContext(pi, context, deliverAs, { display = false } = {}) {
789
795
  pi.sendMessage(hookContextMessage(content, { display }), { deliverAs });
790
796
  }
791
797
 
792
- export async function runToolCall(pi, event, { projectRoot, context: extensionContext = {} }) {
798
+ // eval threat model (F2): the Bash chain is a text matcher over a command string. It cannot
799
+ // prove that arbitrary Python/JS is safe (shutil.rmtree, fs.rmSync, pathlib loops, dynamic
800
+ // strings all pass it — verified in a sentinel sandbox). So eval is DENIED by the bridge unless
801
+ // the user opted in with orchestration.allowUnsafeEval=true in .ukit/storage/config.json,
802
+ // independent of .omp/config.yml tools.approval.eval. When opted in, the Bash chain still runs
803
+ // as best-effort defence in depth; it is NOT a safety guarantee for eval code.
804
+ async function readAllowUnsafeEval(projectRoot) {
805
+ try {
806
+ const parsed = JSON.parse(
807
+ await fs.promises.readFile(path.join(projectRoot, '.ukit', 'storage', 'config.json'), 'utf8'),
808
+ );
809
+ return parsed?.orchestration?.allowUnsafeEval === true;
810
+ } catch {
811
+ return false; // missing/corrupt config → fail closed
812
+ }
813
+ }
814
+
815
+ export async function runToolCall(pi, event, { projectRoot, context: extensionContext = {}, allowUnsafeEval } = {}) {
793
816
  const rawToolName = event.toolName ?? event.tool;
817
+ if (String(rawToolName ?? '').trim().toLowerCase() === 'eval') {
818
+ const allowed = allowUnsafeEval ?? await readAllowUnsafeEval(projectRoot);
819
+ if (!allowed) {
820
+ return {
821
+ block: true,
822
+ toolName: 'Bash',
823
+ invoked: [],
824
+ context: [],
825
+ reason: 'UKit denies the omp eval tool: arbitrary Python/JS cannot be verified by the shell guard. '
826
+ + 'Use the bash tool for commands (guarded), or opt in explicitly with orchestration.allowUnsafeEval=true '
827
+ + 'in .ukit/storage/config.json (unsafe; the guard is best-effort for eval).',
828
+ };
829
+ }
830
+ }
794
831
  const mappedToolName = mapToolName(rawToolName);
795
832
  // An unmapped tool that LOOKS like a file mutation must not sail past the guards — but
796
833
  // hard-blocking it froze the run whenever omp renamed or added a tool, because the model
@@ -811,9 +848,16 @@ export async function runToolCall(pi, event, { projectRoot, context: extensionCo
811
848
  toolUseId: event.toolCallId,
812
849
  ...metadata,
813
850
  });
814
- // Only Edit|Write stays fail-closed on a bridge/runner transport failure; Bash and
815
- // Read|Grep|Glob fail open so diagnosis/recovery tools are never bricked by a runner outage.
816
- const failClosedOnTransportError = matcherGroupFor(toolName) === 'Edit|Write';
851
+ // F4: Edit|Write and Bash (which also carries omp's `eval`) are security-critical and fail
852
+ // CLOSED on a bridge/runner transport failure — an unverifiable shell/eval call is not
853
+ // allowed to proceed. Read|Grep|Glob stay fail-open (documented limit: the secret-read guard
854
+ // is advisory on a runner outage) so diagnosis tools are not bricked. Recovery for a Bash
855
+ // outage is a user action, not a model one: the user exports UKIT_OMP_BASH_FAIL_OPEN=1 (or
856
+ // fixes Node/PATH) and restarts omp. This only affects PreToolUse; the Stop gate is separate,
857
+ // so a blocked Bash call cannot create a Stop-hook loop.
858
+ const group = matcherGroupFor(toolName);
859
+ const bashFailOpenOverride = process.env.UKIT_OMP_BASH_FAIL_OPEN === '1';
860
+ const failClosedOnTransportError = group === 'Edit|Write' || (group === 'Bash' && !bashFailOpenOverride);
817
861
  const result = await runScriptChain(pi, scriptsForToolCall(toolName), payload, {
818
862
  projectRoot,
819
863
  failClosedOnTransportError,