@ngockhoale/ukit 3.4.31 → 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 +17 -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/decision/reviewVerdict.js +16 -2
- package/src/index/fixLoopEscalation.js +10 -4
- 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/review-verdict.mjs +34 -5
- package/template_project/.claude/ukit/index/route-resolver.mjs +19 -5
- package/template_project/.claude/ukit/index/route-task.mjs +150 -3
- package/template_project/.claude/ukit/index/sidecar-decision.mjs +3 -58
- 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
|
@@ -135,6 +135,12 @@ if printf '%s' "$INPUT" | grep -qE '"tool_name"[[:space:]]*:[[:space:]]*"(Agent|
|
|
|
135
135
|
&& printf '%s' "$INPUT" | grep -qE 'TASK-([Cc][0-9]+-)?[0-9]+'; then
|
|
136
136
|
TASK_CLAIM_RUN=1
|
|
137
137
|
fi
|
|
138
|
+
# Adaptive task-graph pre-filter: a delegation tagged [ukit-subtask <id>] needs the dependency gate.
|
|
139
|
+
SUBTASK_RUN=0
|
|
140
|
+
if printf '%s' "$INPUT" | grep -qE '"tool_name"[[:space:]]*:[[:space:]]*"(Agent|Task)"' \
|
|
141
|
+
&& printf '%s' "$INPUT" | grep -qF '[ukit-subtask '; then
|
|
142
|
+
SUBTASK_RUN=1
|
|
143
|
+
fi
|
|
138
144
|
# Main-agent ownership pre-filter: a source Edit/Write only needs the node gate while a
|
|
139
145
|
# handoff-fullstack run sits in an implement phase (cheap RUN.md grep, no node spawn otherwise).
|
|
140
146
|
MAIN_OWNER_RUN=0
|
|
@@ -142,11 +148,11 @@ if printf '%s' "$INPUT" | grep -qE '"tool_name"[[:space:]]*:[[:space:]]*"(Edit|W
|
|
|
142
148
|
&& grep -qE '^Phase:[[:space:]]*I[1-4]\b' "$PROJECT_ROOT/docs/AI_HANDOFF/RUN.md" 2>/dev/null; then
|
|
143
149
|
MAIN_OWNER_RUN=1
|
|
144
150
|
fi
|
|
145
|
-
if [ "$CREATE_SCOPE_RUN" != 1 ] && [ "$TASK_CLAIM_RUN" != 1 ] && [ "$MAIN_OWNER_RUN" != 1 ] && ! printf '%s' "$INPUT" | grep -qE 'AI_HANDOFF|git[[:space:]]+push'; then
|
|
151
|
+
if [ "$CREATE_SCOPE_RUN" != 1 ] && [ "$TASK_CLAIM_RUN" != 1 ] && [ "$SUBTASK_RUN" != 1 ] && [ "$MAIN_OWNER_RUN" != 1 ] && ! printf '%s' "$INPUT" | grep -qE 'AI_HANDOFF|git[[:space:]]+push'; then
|
|
146
152
|
exit 0
|
|
147
153
|
fi
|
|
148
154
|
|
|
149
|
-
INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" CREATE_SCOPE_RUN="$CREATE_SCOPE_RUN" TASK_CLAIM_RUN="$TASK_CLAIM_RUN" HOOK_DIR="$SCRIPT_DIR" node <<'NODE'
|
|
155
|
+
INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" CREATE_SCOPE_RUN="$CREATE_SCOPE_RUN" TASK_CLAIM_RUN="$TASK_CLAIM_RUN" SUBTASK_RUN="$SUBTASK_RUN" HOOK_DIR="$SCRIPT_DIR" node <<'NODE'
|
|
150
156
|
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
|
|
151
157
|
// SPEC §8: a timed-out gate silently passes work it never evaluated — announce the
|
|
152
158
|
// degrade (fail-open posture kept: still exit 0).
|
|
@@ -793,6 +799,35 @@ async function taskClaimGate() {
|
|
|
793
799
|
}
|
|
794
800
|
await taskClaimGate();
|
|
795
801
|
|
|
802
|
+
// Adaptive-decomposition task graph: a delegation tagged `[ukit-subtask <id>]` is dispatched only
|
|
803
|
+
// when its dependencies are done and it is neither running nor already done (resume never
|
|
804
|
+
// re-dispatches finished work). Untagged delegations and IO trouble pass through untouched.
|
|
805
|
+
async function subtaskGate() {
|
|
806
|
+
if (process.env.SUBTASK_RUN !== '1' || toolName !== 'Agent' && toolName !== 'Task') return;
|
|
807
|
+
try {
|
|
808
|
+
const dispatch = await import(require('url').pathToFileURL(path.resolve(process.env.HOOK_DIR || '.', '../ukit/runtime/plan-dispatch.mjs')).href);
|
|
809
|
+
const subtaskId = dispatch.subtaskIdFromInput(input);
|
|
810
|
+
if (!subtaskId) return;
|
|
811
|
+
const verdict = await dispatch.dispatchSubtask(projectRoot, subtaskId, input);
|
|
812
|
+
if (verdict.ok) {
|
|
813
|
+
const b = verdict.budget;
|
|
814
|
+
if (b && (b.status === 'over-soft-budget' || b.status === 'over-cost-boundary')) {
|
|
815
|
+
process.on('exit', (code) => {
|
|
816
|
+
if (code !== 0) return;
|
|
817
|
+
try { fs.writeSync(1, `${JSON.stringify({ systemMessage: `UKit: subtask ${subtaskId} scoped context (over the ${b.role} soft budget of ${b.softBudget} tokens) — consider splitting it further. Advisory only; dispatch proceeds.` })}\n`); } catch {}
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const why = verdict.reason === 'deps-pending' ? `dependencies not done yet: ${(verdict.missing || []).join(', ')}`
|
|
823
|
+
: verdict.reason === 'already-done' ? 'it is already done (resume never re-dispatches completed subtasks)'
|
|
824
|
+
: verdict.reason === 'already-running' ? 'it is already running'
|
|
825
|
+
: verdict.reason;
|
|
826
|
+
block(`Subtask ${subtaskId} not dispatched: ${why}. Dispatch the ready subtasks first (see the route "plan:" lines); integrate last.`);
|
|
827
|
+
} catch { /* fail-open on IO trouble */ }
|
|
828
|
+
}
|
|
829
|
+
await subtaskGate();
|
|
830
|
+
|
|
796
831
|
// Main-agent task ownership. handoff-fullstack contracts implementation to claim-bearing
|
|
797
832
|
// executor subagents ("Do NOT implement the tasks yourself in the current session"). The
|
|
798
833
|
// delegation path is claim-gated above; this closes the direct path: while a run is in an
|
|
@@ -53,6 +53,15 @@ async function releaseDelegatedClaims(payload, projectRoot) {
|
|
|
53
53
|
await mod.renewSessionClaims(projectRoot, String(payload.session_id));
|
|
54
54
|
}
|
|
55
55
|
if (payload.tool_name !== 'Agent' && payload.tool_name !== 'Task') return;
|
|
56
|
+
try {
|
|
57
|
+
const dispatch = await import(pathToFileURL(path.join(MODULE_DIR, '..', 'ukit', 'runtime', 'plan-dispatch.mjs')).href);
|
|
58
|
+
const subtaskId = dispatch.subtaskIdFromInput(payload.tool_input);
|
|
59
|
+
if (subtaskId) {
|
|
60
|
+
const r = payload.tool_response ?? payload.tool_output ?? payload.tool_result ?? {};
|
|
61
|
+
const failed = [payload.is_error, r?.is_error, r?.isError, payload.tool_response?.isError].some((v) => v === true);
|
|
62
|
+
await dispatch.completeSubtask(projectRoot, subtaskId, { success: !failed });
|
|
63
|
+
}
|
|
64
|
+
} catch { /* advisory */ }
|
|
56
65
|
const owner = mod.delegatedClaimOwner(payload);
|
|
57
66
|
if (!owner) return;
|
|
58
67
|
for (const { taskId } of mod.delegatedClaimTargets(payload.tool_input)) {
|
|
@@ -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
|
|
@@ -146,7 +146,7 @@ function stableBatchId(findings) {
|
|
|
146
146
|
return `rv-${(h >>> 0).toString(16).padStart(8, '0')}`;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
export function buildReviewVerdictBatch({ findings = [], mode = 'solo' } = {}) {
|
|
149
|
+
export function buildReviewVerdictBatch({ findings = [], mode = 'solo', maxQuestions = Infinity } = {}) {
|
|
150
150
|
const usedIds = new Set();
|
|
151
151
|
// Sanitize the FULL finding set — MAX_FINDINGS is a display/transport bound
|
|
152
152
|
// for the batch, not a verdict bound. The deterministic verdict and the
|
|
@@ -156,6 +156,19 @@ export function buildReviewVerdictBatch({ findings = [], mode = 'solo' } = {}) {
|
|
|
156
156
|
const batchFindings = sanitized.slice(0, MAX_FINDINGS);
|
|
157
157
|
const truncated = sanitized.length > batchFindings.length;
|
|
158
158
|
|
|
159
|
+
// Request-cap handling (no extra call): when verdict + one bucket question per
|
|
160
|
+
// finding would exceed `maxQuestions`, ask only findings whose bucket the model
|
|
161
|
+
// can actually change. `Act on` is a deterministic floor the model cannot
|
|
162
|
+
// lower, so critical/important/unknown findings carry no semantic value in the
|
|
163
|
+
// batch; the rest keep their deterministic bucket and are reported as skipped.
|
|
164
|
+
const slots = Number.isInteger(maxQuestions) && maxQuestions > 0
|
|
165
|
+
? maxQuestions - 1 : Infinity;
|
|
166
|
+
const askedFindings = batchFindings.length <= slots
|
|
167
|
+
? batchFindings
|
|
168
|
+
: batchFindings.filter((f) => deriveDeterministicBucket(f) !== 'Act on').slice(0, slots);
|
|
169
|
+
const askedIds = new Set(askedFindings.map((f) => f.id));
|
|
170
|
+
const skippedFindingIds = batchFindings.filter((f) => !askedIds.has(f.id)).map((f) => f.id);
|
|
171
|
+
|
|
159
172
|
const verdictDecisionKey = mode === 'panel'
|
|
160
173
|
? REVIEW_VERDICT_KEYS.panel
|
|
161
174
|
: REVIEW_VERDICT_KEYS.solo;
|
|
@@ -177,7 +190,7 @@ export function buildReviewVerdictBatch({ findings = [], mode = 'solo' } = {}) {
|
|
|
177
190
|
hardConstraintRefs: ['verdict-vocabulary'],
|
|
178
191
|
};
|
|
179
192
|
|
|
180
|
-
const bucketQuestions =
|
|
193
|
+
const bucketQuestions = askedFindings.map((f) => ({
|
|
181
194
|
decisionKey: `${REVIEW_VERDICT_KEYS.bucket}#${f.id}`,
|
|
182
195
|
schemaVersion: 1,
|
|
183
196
|
family: 'review',
|
|
@@ -213,6 +226,7 @@ export function buildReviewVerdictBatch({ findings = [], mode = 'solo' } = {}) {
|
|
|
213
226
|
findings: batchFindings,
|
|
214
227
|
fullFindings: sanitized,
|
|
215
228
|
truncated,
|
|
229
|
+
skippedFindingIds,
|
|
216
230
|
verdictDecisionKey,
|
|
217
231
|
};
|
|
218
232
|
}
|
|
@@ -362,7 +376,7 @@ function readMergedConfig(rootDir, homeDir) {
|
|
|
362
376
|
// Deterministic output object. Field order is fixed so the printed JSON is
|
|
363
377
|
// byte-identical for identical findings on the off/failure paths.
|
|
364
378
|
function emitVerdict({ mode, stage, outcomeClass, verdict, verdictStatus,
|
|
365
|
-
verdictSource, buckets, batchId, fallbackCode }) {
|
|
379
|
+
verdictSource, buckets, batchId, fallbackCode, coverage }) {
|
|
366
380
|
return {
|
|
367
381
|
schema: 'review-verdict/v1',
|
|
368
382
|
mode,
|
|
@@ -374,6 +388,7 @@ function emitVerdict({ mode, stage, outcomeClass, verdict, verdictStatus,
|
|
|
374
388
|
buckets,
|
|
375
389
|
batchId,
|
|
376
390
|
fallbackCode: fallbackCode ?? null,
|
|
391
|
+
...(coverage ? { coverage } : {}),
|
|
377
392
|
};
|
|
378
393
|
}
|
|
379
394
|
|
|
@@ -413,11 +428,19 @@ function foldBatchResult(result, built, mode, stage) {
|
|
|
413
428
|
fallbackCode: 'shadow-observation-only',
|
|
414
429
|
verdictOverride: deriveDeterministicVerdict(built.fullFindings ?? built.findings) });
|
|
415
430
|
}
|
|
431
|
+
const skipped = new Set(built.skippedFindingIds ?? []);
|
|
416
432
|
folded.buckets = folded.buckets.map((bucket, index) => {
|
|
417
433
|
const floor = deriveDeterministicBucket(built.findings[index]);
|
|
434
|
+
if (skipped.has(bucket.findingId)) {
|
|
435
|
+
return { findingId: bucket.findingId, bucket: floor, bucketStatus: 'deterministic:not-asked' };
|
|
436
|
+
}
|
|
418
437
|
return floor === 'Act on' && bucket.bucket !== floor
|
|
419
438
|
? { ...bucket, bucket: floor, bucketStatus: 'clamp:safety-floor' } : bucket;
|
|
420
439
|
});
|
|
440
|
+
const coverage = skipped.size > 0
|
|
441
|
+
? { asked: built.findings.length - skipped.size, total: built.findings.length,
|
|
442
|
+
skippedFindingIds: [...skipped], reason: 'batch-cap', complete: false }
|
|
443
|
+
: null;
|
|
421
444
|
const modelVerdict = folded.verdict;
|
|
422
445
|
// W3-RV1: the batch may be display-truncated at MAX_FINDINGS — the model
|
|
423
446
|
// verdict was computed on `built.findings` only, so when truncation
|
|
@@ -433,8 +456,10 @@ function foldBatchResult(result, built, mode, stage) {
|
|
|
433
456
|
// every transport/gateway class surfaces as 'unavailable' — the fallback
|
|
434
457
|
// code carries the finer-grained reason for receipts.
|
|
435
458
|
const PARSE_CLASSES = new Set(['accepted', 'partial', 'invalid', 'abstained']);
|
|
436
|
-
|
|
459
|
+
let outcomeClass = PARSE_CLASSES.has(folded.status) ? folded.status : 'unavailable';
|
|
460
|
+
if (coverage && outcomeClass === 'accepted') outcomeClass = 'partial';
|
|
437
461
|
return emitVerdict({
|
|
462
|
+
coverage,
|
|
438
463
|
mode,
|
|
439
464
|
stage,
|
|
440
465
|
outcomeClass,
|
|
@@ -460,7 +485,11 @@ function foldBatchResult(result, built, mode, stage) {
|
|
|
460
485
|
*/
|
|
461
486
|
export async function runReviewVerdict({ findings, mode = 'solo', config,
|
|
462
487
|
projectRoot, homeDir, env, transport } = {}) {
|
|
463
|
-
const
|
|
488
|
+
const capValue = config?.decisionPlane?.maxBatchQuestions;
|
|
489
|
+
const built = buildReviewVerdictBatch({
|
|
490
|
+
findings, mode,
|
|
491
|
+
maxQuestions: Number.isInteger(capValue) && capValue > 0 ? capValue : 8,
|
|
492
|
+
});
|
|
464
493
|
const normalizedMode = mode === 'panel' ? 'panel' : 'solo';
|
|
465
494
|
const stage = resolveReviewStage(config);
|
|
466
495
|
|
|
@@ -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,
|
|
@@ -1663,6 +1666,11 @@ function probabilityBand(value) {
|
|
|
1663
1666
|
// executionMode. The review/cross-check/verification-depth baselines are
|
|
1664
1667
|
// produced by later wave modules; until then their baseline stays null and
|
|
1665
1668
|
// receipts record bands + agreement 'unknown' — real data, not fabricated.
|
|
1669
|
+
// Route-shadow questions that add no observation: asked authoritatively by the
|
|
1670
|
+
// fix-loop escalation batch (verification.depth.v1) and without a route-level
|
|
1671
|
+
// baseline to compare against here.
|
|
1672
|
+
const SHADOW_REDUNDANT_KEYS = new Set(['verification.depth.v1']);
|
|
1673
|
+
|
|
1666
1674
|
function shadowBaselineFor(decisionKey, routeSummary) {
|
|
1667
1675
|
if (decisionKey === 'route.intent-kind.v1') return routeSummary?.intent?.kind ?? null;
|
|
1668
1676
|
if (decisionKey === 'route.rigor.v1') return routeSummary?.execution?.rigor ?? null;
|
|
@@ -1819,7 +1827,42 @@ export async function runRouteDecisionShadow({
|
|
|
1819
1827
|
// Greeting fast-path: a social turn carries nothing the decision plane could
|
|
1820
1828
|
// answer — never spawn the adapter shadow for one.
|
|
1821
1829
|
if (routingContext?.greetingFastPath) return null;
|
|
1822
|
-
|
|
1830
|
+
// Observation-only batch: honour the adapter request caps without a second
|
|
1831
|
+
// call. Selection is semantic, not positional: (1) enums over maxEnumLabels
|
|
1832
|
+
// are never truncated, they are skipped (`enum-cap`); (2) questions the route
|
|
1833
|
+
// cannot compare to a deterministic baseline AND that another stage already
|
|
1834
|
+
// asks authoritatively are redundant here; (3) the remainder is ranked
|
|
1835
|
+
// baseline-comparable first, table order within a rank, and cut at
|
|
1836
|
+
// maxBatchQuestions (`batch-cap`). Every skip is reported with its reason.
|
|
1837
|
+
const shadowCaps = {
|
|
1838
|
+
batch: Number.isInteger(config?.decisionPlane?.maxBatchQuestions) && config.decisionPlane.maxBatchQuestions > 0
|
|
1839
|
+
? config.decisionPlane.maxBatchQuestions : 8,
|
|
1840
|
+
labels: Number.isInteger(config?.decisionPlane?.maxEnumLabels) && config.decisionPlane.maxEnumLabels > 0
|
|
1841
|
+
? config.decisionPlane.maxEnumLabels : 11,
|
|
1842
|
+
};
|
|
1843
|
+
const wanted = buildShadowDecisionQuestions(config);
|
|
1844
|
+
const skippedDecisions = [];
|
|
1845
|
+
const candidatesForBatch = [];
|
|
1846
|
+
for (const q of wanted) {
|
|
1847
|
+
if ((q.candidates?.length ?? 0) > shadowCaps.labels) {
|
|
1848
|
+
skippedDecisions.push({ decisionKey: q.decisionKey, reason: 'enum-cap' });
|
|
1849
|
+
} else if (SHADOW_REDUNDANT_KEYS.has(q.decisionKey)) {
|
|
1850
|
+
skippedDecisions.push({ decisionKey: q.decisionKey, reason: 'redundant' });
|
|
1851
|
+
} else {
|
|
1852
|
+
candidatesForBatch.push(q);
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
const hasBaseline = (q) => shadowBaselineFor(q.decisionKey, routeSummary) != null;
|
|
1856
|
+
const ranked = [
|
|
1857
|
+
...candidatesForBatch.filter(hasBaseline),
|
|
1858
|
+
...candidatesForBatch.filter((q) => !hasBaseline(q)),
|
|
1859
|
+
];
|
|
1860
|
+
const picked = new Set(ranked.slice(0, shadowCaps.batch));
|
|
1861
|
+
for (const q of candidatesForBatch) {
|
|
1862
|
+
if (!picked.has(q)) skippedDecisions.push({ decisionKey: q.decisionKey, reason: 'batch-cap' });
|
|
1863
|
+
}
|
|
1864
|
+
const questions = candidatesForBatch.filter((q) => picked.has(q));
|
|
1865
|
+
const skippedDecisionKeys = skippedDecisions.map((d) => d.decisionKey);
|
|
1823
1866
|
if (questions.length === 0) {
|
|
1824
1867
|
return { stage, outcomeClass: 'skipped', decisionKeys: [], probabilityBands: {},
|
|
1825
1868
|
checkpoint: null, latencyClass: 'unknown', fallbackCode: 'no-enabled-families',
|
|
@@ -1904,6 +1947,10 @@ export async function runRouteDecisionShadow({
|
|
|
1904
1947
|
};
|
|
1905
1948
|
}
|
|
1906
1949
|
|
|
1950
|
+
// Partial coverage is never reported as full success.
|
|
1951
|
+
if (skippedDecisions.length > 0 && result.status === 'accepted') {
|
|
1952
|
+
result = { ...result, status: 'partial' };
|
|
1953
|
+
}
|
|
1907
1954
|
const receipt = buildDecisionShadowReceipt({
|
|
1908
1955
|
stage,
|
|
1909
1956
|
result,
|
|
@@ -1960,7 +2007,14 @@ export async function runRouteDecisionShadow({
|
|
|
1960
2007
|
// Receipt loss is advisory — never fail the route over telemetry.
|
|
1961
2008
|
}
|
|
1962
2009
|
}
|
|
1963
|
-
return
|
|
2010
|
+
return skippedDecisions.length > 0
|
|
2011
|
+
? {
|
|
2012
|
+
...receipt,
|
|
2013
|
+
skippedDecisionKeys,
|
|
2014
|
+
skippedDecisions,
|
|
2015
|
+
coverage: { asked: questions.length, total: wanted.length, complete: false },
|
|
2016
|
+
}
|
|
2017
|
+
: receipt;
|
|
1964
2018
|
}
|
|
1965
2019
|
|
|
1966
2020
|
// BL-018 (SPEC FR-006): the bounded-question `ask` seam — one batch through
|
|
@@ -2065,12 +2119,26 @@ function resumableTaskId({ sessionId = null, taskBoundary }) {
|
|
|
2065
2119
|
// (hypotheses/decisions/failures) and completed receipts carry forward; a new
|
|
2066
2120
|
// boundary starts a fresh record. The phase that just completed is appended to
|
|
2067
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
|
+
|
|
2068
2135
|
function buildResumableRunRecord({
|
|
2069
2136
|
taskId,
|
|
2070
2137
|
taskBoundary,
|
|
2071
2138
|
routeSummary,
|
|
2072
2139
|
previousRecord = null,
|
|
2073
2140
|
sourceSnapshot,
|
|
2141
|
+
decomposition = null,
|
|
2074
2142
|
}) {
|
|
2075
2143
|
const previous = previousRecord && previousRecord.taskBoundary === taskBoundary
|
|
2076
2144
|
? previousRecord
|
|
@@ -2111,7 +2179,7 @@ function buildResumableRunRecord({
|
|
|
2111
2179
|
},
|
|
2112
2180
|
invariants: previous?.invariants ?? [],
|
|
2113
2181
|
hypotheses: previous?.hypotheses ?? [],
|
|
2114
|
-
decisions: previous?.decisions ?? [],
|
|
2182
|
+
decisions: mergeDecompositionDecision(previous?.decisions ?? [], decomposition),
|
|
2115
2183
|
evidenceRefs: previous?.evidenceRefs ?? [],
|
|
2116
2184
|
escalationHistory: previous?.escalationHistory ?? [],
|
|
2117
2185
|
unresolvedFailures: previous?.unresolvedFailures ?? [],
|
|
@@ -2135,6 +2203,7 @@ export async function emitResumableRun({
|
|
|
2135
2203
|
projectVerification = null,
|
|
2136
2204
|
signal = null,
|
|
2137
2205
|
deadlineMs = null,
|
|
2206
|
+
decomposition = null,
|
|
2138
2207
|
} = {}) {
|
|
2139
2208
|
const stage = resolveResumableRunStage(config);
|
|
2140
2209
|
if (stage === 'off') return null;
|
|
@@ -2161,6 +2230,7 @@ export async function emitResumableRun({
|
|
|
2161
2230
|
routeSummary,
|
|
2162
2231
|
previousRecord,
|
|
2163
2232
|
sourceSnapshot,
|
|
2233
|
+
decomposition,
|
|
2164
2234
|
});
|
|
2165
2235
|
const write = await writeResumableRun(rootDir, record, {
|
|
2166
2236
|
signal,
|
|
@@ -2200,6 +2270,63 @@ export async function emitResumableRun({
|
|
|
2200
2270
|
}
|
|
2201
2271
|
}
|
|
2202
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
|
+
|
|
2203
2330
|
async function finalizeTaskRoute({
|
|
2204
2331
|
rootDir = process.cwd(),
|
|
2205
2332
|
commandNamespace = '.claude',
|
|
@@ -2224,6 +2351,7 @@ async function finalizeTaskRoute({
|
|
|
2224
2351
|
// BL-013: precomputed bounded history struct from the caller's transcript
|
|
2225
2352
|
// extraction — merged into the route record additively + decisions.tsv.
|
|
2226
2353
|
historySignals = null,
|
|
2354
|
+
transcriptPath = null,
|
|
2227
2355
|
} = {}) {
|
|
2228
2356
|
const absoluteRoot = path.resolve(rootDir);
|
|
2229
2357
|
const activeSkills = preparedRoute?.activeSkills ?? [];
|
|
@@ -2486,7 +2614,20 @@ async function finalizeTaskRoute({
|
|
|
2486
2614
|
// persisted/updated under .ukit/storage/runs/ and a bounded pointer lands on
|
|
2487
2615
|
// the execution ledger. The pointer rides routeSummary.resumableRun so the
|
|
2488
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
|
+
}
|
|
2489
2629
|
const resumableRun = await emitResumableRun({
|
|
2630
|
+
decomposition,
|
|
2490
2631
|
rootDir: absoluteRoot,
|
|
2491
2632
|
config: escalationConfig,
|
|
2492
2633
|
routeSummary,
|
|
@@ -2542,6 +2683,12 @@ function printRouteState(state) {
|
|
|
2542
2683
|
);
|
|
2543
2684
|
}
|
|
2544
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
|
+
}
|
|
2545
2692
|
if (state.routeSummary?.executionContract?.modelTier) {
|
|
2546
2693
|
console.log(`modelTier: ${state.routeSummary.executionContract.modelTier}`);
|
|
2547
2694
|
}
|