@ngockhoale/ukit 3.4.32 → 3.4.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/manifests/documentation.yaml +11 -0
- package/package.json +1 -1
- package/src/core/diffPlan.js +87 -8
- package/src/core/executionContracts.js +54 -8
- 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/index/taskRouting.js +9 -0
- package/src/render/buildVariables.js +4 -0
- package/template_project/.claude/hooks/block-dangerous.mjs +15 -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 +119 -10
- package/template_project/.claude/ukit/index/post-edit-verify.mjs +9 -0
- package/template_project/.claude/ukit/index/route-resolver.mjs +54 -8
- package/template_project/.claude/ukit/index/route-task.mjs +106 -1
- package/template_project/.claude/ukit/runtime/context-budget-policy.mjs +309 -0
- package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +67 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +47 -10
- package/template_project/.claude/ukit/runtime/operation-evidence.mjs +7 -3
- 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)) {
|
|
@@ -1791,6 +1791,28 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
1791
1791
|
}
|
|
1792
1792
|
|
|
1793
1793
|
|
|
1794
|
+
// What exactly was ordered, so a receipt for a DIFFERENT verb/service/probe cannot discharge it.
|
|
1795
|
+
// Unnamed specifics stay empty (any proven receipt of the domain counts); named ones are required.
|
|
1796
|
+
const NOT_A_SERVICE = new Set(['it', 'this', 'that', 'them', 'the', 'all', 'everything', 'service', 'services', 'again', 'now', 'to', 'and', 'then']);
|
|
1797
|
+
function nativeOperationGoal(domain, actionable, text) {
|
|
1798
|
+
const goal = { domain, target: null, desiredState: 'verified', completionSemantics: 'execute-and-verify', requiredTargets: [] };
|
|
1799
|
+
const clauses = actionable.join(' . ');
|
|
1800
|
+
if (domain === 'git-integrity') {
|
|
1801
|
+
const verbs = [...clauses.matchAll(/\bgit\s+(push|clean|commit|reset|rebase|merge|tag|stash|checkout|restore|cherry-pick)\b/g)].map((m) => m[1]);
|
|
1802
|
+
if (/\bpush\s+(?:to\s+)?origin\b/.test(clauses)) verbs.push('push');
|
|
1803
|
+
goal.requiredTargets = [...new Set(verbs)];
|
|
1804
|
+
goal.check = /\bfsck\b/.test(text) ? 'fsck' : /\bdiff\s+--check\b/.test(text) ? 'diff-check' : null;
|
|
1805
|
+
return goal;
|
|
1806
|
+
}
|
|
1807
|
+
const services = [...clauses.matchAll(/\b(?:restart|redeploy|deploy|khoi dong lai|trien khai)\s+(?:the\s+|lai\s+)?([a-z0-9][\w.-]*)/g)]
|
|
1808
|
+
.map((m) => m[1]).filter((name) => !NOT_A_SERVICE.has(name));
|
|
1809
|
+
goal.requiredTargets = [...new Set(services)];
|
|
1810
|
+
const probePath = text.match(/(?:^|\s)(\/[a-z0-9_.-]+(?:\/[a-z0-9_.-]+)*)/)?.[1] || null;
|
|
1811
|
+
const probeStatus = text.match(/\b(?:returns?|returning|responds?(?:\s+with)?|http|status(?:\s+code)?)\s+([1-5]\d\d)\b/)?.[1] || null;
|
|
1812
|
+
if (probePath || probeStatus) goal.probe = { path: probePath, status: probeStatus };
|
|
1813
|
+
return goal;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1794
1816
|
// Task semantics are independent of source execution strategy and observed progress.
|
|
1795
1817
|
function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
|
|
1796
1818
|
const text = String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd').toLowerCase();
|
|
@@ -1799,17 +1821,40 @@ function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
|
|
|
1799
1821
|
&& !/^\s*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause));
|
|
1800
1822
|
const sourceOrder = actionable.some((clause) => /\b(?:implement|fix|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
|
|
1801
1823
|
&& /\b(?:source|code|logic|scheduler|implementation|ma nguon)\b|\.[cm]?[jt]sx?\b/.test(clause));
|
|
1802
|
-
const
|
|
1803
|
-
|
|
1804
|
-
|
|
1824
|
+
const codeChangeVerb = actionable.some((clause) => /\b(?:implement|fix|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause));
|
|
1825
|
+
const hasOperationId = /\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/.test(text);
|
|
1826
|
+
// A cron/job MENTION is not an operation: with a code-change verb in the request, an
|
|
1827
|
+
// operation needs a concrete target id; otherwise it is a coding task about scheduling.
|
|
1828
|
+
const cronOperation = actionable.some((clause) => /\b(?:delete|remove|cancel|xoa|huy|ensure|dam bao)\b/.test(clause)
|
|
1829
|
+
&& /\bcron\b|scheduled\s+(?:cron\s+)?(?:task|job)|tac vu\s+(?:cron|dinh ky)/.test(clause))
|
|
1830
|
+
&& (!codeChangeVerb || hasOperationId);
|
|
1831
|
+
// Non-source operations (git history/push, service restart/deploy): ordered only by an
|
|
1832
|
+
// actionable clause (negated/deferred/audit clauses are already filtered out). Git and
|
|
1833
|
+
// runtime are separate domains with separate proof; a prompt naming both stays a coding
|
|
1834
|
+
// task rather than being half-verified. A code-change verb anywhere keeps write evidence.
|
|
1835
|
+
const gitOperation = actionable.some((clause) => /\bgit\s+(?:push|clean|commit|reset|rebase|merge|tag|stash|checkout|restore|cherry-pick)\b|\bpush\s+(?:to\s+)?origin\b/.test(clause));
|
|
1836
|
+
const runtimeOperation = actionable.some((clause) => /\b(?:restart|redeploy|deploy|khoi dong lai|trien khai)\b/.test(clause)
|
|
1837
|
+
&& !/\.[cm]?[jt]sx?\b/.test(clause) && !/\bgit\b/.test(clause));
|
|
1838
|
+
const nativeDomain = cronOperation ? 'scheduled-job' : gitOperation !== runtimeOperation ? (gitOperation ? 'git-integrity' : 'service-runtime') : null;
|
|
1839
|
+
const operation = Boolean(nativeDomain);
|
|
1840
|
+
// A config MENTION is not a config change: the same clause needs a mutation verb, and a
|
|
1841
|
+
// request that also changes source/code is a coding task (config is part of its tests/fixtures).
|
|
1842
|
+
const config = !sourceOrder && actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause)
|
|
1843
|
+
&& /\b(?:set|change|toggle|enable|disable|switch|turn|configure|modify|update|edit|doi|bat|tat|chinh)\b/.test(clause));
|
|
1805
1844
|
const repository = Boolean(targetFile) || /[\w.-]+\.(?:json|ya?ml|toml|ini)\b/.test(text);
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
const
|
|
1845
|
+
// A status/result lookup ("check the suite output and report") orders no change: it needs a
|
|
1846
|
+
// lookup verb AND no mutation vocabulary anywhere in the request (fail-closed otherwise).
|
|
1847
|
+
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);
|
|
1848
|
+
const statusReport = !mutationWord && !operation && !config
|
|
1849
|
+
&& /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua)\b/.test(text);
|
|
1850
|
+
const readOnly = (/\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text) || statusReport)
|
|
1851
|
+
&& !sourceOrder && !codeChangeVerb && !operation && !config;
|
|
1852
|
+
const taskKind = operation ? (sourceOrder || (nativeDomain !== 'scheduled-job' && codeChangeVerb) ? 'MIXED' : 'OPERATIONAL')
|
|
1809
1853
|
: config ? 'CONFIGURATION' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
|
|
1810
1854
|
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' } : {}),
|
|
1812
|
-
...(operation
|
|
1855
|
+
return { taskKind, ...(taskKind === 'READ_ONLY' && statusReport ? { statusReport: true } : {}), ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
|
|
1856
|
+
...(operation && nativeDomain !== 'scheduled-job' ? { operationGoal: nativeOperationGoal(nativeDomain, actionable, text) } : {}),
|
|
1857
|
+
...(operation && nativeDomain === 'scheduled-job' ? { operationGoal: { domain: 'scheduled-job', target: id, desiredState: /\b(?:delete|remove|cancel|xoa|huy|absent|removed|deleted|no longer|khong con)\b/.test(actionable.join(' ')) ? 'absent' : 'present',
|
|
1813
1858
|
completionSemantics: /\b(?:ensure|dam bao)\b/.test(text) ? 'ensure-state' : 'execute-and-verify' } } : {}) };
|
|
1814
1859
|
}
|
|
1815
1860
|
|
|
@@ -1820,6 +1865,7 @@ function applyTaskSemantics(contract, semantic) {
|
|
|
1820
1865
|
return { ...(contract || {}), ...semantic,
|
|
1821
1866
|
completionEvidence: operationOnly ? ['operation-verification']
|
|
1822
1867
|
: semantic.taskKind === 'MIXED' ? [...new Set([...evidence, 'write-evidence', 'operation-verification'])]
|
|
1868
|
+
: semantic.taskKind === 'READ_ONLY' && semantic.statusReport ? []
|
|
1823
1869
|
: semantic.taskKind === 'READ_ONLY' ? (evidence.length === 1 && evidence[0] === 'verification-evidence' ? evidence : ['impact-evidence']) : evidence };
|
|
1824
1870
|
}
|
|
1825
1871
|
|
|
@@ -2242,6 +2288,15 @@ function applyTaskSemantics(contract, semantic) {
|
|
|
2242
2288
|
// '…nhưng sửa phần này' order survives.
|
|
2243
2289
|
.replace(new RegExp(`\\bchua\\s+(?:can|nen|phai)\\s+${WAIT_TAIL}`, 'g'), ' ')
|
|
2244
2290
|
.replace(new RegExp(`\\b(?:neu|if)\\s+${TAIL}`, 'g'), ' ')
|
|
2291
|
+
// 'đối chiếu' (cross-check) folds to 'doi chieu' and is not the verb 'đổi' (change).
|
|
2292
|
+
.replace(/\bdoi\s+chieu\b/g, ' ')
|
|
2293
|
+
// R2: a deferred/future clause ("chua sua", "how we would later implement", "R2 onward: ...",
|
|
2294
|
+
// "fix strategy ...") describes work for a LATER round, not an order for this turn.
|
|
2295
|
+
.replace(new RegExp(`\\bchua\\s+(?:sua|fix|implement|trien\\s+khai|viet|thay\\s+doi)\\b${WAIT_TAIL}`, 'g'), ' ')
|
|
2296
|
+
.replace(/\bhow\s+(?:we|i|you|they)\s+(?:would|will|could|should|might)\b[^.,;\n?]*/g, ' ')
|
|
2297
|
+
.replace(/\b(?:would|will|could|might)\s+later\b[^.,;\n?]*/g, ' ')
|
|
2298
|
+
.replace(/\b(?:r\d+|round\s*\d+)\s+onward\b[^\n]*/g, ' ')
|
|
2299
|
+
.replace(/\bfix\s+strategy\b[^\n]*/g, ' ')
|
|
2245
2300
|
// C85-019b: a "khi <subject> <verb>" when-clause is temporal context —
|
|
2246
2301
|
// "khi tôi cài hệ thống này" narrates WHEN something happens; the verb
|
|
2247
2302
|
// inside it is never the order.
|
|
@@ -3625,6 +3680,15 @@ function applyTaskSemantics(contract, semantic) {
|
|
|
3625
3680
|
if (continuingTask && previous.routingContext?.lastExplicitUserPromptText) {
|
|
3626
3681
|
persisted.routingContext ||= {};
|
|
3627
3682
|
persisted.routingContext.lastExplicitUserPromptText = previous.routingContext.lastExplicitUserPromptText;
|
|
3683
|
+
// A tool event or a bare "continue" carries no order of its own: mutationOrder recomputed
|
|
3684
|
+
// from a file path / the word "continue" says nothing about what the user ordered, so the
|
|
3685
|
+
// original prompt's stamp stays authoritative.
|
|
3686
|
+
if ((!extractPrompt(payload) || continuationPrompt) && typeof previous.routingContext.mutationOrder === 'boolean') {
|
|
3687
|
+
persisted.routingContext.mutationOrder = previous.routingContext.mutationOrder;
|
|
3688
|
+
}
|
|
3689
|
+
}
|
|
3690
|
+
if (continuingTask && !persisted.suspendedContract && previous.suspendedContract) {
|
|
3691
|
+
persisted.suspendedContract = previous.suspendedContract;
|
|
3628
3692
|
}
|
|
3629
3693
|
const semantic = continuingTask && previous.routeSummary?.executionContract?.taskKind
|
|
3630
3694
|
? previous.routeSummary.executionContract
|
|
@@ -3632,11 +3696,39 @@ function applyTaskSemantics(contract, semantic) {
|
|
|
3632
3696
|
const informationalTurn = !continuingTask && persisted.routingContext?.executionMode === 'informational'
|
|
3633
3697
|
&& semantic.taskKind === 'CODE_IMPLEMENTATION';
|
|
3634
3698
|
if (informationalTurn) {
|
|
3699
|
+
// An answer-only turn must not enforce carried write debt, but it must not ERASE the
|
|
3700
|
+
// contract either: park it (session- and task-bound) so a continuation or the next
|
|
3701
|
+
// tool event of the same session restores it instead of finding "no contract routed".
|
|
3702
|
+
const parked = previous.routeSummary?.executionContract?.completionEvidence?.length
|
|
3703
|
+
? { sessionId, taskInstanceId: previous.taskInstanceId, requestKey: previous.requestKey,
|
|
3704
|
+
routeSummary: previous.routeSummary, routingContext: previous.routingContext, ts: Date.now() }
|
|
3705
|
+
: previous.suspendedContract;
|
|
3706
|
+
if (parked) persisted.suspendedContract = parked;
|
|
3635
3707
|
delete persisted.routeSummary;
|
|
3636
3708
|
delete persisted.requestKey;
|
|
3637
|
-
} else if (semantic.
|
|
3709
|
+
} else if (semantic.statusReport && !continuingTask) {
|
|
3710
|
+
// A status/result lookup is a read-only turn with its own task id: it carries no write
|
|
3711
|
+
// debt, but it must not erase a running implementation's contract — park it for `continue`.
|
|
3712
|
+
const prior = previous.routeSummary?.executionContract;
|
|
3713
|
+
const parked = prior && !prior.statusReport && prior.completionEvidence?.length
|
|
3714
|
+
? { sessionId, taskInstanceId: previous.taskInstanceId, requestKey: previous.requestKey,
|
|
3715
|
+
routeSummary: previous.routeSummary, routingContext: previous.routingContext, ts: Date.now() }
|
|
3716
|
+
: previous.suspendedContract;
|
|
3717
|
+
if (parked) persisted.suspendedContract = parked;
|
|
3638
3718
|
persisted.routeSummary ||= { executionMode: 'local-build' };
|
|
3639
3719
|
persisted.routeSummary.executionContract = applyTaskSemantics(
|
|
3720
|
+
persisted.routeSummary.executionContract || buildExecutionContract('local-build'), semantic);
|
|
3721
|
+
delete persisted.routeSummary.completionState;
|
|
3722
|
+
delete persisted.routeSummary.continuationState;
|
|
3723
|
+
} else if (semantic.taskKind !== 'CODE_IMPLEMENTATION' || persisted.routeSummary) {
|
|
3724
|
+
persisted.routeSummary ||= { executionMode: 'local-build' };
|
|
3725
|
+
// A carried status-report contract must not leak its empty evidence / flag onto a
|
|
3726
|
+
// turn that is not itself a status lookup: rebuild the base contract instead.
|
|
3727
|
+
const carriedStatus = persisted.routeSummary.executionContract?.statusReport === true && !semantic.statusReport;
|
|
3728
|
+
if (carriedStatus && persisted.routingContext?.mutationOrder === true) {
|
|
3729
|
+
delete persisted.routeSummary.executionContract;
|
|
3730
|
+
}
|
|
3731
|
+
if (!carriedStatus || persisted.routingContext?.mutationOrder === true) persisted.routeSummary.executionContract = applyTaskSemantics(
|
|
3640
3732
|
persisted.routeSummary.executionContract || buildExecutionContract(persisted.routeSummary.executionMode === 'informational' ? 'local-build' : persisted.routeSummary.executionMode), semantic);
|
|
3641
3733
|
if (semantic.taskKind === 'OPERATIONAL' || semantic.taskKind === 'READ_ONLY'
|
|
3642
3734
|
|| (semantic.taskKind === 'CONFIGURATION' && semantic.configurationScope === 'external')) {
|
|
@@ -3647,6 +3739,12 @@ function applyTaskSemantics(contract, semantic) {
|
|
|
3647
3739
|
persisted.routeSummary.continuationState = buildContinuationState({ completionState: persisted.routeSummary.completionState });
|
|
3648
3740
|
}
|
|
3649
3741
|
}
|
|
3742
|
+
// Keep a parked implementation contract alive across further answer/status turns of the
|
|
3743
|
+
// same session (previous is already session-scoped); a fresh implementation route drops it.
|
|
3744
|
+
if (!persisted.suspendedContract && previous.suspendedContract
|
|
3745
|
+
&& (!persisted.routeSummary || persisted.routeSummary.executionContract?.statusReport)) {
|
|
3746
|
+
persisted.suspendedContract = previous.suspendedContract;
|
|
3747
|
+
}
|
|
3650
3748
|
text = JSON.stringify(persisted);
|
|
3651
3749
|
await writeJsonFileAtomic(statePath, text);
|
|
3652
3750
|
if (!sessionId) return;
|
|
@@ -3668,11 +3766,22 @@ function applyTaskSemantics(contract, semantic) {
|
|
|
3668
3766
|
const sidecarPrevious = sessionId && String(storedPrevious?.sessionId || '').trim() !== sessionId
|
|
3669
3767
|
? await readJson(path.join(projectRoot, '.ukit', 'storage', 'cache', 'route-state', `${sessionId.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96) || 'default'}.json`), {})
|
|
3670
3768
|
: {};
|
|
3671
|
-
|
|
3769
|
+
let previous = sessionId
|
|
3672
3770
|
? [storedPrevious, sidecarPrevious].find((state) => String(state?.sessionId || '').trim() === sessionId) || {}
|
|
3673
3771
|
: storedPrevious;
|
|
3674
3772
|
const continuationPrompt = /^(?:continue|resume|keep going|proceed|tiep tuc)(?:\s+(?:the task|this task|previous task))?[.!]?$/i.test(
|
|
3675
3773
|
extractPrompt(payload).normalize('NFD').replace(/[̀-ͯ]/g, '').trim());
|
|
3774
|
+
// Restore a contract parked by an informational turn: only for the SAME session, and only
|
|
3775
|
+
// on an explicit continuation or a mutating tool event (never a fresh prompt, which routes on its own,
|
|
3776
|
+
// and never a read during an answer/status turn: that must not resurrect write debt).
|
|
3777
|
+
const parkedContract = previous.suspendedContract;
|
|
3778
|
+
if ((!previous.routeSummary || previous.routeSummary?.executionContract?.statusReport) && parkedContract?.routeSummary && parkedContract.taskInstanceId
|
|
3779
|
+
&& sessionId && parkedContract.sessionId === sessionId
|
|
3780
|
+
&& (continuationPrompt || /^(?:Edit|Write|MultiEdit|NotebookEdit)$/.test(String(payload.tool_name || '')))) {
|
|
3781
|
+
previous = { ...previous, taskInstanceId: parkedContract.taskInstanceId, routeSummary: parkedContract.routeSummary,
|
|
3782
|
+
requestKey: parkedContract.requestKey, routingContext: parkedContract.routingContext || previous.routingContext };
|
|
3783
|
+
delete previous.suspendedContract;
|
|
3784
|
+
}
|
|
3676
3785
|
const continuingTask = Boolean(previous.taskInstanceId && (payload.tool_name || continuationPrompt));
|
|
3677
3786
|
const taskInstanceId = continuingTask
|
|
3678
3787
|
? previous.taskInstanceId
|
|
@@ -276,6 +276,15 @@ if (process.argv[1] && path.basename(fileURLToPath(import.meta.url)) === path.ba
|
|
|
276
276
|
process.stderr.write(`[ukit-safe-patch] post-edit ERROR: ${error?.message || error}\n`);
|
|
277
277
|
// BUG-C23-01: stderr-only crash announces nothing to the user — §8 requires
|
|
278
278
|
// the paired stdout systemMessage before the non-zero exit.
|
|
279
|
+
// A containment refusal is the guard working, not a crash: the target is outside the
|
|
280
|
+
// project (and any listed linked worktree), so it is still refused and never read.
|
|
281
|
+
if (/^Path escapes project root:/.test(String(error?.message || ''))) {
|
|
282
|
+
emitSystemMessage(
|
|
283
|
+
`UKit Safe Patch refused an out-of-project edit target (${error.message}); `
|
|
284
|
+
+ 'post-edit verification was NOT run for it. This is a scope refusal, not a hook crash.',
|
|
285
|
+
);
|
|
286
|
+
process.exit(1);
|
|
287
|
+
}
|
|
279
288
|
emitSystemMessage(
|
|
280
289
|
`UKit post-edit-verify crashed before it could verify the edit (${error?.message || error}); `
|
|
281
290
|
+ 'the Safe Patch check was skipped this pass. Run `ukit doctor` if this repeats.',
|
|
@@ -1063,6 +1063,28 @@ export function deriveRouteFields({
|
|
|
1063
1063
|
};
|
|
1064
1064
|
}
|
|
1065
1065
|
|
|
1066
|
+
// What exactly was ordered, so a receipt for a DIFFERENT verb/service/probe cannot discharge it.
|
|
1067
|
+
// Unnamed specifics stay empty (any proven receipt of the domain counts); named ones are required.
|
|
1068
|
+
const NOT_A_SERVICE = new Set(['it', 'this', 'that', 'them', 'the', 'all', 'everything', 'service', 'services', 'again', 'now', 'to', 'and', 'then']);
|
|
1069
|
+
function nativeOperationGoal(domain, actionable, text) {
|
|
1070
|
+
const goal = { domain, target: null, desiredState: 'verified', completionSemantics: 'execute-and-verify', requiredTargets: [] };
|
|
1071
|
+
const clauses = actionable.join(' . ');
|
|
1072
|
+
if (domain === 'git-integrity') {
|
|
1073
|
+
const verbs = [...clauses.matchAll(/\bgit\s+(push|clean|commit|reset|rebase|merge|tag|stash|checkout|restore|cherry-pick)\b/g)].map((m) => m[1]);
|
|
1074
|
+
if (/\bpush\s+(?:to\s+)?origin\b/.test(clauses)) verbs.push('push');
|
|
1075
|
+
goal.requiredTargets = [...new Set(verbs)];
|
|
1076
|
+
goal.check = /\bfsck\b/.test(text) ? 'fsck' : /\bdiff\s+--check\b/.test(text) ? 'diff-check' : null;
|
|
1077
|
+
return goal;
|
|
1078
|
+
}
|
|
1079
|
+
const services = [...clauses.matchAll(/\b(?:restart|redeploy|deploy|khoi dong lai|trien khai)\s+(?:the\s+|lai\s+)?([a-z0-9][\w.-]*)/g)]
|
|
1080
|
+
.map((m) => m[1]).filter((name) => !NOT_A_SERVICE.has(name));
|
|
1081
|
+
goal.requiredTargets = [...new Set(services)];
|
|
1082
|
+
const probePath = text.match(/(?:^|\s)(\/[a-z0-9_.-]+(?:\/[a-z0-9_.-]+)*)/)?.[1] || null;
|
|
1083
|
+
const probeStatus = text.match(/\b(?:returns?|returning|responds?(?:\s+with)?|http|status(?:\s+code)?)\s+([1-5]\d\d)\b/)?.[1] || null;
|
|
1084
|
+
if (probePath || probeStatus) goal.probe = { path: probePath, status: probeStatus };
|
|
1085
|
+
return goal;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1066
1088
|
// Task semantics are independent of source execution strategy and observed progress.
|
|
1067
1089
|
export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
|
|
1068
1090
|
const text = String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd').toLowerCase();
|
|
@@ -1071,17 +1093,40 @@ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {})
|
|
|
1071
1093
|
&& !/^\s*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause));
|
|
1072
1094
|
const sourceOrder = actionable.some((clause) => /\b(?:implement|fix|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
|
|
1073
1095
|
&& /\b(?:source|code|logic|scheduler|implementation|ma nguon)\b|\.[cm]?[jt]sx?\b/.test(clause));
|
|
1074
|
-
const
|
|
1075
|
-
|
|
1076
|
-
|
|
1096
|
+
const codeChangeVerb = actionable.some((clause) => /\b(?:implement|fix|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause));
|
|
1097
|
+
const hasOperationId = /\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/.test(text);
|
|
1098
|
+
// A cron/job MENTION is not an operation: with a code-change verb in the request, an
|
|
1099
|
+
// operation needs a concrete target id; otherwise it is a coding task about scheduling.
|
|
1100
|
+
const cronOperation = actionable.some((clause) => /\b(?:delete|remove|cancel|xoa|huy|ensure|dam bao)\b/.test(clause)
|
|
1101
|
+
&& /\bcron\b|scheduled\s+(?:cron\s+)?(?:task|job)|tac vu\s+(?:cron|dinh ky)/.test(clause))
|
|
1102
|
+
&& (!codeChangeVerb || hasOperationId);
|
|
1103
|
+
// Non-source operations (git history/push, service restart/deploy): ordered only by an
|
|
1104
|
+
// actionable clause (negated/deferred/audit clauses are already filtered out). Git and
|
|
1105
|
+
// runtime are separate domains with separate proof; a prompt naming both stays a coding
|
|
1106
|
+
// task rather than being half-verified. A code-change verb anywhere keeps write evidence.
|
|
1107
|
+
const gitOperation = actionable.some((clause) => /\bgit\s+(?:push|clean|commit|reset|rebase|merge|tag|stash|checkout|restore|cherry-pick)\b|\bpush\s+(?:to\s+)?origin\b/.test(clause));
|
|
1108
|
+
const runtimeOperation = actionable.some((clause) => /\b(?:restart|redeploy|deploy|khoi dong lai|trien khai)\b/.test(clause)
|
|
1109
|
+
&& !/\.[cm]?[jt]sx?\b/.test(clause) && !/\bgit\b/.test(clause));
|
|
1110
|
+
const nativeDomain = cronOperation ? 'scheduled-job' : gitOperation !== runtimeOperation ? (gitOperation ? 'git-integrity' : 'service-runtime') : null;
|
|
1111
|
+
const operation = Boolean(nativeDomain);
|
|
1112
|
+
// A config MENTION is not a config change: the same clause needs a mutation verb, and a
|
|
1113
|
+
// request that also changes source/code is a coding task (config is part of its tests/fixtures).
|
|
1114
|
+
const config = !sourceOrder && actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause)
|
|
1115
|
+
&& /\b(?:set|change|toggle|enable|disable|switch|turn|configure|modify|update|edit|doi|bat|tat|chinh)\b/.test(clause));
|
|
1077
1116
|
const repository = Boolean(targetFile) || /[\w.-]+\.(?:json|ya?ml|toml|ini)\b/.test(text);
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
const
|
|
1117
|
+
// A status/result lookup ("check the suite output and report") orders no change: it needs a
|
|
1118
|
+
// lookup verb AND no mutation vocabulary anywhere in the request (fail-closed otherwise).
|
|
1119
|
+
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);
|
|
1120
|
+
const statusReport = !mutationWord && !operation && !config
|
|
1121
|
+
&& /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua)\b/.test(text);
|
|
1122
|
+
const readOnly = (/\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text) || statusReport)
|
|
1123
|
+
&& !sourceOrder && !codeChangeVerb && !operation && !config;
|
|
1124
|
+
const taskKind = operation ? (sourceOrder || (nativeDomain !== 'scheduled-job' && codeChangeVerb) ? 'MIXED' : 'OPERATIONAL')
|
|
1081
1125
|
: config ? 'CONFIGURATION' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
|
|
1082
1126
|
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' } : {}),
|
|
1084
|
-
...(operation
|
|
1127
|
+
return { taskKind, ...(taskKind === 'READ_ONLY' && statusReport ? { statusReport: true } : {}), ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
|
|
1128
|
+
...(operation && nativeDomain !== 'scheduled-job' ? { operationGoal: nativeOperationGoal(nativeDomain, actionable, text) } : {}),
|
|
1129
|
+
...(operation && nativeDomain === 'scheduled-job' ? { operationGoal: { domain: 'scheduled-job', target: id, desiredState: /\b(?:delete|remove|cancel|xoa|huy|absent|removed|deleted|no longer|khong con)\b/.test(actionable.join(' ')) ? 'absent' : 'present',
|
|
1085
1130
|
completionSemantics: /\b(?:ensure|dam bao)\b/.test(text) ? 'ensure-state' : 'execute-and-verify' } } : {}) };
|
|
1086
1131
|
}
|
|
1087
1132
|
|
|
@@ -1092,5 +1137,6 @@ export function applyTaskSemantics(contract, semantic) {
|
|
|
1092
1137
|
return { ...(contract || {}), ...semantic,
|
|
1093
1138
|
completionEvidence: operationOnly ? ['operation-verification']
|
|
1094
1139
|
: semantic.taskKind === 'MIXED' ? [...new Set([...evidence, 'write-evidence', 'operation-verification'])]
|
|
1140
|
+
: semantic.taskKind === 'READ_ONLY' && semantic.statusReport ? []
|
|
1095
1141
|
: semantic.taskKind === 'READ_ONLY' ? (evidence.length === 1 && evidence[0] === 'verification-evidence' ? evidence : ['impact-evidence']) : evidence };
|
|
1096
1142
|
}
|
|
@@ -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
|
}
|
|
@@ -4470,6 +4566,15 @@ function hasMutationOrder({ promptText = '', commandText = '' } = {}) {
|
|
|
4470
4566
|
// '…nhưng sửa phần này' order survives.
|
|
4471
4567
|
.replace(new RegExp(`\\bchua\\s+(?:can|nen|phai)\\s+${WAIT_TAIL}`, 'g'), ' ')
|
|
4472
4568
|
.replace(new RegExp(`\\b(?:neu|if)\\s+${TAIL}`, 'g'), ' ')
|
|
4569
|
+
// 'đối chiếu' (cross-check) folds to 'doi chieu' and is not the verb 'đổi' (change).
|
|
4570
|
+
.replace(/\bdoi\s+chieu\b/g, ' ')
|
|
4571
|
+
// R2: a deferred/future clause ("chua sua", "how we would later implement", "R2 onward: ...",
|
|
4572
|
+
// "fix strategy ...") describes work for a LATER round, not an order for this turn.
|
|
4573
|
+
.replace(new RegExp(`\\bchua\\s+(?:sua|fix|implement|trien\\s+khai|viet|thay\\s+doi)\\b${WAIT_TAIL}`, 'g'), ' ')
|
|
4574
|
+
.replace(/\bhow\s+(?:we|i|you|they)\s+(?:would|will|could|should|might)\b[^.,;\n?]*/g, ' ')
|
|
4575
|
+
.replace(/\b(?:would|will|could|might)\s+later\b[^.,;\n?]*/g, ' ')
|
|
4576
|
+
.replace(/\b(?:r\d+|round\s*\d+)\s+onward\b[^\n]*/g, ' ')
|
|
4577
|
+
.replace(/\bfix\s+strategy\b[^\n]*/g, ' ')
|
|
4473
4578
|
// C85-019b: a "khi <subject> <verb>" when-clause is temporal context —
|
|
4474
4579
|
// "khi tôi cài hệ thống này" narrates WHEN something happens; the verb
|
|
4475
4580
|
// inside it is never the order.
|