@ngockhoale/ukit 3.4.27 → 3.4.28
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 +18 -0
- package/manifests/documentation.yaml +59 -0
- package/manifests/platform.full.yaml +24 -0
- package/package.json +6 -1
- package/scripts/audit/subagent-cost/metrics.mjs +14 -2
- package/scripts/audit/subagent-cost/reader.mjs +15 -3
- package/scripts/audit/subagent-cost/usage-fields.mjs +82 -0
- package/src/cli/commands/update.js +59 -2
- package/src/core/agentRuntime/context.js +78 -0
- package/src/core/diffPlan.js +100 -102
- package/src/core/observability/emit/sessionBoot.js +14 -0
- package/src/core/runtimeConfigMerge.js +125 -0
- package/src/core/updateSafety.js +174 -0
- package/src/index/contextPack.js +285 -0
- package/src/index/sensitivePath.js +21 -0
- package/src/index/taskRouting.js +3 -0
- package/template_project/.claude/commands/ukit/ci-check.md +53 -0
- package/template_project/.claude/hooks/handoff-model-guard.sh +9 -2
- package/template_project/.claude/hooks/handoff-resume.sh +13 -0
- package/template_project/.claude/hooks/record-execution.mjs +9 -0
- package/template_project/.claude/hooks/skill-router.sh +3 -0
- package/template_project/.claude/ukit/index/ci-check.mjs +665 -0
- package/template_project/.claude/ukit/index/route-task.mjs +3 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +19 -7
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +8 -0
- package/template_project/.claude/ukit/runtime/loop-state.mjs +131 -0
- package/template_project/.claude/ukit/runtime/observability-emit.mjs +14 -0
- package/template_project/.claude/ukit/runtime/read-advisory.mjs +92 -0
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +33 -1
- package/template_project/.claude/ukit/runtime/usage-capture.mjs +338 -0
- package/template_project/.omp/hooks/pre/ukit-bridge.js +48 -4
package/src/core/diffPlan.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import { isSymlinkTo } from './fileOps.js';
|
|
3
3
|
import { GATEWAY_RESILIENCE_ENV_DEFAULTS } from './gatewayResilienceEnv.js';
|
|
4
4
|
import { isOmpConfigTarget, mergeOmpConfig } from './ompConfigMerge.js';
|
|
5
|
+
import { mergeRuntimeConfigText } from './runtimeConfigMerge.js';
|
|
5
6
|
|
|
6
7
|
const GATEWAY_RESILIENCE_KEYS = new Set(Object.keys(GATEWAY_RESILIENCE_ENV_DEFAULTS));
|
|
7
8
|
|
|
@@ -45,130 +46,121 @@ async function checkLinkStatus(targetPath, linkTarget) {
|
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
// Settings.json
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
// (
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
49
|
+
// Settings.json ownership model (U1). `.claude/settings.json` is a shared file: UKit ships
|
|
50
|
+
// a baseline and users customise it. Ownership per key:
|
|
51
|
+
// UKit-owned (template always wins): `hooks` (the safety/enforcement wiring), the
|
|
52
|
+
// template's own `env` keys, and every `permissions.deny`
|
|
53
|
+
// entry the template ships.
|
|
54
|
+
// Additive (union, template first): `permissions.allow|deny|ask|additionalDirectories` —
|
|
55
|
+
// user entries are kept; the template's entries are
|
|
56
|
+
// always present.
|
|
57
|
+
// User-owned (existing wins): `permissions.defaultMode`, and every other key the
|
|
58
|
+
// user already has (model, effortLevel, statusLine,
|
|
59
|
+
// enabledPlugins, unknown/future keys, user env keys…).
|
|
60
|
+
// Template-filled: keys the user does not have take the template value.
|
|
61
|
+
// Gateway-resilience env keys are post-apply managed (applyGatewayResilienceEnv) and are
|
|
62
|
+
// ignored by the drift comparison.
|
|
63
|
+
const UKIT_OWNED_TOP_LEVEL_KEYS = new Set(['hooks']);
|
|
64
|
+
const PERMISSION_LIST_KEYS = new Set(['allow', 'deny', 'ask', 'additionalDirectories']);
|
|
65
|
+
const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
66
|
+
|
|
67
|
+
function isPlainObject(value) {
|
|
68
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
69
|
+
}
|
|
61
70
|
|
|
62
71
|
function parseSettingsJson(content) {
|
|
63
72
|
if (typeof content !== 'string') return null;
|
|
64
73
|
try {
|
|
65
74
|
const parsed = JSON.parse(content);
|
|
66
|
-
return
|
|
75
|
+
return isPlainObject(parsed) ? parsed : null;
|
|
67
76
|
} catch {
|
|
68
77
|
return null;
|
|
69
78
|
}
|
|
70
79
|
}
|
|
71
80
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// Merge rendered settings.json with the user's existing file: the rendered template
|
|
84
|
-
// wins every key EXCEPT user-owned permission keys (currently permissions.defaultMode),
|
|
85
|
-
// which keep the value the user set. Returns the merged JSON as a string, or the
|
|
86
|
-
// rendered content unchanged when either side is unparseable.
|
|
87
|
-
export function mergeSettingsJson(existingContent, renderedContent) {
|
|
88
|
-
const existing = parseSettingsJson(existingContent);
|
|
89
|
-
const rendered = parseSettingsJson(renderedContent);
|
|
90
|
-
if (!existing || !rendered) return renderedContent;
|
|
91
|
-
const merged = { ...rendered };
|
|
92
|
-
if (existing.permissions && typeof existing.permissions === 'object' && !Array.isArray(existing.permissions)) {
|
|
93
|
-
const mergedPermissions = { ...(rendered.permissions && typeof rendered.permissions === 'object' ? rendered.permissions : {}) };
|
|
94
|
-
for (const key of USER_OWNED_PERMISSION_KEYS) {
|
|
95
|
-
if (key in existing.permissions) mergedPermissions[key] = existing.permissions[key];
|
|
81
|
+
function unionList(templateList, userList) {
|
|
82
|
+
const out = Array.isArray(templateList) ? [...templateList] : [];
|
|
83
|
+
if (!Array.isArray(userList)) return out;
|
|
84
|
+
const seen = new Set(out.map((item) => JSON.stringify(item)));
|
|
85
|
+
for (const item of userList) {
|
|
86
|
+
const key = JSON.stringify(item);
|
|
87
|
+
if (!seen.has(key)) {
|
|
88
|
+
seen.add(key);
|
|
89
|
+
out.push(item);
|
|
96
90
|
}
|
|
97
|
-
merged.permissions = mergedPermissions;
|
|
98
91
|
}
|
|
99
|
-
return
|
|
92
|
+
return out;
|
|
100
93
|
}
|
|
101
94
|
|
|
102
|
-
function
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
let parsedRendered;
|
|
108
|
-
try {
|
|
109
|
-
parsedExisting = JSON.parse(existingContent);
|
|
110
|
-
parsedRendered = JSON.parse(renderedContent);
|
|
111
|
-
} catch {
|
|
112
|
-
return false;
|
|
95
|
+
function mergeSettingsObjects(existing, rendered) {
|
|
96
|
+
const merged = {};
|
|
97
|
+
for (const key of Object.keys(rendered)) {
|
|
98
|
+
if (BLOCKED_KEYS.has(key)) continue;
|
|
99
|
+
merged[key] = rendered[key];
|
|
113
100
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
101
|
+
for (const [key, userValue] of Object.entries(existing)) {
|
|
102
|
+
if (BLOCKED_KEYS.has(key) || UKIT_OWNED_TOP_LEVEL_KEYS.has(key)) continue;
|
|
103
|
+
if (!(key in rendered)) {
|
|
104
|
+
merged[key] = userValue; // unknown/user key — preserved
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const templateValue = rendered[key];
|
|
108
|
+
if (key === 'permissions' && isPlainObject(userValue) && isPlainObject(templateValue)) {
|
|
109
|
+
const permissions = { ...templateValue };
|
|
110
|
+
for (const [pKey, pValue] of Object.entries(userValue)) {
|
|
111
|
+
if (BLOCKED_KEYS.has(pKey)) continue;
|
|
112
|
+
if (PERMISSION_LIST_KEYS.has(pKey)) {
|
|
113
|
+
permissions[pKey] = unionList(templateValue[pKey], pValue);
|
|
114
|
+
} else {
|
|
115
|
+
permissions[pKey] = pValue; // defaultMode + unknown permission keys are user-owned
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
merged.permissions = permissions;
|
|
119
|
+
} else if (key === 'env' && isPlainObject(userValue) && isPlainObject(templateValue)) {
|
|
120
|
+
merged.env = { ...userValue, ...templateValue };
|
|
121
|
+
} else if (key === 'permissions' || key === 'env') {
|
|
122
|
+
// user value has the wrong shape (e.g. permissions: "x") — template shape wins
|
|
123
|
+
merged[key] = templateValue;
|
|
124
|
+
} else {
|
|
125
|
+
merged[key] = userValue; // user-owned scalar/section (model, effortLevel, …)
|
|
132
126
|
}
|
|
133
127
|
}
|
|
128
|
+
return merged;
|
|
129
|
+
}
|
|
134
130
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
131
|
+
// Merge rendered settings.json with the user's existing file per the ownership model above.
|
|
132
|
+
// Returns the merged JSON as a string, or the rendered content unchanged when either side is
|
|
133
|
+
// unparseable (the caller still takes a timestamped backup before writing).
|
|
134
|
+
export function mergeSettingsJson(existingContent, renderedContent) {
|
|
135
|
+
const existing = parseSettingsJson(existingContent);
|
|
136
|
+
const rendered = parseSettingsJson(renderedContent);
|
|
137
|
+
if (!existing || !rendered) return renderedContent;
|
|
138
|
+
return `${JSON.stringify(mergeSettingsObjects(existing, rendered), null, 2)}\n`;
|
|
139
|
+
}
|
|
144
140
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
141
|
+
function withoutGatewayEnv(settings) {
|
|
142
|
+
if (!isPlainObject(settings.env)) return settings;
|
|
143
|
+
const env = { ...settings.env };
|
|
144
|
+
for (const key of GATEWAY_RESILIENCE_KEYS) delete env[key];
|
|
145
|
+
return { ...settings, env };
|
|
146
|
+
}
|
|
151
147
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
148
|
+
function settingsJsonIgnoresGatewayEnvDiff(existingContent, renderedContent) {
|
|
149
|
+
const existing = parseSettingsJson(existingContent);
|
|
150
|
+
const rendered = parseSettingsJson(renderedContent);
|
|
151
|
+
if (!existing || !rendered) return false;
|
|
152
|
+
const merged = mergeSettingsObjects(existing, rendered);
|
|
153
|
+
return JSON.stringify(canonicalize(withoutGatewayEnv(merged)))
|
|
154
|
+
=== JSON.stringify(canonicalize(withoutGatewayEnv(existing)));
|
|
155
|
+
}
|
|
161
156
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if (GATEWAY_RESILIENCE_KEYS.has(key)) continue;
|
|
168
|
-
if (!(key in templateEnv)) return false;
|
|
157
|
+
// Key-order-insensitive structure for comparison (arrays keep order).
|
|
158
|
+
function canonicalize(value) {
|
|
159
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
160
|
+
if (isPlainObject(value)) {
|
|
161
|
+
return Object.fromEntries(Object.keys(value).sort().map((k) => [k, canonicalize(value[k])]));
|
|
169
162
|
}
|
|
170
|
-
|
|
171
|
-
return true;
|
|
163
|
+
return value;
|
|
172
164
|
}
|
|
173
165
|
|
|
174
166
|
function resolveFileAction(entry, existingContent) {
|
|
@@ -205,6 +197,12 @@ function resolveFileAction(entry, existingContent) {
|
|
|
205
197
|
const merged = mergeSettingsJson(existingContent, entry.renderedContent);
|
|
206
198
|
action = settingsJsonIgnoresGatewayEnvDiff(existingContent, entry.renderedContent) ? 'unchanged' : 'update';
|
|
207
199
|
return { ...entry, renderedContent: merged, exists, action, existingContent };
|
|
200
|
+
} else if (entry.id === 'runtime-config' && typeof existingContent === 'string' && typeof entry.renderedContent === 'string') {
|
|
201
|
+
// U2: .ukit/storage/config.json is user-owned state seeded from the template — merge
|
|
202
|
+
// by ownership instead of overwriting (see runtimeConfigMerge.js).
|
|
203
|
+
const merged = mergeRuntimeConfigText(existingContent, entry.renderedContent);
|
|
204
|
+
action = merged.changed ? 'update' : 'unchanged';
|
|
205
|
+
return { ...entry, renderedContent: merged.text, migrationReport: merged.report, exists, action, existingContent };
|
|
208
206
|
} else if (existingContent === entry.renderedContent) {
|
|
209
207
|
action = 'unchanged';
|
|
210
208
|
} else if (entry.mergeStrategy === 'skip') {
|
|
@@ -334,6 +334,15 @@ function outcomeCost(value) {
|
|
|
334
334
|
};
|
|
335
335
|
}
|
|
336
336
|
|
|
337
|
+
// A3 (C100 O8): agent/model/provider linkage. Nullable and additive: a value the hook payload
|
|
338
|
+
// or emitter did not supply stays null (UNKNOWN) — never a guess, never 0, never inferred from a model name.
|
|
339
|
+
function linkageField(...candidates) {
|
|
340
|
+
for (const value of candidates) {
|
|
341
|
+
if (typeof value === 'string' && value.trim().length > 0) return value.trim().slice(0, 96);
|
|
342
|
+
}
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
|
|
337
346
|
/**
|
|
338
347
|
* buildOutcomeObservedRecord({ outcome, projectRoot, payload, sessionId,
|
|
339
348
|
* engine, env }) → semantic-record candidate for `outcome.observed`.
|
|
@@ -375,6 +384,11 @@ export function buildOutcomeObservedRecord({
|
|
|
375
384
|
evidenceIds: outcomeStringList(safe.evidenceIds, 32),
|
|
376
385
|
cost: outcomeCost(safe.cost),
|
|
377
386
|
reviewActions: outcomeStringList(safe.reviewActions, 32),
|
|
387
|
+
agentId: linkageField(safe.agentId, payload?.agent_id),
|
|
388
|
+
parentAgentId: linkageField(safe.parentAgentId, payload?.parent_agent_id),
|
|
389
|
+
agentType: linkageField(safe.agentType, payload?.agent_type),
|
|
390
|
+
model: linkageField(safe.model, payload?.model),
|
|
391
|
+
provider: linkageField(safe.provider),
|
|
378
392
|
enforcement: OUTCOME_ENFORCEMENTS.has(safe.enforcement)
|
|
379
393
|
? safe.enforcement
|
|
380
394
|
: deriveEnforcement({ engine: engineName, env }),
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { buildDefaultRuntimeConfig, validateRuntimeConfig } from './runtimeConfig.js';
|
|
2
|
+
|
|
3
|
+
// U2: ownership-based migration for `.ukit/storage/config.json`.
|
|
4
|
+
//
|
|
5
|
+
// Template-owned (always the new release's value): `version`, `agent`, `_help`
|
|
6
|
+
// (documentation block) and every key listed in REMOVED_KEYS (dropped on migration).
|
|
7
|
+
// Security-reset (never carried over silently, reported instead): SECURITY_RESET_KEYS —
|
|
8
|
+
// an unsafe opt-in must be re-confirmed after an upgrade; the new default applies.
|
|
9
|
+
// User-owned (existing value wins): every other key the user already has — model routing
|
|
10
|
+
// (`modelRoles`, `router`, `subagents.*`), `autonomy`, `orchestration.permissionMode`,
|
|
11
|
+
// thresholds, arrays, and unknown keys (kept verbatim).
|
|
12
|
+
// Template-filled: keys the user does not have take the new default (so new features get
|
|
13
|
+
// their defaults, and a partial/old config is upgraded without losing user values).
|
|
14
|
+
//
|
|
15
|
+
// If the merged result fails validateRuntimeConfig, the merge refuses to guess: the existing
|
|
16
|
+
// file is left untouched and the report says why. An unparseable (corrupt) existing file is
|
|
17
|
+
// replaced by the rendered template (applyPlan takes a timestamped backup first).
|
|
18
|
+
|
|
19
|
+
export const TEMPLATE_OWNED_KEYS = Object.freeze(['version', 'agent', '_help']);
|
|
20
|
+
export const SECURITY_RESET_KEYS = Object.freeze(['orchestration.allowUnsafeEval']);
|
|
21
|
+
export const REMOVED_KEYS = Object.freeze([]);
|
|
22
|
+
|
|
23
|
+
const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
24
|
+
|
|
25
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
26
|
+
|
|
27
|
+
function getPath(obj, dotted) {
|
|
28
|
+
return dotted.split('.').reduce((acc, k) => (isPlainObject(acc) ? acc[k] : undefined), obj);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function deletePath(obj, dotted) {
|
|
32
|
+
const parts = dotted.split('.');
|
|
33
|
+
let cur = obj;
|
|
34
|
+
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
35
|
+
if (!isPlainObject(cur[parts[i]])) return;
|
|
36
|
+
cur = cur[parts[i]];
|
|
37
|
+
}
|
|
38
|
+
delete cur[parts[parts.length - 1]];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function canonicalize(value) {
|
|
42
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
43
|
+
if (isPlainObject(value)) {
|
|
44
|
+
return Object.fromEntries(Object.keys(value).sort().map((k) => [k, canonicalize(value[k])]));
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function mergeOwned(template, existing) {
|
|
50
|
+
const merged = {};
|
|
51
|
+
for (const key of Object.keys(template)) {
|
|
52
|
+
if (!BLOCKED_KEYS.has(key)) merged[key] = template[key];
|
|
53
|
+
}
|
|
54
|
+
for (const [key, userValue] of Object.entries(existing)) {
|
|
55
|
+
if (BLOCKED_KEYS.has(key)) continue;
|
|
56
|
+
if (!(key in template)) {
|
|
57
|
+
merged[key] = userValue;
|
|
58
|
+
} else if (isPlainObject(template[key]) && isPlainObject(userValue)) {
|
|
59
|
+
merged[key] = mergeOwned(template[key], userValue);
|
|
60
|
+
} else {
|
|
61
|
+
merged[key] = userValue;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return merged;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function mergeRuntimeConfigText(existingText, renderedText) {
|
|
68
|
+
const report = { merged: false, fallback: null, resetSecurityKeys: [], removedKeys: [] };
|
|
69
|
+
let existing;
|
|
70
|
+
let rendered;
|
|
71
|
+
try {
|
|
72
|
+
rendered = JSON.parse(renderedText);
|
|
73
|
+
} catch {
|
|
74
|
+
report.fallback = 'rendered-template-unparseable';
|
|
75
|
+
return { text: renderedText, changed: renderedText !== existingText, report };
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
existing = JSON.parse(existingText);
|
|
79
|
+
} catch {
|
|
80
|
+
report.fallback = 'existing-unparseable';
|
|
81
|
+
return { text: renderedText, changed: true, report };
|
|
82
|
+
}
|
|
83
|
+
if (!isPlainObject(existing) || !isPlainObject(rendered)) {
|
|
84
|
+
report.fallback = 'not-an-object';
|
|
85
|
+
return { text: renderedText, changed: true, report };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const merged = mergeOwned(rendered, existing);
|
|
89
|
+
for (const key of TEMPLATE_OWNED_KEYS) {
|
|
90
|
+
if (key in rendered) merged[key] = rendered[key];
|
|
91
|
+
else delete merged[key];
|
|
92
|
+
}
|
|
93
|
+
for (const dotted of REMOVED_KEYS) {
|
|
94
|
+
if (getPath(existing, dotted) !== undefined) report.removedKeys.push(dotted);
|
|
95
|
+
deletePath(merged, dotted);
|
|
96
|
+
}
|
|
97
|
+
for (const dotted of SECURITY_RESET_KEYS) {
|
|
98
|
+
const before = getPath(merged, dotted);
|
|
99
|
+
const fallback = getPath(rendered, dotted);
|
|
100
|
+
if (before !== fallback) {
|
|
101
|
+
report.resetSecurityKeys.push(dotted);
|
|
102
|
+
const parts = dotted.split('.');
|
|
103
|
+
let cur = merged;
|
|
104
|
+
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
105
|
+
if (!isPlainObject(cur[parts[i]])) cur[parts[i]] = {};
|
|
106
|
+
cur = cur[parts[i]];
|
|
107
|
+
}
|
|
108
|
+
if (fallback === undefined) delete cur[parts[parts.length - 1]];
|
|
109
|
+
else cur[parts[parts.length - 1]] = fallback;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The runtime loader validates the file layered over built-in defaults, so do the same.
|
|
114
|
+
const validation = validateRuntimeConfig(mergeOwned(buildDefaultRuntimeConfig(), merged));
|
|
115
|
+
if (!validation.valid) {
|
|
116
|
+
// Never discard the user's customisations on a guess: leave the file untouched and
|
|
117
|
+
// report why. `ukit doctor` surfaces the invalid value; the user's file stays intact.
|
|
118
|
+
report.fallback = `merged-config-invalid (file left untouched): ${validation.errors.slice(0, 3).join('; ')}`;
|
|
119
|
+
return { text: existingText, changed: false, report };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
report.merged = true;
|
|
123
|
+
const changed = JSON.stringify(canonicalize(merged)) !== JSON.stringify(canonicalize(existing));
|
|
124
|
+
return { text: `${JSON.stringify(merged, null, 2)}\n`, changed, report };
|
|
125
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawnSync as defaultSpawnSync } from 'node:child_process';
|
|
5
|
+
import { readInstallMetadata } from './metadata.js';
|
|
6
|
+
import { writeFileAtomic } from './fileOps.js';
|
|
7
|
+
|
|
8
|
+
// F5: safety net around `ukit update`.
|
|
9
|
+
//
|
|
10
|
+
// preflight -> before ANY change: npm answers, project metadata readable and project
|
|
11
|
+
// writable. A failed preflight aborts with nothing modified.
|
|
12
|
+
// snapshot -> copies every UKit-managed regular file (install.json ledger + the known
|
|
13
|
+
// user-edited configs) plus a sha256 manifest, BEFORE `npm install -g`.
|
|
14
|
+
// restore -> if the project refresh fails, puts the project back and VERIFIES hashes.
|
|
15
|
+
//
|
|
16
|
+
// Honest scope: the snapshot covers PROJECT files only. The global npm package is never
|
|
17
|
+
// rolled back automatically — after a failed refresh the global CLI stays on the new
|
|
18
|
+
// version while the project is back on the old files. The report says so and prints the
|
|
19
|
+
// exact npm command to pin the previous version; it never claims a full rollback.
|
|
20
|
+
|
|
21
|
+
const SNAPSHOT_REL_DIR = path.join('.claude', 'ukit', '.ukit', 'update-snapshots');
|
|
22
|
+
const INSTALL_META_REL = path.join('.claude', 'ukit', '.ukit', 'install.json');
|
|
23
|
+
const EXTRA_SNAPSHOT_PATHS = [
|
|
24
|
+
'.claude/settings.json',
|
|
25
|
+
'.ukit/storage/config.json',
|
|
26
|
+
'.omp/config.yml',
|
|
27
|
+
'.gitignore',
|
|
28
|
+
'CLAUDE.md',
|
|
29
|
+
'AGENTS.md',
|
|
30
|
+
];
|
|
31
|
+
export const SNAPSHOT_KEEP = 3;
|
|
32
|
+
|
|
33
|
+
const toPosix = (p) => p.replace(/\\/g, '/');
|
|
34
|
+
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
|
|
35
|
+
|
|
36
|
+
function insideProject(projectRoot, rel) {
|
|
37
|
+
const abs = path.resolve(projectRoot, rel);
|
|
38
|
+
const relBack = path.relative(path.resolve(projectRoot), abs);
|
|
39
|
+
return relBack !== '' && !relBack.startsWith('..') && !path.isAbsolute(relBack) ? abs : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function preflightUpdate({ projectRoot, spawnSync = defaultSpawnSync, checkNpm = true } = {}) {
|
|
43
|
+
const problems = [];
|
|
44
|
+
if (checkNpm) {
|
|
45
|
+
const r = spawnSync('npm', ['--version'], { encoding: 'utf8', timeout: 15_000, killSignal: 'SIGKILL' });
|
|
46
|
+
if (r.error || r.status !== 0) problems.push(`npm is not runnable (${r.error?.message ?? `exit ${r.status}`})`);
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
await readInstallMetadata(path.join(projectRoot, INSTALL_META_REL));
|
|
50
|
+
} catch (error) {
|
|
51
|
+
problems.push(`install metadata unreadable: ${error.message}`);
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
await fs.access(projectRoot, fs.constants.W_OK);
|
|
55
|
+
} catch {
|
|
56
|
+
problems.push(`project directory is not writable: ${projectRoot}`);
|
|
57
|
+
}
|
|
58
|
+
return { ok: problems.length === 0, problems };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function listManagedFiles(projectRoot) {
|
|
62
|
+
const rels = new Set(EXTRA_SNAPSHOT_PATHS.map(toPosix));
|
|
63
|
+
rels.add(toPosix(INSTALL_META_REL));
|
|
64
|
+
try {
|
|
65
|
+
const meta = await readInstallMetadata(path.join(projectRoot, INSTALL_META_REL));
|
|
66
|
+
for (const entry of Array.isArray(meta?.files) ? meta.files : []) {
|
|
67
|
+
const p = typeof entry === 'string' ? entry : entry?.p;
|
|
68
|
+
if (typeof p === 'string' && p.trim()) rels.add(toPosix(p.trim()));
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// preflight already reported unreadable metadata
|
|
72
|
+
}
|
|
73
|
+
return [...rels].filter((rel) => !rel.startsWith(`${toPosix(SNAPSHOT_REL_DIR)}/`));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function createUpdateSnapshot({ projectRoot, previousGlobalVersion = null, now = Date.now() }) {
|
|
77
|
+
const dir = path.join(projectRoot, SNAPSHOT_REL_DIR, String(now));
|
|
78
|
+
const filesDir = path.join(dir, 'files');
|
|
79
|
+
await fs.mkdir(filesDir, { recursive: true });
|
|
80
|
+
const files = [];
|
|
81
|
+
for (const rel of await listManagedFiles(projectRoot)) {
|
|
82
|
+
const abs = insideProject(projectRoot, rel);
|
|
83
|
+
if (!abs) continue;
|
|
84
|
+
let stat;
|
|
85
|
+
try {
|
|
86
|
+
stat = await fs.lstat(abs);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error?.code === 'ENOENT') {
|
|
89
|
+
files.push({ rel, existed: false });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
throw new Error(`snapshot failed: cannot stat ${rel}: ${error.message}`);
|
|
93
|
+
}
|
|
94
|
+
if (!stat.isFile()) continue; // links and directories are re-created by install
|
|
95
|
+
const content = await fs.readFile(abs);
|
|
96
|
+
const dest = path.join(filesDir, rel);
|
|
97
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
98
|
+
await fs.writeFile(dest, content);
|
|
99
|
+
files.push({ rel, existed: true, sha256: sha256(content), mode: stat.mode & 0o777 });
|
|
100
|
+
}
|
|
101
|
+
const manifest = { version: 1, createdAt: new Date(now).toISOString(), previousGlobalVersion, files };
|
|
102
|
+
await fs.writeFile(path.join(dir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
103
|
+
await pruneSnapshots(path.join(projectRoot, SNAPSHOT_REL_DIR));
|
|
104
|
+
return { dir, manifest };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function pruneSnapshots(root) {
|
|
108
|
+
try {
|
|
109
|
+
const names = (await fs.readdir(root)).filter((n) => /^\d+$/.test(n)).sort((a, b) => Number(b) - Number(a));
|
|
110
|
+
for (const name of names.slice(SNAPSHOT_KEEP)) await fs.rm(path.join(root, name), { recursive: true, force: true });
|
|
111
|
+
} catch {
|
|
112
|
+
// advisory
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Restore the project to the snapshot and verify every file by hash. Files the failed
|
|
117
|
+
// install created (tracked in the NEW ledger but absent from the snapshot ledger) are removed.
|
|
118
|
+
export async function restoreUpdateSnapshot({ projectRoot, snapshotDir }) {
|
|
119
|
+
const manifest = JSON.parse(await fs.readFile(path.join(snapshotDir, 'manifest.json'), 'utf8'));
|
|
120
|
+
const failures = [];
|
|
121
|
+
let restored = 0;
|
|
122
|
+
|
|
123
|
+
for (const file of manifest.files) {
|
|
124
|
+
const abs = insideProject(projectRoot, file.rel);
|
|
125
|
+
if (!abs) continue;
|
|
126
|
+
try {
|
|
127
|
+
if (file.existed) {
|
|
128
|
+
const content = await fs.readFile(path.join(snapshotDir, 'files', file.rel));
|
|
129
|
+
await writeFileAtomic(abs, content);
|
|
130
|
+
if (typeof file.mode === 'number') await fs.chmod(abs, file.mode).catch(() => {});
|
|
131
|
+
restored += 1;
|
|
132
|
+
} else {
|
|
133
|
+
await fs.rm(abs, { force: true }); // created by the failed refresh
|
|
134
|
+
}
|
|
135
|
+
} catch (error) {
|
|
136
|
+
failures.push(`${file.rel}: ${error.message}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Files tracked by the failed install but unknown to the snapshot: created by it.
|
|
141
|
+
const known = new Set(manifest.files.map((f) => f.rel));
|
|
142
|
+
try {
|
|
143
|
+
const meta = await readInstallMetadata(path.join(projectRoot, INSTALL_META_REL));
|
|
144
|
+
for (const entry of Array.isArray(meta?.files) ? meta.files : []) {
|
|
145
|
+
const rel = toPosix(typeof entry === 'string' ? entry : entry?.p ?? '');
|
|
146
|
+
if (!rel || known.has(rel)) continue;
|
|
147
|
+
const abs = insideProject(projectRoot, rel);
|
|
148
|
+
if (!abs) continue;
|
|
149
|
+
const stat = await fs.lstat(abs).catch(() => null);
|
|
150
|
+
if (stat?.isFile()) await fs.rm(abs, { force: true });
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
// ledger itself was restored above; nothing more to reconcile
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const mismatches = [];
|
|
157
|
+
for (const file of manifest.files) {
|
|
158
|
+
const abs = insideProject(projectRoot, file.rel);
|
|
159
|
+
if (!abs) continue;
|
|
160
|
+
if (file.existed) {
|
|
161
|
+
const now = await fs.readFile(abs).catch(() => null);
|
|
162
|
+
if (!now || sha256(now) !== file.sha256) mismatches.push(file.rel);
|
|
163
|
+
} else if (await fs.lstat(abs).then(() => true, () => false)) {
|
|
164
|
+
mismatches.push(file.rel);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
ok: failures.length === 0 && mismatches.length === 0,
|
|
169
|
+
restored,
|
|
170
|
+
failures,
|
|
171
|
+
mismatches,
|
|
172
|
+
previousGlobalVersion: manifest.previousGlobalVersion ?? null,
|
|
173
|
+
};
|
|
174
|
+
}
|