@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.
@@ -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
- const config = actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause));
1807
+ && /\bcron\b|scheduled\s+(?:cron\s+)?(?:task|job)|tac vu\s+(?:cron|dinh ky)/.test(clause))
1808
+ && (!codeChangeVerb || hasOperationId);
1809
+ const config = !sourceOrder && actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause)
1810
+ && /\b(?:set|change|toggle|enable|disable|switch|turn|configure|modify|update|edit|doi|bat|tat|chinh)\b/.test(clause));
1805
1811
  const repository = Boolean(targetFile) || /[\w.-]+\.(?:json|ya?ml|toml|ini)\b/.test(text);
1806
- const readOnly = /\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text)
1807
- && !sourceOrder && !operation && !config;
1812
+ // A status/result lookup ("check the suite output and report") orders no change: it needs a
1813
+ // lookup verb AND no mutation vocabulary anywhere in the request (fail-closed otherwise).
1814
+ const mutationWord = /\b(?:implement|fix|refactor|modify|edit|write|update|add|create|build|make|install|deploy|remove|delete|rename|replace|change|sua|viet|chinh|them|tao|xoa|doi|cai|cap nhat)\b/.test(text);
1815
+ const statusReport = !mutationWord && !operation && !config
1816
+ && /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua)\b/.test(text);
1817
+ const readOnly = (/\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text) || statusReport)
1818
+ && !sourceOrder && !codeChangeVerb && !operation && !config;
1808
1819
  const taskKind = operation ? (sourceOrder ? 'MIXED' : 'OPERATIONAL')
1809
1820
  : config ? 'CONFIGURATION' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
1810
1821
  const id = text.match(/\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/)?.[0] || null;
1811
- return { taskKind, ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
1822
+ return { taskKind, ...(taskKind === 'READ_ONLY' && statusReport ? { statusReport: true } : {}), ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
1812
1823
  ...(operation ? { operationGoal: { domain: 'scheduled-job', target: id, desiredState: /\b(?:delete|remove|cancel|xoa|huy|absent|removed|deleted|no longer|khong con)\b/.test(actionable.join(' ')) ? 'absent' : 'present',
1813
1824
  completionSemantics: /\b(?:ensure|dam bao)\b/.test(text) ? 'ensure-state' : 'execute-and-verify' } } : {}) };
1814
1825
  }
@@ -1820,6 +1831,7 @@ function applyTaskSemantics(contract, semantic) {
1820
1831
  return { ...(contract || {}), ...semantic,
1821
1832
  completionEvidence: operationOnly ? ['operation-verification']
1822
1833
  : semantic.taskKind === 'MIXED' ? [...new Set([...evidence, 'write-evidence', 'operation-verification'])]
1834
+ : semantic.taskKind === 'READ_ONLY' && semantic.statusReport ? []
1823
1835
  : semantic.taskKind === 'READ_ONLY' ? (evidence.length === 1 && evidence[0] === 'verification-evidence' ? evidence : ['impact-evidence']) : evidence };
1824
1836
  }
1825
1837
 
@@ -3626,17 +3638,48 @@ function applyTaskSemantics(contract, semantic) {
3626
3638
  persisted.routingContext ||= {};
3627
3639
  persisted.routingContext.lastExplicitUserPromptText = previous.routingContext.lastExplicitUserPromptText;
3628
3640
  }
3641
+ if (continuingTask && !persisted.suspendedContract && previous.suspendedContract) {
3642
+ persisted.suspendedContract = previous.suspendedContract;
3643
+ }
3629
3644
  const semantic = continuingTask && previous.routeSummary?.executionContract?.taskKind
3630
3645
  ? previous.routeSummary.executionContract
3631
3646
  : classifyTaskSemantics({ promptText: extractPrompt(payload) || persisted.routingContext?.lastExplicitUserPromptText, targetFile: persisted.routingContext?.targetFile });
