@ngockhoale/ukit 3.1.0 → 3.1.2
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 +17 -0
- package/manifests/platform.full.yaml +24 -0
- package/package.json +1 -1
- package/src/core/agentRuntime/completionGate.js +4 -1
- package/src/core/agentRuntime/vmEngine.js +8 -1
- package/src/core/experiments/deliberation.js +6 -0
- package/src/core/gatewayProbe.js +53 -0
- package/src/core/memory/memoryFlags.js +6 -0
- package/src/core/runtimeConfig.js +3 -3
- package/src/decision/client.js +77 -8
- package/src/decision/protocol.js +4 -1
- package/src/decision/registry.js +145 -1
- package/src/decision/reviewVerdict.js +309 -0
- package/template_project/.claude/agents/code-reviewer.md +25 -1
- package/template_project/.claude/agents/ukit-small-task-maintainer.md +16 -0
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +2 -0
- package/template_project/.claude/commands/ukit/handoff-review.md +12 -0
- package/template_project/.claude/ukit/index/review-verdict.mjs +592 -0
- package/template_project/.claude/ukit/index/route-task.mjs +41 -0
- package/template_project/.claude/ukit/index/sidecar-decision.mjs +595 -0
- package/template_project/.claude/ukit/index/unic-decision.mjs +119 -13
- package/template_project/.codex/settings.json +3 -0
- package/template_project/.omp/agents/code-reviewer.md +25 -1
- package/template_project/.omp/agents/ukit-small-task-maintainer.md +16 -0
- package/template_project/ukit/storage/config.json +276 -178
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* review-verdict.mjs (S2 / UNIC_DECISION_MIGRATION)
|
|
4
|
+
*
|
|
5
|
+
* Review-verdict adapter — the bounded classification pass for the `review`
|
|
6
|
+
* decision family (unic-decision batch: review.verdict.v1 solo /
|
|
7
|
+
* review.panel-verdict.v1 panel + review.finding-bucket.v1 per finding).
|
|
8
|
+
* Reviewers still GENERATE the findings; when the family stage is enabled the
|
|
9
|
+
* FINAL verdict/bucket emission goes through this adapter. Model isolation is
|
|
10
|
+
* preserved — the verdict model is the local decision model, never the
|
|
11
|
+
* executor and never another general LLM.
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* cat findings.json | node .claude/ukit/index/review-verdict.mjs [--panel]
|
|
15
|
+
* node .claude/ukit/index/review-verdict.mjs --file findings.json [--panel]
|
|
16
|
+
* node .claude/ukit/index/review-verdict.mjs --fixture fixture.json
|
|
17
|
+
*
|
|
18
|
+
* Input findings JSON: an array, or {findings: [...]}. Each finding is
|
|
19
|
+
* whitelisted to {id, severity, file, line, claim} — diff hunks, prose and
|
|
20
|
+
* any other field never cross the transport.
|
|
21
|
+
*
|
|
22
|
+
* Stage contract (decisionPlane.families.review.stage, resolved like
|
|
23
|
+
* route-task.mjs resolveDecisionFamilyStage):
|
|
24
|
+
* 'off' → deterministic fallback, ZERO model calls. Rule:
|
|
25
|
+
* any critical → CHANGES-REQUESTED; ≥1 important or
|
|
26
|
+
* unclassified → APPROVED-WITH-MINOR; else APPROVED.
|
|
27
|
+
* otherwise → one unic-decision batch over the whitelisted findings; the
|
|
28
|
+
* answer is folded into verdict + buckets. Any failure
|
|
29
|
+
* (gateway unavailable, malformed/invalid answers) emits the
|
|
30
|
+
* deterministic fallback verdict with outcomeClass reflecting
|
|
31
|
+
* the typed failure — the fallback stays authoritative.
|
|
32
|
+
*
|
|
33
|
+
* Never throws: malformed input still prints a fallback verdict
|
|
34
|
+
* (outcomeClass 'invalid-input', conservative CHANGES-REQUESTED) so an
|
|
35
|
+
* unattended pipeline always gets a parseable verdict. Advisory by design —
|
|
36
|
+
* exits 0 on every input path; a missing/unreadable --fixture/--file is the
|
|
37
|
+
* only exit-1 usage error.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import fs from 'node:fs';
|
|
41
|
+
import os from 'node:os';
|
|
42
|
+
import path from 'node:path';
|
|
43
|
+
import process from 'node:process';
|
|
44
|
+
import { fileURLToPath } from 'node:url';
|
|
45
|
+
|
|
46
|
+
import { requestBatch, parseBatchResponse } from './unic-decision.mjs';
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Review-verdict logic — literal port of src/decision/reviewVerdict.js.
|
|
50
|
+
// Parity is locked by tests/index/reviewVerdictCli.test.js; edit both or
|
|
51
|
+
// neither.
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
const VERDICT_CANDIDATES = Object.freeze([
|
|
55
|
+
'APPROVED',
|
|
56
|
+
'APPROVED-WITH-MINOR',
|
|
57
|
+
'CHANGES-REQUESTED',
|
|
58
|
+
'CRITICAL',
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const BUCKET_CANDIDATES = Object.freeze([
|
|
62
|
+
'Act on',
|
|
63
|
+
'Consider',
|
|
64
|
+
'Noted',
|
|
65
|
+
'Dismissed',
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
const REVIEW_VERDICT_KEYS = Object.freeze({
|
|
69
|
+
solo: 'review.verdict.v1',
|
|
70
|
+
bucket: 'review.finding-bucket.v1',
|
|
71
|
+
panel: 'review.panel-verdict.v1',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
export const MAX_FINDINGS = 24;
|
|
75
|
+
export const MAX_CLAIM_LENGTH = 160;
|
|
76
|
+
const MAX_LOCATION_LENGTH = 160;
|
|
77
|
+
const MAX_ID_LENGTH = 64;
|
|
78
|
+
const MAX_SEVERITY_LENGTH = 24;
|
|
79
|
+
|
|
80
|
+
const KNOWN_SEVERITIES = new Set(['critical', 'important', 'minor']);
|
|
81
|
+
|
|
82
|
+
const BUCKET_INSTRUCTION =
|
|
83
|
+
'Triage this single review finding into exactly one bucket: '
|
|
84
|
+
+ '"Act on" feeds the auto-fix loop; "Consider" is advisory but worth the '
|
|
85
|
+
+ 'author\'s attention; "Noted" is informational only; "Dismissed" is '
|
|
86
|
+
+ 'rejected as not actionable.';
|
|
87
|
+
|
|
88
|
+
function isPlainObject(value) {
|
|
89
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function collapseWhitespace(text) {
|
|
93
|
+
return String(text).replace(/\s+/g, ' ').trim();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function truncate(text, max) {
|
|
97
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalizeSeverity(raw) {
|
|
101
|
+
const severity = typeof raw === 'string'
|
|
102
|
+
? raw.trim().toLowerCase().slice(0, MAX_SEVERITY_LENGTH)
|
|
103
|
+
: '';
|
|
104
|
+
return KNOWN_SEVERITIES.has(severity) ? severity : 'unknown';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function sanitizeFinding(raw, index, usedIds) {
|
|
108
|
+
const source = isPlainObject(raw) ? raw : {};
|
|
109
|
+
let id = typeof source.id === 'string' && source.id.trim().length > 0
|
|
110
|
+
? source.id.trim().replace(/[^A-Za-z0-9._-]/g, '_').slice(0, MAX_ID_LENGTH)
|
|
111
|
+
: `F${index + 1}`;
|
|
112
|
+
if (id.length === 0 || usedIds.has(id)) {
|
|
113
|
+
// Collision fallback must itself be unique — two findings both claiming
|
|
114
|
+
// 'F2' must not collapse into one wire name / one bucket row.
|
|
115
|
+
let suffix = '';
|
|
116
|
+
while (usedIds.has(`F${index + 1}${suffix}`)) suffix = `${suffix}x`;
|
|
117
|
+
id = `F${index + 1}${suffix}`;
|
|
118
|
+
}
|
|
119
|
+
usedIds.add(id);
|
|
120
|
+
|
|
121
|
+
let file = typeof source.file === 'string' ? collapseWhitespace(source.file) : '';
|
|
122
|
+
const line = Number.isInteger(source.line) && source.line > 0 ? source.line : null;
|
|
123
|
+
if (file.length > 0) file = truncate(file, MAX_LOCATION_LENGTH - 8);
|
|
124
|
+
const location = file.length > 0
|
|
125
|
+
? (line !== null ? `${file}:${line}` : file)
|
|
126
|
+
: (line !== null ? `line ${line}` : '-');
|
|
127
|
+
|
|
128
|
+
const claimSource = [source.claim, source.title, source.summary, source.message]
|
|
129
|
+
.find((v) => typeof v === 'string' && v.trim().length > 0);
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
id,
|
|
133
|
+
severity: normalizeSeverity(source.severity),
|
|
134
|
+
location,
|
|
135
|
+
claim: truncate(collapseWhitespace(claimSource ?? ''), MAX_CLAIM_LENGTH),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function stableBatchId(findings) {
|
|
140
|
+
let h = 0x811c9dc5;
|
|
141
|
+
const text = JSON.stringify(findings.map((f) => [f.id, f.severity, f.location, f.claim]));
|
|
142
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
143
|
+
h ^= text.charCodeAt(i);
|
|
144
|
+
h = Math.imul(h, 0x01000193);
|
|
145
|
+
}
|
|
146
|
+
return `rv-${(h >>> 0).toString(16).padStart(8, '0')}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function buildReviewVerdictBatch({ findings = [], mode = 'solo' } = {}) {
|
|
150
|
+
const usedIds = new Set();
|
|
151
|
+
const sanitized = (Array.isArray(findings) ? findings : [])
|
|
152
|
+
.slice(0, MAX_FINDINGS)
|
|
153
|
+
.map((raw, index) => sanitizeFinding(raw, index, usedIds));
|
|
154
|
+
|
|
155
|
+
const verdictDecisionKey = mode === 'panel'
|
|
156
|
+
? REVIEW_VERDICT_KEYS.panel
|
|
157
|
+
: REVIEW_VERDICT_KEYS.solo;
|
|
158
|
+
|
|
159
|
+
const counts = { critical: 0, important: 0, minor: 0, unknown: 0 };
|
|
160
|
+
for (const f of sanitized) counts[f.severity] += 1;
|
|
161
|
+
|
|
162
|
+
const verdictQuestion = {
|
|
163
|
+
decisionKey: verdictDecisionKey,
|
|
164
|
+
schemaVersion: 1,
|
|
165
|
+
family: 'review',
|
|
166
|
+
owner: 'handoffReview',
|
|
167
|
+
kind: 'choice',
|
|
168
|
+
instruction:
|
|
169
|
+
'Pick the bounded review verdict for this finding set. '
|
|
170
|
+
+ 'APPROVED = clean; APPROVED-WITH-MINOR = ship with nits logged; '
|
|
171
|
+
+ 'CHANGES-REQUESTED = must fix before merge; CRITICAL = unsafe to ship.',
|
|
172
|
+
candidates: [...VERDICT_CANDIDATES],
|
|
173
|
+
hardConstraintRefs: ['verdict-vocabulary'],
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const bucketQuestions = sanitized.map((f) => ({
|
|
177
|
+
decisionKey: `${REVIEW_VERDICT_KEYS.bucket}#${f.id}`,
|
|
178
|
+
schemaVersion: 1,
|
|
179
|
+
family: 'review',
|
|
180
|
+
owner: 'handoffReview',
|
|
181
|
+
kind: 'choice',
|
|
182
|
+
instruction: `${BUCKET_INSTRUCTION} Finding ${f.id} [${f.severity}] ${f.location}: ${f.claim || '(no claim text)'}`,
|
|
183
|
+
candidates: [...BUCKET_CANDIDATES],
|
|
184
|
+
hardConstraintRefs: ['bucket-vocabulary'],
|
|
185
|
+
}));
|
|
186
|
+
|
|
187
|
+
const questions = [verdictQuestion, ...bucketQuestions];
|
|
188
|
+
|
|
189
|
+
const statePacket = {
|
|
190
|
+
kind: 'review-verdict',
|
|
191
|
+
mode: mode === 'panel' ? 'panel' : 'solo',
|
|
192
|
+
findingCount: sanitized.length,
|
|
193
|
+
severityCounts: counts,
|
|
194
|
+
findings: sanitized,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const batch = {
|
|
198
|
+
batchVersion: 1,
|
|
199
|
+
batchId: stableBatchId(sanitized),
|
|
200
|
+
boundary: 'review-verdict',
|
|
201
|
+
questions,
|
|
202
|
+
statePacket,
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
return { questions, statePacket, batch, findings: sanitized, verdictDecisionKey };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Deterministic fallback — the documented off-stage rule and the
|
|
209
|
+
// authoritative verdict on every failure path:
|
|
210
|
+
// any critical → CHANGES-REQUESTED
|
|
211
|
+
// ≥1 important or ≥1 unclassified → APPROVED-WITH-MINOR
|
|
212
|
+
// else → APPROVED
|
|
213
|
+
export function deriveDeterministicVerdict(findings) {
|
|
214
|
+
const list = Array.isArray(findings) ? findings : [];
|
|
215
|
+
let importantish = false;
|
|
216
|
+
for (const raw of list) {
|
|
217
|
+
const severity = isPlainObject(raw) ? normalizeSeverity(raw.severity) : 'unknown';
|
|
218
|
+
if (severity === 'critical') return 'CHANGES-REQUESTED';
|
|
219
|
+
if (severity === 'important' || severity === 'unknown') importantish = true;
|
|
220
|
+
}
|
|
221
|
+
return importantish ? 'APPROVED-WITH-MINOR' : 'APPROVED';
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function deriveDeterministicBucket(finding) {
|
|
225
|
+
const severity = normalizeSeverity(isPlainObject(finding) ? finding.severity : null);
|
|
226
|
+
return severity === 'minor' ? 'Noted' : 'Act on';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function splitWireName(name) {
|
|
230
|
+
const text = String(name ?? '');
|
|
231
|
+
const hash = text.lastIndexOf('#');
|
|
232
|
+
if (hash > 0 && text.slice(0, hash) === REVIEW_VERDICT_KEYS.bucket) {
|
|
233
|
+
return { decisionKey: REVIEW_VERDICT_KEYS.bucket, findingId: text.slice(hash + 1) };
|
|
234
|
+
}
|
|
235
|
+
return { decisionKey: text, findingId: null };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function parseVerdictAnswer(result, { findings = [] } = {}) {
|
|
239
|
+
const status = typeof result?.status === 'string' ? result.status : 'invalid';
|
|
240
|
+
const answers = Array.isArray(result?.answers) ? result.answers : [];
|
|
241
|
+
const list = Array.isArray(findings) ? findings : [];
|
|
242
|
+
|
|
243
|
+
let verdict = null;
|
|
244
|
+
let verdictStatus = 'missing';
|
|
245
|
+
const bucketByFinding = new Map();
|
|
246
|
+
for (const raw of list) {
|
|
247
|
+
if (!isPlainObject(raw)) continue;
|
|
248
|
+
const id = typeof raw.id === 'string' && raw.id.length > 0 ? raw.id : null;
|
|
249
|
+
if (id === null) continue;
|
|
250
|
+
bucketByFinding.set(id, {
|
|
251
|
+
bucket: deriveDeterministicBucket(raw),
|
|
252
|
+
bucketStatus: 'default',
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
for (const answer of answers) {
|
|
257
|
+
if (!isPlainObject(answer)) continue;
|
|
258
|
+
const { decisionKey, findingId } = splitWireName(answer.decisionKey);
|
|
259
|
+
const valid = answer.validationStatus === 'valid'
|
|
260
|
+
&& typeof answer.value === 'string';
|
|
261
|
+
if (decisionKey === REVIEW_VERDICT_KEYS.solo
|
|
262
|
+
|| decisionKey === REVIEW_VERDICT_KEYS.panel) {
|
|
263
|
+
if (valid && VERDICT_CANDIDATES.includes(answer.value)) {
|
|
264
|
+
verdict = answer.value;
|
|
265
|
+
verdictStatus = 'valid';
|
|
266
|
+
} else {
|
|
267
|
+
verdictStatus = 'invalid';
|
|
268
|
+
}
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (decisionKey === REVIEW_VERDICT_KEYS.bucket && findingId !== null) {
|
|
272
|
+
bucketByFinding.set(
|
|
273
|
+
findingId,
|
|
274
|
+
valid && BUCKET_CANDIDATES.includes(answer.value)
|
|
275
|
+
? { bucket: answer.value, bucketStatus: 'valid' }
|
|
276
|
+
: { bucket: deriveDeterministicBucket({ severity: 'unknown' }), bucketStatus: 'invalid' },
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const buckets = [...bucketByFinding.entries()].map(([findingId, entry]) => ({
|
|
282
|
+
findingId,
|
|
283
|
+
bucket: entry.bucket,
|
|
284
|
+
bucketStatus: entry.bucketStatus,
|
|
285
|
+
}));
|
|
286
|
+
|
|
287
|
+
return { status, verdict, verdictStatus, buckets };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// Stage resolution — same contract as route-task.mjs
|
|
292
|
+
// resolveDecisionFamilyStage(config, 'review'): enabled===false or absent/
|
|
293
|
+
// malformed values read as 'off'; the family override wins over the global
|
|
294
|
+
// decisionPlane.stage when present.
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
const ROUTE_SCHEMA_STAGES = new Set(['off', 'shadow', 'canary', 'default']);
|
|
298
|
+
|
|
299
|
+
export function resolveReviewStage(config = null) {
|
|
300
|
+
const plane = config?.decisionPlane;
|
|
301
|
+
if (!plane || typeof plane !== 'object' || plane.enabled === false) return 'off';
|
|
302
|
+
const override = plane?.families?.review?.stage;
|
|
303
|
+
if (ROUTE_SCHEMA_STAGES.has(override)) return override;
|
|
304
|
+
return ROUTE_SCHEMA_STAGES.has(plane.stage) ? plane.stage : 'off';
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// Runtime config — same merged view (project over user) as unic-decision.mjs.
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
function safeReadJson(filePath) {
|
|
312
|
+
try {
|
|
313
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function mergeConfigObjects(base, override) {
|
|
320
|
+
if (!isPlainObject(base)) return isPlainObject(override) ? { ...override } : {};
|
|
321
|
+
if (!isPlainObject(override)) return { ...base };
|
|
322
|
+
const out = { ...base };
|
|
323
|
+
for (const [key, value] of Object.entries(override)) {
|
|
324
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
|
|
325
|
+
out[key] = isPlainObject(value) && isPlainObject(base[key])
|
|
326
|
+
? mergeConfigObjects(base[key], value)
|
|
327
|
+
: value;
|
|
328
|
+
}
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function readMergedConfig(rootDir, homeDir) {
|
|
333
|
+
const projectRaw = rootDir
|
|
334
|
+
? safeReadJson(path.join(rootDir, '.ukit', 'storage', 'config.json'))
|
|
335
|
+
: null;
|
|
336
|
+
const userRaw = safeReadJson(
|
|
337
|
+
path.join(homeDir ?? os.homedir(), '.ukit', 'storage', 'config.json'),
|
|
338
|
+
);
|
|
339
|
+
return mergeConfigObjects(userRaw ?? {}, projectRaw ?? {});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
// Verdict run — shared by the CLI paths and tests.
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
// Deterministic output object. Field order is fixed so the printed JSON is
|
|
347
|
+
// byte-identical for identical findings on the off/failure paths.
|
|
348
|
+
function emitVerdict({ mode, stage, outcomeClass, verdict, verdictStatus,
|
|
349
|
+
verdictSource, buckets, batchId, fallbackCode }) {
|
|
350
|
+
return {
|
|
351
|
+
schema: 'review-verdict/v1',
|
|
352
|
+
mode,
|
|
353
|
+
stage,
|
|
354
|
+
outcomeClass,
|
|
355
|
+
verdict,
|
|
356
|
+
verdictStatus,
|
|
357
|
+
verdictSource,
|
|
358
|
+
buckets,
|
|
359
|
+
batchId,
|
|
360
|
+
fallbackCode: fallbackCode ?? null,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function deterministicResult({ mode, stage, outcomeClass, findings, batchId, fallbackCode }) {
|
|
365
|
+
return emitVerdict({
|
|
366
|
+
mode,
|
|
367
|
+
stage,
|
|
368
|
+
outcomeClass,
|
|
369
|
+
verdict: deriveDeterministicVerdict(findings),
|
|
370
|
+
verdictStatus: 'deterministic',
|
|
371
|
+
verdictSource: 'deterministic',
|
|
372
|
+
buckets: findings.map((f) => ({
|
|
373
|
+
findingId: f.id,
|
|
374
|
+
bucket: deriveDeterministicBucket(f),
|
|
375
|
+
bucketStatus: 'deterministic',
|
|
376
|
+
})),
|
|
377
|
+
batchId,
|
|
378
|
+
fallbackCode,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function foldBatchResult(result, built, mode, stage) {
|
|
383
|
+
const folded = parseVerdictAnswer(result, { findings: built.findings });
|
|
384
|
+
const modelVerdict = folded.verdict;
|
|
385
|
+
// Parse-level classes (accepted/partial/invalid/abstained) pass through;
|
|
386
|
+
// every transport/gateway class surfaces as 'unavailable' — the fallback
|
|
387
|
+
// code carries the finer-grained reason for receipts.
|
|
388
|
+
const PARSE_CLASSES = new Set(['accepted', 'partial', 'invalid', 'abstained']);
|
|
389
|
+
const outcomeClass = PARSE_CLASSES.has(folded.status) ? folded.status : 'unavailable';
|
|
390
|
+
return emitVerdict({
|
|
391
|
+
mode,
|
|
392
|
+
stage,
|
|
393
|
+
outcomeClass,
|
|
394
|
+
verdict: modelVerdict ?? deriveDeterministicVerdict(built.findings),
|
|
395
|
+
verdictStatus: modelVerdict === null
|
|
396
|
+
? `fallback:${folded.verdictStatus}`
|
|
397
|
+
: folded.verdictStatus,
|
|
398
|
+
verdictSource: modelVerdict === null ? 'deterministic' : 'model',
|
|
399
|
+
buckets: folded.buckets,
|
|
400
|
+
batchId: built.batch.batchId,
|
|
401
|
+
fallbackCode: modelVerdict === null
|
|
402
|
+
? (result?.fallbackCode ?? folded.status)
|
|
403
|
+
: null,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Run the verdict pass over a findings list. Returns the emitted object; the
|
|
409
|
+
* caller prints it. Never throws and never touches transport when stage is
|
|
410
|
+
* 'off'.
|
|
411
|
+
*/
|
|
412
|
+
export async function runReviewVerdict({ findings, mode = 'solo', config,
|
|
413
|
+
projectRoot, homeDir, env, transport } = {}) {
|
|
414
|
+
const built = buildReviewVerdictBatch({ findings, mode });
|
|
415
|
+
const normalizedMode = mode === 'panel' ? 'panel' : 'solo';
|
|
416
|
+
const stage = resolveReviewStage(config);
|
|
417
|
+
|
|
418
|
+
if (stage === 'off') {
|
|
419
|
+
return deterministicResult({
|
|
420
|
+
mode: normalizedMode,
|
|
421
|
+
stage,
|
|
422
|
+
outcomeClass: 'skipped',
|
|
423
|
+
findings: built.findings,
|
|
424
|
+
batchId: built.batch.batchId,
|
|
425
|
+
fallbackCode: 'stage-off',
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const result = await requestBatch(built.batch, {
|
|
430
|
+
config,
|
|
431
|
+
transport,
|
|
432
|
+
projectRoot,
|
|
433
|
+
homeDir,
|
|
434
|
+
env,
|
|
435
|
+
});
|
|
436
|
+
return foldBatchResult(result, built, normalizedMode, stage);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Offline fixture replay: {findings, mode?, response} → emitted verdict
|
|
441
|
+
* object. Zero transport, zero dispatch — the fixture's tool_calls are parsed
|
|
442
|
+
* as data exactly once (same contract as unic-decision --fixture).
|
|
443
|
+
*/
|
|
444
|
+
export function runFixture(fixture) {
|
|
445
|
+
const mode = fixture?.mode === 'panel' ? 'panel' : 'solo';
|
|
446
|
+
const built = buildReviewVerdictBatch({ findings: fixture?.findings, mode });
|
|
447
|
+
if (!fixture || fixture.response === undefined) {
|
|
448
|
+
// No recorded response — emit the batch that would be sent, so fixtures
|
|
449
|
+
// can also be used to inspect question generation offline.
|
|
450
|
+
return { ...built.batch, emitted: 'batch-only' };
|
|
451
|
+
}
|
|
452
|
+
const parsed = parseBatchResponse(fixture.response, built.questions);
|
|
453
|
+
return foldBatchResult(parsed, built, mode, 'fixture');
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ---------------------------------------------------------------------------
|
|
457
|
+
// CLI
|
|
458
|
+
// ---------------------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
function readFlagValue(argv, flag) {
|
|
461
|
+
const index = argv.indexOf(flag);
|
|
462
|
+
if (index === -1) return null;
|
|
463
|
+
const value = argv[index + 1];
|
|
464
|
+
return value && !value.startsWith('--') ? value : null;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function readStdin() {
|
|
468
|
+
return new Promise((resolve, reject) => {
|
|
469
|
+
let data = '';
|
|
470
|
+
process.stdin.setEncoding('utf8');
|
|
471
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
472
|
+
process.stdin.on('end', () => resolve(data));
|
|
473
|
+
process.stdin.on('error', reject);
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function printResult(result) {
|
|
478
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Findings input accepts a bare array or {findings:[...]}; anything else
|
|
482
|
+
// (unparseable JSON, wrong shape) still yields a verdict — conservative
|
|
483
|
+
// CHANGES-REQUESTED, flagged 'invalid-input' — never a crash, never APPROVED.
|
|
484
|
+
function invalidInputResult(mode, detail) {
|
|
485
|
+
return emitVerdict({
|
|
486
|
+
mode,
|
|
487
|
+
stage: 'unknown',
|
|
488
|
+
outcomeClass: 'invalid-input',
|
|
489
|
+
verdict: 'CHANGES-REQUESTED',
|
|
490
|
+
verdictStatus: 'deterministic',
|
|
491
|
+
verdictSource: 'deterministic',
|
|
492
|
+
buckets: [],
|
|
493
|
+
batchId: null,
|
|
494
|
+
fallbackCode: detail ?? 'invalid-input',
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function main() {
|
|
499
|
+
const args = process.argv.slice(2);
|
|
500
|
+
const rootDir = readFlagValue(args, '--root') ?? process.env.UKIT_TEST_ROOT ?? process.cwd();
|
|
501
|
+
const homeDir = process.env.UKIT_TEST_HOME ?? os.homedir();
|
|
502
|
+
const mode = args.includes('--panel') ? 'panel' : 'solo';
|
|
503
|
+
const config = readMergedConfig(rootDir, homeDir);
|
|
504
|
+
|
|
505
|
+
const fixturePath = readFlagValue(args, '--fixture');
|
|
506
|
+
if (fixturePath !== null) {
|
|
507
|
+
let fixture;
|
|
508
|
+
try {
|
|
509
|
+
fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
|
|
510
|
+
} catch (error) {
|
|
511
|
+
process.stderr.write(
|
|
512
|
+
`review-verdict: cannot read fixture ${fixturePath}: ${error?.message ?? error}\n`,
|
|
513
|
+
);
|
|
514
|
+
return 1;
|
|
515
|
+
}
|
|
516
|
+
printResult(runFixture(fixture));
|
|
517
|
+
return 0;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const filePath = readFlagValue(args, '--file');
|
|
521
|
+
let raw = null;
|
|
522
|
+
if (filePath !== null) {
|
|
523
|
+
try {
|
|
524
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
525
|
+
} catch (error) {
|
|
526
|
+
process.stderr.write(
|
|
527
|
+
`review-verdict: cannot read file ${filePath}: ${error?.message ?? error}\n`,
|
|
528
|
+
);
|
|
529
|
+
return 1;
|
|
530
|
+
}
|
|
531
|
+
} else {
|
|
532
|
+
raw = await readStdin();
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
let parsed;
|
|
536
|
+
try {
|
|
537
|
+
parsed = JSON.parse(raw);
|
|
538
|
+
} catch {
|
|
539
|
+
process.stderr.write(
|
|
540
|
+
'review-verdict: expected findings JSON on stdin or --file '
|
|
541
|
+
+ '(array, or {findings: [...]}); emitting fallback verdict\n',
|
|
542
|
+
);
|
|
543
|
+
printResult(invalidInputResult(mode, 'unparseable-input'));
|
|
544
|
+
return 0;
|
|
545
|
+
}
|
|
546
|
+
const findings = Array.isArray(parsed)
|
|
547
|
+
? parsed
|
|
548
|
+
: (isPlainObject(parsed) ? parsed.findings : null);
|
|
549
|
+
if (!Array.isArray(findings)) {
|
|
550
|
+
process.stderr.write(
|
|
551
|
+
'review-verdict: input has no findings array; emitting fallback verdict\n',
|
|
552
|
+
);
|
|
553
|
+
printResult(invalidInputResult(mode, 'no-findings-array'));
|
|
554
|
+
return 0;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
printResult(await runReviewVerdict({
|
|
558
|
+
findings,
|
|
559
|
+
mode,
|
|
560
|
+
config,
|
|
561
|
+
projectRoot: rootDir,
|
|
562
|
+
homeDir,
|
|
563
|
+
env: process.env,
|
|
564
|
+
}));
|
|
565
|
+
return 0;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const isMainModule = (() => {
|
|
569
|
+
try {
|
|
570
|
+
const invoked = process.argv[1] ?? '';
|
|
571
|
+
if (!invoked) return false;
|
|
572
|
+
const self = path.resolve(fileURLToPath(import.meta.url));
|
|
573
|
+
const target = path.resolve(invoked);
|
|
574
|
+
if (self === target) return true;
|
|
575
|
+
// Installed mirrors may be reached through a symlinked directory (e.g.
|
|
576
|
+
// .codex/ukit -> ../.claude/ukit): realpath resolves the link, basename
|
|
577
|
+
// equality keeps same-named unrelated scripts from matching.
|
|
578
|
+
return path.basename(invoked) === 'review-verdict.mjs'
|
|
579
|
+
&& fs.realpathSync(target) === self;
|
|
580
|
+
} catch {
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
})();
|
|
584
|
+
|
|
585
|
+
if (isMainModule) {
|
|
586
|
+
main()
|
|
587
|
+
.then((code) => { process.exitCode = code; })
|
|
588
|
+
.catch((error) => {
|
|
589
|
+
process.stderr.write(`review-verdict: ${error?.message ?? error}\n`);
|
|
590
|
+
process.exitCode = 1;
|
|
591
|
+
});
|
|
592
|
+
}
|
|
@@ -1883,6 +1883,36 @@ const ROUTE_SHADOW_QUESTIONS = Object.freeze([
|
|
|
1883
1883
|
instruction: 'R0-R4 rigor recommendation inside deterministic floors.',
|
|
1884
1884
|
candidates: ['r0', 'r1', 'r2', 'r3', 'r4'],
|
|
1885
1885
|
},
|
|
1886
|
+
{
|
|
1887
|
+
decisionKey: 'resume.next-action.v1',
|
|
1888
|
+
family: 'resume',
|
|
1889
|
+
kind: 'choice',
|
|
1890
|
+
instruction: 'Next resumable action at the continuation boundary.',
|
|
1891
|
+
// deriveNextAction vocabulary (read from source); the rescue-bias override
|
|
1892
|
+
// 'execute-current-milestone' can overwrite nextActionType post-derivation —
|
|
1893
|
+
// baseline compare then scores 'disagree', which is a real signal, not noise.
|
|
1894
|
+
candidates: [
|
|
1895
|
+
'ask-user-confirmation',
|
|
1896
|
+
'run-primary-verification',
|
|
1897
|
+
'run-fallback-verification',
|
|
1898
|
+
'pull-indexed-context',
|
|
1899
|
+
'read-skill-instructions',
|
|
1900
|
+
'inspect-structure',
|
|
1901
|
+
],
|
|
1902
|
+
},
|
|
1903
|
+
{
|
|
1904
|
+
decisionKey: 'verify.depth.v1',
|
|
1905
|
+
family: 'verify',
|
|
1906
|
+
kind: 'choice',
|
|
1907
|
+
instruction: 'Verification depth among allowed levels.',
|
|
1908
|
+
candidates: ['sanity', 'targeted', 'impact', 'full'],
|
|
1909
|
+
},
|
|
1910
|
+
// NOT wired — recorded per migration-backlog review:
|
|
1911
|
+
// capability.impact.v1 — noul kind; no threshold baseline exists on
|
|
1912
|
+
// routeSummary (capabilityPolicy.recommended is unpopulated upstream), so
|
|
1913
|
+
// the question has no comparison value.
|
|
1914
|
+
// learn.candidate-class.v1 — reserved for the memoryV2 `decision` plane
|
|
1915
|
+
// (memoryFlags.js), not the route boundary.
|
|
1886
1916
|
]);
|
|
1887
1917
|
|
|
1888
1918
|
// Only families whose resolved stage is not 'off' get a question.
|
|
@@ -1906,6 +1936,17 @@ function probabilityBand(value) {
|
|
|
1906
1936
|
function shadowBaselineFor(decisionKey, routeSummary) {
|
|
1907
1937
|
if (decisionKey === 'route.intent-kind.v1') return routeSummary?.intent?.kind ?? null;
|
|
1908
1938
|
if (decisionKey === 'route.rigor.v1') return routeSummary?.execution?.rigor ?? null;
|
|
1939
|
+
if (decisionKey === 'resume.next-action.v1') return routeSummary?.nextActionType ?? null;
|
|
1940
|
+
// Verification depth: routeSummary carries neither verificationRecommendation
|
|
1941
|
+
// nor resolvedVerificationPlan (the plan lives on the outer route result at
|
|
1942
|
+
// ~:2412, and its `mode` uses a different vocabulary — docs-only/
|
|
1943
|
+
// targeted-tests-first/… — NOT a depth). Baseline resolves null → agreement
|
|
1944
|
+
// 'unknown'; bands still record.
|
|
1945
|
+
if (decisionKey === 'verify.depth.v1') {
|
|
1946
|
+
return routeSummary?.verificationRecommendation?.depth
|
|
1947
|
+
?? routeSummary?.resolvedVerificationPlan?.depth
|
|
1948
|
+
?? null;
|
|
1949
|
+
}
|
|
1909
1950
|
return null;
|
|
1910
1951
|
}
|
|
1911
1952
|
|