@ngockhoale/ukit 3.4.26 → 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 (38) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/manifests/documentation.yaml +61 -0
  3. package/manifests/platform.full.yaml +24 -0
  4. package/package.json +7 -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/scripts/dev/worktree-preflight.mjs +147 -0
  9. package/src/cli/commands/update.js +59 -2
  10. package/src/core/agentRuntime/context.js +78 -0
  11. package/src/core/diffPlan.js +100 -102
  12. package/src/core/docContracts.js +3 -0
  13. package/src/core/observability/emit/sessionBoot.js +14 -0
  14. package/src/core/runtimeConfigMerge.js +125 -0
  15. package/src/core/updateSafety.js +174 -0
  16. package/src/index/contextPack.js +285 -0
  17. package/src/index/sensitivePath.js +21 -0
  18. package/src/index/taskRouting.js +3 -0
  19. package/template_project/.claude/commands/ukit/ci-check.md +53 -0
  20. package/template_project/.claude/hooks/auto-allow-bash.sh +20 -2
  21. package/template_project/.claude/hooks/handoff-model-guard.sh +9 -2
  22. package/template_project/.claude/hooks/handoff-resume.sh +25 -1
  23. package/template_project/.claude/hooks/record-execution.mjs +9 -0
  24. package/template_project/.claude/hooks/skill-router.sh +3 -0
  25. package/template_project/.claude/ukit/index/ci-check.mjs +665 -0
  26. package/template_project/.claude/ukit/index/post-edit-verify.mjs +4 -4
  27. package/template_project/.claude/ukit/index/pre-edit-backup.mjs +3 -3
  28. package/template_project/.claude/ukit/index/route-task.mjs +3 -0
  29. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +154 -12
  30. package/template_project/.claude/ukit/runtime/handoff-intent.mjs +61 -0
  31. package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +70 -4
  32. package/template_project/.claude/ukit/runtime/loop-state.mjs +131 -0
  33. package/template_project/.claude/ukit/runtime/observability-emit.mjs +14 -0
  34. package/template_project/.claude/ukit/runtime/read-advisory.mjs +92 -0
  35. package/template_project/.claude/ukit/runtime/safe-patch-core.mjs +71 -0
  36. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +53 -6
  37. package/template_project/.claude/ukit/runtime/usage-capture.mjs +338 -0
  38. package/template_project/.omp/hooks/pre/ukit-bridge.js +48 -4
@@ -15,6 +15,7 @@ import crypto from 'node:crypto';
15
15
 
16
16
  import { validateTaskContract } from './contract.js';
17
17
  import { eligible } from '../memory/policy.js';
18
+ import { isSensitivePath } from '../../index/sensitivePath.js';
18
19
 
19
20
  const STALE_MS = 15 * 60 * 1000; // default freshness window for evidence
20
21
 
@@ -491,3 +492,80 @@ export async function buildSubagentContext({
491
492
  contextRefs,
492
493
  };
493
494
  }