3632
3647
  const informationalTurn = !continuingTask && persisted.routingContext?.executionMode === 'informational'
3633
3648
  && semantic.taskKind === 'CODE_IMPLEMENTATION';
3634
3649
  if (informationalTurn) {
3650
+ // An answer-only turn must not enforce carried write debt, but it must not ERASE the
3651
+ // contract either: park it (session- and task-bound) so a continuation or the next
3652
+ // tool event of the same session restores it instead of finding "no contract routed".
3653
+ const parked = previous.routeSummary?.executionContract?.completionEvidence?.length
3654
+ ? { sessionId, taskInstanceId: previous.taskInstanceId, requestKey: previous.requestKey,
3655
+ routeSummary: previous.routeSummary, routingContext: previous.routingContext, ts: Date.now() }
3656
+ : previous.suspendedContract;
3657
+ if (parked) persisted.suspendedContract = parked;
3635
3658
  delete persisted.routeSummary;
3636
3659
  delete persisted.requestKey;
3637
- } else if (semantic.taskKind !== 'CODE_IMPLEMENTATION' || persisted.routeSummary) {
3660
+ } else if (semantic.statusReport && !continuingTask) {
3661
+ // A status/result lookup is a read-only turn with its own task id: it carries no write
3662
+ // debt, but it must not erase a running implementation's contract — park it for `continue`.
3663
+ const prior = previous.routeSummary?.executionContract;
3664
+ const parked = prior && !prior.statusReport && prior.completionEvidence?.length
3665
+ ? { sessionId, taskInstanceId: previous.taskInstanceId, requestKey: previous.requestKey,
3666
+ routeSummary: previous.routeSummary, routingContext: previous.routingContext, ts: Date.now() }
3667
+ : previous.suspendedContract;
3668
+ if (parked) persisted.suspendedContract = parked;
3638
3669
  persisted.routeSummary ||= { executionMode: 'local-build' };
3639
3670
  persisted.routeSummary.executionContract = applyTaskSemantics(
3671
+ persisted.routeSummary.executionContract || buildExecutionContract('local-build'), semantic);
3672
+ delete persisted.routeSummary.completionState;
3673
+ delete persisted.routeSummary.continuationState;
3674
+ } else if (semantic.taskKind !== 'CODE_IMPLEMENTATION' || persisted.routeSummary) {
3675
+ persisted.routeSummary ||= { executionMode: 'local-build' };
3676
+ // A carried status-report contract must not leak its empty evidence / flag onto a
3677
+ // turn that is not itself a status lookup: rebuild the base contract instead.
3678
+ const carriedStatus = persisted.routeSummary.executionContract?.statusReport === true && !semantic.statusReport;
3679
+ if (carriedStatus && persisted.routingContext?.mutationOrder === true) {
3680
+ delete persisted.routeSummary.executionContract;
3681
+ }
3682
+ if (!carriedStatus || persisted.routingContext?.mutationOrder === true) persisted.routeSummary.executionContract = applyTaskSemantics(
3640
3683
  persisted.routeSummary.executionContract || buildExecutionContract(persisted.routeSummary.executionMode === 'informational' ? 'local-build' : persisted.routeSummary.executionMode), semantic);
3641
3684
  if (semantic.taskKind === 'OPERATIONAL' || semantic.taskKind === 'READ_ONLY'
3642
3685
  || (semantic.taskKind === 'CONFIGURATION' && semantic.configurationScope === 'external')) {
@@ -3647,6 +3690,12 @@ function applyTaskSemantics(contract, semantic) {
3647
3690
  persisted.routeSummary.continuationState = buildContinuationState({ completionState: persisted.routeSummary.completionState });
3648
3691
  }
3649
3692
  }
3693
+ // Keep a parked implementation contract alive across further answer/status turns of the
3694
+ // same session (previous is already session-scoped); a fresh implementation route drops it.
3695
+ if (!persisted.suspendedContract && previous.suspendedContract
3696
+ && (!persisted.routeSummary || persisted.routeSummary.executionContract?.statusReport)) {
3697
+ persisted.suspendedContract = previous.suspendedContract;
3698
+ }
3650
3699
  text = JSON.stringify(persisted);
3651
3700
  await writeJsonFileAtomic(statePath, text);
3652
3701
  if (!sessionId) return;
@@ -3668,11 +3717,22 @@ function applyTaskSemantics(contract, semantic) {
3668
3717
  const sidecarPrevious = sessionId && String(storedPrevious?.sessionId || '').trim() !== sessionId
3669
3718
  ? await readJson(path.join(projectRoot, '.ukit', 'storage', 'cache', 'route-state', `${sessionId.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96) || 'default'}.json`), {})
3670
3719
  : {};
3671
- const previous = sessionId
3720
+ let previous = sessionId
3672
3721
  ? [storedPrevious, sidecarPrevious].find((state) => String(state?.sessionId || '').trim() === sessionId) || {}
3673
3722
  : storedPrevious;
3674
3723
  const continuationPrompt = /^(?:continue|resume|keep going|proceed|tiep tuc)(?:\s+(?:the task|this task|previous task))?[.!]?$/i.test(
3675
3724
  extractPrompt(payload).normalize('NFD').replace(/[̀-ͯ]/g, '').trim());
3725
+ // Restore a contract parked by an informational turn: only for the SAME session, and only
3726
+ // on an explicit continuation or a mutating tool event (never a fresh prompt, which routes on its own,
3727
+ // and never a read during an answer/status turn: that must not resurrect write debt).
3728
+ const parkedContract = previous.suspendedContract;
3729
+ if ((!previous.routeSummary || previous.routeSummary?.executionContract?.statusReport) && parkedContract?.routeSummary && parkedContract.taskInstanceId
3730
+ && sessionId && parkedContract.sessionId === sessionId
3731
+ && (continuationPrompt || /^(?:Edit|Write|MultiEdit|NotebookEdit)$/.test(String(payload.tool_name || '')))) {
3732
+ previous = { ...previous, taskInstanceId: parkedContract.taskInstanceId, routeSummary: parkedContract.routeSummary,
3733
+ requestKey: parkedContract.requestKey, routingContext: parkedContract.routingContext || previous.routingContext };
3734
+ delete previous.suspendedContract;
3735
+ }
3676
3736
  const continuingTask = Boolean(previous.taskInstanceId && (payload.tool_name || continuationPrompt));
3677
3737
  const taskInstanceId = continuingTask
3678
3738
  ? previous.taskInstanceId
@@ -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 = batchFindings.map((f) => ({
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
- const outcomeClass = PARSE_CLASSES.has(folded.status) ? folded.status : 'unavailable';
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 built = buildReviewVerdictBatch({ findings, mode });
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
- const config = actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause));
1079
+ && /\bcron\b|scheduled\s+(?:cron\s+)?(?:task|job)|tac vu\s+(?:cron|dinh ky)/.test(clause))
1080
+ && (!codeChangeVerb || hasOperationId);
1081
+ // A config MENTION is not a config change: the same clause needs a mutation verb, and a
1082
+ // request that also changes source/code is a coding task (config is part of its tests/fixtures).
1083
+ const config = !sourceOrder && actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause)
1084
+ && /\b(?:set|change|toggle|enable|disable|switch|turn|configure|modify|update|edit|doi|bat|tat|chinh)\b/.test(clause));
1077
1085
  const repository = Boolean(targetFile) || /[\w.-]+\.(?:json|ya?ml|toml|ini)\b/.test(text);
