@ngockhoale/ukit 3.4.30 → 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.
Files changed (27) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/manifests/documentation.yaml +12 -0
  3. package/package.json +1 -1
  4. package/src/core/executionContracts.js +32 -0
  5. package/src/decision/client.js +15 -0
  6. package/src/decision/protocol.js +12 -14
  7. package/src/decision/reviewVerdict.js +16 -2
  8. package/src/index/fixLoopEscalation.js +10 -4
  9. package/src/index/routeResolver.js +2 -2
  10. package/src/index/taskRouting.js +3 -1
  11. package/template_project/.claude/hooks/record-execution.mjs +1 -3
  12. package/template_project/.claude/hooks/skill-router.sh +82 -9
  13. package/template_project/.claude/settings.json +22 -0
  14. package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +10 -4
  15. package/template_project/.claude/ukit/index/review-verdict.mjs +48 -9
  16. package/template_project/.claude/ukit/index/route-resolver.mjs +34 -2
  17. package/template_project/.claude/ukit/index/route-task.mjs +88 -9
  18. package/template_project/.claude/ukit/index/sidecar-decision.mjs +6 -117
  19. package/template_project/.claude/ukit/index/unic-decision.mjs +27 -14
  20. package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +43 -0
  21. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +160 -33
  22. package/template_project/.claude/ukit/runtime/loop-state.mjs +1 -1
  23. package/template_project/.claude/ukit/runtime/operation-evidence.mjs +87 -0
  24. package/template_project/.omp/hooks/pre/ukit-bridge.js +8 -1
  25. package/template_project/AGENTS.md +1 -1
  26. package/template_project/CLAUDE.md +1 -1
  27. package/template_project/instructions/core.md +1 -1
@@ -653,12 +653,12 @@ function unique(values) {
653
653
 
654
654
  // Completion contract for a lane. Informational routes carry no completion debt;
655
655
  // every contract lane returns the still-missing evidence classes plus a reason.
656
- export function buildCompletionState({ executionMode = null, verificationRecommendation = null } = {}) {
656
+ export function buildCompletionState({ executionMode = null, verificationRecommendation = null, executionContract = null } = {}) {
657
657
  if (!executionMode || executionMode === 'informational') {
658
658
  return null;
659
659
  }
660
660
 
661
- const contract = buildExecutionContract(executionMode);
661
+ const contract = executionContract || buildExecutionContract(executionMode);
662
662
  const missingEvidence = [...(contract?.completionEvidence ?? [])];
663
663
  const requiresVerification = missingEvidence.includes('verification-evidence');
664
664
  if (
@@ -1062,3 +1062,35 @@ export function deriveRouteFields({
1062
1062
  resolved,
1063
1063
  };
1064
1064
  }
1065
+
1066
+ // Task semantics are independent of source execution strategy and observed progress.
1067
+ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
1068
+ const text = String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd').toLowerCase();
1069
+ const clauses = text.split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/);
1070
+ const actionable = clauses.map((clause) => clause.split(/\bwithout\b/)[0]).filter((clause) => !/\b(?:do not|don't|never|khong|dung)\b/.test(clause)
1071
+ && !/^\s*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause));
1072
+ const sourceOrder = actionable.some((clause) => /\b(?:implement|fix|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
1073
+ && /\b(?:source|code|logic|scheduler|implementation|ma nguon)\b|\.[cm]?[jt]sx?\b/.test(clause));
1074
+ const operation = actionable.some((clause) => /\b(?:delete|remove|cancel|xoa|huy|ensure|dam bao)\b/.test(clause)
1075
+ && /\bcron\b|scheduled\s+(?:cron\s+)?(?:task|job)|tac vu\s+(?:cron|dinh ky)/.test(clause));
1076
+ const config = actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause));
1077
+ const repository = Boolean(targetFile) || /[\w.-]+\.(?:json|ya?ml|toml|ini)\b/.test(text);
1078
+ const readOnly = /\b(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(text)
1079
+ && !sourceOrder && !operation && !config;
1080
+ const taskKind = operation ? (sourceOrder ? 'MIXED' : 'OPERATIONAL')
1081
+ : config ? 'CONFIGURATION' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
1082
+ const id = text.match(/\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/)?.[0] || null;
1083
+ return { taskKind, ...(config ? { configurationScope: repository ? 'repository' : 'external' } : {}),
1084
+ ...(operation ? { operationGoal: { domain: 'scheduled-job', target: id, desiredState: /\b(?:delete|remove|cancel|xoa|huy|absent|removed|deleted|no longer|khong con)\b/.test(actionable.join(' ')) ? 'absent' : 'present',
1085
+ completionSemantics: /\b(?:ensure|dam bao)\b/.test(text) ? 'ensure-state' : 'execute-and-verify' } } : {}) };
1086
+ }
1087
+
1088
+ export function applyTaskSemantics(contract, semantic) {
1089
+ const evidence = contract?.completionEvidence || [];
1090
+ const operationOnly = semantic.taskKind === 'OPERATIONAL'
1091
+ || (semantic.taskKind === 'CONFIGURATION' && semantic.configurationScope === 'external');
1092
+ return { ...(contract || {}), ...semantic,
1093
+ completionEvidence: operationOnly ? ['operation-verification']
1094
+ : semantic.taskKind === 'MIXED' ? [...new Set([...evidence, 'write-evidence', 'operation-verification'])]
1095
+ : semantic.taskKind === 'READ_ONLY' ? (evidence.length === 1 && evidence[0] === 'verification-evidence' ? evidence : ['impact-evidence']) : evidence };
1096
+ }
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { classifyTaskSemantics, applyTaskSemantics } from './route-resolver.mjs';
2
3
  import fs from 'node:fs/promises';
3
4
  // `fsSync` aliases the SAME fs module — used ONLY by the process.on('exit')
4
5
  // audit-sidecar fold below (event loop is gone; async cannot run) and by the
@@ -799,6 +800,7 @@ function buildRouteHotCacheState(sharedState = {}) {
799
800
  delete routingContext.lastExplicitUserPromptText;
800
801
  delete routingContext.commandText;
801
802
  delete state.sessionId;
803
+ delete state.taskInstanceId;
802
804
  state.routingContext = routingContext;
803
805
  return state;
804
806
  }
@@ -895,6 +897,7 @@ function buildReducedRouteState({ input, reason }) {
895
897
  source: 'task-route',
896
898
  requestKey: null,
897
899
  sessionId: input.sessionId ?? null,
900
+ taskInstanceId: input.taskInstanceId || undefined,
898
901
  });
899
902
  }
