@ngockhoale/ukit 3.4.30 → 3.4.32

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.
Files changed (27) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/manifests/documentation.yaml +12 -0
  3. package/package.json +1 -1
  4. package/src/core/executionContracts.js +32 -0
  5. package/src/decision/client.js +15 -0
  6. package/src/decision/protocol.js +12 -14
  7. package/src/decision/reviewVerdict.js +16 -2
  8. package/src/index/fixLoopEscalation.js +10 -4
  9. package/src/index/routeResolver.js +2 -2
  10. package/src/index/taskRouting.js +3 -1
  11. package/template_project/.claude/hooks/record-execution.mjs +1 -3
  12. package/template_project/.claude/hooks/skill-router.sh +82 -9
  13. package/template_project/.claude/settings.json +22 -0
  14. package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +10 -4
  15. package/template_project/.claude/ukit/index/review-verdict.mjs +48 -9
  16. package/template_project/.claude/ukit/index/route-resolver.mjs +34 -2
  17. package/template_project/.claude/ukit/index/route-task.mjs +88 -9
  18. package/template_project/.claude/ukit/index/sidecar-decision.mjs +6 -117
  19. package/template_project/.claude/ukit/index/unic-decision.mjs +27 -14
  20. package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +43 -0
  21. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +160 -33
  22. package/template_project/.claude/ukit/runtime/loop-state.mjs +1 -1
  23. package/template_project/.claude/ukit/runtime/operation-evidence.mjs +87 -0
  24. package/template_project/.omp/hooks/pre/ukit-bridge.js +8 -1
  25. package/template_project/AGENTS.md +1 -1
  26. package/template_project/CLAUDE.md +1 -1
  27. package/template_project/instructions/core.md +1 -1
package/CHANGELOG.md CHANGED
@@ -9,6 +9,29 @@ All notable changes to UKit are documented here.
9
9
  - New `/ukit:ci-check` command + `ci-check.mjs` helper (core pack): prechecks, fail-closed budget guard, local preflight, at most one dispatch per invocation, write-ahead run-id state, `status` (never dispatches), `--ref`, `--dry-run`, per-OS report. Never pushes, merges or publishes.
10
10
  - Default-branch bootstrap stub documented in `docs/design/CI-CROSS-PLATFORM-MATRIX.md` (requires separate approval).
11
11
 
12
+ ## 3.4.32 - 2026-10-11
13
+
14
+ **UNIC-Decision integration P1: safety floors, ordinal score contract, request caps.** Verified with the full suite (566 files) and `yarn release:verify`; no live inference was run (offline fixtures plus an offline run of the Laya server translator).
15
+ - **Sidecar is deterministic-only.** The six `workflow.*` sidecar decisions always return the deterministic answer at every stage; no adapter call is made. `modelAnswer`/`agreement` are no longer emitted; `--fixture`, `--list` and the result schema keys are unchanged.
16
+ - **Escalation floors.** Fix-loop escalation never lowers verification depth (depth = max(deterministic floor, model), order `none<runnable<review-round<escalate`), never swaps the lane, and never applies model output at `shadow`. Fixed in both the template and the live `src/index/fixLoopEscalation.js`.
17
+ - **Review safety floor.** The verdict floor always runs over all findings: critical → `CHANGES-REQUESTED`; important/unknown → at least `APPROVED-WITH-MINOR`; `Act on` buckets cannot be downgraded; `shadow` keeps the deterministic verdict.
18
+ - **Score contract.** Ordinal `score` questions are sent as a string enum of ordered labels (rubric in the function description) and decoded as `indexOf(label)+1`; numeric answers are rejected. Wire format of `route.rigor.v1`/`preflight.rigor.v1` is now labels (`r0`..`r4`); parsed values stay integer 1..N.
19
+ - **Request caps.** `decisionPlane.maxBatchQuestions` (default 8) and `maxEnumLabels` (default 11) are enforced before transport (`unsupported`, deterministic fallback); duplicate question keys are `invalid`. Route shadow and review use partial coverage in ONE batch: skipped decisions are reported (`coverage`, `skippedDecisions`, `outcomeClass: partial`) and keep deterministic results; no request splitting.
20
+ - Limits: oversize requests outside route shadow/review fall back deterministically; escalation lane model answers are never applied; the server-side `unic-decision` model alias is unchanged. Details: `docs/UNIC-DECISION-INTEGRATION-P1-FIX.md`.
21
+
22
+ ## 3.4.31 - 2026-10-11
23
+
24
+ **Completion Gate: task-aware operational completion (fixes the CronDelete/CronList false positive).** Verified with the full suite (`yarn test`, 566 files) plus focused six-scenario routing → recorder → Stop regressions.
25
+ - **Task kinds.** CODE_IMPLEMENTATION / OPERATIONAL / READ_ONLY / CONFIGURATION / MIXED are classified independently of write activity. Implementation still requires a source write plus risk-based verification; mixed tasks keep both obligations.
26
+ - **Operational evidence.** New bounded, tool-agnostic proof bank (`operation-evidence.mjs`) and strict native-result normalization (`cron-operation-adapter.mjs`): completion needs an accepted execution, matching target/scope, a fresh verified postcondition and active-task evidence. Unsupported result shapes fail closed.
27
+ - **Task-instance isolation.** Each request gets its own task identity; identical repeated requests are distinct tasks, while continue/resume keeps identity. Evidence and journals from an earlier task cannot satisfy a new one.
28
+ - **Receipt retention / replay protection.** Failed operations survive receipt eviction; native tool-use IDs are tracked (bounded, fail-closed on saturation) so an overwritten observation cannot replay as fresh proof.
29
+ - **Claude Code / OMP.** The Claude recorder and settings matchers cover `CronDelete`/`CronList` success and failure events; the OMP bridge normalizes `cron_delete`/`cron_list` results. Codex uses the shared policy helper (no native Stop enforcement claimed).
30
+ - **Negation-aware classification.** "Xóa cron …, không thay đổi code, Git hoặc Docker" / "Delete cron …. Do not modify source code, Git or Docker." classify as OPERATIONAL; negated source instructions no longer become source requirements.
31
+ - **No-route mutation.** A prompt that ordered a change but matched no skill (no routed contract) now ends with an explicit "nothing was verified" notice instead of a silent stop. This is a notice, not a block — a known limitation.
32
+ - Loop-breaker release note no longer claims no verification ran; docs retry-guidance wording aligned in the template instructions.
33
+ - Limits: classifier is lexical; the Cron adapter does not support an "ensure presence" goal (fails closed); no live native end-to-end run was performed.
34
+
12
35
  ## 3.4.30 - 2026-10-10
