@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 +122 -19
- package/manifests/platform.full.yaml +26 -1
- package/package.json +1 -1
- package/src/cli/commands/doctor.js +49 -2
- package/src/cli/commands/install.js +5 -0
- package/src/core/applyPlan.js +1 -1
- package/src/core/diffPlan.js +80 -0
- package/src/core/gatewayProbe.js +441 -0
- package/src/core/gatewayResilienceEnv.js +292 -0
- package/src/core/runInstallPipeline.js +11 -0
- package/src/core/taskBudgetValidator.js +241 -0
- package/src/core/taskProgressGuard.js +157 -0
- package/src/manifest/validateManifest.js +1 -1
- package/templates/.claude/agents/feature-implementer.md +30 -0
- package/templates/.claude/agents/handoff-planner.md +12 -0
- package/templates/.claude/commands/ukit/handoff-fullstack.md +4 -1
- package/templates/.claude/hooks/task-watchdog.sh +221 -0
- package/templates/.claude/settings.json +10 -0
- package/templates/.claude/ukit/index/task-budget-validator.mjs +236 -0
- package/templates/.claude/ukit/runtime/task-watchdog.mjs +299 -0
- package/templates/.gitignore +5 -4
- package/templates/.omp/agents/feature-implementer.md +30 -0
- package/templates/.omp/agents/handoff-planner.md +12 -0
- package/templates/.omp/hooks/pre/ukit-bridge.js +2 -1
- package/templates/docs/AI_HANDOFF/tasks/_TEMPLATE.md +17 -0
- package/templates/ukit/storage/config.json +8 -1
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 —
|
|
8
|
-
failing sessions across four real projects (UnicDB, VSDB, BAGuide, AI-Gateway)
|
|
9
|
-
|
|
10
|
-
(
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 —
|
|
23
|
-
|
|
24
|
-
templates now decide ONLY by the first image Read (the probe):
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
@@ -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:
|
|
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',
|
package/src/core/applyPlan.js
CHANGED
|
@@ -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
|
) {
|
package/src/core/diffPlan.js
CHANGED
|
@@ -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') {
|