900
903
 
@@ -911,7 +914,7 @@ export async function routeTask(input = {}, { signal = null, deadlineMs = null,
911
914
  const absoluteRoot = path.resolve(input.rootDir ?? process.cwd());
912
915
  const budget = createRouteBudget({ signal, deadlineMs });
913
916
  const hotCache = cache ?? createDefaultRouteHotCache({ rootDir: absoluteRoot });
914
- const normalizedInput = { ...input, rootDir: absoluteRoot };
917
+ const normalizedInput = { ...input, rootDir: absoluteRoot, taskInstanceId: input.taskInstanceId || `task-${crypto.randomBytes(16).toString('hex')}` };
915
918
  const baseMeta = {
916
919
  cacheHit: false,
917
920
  reduced: false,
@@ -929,7 +932,7 @@ export async function routeTask(input = {}, { signal = null, deadlineMs = null,
929
932
  elapsedMs: Date.now() - budget.startedAt,
930
933
  ioReads: budget.ioReads,
931
934
  };
932
- return { ...state, cacheHit: routeMeta.cacheHit, routeMeta };
935
+ return { ...state, taskInstanceId: normalizedInput.taskInstanceId || state.taskInstanceId || `task-${crypto.randomBytes(16).toString('hex')}`, cacheHit: routeMeta.cacheHit, routeMeta };
933
936
  };
934
937
  const reduce = (reason) => finish(
935
938
  buildReducedRouteState({ input: normalizedInput, reason }),
@@ -1079,6 +1082,7 @@ export async function routeTask(input = {}, { signal = null, deadlineMs = null,
1079
1082
  requestKey,
1080
1083
  indexGeneratedAtMs,
1081
1084
  sessionId: normalizedInput.sessionId ?? null,
1085
+ taskInstanceId: normalizedInput.taskInstanceId || undefined,
1082
1086
  });
1083
1087
 
1084
1088
  // Phase 3 — store under a POST-route fingerprint so the next identical call (whose own
@@ -1126,7 +1130,12 @@ async function main() {
1126
1130
  const sessionId = normalizeSessionId(readFlagValue(args, '--session-id'));
1127
1131
  const previousStateOwnershipBlocked = hasRouteStateOwner(persistedPreviousState)
1128
1132
  && !isRouteStateCompatible(persistedPreviousState, sessionId);
1129
- const previousState = previousStateOwnershipBlocked ? {} : persistedPreviousState;
1133
+ const sessionSidecar = previousStateOwnershipBlocked && sessionId
1134
+ ? await readJson(path.join(rootDir, '.ukit', 'storage', 'cache', 'route-state', `${sessionId.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96) || 'default'}.json`), {})
1135
+ : {};
1136
+ const previousState = previousStateOwnershipBlocked
1137
+ ? (isRouteStateCompatible(sessionSidecar, sessionId) && sessionSidecar.sessionId === sessionId ? sessionSidecar : {})
1138
+ : persistedPreviousState;
1130
1139
  const escalationConfig = await readMergedConfig(rootDir);
1131
1140
  const targetFile = readFlagValue(args, '--target');
1132
1141
  const taskType = readFlagValue(args, '--type');
@@ -1144,7 +1153,7 @@ async function main() {
1144
1153
  const commandNamespace = adapter === 'codex'
1145
1154
  ? '.codex'
1146
1155
  : (adapter === 'antigravity' ? '.antigravity' : '.claude');
1147
- const promptText = args
1156
+ let promptText = args
1148
1157
  .filter((arg, index) => !isFlagOrValue(args, index))
1149
1158
  .join(' ')
1150
1159
  .trim();
@@ -1155,6 +1164,15 @@ async function main() {
1155
1164
  return;
1156
1165
  }
1157
1166
 
1167
+ const continuingTask = Boolean(previousState.taskInstanceId && lastPrompt
1168
+ && /^(?:continue|resume|keep going|proceed|tiep tuc)(?:\s+(?:the task|this task|previous task))?[.!]?$/i.test(
1169
+ promptText.normalize('NFD').replace(/[̀-ͯ]/g, '').trim()));
1170
+ const taskInstanceId = readFlagValue(args, '--task-instance-id')
1171
+ || (((['skill-router', 'skill-router-provisional'].includes(previousState.source)
1172
+ && previousState.routingContext?.lastExplicitUserPromptText === promptText) || continuingTask)
1173
+ && previousState.taskInstanceId
1174
+ ? previousState.taskInstanceId : `task-${crypto.randomBytes(16).toString('hex')}`);
1175
+ if (continuingTask) promptText = lastPrompt;
1158
1176
  const preparedRoute = await prepareTaskRoute({
1159
1177
  rootDir,
1160
1178
  promptText,
@@ -1249,7 +1267,12 @@ async function main() {
1249
1267
  historySignals,
1250
1268
  };
1251
1269
  }
1252
- printRouteState(reusableState);
1270
+ const sharedState = { ...reusableState, taskInstanceId, source: 'task-route', ts: Date.now() };
1271
+ if (canPersistRouteState) {
1272
+ try { await persistSharedRouteState(sharedStatePath, sharedState); }
1273
+ catch (error) { console.error(`route-task: state persist degraded — ${error?.message ?? error}`); }
1274
+ }
1275
+ printRouteState(sharedState);
1253
1276
  return;
1254
1277
  }
1255
1278
 
@@ -1331,6 +1354,7 @@ async function main() {
1331
1354
  }
1332
1355
  const sharedState = {
1333
1356
  ...cachedRouteState,
1357
+ taskInstanceId,
1334
1358
  ...(sessionId ? { sessionId } : {}),
1335
1359
  source: 'task-route',
1336
1360
  ts: Date.now(),
@@ -1391,6 +1415,7 @@ async function main() {
1391
1415
  requestKey,
1392
1416
  indexGeneratedAtMs,
1393
1417
  sessionId,
1418
+ taskInstanceId,
1394
1419
  });
1395
1420
  if (canPersistRouteState) {
1396
1421
  // Advisory persistence — an EACCES/ENOSPC under the lock must never lose
@@ -1638,6 +1663,11 @@ function probabilityBand(value) {
1638
1663
  // executionMode. The review/cross-check/verification-depth baselines are
1639
1664
  // produced by later wave modules; until then their baseline stays null and
1640
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
+
1641
1671
  function shadowBaselineFor(decisionKey, routeSummary) {
1642
1672
  if (decisionKey === 'route.intent-kind.v1') return routeSummary?.intent?.kind ?? null;
1643
1673
  if (decisionKey === 'route.rigor.v1') return routeSummary?.execution?.rigor ?? null;
@@ -1794,7 +1824,42 @@ export async function runRouteDecisionShadow({
1794
1824
  // Greeting fast-path: a social turn carries nothing the decision plane could
1795
1825
  // answer — never spawn the adapter shadow for one.
1796
1826
  if (routingContext?.greetingFastPath) return null;
1797
- 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);
1798
1863
  if (questions.length === 0) {
1799
1864
  return { stage, outcomeClass: 'skipped', decisionKeys: [], probabilityBands: {},
1800
1865
  checkpoint: null, latencyClass: 'unknown', fallbackCode: 'no-enabled-families',
@@ -1879,6 +1944,10 @@ export async function runRouteDecisionShadow({
1879
1944
  };
1880
1945
  }
1881
1946
 
1947
+ // Partial coverage is never reported as full success.
1948
+ if (skippedDecisions.length > 0 && result.status === 'accepted') {
1949
+ result = { ...result, status: 'partial' };
1950
+ }
1882
1951
  const receipt = buildDecisionShadowReceipt({
1883
1952
  stage,
1884
1953
  result,
@@ -1935,7 +2004,14 @@ export async function runRouteDecisionShadow({
1935
2004
  // Receipt loss is advisory — never fail the route over telemetry.
1936
2005
  }
1937
2006
  }
1938
- 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;
1939
2015
  }
1940
2016
 
1941
2017
  // BL-018 (SPEC FR-006): the bounded-question `ask` seam — one batch through
@@ -3775,11 +3851,12 @@ function buildRouteSummary({
3775
3851
  executionScores,
3776
3852
  executionCandidates,
3777
3853
  });
3778
- const executionContract = buildExecutionContract(executionMode);
3854
+ const executionContract = applyTaskSemantics(buildExecutionContract(executionMode), classifyTaskSemantics({ promptText: routingContext.lastExplicitUserPromptText || routingContext.promptText, targetFile: routingContext.targetFile }));
3779
3855
  const continuousExecution = buildContinuousExecutionPolicy(autonomyLevel);
3780
3856
  const tierLane = buildTierLane({ modelTier: executionContract?.modelTier ?? null });
3781
3857
  const completionState = buildCompletionState({
3782
3858
  executionMode,
3859
+ executionContract,
3783
3860
  verificationRecommendation,
3784
3861
  });
3785
3862
  const continuationState = buildContinuationState({
@@ -5417,7 +5494,7 @@ function isFlagOrValue(argv, index) {
5417
5494
  const arg = argv[index];
5418
5495
  if (!arg.startsWith('--')) {
5419
5496
  const prev = argv[index - 1];
5420
- return Boolean(prev && ['--root', '--target', '--type', '--tool-command', '--last-prompt', '--session-id', '--adapter', '--transcript'].includes(prev));
5497
+ return Boolean(prev && ['--root', '--target', '--type', '--tool-command', '--last-prompt', '--session-id', '--task-instance-id', '--adapter', '--transcript'].includes(prev));
5421
5498
  }
5422
5499
  return true;
5423
5500
  }
@@ -5550,6 +5627,7 @@ function createSharedRouteState({
5550
5627
  source = 'task-route',
5551
5628
  requestKey = null,
5552
5629
  sessionId = null,
5630
+ taskInstanceId = `task-${crypto.randomBytes(16).toString('hex')}`,
5553
5631
  }) {
5554
5632
  const helpers = compactHelpers({
5555
5633
  verificationRecommendation: route.verificationRecommendation ?? null,
@@ -5559,6 +5637,7 @@ function createSharedRouteState({
5559
5637
  return {
5560
5638
  ...(sessionId ? { sessionId } : {}),
5561
5639
  requestKey,
5640
+ taskInstanceId,
5562
5641
  fingerprint: buildRouteStateFingerprint(route),
5563
5642
  ts: Date.now(),
5564
5643
  source,
@@ -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
@@ -393,65 +338,9 @@ export async function runSidecarDecision({ decision, context = {}, rootDir, home
393
338
  fallbackPolicy: spec.fallbackPolicy,
394
339
  };
395
340
 
396
- if (stage === 'off') {
397
- return { ...base, answer: deterministic, source: 'deterministic', outcomeClass: 'deterministic' };
398
- }
399
-
400
- const result = runAdapterBatch({
401
- spec, context: ctx, rootDir: rootDir ?? process.cwd(), config: mergedConfig,
402
- batchId: `sidecar-${decision}-${Date.now().toString(36)}`,
403
- });
404
- const modelAnswer = validModelAnswer(result, spec.decisionKey);
405
- const outcomeClass = result?.status ?? 'unavailable';
406
- const agreement = modelAnswer === null
407
- ? 'unknown'
408
- : modelAnswer.toLowerCase() === String(deterministic).toLowerCase()
409
- ? 'agree'
410
- : 'disagree';
411
-
412
- if (outcomeClass === 'unavailable' || outcomeClass === 'unsupported' || outcomeClass === 'invalid') {
413
- return {
414
- ...base,
415
- answer: deterministic,
416
- source: 'deterministic-fallback',
417
- outcomeClass: 'unavailable',
418
- modelAnswer,
419
- agreement,
420
- fallbackCode: result?.fallbackCode ?? outcomeClass,
421
- };
422
- }
423
- if (stage === 'shadow') {
424
- return {
425
- ...base,
426
- answer: deterministic,
427
- source: 'deterministic',
428
- outcomeClass,
429
- modelAnswer,
430
- agreement,
431
- fallbackCode: result?.fallbackCode ?? null,
432
- };
433
- }
434
- // canary/default: valid model answer wins; invalid → deterministic fallback.
435
- if (modelAnswer !== null) {
436
- return {
437
- ...base,
438
- answer: modelAnswer,
439
- source: 'unic-decision',
440
- outcomeClass,
441
- modelAnswer,
442
- agreement,
443
- fallbackCode: result?.fallbackCode ?? null,
444
- };
445
- }
446
- return {
447
- ...base,
448
- answer: deterministic,
449
- source: 'deterministic-fallback',
450
- outcomeClass,
451
- modelAnswer,
452
- agreement,
453
- fallbackCode: result?.fallbackCode ?? 'missing-answer',
454
- };
341
+ // All six sidecar decisions have complete deterministic rules. A model vote
342
+ // cannot waive their constraints; skip inference rather than ask and clamp.
343
+ return { ...base, answer: deterministic, source: 'deterministic', outcomeClass: 'deterministic' };
455
344
  }
456
345
 
457
346
  // ---------------------------------------------------------------------------
@@ -83,23 +83,20 @@ export function encodeQuestion(question) {
83
83
  description: 'true iff the proposition holds; interpreted as P(true).',
84
84
  };
85
85
  } else {
86
- // score — ordinal integer; the legend MUST be defined in the description.
87
- const legend = Array.isArray(candidates) ? candidates : [];
88
- value = { type: 'integer' };
89
- if (legend.length > 0) {
90
- value.minimum = 1;
91
- value.maximum = legend.length;
92
- value.description = `Ordered scale: ${legend
93
- .map((label, i) => `${i + 1}=${label}`)
94
- .join(', ')}.`;
86
+ if (!Array.isArray(candidates) || candidates.length === 0
87
+ || candidates.some(label => typeof label !== 'string' || !label.trim())
88
+ || new Set(candidates).size !== candidates.length) {
89
+ throw new TypeError('score requires unique non-empty ordinal labels.');
95
90
  }
91
+ value = { type: 'string', enum: [...candidates] };
96
92
  }
97
93
 
98
94
  return {
99
95
  type: 'function',
100
96
  function: {
101
97
  name: decisionKey,
102
- description: String(instruction ?? ''),
98
+ description: String(instruction ?? '') + (kind === 'score'
99
+ ? `\nOrdered ordinal rubric: ${candidates.map((label, i) => `${i + 1}=${JSON.stringify(label)}`).join(', ')}. Choose exactly one label; do not return an expected numeric score.` : ''),
103
100
  parameters: {
104
101
  type: 'object',
105
102
  properties: { value },
@@ -196,11 +193,12 @@ function validateValue(question, args) {
196
193
  if (isFiniteNumber(value) && value >= 0 && value <= 1) return { value };
197
194
  return 'missing-value';
198
195
  }
199
- // score — finite integer within the declared legend range.
200
- if (!isFiniteNumber(value) || !Number.isInteger(value)) return 'missing-value';
196
+ // Decode an explicit choice losslessly; never reinterpret native expectations.
201
197
  const legend = Array.isArray(question.candidates) ? question.candidates : [];
202
- if (legend.length > 0 && (value < 1 || value > legend.length)) return 'out-of-enum';
203
- return { value };
198
+ if (!legend.length || new Set(legend).size !== legend.length) return 'out-of-enum';
199
+ if (typeof value !== 'string') return 'missing-value';
200
+ const ordinal = legend.indexOf(value);
201
+ return ordinal < 0 ? 'out-of-enum' : { value: ordinal + 1 };
204
202
  }
205
203
 
206
204
  export function parseBatchResponse(response, questions) {
@@ -834,6 +832,21 @@ export async function requestBatch(batch, {
834
832
  return outcome('invalid', { fallbackCode: 'empty-batch' });
835
833
  }
836
834
 
835
+ const maxBatch = dp.maxBatchQuestions ?? 8;
836
+ const maxEnum = dp.maxEnumLabels ?? 11;
837
+ if (![maxBatch, maxEnum].every(n => Number.isInteger(n) && n > 0)) {
838
+ return outcome('unsupported', { fallbackCode: 'invalid-decision-limits' });
839
+ }
840
+ if (questions.length > maxBatch) {
841
+ return outcome('unsupported', { fallbackCode: 'max-batch-questions' });
842
+ }
843
+ if (questions.some(q => Array.isArray(q.candidates) && q.candidates.length > maxEnum)) {
844
+ return outcome('unsupported', { fallbackCode: 'max-enum-labels' });
845
+ }
846
+ if (new Set(questions.map(q => q.decisionKey)).size !== questions.length) {
847
+ return outcome('invalid', { fallbackCode: 'duplicate-question' });
848
+ }
849
+
837
850
  // Language classification covers state + question text; the English
838
851
  // checkpoint is reachable only when the whole request proves English.
839
852
  // Malformed batch input (cyclic state, non-serializable values, internal
@@ -0,0 +1,43 @@
1
+ // Capability adapters normalize native results; completion consumes their common receipt shape.
2
+ const ID = /^[a-f0-9-]{8,36}$/i;
3
+ function responseText(response) {
4
+ if (typeof response === 'string') return response.trim();
5
+ if (response?.content !== undefined && (!Array.isArray(response.content)
6
+ || !response.content.every((c) => c?.type === 'text' && typeof c.text === 'string'))) return '';
7
+ if (typeof response?.output === 'string') return response.output.trim();
8
+ if (typeof response?.stdout === 'string') return response.stdout.trim();
9
+ if (Array.isArray(response?.content) && response.content.every((c) => c?.type === 'text' && typeof c.text === 'string')) {
10
+ return response.content.map((c) => c.text).join('\n').trim();
11
+ }
12
+ return '';
13
+ }
14
+ const capabilities = {
15
+ CronDelete: { domain: 'scheduled-job', role: 'mutation', normalize(input, response) {
16
+ const target = typeof input?.id === 'string' && ID.test(input.id) ? input.id.toLowerCase() : null;
17
+ return { target, accepted: Boolean(target && responseText(response) === `Cancelled ${input.id}`) };
18
+ } },
19
+ CronList: { domain: 'scheduled-job', role: 'state', normalize(_input, response) {
20
+ const body = response?.details && Object.hasOwn(response.details, 'jobs') ? response.details : response;
21
+ const structured = body != null && typeof body === 'object' && Object.hasOwn(body, 'jobs');
22
+ const jobs = Array.isArray(body?.jobs) ? body.jobs : null;
23
+ const ids = jobs && jobs.length <= 256 && jobs.every((job) => typeof job?.id === 'string' && ID.test(job.id))
24
+ ? jobs.map((job) => job.id.toLowerCase()) : null;
25
+ const partial = [body, response].some((value) => value?.partial === true || value?.hasMore === true
26
+ || Boolean(value?.nextCursor) || value?.complete === false);
27
+ const supportedContent = response?.content === undefined || (Array.isArray(response.content)
28
+ && response.content.every((c) => c?.type === 'text' && typeof c.text === 'string'));
29
+ const valid = !partial && supportedContent
30
+ && (structured ? ids !== null : /^No scheduled jobs\.?$/.test(responseText(response)));
31
+ return { target: null, state: 'absent', presentTargets: valid ? ids || [] : null, complete: valid, accepted: valid };
32
+ } },
33
+ };
34
+ export function cronOperationReceipt(toolName, input, response, transportSuccess, { sessionId, harness } = {}) {
35
+ const capability = capabilities[toolName];
36
+ if (!capability) return null;
37
+ const normalized = capability.normalize(input, response);
38
+ const { accepted, ...state } = normalized;
39
+ const scope = typeof sessionId === 'string' && sessionId.trim() ? `session:${sessionId.trim()}` : null;
40
+ return { kind: 'operation', success: transportSuccess === true && accepted && Boolean(scope),
41
+ transportSuccess: transportSuccess === true, harness,
42
+ operation: { domain: capability.domain, role: capability.role, scope, ...state } };
43
+ }