@ngockhoale/ukit 2.2.3 → 2.2.4

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 CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.2.4 - 2026-08-27
6
+
7
+ Follow-up to 2.2.3's hook-chain collapse: a real omp session on a CloudMounter/FUSE-mounted project reported every tool call — including harmless `echo`, `pwd`, `ls`, and `Edit` — failing with `invalid hook-chain output`, and in one case `protect-files.sh hook chain was killed before safety gates completed`, despite `protect-files.sh` itself passing trivially. The bridge was misattributing any aggregate-runner transport failure to the first fail-closed script in the chain, whether or not that script ever ran.
8
+
9
+ ### Fixed
10
+
11
+ - **Outer transport failures no longer impersonate a specific safety gate.** `runScriptChain` in `ukit-bridge.js` treated a killed/timed-out `pi.exec`, empty stdout, or malformed JSON from `hook-chain-runner.mjs` as proof that the chain's first fail-closed script (e.g. `protect-files.sh`) had failed — without verifying that script ever ran. It now distinguishes "the runner returned a verifiable per-script result" from "the runner itself failed before producing one," and on the latter reports the real failure (killed/code/elapsedMs/runtime) instead of a fabricated gate name. Diagnostics are appended to `.ukit/storage/cache/hook-errors/<session>.jsonl`.
12
+ - **Bash and Read/Grep/Glob no longer fail closed on a bridge/runner outage.** Only `Edit`/`Write` chains still block when the runner can't produce a verdict, since those guard file mutation. Ordinary shell commands and read-only tools now fail open (with a logged warning), so a runner-level hiccup can no longer brick command execution or diagnosis tools.
13
+ - **`process.execPath` is no longer trusted blindly for the runner subprocess.** The bridge resolves `process.env.UKIT_NODE_PATH || process.execPath` and records the resolved runtime in the failure diagnostic, so a Bun-hosted omp launching the Node-only runner under the wrong binary is now visible instead of silently assumed.
14
+
15
+ ### Added
16
+
17
+ - **6 regression tests** in `tests/hooks/ompHookBridge.test.js` (`case 13`) covering a killed outer exec, malformed stdout, a non-zero exit with empty stdout before any script ran, fail-open behavior for Bash and Read chains under the same failures, and `UKIT_NODE_PATH` override.
18
+
5
19
  ## 2.2.3 - 2026-08-26
6
20
 
7
21
  Sessions were stalling mid-task — the model would read source, then simply stop, with the work half done and nothing wrong upstream. This wave stops treating that as a prompting problem. Instructions asking a model to "continue until the edit is made" are advisory by nature: every hidden backend behind `unic-lite` / `unic-code` / `unic-smart` / `unic-vision` reads them slightly differently, and the ones that read them loosely stall. UKit now records what actually happened — real Edit/Write receipts, real verification exit codes — and enforces completion mechanically at the point of stopping, identically on Claude Code and omp. Nothing in the enforcement path branches on provider branding.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.2.3",
3
+ "version": "2.2.4",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex, OpenCode, and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -5,6 +5,7 @@
5
5
  // payloads, executes the same ordered script chains, and translates only the
6
6
  // result fields that omp consumes.
7
7
 
8
+ import fs from 'node:fs';
8
9
  import path from 'node:path';
9
10
  import { fileURLToPath } from 'node:url';
