@ngockhoale/ukit 2.3.15 → 2.3.17

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,28 +2,131 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.3.17 - 2026-09-12
6
+
7
+ Long-task resilience — a handoff run is now measured before planning, checkpointed during
8
+ execution, and auto-split on wall-clock overrun instead of silently wedging. Cycle C12 turns
9
+ three questions into enforced machinery: "is this task too big to hand to an executor?"
10
+ (answered with data before a task is ever marked `ready`), "did the executor actually record
11
+ where it last stood green?" (answered by a machine-checkable milestone protocol), and "what
12
+ happens when a task blows its time budget?" (answered by a watchdog that cuts the task in
13
+ half and keeps the pipeline moving). Also fixes a repo-level gitignore trap that could
14
+ silently swallow new template files.
15
+
16
+ - **Task-budget validator — `needs_breakdown` as data, not prose.** New
17
+ `src/core/taskBudgetValidator.js` plus a behavior-identical shipped CLI twin at
18
+ `templates/.claude/ukit/index/task-budget-validator.mjs` (self-contained, no `src/`
19
+ dependency, so user installs get the same gate). Thresholds: `maxTargetFiles: 3`,
20
+ `maxTestCases: 8`, `maxVerificationMinutes: 10`, with a table-driven verification-minutes
21
+ estimator (`yarn test:release-core` = 3 min, `node scripts/release/verify-release.mjs` =
22
+ 2 min, `yarn vitest run` = 0.5 min per file argument, unknown commands flat 1 min) and an
23
+ `investigate`+`first` prose rule for spike-vs-build splits. The `handoff-planner` agents
24
+ (Claude Code + omp) run the twin on every candidate task and must mark over-budget
25
+ `needs_breakdown` instead of shipping it as `ready`. Missing required sections emit
26
+ `missing-field` reasons; the validator never throws and the CLI always exits 0 (advisory
27
+ gate). TDD: 14/14 vitest cases + 4/4 ship-contract checks.
28
+ - **Executor milestone protocol.** Task executors now append `## Progress` entries in a
29
+ fixed, parseable shape — `- <ISO-8601> · milestone: <name> · last-green: <what passed> ·
30
+ files: <paths> · drift: none|<why>` — and both task templates gain a `- Size: S|M|L`
31
+ line. New `src/core/taskProgressGuard.js` makes the entries machine-checkable, with the
32
+ staleness boundary at exactly 2 × `milestoneIntervalMin` (inclusive = ok, beyond = stale).
33
+ The feature-implementer agents (Claude Code + omp) and the `handoff-fullstack` command
34
+ carry matching protocol text as byte-identical pairs. TDD: 6/6 unit cases + 4/4 protocol
35
+ contract checks.
36
+ - **Wall-clock watchdog hook, `hardPolicy: "split"`.** New
37
+ `templates/.claude/hooks/task-watchdog.sh` + `templates/.claude/ukit/runtime/
38
+ task-watchdog.mjs`, wired through `.claude/settings.json` (Stop + PostToolUse Edit/Write,
39
+ timeout 4) and `manifests/platform.full.yaml` (`hook-task-watchdog`). Budgets come from
40
+ `handoff.taskBudgets`: S 8/15, M 15/30, L 25/45 soft/hard minutes, `milestoneIntervalMin:
41
+ 5` default, `hardPolicy` `"split"` (default) or `"pause"`. Soft overrun emits a visible
42
+ "checkpoint a milestone now" advisory; hard overrun under split policy emits a Stop
43
+ `decision=block` naming the follow-up `TASK-<id>-b` — blocking the stop IS the
44
+ auto-split-and-continue mechanism — capped at 2 blocks per task before degrading to an
45
+ advisory that hands back to the user. The hook can never hang a session: 3 s self-kill
46
+ (`HOOK_DEADLINE_MS`), all-async I/O, 64 KB stdin cap, fail-open `exit 0` on every path;
47
+ PostToolUse is advisory-only and never emits a decision. State lives at
48
+ `.ukit/storage/cache/task-watchdog/state.json`; config load is fail-open to
49
+ `DEFAULT_CONFIG`. TDD: 16 sandbox checks (hard-trip block, pause, cap, fail-open,
50
+ wiring, no-undefined advisories) + config-docs sync coverage.
51
+ - **Gitignore trap fixed at both layers.** An unanchored `.claude/` pattern treats every
52
+ directory named `.claude` at any depth as ignored, and `templates/.gitignore` is a
53
+ per-directory ignore for the `templates/` subtree — so it silently hid NEW files under
54
+ `templates/.claude/**` from `git status`/`diff`/`add` (already-tracked files were
55
+ unaffected, which is why the trap stayed invisible). It ate the TASK-014 CLI twin, which
56
+ a `git worktree remove --force` then destroyed. Root `.gitignore` runtime dirs are now
57
+ anchored (`/.claude/`, `/.codex/`, `/.omp/`, `/.ukit/`, `/.worktrees/`) and the
58
+ `templates/.gitignore` runtime-dir lines are removed with an explanatory NOTE; new
59
+ template files no longer need `git add -f`.
60
+
61
+ ## 2.3.16 - 2026-09-11
62
+
63
+ Gateway-stall root cause, part 5 — the operator's gateway stayed invisible in the repo. Wave 1
64
+ (TASK-011 + TASK-012) shipped the two client-side fixes that defend Claude Code against the
65
+ stall and the malformed-body incident; this release closes the loop with a written contract
66
+ the operator (UNIC or any other proxy in front of `ANTHROPIC_BASE_URL`) can be handed. The
67
+ two client-side fixes are also safe no-ops on the official Anthropic endpoint — they only
68
+ fire when a gateway is in the path, so uninstall does not need to clean them up.
69
+
70
+ - **Managed gateway resilience env defaults.** When `ANTHROPIC_BASE_URL` is non-empty (env,
71
+ project `.claude/settings.json`, or home `~/.claude/settings.json`), `ukit install` now
72
+ writes `CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK='1'` and
73
+ `CLAUDE_STREAM_IDLE_TIMEOUT_MS='600000'` into the project `.claude/settings.json` `env`
74
+ block via a new `merge_env_overwrite_with_backup` strategy. Never clobbers a user-set
75
+ value (a non-default value moves into the report's `skipped` list); re-running `ukit
76
+ install` is a no-op (`unchanged`); corrupt settings JSON fails safe without overwriting
77
+ the file. New module `src/core/gatewayResilienceEnv.js` + wiring in
78
+ `src/core/runInstallPipeline.js` and `src/cli/commands/install.js`. TDD: 12/12 unit
79
+ cases + 5/5 installCommand wiring cases green.
80
+ - **`ukit doctor --gateway` live probe.** New opt-in flag on `ukit doctor` posts one tiny
81
+ streaming and one tiny non-streaming probe to `/v1/messages` and prints a `✓`/`✗` per
82
+ requirement. Streaming is flagged as `buffered` when the first body read alone already
83
+ carries ≥2 SSE events (the exact stall signature). Non-streaming fails when the body is
84
+ HTTP 200 but not an Anthropic Message **or** the `request-id` header is missing — the
85
+ exact check whose absence let the 888-byte gateway error envelope slip through and kill
86
+ the user's turn. New module `src/core/gatewayProbe.js` (`fetchImpl` injectable, fully
87
+ hermetic test surface); wiring in `src/cli/commands/doctor.js` (`--gateway` added to
88
+ `KNOWN_FLAGS` + help; verdict is advisory and MUST NOT set `process.exitCode` so
89
+ scripted `ukit doctor` runs stay hermetic). TDD: 6/6 unit cases + 8/8 doctorCommand
90
+ wiring cases green.
91
+ - **Gateway operator requirements doc.** New `docs/GATEWAY.md` states the three
92
+ requirements the operator must satisfy — (1) SSE/bytes relayed incrementally (no
93
+ full-response buffering), (2) idle streams kept alive ≥600 s under the watchdog, (3)
94
+ non-streaming route returns a real Anthropic Message with a `request-id` header — plus a
95
+ client-side knob table (`CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK`,
96
+ `CLAUDE_STREAM_IDLE_TIMEOUT_MS`, `ukit doctor --gateway`) and the explicit safe-no-op
97
+ guarantee on the official Anthropic endpoint. Probe hint lines from TASK-012's
98
+ `gatewayProbe.js` point at this doc.
99
+
100
+ Residual unknown: `gatewayProbe.js` is exercised end-to-end against the real gateway only
101
+ through a hermetic `fetchImpl` (every test path uses an injected `ReadableStream`). A live
102
+ `ukit doctor --gateway` round-trip against the real gateway was NOT run in CI; it is a
103
+ manual post-release step on a gateway-connected machine. Recorded in `docs/STATUS.md`.
104
+
5
105
  ## 2.3.15 - 2026-09-11
6
106
 
7
- Vision-lane root cause, part 4 — native-first dispatch order. Found by mining the user's own
8
- failing sessions across four real projects (UnicDB, VSDB, BAGuide, AI-Gateway): the API `model`
9
- field in the transcripts proved failed specialist dispatches landed on `glm-5-turbo` / lite lanes
10
- (self-reported `MODEL: unic-lite`, `STATUS: WRONG_MODEL`) while the same sessions' main Claude
11
- lanes read images fine (`STATUS: OK` ×14 in one transcript). Two stacked defects:
12
-
13
- - **No fallback when the specialist fails.** The honored-lane hint ordered the parent to dispatch
14
- `ukit-vision-analyst` and simply continue; when the gateway routed the alias to a backend that
15
- cannot receive the image through the Read tool_result, the analyst returned `WRONG_MODEL` and
16
- the image ended up unread even though the parent could have read it natively. The hint now
17
- verifies the parent's own vision FIRST (step 2: Read the materialized files yourself, write the
18
- receipts, do NOT dispatch), dispatches the specialist only when the parent's own Read yields no
19
- image (step 3), and defines the fallback: on `WRONG_MODEL`/`NO_IMAGE` re-read natively, analyse
20
- from your own view for pasted images, and never fabricate when no reader can see it (step 4).
107
+ Vision-lane root cause, part 4 — the analyst never looked at the image. Found by mining the
108
+ user's own failing sessions across four real projects (UnicDB, VSDB, BAGuide, AI-Gateway). The
109
+ decisive record: a failed analyst subagent transcript is THREE lines with ZERO Read calls — the
110
+ analyst refused by name ("I am running on unic-lite, which is not a vision-capable model … I
111
+ must refuse to open, describe, or guess") without ever probing. The user confirmed the gateway's
112
+ vision routing is stable by design: unic-vision (backed by minimax/glm/chatgpt in their setup)
113
+ always reads images; nothing falls back to a non-visual lane. The failure was entirely
114
+ UKit-side, two stacked defects:
115
+
21
116
  - **Refusal by name instead of by probe.** The omp analyst template mandated refusing *before
22
- touching any image* when the runtime model could not be confirmed vision-capable — field
23
- transcripts show exactly this refusal (`I must refuse to open, describe, or guess`). Both agent
24
- templates now decide ONLY by the first image Read (the probe): a backend that self-identifies as
25
- `glm-5-turbo`/`MiniMax-M3`/a lite lane does not license refusal when the probe actually shows
26
- the image; a vision-sounding name with a failed probe is still `WRONG_MODEL`.
117
+ touching any image* when the runtime model could not be confirmed vision-capable — and any
118
+ backend self-identification (`glm-5-turbo`, `MiniMax-M3`) or lite-lane label counted as "not
119
+ confirmed". Both agent templates now decide ONLY by the first image Read (the probe): if the
120
+ probe shows the image the lane reads it and reports the mapping it actually ran on in
121
+ `MODEL:`; a probe that fails to deliver the image is still `STATUS: WRONG_MODEL`.
122
+ - **No fallback after a refusal.** The honored-lane hint ordered the parent to dispatch the
123
+ specialist and simply continue, so a name-refusal left the image unread even though the parent
124
+ could read it natively (`STATUS: OK` ×14 from main Claude lanes in one VSDB transcript). The
125
+ hint now verifies the parent's own vision FIRST (step 2: Read the materialized files yourself,
126
+ write the receipts, do NOT dispatch), dispatches the specialist only when the parent's own
127
+ Read yields no image (step 3), and defines the fallback: on `WRONG_MODEL`/`NO_IMAGE` re-read
128
+ natively, analyse from your own view for pasted images, and never fabricate when no reader can
129
+ see it (step 4).
27
130
 
28
131
  Unchanged doctrine: never guess at image contents — the fix removes the two paths that turned
29
132
  "don't guess" into "don't read". omp mirrors synced. TDD: `vision-router-hint` tests 13-14 and
@@ -973,7 +973,10 @@ items:
973
973
  - hook-block-dangerous
974
974
  - hook-handoff-model-guard
975
975
  - hook-auto-prune-bash
976
- mergeStrategy: overwrite_with_backup
976
+ # Env block is post-merged by applyGatewayResilienceEnv (TASK-011) to add managed
977
+ # gateway-resilience defaults without clobbering user values, so the diff ignores
978
+ # the env block — byte-equality on the rest of the file is enough to flag an update.
979
+ mergeStrategy: merge_env_overwrite_with_backup
977
980
  variables: []
978
981
  enabledByDefault: true
979
982
  packs:
@@ -1176,6 +1179,28 @@ items:
1176
1179
  packs:
1177
1180
  - core
1178
1181
 
1182
+ - id: hook-task-watchdog
1183
+ type: hook
1184
+ sourceTemplate: .claude/hooks/task-watchdog.sh
1185
+ targetPath: .claude/hooks/task-watchdog.sh
1186
+ requires: []
1187
+ mergeStrategy: overwrite_with_backup
1188
+ variables: []
1189
+ enabledByDefault: true
1190
+ packs:
1191
+ - core
1192
+
1193
+ - id: ukit-runtime-task-watchdog-script
1194
+ type: config
1195
+ sourceTemplate: .claude/ukit/runtime/task-watchdog.mjs
1196
+ targetPath: .claude/ukit/runtime/task-watchdog.mjs
1197
+ requires: []
1198
+ mergeStrategy: overwrite_with_backup
1199
+ variables: []
1200
+ enabledByDefault: true
1201
+ packs:
1202
+ - core
1203
+
1179
1204
  - id: hook-reset-compact-pressure
1180
1205
  type: hook
1181
1206
  sourceTemplate: .claude/hooks/reset-compact-pressure.sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.3.15",
3
+ "version": "2.3.17",
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",
@@ -8,9 +8,14 @@ import { loadManifest } from '../../manifest/loadManifest.js';
8
8
  import { detectStack } from '../../stack/detectStack.js';
9
9
  import { detectProviders } from '../../context/detectProviders.js';
10
10
  import { profileSkills } from '../../core/skillProfile.js';
11
+ import {
12
+ resolveGatewayBaseUrl,
13
+ probeGateway,
14
+ } from '../../core/gatewayProbe.js';
11
15
 
12
16
  export const DOCTOR_HELP_FLAGS = new Set(['--help', '-h']);
13
- const KNOWN_FLAGS = new Set([...DOCTOR_HELP_FLAGS, '--skills']);
17
+ const KNOWN_FLAGS = new Set([...DOCTOR_HELP_FLAGS, '--skills', '--gateway']);
18
+ const SUPPORTED_FLAGS_LIST = '--help, -h, --skills, --gateway';
14
19
 
15
20
  export function printDoctorHelp() {
16
21
  console.log('Usage: ukit doctor [options]');
@@ -20,12 +25,13 @@ export function printDoctorHelp() {
20
25
  console.log('Options:');
21
26
  console.log(' --help, -h Show this help message');
22
27
  console.log(' --skills Also print a skill word-count/budget report');
28
+ console.log(' --gateway Live gateway probe (streaming + non-streaming); advisory only');
23
29
  }
24
30
 
25
31
  export async function runDoctor({ packageRoot, projectRoot, argv = [] }) {
26
32
  const unknownFlags = argv.filter((flag) => !KNOWN_FLAGS.has(flag));
27
33
  if (unknownFlags.length > 0) {
28
- throw new Error(`Unknown option: ${unknownFlags[0]}. Supported: --help, -h, --skills`);
34
+ throw new Error(`Unknown option: ${unknownFlags[0]}. Supported: ${SUPPORTED_FLAGS_LIST}`);
29
35
  }
30
36
 
31
37
  if (argv.some((flag) => DOCTOR_HELP_FLAGS.has(flag))) {
@@ -149,6 +155,47 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [] }) {
149
155
  );
150
156
  }
151
157
 
158
+ if (argv.includes('--gateway')) {
159
+ console.log('');
160
+ console.log('[UKit] Gateway probe (advisory — does not affect exit code):');
161
+ let resolved;
162
+ try {
163
+ resolved = await resolveGatewayBaseUrl({ projectRoot });
164
+ } catch {
165
+ resolved = { baseUrl: null, source: null };
166
+ }
167
+ if (!resolved.baseUrl) {
168
+ 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
+ } else {
170
+ console.log(`[UKit] baseUrl: ${resolved.baseUrl} (source: ${resolved.source})`);
171
+ let result;
172
+ try {
173
+ result = await probeGateway({ baseUrl: resolved.baseUrl });
174
+ } catch (error) {
175
+ result = null;
176
+ console.log(`[UKit] ✗ Gateway probe threw: ${error?.message ?? String(error)}`);
177
+ }
178
+ if (result) {
179
+ const s = result.streaming;
180
+ const n = result.nonStreaming;
181
+ const streamingLabel = s.ok
182
+ ? `streaming OK (events=${s.eventsReceived}, chunks=${s.chunks})`
183
+ : `streaming FAIL (events=${s.eventsReceived}, chunks=${s.chunks}, buffered=${s.buffered}${s.error ? `, error=${s.error}` : ''})`;
184
+ const nonStreamingLabel = n.ok
185
+ ? `non-streaming OK (http=${n.httpOk}, isAnthropicMessage=${n.isAnthropicMessage}, request-id=${n.requestIdPresent}, bytes=${n.bodyBytes ?? 0})`
186
+ : `non-streaming FAIL (http=${n.httpOk}, isAnthropicMessage=${n.isAnthropicMessage}, request-id=${n.requestIdPresent}${n.error ? `, error=${n.error}` : ''})`;
187
+ console.log(`[UKit] ${ok(s.ok)} ${streamingLabel}`);
188
+ console.log(`[UKit] ${ok(n.ok)} ${nonStreamingLabel}`);
189
+ console.log(
190
+ `[UKit] ${ok(result.verdict === 'pass')} verdict: ${result.verdict}`,
191
+ );
192
+ for (const hint of result.hints) {
193
+ console.log(`[UKit] hint: ${hint}`);
194
+ }
195
+ }
196
+ }
197
+ }
198
+
152
199
  const allPassed = Object.values(checks).every(Boolean);
153
200
  if (!allPassed) {
154
201
  console.log('[UKit] Some checks failed. Run `ukit install` to fix missing files.');
@@ -1,6 +1,7 @@
1
1
  import { buildPathConfig } from '../../core/paths.js';
2
2
  import { runInstallPipeline } from '../../core/runInstallPipeline.js';
3
3
  import { formatRepairReport } from '../../core/repairBrokenHooks.js';
4
+ import { formatGatewayResilienceReport } from '../../core/gatewayResilienceEnv.js';
4
5
  import { buildCodeIndex } from '../../index/buildIndex.js';
5
6
  import { installIndexRefreshHooks } from '../../index/gitHooks.js';
6
7
  import fs from 'node:fs/promises';
@@ -246,6 +247,10 @@ export async function runInstall({ packageRoot, projectRoot, packageVersion, arg
246
247
  console.log(`[UKit] ${line}`);
247
248
  }
248
249
 
250
+ for (const line of formatGatewayResilienceReport(result.gatewayResilience ?? { customGateway: false })) {
251
+ console.log(`[UKit] ${line}`);
252
+ }
253
+
249
254
  const docsLabels = [
250
255
  'docs/PROJECT.md',
251
256
  'docs/MEMORY.md',
@@ -69,7 +69,7 @@ export async function applyDiffResults(diffResults, { backupRoot, projectRoot }
69
69
  // Back up the existing file before overwriting
70
70
  if (
71
71
  entry.action === 'update' &&
72
- entry.mergeStrategy === 'overwrite_with_backup' &&
72
+ (entry.mergeStrategy === 'overwrite_with_backup' || entry.mergeStrategy === 'merge_env_overwrite_with_backup') &&
73
73
  backupRoot &&
74
74
  projectRoot
75
75
  ) {
@@ -1,5 +1,8 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import { isSymlinkTo } from './fileOps.js';
3
+ import { GATEWAY_RESILIENCE_ENV_DEFAULTS } from './gatewayResilienceEnv.js';
4
+
5
+ const GATEWAY_RESILIENCE_KEYS = new Set(Object.keys(GATEWAY_RESILIENCE_ENV_DEFAULTS));
3
6
 
4
7
  async function readFileOrNull(filePath, encoding = 'utf8') {
5
8
  try {
@@ -24,6 +27,78 @@ async function checkLinkStatus(targetPath, linkTarget) {
24
27
  }
25
28
  }
26
29
 
30
+ // Settings.json uses a strategy where only the gateway-resilience env keys (managed by
31
+ // applyGatewayResilienceEnv post-apply) are ignored during the diff. Every other top-level
32
+ // key — including the rest of the env block — is still compared byte-for-byte against the
33
+ // template, so a template-side change to e.g. CLAUDE_CODE_AUTO_COMPACT_WINDOW still triggers
34
+ // a real reinstall. Without this carve-out, the managed gateway keys written by the post-apply
35
+ // step would cause a spurious "update" on every subsequent install.
36
+ function settingsJsonIgnoresGatewayEnvDiff(existingContent, renderedContent) {
37
+ if (typeof existingContent !== 'string' || typeof renderedContent !== 'string') {
38
+ return false;
39
+ }
40
+ let parsedExisting;
41
+ let parsedRendered;
42
+ try {
43
+ parsedExisting = JSON.parse(existingContent);
44
+ parsedRendered = JSON.parse(renderedContent);
45
+ } catch {
46
+ return false;
47
+ }
48
+ if (!parsedExisting || typeof parsedExisting !== 'object' || Array.isArray(parsedExisting)) {
49
+ return false;
50
+ }
51
+ if (!parsedRendered || typeof parsedRendered !== 'object' || Array.isArray(parsedRendered)) {
52
+ return false;
53
+ }
54
+
55
+ for (const key of Object.keys(parsedRendered)) {
56
+ if (key === 'env') continue;
57
+ if (!(key in parsedExisting)) return false;
58
+ if (JSON.stringify(parsedExisting[key]) !== JSON.stringify(parsedRendered[key])) {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ // Reverse direction: an existing top-level key that the template does NOT ship is also
64
+ // drift. Catches template key REMOVAL (e.g. a previous template shipped `permissions`
65
+ // and the new one dropped it) and user-side additions. The `merge_env_overwrite_with_backup`
66
+ // strategy is a narrowed overwrite — the only carve-out is the UKit-managed gateway env
67
+ // keys below — so every other byte mismatch must reinstall, in either direction.
68
+ for (const key of Object.keys(parsedExisting)) {
69
+ if (key === 'env') continue;
70
+ if (!(key in parsedRendered)) return false;
71
+ }
72
+
73
+ const templateEnv = parsedRendered.env && typeof parsedRendered.env === 'object' && !Array.isArray(parsedRendered.env)
74
+ ? parsedRendered.env
75
+ : {};
76
+ const fileEnv = parsedExisting.env && typeof parsedExisting.env === 'object' && !Array.isArray(parsedExisting.env)
77
+ ? parsedExisting.env
78
+ : {};
79
+
80
+ // Only enforce template env keys that are NOT UKit-managed. UKit-managed keys
81
+ // (CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK, CLAUDE_STREAM_IDLE_TIMEOUT_MS) are owned by
82
+ // applyGatewayResilienceEnv — it adds them post-apply and preserves user overrides.
83
+ for (const key of Object.keys(templateEnv)) {
84
+ if (GATEWAY_RESILIENCE_KEYS.has(key)) continue;
85
+ if (JSON.stringify(fileEnv[key]) !== JSON.stringify(templateEnv[key])) {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ // Reverse direction for env: an env key in the file that the template does NOT ship
91
+ // (and that is not UKit-managed) is also drift — a removed template env key, or a
92
+ // user-added env key, must reinstall. UKit-managed gateway keys remain carved out
93
+ // because applyGatewayResilienceEnv owns them post-apply.
94
+ for (const key of Object.keys(fileEnv)) {
95
+ if (GATEWAY_RESILIENCE_KEYS.has(key)) continue;
96
+ if (!(key in templateEnv)) return false;
97
+ }
98
+
99
+ return true;
100
+ }
101
+
27
102
  function resolveFileAction(entry, existingContent) {
28
103
  const exists = existingContent !== null;
29
104
  const binaryEntry = Buffer.isBuffer(entry.renderedContent);
@@ -41,6 +116,11 @@ function resolveFileAction(entry, existingContent) {
41
116
  : entry.mergeStrategy === 'skip'
42
117
  ? 'skip'
43
118
  : 'update';
119
+ } else if (entry.mergeStrategy === 'merge_env_overwrite_with_backup' && typeof existingContent === 'string') {
120
+ // Settings.json: ignore UKit-managed gateway resilience env keys (post-apply written
121
+ // and may carry user overrides). All other top-level keys — and the rest of the env
122
+ // block — must still match the template, so genuine template updates still reinstall.
123
+ action = settingsJsonIgnoresGatewayEnvDiff(existingContent, entry.renderedContent) ? 'unchanged' : 'update';
44
124
  } else if (existingContent === entry.renderedContent) {
45
125
  action = 'unchanged';
46
126
  } else if (entry.mergeStrategy === 'skip') {