@ngockhoale/ukit 2.4.1 → 2.4.3

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 (56) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/manifests/platform.full.yaml +19 -111
  3. package/package.json +2 -1
  4. package/scripts/index/refresh-index.mjs +48 -18
  5. package/src/cli/commands/doctor.js +59 -2
  6. package/src/core/compact/threshold.js +36 -6
  7. package/src/core/gatewayProbe.js +143 -15
  8. package/src/core/gatewayResilienceEnv.js +136 -7
  9. package/src/diagnostics/classifyHang.js +246 -0
  10. package/src/index/buildIndex.js +1096 -75
  11. package/templates/.claude/hooks/auto-allow-bash.sh +99 -87
  12. package/templates/.claude/hooks/auto-prune-bash.sh +4 -0
  13. package/templates/.claude/hooks/block-dangerous.sh +46 -1
  14. package/templates/.claude/hooks/completion-gate.sh +65 -7
  15. package/templates/.claude/hooks/compress-output.sh +49 -2
  16. package/templates/.claude/hooks/context-hardcap-gate.sh +52 -4
  17. package/templates/.claude/hooks/context-window-guard.sh +204 -71
  18. package/templates/.claude/hooks/handoff-model-guard.sh +50 -3
  19. package/templates/.claude/hooks/handoff-resume.sh +47 -3
  20. package/templates/.claude/hooks/post-edit-verify.sh +45 -2
  21. package/templates/.claude/hooks/pre-edit-backup.sh +45 -2
  22. package/templates/.claude/hooks/protect-files.sh +46 -1
  23. package/templates/.claude/hooks/record-execution.sh +46 -2
  24. package/templates/.claude/hooks/reinject-context.sh +1 -1
  25. package/templates/.claude/hooks/reset-compact-pressure.sh +4 -0
  26. package/templates/.claude/hooks/sensitive-data-guard.sh +101 -18
  27. package/templates/.claude/hooks/skill-router.sh +59 -5
  28. package/templates/.claude/hooks/stale-spec-guard.sh +47 -2
  29. package/templates/.claude/hooks/task-watchdog.sh +129 -126
  30. package/templates/.claude/hooks/verification-guard.sh +136 -106
  31. package/templates/.claude/hooks/vision-router.sh +138 -18
  32. package/templates/.claude/settings.json +0 -5
  33. package/templates/.claude/ukit/index/lib/index-core.mjs +1027 -68
  34. package/templates/.claude/ukit/index/post-edit-verify.mjs +8 -0
  35. package/templates/.claude/ukit/index/pre-edit-backup.mjs +8 -0
  36. package/templates/.claude/ukit/index/refresh-index.mjs +48 -18
  37. package/templates/.claude/ukit/index/route-task.mjs +610 -4
  38. package/templates/.claude/ukit/index/stale-spec-check.mjs +8 -0
  39. package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
  40. package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
  41. package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
  42. package/templates/.claude/ukit/runtime/execution-ledger.mjs +672 -170
  43. package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
  44. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
  45. package/templates/.claude/ukit/runtime/hook-input.mjs +120 -0
  46. package/templates/.claude/ukit/runtime/hook-input.sh +140 -0
  47. package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
  48. package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
  49. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
  50. package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
  51. package/templates/.claude/ukit/runtime/output-compression.mjs +8 -0
  52. package/templates/.claude/ukit/runtime/reinject-context.mjs +8 -0
  53. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
  54. package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
  55. package/templates/.claude/ukit/runtime/transcript-tail.mjs +107 -0
  56. package/templates/.omp/hooks/pre/ukit-bridge.js +171 -57
@@ -22,6 +22,7 @@ import os from 'node:os';
22
22
  import path from 'node:path';
23
23
 
24
24
  const GATEWAY_DOC = 'docs/GATEWAY.md';
25
+ const SETTINGS_RELATIVE_PROBE_PATH = path.join('.claude', 'settings.json');
25
26
 
