@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.
@@ -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 operation = actionable.some((clause) => /\b(?:delete|remove|cancel|xoa|huy|ensure|dam bao)\b/.test(clause)
1803
- && /\bcron\b|scheduled\s+(?:cron\s+)?(?:task|job)|tac vu\s+(?:cron|dinh ky)/.test(clause));
1804
- const config = actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause));
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
- const readOnly = /\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text)
1807
- && !sourceOrder && !operation && !config;
1808
- const taskKind = operation ? (sourceOrder ? 'MIXED' : 'OPERATIONAL')
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 ? { 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',
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.taskKind !== 'CODE_IMPLEMENTATION' || persisted.routeSummary) {
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
- const previous = sessionId
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 operation = actionable.some((clause) => /\b(?:delete|remove|cancel|xoa|huy|ensure|dam bao)\b/.test(clause)
1075
- && /\bcron\b|scheduled\s+(?:cron\s+)?(?:task|job)|tac vu\s+(?:cron|dinh ky)/.test(clause));
1076
- const config = actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause));
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
- const readOnly = /\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text)
1079
- && !sourceOrder && !operation && !config;
1080
- const taskKind = operation ? (sourceOrder ? 'MIXED' : 'OPERATIONAL')
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 ? { 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',
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.