@ngockhoale/ukit 2.7.7 → 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.
Files changed (35) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/package.json +1 -1
  3. package/src/context/detectProjectContext.js +5 -0
  4. package/src/core/codeintel/invalidation.js +4 -0
  5. package/src/core/diffPlan.js +60 -1
  6. package/src/core/fileOps.js +46 -119
  7. package/src/render/buildVariables.js +10 -0
  8. package/templates/.claude/agents/bug-debugger.md +1 -1
  9. package/templates/.claude/agents/feature-implementer.md +2 -2
  10. package/templates/.claude/commands/ukit/handoff-clear.md +11 -0
  11. package/templates/.claude/commands/ukit/handoff-create.md +1 -1
  12. package/templates/.claude/commands/ukit/handoff-fullstack.md +26 -1
  13. package/templates/.claude/commands/ukit/handoff-implement.md +1 -1
  14. package/templates/.claude/commands/ukit/handoff-review.md +1 -1
  15. package/templates/.claude/hooks/context-hardcap-gate.sh +4 -1
  16. package/templates/.claude/hooks/handoff-model-guard.sh +22 -11
  17. package/templates/.claude/hooks/reset-compact-pressure.sh +10 -0
  18. package/templates/.claude/hooks/skill-router.sh +15 -8
  19. package/templates/.claude/hooks/verification-guard.sh +3 -0
  20. package/templates/.claude/ukit/index/route-task.mjs +237 -32
  21. package/templates/.claude/ukit/index/stale-spec-check.mjs +38 -3
  22. package/templates/.claude/ukit/runtime/async-lock.mjs +240 -42
  23. package/templates/.claude/ukit/runtime/compact-threshold.mjs +5 -2
  24. package/templates/.claude/ukit/runtime/execution-ledger.mjs +217 -17
  25. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +38 -4
  26. package/templates/.claude/ukit/runtime/hook-payload-store.mjs +131 -0
  27. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +35 -20
  28. package/templates/.claude/ukit/runtime/token-utils.mjs +37 -126
  29. package/templates/.codex/settings.json +1 -5
  30. package/templates/.omp/agents/bug-debugger.md +1 -1
  31. package/templates/.omp/agents/feature-implementer.md +2 -2
  32. package/templates/.omp/hooks/pre/ukit-bridge.js +216 -51
  33. package/templates/docs/AI_HANDOFF/INDEX.md +1 -1
  34. package/templates/docs/AI_HANDOFF/RULES.md +6 -6
  35. package/templates/ukit/storage/config.json +2 -2
@@ -151,6 +151,13 @@ process.stdin.on("end", () => {
151
151
  }
152
152
  })();
153
153
  const sessionId = typeof payload.session_id === "string" ? payload.session_id.trim() : "";
154
+ // TASK-008 fix round: the pressure writer keys records by payload.session_id,
155
+ // and hosts that omit session_id on prompt/tool events (omp) now send the
156
+ // transcript path as the session key. When session_start DOES carry a real id
157
+ // but the writers did not, the record lives under the transcript key — delete
158
+ // both so the targeted reset cannot silently miss. Transcript paths are
159
+ // unique per session, so this can never touch a sibling record.
160
+ const transcriptKey = typeof payload.transcript_path === "string" ? payload.transcript_path.trim() : "";
154
161
  const pressurePath = process.argv[1];
155
162
 
