@ngockhoale/ukit 3.4.32 → 3.4.34

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 CHANGED
@@ -9,6 +9,22 @@ All notable changes to UKit are documented here.
9
9
  - New `/ukit:ci-check` command + `ci-check.mjs` helper (core pack): prechecks, fail-closed budget guard, local preflight, at most one dispatch per invocation, write-ahead run-id state, `status` (never dispatches), `--ref`, `--dry-run`, per-OS report. Never pushes, merges or publishes.
10
10
  - Default-branch bootstrap stub documented in `docs/design/CI-CROSS-PLATFORM-MATRIX.md` (requires separate approval).
11
11
 
12
+ ## 3.4.34 - 2026-10-12
13
+
14
+ **Hook interference fixes (P1): task intent, operation evidence, heredoc data, containment reporting.**
15
+ - **Task intent.** Forensic, negated or deferred wording ("fix", "implement") is no longer read as a mutation order; `mutationOrder` is kept across tool events and "continue". Read-only and forensic tasks release at Stop without Edit/Write.
16
+ - **Operation evidence.** Git push/clean and service restart + probe complete on matching operation receipts (whole-tree git state after the mutation, matching target/probe path/status, session- and task-scoped); dry-run, stale, failed, masked-exit and cross-task receipts are ignored. Mixed implementation + deploy keeps write evidence.
17
+ - **Dangerous Command Guard.** A quoted heredoc fed to a plain text tool behind `cd x;` / `cd x &&` is data; interpreters, unquoted delimiters, substitution and pipe-to-shell stay blocked.
18
+ - **Safe Patch.** `post-edit-verify` reports an out-of-project target as a scope refusal (still non-zero, never read) instead of a hook crash.
19
+ - Project `.omp/config.yml` now carries the per-agent `task.agentCompactionThresholdOverrides` block that `ukit install` already generates, so a clean worktree install no longer modifies a tracked file.
20
+
21
+ ## 3.4.33 - 2026-10-11
22
+
23
+ **OMP token optimization, adaptive task decomposition and Completion Gate reliability.** Verified with the full suite and `yarn release:verify`.
24
+ - **OMP project compaction.** Project `.omp/config.yml` defaults `compaction.thresholdTokens` to 90K; legacy 350K values migrate; install/update merge safely, preserve user overrides and per-agent compaction thresholds; uninstall is ownership-aware. Global OMP config is never touched (OMP 18.8.9 compatible).
25
+ - **Adaptive task decomposition.** Context budget policy, cost-aware decomposition, runtime plan dispatch enforcement for tagged subtasks, dependency handling, resume/progress preservation and parent acceptance criteria; no redundant replanning.
26
+ - **Completion Gate.** Status/result lookups are classified READ_ONLY (no fake write/verification evidence); coding tasks still require write and verification evidence. Contracts are bound to task, session and source snapshot; a read-only or answer-only reroute parks the running implementation contract and a continuation restores it; new task ids never inherit old evidence.
27
+
12
28
  ## 3.4.32 - 2026-10-11
13
29
 
14
30
  **UNIC-Decision integration P1: safety floors, ordinal score contract, request caps.** Verified with the full suite (566 files) and `yarn release:verify`; no live inference was run (offline fixtures plus an offline run of the Laya server translator).
@@ -418,6 +418,17 @@ entries:
418
418
  validation: [none]
419
419
  archive_policy: immutable
420
420
  notes: C99 measurement-only sub-agent cost audit snapshot; registered in 3.4.25 (was unclassified on 3.4.24)
421
+ - id: docs-analysis-context-threshold-simulation
422
+ path: docs/analysis/CONTEXT-THRESHOLD-SIMULATION.md
423
+ class: archive
424
+ audience: [maintainer]
425
+ owner: product
426
+ source_of_truth: docs/analysis/CONTEXT-THRESHOLD-SIMULATION.md
427
+ merge_strategy: none
428
+ load_policy: never
429
+ validation: [none]
430
+ archive_policy: immutable
431
+ notes: Corrected compaction-threshold simulation (observed vs hypothetical, LIMITED validity)
421
432
  - id: docs-analysis-handoff-root-cause-audit
422
433
  path: docs/analysis/HANDOFF-ROOT-CAUSE-AUDIT.md
423
434
  class: archive
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.32",
3
+ "version": "3.4.34",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,7 +1,9 @@
1
1
  import fs from 'node:fs/promises';