13
36
 
14
37
  **Orchestration: routing, goal/cycle continuation, task ownership, discovery, landing.** Verified with `yarn test:handoff` (184/184) and `yarn release:verify`; live Claude Code two-cycle run-all and the Claude create-only permission denial were exercised on disposable fixtures. Known limits: OMP permission denial is verified through the hook bridge only, and Codex has no pre-tool hook for ownership enforcement. No performance gain is claimed beyond removing the identified redundant Stop-denial / archive-repair round.
@@ -933,6 +933,18 @@ entries:
933
933
  archive_policy: never
934
934
  notes: C69 G7 operator doc — timeline render, support bundle contents/exclusions, replay limits
935
935
 
936
+ - id: docs-unic-decision-integration-p1-fix
937
+ path: docs/UNIC-DECISION-INTEGRATION-P1-FIX.md
938
+ class: canonical
939
+ audience: [maintainer]
940
+ owner: runtime
941
+ source_of_truth: docs/UNIC-DECISION-INTEGRATION-P1-FIX.md
942
+ merge_strategy: none
943
+ load_policy: on-demand
944
+ validation: [manual]
945
+ archive_policy: never
946
+ notes: P1 safety-floor / ordinal choice score / request-cap fix record for the unic-decision integration (9971eac0)
947
+
936
948
  - id: docs-design-ci-cross-platform-matrix
937
949
  path: docs/design/CI-CROSS-PLATFORM-MATRIX.md
938
950
  class: canonical
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.30",
3
+ "version": "3.4.32",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -166,3 +166,35 @@ export function buildConfigContracts() {
166
166
  Object.entries(EXECUTION_CONTRACTS).map(([mode, contract]) => [mode, stripContractForConfig(contract)])
167
167
  );
168
168
  }