156
163
  withLock(`${pressurePath}.lock`, async () => {
@@ -179,6 +186,9 @@ process.stdin.on("end", () => {
179
186
 
180
187
  const sessions = { ...raw.sessions };
181
188
  delete sessions[sessionId];
189
+ if (transcriptKey && transcriptKey !== sessionId) {
190
+ delete sessions[transcriptKey];
191
+ }
182
192
  const newest = Object.entries(sessions)
183
193
  .sort(([, a], [, b]) => (b?.updatedAt ?? 0) - (a?.updatedAt ?? 0))[0]?.[1] ?? null;
184
194
  const tempPath = `${pressurePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
@@ -2523,6 +2523,10 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2523
2523
  if (!entry || typeof entry !== 'object') {
2524
2524
  return;
2525
2525
  }
2526
+ // The sandbox starts without .ukit/storage/cache. Create it before the
2527
+ // sidecar append and lock acquisition; otherwise both operations fail with
2528
+ // ENOENT and withStateLock falls through to an unlocked read-modify-write.
2529
+ await ensureDir(filePath);
2526
2530
 
2527
2531
  // BUG-C23-15: the entry is STAGED to an append-only sidecar before the lock is
2528
2532
  // attempted (small appendFile is atomic — no read-modify-write, so no lost
@@ -2605,14 +2609,17 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2605
2609
  if (!error || error.code !== 'EEXIST') return;
2606
2610
  try {
2607
2611
  const stat = fsSync.statSync(lockPath);
2608
- if (Date.now() - stat.mtimeMs > 10000) {
2609
- try {
2610
- const owner = JSON.parse(fsSync.readFileSync(path.join(lockPath, 'owner'), 'utf8'));
2611
- if (!owner || !Number.isInteger(owner.pid) || !isPidAlive(owner.pid)) {
2612
- fsSync.rmSync(lockPath, { recursive: true, force: true });
2613
- continue;
2614
- }
2615
- } catch {
2612
+ let owner = null;
2613
+ try {
2614
+ owner = JSON.parse(fsSync.readFileSync(path.join(lockPath, 'owner'), 'utf8'));
2615
+ } catch {}
2616
+ // The watchdog may call process.exit while this process is in the
2617
+ // async merge. Its finally block cannot run after that synchronous
2618
+ // exit, so the exit fold must reclaim its own abandoned lock before
2619
+ // folding the staged sidecar. A live foreign owner remains protected.
2620
+ const ownInterruptedLock = owner?.pid === process.pid;
2621
+ if (ownInterruptedLock || Date.now() - stat.mtimeMs > 10000) {
2622
+ if (ownInterruptedLock || !owner || !isPidAlive(owner.pid)) {
2616
2623
  fsSync.rmSync(lockPath, { recursive: true, force: true });
2617
2624
  continue;
2618
2625
  }
@@ -215,6 +215,9 @@ function isVerificationCommand(command) {
215
215
  ].some((pattern) => pattern.test(command));
216
216
  }
217
217
 
218
+ // F-15: execution-ledger.mjs mirrors this list in its own
219
+ // isExplicitBroadVerificationRequest — the completion gate honors the same
220
+ // explicit-broad signal when classifying a scope='broad' receipt. Keep both in sync.
218
221
  function isExplicitBroadVerificationRequest(promptText) {
219
222
  const text = String(promptText || '').toLowerCase();
220
223
  return [
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs/promises';
3
- import { realpathSync } from 'node:fs';
3
+ // `fsSync` aliases the SAME fs module — used ONLY by the process.on('exit')
4
+ // audit-sidecar fold below (event loop is gone; async cannot run) and by the
5
+ // module-bottom direct-CLI check. Normal-path fs stays async.
6
+ import * as fsSync from 'node:fs';
4
7
  import path from 'node:path';
5
8
  import crypto from 'node:crypto';
6
9
  import { spawnSync } from 'node:child_process';
@@ -13,6 +16,7 @@ import {
13
16
  } from './cache-utils.mjs';
14
17
  import { ROUTE_CATALOG } from './route-catalog.mjs';
15
18
  import { detectUnicGateway } from './unic-gateway.mjs';
19
+ import { withAsyncLock } from '../runtime/async-lock.mjs';
16
20
 
17
21
  const {
18
22
  resolveContext,
@@ -946,7 +950,7 @@ async function main() {
946
950
  };
947
951
  sharedState.fingerprint = buildRouteStateFingerprint(sharedState);
948
952
  if (canPersistCachedRouteState) {
949
- await writeJson(sharedStatePath, sharedState);
953
+ await persistSharedRouteState(sharedStatePath, sharedState);
950
954
  await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
951
955
  state: sharedState,
952
956
  }));
@@ -991,7 +995,7 @@ async function main() {
991
995
  sessionId,
992
996
  });
993
997
  if (canPersistRouteState) {
994
- await writeJson(sharedStatePath, sharedState);
998
+ await persistSharedRouteState(sharedStatePath, sharedState);
995
999
  await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
996
1000
  route,
997
1001
  state: sharedState,
@@ -3816,35 +3820,241 @@ function buildRouteAuditEntry({ route = null, state = null } = {}) {
3816
3820
  };
3817
3821
  }
3818
3822
 
3819
- async function appendRouteAuditEntry(filePath, entry) {
3823
+ // CX-8: the shared route-state/audit writes must obey the same protocol as the
3824
+ // claude/omp writers (skill-router.sh + token-utils withFileLock): tmp+rename
3825
+ // atomicity AND the owner-token `<file>.lock` serialization. The pre-fix bare
3826
+ // fs.writeFile raced the locked writers — a torn skill-router-state.json parses
3827
+ // as `{}` downstream and releases a premature Stop; the unlocked audit
3828
+ // read-modify-write lost entries under concurrent routers.
3829
+ export async function writeJsonAtomic(filePath, value) {
3830
+ // tmp+rename: readers see old-or-new, never a torn/half file.
3831
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
3832
+ const tempPath = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
3833
+ try {
3834
+ await fs.writeFile(tempPath, JSON.stringify(value), 'utf8');
3835
+ await fs.rename(tempPath, filePath);
3836
+ } catch (error) {
3837
+ try {
3838
+ await fs.rm(tempPath, { force: true });
3839
+ } catch {
3840
+ // ignore cleanup errors
3841
+ }
3842
+ throw error;
3843
+ }
3844
+ }
3845
+
3846
+ // The state write is a pure overwrite, but it must still serialize under the
3847
+ // owner-token lock so a codex writer cannot interleave with a locked claude/omp
3848
+ // writer mid-flight. Fail-closed (CX-8): a busy lock skips the write — the next
3849
+ // route recomputes and overwrites the whole document anyway, so a skipped
3850
+ // overwrite loses nothing; an unlocked one can interleave with a live holder.
3851
+ export async function persistSharedRouteState(filePath, state) {
3852
+ const result = await withAsyncLock(filePath, {}, () => writeJsonAtomic(filePath, state));
3853
+ if (!result.ok) {
3854
+ console.error(`route-task: skill-router-state lock ${result.reason} after ${result.waitedMs}ms — state write skipped (fail-closed)`);
3855
+ }
3856
+ return result;
3857
+ }
3858
+
3859
+ // Ported from skill-router.sh entryDedupeKey — `|| null` (not `?? null`) so
3860
+ // falsy fields like repeatCount: 0 produce the same digest in both engines and
3861
+ // cross-engine dedupe of the shared route-audit.json cannot duplicate entries.
3862
+ function routeAuditEntryDedupeKey(item) {
3863
+ return stableMachineDigest({
3864
+ requestKey: item?.requestKey || null,
3865
+ executionMode: item?.executionMode || null,
3866
+ nextActionType: item?.nextActionType || null,
3867
+ nextMilestone: item?.nextMilestone || null,
3868
+ repeatCount: item?.repeatCount || null,
3869
+ rescueMode: item?.rescueMode || null,
3870
+ });
3871
+ }
3872
+
3873
+ // Merge order: the fresh entry first, then staged sidecar lines, then the
3874
+ // existing file. A staged/existing row with the new entry's dedupe key is
3875
+ // replaced by the fresh entry; every other key keeps its first occurrence.
3876
+ function mergeRouteAuditEntries(staged, parsed, newEntry) {
3877
+ const dedupeKey = newEntry ? routeAuditEntryDedupeKey(newEntry) : null;
3878
+ const merged = [];
3879
+ const seen = new Set();
3880
+ for (const item of [newEntry, ...staged, ...(Array.isArray(parsed?.entries) ? parsed.entries : [])]) {
3881
+ if (!item || typeof item !== 'object') continue;
3882
+ const key = routeAuditEntryDedupeKey(item);
3883
+ if (dedupeKey && key === dedupeKey && item !== newEntry) continue;
3884
+ if (seen.has(key)) continue;
3885
+ seen.add(key);
3886
+ merged.push(item);
3887
+ }
3888
+ return merged.slice(0, 40);
3889
+ }
3890
+
3891
+ function parseRouteAuditSidecar(raw) {
3892
+ const recovered = [];
3893
+ for (const line of String(raw || '').split('\n')) {
3894
+ if (!line.trim()) continue;
3895
+ try {
3896
+ const item = JSON.parse(line);
3897
+ if (item && typeof item === 'object') recovered.push(item);
3898
+ } catch {
3899
+ // skip unparseable sidecar lines
3900
+ }
3901
+ }
3902
+ return recovered;
3903
+ }
3904
+
3905
+ function isPidAlive(pid) {
3906
+ if (!Number.isInteger(pid) || pid <= 0) return false;
3907
+ try {
3908
+ process.kill(pid, 0);
3909
+ return true;
3910
+ } catch (error) {
3911
+ // EPERM: the process exists but belongs to another user — still alive.
3912
+ return error?.code === 'EPERM';
3913
+ }
3914
+ }
3915
+
3916
+ // Synchronous last-chance fold for the process-exit path: process.on('exit')
3917
+ // handlers run without the event loop, so sync fs is legal here (fsSync alias).
3918
+ // Bounded spin-retry on the mkdir lock so the LAST exiting process still folds
3919
+ // staged entries after the previous holder releases. The sidecar is claimed by
3920
+ // rename (atomic): appends in flight land on the claimed inode and are read,
3921
+ // appends after the claim create a fresh sidecar for the next fold.
3922
+ function foldRouteAuditSidecarSync(filePath) {
3923
+ const sidecarPath = `${filePath}.unlocked.jsonl`;
3924
+ const lockPath = `${filePath}.lock`;
3925
+ const deadline = Date.now() + 300;
3926
+ let held = false;
3927
+ while (!held && Date.now() < deadline) {
3928
+ try {
3929
+ fsSync.mkdirSync(lockPath);
3930
+ held = true;
3931
+ } catch (error) {
3932
+ if (!error || error.code !== 'EEXIST') return;
3933
+ try {
3934
+ const stat = fsSync.statSync(lockPath);
3935
+ let owner = null;
3936
+ try {
3937
+ owner = JSON.parse(fsSync.readFileSync(path.join(lockPath, 'owner'), 'utf8'));
3938
+ } catch {
3939
+ // unreadable owner — treated as no owner below
3940
+ }
3941
+ // An exit during our own async merge abandons our lock; reclaim it. A
3942
+ // live foreign owner stays protected; a dead/stale one is reclaimed.
3943
+ const ownInterruptedLock = owner?.pid === process.pid;
3944
+ if (ownInterruptedLock || Date.now() - stat.mtimeMs > 10000) {
3945
+ if (ownInterruptedLock || !owner || !isPidAlive(owner.pid)) {
3946
+ fsSync.rmSync(lockPath, { recursive: true, force: true });
3947
+ continue;
3948
+ }
3949
+ }
3950
+ } catch {
3951
+ // stat failed — fall through to the bounded spin
3952
+ }
3953
+ const until = Date.now() + 5;
3954
+ while (Date.now() < until) { /* bounded spin */ }
3955
+ }
3956
+ }
3957
+ if (!held) return;
3958
+ try {
3959
+ const claimedPath = `${sidecarPath}.claim-${process.pid}-${Date.now()}`;
3960
+ try {
3961
+ fsSync.renameSync(sidecarPath, claimedPath);
3962
+ } catch {
3963
+ return; // no staged sidecar — nothing to fold
3964
+ }
3965
+ let staged = [];
3966
+ try {
3967
+ staged = parseRouteAuditSidecar(fsSync.readFileSync(claimedPath, 'utf8'));
3968
+ } catch {
3969
+ // unreadable claim — treated as empty below
3970
+ }
3971
+ try {
3972
+ fsSync.rmSync(claimedPath, { force: true });
3973
+ } catch {
3974
+ // best-effort cleanup
3975
+ }
3976
+ if (!staged.length) return;
3977
+ let parsed = { entries: [] };
3978
+ try {
3979
+ parsed = JSON.parse(fsSync.readFileSync(filePath, 'utf8'));
3980
+ } catch {
3981
+ parsed = { entries: [] };
3982
+ }
3983
+ const tmpPath = `${filePath}.exitfold-${process.pid}`;
3984
+ fsSync.writeFileSync(tmpPath, JSON.stringify({
3985
+ entries: mergeRouteAuditEntries(staged, parsed, null),
3986
+ }));
3987
+ fsSync.renameSync(tmpPath, filePath);
3988
+ } finally {
3989
+ try {
3990
+ fsSync.rmSync(lockPath, { recursive: true, force: true });
3991
+ } catch {
3992
+ // best-effort release
3993
+ }
3994
+ }
3995
+ }
3996
+
3997
+ const armedRouteAuditFolds = new Set();
3998
+
3999
+ export async function appendRouteAuditEntry(filePath, entry) {
3820
4000
  if (!entry || typeof entry !== 'object') {
3821
4001
  return;
3822
4002
  }
4003
+ // The sandbox starts without .ukit/storage/cache. Create it before the
4004
+ // sidecar append and lock acquisition; otherwise both fail with ENOENT.
4005
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
3823
4006
 
3824
- const existing = await readJson(filePath, { entries: [] });
3825
- const entries = Array.isArray(existing?.entries) ? existing.entries : [];
3826
- const dedupeKey = stableMachineDigest({
3827
- requestKey: entry.requestKey,
3828
- executionMode: entry.executionMode,
3829
- nextActionType: entry.nextActionType,
3830
- nextMilestone: entry.nextMilestone,
3831
- repeatCount: entry.repeatCount,
3832
- rescueMode: entry.rescueMode,
3833
- });
3834
- const filtered = entries.filter((item) => {
3835
- const itemKey = stableMachineDigest({
3836
- requestKey: item?.requestKey ?? null,
3837
- executionMode: item?.executionMode ?? null,
3838
- nextActionType: item?.nextActionType ?? null,
3839
- nextMilestone: item?.nextMilestone ?? null,
3840
- repeatCount: item?.repeatCount ?? null,
3841
- rescueMode: item?.rescueMode ?? null,
4007
+ // The entry is STAGED to an append-only sidecar before the lock is attempted
4008
+ // (small appendFile is atomic — no read-modify-write, so no lost updates even
4009
+ // under full contention). Every lock holder folds all staged entries into
4010
+ // route-audit.json inside its merge; a synchronous last-chance fold is also
4011
+ // armed on process exit — whichever process exits last folds every staged
4012
+ // entry. The audit entry can never be silently dropped.
4013
+ const sidecarPath = `${filePath}.unlocked.jsonl`;
4014
+ try {
4015
+ await fs.appendFile(sidecarPath, `${JSON.stringify(entry)}\n`);
4016
+ } catch {
4017
+ // staging failed — still attempt the locked merge so the entry is not lost
4018
+ }
4019
+ if (!armedRouteAuditFolds.has(filePath)) {
4020
+ armedRouteAuditFolds.add(filePath);
4021
+ process.on('exit', () => foldRouteAuditSidecarSync(filePath));
4022
+ }
4023
+
4024
+ const result = await withAsyncLock(filePath, {}, async () => {
4025
+ // Claim staged lines atomically inside the lock: rename detaches the inode,
4026
+ // so lines appended during the fold land in a fresh sidecar for the next
4027
+ // pass instead of being deleted unread.
4028
+ const claimedPath = `${sidecarPath}.claim-${process.pid}-${Date.now()}`;
4029
+ let claimed = false;
4030
+ try {
4031
+ await fs.rename(sidecarPath, claimedPath);
4032
+ claimed = true;
4033
+ } catch {
4034
+ // no sidecar — nothing staged
4035
+ }
4036
+ const sidecarEntries = claimed
4037
+ ? parseRouteAuditSidecar(await fs.readFile(claimedPath, 'utf8').catch(() => ''))
4038
+ : [];
4039
+ if (claimed) {
4040
+ try {
4041
+ await fs.rm(claimedPath, { force: true });
4042
+ } catch {
4043
+ // best-effort cleanup
4044
+ }
4045
+ }
4046
+
4047
+ const parsed = await readJson(filePath, { entries: [] });
4048
+ await writeJsonAtomic(filePath, {
4049
+ entries: mergeRouteAuditEntries(sidecarEntries, parsed, entry),
3842
4050
  });
3843
- return itemKey !== dedupeKey;
3844
- });
3845
- await writeJson(filePath, {
3846
- entries: [entry, ...filtered].slice(0, 40),
3847
4051
  });
4052
+ if (!result.ok) {
4053
+ // Fail-closed on the merge (CX-8): the entry stays staged in the sidecar —
4054
+ // the next locked merge or the exit fold picks it up.
4055
+ console.error(`route-task: route-audit lock ${result.reason} after ${result.waitedMs}ms — audit entry staged to the append-only sidecar`);
4056
+ }
4057
+ return result;
3848
4058
  }
3849
4059
 
3850
4060
  async function readJson(filePath, fallback = null) {
@@ -3855,11 +4065,6 @@ async function readJson(filePath, fallback = null) {
3855
4065
  }
3856
4066
  }
3857
4067
 
3858
- async function writeJson(filePath, value) {
3859
- await fs.mkdir(path.dirname(filePath), { recursive: true });
3860
- await fs.writeFile(filePath, JSON.stringify(value), 'utf8');
3861
- }
3862
-
3863
4068
  function normalize(text) {
3864
4069
  return String(text ?? '')
3865
4070
  .toLowerCase()
@@ -4233,7 +4438,7 @@ function isDirectCliInvocation() {
4233
4438
  if (import.meta.url === pathToFileURL(entry).href) {
4234
4439
  return true;
4235
4440
  }
4236
- return import.meta.url === pathToFileURL(realpathSync(entry)).href;
4441
+ return import.meta.url === pathToFileURL(fsSync.realpathSync(entry)).href;
4237
4442
  } catch {
4238
4443
  return false;
4239
4444
  }
@@ -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;