@ngockhoale/ukit 3.4.47 → 3.4.48
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 +8 -0
- package/manifests/documentation.yaml +22 -0
- package/manifests/platform.full.yaml +27 -0
- package/package.json +4 -1
- package/scripts/dev/worktree-preflight.mjs +20 -1
- package/src/core/diffPlan.js +38 -12
- package/src/core/executionContracts.js +67 -16
- package/src/core/metadata.js +4 -0
- package/src/core/runInstallPipeline.js +19 -2
- package/src/core/runtimeConfig.js +1 -1
- package/src/index/playbookRegistry.js +22 -3
- package/src/index/taskRouting.js +2 -2
- package/template_project/.claude/agents/bug-debugger.md +1 -0
- package/template_project/.claude/agents/code-reviewer.md +1 -0
- package/template_project/.claude/agents/feature-implementer.md +1 -0
- package/template_project/.claude/agents/handoff-planner.md +1 -0
- package/template_project/.claude/agents/ukit-small-task-maintainer.md +1 -0
- package/template_project/.claude/hooks/completion-gate.sh +1 -1
- package/template_project/.claude/hooks/delegation-gate.mjs +343 -0
- package/template_project/.claude/hooks/skill-router.sh +147 -6
- package/template_project/.claude/settings.json +8 -8
- package/template_project/.claude/ukit/index/playbook-registry.mjs +22 -3
- package/template_project/.claude/ukit/index/route-resolver.mjs +67 -15
- package/template_project/.claude/ukit/index/route-task.mjs +115 -37
- package/template_project/.claude/ukit/index/tier-lane.mjs +202 -0
- package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +3 -1
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +306 -20
- package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +8 -0
- package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
- package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -0
- package/template_project/.claude/ukit/runtime/playbook-execution.mjs +704 -0
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +79 -3
- package/template_project/.omp/agents/bug-debugger.md +1 -0
- package/template_project/.omp/agents/code-reviewer.md +1 -0
- package/template_project/.omp/agents/feature-implementer.md +1 -0
- package/template_project/.omp/agents/handoff-planner.md +1 -0
- package/template_project/.omp/agents/ukit-small-task-maintainer.md +1 -0
- package/template_project/.omp/hooks/pre/ukit-bridge.js +254 -7
- package/template_project/ukit/storage/config.json +1 -1
- package/scripts/bench/data-foundation.mjs +0 -929
- package/scripts/bench/decision-runtime-baseline.mjs +0 -427
- package/scripts/bench/decision-runtime-variant.mjs +0 -626
- package/scripts/bench/goldTasks.json +0 -38
- package/scripts/bench/memory-ablation.mjs +0 -495
- package/scripts/bench/memory-baseline.mjs +0 -596
- package/scripts/bench/memory-bench.mjs +0 -661
- package/scripts/bench/memory-canary.mjs +0 -331
- package/scripts/bench/memory-corpus.mjs +0 -354
- package/scripts/bench/memory-gate.mjs +0 -389
- package/scripts/bench/memory-metrics.mjs +0 -179
- package/scripts/bench/outline-savings.mjs +0 -107
- package/scripts/bench/parallel-agents.mjs +0 -224
- package/scripts/bench/recorder-overhead.mjs +0 -204
- package/scripts/bench/runGold.mjs +0 -238
- package/scripts/bench/sqlite-spike.mjs +0 -451
- package/scripts/bench/subagent-orchestrator-corpus.mjs +0 -275
- package/scripts/bench/subagent-orchestrator-eval.mjs +0 -565
- package/scripts/bench/v3-ceremony.mjs +0 -220
- package/scripts/perf/audit-perf.mjs +0 -1195
- package/scripts/perf/diff-perf-findings.mjs +0 -136
- package/scripts/perf/perf-findings.json +0 -474
- package/scripts/perf/perf-measure.md +0 -271
|
@@ -17,6 +17,9 @@ import {
|
|
|
17
17
|
writeRecentCacheEntry,
|
|
18
18
|
} from './cache-utils.mjs';
|
|
19
19
|
import { ROUTE_CATALOG } from './route-catalog.mjs';
|
|
20
|
+
import { buildTierLane, classifyModelTier, resolveMainTier } from './tier-lane.mjs';
|
|
21
|
+
// Public surface unchanged for tests and callers that import the lane helpers from the route CLI.
|
|
22
|
+
export { buildTierLane, classifyModelTier, resolveMainTier };
|
|
20
23
|
import { detectUnicGateway } from './unic-gateway.mjs';
|
|
21
24
|
import { withAsyncLock } from '../runtime/async-lock.mjs';
|
|
22
25
|
import { appendDecisionReceipt, recordLedgerEvent } from '../runtime/execution-ledger.mjs';
|
|
@@ -1068,6 +1071,7 @@ export async function routeTask(input = {}, { signal = null, deadlineMs = null,
|
|
|
1068
1071
|
recentOutputSnapshot,
|
|
1069
1072
|
previousRouteSummary: normalizedInput.previousRouteSummary ?? null,
|
|
1070
1073
|
escalationConfig,
|
|
1074
|
+
sessionId: normalizedInput.sessionId ?? null,
|
|
1071
1075
|
// BL-018: caller-injected seams — the in-process entry accepts a prior
|
|
1072
1076
|
// emission (exhaustion check) and a stubbed decision/registrar for tests.
|
|
1073
1077
|
escalationDecision: normalizedInput.escalationDecision ?? null,
|
|
@@ -1109,6 +1113,40 @@ export async function routeTask(input = {}, { signal = null, deadlineMs = null,
|
|
|
1109
1113
|
|
|
1110
1114
|
export { DEFAULT_ROUTE_DEADLINE_MS, DEFAULT_ROUTE_IO_BUDGET };
|
|
1111
1115
|
|
|
1116
|
+
const TASK_INSTANCE_ID_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,95}$/;
|
|
1117
|
+
// The prompt hook owns turn boundaries: every real user turn writes a fresh state (new id) with one of
|
|
1118
|
+
// these sources before the model can run this CLI. A CLI-only environment (Codex: "invoked by
|
|
1119
|
+
// instruction") has no such boundary, so there an identical prompt is a REPEATED request and mints a new id.
|
|
1120
|
+
const HOOK_STATE_SOURCES = ['skill-router', 'skill-router-provisional'];
|
|
1121
|
+
|
|
1122
|
+
// @decision-point task.instance-id-ownership — deterministic reuse-vs-mint ownership decision (the
|
|
1123
|
+
// task boundary every evidence receipt is filtered by); regression: tests/index/taskIdentityContinuity.test.js.
|
|
1124
|
+
/**
|
|
1125
|
+
* Same task => same id; genuinely new task => fresh id. Returns { id, origin }.
|
|
1126
|
+
*
|
|
1127
|
+
* An id is reused only from a previous state (already session-filtered by main()) that provably belongs
|
|
1128
|
+
* to the SAME hook-owned turn: it was written by the hook, or by this CLI refreshing that hook turn
|
|
1129
|
+
* (taskIdOrigin 'hook'), AND the prompt is identical (or the refresh is prompt-less: tool command / target
|
|
1130
|
+
* only, routed from the stored explicit prompt). The origin ends at a continue phrase, a different prompt,
|
|
1131
|
+
* or any state the hook did not own, so a repeated request still mints a new id and unrelated evidence
|
|
1132
|
+
* stays rejected. An explicit --task-instance-id is honored when well-formed.
|
|
1133
|
+
*/
|
|
1134
|
+
export function reuseOrMintTaskInstanceId({ previousState = {}, promptText = '', commandText = '', targetFile = null, explicitId = null, continuingTask = false } = {}) {
|
|
1135
|
+
const explicit = typeof explicitId === 'string' ? explicitId.trim() : '';
|
|
1136
|
+
if (explicit && TASK_INSTANCE_ID_SHAPE.test(explicit)) return { id: explicit, origin: null };
|
|
1137
|
+
const prevId = previousState?.taskInstanceId;
|
|
1138
|
+
if (prevId) {
|
|
1139
|
+
const hookTurn = HOOK_STATE_SOURCES.includes(previousState.source)
|
|
1140
|
+
|| (previousState.source === 'task-route' && previousState.taskIdOrigin === 'hook');
|
|
1141
|
+
const stored = previousState.routingContext?.lastExplicitUserPromptText;
|
|
1142
|
+
const samePrompt = Boolean(stored) && stored === promptText;
|
|
1143
|
+
const promptlessRefresh = Boolean(stored) && !promptText && Boolean(commandText || targetFile);
|
|
1144
|
+
if (hookTurn && (samePrompt || promptlessRefresh)) return { id: prevId, origin: 'hook' };
|
|
1145
|
+
if (continuingTask) return { id: prevId, origin: null };
|
|
1146
|
+
}
|
|
1147
|
+
return { id: `task-${crypto.randomBytes(16).toString('hex')}`, origin: null };
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1112
1150
|
async function main() {
|
|
1113
1151
|
const args = process.argv.slice(2);
|
|
1114
1152
|
const rootDir = getRootDir(args);
|
|
@@ -1169,11 +1207,14 @@ async function main() {
|
|
|
1169
1207
|
const continuingTask = Boolean(previousState.taskInstanceId && lastPrompt
|
|
1170
1208
|
&& /^(?:continue|resume|keep going|go on|proceed|tiep tuc|lam tiep)(?:\s+(?:with\s+)?(?:the\s+|this\s+)?(?:previous\s+|last\s+|current\s+|same\s+)?(?:implementation\s+)?(?:task|work))?(?:\s+(?:thoi|di|đi|nhe|nao|nua|a|ha|please|pls|now|then))*\s*[.!]?$/i.test(
|
|
1171
1209
|
promptText.normalize('NFD').replace(/[̀-ͯ]/g, '').trim()));
|
|
1172
|
-
const taskInstanceId =
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1210
|
+
const { id: taskInstanceId, origin: taskIdOrigin } = reuseOrMintTaskInstanceId({
|
|
1211
|
+
previousState,
|
|
1212
|
+
promptText,
|
|
1213
|
+
commandText,
|
|
1214
|
+
targetFile,
|
|
1215
|
+
explicitId: readFlagValue(args, '--task-instance-id'),
|
|
1216
|
+
continuingTask,
|
|
1217
|
+
});
|
|
1177
1218
|
if (continuingTask) promptText = lastPrompt;
|
|
1178
1219
|
const preparedRoute = await prepareTaskRoute({
|
|
1179
1220
|
rootDir,
|
|
@@ -1269,7 +1310,7 @@ async function main() {
|
|
|
1269
1310
|
historySignals,
|
|
1270
1311
|
};
|
|
1271
1312
|
}
|
|
1272
|
-
const sharedState = { ...reusableState, taskInstanceId, source: 'task-route', ts: Date.now() };
|
|
1313
|
+
const sharedState = { ...reusableState, taskInstanceId, taskIdOrigin, source: 'task-route', ts: Date.now() };
|
|
1273
1314
|
if (canPersistRouteState) {
|
|
1274
1315
|
try { await persistSharedRouteState(sharedStatePath, sharedState); }
|
|
1275
1316
|
catch (error) { console.error(`route-task: state persist degraded — ${error?.message ?? error}`); }
|
|
@@ -1324,6 +1365,8 @@ async function main() {
|
|
|
1324
1365
|
applyEscalationToRouteSummary(rescuedRouteSummary, {
|
|
1325
1366
|
config: escalationConfig,
|
|
1326
1367
|
targetFile: cachedRouteState.routingContext?.targetFile ?? null,
|
|
1368
|
+
rootDir: rootDir,
|
|
1369
|
+
sessionId,
|
|
1327
1370
|
});
|
|
1328
1371
|
// BL-018: re-run the fix-loop transition on the rescued summary — a
|
|
1329
1372
|
// refreshed fixLoopCount may trip, escalate, or exhaust (BLOCKED); the
|
|
@@ -1357,6 +1400,7 @@ async function main() {
|
|
|
1357
1400
|
const sharedState = {
|
|
1358
1401
|
...cachedRouteState,
|
|
1359
1402
|
taskInstanceId,
|
|
1403
|
+
taskIdOrigin,
|
|
1360
1404
|
...(sessionId ? { sessionId } : {}),
|
|
1361
1405
|
source: 'task-route',
|
|
1362
1406
|
ts: Date.now(),
|
|
@@ -1420,6 +1464,7 @@ async function main() {
|
|
|
1420
1464
|
sessionId,
|
|
1421
1465
|
taskInstanceId,
|
|
1422
1466
|
});
|
|
1467
|
+
if (taskIdOrigin) sharedState.taskIdOrigin = taskIdOrigin;
|
|
1423
1468
|
if (canPersistRouteState) {
|
|
1424
1469
|
// Advisory persistence — an EACCES/ENOSPC under the lock must never lose
|
|
1425
1470
|
// the printed route; degrade to stderr and print anyway.
|
|
@@ -2504,6 +2549,8 @@ async function finalizeTaskRoute({
|
|
|
2504
2549
|
applyEscalationToRouteSummary(routeSummary, {
|
|
2505
2550
|
config: escalationConfig,
|
|
2506
2551
|
targetFile: routingContext.targetFile ?? null,
|
|
2552
|
+
rootDir: absoluteRoot,
|
|
2553
|
+
sessionId,
|
|
2507
2554
|
});
|
|
2508
2555
|
// BL-018 (SPEC FR-006): fix-loop escalation. historySignals.fixLoopCount is
|
|
2509
2556
|
// fingerprint-joined (same routeFingerprint/requestKey, never session-wide);
|
|
@@ -2694,6 +2741,9 @@ function printRouteState(state) {
|
|
|
2694
2741
|
}
|
|
2695
2742
|
if (state.routeSummary?.tierLane?.instruction) {
|
|
2696
2743
|
console.log(`tierLane: ${state.routeSummary.tierLane.instruction}`);
|
|
2744
|
+
if (state.routeSummary.tierLane.delegationRequired === true && state.taskInstanceId) {
|
|
2745
|
+
console.log(`handback: start each worker prompt with "[ukit-task ${state.taskInstanceId}]" so its result binds to this task`);
|
|
2746
|
+
}
|
|
2697
2747
|
}
|
|
2698
2748
|
if (state.routeSummary?.escalatedTier) {
|
|
2699
2749
|
console.log(`escalatedTier: ${state.routeSummary.escalatedTier}`);
|
|
@@ -3182,7 +3232,7 @@ function deriveIntentMode({ promptText = '', commandText = '', targetFile = null
|
|
|
3182
3232
|
}
|
|
3183
3233
|
|
|
3184
3234
|
if (concreteTask) {
|
|
3185
|
-
if (/\b(bug|debug|error|crash|broken|failing|stack trace|triage|fix|timeout)\b/.test(lower)) {
|
|
3235
|
+
if (/\b(bug|debug|error|crash|broken|failing|stack trace|triage|fix|timeout)\b/.test(lower.replace(/\b(?:throw|raise|emit|reject|return|log|show|surface)(?:s|es|ing)?\s+(?:an?\s+|the\s+)?(?:[a-z-]+\s+){0,2}[a-z]*(?:error|exception)s?\b/g, ' '))) {
|
|
3186
3236
|
return 'debug-specific';
|
|
3187
3237
|
}
|
|
3188
3238
|
if (/\b(review|audit|diff|pr feedback|code review|kiem tra|soat)\b/.test(raw)) {
|
|
@@ -4186,7 +4236,7 @@ function deriveExecutionScores({
|
|
|
4186
4236
|
const buildSignal = /\b(build|create|add|implement|feature)\b/.test(raw);
|
|
4187
4237
|
const debugSignal = intentMode === 'debug-specific' || /\b(root cause|debug|triage|flaky|investigate|why)\b/.test(raw);
|
|
4188
4238
|
const reviewSignal = intentMode === 'review-specific' || /\b(review|audit|verify|release readiness)\b/.test(raw);
|
|
4189
|
-
const failureSignal = /\b(failing|failed|broken|error|crash|timeout|undefined|exception|eacces|trace)\b/.test(raw);
|
|
4239
|
+
const failureSignal = /\b(failing|failed|broken|error|crash|timeout|undefined|exception|eacces|trace)\b/.test(raw.replace(/\b(?:throw|raise|emit|reject|return|log|show|surface)(?:s|es|ing)?\s+(?:an?\s+|the\s+)?(?:[a-z-]+\s+){0,2}[a-z]*(?:error|exception)s?\b/g, ' '));
|
|
4190
4240
|
const impactSignal = /\b(map impact|impact|blast radius|all affected|across (?:runtime|templates|helpers|adapters|mirrors)|affected adapters|affected mirrors|inspect impact)\b/.test(raw);
|
|
4191
4241
|
const boundedEditSignal = /\b(one[- ]line|small|tiny|single|local|guard|log line|loading message|label)\b/.test(raw);
|
|
4192
4242
|
const implementSignal = /\b(implement|apply|update|modify|add|create|ship|deliver|fix)\b/.test(raw)
|
|
@@ -5026,10 +5076,16 @@ function resolveEscalationSettings(config = null) {
|
|
|
5026
5076
|
* Escalates exactly one rung above `modelTier` on `tierOrder`, capped at `cap`. Returns null when
|
|
5027
5077
|
* escalation is disabled, `repeatCount` hasn't reached `debugLoopThreshold`, or `modelTier` isn't
|
|
5028
5078
|
* on the cost ladder (e.g. no contract selected). Never returns 'vision'.
|
|
5029
|
-
* FR-005:
|
|
5030
|
-
*
|
|
5031
|
-
*
|
|
5032
|
-
* escalation
|
|
5079
|
+
* FR-005: `riskEscalation` (level 'high') is the Stop-gate carrier and has two kinds of evidence with
|
|
5080
|
+
* different meaning here:
|
|
5081
|
+
* - `stuck-risk` (verified failure evidence): the repeat-count threshold is treated as satisfied
|
|
5082
|
+
* (immediate one-rung escalation) and the effective cap rises to 'smart', overriding the
|
|
5083
|
+
* configured cap. This is a capability escalation, so it also counts as complexity evidence.
|
|
5084
|
+
* - `risk-floor` only (the task touches something dangerous): risk raises verification rigor, not the
|
|
5085
|
+
* model tier. It lifts a Tier 1 chore to the Tier 2 executor at most, so a code lane is never
|
|
5086
|
+
* relabelled smart and the Tier 3 orchestrator is never turned into the implementation worker.
|
|
5087
|
+
* The reason is prefixed `stuck-risk:` / `risk-floor:` so consumers can tell the kinds apart from
|
|
5088
|
+
* repeat-count escalation. `complexityEvidence` is the only input that may ask for High effort.
|
|
5033
5089
|
*/
|
|
5034
5090
|
function computeEscalation({
|
|
5035
5091
|
modelTier = null,
|
|
@@ -5042,8 +5098,11 @@ function computeEscalation({
|
|
|
5042
5098
|
return null;
|
|
5043
5099
|
}
|
|
5044
5100
|
const settings = resolveEscalationSettings(config);
|
|
5045
|
-
const
|
|
5046
|
-
|
|
5101
|
+
const riskHigh = riskEscalation?.level === 'high';
|
|
5102
|
+
const stuckDriven = riskHigh && (riskEscalation.sources ?? []).includes('stuck-risk');
|
|
5103
|
+
const floorDriven = riskHigh && !stuckDriven;
|
|
5104
|
+
const repeatDriven = Number.isFinite(repeatCount) && repeatCount >= settings.debugLoopThreshold;
|
|
5105
|
+
if (!settings.enabled || (!stuckDriven && !floorDriven && !repeatDriven)) {
|
|
5047
5106
|
return null;
|
|
5048
5107
|
}
|
|
5049
5108
|
const currentIndex = settings.tierOrder.indexOf(modelTier);
|
|
@@ -5052,22 +5111,30 @@ function computeEscalation({
|
|
|
5052
5111
|
}
|
|
5053
5112
|
const capIndex = settings.tierOrder.indexOf(settings.cap);
|
|
5054
5113
|
const smartIndex = settings.tierOrder.indexOf('smart');
|
|
5055
|
-
const
|
|
5114
|
+
const codeIndex = settings.tierOrder.indexOf('code');
|
|
5115
|
+
let effectiveCapIndex = stuckDriven
|
|
5056
5116
|
? (smartIndex === -1 ? settings.tierOrder.length - 1 : smartIndex)
|
|
5057
5117
|
: (capIndex === -1 ? settings.tierOrder.length - 1 : capIndex);
|
|
5118
|
+
if (floorDriven && !repeatDriven && codeIndex !== -1) {
|
|
5119
|
+
effectiveCapIndex = Math.min(effectiveCapIndex, codeIndex);
|
|
5120
|
+
}
|
|
5058
5121
|
const escalatedIndex = Math.min(currentIndex + 1, effectiveCapIndex);
|
|
5122
|
+
if (floorDriven && !repeatDriven && escalatedIndex <= currentIndex) {
|
|
5123
|
+
return null;
|
|
5124
|
+
}
|
|
5059
5125
|
const escalatedTier = settings.tierOrder[escalatedIndex];
|
|
5060
5126
|
|
|
5061
5127
|
return {
|
|
5062
5128
|
escalatedTier,
|
|
5063
|
-
|
|
5064
|
-
|
|
5129
|
+
complexityEvidence: stuckDriven || repeatDriven,
|
|
5130
|
+
escalationReason: stuckDriven || floorDriven
|
|
5131
|
+
? `${stuckDriven ? 'stuck-risk' : 'risk-floor'}: level=${riskEscalation.level} sources=${(riskEscalation.sources ?? []).join(',')} file=${targetFile ?? 'null'}`
|
|
5065
5132
|
: `repeat-count=${repeatCount} threshold=${settings.debugLoopThreshold} file=${targetFile ?? 'null'}`,
|
|
5066
5133
|
};
|
|
5067
5134
|
}
|
|
5068
5135
|
|
|
5069
5136
|
/** Attaches escalatedTier/escalationReason to routeSummary, additive alongside modelTier. */
|
|
5070
|
-
export function applyEscalationToRouteSummary(routeSummary = null, { config = null, targetFile = null } = {}) {
|
|
5137
|
+
export function applyEscalationToRouteSummary(routeSummary = null, { config = null, targetFile = null, rootDir = null, sessionId = null } = {}) {
|
|
5071
5138
|
if (!routeSummary || typeof routeSummary !== 'object') {
|
|
5072
5139
|
return routeSummary;
|
|
5073
5140
|
}
|
|
@@ -5089,9 +5156,17 @@ export function applyEscalationToRouteSummary(routeSummary = null, { config = nu
|
|
|
5089
5156
|
}
|
|
5090
5157
|
// Recomputed here (not only in buildRouteSummary) so the lane always reflects the final
|
|
5091
5158
|
// tier after escalation, on every path that emits a route summary.
|
|
5159
|
+
const mainTier = resolveMainTier({ rootDir, config, sessionId: sessionId ?? null });
|
|
5092
5160
|
routeSummary.tierLane = buildTierLane({
|
|
5093
5161
|
modelTier: routeSummary.executionContract?.modelTier ?? null,
|
|
5094
5162
|
escalatedTier: escalation?.escalatedTier ?? null,
|
|
5163
|
+
mainTier: mainTier.tier,
|
|
5164
|
+
mainTierSource: mainTier.source,
|
|
5165
|
+
delegationPolicy: routeSummary.executionContract?.delegationPolicy ?? null,
|
|
5166
|
+
executionMode: routeSummary.executionMode ?? null,
|
|
5167
|
+
complexityHigh: escalation?.complexityEvidence === true,
|
|
5168
|
+
riskHigh: routeSummary.riskFloor?.floor === 'high-risk',
|
|
5169
|
+
playbookId: routeSummary.playbookId ?? null,
|
|
5095
5170
|
});
|
|
5096
5171
|
|
|
5097
5172
|
return routeSummary;
|
|
@@ -5156,25 +5231,6 @@ function appendRiskEscalationSegment(routeSummary = null) {
|
|
|
5156
5231
|
return routeSummary;
|
|
5157
5232
|
}
|
|
5158
5233
|
|
|
5159
|
-
// WS-E tier-role wiring: a contract tier only takes effect when the executing session hands
|
|
5160
|
-
// the work to an agent whose definition binds that tier — otherwise every session behaves as
|
|
5161
|
-
// if it only had one model. `code` is the DEFAULT implementation lane, so a code-tier contract
|
|
5162
|
-
// produces no instruction (inline execution on a code session is already correct, and silence
|
|
5163
|
-
// keeps the hook flow smooth). Labels are stable tier roles (lite/code/smart), never provider
|
|
5164
|
-
// or model identity. Vision never appears here — it is a capability lane, not a cost tier.
|
|
5165
|
-
function buildTierLane({ modelTier = null, escalatedTier = null } = {}) {
|
|
5166
|
-
const tier = escalatedTier ?? modelTier;
|
|
5167
|
-
if (!tier || tier === 'vision') {
|
|
5168
|
-
return null;
|
|
5169
|
-
}
|
|
5170
|
-
return {
|
|
5171
|
-
tier,
|
|
5172
|
-
defaultLane: tier === 'code',
|
|
5173
|
-
instruction: tier === 'code'
|
|
5174
|
-
? null
|
|
5175
|
-
: `Hand this task to an agent bound to the ${tier} tier instead of doing it inline, unless this session already runs on the ${tier} tier.`,
|
|
5176
|
-
};
|
|
5177
|
-
}
|
|
5178
5234
|
|
|
5179
5235
|
function advanceContinuationState(continuationState = null, previousContinuationState = null, thresholds = null) {
|
|
5180
5236
|
if (!continuationState || typeof continuationState !== 'object') {
|
|
@@ -6028,6 +6084,27 @@ function shouldIncludeRecentOutput({
|
|
|
6028
6084
|
return true;
|
|
6029
6085
|
}
|
|
6030
6086
|
|
|
6087
|
+
// Orchestration telemetry for the audit row: what the route REQUESTED (tier lane / effort) next to
|
|
6088
|
+
// what the host evidences. Effective values are never inferred from an alias — the only host
|
|
6089
|
+
// evidence available to a hook child is the CLAUDE_EFFORT env; the model stays UNKNOWN.
|
|
6090
|
+
// skill-router.sh builds the same object (audit row parity).
|
|
6091
|
+
export function buildOrchestrationAudit(routeSummary = null, env = process.env) {
|
|
6092
|
+
const lane = routeSummary?.tierLane;
|
|
6093
|
+
if (!lane || typeof lane !== 'object') {
|
|
6094
|
+
return null;
|
|
6095
|
+
}
|
|
6096
|
+
return {
|
|
6097
|
+
mainTier: lane.mainTier ?? null,
|
|
6098
|
+
mainTierSource: lane.mainTierSource ?? null,
|
|
6099
|
+
requestedTier: lane.tier ?? null,
|
|
6100
|
+
lane: lane.instruction ? (lane.mainTier === 'smart' ? 'dispatch-worker' : 'handoff') : 'inline',
|
|
6101
|
+
requestedEffort: lane.effort ?? null,
|
|
6102
|
+
effortBinding: lane.effortBinding ?? null,
|
|
6103
|
+
effectiveEffort: env?.CLAUDE_EFFORT || 'UNKNOWN',
|
|
6104
|
+
effectiveModel: 'UNKNOWN',
|
|
6105
|
+
};
|
|
6106
|
+
}
|
|
6107
|
+
|
|
6031
6108
|
function buildRouteAuditEntry({ route = null, state = null } = {}) {
|
|
6032
6109
|
const routingContext = route?.routingContext ?? state?.routingContext ?? {};
|
|
6033
6110
|
const routeSummary = route?.routeSummary ?? state?.routeSummary ?? {};
|
|
@@ -6086,6 +6163,7 @@ function buildRouteAuditEntry({ route = null, state = null } = {}) {
|
|
|
6086
6163
|
// BL-013: bounded history counters on the audit row — same struct as the
|
|
6087
6164
|
// route record, identical to the hook's audit emission.
|
|
6088
6165
|
historySignals: routeSummary?.historySignals ?? null,
|
|
6166
|
+
orchestration: buildOrchestrationAudit(routeSummary),
|
|
6089
6167
|
};
|
|
6090
6168
|
}
|
|
6091
6169
|
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Tier-lane dispatch: who performs the edit (worker hand-off vs inline) for a routed task, and the
|
|
2
|
+
// tier of the MAIN session. A leaf module (node builtins only) so route-task.mjs and the
|
|
3
|
+
// UserPromptSubmit hook share ONE implementation — the hook cannot import the large route-task CLI.
|
|
4
|
+
import * as fsSync from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
// WS-E tier-role wiring: a contract tier only takes effect when the executing session hands
|
|
8
|
+
// the work to an agent whose definition binds that tier — otherwise every session behaves as
|
|
9
|
+
// if it only had one model. Labels are stable tier roles (lite/code/smart), never provider
|
|
10
|
+
// or model identity. Vision never appears here — it is a capability lane, not a cost tier.
|
|
11
|
+
//
|
|
12
|
+
// `mainTier` is the tier the MAIN session actually runs on (resolveMainTier; null = unknown).
|
|
13
|
+
// - main is not smart (or unknown): legacy behavior — `code` is the silent default lane,
|
|
14
|
+
// lite/smart get a hand-off.
|
|
15
|
+
// - main is smart (UKit's default orchestrator): the smart lane is silent. A code lane is
|
|
16
|
+
// dispatched to its Tier 2 worker only where delegationPolicy admits it; `disallow` lanes
|
|
17
|
+
// (tiny-fix/local-fix) stay inline because the hand-off costs more than the work, and
|
|
18
|
+
// `disallow-by-default` (local-build) delegates only for multi-file / long edit loops.
|
|
19
|
+
export const TIER_WORKER_AGENT = { lite: 'ukit-small-task-maintainer', code: 'feature-implementer' };
|
|
20
|
+
|
|
21
|
+
// @decision-point orchestration.tier-lane-dispatch — deterministic choice of who performs the edit
|
|
22
|
+
// (worker hand-off vs inline) from tier, main tier, delegation policy and risk; regression:
|
|
23
|
+
// tests/orchestration/tierRiskIndependence.test.js, mainTierLane.test.js.
|
|
24
|
+
export function buildTierLane({ modelTier = null, escalatedTier = null, mainTier = null, mainTierSource = null, delegationPolicy = null, executionMode = null, complexityHigh = false, riskHigh = false, playbookId = null, subagentSession = false } = {}) {
|
|
25
|
+
const tier = escalatedTier ?? modelTier;
|
|
26
|
+
if (!tier || tier === 'vision') {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
// A host-marked subagent session IS the worker: it executes the brief it was handed. Handing the
|
|
30
|
+
// same brief onward (or requiring a delegation it cannot make) would nest orchestration — omp runs
|
|
31
|
+
// the router in child sessions too (live: child session routed with mainTier smart).
|
|
32
|
+
if (subagentSession) {
|
|
33
|
+
return { tier, mainTier: null, mainTierSource: 'subagent-session', defaultLane: true, executorTier: tier, instruction: null, subagentSession: true };
|
|
34
|
+
}
|
|
35
|
+
// Effort is independent of tier and of risk: Medium unless complexity evidence (verified failure /
|
|
36
|
+
// stuck) says High. The per-task effort can only be bound on a dispatch (Agent tool `effort` / omp
|
|
37
|
+
// task effort); the running session's own effort is never changed mid-task, so an inline lane
|
|
38
|
+
// records the High need as unbound.
|
|
39
|
+
const effort = complexityHigh ? 'high' : null;
|
|
40
|
+
const withEffort = (instruction) => (instruction && effort
|
|
41
|
+
? `${instruction} Complexity evidence is high: pass effort="high" on that Agent/task call.`
|
|
42
|
+
: instruction);
|
|
43
|
+
const effortFields = effort ? { effort, effortBinding: 'dispatch-param-only' } : {};
|
|
44
|
+
if (mainTier !== 'smart') {
|
|
45
|
+
return {
|
|
46
|
+
tier,
|
|
47
|
+
defaultLane: tier === 'code',
|
|
48
|
+
instruction: tier === 'code'
|
|
49
|
+
? null
|
|
50
|
+
: withEffort(`Hand this task to an agent bound to the ${tier} tier instead of doing it inline, unless this session already runs on the ${tier} tier.`),
|
|
51
|
+
...effortFields,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const policy = delegationPolicy ?? 'allow-qualified-sidecar';
|
|
55
|
+
const brief = 'Give the worker scope, dependencies, acceptance criteria and the verification to run; verify its diff yourself after it returns, then run the project\'s bare test runner yourself (e.g. `npm test`, no pipe/tail/grep/compound command) and read its exit status — that status is the verification evidence.';
|
|
56
|
+
// A high-risk target on a Tier 2 coding lane is still Tier 2 work: the inline-by-policy exemption
|
|
57
|
+
// (hand-off costs more than the work) does not apply, otherwise the Tier 3 orchestrator would be the
|
|
58
|
+
// one editing the dangerous file. Risk raises verification rigor, not the executor.
|
|
59
|
+
if (tier === 'code' && riskHigh && (policy === 'disallow' || policy === 'disallow-by-default')) {
|
|
60
|
+
const worker = executionMode === 'find-cause' ? 'bug-debugger' : TIER_WORKER_AGENT.code;
|
|
61
|
+
return {
|
|
62
|
+
tier, mainTier, mainTierSource, defaultLane: false, executorTier: 'code', delegationRequired: true, ...effortFields,
|
|
63
|
+
instruction: withEffort(`High-risk target: hand the implementation to a code-tier worker (${worker}) instead of editing inline; this session runs on the smart tier. ${brief}`),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
// A routed feature-implementation playbook is the contract-bearing / multi-surface build path, not a
|
|
67
|
+
// small local edit: the inline-by-policy exemption (hand-off costs more than the work) belongs to
|
|
68
|
+
// small-feature, so the Tier 3 orchestrator hands this implementation to the Tier 2 worker.
|
|
69
|
+
if (tier === 'code' && playbookId === 'feature-implementation' && policy === 'disallow-by-default') {
|
|
70
|
+
return {
|
|
71
|
+
tier, mainTier, mainTierSource, defaultLane: false, executorTier: 'code', delegationRequired: true, ...effortFields,
|
|
72
|
+
instruction: withEffort(`Feature-implementation playbook: hand the implementation to a code-tier worker (${TIER_WORKER_AGENT.code}) instead of editing inline; this session runs on the smart tier. ${brief}`),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// executorTier = the tier that actually performs the edit: the worker's when a hand-off is
|
|
76
|
+
// instructed, the main session's when the lane stays inline (an inline lane under a smart main is
|
|
77
|
+
// Tier 3 work), null when the hand-off is conditional on edit size.
|
|
78
|
+
const base = { tier, mainTier, mainTierSource, defaultLane: tier === 'smart', ...effortFields };
|
|
79
|
+
// Failure evidence escalated a coding lane to smart: the orchestrator takes the analysis, not the edit.
|
|
80
|
+
// Where delegation is admitted it still hands the implementation to the Tier 2 worker with its diagnosis.
|
|
81
|
+
if (tier === 'smart' && modelTier === 'code' && policy !== 'disallow' && policy !== 'disallow-by-default') {
|
|
82
|
+
const worker = executionMode === 'find-cause' ? 'bug-debugger' : TIER_WORKER_AGENT.code;
|
|
83
|
+
return {
|
|
84
|
+
...base,
|
|
85
|
+
defaultLane: false,
|
|
86
|
+
executorTier: 'code',
|
|
87
|
+
delegationRequired: true,
|
|
88
|
+
instruction: withEffort(`Repeated failure evidence escalated this task: do the root-cause analysis yourself, then hand the implementation to a code-tier worker (${worker}) with your diagnosis instead of editing inline. ${brief}`),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (tier === 'smart' || policy === 'disallow') {
|
|
92
|
+
return { ...base, executorTier: mainTier, instruction: null };
|
|
93
|
+
}
|
|
94
|
+
const worker = executionMode === 'find-cause' && tier === 'code' ? 'bug-debugger' : TIER_WORKER_AGENT[tier];
|
|
95
|
+
return {
|
|
96
|
+
...base,
|
|
97
|
+
executorTier: policy === 'disallow-by-default' ? null : tier,
|
|
98
|
+
...(policy === 'disallow-by-default' ? {} : { delegationRequired: true }),
|
|
99
|
+
instruction: withEffort(policy === 'disallow-by-default'
|
|
100
|
+
? `Work inline unless the change spans several files or needs a long edit loop; then hand it to a ${tier}-tier worker (${worker}). ${brief}`
|
|
101
|
+
: `Hand the implementation to a ${tier}-tier worker (${worker}) instead of doing it inline; this session runs on the smart tier. ${brief}`),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const MODEL_TIER_ALIASES = [
|
|
106
|
+
[/opus|unic-smart|^slow$|^smart$/, 'smart'],
|
|
107
|
+
[/sonnet|unic-code|^default$|^code$/, 'code'],
|
|
108
|
+
[/haiku|unic-lite|^smol$|^lite$/, 'lite'],
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
/** Maps a model alias/id/role to lite|code|smart (config modelTiers first, then name families). */
|
|
112
|
+
export function classifyModelTier(model, config = null) {
|
|
113
|
+
const value = typeof model === 'string' ? model.trim().toLowerCase().replace(/\[[^\]]*\]$/, '').replace(/:(off|minimal|low|medium|high|xhigh|max|auto)$/, '') : '';
|
|
114
|
+
if (!value) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const tiers = config?.orchestration?.modelTiers;
|
|
118
|
+
for (const name of ['lite', 'code', 'smart']) {
|
|
119
|
+
const entry = tiers?.[name];
|
|
120
|
+
if (entry && [entry.claudeModel, entry.genericModel, entry.ompRole].some((v) => typeof v === 'string' && v.toLowerCase() === value)) {
|
|
121
|
+
return name;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const bare = value.includes('/') ? value.slice(value.lastIndexOf('/') + 1) : value;
|
|
125
|
+
return MODEL_TIER_ALIASES.find(([re]) => re.test(bare))?.[1] ?? null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function readJsonFileSync(file) {
|
|
129
|
+
try {
|
|
130
|
+
return JSON.parse(fsSync.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Tier of the MAIN session, with provenance: {tier, source}. Host-selected model first
|
|
138
|
+
* (Claude Code: ANTHROPIC_MODEL > settings.local.json > settings.json `model`; OMP: the model the
|
|
139
|
+
* bridge recorded at session_start), then orchestration.orchestratorModel. Unknown → {tier:null}
|
|
140
|
+
* so buildTierLane keeps the legacy behavior. Never infers a model from anything else.
|
|
141
|
+
*/
|
|
142
|
+
export function resolveMainTier({ rootDir = null, config = null, env = process.env, sessionId = null } = {}) {
|
|
143
|
+
const candidates = [];
|
|
144
|
+
if (env?.UKIT_HARNESS === 'codex') {
|
|
145
|
+
return { tier: null, source: 'unknown' }; // codex cannot bind a model; never guess its main tier
|
|
146
|
+
}
|
|
147
|
+
if (env?.UKIT_HARNESS === 'omp') {
|
|
148
|
+
const recorded = rootDir ? readJsonFileSync(path.join(rootDir, '.ukit', 'storage', 'cache', 'session-main-model.json')) : null;
|
|
149
|
+
// STRICT ownership: the record is a single per-project slot describing ONE main session
|
|
150
|
+
// (the omp bridge stamps the writer's host session id). Without the caller's host session
|
|
151
|
+
// id — or with a record that is unstamped (a pre-provenance/legacy write) or owned by a
|
|
152
|
+
// DIFFERENT session — the OMP main tier is UNKNOWN: never read a foreign slot, and never
|
|
153
|
+
// fall through to the Claude orchestrator default on another session's data. The omp bridge
|
|
154
|
+
// refreshes the owner's record on session_start/session_switch/before_agent_start
|
|
155
|
+
// (observed-active-model-changed), so a matched owner sees current state, not a stale role.
|
|
156
|
+
const owned = recorded?.harness === 'omp'
|
|
157
|
+
&& typeof sessionId === 'string' && sessionId !== ''
|
|
158
|
+
&& typeof recorded.session === 'string' && recorded.session !== ''
|
|
159
|
+
&& recorded.session === sessionId;
|
|
160
|
+
// OMP result owns the answer end-to-end: never fall through to the Claude
|
|
161
|
+
// orchestrator default on top of (or instead of) this harness's own record.
|
|
162
|
+
return { tier: classifyModelTier(owned ? (recorded.role ?? recorded.model) : null, config), source: owned ? 'omp-session' : 'unknown' };
|
|
163
|
+
} else {
|
|
164
|
+
candidates.push(['env:ANTHROPIC_MODEL', env?.ANTHROPIC_MODEL]);
|
|
165
|
+
for (const file of ['settings.local.json', 'settings.json']) {
|
|
166
|
+
candidates.push([`.claude/${file}`, rootDir ? readJsonFileSync(path.join(rootDir, '.claude', file))?.model : null]);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
candidates.push(['orchestration.orchestratorModel', config?.orchestration?.orchestratorModel]);
|
|
170
|
+
for (const [source, model] of candidates) {
|
|
171
|
+
const tier = classifyModelTier(model, config);
|
|
172
|
+
if (tier) {
|
|
173
|
+
return { tier, source };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { tier: null, source: 'unknown' };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Attaches `tierLane` to a route summary from the final route fields. Shared by the route CLI and the
|
|
181
|
+
* UserPromptSubmit hook so both persist the same delegation requirement; the Stop-side playbook
|
|
182
|
+
* acceptance reads `tierLane.delegationRequired` from that persisted state.
|
|
183
|
+
*/
|
|
184
|
+
export function applyTierLaneToRouteSummary(routeSummary, { rootDir = null, config = null, env = process.env, complexityHigh = false, subagentSession = false, sessionId = null } = {}) {
|
|
185
|
+
if (!routeSummary || typeof routeSummary !== 'object') {
|
|
186
|
+
return routeSummary;
|
|
187
|
+
}
|
|
188
|
+
const mainTier = resolveMainTier({ rootDir, config, env, sessionId });
|
|
189
|
+
routeSummary.tierLane = buildTierLane({
|
|
190
|
+
modelTier: routeSummary.executionContract?.modelTier ?? routeSummary.execution?.modelTier ?? null,
|
|
191
|
+
escalatedTier: routeSummary.escalatedTier ?? null,
|
|
192
|
+
mainTier: mainTier.tier,
|
|
193
|
+
mainTierSource: mainTier.source,
|
|
194
|
+
delegationPolicy: routeSummary.executionContract?.delegationPolicy ?? null,
|
|
195
|
+
executionMode: routeSummary.executionMode ?? null,
|
|
196
|
+
complexityHigh,
|
|
197
|
+
riskHigh: routeSummary.riskFloor?.floor === 'high-risk',
|
|
198
|
+
playbookId: routeSummary.playbookId ?? null,
|
|
199
|
+
subagentSession: subagentSession === true,
|
|
200
|
+
});
|
|
201
|
+
return routeSummary;
|
|
202
|
+
}
|
|
@@ -82,7 +82,9 @@ export function bashOperationReceipt(rawCommand, response, transportSuccess, goa
|
|
|
82
82
|
const hasOutputField = typeof response === 'string' || typeof response?.stdout === 'string' || typeof response?.output === 'string';
|
|
83
83
|
let role = null; let proven = false; let target = null; let remoteSync = null;
|
|
84
84
|
for (const seg of real) {
|
|
85
|
-
|
|
85
|
+
// `git diff --check` is a real verification, not a dry-run mutation.
|
|
86
|
+
if (!seg || /^cd\s+\S+$/.test(seg) || (DRY_RUN.test(seg.replace(/'[^']*'|"[^"]*"/g, '""'))
|
|
87
|
+
&& !(domain === 'git-integrity' && /^git\s+diff\s+--check(?:\s|$)/.test(seg)))) continue;
|
|
86
88
|
if (domain === 'git-integrity') {
|
|
87
89
|
const lsRemote = seg.match(GIT_LS_REMOTE);
|
|
88
90
|
if (lsRemote) {
|