@ngockhoale/ukit 3.1.0 → 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.
@@ -0,0 +1,595 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sidecar-decision.mjs (UNIC_DECISION_MIGRATION slice S3)
4
+ *
5
+ * Installed-side producer for the six bounded sidecar decisions enumerated in
6
+ * `.codex/settings.json` `smallTaskModel.decisionPolicy.decisions`. Each
7
+ * decision maps to a registered `workflow.*` key in src/decision/registry.js
8
+ * (owner: smallTaskMaintainer, fallbackPolicy: deterministic-*).
9
+ *
10
+ * Stage contract — `decisionPlane.families.workflow.stage` (family override
11
+ * wins over the global decisionPlane.stage; absent/malformed → 'off';
12
+ * decisionPlane.enabled === false → 'off'):
13
+ * off → deterministic rule answer, zero transport.
14
+ * shadow → unic-decision batch is advisory; deterministic rule
15
+ * stays authoritative (agreement reported).
16
+ * canary/default → a valid unic-decision answer wins; invalid or
17
+ * unavailable falls back to the deterministic rule.
18
+ *
19
+ * Transport is delegated to the sibling `unic-decision.mjs` CLI (spawned once
20
+ * with a bounded single-question batch on stdin). That adapter owns protocol
21
+ * encode/parse, gateway resolution, and the sensitive-value gate — this file
22
+ * never duplicates it. Any adapter failure resolves to the deterministic
23
+ * answer with outcomeClass 'unavailable' — NEVER another LLM for the verdict.
24
+ * unic-lite keeps summarization/doc work only; it emits no verdicts here.
25
+ *
26
+ * Verdict inputs are already-extracted, whitelisted context fields — no raw
27
+ * diffs, transcripts, or secrets cross the transport (statePacket whitelist).
28
+ *
29
+ * Modes:
30
+ * node sidecar-decision.mjs --list print the name → key table
31
+ * node sidecar-decision.mjs --fixture <path> offline replay passthrough
32
+ * to unic-decision.mjs
33
+ * node sidecar-decision.mjs --decision <name> reads optional context JSON
34
+ * [--root <dir>] on stdin ({context:{...}}
35
+ * or bare fields); prints
36
+ * the typed result.
37
+ *
38
+ * Exit codes: 0 for every typed outcome (deterministic, accepted, unavailable
39
+ * — the JSON fields carry the truth); 1 only for usage errors or malformed
40
+ * stdin JSON. Never throws.
41
+ */
42
+
43
+ import fs from 'node:fs';
44
+ import path from 'node:path';
45
+ import os from 'node:os';
46
+ import { spawnSync } from 'node:child_process';
47
+ import { fileURLToPath } from 'node:url';
48
+
49
+ const __sidecarDir = path.dirname(fileURLToPath(import.meta.url));
50
+ const UNIC_DECISION_CLI_PATH = path.join(__sidecarDir, 'unic-decision.mjs');
51
+ const ADAPTER_TIMEOUT_CAP_MS = 3000;
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Decision catalog — the name → workflow.* key mapping lives HERE (not in
55
+ // .codex/settings.json) so the settings file keeps its existing schema. Each
56
+ // entry mirrors its DECISION_REGISTRY row: decisionKey, choice candidates as
57
+ // protocol-local labels, fallbackPolicy, and the deterministic rule that stays
58
+ // authoritative at stage 'off' / 'shadow' / adapter-failure.
59
+ // ---------------------------------------------------------------------------
60
+
61
+ // Shared/security/irreversible path signals — a match forces 'risky' (and the
62
+ // lane decision biases 'slow'). Token scan over normalized path segments.
63
+ const RISKY_PATH_TOKENS = new Set([
64
+ 'src', '.github', 'workflows', 'release', 'scripts', 'migrations',
65
+ 'secrets', '.env', 'auth', 'security', 'package.json', 'yarn.lock',
66
+ 'package-lock.json', 'pnpm-lock.yaml',
67
+ ]);
68
+
69
+ function normalizedPathTokens(targetPath) {
70
+ if (typeof targetPath !== 'string' || targetPath.length === 0) return [];
71
+ return targetPath
72
+ .split(/[\\/]+/)
73
+ .map((segment) => segment.trim().toLowerCase())
74
+ .filter(Boolean);
75
+ }
76
+
77
+ function targetIsRisky(context) {
78
+ for (const token of normalizedPathTokens(context?.targetPath)) {
79
+ if (RISKY_PATH_TOKENS.has(token)) return true;
80
+ }
81
+ return false;
82
+ }
83
+
84
+ function isFiniteNumber(value) {
85
+ return typeof value === 'number' && Number.isFinite(value);
86
+ }
87
+
88
+ export const SIDECAR_DECISIONS = Object.freeze({
89
+ 'fast-vs-slow-lane': {
90
+ decisionKey: 'workflow.sidecar-lane.v1',
91
+ fallbackPolicy: 'deterministic-lane-rules',
92
+ question: {
93
+ decisionKey: 'workflow.sidecar-lane.v1',
94
+ kind: 'choice',
95
+ instruction:
96
+ 'Chore lane: fast (single reversible step on local state) | slow (needs main-model review).',
97
+ candidates: ['fast', 'slow'],
98
+ },
99
+ contextFields: ['targetPath', 'riskLevel', 'riskSignal', 'stepsUsed', 'maxSteps'],
100
+ // Deterministic rule: any risky/shared-path signal or explicit high risk
101
+ // escalates to the slow lane; everything else stays fast.
102
+ decide(context) {
103
+ if (targetIsRisky(context) || context.riskLevel === 'high' || context.riskSignal === true) {
104
+ return 'slow';
105
+ }
106
+ return 'fast';
107
+ },
108
+ },
109
+ 'safe-vs-risky-lane': {
110
+ decisionKey: 'workflow.sidecar-risk.v1',
111
+ fallbackPolicy: 'deterministic-lane-rules',
112
+ question: {
113
+ decisionKey: 'workflow.sidecar-risk.v1',
114
+ kind: 'choice',
115
+ instruction:
116
+ 'Risk class for the sidecar candidate: safe (reversible local state) | risky (escalate to main model).',
117
+ candidates: ['safe', 'risky'],
118
+ },
119
+ contextFields: ['targetPath', 'riskLevel', 'riskSignal', 'irreversible'],
120
+ // Deterministic rule: risky when the target matches shared/security/
121
+ // irreversible paths or an explicit risk/irreversibility signal is set;
122
+ // otherwise safe.
123
+ decide(context) {
124
+ if (
125
+ targetIsRisky(context)
126
+ || context.riskSignal === true
127
+ || context.irreversible === true
128
+ || context.riskLevel === 'high'
129
+ ) {
130
+ return 'risky';
131
+ }
132
+ return 'safe';
133
+ },
134
+ },
135
+ 'skill-routing-needed': {
136
+ decisionKey: 'workflow.routing-needed.v1',
137
+ fallbackPolicy: 'deterministic-route',
138
+ question: {
139
+ decisionKey: 'workflow.routing-needed.v1',
140
+ kind: 'choice',
141
+ instruction:
142
+ 'Does this prompt still need routing/intent classification: needed | skip.',
143
+ candidates: ['needed', 'skip'],
144
+ },
145
+ contextFields: ['routingNeeded', 'resolution'],
146
+ // Deterministic rule: an existing route resolution (or routingNeeded=false)
147
+ // skips; otherwise routing is needed.
148
+ decide(context) {
149
+ if (context.routingNeeded === false) return 'skip';
150
+ if (typeof context.resolution === 'string' && context.resolution.length > 0) return 'skip';
151
+ return 'needed';
152
+ },
153
+ },
154
+ 'step-budget-enough': {
155
+ decisionKey: 'workflow.step-budget.v1',
156
+ fallbackPolicy: 'deterministic-budget',
157
+ question: {
158
+ decisionKey: 'workflow.step-budget.v1',
159
+ kind: 'choice',
160
+ instruction:
161
+ 'Is the remaining planned step budget enough for this task: enough | exceeds.',
162
+ candidates: ['enough', 'exceeds'],
163
+ },
164
+ contextFields: ['stepsUsed', 'maxSteps'],
165
+ // Deterministic rule: when both counters are present, at/over the budget
166
+ // means it exceeds; missing counters default to enough (no fake precision).
167
+ decide(context) {
168
+ if (isFiniteNumber(context.stepsUsed) && isFiniteNumber(context.maxSteps)) {
169
+ return context.stepsUsed >= context.maxSteps ? 'exceeds' : 'enough';
170
+ }
171
+ return 'enough';
172
+ },
173
+ },
174
+ 'compact-now-or-later': {
175
+ decisionKey: 'workflow.compact-now.v1',
176
+ fallbackPolicy: 'deterministic-threshold',
177
+ question: {
178
+ decisionKey: 'workflow.compact-now.v1',
179
+ kind: 'choice',
180
+ instruction:
181
+ 'Context hygiene at this boundary: compact-now | compact-later.',
182
+ candidates: ['compact-now', 'compact-later'],
183
+ },
184
+ contextFields: ['lineCount', 'tokenCount', 'budgetTokens', 'compactTargetMax'],
185
+ // Deterministic rule: compact now when the observed size crosses the
186
+ // configured hard limits — lineCount at/over compactTargetMax (default 170)
187
+ // or tokenCount at/over 90% of budgetTokens (default 100000).
188
+ decide(context) {
189
+ const targetMax = isFiniteNumber(context.compactTargetMax) ? context.compactTargetMax : 170;
190
+ const budget = isFiniteNumber(context.budgetTokens) ? context.budgetTokens : 100000;
191
+ if (isFiniteNumber(context.lineCount) && context.lineCount >= targetMax) return 'compact-now';
192
+ if (isFiniteNumber(context.tokenCount) && context.tokenCount >= budget * 0.9) {
193
+ return 'compact-now';
194
+ }
195
+ return 'compact-later';
196
+ },
197
+ },
198
+ 'summarize-docs-or-keep-detail': {
199
+ decisionKey: 'workflow.summarize-vs-keep.v1',
200
+ fallbackPolicy: 'deterministic-threshold',
201
+ question: {
202
+ decisionKey: 'workflow.summarize-vs-keep.v1',
203
+ kind: 'choice',
204
+ instruction:
205
+ 'Stored artifact/doc handling: summarize | keep-detail.',
206
+ candidates: ['summarize', 'keep-detail'],
207
+ },
208
+ contextFields: ['docLength', 'lineCount', 'preserveDetail'],
209
+ // Deterministic rule: an explicit preserve flag keeps detail; long docs
210
+ // (docLength/lineCount >= 200) summarize; anything else keeps detail
211
+ // (prefer no-op over lossy cleanup).
212
+ decide(context) {
213
+ if (context.preserveDetail === true) return 'keep-detail';
214
+ const length = isFiniteNumber(context.docLength) ? context.docLength
215
+ : isFiniteNumber(context.lineCount) ? context.lineCount
216
+ : 0;
217
+ return length >= 200 ? 'summarize' : 'keep-detail';
218
+ },
219
+ },
220
+ });
221
+
222
+ const SIDECAR_FAMILY = 'workflow';
223
+ const DECISION_STAGES = new Set(['off', 'shadow', 'canary', 'default']);
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Runtime config + stage resolution — mirror of route-task.mjs
227
+ // resolveDecisionPlaneStage/resolveDecisionFamilyStage (absent/malformed →
228
+ // 'off'; enabled === false → 'off'; family override wins over global stage).
229
+ // ---------------------------------------------------------------------------
230
+
231
+ function safeReadJson(filePath) {
232
+ try {
233
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
234
+ } catch {
235
+ return null;
236
+ }
237
+ }
238
+
239
+ function isPlainObject(value) {
240
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
241
+ }
242
+
243
+ function mergeConfigObjects(base, override) {
244
+ if (!isPlainObject(base)) return isPlainObject(override) ? { ...override } : {};
245
+ if (!isPlainObject(override)) return { ...base };
246
+ const out = { ...base };
247
+ for (const [key, value] of Object.entries(override)) {
248
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
249
+ out[key] = isPlainObject(value) && isPlainObject(base[key])
250
+ ? mergeConfigObjects(base[key], value)
251
+ : value;
252
+ }
253
+ return out;
254
+ }
255
+
256
+ function readMergedConfig(rootDir, homeDir) {
257
+ const projectRaw = rootDir
258
+ ? safeReadJson(path.join(rootDir, '.ukit', 'storage', 'config.json'))
259
+ : null;
260
+ const userRaw = safeReadJson(
261
+ path.join(homeDir ?? os.homedir(), '.ukit', 'storage', 'config.json'),
262
+ );
263
+ return mergeConfigObjects(userRaw ?? {}, projectRaw ?? {});
264
+ }
265
+
266
+ export function resolveSidecarStage(config = null) {
267
+ const plane = config?.decisionPlane;
268
+ if (!plane || typeof plane !== 'object' || plane.enabled === false) return 'off';
269
+ const globalStage = DECISION_STAGES.has(plane.stage) ? plane.stage : 'off';
270
+ const override = plane?.families?.[SIDECAR_FAMILY]?.stage;
271
+ return DECISION_STAGES.has(override) ? override : globalStage;
272
+ }
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // State packet — whitelisted fields only (per-decision contextFields). Strings
276
+ // are truncated; non-finite numbers and unknown keys are dropped before they
277
+ // can reach the transport.
278
+ // ---------------------------------------------------------------------------
279
+
280
+ const MAX_FIELD_LENGTH = 200;
281
+
282
+ export function buildStatePacket(decision, context) {
283
+ const spec = SIDECAR_DECISIONS[decision];
284
+ const fields = spec?.contextFields ?? [];
285
+ const packet = { stateVersion: 1, boundary: 'sidecar', decision };
286
+ for (const field of fields) {
287
+ const value = context?.[field];
288
+ if (typeof value === 'string' && value.length > 0) {
289
+ packet[field] = value.slice(0, MAX_FIELD_LENGTH);
290
+ } else if (isFiniteNumber(value)) {
291
+ packet[field] = value;
292
+ } else if (typeof value === 'boolean') {
293
+ packet[field] = value;
294
+ }
295
+ }
296
+ return packet;
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // Adapter spawn — one bounded single-question batch to unic-decision.mjs.
301
+ // Never throws: every failure resolves to a typed 'unavailable' result.
302
+ // ---------------------------------------------------------------------------
303
+
304
+ function runAdapterBatch({ spec, context, rootDir, config, batchId }) {
305
+ const cliPath = process.env.UKIT_DECISION_CLI_PATH || UNIC_DECISION_CLI_PATH;
306
+ const timeoutMs = Math.min(
307
+ (isFiniteNumber(config?.decisionPlane?.timeoutMs)
308
+ ? config.decisionPlane.timeoutMs
309
+ : ADAPTER_TIMEOUT_CAP_MS) + 1000,
310
+ ADAPTER_TIMEOUT_CAP_MS + 1000,
311
+ );
312
+ const batch = {
313
+ batchId,
314
+ boundary: 'sidecar',
315
+ deadlineMs: Math.max(500, timeoutMs - 1000),
316
+ statePacket: buildStatePacket(spec.decision, context),
317
+ questions: [spec.question],
318
+ };
319
+ let result = null;
320
+ try {
321
+ const spawned = spawnSync(
322
+ process.execPath,
323
+ [cliPath, '--root', rootDir],
324
+ {
325
+ cwd: rootDir,
326
+ input: JSON.stringify(batch),
327
+ encoding: 'utf8',
328
+ timeout: timeoutMs,
329
+ },
330
+ );
331
+ if (spawned && !spawned.error && spawned.status === 0 && spawned.stdout) {
332
+ result = JSON.parse(spawned.stdout);
333
+ } else {
334
+ result = { status: 'unavailable', fallbackCode: 'adapter-spawn-failed' };
335
+ }
336
+ } catch {
337
+ result = { status: 'unavailable', fallbackCode: 'adapter-spawn-failed' };
338
+ }
339
+ if (!result || typeof result !== 'object') {
340
+ result = { status: 'unavailable', fallbackCode: 'malformed-response' };
341
+ }
342
+ return result;
343
+ }
344
+
345
+ function validModelAnswer(result, decisionKey) {
346
+ const answers = Array.isArray(result?.answers) ? result.answers : [];
347
+ const answer = answers.find(
348
+ (a) => a?.decisionKey === decisionKey && a.validationStatus === 'valid',
349
+ );
350
+ return typeof answer?.value === 'string' ? answer.value : null;
351
+ }
352
+
353
+ /**
354
+ * Resolve one sidecar decision. Returns a typed result; never throws.
355
+ * {decision, decisionKey, stage, answer, deterministic, source,
356
+ * outcomeClass, modelAnswer?, agreement?, fallbackCode}
357
+ * `answer` is the authoritative value: the deterministic rule at 'off' and
358
+ * 'shadow', the model's valid answer at 'canary'/'default', and the
359
+ * deterministic rule again on any adapter failure (outcomeClass 'unavailable').
360
+ */
361
+ export async function runSidecarDecision({ decision, context = {}, rootDir, homeDir, config } = {}) {
362
+ const spec = SIDECAR_DECISIONS[decision];
363
+ if (!spec) {
364
+ return {
365
+ decision: decision ?? null,
366
+ decisionKey: null,
367
+ stage: 'off',
368
+ answer: null,
369
+ deterministic: null,
370
+ source: 'none',
371
+ outcomeClass: 'invalid',
372
+ fallbackCode: 'unknown-decision',
373
+ };
374
+ }
375
+ const mergedConfig = config ?? readMergedConfig(rootDir, homeDir);
376
+ const stage = resolveSidecarStage(mergedConfig);
377
+ const ctx = isPlainObject(context) ? context : {};
378
+ let deterministic;
379
+ try {
380
+ deterministic = spec.decide(ctx);
381
+ } catch {
382
+ deterministic = spec.question.candidates[0];
383
+ }
384
+ const base = {
385
+ decision,
386
+ decisionKey: spec.decisionKey,
387
+ stage,
388
+ deterministic,
389
+ fallbackPolicy: spec.fallbackPolicy,
390
+ };
391
+
392
+ if (stage === 'off') {
393
+ return { ...base, answer: deterministic, source: 'deterministic', outcomeClass: 'deterministic' };
394
+ }
395
+
396
+ const result = runAdapterBatch({
397
+ spec, context: ctx, rootDir: rootDir ?? process.cwd(), config: mergedConfig,
398
+ batchId: `sidecar-${decision}-${Date.now().toString(36)}`,
399
+ });
400
+ const modelAnswer = validModelAnswer(result, spec.decisionKey);
401
+ const outcomeClass = result?.status ?? 'unavailable';
402
+ const agreement = modelAnswer === null
403
+ ? 'unknown'
404
+ : modelAnswer.toLowerCase() === String(deterministic).toLowerCase()
405
+ ? 'agree'
406
+ : 'disagree';
407
+
408
+ if (outcomeClass === 'unavailable' || outcomeClass === 'unsupported' || outcomeClass === 'invalid') {
409
+ return {
410
+ ...base,
411
+ answer: deterministic,
412
+ source: 'deterministic-fallback',
413
+ outcomeClass: 'unavailable',
414
+ modelAnswer,
415
+ agreement,
416
+ fallbackCode: result?.fallbackCode ?? outcomeClass,
417
+ };
418
+ }
419
+ if (stage === 'shadow') {
420
+ return {
421
+ ...base,
422
+ answer: deterministic,
423
+ source: 'deterministic',
424
+ outcomeClass,
425
+ modelAnswer,
426
+ agreement,
427
+ fallbackCode: result?.fallbackCode ?? null,
428
+ };
429
+ }
430
+ // canary/default: valid model answer wins; invalid → deterministic fallback.
431
+ if (modelAnswer !== null) {
432
+ return {
433
+ ...base,
434
+ answer: modelAnswer,
435
+ source: 'unic-decision',
436
+ outcomeClass,
437
+ modelAnswer,
438
+ agreement,
439
+ fallbackCode: result?.fallbackCode ?? null,
440
+ };
441
+ }
442
+ return {
443
+ ...base,
444
+ answer: deterministic,
445
+ source: 'deterministic-fallback',
446
+ outcomeClass,
447
+ modelAnswer,
448
+ agreement,
449
+ fallbackCode: result?.fallbackCode ?? 'missing-answer',
450
+ };
451
+ }
452
+
453
+ // ---------------------------------------------------------------------------
454
+ // CLI
455
+ // ---------------------------------------------------------------------------
456
+
457
+ function readFlagValue(argv, flag) {
458
+ const index = argv.indexOf(flag);
459
+ if (index === -1) return null;
460
+ const value = argv[index + 1];
461
+ return value && !value.startsWith('--') ? value : null;
462
+ }
463
+
464
+ function readStdin() {
465
+ return new Promise((resolve, reject) => {
466
+ let data = '';
467
+ process.stdin.setEncoding('utf8');
468
+ process.stdin.on('data', (chunk) => { data += chunk; });
469
+ process.stdin.on('end', () => resolve(data));
470
+ process.stdin.on('error', reject);
471
+ });
472
+ }
473
+
474
+ function printResult(result) {
475
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
476
+ }
477
+
478
+ function printDecisionList() {
479
+ const rows = Object.entries(SIDECAR_DECISIONS).map(([name, spec]) => ({
480
+ decision: name,
481
+ decisionKey: spec.decisionKey,
482
+ fallbackPolicy: spec.fallbackPolicy,
483
+ candidates: spec.question.candidates,
484
+ }));
485
+ printResult({ family: SIDECAR_FAMILY, decisions: rows });
486
+ }
487
+
488
+ async function main() {
489
+ const args = process.argv.slice(2);
490
+ const rootDir = readFlagValue(args, '--root') ?? process.env.UKIT_TEST_ROOT ?? process.cwd();
491
+ const homeDir = process.env.UKIT_TEST_HOME ?? os.homedir();
492
+
493
+ if (args.includes('--list')) {
494
+ printDecisionList();
495
+ return 0;
496
+ }
497
+
498
+ // Offline replay passthrough: the sibling adapter owns fixture semantics;
499
+ // this CLI adds none of its own. Exit code is propagated verbatim.
500
+ const fixturePath = readFlagValue(args, '--fixture');
501
+ if (fixturePath !== null) {
502
+ const cliPath = process.env.UKIT_DECISION_CLI_PATH || UNIC_DECISION_CLI_PATH;
503
+ try {
504
+ const spawned = spawnSync(
505
+ process.execPath,
506
+ [cliPath, '--fixture', fixturePath],
507
+ { cwd: rootDir, encoding: 'utf8', timeout: ADAPTER_TIMEOUT_CAP_MS + 2000 },
508
+ );
509
+ if (spawned?.stdout) process.stdout.write(spawned.stdout);
510
+ if (spawned?.stderr) process.stderr.write(spawned.stderr);
511
+ return spawned?.status ?? 1;
512
+ } catch (error) {
513
+ process.stderr.write(
514
+ `sidecar-decision: fixture replay failed: ${error?.message ?? error}\n`,
515
+ );
516
+ return 1;
517
+ }
518
+ }
519
+
520
+ const raw = await readStdin();
521
+ let input = {};
522
+ if (raw.trim().length > 0) {
523
+ try {
524
+ input = JSON.parse(raw);
525
+ } catch {
526
+ process.stderr.write(
527
+ 'sidecar-decision: expected context JSON on stdin ({} allowed) '
528
+ + 'or --decision <name> or --fixture <path> or --list\n',
529
+ );
530
+ return 1;
531
+ }
532
+ if (!isPlainObject(input)) {
533
+ process.stderr.write('sidecar-decision: stdin context must be a JSON object\n');
534
+ return 1;
535
+ }
536
+ }
537
+
538
+ const decision = readFlagValue(args, '--decision') ?? input.decision;
539
+ if (typeof decision !== 'string' || !SIDECAR_DECISIONS[decision]) {
540
+ process.stderr.write(
541
+ `sidecar-decision: unknown or missing decision; expected one of: `
542
+ + `${Object.keys(SIDECAR_DECISIONS).join(', ')}\n`,
543
+ );
544
+ return 1;
545
+ }
546
+ const context = isPlainObject(input.context) ? input.context : input;
547
+
548
+ try {
549
+ printResult(await runSidecarDecision({ decision, context, rootDir, homeDir }));
550
+ } catch (error) {
551
+ // Last-resort never-throws guard: emit the deterministic rule answer.
552
+ const spec = SIDECAR_DECISIONS[decision];
553
+ let deterministic = spec.question.candidates[0];
554
+ try {
555
+ deterministic = spec.decide(isPlainObject(context) ? context : {});
556
+ } catch { /* keep first candidate */ }
557
+ printResult({
558
+ decision,
559
+ decisionKey: spec.decisionKey,
560
+ stage: 'unknown',
561
+ answer: deterministic,
562
+ deterministic,
563
+ source: 'deterministic-fallback',
564
+ outcomeClass: 'unavailable',
565
+ fallbackCode: 'internal-error',
566
+ });
567
+ }
568
+ return 0;
569
+ }
570
+
571
+ const isMainModule = (() => {
572
+ try {
573
+ const invoked = process.argv[1] ?? '';
574
+ if (!invoked) return false;
575
+ const self = path.resolve(fileURLToPath(import.meta.url));
576
+ const target = path.resolve(invoked);
577
+ if (self === target) return true;
578
+ // Installed mirrors may be reached through a symlinked directory (e.g.
579
+ // .codex/ukit -> ../.claude/ukit): realpath resolves the link, basename
580
+ // equality keeps same-named unrelated scripts from matching.
581
+ return path.basename(invoked) === 'sidecar-decision.mjs'
582
+ && fs.realpathSync(target) === self;
583
+ } catch {
584
+ return false;
585
+ }
586
+ })();
587
+
588
+ if (isMainModule) {
589
+ main()
590
+ .then((code) => { process.exitCode = code; })
591
+ .catch((error) => {
592
+ process.stderr.write(`sidecar-decision: ${error?.message ?? error}\n`);
593
+ process.exitCode = 1;
594
+ });
595
+ }
@@ -789,7 +789,16 @@ async function main() {
789
789
 
790
790
  const isMainModule = (() => {
791
791
  try {
792
- return path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1] ?? '');
792
+ const invoked = process.argv[1] ?? '';
793
+ if (!invoked) return false;
794
+ const self = path.resolve(fileURLToPath(import.meta.url));
795
+ const target = path.resolve(invoked);
796
+ if (self === target) return true;
797
+ // Installed mirrors may be reached through a symlinked directory (e.g.
798
+ // .codex/ukit -> ../.claude/ukit): realpath resolves the link, basename
799
+ // equality keeps same-named unrelated scripts from matching.
800
+ return path.basename(invoked) === 'unic-decision.mjs'
801
+ && fs.realpathSync(target) === self;
793
802
  } catch {
794
803
  return false;
795
804
  }
@@ -188,6 +188,9 @@
188
188
  "compact-now-or-later",
189
189
  "summarize-docs-or-keep-detail"
190
190
  ],
191
+ "decisionAdapter": "sidecar-decision.mjs",
192
+ "decisionStageGate": "decisionPlane.families.workflow.stage",
193
+ "decisionKeysSource": "cli",
191
194
  "stepBudgets": {
192
195
  "trivial": {
193
196
  "maxSteps": 1,
@@ -48,6 +48,26 @@ If any input is missing, return `CHANGES-REQUESTED` with reason "incomplete hand
48
48
  - **APPROVED-WITH-MINOR** — Minor naming / doc / style issues. Logged on task file but handoff allowed.
49
49
  - **APPROVED** — Clean.
50
50
 
51
+ ### Stage gate — verdict emission (unic-decision)
52
+
53
+ You generate the findings; the FINAL verdict/bucket classification is a bounded
54
+ decision owned by the local decision model when staged. Read
55
+ `decisionPlane.families.review.stage` from `.ukit/storage/config.json`:
56
+
57
+ - `off` (default) or any adapter failure (`outcomeClass` ≠ `accepted`/`partial`) →
58
+ you emit the verdict directly, exactly as today; the deterministic severity
59
+ aggregate (any critical → CHANGES-REQUESTED; ≥1 important or any
60
+ unclassified/unknown severity → APPROVED-WITH-MINOR; else APPROVED) stays
61
+ authoritative.
62
+ - not `off` → emit the verdict via `node .claude/ukit/index/review-verdict.mjs`
63
+ (registered key `review.verdict.v1`): pipe your extracted findings as JSON on
64
+ stdin — whitelisted fields only (`id`, `severity`, `file`, `line`, short
65
+ `claim`; never diff hunks or secrets). Use the adapter's `verdict`/`buckets`
66
+ fields in the `## Reviewer Verdict` block.
67
+
68
+ Model isolation is preserved: the verdict pass runs on the local unic-decision
69
+ model, so reviewer ≠ executor still stands.
70
+
51
71
  ### Output (append to task file as `## Reviewer Verdict`)
52
72
 
53
73
  ```
@@ -139,7 +159,11 @@ additions:
139
159
  `node .claude/ukit/index/review-panel-aggregate.mjs <TASK-xxx.md...>` and hands you
140
160
  the output, the lead fills `AGREEMENT_MAP` with the emitted finding → members map
141
161
  and applies the lead-judgment buckets to every finding: **Act on** / **Consider** /
142
- **Noted** / **Dismissed**.
162
+ **Noted** / **Dismissed**. When `decisionPlane.families.review.stage` is not `off`,
163
+ the lead's bucketing and panel verdict go through
164
+ `node .claude/ukit/index/review-verdict.mjs --panel` (`review.finding-bucket.v1` /
165
+ `review.panel-verdict.v1`) per the stage gate above — the deterministic aggregate
166
+ stays authoritative at `off` or on adapter failure.
143
167
  - `consensus≥2 identical findings = high signal`: a finding reported by two or more
144
168
  panel members is high-signal and must not be bucketed below **Consider** without a
145
169
  stated reason.
@@ -27,6 +27,22 @@ You are UKit's internal small-task maintainer. You run as a sidecar/parallel/non
27
27
  - Keeping agent context compact without removing existing lanes: Claude PreCompact/reinject stays active and Codex Desktop soft handoffs use `compact.codexContext.compactTarget` (default 150 lines; preferred 120-150; hard max 170) while preserving critical state.
28
28
  - Small, reversible UKit runtime maintenance decisions.
29
29
 
30
+ ## Bounded Decisions (stage-gated unic-decision)
31
+
32
+ The six bounded decisions enumerated in `.codex/settings.json` `smallTaskModel.decisionPolicy.decisions` — `fast-vs-slow-lane`, `safe-vs-risky-lane`, `skill-routing-needed`, `step-budget-enough`, `compact-now-or-later`, `summarize-docs-or-keep-detail` — consult `unic-decision` first via the installed CLI when the decision-plane stage allows:
33
+
34
+ ```
35
+ node .claude/ukit/index/sidecar-decision.mjs --decision <name> [--root <dir>] # context JSON on stdin
36
+ ```
37
+
38
+ The CLI resolves `decisionPlane.families.workflow.stage` and owns the name → `workflow.*` decision-key mapping (`--list` prints it):
39
+
40
+ - `off` (default): the CLI's deterministic rules answer directly — zero transport, authoritative.
41
+ - `shadow`: run the batch for comparison only; the deterministic rule answer stays authoritative.
42
+ - `canary`/`default`: a valid unic-decision answer wins; an invalid or unavailable adapter falls back to the deterministic rule (`outcomeClass: 'unavailable'`).
43
+
44
+ `unic-decision` is the only model allowed in these decision steps — on any failure the deterministic rule is the fallback, never another LLM for the verdict. This lane's own model (`unic-lite`) keeps generative work only: summarization, doc maintenance, and cleanup — it never emits a verdict for these decisions.
45
+
30
46
  ## Never Use For
31
47
 
32
48
  - Security/auth/permission/secrets work.