@ngockhoale/ukit 2.3.15 → 2.3.19

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,146 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.3.19 - 2026-09-12
6
+
7
+ C14 bug-fix release record — one correctness fix is shipped; this release adds no new features.
8
+
9
+ - **Completion gate no longer silently releases a reentrant stop.** `templates/.claude/ukit/runtime/execution-ledger.mjs` (`--evaluate-stop`) had a `stop_hook_active` valve: when Claude Code re-fired `Stop` after the gate had already blocked once, the valve emitted only a `systemMessage` with no `decision` while completion evidence was still missing, so the turn ended and the session idled until the human typed "tiếp". The valve is removed — a reentrant stop is now evaluated like any other stop, blocking again with the actionable missing-evidence reason (produce the evidence or report a blocker), and the loop stays bounded by the existing visible continuation cap and the verification-loop blocker, so a finished task is never trapped.
10
+
11
+ ## 2.3.18 - 2026-09-12
12
+
13
+ C13 bug-sweep release record — two wave-1 correctness fixes are shipped; this release adds no new features.
14
+
15
+ - **Task-budget validator heading and target-count fix.** `src/core/taskBudgetValidator.js` and the shipped twin `templates/.claude/ukit/index/task-budget-validator.mjs` now accept the template's `## Test Cases (REQUIRED — TDD)` heading without a false `missing-field` verdict and count only top-level `## Target Files` bullets, avoiding indented sub-bullet miscounts.
16
+ - **Task-progress guard multi-section fix.** `src/core/taskProgressGuard.js` now evaluates milestone entries across every `## Progress` section, so a fresh appended entry is not masked by an older first-section entry while malformed and drift checks remain active.
17
+
18
+ Verification: version pin and targeted regression suites pass; `yarn release:verify` is the closing release gate.
19
+
20
+ ## 2.3.17 - 2026-09-12
21
+
22
+ Long-task resilience — a handoff run is now measured before planning, checkpointed during
23
+ execution, and auto-split on wall-clock overrun instead of silently wedging. Cycle C12 turns
24
+ three questions into enforced machinery: "is this task too big to hand to an executor?"
25
+ (answered with data before a task is ever marked `ready`), "did the executor actually record
26
+ where it last stood green?" (answered by a machine-checkable milestone protocol), and "what
27
+ happens when a task blows its time budget?" (answered by a watchdog that cuts the task in
28
+ half and keeps the pipeline moving). Also fixes a repo-level gitignore trap that could
29
+ silently swallow new template files.
30
+
31
+ - **Task-budget validator — `needs_breakdown` as data, not prose.** New
32
+ `src/core/taskBudgetValidator.js` plus a behavior-identical shipped CLI twin at
33
+ `templates/.claude/ukit/index/task-budget-validator.mjs` (self-contained, no `src/`
34
+ dependency, so user installs get the same gate). Thresholds: `maxTargetFiles: 3`,
35
+ `maxTestCases: 8`, `maxVerificationMinutes: 10`, with a table-driven verification-minutes
36
+ estimator (`yarn test:release-core` = 3 min, `node scripts/release/verify-release.mjs` =
37
+ 2 min, `yarn vitest run` = 0.5 min per file argument, unknown commands flat 1 min) and an
38
+ `investigate`+`first` prose rule for spike-vs-build splits. The `handoff-planner` agents
39
+ (Claude Code + omp) run the twin on every candidate task and must mark over-budget
40
+ `needs_breakdown` instead of shipping it as `ready`. Missing required sections emit
41
+ `missing-field` reasons; the validator never throws and the CLI always exits 0 (advisory
42
+ gate). TDD: 14/14 vitest cases + 4/4 ship-contract checks.
43
+ - **Executor milestone protocol.** Task executors now append `## Progress` entries in a
44
+ fixed, parseable shape — `- <ISO-8601> · milestone: <name> · last-green: <what passed> ·
45
+ files: <paths> · drift: none|<why>` — and both task templates gain a `- Size: S|M|L`
46
+ line. New `src/core/taskProgressGuard.js` makes the entries machine-checkable, with the
47
+ staleness boundary at exactly 2 × `milestoneIntervalMin` (inclusive = ok, beyond = stale).
48
+ The feature-implementer agents (Claude Code + omp) and the `handoff-fullstack` command
49
+ carry matching protocol text as byte-identical pairs. TDD: 6/6 unit cases + 4/4 protocol
50
+ contract checks.
51
+ - **Wall-clock watchdog hook, `hardPolicy: "split"`.** New
52
+ `templates/.claude/hooks/task-watchdog.sh` + `templates/.claude/ukit/runtime/
53
+ task-watchdog.mjs`, wired through `.claude/settings.json` (Stop + PostToolUse Edit/Write,
54
+ timeout 4) and `manifests/platform.full.yaml` (`hook-task-watchdog`). Budgets come from
55
+ `handoff.taskBudgets`: S 8/15, M 15/30, L 25/45 soft/hard minutes, `milestoneIntervalMin:
56
+ 5` default, `hardPolicy` `"split"` (default) or `"pause"`. Soft overrun emits a visible
57
+ "checkpoint a milestone now" advisory; hard overrun under split policy emits a Stop
58
+ `decision=block` naming the follow-up `TASK-<id>-b` — blocking the stop IS the
59
+ auto-split-and-continue mechanism — capped at 2 blocks per task before degrading to an
60
+ advisory that hands back to the user. The hook can never hang a session: 3 s self-kill
61
+ (`HOOK_DEADLINE_MS`), all-async I/O, 64 KB stdin cap, fail-open `exit 0` on every path;
62
+ PostToolUse is advisory-only and never emits a decision. State lives at
63
+ `.ukit/storage/cache/task-watchdog/state.json`; config load is fail-open to
64
+ `DEFAULT_CONFIG`. TDD: 16 sandbox checks (hard-trip block, pause, cap, fail-open,
65
+ wiring, no-undefined advisories) + config-docs sync coverage.
66
+ - **Gitignore trap fixed at both layers.** An unanchored `.claude/` pattern treats every
67
+ directory named `.claude` at any depth as ignored, and `templates/.gitignore` is a
68
+ per-directory ignore for the `templates/` subtree — so it silently hid NEW files under
69
+ `templates/.claude/**` from `git status`/`diff`/`add` (already-tracked files were
70
+ unaffected, which is why the trap stayed invisible). It ate the TASK-014 CLI twin, which
71
+ a `git worktree remove --force` then destroyed. Root `.gitignore` runtime dirs are now
72
+ anchored (`/.claude/`, `/.codex/`, `/.omp/`, `/.ukit/`, `/.worktrees/`) and the
73
+ `templates/.gitignore` runtime-dir lines are removed with an explanatory NOTE; new
74
+ template files no longer need `git add -f`.
75
+
76
+ ## 2.3.16 - 2026-09-11
77
+
78
+ Gateway-stall root cause, part 5 — the operator's gateway stayed invisible in the repo. Wave 1
79
+ (TASK-011 + TASK-012) shipped the two client-side fixes that defend Claude Code against the
80
+ stall and the malformed-body incident; this release closes the loop with a written contract
81
+ the operator (UNIC or any other proxy in front of `ANTHROPIC_BASE_URL`) can be handed. The
82
+ two client-side fixes are also safe no-ops on the official Anthropic endpoint — they only
83
+ fire when a gateway is in the path, so uninstall does not need to clean them up.
84
+
85
+ - **Managed gateway resilience env defaults.** When `ANTHROPIC_BASE_URL` is non-empty (env,
86
+ project `.claude/settings.json`, or home `~/.claude/settings.json`), `ukit install` now
87
+ writes `CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK='1'` and
88
+ `CLAUDE_STREAM_IDLE_TIMEOUT_MS='600000'` into the project `.claude/settings.json` `env`
89
+ block via a new `merge_env_overwrite_with_backup` strategy. Never clobbers a user-set
90
+ value (a non-default value moves into the report's `skipped` list); re-running `ukit
91
+ install` is a no-op (`unchanged`); corrupt settings JSON fails safe without overwriting
92
+ the file. New module `src/core/gatewayResilienceEnv.js` + wiring in
93
+ `src/core/runInstallPipeline.js` and `src/cli/commands/install.js`. TDD: 12/12 unit
94
+ cases + 5/5 installCommand wiring cases green.
95
+ - **`ukit doctor --gateway` live probe.** New opt-in flag on `ukit doctor` posts one tiny
96
+ streaming and one tiny non-streaming probe to `/v1/messages` and prints a `✓`/`✗` per
97
+ requirement. Streaming is flagged as `buffered` when the first body read alone already
98
+ carries ≥2 SSE events (the exact stall signature). Non-streaming fails when the body is
99
+ HTTP 200 but not an Anthropic Message **or** the `request-id` header is missing — the
100
+ exact check whose absence let the 888-byte gateway error envelope slip through and kill
101
+ the user's turn. New module `src/core/gatewayProbe.js` (`fetchImpl` injectable, fully
102
+ hermetic test surface); wiring in `src/cli/commands/doctor.js` (`--gateway` added to
103
+ `KNOWN_FLAGS` + help; verdict is advisory and MUST NOT set `process.exitCode` so
104
+ scripted `ukit doctor` runs stay hermetic). TDD: 6/6 unit cases + 8/8 doctorCommand
105
+ wiring cases green.
106
+ - **Gateway operator requirements doc.** New `docs/GATEWAY.md` states the three
107
+ requirements the operator must satisfy — (1) SSE/bytes relayed incrementally (no
108
+ full-response buffering), (2) idle streams kept alive ≥600 s under the watchdog, (3)
109
+ non-streaming route returns a real Anthropic Message with a `request-id` header — plus a
110
+ client-side knob table (`CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK`,
111
+ `CLAUDE_STREAM_IDLE_TIMEOUT_MS`, `ukit doctor --gateway`) and the explicit safe-no-op
112
+ guarantee on the official Anthropic endpoint. Probe hint lines from TASK-012's
113
+ `gatewayProbe.js` point at this doc.
114
+
115
+ Residual unknown: `gatewayProbe.js` is exercised end-to-end against the real gateway only
116
+ through a hermetic `fetchImpl` (every test path uses an injected `ReadableStream`). A live
117
+ `ukit doctor --gateway` round-trip against the real gateway was NOT run in CI; it is a
118
+ manual post-release step on a gateway-connected machine. Recorded in `docs/STATUS.md`.
119
+
5
120
  ## 2.3.15 - 2026-09-11
6
121
 
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).
122
+ Vision-lane root cause, part 4 — the analyst never looked at the image. Found by mining the
123
+ user's own failing sessions across four real projects (UnicDB, VSDB, BAGuide, AI-Gateway). The
124
+ decisive record: a failed analyst subagent transcript is THREE lines with ZERO Read calls — the
125
+ analyst refused by name ("I am running on unic-lite, which is not a vision-capable model … I
126
+ must refuse to open, describe, or guess") without ever probing. The user confirmed the gateway's
127
+ vision routing is stable by design: unic-vision (backed by minimax/glm/chatgpt in their setup)
128
+ always reads images; nothing falls back to a non-visual lane. The failure was entirely
129
+ UKit-side, two stacked defects:
130
+
21
131
  - **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`.
132
+ touching any image* when the runtime model could not be confirmed vision-capable — and any
133
+ backend self-identification (`glm-5-turbo`, `MiniMax-M3`) or lite-lane label counted as "not
134
+ confirmed". Both agent templates now decide ONLY by the first image Read (the probe): if the
135
+ probe shows the image the lane reads it and reports the mapping it actually ran on in
136
+ `MODEL:`; a probe that fails to deliver the image is still `STATUS: WRONG_MODEL`.
137
+ - **No fallback after a refusal.** The honored-lane hint ordered the parent to dispatch the
138
+ specialist and simply continue, so a name-refusal left the image unread even though the parent
139
+ could read it natively (`STATUS: OK` ×14 from main Claude lanes in one VSDB transcript). The
140
+ hint now verifies the parent's own vision FIRST (step 2: Read the materialized files yourself,
141
+ write the receipts, do NOT dispatch), dispatches the specialist only when the parent's own
142
+ Read yields no image (step 3), and defines the fallback: on `WRONG_MODEL`/`NO_IMAGE` re-read
143
+ natively, analyse from your own view for pasted images, and never fabricate when no reader can
144
+ see it (step 4).
27
145
 
