@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 +10 -0
- package/package.json +1 -1
- package/src/decision/reviewVerdict.js +16 -2
- package/src/index/fixLoopEscalation.js +10 -4
- package/template_project/.claude/ukit/index/review-verdict.mjs +34 -5
- package/template_project/.claude/ukit/index/route-task.mjs +53 -2
- package/template_project/.claude/ukit/index/sidecar-decision.mjs +3 -58
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
|
@@ -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 =
|
|
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
|
-
|
|
398
|
-
|
|
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
|
|
404
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
357
|
-
* `answer` is
|
|
358
|
-
*
|
|
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
|