@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.
Files changed (31) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/manifests/documentation.yaml +59 -0
  3. package/manifests/platform.full.yaml +24 -0
  4. package/package.json +6 -1
  5. package/scripts/audit/subagent-cost/metrics.mjs +14 -2
  6. package/scripts/audit/subagent-cost/reader.mjs +15 -3
  7. package/scripts/audit/subagent-cost/usage-fields.mjs +82 -0
  8. package/src/cli/commands/update.js +59 -2
  9. package/src/core/agentRuntime/context.js +78 -0
  10. package/src/core/diffPlan.js +100 -102
  11. package/src/core/observability/emit/sessionBoot.js +14 -0
  12. package/src/core/runtimeConfigMerge.js +125 -0
  13. package/src/core/updateSafety.js +174 -0
  14. package/src/index/contextPack.js +285 -0
  15. package/src/index/sensitivePath.js +21 -0
  16. package/src/index/taskRouting.js +3 -0
  17. package/template_project/.claude/commands/ukit/ci-check.md +53 -0
  18. package/template_project/.claude/hooks/handoff-model-guard.sh +9 -2
  19. package/template_project/.claude/hooks/handoff-resume.sh +13 -0
  20. package/template_project/.claude/hooks/record-execution.mjs +9 -0
  21. package/template_project/.claude/hooks/skill-router.sh +3 -0
  22. package/template_project/.claude/ukit/index/ci-check.mjs +665 -0
  23. package/template_project/.claude/ukit/index/route-task.mjs +3 -0
  24. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +19 -7
  25. package/template_project/.claude/ukit/runtime/handoff-intent.mjs +8 -0
  26. package/template_project/.claude/ukit/runtime/loop-state.mjs +131 -0
  27. package/template_project/.claude/ukit/runtime/observability-emit.mjs +14 -0
  28. package/template_project/.claude/ukit/runtime/read-advisory.mjs +92 -0
  29. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +33 -1
  30. package/template_project/.claude/ukit/runtime/usage-capture.mjs +338 -0
  31. package/template_project/.omp/hooks/pre/ukit-bridge.js +48 -4
@@ -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 uses a strategy where only the gateway-resilience env keys (managed by
49
- // applyGatewayResilienceEnv post-apply) are ignored during the diff. Every other top-level
50
- // key — including the rest of the env block — is still compared byte-for-byte against the
51
- // template, so a template-side change to e.g. CLAUDE_CODE_AUTO_COMPACT_WINDOW still triggers
52
- // a real reinstall. Without this carve-out, the managed gateway keys written by the post-apply
53
- // step would cause a spurious "update" on every subsequent install.
54
- //
55
- // `permissions.defaultMode` is user-owned: the user picks their permission mode
56
- // (bypassPermissions / plan / acceptEdits / default) and UKit must never change it.
57
- // It is excluded from the drift comparison AND re-injected into the rendered content
58
- // before write (mergeSettingsJson), so an install/update can never flip the user's mode.
59
- // When the user has NOT set it, the template's shipped default applies on create/update.
60
- const USER_OWNED_PERMISSION_KEYS = new Set(['defaultMode']);
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 parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
75
+ return isPlainObject(parsed) ? parsed : null;
67
76
  } catch {
68
77
  return null;
69
78
  }
70
79
  }
71
80
 
72
- // Strip user-owned permission keys from a parsed permissions object for comparison.
73
- // Returns a new object; never mutates the input.
74
- function stripUserOwnedPermissionKeys(permissions) {
75
- if (!permissions || typeof permissions !== 'object' || Array.isArray(permissions)) {
76
- return permissions;
77
- }
78
- const stripped = { ...permissions };
79
- for (const key of USER_OWNED_PERMISSION_KEYS) delete stripped[key];
80
- return stripped;
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 `${JSON.stringify(merged, null, 2)}\n`;
92
+ return out;
100
93
  }
101
94
 
102
- function settingsJsonIgnoresGatewayEnvDiff(existingContent, renderedContent) {
103
- if (typeof existingContent !== 'string' || typeof renderedContent !== 'string') {
104
- return false;
105
- }
106
- let parsedExisting;
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
- if (!parsedExisting || typeof parsedExisting !== 'object' || Array.isArray(parsedExisting)) {
115
- return false;
116
- }
117
- if (!parsedRendered || typeof parsedRendered !== 'object' || Array.isArray(parsedRendered)) {
118
- return false;
119
- }
120
-
121
- for (const key of Object.keys(parsedRendered)) {
122
- if (key === 'env') continue;
123
- if (!(key in parsedExisting)) return false;
124
- const renderedValue = key === 'permissions'
125
- ? stripUserOwnedPermissionKeys(parsedRendered[key])
126
- : parsedRendered[key];
127
- const existingValue = key === 'permissions'
128
- ? stripUserOwnedPermissionKeys(parsedExisting[key])
129
- : parsedExisting[key];
130
- if (JSON.stringify(existingValue) !== JSON.stringify(renderedValue)) {
131
- return false;
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
- // Reverse direction: an existing top-level key that the template does NOT ship is also
136
- // drift. Catches template key REMOVAL (e.g. a previous template shipped `permissions`
137
- // and the new one dropped it) and user-side additions. The `merge_env_overwrite_with_backup`
138
- // strategy is a narrowed overwrite — the only carve-out is the UKit-managed gateway env
139
- // keys below — so every other byte mismatch must reinstall, in either direction.
140
- for (const key of Object.keys(parsedExisting)) {
141
- if (key === 'env') continue;
142
- if (!(key in parsedRendered)) return false;
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
- const templateEnv = parsedRendered.env && typeof parsedRendered.env === 'object' && !Array.isArray(parsedRendered.env)
146
- ? parsedRendered.env
147
- : {};
148
- const fileEnv = parsedExisting.env && typeof parsedExisting.env === 'object' && !Array.isArray(parsedExisting.env)
149
- ? parsedExisting.env
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
- // Only enforce template env keys that are NOT UKit-managed. UKit-managed keys
153
- // (CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK, CLAUDE_STREAM_IDLE_TIMEOUT_MS) are owned by
154
- // applyGatewayResilienceEnv — it adds them post-apply and preserves user overrides.
155
- for (const key of Object.keys(templateEnv)) {
156
- if (GATEWAY_RESILIENCE_KEYS.has(key)) continue;
157
- if (JSON.stringify(fileEnv[key]) !== JSON.stringify(templateEnv[key])) {
158
- return false;
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
- // Reverse direction for env: an env key in the file that the template does NOT ship
163
- // (and that is not UKit-managed) is also drift — a removed template env key, or a
164
- // user-added env key, must reinstall. UKit-managed gateway keys remain carved out
165
- // because applyGatewayResilienceEnv owns them post-apply.
166
- for (const key of Object.keys(fileEnv)) {
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
+ }