28
146
  Unchanged doctrine: never guess at image contents — the fix removes the two paths that turned
29
147
  "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.19",
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') {
@@ -2,17 +2,22 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { writeFileAtomic } from './fileOps.js';
4
4
 
5
+ // Root-anchored (leading `/`) so the block only ignores project-root entries.
6
+ // Unanchored dir entries (`.claude/`) match same-named dirs at ANY depth and
7
+ // hid templates/.claude from git — the C12 trap `ukit install` kept re-adding.
8
+ // Entries with an inner slash (`.claude/ukit/...`) are already root-anchored
9
+ // by gitignore semantics.
5
10
  const UKIT_ENTRIES = [
6
- '.cache/',
11
+ '/.cache/',
7
12
  // legacy: Antigravity adapter removed in v2.2.0; leftovers must stay ignored
8
- '.antigravity/',
9
- '.claude/',
10
- '.codex/',
11
- '.omp/',
12
- '.ukit/',
13
- 'opencode.json',
14
- 'AGENTS.md',
15
- 'CLAUDE.md',
13
+ '/.antigravity/',
14
+ '/.claude/',
15
+ '/.codex/',
16
+ '/.omp/',
17
+ '/.ukit/',
18
+ '/opencode.json',
19
+ '/AGENTS.md',
20
+ '/CLAUDE.md',
16
21
  '.claude/ukit/.ukit/',
17
22
  '.claude/ukit/permission-usage.json',
18
23
  '.claude/ukit/permission-audit.log',