@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 +7 -0
- package/manifests/documentation.yaml +11 -0
- package/package.json +1 -1
- package/src/core/diffPlan.js +87 -8
- package/src/core/executionContracts.js +8 -2
- package/src/core/metadata.js +5 -0
- package/src/core/ompConfigMerge.js +60 -0
- package/src/core/ompProjectConfig.js +462 -0
- package/src/core/runInstallPipeline.js +25 -1
- package/src/core/uninstall.js +28 -1
- package/src/render/buildVariables.js +4 -0
- package/template_project/.claude/hooks/handoff-model-guard.sh +37 -2
- package/template_project/.claude/hooks/record-execution.mjs +9 -0
- package/template_project/.claude/hooks/skill-router.sh +67 -7
- package/template_project/.claude/ukit/index/route-resolver.mjs +19 -5
- package/template_project/.claude/ukit/index/route-task.mjs +97 -1
- package/template_project/.claude/ukit/runtime/context-budget-policy.mjs +309 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +28 -5
- package/template_project/.claude/ukit/runtime/plan-dispatch.mjs +74 -0
- package/template_project/.claude/ukit/runtime/reinject-context.mjs +7 -0
- package/template_project/.omp/config.yml +30 -14
|
@@ -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
|
-
|
|
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
|
-
|
|
1807
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1079
|
-
|
|
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']);
|