10
11
  import {
@@ -172,7 +173,23 @@ export { translateExecResult };
172
173
 
173
174
  const HOOK_CHAIN_TIMEOUT_MS = 12000;
174
175
 
175
- export async function runScriptChain(pi, scripts, payload, { projectRoot }) {
176
+ function recordHookErrorDiagnostic(projectRoot, sessionId, diagnostic) {
177
+ try {
178
+ const dir = path.join(projectRoot, '.ukit', 'storage', 'cache', 'hook-errors');
179
+ fs.mkdirSync(dir, { recursive: true });
180
+ const safeSession = String(sessionId || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96) || 'unknown';
181
+ fs.appendFileSync(path.join(dir, `${safeSession}.jsonl`), `${JSON.stringify(diagnostic)}\n`, 'utf8');
182
+ } catch {
183
+ // Diagnostics are advisory and must never block or throw.
184
+ }
185
+ }
186
+
187
+ export async function runScriptChain(
188
+ pi,
189
+ scripts,
190
+ payload,
191
+ { projectRoot, failClosedOnTransportError = false },
192
+ ) {
176
193
  const invoked = [];
177
194
  const context = [];
178
195
  if (scripts.length === 0) {
@@ -181,50 +198,64 @@ export async function runScriptChain(pi, scripts, payload, { projectRoot }) {
181
198
 
182
199
  const runnerPath = path.join(projectRoot, '.claude', 'ukit', 'runtime', 'hook-chain-runner.mjs');
183
200
  const scriptPaths = scripts.map((scriptName) => path.join(projectRoot, '.claude', 'hooks', scriptName));
201
+ // Do not blindly trust process.execPath: under a Bun-hosted omp, it points at bun, not node.
202
+ const nodeExecutable = process.env.UKIT_NODE_PATH || process.execPath;
203
+ const startedAt = Date.now();
184
204
  let execResult;
185
205
  try {
186
206
  execResult = await pi.exec(
187
- process.execPath,
207
+ nodeExecutable,
188
208
  [runnerPath, JSON.stringify(payload), ...scriptPaths],
189
209
  { cwd: projectRoot, timeout: HOOK_CHAIN_TIMEOUT_MS },
190
210
  );
191
211
  } catch (error) {
192
212
  execResult = { code: 1, stdout: '', stderr: error?.message ?? String(error), killed: false };
193
213
  }
214
+ const elapsedMs = Date.now() - startedAt;
194
215
 
195
- if (execResult?.killed) {
196
- const firstGate = scripts.find((scriptName) => FAIL_CLOSED_SCRIPTS.has(scriptName));
197
- return firstGate
198
- ? {
199
- block: true,
200
- reason: `${firstGate} hook chain was killed before safety gates completed`,
201
- context,
202
- invoked,
203
- }
204
- : { block: false, context, invoked };
205
- }
206
-
207
- let chainResult;
208
- try {
209
- chainResult = JSON.parse(execResult?.stdout || '{}');
210
- } catch {
211
- chainResult = { results: [], wrapperError: execResult?.stderr || 'invalid hook-chain output' };
216
+ let chainResult = null;
217
+ let parseError = null;
218
+ if (!execResult?.killed) {
219
+ try {
220
+ chainResult = JSON.parse(execResult?.stdout || '{}');
221
+ } catch (error) {
222
+ parseError = error;
223
+ }
212
224
  }
213
225
 
214
- if (chainResult.wrapperError || execResult?.code) {
215
- const firstGate = scripts.find((scriptName) => FAIL_CLOSED_SCRIPTS.has(scriptName));
216
- if (firstGate) {
217
- return {
218
- block: true,
219
- reason: chainResult.wrapperError || execResult?.stderr || `${firstGate} hook chain failed`,
220
- context,
221
- invoked,
222
- };
226
+ const hasUsableResults = Boolean(chainResult) && Array.isArray(chainResult.results) && chainResult.results.length > 0;
227
+ const transportFailed = Boolean(execResult?.killed) || Boolean(parseError) || Boolean(chainResult?.wrapperError) || !hasUsableResults;
228
+
229
+ if (transportFailed) {
230
+ // The aggregate runner produced no verifiable per-script verdict. Never relabel this as a
231
+ // specific fail-closed script's decision -- that script may never have run.
232
+ const diagnostic = {
233
+ ts: Date.now(),
234
+ scripts,
235
+ killed: Boolean(execResult?.killed),
236
+ code: execResult?.code ?? null,
237
+ stdoutLength: (execResult?.stdout || '').length,
238
+ stderr: execResult?.stderr || '',
239
+ elapsedMs,
240
+ nodeExecutable,
241
+ nodeVersion: process.version,
242
+ runnerPath,
243
+ parseError: parseError?.message || null,
244
+ wrapperError: chainResult?.wrapperError || null,
245
+ };
246
+ recordHookErrorDiagnostic(projectRoot, payload.session_id, diagnostic);
247
+ const reason = `UKit OMP hook runner failed before producing a valid result `
248
+ + `(killed=${diagnostic.killed}, code=${diagnostic.code}, elapsedMs=${diagnostic.elapsedMs}, `
249
+ + `runtime=${diagnostic.nodeExecutable}). No safety-gate verdict was available for [${scripts.join(', ')}]. `
250
+ + `See .ukit/storage/cache/hook-errors/.`;
251
+ if (failClosedOnTransportError) {
252
+ return { block: true, reason, context, invoked };
223
253
  }
224
- pi.logger?.warn?.(`[UKit] hook chain failed open: ${chainResult.wrapperError || execResult?.stderr || 'unknown error'}`);
254
+ pi.logger?.warn?.(`[UKit] ${reason}`);
255
+ return { block: false, context, invoked };
225
256
  }
226
257
 
227
- for (const item of chainResult.results ?? []) {
258
+ for (const item of chainResult.results) {
228
259
  const scriptName = item?.scriptName;
229
260
  if (!scriptName || !scripts.includes(scriptName)) continue;
230
261
  invoked.push(scriptName);
@@ -314,7 +345,13 @@ export async function runToolCall(pi, event, { projectRoot, context: extensionCo
314
345
  toolUseId: event.toolCallId,
315
346
  ...metadata,
316
347
  });
317
- const result = await runScriptChain(pi, scriptsForToolCall(toolName), payload, { projectRoot });
348
+ // Only Edit|Write stays fail-closed on a bridge/runner transport failure; Bash and
349
+ // Read|Grep|Glob fail open so diagnosis/recovery tools are never bricked by a runner outage.
350
+ const failClosedOnTransportError = matcherGroupFor(toolName) === 'Edit|Write';
351
+ const result = await runScriptChain(pi, scriptsForToolCall(toolName), payload, {
352
+ projectRoot,
353
+ failClosedOnTransportError,
354
+ });
318
355
  if (!result.block) sendContext(pi, result.context, 'steer');
319
356
  return { ...result, toolName };
320
357
  }