@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
package/CHANGELOG.md CHANGED
@@ -2,6 +2,71 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.4.2 - 2026-09-16
6
+
7
+ Fix: the gateway resilience posture disabled the only recovery path a buffering gateway
8
+ leaves open — the direct client-side cause of the recurring "session stands still mid-turn
9
+ until the user interrupts and re-prompts" freeze. Live probes of the stall-class gateway on
10
+ 2026-09-16 proved the full chain: the gateway still buffers entire streams (a 16-token
11
+ probe returned 23 SSE events in ONE body read), so a long generation is silent until it
12
+ finishes; the non-streaming route is healthy (HTTP 200 + valid Anthropic Message); and the
13
+ managed env shipped since 2.3.16 kept the non-streaming fallback DISABLED with a 10-minute
14
+ stream-idle watchdog — meaning the client sat in dead air and, on abort, had no recovery
15
+ path at all. The managed posture now flips to fail-fast-and-recover.
16
+
17
+ - **Posture flip** (`src/core/gatewayResilienceEnv.js`): `CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK`
18
+ managed default `'1'` → `'0'` (fallback stays enabled — on a buffering gateway a
19
+ non-streaming POST is the one request shape without an idle watchdog), and
20
+ `CLAUDE_STREAM_IDLE_TIMEOUT_MS` `'600000'` → `'120000'` (a silent stream aborts after
21
+ 2 minutes and recovers via the healthy non-streaming route instead of hanging for 10).
22
+ Values equal to the previously shipped defaults are UKit's own earlier writes and are
23
+ migrated on the next `ukit install` (new `GATEWAY_RESILIENCE_ENV_LEGACY_DEFAULTS` map +
24
+ `migrated` report field, backup written as usual); genuinely user-tuned values are still
25
+ never touched.
26
+ - **Probe misdiagnosis fixed** (`src/core/gatewayProbe.js`): the probe 404'd healthy
27
+ alias-based gateways because it POSTed the built-in default `claude-sonnet-4-5`, which is
28
+ not provisioned there ("No active credentials for provider: claude", `model_not_found`,
29
+ reproduced live) while real sessions run on the `ANTHROPIC_DEFAULT_*_MODEL` lane aliases.
30
+ The probe now resolves `ANTHROPIC_MODEL` → `ANTHROPIC_DEFAULT_OPUS/SONNET/HAIKU_MODEL`
31
+ before the built-in default and reports the probed model. `anthropic-version` is now sent
32
+ for the Bearer scheme too (live-verified accepted), and the API key is resolved through
33
+ the same 3-tier order as the base URL (env → project settings → home settings) so plain
34
+ terminals no longer probe keyless (`resolveGatewayApiKey`).
35
+ - **request-id re-classified** (same file, `docs/GATEWAY.md` §3): a missing `request-id`
36
+ on an HTTP 200 + valid Anthropic Message is an advisory hint, not a FAIL — the real
37
+ gateway ships no request-id even on healthy responses and sessions complete fine. The
38
+ Message-shape hard gate still catches the 888-byte error-envelope class from 2.3.16.
39
+ - **Docs**: `docs/GATEWAY.md` intro/§1/§2/§3/§4/§5 updated for the fail-fast posture,
40
+ the 2026-09-16 buffering reconfirmation, the legacy-value migration, and the probe's
41
+ alias-model + key resolution.
42
+ - **Verification**: focused suites green (gatewayProbe 14, gatewayResilienceEnv 23,
43
+ packageVersion 3); full `yarn test` green; live re-probe after the fix reports the alias
44
+ model, a healthy non-streaming route, and the still-present buffering signature.
45
+ - **Orphan-leak class closed for the remaining hooks (2.4.1 follow-up)**: the 2.4.1 watchdog
46
+ shipped in exactly one hook (`skill-router.sh`); the other 18 node-spawning hooks could still
47
+ orphan a node grandchild to launchd when their work hung (wedged import, stalled mount — this
48
+ repo lives on an external volume). Every remaining hook now arms the same wall-clock
49
+ self-deadline: inline heredoc/`node -e` blocks carry
50
+ `setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref()` directly, wrapper hooks pass
51
+ `UKIT_HOOK_DEADLINE_MS` (default 3000 ms) and their runtime scripts (`execution-ledger`,
52
+ `output-compression`, `reinject-context`, `post-edit-verify`, `pre-edit-backup`,
53
+ `stale-spec-check`) self-exit only when that env var is present, so CLI usage is never
54
+ self-killed. Lock-mutation blocks with a 5000 ms bounded wait (auto-allow-bash,
55
+ auto-prune-bash, reset-compact-pressure) use an 8000 ms deadline so the watchdog can never
56
+ fire while the mutation is legally waiting for the lock.
57
+ `tests/consistency/hookWatchdogCoverage.test.js` enforces the coverage for every future hook.
58
+ - **Bounded stdin transport for the hook chain (C19 wave 1, H01)**: hook payloads no longer
59
+ materialize unbounded in a shell variable or the exec environment — the exec-env path that
60
+ made large PreToolUse payloads fail `exec` outright (E2BIG) on macOS. New
61
+ `templates/.claude/ukit/runtime/hook-input.sh` (`ukit_stage_hook_input` via `mktemp` +
62
+ byte-capped `head`, drained producer, overflow policies `truncate`/`temp-file`/`refuse`)
63
+ and `hook-input.mjs` (streaming reader + CLI) stage every payload to a bounded temp file;
64
+ all 16 node-spawning wrapper hooks now pass `INPUT_FILE`, never the payload, with a
65
+ bounded inline fallback when the helper is absent. Sensitive-data-guard overflow is
66
+ fail-closed (`refuse`, silent exit 2); 32 MiB receipts verified < 10 s.
67
+
68
+ - Machine-envelope lane in the sensitive-data gate: harness-injected notifications (task-notification / system-reminder / cross-session-message tags) now deliver with a redacted labels/counts advisory instead of being blocked when they quote secret-shaped test fixtures — ends the silent "Waiting for N background agents" stall. User-typed prompts, file/grep/bash channels, allowlist and overflow-refuse posture unchanged (fail-closed, default-ON, never echoes values).
69
+
5
70
  ## 2.4.1 - 2026-09-14
