@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.
- package/CHANGELOG.md +9 -0
- package/manifests/platform.full.yaml +24 -0
- package/package.json +1 -1
- package/src/decision/registry.js +144 -0
- 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/sidecar-decision.mjs +595 -0
- package/template_project/.claude/ukit/index/unic-decision.mjs +10 -1
- 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
|
@@ -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
|
-
|
|
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.
|