@ngockhoale/ukit 3.4.30 → 3.4.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,19 @@ 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.31 - 2026-10-11
13
+
14
+ **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.
15
+ - **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.
16
+ - **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.
17
+ - **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.
18
+ - **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.
19
+ - **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).
20
+ - **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.
21
+ - **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.
22
+ - Loop-breaker release note no longer claims no verification ran; docs retry-guidance wording aligned in the template instructions.
23
+ - 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.
24
+
12
25
  ## 3.4.30 - 2026-10-10
13
26
 
14
27
  **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.31",
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
  /**
@@ -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`,
@@ -407,6 +407,17 @@ const VERDICT_SEVERITY_RANK = {
407
407
 
408
408
  function foldBatchResult(result, built, mode, stage) {
409
409
  const folded = parseVerdictAnswer(result, { findings: built.findings });
410
+ if (stage === 'shadow') {
411
+ return deterministicResult({ mode, stage, outcomeClass: folded.status,
412
+ findings: built.findings, batchId: built.batch.batchId,
413
+ fallbackCode: 'shadow-observation-only',
414
+ verdictOverride: deriveDeterministicVerdict(built.fullFindings ?? built.findings) });
415
+ }
416
+ folded.buckets = folded.buckets.map((bucket, index) => {
417
+ const floor = deriveDeterministicBucket(built.findings[index]);
418
+ return floor === 'Act on' && bucket.bucket !== floor
419
+ ? { ...bucket, bucket: floor, bucketStatus: 'clamp:safety-floor' } : bucket;
420
+ });
410
421
  const modelVerdict = folded.verdict;
411
422
  // W3-RV1: the batch may be display-truncated at MAX_FINDINGS — the model
412
423
  // verdict was computed on `built.findings` only, so when truncation
@@ -415,8 +426,7 @@ function foldBatchResult(result, built, mode, stage) {
415
426
  const fullVerdict = deriveDeterministicVerdict(
416
427
  built.fullFindings ?? built.findings,
417
428
  );
418
- const clamped = built.truncated
419
- && modelVerdict !== null
429
+ const clamped = modelVerdict !== null
420
430
  && (VERDICT_SEVERITY_RANK[fullVerdict] ?? 0)
421
431
  > (VERDICT_SEVERITY_RANK[modelVerdict] ?? 0);
422
432
  // Parse-level classes (accepted/partial/invalid/abstained) pass through;
@@ -431,12 +441,12 @@ function foldBatchResult(result, built, mode, stage) {
431
441
  verdict: clamped ? fullVerdict : (modelVerdict ?? fullVerdict),
432
442
  verdictStatus: modelVerdict === null
433
443
  ? `fallback:${folded.verdictStatus}`
434
- : (clamped ? 'clamp:truncated-findings' : folded.verdictStatus),
444
+ : (clamped ? 'clamp:safety-floor' : folded.verdictStatus),
435
445
  verdictSource: modelVerdict === null || clamped ? 'deterministic' : 'model',
436
446
  buckets: folded.buckets,
437
447
  batchId: built.batch.batchId,
438
448
  fallbackCode: clamped
439
- ? 'truncated-findings'
449
+ ? 'safety-floor'
440
450
  : (modelVerdict === null
441
451
  ? (result?.fallbackCode ?? folded.status)
442
452
  : null),
@@ -653,12 +653,12 @@ function unique(values) {
653
653
 
654
654
  // Completion contract for a lane. Informational routes carry no completion debt;
655
655
  // every contract lane returns the still-missing evidence classes plus a reason.
656
- export function buildCompletionState({ executionMode = null, verificationRecommendation = null } = {}) {
656
+ export function buildCompletionState({ executionMode = null, verificationRecommendation = null, executionContract = null } = {}) {
657
657
  if (!executionMode || executionMode === 'informational') {
658
658
  return null;
659
659
  }
660
660
 
661
- const contract = buildExecutionContract(executionMode);
661
+ const contract = executionContract || buildExecutionContract(executionMode);
662
662
  const missingEvidence = [...(contract?.completionEvidence ?? [])];
663
663
  const requiresVerification = missingEvidence.includes('verification-evidence');
664
664
  if (
@@ -1062,3 +1062,35 @@ export function deriveRouteFields({
1062
1062
  resolved,
1063
1063
  };
1064
1064
  }
1065
+
1066
+ // Task semantics are independent of source execution strategy and observed progress.
1067
+ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
1068
+ const text = String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd').toLowerCase();
1069
+ const clauses = text.split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/);
1070
+ const actionable = clauses.map((clause) => clause.split(/\bwithout\b/)[0]).filter((clause) => !/\b(?:do not|don't|never|khong|dung)\b/.test(clause)
1071
+ && !/^\s*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause));
1072
+ const sourceOrder = actionable.some((clause) => /\b(?:implement|fix|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
1073
+ && /\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));
1077
+ 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')
1081
+ : config ? 'CONFIGURATION' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
1082
+ 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',
1085
+ completionSemantics: /\b(?:ensure|dam bao)\b/.test(text) ? 'ensure-state' : 'execute-and-verify' } } : {}) };
1086
+ }
1087
+
1088
+ export function applyTaskSemantics(contract, semantic) {
1089
+ const evidence = contract?.completionEvidence || [];
1090
+ const operationOnly = semantic.taskKind === 'OPERATIONAL'
1091
+ || (semantic.taskKind === 'CONFIGURATION' && semantic.configurationScope === 'external');
1092
+ return { ...(contract || {}), ...semantic,
1093
+ completionEvidence: operationOnly ? ['operation-verification']
1094
+ : semantic.taskKind === 'MIXED' ? [...new Set([...evidence, 'write-evidence', 'operation-verification'])]
1095
+ : semantic.taskKind === 'READ_ONLY' ? (evidence.length === 1 && evidence[0] === 'verification-evidence' ? evidence : ['impact-evidence']) : evidence };
1096
+ }