6
71
 
7
72
  Fix: the skill-router hook could hang the session and leak an orphaned process. `skill-router.sh`
@@ -1059,7 +1059,7 @@ items:
1059
1059
  sourceTemplate: .claude/hooks/record-execution.sh
1060
1060
  targetPath: .claude/hooks/record-execution.sh
1061
1061
  requires:
1062
- - ukit-runtime-execution-ledger-script
1062
+ - ukit-runtime-scripts
1063
1063
  mergeStrategy: overwrite_with_backup
1064
1064
  variables: []
1065
1065
  enabledByDefault: true
@@ -1071,7 +1071,7 @@ items:
1071
1071
  sourceTemplate: .claude/hooks/completion-gate.sh
1072
1072
  targetPath: .claude/hooks/completion-gate.sh
1073
1073
  requires:
1074
- - ukit-runtime-execution-ledger-script
1074
+ - ukit-runtime-scripts
1075
1075
  mergeStrategy: overwrite_with_backup
1076
1076
  variables: []
1077
1077
  enabledByDefault: true
@@ -1139,7 +1139,7 @@ items:
1139
1139
  targetPath: .claude/hooks/skill-router.sh
1140
1140
  requires:
1141
1141
  - ukit-index-route-catalog-script
1142
- - ukit-runtime-compact-threshold-script
1142
+ - ukit-runtime-scripts
1143
1143
  mergeStrategy: overwrite_with_backup
1144
1144
  variables: []
1145
1145
  enabledByDefault: true
@@ -1164,7 +1164,7 @@ items:
1164
1164
  sourceTemplate: .claude/hooks/context-hardcap-gate.sh