1078
- const readOnly = /\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text)
1079
- && !sourceOrder && !operation && !config;
1086
+ // A status/result lookup ("check the suite output and report") orders no change: it needs a
1087
+ // lookup verb AND no mutation vocabulary anywhere in the request (fail-closed otherwise).
1088
+ const mutationWord = /\b(?:implement|fix|refactor|modify|edit|write|update|add|create|build|make|install|deploy|remove|delete|rename|replace|change|sua|viet|chinh|them|tao|xoa|doi|cai|cap nhat)\b/.test(text);
1089
+ const statusReport = !mutationWord && !operation && !config
1090
+ && /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua)\b/.test(text);
1091
+ const readOnly = (/\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text) || statusReport)
1092
+ && !sourceOrder && !codeChangeVerb && !operation && !config;
1080
1093
  const taskKind = operation ? (sourceOrder ? 'MIXED' : 'OPERATIONAL')
1081
1094
  : config ? 'CONFIGURATION' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
1082
1095
  const id = text.match(/\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/)?.[0] || null;
1083
- return { taskKind, ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
1096
+ return { taskKind, ...(taskKind === 'READ_ONLY' && statusReport ? { statusReport: true } : {}), ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
1084
1097
  ...(operation ? { operationGoal: { domain: 'scheduled-job', target: id, desiredState: /\b(?:delete|remove|cancel|xoa|huy|absent|removed|deleted|no longer|khong con)\b/.test(actionable.join(' ')) ? 'absent' : 'present',
1085
1098
  completionSemantics: /\b(?:ensure|dam bao)\b/.test(text) ? 'ensure-state' : 'execute-and-verify' } } : {}) };
1086
1099
  }
