@ngockhoale/ukit 3.4.31 → 3.4.32

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
@@ -9,6 +9,16 @@ All notable changes to UKit are documented here.
9
9
  - New `/ukit:ci-check` command + `ci-check.mjs` helper (core pack): prechecks, fail-closed budget guard, local preflight, at most one dispatch per invocation, write-ahead run-id state, `status` (never dispatches), `--ref`, `--dry-run`, per-OS report. Never pushes, merges or publishes.
10
10
  - Default-branch bootstrap stub documented in `docs/design/CI-CROSS-PLATFORM-MATRIX.md` (requires separate approval).
11
11
 
12
+ ## 3.4.32 - 2026-10-11
13
+
14
+ **UNIC-Decision integration P1: safety floors, ordinal score contract, request caps.** Verified with the full suite (566 files) and `yarn release:verify`; no live inference was run (offline fixtures plus an offline run of the Laya server translator).
15
+ - **Sidecar is deterministic-only.** The six `workflow.*` sidecar decisions always return the deterministic answer at every stage; no adapter call is made. `modelAnswer`/`agreement` are no longer emitted; `--fixture`, `--list` and the result schema keys are unchanged.
16
+ - **Escalation floors.** Fix-loop escalation never lowers verification depth (depth = max(deterministic floor, model), order `none<runnable<review-round<escalate`), never swaps the lane, and never applies model output at `shadow`. Fixed in both the template and the live `src/index/fixLoopEscalation.js`.
17
+ - **Review safety floor.** The verdict floor always runs over all findings: critical → `CHANGES-REQUESTED`; important/unknown → at least `APPROVED-WITH-MINOR`; `Act on` buckets cannot be downgraded; `shadow` keeps the deterministic verdict.
18
+ - **Score contract.** Ordinal `score` questions are sent as a string enum of ordered labels (rubric in the function description) and decoded as `indexOf(label)+1`; numeric answers are rejected. Wire format of `route.rigor.v1`/`preflight.rigor.v1` is now labels (`r0`..`r4`); parsed values stay integer 1..N.
19
+ - **Request caps.** `decisionPlane.maxBatchQuestions` (default 8) and `maxEnumLabels` (default 11) are enforced before transport (`unsupported`, deterministic fallback); duplicate question keys are `invalid`. Route shadow and review use partial coverage in ONE batch: skipped decisions are reported (`coverage`, `skippedDecisions`, `outcomeClass: partial`) and keep deterministic results; no request splitting.
20
+ - Limits: oversize requests outside route shadow/review fall back deterministically; escalation lane model answers are never applied; the server-side `unic-decision` model alias is unchanged. Details: `docs/UNIC-DECISION-INTEGRATION-P1-FIX.md`.
21
+
12
22
  ## 3.4.31 - 2026-10-11
13
23
 
14
24
  **Completion Gate: task-aware operational completion (fixes the CronDelete/CronList false positive).** Verified with the full suite (`yarn test`, 566 files) plus focused six-scenario routing → recorder → Stop regressions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.31",
3
+ "version": "3.4.32",
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",
@@ -135,7 +135,7 @@ function stableBatchId(findings) {
135
135
  * findings: object[], verdictDecisionKey: string}}
136
136
  * Never throws — garbage input sanitizes to an empty finding list.
137
137
  */