1165
1165
  targetPath: .claude/hooks/context-hardcap-gate.sh
1166
1166
  requires:
1167
- - ukit-runtime-compact-threshold-script
1167
+ - ukit-runtime-scripts
1168
1168
  mergeStrategy: overwrite_with_backup
1169
1169
  variables: []
1170
1170
  enabledByDefault: true
@@ -1204,10 +1204,16 @@ items:
1204
1204
  packs:
1205
1205
  - core
1206
1206
 
1207
- - id: ukit-runtime-task-watchdog-script
1207
+ # One directory item ships the whole runtime dir. autoDiscoverTemplates:false silently
1208
+ # skips any template file without an explicit item, which is exactly how later-wave
1209
+ # runtime modules (async-lock.mjs, hook-process.mjs, transcript-tail.mjs, hook-input.*)
1210
+ # never reached installed projects and broke execution-ledger.mjs imports at hook time.
1211
+ # A directory scan keeps every new runtime module shipping without a manifest edit; the
1212
+ # walker preserves per-file modes (hook-input.sh keeps its exec bit).
1213
+ - id: ukit-runtime-scripts
1208
1214
  type: config
1209
- sourceTemplate: .claude/ukit/runtime/task-watchdog.mjs
1210
- targetPath: .claude/ukit/runtime/task-watchdog.mjs
1215
+ sourceTemplate: .claude/ukit/runtime
1216
+ targetPath: .claude/ukit/runtime
1211
1217
  requires: []
1212
1218
  mergeStrategy: overwrite_with_backup
1213
1219
  variables: []
@@ -1231,7 +1237,7 @@ items:
1231
1237
  sourceTemplate: .claude/hooks/compress-output.sh
1232
1238
  targetPath: .claude/hooks/compress-output.sh
1233
1239
  requires:
1234
- - ukit-runtime-output-compression-script
1240
+ - ukit-runtime-scripts
1235
1241
  mergeStrategy: overwrite_with_backup
1236
1242
  variables: []
1237
1243
  enabledByDefault: true
@@ -1273,58 +1279,12 @@ items:
1273
1279
  packs:
1274
1280
  - core
1275
1281
 
1276
- - id: ukit-runtime-text-profile-script
1277
- type: config
1278
- sourceTemplate: .claude/ukit/runtime/text-profile.mjs
1279
- targetPath: .claude/ukit/runtime/text-profile.mjs
1280
- requires: []
1281
- mergeStrategy: overwrite_with_backup
1282
- variables: []
1283
- enabledByDefault: true
1284
- packs:
1285
- - core
1286
-
1287
- - id: ukit-runtime-execution-ledger-script
1288
- type: config
1289
- sourceTemplate: .claude/ukit/runtime/execution-ledger.mjs
1290
- targetPath: .claude/ukit/runtime/execution-ledger.mjs
1291
- requires: []
1292
- mergeStrategy: overwrite_with_backup
1293
- variables: []
1294
- enabledByDefault: true
1295
- packs:
1296
- - core
1297
-
1298
- - id: ukit-runtime-hook-chain-runner-script
1299
- type: config
1300
- sourceTemplate: .claude/ukit/runtime/hook-chain-runner.mjs
1301
- targetPath: .claude/ukit/runtime/hook-chain-runner.mjs
1302
- requires: []
1303
- mergeStrategy: overwrite_with_backup
1304
- variables: []
1305
- enabledByDefault: true
1306
- packs:
1307
- - core
1308
-
1309
- - id: ukit-runtime-safe-patch-core-script
1310
- type: config
1311
- sourceTemplate: .claude/ukit/runtime/safe-patch-core.mjs
1312
- targetPath: .claude/ukit/runtime/safe-patch-core.mjs
1313
- requires:
1314
- - ukit-runtime-text-profile-script
1315
- mergeStrategy: overwrite_with_backup
1316
- variables: []
1317
- enabledByDefault: true
1318
- packs:
1319
- - core
1320
-
1321
1282
  - id: ukit-index-anchor-search-script
