@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.
@@ -1799,16 +1799,27 @@ function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
1799
1799
  && !/^\s*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause));
1800
1800
  const sourceOrder = actionable.some((clause) => /\b(?:implement|fix|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
1801
1801
  && /\b(?:source|code|logic|scheduler|implementation|ma nguon)\b|\.[cm]?[jt]sx?\b/.test(clause));
1802
+ const codeChangeVerb = actionable.some((clause) => /\b(?:implement|fix|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause));
1803
+ const hasOperationId = /\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/.test(text);
1804
+ // A cron/job MENTION is not an operation: with a code-change verb in the request, an
1805
+ // operation needs a concrete target id; otherwise it is a coding task about scheduling.
1802
1806
  const operation = actionable.some((clause) => /\b(?:delete|remove|cancel|xoa|huy|ensure|dam bao)\b/.test(clause)
1803
- && /\bcron\b|scheduled\s+(?:cron\s+)?(?:task|job)|tac vu\s+(?:cron|dinh ky)/.test(clause));
1804
- const config = actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause));
1807
+ && /\bcron\b|scheduled\s+(?:cron\s+)?(?:task|job)|tac vu\s+(?:cron|dinh ky)/.test(clause))
1808
+ && (!codeChangeVerb || hasOperationId);
1809
+ const config = !sourceOrder && actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause)
1810
+ && /\b(?:set|change|toggle|enable|disable|switch|turn|configure|modify|update|edit|doi|bat|tat|chinh)\b/.test(clause));
1805
1811
  const repository = Boolean(targetFile) || /[\w.-]+\.(?:json|ya?ml|toml|ini)\b/.test(text);
1806
- const readOnly = /\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text)
1807
- && !sourceOrder && !operation && !config;
1812
+ // A status/result lookup ("check the suite output and report") orders no change: it needs a
1813
+ // lookup verb AND no mutation vocabulary anywhere in the request (fail-closed otherwise).
1814
+ 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);
1815
+ const statusReport = !mutationWord && !operation && !config
1816
+ && /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua)\b/.test(text);
1817
+ const readOnly = (/\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text) || statusReport)
1818
+ && !sourceOrder && !codeChangeVerb && !operation && !config;
1808
1819
  const taskKind = operation ? (sourceOrder ? 'MIXED' : 'OPERATIONAL')
1809
1820
  : config ? 'CONFIGURATION' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
1810
1821
  const id = text.match(/\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/)?.[0] || null;
1811
- return { taskKind, ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
1822
+ return { taskKind, ...(taskKind === 'READ_ONLY' && statusReport ? { statusReport: true } : {}), ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
1812
1823
  ...(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',
1813
1824
  completionSemantics: /\b(?:ensure|dam bao)\b/.test(text) ? 'ensure-state' : 'execute-and-verify' } } : {}) };
1814
1825
  }
@@ -1820,6 +1831,7 @@ function applyTaskSemantics(contract, semantic) {
1820
1831
  return { ...(contract || {}), ...semantic,
1821
1832
  completionEvidence: operationOnly ? ['operation-verification']
1822
1833
  : semantic.taskKind === 'MIXED' ? [...new Set([...evidence, 'write-evidence', 'operation-verification'])]
1834
+ : semantic.taskKind === 'READ_ONLY' && semantic.statusReport ? []
1823
1835
  : semantic.taskKind === 'READ_ONLY' ? (evidence.length === 1 && evidence[0] === 'verification-evidence' ? evidence : ['impact-evidence']) : evidence };
1824
1836
  }
1825
1837
 
@@ -3626,17 +3638,48 @@ function applyTaskSemantics(contract, semantic) {
3626
3638
  persisted.routingContext ||= {};
3627
3639
  persisted.routingContext.lastExplicitUserPromptText = previous.routingContext.lastExplicitUserPromptText;
3628
3640
  }
3641
+ if (continuingTask && !persisted.suspendedContract && previous.suspendedContract) {
3642
+ persisted.suspendedContract = previous.suspendedContract;
3643
+ }
3629
3644
  const semantic = continuingTask && previous.routeSummary?.executionContract?.taskKind
3630
3645
  ? previous.routeSummary.executionContract
3631
3646
  : classifyTaskSemantics({ promptText: extractPrompt(payload) || persisted.routingContext?.lastExplicitUserPromptText, targetFile: persisted.routingContext?.targetFile });
3632
3647
  const informationalTurn = !continuingTask && persisted.routingContext?.executionMode === 'informational'
3633
3648
  && semantic.taskKind === 'CODE_IMPLEMENTATION';