495
+
496
+ // --- TASK-B3 (PLAN-ENDUR-B §B3): context pack → delegation candidates -------
497
+ // Pure mapping from a buildContextPack result (TASK-B2, plain data) into
498
+ // candidates for buildSubagentContext. No fs/config/network/LLM access here —
499
+ // the pack is supplied by the caller; this module stays I/O-free.
500
+ //
501
+ // Freshness honesty (documented choice, TASK-B3 Discussion): the pack carries
502
+ // no generation timestamp (B2 is clock-free), so "not fresh" is expressed via
503
+ // the EXISTING observedAt/ttlMs staleness path of buildSubagentContext —
504
+ // no new exclusion machinery, no change to its output shape:
505
+ // - fresh pack (stale:false, fallback:null): observedAt = mapping `now`,
506
+ // ttlMs = STALE_MS → included while within the default evidence window.
507
+ // - degraded pack (stale:true OR fallback set): observedAt = 0, ttlMs = 0 →
508
+ // excluded 'stale' by any real downstream clock (nowMs > 0). Candidates are
509
+ // still mapped (visible as excluded manifest items), never fresh-labeled.
510
+ // Sensitive paths use the shared pure predicate src/index/sensitivePath.js
511
+ // (same one contextPack.js uses — no mirrored copy) and map with
512
+ // sensitivity:'sensitive' so the existing redaction path excludes them.
513
+
514
+ // Deterministic content text over path/reason/outline only. Outline order is
515
+ // authoritative from the pack (B2 emits line-sorted outlines).
516
+ function packEntryText(entry) {
517
+ const lines = [
518
+ `path: ${entry.path}`,
519
+ `reason: ${isNonEmptyStr(entry.reason) ? entry.reason : 'unknown'}`,
520
+ ];
521
+ for (const symbol of Array.isArray(entry.outline) ? entry.outline : []) {
522
+ if (symbol == null || typeof symbol !== 'object' || !isNonEmptyStr(symbol.name)) {
523
+ continue;
524
+ }
525
+ const type = isNonEmptyStr(symbol.type) ? symbol.type : 'symbol';
526
+ const signature = isNonEmptyStr(symbol.signature) ? ` ${symbol.signature}` : '';
527
+ lines.push(`symbol: ${symbol.name} (${type})${signature}`);
528
+ }
529
+ return lines.join('\n');
530
+ }
531
+
532
+ /**
533
+ * Map a buildContextPack result to buildSubagentContext candidates (pure).
534
+ *
535
+ * @param {object|null} pack buildContextPack result; malformed input maps to [].
536
+ * @param {object} [options]
537
+ * @param {number} [options.now] Mapping clock; anchors fresh candidates'
538
+ * observedAt (default Date.now(), matching module convention).
539
+ * @returns {Array<object>} One candidate per pack file: ref `pack:<path>`,
540
+ * kind 'evidence', provenance 'context-pack', required:false, deterministic
541
+ * content; degraded packs (stale/fallback) carry observedAt:0/ttlMs:0 so the
542
+ * existing staleness path excludes them; sensitive paths carry
543
+ * sensitivity:'sensitive' for the existing redaction path.
544
+ */
545
+ export function contextPackToCandidates(pack, { now } = {}) {
546
+ const nowMs = typeof now === 'number' && Number.isFinite(now) ? now : Date.now();
547
+ const files = pack != null && typeof pack === 'object' && Array.isArray(pack.files)
548
+ ? pack.files
549
+ : [];
550
+ const degraded = pack?.stale === true || pack?.fallback != null;
551
+ const observedAt = degraded ? 0 : nowMs;
552
+ const ttlMs = degraded ? 0 : STALE_MS;
553
+
554
+ const candidates = [];
555
+ for (const entry of files) {
556
+ if (entry == null || typeof entry !== 'object' || !isNonEmptyStr(entry.path)) {
557
+ continue;
558
+ }
559
+ candidates.push({
560
+ ref: `pack:${entry.path}`,
561
+ content: packEntryText(entry),
562
+ required: false,
563
+ kind: 'evidence',
564
+ provenance: 'context-pack',
565
+ observedAt,
566
+ ttlMs,
567
+ ...(isSensitivePath(entry.path) ? { sensitivity: 'sensitive' } : {}),
568
+ });
569
+ }
570
+ return candidates;
571
+ }
@@ -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') {
@@ -232,6 +232,7 @@ function validateRegistryDoc(doc, fileExists) {
232
232
  }
233
233
  }
234
234
  if (e.notes !== undefined && typeof e.notes !== 'string') problems.push(`${tag}: notes must be a string`);
235
+ if (e.local_only !== undefined && typeof e.local_only !== 'boolean') problems.push(`${tag}: local_only must be a boolean`);
235
236
  });
236
237
  return problems;
237
238
  }
@@ -626,6 +627,8 @@ async function checkRegistry(rootDir, registry, fileExists) {
626
627
  const problems = validateRegistryDoc(registry, fileExists);
627
628
  for (const e of registry.entries || []) {
628
629
  if (typeof e.path !== 'string' || e.path.length === 0) continue;
630
+ // local_only = optional gitignored machine-local doc: absent on a clean checkout is valid
631
+ if (e.local_only === true && !fileExists(e.path)) continue;
629
632
  if (!fileExists(e.path)) problems.push(`entry path '${e.path}' does not exist on disk`);
630
633
  if (typeof e.source_of_truth === 'string' && e.source_of_truth.length > 0 && !fileExists(e.source_of_truth)) {
631
634
  problems.push(`entry '${e.path}': source_of_truth '${e.source_of_truth}' does not exist on disk`);
@@ -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
+ }