@ngockhoale/ukit 3.0.12 → 3.1.1

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