@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.
- package/CHANGELOG.md +21 -0
- package/README.md +1 -0
- package/manifests/documentation.yaml +12 -0
- package/manifests/platform.full.yaml +24 -0
- package/package.json +1 -1
- package/scripts/bench/data-foundation.mjs +368 -50
- package/src/cli/commands/doctor.js +232 -3
- package/src/cli/commands/feedback.js +64 -1
- package/src/cli/commands/install.js +18 -0
- package/src/cli/commands/memory.js +42 -37
- package/src/cli/commands/telemetry.js +460 -0
- package/src/cli/index.js +7 -0
- package/src/core/agentRuntime/adapters.js +83 -2
- package/src/core/agentRuntime/diagnostics.js +104 -0
- package/src/core/agentRuntime/supervisor.js +137 -0
- package/src/core/agentRuntime/telemetry.js +204 -0
- package/src/core/memory/memoryEmit.js +131 -0
- package/src/core/memory/memoryHit.js +1 -1
- package/src/core/memory/migrate.js +18 -11
- package/src/core/memory/migrateMapping.js +15 -7
- package/src/core/memory/mutateMemory.js +22 -4
- package/src/core/memory/recordIndex.js +10 -3
- package/src/core/memory/recordStore.js +28 -3
- package/src/core/memory/retrieval.js +79 -38
- package/src/core/memory/store.js +37 -37
- package/src/core/memory/storeV2.js +28 -25
- package/src/core/memory/storeV2Loader.js +2 -2
- package/src/core/observability/adapters/ingest.js +576 -0
- package/src/core/observability/analytics/anomalies.js +415 -0
- package/src/core/observability/analytics/summary.js +16 -1
- package/src/core/observability/emit/config.js +69 -1
- package/src/core/observability/emit/crash.js +434 -0
- package/src/core/observability/emit/lifecycle.js +349 -0
- package/src/core/observability/emit/recorder.js +135 -9
- package/src/core/observability/evaluation/aiPacket.js +52 -10
- package/src/core/observability/evaluation/outcomes.js +95 -0
- package/src/core/observability/evaluation/runner.js +225 -0
- package/src/core/observability/privacy/allowlist.js +23 -3
- package/src/core/observability/schema/compatibility.js +48 -3
- package/src/core/observability/schema/constants.js +5 -0
- package/src/core/observability/schema/registry.js +57 -0
- package/src/core/observability/schema/validate.js +68 -6
- package/src/core/observability/segments/internal.js +42 -8
- package/src/core/observability/segments/readSegments.js +35 -1
- package/src/core/observability/segments/recovery.js +3 -2
- package/src/core/observability/segments/retention.js +137 -33
- package/src/core/observability/support/projector.js +88 -18
- package/src/core/observability/support/provision.js +160 -0
- package/src/core/observability/support/renderer.js +2 -2
- package/src/core/observability/support/schedule.js +174 -0
- 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/hooks/auto-allow-bash.sh +7 -1
- package/template_project/.claude/hooks/auto-prune-bash.sh +16 -7
- package/template_project/.claude/hooks/verification-guard.sh +13 -4
- 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/.claude/ukit/runtime/async-lock.mjs +26 -0
- 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
|
}
|
|
@@ -452,6 +452,15 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
|
|
|
452
452
|
const releaseRetry = (op) => withTransientFsRetry(op, { deadlineMs: LOCK_RESERVE_MS });
|
|
453
453
|
|
|
454
454
|
while (true) {
|
|
455
|
+
// Abort wins over every other condition, checked once per iteration: an abort
|
|
456
|
+
// landing mid-sweep/mid-op must still surface the typed aborted outcome — the
|
|
457
|
+
// ops below can throw the raw signal.reason (withTransientFsRetry rethrows
|
|
458
|
+
// AbortError when the signal fires during a transient retry), and a non-EEXIST
|
|
459
|
+
// throw would otherwise escape as an unhandled rejection instead of the
|
|
460
|
+
// contract's { ok: false, reason: 'aborted' } envelope.
|
|
461
|
+
if (signal?.aborted) {
|
|
462
|
+
return { ok: false, reason: 'aborted', waitedMs: Date.now() - startedAt };
|
|
463
|
+
}
|
|
455
464
|
// Reap stale `*.reclaim-*` quarantine dirs stranded by dead reapers. Cheap:
|
|
456
465
|
// one readdir per attempt, all failures swallowed (C79-21).
|
|
457
466
|
await sweepStaleReclaims(lockPath, stale);
|
|
@@ -489,6 +498,11 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
|
|
|
489
498
|
owned = true;
|
|
490
499
|
break;
|
|
491
500
|
} catch (error) {
|
|
501
|
+
// An abort surfacing as the raw AbortError out of a transient retry is the
|
|
502
|
+
// typed aborted outcome, never an untyped throw.
|
|
503
|
+
if (signal?.aborted) {
|
|
504
|
+
return { ok: false, reason: 'aborted', waitedMs: Date.now() - startedAt };
|
|
505
|
+
}
|
|
492
506
|
if (error?.code !== 'EEXIST') throw error;
|
|
493
507
|
}
|
|
494
508
|
|
|
@@ -533,6 +547,12 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
|
|
|
533
547
|
}
|
|
534
548
|
if (reclaimed) continue;
|
|
535
549
|
|
|
550
|
+
// Abort after the wait phase still returns the typed aborted outcome — the
|
|
551
|
+
// budget check must not claim 'busy' for a caller that was actually cancelled.
|
|
552
|
+
if (signal?.aborted) {
|
|
553
|
+
return { ok: false, reason: 'aborted', waitedMs: Date.now() - startedAt };
|
|
554
|
+
}
|
|
555
|
+
|
|
536
556
|
const waitedMs = Date.now() - startedAt;
|
|
537
557
|
if (waitedMs >= budget) {
|
|
538
558
|
// Fail closed: the caller's policy decides what a busy lock means. The callback
|
|
@@ -548,6 +568,12 @@ export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SL
|
|
|
548
568
|
}
|
|
549
569
|
}
|
|
550
570
|
|
|
571
|
+
// Abort between acquire and the critical section releases the owned lock through
|
|
572
|
+
// the normal finally path and reports 'aborted' instead of running fn after the
|
|
573
|
+
// caller was cancelled.
|
|
574
|
+
if (signal?.aborted) {
|
|
575
|
+
return { ok: false, reason: 'aborted', waitedMs: Date.now() - startedAt };
|
|
576
|
+
}
|
|
551
577
|
try {
|
|
552
578
|
return { ok: true, value: await fn() };
|
|
553
579
|
} finally {
|
|
@@ -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,
|