3634
3649
  if (informationalTurn) {
3650
+ // An answer-only turn must not enforce carried write debt, but it must not ERASE the
3651
+ // contract either: park it (session- and task-bound) so a continuation or the next
3652
+ // tool event of the same session restores it instead of finding "no contract routed".
3653
+ const parked = previous.routeSummary?.executionContract?.completionEvidence?.length
3654
+ ? { sessionId, taskInstanceId: previous.taskInstanceId, requestKey: previous.requestKey,
3655
+ routeSummary: previous.routeSummary, routingContext: previous.routingContext, ts: Date.now() }
3656
+ : previous.suspendedContract;
3657
+ if (parked) persisted.suspendedContract = parked;
3635
3658
  delete persisted.routeSummary;
3636
3659
  delete persisted.requestKey;
3637
- } else if (semantic.taskKind !== 'CODE_IMPLEMENTATION' || persisted.routeSummary) {
3660
+ } else if (semantic.statusReport && !continuingTask) {
3661
+ // A status/result lookup is a read-only turn with its own task id: it carries no write
3662
+ // debt, but it must not erase a running implementation's contract — park it for `continue`.
3663
+ const prior = previous.routeSummary?.executionContract;
3664
+ const parked = prior && !prior.statusReport && prior.completionEvidence?.length
3665
+ ? { sessionId, taskInstanceId: previous.taskInstanceId, requestKey: previous.requestKey,
3666
+ routeSummary: previous.routeSummary, routingContext: previous.routingContext, ts: Date.now() }
3667
+ : previous.suspendedContract;
3668
+ if (parked) persisted.suspendedContract = parked;
3638
3669
  persisted.routeSummary ||= { executionMode: 'local-build' };
3639
3670
  persisted.routeSummary.executionContract = applyTaskSemantics(
3671
+ persisted.routeSummary.executionContract || buildExecutionContract('local-build'), semantic);
3672
+ delete persisted.routeSummary.completionState;
3673
+ delete persisted.routeSummary.continuationState;
3674
+ } else if (semantic.taskKind !== 'CODE_IMPLEMENTATION' || persisted.routeSummary) {
3675
+ persisted.routeSummary ||= { executionMode: 'local-build' };
3676
+ // A carried status-report contract must not leak its empty evidence / flag onto a
3677
+ // turn that is not itself a status lookup: rebuild the base contract instead.
3678
+ const carriedStatus = persisted.routeSummary.executionContract?.statusReport === true && !semantic.statusReport;
3679
+ if (carriedStatus && persisted.routingContext?.mutationOrder === true) {
3680
+ delete persisted.routeSummary.executionContract;
3681
+ }
3682
+ if (!carriedStatus || persisted.routingContext?.mutationOrder === true) persisted.routeSummary.executionContract = applyTaskSemantics(
3640
3683
  persisted.routeSummary.executionContract || buildExecutionContract(persisted.routeSummary.executionMode === 'informational' ? 'local-build' : persisted.routeSummary.executionMode), semantic);
3641
3684
  if (semantic.taskKind === 'OPERATIONAL' || semantic.taskKind === 'READ_ONLY'
3642
3685
  || (semantic.taskKind === 'CONFIGURATION' && semantic.configurationScope === 'external')) {
@@ -3647,6 +3690,12 @@ function applyTaskSemantics(contract, semantic) {
3647
3690
  persisted.routeSummary.continuationState = buildContinuationState({ completionState: persisted.routeSummary.completionState });
3648
3691
  }
3649
3692
  }
3693
+ // Keep a parked implementation contract alive across further answer/status turns of the
3694
+ // same session (previous is already session-scoped); a fresh implementation route drops it.
3695
+ if (!persisted.suspendedContract && previous.suspendedContract
3696
+ && (!persisted.routeSummary || persisted.routeSummary.executionContract?.statusReport)) {
3697
+ persisted.suspendedContract = previous.suspendedContract;
3698
+ }
3650
3699
  text = JSON.stringify(persisted);
3651
3700
  await writeJsonFileAtomic(statePath, text);
3652
3701
  if (!sessionId) return;
@@ -3668,11 +3717,22 @@ function applyTaskSemantics(contract, semantic) {
3668
3717
  const sidecarPrevious = sessionId && String(storedPrevious?.sessionId || '').trim() !== sessionId
3669
3718
  ? await readJson(path.join(projectRoot, '.ukit', 'storage', 'cache', 'route-state', `${sessionId.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96) || 'default'}.json`), {})
3670
3719
  : {};
3671
- const previous = sessionId
3720
+ let previous = sessionId
3672
3721
  ? [storedPrevious, sidecarPrevious].find((state) => String(state?.sessionId || '').trim() === sessionId) || {}
3673
3722
  : storedPrevious;
3674
3723
  const continuationPrompt = /^(?:continue|resume|keep going|proceed|tiep tuc)(?:\s+(?:the task|this task|previous task))?[.!]?$/i.test(
3675
3724
  extractPrompt(payload).normalize('NFD').replace(/[̀-ͯ]/g, '').trim());
3725
+ // Restore a contract parked by an informational turn: only for the SAME session, and only
3726
+ // on an explicit continuation or a mutating tool event (never a fresh prompt, which routes on its own,
3727
+ // and never a read during an answer/status turn: that must not resurrect write debt).
3728
+ const parkedContract = previous.suspendedContract;
3729
+ if ((!previous.routeSummary || previous.routeSummary?.executionContract?.statusReport) && parkedContract?.routeSummary && parkedContract.taskInstanceId
3730
+ && sessionId && parkedContract.sessionId === sessionId
3731
+ && (continuationPrompt || /^(?:Edit|Write|MultiEdit|NotebookEdit)$/.test(String(payload.tool_name || '')))) {
3732
+ previous = { ...previous, taskInstanceId: parkedContract.taskInstanceId, routeSummary: parkedContract.routeSummary,
3733
+ requestKey: parkedContract.requestKey, routingContext: parkedContract.routingContext || previous.routingContext };
3734
+ delete previous.suspendedContract;
3735
+ }
3676
3736
  const continuingTask = Boolean(previous.taskInstanceId && (payload.tool_name || continuationPrompt));
3677
3737
  const taskInstanceId = continuingTask
3678
3738
  ? previous.taskInstanceId
@@ -1071,16 +1071,29 @@ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {})
1071
1071
  && !/^\s*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause));