1322
1283
  type: config
1323
1284
  sourceTemplate: .claude/ukit/index/anchor-search.mjs
1324
1285
  targetPath: .claude/ukit/index/anchor-search.mjs
1325
1286
  requires:
1326
- - ukit-runtime-text-profile-script
1327
- - ukit-runtime-safe-patch-core-script
1287
+ - ukit-runtime-scripts
1328
1288
  mergeStrategy: overwrite_with_backup
1329
1289
  variables: []
1330
1290
  enabledByDefault: true
@@ -1348,8 +1308,7 @@ items:
1348
1308
  sourceTemplate: .claude/ukit/index/provision-worktree.mjs
1349
1309
  targetPath: .claude/ukit/index/provision-worktree.mjs
1350
1310
  requires:
1351
- - ukit-runtime-text-profile-script
1352
- - ukit-runtime-safe-patch-core-script
1311
+ - ukit-runtime-scripts
1353
1312
  mergeStrategy: overwrite_with_backup
1354
1313
  variables: []
1355
1314
  enabledByDefault: true
@@ -1361,8 +1320,7 @@ items:
1361
1320
  sourceTemplate: .claude/ukit/index/stale-spec-check.mjs
1362
1321
  targetPath: .claude/ukit/index/stale-spec-check.mjs
1363
1322
  requires:
1364
- - ukit-runtime-text-profile-script
1365
- - ukit-runtime-safe-patch-core-script
1323
+ - ukit-runtime-scripts
1366
1324
  mergeStrategy: overwrite_with_backup
1367
1325
  variables: []
1368
1326
  enabledByDefault: true
@@ -1374,8 +1332,7 @@ items:
1374
1332
  sourceTemplate: .claude/ukit/index/pre-edit-backup.mjs
1375
1333
  targetPath: .claude/ukit/index/pre-edit-backup.mjs
1376
1334
  requires:
1377
- - ukit-runtime-text-profile-script
1378
- - ukit-runtime-safe-patch-core-script
1335
+ - ukit-runtime-scripts
1379
1336
  mergeStrategy: overwrite_with_backup
1380
1337
  variables: []
1381
1338
  enabledByDefault: true
@@ -1387,8 +1344,7 @@ items:
1387
1344
  sourceTemplate: .claude/ukit/index/post-edit-verify.mjs
1388
1345
  targetPath: .claude/ukit/index/post-edit-verify.mjs
1389
1346
  requires:
1390
- - ukit-runtime-text-profile-script
1391
- - ukit-runtime-safe-patch-core-script
1347
+ - ukit-runtime-scripts
1392
1348
  mergeStrategy: overwrite_with_backup
1393
1349
  variables: []
1394
1350
  enabledByDefault: true
@@ -1549,54 +1505,6 @@ items:
1549
1505
  packs:
1550
1506
  - core
1551
1507
 
1552
- - id: ukit-runtime-token-utils-script
1553
- type: config
1554
- sourceTemplate: .claude/ukit/runtime/token-utils.mjs
1555
- targetPath: .claude/ukit/runtime/token-utils.mjs
1556
- mergeStrategy: overwrite_with_backup
1557
- variables: []
1558
- enabledByDefault: true
1559
- packs:
1560
- - core
1561
-
1562
- - id: ukit-runtime-compact-threshold-script
1563
- type: config
1564
- sourceTemplate: .claude/ukit/runtime/compact-threshold.mjs
1565
- targetPath: .claude/ukit/runtime/compact-threshold.mjs
1566
- requires:
1567
- - ukit-runtime-token-utils-script
1568
- mergeStrategy: overwrite_with_backup
1569
- variables: []
1570
- enabledByDefault: true
1571
- packs:
1572
- - core
1573
-
1574
- - id: ukit-runtime-reinject-context-script
1575
- type: config
1576
- sourceTemplate: .claude/ukit/runtime/reinject-context.mjs
1577
- targetPath: .claude/ukit/runtime/reinject-context.mjs
1578
- requires:
1579
- - ukit-runtime-token-utils-script
1580
- - ukit-runtime-compact-threshold-script
1581
- mergeStrategy: overwrite_with_backup
1582
- variables: []
1583
- enabledByDefault: true
1584
- packs:
1585
- - core
1586
-
1587
- - id: ukit-runtime-output-compression-script
1588
- type: config
1589
- sourceTemplate: .claude/ukit/runtime/output-compression.mjs
1590
- targetPath: .claude/ukit/runtime/output-compression.mjs
1591
- requires:
1592
- - ukit-runtime-token-utils-script
1593
- - ukit-runtime-compact-threshold-script
1594
- mergeStrategy: overwrite_with_backup
1595
- variables: []
1596
- enabledByDefault: true
1597
- packs:
1598
- - core
1599
-
1600
1508
  - id: ukit-index-reset-auto-permissions-script
