@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/manifests/documentation.yaml +22 -0
  3. package/manifests/platform.full.yaml +27 -0
  4. package/package.json +4 -1
  5. package/scripts/dev/worktree-preflight.mjs +20 -1
  6. package/src/core/diffPlan.js +38 -12
  7. package/src/core/executionContracts.js +67 -16
  8. package/src/core/metadata.js +4 -0
  9. package/src/core/runInstallPipeline.js +19 -2
  10. package/src/core/runtimeConfig.js +1 -1
  11. package/src/index/playbookRegistry.js +22 -3
  12. package/src/index/taskRouting.js +2 -2
  13. package/template_project/.claude/agents/bug-debugger.md +1 -0
  14. package/template_project/.claude/agents/code-reviewer.md +1 -0
  15. package/template_project/.claude/agents/feature-implementer.md +1 -0
  16. package/template_project/.claude/agents/handoff-planner.md +1 -0
  17. package/template_project/.claude/agents/ukit-small-task-maintainer.md +1 -0
  18. package/template_project/.claude/hooks/completion-gate.sh +1 -1
  19. package/template_project/.claude/hooks/delegation-gate.mjs +343 -0
  20. package/template_project/.claude/hooks/skill-router.sh +147 -6
  21. package/template_project/.claude/settings.json +8 -8
  22. package/template_project/.claude/ukit/index/playbook-registry.mjs +22 -3
  23. package/template_project/.claude/ukit/index/route-resolver.mjs +67 -15
  24. package/template_project/.claude/ukit/index/route-task.mjs +115 -37
  25. package/template_project/.claude/ukit/index/tier-lane.mjs +202 -0
  26. package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +3 -1
  27. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +306 -20
  28. package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +8 -0
  29. package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
  30. package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -0
  31. package/template_project/.claude/ukit/runtime/playbook-execution.mjs +704 -0
  32. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +79 -3
  33. package/template_project/.omp/agents/bug-debugger.md +1 -0
  34. package/template_project/.omp/agents/code-reviewer.md +1 -0
  35. package/template_project/.omp/agents/feature-implementer.md +1 -0
  36. package/template_project/.omp/agents/handoff-planner.md +1 -0
  37. package/template_project/.omp/agents/ukit-small-task-maintainer.md +1 -0
  38. package/template_project/.omp/hooks/pre/ukit-bridge.js +254 -7
  39. package/template_project/ukit/storage/config.json +1 -1
  40. package/scripts/bench/data-foundation.mjs +0 -929
  41. package/scripts/bench/decision-runtime-baseline.mjs +0 -427
  42. package/scripts/bench/decision-runtime-variant.mjs +0 -626
  43. package/scripts/bench/goldTasks.json +0 -38
  44. package/scripts/bench/memory-ablation.mjs +0 -495
  45. package/scripts/bench/memory-baseline.mjs +0 -596
  46. package/scripts/bench/memory-bench.mjs +0 -661
  47. package/scripts/bench/memory-canary.mjs +0 -331
  48. package/scripts/bench/memory-corpus.mjs +0 -354
  49. package/scripts/bench/memory-gate.mjs +0 -389
  50. package/scripts/bench/memory-metrics.mjs +0 -179
  51. package/scripts/bench/outline-savings.mjs +0 -107
  52. package/scripts/bench/parallel-agents.mjs +0 -224
  53. package/scripts/bench/recorder-overhead.mjs +0 -204
  54. package/scripts/bench/runGold.mjs +0 -238
  55. package/scripts/bench/sqlite-spike.mjs +0 -451
  56. package/scripts/bench/subagent-orchestrator-corpus.mjs +0 -275
  57. package/scripts/bench/subagent-orchestrator-eval.mjs +0 -565
  58. package/scripts/bench/v3-ceremony.mjs +0 -220
  59. package/scripts/perf/audit-perf.mjs +0 -1195
  60. package/scripts/perf/diff-perf-findings.mjs +0 -136
  61. package/scripts/perf/perf-findings.json +0 -474
  62. 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 = readFlagValue(args, '--task-instance-id')
1173
- || (((['skill-router', 'skill-router-provisional'].includes(previousState.source)
1174
- && previousState.routingContext?.lastExplicitUserPromptText === promptText) || continuingTask)
1175
- && previousState.taskInstanceId
1176
- ? previousState.taskInstanceId : `task-${crypto.randomBytes(16).toString('hex')}`);
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: when `riskEscalation.level === 'high'` the repeat-count threshold is treated as
5030
- * satisfied (immediate one-rung escalation) and the effective cap rises to 'smart', overriding
5031
- * the configured cap; the reason is prefixed `risk-floor:` so consumers can tell risk-driven
5032
- * escalation from repeat-count 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 riskDriven = riskEscalation?.level === 'high';
5046
- if (!settings.enabled || (!riskDriven && (!Number.isFinite(repeatCount) || repeatCount < settings.debugLoopThreshold))) {
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 effectiveCapIndex = riskDriven
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
- escalationReason: riskDriven
5064
- ? `risk-floor: level=${riskEscalation.level} sources=${(riskEscalation.sources ?? []).join(',')} file=${targetFile ?? 'null'}`
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
- if (!seg || /^cd\s+\S+$/.test(seg) || DRY_RUN.test(seg.replace(/'[^']*'|"[^"]*"/g, '""'))) continue;
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) {