@ngockhoale/ukit 3.1.0 → 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.
@@ -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({
@@ -789,7 +886,16 @@ async function main() {
789
886
 
790
887
  const isMainModule = (() => {
791
888
  try {
792
- return path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1] ?? '');
889
+ const invoked = process.argv[1] ?? '';
890
+ if (!invoked) return false;
891
+ const self = path.resolve(fileURLToPath(import.meta.url));
892
+ const target = path.resolve(invoked);
893
+ if (self === target) return true;
894
+ // Installed mirrors may be reached through a symlinked directory (e.g.
895
+ // .codex/ukit -> ../.claude/ukit): realpath resolves the link, basename
896
+ // equality keeps same-named unrelated scripts from matching.
897
+ return path.basename(invoked) === 'unic-decision.mjs'
898
+ && fs.realpathSync(target) === self;
793
899
  } catch {
794
900
  return false;
795
901
  }
@@ -188,6 +188,9 @@
188
188
  "compact-now-or-later",
189
189
  "summarize-docs-or-keep-detail"
190
190
  ],
191
+ "decisionAdapter": "sidecar-decision.mjs",
192
+ "decisionStageGate": "decisionPlane.families.workflow.stage",
193
+ "decisionKeysSource": "cli",
191
194
  "stepBudgets": {
192
195
  "trivial": {
193
196
  "maxSteps": 1,
@@ -48,6 +48,26 @@ If any input is missing, return `CHANGES-REQUESTED` with reason "incomplete hand
48
48
  - **APPROVED-WITH-MINOR** — Minor naming / doc / style issues. Logged on task file but handoff allowed.
49
49
  - **APPROVED** — Clean.
50
50
 
51
+ ### Stage gate — verdict emission (unic-decision)
52
+
53
+ You generate the findings; the FINAL verdict/bucket classification is a bounded
54
+ decision owned by the local decision model when staged. Read
55
+ `decisionPlane.families.review.stage` from `.ukit/storage/config.json`:
56
+
57
+ - `off` (default) or any adapter failure (`outcomeClass` ≠ `accepted`/`partial`) →
58
+ you emit the verdict directly, exactly as today; the deterministic severity
59
+ aggregate (any critical → CHANGES-REQUESTED; ≥1 important or any
60
+ unclassified/unknown severity → APPROVED-WITH-MINOR; else APPROVED) stays
61
+ authoritative.
62
+ - not `off` → emit the verdict via `node .claude/ukit/index/review-verdict.mjs`
63
+ (registered key `review.verdict.v1`): pipe your extracted findings as JSON on
64
+ stdin — whitelisted fields only (`id`, `severity`, `file`, `line`, short
65
+ `claim`; never diff hunks or secrets). Use the adapter's `verdict`/`buckets`
66
+ fields in the `## Reviewer Verdict` block.
67
+
68
+ Model isolation is preserved: the verdict pass runs on the local unic-decision
69
+ model, so reviewer ≠ executor still stands.
70
+
51
71
  ### Output (append to task file as `## Reviewer Verdict`)
52
72
 
53
73
  ```
@@ -139,7 +159,11 @@ additions:
139
159
  `node .claude/ukit/index/review-panel-aggregate.mjs <TASK-xxx.md...>` and hands you
140
160
  the output, the lead fills `AGREEMENT_MAP` with the emitted finding → members map
141
161
  and applies the lead-judgment buckets to every finding: **Act on** / **Consider** /
142
- **Noted** / **Dismissed**.
162
+ **Noted** / **Dismissed**. When `decisionPlane.families.review.stage` is not `off`,
163
+ the lead's bucketing and panel verdict go through
164
+ `node .claude/ukit/index/review-verdict.mjs --panel` (`review.finding-bucket.v1` /
165
+ `review.panel-verdict.v1`) per the stage gate above — the deterministic aggregate
166
+ stays authoritative at `off` or on adapter failure.
143
167
  - `consensus≥2 identical findings = high signal`: a finding reported by two or more
144
168
  panel members is high-signal and must not be bucketed below **Consider** without a
145
169
  stated reason.
@@ -27,6 +27,22 @@ You are UKit's internal small-task maintainer. You run as a sidecar/parallel/non
27
27
  - Keeping agent context compact without removing existing lanes: Claude PreCompact/reinject stays active and Codex Desktop soft handoffs use `compact.codexContext.compactTarget` (default 150 lines; preferred 120-150; hard max 170) while preserving critical state.
28
28
  - Small, reversible UKit runtime maintenance decisions.
29
29
 
30
+ ## Bounded Decisions (stage-gated unic-decision)
31
+
32
+ The six bounded decisions enumerated in `.codex/settings.json` `smallTaskModel.decisionPolicy.decisions` — `fast-vs-slow-lane`, `safe-vs-risky-lane`, `skill-routing-needed`, `step-budget-enough`, `compact-now-or-later`, `summarize-docs-or-keep-detail` — consult `unic-decision` first via the installed CLI when the decision-plane stage allows:
33
+
34
+ ```
35
+ node .claude/ukit/index/sidecar-decision.mjs --decision <name> [--root <dir>] # context JSON on stdin
36
+ ```
37
+
38
+ The CLI resolves `decisionPlane.families.workflow.stage` and owns the name → `workflow.*` decision-key mapping (`--list` prints it):
39
+
40
+ - `off` (default): the CLI's deterministic rules answer directly — zero transport, authoritative.
41
+ - `shadow`: run the batch for comparison only; the deterministic rule answer stays authoritative.
42
+ - `canary`/`default`: a valid unic-decision answer wins; an invalid or unavailable adapter falls back to the deterministic rule (`outcomeClass: 'unavailable'`).
43
+
44
+ `unic-decision` is the only model allowed in these decision steps — on any failure the deterministic rule is the fallback, never another LLM for the verdict. This lane's own model (`unic-lite`) keeps generative work only: summarization, doc maintenance, and cleanup — it never emits a verdict for these decisions.
45
+
30
46
  ## Never Use For
31
47
 
32
48
  - Security/auth/permission/secrets work.