@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.
- package/CHANGELOG.md +30 -0
- package/manifests/documentation.yaml +61 -0
- package/manifests/platform.full.yaml +24 -0
- package/package.json +7 -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/scripts/dev/worktree-preflight.mjs +147 -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/docContracts.js +3 -0
- 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/auto-allow-bash.sh +20 -2
- package/template_project/.claude/hooks/handoff-model-guard.sh +9 -2
- package/template_project/.claude/hooks/handoff-resume.sh +25 -1
- 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/post-edit-verify.mjs +4 -4
- package/template_project/.claude/ukit/index/pre-edit-backup.mjs +3 -3
- package/template_project/.claude/ukit/index/route-task.mjs +3 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +154 -12
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +61 -0
- package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +70 -4
- 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/safe-patch-core.mjs +71 -0
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +53 -6
- package/template_project/.claude/ukit/runtime/usage-capture.mjs +338 -0
- 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
|
+
}
|
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') {
|
package/src/core/docContracts.js
CHANGED
|
@@ -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
|
+
}
|