@@ -1092,5 +1105,6 @@ export function applyTaskSemantics(contract, semantic) {
1092
1105
  return { ...(contract || {}), ...semantic,
1093
1106
  completionEvidence: operationOnly ? ['operation-verification']
1094
1107
  : semantic.taskKind === 'MIXED' ? [...new Set([...evidence, 'write-evidence', 'operation-verification'])]
1108
+ : semantic.taskKind === 'READ_ONLY' && semantic.statusReport ? []
1095
1109
  : semantic.taskKind === 'READ_ONLY' ? (evidence.length === 1 && evidence[0] === 'verification-evidence' ? evidence : ['impact-evidence']) : evidence };
1096
1110
  }
@@ -73,6 +73,7 @@ import {
73
73
  import { applyMeasuredTier, loadTierMap } from './tier-selection.mjs';
74
74
  import { resolvePlaybookRoute, loadRegistry, deriveRouteOrchestrationSignals } from './playbook-registry.mjs';
75
75
  import { extractHistorySignalsAsync } from './session-history-extractor.mjs';
76
+ import { carryProgress, buildDecompositionPlan, estimateContextWindow, MIN_AUTO_SPLIT_CONFIDENCE } from '../runtime/context-budget-policy.mjs';
76
77
  import {
77
78
  applyFixLoopEscalation,
78
79
  isFixLoopEscalationDue,
@@ -1060,6 +1061,7 @@ export async function routeTask(input = {}, { signal = null, deadlineMs = null,
1060
1061
  preparedRoute,
1061
1062
  projectVerification,
1062
1063
  cachedContextResult: helperCacheSnapshot.contextResult,
1064
+ transcriptPath: normalizedInput.transcriptPath ?? process.env.UKIT_TRANSCRIPT_PATH ?? null,
1063
1065
  cachedVerificationPlan: helperCacheSnapshot.verificationPlan,
1064
1066
  useIndexedContext,
1065
1067
  previousContextSnapshot,
@@ -1390,6 +1392,7 @@ async function main() {
1390
1392
  commandNamespace,
1391
1393
  preparedRoute,
1392
1394
  projectVerification,
1395
+ transcriptPath,
1393
1396
  cachedContextResult: helperCacheSnapshot.contextResult,
1394
1397
  cachedVerificationPlan: helperCacheSnapshot.verificationPlan,
1395
1398
  useIndexedContext,
@@ -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
- const questions = buildShadowDecisionQuestions(config);
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 receipt;
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
  }