1601
1509
  type: config
1602
1510
  sourceTemplate: .claude/ukit/index/reset-auto-permissions.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.4.1",
3
+ "version": "2.4.3",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex, OpenCode, and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -59,6 +59,7 @@
59
59
  "test:release-core": "vitest run --exclude tests/integration/packageArtifact.test.js",
60
60
  "release:verify": "node ./scripts/release/verify-release.mjs",
61
61
  "test": "vitest run",
62
+ "test:liveness": "vitest run tests/liveness/",
62
63
  "test:watch": "vitest"
63
64
  },
64
65
  "engines": {
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
 
5
5
  import {
6
6
  buildCodeIndex,
7
- isIndexStale,
7
+ inspectIndexStaleness,
8
8
  DEFAULT_INDEX_CACHE_MAX_AGE_MS,
9
9
  } from '../../src/index/buildIndex.js';
10
10
 
@@ -19,34 +19,64 @@ const changedFiles = changedArg
19
19
 
20
20
  const force = readBooleanFlag(args, '--force');
21
21
  const lastRefreshMs = await getLastRefreshTime(rootDir);
22
- const stale = force
23
- ? true
24
- : await isIndexStale({ rootDir, maxAgeMs: DEFAULT_INDEX_CACHE_MAX_AGE_MS });
25
22
 
26
- if (!stale) {
27
- console.log('[index:refresh] skipped (cache fresh)');
28
- if (lastRefreshMs !== null) {
29
- console.log(`lastRefresh: ${new Date(lastRefreshMs).toISOString()}`);
30
- }
31
- if (changedFiles.length > 0) {
32
- console.log(`changed-files-hint: ${changedFiles.join(', ')}`);
33
- }
34
- console.log(`root: ${rootDir}`);
35
- } else {
36
- const summary = await buildCodeIndex({ rootDir });
23
+ if (changedFiles.length > 0 && !force) {
24
+ // Hints carry the change set, so no staleness enumeration is needed:
25
+ // buildCodeIndex merges the hinted paths into the existing artifacts and
26
+ // falls back to the bounded full discovery path on its own when the hints
27
+ // are unsafe or the previous artifacts cannot be merged (TASK-023).
28
+ const summary = await buildCodeIndex({ rootDir, changedFiles });
37
29
 
38
30
  console.log('[index:refresh] completed');
31
+ console.log(`mode: ${summary.mode}`);
39
32
  if (lastRefreshMs !== null) {
40
33
  console.log(`lastRefreshBefore: ${new Date(lastRefreshMs).toISOString()}`);
41
34
  }
42
- if (changedFiles.length > 0) {
43
- console.log(`changed-files-hint: ${changedFiles.join(', ')}`);
44
- }
35
+ console.log(`changed-files-hint: ${changedFiles.join(', ')}`);
45
36
  console.log(`root: ${rootDir}`);
46
37
  console.log(`files: ${summary.fileCount}`);
47
38
  console.log(`parsed: ${summary.parsedCodeFileCount}`);
48
39
  console.log(`reused: ${summary.reusedCodeFileCount}`);
40
+ if (summary.removedFiles.length > 0) {
41
+ console.log(`removed: ${summary.removedFiles.length}`);
42
+ }
49
43
  console.log(`indexDir: ${summary.indexDir}`);
44
+ } else {
45
+ const staleness = force
46
+ ? null
47
+ : await inspectIndexStaleness({ rootDir, maxAgeMs: DEFAULT_INDEX_CACHE_MAX_AGE_MS });
48
+ const stale = force || staleness.stale;
49
+
50
+ if (!stale) {
51
+ console.log('[index:refresh] skipped (cache fresh)');
52
+ if (lastRefreshMs !== null) {
53
+ console.log(`lastRefresh: ${new Date(lastRefreshMs).toISOString()}`);
54
+ }
55
+ if (changedFiles.length > 0) {
56
+ console.log(`changed-files-hint: ${changedFiles.join(', ')}`);
57
+ }
58
+ console.log(`root: ${rootDir}`);
59
+ } else {
60
+ // Reuse the staleness check's discovery snapshot: one enumeration per refresh.
61
+ const summary = await buildCodeIndex({
62
+ rootDir,
63
+ discoverySnapshot: staleness ? staleness.snapshot : null,
64
+ });
65
+
66
+ console.log('[index:refresh] completed');
67
+ console.log(`mode: ${summary.mode}`);
68
+ if (lastRefreshMs !== null) {
69
+ console.log(`lastRefreshBefore: ${new Date(lastRefreshMs).toISOString()}`);
70
+ }
71
+ if (changedFiles.length > 0) {
72
+ console.log(`changed-files-hint: ${changedFiles.join(', ')}`);
73
+ }
74
+ console.log(`root: ${rootDir}`);
75
+ console.log(`files: ${summary.fileCount}`);
76
+ console.log(`parsed: ${summary.parsedCodeFileCount}`);
77
+ console.log(`reused: ${summary.reusedCodeFileCount}`);
78
+ console.log(`indexDir: ${summary.indexDir}`);
79
+ }
50
80
  }
51
81
 
52
82
  function readFlagValue(argv, flag) {
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs/promises';
3
+ import os from 'node:os';
3
4
  import { pathExists, readJsonIfExists } from '../../core/fileOps.js';
4
5
  import { buildPathConfig } from '../../core/paths.js';
5
6
  import { buildRuntimePaths } from '../../core/runtimePaths.js';
@@ -10,8 +11,13 @@ import { detectProviders } from '../../context/detectProviders.js';
10
11
  import { profileSkills } from '../../core/skillProfile.js';
11
12
  import {
12
13
  resolveGatewayBaseUrl,
14
+ resolveGatewayApiKey,
13
15
  probeGateway,
14
16
  } from '../../core/gatewayProbe.js';
17
+ import {
18
+ GATEWAY_RESILIENCE_ENV_DEFAULTS,
19
+ scanProfileResilienceEnv,
20
+ } from '../../core/gatewayResilienceEnv.js';
15
21
 
16
22
  export const DOCTOR_HELP_FLAGS = new Set(['--help', '-h']);
17
23
  const KNOWN_FLAGS = new Set([...DOCTOR_HELP_FLAGS, '--skills', '--gateway']);
@@ -28,7 +34,7 @@ export function printDoctorHelp() {
28
34
  console.log(' --gateway Live gateway probe (streaming + non-streaming); advisory only');
29
35
  }
30
36
 
31
- export async function runDoctor({ packageRoot, projectRoot, argv = [] }) {
37
+ export async function runDoctor({ packageRoot, projectRoot, argv = [], homeDir = os.homedir() }) {
32
38
  const unknownFlags = argv.filter((flag) => !KNOWN_FLAGS.has(flag));
33
39
  if (unknownFlags.length > 0) {
34
40
  throw new Error(`Unknown option: ${unknownFlags[0]}. Supported: ${SUPPORTED_FLAGS_LIST}`);
@@ -158,6 +164,40 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [] }) {
158
164
  if (argv.includes('--gateway')) {
159
165
  console.log('');
160
166
  console.log('[UKit] Gateway probe (advisory — does not affect exit code):');
167
+
168
+ // Per-profile resilience scan. A session launched with
169
+ // `--settings ~/.claude/profile/<name>.json` bypasses ~/.claude/settings.json, so the
170
+ // managed keys can be missing exactly where they matter while the main settings file
171
+ // looks healthy. Advisory only: this never enters `checks` and never touches exitCode.
172
+ console.log(
173
+ `[UKit] Profile resilience scan (advisory) — managed keys: ${Object.keys(GATEWAY_RESILIENCE_ENV_DEFAULTS).join(', ')}`,
174
+ );
175
+ let profileScan = null;
176
+ try {
177
+ profileScan = await scanProfileResilienceEnv({ homeDir });
178
+ } catch {
179
+ profileScan = null;
180
+ }
181
+ if (!profileScan || profileScan.scanned.length === 0) {
182
+ console.log('[UKit] - No profile files found (nothing to check).');
183
+ } else {
184
+ for (const entry of profileScan.scanned) {
185
+ if (entry.missing.length === 0) {
186
+ console.log(`[UKit] ✓ ${entry.file}`);
187
+ continue;
188
+ }
189
+ console.log(
190
+ `[UKit] ✗ ${entry.file} — ${entry.validJson ? `missing ${entry.missing.join(', ')}` : 'not valid JSON (assuming every managed key missing)'}`,
191
+ );
192
+ console.log(
193
+ `[UKit] remedy: add ${entry.missing.join(', ')} to the profile's "env" block; see docs/GATEWAY.md`,
194
+ );
195
+ }
196
+ if (profileScan.compliant) {
197
+ console.log('[UKit] All scanned profile files carry the managed resilience keys.');
198
+ }
199
+ }
200
+
161
201
  let resolved;
162
202
  try {
163
203
  resolved = await resolveGatewayBaseUrl({ projectRoot });
@@ -168,9 +208,25 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [] }) {
168
208
  console.log('[UKit] - Gateway probe skipped — no custom gateway configured (ANTHROPIC_BASE_URL not set in env, project .claude/settings.json, or ~/.claude/settings.json).');
169
209
  } else {
170
210
  console.log(`[UKit] baseUrl: ${resolved.baseUrl} (source: ${resolved.source})`);
211
+ // Plain terminals do not export ANTHROPIC_* (the key lives in settings.json env and is
212
+ // only injected into Claude Code sessions), so resolve the key through the same 3-tier
213
+ // order as the baseUrl — otherwise the probe authenticates keyless and misreports.
214
+ let keyInfo = null;
215
+ try {
216
+ keyInfo = await resolveGatewayApiKey({ projectRoot });
217
+ } catch {
218
+ keyInfo = null;
219
+ }
220
+ if (!keyInfo?.value) {
221
+ console.log('[UKit] - No API key found (env, project settings, home settings) — probe runs unauthenticated and may 401/404.');
222
+ }
171
223
  let result;
172
224
  try {
173
- result = await probeGateway({ baseUrl: resolved.baseUrl });
225
+ result = await probeGateway({
226
+ baseUrl: resolved.baseUrl,
227
+ apiKey: keyInfo?.value ?? null,
228
+ apiKeySource: keyInfo?.scheme ?? null,
229
+ });
174
230
  } catch (error) {
175
231
  result = null;
176
232
  console.log(`[UKit] ✗ Gateway probe threw: ${error?.message ?? String(error)}`);
@@ -178,6 +234,7 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [] }) {
178
234
  if (result) {
179
235
  const s = result.streaming;
180
236
  const n = result.nonStreaming;
237
+ console.log(`[UKit] probed model: ${result.model}`);
181
238
  const streamingLabel = s.ok
182
239
  ? `streaming OK (events=${s.eventsReceived}, chunks=${s.chunks})`
183
240
  : `streaming FAIL (events=${s.eventsReceived}, chunks=${s.chunks}, buffered=${s.buffered}${s.error ? `, error=${s.error}` : ''})`;
@@ -1,4 +1,11 @@
1
1
  import { readJsonIfExists, writeJson, withFileLock } from '../fileOps.js';
2
+ // The installed runtime is the single implementation of capacity negotiation; this source
3
+ // twin consumes it rather than re-deriving the precedence rules. Both files ship together.
4
+ import {
5
+ readContextCapacityRecord,
6
+ SOFT_TO_CAP_RATIO,
7
+ } from '../../../templates/.claude/ukit/runtime/compact-threshold.mjs';
8
+ import { resolveContextCapTokens } from '../../../templates/.claude/ukit/runtime/context-capacity.mjs';
2
9
  import { buildRuntimePaths } from '../runtimePaths.js';
3
10
  import { buildCompactMachineKey, compressLine, estimateTokenCount } from '../token/index.js';
4
11
  import { compactContextBlock } from './index.js';
@@ -436,22 +443,45 @@ function computeEstimatedTotalTokens({
436
443
  return baselineTokens + estimatedContextTokens + windowTokens + sessionExcess;
437
444
  }
438
445
 
446
+ // The record the advisory guard published for this project, or null when nothing has been
447
+ // negotiated yet (fresh install, non-project caller, tests).
448
+ function readNegotiatedCapacity(config = {}) {
449
+ const record = readContextCapacityRecord(config?.projectRoot);
450
+ if (!record) return null;
451
+ return resolveContextCapTokens({
452
+ env: process.env,
453
+ config,
454
+ modelMetadata: { model: record.model },
455
+ });
456
+ }
457
+
439
458
  export function buildCompactThresholds(config = {}) {
440
- const softThreshold = Math.max(
441
- 1,
442
- finiteNumber(config?.compact?.tokenThreshold, loadShippedCompactBudget().tokenThreshold),
459
+ const shippedHardCap = positiveInteger(
460
+ config?.compact?.hardCapTokens,
461
+ loadShippedCompactBudget().hardCapTokens,
443
462
  );
463
+ const explicitSoftThreshold = finiteNumber(config?.compact?.tokenThreshold, 0);
464
+ // Negotiated capacity (H22): the guard publishes what it derived from the live route and
465
+ // this shared path consumes it; without a record the shipped tuning applies unchanged.
466
+ const negotiated = readNegotiatedCapacity(config);
467
+ const hardCapTokens = negotiated
468
+ ? Math.max(1, Math.min(shippedHardCap, negotiated.capTokens))
469
+ : shippedHardCap;
470
+ // An explicit operator tokenThreshold is honored as-is; otherwise the advisory phase is
471
+ // derived from the negotiating cap so it can never sit above the cap it precedes.
472
+ const softThreshold = explicitSoftThreshold > 0
473
+ ? Math.max(1, explicitSoftThreshold)
474
+ : Math.max(1, Math.round(hardCapTokens * SOFT_TO_CAP_RATIO));
444
475
  const hardThreshold = Math.max(softThreshold + 1, Math.round(softThreshold * 1.6));
445
476
  const baselineTokens = Math.max(120, Math.min(18_000, Math.round(softThreshold * 0.18)));
446
- // Keep the source mirror's config contract aligned with the installed runtime: malformed
447
- // caps fall back to a safe ceiling rather than turning every mutation into an over-cap call.
448
- const hardCapTokens = positiveInteger(config?.compact?.hardCapTokens, loadShippedCompactBudget().hardCapTokens);
449
477
 
450
478
  return {
451
479
  softThreshold,
452
480
  hardThreshold,
453
481
  baselineTokens,
454
482
  hardCapTokens,
483
+ capacitySource: negotiated?.capacity?.source ?? 'shipped',
484
+ capacityTokens: negotiated?.capacity?.tokens ?? null,
455
485
  };
456
486
  }
457
487