@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.
- package/CHANGELOG.md +23 -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/decision/reviewVerdict.js +16 -2
- package/src/index/fixLoopEscalation.js +10 -4
- 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 +48 -9
- package/template_project/.claude/ukit/index/route-resolver.mjs +34 -2
- package/template_project/.claude/ukit/index/route-task.mjs +88 -9
- package/template_project/.claude/ukit/index/sidecar-decision.mjs +6 -117
- 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,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
|
@@ -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
|
/**
|
|
@@ -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 =
|
|
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
|
-
|
|
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`,
|
|
@@ -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`,
|
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
-
? '
|
|
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
|
|
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
|
|