@ngockhoale/ukit 3.1.1 → 3.1.2

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 CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 3.1.2 - 2026-09-26
6
+
7
+ - **Decision plane goes live on the UNIC provider** — the decision lane now resolves its own endpoint independent of the engine's LLM gateway, so UKit uses UNIC even when Claude Code/Codex/omp point elsewhere:
8
+ - **Endpoint chain**: `~/.ukit/storage/gateway.json` `{decision:{baseUrl,apiKey,scheme}}` → env `UKIT_DECISION_BASE_URL`/`UKIT_DECISION_API_KEY` → default `https://openai.unicjsc.com`; credential falls back to the shared UNIC token (ANTHROPIC_*). New resolvers `resolveDecisionBaseUrl`/`resolveDecisionApiKey` in `src/core/gatewayProbe.js`, mirrored in `unic-decision.mjs`.
9
+ - **Live transport fixes** (verified against the real gateway): SSE `data: [DONE]` trailer — sometimes glued to the JSON body — is stripped via balanced-object extraction; model answers use native arg names (`choice`/`noul`/`score`) now accepted alongside the schema's `value`; a `model_not_found` variant checkpoint retries once on the bare `unic-decision` alias.
10
+ - **Checkpoints**: shipped defaults collapse to the bare `unic-decision` model (the credential-safe alias); `unic-decision-multilingual`/variants remain configurable via `decisionPlane.checkpoints`.
11
+ - **Optional backlog drained**: injection-point contracts documented (deliberation `judge`, vmEngine `classifyFn`, completionGate `modelJudgment`), `memoryV2.decision` plane marked reserved, `resume.next-action.v1` + `verify.depth.v1` shadow questions wired, gatewayProbe marked migration-exempt.
12
+
5
13
  ## 3.1.1 - 2026-09-26
6
14
 
7
15
  - **Decision plane — review + workflow families (UNIC_DECISION_MIGRATION S1–S4)** — the reserved `review` and `workflow` registry families are now populated and wired; all keys ship `rolloutStage: 'off'` so deterministic fallbacks stay authoritative (stage promotion is a separate owner decision):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.1.1",
3
+ "version": "3.1.2",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -152,7 +152,10 @@ export function evaluateCompletionEvidence({
152
152
  verdict = 'FAILED';
153
153
  }
154
154
 
155
- // modelJudgment may only request more evidence, never promote.
155
+ // modelJudgment may only request more evidence, never promote (enforced
156
+ // below: a judgment can downgrade PASS → MORE_EVIDENCE, nothing else). When
157
+ // model-backed, the judgment MUST originate from the decision plane
158
+ // (unic-decision, a bounded verdict question) — never a general LLM call.
156
159
  const judgment = modelJudgment != null
157
160
  ? (typeof modelJudgment === 'string' ? modelJudgment : String(modelJudgment))
158
161
  : null;
@@ -85,6 +85,11 @@ async function writeFileAtomic(target, data) {
85
85
  }
86
86
 
87
87
  /**
88
+ * Migration contract (decision plane): `classifyFn` is invoked only when the
89
+ * IR itself cannot route an event (see deliver()). It may only consult the
90
+ * decision plane (unic-decision / createDecisionClient registry keys) for a
91
+ * bounded route/escalate answer — never a general LLM call.
92
+ *
88
93
  * @param {{dir:string, now?:()=>Date, classifyFn?:Function, startFn?:Function, hooks?:object}} opts
89
94
  */
90
95
  export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
@@ -512,7 +517,9 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
512
517
  return { consumed: false, code: 'out_of_order' };
513
518
  }
514
519
 
515
- // classify only when the IR itself cannot route the event (G4-FR05)
520
+ // classify only when the IR itself cannot route the event (G4-FR05);
521
+ // classifyFn may consult the decision plane for bounded route/escalate
522
+ // answers only — never a general LLM call (see createVmEngine opts).
516
523
  let outcome = event.safePayload?.outcome;
517
524
  let targetMatched = matched;