138
- export function buildReviewVerdictBatch({ findings = [], mode = 'solo' } = {}) {
138
+ export function buildReviewVerdictBatch({ findings = [], mode = 'solo', maxQuestions = Infinity } = {}) {
139
139
  const usedIds = new Set();
140
140
  // Sanitize the FULL finding set — MAX_FINDINGS is a display/transport bound
141
141
  // for the batch, not a verdict bound. The deterministic verdict and the
@@ -145,6 +145,19 @@ export function buildReviewVerdictBatch({ findings = [], mode = 'solo' } = {}) {
145
145
  const batchFindings = sanitized.slice(0, MAX_FINDINGS);
146
146
  const truncated = sanitized.length > batchFindings.length;
147
147
 
148
+ // Request-cap handling (no extra call): when verdict + one bucket question per
149
+ // finding would exceed `maxQuestions`, ask only findings whose bucket the model
150
+ // can actually change. `Act on` is a deterministic floor the model cannot
151
+ // lower, so critical/important/unknown findings carry no semantic value in the
152
+ // batch; the rest keep their deterministic bucket and are reported as skipped.
153
+ const slots = Number.isInteger(maxQuestions) && maxQuestions > 0
154
+ ? maxQuestions - 1 : Infinity;
155
+ const askedFindings = batchFindings.length <= slots
156
+ ? batchFindings
157
+ : batchFindings.filter((f) => deriveDeterministicBucket(f) !== 'Act on').slice(0, slots);
158
+ const askedIds = new Set(askedFindings.map((f) => f.id));
159
+ const skippedFindingIds = batchFindings.filter((f) => !askedIds.has(f.id)).map((f) => f.id);
160
+
148
161
  const verdictDecisionKey = mode === 'panel'
149
162
  ? REVIEW_VERDICT_KEYS.panel
150
163
  : REVIEW_VERDICT_KEYS.solo;
@@ -169,7 +182,7 @@ export function buildReviewVerdictBatch({ findings = [], mode = 'solo' } = {}) {
169
182
  // One bucket question per finding. The shared registered key cannot repeat
170
183
  // in one batch (name-based reconciliation), so each wire name carries
171
184
  // `#<findingId>`; parseVerdictAnswer splits it back off.
172
- const bucketQuestions = batchFindings.map((f) => ({
185
+ const bucketQuestions = askedFindings.map((f) => ({
173
186
  decisionKey: `${REVIEW_VERDICT_KEYS.bucket}#${f.id}`,
174
187
  schemaVersion: 1,
175
188
  family: 'review',
@@ -207,6 +220,7 @@ export function buildReviewVerdictBatch({ findings = [], mode = 'solo' } = {}) {
207
220
  findings: batchFindings,
208
221
  fullFindings: sanitized,
209
222
  truncated,
223
+ skippedFindingIds,
210
224
  verdictDecisionKey,
211
225
  };
212
226
  }
@@ -394,14 +394,20 @@ export async function applyFixLoopEscalation({
394
394
  : null;
395
395
  // A model lane that refuses to deepen is not an escalation answer — the
396
396
  // deterministic deeper lane wins so the loop actually exits its lane.
397
- const usableModelLane = modelLane && modelLane !== currentLane ? modelLane : null;
398
- const lane = usableModelLane ?? deterministicLane;
397
+ // Lanes are capabilities, not an ordinal ladder. Without a validated policy
398
+ // relation only the deterministic destination is admissible.
399
+ const usableModelLane = planeStage !== 'shadow' && modelLane === deterministicLane
400
+ ? modelLane : null;
401
+ const lane = deterministicLane;
399
402
  const endpointDown = batchResult !== null
400
403
  && batchResult.status !== 'accepted'
401
404
  && batchResult.status !== 'partial'
402
405
  && !usableModelLane;
403
- const depth = (modelDepth && modelDepth !== 'none' ? modelDepth : null)
404
- ?? resolveEscalationVerificationDepth({ routeSummary, config });
406
+ const depthFloor = resolveEscalationVerificationDepth({ routeSummary, config });
407
+ const depthOrder = ['none', 'runnable', 'review-round', 'escalate'];
408
+ const depth = planeStage !== 'shadow'
409
+ && depthOrder.indexOf(modelDepth) > depthOrder.indexOf(depthFloor)
410
+ ? modelDepth : depthFloor;
405
411
 
406
412
  const reason = [
407
413
  `fix-loop: fixLoopCount=${fixLoopCount} threshold=${settings.debugLoopThreshold} fingerprint-joined`,
@@ -146,7 +146,7 @@ function stableBatchId(findings) {
146
146
  return `rv-${(h >>> 0).toString(16).padStart(8, '0')}`;
147
147
  }
148
148
 
149
- export function buildReviewVerdictBatch({ findings = [], mode = 'solo' } = {}) {
149
+ export function buildReviewVerdictBatch({ findings = [], mode = 'solo', maxQuestions = Infinity } = {}) {
150
150
  const usedIds = new Set();
151
151
  // Sanitize the FULL finding set — MAX_FINDINGS is a display/transport bound
152
152
  // for the batch, not a verdict bound. The deterministic verdict and the
@@ -156,6 +156,19 @@ export function buildReviewVerdictBatch({ findings = [], mode = 'solo' } = {}) {
156
156
  const batchFindings = sanitized.slice(0, MAX_FINDINGS);
157
157
  const truncated = sanitized.length > batchFindings.length;
158
158
 
159
+ // Request-cap handling (no extra call): when verdict + one bucket question per
160
+ // finding would exceed `maxQuestions`, ask only findings whose bucket the model
161
+ // can actually change. `Act on` is a deterministic floor the model cannot
162
+ // lower, so critical/important/unknown findings carry no semantic value in the
163
+ // batch; the rest keep their deterministic bucket and are reported as skipped.
164
+ const slots = Number.isInteger(maxQuestions) && maxQuestions > 0
165
+ ? maxQuestions - 1 : Infinity;
166
+ const askedFindings = batchFindings.length <= slots
167
+ ? batchFindings
168
+ : batchFindings.filter((f) => deriveDeterministicBucket(f) !== 'Act on').slice(0, slots);
169
+ const askedIds = new Set(askedFindings.map((f) => f.id));
170
+ const skippedFindingIds = batchFindings.filter((f) => !askedIds.has(f.id)).map((f) => f.id);
171
+
159
172
  const verdictDecisionKey = mode === 'panel'
160
173
  ? REVIEW_VERDICT_KEYS.panel
161
174
  : REVIEW_VERDICT_KEYS.solo;
@@ -177,7 +190,7 @@ export function buildReviewVerdictBatch({ findings = [], mode = 'solo' } = {}) {
177
190
  hardConstraintRefs: ['verdict-vocabulary'],
178
191
  };
179
192
 
180
- const bucketQuestions = batchFindings.map((f) => ({
193
+ const bucketQuestions = askedFindings.map((f) => ({
181
194
  decisionKey: `${REVIEW_VERDICT_KEYS.bucket}#${f.id}`,
182
195
  schemaVersion: 1,
183
196
  family: 'review',
@@ -213,6 +226,7 @@ export function buildReviewVerdictBatch({ findings = [], mode = 'solo' } = {}) {
213
226
  findings: batchFindings,
214
227
  fullFindings: sanitized,
215
228
  truncated,
229
+ skippedFindingIds,
216
230
  verdictDecisionKey,
217
231
  };
218
232
  }
@@ -362,7 +376,7 @@ function readMergedConfig(rootDir, homeDir) {
362
376
  // Deterministic output object. Field order is fixed so the printed JSON is
363
377
  // byte-identical for identical findings on the off/failure paths.
364
378
  function emitVerdict({ mode, stage, outcomeClass, verdict, verdictStatus,
365
- verdictSource, buckets, batchId, fallbackCode }) {
379
+ verdictSource, buckets, batchId, fallbackCode, coverage }) {
366
380
  return {
367
381
  schema: 'review-verdict/v1',
368
382
  mode,
@@ -374,6 +388,7 @@ function emitVerdict({ mode, stage, outcomeClass, verdict, verdictStatus,
374
388
  buckets,
375
389
  batchId,
376
390
  fallbackCode: fallbackCode ?? null,
391
+ ...(coverage ? { coverage } : {}),
377
392
  };
378
393
  }
379
394
 
@@ -413,11 +428,19 @@ function foldBatchResult(result, built, mode, stage) {
413
428
  fallbackCode: 'shadow-observation-only',
414
429
  verdictOverride: deriveDeterministicVerdict(built.fullFindings ?? built.findings) });
415
430
  }
431
+ const skipped = new Set(built.skippedFindingIds ?? []);
416
432
  folded.buckets = folded.buckets.map((bucket, index) => {
417
433
  const floor = deriveDeterministicBucket(built.findings[index]);
434
+ if (skipped.has(bucket.findingId)) {
435
+ return { findingId: bucket.findingId, bucket: floor, bucketStatus: 'deterministic:not-asked' };
436
+ }
418
437
  return floor === 'Act on' && bucket.bucket !== floor
419
438
  ? { ...bucket, bucket: floor, bucketStatus: 'clamp:safety-floor' } : bucket;
420
439
  });
440
+ const coverage = skipped.size > 0
441
+ ? { asked: built.findings.length - skipped.size, total: built.findings.length,
442
+ skippedFindingIds: [...skipped], reason: 'batch-cap', complete: false }
443
+ : null;
421
444
  const modelVerdict = folded.verdict;
422
445
  // W3-RV1: the batch may be display-truncated at MAX_FINDINGS — the model
423
446
  // verdict was computed on `built.findings` only, so when truncation
@@ -433,8 +456,10 @@ function foldBatchResult(result, built, mode, stage) {
433
456
  // every transport/gateway class surfaces as 'unavailable' — the fallback
434
457
  // code carries the finer-grained reason for receipts.
435
458
  const PARSE_CLASSES = new Set(['accepted', 'partial', 'invalid', 'abstained']);
436
- const outcomeClass = PARSE_CLASSES.has(folded.status) ? folded.status : 'unavailable';
459
+ let outcomeClass = PARSE_CLASSES.has(folded.status) ? folded.status : 'unavailable';
460
+ if (coverage && outcomeClass === 'accepted') outcomeClass = 'partial';
437
461
  return emitVerdict({
462
+ coverage,
438
463
  mode,
439
464
  stage,
440
465
  outcomeClass,
@@ -460,7 +485,11 @@ function foldBatchResult(result, built, mode, stage) {
460
485
  */
461
486
  export async function runReviewVerdict({ findings, mode = 'solo', config,
462
487
  projectRoot, homeDir, env, transport } = {}) {
463
- const built = buildReviewVerdictBatch({ findings, mode });
488
+ const capValue = config?.decisionPlane?.maxBatchQuestions;
489
+ const built = buildReviewVerdictBatch({
490
+ findings, mode,
491
+ maxQuestions: Number.isInteger(capValue) && capValue > 0 ? capValue : 8,
492
+ });
464
493
  const normalizedMode = mode === 'panel' ? 'panel' : 'solo';
465
494
  const stage = resolveReviewStage(config);
466
495
 
@@ -1663,6 +1663,11 @@ function probabilityBand(value) {
1663
1663
  // executionMode. The review/cross-check/verification-depth baselines are
1664
1664
  // produced by later wave modules; until then their baseline stays null and
1665
1665
  // receipts record bands + agreement 'unknown' — real data, not fabricated.
1666
+ // Route-shadow questions that add no observation: asked authoritatively by the
1667
+ // fix-loop escalation batch (verification.depth.v1) and without a route-level
1668
+ // baseline to compare against here.
1669
+ const SHADOW_REDUNDANT_KEYS = new Set(['verification.depth.v1']);
1670
+
1666
1671
  function shadowBaselineFor(decisionKey, routeSummary) {
1667
1672
  if (decisionKey === 'route.intent-kind.v1') return routeSummary?.intent?.kind ?? null;
1668
1673
  if (decisionKey === 'route.rigor.v1') return routeSummary?.execution?.rigor ?? null;
@@ -1819,7 +1824,42 @@ export async function runRouteDecisionShadow({
1819
1824
  // Greeting fast-path: a social turn carries nothing the decision plane could
1820
1825
  // answer — never spawn the adapter shadow for one.
1821
1826
  if (routingContext?.greetingFastPath) return null;
1822
- const questions = buildShadowDecisionQuestions(config);
1827
+ // Observation-only batch: honour the adapter request caps without a second
1828
+ // call. Selection is semantic, not positional: (1) enums over maxEnumLabels
1829
+ // are never truncated, they are skipped (`enum-cap`); (2) questions the route
1830
+ // cannot compare to a deterministic baseline AND that another stage already
1831
+ // asks authoritatively are redundant here; (3) the remainder is ranked
1832
+ // baseline-comparable first, table order within a rank, and cut at
1833
+ // maxBatchQuestions (`batch-cap`). Every skip is reported with its reason.
1834
+ const shadowCaps = {
1835
+ batch: Number.isInteger(config?.decisionPlane?.maxBatchQuestions) && config.decisionPlane.maxBatchQuestions > 0
1836
+ ? config.decisionPlane.maxBatchQuestions : 8,
1837
+ labels: Number.isInteger(config?.decisionPlane?.maxEnumLabels) && config.decisionPlane.maxEnumLabels > 0
1838
+ ? config.decisionPlane.maxEnumLabels : 11,
1839
+ };
1840
+ const wanted = buildShadowDecisionQuestions(config);
1841
+ const skippedDecisions = [];
1842
+ const candidatesForBatch = [];
1843
+ for (const q of wanted) {
1844
+ if ((q.candidates?.length ?? 0) > shadowCaps.labels) {
1845
+ skippedDecisions.push({ decisionKey: q.decisionKey, reason: 'enum-cap' });
1846
+ } else if (SHADOW_REDUNDANT_KEYS.has(q.decisionKey)) {
1847
+ skippedDecisions.push({ decisionKey: q.decisionKey, reason: 'redundant' });
1848
+ } else {
1849
+ candidatesForBatch.push(q);
1850
+ }
1851
+ }
1852
+ const hasBaseline = (q) => shadowBaselineFor(q.decisionKey, routeSummary) != null;
1853
+ const ranked = [
1854
+ ...candidatesForBatch.filter(hasBaseline),
1855
+ ...candidatesForBatch.filter((q) => !hasBaseline(q)),
1856
+ ];
1857
+ const picked = new Set(ranked.slice(0, shadowCaps.batch));
1858
+ for (const q of candidatesForBatch) {
1859
+ if (!picked.has(q)) skippedDecisions.push({ decisionKey: q.decisionKey, reason: 'batch-cap' });
1860
+ }
1861
+ const questions = candidatesForBatch.filter((q) => picked.has(q));
1862
+ const skippedDecisionKeys = skippedDecisions.map((d) => d.decisionKey);
1823
1863
  if (questions.length === 0) {
1824
1864
  return { stage, outcomeClass: 'skipped', decisionKeys: [], probabilityBands: {},
1825
1865
  checkpoint: null, latencyClass: 'unknown', fallbackCode: 'no-enabled-families',
@@ -1904,6 +1944,10 @@ export async function runRouteDecisionShadow({
1904
1944
  };
1905
1945
  }
1906
1946
 
1947
+ // Partial coverage is never reported as full success.
1948
+ if (skippedDecisions.length > 0 && result.status === 'accepted') {
1949
+ result = { ...result, status: 'partial' };
1950
+ }
1907
1951
  const receipt = buildDecisionShadowReceipt({
1908
1952
  stage,
1909
1953
  result,
@@ -1960,7 +2004,14 @@ export async function runRouteDecisionShadow({
1960
2004
  // Receipt loss is advisory — never fail the route over telemetry.
1961
2005
  }
1962
2006
  }
1963
- return receipt;
2007
+ return skippedDecisions.length > 0
2008
+ ? {
2009
+ ...receipt,
2010
+ skippedDecisionKeys,
2011
+ skippedDecisions,
2012
+ coverage: { asked: questions.length, total: wanted.length, complete: false },
2013
+ }
2014
+ : receipt;
1964
2015
  }
1965
2016
 
1966
2017
  // BL-018 (SPEC FR-006): the bounded-question `ask` seam — one batch through
@@ -296,67 +296,12 @@ export function buildStatePacket(decision, context) {
296
296
  return packet;
297
297
  }
298
298
 
299
- // ---------------------------------------------------------------------------
300
- // Adapter spawn — one bounded single-question batch to unic-decision.mjs.
301
- // Never throws: every failure resolves to a typed 'unavailable' result.
302
- // ---------------------------------------------------------------------------
303
-
304
- function runAdapterBatch({ spec, context, rootDir, config, batchId }) {
305
- const cliPath = process.env.UKIT_DECISION_CLI_PATH || UNIC_DECISION_CLI_PATH;
306
- const timeoutMs = Math.min(
307
- (isFiniteNumber(config?.decisionPlane?.timeoutMs)
308
- ? config.decisionPlane.timeoutMs
309
- : ADAPTER_TIMEOUT_CAP_MS) + 1000,
310
- ADAPTER_TIMEOUT_CAP_MS + 1000,
311
- );
312
- const batch = {
313
- batchId,
314
- boundary: 'sidecar',
315
- deadlineMs: Math.max(500, timeoutMs - 1000),
316
- statePacket: buildStatePacket(spec.decision, context),
317
- questions: [spec.question],
318
- };
319
- let result = null;
320
- try {
321
- const spawned = spawnSync(
322
- process.execPath,
323
- [cliPath, '--root', rootDir],
324
- {
325
- cwd: rootDir,
326
- input: JSON.stringify(batch),
327
- encoding: 'utf8',
328
- timeout: timeoutMs,
329
- },
330
- );
331
- if (spawned && !spawned.error && spawned.status === 0 && spawned.stdout) {
332
- result = JSON.parse(spawned.stdout);
333
- } else {
334
- result = { status: 'unavailable', fallbackCode: 'adapter-spawn-failed' };
335
- }
336
- } catch {
337
- result = { status: 'unavailable', fallbackCode: 'adapter-spawn-failed' };
338
- }
339
- if (!result || typeof result !== 'object') {
340
- result = { status: 'unavailable', fallbackCode: 'malformed-response' };
341
- }
342
- return result;
343
- }
344
-
345
- function validModelAnswer(result, decisionKey) {
346
- const answers = Array.isArray(result?.answers) ? result.answers : [];
347
- const answer = answers.find(
348
- (a) => a?.decisionKey === decisionKey && a.validationStatus === 'valid',
349
- );
350
- return typeof answer?.value === 'string' ? answer.value : null;
351
- }
352
-
353
299
  /**
354
300
  * Resolve one sidecar decision. Returns a typed result; never throws.
355
301
  * {decision, decisionKey, stage, answer, deterministic, source,
356
- * outcomeClass, modelAnswer?, agreement?, fallbackCode}
357
- * `answer` is the authoritative value: the deterministic rule at 'off' and
358
- * 'shadow', the model's valid answer at 'canary'/'default', and the
359
- * deterministic rule again on any adapter failure (outcomeClass 'unavailable').
302
+ * outcomeClass, fallbackPolicy}
303
+ * `answer` is always the deterministic rule at every stage: no model call is
304
+ * made, so there is no model answer, agreement or adapter fallback to report.
360
305
  */
361
306
  export async function runSidecarDecision({ decision, context = {}, rootDir, homeDir, config } = {}) {
362
307
  // Own-property lookup (W3-SD1): `SIDECAR_DECISIONS[name]` would also resolve