1072
1072
  const sourceOrder = actionable.some((clause) => /\b(?:implement|fix|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
1073
1073
  && /\b(?:source|code|logic|scheduler|implementation|ma nguon)\b|\.[cm]?[jt]sx?\b/.test(clause));
1074
+ const codeChangeVerb = actionable.some((clause) => /\b(?:implement|fix|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause));
1075
+ const hasOperationId = /\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/.test(text);
1076
+ // A cron/job MENTION is not an operation: with a code-change verb in the request, an
1077
+ // operation needs a concrete target id; otherwise it is a coding task about scheduling.
1074
1078
  const operation = actionable.some((clause) => /\b(?:delete|remove|cancel|xoa|huy|ensure|dam bao)\b/.test(clause)
1075
- && /\bcron\b|scheduled\s+(?:cron\s+)?(?:task|job)|tac vu\s+(?:cron|dinh ky)/.test(clause));
1076
- const config = actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause));
1079
+ && /\bcron\b|scheduled\s+(?:cron\s+)?(?:task|job)|tac vu\s+(?:cron|dinh ky)/.test(clause))
1080
+ && (!codeChangeVerb || hasOperationId);
1081
+ // A config MENTION is not a config change: the same clause needs a mutation verb, and a
1082
+ // request that also changes source/code is a coding task (config is part of its tests/fixtures).
1083
+ const config = !sourceOrder && actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause)
1084
+ && /\b(?:set|change|toggle|enable|disable|switch|turn|configure|modify|update|edit|doi|bat|tat|chinh)\b/.test(clause));
1077
1085
  const repository = Boolean(targetFile) || /[\w.-]+\.(?:json|ya?ml|toml|ini)\b/.test(text);
1078
- const readOnly = /\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text)
1079
- && !sourceOrder && !operation && !config;
1086
+ // A status/result lookup ("check the suite output and report") orders no change: it needs a
1087
+ // lookup verb AND no mutation vocabulary anywhere in the request (fail-closed otherwise).
1088
+ 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);
1089
+ const statusReport = !mutationWord && !operation && !config
1090
+ && /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua)\b/.test(text);
1091
+ const readOnly = (/\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text) || statusReport)
1092
+ && !sourceOrder && !codeChangeVerb && !operation && !config;
1080
1093
  const taskKind = operation ? (sourceOrder ? 'MIXED' : 'OPERATIONAL')
1081
1094
  : config ? 'CONFIGURATION' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
1082
1095
  const id = text.match(/\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/)?.[0] || null;
1083
- return { taskKind, ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
1096
+ return { taskKind, ...(taskKind === 'READ_ONLY' && statusReport ? { statusReport: true } : {}), ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
1084
1097
  ...(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',
1085
1098
  completionSemantics: /\b(?:ensure|dam bao)\b/.test(text) ? 'ensure-state' : 'execute-and-verify' } } : {}) };
1086
1099
  }
@@ -1092,5 +1105,6 @@ export function applyTaskSemantics(contract, semantic) {
1092
1105
  return { ...(contract || {}), ...semantic,
1093
1106
  completionEvidence: operationOnly ? ['operation-verification']
1094
1107
  : semantic.taskKind === 'MIXED' ? [...new Set([...evidence, 'write-evidence', 'operation-verification'])]
1108
+ : semantic.taskKind === 'READ_ONLY' && semantic.statusReport ? []
1095
1109
  : semantic.taskKind === 'READ_ONLY' ? (evidence.length === 1 && evidence[0] === 'verification-evidence' ? evidence : ['impact-evidence']) : evidence };
1096
1110
  }