26
27
  function safeReadFile(filePath) {
27
28
  try {
@@ -96,6 +97,53 @@ export async function resolveGatewayBaseUrl({
96
97
  return { baseUrl: null, source: null };
97
98
  }
98
99
 
100
+ function readSettingsEnvBlock(filePath) {
101
+ const json = safeReadJson(filePath);
102
+ if (!json || typeof json !== 'object') return null;
103
+ const envBlock = json.env;
104
+ if (!envBlock || typeof envBlock !== 'object' || Array.isArray(envBlock)) return null;
105
+ return envBlock;
106
+ }
107
+
108
+ /**
109
+ * Resolves the API key the probe should authenticate with, mirroring the same 3-tier probe
110
+ * order as `resolveGatewayBaseUrl`: env → project `.claude/settings.json` env → home
111
+ * `~/.claude/settings.json` env. Plain terminals do not export ANTHROPIC_* (Claude Code
112
+ * injects its settings env only into its own sessions), so without the settings tiers
113
+ * `ukit doctor --gateway` probes keyless and misreports an auth-gated gateway.
114
+ *
115
+ * The `scheme` field mirrors `pickApiKey`'s source (`'api-key'` → x-api-key,
116
+ * `'auth-token'` → Authorization: Bearer) so callers can pass it straight into
117
+ * `probeGateway` as `apiKeySource`. Never throws.
118
+ *
119
+ * @param {{ projectRoot: string, homeDir?: string, env?: object }} options
120
+ * @returns {{ value: string|null, scheme: 'api-key'|'auth-token'|null,
121
+ * source: 'env'|'claude-settings'|null }}
122
+ */
123
+ export function resolveGatewayApiKey({
124
+ projectRoot,
125
+ homeDir = os.homedir(),
126
+ env = process.env,
127
+ } = {}) {
128
+ const envKey = pickApiKey(env ?? {});
129
+ if (envKey) {
130
+ return { value: envKey.value, scheme: envKey.source, source: 'env' };
131
+ }
132
+ const settingsPaths = [
133
+ ...(projectRoot ? [path.join(projectRoot, SETTINGS_RELATIVE_PROBE_PATH)] : []),
134
+ path.join(homeDir, SETTINGS_RELATIVE_PROBE_PATH),
135
+ ];
136
+ for (const settingsPath of settingsPaths) {
137
+ const envBlock = readSettingsEnvBlock(settingsPath);
138
+ if (!envBlock) continue;
139
+ const key = pickApiKey(envBlock);
140
+ if (key) {
141
+ return { value: key.value, scheme: key.source, source: 'claude-settings' };
142
+ }
143
+ }
144
+ return { value: null, scheme: null, source: null };
145
+ }
146
+
99
147
  function pickApiKey(envLike) {
100
148
  if (!envLike || typeof envLike !== 'object') return null;
101
149
  // Source matters: ANTHROPIC_API_KEY is sent by Claude Code as `x-api-key`, while
@@ -112,7 +160,16 @@ function pickApiKey(envLike) {
112
160
 
113
161
  function pickModel(envLike) {
114
162
  if (!envLike || typeof envLike !== 'object') return null;
115
- return pickString(envLike.ANTHROPIC_MODEL);
163
+ // ANTHROPIC_MODEL wins, then the per-lane alias mappings Claude Code itself resolves
164
+ // (2026-09-16 live evidence: alias-based gateways provision ONLY the mapped lane models;
165
+ // probing the built-in default `claude-sonnet-4-5` returned HTTP 404 `model_not_found` /
166
+ // "No active credentials for provider: claude" on a gateway whose real sessions work).
167
+ return pickString(
168
+ envLike.ANTHROPIC_MODEL,
169
+ envLike.ANTHROPIC_DEFAULT_OPUS_MODEL,
170
+ envLike.ANTHROPIC_DEFAULT_SONNET_MODEL,
171
+ envLike.ANTHROPIC_DEFAULT_HAIKU_MODEL,
172
+ );
116
173
  }
117
174
 
118
175
  // Minimal VALID Anthropic Messages API body. The probe only measures transport shape
@@ -132,11 +189,14 @@ function buildRequestBody({ stream, model }) {
132
189
  // * ANTHROPIC_API_KEY -> x-api-key (Anthropic-native)
133
190
  // * ANTHROPIC_AUTH_TOKEN -> Authorization: Bearer (OpenAI-style / proxy gateways)
134
191
  // Without this split, token-auth gateways 401 the probe even when transport shape is
135
- // valid (Reviewer Round-1 important finding #3).
192
+ // valid (Reviewer Round-1 important finding #3). `anthropic-version` is sent for BOTH
193
+ // schemes: it is required by Anthropic-native gateways and ignored by OpenAI-style proxies
194
+ // (2026-09-16 live evidence: the token-auth UNIC gateway returns 200 + valid Message with
195
+ // the header present).
136
196
  function buildAuthHeaders(keyValue, keySource) {
137
197
  if (!keyValue) return {};
138
198
  if (keySource === 'auth-token') {
139
- return { Authorization: `Bearer ${keyValue}` };
199
+ return { Authorization: `Bearer ${keyValue}`, 'anthropic-version': '2023-06-01' };
140
200
  }
141
201
  // Default to the Anthropic-native scheme — covers explicit apiKey arg and
142
202
  // ANTHROPIC_API_KEY env (the dominant case).
@@ -161,6 +221,62 @@ function parseSseEvents(text) {
161
221
  return count;
162
222
  }
163
223
 
224
+ // TASK-010 (error-body surfacing): on a non-OK response the gateway body often carries the
225
+ // operator-actionable cause (2026-09-16 live evidence: HTTP 404 + `{"error":{"message":"No
226
+ // active credentials for provider: anthropic"}}`). The read is bounded and the surfaced
227
+ // message is capped at 300 chars (PLAN §3/§4 contract) so a giant HTML error page can never
228
+ // flood the doctor output.
229
+ const ERROR_DETAIL_MAX_CHARS = 300;
230
+ const ERROR_BODY_MAX_CHARS = 4096;
231
+
232
+ async function readBodyTextBounded(response) {
233
+ const body = response?.body;
234
+ if (!body || typeof body.getReader !== 'function') return '';
235
+ const reader = body.getReader();
236
+ const decoder = new TextDecoder();
237
+ let text = '';
238
+ try {
239
+ while (text.length < ERROR_BODY_MAX_CHARS) {
240
+ const { value, done } = await reader.read();
241
+ if (done) break;
242
+ if (value) text += decoder.decode(value, { stream: true });
243
+ }
244
+ } catch {
245
+ // A failing/unreadable error body must never turn into a probe crash — fall back to
246
+ // whatever was collected so far (possibly empty).
247
+ } finally {
248
+ try {
249
+ reader.releaseLock();
250
+ } catch {
251
+ // reader may already be released
252
+ }
253
+ }
254
+ return text;
255
+ }
256
+
257
+ /**
258
+ * Extracts the gateway error message from a non-OK response body:
259
+ * `JSON.parse(text).error.message`, truncated to `ERROR_DETAIL_MAX_CHARS`. Returns `null`
260
+ * when the body is missing, unreadable, non-JSON, or carries no error message — callers
261
+ * then leave `errorDetail` unset and keep today's `HTTP <status>` wording. Never throws.
262
+ */
263
+ async function extractErrorDetail(response) {
264
+ const text = await readBodyTextBounded(response);
265
+ if (!text) return null;
266
+ let parsed = null;
267
+ try {
268
+ parsed = JSON.parse(text);
269
+ } catch {
270
+ return null;
271
+ }
272
+ const message =
273
+ parsed && typeof parsed === 'object' && typeof parsed?.error?.message === 'string'
274
+ ? parsed.error.message
275
+ : null;
276
+ if (!message) return null;
277
+ return message.slice(0, ERROR_DETAIL_MAX_CHARS);
278
+ }
279
+
164
280
  /**
165
281
  * Streaming probe: posts `stream: true` and counts SSE frames + distinct body reads.
166
282
  *
@@ -169,7 +285,8 @@ function parseSseEvents(text) {
169
285
  * buffering gateway dumps everything in one read.
170
286
  *
171
287
  * @returns {Promise<{ ok: boolean, eventsReceived: number, firstEventMs: number|null,
172
- * chunks: number, buffered: boolean, error?: string }>}
288
+ * chunks: number, buffered: boolean, error?: string,
289
+ * errorDetail?: string|null }>}
173
290
  */
174
291
  async function runStreamingProbe({ url, headers, body: requestBody, fetchImpl, timeoutMs }) {
175
292
  const controller = new AbortController();
@@ -190,6 +307,7 @@ async function runStreamingProbe({ url, headers, body: requestBody, fetchImpl, t
190
307
  chunks: 0,
191
308
  buffered: false,
192
309
  error: `HTTP ${response?.status ?? 'unknown'}`,
310
+ errorDetail: await extractErrorDetail(response),
193
311
  };
194
312
  }
195
313
  const body = response.body;
@@ -278,7 +396,8 @@ async function runStreamingProbe({ url, headers, body: requestBody, fetchImpl, t
278
396
  * gateway error envelope slip through Claude Code's retry path and kill the user's turn.
279
397
  *
280
398
  * @returns {Promise<{ ok: boolean, httpOk: boolean, isAnthropicMessage: boolean,
281
- * requestIdPresent: boolean, bodyBytes: number|null, error?: string }>}
399
+ * requestIdPresent: boolean, bodyBytes: number|null, error?: string,
400
+ * errorDetail?: string|null }>}
282
401
  */
283
402
  async function runNonStreamingProbe({ url, headers, body: requestBody, fetchImpl, timeoutMs }) {
284
403
  const controller = new AbortController();
@@ -300,6 +419,7 @@ async function runNonStreamingProbe({ url, headers, body: requestBody, fetchImpl
300
419
  requestIdPresent,
301
420
  bodyBytes: null,
302
421
  error: `HTTP ${response?.status ?? 'unknown'}`,
422
+ errorDetail: await extractErrorDetail(response),
303
423
  };
304
424
  }
305
425
  const body = response.body;
@@ -341,7 +461,12 @@ async function runNonStreamingProbe({ url, headers, body: requestBody, fetchImpl
341
461
  typeof parsed === 'object' &&
342
462
  parsed.type === 'message' &&
343
463
  Array.isArray(parsed.content);
344
- const ok = isAnthropicMessage && requestIdPresent;
464
+ // 2026-09-16 live re-classification: the real UNIC gateway returns HTTP 200 + a valid
465
+ // Anthropic Message with NO request-id header, and real sessions complete fine — the
466
+ // hard pass/fail gate is the Message shape (which still catches the 888-byte error-
467
+ // envelope class). A missing request-id is reported as an advisory hint instead of a
468
+ // hard fail; see GATEWAY_DOC §3.
469
+ const ok = isAnthropicMessage;
345
470
  return {
346
471
  ok,
347
472
  httpOk: true,
@@ -406,11 +531,12 @@ export async function probeGateway({
406
531
  if (!streaming.ok) {
407
532
  if (streaming.buffered) {
408
533
  hints.push(
409
- `Set CLAUDE_STREAM_IDLE_TIMEOUT_MS=600000 in .claude/settings.json env — gateway appears to buffer the entire stream; see ${GATEWAY_DOC}.`,
534
+ `Gateway buffers the entire stream (the stall signature). Managed posture: keep CLAUDE_STREAM_IDLE_TIMEOUT_MS low so the idle watchdog fires, and keep the non-streaming fallback ENABLED so the aborted stream recovers via a non-streaming retry; see ${GATEWAY_DOC}.`,
410
535
  );
411
536
  } else if (streaming.error) {
537
+ const gatewayCause = streaming.errorDetail ? ` — ${streaming.errorDetail}` : '';
412
538
  hints.push(
413
- `Streaming probe failed: ${streaming.error} — see ${GATEWAY_DOC}.`,
539
+ `Streaming probe failed: ${streaming.error}${gatewayCause} (probed model: ${effectiveModel}) — see ${GATEWAY_DOC}.`,
414
540
  );
415
541
  } else {
416
542
  hints.push(
@@ -421,21 +547,23 @@ export async function probeGateway({
421
547
  if (!nonStreaming.ok) {
422
548
  if (nonStreaming.httpOk && !nonStreaming.isAnthropicMessage) {
423
549
  hints.push(
424
- `Set CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK=1 in .claude/settings.json env — non-streaming route returned JSON but not an Anthropic Message; see ${GATEWAY_DOC}.`,
425
- );
426
- } else if (nonStreaming.httpOk && !nonStreaming.requestIdPresent) {
427
- hints.push(
428
- `Gateway must set the request-id (or anthropic-request-id) response header — see ${GATEWAY_DOC}.`,
550
+ `Non-streaming route returned JSON but not an Anthropic Message — leave CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK=1 (fallback must stay off); see ${GATEWAY_DOC}.`,
429
551
  );
430
552
  } else if (nonStreaming.error) {
553
+ const gatewayCause = nonStreaming.errorDetail ? ` — ${nonStreaming.errorDetail}` : '';
431
554
  hints.push(
432
- `Non-streaming probe failed: ${nonStreaming.error} — see ${GATEWAY_DOC}.`,
555
+ `Non-streaming probe failed: ${nonStreaming.error}${gatewayCause} (probed model: ${effectiveModel}) — see ${GATEWAY_DOC}.`,
433
556
  );
434
557
  } else {
435
558
  hints.push(`Non-streaming probe failed — see ${GATEWAY_DOC}.`);
436
559
  }
437
560
  }
561
+ if (nonStreaming.httpOk && nonStreaming.isAnthropicMessage && !nonStreaming.requestIdPresent) {
562
+ hints.push(
563
+ `Gateway did not set a request-id (or anthropic-request-id) header — advisory only, diagnosability is degraded but transport works; see ${GATEWAY_DOC}.`,
564
+ );
565
+ }
438
566
 
439
567
  const verdict = streaming.ok && nonStreaming.ok ? 'pass' : 'fail';
440
- return { streaming, nonStreaming, verdict, hints };
568
+ return { streaming, nonStreaming, verdict, hints, model: effectiveModel };
441
569
  }
@@ -4,14 +4,24 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
 
6
6
  // Managed defaults that keep Claude Code responsive when it is pointed at a custom
7
- // gateway (any non-empty ANTHROPIC_BASE_URL). `CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK='1'`
8
- // forces streaming mode so the client never falls back to a non-streaming POST that can
9
- // stall for minutes waiting for the full response. `CLAUDE_STREAM_IDLE_TIMEOUT_MS='600000'`
10
- // raises Claude Code's internal idle timeout from its default (the gateway-side keep-alive
11
- // can sit idle longer than the default when the upstream is slow).
7
+ // gateway (any non-empty ANTHROPIC_BASE_URL). Posture since 2.4.2 — "fail fast, recover
8
+ // via non-streaming" — based on 2026-09-16 live probes of the stall-class gateway:
9
+ // * The gateway buffers entire streams (23 SSE events delivered in ONE body read), so a
10
+ // long generation is silent until it finishes and Claude Code's stream-idle watchdog
11
+ // is the only thing that can end the silence. `CLAUDE_STREAM_IDLE_TIMEOUT_MS='120000'`
12
+ // bounds that dead air to 2 minutes instead of hanging for 10.
13
+ // * The non-streaming route is HEALTHY on the stall-class gateway (HTTP 200 + valid
14
+ // Anthropic Message), and a non-streaming POST has no idle watchdog at all — it is the
15
+ // one request shape that reliably survives a buffered long generation. So the fallback
16
+ // must stay ENABLED (`CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK='0'`); disabling it
17
+ // (the 2.3.16–2.4.1 posture) removed the only recovery path and turned stalls into
18
+ // silent freezes the user had to interrupt by hand.
12
19
  //
13
20
  // These are written ONLY when no user-set value already lives in the project's
14
- // `.claude/settings.json` env block — never clobber, always preserve. Re-running
21
+ // `.claude/settings.json` env block — never clobber, always preserve — EXCEPT for values
22
+ // equal to `GATEWAY_RESILIENCE_ENV_LEGACY_DEFAULTS` (the shipped defaults of
23
+ // 2.3.16–2.4.1): those were written by UKit itself, so they migrate to the current
24
+ // defaults on the next install instead of being mistaken for user intent. Re-running
15
25
  // `ukit install` is a no-op once the keys are in place.
16
26
  //
17
27
  // Probe order mirrors `templates/.claude/ukit/index/unic-gateway.mjs` (env → project
@@ -21,12 +31,22 @@ import path from 'node:path';
21
31
  // is pointed at ANY gateway, so we apply resilience regardless of vendor.
22
32
 
23
33
  export const GATEWAY_RESILIENCE_ENV_DEFAULTS = Object.freeze({
34
+ CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK: '0',
35
+ CLAUDE_STREAM_IDLE_TIMEOUT_MS: '120000',
36
+ });
37
+
38
+ // Values shipped as the managed defaults by 2.3.16–2.4.1. A settings env key holding one
39
+ // of these exact values was written by UKit, not by the user, so `applyGatewayResilienceEnv`
40
+ // migrates it to the current default. A user who deliberately tuned a value always uses a
41
+ // non-shipped number (e.g. '300000', '900000') and stays untouched.
42
+ export const GATEWAY_RESILIENCE_ENV_LEGACY_DEFAULTS = Object.freeze({
24
43
  CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK: '1',
25
44
  CLAUDE_STREAM_IDLE_TIMEOUT_MS: '600000',
26
45
  });
27
46
 
28
47
  const BASE_URL_ENV_VAR = 'ANTHROPIC_BASE_URL';
29
48
  const SETTINGS_RELATIVE_PATH = path.join('.claude', 'settings.json');
49
+ const PROFILE_RELATIVE_PATH = path.join('.claude', 'profile');
30
50
 
31
51
  function readBaseUrlFromEnvValue(value) {
32
52
  return typeof value === 'string' && value.trim().length > 0 ? value : null;
@@ -124,6 +144,7 @@ function backupPathFor(settingsPath) {
124
144
  * applied: string[],
125
145
  * unchanged: string[],
126
146
  * skipped: string[],
147
+ * migrated: string[],
127
148
  * changed: boolean,
128
149
  * reason?: string,
129
150
  * }>}
@@ -162,6 +183,7 @@ export async function applyGatewayResilienceEnv({
162
183
  applied: [],
163
184
  unchanged: [],
164
185
  skipped: [],
186
+ migrated: [],
165
187
  changed: false,
166
188
  reason: `failed to read settings: ${error?.message ?? String(error)}`,
167
189
  };
@@ -180,6 +202,7 @@ export async function applyGatewayResilienceEnv({
180
202
  applied: [],
181
203
  unchanged: [],
182
204
  skipped: [],
205
+ migrated: [],
183
206
  changed: false,
184
207
  reason: `failed to parse settings JSON: ${error?.message ?? String(error)}`,
185
208
  };
@@ -204,6 +227,7 @@ export async function applyGatewayResilienceEnv({
204
227
  const applied = [];
205
228
  const unchanged = [];
206
229
  const skipped = [];
230
+ const migrated = [];
207
231
  let mutated = false;
208
232
 
209
233
  for (const [key, defaultValue] of Object.entries(GATEWAY_RESILIENCE_ENV_DEFAULTS)) {
@@ -218,6 +242,18 @@ export async function applyGatewayResilienceEnv({
218
242
  unchanged.push(key);
219
243
  continue;
220
244
  }
245
+ // A value equal to a previously-shipped UKit default was written by UKit itself —
246
+ // migrate it to the current default instead of treating it as user intent.
247
+ if (
248
+ typeof currentValue === 'string' &&
249
+ currentValue === GATEWAY_RESILIENCE_ENV_LEGACY_DEFAULTS[key]
250
+ ) {
251
+ settings.env[key] = defaultValue;
252
+ applied.push(key);
253
+ migrated.push(key);
254
+ mutated = true;
255
+ continue;
256
+ }
221
257
  // User owns a non-default value — never touch it.
222
258
  skipped.push(key);
223
259
  }
@@ -228,6 +264,7 @@ export async function applyGatewayResilienceEnv({
228
264
  applied,
229
265
  unchanged,
230
266
  skipped,
267
+ migrated,
231
268
  changed: false,
232
269
  };
233
270
  }
@@ -243,6 +280,7 @@ export async function applyGatewayResilienceEnv({
243
280
  applied,
244
281
  unchanged,
245
282
  skipped,
283
+ migrated,
246
284
  changed: true,
247
285
  };
248
286
  }
@@ -271,7 +309,11 @@ export function formatGatewayResilienceReport(report) {
271
309
  );
272
310
 
273
311
  for (const key of report.applied || []) {
274
- lines.push(` - ${key} = ${JSON.stringify(GATEWAY_RESILIENCE_ENV_DEFAULTS[key])} (applied)`);
312
+ const legacyValue = GATEWAY_RESILIENCE_ENV_LEGACY_DEFAULTS[key];
313
+ const migrated = (report.migrated || []).includes(key) && legacyValue !== undefined;
314
+ lines.push(
315
+ ` - ${key} = ${JSON.stringify(GATEWAY_RESILIENCE_ENV_DEFAULTS[key])}${migrated ? ` (migrated from previous UKit default ${JSON.stringify(legacyValue)})` : ' (applied)'}`,
316
+ );
275
317
  }
276
318
  for (const key of report.unchanged || []) {
277
319
  lines.push(` - ${key} = ${JSON.stringify(GATEWAY_RESILIENCE_ENV_DEFAULTS[key])} (already set)`);
@@ -290,3 +332,90 @@ export function formatGatewayResilienceReport(report) {
290
332
 
291
333
  return lines;
292
334
  }
335
+
336
+ /**
337
+ * Advisory presence scan of the per-profile settings files under
338
+ * `<homeDir>/.claude/profile/`. A session launched with
339
+ * `--settings ~/.claude/profile/<name>.json` bypasses `~/.claude/settings.json` entirely, so
340
+ * a managed resilience key can be absent exactly where it matters while every other check
341
+ * reports green. This is the delivery-gap detector for that hole.
342
+ *
343
+ * Presence-check only — never a value-check. After the documented posture flip the values
344
+ * are user/machine-owned (a profile may legitimately hold a legacy `'1'`/`'600000'` pair or a
345
+ * tuned number), so "is the key present" is the whole contract and no value is compared
346
+ * against `GATEWAY_RESILIENCE_ENV_DEFAULTS`.
347
+ *
348
+ * Only regular files ending in `.json` are scanned (sorted by name); decoys that live in the
349
+ * real profile dir — backups like `x.json.bak-20260916`, `notes.sh`, and subdirectories — are
350
+ * ignored, and the scan is not recursive. An absent profile directory is not an error: it
351
+ * resolves `{ scanned: [], compliant: true }` so a machine that never used profiles stays
352
+ * quiet. Never throws: an unreadable directory, unreadable file, or invalid JSON is
353
+ * reflected in the per-file result instead of propagating.
354
+ *
355
+ * @param {{ homeDir?: string, profileDir?: string|null, keys?: string[]|null }} [options]
356
+ * @returns {Promise<{ profileDir: string, scanned: Array<{ file: string, validJson: boolean, hasEnvBlock: boolean, missing: string[] }>, compliant: boolean }>}
357
+ */
358
+ export async function scanProfileResilienceEnv({
359
+ homeDir = os.homedir(),
360
+ profileDir = null,
361
+ keys = null,
362
+ } = {}) {
363
+ const resolvedProfileDir = profileDir || path.join(homeDir, PROFILE_RELATIVE_PATH);
364
+ const managedKeys = Array.isArray(keys) && keys.length > 0
365
+ ? keys
366
+ : Object.keys(GATEWAY_RESILIENCE_ENV_DEFAULTS);
367
+
368
+ const emptyResult = { profileDir: resolvedProfileDir, scanned: [], compliant: true };
369
+
370
+ let entries;
371
+ try {
372
+ entries = await fs.readdir(resolvedProfileDir, { withFileTypes: true });
373
+ } catch {
374
+ // Absent (ENOENT) or unreadable (EACCES) profile dir — nothing to warn about.
375
+ return emptyResult;
376
+ }
377
+
378
+ const jsonFiles = entries
379
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
380
+ .map((entry) => entry.name)
381
+ .sort();
382
+
383
+ if (jsonFiles.length === 0) {
384
+ return emptyResult;
385
+ }
386
+
387
+ const scanned = [];
388
+ for (const name of jsonFiles) {
389
+ const file = path.join(resolvedProfileDir, name);
390
+ let raw;
391
+ try {
392
+ raw = await fs.readFile(file, 'utf8');
393
+ } catch {
394
+ scanned.push({ file, validJson: false, hasEnvBlock: false, missing: [...managedKeys] });
395
+ continue;
396
+ }
397
+
398
+ let parsed;
399
+ try {
400
+ parsed = JSON.parse(raw);
401
+ } catch {
402
+ scanned.push({ file, validJson: false, hasEnvBlock: false, missing: [...managedKeys] });
403
+ continue;
404
+ }
405
+
406
+ const envBlock = parsed?.env;
407
+ const hasEnvBlock =
408
+ Boolean(envBlock) && typeof envBlock === 'object' && !Array.isArray(envBlock);
409
+ const missing = hasEnvBlock
410
+ ? managedKeys.filter((key) => !(key in envBlock))
411
+ : [...managedKeys];
412
+
413
+ scanned.push({ file, validJson: true, hasEnvBlock, missing });
414
+ }
415
+
416
+ return {
417
+ profileDir: resolvedProfileDir,
418
+ scanned,
419
+ compliant: scanned.every((entry) => entry.missing.length === 0),
420
+ };
421
+ }