@ngockhoale/ukit 3.1.1 → 3.1.3
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 +12 -0
- package/package.json +1 -1
- package/src/core/agentRuntime/completionGate.js +4 -1
- package/src/core/agentRuntime/vmEngine.js +8 -1
- package/src/core/experiments/deliberation.js +6 -0
- package/src/core/gatewayProbe.js +53 -0
- package/src/core/memory/memoryFlags.js +6 -0
- package/src/core/runtimeConfig.js +3 -3
- package/src/decision/client.js +76 -8
- package/src/decision/protocol.js +4 -1
- package/src/decision/registry.js +1 -1
- package/template_project/.claude/ukit/index/route-task.mjs +41 -0
- package/template_project/.claude/ukit/index/unic-decision.mjs +108 -12
- package/template_project/ukit/storage/config.json +276 -178
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 3.1.3 - 2026-09-26
|
|
6
|
+
|
|
7
|
+
- **`unic-decision-multilingual` becomes the shipped default checkpoint** — the variant is credential-bound on UNIC Provider (verified via live `health-probe` + `safe-vs-risky-lane` decision). `english`/`unknownLanguage` classes keep the bare `unic-decision` alias; `model_not_found` auto-fallback still covers unbound variants.
|
|
8
|
+
|
|
9
|
+
## 3.1.2 - 2026-09-26
|
|
10
|
+
|
|
11
|
+
- **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:
|
|
12
|
+
- **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`.
|
|
13
|
+
- **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.
|
|
14
|
+
- **Checkpoints**: shipped defaults collapse to the bare `unic-decision` model (the credential-safe alias); `unic-decision-multilingual`/variants remain configurable via `decisionPlane.checkpoints`.
|
|
15
|
+
- **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.
|
|
16
|
+
|
|
5
17
|
## 3.1.1 - 2026-09-26
|
|
6
18
|
|
|
7
19
|
- **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
|
@@ -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,
|
package/src/core/gatewayProbe.js
CHANGED
|
@@ -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
|
|
308
|
-
english: 'unic-decision
|
|
309
|
-
unknownLanguage: 'unic-decision
|
|
307
|
+
default: 'unic-decision-multilingual',
|
|
308
|
+
english: 'unic-decision',
|
|
309
|
+
unknownLanguage: 'unic-decision',
|
|
310
310
|
},
|
|
311
311
|
timeoutMs: 5000,
|
|
312
312
|
maxStateTokens: { multilingual: 1024, english: 512 },
|
package/src/decision/client.js
CHANGED
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
|
|
28
28
|
import { createHash } from 'node:crypto';
|
|
29
29
|
import {
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
resolveDecisionBaseUrl,
|
|
31
|
+
resolveDecisionApiKey,
|
|
32
32
|
} from '../core/gatewayProbe.js';
|
|
33
33
|
import {
|
|
34
34
|
encodeBatch,
|
|
@@ -42,11 +42,19 @@ import {
|
|
|
42
42
|
} from './statePacket.js';
|
|
43
43
|
|
|
44
44
|
const DEFAULT_CHECKPOINTS = {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
// 'unic-decision-multilingual' is credential-bound (verified live
|
|
46
|
+
// 2026-09-26) and is the shipped default; english/unknown classes keep the
|
|
47
|
+
// bare 'unic-decision' alias until laya/auto variants are bound.
|
|
48
|
+
default: 'unic-decision-multilingual',
|
|
49
|
+
english: 'unic-decision',
|
|
50
|
+
unknownLanguage: 'unic-decision',
|
|
48
51
|
};
|
|
49
52
|
|
|
53
|
+
// Credential-safe alias: every UNIC decision credential binds the bare name
|
|
54
|
+
// first — variants (unic-decision-multilingual, …) may lag. On model_not_found
|
|
55
|
+
// the client falls back here once per batch.
|
|
56
|
+
const FALLBACK_CHECKPOINT = 'unic-decision';
|
|
57
|
+
|
|
50
58
|
const DEFAULT_TIMEOUT_MS = 5000;
|
|
51
59
|
const DEFAULT_MAX_RETRIES = 1;
|
|
52
60
|
const DEFAULT_BREAKER = { failureThreshold: 3, cooldownMs: 30_000 };
|
|
@@ -102,9 +110,44 @@ function isTransientStatus(status) {
|
|
|
102
110
|
return status === 429 || (status >= 500 && status <= 599);
|
|
103
111
|
}
|
|
104
112
|
|
|
113
|
+
// The UNIC decision endpoint streams the completion even without stream:true —
|
|
114
|
+
// the body is one JSON object followed by an SSE "data: [DONE]" trailer.
|
|
115
|
+
// Parse the first JSON object, ignore the trailer.
|
|
116
|
+
function parseMaybeSse(text) {
|
|
117
|
+
const trimmed = String(text ?? '').trim();
|
|
118
|
+
if (!trimmed.startsWith('{')) {
|
|
119
|
+
// Pure SSE frames: join data payloads.
|
|
120
|
+
const payload = trimmed
|
|
121
|
+
.split('\n')
|
|
122
|
+
.filter((line) => line.startsWith('data:') && !line.includes('[DONE]'))
|
|
123
|
+
.map((line) => line.slice(5).trim())
|
|
124
|
+
.join('');
|
|
125
|
+
return JSON.parse(payload || trimmed);
|
|
126
|
+
}
|
|
127
|
+
// JSON object possibly followed by a glued SSE trailer (`}data: [DONE]`,
|
|
128
|
+
// observed live) — extract the balanced object, strings/comments aware.
|
|
129
|
+
let depth = 0;
|
|
130
|
+
for (let i = 0; i < trimmed.length; i += 1) {
|
|
131
|
+
const ch = trimmed[i];
|
|
132
|
+
if (ch === '"' || ch === "'") {
|
|
133
|
+
for (let j = i + 1; j < trimmed.length; j += 1) {
|
|
134
|
+
if (trimmed[j] === '\\') j += 1;
|
|
135
|
+
else if (trimmed[j] === ch) { i = j; break; }
|
|
136
|
+
}
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (ch === '{') depth += 1;
|
|
140
|
+
else if (ch === '}') {
|
|
141
|
+
depth -= 1;
|
|
142
|
+
if (depth === 0) return JSON.parse(trimmed.slice(0, i + 1));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return JSON.parse(trimmed);
|
|
146
|
+
}
|
|
147
|
+
|
|
105
148
|
async function readResponseBody(res) {
|
|
149
|
+
if (res && typeof res.text === 'function') return parseMaybeSse(await res.text());
|
|
106
150
|
if (res && typeof res.json === 'function') return res.json();
|
|
107
|
-
if (res && typeof res.text === 'function') return JSON.parse(await res.text());
|
|
108
151
|
return res;
|
|
109
152
|
}
|
|
110
153
|
|
|
@@ -167,9 +210,13 @@ export function createDecisionClient({
|
|
|
167
210
|
}
|
|
168
211
|
|
|
169
212
|
async function resolveEndpoint() {
|
|
170
|
-
|
|
213
|
+
// Decision lane resolves its own endpoint (user-level gateway.json →
|
|
214
|
+
// env → openai.unicjsc.com default), independent of the host's LLM
|
|
215
|
+
// endpoint — the UNIC provider is used even when the engine points
|
|
216
|
+
// elsewhere.
|
|
217
|
+
const base = await resolveDecisionBaseUrl({ projectRoot, homeDir, env });
|
|
171
218
|
if (!base?.baseUrl) return null;
|
|
172
|
-
const key = await
|
|
219
|
+
const key = await resolveDecisionApiKey({ projectRoot, homeDir, env });
|
|
173
220
|
return {
|
|
174
221
|
url: `${String(base.baseUrl).replace(/\/+$/, '')}/v1/chat/completions`,
|
|
175
222
|
headers: buildAuthHeaders(key),
|
|
@@ -305,6 +352,27 @@ export function createDecisionClient({
|
|
|
305
352
|
|
|
306
353
|
const status = res?.status ?? (res?.ok === false ? 500 : 200);
|
|
307
354
|
if (res?.ok === false || status < 200 || status >= 300) {
|
|
355
|
+
// A variant checkpoint without bound credentials (e.g.
|
|
356
|
+
// unic-decision-multilingual before provider binding) returns a 4xx
|
|
357
|
+
// model_not_found body — retry once on the bare `unic-decision`
|
|
358
|
+
// model, the credential-safe alias the owner provisions first.
|
|
359
|
+
if (checkpoint !== FALLBACK_CHECKPOINT) {
|
|
360
|
+
let notFound = status === 404;
|
|
361
|
+
try {
|
|
362
|
+
const clone = typeof res?.clone === 'function' ? res.clone() : null;
|
|
363
|
+
const errBody = clone ? parseMaybeSse(await clone.text()) : null;
|
|
364
|
+
if (errBody?.error?.code === 'model_not_found') notFound = true;
|
|
365
|
+
} catch { /* keep status-based guess */ }
|
|
366
|
+
if (notFound) {
|
|
367
|
+
checkpoint = FALLBACK_CHECKPOINT;
|
|
368
|
+
try {
|
|
369
|
+
body = JSON.stringify(
|
|
370
|
+
encodeBatch({ model: checkpoint, statePacket: serialized, questions }),
|
|
371
|
+
);
|
|
372
|
+
} catch { /* keep prior body */ }
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
308
376
|
if (isTransientStatus(status) && attempt + 1 < maxAttempts) continue;
|
|
309
377
|
if (status === 401 || status === 403) {
|
|
310
378
|
lastError = 'unavailable';
|
package/src/decision/protocol.js
CHANGED
|
@@ -169,7 +169,10 @@ function normalizeProbabilities(raw) {
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
function validateValue(question, args) {
|
|
172
|
-
|
|
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';
|
package/src/decision/registry.js
CHANGED
|
@@ -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
|
-
|
|
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,13 @@ 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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
467
|
+
// 'unic-decision-multilingual' is credential-bound (verified live
|
|
468
|
+
// 2026-09-26) and is the shipped default; english/unknown classes keep the
|
|
469
|
+
// bare 'unic-decision' alias until laya/auto variants are bound.
|
|
470
|
+
default: 'unic-decision-multilingual',
|
|
471
|
+
english: 'unic-decision',
|
|
472
|
+
unknownLanguage: 'unic-decision',
|
|
432
473
|
};
|
|
433
474
|
|
|
434
475
|
const DEFAULT_TIMEOUT_MS = 5000;
|
|
@@ -466,9 +507,44 @@ function isTransientStatus(status) {
|
|
|
466
507
|
return status === 429 || (status >= 500 && status <= 599);
|
|
467
508
|
}
|
|
468
509
|
|
|
510
|
+
// The UNIC decision endpoint streams the completion even without stream:true —
|
|
511
|
+
// the body is one JSON object followed by an SSE "data: [DONE]" trailer.
|
|
512
|
+
// Parse the first JSON object, ignore the trailer.
|
|
513
|
+
function parseMaybeSse(text) {
|
|
514
|
+
const trimmed = String(text ?? '').trim();
|
|
515
|
+
if (!trimmed.startsWith('{')) {
|
|
516
|
+
// Pure SSE frames: join data payloads.
|
|
517
|
+
const payload = trimmed
|
|
518
|
+
.split('\n')
|
|
519
|
+
.filter((line) => line.startsWith('data:') && !line.includes('[DONE]'))
|
|
520
|
+
.map((line) => line.slice(5).trim())
|
|
521
|
+
.join('');
|
|
522
|
+
return JSON.parse(payload || trimmed);
|
|
523
|
+
}
|
|
524
|
+
// JSON object possibly followed by a glued SSE trailer (`}data: [DONE]`,
|
|
525
|
+
// observed live) — extract the balanced object, strings/comments aware.
|
|
526
|
+
let depth = 0;
|
|
527
|
+
for (let i = 0; i < trimmed.length; i += 1) {
|
|
528
|
+
const ch = trimmed[i];
|
|
529
|
+
if (ch === '"' || ch === "'") {
|
|
530
|
+
for (let j = i + 1; j < trimmed.length; j += 1) {
|
|
531
|
+
if (trimmed[j] === '\\') j += 1;
|
|
532
|
+
else if (trimmed[j] === ch) { i = j; break; }
|
|
533
|
+
}
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (ch === '{') depth += 1;
|
|
537
|
+
else if (ch === '}') {
|
|
538
|
+
depth -= 1;
|
|
539
|
+
if (depth === 0) return JSON.parse(trimmed.slice(0, i + 1));
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return JSON.parse(trimmed);
|
|
543
|
+
}
|
|
544
|
+
|
|
469
545
|
async function readResponseBody(res) {
|
|
546
|
+
if (res && typeof res.text === 'function') return parseMaybeSse(await res.text());
|
|
470
547
|
if (res && typeof res.json === 'function') return res.json();
|
|
471
|
-
if (res && typeof res.text === 'function') return JSON.parse(await res.text());
|
|
472
548
|
return res;
|
|
473
549
|
}
|
|
474
550
|
|
|
@@ -574,14 +650,14 @@ export async function requestBatch(batch, {
|
|
|
574
650
|
return outcome('blocked-sensitive', { checkpoint });
|
|
575
651
|
}
|
|
576
652
|
|
|
577
|
-
const base =
|
|
653
|
+
const base = resolveDecisionBaseUrl({ homeDir, env });
|
|
578
654
|
if (!base?.baseUrl) {
|
|
579
655
|
return outcome('unsupported', {
|
|
580
656
|
fallbackCode: 'endpoint-unconfigured',
|
|
581
657
|
checkpoint,
|
|
582
658
|
});
|
|
583
659
|
}
|
|
584
|
-
const key =
|
|
660
|
+
const key = resolveDecisionApiKey({ projectRoot, homeDir, env });
|
|
585
661
|
const endpoint = {
|
|
586
662
|
url: `${String(base.baseUrl).replace(/\/+$/, '')}/v1/chat/completions`,
|
|
587
663
|
headers: buildAuthHeaders(key),
|
|
@@ -621,6 +697,26 @@ export async function requestBatch(batch, {
|
|
|
621
697
|
|
|
622
698
|
const status = res?.status ?? (res?.ok === false ? 500 : 200);
|
|
623
699
|
if (res?.ok === false || status < 200 || status >= 300) {
|
|
700
|
+
// A variant checkpoint without bound credentials (e.g.
|
|
701
|
+
// unic-decision-multilingual before provider binding) returns a 4xx
|
|
702
|
+
// model_not_found body — retry once on the bare `unic-decision` model.
|
|
703
|
+
if (checkpoint !== 'unic-decision') {
|
|
704
|
+
let notFound = status === 404;
|
|
705
|
+
try {
|
|
706
|
+
const clone = typeof res?.clone === 'function' ? res.clone() : null;
|
|
707
|
+
const errBody = clone ? parseMaybeSse(await clone.text()) : null;
|
|
708
|
+
if (errBody?.error?.code === 'model_not_found') notFound = true;
|
|
709
|
+
} catch { /* keep status-based guess */ }
|
|
710
|
+
if (notFound) {
|
|
711
|
+
checkpoint = 'unic-decision';
|
|
712
|
+
try {
|
|
713
|
+
body = JSON.stringify(
|
|
714
|
+
encodeBatch({ model: checkpoint, statePacket: serialized, questions }),
|
|
715
|
+
);
|
|
716
|
+
} catch { /* keep prior body */ }
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
624
720
|
if (isTransientStatus(status) && attempt + 1 < maxAttempts) continue;
|
|
625
721
|
if (status === 401 || status === 403) {
|
|
626
722
|
lastError = 'unavailable';
|
|
@@ -680,9 +776,9 @@ export async function runHealthProbe({
|
|
|
680
776
|
const dp = decisionPlaneConfig(config);
|
|
681
777
|
if (dp.enabled === false) return { status: 'unavailable' };
|
|
682
778
|
if (!transport) return { status: 'unsupported' };
|
|
683
|
-
const base =
|
|
779
|
+
const base = resolveDecisionBaseUrl({ homeDir, env });
|
|
684
780
|
if (!base?.baseUrl) return { status: 'unsupported', fallbackCode: 'endpoint-unconfigured' };
|
|
685
|
-
const key =
|
|
781
|
+
const key = resolveDecisionApiKey({ projectRoot, homeDir, env });
|
|
686
782
|
const checkpoint = resolveCheckpoint('multilingual', config);
|
|
687
783
|
const timeoutMs = Number.isFinite(dp.timeoutMs) ? dp.timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
688
784
|
const body = JSON.stringify({
|