@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.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,13 @@ 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.33 - 2026-10-11
13
+
14
+ **OMP token optimization, adaptive task decomposition and Completion Gate reliability.** Verified with the full suite and `yarn release:verify`.
15
+ - **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).
16
+ - **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.
17
+ - **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.
18
+
12
19
  ## 3.4.32 - 2026-10-11
13
20
 
14
21
  **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.33",
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
 
@@ -179,12 +179,17 @@ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {})
179
179
  && /\bcron\b|scheduled\s+(?:cron\s+)?(?:task|job)|tac vu\s+(?:cron|dinh ky)/.test(clause));
180
180
  const config = actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause));
181
181
  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)
182
+ // A status/result lookup ("check the suite output and report") orders no change: it needs a
183
+ // lookup verb AND no mutation vocabulary anywhere in the request (fail-closed otherwise).
184
+ 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);
185
+ const statusReport = !mutationWord && !operation && !config
186
+ && /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua)\b/.test(text);
187
+ const readOnly = (/\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text) || statusReport)
183
188
  && !sourceOrder && !operation && !config;
184
189
  const taskKind = operation ? (sourceOrder ? 'MIXED' : 'OPERATIONAL')
185
190
  : config ? 'CONFIGURATION' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
186
191
  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' } : {}),
192
+ return { taskKind, ...(taskKind === 'READ_ONLY' && statusReport ? { statusReport: true } : {}), ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
188
193
  ...(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',
189
194
  completionSemantics: /\b(?:ensure|dam bao)\b/.test(text) ? 'ensure-state' : 'execute-and-verify' } } : {}) };
190
195
  }
@@ -196,5 +201,6 @@ export function applyTaskSemantics(contract, semantic) {
196
201
  return { ...(contract || {}), ...semantic,
197
202
  completionEvidence: operationOnly ? ['operation-verification']
198
203
  : semantic.taskKind === 'MIXED' ? [...new Set([...evidence, 'write-evidence', 'operation-verification'])]
204
+ : semantic.taskKind === 'READ_ONLY' && semantic.statusReport ? []
199
205
  : semantic.taskKind === 'READ_ONLY' ? (evidence.length === 1 && evidence[0] === 'verification-evidence' ? evidence : ['impact-evidence']) : evidence };
200
206
  }
@@ -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) {