@ngockhoale/ukit 3.4.31 → 3.4.33
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 +17 -0
- package/manifests/documentation.yaml +11 -0
- package/package.json +1 -1
- package/src/core/diffPlan.js +87 -8
- package/src/core/executionContracts.js +8 -2
- package/src/core/metadata.js +5 -0
- package/src/core/ompConfigMerge.js +60 -0
- package/src/core/ompProjectConfig.js +462 -0
- package/src/core/runInstallPipeline.js +25 -1
- package/src/core/uninstall.js +28 -1
- package/src/decision/reviewVerdict.js +16 -2
- package/src/index/fixLoopEscalation.js +10 -4
- package/src/render/buildVariables.js +4 -0
- package/template_project/.claude/hooks/handoff-model-guard.sh +37 -2
- package/template_project/.claude/hooks/record-execution.mjs +9 -0
- package/template_project/.claude/hooks/skill-router.sh +67 -7
- package/template_project/.claude/ukit/index/review-verdict.mjs +34 -5
- package/template_project/.claude/ukit/index/route-resolver.mjs +19 -5
- package/template_project/.claude/ukit/index/route-task.mjs +150 -3
- package/template_project/.claude/ukit/index/sidecar-decision.mjs +3 -58
- package/template_project/.claude/ukit/runtime/context-budget-policy.mjs +309 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +28 -5
- package/template_project/.claude/ukit/runtime/plan-dispatch.mjs +74 -0
- package/template_project/.claude/ukit/runtime/reinject-context.mjs +7 -0
- package/template_project/.omp/config.yml +30 -14
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import YAML, { Alias, Pair, Scalar, YAMLMap, YAMLSeq, parseDocument } from 'yaml';
|
|
2
|
+
|
|
3
|
+
// OMP project config lifecycle (.omp/config.yml compaction block) — pure text
|
|
4
|
+
// module, no fs. Callers own reading/writing files and persisting the ownership
|
|
5
|
+
// record in install.json.
|
|
6
|
+
//
|
|
7
|
+
// mergeOmpProjectConfig(existingText, defaultsText, ownership):
|
|
8
|
+
// Surgically adds missing `compaction` defaults using yaml CST ranges so every
|
|
9
|
+
// other byte (comments, BOM, CRLF, unrelated keys) survives untouched. The
|
|
10
|
+
// only value ever REWRITTEN is a legacy `thresholdTokens: 350000`, and only
|
|
11
|
+
// when the ownership record proves ukit itself installed that same value
|
|
12
|
+
// (ownership.installedText parses to the same thresholdTokens). Everything
|
|
13
|
+
// else the user set stays.
|
|
14
|
+
//
|
|
15
|
+
// cleanupOmpProjectConfig(currentText, ownership):
|
|
16
|
+
// Uninstall counterpart. Unchanged file → restore originalText (delete, i.e.
|
|
17
|
+
// text null, when the file was fresh). User-edited file → keep every user
|
|
18
|
+
// modification and only remove/revert the owned scalar compaction keys that
|
|
19
|
+
// still carry the installed value AND were originally absent or different.
|
|
20
|
+
//
|
|
21
|
+
// Conservative no-write: if the compaction subtree uses flow collections,
|
|
22
|
+
// anchors, aliases or merge keys (`<<`), the functions refuse to splice and
|
|
23
|
+
// return the original text plus a warning — surgical edits are only proven
|
|
24
|
+
// safe on plain block mappings. Invalid YAML throws instead.
|
|
25
|
+
|
|
26
|
+
// The scalar compaction keys ukit owns and may remove on cleanup. Extra keys
|
|
27
|
+
// the user added under compaction are never owned, only these four.
|
|
28
|
+
export const OWNED_COMPACTION_KEYS = ['enabled', 'thresholdTokens', 'midTurnEnabled', 'keepRecentTokens'];
|
|
29
|
+
|
|
30
|
+
// Pre-90000 ukit default. Migrating it requires ownership proof; a hand-set
|
|
31
|
+
// 350000 (no ownership record, or installedText disagrees) is preserved.
|
|
32
|
+
export const LEGACY_THRESHOLD_TOKENS = 350000;
|
|
33
|
+
|
|
34
|
+
const COMPACTION_KEY = 'compaction';
|
|
35
|
+
|
|
36
|
+
function isPlainObject(value) {
|
|
37
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function firstError(doc) {
|
|
41
|
+
return doc.errors[0]?.message ?? 'unknown parse error';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// parseDocument keeps CST ranges pointing into the ORIGINAL string (BOM
|
|
45
|
+
// inclusive), which is what makes byte-exact splicing possible. Parse errors
|
|
46
|
+
// are collected on the document instead of thrown, so we reject them here.
|
|
47
|
+
function parseDocOrThrow(text, label) {
|
|
48
|
+
const doc = parseDocument(text);
|
|
49
|
+
if (doc.errors.length > 0) {
|
|
50
|
+
throw new Error(`${label}: invalid YAML — ${firstError(doc)}`);
|
|
51
|
+
}
|
|
52
|
+
return doc;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseDataOrThrow(text, label) {
|
|
56
|
+
try {
|
|
57
|
+
return YAML.parse(text);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
throw new Error(`${label}: invalid YAML — ${error?.message ?? error}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function detectEol(text) {
|
|
64
|
+
return text.includes('\r\n') ? '\r\n' : '\n';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Render a value the way it is spliced into the document: block YAML, trailing
|
|
68
|
+
// newline trimmed. Scalars render single-line; collections render nested lines.
|
|
69
|
+
function formatYamlValue(value) {
|
|
70
|
+
return YAML.stringify(value).replace(/\n+$/, '');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function lineStartBefore(text, offset) {
|
|
74
|
+
const i = text.lastIndexOf('\n', Math.max(offset - 1, 0));
|
|
75
|
+
return i + 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Index just past the newline that terminates the line containing `offset`
|
|
79
|
+
// (or text length at EOF without a trailing newline).
|
|
80
|
+
function lineEndAfter(text, offset) {
|
|
81
|
+
const i = text.indexOf('\n', offset);
|
|
82
|
+
return i === -1 ? text.length : i + 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function indentOfLine(text, offset) {
|
|
86
|
+
const start = lineStartBefore(text, offset);
|
|
87
|
+
const match = /^[ ]*/.exec(text.slice(start));
|
|
88
|
+
return match ? match[0] : '';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function findCompactionPair(mapNode) {
|
|
92
|
+
return mapNode.items.find(
|
|
93
|
+
(pair) => pair.key instanceof Scalar && pair.key.value === COMPACTION_KEY,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Reason string when the subtree carries a construct we refuse to edit
|
|
98
|
+
// surgically: flow collections, anchors, aliases, merge keys.
|
|
99
|
+
function unsafeReason(node, depth = 0) {
|
|
100
|
+
if (!node || depth > 64) return null;
|
|
101
|
+
if (node instanceof Alias) return 'an alias';
|
|
102
|
+
if (node.anchor) return 'an anchor';
|
|
103
|
+
if (node instanceof YAMLMap || node instanceof YAMLSeq) {
|
|
104
|
+
if (node.flow) return 'flow style';
|
|
105
|
+
for (const item of node.items) {
|
|
106
|
+
if (item instanceof Pair) {
|
|
107
|
+
if (item.key instanceof Scalar && item.key.value === '<<') return 'a merge key';
|
|
108
|
+
const reason = unsafeReason(item.key, depth + 1) ?? unsafeReason(item.value, depth + 1);
|
|
109
|
+
if (reason) return reason;
|
|
110
|
+
} else {
|
|
111
|
+
const reason = unsafeReason(item, depth + 1);
|
|
112
|
+
if (reason) return reason;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function noWrite(text, ownership, warning) {
|
|
120
|
+
return { text, ownership: ownership ?? null, warning };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function applyEdits(text, edits) {
|
|
124
|
+
let out = text;
|
|
125
|
+
for (const edit of [...edits].sort((a, b) => b.start - a.start)) {
|
|
126
|
+
out = out.slice(0, edit.start) + edit.replacement + out.slice(edit.end);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// One `key: value` line (or block for collection values) at the given indent.
|
|
132
|
+
function renderEntry(key, value, indent, eol) {
|
|
133
|
+
const parts = formatYamlValue(value).split('\n');
|
|
134
|
+
const lines = [`${indent}${key}: ${parts[0]}`, ...parts.slice(1).map((line) => (line === '' ? '' : indent + line))];
|
|
135
|
+
return lines.join(eol);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Edit appending the full compaction block at end of file.
|
|
139
|
+
function appendBlockEdit(text, compaction, eol) {
|
|
140
|
+
const lines = [
|
|
141
|
+
`${COMPACTION_KEY}:`,
|
|
142
|
+
...Object.entries(compaction).map(([k, v]) => renderEntry(k, v, ' ', eol)),
|
|
143
|
+
];
|
|
144
|
+
const prefix = text.length > 0 && !text.endsWith('\n') ? eol : '';
|
|
145
|
+
return { start: text.length, end: text.length, replacement: prefix + lines.join(eol) + eol };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Edit inserting entries after the compaction map's last item (or right after
|
|
149
|
+
// the `compaction:` line when the map is empty), matching existing indent.
|
|
150
|
+
function insertEntriesEdit(text, pair, entries, eol) {
|
|
151
|
+
const map = pair.value;
|
|
152
|
+
let insertAt;
|
|
153
|
+
let indent;
|
|
154
|
+
if (map.items.length > 0) {
|
|
155
|
+
insertAt = map.range[1];
|
|
156
|
+
indent = indentOfLine(text, map.range[0]);
|
|
157
|
+
} else {
|
|
158
|
+
insertAt = lineEndAfter(text, pair.key.range[0]);
|
|
159
|
+
indent = `${indentOfLine(text, pair.key.range[0])} `;
|
|
160
|
+
}
|
|
161
|
+
const lines = entries.map(([k, v]) => renderEntry(k, v, indent, eol));
|
|
162
|
+
// When the insertion point does not start a fresh line (EOF without a
|
|
163
|
+
// trailing newline), open one first.
|
|
164
|
+
const prefix = insertAt > 0 && text[insertAt - 1] !== '\n' ? eol : '';
|
|
165
|
+
return { start: insertAt, end: insertAt, replacement: prefix + lines.join(eol) + eol };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Proof that ukit itself installed `value` as compaction.thresholdTokens: the
|
|
169
|
+
// ownership record's installedText (latest content ukit ensured) must parse to
|
|
170
|
+
// that same value.
|
|
171
|
+
function ownershipProvesThreshold(ownership, value) {
|
|
172
|
+
if (!isPlainObject(ownership) || typeof ownership.installedText !== 'string') return false;
|
|
173
|
+
try {
|
|
174
|
+
const data = YAML.parse(ownership.installedText);
|
|
175
|
+
return isPlainObject(data?.compaction) && data.compaction.thresholdTokens === value;
|
|
176
|
+
} catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function mergeOmpProjectConfig(existingText, defaultsText, ownership) {
|
|
182
|
+
if (typeof defaultsText !== 'string') {
|
|
183
|
+
throw new TypeError('mergeOmpProjectConfig: defaultsText must be a string');
|
|
184
|
+
}
|
|
185
|
+
if (existingText != null && typeof existingText !== 'string') {
|
|
186
|
+
throw new TypeError('mergeOmpProjectConfig: existingText must be a string or null');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const defaultsData = parseDataOrThrow(defaultsText, 'defaultsText');
|
|
190
|
+
const defaultsCompaction = defaultsData?.compaction;
|
|
191
|
+
if (!isPlainObject(defaultsCompaction)) {
|
|
192
|
+
throw new Error('defaultsText: must define a top-level `compaction` mapping');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Fresh install — nothing to preserve; the defaults text ships verbatim.
|
|
196
|
+
if (existingText == null) {
|
|
197
|
+
return {
|
|
198
|
+
text: defaultsText,
|
|
199
|
+
ownership: { originalText: null, installedText: defaultsText },
|
|
200
|
+
warning: null,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const doc = parseDocOrThrow(existingText, 'existing omp project config');
|
|
205
|
+
|
|
206
|
+
// Empty file: appending the block keeps it valid YAML, so treat it as
|
|
207
|
+
// "no compaction" rather than refusing.
|
|
208
|
+
if (doc.contents != null && !(doc.contents instanceof YAMLMap)) {
|
|
209
|
+
return noWrite(existingText, ownership, 'merge skipped: top-level YAML document is not a mapping');
|
|
210
|
+
}
|
|
211
|
+
const pair = doc.contents ? findCompactionPair(doc.contents) : undefined;
|
|
212
|
+
const eol = detectEol(existingText);
|
|
213
|
+
const edits = [];
|
|
214
|
+
let warning = null;
|
|
215
|
+
|
|
216
|
+
if (!pair) {
|
|
217
|
+
edits.push(appendBlockEdit(existingText, defaultsCompaction, eol));
|
|
218
|
+
} else {
|
|
219
|
+
const unsafe = unsafeReason(pair.key) ?? unsafeReason(pair.value);
|
|
220
|
+
if (unsafe) {
|
|
221
|
+
return noWrite(existingText, ownership, `merge skipped: compaction uses ${unsafe} — surgical edit refused`);
|
|
222
|
+
}
|
|
223
|
+
if (!(pair.value instanceof YAMLMap)) {
|
|
224
|
+
return noWrite(existingText, ownership, 'merge skipped: compaction is not a block mapping');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const missing = [];
|
|
228
|
+
for (const [defaultKey, defaultValue] of Object.entries(defaultsCompaction)) {
|
|
229
|
+
const existingPair = pair.value.items.find(
|
|
230
|
+
(item) => item.key instanceof Scalar && item.key.value === defaultKey,
|
|
231
|
+
);
|
|
232
|
+
if (!existingPair) {
|
|
233
|
+
// Missing key: added. Present keys are user state and never touched
|
|
234
|
+
// (with the single legacy exception below).
|
|
235
|
+
missing.push([defaultKey, defaultValue]);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (
|
|
239
|
+
defaultKey === 'thresholdTokens' &&
|
|
240
|
+
existingPair.value instanceof Scalar &&
|
|
241
|
+
existingPair.value.value === LEGACY_THRESHOLD_TOKENS &&
|
|
242
|
+
defaultValue !== LEGACY_THRESHOLD_TOKENS
|
|
243
|
+
) {
|
|
244
|
+
if (ownershipProvesThreshold(ownership, LEGACY_THRESHOLD_TOKENS)) {
|
|
245
|
+
edits.push({
|
|
246
|
+
start: existingPair.value.range[0],
|
|
247
|
+
end: existingPair.value.range[1],
|
|
248
|
+
replacement: formatYamlValue(defaultValue),
|
|
249
|
+
});
|
|
250
|
+
} else {
|
|
251
|
+
// Unknown old config: ukit cannot prove 350000 was installed, so the
|
|
252
|
+
// value may be deliberate — preserved with a warning.
|
|
253
|
+
warning = `compaction.thresholdTokens=${LEGACY_THRESHOLD_TOKENS} kept — ownership does not prove ukit installed this value (legacy migration skipped)`;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (missing.length > 0) {
|
|
258
|
+
edits.push(insertEntriesEdit(existingText, pair, missing, eol));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const text = applyEdits(existingText, edits);
|
|
263
|
+
const changed = text !== existingText;
|
|
264
|
+
return {
|
|
265
|
+
text,
|
|
266
|
+
ownership: {
|
|
267
|
+
// originalText is recorded exactly once — the first merge that sees the
|
|
268
|
+
// file captures it; later merges keep the original provenance.
|
|
269
|
+
originalText: isPlainObject(ownership) && 'originalText' in ownership ? ownership.originalText : existingText,
|
|
270
|
+
// installedText always reflects the latest content this module ensured.
|
|
271
|
+
installedText: changed ? text : isPlainObject(ownership) && ownership.installedText != null ? ownership.installedText : text,
|
|
272
|
+
},
|
|
273
|
+
warning,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function sameValue(a, b) {
|
|
278
|
+
return a === b;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function originalCompactionOf(ownership) {
|
|
282
|
+
if (!isPlainObject(ownership) || typeof ownership.originalText !== 'string') return null;
|
|
283
|
+
try {
|
|
284
|
+
const data = YAML.parse(ownership.originalText);
|
|
285
|
+
return isPlainObject(data?.compaction) ? data.compaction : null;
|
|
286
|
+
} catch {
|
|
287
|
+
// Unparseable original: no per-key provenance — owned keys are treated as
|
|
288
|
+
// originally absent (they are only removed when they still match what
|
|
289
|
+
// ukit installed, so user values remain safe either way).
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function cleanupCompaction(currentText, ownership) {
|
|
295
|
+
if (currentText == null) {
|
|
296
|
+
return { text: null, ownership: ownership ?? null, warning: null };
|
|
297
|
+
}
|
|
298
|
+
if (typeof currentText !== 'string') {
|
|
299
|
+
throw new TypeError('cleanupOmpProjectConfig: currentText must be a string or null');
|
|
300
|
+
}
|
|
301
|
+
if (!isPlainObject(ownership)) {
|
|
302
|
+
return noWrite(currentText, ownership, 'cleanup skipped: no ownership record — cannot prove which settings ukit installed');
|
|
303
|
+
}
|
|
304
|
+
if (typeof ownership.installedText !== 'string') {
|
|
305
|
+
return noWrite(currentText, ownership, 'cleanup skipped: ownership record has no installedText');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Unchanged since install: hand the file back exactly as ukit found it
|
|
309
|
+
// (null = the file did not exist before ukit created it → delete).
|
|
310
|
+
if (currentText === ownership.installedText) {
|
|
311
|
+
return { text: ownership.originalText ?? null, ownership, warning: null };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// User edited the file: keep every modification, surgically remove/revert
|
|
315
|
+
// only what ukit owns.
|
|
316
|
+
const doc = parseDocOrThrow(currentText, 'current omp project config');
|
|
317
|
+
if (!(doc.contents instanceof YAMLMap)) {
|
|
318
|
+
return noWrite(currentText, ownership, 'cleanup skipped: top-level YAML document is not a mapping');
|
|
319
|
+
}
|
|
320
|
+
const pair = findCompactionPair(doc.contents);
|
|
321
|
+
if (!pair) {
|
|
322
|
+
return { text: currentText, ownership, warning: null };
|
|
323
|
+
}
|
|
324
|
+
const unsafe = unsafeReason(pair.key) ?? unsafeReason(pair.value);
|
|
325
|
+
if (unsafe) {
|
|
326
|
+
return noWrite(currentText, ownership, `cleanup skipped: compaction uses ${unsafe} — surgical edit refused`);
|
|
327
|
+
}
|
|
328
|
+
if (!(pair.value instanceof YAMLMap)) {
|
|
329
|
+
return noWrite(currentText, ownership, 'cleanup skipped: compaction is not a block mapping');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
let installedCompaction;
|
|
333
|
+
try {
|
|
334
|
+
const data = YAML.parse(ownership.installedText);
|
|
335
|
+
if (!isPlainObject(data?.compaction)) {
|
|
336
|
+
return noWrite(currentText, ownership, 'cleanup skipped: installedText has no compaction mapping');
|
|
337
|
+
}
|
|
338
|
+
installedCompaction = data.compaction;
|
|
339
|
+
} catch (error) {
|
|
340
|
+
return noWrite(currentText, ownership, `cleanup skipped: installedText is not valid YAML — ${error?.message ?? error}`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const originalCompaction = originalCompactionOf(ownership);
|
|
344
|
+
const eol = detectEol(currentText);
|
|
345
|
+
const edits = [];
|
|
346
|
+
let removedCount = 0;
|
|
347
|
+
|
|
348
|
+
for (const key of OWNED_COMPACTION_KEYS) {
|
|
349
|
+
if (!(key in installedCompaction)) continue;
|
|
350
|
+
const ownedPair = pair.value.items.find(
|
|
351
|
+
(item) => item.key instanceof Scalar && item.key.value === key,
|
|
352
|
+
);
|
|
353
|
+
if (!ownedPair || !(ownedPair.value instanceof Scalar)) continue;
|
|
354
|
+
|
|
355
|
+
const installedValue = installedCompaction[key];
|
|
356
|
+
if (!sameValue(ownedPair.value.value, installedValue)) continue; // user changed it — keep
|
|
357
|
+
|
|
358
|
+
const hasOriginal = originalCompaction !== null && key in originalCompaction;
|
|
359
|
+
if (!hasOriginal) {
|
|
360
|
+
// ukit added this key (fresh file or key absent pre-install): remove the
|
|
361
|
+
// whole line — from line start (node ranges begin after the indent) to
|
|
362
|
+
// the end of the value's line, including any trailing comment/break.
|
|
363
|
+
const start = lineStartBefore(currentText, ownedPair.key.range[0]);
|
|
364
|
+
const end = Math.max(ownedPair.value.range[2], lineEndAfter(currentText, ownedPair.value.range[1]));
|
|
365
|
+
edits.push({ start, end, replacement: '' });
|
|
366
|
+
removedCount += 1;
|
|
367
|
+
} else if (!sameValue(originalCompaction[key], installedValue)) {
|
|
368
|
+
// ukit changed a pre-existing value: restore the original one in place.
|
|
369
|
+
edits.push({
|
|
370
|
+
start: ownedPair.value.range[0],
|
|
371
|
+
end: ownedPair.value.range[1],
|
|
372
|
+
replacement: formatYamlValue(originalCompaction[key]),
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
// Originally present with the installed value: ukit never altered it — keep.
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// If every remaining compaction entry was ukit-added and got removed, the
|
|
379
|
+
// `compaction:` parent line goes too (fresh files end up compaction-free
|
|
380
|
+
// again). Only the key's own line is removed — the child lines above were
|
|
381
|
+
// spliced by their own edits, and the map's range end already sits past the
|
|
382
|
+
// last child's newline, so extending further would swallow foreign lines.
|
|
383
|
+
if (removedCount > 0 && removedCount === pair.value.items.length) {
|
|
384
|
+
edits.push({
|
|
385
|
+
start: lineStartBefore(currentText, pair.key.range[0]),
|
|
386
|
+
end: lineEndAfter(currentText, pair.key.range[0]),
|
|
387
|
+
replacement: '',
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (edits.length === 0) {
|
|
392
|
+
return { text: currentText, ownership, warning: null };
|
|
393
|
+
}
|
|
394
|
+
return { text: applyEdits(currentText, edits), ownership, warning: null };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// --- task.agentCompactionThresholdOverrides (per-agent soft compaction targets) ---
|
|
398
|
+
// Same ownership rule as the compaction keys: an agent entry is removed (or restored)
|
|
399
|
+
// only when it still carries the value UKit installed; user-set values and every other
|
|
400
|
+
// `task` key stay. Empty parents UKit created are removed with their last child.
|
|
401
|
+
function taskOverridesOf(text) {
|
|
402
|
+
try {
|
|
403
|
+
const data = YAML.parse(text);
|
|
404
|
+
const map = data?.task?.agentCompactionThresholdOverrides;
|
|
405
|
+
return isPlainObject(map) ? map : null;
|
|
406
|
+
} catch {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function cleanupTaskOverrides(currentText, ownership) {
|
|
412
|
+
const installed = taskOverridesOf(ownership.installedText);
|
|
413
|
+
if (!installed) return currentText;
|
|
414
|
+
let doc;
|
|
415
|
+
try {
|
|
416
|
+
doc = parseDocOrThrow(currentText, 'current omp project config');
|
|
417
|
+
} catch {
|
|
418
|
+
return currentText;
|
|
419
|
+
}
|
|
420
|
+
if (!(doc.contents instanceof YAMLMap)) return currentText;
|
|
421
|
+
const taskPair = doc.contents.items.find((p) => p.key instanceof Scalar && p.key.value === 'task');
|
|
422
|
+
if (!taskPair || !(taskPair.value instanceof YAMLMap) || unsafeReason(taskPair)) return currentText;
|
|
423
|
+
const ovPair = taskPair.value.items.find((p) => p.key instanceof Scalar && p.key.value === 'agentCompactionThresholdOverrides');
|
|
424
|
+
if (!ovPair || !(ovPair.value instanceof YAMLMap)) return currentText;
|
|
425
|
+
const original = taskOverridesOf(typeof ownership.originalText === 'string' ? ownership.originalText : '') ?? {};
|
|
426
|
+
const edits = [];
|
|
427
|
+
let removed = 0;
|
|
428
|
+
for (const [agent, value] of Object.entries(installed)) {
|
|
429
|
+
const entry = ovPair.value.items.find((p) => p.key instanceof Scalar && p.key.value === agent);
|
|
430
|
+
if (!entry || !(entry.value instanceof Scalar) || entry.value.value !== value) continue;
|
|
431
|
+
if (!(agent in original)) {
|
|
432
|
+
const start = lineStartBefore(currentText, entry.key.range[0]);
|
|
433
|
+
const end = Math.max(entry.value.range[2], lineEndAfter(currentText, entry.value.range[1]));
|
|
434
|
+
edits.push({ start, end, replacement: '' });
|
|
435
|
+
removed += 1;
|
|
436
|
+
} else if (original[agent] !== value) {
|
|
437
|
+
edits.push({ start: entry.value.range[0], end: entry.value.range[1], replacement: formatYamlValue(original[agent]) });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (removed > 0 && removed === ovPair.value.items.length) {
|
|
441
|
+
edits.push({ start: lineStartBefore(currentText, ovPair.key.range[0]), end: lineEndAfter(currentText, ovPair.key.range[0]), replacement: '' });
|
|
442
|
+
if (taskPair.value.items.length === 1) {
|
|
443
|
+
edits.push({ start: lineStartBefore(currentText, taskPair.key.range[0]), end: lineEndAfter(currentText, taskPair.key.range[0]), replacement: '' });
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return edits.length > 0 ? applyEdits(currentText, edits) : currentText;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export function cleanupOmpProjectConfig(currentText, ownership) {
|
|
450
|
+
const result = cleanupCompaction(currentText, ownership);
|
|
451
|
+
if (
|
|
452
|
+
typeof currentText !== 'string'
|
|
453
|
+
|| !isPlainObject(ownership)
|
|
454
|
+
|| typeof ownership.installedText !== 'string'
|
|
455
|
+
|| currentText === ownership.installedText
|
|
456
|
+
|| typeof result.text !== 'string'
|
|
457
|
+
) {
|
|
458
|
+
return result;
|
|
459
|
+
}
|
|
460
|
+
const text = cleanupTaskOverrides(result.text, ownership);
|
|
461
|
+
return text === result.text ? result : { ...result, text };
|
|
462
|
+
}
|
|
@@ -432,7 +432,14 @@ export async function runInstallPipeline({
|
|
|
432
432
|
});
|
|
433
433
|
}
|
|
434
434
|
|
|
435
|
-
|
|
435
|
+
// Ownership proof for .omp/config.yml: the ledger record, or — for installs that
|
|
436
|
+
// predate it — a synthetic proof that UKit shipped the legacy 350000 default
|
|
437
|
+
// (only when UKit tracked the file). Never touches the user's other values.
|
|
438
|
+
const trackedOmpConfig = (Array.isArray(installMetaData?.files) ? installMetaData.files : [])
|
|
439
|
+
.some((e) => (typeof e === 'string' ? e : e?.p) === '.omp/config.yml');
|
|
440
|
+
const ompOwnership = installMetaData?.ompConfigOwnership
|
|
441
|
+
?? (trackedOmpConfig ? { originalText: null, installedText: 'compaction:\n thresholdTokens: 350000\n' } : null);
|
|
442
|
+
const diffResults = await diffInstallPlan(plan, { ompOwnership });
|
|
436
443
|
const plannedSummary = summarizeDiff(diffResults);
|
|
437
444
|
|
|
438
445
|
if (!dryRun) {
|
|
@@ -441,6 +448,22 @@ export async function runInstallPipeline({
|
|
|
441
448
|
projectRoot: pathConfig.projectRoot,
|
|
442
449
|
});
|
|
443
450
|
|
|
451
|
+
for (const entry of diffResults) {
|
|
452
|
+
const parseError = entry?.migrationReport?.parseError;
|
|
453
|
+
if (!parseError || !/\.omp\/config\.yml$/.test(String(entry.targetPath).replace(/\\/g, '/'))) continue;
|
|
454
|
+
console.warn(`[UKit] Warning: .omp/config.yml was left untouched (cannot merge: ${parseError}). Fix the YAML and rerun \`ukit install\` to apply UKit's OMP settings.`);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
let ompConfigOwnership = null;
|
|
458
|
+
for (const entry of diffResults) {
|
|
459
|
+
if (!entry.ompOwnership) continue;
|
|
460
|
+
if (entry.ompOwnershipWarning) console.warn(`[UKit] .omp/config.yml: ${entry.ompOwnershipWarning}`);
|
|
461
|
+
if (entry.action === 'create' || entry.action === 'update' || entry.action === 'unchanged') {
|
|
462
|
+
const written = writes.some((w) => w.targetPath === entry.targetPath);
|
|
463
|
+
if (written || entry.action === 'unchanged') ompConfigOwnership = entry.ompOwnership;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
444
467
|
// SPEC §7 step 9: surface the .omp/config.yml unattended migration result.
|
|
445
468
|
for (const entry of diffResults) {
|
|
446
469
|
const report = entry?.migrationReport;
|
|
@@ -522,6 +545,7 @@ export async function runInstallPipeline({
|
|
|
522
545
|
projectRoot: pathConfig.projectRoot,
|
|
523
546
|
managedRelativePaths: [...trackedManagedRelativePaths],
|
|
524
547
|
retainedManagedRelativePaths,
|
|
548
|
+
ompConfigOwnership,
|
|
525
549
|
});
|
|
526
550
|
|
|
527
551
|
// 3.3.0: hooks spawn the CLI (episodes, self-improve) from a GUI-launched
|
package/src/core/uninstall.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
resolveProjectRelativePath,
|
|
10
10
|
} from './fileOps.js';
|
|
11
11
|
import { readInstallMetadata } from './metadata.js';
|
|
12
|
+
import { cleanupOmpProjectConfig } from './ompProjectConfig.js';
|
|
12
13
|
import { removeGitignoreBlock } from './ensureGitignore.js';
|
|
13
14
|
import { PROJECT_IMPORTANT_FILENAME } from './projectImportant.js';
|
|
14
15
|
|
|
@@ -424,6 +425,29 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
|
|
|
424
425
|
}
|
|
425
426
|
|
|
426
427
|
|
|
428
|
+
// .omp/config.yml is user-shared: revert only what UKit owns instead of deleting it.
|
|
429
|
+
const ompConfigAbs = path.join(projectRoot, '.omp', 'config.yml');
|
|
430
|
+
let ompConfigRewrite = null;
|
|
431
|
+
const ompIdx = regularPaths.findIndex((p) => p.abs === ompConfigAbs);
|
|
432
|
+
if (ompIdx !== -1) {
|
|
433
|
+
const currentText = await fs.readFile(ompConfigAbs, 'utf8').catch(() => null);
|
|
434
|
+
if (currentText !== null) {
|
|
435
|
+
let outcome;
|
|
436
|
+
try {
|
|
437
|
+
outcome = cleanupOmpProjectConfig(currentText, installData.ompConfigOwnership ?? null);
|
|
438
|
+
} catch (error) {
|
|
439
|
+
outcome = { text: currentText, warning: `cleanup skipped: ${error?.message ?? error}` };
|
|
440
|
+
}
|
|
441
|
+
if (outcome.warning) {
|
|
442
|
+
console.warn(`[UKit] .omp/config.yml kept: ${outcome.warning}`);
|
|
443
|
+
}
|
|
444
|
+
if (outcome.text !== null) {
|
|
445
|
+
regularPaths.splice(ompIdx, 1); // not deleted: rewritten (or kept) below
|
|
446
|
+
if (outcome.text !== currentText) ompConfigRewrite = outcome.text;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
427
451
|
const allEntries = [
|
|
428
452
|
...regularPaths.map(({ abs, recursive }) => ({ abs, useLink: false, recursive })),
|
|
429
453
|
...linkPaths.map((abs) => ({ abs, useLink: true, recursive: false })),
|
|
@@ -485,7 +509,10 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
|
|
|
485
509
|
backupPaths.push(importantPlan.backupPath); // existing byte-identical backup
|
|
486
510
|
}
|
|
487
511
|
|
|
488
|
-
|
|
512
|
+
if (ompConfigRewrite !== null) {
|
|
513
|
+
await fs.writeFile(ompConfigAbs, ompConfigRewrite, 'utf8');
|
|
514
|
+
}
|
|
515
|
+
|
|
489
516
|
// Remove all paths in parallel. Tracked file entries use removeFileOrLinkOnly:
|
|
490
517
|
// a real directory at a tracked path is user content (the user replaced the
|
|
491
518
|
// managed file) and is refused — same protection link entries already had.
|
|
@@ -135,7 +135,7 @@ function stableBatchId(findings) {
|
|
|
135
135
|
* findings: object[], verdictDecisionKey: string}}
|
|
136
136
|
* Never throws — garbage input sanitizes to an empty finding list.
|
|
137
137
|
*/
|
|
138
|
-
export function buildReviewVerdictBatch({ findings = [], mode = 'solo' } = {}) {
|
|
138
|
+
export function buildReviewVerdictBatch({ findings = [], mode = 'solo', maxQuestions = Infinity } = {}) {
|
|
139
139
|
const usedIds = new Set();
|
|
140
140
|
// Sanitize the FULL finding set — MAX_FINDINGS is a display/transport bound
|
|
141
141
|
// for the batch, not a verdict bound. The deterministic verdict and the
|
|
@@ -145,6 +145,19 @@ export function buildReviewVerdictBatch({ findings = [], mode = 'solo' } = {}) {
|
|
|
145
145
|
const batchFindings = sanitized.slice(0, MAX_FINDINGS);
|
|
146
146
|
const truncated = sanitized.length > batchFindings.length;
|
|
147
147
|
|
|
148
|
+
// Request-cap handling (no extra call): when verdict + one bucket question per
|
|
149
|
+
// finding would exceed `maxQuestions`, ask only findings whose bucket the model
|
|
150
|
+
// can actually change. `Act on` is a deterministic floor the model cannot
|
|
151
|
+
// lower, so critical/important/unknown findings carry no semantic value in the
|
|
152
|
+
// batch; the rest keep their deterministic bucket and are reported as skipped.
|
|
153
|
+
const slots = Number.isInteger(maxQuestions) && maxQuestions > 0
|
|
154
|
+
? maxQuestions - 1 : Infinity;
|
|
155
|
+
const askedFindings = batchFindings.length <= slots
|
|
156
|
+
? batchFindings
|
|
157
|
+
: batchFindings.filter((f) => deriveDeterministicBucket(f) !== 'Act on').slice(0, slots);
|
|
158
|
+
const askedIds = new Set(askedFindings.map((f) => f.id));
|
|
159
|
+
const skippedFindingIds = batchFindings.filter((f) => !askedIds.has(f.id)).map((f) => f.id);
|
|
160
|
+
|
|
148
161
|
const verdictDecisionKey = mode === 'panel'
|
|
149
162
|
? REVIEW_VERDICT_KEYS.panel
|
|
150
163
|
: REVIEW_VERDICT_KEYS.solo;
|
|
@@ -169,7 +182,7 @@ export function buildReviewVerdictBatch({ findings = [], mode = 'solo' } = {}) {
|
|
|
169
182
|
// One bucket question per finding. The shared registered key cannot repeat
|
|
170
183
|
// in one batch (name-based reconciliation), so each wire name carries
|
|
171
184
|
// `#<findingId>`; parseVerdictAnswer splits it back off.
|
|
172
|
-
const bucketQuestions =
|
|
185
|
+
const bucketQuestions = askedFindings.map((f) => ({
|
|
173
186
|
decisionKey: `${REVIEW_VERDICT_KEYS.bucket}#${f.id}`,
|
|
174
187
|
schemaVersion: 1,
|
|
175
188
|
family: 'review',
|
|
@@ -207,6 +220,7 @@ export function buildReviewVerdictBatch({ findings = [], mode = 'solo' } = {}) {
|
|
|
207
220
|
findings: batchFindings,
|
|
208
221
|
fullFindings: sanitized,
|
|
209
222
|
truncated,
|
|
223
|
+
skippedFindingIds,
|
|
210
224
|
verdictDecisionKey,
|
|
211
225
|
};
|
|
212
226
|
}
|
|
@@ -394,14 +394,20 @@ export async function applyFixLoopEscalation({
|
|
|
394
394
|
: null;
|
|
395
395
|
// A model lane that refuses to deepen is not an escalation answer — the
|
|
396
396
|
// deterministic deeper lane wins so the loop actually exits its lane.
|
|
397
|
-
|
|
398
|
-
|
|
397
|
+
// Lanes are capabilities, not an ordinal ladder. Without a validated policy
|
|
398
|
+
// relation only the deterministic destination is admissible.
|
|
399
|
+
const usableModelLane = planeStage !== 'shadow' && modelLane === deterministicLane
|
|
400
|
+
? modelLane : null;
|
|
401
|
+
const lane = deterministicLane;
|
|
399
402
|
const endpointDown = batchResult !== null
|
|
400
403
|
&& batchResult.status !== 'accepted'
|
|
401
404
|
&& batchResult.status !== 'partial'
|
|
402
405
|
&& !usableModelLane;
|
|
403
|
-
const
|
|
404
|
-
|
|
406
|
+
const depthFloor = resolveEscalationVerificationDepth({ routeSummary, config });
|
|
407
|
+
const depthOrder = ['none', 'runnable', 'review-round', 'escalate'];
|
|
408
|
+
const depth = planeStage !== 'shadow'
|
|
409
|
+
&& depthOrder.indexOf(modelDepth) > depthOrder.indexOf(depthFloor)
|
|
410
|
+
? modelDepth : depthFloor;
|
|
405
411
|
|
|
406
412
|
const reason = [
|
|
407
413
|
`fix-loop: fixLoopCount=${fixLoopCount} threshold=${settings.debugLoopThreshold} fingerprint-joined`,
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { loadShippedCompactBudget } from '../core/compact/contextBudget.js';
|
|
2
2
|
|
|
3
|
+
// Starting omp compaction threshold; capped by the shared auto-compact window above.
|
|
4
|
+
const OMP_COMPACTION_THRESHOLD_TOKENS = 90_000;
|
|
5
|
+
|
|
3
6
|
const CODEGRAPH_SECTION = `
|
|
4
7
|
## CodeGraph Integration (active)
|
|
5
8
|
|
|
@@ -76,6 +79,7 @@ export function buildTemplateVariables({ projectContext, stackContext, packageVe
|
|
|
76
79
|
},
|
|
77
80
|
omp: {
|
|
78
81
|
evalApproval,
|
|
82
|
+
compactionThresholdTokens: String(Math.min(OMP_COMPACTION_THRESHOLD_TOKENS, autoCompactWindow)),
|
|
79
83
|
},
|
|
80
84
|
codegraphSection: withCodegraph ? CODEGRAPH_SECTION : '',
|
|
81
85
|
};
|