@ngockhoale/ukit 2.7.8 → 2.7.9

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,18 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+
6
+ ## 2.7.9 - 2026-09-22
7
+
8
+ - **User permission mode is never overwritten**: `ukit install`/`ukit update`
9
+ used to rewrite `.claude/settings.json` wholesale, so the template's
10
+ `permissions.defaultMode: "bypassPermissions"` silently clobbered a
11
+ user-chosen mode (e.g. `plan`) on every install. `defaultMode` is now
12
+ user-owned in `merge_env_overwrite_with_backup`: excluded from the drift
13
+ comparison and merged back into the written content. The template default
14
+ still applies on fresh installs or when the user never set a mode.
15
+ (`src/core/diffPlan.js`, +4 tests in `tests/core/diffPlan.test.js`)
16
+
5
17
  ## 2.7.8 - 2026-09-22
6
18
 
7
19
  Stall/hang fixes — cycle C43 (TASK-001..008): the indefinite-stall producers
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.7.8",
3
+ "version": "2.7.9",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,6 +34,54 @@ async function checkLinkStatus(targetPath, linkTarget) {
34
34
  // template, so a template-side change to e.g. CLAUDE_CODE_AUTO_COMPACT_WINDOW still triggers
35
35
  // a real reinstall. Without this carve-out, the managed gateway keys written by the post-apply
36
36
  // step would cause a spurious "update" on every subsequent install.
37
+ //
38
+ // `permissions.defaultMode` is user-owned: the user picks their permission mode
39
+ // (bypassPermissions / plan / acceptEdits / default) and UKit must never change it.
40
+ // It is excluded from the drift comparison AND re-injected into the rendered content
41
+ // before write (mergeSettingsJson), so an install/update can never flip the user's mode.
42
+ // When the user has NOT set it, the template's shipped default applies on create/update.
43
+ const USER_OWNED_PERMISSION_KEYS = new Set(['defaultMode']);
44
+
45
+ function parseSettingsJson(content) {
46
+ if (typeof content !== 'string') return null;
47
+ try {
48
+ const parsed = JSON.parse(content);
49
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ // Strip user-owned permission keys from a parsed permissions object for comparison.
56
+ // Returns a new object; never mutates the input.
57
+ function stripUserOwnedPermissionKeys(permissions) {
58
+ if (!permissions || typeof permissions !== 'object' || Array.isArray(permissions)) {
59
+ return permissions;
60
+ }
61
+ const stripped = { ...permissions };
62
+ for (const key of USER_OWNED_PERMISSION_KEYS) delete stripped[key];
63
+ return stripped;
64
+ }
65
+
66
+ // Merge rendered settings.json with the user's existing file: the rendered template
67
+ // wins every key EXCEPT user-owned permission keys (currently permissions.defaultMode),
68
+ // which keep the value the user set. Returns the merged JSON as a string, or the
69
+ // rendered content unchanged when either side is unparseable.
70
+ export function mergeSettingsJson(existingContent, renderedContent) {
71
+ const existing = parseSettingsJson(existingContent);
72
+ const rendered = parseSettingsJson(renderedContent);
73
+ if (!existing || !rendered) return renderedContent;
74
+ const merged = { ...rendered };
75
+ if (existing.permissions && typeof existing.permissions === 'object' && !Array.isArray(existing.permissions)) {
76
+ const mergedPermissions = { ...(rendered.permissions && typeof rendered.permissions === 'object' ? rendered.permissions : {}) };
77
+ for (const key of USER_OWNED_PERMISSION_KEYS) {
78
+ if (key in existing.permissions) mergedPermissions[key] = existing.permissions[key];
79
+ }
80
+ merged.permissions = mergedPermissions;
81
+ }
82
+ return `${JSON.stringify(merged, null, 2)}\n`;
83
+ }
84
+
37
85
  function settingsJsonIgnoresGatewayEnvDiff(existingContent, renderedContent) {
38
86
  if (typeof existingContent !== 'string' || typeof renderedContent !== 'string') {
39
87
  return false;
@@ -56,7 +104,13 @@ function settingsJsonIgnoresGatewayEnvDiff(existingContent, renderedContent) {
56
104
  for (const key of Object.keys(parsedRendered)) {
57
105
  if (key === 'env') continue;
58
106
  if (!(key in parsedExisting)) return false;
59
- if (JSON.stringify(parsedExisting[key]) !== JSON.stringify(parsedRendered[key])) {
107
+ const renderedValue = key === 'permissions'
108
+ ? stripUserOwnedPermissionKeys(parsedRendered[key])
109
+ : parsedRendered[key];
110
+ const existingValue = key === 'permissions'
111
+ ? stripUserOwnedPermissionKeys(parsedExisting[key])
112
+ : parsedExisting[key];
113
+ if (JSON.stringify(existingValue) !== JSON.stringify(renderedValue)) {
60
114
  return false;
61
115
  }
62
116
  }
@@ -128,7 +182,12 @@ function resolveFileAction(entry, existingContent) {
128
182
  // Settings.json: ignore UKit-managed gateway resilience env keys (post-apply written
129
183
  // and may carry user overrides). All other top-level keys — and the rest of the env
130
184
  // block — must still match the template, so genuine template updates still reinstall.
185
+ // User-owned permission keys (permissions.defaultMode) are excluded from the drift
186
+ // check AND merged back into the content that gets written, so install/update can
187
+ // never flip the user's chosen permission mode.
188
+ const merged = mergeSettingsJson(existingContent, entry.renderedContent);
131
189
  action = settingsJsonIgnoresGatewayEnvDiff(existingContent, entry.renderedContent) ? 'unchanged' : 'update';
190
+ return { ...entry, renderedContent: merged, exists, action, existingContent };
132
191
  } else if (existingContent === entry.renderedContent) {
133
192
  action = 'unchanged';
134
193
  } else if (entry.mergeStrategy === 'skip') {
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  // The shipped runtime carries the single lock implementation; src and the
5
5
  // installed package always ship templates/ together (package.json files list),
6
6
  // so the protocol twin delegates instead of keeping a driftable copy.
7
- import { journalDroppedLockMutation, withAsyncLock } from '../../templates/.claude/ukit/runtime/async-lock.mjs';
7
+ import { journalDroppedLockMutation, withAsyncLock, withTransientFsRetry } from '../../templates/.claude/ukit/runtime/async-lock.mjs';
8
8
 
9
9
  export async function pathExists(targetPath) {
10
10
  try {
@@ -103,21 +103,25 @@ export async function writeFileAtomic(filePath, content) {
103
103
  const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
104
104
 
105
105
  try {
106
+ // C44: temp write, rename, and the EXDEV copy+rm fallback each retry
107
+ // transient kernel errors (EAGAIN/EBUSY/EMFILE/ENFILE/ESTALE) — the same
108
+ // transient-open EAGAIN observed on external APFS volumes under metadata
109
+ // churn. Non-transient codes (EXDEV, EISDIR) throw immediately, unchanged.
106
110
  if (Buffer.isBuffer(content)) {
107
- await fs.writeFile(tempPath, content);
111
+ await withTransientFsRetry(() => fs.writeFile(tempPath, content));
108
112
  } else {
109
- await fs.writeFile(tempPath, content, 'utf8');
113
+ await withTransientFsRetry(() => fs.writeFile(tempPath, content, 'utf8'));
110
114
  }
111
115
  try {
112
- await fs.rename(tempPath, filePath);
116
+ await withTransientFsRetry(() => fs.rename(tempPath, filePath));
113
117
  } catch (renameError) {
114
118
  // EXDEV: the tmp file and the destination sit on different mounts (union
115
119
  // mounts, per-dir bind mounts, tmpfs overlays), so rename cannot link them.
116
120
  // The payload is already fully written — copy it over and unlink the tmp.
117
121
  // Less atomic than rename, but the update must not be silently lost.
118
122
  if (renameError?.code !== 'EXDEV') throw renameError;
119
- await fs.copyFile(tempPath, filePath);
120
- await fs.rm(tempPath, { force: true });
123
+ await withTransientFsRetry(() => fs.copyFile(tempPath, filePath));
124
+ await withTransientFsRetry(() => fs.rm(tempPath, { force: true }));
121
125
  }
122
126
  } catch (error) {
123
127
  try {
@@ -42,6 +42,11 @@ Write `docs/AI_HANDOFF/archive/cycle-NNN.md` (if there is anything worth archivi
42
42
  ```
43
43
  If `archive/` has > 3 files → delete oldest, append 1-line summary to `HISTORY.md`.
44
44
 
45
+ After writing the archive file, **verify the write** — read it back and confirm
46
+ the `ABORTED` heading is present; on mismatch or read failure retry once, on
47
+ second failure stop and report (same rule as handoff-fullstack's "State-file
48
+ write verification").
49
+
45
50
  ## Step 4 — Reset state files
46
51
 
47
52
  ```
@@ -54,6 +59,12 @@ RUN.md → set `Phase: done` (or delete) — a live cursor that is neither `do
54
59
  tasks/TASK-*.md → delete all (keep _TEMPLATE.md)
55
60
  ```
56
61
 
62
+ Every file rewritten above is a state file — **verify the write** on each:
63
+ read it back and confirm the marker you just wrote (empty INDEX header,
64
+ `_(no active cycle)_`, `Phase: done`). On mismatch or read failure retry the
65
+ write once; on second failure STOP and report — never leave unverified state
66
+ files behind, or the next cycle resumes from stale bytes.
67
+
57
68
  ## Step 5 — Report
58
69
 
59
70
  ```
@@ -75,6 +75,23 @@ QuietScans: <n>/<required> # only while sweeping for stragglers near the end
75
75
  This file is the resume contract. It costs one small write per step and is what turns an
76
76
  interrupted run into a continuable one.
77
77
 
78
+ ### State-file write verification — "verify the write"
79
+
80
+ Every Write/Edit to a handoff state file — `INDEX.md`, `ACTIVE.md`, `RUN.md`,
81
+ `SPEC.md`, `PLAN.md`, `tasks/TASK-*.md` — can be silently lost to a transient
82
+ filesystem error (the Write tool's own `open()` has been observed to fail with
83
+ EAGAIN while the write is reported as done). After each such write:
84
+
85
+ 1. **Read it back** and confirm the marker you just wrote is present — the
86
+ `Status:` row, `Phase:`/`Cursor:` line, or section heading the write was
87
+ supposed to change.
88
+ 2. **On mismatch or read failure, retry the write once.**
89
+ 3. **On second failure, STOP the pipeline** and report the file plus the error —
90
+ never continue on unverified state. A stale `INDEX.md`/`RUN.md` is how a run
91
+ ships silent corruption.
92
+
93
+ Wherever a step below says **verify the write**, it means this rule.
94
+
78
95
  ### Resume — when the run is re-invoked mid-flight
79
96
 
80
97
  If `docs/AI_HANDOFF/RUN.md` exists with `Phase:` not `done` or `blocked`, this is a
@@ -240,6 +257,7 @@ The planner agent does the following (use P1 summary — do NOT re-read files):
240
257
  6. **Update `docs/AI_HANDOFF/INDEX.md`** — one row per task, `status=ready`. Every
241
258
  unfinished item from the Phase 0 sweep is either a row here or superseded by a
242
259
  `-R<n>` recovery row.
260
+ Then **verify the write** (read back the new rows).
243
261
 
244
262
  7. **Update `docs/AI_HANDOFF/ACTIVE.md`:**
245
263
  ```
@@ -249,6 +267,7 @@ The planner agent does the following (use P1 summary — do NOT re-read files):
249
267
  Tasks: <N> total
250
268
  Status: planning_done — ready for executor
251
269
  ```
270
+ Then **verify the write** (read back the `Status:` line).
252
271
 
253
272
  8. **Report:** task IDs, dependency graph, recovery/superseded tasks, any
254
273
  `needs_breakdown` tasks + reason.
@@ -444,6 +463,7 @@ already on disk (task files hold the full logs, git holds the code). Do all four
444
463
 
445
464
  2. **Update the run cursor** — rewrite `docs/AI_HANDOFF/RUN.md` with `Phase: I3`,
446
465
  `Cursor: wave <N> done`, `Next: wave <N+1>` (or `I4` if that was the last wave).
466
+ Then **verify the write** (read back the `Phase:`/`Cursor:` lines).
447
467
 
448
468
  3. **Collapse the wave in working memory.** From this point on, refer to the finished wave
449
469
  only by its one-line-per-task summary (`TASK-xxx PASS <files>`). Do not re-read the task
@@ -469,6 +489,7 @@ logs used to cost. That difference is what makes a multi-wave cycle finish in on
469
489
  - PASS tasks → `pending_review`
470
490
  - FAIL tasks → `blocked`
471
491
  - EXECUTOR_MODEL missing → `needs_executor_report`
492
+ Then **verify the write** (read back the updated status column).
472
493
 
473
494
  2. Verify cleanup:
474
495
  ```bash
@@ -647,6 +668,8 @@ conventions so a future session needs no transcript to understand the cycle:
647
668
  summary in `HISTORY.md` and remove it.
648
669
  3. Reset `ACTIVE.md` to its empty template; clear `INDEX.md` rows to a fresh header;
649
670
  remove `tasks/TASK-*.md`; clear `PLAN.md`/`SPEC.md` (templates stay).
671
+ Then **verify the write** on each reset file (read back the empty header /
672
+ template marker).
650
673
  4. Commit the docs + archive changes:
651
674
  `git add -A && git commit -m "handoff: finalize + archive cycle <ID>"`
652
675
 
@@ -654,6 +677,8 @@ conventions so a future session needs no transcript to understand the cycle:
654
677
 
655
678
  Set `Phase: done` in `docs/AI_HANDOFF/RUN.md`, disarm any watchdog job armed at the
656
679
  start, and emit the Final Report below.
680
+ Then **verify the write** (read back `Phase: done`) — a lost cursor write
681
+ leaves the next session resuming a finished run.
657
682
 
658
683
  ---
659
684
 
@@ -1,6 +1,8 @@
1
1
  import { analyzeTextFile, publicTextProfile } from '../runtime/text-profile.mjs';
2
+ import { TRANSIENT_FS_CODES, withTransientFsRetry } from '../runtime/async-lock.mjs';
2
3
  import { fileURLToPath } from 'node:url';
3
4
  import fsSync from 'node:fs';
5
+ import fs from 'node:fs/promises';
4
6
  import path from 'node:path';
5
7
  import {
6
8
  classifySafePatchRisk,
@@ -8,7 +10,6 @@ import {
8
10
  isSafePatchAdvisoryOnly,
9
11
  lineNumberForIndex,
10
12
  parseJsonInput,
11
- pathExists,
12
13
  readRuntimeSafePatchConfig,
13
14
  resolveProjectFile,
14
15
  summarizeSnippet,
@@ -40,6 +41,34 @@ if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
40
41
  }, HOOK_DEADLINE_MS).unref();
41
42
  }
42
43
 
44
+ // C44: the target reads below can surface a transient kernel EAGAIN on external
45
+ // volumes. Retry them inside a bounded slice of the hook deadline; on final
46
+ // failure rethrow with a transient-fs classification so the crash handler's
47
+ // stderr is identifiable (and the bridge's fail-open translation can match it).
48
+ const TRANSIENT_READ_DEADLINE_MS = 800;
49
+
50
+ function classifyTransientFsError(error) {
51
+ if (TRANSIENT_FS_CODES.has(error?.code)) {
52
+ throw new Error(`transient-fs ${error.code} on target read: ${error.message}`);
53
+ }
54
+ throw error;
55
+ }
56
+
57
+ // Existence probe that surfaces transient codes to the retry wrapper. The
58
+ // shared pathExists() helper swallows every error into `false`, which would
59
+ // silently degrade a transient EAGAIN into "new file" and skip the stale-spec
60
+ // check on a file that is actually there — non-transient misses keep that
61
+ // same false mapping here.
62
+ async function probeExists(filePath) {
63
+ try {
64
+ await fs.access(filePath);
65
+ return true;
66
+ } catch (error) {
67
+ if (TRANSIENT_FS_CODES.has(error?.code)) throw error;
68
+ return false;
69
+ }
70
+ }
71
+
43
72
  function getToolName(payload = {}) {
44
73
  return String(payload.tool_name || payload.tool || payload.name || '').trim();
45
74
  }
@@ -66,12 +95,18 @@ export async function checkStaleSpec({ projectRoot = process.cwd(), payload = {}
66
95
  const filePath = getFilePath(payload);
67
96
  if (!filePath) return { status: 'skipped', reason: 'no-file' };
68
97
  const resolved = resolveProjectFile(projectRoot, filePath);
69
- const exists = await pathExists(resolved.absolute);
98
+ const exists = await withTransientFsRetry(
99
+ () => probeExists(resolved.absolute),
100
+ { deadlineMs: TRANSIENT_READ_DEADLINE_MS },
101
+ ).catch(classifyTransientFsError);
70
102
 
71
103
  let profile = null;
72
104
  let risk = classifySafePatchRisk(resolved.relative, null, config);
73
105
  if (exists) {
74
- profile = await analyzeTextFile(resolved.absolute);
106
+ profile = await withTransientFsRetry(
107
+ () => analyzeTextFile(resolved.absolute),
108
+ { deadlineMs: TRANSIENT_READ_DEADLINE_MS },
109
+ ).catch(classifyTransientFsError);
75
110
  risk = classifySafePatchRisk(resolved.relative, profile, config);
76
111
  }
77
112
  const strict = Boolean(config.strictSharedRisk) && risk.strict;
@@ -94,6 +94,58 @@ function sleepWithAbort(ms, signal) {
94
94
  });
95
95
  }
96
96
 
97
+ // Transient kernel-level fs failures worth a bounded retry (C44): observed on
98
+ // external APFS volumes under metadata churn — `open()`/`mkdir()`/`rename()`
99
+ // can return EAGAIN once and succeed on immediate retry. EEXIST is deliberately
100
+ // absent: it is the lock-contend signal, not a flake.
101
+ export const TRANSIENT_FS_CODES = new Set(['EAGAIN', 'EBUSY', 'EMFILE', 'ENFILE', 'ESTALE']);
102
+
103
+ /**
104
+ * Retry an fs operation that can surface a transient kernel error
105
+ * (TRANSIENT_FS_CODES) with jittered exponential backoff. Non-transient codes
106
+ * throw immediately — EEXIST contention and EISDIR mapping are unaffected.
107
+ * The LAST error rethrows on retry exhaustion, deadline, or abort: a retry
108
+ * never extends the caller past `deadlineMs` and never swallows the failure.
109
+ * @param {() => Promise<*>} op
110
+ * @param {{
111
+ * retries?: number,
112
+ * baseDelayMs?: number,
113
+ * maxDelayMs?: number,
114
+ * deadlineMs?: number,
115
+ * signal?: AbortSignal,
116
+ * }} [options]
117
+ * @returns {Promise<*>} op's resolved value
118
+ */
119
+ export async function withTransientFsRetry(op, {
120
+ retries = 3,
121
+ baseDelayMs = 15,
122
+ maxDelayMs = 150,
123
+ deadlineMs = Infinity,
124
+ signal,
125
+ } = {}) {
126
+ const startedAt = Date.now();
127
+ const maxAttempts = Number.isFinite(retries) && retries >= 0 ? Math.floor(retries) + 1 : 1;
128
+ let lastError = null;
129
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
130
+ if (signal?.aborted) {
131
+ throw lastError ?? signal.reason ?? new Error('withTransientFsRetry aborted');
132
+ }
133
+ try {
134
+ return await op();
135
+ } catch (error) {
136
+ lastError = error;
137
+ if (!TRANSIENT_FS_CODES.has(error?.code) || attempt + 1 >= maxAttempts) throw error;
138
+ const remaining = deadlineMs - (Date.now() - startedAt);
139
+ if (remaining <= 0) throw error;
140
+ const backoff = Math.min(baseDelayMs * (2 ** attempt), maxDelayMs);
141
+ const waitMs = Math.min(backoff * (0.5 + Math.random()), remaining);
142
+ const sleptFully = await sleepWithAbort(Math.max(0, waitMs), signal);
143
+ if (!sleptFully || Date.now() - startedAt >= deadlineMs) throw error;
144
+ }
145
+ }
146
+ throw lastError;
147
+ }
148
+
97
149
  function isPidAlive(pid) {
98
150
  try {
99
151
  process.kill(pid, 0);
@@ -153,9 +205,9 @@ function recordedProcessGone(owner) {
153
205
  return Math.abs(actual - stamped) > PID_START_TOLERANCE_MS;
154
206
  }
155
207
 
156
- async function readLockOwner(lockPath) {
208
+ async function readLockOwner(lockPath, retry = withTransientFsRetry) {
157
209
  try {
158
- const raw = JSON.parse(await fs.readFile(path.join(lockPath, 'owner'), 'utf8'));
210
+ const raw = JSON.parse(await retry(() => fs.readFile(path.join(lockPath, 'owner'), 'utf8')));
159
211
  const pid = Number(raw?.pid);
160
212
  const pstart = Number(raw?.pstart);
161
213
  return Number.isInteger(pid) && pid > 0
@@ -177,9 +229,9 @@ function sameLockOwner(left, right) {
177
229
  return left.pid === right.pid && left.token === right.token;
178
230
  }
179
231
 
180
- async function readReclaimOwner(lockPath) {
232
+ async function readReclaimOwner(lockPath, retry = withTransientFsRetry) {
181
233
  try {
182
- const raw = JSON.parse(await fs.readFile(path.join(lockPath, RECLAIM_FILE), 'utf8'));
234
+ const raw = JSON.parse(await retry(() => fs.readFile(path.join(lockPath, RECLAIM_FILE), 'utf8')));
183
235
  const pid = Number(raw?.pid);
184
236
  const pstart = Number(raw?.pstart);
185
237
  return Number.isInteger(pid) && pid > 0
@@ -194,19 +246,20 @@ async function readReclaimOwner(lockPath) {
194
246
  }
195
247
  }
196
248
 
249
+
197
250
  // Stale observers must claim the existing lock before removing it. The claim lives
198
251
  // inside the old generation, so a second observer cannot remove that generation while
199
252
  // the first observer is between its owner check and rm(). This closes the TOCTOU race
200
253
  // where a delayed stale observer deleted a freshly acquired successor lock.
201
- async function claimReclaim(lockPath, ownerToken, staleMs) {
254
+ async function claimReclaim(lockPath, ownerToken, staleMs, retry = withTransientFsRetry) {
202
255
  const reclaimPath = path.join(lockPath, RECLAIM_FILE);
203
256
  try {
204
- const handle = await fs.open(reclaimPath, 'wx');
257
+ const handle = await retry(() => fs.open(reclaimPath, 'wx'));
205
258
  try {
206
- await handle.writeFile(
259
+ await retry(() => handle.writeFile(
207
260
  `${JSON.stringify({ pid: process.pid, token: ownerToken, ts: Date.now(), pstart: PROCESS_START_MS })}\n`,
208
261
  'utf8',
209
- );
262
+ ));
210
263
  } finally {
211
264
  await handle.close();
212
265
  }
@@ -217,11 +270,11 @@ async function claimReclaim(lockPath, ownerToken, staleMs) {
217
270
  // A killed reclaimer can leave its claim behind. Only clear an old claim whose
218
271
  // recorded process is gone; a live claim remains the exclusive reclaim authority.
219
272
  try {
220
- const stat = await fs.stat(reclaimPath);
273
+ const stat = await retry(() => fs.stat(reclaimPath));
221
274
  if (Date.now() - stat.mtimeMs > staleMs) {
222
- const claim = await readReclaimOwner(lockPath);
275
+ const claim = await readReclaimOwner(lockPath, retry);
223
276
  if (!claim || recordedProcessGone(claim)) {
224
- await fs.rm(reclaimPath, { force: true });
277
+ await retry(() => fs.rm(reclaimPath, { force: true }));
225
278
  }
226
279
  }
227
280
  } catch {
@@ -231,38 +284,38 @@ async function claimReclaim(lockPath, ownerToken, staleMs) {
231
284
  }
232
285
  }
233
286
 
234
- async function releaseReclaimClaim(lockPath, ownerToken) {
287
+ async function releaseReclaimClaim(lockPath, ownerToken, retry = withTransientFsRetry) {
235
288
  try {
236
- const claim = await readReclaimOwner(lockPath);
289
+ const claim = await readReclaimOwner(lockPath, retry);
237
290
  if (claim?.token === ownerToken) {
238
- await fs.rm(path.join(lockPath, RECLAIM_FILE), { force: true });
291
+ await retry(() => fs.rm(path.join(lockPath, RECLAIM_FILE), { force: true }));
239
292
  }
240
293
  } catch {
241
294
  // best-effort claim release; the stale-claim path handles an interrupted cleanup
242
295
  }
243
296
  }
244
297
 
245
- async function quarantineReclaim(lockPath, owner, ownerToken) {
298
+ async function quarantineReclaim(lockPath, owner, ownerToken, retry = withTransientFsRetry) {
246
299
  const quarantinePath = `${lockPath}.reclaim-${ownerToken}`;
247
300
  try {
248
301
  // The claim serializes stale observers. Re-read before the atomic rename so an
249
302
  // observer never detaches a generation different from the one it validated.
250
- const current = await readLockOwner(lockPath);
303
+ const current = await readLockOwner(lockPath, retry);
251
304
  if (!sameLockOwner(current, owner)) {
252
- await releaseReclaimClaim(lockPath, ownerToken);
305
+ await releaseReclaimClaim(lockPath, ownerToken, retry);
253
306
  return false;
254
307
  }
255
308
  // Rename is atomic within the lock's parent directory: the old generation is
256
309
  // detached as one filesystem operation, so a successor created at lockPath can
257
310
  // never be reached by cleanup of this quarantined generation.
258
- await fs.rename(lockPath, quarantinePath);
259
- await fs.rm(quarantinePath, { recursive: true, force: true });
311
+ await retry(() => fs.rename(lockPath, quarantinePath));
312
+ await retry(() => fs.rm(quarantinePath, { recursive: true, force: true }));
260
313
  return true;
261
314
  } catch {
262
315
  // Never recursively remove lockPath here. If rename lost a race or failed, the
263
316
  // original path belongs to whoever currently holds it; a later poll can retry.
264
317
  try {
265
- await fs.rm(quarantinePath, { recursive: true, force: true });
318
+ await retry(() => fs.rm(quarantinePath, { recursive: true, force: true }));
266
319
  } catch {
267
320
  // best-effort cleanup of only this acquisition's quarantine path
268
321
  }
@@ -356,20 +409,31 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
356
409
  // True only when THIS acquisition holds a lock it can prove it owns: the atomic
357
410
  // mkdir succeeded AND the owner stamp is on disk. Release is verified against it.
358
411
  let owned = false;
412
+ // C44: every lock-protocol fs op retries transient kernel errors (EAGAIN et
413
+ // al.) inside the caller's remaining acquisition budget — a kernel flake must
414
+ // never escape as an untyped throw while budget remains, and a retry must
415
+ // never extend the wait past it.
416
+ const retry = (op) => withTransientFsRetry(op, {
417
+ deadlineMs: Math.max(0, budget - (Date.now() - startedAt)),
418
+ signal,
419
+ });
420
+ // Post-acquisition ops (release, claim cleanup) run after the budget is spent;
421
+ // they get the fixed cleanup reserve instead of the acquisition slice.
422
+ const releaseRetry = (op) => withTransientFsRetry(op, { deadlineMs: LOCK_RESERVE_MS });
359
423
 
360
424
  while (true) {
361
425
  try {
362
426
  // The lock parent must exist before the atomic acquire — a first-ever run in a
363
427
  // fresh project would otherwise fail mkdir with ENOENT.
364
- await fs.mkdir(path.dirname(lockPath), { recursive: true });
365
- await fs.mkdir(lockPath); // atomic acquire — EEXIST means another holder exists
428
+ await retry(() => fs.mkdir(path.dirname(lockPath), { recursive: true }));
429
+ await retry(() => fs.mkdir(lockPath)); // atomic acquire — EEXIST means another holder exists
366
430
  inProcessLockHolders.set(lockPath, ownerToken);
367
431
  try {
368
- await fs.writeFile(
432
+ await retry(() => fs.writeFile(
369
433
  path.join(lockPath, 'owner'),
370
434
  `${JSON.stringify({ pid: process.pid, token: ownerToken, ts: Date.now(), pstart: PROCESS_START_MS })}\n`,
371
435
  'utf8',
372
- );
436
+ ));
373
437
  } catch {
374
438
  // Fail closed: an unstamped lock is not ours to enter. Running the callback
375
439
  // anyway would leave an ownerless directory, and every other process reads a
@@ -377,11 +441,11 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
377
441
  // lock mid-critical-section and letting two mutations interleave. Undo the
378
442
  // acquire and report the typed busy outcome callers already handle.
379
443
  try {
380
- const current = await readLockOwner(lockPath);
444
+ const current = await readLockOwner(lockPath, retry);
381
445
  // Never remove a directory some other holder has since stamped (only
382
446
  // possible if this one was reclaimed in the window above).
383
447
  if (!current || current.token === ownerToken) {
384
- await fs.rm(lockPath, { recursive: true, force: true });
448
+ await retry(() => fs.rm(lockPath, { recursive: true, force: true }));
385
449
  }
386
450
  } catch {
387
451
  // best-effort undo; a leftover dir is unheld and reclaimed by the next waiter
@@ -408,12 +472,12 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
408
472
  // (stamped pstart no longer matches the running process).
409
473
  let reclaimed = false;
410
474
  try {
411
- const stat = await fs.stat(lockPath);
475
+ const stat = await retry(() => fs.stat(lockPath));
412
476
  const ageMs = Date.now() - stat.mtimeMs;
413
477
  const remainingMs = budget - (Date.now() - startedAt);
414
478
  const effectiveStaleMs = remainingMs > 0 ? Math.min(stale, remainingMs) : stale;
415
479
  if (ageMs > effectiveStaleMs) {
416
- const owner = await readLockOwner(lockPath);
480
+ const owner = await readLockOwner(lockPath, retry);
417
481
  const liveInProcess = inProcessLockHolders.has(lockPath);
418
482
  // An ownerless lock may be a holder mid-stamp — only the FULL stale
419
483
  // threshold proves abandonment there. A stamped owner provably gone
@@ -424,11 +488,11 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
424
488
  : owner.pid === process.pid
425
489
  ? !liveInProcess
426
490
  : recordedProcessGone(owner);
427
- if (reclaimable && await claimReclaim(lockPath, ownerToken, stale)) {
491
+ if (reclaimable && await claimReclaim(lockPath, ownerToken, stale, retry)) {
428
492
  // Detach and clean only the generation that was validated. The atomic rename
429
493
  // makes this safe even when another process acquires lockPath immediately
430
494
  // after the stale generation is removed.
431
- reclaimed = await quarantineReclaim(lockPath, owner, ownerToken);
495
+ reclaimed = await quarantineReclaim(lockPath, owner, ownerToken, retry);
432
496
  }
433
497
  }
434
498
  } catch {
@@ -461,9 +525,9 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
461
525
  // Remove the lock only if THIS acquisition still owns it: after a stale
462
526
  // reclaim another holder may already own the dir, and deleting it would
463
527
  // unlock their critical section for a third waiter.
464
- const current = await readLockOwner(lockPath);
528
+ const current = await readLockOwner(lockPath, releaseRetry);
465
529
  if (current && current.token === ownerToken) {
466
- await fs.rm(lockPath, { recursive: true, force: true });
530
+ await releaseRetry(() => fs.rm(lockPath, { recursive: true, force: true }));
467
531
  }
468
532
  } catch {
469
533
  // best-effort release; a leaked dir is reclaimed by the next waiter
@@ -42,6 +42,7 @@ function inlineReference(payloadText, bytes) {
42
42
  path: null,
43
43
  bytes,
44
44
  cleanup() { /* nothing was staged — O(1) by construction */ },
45
+ async cleanupAsync() { /* nothing was staged — O(1) by construction */ },
45
46
  };
46
47
  }
47
48
 
@@ -125,8 +126,15 @@ export async function createPayloadReferenceAsync(text, {
125
126
  text: payloadText,
126
127
  path: finalPath,
127
128
  bytes,
129
+ // TASK-002 (OMP-4): the bridge request path awaits cleanupAsync(); the
130
+ // sync-named cleanup() is kept for tests/CLI but must not run a blocking
131
+ // fs call on the omp host loop either — it delegates to the same async
132
+ // removal fire-and-forget.
128
133
  cleanup() {
129
- try { fs.rmSync(finalPath, { force: true }); } catch { /* best effort */ }
134
+ fsp.rm(finalPath, { force: true }).catch(() => { /* best effort */ });
135
+ },
136
+ async cleanupAsync() {
137
+ try { await fsp.rm(finalPath, { force: true }); } catch { /* best effort */ }
130
138
  },
131
139
  };
132
140
  })();
@@ -163,6 +171,72 @@ export function probePayloadIntegrity(reference) {
163
171
  }
164
172
  }
165
173
 
174
+ // probePayloadIntegrityAsync(reference) -> Promise<null | 'missing' | 'partial'>
175
+ //
176
+ // TASK-002 (OMP-4): async twin of probePayloadIntegrity. The sync variant's
177
+ // statSync runs on the CALLER's event loop — on the omp host a stalled mount
178
+ // freezes the whole app (the socket-closed bug class). Identical verdicts.
179
+ export async function probePayloadIntegrityAsync(reference) {
180
+ if (!reference || reference.mode !== 'file' || !reference.path) return null;
181
+ try {
182
+ const stats = await fsp.stat(reference.path);
183
+ return stats.size === reference.bytes ? null : 'partial';
184
+ } catch {
185
+ return 'missing';
186
+ }
187
+ }
188
+
189
+ // sweepStalePayloadsAsync(dir, {now, maxAgeMs, maxEntries}) ->
190
+ // Promise<{sampled, scanned, removed}>
191
+ //
192
+ // TASK-002 (OMP-4): async twin of sweepStalePayloads — same bounded semantics
193
+ // through fs.promises so the omp host loop never blocks on directory work.
194
+ export async function sweepStalePayloadsAsync(dir, {
195
+ now = Date.now,
196
+ maxAgeMs = SWEEP_MAX_AGE_MS,
197
+ maxEntries = SWEEP_MAX_ENTRIES,
198
+ } = {}) {
199
+ let names;
200
+ try {
201
+ names = await fsp.readdir(dir);
202
+ } catch {
203
+ return { sampled: true, scanned: 0, removed: 0 };
204
+ }
205
+ const cutoff = now() - maxAgeMs;
206
+ let scanned = 0;
207
+ let removed = 0;
208
+ for (const name of names) {
209
+ if (scanned >= maxEntries) break;
210
+ scanned += 1;
211
+ if (!name.endsWith('.json') && !name.endsWith('.tmp')) continue;
212
+ const filePath = path.join(dir, name);
213
+ try {
214
+ if ((await fsp.stat(filePath)).mtimeMs < cutoff) {
215
+ await fsp.rm(filePath, { force: true });
216
+ removed += 1;
217
+ }
218
+ } catch { /* raced away — fine */ }
219
+ }
220
+ return { sampled: true, scanned, removed };
221
+ }
222
+
223
+ // maybeSweepStalePayloadsAsync(dir, {probability, random, ...}) ->
224
+ // Promise<sweep report>
225
+ //
226
+ // TASK-002 (OMP-4): async twin of maybeSweepStalePayloads — identical sampled
227
+ // gate, delegating to sweepStalePayloadsAsync.
228
+ export async function maybeSweepStalePayloadsAsync(dir, {
229
+ probability,
230
+ random = Math.random,
231
+ now,
232
+ maxAgeMs,
233
+ maxEntries,
234
+ } = {}) {
235
+ const p = Number.isFinite(probability) ? Math.min(1, Math.max(0, probability)) : sweepProbabilityFromEnv();
236
+ if (random() >= p) return { sampled: false, scanned: 0, removed: 0 };
237
+ return sweepStalePayloadsAsync(dir, { now, maxAgeMs, maxEntries });
238
+ }
239
+
166
240
  // sweepStalePayloads(dir, {now, maxAgeMs, maxEntries}) -> {sampled, scanned, removed}
167
241
  //
168
242
  // Bounded: processes at most maxEntries directory entries regardless of how many
@@ -20,8 +20,8 @@ import {
20
20
  import {
21
21
  PAYLOAD_INLINE_MAX_BYTES,
22
22
  createPayloadReferenceAsync,
23
- maybeSweepStalePayloads,
24
- probePayloadIntegrity,
23
+ maybeSweepStalePayloadsAsync,
24
+ probePayloadIntegrityAsync,
25
25
  } from '../../../.claude/ukit/runtime/hook-payload-store.mjs';
26
26
  // TASK-018 review fix round 1: the chain budget is resolved by ONE shared module,
27
27
  // so the runner's inner deadline and this bridge's outer pi.exec timeout can never
@@ -146,6 +146,17 @@ function classifyFailure(scriptName) {
146
146
  // closed (the .sh thin wrapper is still invocable as a fallback path).
147
147
  const TIMEOUT_STAYS_CLOSED = new Set(['block-dangerous.sh', 'block-dangerous.mjs']);
148
148
 
149
+ // TASK-002 (OMP-4): a hook crash or transport failure whose stderr is a
150
+ // transient filesystem error (EAGAIN/EBUSY/EMFILE/ENFILE/ESTALE — the
151
+ // stalled-external-mount class) produced NO verdict, so it must degrade to a
152
+ // loud "could not verify" warning instead of a block showing raw errno text.
153
+ // The match is deliberately conservative: the errno code must appear in an
154
+ // fs-syscall context (open/read/write/mkdir/stat/rename/unlink/rm), in either
155
+ // order, or the Node "resource temporarily unavailable" phrasing — a genuine
156
+ // block reason containing e.g. "again" in prose never downgrades. ONE pattern
157
+ // shared by translateExecResult and runScriptChain.
158
+ const TRANSIENT_INFRA_STDERR_RE = /\bresource temporarily unavailable\b|\b(?:EAGAIN|EBUSY|EMFILE|ENFILE|ESTALE)\b[\s\S]*?\b(?:open|read|write|mkdir|stat|rename|unlink|rm)\b|\b(?:open|read|write|mkdir|stat|rename|unlink|rm)\b[\s\S]*?\b(?:EAGAIN|EBUSY|EMFILE|ENFILE|ESTALE)\b/i;
159
+
149
160
  // TASK-018: the hook-chain-runner's failure taxonomy. Infrastructure outcomes
150
161
  // (overflow / timeout / signal / budget-exhausted) produced NO verdict, so their
151
162
  // captured output is untrustworthy and never reaches the model context, a block
@@ -337,6 +348,18 @@ function translateExecResult(scriptName, execResult) {
337
348
  }
338
349
  return { block: true, reason: stderr || `${scriptName} exited 2 (blocked)`, stdout, stderr };
339
350
  }
351
+ // TASK-002 (OMP-4): a crash whose stderr is a transient fs error produced no
352
+ // verdict — it is an infrastructure event, not a safety decision. Fail open
353
+ // loudly instead of blocking on raw errno text. TIMEOUT_STAYS_CLOSED scripts
354
+ // (block-dangerous .sh/.mjs) keep their never-fail-open contract.
355
+ if (TRANSIENT_INFRA_STDERR_RE.test(stderr) && !TIMEOUT_STAYS_CLOSED.has(scriptName)) {
356
+ return {
357
+ block: false,
358
+ warning: `${scriptName} exited ${code} with a transient filesystem error — treated as "could not verify", not as a block (an infrastructure event, not a verdict): ${redactDiagnosticText(stderr) || 'no stderr'}`,
359
+ stdout,
360
+ stderr,
361
+ };
362
+ }
340
363
  if (classifyFailure(scriptName) === 'closed') {
341
364
  return {
342
365
  block: true,
@@ -380,17 +403,18 @@ function hookErrorsDirFor(projectRoot) {
380
403
  }
381
404
 
382
405
  // Bounded work on THIS session's file only — identical keep-newest-half shape
383
- // as hook-telemetry's rotateIfNeeded.
384
- function rotateHookErrorFileIfNeeded(filePath, incomingBytes, maxBytes) {
406
+ // as hook-telemetry's rotateIfNeeded. TASK-002 (OMP-4): async fs only — a
407
+ // stalled mount must never freeze the omp host loop.
408
+ async function rotateHookErrorFileIfNeeded(filePath, incomingBytes, maxBytes) {
385
409
  let size = 0;
386
410
  try {
387
- size = fs.statSync(filePath).size;
411
+ size = (await fs.promises.stat(filePath)).size;
388
412
  } catch {
389
413
  return; // first row for this session
390
414
  }
391
415
  if (size + incomingBytes <= maxBytes) return;
392
416
  try {
393
- const lines = fs.readFileSync(filePath, 'utf8').split('\n');
417
+ const lines = (await fs.promises.readFile(filePath, 'utf8')).split('\n');
394
418
  if (lines.length && lines[lines.length - 1] === '') lines.pop();
395
419
  const keepBudget = Math.floor(maxBytes / 2);
396
420
  const keep = [];
@@ -401,16 +425,17 @@ function rotateHookErrorFileIfNeeded(filePath, incomingBytes, maxBytes) {
401
425
  keep.unshift(lines[i]);
402
426
  kept += lineBytes;
403
427
  }
404
- fs.writeFileSync(filePath, keep.length ? `${keep.join('\n')}\n` : '', 'utf8');
428
+ await fs.promises.writeFile(filePath, keep.length ? `${keep.join('\n')}\n` : '', 'utf8');
405
429
  } catch {
406
430
  // Rotation failed; drop this row rather than grow past the cap.
407
431
  }
408
432
  }
409
433
 
410
434
  // sweepHookErrorsDir(dir, {now, maxAgeMs, maxFiles, maxEntries, maxRemovals}) ->
411
- // { scanned, removed } — bounded: never scans or removes more than the caps,
412
- // so a pre-existing oversized dir is amortized down across sampled sweeps.
413
- function sweepHookErrorsDir(dir, {
435
+ // Promise<{ scanned, removed }> — bounded: never scans or removes more than
436
+ // the caps, so a pre-existing oversized dir is amortized down across sampled
437
+ // sweeps. TASK-002 (OMP-4): async fs only — host loop must never block.
438
+ async function sweepHookErrorsDir(dir, {
414
439
  now = Date.now,
415
440
  maxAgeMs = HOOK_ERROR_MAX_AGE_MS,
416
441
  maxFiles = HOOK_ERROR_MAX_FILES,
@@ -419,7 +444,7 @@ function sweepHookErrorsDir(dir, {
419
444
  } = {}) {
420
445
  let names;
421
446
  try {
422
- names = fs.readdirSync(dir);
447
+ names = await fs.promises.readdir(dir);
423
448
  } catch {
424
449
  return { scanned: 0, removed: 0 };
425
450
  }
@@ -435,7 +460,7 @@ function sweepHookErrorsDir(dir, {
435
460
  if (scanned >= maxEntries) break;
436
461
  scanned += 1;
437
462
  try {
438
- const stats = fs.statSync(path.join(dir, name));
463
+ const stats = await fs.promises.stat(path.join(dir, name));
439
464
  if (stats.isFile()) entries.push({ name, mtimeMs: stats.mtimeMs });
440
465
  } catch { /* raced away — fine */ }
441
466
  }
@@ -449,7 +474,7 @@ function sweepHookErrorsDir(dir, {
449
474
  if (removed >= maxRemovals) break;
450
475
  if (removed < overflow || entry.mtimeMs < cutoff) {
451
476
  try {
452
- fs.rmSync(path.join(dir, entry.name), { force: true });
477
+ await fs.promises.rm(path.join(dir, entry.name), { force: true });
453
478
  removed += 1;
454
479
  } catch { /* raced away — fine */ }
455
480
  }
@@ -463,18 +488,18 @@ function hookErrorsSweepProbabilityFromEnv() {
463
488
  return Math.min(1, Math.max(0, raw));
464
489
  }
465
490
 
466
- function recordHookErrorDiagnostic(projectRoot, sessionId, diagnostic, { maxBytes = HOOK_ERROR_MAX_BYTES } = {}) {
491
+ async function recordHookErrorDiagnostic(projectRoot, sessionId, diagnostic, { maxBytes = HOOK_ERROR_MAX_BYTES } = {}) {
467
492
  try {
468
493
  const dir = hookErrorsDirFor(projectRoot);
469
- fs.mkdirSync(dir, { recursive: true });
494
+ await fs.promises.mkdir(dir, { recursive: true });
470
495
  const safeSession = String(sessionId || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96) || 'unknown';
471
496
  const filePath = path.join(dir, `${safeSession}.jsonl`);
472
497
  const line = `${JSON.stringify(diagnostic)}\n`;
473
- rotateHookErrorFileIfNeeded(filePath, Buffer.byteLength(line, 'utf8'), maxBytes);
474
- fs.appendFileSync(filePath, line, 'utf8');
498
+ await rotateHookErrorFileIfNeeded(filePath, Buffer.byteLength(line, 'utf8'), maxBytes);
499
+ await fs.promises.appendFile(filePath, line, 'utf8');
475
500
  // Sampled bounded dir sweep — amortizes down any pre-existing oversized dir.
476
501
  if (Math.random() < hookErrorsSweepProbabilityFromEnv()) {
477
- sweepHookErrorsDir(dir);
502
+ await sweepHookErrorsDir(dir);
478
503
  }
479
504
  } catch {
480
505
  // Diagnostics are advisory and must never block or throw.
@@ -519,11 +544,11 @@ function payloadsDirFor(projectRoot) {
519
544
  }
520
545
 
521
546
  function schedulePayloadSweep(projectRoot) {
522
- // Deferred: runs after the current turn settles, never inside the chain request.
547
+ // Deferred: runs after the current turn settles, never inside the chain
548
+ // request. Async fs only — a stalled mount must not freeze the host loop.
523
549
  const timer = setTimeout(() => {
524
- try {
525
- maybeSweepStalePayloads(payloadsDirFor(projectRoot));
526
- } catch { /* deferred sweeping is best effort */ }
550
+ maybeSweepStalePayloadsAsync(payloadsDirFor(projectRoot))
551
+ .catch(() => { /* deferred sweeping is best effort */ });
527
552
  }, 0);
528
553
  timer.unref?.();
529
554
  }
@@ -563,8 +588,8 @@ export async function runScriptChain(
563
588
  // TASK-031: verify the staged payload survived the chain intact BEFORE removing it —
564
589
  // a file that vanished or was truncated mid-flight means the scripts ran against a
565
590
  // different payload than the host captured, so their verdicts are void.
566
- payloadProbe = probePayloadIntegrity(payloadReference);
567
- payloadReference.cleanup();
591
+ payloadProbe = await probePayloadIntegrityAsync(payloadReference);
592
+ await payloadReference.cleanupAsync();
568
593
  if (payloadReference.mode === 'file') schedulePayloadSweep(projectRoot);
569
594
  }
570
595
  const elapsedMs = Date.now() - startedAt;
@@ -609,7 +634,7 @@ export async function runScriptChain(
609
634
  parseError: parseError?.message || null,
610
635
  wrapperError: chainResult?.wrapperError || null,
611
636
  };
612
- recordHookErrorDiagnostic(projectRoot, payload.session_id, diagnostic);
637
+ await recordHookErrorDiagnostic(projectRoot, payload.session_id, diagnostic);
613
638
  // TASK-031: a staged payload that was lost or corrupted mid-chain voids every
614
639
  // verdict below it. Chains that must not fail open on an unverifiable verdict
615
640
  // (Edit|Write transport policy, and block-dangerous's never-fail-open rule)
@@ -640,6 +665,15 @@ export async function runScriptChain(
640
665
  + `(killed=${diagnostic.killed}, code=${diagnostic.code}, elapsedMs=${diagnostic.elapsedMs}, `
641
666
  + `runtime=${diagnostic.nodeExecutable}). No safety-gate verdict was available for [${scripts.join(', ')}]. `
642
667
  + `See .ukit/storage/cache/hook-errors/.${nodePathHint}`;
668
+ // TASK-002 (OMP-4): a transport failure whose stderr is a transient fs
669
+ // error produced no verdict — an infrastructure event, not a safety
670
+ // decision. Even on a fail-closed chain it degrades to a loud warning
671
+ // instead of a block showing raw errno text. A staged-payload integrity
672
+ // failure (payloadProbe !== null) already returned above and stays closed.
673
+ if (failClosedOnTransportError && TRANSIENT_INFRA_STDERR_RE.test(diagnostic.stderrExcerpt)) {
674
+ pi.logger?.warn?.(`[UKit] ${reason} Transient filesystem error — treated as "could not verify", not as a block.`);
675
+ return { block: false, context, invoked };
676
+ }
643
677
  if (failClosedOnTransportError) {
644
678
  return { block: true, reason, context, invoked };
645
679
  }