2
+ import YAML from 'yaml';
2
3
  import { isSymlinkTo } from './fileOps.js';
3
4
  import { GATEWAY_RESILIENCE_ENV_DEFAULTS } from './gatewayResilienceEnv.js';
4
5
  import { isOmpConfigTarget, mergeOmpConfig } from './ompConfigMerge.js';
6
+ import { mergeOmpProjectConfig } from './ompProjectConfig.js';
5
7
  import { mergeRuntimeConfigText } from './runtimeConfigMerge.js';
6
8
 
7
9
  const GATEWAY_RESILIENCE_KEYS = new Set(Object.keys(GATEWAY_RESILIENCE_ENV_DEFAULTS));
@@ -163,13 +165,65 @@ function canonicalize(value) {
163
165
  return value;
164
166
  }
165
167
 
166
- function resolveFileAction(entry, existingContent) {
168
+ // What UKit itself ensured in the file: the merged text, but with compaction values
169
+ // the user overrode shown as UKit's rendered defaults — so uninstall can tell a
170
+ // user-owned override (differs → kept) from a UKit-installed value (same → removed).
171
+ function ukitInstalledText(mergedText, renderedText) {
172
+ try {
173
+ const merged = YAML.parseDocument(mergedText);
174
+ const defaults = YAML.parse(renderedText)?.compaction;
175
+ const current = merged.get('compaction');
176
+ if (!isPlainObject(defaults) || !current || typeof current.get !== 'function') return mergedText;
177
+ let differs = false;
178
+ for (const [key, value] of Object.entries(defaults)) {
179
+ if (current.get(key) !== value) {
180
+ differs = true;
181
+ current.set(key, value);
182
+ }
183
+ }
184
+ // Same rule for UKit's per-agent task compaction overrides: a user-set value is
185
+ // shown as the shipped default so uninstall treats it as user-owned.
186
+ const defaultOverrides = YAML.parse(renderedText)?.task?.agentCompactionThresholdOverrides;
187
+ if (isPlainObject(defaultOverrides)) {
188
+ for (const [agent, value] of Object.entries(defaultOverrides)) {
189
+ const currentValue = merged.getIn(['task', 'agentCompactionThresholdOverrides', agent]);
190
+ if (currentValue !== undefined && currentValue !== value) {
191
+ differs = true;
192
+ merged.setIn(['task', 'agentCompactionThresholdOverrides', agent], value);
193
+ }
194
+ }
195
+ }
196
+ // Agents the user added are not UKit's: drop them from the installed view.
197
+ const userMap = merged.getIn(['task', 'agentCompactionThresholdOverrides']);
198
+ if (isPlainObject(defaultOverrides) && userMap && typeof userMap.items !== 'undefined') {
199
+ const extra = userMap.items.filter((p) => !(String(p.key?.value ?? p.key) in defaultOverrides));
200
+ for (const p of extra) {
201
+ differs = true;
202
+ merged.deleteIn(['task', 'agentCompactionThresholdOverrides', String(p.key?.value ?? p.key)]);
203
+ }
204
+ }
205
+ return differs ? String(merged) : mergedText;
206
+ } catch {
207
+ return mergedText;
208
+ }
209
+ }
210
+
211
+ function resolveFileAction(entry, existingContent, ompOwnership = null) {
167
212
  const exists = existingContent !== null;
168
213
  const binaryEntry = Buffer.isBuffer(entry.renderedContent);
169
214
 
170
215
  let action;
171
216
  if (!exists) {
172
217
  action = 'create';
218
+ if (isOmpConfigTarget(entry.targetPath) && typeof entry.renderedContent === 'string') {
219
+ return {
220
+ ...entry,
221
+ exists,
222
+ action,
223
+ existingContent,
224
+ ompOwnership: { originalText: null, installedText: entry.renderedContent },
225
+ };
226
+ }
173
227
  } else if (entry.mergeStrategy === 'append' && !binaryEntry) {
174
228
  action = existingContent.endsWith(entry.renderedContent) ? 'unchanged' : 'update';
175
229
  } else if (entry.mergeStrategy === 'prepend' && !binaryEntry) {
@@ -184,9 +238,34 @@ function resolveFileAction(entry, existingContent) {
184
238
  // SPEC §7 step 9: `.omp/config.yml` gets a semantic, comment-safe migration
185
239
  // merge — the action compares the MERGED text against the existing file, so
186
240
  // preserved user regions do not count as drift.
187
- const merged = mergeOmpConfig(existingContent, entry.renderedContent);
241
+ // Compaction first (ownership-aware, surgical, comment-preserving); the approval
242
+ // merge then runs over that text, so user compaction values survive verbatim.
243
+ let projectMerge = null;
244
+ try {
245
+ projectMerge = mergeOmpProjectConfig(existingContent, entry.renderedContent, ompOwnership);
246
+ } catch {
247
+ projectMerge = null; // unparsable → mergeOmpConfig reports parseError below
248
+ }
249
+ const merged = mergeOmpConfig(projectMerge ? projectMerge.text : existingContent, entry.renderedContent);
250
+ if (merged.report.parseError) {
251
+ // Unparseable existing file (or broken template): never replace the user's file
252
+ // with the template — leave it untouched and let install report it.
253
+ return { ...entry, renderedContent: existingContent, migrationReport: merged.report, exists, action: 'unchanged', existingContent };
254
+ }
188
255
  action = merged.text === existingContent ? 'unchanged' : 'update';
189
- return { ...entry, renderedContent: merged.text, migrationReport: merged.report, exists, action, existingContent };
256
+ const ownership = projectMerge
257
+ ? { originalText: projectMerge.ownership.originalText, installedText: ukitInstalledText(merged.text, entry.renderedContent) }
258
+ : null;
259
+ return {
260
+ ...entry,
261
+ renderedContent: merged.text,
262
+ migrationReport: merged.report,
263
+ ompOwnership: ownership,
264
+ ompOwnershipWarning: projectMerge?.warning ?? null,
265
+ exists,
266
+ action,
267
+ existingContent,
268
+ };
190
269
  } else if (entry.mergeStrategy === 'merge_env_overwrite_with_backup' && typeof existingContent === 'string') {
191
270
  // Settings.json: ignore UKit-managed gateway resilience env keys (post-apply written
192
271
  // and may carry user overrides). All other top-level keys — and the rest of the env
@@ -222,7 +301,7 @@ function resolveFileAction(entry, existingContent) {
222
301
  // write then crashes mid-install on a target that may have been there all along.
223
302
  // Existence for skip entries is decided by lstat (no symlink follow, no device open —
224
303
  // reading a FIFO would hang).
225
- async function resolveSkipEntryAction(entry) {
304
+ async function resolveSkipEntryAction(entry, ompOwnership = null) {
226
305
  let stat;
227
306
  try {
228
307
  stat = await fs.lstat(entry.targetPath);
@@ -250,10 +329,10 @@ async function resolveSkipEntryAction(entry) {
250
329
  // means present-but-inaccessible. Either way: owner territory, skip it.
251
330
  return { ...entry, exists: read.status === 'unreadable', action: 'skip', existingContent: null };
252
331
  }
253
- return resolveFileAction(entry, read.content);
332
+ return resolveFileAction(entry, read.content, ompOwnership);
254
333
  }
255
334
 
256
- export async function diffInstallPlan(plan) {
335
+ export async function diffInstallPlan(plan, { ompOwnership = null } = {}) {
257
336
  // Check all entries in parallel
258
337
  const results = await Promise.all(
259
338
  plan.entries.map(async (entry) => {
@@ -263,7 +342,7 @@ export async function diffInstallPlan(plan) {
263
342
  }
264
343
 
265
344
  if (entry.mergeStrategy === 'skip') {
266
- return resolveSkipEntryAction(entry);
345
+ return resolveSkipEntryAction(entry, ompOwnership);
267
346
  }
268
347
 
269
348
  const read = await readFileOrStatus(
@@ -273,7 +352,7 @@ export async function diffInstallPlan(plan) {
273
352
  if (read.status === 'unreadable') {
274
353
  return conflictResult(entry, read.error);
275
354
  }
276
- return resolveFileAction(entry, read.status === 'ok' ? read.content : null);
355
+ return resolveFileAction(entry, read.status === 'ok' ? read.content : null, ompOwnership);
277
356
  }),
278
357
  );
279
358
 
@@ -167,6 +167,28 @@ export function buildConfigContracts() {
167
167
  );
168
168
  }
169
169
 
170
+ // What exactly was ordered, so a receipt for a DIFFERENT verb/service/probe cannot discharge it.
171
+ // Unnamed specifics stay empty (any proven receipt of the domain counts); named ones are required.
172
+ const NOT_A_SERVICE = new Set(['it', 'this', 'that', 'them', 'the', 'all', 'everything', 'service', 'services', 'again', 'now', 'to', 'and', 'then']);
173
+ function nativeOperationGoal(domain, actionable, text) {
174
+ const goal = { domain, target: null, desiredState: 'verified', completionSemantics: 'execute-and-verify', requiredTargets: [] };
175
+ const clauses = actionable.join(' . ');
176
+ if (domain === 'git-integrity') {
177
+ const verbs = [...clauses.matchAll(/\bgit\s+(push|clean|commit|reset|rebase|merge|tag|stash|checkout|restore|cherry-pick)\b/g)].map((m) => m[1]);
178
+ if (/\bpush\s+(?:to\s+)?origin\b/.test(clauses)) verbs.push('push');
179
+ goal.requiredTargets = [...new Set(verbs)];
180
+ goal.check = /\bfsck\b/.test(text) ? 'fsck' : /\bdiff\s+--check\b/.test(text) ? 'diff-check' : null;
181
+ return goal;
182
+ }
183
+ const services = [...clauses.matchAll(/\b(?:restart|redeploy|deploy|khoi dong lai|trien khai)\s+(?:the\s+|lai\s+)?([a-z0-9][\w.-]*)/g)]
184
+ .map((m) => m[1]).filter((name) => !NOT_A_SERVICE.has(name));
185
+ goal.requiredTargets = [...new Set(services)];
186
+ const probePath = text.match(/(?:^|\s)(\/[a-z0-9_.-]+(?:\/[a-z0-9_.-]+)*)/)?.[1] || null;
187
+ const probeStatus = text.match(/\b(?:returns?|returning|responds?(?:\s+with)?|http|status(?:\s+code)?)\s+([1-5]\d\d)\b/)?.[1] || null;
188
+ if (probePath || probeStatus) goal.probe = { path: probePath, status: probeStatus };
189
+ return goal;
190
+ }
191
+
170
192
  // Task semantics are independent of source execution strategy and observed progress.
171
193
  export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
172
194
  const text = String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd').toLowerCase();
@@ -175,17 +197,40 @@ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {})
175
197
  && !/^\s*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause));
176
198
  const sourceOrder = actionable.some((clause) => /\b(?:implement|fix|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
177
199
  && /\b(?:source|code|logic|scheduler|implementation|ma nguon)\b|\.[cm]?[jt]sx?\b/.test(clause));
178
- const operation = actionable.some((clause) => /\b(?:delete|remove|cancel|xoa|huy|ensure|dam bao)\b/.test(clause)
179
- && /\bcron\b|scheduled\s+(?:cron\s+)?(?:task|job)|tac vu\s+(?:cron|dinh ky)/.test(clause));
180
- const config = actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause));
200
+ const codeChangeVerb = actionable.some((clause) => /\b(?:implement|fix|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause));
201
+ const hasOperationId = /\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/.test(text);
202
+ // A cron/job MENTION is not an operation: with a code-change verb in the request, an
203
+ // operation needs a concrete target id; otherwise it is a coding task about scheduling.
204
+ const cronOperation = actionable.some((clause) => /\b(?:delete|remove|cancel|xoa|huy|ensure|dam bao)\b/.test(clause)
205
+ && /\bcron\b|scheduled\s+(?:cron\s+)?(?:task|job)|tac vu\s+(?:cron|dinh ky)/.test(clause))
206
+ && (!codeChangeVerb || hasOperationId);
207
+ // Non-source operations (git history/push, service restart/deploy): ordered only by an
208
+ // actionable clause (negated/deferred/audit clauses are already filtered out). Git and
209
+ // runtime are separate domains with separate proof; a prompt naming both stays a coding
210
+ // task rather than being half-verified. A code-change verb anywhere keeps write evidence.
211
+ const gitOperation = actionable.some((clause) => /\bgit\s+(?:push|clean|commit|reset|rebase|merge|tag|stash|checkout|restore|cherry-pick)\b|\bpush\s+(?:to\s+)?origin\b/.test(clause));
212
+ const runtimeOperation = actionable.some((clause) => /\b(?:restart|redeploy|deploy|khoi dong lai|trien khai)\b/.test(clause)
213
+ && !/\.[cm]?[jt]sx?\b/.test(clause) && !/\bgit\b/.test(clause));
214
+ const nativeDomain = cronOperation ? 'scheduled-job' : gitOperation !== runtimeOperation ? (gitOperation ? 'git-integrity' : 'service-runtime') : null;
215
+ const operation = Boolean(nativeDomain);
216
+ // A config MENTION is not a config change: the same clause needs a mutation verb, and a
217
+ // request that also changes source/code is a coding task (config is part of its tests/fixtures).
218
+ const config = !sourceOrder && actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause)
219
+ && /\b(?:set|change|toggle|enable|disable|switch|turn|configure|modify|update|edit|doi|bat|tat|chinh)\b/.test(clause));
181
220
  const repository = Boolean(targetFile) || /[\w.-]+\.(?:json|ya?ml|toml|ini)\b/.test(text);
182
- const readOnly = /\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text)
183
- && !sourceOrder && !operation && !config;
184
- const taskKind = operation ? (sourceOrder ? 'MIXED' : 'OPERATIONAL')
221
+ // A status/result lookup ("check the suite output and report") orders no change: it needs a
222
+ // lookup verb AND no mutation vocabulary anywhere in the request (fail-closed otherwise).
223
+ const mutationWord = /\b(?:implement|fix|refactor|modify|edit|write|update|add|create|build|make|install|deploy|remove|delete|rename|replace|change|sua|viet|chinh|them|tao|xoa|doi|cai|cap nhat)\b/.test(text);
224
+ const statusReport = !mutationWord && !operation && !config
225
+ && /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua)\b/.test(text);
226
+ const readOnly = (/\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text) || statusReport)
227
+ && !sourceOrder && !codeChangeVerb && !operation && !config;
228
+ const taskKind = operation ? (sourceOrder || (nativeDomain !== 'scheduled-job' && codeChangeVerb) ? 'MIXED' : 'OPERATIONAL')
185
229
  : config ? 'CONFIGURATION' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
186
230
  const id = text.match(/\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/)?.[0] || null;
187
- return { taskKind, ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
188
- ...(operation ? { operationGoal: { domain: 'scheduled-job', target: id, desiredState: /\b(?:delete|remove|cancel|xoa|huy|absent|removed|deleted|no longer|khong con)\b/.test(actionable.join(' ')) ? 'absent' : 'present',
231
+ return { taskKind, ...(taskKind === 'READ_ONLY' && statusReport ? { statusReport: true } : {}), ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
232
+ ...(operation && nativeDomain !== 'scheduled-job' ? { operationGoal: nativeOperationGoal(nativeDomain, actionable, text) } : {}),
233
+ ...(operation && nativeDomain === 'scheduled-job' ? { operationGoal: { domain: 'scheduled-job', target: id, desiredState: /\b(?:delete|remove|cancel|xoa|huy|absent|removed|deleted|no longer|khong con)\b/.test(actionable.join(' ')) ? 'absent' : 'present',
189
234
  completionSemantics: /\b(?:ensure|dam bao)\b/.test(text) ? 'ensure-state' : 'execute-and-verify' } } : {}) };
190
235
  }
191
236
 
@@ -196,5 +241,6 @@ export function applyTaskSemantics(contract, semantic) {
196
241
  return { ...(contract || {}), ...semantic,
197
242
  completionEvidence: operationOnly ? ['operation-verification']
198
243
  : semantic.taskKind === 'MIXED' ? [...new Set([...evidence, 'write-evidence', 'operation-verification'])]
244
+ : semantic.taskKind === 'READ_ONLY' && semantic.statusReport ? []
199
245
  : semantic.taskKind === 'READ_ONLY' ? (evidence.length === 1 && evidence[0] === 'verification-evidence' ? evidence : ['impact-evidence']) : evidence };
200
246
  }
@@ -68,6 +68,7 @@ export async function writeInstallMetadata({
68
68
  projectRoot,
69
69
  managedRelativePaths = null,
70
70
  retainedManagedRelativePaths = [],
71
+ ompConfigOwnership = null,
71
72
  lockStaleMs,
72
73
  lockMaxWaitMs,
73
74
  }) {
@@ -133,6 +134,10 @@ export async function writeInstallMetadata({
133
134
  summary,
134
135
  files: allFiles,
135
136
  };
137
+ // Ownership record for .omp/config.yml (what UKit installed vs what was there
138
+ // before) — lets uninstall revert only UKit's changes. Kept across runs.
139
+ const ownership = ompConfigOwnership ?? (previousData?.tool === 'ukit' ? previousData.ompConfigOwnership : null);
140
+ if (ownership) metadata.ompConfigOwnership = ownership;
136
141
 
137
142
  await ensureDir(path.dirname(installMetaPath));
138
143
  await writeJson(installMetaPath, metadata);
@@ -21,6 +21,8 @@ function emptyReport() {
21
21
  coercedPromptPatterns: [],
22
22
  preservedModelRoles: false,
23
23
  addedModelRoleKeys: [],
24
+ preservedCompactionKeys: [],
25
+ migratedCompactionKeys: [],
24
26
  preservedTopLevelKeys: [],
25
27
  preservedPatterns: [],
26
28
  // TASK-002 / FR-002 — every `{{token}}` left in the merged output. A token
@@ -203,6 +205,64 @@ export function mergeOmpConfig(existingText, renderedText) {
203
205
  }
204
206
  }
205
207
 
208
+ // compaction per-key union: existing keys always win (user overrides, thresholdPercent,
209
+ // unknown keys). The legacy 350000 migration needs ownership proof and lives in
210
+ // ompProjectConfig.js (applied before this merge). Absent keys come from the defaults.
211
+ const renderedCompaction = extractScalarMapSpan(lines, 'compaction');
212
+ if (renderedCompaction && isPlainObject(existing.compaction) && isPlainObject(rendered?.compaction)) {
213
+ const merged = { ...rendered.compaction };
214
+ for (const [key, value] of Object.entries(existing.compaction)) {
215
+ // A non-scalar/unresolved-token value is never a deliberate user choice.
216
+ if (value !== null && typeof value === 'object' && key in merged) continue;
217
+ merged[key] = value;
218
+ if (key in rendered.compaction && value !== rendered.compaction[key]) {
219
+ report.preservedCompactionKeys.push(key);
220
+ }
221
+ }
222
+ const renderedKeys = Object.keys(rendered.compaction);
223
+ const mergedKeys = Object.keys(merged);
224
+ const identical = mergedKeys.length === renderedKeys.length
225
+ && mergedKeys.every((k) => merged[k] === rendered.compaction[k]);
226
+ if (!identical) {
227
+ const fragment = YAML.stringify(merged, { indent: 2 })
228
+ .trimEnd()
229
+ .split('\n')
230
+ .map((line) => ` ${line}`);
231
+ lines.splice(renderedCompaction.start, renderedCompaction.end - renderedCompaction.start, 'compaction:', ...fragment);
232
+ }
233
+ }
234
+
235
+ // task per-key union: the only UKit-shipped task key is the per-agent compaction override
236
+ // map. User task keys (effort, isolation, …) and user-set agent entries win verbatim;
237
+ // agents UKit ships but the file lacks are added. A non-map user `task` value is kept.
238
+ const renderedTask = extractScalarMapSpan(lines, 'task');
239
+ if (renderedTask && isPlainObject(rendered?.task) && existing.task !== undefined) {
240
+ const baseTask = rendered.task;
241
+ const userTask = existing.task === null ? {} : existing.task;
242
+ let merged;
243
+ if (isPlainObject(userTask)) {
244
+ merged = { ...userTask };
245
+ for (const k of Object.keys(baseTask)) if (!(k in merged)) merged[k] = baseTask[k];
246
+ const userOverrides = userTask.agentCompactionThresholdOverrides;
247
+ if (userOverrides !== undefined) {
248
+ merged.agentCompactionThresholdOverrides = isPlainObject(userOverrides)
249
+ ? { ...userOverrides, ...Object.fromEntries(Object.entries(baseTask.agentCompactionThresholdOverrides ?? {}).filter(([k]) => !(k in userOverrides))) }
250
+ : userOverrides === null
251
+ ? { ...(baseTask.agentCompactionThresholdOverrides ?? {}) }
252
+ : userOverrides;
253
+ }
254
+ } else {
255
+ merged = userTask;
256
+ }
257
+ if (JSON.stringify(merged) !== JSON.stringify(baseTask)) {
258
+ const fragment = YAML.stringify(merged, { indent: 2 })
259
+ .trimEnd()
260
+ .split('\n')
261
+ .map((line) => ` ${line}`);
262
+ lines.splice(renderedTask.start, renderedTask.end - renderedTask.start, 'task:', ...fragment);
263
+ }
264
+ }
265
+
206
266
  // §7.2 splice — append preserved allow/deny keys to the rendered
207
267
  // tools.approval map (before the first line that leaves the map's indent).
208
268
  if (extraApprovalEntries.length > 0) {