169
+
170
+ // Task semantics are independent of source execution strategy and observed progress.
171
+ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
172
+ const text = String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd').toLowerCase();
173
+ const clauses = text.split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/);
174
+ const actionable = clauses.map((clause) => clause.split(/\bwithout\b/)[0]).filter((clause) => !/\b(?:do not|don't|never|khong|dung)\b/.test(clause)
175
+ && !/^\s*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause));
176
+ const sourceOrder = actionable.some((clause) => /\b(?:implement|fix|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
177
+ && /\b(?:source|code|logic|scheduler|implementation|ma nguon)\b|\.[cm]?[jt]sx?\b/.test(clause));
178
+ const operation = actionable.some((clause) => /\b(?:delete|remove|cancel|xoa|huy|ensure|dam bao)\b/.test(clause)
179
+ && /\bcron\b|scheduled\s+(?:cron\s+)?(?:task|job)|tac vu\s+(?:cron|dinh ky)/.test(clause));
180
+ const config = actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause));
181
+ const repository = Boolean(targetFile) || /[\w.-]+\.(?:json|ya?ml|toml|ini)\b/.test(text);
182
+ const readOnly = /\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text)
183
+ && !sourceOrder && !operation && !config;
184
+ const taskKind = operation ? (sourceOrder ? 'MIXED' : 'OPERATIONAL')
185
+ : config ? 'CONFIGURATION' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
186
+ const id = text.match(/\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/)?.[0] || null;
187
+ return { taskKind, ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
188
+ ...(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',
189
+ completionSemantics: /\b(?:ensure|dam bao)\b/.test(text) ? 'ensure-state' : 'execute-and-verify' } } : {}) };
190
+ }
191
+
192
+ export function applyTaskSemantics(contract, semantic) {
193
+ const evidence = contract?.completionEvidence || [];
194
+ const operationOnly = semantic.taskKind === 'OPERATIONAL'
195
+ || (semantic.taskKind === 'CONFIGURATION' && semantic.configurationScope === 'external');
196
+ return { ...(contract || {}), ...semantic,
197
+ completionEvidence: operationOnly ? ['operation-verification']
198
+ : semantic.taskKind === 'MIXED' ? [...new Set([...evidence, 'write-evidence', 'operation-verification'])]
199
+ : semantic.taskKind === 'READ_ONLY' ? (evidence.length === 1 && evidence[0] === 'verification-evidence' ? evidence : ['impact-evidence']) : evidence };
200
+ }
@@ -368,6 +368,21 @@ export function createDecisionClient({
368
368
  return outcome('invalid', { fallbackCode: 'empty-batch' });
369
369
  }
370
370
 
371
+ const maxBatch = dp.maxBatchQuestions ?? 8;
372
+ const maxEnum = dp.maxEnumLabels ?? 11;
373
+ if (![maxBatch, maxEnum].every(n => Number.isInteger(n) && n > 0)) {
374
+ return outcome('unsupported', { fallbackCode: 'invalid-decision-limits' });
375
+ }
376
+ if (questions.length > maxBatch) {
377
+ return outcome('unsupported', { fallbackCode: 'max-batch-questions' });
378
+ }
379
+ if (questions.some(q => Array.isArray(q.candidates) && q.candidates.length > maxEnum)) {
380
+ return outcome('unsupported', { fallbackCode: 'max-enum-labels' });
381
+ }
382
+ if (new Set(questions.map(q => q.decisionKey)).size !== questions.length) {
383
+ return outcome('invalid', { fallbackCode: 'duplicate-question' });
384
+ }
385
+
371
386
  // Language classification covers state + question text; the English
372
387
  // checkpoint is reachable only when the whole request proves English.
373
388
  // Malformed batch input (cyclic state, non-serializable values, internal
@@ -51,23 +51,20 @@ export function encodeQuestion(question) {
51
51
  description: 'true iff the proposition holds; interpreted as P(true).',
52
52
  };
53
53
  } else {
54
- // score — ordinal integer; the legend MUST be defined in the description.
55
- const legend = Array.isArray(candidates) ? candidates : [];
56
- value = { type: 'integer' };
57
- if (legend.length > 0) {
58
- value.minimum = 1;
59
- value.maximum = legend.length;
60
- value.description = `Ordered scale: ${legend
61
- .map((label, i) => `${i + 1}=${label}`)
62
- .join(', ')}.`;
54
+ if (!Array.isArray(candidates) || candidates.length === 0
55
+ || candidates.some(label => typeof label !== 'string' || !label.trim())
56
+ || new Set(candidates).size !== candidates.length) {
57
+ throw new TypeError('score requires unique non-empty ordinal labels.');
63
58
  }
59
+ value = { type: 'string', enum: [...candidates] };
64
60
  }
65
61
 
66
62
  return {
67
63
  type: 'function',
68
64
  function: {
69
65
  name: decisionKey,
70
- description: String(instruction ?? ''),
66
+ description: String(instruction ?? '') + (kind === 'score'
67
+ ? `\nOrdered ordinal rubric: ${candidates.map((label, i) => `${i + 1}=${JSON.stringify(label)}`).join(', ')}. Choose exactly one label; do not return an expected numeric score.` : ''),
71
68
  parameters: {
72
69
  type: 'object',
73
70
  properties: { value },
@@ -184,11 +181,12 @@ function validateValue(question, args) {
184
181
  if (isFiniteNumber(value) && value >= 0 && value <= 1) return { value };
185
182
  return 'missing-value';
186
183
  }
187
- // score — finite integer within the declared legend range.
188
- if (!isFiniteNumber(value) || !Number.isInteger(value)) return 'missing-value';
184
+ // Decode an explicit choice losslessly; never reinterpret native expectations.
189
185
  const legend = Array.isArray(question.candidates) ? question.candidates : [];
190
- if (legend.length > 0 && (value < 1 || value > legend.length)) return 'out-of-enum';
191
- return { value };
186
+ if (!legend.length || new Set(legend).size !== legend.length) return 'out-of-enum';
187
+ if (typeof value !== 'string') return 'missing-value';
188
+ const ordinal = legend.indexOf(value);
189
+ return ordinal < 0 ? 'out-of-enum' : { value: ordinal + 1 };
192
190
  }
193
191
 
194
192
  /**
@@ -135,7 +135,7 @@ function stableBatchId(findings) {
135
135
  * findings: object[], verdictDecisionKey: string}}
136
136
  * Never throws — garbage input sanitizes to an empty finding list.
137
137
  */
138
- export function buildReviewVerdictBatch({ findings = [], mode = 'solo' } = {}) {
138
+ export function buildReviewVerdictBatch({ findings = [], mode = 'solo', maxQuestions = Infinity } = {}) {
139
139
  const usedIds = new Set();
140
140
  // Sanitize the FULL finding set — MAX_FINDINGS is a display/transport bound
141
141
  // for the batch, not a verdict bound. The deterministic verdict and the
@@ -145,6 +145,19 @@ export function buildReviewVerdictBatch({ findings = [], mode = 'solo' } = {}) {
145
145
  const batchFindings = sanitized.slice(0, MAX_FINDINGS);
146
146
  const truncated = sanitized.length > batchFindings.length;
147
147
 
148
+ // Request-cap handling (no extra call): when verdict + one bucket question per
149
+ // finding would exceed `maxQuestions`, ask only findings whose bucket the model
150
+ // can actually change. `Act on` is a deterministic floor the model cannot
151
+ // lower, so critical/important/unknown findings carry no semantic value in the
152
+ // batch; the rest keep their deterministic bucket and are reported as skipped.
153
+ const slots = Number.isInteger(maxQuestions) && maxQuestions > 0
154
+ ? maxQuestions - 1 : Infinity;
155
+ const askedFindings = batchFindings.length <= slots
156
+ ? batchFindings
157
+ : batchFindings.filter((f) => deriveDeterministicBucket(f) !== 'Act on').slice(0, slots);
158
+ const askedIds = new Set(askedFindings.map((f) => f.id));
159
+ const skippedFindingIds = batchFindings.filter((f) => !askedIds.has(f.id)).map((f) => f.id);
160
+
148
161
  const verdictDecisionKey = mode === 'panel'
149
162
  ? REVIEW_VERDICT_KEYS.panel
150
163
  : REVIEW_VERDICT_KEYS.solo;
@@ -169,7 +182,7 @@ export function buildReviewVerdictBatch({ findings = [], mode = 'solo' } = {}) {
169
182
  // One bucket question per finding. The shared registered key cannot repeat
170
183
  // in one batch (name-based reconciliation), so each wire name carries
171
184
  // `#<findingId>`; parseVerdictAnswer splits it back off.
172
- const bucketQuestions = batchFindings.map((f) => ({
185
+ const bucketQuestions = askedFindings.map((f) => ({
173
186
  decisionKey: `${REVIEW_VERDICT_KEYS.bucket}#${f.id}`,
174
187
  schemaVersion: 1,
175
188
  family: 'review',
@@ -207,6 +220,7 @@ export function buildReviewVerdictBatch({ findings = [], mode = 'solo' } = {}) {
207
220
  findings: batchFindings,
208
221
  fullFindings: sanitized,
209
222
  truncated,
223
+ skippedFindingIds,
210
224
  verdictDecisionKey,
211
225
  };
212
226
  }
@@ -394,14 +394,20 @@ export async function applyFixLoopEscalation({
394
394
  : null;
395
395
  // A model lane that refuses to deepen is not an escalation answer — the
396
396
  // deterministic deeper lane wins so the loop actually exits its lane.
397
- const usableModelLane = modelLane && modelLane !== currentLane ? modelLane : null;
398
- const lane = usableModelLane ?? deterministicLane;
397
+ // Lanes are capabilities, not an ordinal ladder. Without a validated policy
398
+ // relation only the deterministic destination is admissible.
399
+ const usableModelLane = planeStage !== 'shadow' && modelLane === deterministicLane
400
+ ? modelLane : null;
401
+ const lane = deterministicLane;
399
402
  const endpointDown = batchResult !== null
400
403
  && batchResult.status !== 'accepted'
401
404
  && batchResult.status !== 'partial'
402
405
  && !usableModelLane;
403
- const depth = (modelDepth && modelDepth !== 'none' ? modelDepth : null)
404
- ?? resolveEscalationVerificationDepth({ routeSummary, config });
406
+ const depthFloor = resolveEscalationVerificationDepth({ routeSummary, config });
407
+ const depthOrder = ['none', 'runnable', 'review-round', 'escalate'];
408
+ const depth = planeStage !== 'shadow'
409
+ && depthOrder.indexOf(modelDepth) > depthOrder.indexOf(depthFloor)
410
+ ? modelDepth : depthFloor;
405
411
 
406
412
  const reason = [
407
413
  `fix-loop: fixLoopCount=${fixLoopCount} threshold=${settings.debugLoopThreshold} fingerprint-joined`,
@@ -509,12 +509,12 @@ function unique(values) {
509
509
 
510
510
  // Completion contract for a lane. Informational routes carry no completion debt;
511
511
  // every contract lane returns the still-missing evidence classes plus a reason.
512
- export function buildCompletionState({ executionMode = null, verificationRecommendation = null } = {}) {
512
+ export function buildCompletionState({ executionMode = null, verificationRecommendation = null, executionContract = null } = {}) {
513
513
  if (!executionMode || executionMode === 'informational') {
514
514
  return null;
515
515
  }
516
516
 
517
- const contract = buildExecutionContract(executionMode);
517
+ const contract = executionContract || buildExecutionContract(executionMode);
518
518
  const missingEvidence = [...(contract?.completionEvidence ?? [])];
519
519
  const requiresVerification = missingEvidence.includes('verification-evidence');
520
520
  if (
@@ -1,3 +1,4 @@
1
+ import { classifyTaskSemantics, applyTaskSemantics } from '../core/executionContracts.js';
1
2
  import fs from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
 
@@ -669,10 +670,11 @@ export function buildRouteSummary({
669
670
  executionScores,
670
671
  executionCandidates,
671
672
  });
672
- const executionContract = buildExecutionContract(executionMode);
673
+ const executionContract = applyTaskSemantics(buildExecutionContract(executionMode), classifyTaskSemantics({ promptText: routingContext.lastExplicitUserPromptText || routingContext.promptText, targetFile: routingContext.targetFile }));
673
674
  const continuousExecution = buildContinuousExecutionPolicy(autonomyLevel);
674
675
  const completionState = buildCompletionState({
675
676
  executionMode,
677
+ executionContract,
676
678
  verificationRecommendation,
677
679
  });
678
680
  const continuationState = buildContinuationState({
@@ -68,9 +68,7 @@ async function releaseDelegatedClaims(payload, projectRoot) {
68
68
  async function record({ rawInput, projectRoot, env, signal, deadlineMs }) {
69
69
  const payload = parsePayload(rawInput) ?? {};
70
70
  await releaseDelegatedClaims(payload, projectRoot);
71
- // A failed delegation never reaches PostToolUse; the claim is already handed back above and
72
- // there is no successful receipt to record.
73
- if (payload.hook_event_name === 'PostToolUseFailure') return { code: 0, stdout: '', stderr: '' };
71
+ // Failed native invocations retain their real identity and never mint success evidence.
74
72
  const ledgerPath = env.UKIT_EXECUTION_LEDGER_PATH || DEFAULT_LEDGER_PATH;
75
73
  let ledger;
76
74
  try {
@@ -1790,6 +1790,39 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
1790
1790
  return null;
1791
1791
  }
1792
1792
 
1793
+
1794
+ // Task semantics are independent of source execution strategy and observed progress.
1795
+ function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
1796
+ const text = String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd').toLowerCase();
1797
+ const clauses = text.split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/);
1798
+ const actionable = clauses.map((clause) => clause.split(/\bwithout\b/)[0]).filter((clause) => !/\b(?:do not|don't|never|khong|dung)\b/.test(clause)
1799
+ && !/^\s*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause));
1800
+ const sourceOrder = actionable.some((clause) => /\b(?:implement|fix|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
1801
+ && /\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));
1805
+ 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')
1809
+ : config ? 'CONFIGURATION' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
1810
+ 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',
1813
+ completionSemantics: /\b(?:ensure|dam bao)\b/.test(text) ? 'ensure-state' : 'execute-and-verify' } } : {}) };
1814
+ }
1815
+
1816
+ function applyTaskSemantics(contract, semantic) {
1817
+ const evidence = contract?.completionEvidence || [];
1818
+ const operationOnly = semantic.taskKind === 'OPERATIONAL'
1819
+ || (semantic.taskKind === 'CONFIGURATION' && semantic.configurationScope === 'external');
1820
+ return { ...(contract || {}), ...semantic,
1821
+ completionEvidence: operationOnly ? ['operation-verification']
1822
+ : semantic.taskKind === 'MIXED' ? [...new Set([...evidence, 'write-evidence', 'operation-verification'])]
1823
+ : semantic.taskKind === 'READ_ONLY' ? (evidence.length === 1 && evidence[0] === 'verification-evidence' ? evidence : ['impact-evidence']) : evidence };
1824
+ }
1825
+
1793
1826
  function buildRouteSummary({
1794
1827
  activeSkills = [],
1795
1828
  routingContext = {},
@@ -1830,9 +1863,10 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
1830
1863
  executionScores,
1831
1864
  executionCandidates,
1832
1865
  });
1833
- const executionContract = buildExecutionContract(executionMode);
1866
+ const executionContract = applyTaskSemantics(buildExecutionContract(executionMode), classifyTaskSemantics({ promptText: routingContext.lastExplicitUserPromptText || routingContext.promptText, targetFile: routingContext.targetFile }));
1834
1867
  const completionState = buildCompletionState({
1835
1868
  executionMode,
1869
+ executionContract,
1836
1870
  verificationRecommendation,
1837
1871
  });
1838
1872
  const continuationState = buildContinuationState({
@@ -2698,12 +2732,12 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2698
2732
  };
2699
2733
  }
2700
2734
 
2701
- function buildCompletionState({ executionMode = null, verificationRecommendation = null } = {}) {
2735
+ function buildCompletionState({ executionMode = null, verificationRecommendation = null, executionContract = null } = {}) {
2702
2736
  if (!executionMode || executionMode === 'informational') {
2703
2737
  return null;
2704
2738
  }
2705
2739
 
2706
- const contract = buildExecutionContract(executionMode);
2740
+ const contract = executionContract || buildExecutionContract(executionMode);
2707
2741
  const missingEvidence = [...(contract?.completionEvidence ?? [])];
2708
2742
  const requiresVerification = missingEvidence.includes('verification-evidence');
2709
2743
  if (
@@ -3586,6 +3620,34 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
3586
3620
  // after this one overwrites the shared file, and this session's Stop would then see a
3587
3621
  // foreign owner and report its route as lost. Best-effort — the shared write is authoritative.
3588
3622
  async function persistRouteState(text) {
3623
+ const persisted = JSON.parse(text);
3624
+ persisted.taskInstanceId = taskInstanceId;
3625
+ if (continuingTask && previous.routingContext?.lastExplicitUserPromptText) {
3626
+ persisted.routingContext ||= {};
3627
+ persisted.routingContext.lastExplicitUserPromptText = previous.routingContext.lastExplicitUserPromptText;
3628
+ }
3629
+ const semantic = continuingTask && previous.routeSummary?.executionContract?.taskKind
3630
+ ? previous.routeSummary.executionContract
3631
+ : classifyTaskSemantics({ promptText: extractPrompt(payload) || persisted.routingContext?.lastExplicitUserPromptText, targetFile: persisted.routingContext?.targetFile });
3632
+ const informationalTurn = !continuingTask && persisted.routingContext?.executionMode === 'informational'
3633
+ && semantic.taskKind === 'CODE_IMPLEMENTATION';
3634
+ if (informationalTurn) {
3635
+ delete persisted.routeSummary;
3636
+ delete persisted.requestKey;
3637
+ } else if (semantic.taskKind !== 'CODE_IMPLEMENTATION' || persisted.routeSummary) {
3638
+ persisted.routeSummary ||= { executionMode: 'local-build' };
3639
+ persisted.routeSummary.executionContract = applyTaskSemantics(
3640
+ persisted.routeSummary.executionContract || buildExecutionContract(persisted.routeSummary.executionMode === 'informational' ? 'local-build' : persisted.routeSummary.executionMode), semantic);
3641
+ if (semantic.taskKind === 'OPERATIONAL' || semantic.taskKind === 'READ_ONLY'
3642
+ || (semantic.taskKind === 'CONFIGURATION' && semantic.configurationScope === 'external')) {
3643
+ persisted.routeSummary.completionState = buildCompletionState({
3644
+ executionMode: persisted.routeSummary.executionMode === 'informational' ? 'local-build' : persisted.routeSummary.executionMode,
3645
+ executionContract: persisted.routeSummary.executionContract,
3646
+ });
3647
+ persisted.routeSummary.continuationState = buildContinuationState({ completionState: persisted.routeSummary.completionState });
3648
+ }
3649
+ }
3650
+ text = JSON.stringify(persisted);
3589
3651
  await writeJsonFileAtomic(statePath, text);
3590
3652
  if (!sessionId) return;
3591
3653
  try {
@@ -3602,13 +3664,19 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
3602
3664
  } catch {}
3603
3665
  }
3604
3666
  const storedPrevious = await readJson(statePath, {});
3605
- // Route state is project-local, while hook ledgers are session-local. Do not let a
3606
- // differently-bound snapshot become the contract for a new session.
3667
+ // The shared slot may belong to another session; recover only our owned sidecar.
3668
+ const sidecarPrevious = sessionId && String(storedPrevious?.sessionId || '').trim() !== sessionId
3669
+ ? await readJson(path.join(projectRoot, '.ukit', 'storage', 'cache', 'route-state', `${sessionId.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96) || 'default'}.json`), {})
3670
+ : {};
3607
3671
  const previous = sessionId
3608
- ? (storedPrevious?.sessionId && String(storedPrevious.sessionId).trim() === sessionId
3609
- ? storedPrevious
3610
- : {})
3672
+ ? [storedPrevious, sidecarPrevious].find((state) => String(state?.sessionId || '').trim() === sessionId) || {}
3611
3673
  : storedPrevious;
3674
+ const continuationPrompt = /^(?:continue|resume|keep going|proceed|tiep tuc)(?:\s+(?:the task|this task|previous task))?[.!]?$/i.test(
3675
+ extractPrompt(payload).normalize('NFD').replace(/[̀-ͯ]/g, '').trim());
3676
+ const continuingTask = Boolean(previous.taskInstanceId && (payload.tool_name || continuationPrompt));
3677
+ const taskInstanceId = continuingTask
3678
+ ? previous.taskInstanceId
3679
+ : `task-${crypto.randomBytes(16).toString("hex")}`;
3612
3680
  const runtimeConfig = await loadRuntimeConfig(projectRoot);
3613
3681
  // BL-013: the hook/bridge payload's transcript pointer (ukit-bridge.js:217,228
3614
3682
  // forwards transcriptPath; Claude Code payloads carry transcript_path). The
@@ -3745,7 +3813,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
3745
3813
  });
3746
3814
 
3747
3815
  if (
3748
- previous?.fingerprint === fingerprint
3816
+ payload.tool_name && previous?.fingerprint === fingerprint
3749
3817
  && !previous?.routeSummary
3750
3818
  && Array.isArray(previous?.activeSkills)
3751
3819
  && previous.activeSkills.length === 0
@@ -4084,6 +4152,11 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
4084
4152
  });
4085
4153
 
4086
4154
  if (previous?.fingerprint === fingerprint && now - previousTs < debounceMs) {
4155
+ await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
4156
+ routingContext,
4157
+ routeSummary,
4158
+ state: { requestKey, fingerprint, source: 'skill-router' },
4159
+ }));
4087
4160
  process.exit(0);
4088
4161
  }
4089
4162
 
@@ -101,6 +101,16 @@
101
101
  }
102
102
  ],
103
103
  "PostToolUse": [
104
+ {
105
+ "matcher": "CronDelete|CronList",
106
+ "hooks": [
107
+ {
108
+ "type": "command",
109
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.mjs\":8",
110
+ "timeout": 18
111
+ }
112
+ ]
113
+ },
104
114
  {
105
115
  "matcher": "Read|Grep|Glob",
106
116
  "hooks": [
@@ -142,6 +152,18 @@
142
152
  ]
143
153
  }
144
154
  ],
155
+ "PostToolUseFailure": [
156
+ {
157
+ "matcher": ".*",
158
+ "hooks": [
159
+ {
160
+ "type": "command",
161
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.mjs\":8",
162
+ "timeout": 18
163
+ }
164
+ ]
165
+ }
166
+ ],
145
167
  "UserPromptSubmit": [
146
168
  {
147
169
  "hooks": [
@@ -394,14 +394,20 @@ export async function applyFixLoopEscalation({
394
394
  : null;
395
395
  // A model lane that refuses to deepen is not an escalation answer — the
396
396
  // deterministic deeper lane wins so the loop actually exits its lane.
397
- const usableModelLane = modelLane && modelLane !== currentLane ? modelLane : null;
398
- const lane = usableModelLane ?? deterministicLane;
397
+ // Lanes are capabilities, not an ordinal ladder. Without a validated policy
398
+ // relation only the deterministic destination is admissible.
399
+ const usableModelLane = planeStage !== 'shadow' && modelLane === deterministicLane
400
+ ? modelLane : null;
401
+ const lane = deterministicLane;
399
402
  const endpointDown = batchResult !== null
400
403
  && batchResult.status !== 'accepted'
401
404
  && batchResult.status !== 'partial'
402
405
  && !usableModelLane;
403
- const depth = (modelDepth && modelDepth !== 'none' ? modelDepth : null)
404
- ?? resolveEscalationVerificationDepth({ routeSummary, config });
406
+ const depthFloor = resolveEscalationVerificationDepth({ routeSummary, config });
407
+ const depthOrder = ['none', 'runnable', 'review-round', 'escalate'];
408
+ const depth = planeStage !== 'shadow'
409
+ && depthOrder.indexOf(modelDepth) > depthOrder.indexOf(depthFloor)
410
+ ? modelDepth : depthFloor;
405
411
 
406
412
  const reason = [
407
413
  `fix-loop: fixLoopCount=${fixLoopCount} threshold=${settings.debugLoopThreshold} fingerprint-joined`,
@@ -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
 
@@ -407,6 +422,25 @@ const VERDICT_SEVERITY_RANK = {
407
422
 
408
423
  function foldBatchResult(result, built, mode, stage) {
409
424
  const folded = parseVerdictAnswer(result, { findings: built.findings });
425
+ if (stage === 'shadow') {
426
+ return deterministicResult({ mode, stage, outcomeClass: folded.status,
427
+ findings: built.findings, batchId: built.batch.batchId,
428
+ fallbackCode: 'shadow-observation-only',
429
+ verdictOverride: deriveDeterministicVerdict(built.fullFindings ?? built.findings) });
430
+ }
431
+ const skipped = new Set(built.skippedFindingIds ?? []);
432
+ folded.buckets = folded.buckets.map((bucket, index) => {
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
+ }
437
+ return floor === 'Act on' && bucket.bucket !== floor
438
+ ? { ...bucket, bucket: floor, bucketStatus: 'clamp:safety-floor' } : bucket;
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;
410
444
  const modelVerdict = folded.verdict;
411
445
  // W3-RV1: the batch may be display-truncated at MAX_FINDINGS — the model
412
446
  // verdict was computed on `built.findings` only, so when truncation
@@ -415,28 +449,29 @@ function foldBatchResult(result, built, mode, stage) {
415
449
  const fullVerdict = deriveDeterministicVerdict(
416
450
  built.fullFindings ?? built.findings,
417
451
  );
418
- const clamped = built.truncated
419
- && modelVerdict !== null
452
+ const clamped = modelVerdict !== null
420
453
  && (VERDICT_SEVERITY_RANK[fullVerdict] ?? 0)
421
454
  > (VERDICT_SEVERITY_RANK[modelVerdict] ?? 0);
422
455
  // Parse-level classes (accepted/partial/invalid/abstained) pass through;
423
456
  // every transport/gateway class surfaces as 'unavailable' — the fallback
424
457
  // code carries the finer-grained reason for receipts.
425
458
  const PARSE_CLASSES = new Set(['accepted', 'partial', 'invalid', 'abstained']);
426
- 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';
427
461
  return emitVerdict({
462
+ coverage,
428
463
  mode,
429
464
  stage,
430
465
  outcomeClass,
431
466
  verdict: clamped ? fullVerdict : (modelVerdict ?? fullVerdict),
432
467
  verdictStatus: modelVerdict === null
433
468
  ? `fallback:${folded.verdictStatus}`
434
- : (clamped ? 'clamp:truncated-findings' : folded.verdictStatus),
469
+ : (clamped ? 'clamp:safety-floor' : folded.verdictStatus),
435
470
  verdictSource: modelVerdict === null || clamped ? 'deterministic' : 'model',
436
471
  buckets: folded.buckets,
437
472
  batchId: built.batch.batchId,
438
473
  fallbackCode: clamped
439
- ? 'truncated-findings'
474
+ ? 'safety-floor'
440
475
  : (modelVerdict === null
441
476
  ? (result?.fallbackCode ?? folded.status)
442
477
  : null),
@@ -450,7 +485,11 @@ function foldBatchResult(result, built, mode, stage) {
450
485
  */
451
486
  export async function runReviewVerdict({ findings, mode = 'solo', config,
452
487
  projectRoot, homeDir, env, transport } = {}) {
453
- 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
+ });
454
493
  const normalizedMode = mode === 'panel' ? 'panel' : 'solo';
455
494
  const stage = resolveReviewStage(config);
456
495