@@ -73,6 +73,7 @@ import {
73
73
  import { applyMeasuredTier, loadTierMap } from './tier-selection.mjs';
74
74
  import { resolvePlaybookRoute, loadRegistry, deriveRouteOrchestrationSignals } from './playbook-registry.mjs';
75
75
  import { extractHistorySignalsAsync } from './session-history-extractor.mjs';
76
+ import { carryProgress, buildDecompositionPlan, estimateContextWindow, MIN_AUTO_SPLIT_CONFIDENCE } from '../runtime/context-budget-policy.mjs';
76
77
  import {
77
78
  applyFixLoopEscalation,
78
79
  isFixLoopEscalationDue,
@@ -1060,6 +1061,7 @@ export async function routeTask(input = {}, { signal = null, deadlineMs = null,
1060
1061
  preparedRoute,
1061
1062
  projectVerification,
1062
1063
  cachedContextResult: helperCacheSnapshot.contextResult,
1064
+ transcriptPath: normalizedInput.transcriptPath ?? process.env.UKIT_TRANSCRIPT_PATH ?? null,
1063
1065
  cachedVerificationPlan: helperCacheSnapshot.verificationPlan,
1064
1066
  useIndexedContext,
1065
1067
  previousContextSnapshot,
@@ -1390,6 +1392,7 @@ async function main() {
1390
1392
  commandNamespace,
1391
1393
  preparedRoute,
1392
1394
  projectVerification,
1395
+ transcriptPath,
1393
1396
  cachedContextResult: helperCacheSnapshot.contextResult,
1394
1397
  cachedVerificationPlan: helperCacheSnapshot.verificationPlan,
1395
1398
  useIndexedContext,
@@ -2116,12 +2119,26 @@ function resumableTaskId({ sessionId = null, taskBoundary }) {
2116
2119
  // (hypotheses/decisions/failures) and completed receipts carry forward; a new
2117
2120
  // boundary starts a fresh record. The phase that just completed is appended to
2118
2121
  // completedBlocks when it changes.
2122
+ // The adaptive-decomposition plan rides the record as ONE `decisions` entry
2123
+ // (type 'decomposition'), replaced in place so replans never accumulate. Loop
2124
+ // state (depth/attempts/seenFingerprints) lives in it, so a re-route of the same
2125
+ // task reads it back and shouldDecompose refuses an unchanged split.
2126
+ export function findDecompositionDecision(decisions) {
2127
+ return (Array.isArray(decisions) ? decisions : []).find((d) => d && typeof d === 'object' && d.type === 'decomposition') ?? null;
2128
+ }
2129
+ function mergeDecompositionDecision(decisions, decomposition) {
2130
+ if (!decomposition?.decompose) return decisions;
2131
+ const rest = decisions.filter((d) => !(d && typeof d === 'object' && d.type === 'decomposition'));
2132
+ return [...rest, { type: 'decomposition', ...decomposition }].slice(-12);
2133
+ }
2134
+
2119
2135
  function buildResumableRunRecord({
2120
2136
  taskId,
2121
2137
  taskBoundary,
2122
2138
  routeSummary,
2123
2139
  previousRecord = null,
2124
2140
  sourceSnapshot,
2141
+ decomposition = null,
2125
2142
  }) {
2126
2143
  const previous = previousRecord && previousRecord.taskBoundary === taskBoundary
2127
2144
  ? previousRecord
@@ -2162,7 +2179,7 @@ function buildResumableRunRecord({
2162
2179
  },
2163
2180
  invariants: previous?.invariants ?? [],
2164
2181
  hypotheses: previous?.hypotheses ?? [],
2165
- decisions: previous?.decisions ?? [],
2182
+ decisions: mergeDecompositionDecision(previous?.decisions ?? [], decomposition),
2166
2183
  evidenceRefs: previous?.evidenceRefs ?? [],
2167
2184
  escalationHistory: previous?.escalationHistory ?? [],
2168
2185
  unresolvedFailures: previous?.unresolvedFailures ?? [],
@@ -2186,6 +2203,7 @@ export async function emitResumableRun({
2186
2203
  projectVerification = null,
2187
2204
  signal = null,
2188
2205
  deadlineMs = null,
2206
+ decomposition = null,
2189
2207
  } = {}) {
2190
2208
  const stage = resolveResumableRunStage(config);
2191
2209
  if (stage === 'off') return null;
@@ -2212,6 +2230,7 @@ export async function emitResumableRun({
2212
2230
  routeSummary,
2213
2231
  previousRecord,
2214
2232
  sourceSnapshot,
2233
+ decomposition,
2215
2234
  });
2216
2235
  const write = await writeResumableRun(rootDir, record, {
2217
2236
  signal,
@@ -2251,6 +2270,63 @@ export async function emitResumableRun({
2251
2270
  }
2252
2271
  }
2253
2272
 
2273
+ // Runtime caller of the decomposition policy. Context size = newest provider usage in the
2274
+ // transcript tail (high confidence); bytes/4 is only a LOW-confidence fallback that never
2275
+ // auto-splits (no LLM, no tokenizer). Runs only when the stage is on and a transcript
2276
+ // pointer exists; returns null otherwise. Pricing metadata comes from
2277
+ // config.contextBudget.pricing when present — without it the 100K boundary is still
2278
+ // used as the planning threshold (shouldDecompose default) but nothing is claimed
2279
+ // about billing. Never throws.
2280
+ export async function planAdaptiveDecomposition({ rootDir, config, routeSummary, routingContext, contextRecommendation, transcriptPath, sessionId }) {
2281
+ try {
2282
+ if (resolveResumableRunStage(config) === 'off' || routingContext?.greetingFastPath || !transcriptPath) return null;
2283
+ const st = await fs.stat(transcriptPath).catch(() => null);
2284
+ if (!st?.isFile()) return null;
2285
+ const preview = contextRecommendation?.preview ?? contextRecommendation ?? {};
2286
+ const primaryTargets = (preview.primaryTargets ?? []).filter((f) => typeof f === 'string').slice(0, 24);
2287
+ const relatedTests = (preview.relatedTests ?? []).filter((f) => typeof f === 'string').slice(0, 24);
2288
+ const fileTokens = {};
2289
+ for (const f of [...primaryTargets, ...relatedTests]) {
2290
+ const fst = await fs.stat(path.resolve(rootDir, f)).catch(() => null);
2291
+ if (fst?.isFile()) fileTokens[f] = Math.ceil(fst.size / 4);
2292
+ }
2293
+ let tailLines = [];
2294
+ try {
2295
+ const fh = await fs.open(transcriptPath, 'r');
2296
+ try {
2297
+ const len = Math.min(st.size, 512 * 1024);
2298
+ const buf = Buffer.alloc(len);
2299
+ await fh.read(buf, 0, len, st.size - len);
2300
+ tailLines = buf.toString('utf8').split('\n').filter(Boolean).slice(-200);
2301
+ } finally { await fh.close(); }
2302
+ } catch { /* fall back to the byte estimate */ }
2303
+ const estimate = estimateContextWindow({ tailLines, transcriptBytes: st.size });
2304
+ const taskBoundary = resumableTaskBoundary(routingContext);
2305
+ const taskId = resumableTaskId({ sessionId, taskBoundary });
2306
+ const resumed = await readResumableRun(rootDir, { taskId, taskBoundary });
2307
+ const previous = findDecompositionDecision(resumed.record?.decisions);
2308
+ const sharedFiles = new Set(primaryTargets).size < primaryTargets.length;
2309
+ const plan = buildDecompositionPlan({
2310
+ parentId: taskId,
2311
+ goal: String(routingContext?.lastExplicitUserPromptText ?? routingContext?.promptText ?? '').slice(0, 200),
2312
+ contextTokens: MIN_AUTO_SPLIT_CONFIDENCE.has(estimate.confidence) ? estimate.tokens : null,
2313
+ primaryTargets,
2314
+ relatedTests,
2315
+ fileTokens,
2316
+ acceptance: routeSummary?.executionContract?.completionEvidence ?? [],
2317
+ coupled: sharedFiles || primaryTargets.length < 2,
2318
+ pricing: config?.contextBudget?.pricing ?? null,
2319
+ previous,
2320
+ });
2321
+ if (!plan.decompose && plan.reason === 'unknown-size') {
2322
+ return { ...plan, reason: `low-confidence-estimate:${estimate.source}`, estimatedTokens: estimate.tokens };
2323
+ }
2324
+ return carryProgress(previous, { ...plan, estimateSource: estimate.source, estimateConfidence: estimate.confidence });
2325
+ } catch {
2326
+ return null;
2327
+ }
2328
+ }
2329
+
2254
2330
  async function finalizeTaskRoute({
2255
2331
  rootDir = process.cwd(),
2256
2332
  commandNamespace = '.claude',
@@ -2275,6 +2351,7 @@ async function finalizeTaskRoute({
2275
2351
  // BL-013: precomputed bounded history struct from the caller's transcript
2276
2352
  // extraction — merged into the route record additively + decisions.tsv.
2277
2353
  historySignals = null,
2354
+ transcriptPath = null,
2278
2355
  } = {}) {
2279
2356
  const absoluteRoot = path.resolve(rootDir);
2280
2357
  const activeSkills = preparedRoute?.activeSkills ?? [];
@@ -2537,7 +2614,20 @@ async function finalizeTaskRoute({
2537
2614
  // persisted/updated under .ukit/storage/runs/ and a bounded pointer lands on
2538
2615
  // the execution ledger. The pointer rides routeSummary.resumableRun so the
2539
2616
  // shared route state carries the taskId reinject-context resumes from.
2617
+ const decomposition = await planAdaptiveDecomposition({
2618
+ rootDir: absoluteRoot,
2619
+ config: escalationConfig,
2620
+ routeSummary,
2621
+ routingContext,
2622
+ contextRecommendation,
2623
+ transcriptPath,
2624
+ sessionId,
2625
+ });
2626
+ if (decomposition?.decompose) {
2627
+ routeSummary.decompositionPlan = decomposition;
2628
+ }
2540
2629
  const resumableRun = await emitResumableRun({
2630
+ decomposition,
2541
2631
  rootDir: absoluteRoot,
2542
2632
  config: escalationConfig,
2543
2633
  routeSummary,
@@ -2593,6 +2683,12 @@ function printRouteState(state) {
2593
2683
  );
2594
2684
  }
2595
2685
  console.log(`summary: ${compactSummary}`);
2686
+ const plan = state.routeSummary?.decompositionPlan;
2687
+ if (plan?.decompose) {
2688
+ console.log(`plan: decomposed into ${plan.tasks.length} steps (est ${plan.estimatedTokens} tok, savings ${plan.costs?.savingsRatio}) — dispatch each scoped subtask to a subagent with only its files + handoff, then integrate`);
2689
+ const doneIds = new Set(plan.done ?? []);
2690
+ for (const t of plan.tasks) console.log(` - ${t.id}: ${t.files.join(', ') || 'integration'} deps=[${t.deps.join(',')}]${doneIds.has(t.id) ? ' DONE' : ''} — start the Agent prompt with "[ukit-subtask ${t.id}]"`);
2691
+ }
2596
2692
  if (state.routeSummary?.executionContract?.modelTier) {
2597
2693
  console.log(`modelTier: ${state.routeSummary.executionContract.modelTier}`);
2598
2694
  }
@@ -0,0 +1,309 @@
1
+ // Context budget + adaptive decomposition policy — PURE decision functions, no fs,
2
+ // no LLM call. Advisory only: nothing here truncates context or blocks a tool call.
3
+ //
4
+ // Soft budgets are planning targets per role. The 100K figure is a COST BOUNDARY
5
+ // that only applies when pricing metadata says the model has a tier above it;
6
+ // with no metadata the boundary is `unknown` and is never assumed to be a cliff.
7
+ import crypto from 'node:crypto';
8
+ import { estimateTokenCount } from './token-utils.mjs';
9
+
10
+ export const COST_BOUNDARY_TOKENS = 100_000;
11
+
12
+ export const ROLE_SOFT_BUDGETS = Object.freeze({
13
+ 'simple-subagent': 40_000,
14
+ subagent: 60_000,
15
+ 'implement-subagent': 80_000,
16
+ 'review-subagent': 80_000,
17
+ orchestrator: 90_000,
18
+ });
19
+
20
+ export const DECOMPOSITION_LIMITS = Object.freeze({ maxDepth: 2, maxAttempts: 2, minSavingsRatio: 0.15 });
21
+
22
+ const num = (v) => (Number.isFinite(v) && v >= 0 ? v : null);
23
+
24
+ /**
25
+ * Uncached (fresh) vs cached input are separate: cache reads are not counted
26
+ * against the boundary's fresh-input pressure, but the prompt window still is.
27
+ */
28
+ export function summarizeUsage({ inputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0 } = {}) {
29
+ const input = num(inputTokens) ?? 0;
30
+ const read = num(cacheReadTokens) ?? 0;
31
+ const write = num(cacheWriteTokens) ?? 0;
32
+ return { promptTokens: input + read + write, uncachedTokens: input + write, cachedTokens: read };
33
+ }
34
+
35
+ /** Soft-budget status for a role. Never a hard limit. */
36
+ export function evaluateContextBudget({ role = 'orchestrator', promptTokens, pricing = null } = {}) {
37
+ const tokens = num(promptTokens);
38
+ const softBudget = ROLE_SOFT_BUDGETS[role] ?? ROLE_SOFT_BUDGETS.subagent;
39
+ if (tokens === null) return { status: 'unknown', softBudget, boundary: 'unknown', hardLimit: false };
40
+ const cliff = num(pricing?.longContextThresholdTokens);
41
+ const boundary = cliff === null ? 'unknown' : cliff;
42
+ const overBoundary = cliff !== null && tokens > cliff;
43
+ let status = 'ok';
44
+ if (overBoundary) status = 'over-cost-boundary';
45
+ else if (tokens > softBudget) status = 'over-soft-budget';
46
+ else if (tokens > softBudget * 0.8) status = 'near-soft-budget';
47
+ return { status, softBudget, boundary, overBoundary, hardLimit: false };
48
+ }
49
+
50
+ function fingerprintOf(parentId, subtasks) {
51
+ const body = subtasks.map((s) => [s.id, [...(s.files ?? [])].sort(), [...(s.deps ?? [])].sort()]);
52
+ return crypto.createHash('sha256').update(JSON.stringify([parentId, body])).digest('hex').slice(0, 16);
53
+ }
54
+
55
+ /**
56
+ * Decide whether an at-risk task should be split. Inputs are plain data from the
57
+ * existing plan/ledger; `state` carries {depth, attempts, seenFingerprints}.
58
+ * Returns {decompose, reason, fingerprint?, costs?}; fallback is always the orchestrator.
59
+ */
60
+ export function shouldDecompose({
61
+ parentId = 'task',
62
+ estimatedTokens,
63
+ pricing = null,
64
+ subtasks = [],
65
+ coupled = false,
66
+ overheadPerSubtask = 4_000,
67
+ integrationTokens = 6_000,
68
+ reworkRisk = 0.1,
69
+ state = {},
70
+ } = {}) {
71
+ const limits = DECOMPOSITION_LIMITS;
72
+ const est = num(estimatedTokens);
73
+ if (est === null) return { decompose: false, reason: 'unknown-size' };
74
+ const cliff = num(pricing?.longContextThresholdTokens) ?? COST_BOUNDARY_TOKENS;
75
+ if (est <= cliff) return { decompose: false, reason: 'within-boundary' };
76
+ if ((state.depth ?? 0) >= limits.maxDepth) return { decompose: false, reason: 'depth-limit' };
77
+ if ((state.attempts ?? 0) >= limits.maxAttempts) return { decompose: false, reason: 'attempt-limit' };
78
+ if (coupled) return { decompose: false, reason: 'tightly-coupled' };
79
+ if (!Array.isArray(subtasks) || subtasks.length < 2) return { decompose: false, reason: 'no-clear-boundaries' };
80
+ if (subtasks.some((s) => !s?.id || num(s.scopeTokens) === null)) {
81
+ return { decompose: false, reason: 'incomplete-subtasks' };
82
+ }
83
+ const fingerprint = fingerprintOf(parentId, subtasks);
84
+ if ((state.seenFingerprints ?? []).includes(fingerprint)) {
85
+ return { decompose: false, reason: 'unchanged-decomposition', fingerprint };
86
+ }
87
+ const biggest = Math.max(...subtasks.map((s) => s.scopeTokens));
88
+ if (biggest > cliff) return { decompose: false, reason: 'subtask-still-over-boundary', fingerprint };
89
+ const decomposed =
90
+ subtasks.reduce((sum, s) => sum + s.scopeTokens + overheadPerSubtask, 0) + integrationTokens;
91
+ // A request above the boundary may bill the WHOLE prompt at the higher tier; the
92
+ // premium is a planning weight (pricing.longContextPremium, default 1.5), not a price.
93
+ const premium = num(pricing?.longContextPremium) ?? 1.5;
94
+ const original = est * premium;
95
+ const decomposedCost = decomposed * (1 + reworkRisk);
96
+ const savings = (original - decomposedCost) / original;
97
+ const costs = { original, decomposed: decomposedCost, savingsRatio: Number(savings.toFixed(3)) };
98
+ if (savings < limits.minSavingsRatio) return { decompose: false, reason: 'not-cost-effective', fingerprint, costs };
99
+ return { decompose: true, reason: 'cost-effective-split', fingerprint, costs };
100
+ }
101
+
102
+ /** Estimate a context's tokens with the shared estimator (no LLM, no tokenizer). */
103
+ export function estimateContextTokens(parts = []) {
104
+ return parts.reduce((sum, p) => sum + estimateTokenCount(p), 0);
105
+ }
106
+
107
+ const HANDOFF_TOKENS = 6_000;
108
+ const MAX_SUBTASKS = 4;
109
+
110
+ /**
111
+ * Turn the route's resolved targets into a task graph and run shouldDecompose on it.
112
+ * Pure: file sizes arrive as `fileTokens` ({path: tokens}); no fs, no LLM. The result
113
+ * is bounded plain data for the resumable-run record. Parent acceptance criteria are
114
+ * kept whole on the plan and checked in full by the integration step; each subtask
115
+ * only receives the criteria that concern its own files/tests (see splitAcceptance), so
116
+ * splitting can never drop a requirement nor bloat every handoff. Fallback is always "main orchestrator continues".
117
+ */
118
+ export function buildDecompositionPlan({
119
+ parentId = 'task',
120
+ goal = '',
121
+ contextTokens,
122
+ primaryTargets = [],
123
+ relatedTests = [],
124
+ fileTokens = {},
125
+ acceptance = [],
126
+ coupled = false,
127
+ pricing = null,
128
+ previous = null,
129
+ } = {}) {
130
+ const state = {
131
+ depth: previous?.depth ?? 0,
132
+ attempts: previous?.attempts ?? 0,
133
+ seenFingerprints: previous?.seenFingerprints ?? [],
134
+ };
135
+ const groups = new Map();
136
+ for (const file of primaryTargets) {
137
+ const parts = String(file).split('/');
138
+ const key = parts.length > 2 ? parts.slice(0, 2).join('/') : parts.length > 1 ? parts[0] : '.';
139
+ if (!groups.has(key)) groups.set(key, []);
140
+ groups.get(key).push(file);
141
+ }
142
+ const subtasks = [...groups.entries()].slice(0, MAX_SUBTASKS).map(([key, files], i) => {
143
+ const tests = relatedTests.filter((t) => files.some((f) => t.includes(String(f).split('/').pop().replace(/\.[^.]+$/, ''))));
144
+ const scopeTokens = [...files, ...tests].reduce((s, f) => s + (num(fileTokens[f]) ?? 2_000), 0) + HANDOFF_TOKENS;
145
+ return { id: `${parentId}.${i + 1}`, key, files, tests, deps: [], scopeTokens };
146
+ });
147
+ const decision = shouldDecompose({
148
+ parentId, estimatedTokens: contextTokens, pricing, subtasks, coupled, state,
149
+ });
150
+ if (!decision.decompose) {
151
+ return { decompose: false, reason: decision.reason, fingerprint: decision.fingerprint ?? null, estimatedTokens: num(contextTokens), depth: state.depth, attempts: state.attempts, seenFingerprints: state.seenFingerprints };
152
+ }
153
+ const accept = acceptance.slice(0, 8).map((a) => String(a).slice(0, 200));
154
+ const split = splitAcceptance(accept, subtasks);
155
+ const tasks = subtasks.map((s, i) => ({
156
+ id: s.id,
157
+ goal: `${String(goal).slice(0, 200)} [scope: ${s.key}]`.slice(0, 400),
158
+ files: s.files.slice(0, 12),
159
+ tests: s.tests.slice(0, 6),
160
+ deps: s.deps,
161
+ scopeTokens: s.scopeTokens,
162
+ acceptance: split.perTask[i],
163
+ handoff: `Scoped context only (${s.files.length} file(s)); return changed files + verification result, not history.`,
164
+ }));
165
+ tasks.push({
166
+ id: `${parentId}.integrate`,
167
+ goal: 'Integrate subtask results and run parent verification',
168
+ files: [],
169
+ tests: [],
170
+ deps: tasks.map((t) => t.id),
171
+ scopeTokens: HANDOFF_TOKENS,
172
+ acceptance: accept, // integration verifies EVERY parent criterion
173
+ handoff: 'Main orchestrator merges handoffs and verifies parent acceptance.',
174
+ });
175
+ return {
176
+ decompose: true,
177
+ reason: decision.reason,
178
+ fingerprint: decision.fingerprint,
179
+ estimatedTokens: num(contextTokens),
180
+ costs: decision.costs,
181
+ depth: state.depth + 1,
182
+ attempts: state.attempts + 1,
183
+ seenFingerprints: [...state.seenFingerprints, decision.fingerprint].slice(-4),
184
+ parentAcceptance: accept,
185
+ tasks,
186
+ };
187
+ }
188
+
189
+ const basenameOf = (f) => String(f).split('/').pop().replace(/\.[^.]+$/, '').toLowerCase();
190
+
191
+ /**
192
+ * Distribute parent criteria: a criterion that names a subtask's file/dir (or its
193
+ * basename) goes to that subtask; test-ish criteria go to subtasks that own tests;
194
+ * `shared:`/`contract:` criteria go to every subtask (shared contract); anything else
195
+ * is parent-level and only checked at integration. Nothing is lost: integration holds all.
196
+ */
197
+ export function splitAcceptance(accept, subtasks) {
198
+ const perTask = subtasks.map(() => []);
199
+ for (const c of accept) {
200
+ const lc = c.toLowerCase();
201
+ const shared = /^(shared|contract):/.test(lc);
202
+ subtasks.forEach((s, i) => {
203
+ const names = [s.key, ...s.files, ...s.files.map(basenameOf)].map((n) => String(n).toLowerCase()).filter((n) => n.length > 2 && n !== '.');
204
+ const direct = names.some((n) => lc.includes(n));
205
+ const testy = /\btests?\b/.test(lc) && s.tests.length > 0;
206
+ if (shared || direct || testy) perTask[i].push(c);
207
+ });
208
+ }
209
+ return { perTask };
210
+ }
211
+
212
+ // ---- task-graph execution (pure) -------------------------------------------------
213
+ // Progress lives on the stored decomposition entry as `done`/`running` id arrays so the
214
+ // existing resumable-run record carries it across resume. Bounded: <= 5 ids each.
215
+
216
+ export const SUBTASK_MARKER_RE = /\[ukit-subtask\s+([A-Za-z0-9_-]+\.(?:\d+|integrate))\]/;
217
+
218
+ export function planProgress(plan) {
219
+ return { done: new Set(plan?.done ?? []), running: new Set(plan?.running ?? []) };
220
+ }
221
+
222
+ /** Tasks whose deps are all done and that are neither done nor running. */
223
+ export function nextDispatchable(plan) {
224
+ if (!plan?.decompose) return [];
225
+ const { done, running } = planProgress(plan);
226
+ return plan.tasks.filter((t) => !done.has(t.id) && !running.has(t.id) && t.deps.every((d) => done.has(d)));
227
+ }
228
+
229
+ /** Gate for dispatching one task id. */
230
+ export function checkDispatch(plan, taskId) {
231
+ const task = plan?.tasks?.find((t) => t.id === taskId);
232
+ if (!task) return { ok: false, reason: 'unknown-subtask' };
233
+ const { done, running } = planProgress(plan);
234
+ if (done.has(taskId)) return { ok: false, reason: 'already-done' };
235
+ if (running.has(taskId)) return { ok: false, reason: 'already-running' };
236
+ const missing = task.deps.filter((d) => !done.has(d));
237
+ if (missing.length) return { ok: false, reason: 'deps-pending', missing };
238
+ return { ok: true, task };
239
+ }
240
+
241
+ /** Pure status transition: 'running' | 'done' | 'pending' (failed → retryable). */
242
+ export function applySubtaskStatus(plan, taskId, status) {
243
+ const { done, running } = planProgress(plan);
244
+ running.delete(taskId);
245
+ if (status === 'done') done.add(taskId);
246
+ if (status === 'running') running.add(taskId);
247
+ return { ...plan, done: [...done], running: [...running] };
248
+ }
249
+
250
+ /** Carry finished work over a replan when the subtask is unchanged. */
251
+ export function carryProgress(oldPlan, newPlan) {
252
+ if (!oldPlan?.decompose || !newPlan?.decompose) return newPlan;
253
+ const same = (a) => {
254
+ const b = oldPlan.tasks.find((t) => t.id === a.id);
255
+ return b && JSON.stringify(b.files) === JSON.stringify(a.files);
256
+ };
257
+ const done = (oldPlan.done ?? []).filter((id) => newPlan.tasks.some((t) => t.id === id && same(t)));
258
+ return { ...newPlan, done };
259
+ }
260
+
261
+ /** The scoped dispatch text for one subtask: its own files/criteria only, no parent history. */
262
+ export function buildSubtaskPrompt(task) {
263
+ return [
264
+ `[ukit-subtask ${task.id}]`,
265
+ `Goal: ${task.goal}`,
266
+ task.files.length ? `Files: ${task.files.join(', ')}` : null,
267
+ task.tests.length ? `Tests: ${task.tests.join(', ')}` : null,
268
+ task.acceptance.length ? `Acceptance: ${task.acceptance.join(' | ')}` : null,
269
+ task.handoff,
270
+ ].filter(Boolean).join('\n');
271
+ }
272
+
273
+ // ---- layered context-size estimate -------------------------------------------------
274
+ // Priority: (1) actual provider usage on the newest main-chain assistant turn (Claude Code
275
+ // `message.usage`, OMP `usage`/`message.usage`) = prompt of that request, confidence HIGH;
276
+ // (2) a caller-supplied window metric (MEDIUM); (3) transcript bytes/4 = UNRELIABLE upper
277
+ // bound (LOW: JSON/tool-output overhead inflates it). LOW never triggers an automatic split.
278
+
279
+ function promptFromUsage(u) {
280
+ if (!u || typeof u !== 'object') return null;
281
+ const input = num(u.input_tokens ?? u.input) ?? 0;
282
+ const read = num(u.cache_read_input_tokens ?? u.cacheRead) ?? 0;
283
+ const write = num(u.cache_creation_input_tokens ?? u.cacheWrite) ?? 0;
284
+ const total = input + read + write;
285
+ return total > 0 ? total : null;
286
+ }
287
+
288
+ export function contextFromUsageLines(lines = []) {
289
+ for (let i = lines.length - 1; i >= 0; i--) {
290
+ let row;
291
+ try { row = JSON.parse(lines[i]); } catch { continue; }
292
+ if (!row || typeof row !== 'object' || row.isSidechain === true) continue;
293
+ const tokens = promptFromUsage(row.message?.usage ?? row.usage);
294
+ if (tokens !== null) return tokens;
295
+ }
296
+ return null;
297
+ }
298
+
299
+ export function estimateContextWindow({ tailLines = [], windowTokens = null, transcriptBytes = null } = {}) {
300
+ const used = contextFromUsageLines(tailLines);
301
+ if (used !== null) return { tokens: used, source: 'provider-usage', confidence: 'high' };
302
+ const win = num(windowTokens);
303
+ if (win !== null && win > 0) return { tokens: win, source: 'window-metric', confidence: 'medium' };
304
+ const bytes = num(transcriptBytes);
305
+ if (bytes !== null) return { tokens: Math.ceil(bytes / 4), source: 'transcript-bytes', confidence: 'low' };
306
+ return { tokens: null, source: 'unknown', confidence: 'unknown' };
307
+ }
308
+
309
+ export const MIN_AUTO_SPLIT_CONFIDENCE = new Set(['high', 'medium']);