@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 +13 -0
- package/manifests/documentation.yaml +12 -0
- package/package.json +1 -1
- package/src/core/executionContracts.js +32 -0
- package/src/decision/client.js +15 -0
- package/src/decision/protocol.js +12 -14
- package/src/index/routeResolver.js +2 -2
- package/src/index/taskRouting.js +3 -1
- package/template_project/.claude/hooks/record-execution.mjs +1 -3
- package/template_project/.claude/hooks/skill-router.sh +82 -9
- package/template_project/.claude/settings.json +22 -0
- package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +10 -4
- package/template_project/.claude/ukit/index/review-verdict.mjs +14 -4
- package/template_project/.claude/ukit/index/route-resolver.mjs +34 -2
- package/template_project/.claude/ukit/index/route-task.mjs +35 -7
- package/template_project/.claude/ukit/index/sidecar-decision.mjs +3 -59
- package/template_project/.claude/ukit/index/unic-decision.mjs +27 -14
- package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +43 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +160 -33
- package/template_project/.claude/ukit/runtime/loop-state.mjs +1 -1
- package/template_project/.claude/ukit/runtime/operation-evidence.mjs +87 -0
- package/template_project/.omp/hooks/pre/ukit-bridge.js +8 -1
- package/template_project/AGENTS.md +1 -1
- package/template_project/CLAUDE.md +1 -1
- package/template_project/instructions/core.md +1 -1
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
|
@@ -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
|
+
}
|
package/src/decision/client.js
CHANGED
|
@@ -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
|
package/src/decision/protocol.js
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
//
|
|
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
|
|
191
|
-
|
|
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 (
|
package/src/index/taskRouting.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
3606
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
398
|
-
|
|
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
|
|
404
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
? '
|
|
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
|
+
}
|