518
525
  if (!targetMatched) {
@@ -244,6 +244,12 @@ function deepFreezeCopy(value) {
244
244
  * verdict is post-checked: hard-constraint waivers/violations and
245
245
  * majority-only (citation-free) verdicts are rejected.
246
246
  *
247
+ * Migration contract (decision plane): an injected `judge` implementation
248
+ * that performs bounded verdict work MUST route it through the decision
249
+ * plane (`createDecisionClient` + `src/decision/registry.js` keys) — never a
250
+ * general LLM call. The default `smartJudge` is deterministic: pure scoring,
251
+ * hard-constraint filtering, and a stable sort — no I/O, no model calls.
252
+ *
247
253
  * @param {{candidates: object[], rubric: object, judge: Function,
248
254
  * config?: object}} input
249
255
  * @returns {{status: 'ok'|'disabled'|'rejected', selected?: object|null,
@@ -23,6 +23,52 @@ import path from 'node:path';
23
23
 
24
24
  const GATEWAY_DOC = 'docs/GATEWAY.md';
25
25
  const SETTINGS_RELATIVE_PROBE_PATH = path.join('.claude', 'settings.json');
26
+ // --- Decision-plane gateway resolution (UNIC_DECISION_MIGRATION backlog) -----
27
+ // The decision plane is a SEPARATE lane from the host's LLM endpoint: it must
28
+ // reach the UNIC OpenAI-compatible provider no matter what Claude Code/omp is
29
+ // configured to. Chain: ~/.ukit/storage/gateway.json {decision:{baseUrl,
30
+ // apiKey, scheme}} → env UKIT_DECISION_BASE_URL / UKIT_DECISION_API_KEY →
31
+ // DEFAULT_DECISION_BASE_URL + the shared UNIC token (ANTHROPIC_*) so a host
32
+ // already pointed at UNIC works with zero extra config. Missing key → the
33
+ // caller surfaces 'unavailable' and deterministic policy stays authoritative.
34
+ export const DEFAULT_DECISION_BASE_URL = 'https://openai.unicjsc.com';
35
+ const USER_GATEWAY_REL = path.join('.ukit', 'storage', 'gateway.json');
36
+
37
+ function readUserGatewayDecision(homeDir) {
38
+ if (!homeDir) return null;
39
+ const json = safeReadJson(path.join(homeDir, USER_GATEWAY_REL));
40
+ const decision = json?.decision;
41
+ return decision && typeof decision === 'object' ? decision : null;
42
+ }
43
+
44
+ // Endpoint for the decision lane. Never throws; file and env malformed values
45
+ // are ignored, the default is the final fallback (not ANTHROPIC_BASE_URL —
46
+ // that env may point at a non-decision gateway).
47
+ export function resolveDecisionBaseUrl({ homeDir = os.homedir(), env = process.env } = {}) {
48
+ const file = readUserGatewayDecision(homeDir);
49
+ const fromFile = pickString(file?.baseUrl);
50
+ if (fromFile) return { baseUrl: fromFile, source: 'ukit-gateway-json' };
51
+ const fromEnv = pickString(env?.UKIT_DECISION_BASE_URL);
52
+ if (fromEnv) return { baseUrl: fromEnv, source: 'env' };
53
+ return { baseUrl: DEFAULT_DECISION_BASE_URL, source: 'default' };
54
+ }
55
+
56
+ // API key for the decision lane. `scheme`: 'auth-token' → Authorization: Bearer
57
+ // (UNIC OpenAI-compatible default) | 'api-key' → x-api-key. Falls back to the
58
+ // host's UNIC token so existing installs need no duplicate credential.
59
+ export function resolveDecisionApiKey({ projectRoot, homeDir = os.homedir(), env = process.env } = {}) {
60
+ const file = readUserGatewayDecision(homeDir);
61
+ const fileKey = pickString(file?.apiKey);
62
+ if (fileKey) {
63
+ const scheme = file?.scheme === 'api-key' ? 'api-key' : 'auth-token';
64
+ return { value: fileKey, scheme, source: 'ukit-gateway-json' };
65
+ }
66
+ const envKey = pickString(env?.UKIT_DECISION_API_KEY);
67
+ if (envKey) return { value: envKey, scheme: 'auth-token', source: 'env' };
68
+ const shared = resolveGatewayApiKey({ projectRoot, homeDir, env });
69
+ if (shared?.value) return { ...shared, source: 'shared-unic-token' };
70
+ return { value: null, scheme: null, source: null };
71
+ }
26
72
 
27
73
  function safeReadFile(filePath) {
28
74
  try {
@@ -491,6 +537,13 @@ async function runNonStreamingProbe({ url, headers, body: requestBody, fetchImpl
491
537
  }
492
538
 
493
539
  /**
540
+ * Migration-contract exemption (decision plane): the two `fetchImpl` calls below
541
+ * are the ONLY generic-LLM HTTP calls under src/. They are diagnostic-only —
542
+ * connectivity probes that send a trivial "pong?" message and measure the
543
+ * transport (SSE events, buffering, Anthropic Message shape) — and are
544
+ * intentionally NOT routed through the decision plane: no bounded verdict
545
+ * vocabulary exists for a connectivity probe. Keep-as-probe; do not migrate.
546
+ *
494
547
  * Runs both probes against `baseUrl`. Returns a structured result; never propagates exceptions
495
548
  * — every failure is reflected in the returned object so the CLI can print it.
496
549
  *
@@ -4,6 +4,12 @@
4
4
  // resolveConfigStage machinery: off → shadow → canary → default. Flags only
5
5
  // ever REDUCE capability — malformed/missing resolves 'off', killSwitch is
6
6
  // absolute, and 'canary' without an opted-in projectId resolves 'off'.
7
+ // The `decision` plane is reserved for the memoryV2 candidate-classification
8
+ // lane (registry key `learn.candidate-class.v1`, family 'learn'). Note the
9
+ // decision-plane stage namespace is separate: promotion gates that key via
10
+ // `decisionPlane.families.learn.stage`, NOT `memoryV2.decision.stage` — this
11
+ // flag only guards the memoryV2 lane itself. Zero consumers today — do not
12
+ // remove; user configs may pin its stage key.
7
13
  //
8
14
  // Shadow receipts are the only telemetry: bounded JSONL of counts/codes/
9
15
  // latency bands — never record text, prompt bodies, or secrets.
@@ -304,9 +304,9 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
304
304
  stage: 'off',
305
305
  logicalModel: 'unic-decision',
306
306
  checkpoints: {
307
- default: 'unic-decision/laya-multilingual',
308
- english: 'unic-decision/laya',
309
- unknownLanguage: 'unic-decision/auto',
307
+ default: 'unic-decision',
308
+ english: 'unic-decision',
309
+ unknownLanguage: 'unic-decision',
310
310
  },
311
311
  timeoutMs: 5000,
312
312
  maxStateTokens: { multilingual: 1024, english: 512 },
@@ -27,8 +27,8 @@
27
27
 
28
28
  import { createHash } from 'node:crypto';
29
29
  import {
30
- resolveGatewayBaseUrl,
31
- resolveGatewayApiKey,
30
+ resolveDecisionBaseUrl,
31
+ resolveDecisionApiKey,
32
32
  } from '../core/gatewayProbe.js';
33
33
  import {
34
34
  encodeBatch,
@@ -42,11 +42,20 @@ import {
42
42
  } from './statePacket.js';
43
43
 
44
44
  const DEFAULT_CHECKPOINTS = {
45
- default: 'unic-decision/laya-multilingual',
46
- english: 'unic-decision/laya',
47
- unknownLanguage: 'unic-decision/auto',
45
+ // Bare 'unic-decision' is the credential-safe alias — the only checkpoint a
46
+ // freshly-bound UNIC decision key serves. Variants (e.g.
47
+ // unic-decision-multilingual once the provider binds it) override via
48
+ // decisionPlane.checkpoints.
49
+ default: 'unic-decision',
50
+ english: 'unic-decision',
51
+ unknownLanguage: 'unic-decision',
48
52
  };
49
53
 
54
+ // Credential-safe alias: every UNIC decision credential binds the bare name
55
+ // first — variants (unic-decision-multilingual, …) may lag. On model_not_found
56
+ // the client falls back here once per batch.
57
+ const FALLBACK_CHECKPOINT = 'unic-decision';
58
+
50
59
  const DEFAULT_TIMEOUT_MS = 5000;
51
60
  const DEFAULT_MAX_RETRIES = 1;
52
61
  const DEFAULT_BREAKER = { failureThreshold: 3, cooldownMs: 30_000 };
@@ -102,9 +111,44 @@ function isTransientStatus(status) {
102
111
  return status === 429 || (status >= 500 && status <= 599);
103
112
  }
104
113
 
114
+ // The UNIC decision endpoint streams the completion even without stream:true —
115
+ // the body is one JSON object followed by an SSE "data: [DONE]" trailer.
116
+ // Parse the first JSON object, ignore the trailer.
117
+ function parseMaybeSse(text) {
118
+ const trimmed = String(text ?? '').trim();
119
+ if (!trimmed.startsWith('{')) {
120
+ // Pure SSE frames: join data payloads.
121
+ const payload = trimmed
122
+ .split('\n')
123
+ .filter((line) => line.startsWith('data:') && !line.includes('[DONE]'))
124
+ .map((line) => line.slice(5).trim())
125
+ .join('');
126
+ return JSON.parse(payload || trimmed);
127
+ }
128
+ // JSON object possibly followed by a glued SSE trailer (`}data: [DONE]`,
129
+ // observed live) — extract the balanced object, strings/comments aware.
130
+ let depth = 0;
131
+ for (let i = 0; i < trimmed.length; i += 1) {
132
+ const ch = trimmed[i];
133
+ if (ch === '"' || ch === "'") {
134
+ for (let j = i + 1; j < trimmed.length; j += 1) {
135
+ if (trimmed[j] === '\\') j += 1;
136
+ else if (trimmed[j] === ch) { i = j; break; }
137
+ }
138
+ continue;
139
+ }
140
+ if (ch === '{') depth += 1;
141
+ else if (ch === '}') {
142
+ depth -= 1;
143
+ if (depth === 0) return JSON.parse(trimmed.slice(0, i + 1));
144
+ }
145
+ }
146
+ return JSON.parse(trimmed);
147
+ }
148
+
105
149
  async function readResponseBody(res) {
150
+ if (res && typeof res.text === 'function') return parseMaybeSse(await res.text());
106
151
  if (res && typeof res.json === 'function') return res.json();
107
- if (res && typeof res.text === 'function') return JSON.parse(await res.text());
108
152
  return res;
109
153
  }
110
154
 
@@ -167,9 +211,13 @@ export function createDecisionClient({
167
211
  }
168
212
 
169
213
  async function resolveEndpoint() {
170
- const base = await resolveGatewayBaseUrl({ projectRoot, homeDir, env });
214
+ // Decision lane resolves its own endpoint (user-level gateway.json →
215
+ // env → openai.unicjsc.com default), independent of the host's LLM
216
+ // endpoint — the UNIC provider is used even when the engine points
217
+ // elsewhere.
218
+ const base = await resolveDecisionBaseUrl({ projectRoot, homeDir, env });
171
219
  if (!base?.baseUrl) return null;
172
- const key = await resolveGatewayApiKey({ projectRoot, homeDir, env });
220
+ const key = await resolveDecisionApiKey({ projectRoot, homeDir, env });
173
221
  return {
174
222
  url: `${String(base.baseUrl).replace(/\/+$/, '')}/v1/chat/completions`,
175
223
  headers: buildAuthHeaders(key),
@@ -305,6 +353,27 @@ export function createDecisionClient({
305
353
 
306
354
  const status = res?.status ?? (res?.ok === false ? 500 : 200);
307
355
  if (res?.ok === false || status < 200 || status >= 300) {
356
+ // A variant checkpoint without bound credentials (e.g.
357
+ // unic-decision-multilingual before provider binding) returns a 4xx
358
+ // model_not_found body — retry once on the bare `unic-decision`
359
+ // model, the credential-safe alias the owner provisions first.
360
+ if (checkpoint !== FALLBACK_CHECKPOINT) {
361
+ let notFound = status === 404;
362
+ try {
363
+ const clone = typeof res?.clone === 'function' ? res.clone() : null;
364
+ const errBody = clone ? parseMaybeSse(await clone.text()) : null;
365
+ if (errBody?.error?.code === 'model_not_found') notFound = true;
366
+ } catch { /* keep status-based guess */ }
367
+ if (notFound) {
368
+ checkpoint = FALLBACK_CHECKPOINT;
369
+ try {
370
+ body = JSON.stringify(
371
+ encodeBatch({ model: checkpoint, statePacket: serialized, questions }),
372
+ );
373
+ } catch { /* keep prior body */ }
374
+ continue;
375
+ }
376
+ }
308
377
  if (isTransientStatus(status) && attempt + 1 < maxAttempts) continue;
309
378
  if (status === 401 || status === 403) {
310
379
  lastError = 'unavailable';
@@ -169,7 +169,10 @@ function normalizeProbabilities(raw) {
169
169
  }
170
170
 
171
171
  function validateValue(question, args) {
172
- const value = args?.value;
172
+ // The model answers with its native field names — `choice`/`noul`/`score` —
173
+ // not the schema's `value`; accept both so a live gateway isn't parsed as
174
+ // malformed.
175
+ const value = args?.value ?? args?.choice ?? args?.noul ?? args?.score;
173
176
  if (question.kind === 'choice') {
174
177
  if (typeof value !== 'string') return 'missing-value';
175
178
  if (!Array.isArray(question.candidates) || !question.candidates.includes(value)) return 'out-of-enum';
@@ -259,7 +259,7 @@ export const DECISION_REGISTRY = Object.freeze([
259
259
  family: 'learn',
260
260
  owner: 'learningCandidates',
261
261
  kind: 'choice',
262
- description: 'Learning candidate classification for a captured signal.',
262
+ description: 'Learning candidate classification for a captured signal. Reserved for the memoryV2 decision lane; promotion gates via decisionPlane.families.learn.stage.',
263
263
  candidatePolicy: 'owner-shortlist',
264
264
  hardConstraints: ['promotion-approval'],
265
265
  probabilityPolicy: 'raw-label',
@@ -1883,6 +1883,36 @@ const ROUTE_SHADOW_QUESTIONS = Object.freeze([
1883
1883
  instruction: 'R0-R4 rigor recommendation inside deterministic floors.',
1884
1884
  candidates: ['r0', 'r1', 'r2', 'r3', 'r4'],
1885
1885
  },
1886
+ {
1887
+ decisionKey: 'resume.next-action.v1',
1888
+ family: 'resume',
1889
+ kind: 'choice',
1890
+ instruction: 'Next resumable action at the continuation boundary.',
1891
+ // deriveNextAction vocabulary (read from source); the rescue-bias override
1892
+ // 'execute-current-milestone' can overwrite nextActionType post-derivation —
1893
+ // baseline compare then scores 'disagree', which is a real signal, not noise.
1894
+ candidates: [
1895
+ 'ask-user-confirmation',
1896
+ 'run-primary-verification',
1897
+ 'run-fallback-verification',
1898
+ 'pull-indexed-context',
1899
+ 'read-skill-instructions',
1900
+ 'inspect-structure',
1901
+ ],
1902
+ },
1903
+ {
1904
+ decisionKey: 'verify.depth.v1',
1905
+ family: 'verify',
1906
+ kind: 'choice',
1907
+ instruction: 'Verification depth among allowed levels.',
1908
+ candidates: ['sanity', 'targeted', 'impact', 'full'],
1909
+ },
1910
+ // NOT wired — recorded per migration-backlog review:
1911
+ // capability.impact.v1 — noul kind; no threshold baseline exists on
1912
+ // routeSummary (capabilityPolicy.recommended is unpopulated upstream), so
1913
+ // the question has no comparison value.
1914
+ // learn.candidate-class.v1 — reserved for the memoryV2 `decision` plane
1915
+ // (memoryFlags.js), not the route boundary.
1886
1916
  ]);
1887
1917
 
1888
1918
  // Only families whose resolved stage is not 'off' get a question.
@@ -1906,6 +1936,17 @@ function probabilityBand(value) {
1906
1936
  function shadowBaselineFor(decisionKey, routeSummary) {
1907
1937
  if (decisionKey === 'route.intent-kind.v1') return routeSummary?.intent?.kind ?? null;
1908
1938
  if (decisionKey === 'route.rigor.v1') return routeSummary?.execution?.rigor ?? null;
1939
+ if (decisionKey === 'resume.next-action.v1') return routeSummary?.nextActionType ?? null;
1940
+ // Verification depth: routeSummary carries neither verificationRecommendation
1941
+ // nor resolvedVerificationPlan (the plan lives on the outer route result at
1942
+ // ~:2412, and its `mode` uses a different vocabulary — docs-only/
1943
+ // targeted-tests-first/… — NOT a depth). Baseline resolves null → agreement
1944
+ // 'unknown'; bands still record.
1945
+ if (decisionKey === 'verify.depth.v1') {
1946
+ return routeSummary?.verificationRecommendation?.depth
1947
+ ?? routeSummary?.resolvedVerificationPlan?.depth
1948
+ ?? null;
1949
+ }
1909
1950
  return null;
1910
1951
  }
1911
1952
 
@@ -181,7 +181,10 @@ function normalizeProbabilities(raw) {
181
181
  }
182
182
 
183
183
  function validateValue(question, args) {
184
- const value = args?.value;
184
+ // The model answers with its native field names — `choice`/`noul`/`score` —
185
+ // not the schema's `value`; accept both so a live gateway isn't parsed as
186
+ // malformed.
187
+ const value = args?.value ?? args?.choice ?? args?.noul ?? args?.score;
185
188
  if (question.kind === 'choice') {
186
189
  if (typeof value !== 'string') return 'missing-value';
187
190
  if (!Array.isArray(question.candidates) || !question.candidates.includes(value)) return 'out-of-enum';
@@ -327,6 +330,44 @@ function pickString(...candidates) {
327
330
  return null;
328
331
  }
329
332
 
333
+ // --- Decision-plane gateway resolution -------------------------------------
334
+ // The decision lane is independent of the host's LLM endpoint: default is the
335
+ // UNIC OpenAI-compatible provider; ~/.ukit/storage/gateway.json {decision:
336
+ // {baseUrl, apiKey, scheme}} overrides; env UKIT_DECISION_BASE_URL /
337
+ // UKIT_DECISION_API_KEY next; the shared UNIC token (ANTHROPIC_*) is the
338
+ // credential fallback so existing installs need nothing extra.
339
+ const DEFAULT_DECISION_BASE_URL = 'https://openai.unicjsc.com';
340
+ const USER_GATEWAY_REL = () => path.join('.ukit', 'storage', 'gateway.json');
341
+
342
+ function readUserGatewayDecision(homeDir) {
343
+ if (!homeDir) return null;
344
+ const json = safeReadJson(path.join(homeDir, USER_GATEWAY_REL()));
345
+ const decision = json?.decision;
346
+ return decision && typeof decision === 'object' ? decision : null;
347
+ }
348
+
349
+ function resolveDecisionBaseUrl({ homeDir, env }) {
350
+ const fileUrl = pickString(readUserGatewayDecision(homeDir)?.baseUrl);
351
+ if (fileUrl) return { baseUrl: fileUrl, source: 'ukit-gateway-json' };
352
+ const envUrl = pickString(env?.UKIT_DECISION_BASE_URL);
353
+ if (envUrl) return { baseUrl: envUrl, source: 'env' };
354
+ return { baseUrl: DEFAULT_DECISION_BASE_URL, source: 'default' };
355
+ }
356
+
357
+ function resolveDecisionApiKey({ projectRoot, homeDir, env }) {
358
+ const file = readUserGatewayDecision(homeDir);
359
+ const fileKey = pickString(file?.apiKey);
360
+ if (fileKey) {
361
+ const scheme = file?.scheme === 'api-key' ? 'api-key' : 'auth-token';
362
+ return { value: fileKey, scheme, source: 'ukit-gateway-json' };
363
+ }
364
+ const envKey = pickString(env?.UKIT_DECISION_API_KEY);
365
+ if (envKey) return { value: envKey, scheme: 'auth-token', source: 'env' };
366
+ const shared = resolveGatewayApiKey({ projectRoot, homeDir, env });
367
+ if (shared?.value) return { ...shared, source: 'shared-unic-token' };
368
+ return { value: null, scheme: null, source: null };
369
+ }
370
+
330
371
  function readSettingsEnvBlock(filePath) {
331
372
  const json = safeReadJson(filePath);
332
373
  if (!json || typeof json !== 'object') return null;
@@ -422,13 +463,14 @@ function readMergedConfig(rootDir, homeDir) {
422
463
 
423
464
  // ---------------------------------------------------------------------------
424
465
  // Client core — mirror of src/decision/client.js semantics minus the circuit
425
- // breaker (a one-shot CLI has no cross-request state to protect).
426
- // ---------------------------------------------------------------------------
427
-
428
466
  const DEFAULT_CHECKPOINTS = {
429
- default: 'unic-decision/laya-multilingual',
430
- english: 'unic-decision/laya',
431
- unknownLanguage: 'unic-decision/auto',
467
+ // Bare 'unic-decision' is the credential-safe alias — the only checkpoint a
468
+ // freshly-bound UNIC decision key serves. Variants (e.g.
469
+ // unic-decision-multilingual once the provider binds it) override via
470
+ // decisionPlane.checkpoints.
471
+ default: 'unic-decision',
472
+ english: 'unic-decision',
473
+ unknownLanguage: 'unic-decision',
432
474
  };
433
475
 
434
476
  const DEFAULT_TIMEOUT_MS = 5000;
@@ -466,9 +508,44 @@ function isTransientStatus(status) {
466
508
  return status === 429 || (status >= 500 && status <= 599);
467
509
  }
468
510
 
511
+ // The UNIC decision endpoint streams the completion even without stream:true —
512
+ // the body is one JSON object followed by an SSE "data: [DONE]" trailer.
513
+ // Parse the first JSON object, ignore the trailer.
514
+ function parseMaybeSse(text) {
515
+ const trimmed = String(text ?? '').trim();
516
+ if (!trimmed.startsWith('{')) {
517
+ // Pure SSE frames: join data payloads.
518
+ const payload = trimmed
519
+ .split('\n')
520
+ .filter((line) => line.startsWith('data:') && !line.includes('[DONE]'))
521
+ .map((line) => line.slice(5).trim())
522
+ .join('');
523
+ return JSON.parse(payload || trimmed);
524
+ }
525
+ // JSON object possibly followed by a glued SSE trailer (`}data: [DONE]`,
526
+ // observed live) — extract the balanced object, strings/comments aware.
527
+ let depth = 0;
528
+ for (let i = 0; i < trimmed.length; i += 1) {
529
+ const ch = trimmed[i];
530
+ if (ch === '"' || ch === "'") {
531
+ for (let j = i + 1; j < trimmed.length; j += 1) {
532
+ if (trimmed[j] === '\\') j += 1;
533
+ else if (trimmed[j] === ch) { i = j; break; }
534
+ }
535
+ continue;
536
+ }
537
+ if (ch === '{') depth += 1;
538
+ else if (ch === '}') {
539
+ depth -= 1;
540
+ if (depth === 0) return JSON.parse(trimmed.slice(0, i + 1));
541
+ }
542
+ }
543
+ return JSON.parse(trimmed);
544
+ }
545
+
469
546
  async function readResponseBody(res) {
547
+ if (res && typeof res.text === 'function') return parseMaybeSse(await res.text());
470
548
  if (res && typeof res.json === 'function') return res.json();
471
- if (res && typeof res.text === 'function') return JSON.parse(await res.text());
472
549
  return res;
473
550
  }
474
551
 
@@ -574,14 +651,14 @@ export async function requestBatch(batch, {
574
651
  return outcome('blocked-sensitive', { checkpoint });
575
652
  }
576
653
 
577
- const base = resolveGatewayBaseUrl({ projectRoot, homeDir, env });
654
+ const base = resolveDecisionBaseUrl({ homeDir, env });
578
655
  if (!base?.baseUrl) {
579
656
  return outcome('unsupported', {
580
657
  fallbackCode: 'endpoint-unconfigured',
581
658
  checkpoint,
582
659
  });
583
660
  }
584
- const key = resolveGatewayApiKey({ projectRoot, homeDir, env });
661
+ const key = resolveDecisionApiKey({ projectRoot, homeDir, env });
585
662
  const endpoint = {
586
663
  url: `${String(base.baseUrl).replace(/\/+$/, '')}/v1/chat/completions`,
587
664
  headers: buildAuthHeaders(key),
@@ -621,6 +698,26 @@ export async function requestBatch(batch, {
621
698
 
622
699
  const status = res?.status ?? (res?.ok === false ? 500 : 200);
623
700
  if (res?.ok === false || status < 200 || status >= 300) {
701
+ // A variant checkpoint without bound credentials (e.g.
702
+ // unic-decision-multilingual before provider binding) returns a 4xx
703
+ // model_not_found body — retry once on the bare `unic-decision` model.
704
+ if (checkpoint !== 'unic-decision') {
705
+ let notFound = status === 404;
706
+ try {
707
+ const clone = typeof res?.clone === 'function' ? res.clone() : null;
708
+ const errBody = clone ? parseMaybeSse(await clone.text()) : null;
709
+ if (errBody?.error?.code === 'model_not_found') notFound = true;
710
+ } catch { /* keep status-based guess */ }
711
+ if (notFound) {
712
+ checkpoint = 'unic-decision';
713
+ try {
714
+ body = JSON.stringify(
715
+ encodeBatch({ model: checkpoint, statePacket: serialized, questions }),
716
+ );
717
+ } catch { /* keep prior body */ }
718
+ continue;
719
+ }
720
+ }
624
721
  if (isTransientStatus(status) && attempt + 1 < maxAttempts) continue;
625
722
  if (status === 401 || status === 403) {
626
723
  lastError = 'unavailable';
@@ -680,9 +777,9 @@ export async function runHealthProbe({
680
777
  const dp = decisionPlaneConfig(config);
681
778
  if (dp.enabled === false) return { status: 'unavailable' };
682
779
  if (!transport) return { status: 'unsupported' };
683
- const base = resolveGatewayBaseUrl({ projectRoot, homeDir, env });
780
+ const base = resolveDecisionBaseUrl({ homeDir, env });
684
781
  if (!base?.baseUrl) return { status: 'unsupported', fallbackCode: 'endpoint-unconfigured' };
685
- const key = resolveGatewayApiKey({ projectRoot, homeDir, env });
782
+ const key = resolveDecisionApiKey({ projectRoot, homeDir, env });
686
783
  const checkpoint = resolveCheckpoint('multilingual', config);
687
784
  const timeoutMs = Number.isFinite(dp.timeoutMs) ? dp.timeoutMs : DEFAULT_TIMEOUT_MS;
688
785
  const body = JSON.stringify({