@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.
- package/CHANGELOG.md +23 -0
- package/manifests/documentation.yaml +12 -0
- package/package.json +1 -1
- package/src/core/executionContracts.js +32 -0
- package/src/decision/client.js +15 -0
- package/src/decision/protocol.js +12 -14
- package/src/decision/reviewVerdict.js +16 -2
- package/src/index/fixLoopEscalation.js +10 -4
- package/src/index/routeResolver.js +2 -2
- package/src/index/taskRouting.js +3 -1
- package/template_project/.claude/hooks/record-execution.mjs +1 -3
- package/template_project/.claude/hooks/skill-router.sh +82 -9
- package/template_project/.claude/settings.json +22 -0
- package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +10 -4
- package/template_project/.claude/ukit/index/review-verdict.mjs +48 -9
- package/template_project/.claude/ukit/index/route-resolver.mjs +34 -2
- package/template_project/.claude/ukit/index/route-task.mjs +88 -9
- package/template_project/.claude/ukit/index/sidecar-decision.mjs +6 -117
- package/template_project/.claude/ukit/index/unic-decision.mjs +27 -14
- package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +43 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +160 -33
- package/template_project/.claude/ukit/runtime/loop-state.mjs +1 -1
- package/template_project/.claude/ukit/runtime/operation-evidence.mjs +87 -0
- package/template_project/.omp/hooks/pre/ukit-bridge.js +8 -1
- package/template_project/AGENTS.md +1 -1
- package/template_project/CLAUDE.md +1 -1
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
@@ -393,65 +338,9 @@ export async function runSidecarDecision({ decision, context = {}, rootDir, home
|
|
|
393
338
|
fallbackPolicy: spec.fallbackPolicy,
|
|
394
339
|
};
|
|
395
340
|
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
//
|
|
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
|
|
203
|
-
|
|
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
|
+
}
|