@ngockhoale/ukit 3.0.3 → 3.0.4
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 +17 -0
- package/bin/ukit +12 -4
- package/manifests/engineConformance.yaml +29 -0
- package/manifests/platform.full.yaml +13 -0
- package/package.json +2 -1
- package/scripts/audit/decision-coverage.mjs +295 -0
- package/scripts/bench/outline-savings.mjs +19 -4
- package/scripts/bench/parallel-agents.mjs +15 -4
- package/scripts/bench/runGold.mjs +22 -4
- package/scripts/bench/v3-ceremony.mjs +7 -1
- package/scripts/bug/triage.mjs +56 -17
- package/scripts/index/build-index.mjs +94 -28
- package/scripts/index/query-index.mjs +48 -14
- package/scripts/index/refresh-index.mjs +142 -62
- package/scripts/perf/audit-perf.mjs +8 -2
- package/scripts/skill/audit-skill.mjs +54 -25
- package/src/bug/triageBug.js +9 -6
- package/src/cli/adapters.js +6 -0
- package/src/cli/commands/code.js +7 -1
- package/src/cli/commands/indexArgs.js +4 -2
- package/src/cli/commands/indexTools.js +8 -1
- package/src/cli/commands/install.js +13 -0
- package/src/cli/commands/memory.js +10 -3
- package/src/cli/commands/status.js +17 -1
- package/src/cli/commands/update.js +7 -0
- package/src/context/detectProjectContext.js +3 -1
- package/src/core/codeintel/analogy.js +1 -1
- package/src/core/codeintel/diagnostics.js +9 -6
- package/src/core/codeintel/graph.js +14 -8
- package/src/core/codeintel/impact.js +0 -1
- package/src/core/codeintel/invalidation.js +5 -3
- package/src/core/codeintel/packet.js +11 -0
- package/src/core/codeintel/router.js +17 -3
- package/src/core/codeintel/semanticProvider.js +1 -1
- package/src/core/codeintel/summaries.js +11 -7
- package/src/core/compact/contextBudget.js +26 -12
- package/src/core/compact/index.js +15 -8
- package/src/core/docContracts.js +10 -2
- package/src/core/experiments/deliberation.js +321 -0
- package/src/core/experiments/dynamicWorkflow.js +492 -0
- package/src/core/fileOps.js +8 -1
- package/src/core/gatewayProbe.js +29 -1
- package/src/core/gatewayResilienceEnv.js +44 -3
- package/src/core/handoffDocValidator.js +3 -1
- package/src/core/hookChainDoctor.js +16 -1
- package/src/core/memory/deltaOverlays.js +448 -0
- package/src/core/memory/learningCandidates.js +302 -0
- package/src/core/memory/migrate.js +59 -32
- package/src/core/memory/recordStore.js +24 -1
- package/src/core/memory/store.js +44 -8
- package/src/core/memory/storeV2.js +48 -33
- package/src/core/memory/userMemory.js +11 -10
- package/src/core/output/index.js +15 -10
- package/src/core/permissionPolicy.js +8 -0
- package/src/core/runtimeConfig.js +224 -4
- package/src/core/sensitiveValueScanner.js +10 -2
- package/src/core/taskBudgetValidator.js +12 -17
- package/src/core/taskProgressGuard.js +59 -9
- package/src/core/unattendedDoctor.js +5 -2
- package/src/core/uninstall.js +37 -8
- package/src/decision/client.js +371 -0
- package/src/decision/lease.js +198 -0
- package/src/decision/preflight.js +492 -0
- package/src/decision/protocol.js +308 -0
- package/src/decision/registry.js +384 -0
- package/src/decision/shadow.js +281 -0
- package/src/decision/statePacket.js +165 -0
- package/src/diagnostics/failurePatterns.js +2 -1
- package/src/diagnostics/ledgerFiles.js +3 -1
- package/src/diagnostics/routeOutcomes.js +1 -29
- package/src/index/buildIndex.js +23 -11
- package/src/index/impactContext.js +21 -2
- package/src/index/importResolution.js +13 -7
- package/src/index/queryIndex.js +11 -5
- package/src/index/resolveContext.js +16 -6
- package/src/index/taskRouting.js +63 -2
- package/src/index/verificationPlan.js +12 -1
- package/src/learning/patternProposals.js +6 -0
- package/src/render/renderTemplate.js +1 -1
- package/src/skill/auditSkill.js +3 -1
- package/src/stack/detectStack.js +3 -1
- package/template_project/.claude/agents/handoff-planner.md +2 -5
- package/template_project/.claude/hooks/auto-allow-bash.sh +5 -0
- package/template_project/.claude/hooks/block-dangerous.mjs +11 -4
- package/template_project/.claude/hooks/context-hardcap-gate.sh +10 -1
- package/template_project/.claude/hooks/handoff-model-guard.sh +14 -4
- package/template_project/.claude/hooks/handoff-resume.sh +10 -1
- package/template_project/.claude/hooks/protect-files.sh +0 -1
- package/template_project/.claude/hooks/record-execution.mjs +13 -1
- package/template_project/.claude/hooks/sensitive-data-guard.mjs +71 -5
- package/template_project/.claude/hooks/session-episode.sh +9 -2
- package/template_project/.claude/skills/pdf-processing-pro/SKILL.md +1 -1
- package/template_project/.claude/ukit/index/handoff-doc-validator.mjs +3 -1
- package/template_project/.claude/ukit/index/lib/index-core.mjs +123 -39
- package/template_project/.claude/ukit/index/route-task.mjs +444 -0
- package/template_project/.claude/ukit/index/task-budget-validator.mjs +12 -16
- package/template_project/.claude/ukit/index/unic-decision.mjs +786 -0
- package/template_project/.claude/ukit/index/verify-context.mjs +11 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +116 -9
- package/template_project/.claude/ukit/runtime/project-important.mjs +9 -7
- package/template_project/.claude/ukit/runtime/reinject-context.mjs +48 -0
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +596 -0
- package/template_project/.claude/ukit/runtime/sensitive-value-scanner.mjs +4 -7
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +1 -1
- package/template_project/docs/AI_HANDOFF/PLAN.md +7 -7
- package/template_project/docs/AI_HANDOFF/RULES.md +1 -1
- package/template_project/ukit/storage/config.json +34 -1
- package/template_project/.claude/ukit/skill-router-state.json +0 -1
- package/template_project/.ukit/storage/cache/hook-latency/unknown.jsonl +0 -4
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* unic-decision.mjs (TASK-005 / M07, SPEC §5 FR-011)
|
|
4
|
+
*
|
|
5
|
+
* Standalone installed-side adapter for the UNIC Decision Agent
|
|
6
|
+
* (docs/pstack/UNIC_DECISION_SPEC.md §8/§9, CONTRACTS.md C13/C14).
|
|
7
|
+
*
|
|
8
|
+
* Modes:
|
|
9
|
+
* node unic-decision.mjs --health-probe one no-tools request; the ONLY
|
|
10
|
+
* request allowed to carry no
|
|
11
|
+
* tools (§9). Plain
|
|
12
|
+
* message.content is accepted
|
|
13
|
+
* here and nowhere else.
|
|
14
|
+
* node unic-decision.mjs --fixture <path> offline replay: reads
|
|
15
|
+
* {questions, response} JSON and
|
|
16
|
+
* prints the normalized
|
|
17
|
+
* DecisionBatchResult. Zero
|
|
18
|
+
* transport, zero dispatch.
|
|
19
|
+
* node unic-decision.mjs [--root <dir>] reads a batch JSON on stdin
|
|
20
|
+
* ({statePacket, questions,
|
|
21
|
+
* deadlineMs?}), posts it to
|
|
22
|
+
* <baseUrl>/v1/chat/completions,
|
|
23
|
+
* prints the typed result.
|
|
24
|
+
*
|
|
25
|
+
* Protocol encode/parse is a LITERAL MIRROR of src/decision/protocol.js — this
|
|
26
|
+
* file cannot import from src/, so the copy is parity-locked by
|
|
27
|
+
* tests/index/unicDecision.test.js. Endpoint/credential resolution mirrors
|
|
28
|
+
* src/core/gatewayProbe.js (env → project .claude/settings.json → home
|
|
29
|
+
* ~/.claude/settings.json); detection conventions follow unic-gateway.mjs.
|
|
30
|
+
* No literal credentials ever appear here — env indirection only.
|
|
31
|
+
*
|
|
32
|
+
* `tool_calls` in responses are data only: parsed exactly once, NEVER
|
|
33
|
+
* dispatched, never fed back into a tool loop.
|
|
34
|
+
*
|
|
35
|
+
* Exit codes: 0 for every typed outcome (including invalid/partial/unavailable
|
|
36
|
+
* — the JSON status field carries the truth); 1 only for usage errors or
|
|
37
|
+
* unreadable/malformed input.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import fs from 'node:fs';
|
|
41
|
+
import path from 'node:path';
|
|
42
|
+
import os from 'node:os';
|
|
43
|
+
import { createHash } from 'node:crypto';
|
|
44
|
+
import { fileURLToPath } from 'node:url';
|
|
45
|
+
import {
|
|
46
|
+
scanText,
|
|
47
|
+
isSensitiveDataGateEnabled,
|
|
48
|
+
loadSensitiveAllowlist,
|
|
49
|
+
} from '../runtime/sensitive-value-scanner.mjs';
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Protocol mirror — literal copy of src/decision/protocol.js (TASK-002).
|
|
53
|
+
// Parity is locked by tests/index/unicDecision.test.js; edit both or neither.
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
const VALID_KINDS = new Set(['choice', 'noul', 'score']);
|
|
57
|
+
|
|
58
|
+
export function encodeQuestion(question) {
|
|
59
|
+
if (!question || typeof question !== 'object') {
|
|
60
|
+
throw new TypeError('question must be an object.');
|
|
61
|
+
}
|
|
62
|
+
const { decisionKey, kind, instruction, candidates } = question;
|
|
63
|
+
if (typeof decisionKey !== 'string' || decisionKey.length === 0) {
|
|
64
|
+
throw new TypeError('question.decisionKey must be a non-empty string.');
|
|
65
|
+
}
|
|
66
|
+
if (!VALID_KINDS.has(kind)) {
|
|
67
|
+
throw new TypeError(`question.kind must be one of ${[...VALID_KINDS].join('|')}.`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let value;
|
|
71
|
+
if (kind === 'choice') {
|
|
72
|
+
if (!Array.isArray(candidates) || candidates.length === 0) {
|
|
73
|
+
throw new TypeError('choice question requires a non-empty candidates array.');
|
|
74
|
+
}
|
|
75
|
+
value = { type: 'string', enum: [...candidates] };
|
|
76
|
+
} else if (kind === 'noul') {
|
|
77
|
+
value = {
|
|
78
|
+
type: 'boolean',
|
|
79
|
+
description: 'true iff the proposition holds; interpreted as P(true).',
|
|
80
|
+
};
|
|
81
|
+
} else {
|
|
82
|
+
// score — ordinal integer; the legend MUST be defined in the description.
|
|
83
|
+
const legend = Array.isArray(candidates) ? candidates : [];
|
|
84
|
+
value = { type: 'integer' };
|
|
85
|
+
if (legend.length > 0) {
|
|
86
|
+
value.minimum = 1;
|
|
87
|
+
value.maximum = legend.length;
|
|
88
|
+
value.description = `Ordered scale: ${legend
|
|
89
|
+
.map((label, i) => `${i + 1}=${label}`)
|
|
90
|
+
.join(', ')}.`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
type: 'function',
|
|
96
|
+
function: {
|
|
97
|
+
name: decisionKey,
|
|
98
|
+
description: String(instruction ?? ''),
|
|
99
|
+
parameters: {
|
|
100
|
+
type: 'object',
|
|
101
|
+
properties: { value },
|
|
102
|
+
required: ['value'],
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Deterministic serialization shared with statePacket.js: sorted keys so the
|
|
109
|
+
// same logical packet always produces the same bytes (fingerprint-stable).
|
|
110
|
+
function stableStringify(value) {
|
|
111
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
112
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
113
|
+
const keys = Object.keys(value).sort();
|
|
114
|
+
return `{${keys
|
|
115
|
+
.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`)
|
|
116
|
+
.join(',')}}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function encodeBatch({ model, statePacket, questions }) {
|
|
120
|
+
if (!Array.isArray(questions) || questions.length === 0) {
|
|
121
|
+
throw new TypeError('encodeBatch requires a non-empty questions array.');
|
|
122
|
+
}
|
|
123
|
+
const content =
|
|
124
|
+
typeof statePacket === 'string' ? statePacket : stableStringify(statePacket);
|
|
125
|
+
return {
|
|
126
|
+
model,
|
|
127
|
+
messages: [{ role: 'user', content }],
|
|
128
|
+
tools: questions.map(encodeQuestion),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function classifyLanguage(text) {
|
|
133
|
+
if (typeof text !== 'string' || text.trim().length === 0) return 'unknown';
|
|
134
|
+
let sawLetter = false;
|
|
135
|
+
for (const ch of text) {
|
|
136
|
+
if (/\p{L}/u.test(ch)) {
|
|
137
|
+
sawLetter = true;
|
|
138
|
+
if (ch.codePointAt(0) > 0x7f) return 'multilingual';
|
|
139
|
+
} else if (ch.codePointAt(0) > 0x7f) {
|
|
140
|
+
// Non-letter non-ASCII (emoji, CJK punctuation, …): cannot prove English.
|
|
141
|
+
return 'multilingual';
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return sawLetter ? 'english' : 'unknown';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isFiniteNumber(v) {
|
|
148
|
+
return typeof v === 'number' && Number.isFinite(v);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function invalidAnswer(question, fallbackCode) {
|
|
152
|
+
return {
|
|
153
|
+
decisionKey: question.decisionKey,
|
|
154
|
+
schemaVersion: question.schemaVersion ?? 1,
|
|
155
|
+
kind: question.kind,
|
|
156
|
+
value: null,
|
|
157
|
+
probabilities: null,
|
|
158
|
+
confidence: null,
|
|
159
|
+
actProbability: null,
|
|
160
|
+
calibration: 'unknown',
|
|
161
|
+
validationStatus: 'invalid',
|
|
162
|
+
fallbackCode,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function normalizeProbabilities(raw) {
|
|
167
|
+
if (raw == null) return { probabilities: null, calibration: 'unknown' };
|
|
168
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
169
|
+
return { error: 'non-finite-probabilities' };
|
|
170
|
+
}
|
|
171
|
+
const out = {};
|
|
172
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
173
|
+
if (!isFiniteNumber(v)) return { error: 'non-finite-probabilities' };
|
|
174
|
+
out[k] = v;
|
|
175
|
+
}
|
|
176
|
+
return { probabilities: out, calibration: 'eligible' };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function validateValue(question, args) {
|
|
180
|
+
const value = args?.value;
|
|
181
|
+
if (question.kind === 'choice') {
|
|
182
|
+
if (typeof value !== 'string') return 'missing-value';
|
|
183
|
+
if (!question.candidates.includes(value)) return 'out-of-enum';
|
|
184
|
+
return { value };
|
|
185
|
+
}
|
|
186
|
+
if (question.kind === 'noul') {
|
|
187
|
+
// P(true): accept a boolean or a finite probability in [0,1].
|
|
188
|
+
if (typeof value === 'boolean') return { value: value ? 1 : 0 };
|
|
189
|
+
if (isFiniteNumber(value) && value >= 0 && value <= 1) return { value };
|
|
190
|
+
return 'missing-value';
|
|
191
|
+
}
|
|
192
|
+
// score — finite integer within the declared legend range.
|
|
193
|
+
if (!isFiniteNumber(value) || !Number.isInteger(value)) return 'missing-value';
|
|
194
|
+
const legend = Array.isArray(question.candidates) ? question.candidates : [];
|
|
195
|
+
if (legend.length > 0 && (value < 1 || value > legend.length)) return 'out-of-enum';
|
|
196
|
+
return { value };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function parseBatchResponse(response, questions) {
|
|
200
|
+
const message = response?.choices?.[0]?.message;
|
|
201
|
+
const finishReason = response?.choices?.[0]?.finish_reason ?? null;
|
|
202
|
+
const toolCalls = Array.isArray(message?.tool_calls) ? message.tool_calls : null;
|
|
203
|
+
|
|
204
|
+
const answers = [];
|
|
205
|
+
let status;
|
|
206
|
+
|
|
207
|
+
if (!toolCalls) {
|
|
208
|
+
status = 'invalid';
|
|
209
|
+
for (const q of questions) answers.push(invalidAnswer(q, 'malformed-response'));
|
|
210
|
+
} else if (toolCalls.length === 0) {
|
|
211
|
+
status = 'abstained';
|
|
212
|
+
for (const q of questions) answers.push(invalidAnswer(q, 'missing-answer'));
|
|
213
|
+
} else {
|
|
214
|
+
// Name-based reconciliation: order in the response is not trusted.
|
|
215
|
+
const byName = new Map();
|
|
216
|
+
const duplicates = new Set();
|
|
217
|
+
const unknown = [];
|
|
218
|
+
const questionNames = new Set(questions.map((q) => q.decisionKey));
|
|
219
|
+
for (const call of toolCalls) {
|
|
220
|
+
const name = call?.function?.name;
|
|
221
|
+
if (typeof name !== 'string' || !questionNames.has(name)) {
|
|
222
|
+
unknown.push(name ?? 'unknown');
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (byName.has(name)) {
|
|
226
|
+
duplicates.add(name);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
byName.set(name, call);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
for (const q of questions) {
|
|
233
|
+
if (duplicates.has(q.decisionKey)) {
|
|
234
|
+
answers.push(invalidAnswer(q, 'duplicate-answer'));
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const call = byName.get(q.decisionKey);
|
|
238
|
+
if (!call) {
|
|
239
|
+
answers.push(invalidAnswer(q, 'missing-answer'));
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
let args;
|
|
243
|
+
try {
|
|
244
|
+
args = JSON.parse(call.function.arguments);
|
|
245
|
+
} catch {
|
|
246
|
+
answers.push(invalidAnswer(q, 'malformed-arguments'));
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) {
|
|
250
|
+
answers.push(invalidAnswer(q, 'malformed-arguments'));
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const checked = validateValue(q, args);
|
|
254
|
+
if (typeof checked === 'string') {
|
|
255
|
+
answers.push(invalidAnswer(q, checked));
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const probs = normalizeProbabilities(args.probabilities);
|
|
259
|
+
if (probs.error) {
|
|
260
|
+
answers.push(invalidAnswer(q, probs.error));
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
answers.push({
|
|
264
|
+
decisionKey: q.decisionKey,
|
|
265
|
+
schemaVersion: q.schemaVersion ?? 1,
|
|
266
|
+
kind: q.kind,
|
|
267
|
+
value: checked.value,
|
|
268
|
+
probabilities: probs.probabilities,
|
|
269
|
+
confidence: isFiniteNumber(args.confidence) ? args.confidence : null,
|
|
270
|
+
actProbability: isFiniteNumber(args.actProbability)
|
|
271
|
+
? args.actProbability
|
|
272
|
+
: isFiniteNumber(args?.action?.act_probability)
|
|
273
|
+
? args.action.act_probability
|
|
274
|
+
: null,
|
|
275
|
+
calibration: probs.calibration,
|
|
276
|
+
validationStatus: 'valid',
|
|
277
|
+
fallbackCode: null,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Unknown call names are data too — surfaced as invalid answers, never run.
|
|
282
|
+
for (const name of unknown) {
|
|
283
|
+
answers.push({
|
|
284
|
+
decisionKey: name,
|
|
285
|
+
schemaVersion: 1,
|
|
286
|
+
kind: null,
|
|
287
|
+
value: null,
|
|
288
|
+
probabilities: null,
|
|
289
|
+
confidence: null,
|
|
290
|
+
actProbability: null,
|
|
291
|
+
calibration: 'unknown',
|
|
292
|
+
validationStatus: 'invalid',
|
|
293
|
+
fallbackCode: 'unknown-answer',
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const validCount = answers.filter((a) => a.validationStatus === 'valid').length;
|
|
298
|
+
status =
|
|
299
|
+
validCount === answers.length ? 'accepted' : validCount > 0 ? 'partial' : 'invalid';
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return { status, finishReason, answers };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
// Gateway resolution — mirror of src/core/gatewayProbe.js probe order
|
|
307
|
+
// (env → project .claude/settings.json → home ~/.claude/settings.json), the
|
|
308
|
+
// same conventions unic-gateway.mjs detects on. Never throws.
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
function safeReadJson(filePath) {
|
|
312
|
+
try {
|
|
313
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function pickString(...candidates) {
|
|
320
|
+
for (const candidate of candidates) {
|
|
321
|
+
if (typeof candidate === 'string' && candidate.length > 0) return candidate;
|
|
322
|
+
}
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function readSettingsEnvBlock(filePath) {
|
|
327
|
+
const json = safeReadJson(filePath);
|
|
328
|
+
if (!json || typeof json !== 'object') return null;
|
|
329
|
+
const envBlock = json.env;
|
|
330
|
+
if (!envBlock || typeof envBlock !== 'object' || Array.isArray(envBlock)) return null;
|
|
331
|
+
return envBlock;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function resolveGatewayBaseUrl({ projectRoot, homeDir, env }) {
|
|
335
|
+
const envUrl = pickString(env?.ANTHROPIC_BASE_URL);
|
|
336
|
+
if (envUrl) return { baseUrl: envUrl, source: 'env' };
|
|
337
|
+
const settingsPaths = [
|
|
338
|
+
...(projectRoot ? [path.join(projectRoot, '.claude', 'settings.json')] : []),
|
|
339
|
+
path.join(homeDir ?? os.homedir(), '.claude', 'settings.json'),
|
|
340
|
+
];
|
|
341
|
+
for (const settingsPath of settingsPaths) {
|
|
342
|
+
const envBlock = readSettingsEnvBlock(settingsPath);
|
|
343
|
+
const value = pickString(envBlock?.ANTHROPIC_BASE_URL);
|
|
344
|
+
if (value) return { baseUrl: value, source: 'claude-settings' };
|
|
345
|
+
}
|
|
346
|
+
return { baseUrl: null, source: null };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function pickApiKey(envLike) {
|
|
350
|
+
if (!envLike || typeof envLike !== 'object') return null;
|
|
351
|
+
// ANTHROPIC_API_KEY → x-api-key; ANTHROPIC_AUTH_TOKEN → Authorization: Bearer.
|
|
352
|
+
// The wrong header 401s token-auth gateways (gatewayProbe.js comment).
|
|
353
|
+
if (typeof envLike.ANTHROPIC_API_KEY === 'string' && envLike.ANTHROPIC_API_KEY.length > 0) {
|
|
354
|
+
return { value: envLike.ANTHROPIC_API_KEY, scheme: 'api-key' };
|
|
355
|
+
}
|
|
356
|
+
if (typeof envLike.ANTHROPIC_AUTH_TOKEN === 'string' && envLike.ANTHROPIC_AUTH_TOKEN.length > 0) {
|
|
357
|
+
return { value: envLike.ANTHROPIC_AUTH_TOKEN, scheme: 'auth-token' };
|
|
358
|
+
}
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function resolveGatewayApiKey({ projectRoot, homeDir, env }) {
|
|
363
|
+
const envKey = pickApiKey(env ?? {});
|
|
364
|
+
if (envKey) return { ...envKey, source: 'env' };
|
|
365
|
+
const settingsPaths = [
|
|
366
|
+
...(projectRoot ? [path.join(projectRoot, '.claude', 'settings.json')] : []),
|
|
367
|
+
path.join(homeDir ?? os.homedir(), '.claude', 'settings.json'),
|
|
368
|
+
];
|
|
369
|
+
for (const settingsPath of settingsPaths) {
|
|
370
|
+
const envBlock = readSettingsEnvBlock(settingsPath);
|
|
371
|
+
if (!envBlock) continue;
|
|
372
|
+
const key = pickApiKey(envBlock);
|
|
373
|
+
if (key) return { ...key, source: 'claude-settings' };
|
|
374
|
+
}
|
|
375
|
+
return { value: null, scheme: null, source: null };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function buildAuthHeaders(key) {
|
|
379
|
+
if (!key?.value) return {};
|
|
380
|
+
if (key.scheme === 'auth-token') {
|
|
381
|
+
return { Authorization: `Bearer ${key.value}` };
|
|
382
|
+
}
|
|
383
|
+
return { 'x-api-key': key.value };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
// Runtime config — minimal merged view (project over user), mirroring
|
|
388
|
+
// route-task.mjs readMergedConfig semantics for the two blocks this adapter
|
|
389
|
+
// reads: decisionPlane and security.
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
function isPlainObject(value) {
|
|
393
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function mergeConfigObjects(base, override) {
|
|
397
|
+
if (!isPlainObject(base)) return isPlainObject(override) ? { ...override } : {};
|
|
398
|
+
if (!isPlainObject(override)) return { ...base };
|
|
399
|
+
const out = { ...base };
|
|
400
|
+
for (const [key, value] of Object.entries(override)) {
|
|
401
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
|
|
402
|
+
out[key] = isPlainObject(value) && isPlainObject(base[key])
|
|
403
|
+
? mergeConfigObjects(base[key], value)
|
|
404
|
+
: value;
|
|
405
|
+
}
|
|
406
|
+
return out;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function readMergedConfig(rootDir, homeDir) {
|
|
410
|
+
const projectRaw = rootDir
|
|
411
|
+
? safeReadJson(path.join(rootDir, '.ukit', 'storage', 'config.json'))
|
|
412
|
+
: null;
|
|
413
|
+
const userRaw = safeReadJson(
|
|
414
|
+
path.join(homeDir ?? os.homedir(), '.ukit', 'storage', 'config.json'),
|
|
415
|
+
);
|
|
416
|
+
return mergeConfigObjects(userRaw ?? {}, projectRaw ?? {});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// Client core — mirror of src/decision/client.js semantics minus the circuit
|
|
421
|
+
// breaker (a one-shot CLI has no cross-request state to protect).
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
const DEFAULT_CHECKPOINTS = {
|
|
425
|
+
default: 'unic-decision/laya-multilingual',
|
|
426
|
+
english: 'unic-decision/laya',
|
|
427
|
+
unknownLanguage: 'unic-decision/auto',
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
431
|
+
const DEFAULT_MAX_RETRIES = 1;
|
|
432
|
+
const DEFAULT_STATE_LIMITS = { multilingual: 1024, english: 512 };
|
|
433
|
+
|
|
434
|
+
function decisionPlaneConfig(config) {
|
|
435
|
+
const dp = config?.decisionPlane ?? config ?? {};
|
|
436
|
+
return dp && typeof dp === 'object' ? dp : {};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function resolveCheckpoint(languageClass, config) {
|
|
440
|
+
const checkpoints = {
|
|
441
|
+
...DEFAULT_CHECKPOINTS,
|
|
442
|
+
...(decisionPlaneConfig(config).checkpoints ?? {}),
|
|
443
|
+
};
|
|
444
|
+
if (languageClass === 'english') return checkpoints.english;
|
|
445
|
+
if (languageClass === 'unknown') return checkpoints.unknownLanguage;
|
|
446
|
+
return checkpoints.default;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Latency classes for receipts — bands, never raw volatile numbers.
|
|
450
|
+
function latencyClass(ms) {
|
|
451
|
+
if (!Number.isFinite(ms) || ms < 0) return 'unknown';
|
|
452
|
+
if (ms < 250) return 'fast';
|
|
453
|
+
if (ms < 2000) return 'normal';
|
|
454
|
+
return 'slow';
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function requestFingerprint(body) {
|
|
458
|
+
return createHash('sha256').update(body).digest('hex').slice(0, 16);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function isTransientStatus(status) {
|
|
462
|
+
return status === 429 || (status >= 500 && status <= 599);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function readResponseBody(res) {
|
|
466
|
+
if (res && typeof res.json === 'function') return res.json();
|
|
467
|
+
if (res && typeof res.text === 'function') return JSON.parse(await res.text());
|
|
468
|
+
return res;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Deterministic token estimator + budget gate — mirror of
|
|
472
|
+
// src/decision/statePacket.js (chars/4 vs bytes/3, overestimate is safe).
|
|
473
|
+
function estimateTokens(text) {
|
|
474
|
+
const chars = String(text).length;
|
|
475
|
+
const bytes = Buffer.byteLength(String(text), 'utf8');
|
|
476
|
+
return Math.max(Math.ceil(chars / 4), Math.ceil(bytes / 3));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function stateBudgetExceeded(serialized, languageClass, limits) {
|
|
480
|
+
const budget =
|
|
481
|
+
languageClass === 'english'
|
|
482
|
+
? (limits?.english ?? DEFAULT_STATE_LIMITS.english)
|
|
483
|
+
: (limits?.multilingual ?? DEFAULT_STATE_LIMITS.multilingual);
|
|
484
|
+
return estimateTokens(serialized) > budget;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Sensitive-value gate over the serialized packet — same scanner the src side
|
|
488
|
+
// uses, fail-closed: a positive detection blocks the request before transport.
|
|
489
|
+
function sensitiveGate(serialized, config) {
|
|
490
|
+
const scan = scanText(serialized, {
|
|
491
|
+
gateEnabled: isSensitiveDataGateEnabled(config),
|
|
492
|
+
allowlistHashes: loadSensitiveAllowlist(config),
|
|
493
|
+
});
|
|
494
|
+
return scan.hasSecret ? { blocked: true, labels: scan.labels } : { blocked: false };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function postWithDeadline(transport, url, headers, body, deadlineMs) {
|
|
498
|
+
const controller = new AbortController();
|
|
499
|
+
const timer = setTimeout(() => controller.abort(), deadlineMs);
|
|
500
|
+
try {
|
|
501
|
+
return await transport(url, {
|
|
502
|
+
method: 'POST',
|
|
503
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
504
|
+
body,
|
|
505
|
+
signal: controller.signal,
|
|
506
|
+
});
|
|
507
|
+
} finally {
|
|
508
|
+
clearTimeout(timer);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function outcome(status, extra = {}) {
|
|
513
|
+
return { status, finishReason: null, answers: [], ...extra };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Send one C13 batch over the configured gateway. `batch` =
|
|
518
|
+
* {batchId?, boundary?, statePacket, questions, deadlineMs?}. Returns a typed
|
|
519
|
+
* DecisionBatchResult; never throws for transport-level failures.
|
|
520
|
+
*/
|
|
521
|
+
export async function requestBatch(batch, {
|
|
522
|
+
config = null,
|
|
523
|
+
transport = globalThis.fetch?.bind(globalThis),
|
|
524
|
+
now = () => Date.now(),
|
|
525
|
+
projectRoot,
|
|
526
|
+
homeDir,
|
|
527
|
+
env = process.env,
|
|
528
|
+
} = {}) {
|
|
529
|
+
const dp = decisionPlaneConfig(config);
|
|
530
|
+
if (dp.enabled === false) {
|
|
531
|
+
return outcome('unavailable', { fallbackCode: 'decision-plane-disabled' });
|
|
532
|
+
}
|
|
533
|
+
if (!transport) {
|
|
534
|
+
return outcome('unsupported', { fallbackCode: 'no-transport' });
|
|
535
|
+
}
|
|
536
|
+
const questions = Array.isArray(batch?.questions) ? batch.questions : [];
|
|
537
|
+
if (questions.length === 0) {
|
|
538
|
+
return outcome('invalid', { fallbackCode: 'empty-batch' });
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const serialized =
|
|
542
|
+
typeof batch?.statePacket === 'string'
|
|
543
|
+
? batch.statePacket
|
|
544
|
+
: stableStringify(batch?.statePacket ?? {});
|
|
545
|
+
const questionText = questions
|
|
546
|
+
.map((q) => `${q.decisionKey} ${q.instruction} ${(q.candidates ?? []).join(' ')}`)
|
|
547
|
+
.join(' ');
|
|
548
|
+
const languageClass = classifyLanguage(`${serialized} ${questionText}`);
|
|
549
|
+
const checkpoint = resolveCheckpoint(languageClass, config);
|
|
550
|
+
|
|
551
|
+
if (stateBudgetExceeded(serialized, languageClass, dp.maxStateTokens)) {
|
|
552
|
+
return outcome('invalid', { fallbackCode: 'state-too-large', checkpoint });
|
|
553
|
+
}
|
|
554
|
+
if (sensitiveGate(serialized, config).blocked) {
|
|
555
|
+
return outcome('blocked-sensitive', { checkpoint });
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const base = resolveGatewayBaseUrl({ projectRoot, homeDir, env });
|
|
559
|
+
if (!base?.baseUrl) {
|
|
560
|
+
return outcome('unsupported', {
|
|
561
|
+
fallbackCode: 'endpoint-unconfigured',
|
|
562
|
+
checkpoint,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
const key = resolveGatewayApiKey({ projectRoot, homeDir, env });
|
|
566
|
+
const endpoint = {
|
|
567
|
+
url: `${String(base.baseUrl).replace(/\/+$/, '')}/v1/chat/completions`,
|
|
568
|
+
headers: buildAuthHeaders(key),
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
let body;
|
|
572
|
+
try {
|
|
573
|
+
body = JSON.stringify(
|
|
574
|
+
encodeBatch({ model: checkpoint, statePacket: serialized, questions }),
|
|
575
|
+
);
|
|
576
|
+
} catch (error) {
|
|
577
|
+
return outcome('invalid', {
|
|
578
|
+
fallbackCode: 'malformed-question',
|
|
579
|
+
checkpoint,
|
|
580
|
+
detail: error?.message ?? String(error),
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
const fingerprint = requestFingerprint(body);
|
|
584
|
+
const timeoutMs = Number.isFinite(dp.timeoutMs) ? dp.timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
585
|
+
const deadlineMs = Number.isFinite(batch?.deadlineMs) ? batch.deadlineMs : timeoutMs;
|
|
586
|
+
|
|
587
|
+
const maxAttempts = 1 + DEFAULT_MAX_RETRIES;
|
|
588
|
+
let lastError = null;
|
|
589
|
+
let lastStatus = null;
|
|
590
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
591
|
+
const startedAt = now();
|
|
592
|
+
let res;
|
|
593
|
+
try {
|
|
594
|
+
res = await postWithDeadline(transport, endpoint.url, endpoint.headers, body, deadlineMs);
|
|
595
|
+
} catch (err) {
|
|
596
|
+
const isTimeout = err?.name === 'AbortError' || err?.code === 'ABORT_ERR';
|
|
597
|
+
lastError = isTimeout ? 'timeout' : 'unavailable';
|
|
598
|
+
lastStatus = null;
|
|
599
|
+
if (isTimeout || attempt + 1 >= maxAttempts) break;
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const status = res?.status ?? (res?.ok === false ? 500 : 200);
|
|
604
|
+
if (res?.ok === false || status < 200 || status >= 300) {
|
|
605
|
+
if (isTransientStatus(status) && attempt + 1 < maxAttempts) continue;
|
|
606
|
+
if (status === 401 || status === 403) {
|
|
607
|
+
lastError = 'unavailable';
|
|
608
|
+
lastStatus = 'auth-rejected';
|
|
609
|
+
} else if (status === 400 || status === 404 || status === 422) {
|
|
610
|
+
lastError = 'unsupported';
|
|
611
|
+
lastStatus = 'endpoint-rejected';
|
|
612
|
+
} else {
|
|
613
|
+
lastError = 'unavailable';
|
|
614
|
+
lastStatus = 'gateway-error';
|
|
615
|
+
}
|
|
616
|
+
break;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
let parsed;
|
|
620
|
+
try {
|
|
621
|
+
parsed = await readResponseBody(res);
|
|
622
|
+
} catch {
|
|
623
|
+
lastError = 'invalid';
|
|
624
|
+
lastStatus = 'malformed-response';
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const result = parseBatchResponse(parsed, questions);
|
|
629
|
+
return {
|
|
630
|
+
batchId: batch?.batchId ?? null,
|
|
631
|
+
checkpoint,
|
|
632
|
+
finishReason: result.finishReason,
|
|
633
|
+
status: result.status,
|
|
634
|
+
answers: result.answers,
|
|
635
|
+
requestFingerprint: fingerprint,
|
|
636
|
+
latencyClass: latencyClass(now() - startedAt),
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return outcome(lastError ?? 'unavailable', {
|
|
641
|
+
batchId: batch?.batchId ?? null,
|
|
642
|
+
checkpoint,
|
|
643
|
+
fallbackCode:
|
|
644
|
+
lastStatus ?? (lastError === 'timeout' ? 'deadline-exceeded' : 'transport-error'),
|
|
645
|
+
requestFingerprint: fingerprint,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Explicit no-tools health probe — the ONLY request allowed to carry no tools
|
|
651
|
+
* (UNIC_DECISION_SPEC §9). A plain `message.content` reply is accepted here and
|
|
652
|
+
* nowhere else; it is never interpreted as a policy decision.
|
|
653
|
+
*/
|
|
654
|
+
export async function runHealthProbe({
|
|
655
|
+
config = null,
|
|
656
|
+
transport = globalThis.fetch?.bind(globalThis),
|
|
657
|
+
projectRoot,
|
|
658
|
+
homeDir,
|
|
659
|
+
env = process.env,
|
|
660
|
+
} = {}) {
|
|
661
|
+
const dp = decisionPlaneConfig(config);
|
|
662
|
+
if (dp.enabled === false) return { status: 'unavailable' };
|
|
663
|
+
if (!transport) return { status: 'unsupported' };
|
|
664
|
+
const base = resolveGatewayBaseUrl({ projectRoot, homeDir, env });
|
|
665
|
+
if (!base?.baseUrl) return { status: 'unsupported', fallbackCode: 'endpoint-unconfigured' };
|
|
666
|
+
const key = resolveGatewayApiKey({ projectRoot, homeDir, env });
|
|
667
|
+
const checkpoint = resolveCheckpoint('multilingual', config);
|
|
668
|
+
const timeoutMs = Number.isFinite(dp.timeoutMs) ? dp.timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
669
|
+
const body = JSON.stringify({
|
|
670
|
+
model: checkpoint,
|
|
671
|
+
messages: [{ role: 'user', content: 'ping' }],
|
|
672
|
+
max_tokens: 1,
|
|
673
|
+
});
|
|
674
|
+
try {
|
|
675
|
+
const res = await postWithDeadline(
|
|
676
|
+
transport,
|
|
677
|
+
`${String(base.baseUrl).replace(/\/+$/, '')}/v1/chat/completions`,
|
|
678
|
+
buildAuthHeaders(key),
|
|
679
|
+
body,
|
|
680
|
+
timeoutMs,
|
|
681
|
+
);
|
|
682
|
+
const status = res?.status ?? (res?.ok === false ? 500 : 200);
|
|
683
|
+
if (res?.ok === false || status < 200 || status >= 300) {
|
|
684
|
+
return { status: 'unavailable', httpStatus: status };
|
|
685
|
+
}
|
|
686
|
+
return { status: 'ok', checkpoint };
|
|
687
|
+
} catch (err) {
|
|
688
|
+
return { status: err?.name === 'AbortError' ? 'timeout' : 'unavailable' };
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Offline fixture replay: {questions, response} → normalized
|
|
694
|
+
* DecisionBatchResult. Zero transport, zero dispatch — the fixture's
|
|
695
|
+
* tool_calls are parsed as data exactly once.
|
|
696
|
+
*/
|
|
697
|
+
export function runFixture(fixture) {
|
|
698
|
+
const questions = Array.isArray(fixture?.questions) ? fixture.questions : [];
|
|
699
|
+
if (questions.length === 0) {
|
|
700
|
+
return outcome('invalid', { fallbackCode: 'empty-batch' });
|
|
701
|
+
}
|
|
702
|
+
return parseBatchResponse(fixture?.response, questions);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// ---------------------------------------------------------------------------
|
|
706
|
+
// CLI
|
|
707
|
+
// ---------------------------------------------------------------------------
|
|
708
|
+
|
|
709
|
+
function readFlagValue(argv, flag) {
|
|
710
|
+
const index = argv.indexOf(flag);
|
|
711
|
+
if (index === -1) return null;
|
|
712
|
+
const value = argv[index + 1];
|
|
713
|
+
return value && !value.startsWith('--') ? value : null;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function readStdin() {
|
|
717
|
+
return new Promise((resolve, reject) => {
|
|
718
|
+
let data = '';
|
|
719
|
+
process.stdin.setEncoding('utf8');
|
|
720
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
721
|
+
process.stdin.on('end', () => resolve(data));
|
|
722
|
+
process.stdin.on('error', reject);
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function printResult(result) {
|
|
727
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async function main() {
|
|
731
|
+
const args = process.argv.slice(2);
|
|
732
|
+
const rootDir = readFlagValue(args, '--root') ?? process.env.UKIT_TEST_ROOT ?? process.cwd();
|
|
733
|
+
const homeDir = process.env.UKIT_TEST_HOME ?? os.homedir();
|
|
734
|
+
const config = readMergedConfig(rootDir, homeDir);
|
|
735
|
+
|
|
736
|
+
if (args.includes('--health-probe')) {
|
|
737
|
+
printResult(await runHealthProbe({ config, projectRoot: rootDir, homeDir }));
|
|
738
|
+
return 0;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const fixturePath = readFlagValue(args, '--fixture');
|
|
742
|
+
if (fixturePath !== null) {
|
|
743
|
+
let fixture;
|
|
744
|
+
try {
|
|
745
|
+
fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
|
|
746
|
+
} catch (error) {
|
|
747
|
+
process.stderr.write(
|
|
748
|
+
`unic-decision: cannot read fixture ${fixturePath}: ${error?.message ?? error}\n`,
|
|
749
|
+
);
|
|
750
|
+
return 1;
|
|
751
|
+
}
|
|
752
|
+
printResult(runFixture(fixture));
|
|
753
|
+
return 0;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const raw = await readStdin();
|
|
757
|
+
let batch;
|
|
758
|
+
try {
|
|
759
|
+
batch = JSON.parse(raw);
|
|
760
|
+
} catch {
|
|
761
|
+
process.stderr.write(
|
|
762
|
+
'unic-decision: expected a batch JSON on stdin ({statePacket, questions}) '
|
|
763
|
+
+ 'or --fixture <path> or --health-probe\n',
|
|
764
|
+
);
|
|
765
|
+
return 1;
|
|
766
|
+
}
|
|
767
|
+
printResult(await requestBatch(batch, { config, projectRoot: rootDir, homeDir }));
|
|
768
|
+
return 0;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const isMainModule = (() => {
|
|
772
|
+
try {
|
|
773
|
+
return path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1] ?? '');
|
|
774
|
+
} catch {
|
|
775
|
+
return false;
|
|
776
|
+
}
|
|
777
|
+
})();
|
|
778
|
+
|
|
779
|
+
if (isMainModule) {
|
|
780
|
+
main()
|
|
781
|
+
.then((code) => { process.exitCode = code; })
|
|
782
|
+
.catch((error) => {
|
|
783
|
+
process.stderr.write(`unic-decision: ${error?.message ?? error}\n`);
|
|
784
|
+
process.exitCode = 1;
|
|
785
|
+
});
|
|
786
|
+
}
|