@ngockhoale/ukit 3.4.32 → 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.
@@ -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
- const diffResults = await diffInstallPlan(plan);
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
@@ -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
- // Remove all paths in parallel
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.
@@ -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
  };
@@ -135,6 +135,12 @@ if printf '%s' "$INPUT" | grep -qE '"tool_name"[[:space:]]*:[[:space:]]*"(Agent|
135
135
  && printf '%s' "$INPUT" | grep -qE 'TASK-([Cc][0-9]+-)?[0-9]+'; then
136
136
  TASK_CLAIM_RUN=1
137
137
  fi
138
+ # Adaptive task-graph pre-filter: a delegation tagged [ukit-subtask <id>] needs the dependency gate.
139
+ SUBTASK_RUN=0
140
+ if printf '%s' "$INPUT" | grep -qE '"tool_name"[[:space:]]*:[[:space:]]*"(Agent|Task)"' \
141
+ && printf '%s' "$INPUT" | grep -qF '[ukit-subtask '; then
142
+ SUBTASK_RUN=1
143
+ fi
138
144
  # Main-agent ownership pre-filter: a source Edit/Write only needs the node gate while a
139
145
  # handoff-fullstack run sits in an implement phase (cheap RUN.md grep, no node spawn otherwise).
140
146
  MAIN_OWNER_RUN=0
@@ -142,11 +148,11 @@ if printf '%s' "$INPUT" | grep -qE '"tool_name"[[:space:]]*:[[:space:]]*"(Edit|W
142
148
  && grep -qE '^Phase:[[:space:]]*I[1-4]\b' "$PROJECT_ROOT/docs/AI_HANDOFF/RUN.md" 2>/dev/null; then
143
149
  MAIN_OWNER_RUN=1
144
150
  fi
145
- if [ "$CREATE_SCOPE_RUN" != 1 ] && [ "$TASK_CLAIM_RUN" != 1 ] && [ "$MAIN_OWNER_RUN" != 1 ] && ! printf '%s' "$INPUT" | grep -qE 'AI_HANDOFF|git[[:space:]]+push'; then
151
+ if [ "$CREATE_SCOPE_RUN" != 1 ] && [ "$TASK_CLAIM_RUN" != 1 ] && [ "$SUBTASK_RUN" != 1 ] && [ "$MAIN_OWNER_RUN" != 1 ] && ! printf '%s' "$INPUT" | grep -qE 'AI_HANDOFF|git[[:space:]]+push'; then
146
152
  exit 0
147
153
  fi
148
154
 
149
- INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" CREATE_SCOPE_RUN="$CREATE_SCOPE_RUN" TASK_CLAIM_RUN="$TASK_CLAIM_RUN" HOOK_DIR="$SCRIPT_DIR" node <<'NODE'
155
+ INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" CREATE_SCOPE_RUN="$CREATE_SCOPE_RUN" TASK_CLAIM_RUN="$TASK_CLAIM_RUN" SUBTASK_RUN="$SUBTASK_RUN" HOOK_DIR="$SCRIPT_DIR" node <<'NODE'
150
156
  const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
151
157
  // SPEC §8: a timed-out gate silently passes work it never evaluated — announce the
152
158
  // degrade (fail-open posture kept: still exit 0).
@@ -793,6 +799,35 @@ async function taskClaimGate() {
793
799
  }
794
800
  await taskClaimGate();
795
801
 
802
+ // Adaptive-decomposition task graph: a delegation tagged `[ukit-subtask <id>]` is dispatched only
803
+ // when its dependencies are done and it is neither running nor already done (resume never
804
+ // re-dispatches finished work). Untagged delegations and IO trouble pass through untouched.
805
+ async function subtaskGate() {
806
+ if (process.env.SUBTASK_RUN !== '1' || toolName !== 'Agent' && toolName !== 'Task') return;
807
+ try {
808
+ const dispatch = await import(require('url').pathToFileURL(path.resolve(process.env.HOOK_DIR || '.', '../ukit/runtime/plan-dispatch.mjs')).href);
809
+ const subtaskId = dispatch.subtaskIdFromInput(input);
810
+ if (!subtaskId) return;
811
+ const verdict = await dispatch.dispatchSubtask(projectRoot, subtaskId, input);
812
+ if (verdict.ok) {
813
+ const b = verdict.budget;
814
+ if (b && (b.status === 'over-soft-budget' || b.status === 'over-cost-boundary')) {
815
+ process.on('exit', (code) => {
816
+ if (code !== 0) return;
817
+ try { fs.writeSync(1, `${JSON.stringify({ systemMessage: `UKit: subtask ${subtaskId} scoped context (over the ${b.role} soft budget of ${b.softBudget} tokens) — consider splitting it further. Advisory only; dispatch proceeds.` })}\n`); } catch {}
818
+ });
819
+ }
820
+ return;
821
+ }
822
+ const why = verdict.reason === 'deps-pending' ? `dependencies not done yet: ${(verdict.missing || []).join(', ')}`
823
+ : verdict.reason === 'already-done' ? 'it is already done (resume never re-dispatches completed subtasks)'
824
+ : verdict.reason === 'already-running' ? 'it is already running'
825
+ : verdict.reason;
826
+ block(`Subtask ${subtaskId} not dispatched: ${why}. Dispatch the ready subtasks first (see the route "plan:" lines); integrate last.`);
827
+ } catch { /* fail-open on IO trouble */ }
828
+ }
829
+ await subtaskGate();
830
+
796
831
  // Main-agent task ownership. handoff-fullstack contracts implementation to claim-bearing
797
832
  // executor subagents ("Do NOT implement the tasks yourself in the current session"). The
798
833
  // delegation path is claim-gated above; this closes the direct path: while a run is in an
@@ -53,6 +53,15 @@ async function releaseDelegatedClaims(payload, projectRoot) {
53
53
  await mod.renewSessionClaims(projectRoot, String(payload.session_id));
54
54
  }
55
55
  if (payload.tool_name !== 'Agent' && payload.tool_name !== 'Task') return;
56
+ try {
57
+ const dispatch = await import(pathToFileURL(path.join(MODULE_DIR, '..', 'ukit', 'runtime', 'plan-dispatch.mjs')).href);
58
+ const subtaskId = dispatch.subtaskIdFromInput(payload.tool_input);
59
+ if (subtaskId) {
60
+ const r = payload.tool_response ?? payload.tool_output ?? payload.tool_result ?? {};
61
+ const failed = [payload.is_error, r?.is_error, r?.isError, payload.tool_response?.isError].some((v) => v === true);
62
+ await dispatch.completeSubtask(projectRoot, subtaskId, { success: !failed });
63
+ }
64
+ } catch { /* advisory */ }
56
65
  const owner = mod.delegatedClaimOwner(payload);
57
66
  if (!owner) return;
58
67
  for (const { taskId } of mod.delegatedClaimTargets(payload.tool_input)) {