@ngockhoale/ukit 3.0.12 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +1 -0
  3. package/manifests/documentation.yaml +12 -0
  4. package/manifests/platform.full.yaml +24 -0
  5. package/package.json +1 -1
  6. package/scripts/bench/data-foundation.mjs +368 -50
  7. package/src/cli/commands/doctor.js +232 -3
  8. package/src/cli/commands/feedback.js +64 -1
  9. package/src/cli/commands/install.js +18 -0
  10. package/src/cli/commands/memory.js +42 -37
  11. package/src/cli/commands/telemetry.js +460 -0
  12. package/src/cli/index.js +7 -0
  13. package/src/core/agentRuntime/adapters.js +83 -2
  14. package/src/core/agentRuntime/diagnostics.js +104 -0
  15. package/src/core/agentRuntime/supervisor.js +137 -0
  16. package/src/core/agentRuntime/telemetry.js +204 -0
  17. package/src/core/memory/memoryEmit.js +131 -0
  18. package/src/core/memory/memoryHit.js +1 -1
  19. package/src/core/memory/migrate.js +18 -11
  20. package/src/core/memory/migrateMapping.js +15 -7
  21. package/src/core/memory/mutateMemory.js +22 -4
  22. package/src/core/memory/recordIndex.js +10 -3
  23. package/src/core/memory/recordStore.js +28 -3
  24. package/src/core/memory/retrieval.js +79 -38
  25. package/src/core/memory/store.js +37 -37
  26. package/src/core/memory/storeV2.js +28 -25
  27. package/src/core/memory/storeV2Loader.js +2 -2
  28. package/src/core/observability/adapters/ingest.js +576 -0
  29. package/src/core/observability/analytics/anomalies.js +415 -0
  30. package/src/core/observability/analytics/summary.js +16 -1
  31. package/src/core/observability/emit/config.js +69 -1
  32. package/src/core/observability/emit/crash.js +434 -0
  33. package/src/core/observability/emit/lifecycle.js +349 -0
  34. package/src/core/observability/emit/recorder.js +135 -9
  35. package/src/core/observability/evaluation/aiPacket.js +52 -10
  36. package/src/core/observability/evaluation/outcomes.js +95 -0
  37. package/src/core/observability/evaluation/runner.js +225 -0
  38. package/src/core/observability/privacy/allowlist.js +23 -3
  39. package/src/core/observability/schema/compatibility.js +48 -3
  40. package/src/core/observability/schema/constants.js +5 -0
  41. package/src/core/observability/schema/registry.js +57 -0
  42. package/src/core/observability/schema/validate.js +68 -6
  43. package/src/core/observability/segments/internal.js +42 -8
  44. package/src/core/observability/segments/readSegments.js +35 -1
  45. package/src/core/observability/segments/recovery.js +3 -2
  46. package/src/core/observability/segments/retention.js +137 -33
  47. package/src/core/observability/support/projector.js +88 -18
  48. package/src/core/observability/support/provision.js +160 -0
  49. package/src/core/observability/support/renderer.js +2 -2
  50. package/src/core/observability/support/schedule.js +174 -0
  51. package/src/decision/registry.js +144 -0
  52. package/src/decision/reviewVerdict.js +309 -0
  53. package/template_project/.claude/agents/code-reviewer.md +25 -1
  54. package/template_project/.claude/agents/ukit-small-task-maintainer.md +16 -0
  55. package/template_project/.claude/commands/ukit/handoff-fullstack.md +2 -0
  56. package/template_project/.claude/commands/ukit/handoff-review.md +12 -0
  57. package/template_project/.claude/hooks/auto-allow-bash.sh +7 -1
  58. package/template_project/.claude/hooks/auto-prune-bash.sh +16 -7
  59. package/template_project/.claude/hooks/verification-guard.sh +13 -4
  60. package/template_project/.claude/ukit/index/review-verdict.mjs +592 -0
  61. package/template_project/.claude/ukit/index/sidecar-decision.mjs +595 -0
  62. package/template_project/.claude/ukit/index/unic-decision.mjs +10 -1
  63. package/template_project/.claude/ukit/runtime/async-lock.mjs +26 -0
  64. package/template_project/.codex/settings.json +3 -0
  65. package/template_project/.omp/agents/code-reviewer.md +25 -1
  66. package/template_project/.omp/agents/ukit-small-task-maintainer.md +16 -0
@@ -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
+ }