@ngockhoale/ukit 3.4.46 → 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 (66) hide show
  1. package/CHANGELOG.md +13 -5
  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/capture-omp-stall.mjs +246 -0
  6. package/scripts/dev/worktree-preflight.mjs +20 -1
  7. package/scripts/docs/render-instructions.mjs +29 -5
  8. package/src/core/agentRuntime/supervisor.js +15 -1
  9. package/src/core/diffPlan.js +38 -12
  10. package/src/core/executionContracts.js +67 -16
  11. package/src/core/metadata.js +4 -0
  12. package/src/core/runInstallPipeline.js +19 -2
  13. package/src/core/runtimeConfig.js +1 -1
  14. package/src/diagnostics/ompStallSnapshot.js +199 -0
  15. package/src/index/playbookRegistry.js +22 -3
  16. package/src/index/taskRouting.js +2 -2
  17. package/template_project/.claude/agents/bug-debugger.md +1 -0
  18. package/template_project/.claude/agents/code-reviewer.md +1 -0
  19. package/template_project/.claude/agents/feature-implementer.md +1 -0
  20. package/template_project/.claude/agents/handoff-planner.md +1 -0
  21. package/template_project/.claude/agents/ukit-small-task-maintainer.md +1 -0
  22. package/template_project/.claude/hooks/completion-gate.sh +1 -1
  23. package/template_project/.claude/hooks/delegation-gate.mjs +343 -0
  24. package/template_project/.claude/hooks/skill-router.sh +147 -6
  25. package/template_project/.claude/settings.json +8 -8
  26. package/template_project/.claude/ukit/index/playbook-registry.mjs +22 -3
  27. package/template_project/.claude/ukit/index/route-resolver.mjs +67 -15
  28. package/template_project/.claude/ukit/index/route-task.mjs +115 -37
  29. package/template_project/.claude/ukit/index/tier-lane.mjs +202 -0
  30. package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +3 -1
  31. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +306 -20
  32. package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +8 -0
  33. package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
  34. package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -0
  35. package/template_project/.claude/ukit/runtime/playbook-execution.mjs +704 -0
  36. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +80 -3
  37. package/template_project/.omp/agents/bug-debugger.md +1 -0
  38. package/template_project/.omp/agents/code-reviewer.md +1 -0
  39. package/template_project/.omp/agents/feature-implementer.md +1 -0
  40. package/template_project/.omp/agents/handoff-planner.md +1 -0
  41. package/template_project/.omp/agents/ukit-small-task-maintainer.md +1 -0
  42. package/template_project/.omp/hooks/pre/ukit-bridge.js +254 -7
  43. package/template_project/ukit/storage/config.json +1 -1
  44. package/scripts/bench/data-foundation.mjs +0 -929
  45. package/scripts/bench/decision-runtime-baseline.mjs +0 -427
  46. package/scripts/bench/decision-runtime-variant.mjs +0 -626
  47. package/scripts/bench/goldTasks.json +0 -38
  48. package/scripts/bench/memory-ablation.mjs +0 -495
  49. package/scripts/bench/memory-baseline.mjs +0 -596
  50. package/scripts/bench/memory-bench.mjs +0 -661
  51. package/scripts/bench/memory-canary.mjs +0 -331
  52. package/scripts/bench/memory-corpus.mjs +0 -354
  53. package/scripts/bench/memory-gate.mjs +0 -389
  54. package/scripts/bench/memory-metrics.mjs +0 -179
  55. package/scripts/bench/outline-savings.mjs +0 -107
  56. package/scripts/bench/parallel-agents.mjs +0 -224
  57. package/scripts/bench/recorder-overhead.mjs +0 -204
  58. package/scripts/bench/runGold.mjs +0 -238
  59. package/scripts/bench/sqlite-spike.mjs +0 -451
  60. package/scripts/bench/subagent-orchestrator-corpus.mjs +0 -275
  61. package/scripts/bench/subagent-orchestrator-eval.mjs +0 -565
  62. package/scripts/bench/v3-ceremony.mjs +0 -220
  63. package/scripts/perf/audit-perf.mjs +0 -1195
  64. package/scripts/perf/diff-perf-findings.mjs +0 -136
  65. package/scripts/perf/perf-findings.json +0 -474
  66. package/scripts/perf/perf-measure.md +0 -271
@@ -1,9 +1,9 @@
1
1
  {
2
- "model": "sonnet",
2
+ "model": "opus",
3
3
  "fastMode": false,
4
4
  "fastModePerSessionOptIn": false,
5
5
  "alwaysThinkingEnabled": true,
6
- "effortLevel": "high",
6
+ "effortLevel": "medium",
7
7
  "includeGitInstructions": false,
8
8
  "promptSuggestionEnabled": false,
9
9
  "showThinkingSummaries": false,
@@ -74,8 +74,8 @@
74
74
  "hooks": [
75
75
  {
76
76
  "type": "command",
77
- "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/protect-files.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/stale-spec-guard.sh\":11 \"$CLAUDE_PROJECT_DIR/.claude/hooks/pre-edit-backup.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\":20 \"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-model-guard.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/context-hardcap-gate.sh\":8",
78
- "timeout": 73
77
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/protect-files.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/stale-spec-guard.sh\":11 \"$CLAUDE_PROJECT_DIR/.claude/hooks/pre-edit-backup.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\":20 \"$CLAUDE_PROJECT_DIR/.claude/hooks/delegation-gate.mjs\":10 \"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-model-guard.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/context-hardcap-gate.sh\":8",
78
+ "timeout": 83
79
79
  }
80
80
  ]
81
81
  },
@@ -84,8 +84,8 @@
84
84
  "hooks": [
85
85
  {
86
86
  "type": "command",
87
- "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-model-guard.sh\":8",
88
- "timeout": 18
87
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/delegation-gate.mjs\":10 \"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-model-guard.sh\":8",
88
+ "timeout": 28
89
89
  }
90
90
  ]
91
91
  },
@@ -94,8 +94,8 @@
94
94
  "hooks": [
95
95
  {
96
96
  "type": "command",
97
- "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/auto-allow-bash.sh\":15 \"$CLAUDE_PROJECT_DIR/.claude/hooks/block-dangerous.mjs\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/sensitive-data-guard.mjs\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-model-guard.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/context-hardcap-gate.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/verification-guard.sh\":8",
98
- "timeout": 65
97
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/auto-allow-bash.sh\":15 \"$CLAUDE_PROJECT_DIR/.claude/hooks/block-dangerous.mjs\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/sensitive-data-guard.mjs\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/delegation-gate.mjs\":10 \"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-model-guard.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/context-hardcap-gate.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/verification-guard.sh\":8",
98
+ "timeout": 75
99
99
  }
100
100
  ]
101
101
  }
@@ -113,6 +113,20 @@ function hasSqlIntent(text) {
113
113
  // The SQL ask touches shape/data rather than a read-only query — routes the
114
114
  // migration row instead of a feature build.
115
115
  const SQL_SCHEMA_RE = /(?<![A-Za-z0-9_])(?:ddl|alter|drop|(?:create|alter|drop|truncate)\s+table|(?:add|drop|rename|change|modify|update|thêm|xóa|xoá|sửa|sua|đổi|doi)\s+(?:a\s+|the\s+|new\s+|một\s+)*(?:column|cột|cot)(?!\s+of\b)|kiểu\s+dữ\s+liệu|kieu\s+du\s+lieu|data\s?type|not\s+null|constraint|migration|migrate)(?![A-Za-z0-9_])/i;
116
+ // Explicit delegation order: the user themself asks for a worker/subagent to implement
117
+ // ("Delegate the implementation to a worker agent", "have a subagent implement it"). That is an
118
+ // opt-in to the orchestrated path (Tier 3 orchestrates, Tier 2 implements, integration verified by
119
+ // the orchestrator), so it lifts the small-feature fast path into feature-implementation. Live omp
120
+ // run: this exact order stayed on small-feature (explicit-local-target fast path, subagents
121
+ // suppressed) and the Tier 3 main implemented nothing of the delegation. Bare mentions of the
122
+ // words ("worker thread", "the delegate pattern") do not match; negated orders ("don't delegate")
123
+ // veto.
124
+ const DELEGATION_ORDER_RE = /\bdelegat(?:e|ing)\b[^.\n]{0,80}?\bto\s+(?:an?\s+|the\s+|your\s+)?(?:[\w-]+\s+){0,2}?(?:worker|sub-?agents?|agents?|implementer)\b|\b(?:use|spawn|dispatch|launch|assign|have|let)\s+(?:an?\s+|the\s+|your\s+)?(?:[\w-]+\s+)?(?:worker|sub-?agents?|implementer)(?:\s+agent)?\s+(?:to|for)\b|(?<![A-Za-z0-9_])giao\s+(?:việc\s+)?cho\s+(?:worker|sub-?agent|agent)/i;
125
+ const DELEGATION_NEGATED_RE = /\b(?:don['’]?t|do\s+not|never|without|avoid|no\s+need\s+to)\s+(?:[\w-]+\s+){0,2}?(?:delegat\w*|(?:use|spawn|dispatch)\s+(?:an?\s+|any\s+)?(?:worker|sub-?agents?))|\bno\s+(?:worker|sub-?agents?|delegation)\b|\b(?:inline|yourself)\s+(?:only|not)\b/i;
126
+ export function hasDelegationOrder(text) {
127
+ const t = String(text ?? '');
128
+ return DELEGATION_ORDER_RE.test(t) && !DELEGATION_NEGATED_RE.test(t);
129
+ }
116
130
  // Reuse-first ask: "use the existing/core function", "dùng hàm có sẵn",
117
131
  // "tái sử dụng" — search for the function before writing a new one.
118
132
  const REUSE_FIRST_RE = /(?:\buse\s+(?:the\s+)?(?:existing|core|shared|same|current)\b)|(?:\buse\s+[\w.]+\s+from\s+(?:the\s+)?(?:core|shared|utils?|common|lib)\b)|\b(?:existing|core|shared)\s+(?:function|helper|util(?:ity|ities)?|service|component)\b|\breuse\b|(?<![A-Za-z0-9_])(?:tái\s+sử\s+dụng|tai\s+su\s+dung)|(?<![A-Za-z0-9_])(?:dùng|dung|dựa|dua)\s+(?:vào\s+|vao\s+)?(?:hàm|ham)(?:\s+(?:có\s+sẵn|co\s+san|sẵn\s+có|san\s+co|đã\s+có|da\s+co|cũ|cu))?(?![A-Za-z0-9_])/i;
@@ -173,7 +187,7 @@ export const PLAYBOOK_ROUTING_TABLE = Object.freeze([
173
187
  // contract signals) escalates to feature-implementation. Reference-driven,
174
188
  // clone, reuse-first and SQL-schema asks take the same escape: they name a
175
189
  // source of truth or a data contract the small path must not gloss over.
176
- discriminator: (s) => !s.escalated && !s.contractSignal && !s.multiSurface && !s.sqlSchemaChange && !s.referenceTarget && !s.cloneIntent && !s.reuseFirst && s.changeVerbOrCreate,
190
+ discriminator: (s) => !s.escalated && !s.contractSignal && !s.multiSurface && !s.sqlSchemaChange && !s.referenceTarget && !s.cloneIntent && !s.reuseFirst && !s.delegationOrdered && s.changeVerbOrCreate,
177
191
  fastPath: 'small-feature',
178
192
  },
179
193
  {
@@ -194,7 +208,7 @@ export const PLAYBOOK_ROUTING_TABLE = Object.freeze([
194
208
  // migration row instead of capturing the build lane.
195
209
  discriminator: (s) => (
196
210
  (s.contractSignal || s.multiSurface || (s.escalated && !s.sqlSchemaChange) || s.wiredShipAsk)
197
- || ((s.referenceTarget || s.cloneIntent || s.reuseFirst) && s.defectIdx === -1)
211
+ || ((s.referenceTarget || s.cloneIntent || s.reuseFirst || s.delegationOrdered) && s.defectIdx === -1)
198
212
  ) && !s.disposable && !s.releaseAsk,
199
213
  },
200
214
  {
@@ -290,7 +304,9 @@ export const PLAYBOOK_ROUTING_TABLE = Object.freeze([
290
304
  lanes: ['local-fix', 'review-release'],
291
305
  triggers: [/\bverify\b|\bcheck\b|\bconfirm\b|\bprove\b|\bvalidate\b|\bassert\b/i],
292
306
  // the ask IS a check — a check verb leads, or no defect verb exists at all.
293
- discriminator: (s) => s.checkFirst || s.defectIdx === -1,
307
+ // A build/create verb that leads the ask makes the check its closing step ("implement X … then run the
308
+ // tests and confirm"), not the ask itself — the build row owns it instead of tying into ambiguity.
309
+ discriminator: (s) => (s.checkFirst || s.defectIdx === -1) && !s.buildLeadsCheck,
294
310
  },
295
311
  {
296
312
  workGroup: 'skill-evaluation',
@@ -408,6 +424,7 @@ async function buildRouteSignals({
408
424
  const explanationAsk = explainIdx !== -1 && (firstVerbIdx === -1 || explainIdx < firstVerbIdx);
409
425
  const changeRequested = !consultLead && firstVerbIdx !== -1 && (explainIdx === -1 || firstVerbIdx < explainIdx);
410
426
  const checkFirst = checkIdx !== -1 && (defectIdx === -1 || checkIdx < defectIdx);
427
+ const buildLeadsCheck = changeRequested && checkIdx !== -1 && firstVerbIdx !== -1 && firstVerbIdx < checkIdx;
411
428
  // C85-019: a QUESTION about a defect is not a defect order. The bug-fix floor
412
429
  // demands repro+root-cause receipts — demanding them on a "did it get fixed
413
430
  // yet?" turn forces edits nobody asked for (user-reported lock). Question
@@ -439,12 +456,14 @@ async function buildRouteSignals({
439
456
  changeRequested,
440
457
  questionAsk,
441
458
  checkFirst,
459
+ buildLeadsCheck,
442
460
  changeVerbOrCreate: changeIdx !== -1 || createIdx !== -1,
443
461
  referenceTarget,
444
462
  cloneIntent,
445
463
  sqlIntent,
446
464
  sqlSchemaChange: sqlIntent && SQL_SCHEMA_RE.test(signalText),
447
465
  reuseFirst,
466
+ delegationOrdered: hasDelegationOrder(signalText) && changeRequested,
448
467
  contractSignal: CONTRACT_SIGNAL_RE.test(signalText) || /\boauth\b/i.test(signalText),
449
468
  multiSurface: MULTI_SURFACE_RE.test(signalText),
450
469
  escalated: Array.isArray(escalationTriggers) && escalationTriggers.length > 0,
@@ -249,36 +249,39 @@ export function buildExecutionContract(executionMode = null) {
249
249
  // base row; resolveModelTier adds the (riskFloor, hostCapabilities) columns.
250
250
  // Deterministic — no registry, no leases, no outbound capability calls.
251
251
  // Literal mirror of src/core/executionContracts.js resolveModelTier.
252
- const MODEL_TIER_ORDER = ['lite', 'code', 'smart'];
252
+ // Tier, effort and risk are independent decisions. Effort is Medium for every tier (Low for chores);
253
+ // High needs complexity evidence (`complexityEvidence` below), never risk or tier alone.
253
254
  const MODEL_TIER_EFFORT = {
254
255
  lite: 'low',
255
256
  code: 'medium',
256
- smart: 'high',
257
+ smart: 'medium',
257
258
  };
258
259
 
259
260
  /**
260
- * Resolves {tier, effort, advisoryOnly} for a route. A 'high-risk' floor escalates
261
- * the tier one band (lite→code→smart, capped at smart) and forces effort 'high'.
262
- * Unknown mode → {tier:null, effort:null, advisoryOnly:true}. Missing/unknown
263
- * riskFloor → 'none'. Missing hostCapabilities → advisoryOnly:true (the route text
264
- * is all the host gets when it cannot bind model and effort).
261
+ * Resolves {tier, effort, advisoryOnly} for a route. A 'high-risk' floor lifts a Tier 1 chore to the
262
+ * Tier 2 executor (lite→code) and goes no further: smart is the orchestrator / acceptance tier and is
263
+ * reached only through its own contract (review-release) or failure evidence, never through risk, so
264
+ * risk cannot turn the orchestrator into the implementation worker. Risk raises verification rigor
265
+ * (the route's riskFloor / rigor), not the tier ceiling or the effort. Effort is 'high' only with
266
+ * `complexityEvidence`. Unknown mode → {tier:null, effort:null, advisoryOnly:true}. Missing/unknown
267
+ * riskFloor → 'none'. Missing hostCapabilities → advisoryOnly:true (the route text is all the host
268
+ * gets when it cannot bind model and effort).
265
269
  */
266
270
  export function resolveModelTier({
267
271
  executionMode = null,
268
272
  riskFloor = null,
269
273
  hostCapabilities = null,
274
+ complexityEvidence = false,
270
275
  } = {}) {
271
276
  const baseTier = buildExecutionContract(executionMode)?.modelTier ?? null;
272
277
  if (!baseTier) {
273
278
  return { tier: null, effort: null, advisoryOnly: true };
274
279
  }
275
280
  const highRisk = riskFloor?.floor === 'high-risk';
276
- const tier = highRisk
277
- ? MODEL_TIER_ORDER[Math.min(MODEL_TIER_ORDER.indexOf(baseTier) + 1, MODEL_TIER_ORDER.length - 1)]
278
- : baseTier;
281
+ const tier = highRisk && baseTier === 'lite' ? 'code' : baseTier;
279
282
  return {
280
283
  tier,
281
- effort: highRisk ? 'high' : MODEL_TIER_EFFORT[tier],
284
+ effort: complexityEvidence === true ? 'high' : MODEL_TIER_EFFORT[tier],
282
285
  advisoryOnly: !(hostCapabilities?.canBindModel === true && hostCapabilities?.canBindEffort === true),
283
286
  };
284
287
  }
@@ -1100,12 +1103,55 @@ function neutralizeNonOrders(text) {
1100
1103
  .replace(/"[^"\n]*\s[^"\n]*"|`[^`\n]*\s[^`\n]*`|\u201c[^\u201d\n]*\u201d/g, ' ')
1101
1104
  .replace(/\b(?:neu\s+(?:phai|can|thieu|khong du|bang chung chua du)|if\s+(?:needed|necessary|required|evidence is insufficient))\b[^.;\n]*/g, ' ')
1102
1105
  .replace(/[^.;\n]*\b(?:nao|gi|sao|the nao|bao nhieu)\b[^.;\n]*\?/g, ' ')
1103
- .replace(/\bchinh\s+xac\b|\b(?:phuong an|cach|de xuat|ban)\s+(?:sua|fix)\b/g, ' ')
1106
+ .replace(/\bchinh\s+(?:xac|sach)\b|\b(?:phuong an|cach|de xuat|ban)\s+(?:sua|fix)\b|\b(?:diem|cho|muc|phan)\s+(?:phai|can|nen)\s+(?:sua|fix)\b/g, ' ')
1104
1107
  .replace(/\bwrite\s*\/\s*verification\b|\bverification\s*\/\s*write\b|\bwrite\s+(?:or|and)\s+verification\b/g, ' ')
1105
1108
  .replace(/\b(?:state|status)\s+changes?\b/g, ' ')
1106
1109
  .replace(/\b(?:proposed|suggested|recommended|minimal|each|the|an?|any|that|this|no|which|exact|smallest|possible)\s+fix(?:es)?\b/g, ' ');
1107
1110
  }
1108
1111
 
1112
+ // Explicit scope declaration (intent ownership): when the request itself declares it is read-only
1113
+ // ("READ-ONLY audit", "chi doc", "make no source changes") and carries no explicit order to change
1114
+ // anything, verbs and operation names inside it are descriptions (a "git stash" that already ran, "a
1115
+ // worklog edit", "confirmed fixes"), not orders - so they must not demand write or operation proof.
1116
+ // An explicit order (imperative-initial or modal request) keeps the request MIXED/IMPLEMENTATION:
1117
+ // the declaration never overrides a real instruction, and fenced/quoted/blockquoted text is never one.
1118
+ // A bare verb with no object is the tail of a negated list ("no commit, release, publish"), not an order.
1119
+ const READ_ONLY_DECLARATION = /\bread[\s-]*only\s+(?:audit|investigation|review|analysis|inspection|task|request|session|pass|check|diagnos\w+|verification|forensics?)\b|(?:^|\n)[\s*#>_-]*read[\s-]*only\s*[.:!—-]|\b(?:audit|investigation|review|analysis)[\s-]*only\b|[(,]\s*read[\s-]*only\s*[).]|\bchi\s+(?:doc|audit|kiem tra|phan tich|dieu tra)\b|\b(?:make|with)\s+no\s+(?:source\s+|code\s+|file\s+)?(?:changes|edits|modifications)\b/;
1120
+ const ORDER_VERBS = 'implement|fix|apply|refactor|modify|edit|write|update|add|create|build|install|deploy|redeploy|restart|push|commit|rename|remove|delete|replace|change|sua|viet|chinh|them|tao|xoa|enable|disable|set|toggle|configure|migrate|upgrade|publish|release|trien khai|khoi dong lai|(?:run|execute)\\s+(?:git\\s+(?:push|clean|commit|reset|rebase|merge|tag|stash|checkout|restore|cherry-pick)|(?:npm|yarn)\\s+publish)|git\\s+(?:push|clean|commit|reset|rebase|merge|tag|stash|checkout|restore|cherry-pick)';
1121
+ const IMPERATIVE_ORDER = new RegExp(`^(?:[-*+\\u2022>\\d.)\\s]*)(?:(?:please|hay|vui long|now|also|first|then)\\s+)*(?:${ORDER_VERBS})\\b\\s+\\S`);
1122
+ const MODAL_ORDER = new RegExp(`\\b(?:please|hay|vui long|need to|needs to|must|should|want(?:\\s+you)? to|toi muon|can phai|yeu cau)\\s+(?:[\\w-]+\\s+){0,2}(?:${ORDER_VERBS})\\b`);
1123
+ function hasExplicitOrder(clauses) {
1124
+ return clauses.some((clause) => IMPERATIVE_ORDER.test(clause) || MODAL_ORDER.test(clause));
1125
+ }
1126
+ function declaredReadOnly(promptText, releasePolicy) {
1127
+ if (releasePolicy) return false;
1128
+ const plain = String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd').toLowerCase()
1129
+ .replace(/```[\s\S]*?```/g, ' ').replace(/^[ \t]*>.*$/gm, ' ');
1130
+ if (!READ_ONLY_DECLARATION.test(plain)) return false;
1131
+ const text = neutralizeNonOrders(plain);
1132
+ const clauses = text.split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/)
1133
+ .map((clause) => clause.split(/\bwithout\b/)[0])
1134
+ .filter((clause) => !/\b(?:do not|don't|never|khong|dung)\b/.test(clause));
1135
+ return /\b(?:audit|inspect|review|analyze|analyse|explain|read|investigate|investigation|forensic|root cause|kiem tra|phan tich|dieu tra|doc log|tim hieu)\b/.test(text)
1136
+ && !hasExplicitOrder(clauses);
1137
+ }
1138
+
1139
+ // Verification-only request. Root cause of a false `write-evidence` block on a "verify / re-run /
1140
+ // confirm" prompt: the vocabulary scans counted nouns and compounds ("coordinator fix", "re-run",
1141
+ // "template/install", "source change") as mutation orders. Order is structural: a request whose
1142
+ // command-position verbs are verification verbs, with no explicit order (hasExplicitOrder) and no
1143
+ // change-leading clause, orders no change - it owes verification evidence, not a write.
1144
+ const VERIFY_LEAD = /^(?:[-*+•>\d.)\s]*)(?:(?:please|hay|vui long|now|also|first|then)\s+)*(?:verify|re-?run|confirm|validate|double-?check)\b/;
1145
+ const CHANGE_LEAD = /^(?:[-*+•>\d.)\s]*)(?:(?:please|hay|vui long|now|also|first|then)\s+)*(?:make|rebuild|redo|rewrite|restore|revert|correct|clean|patch|resolve|ensure|run(?!(?=[^.]*\b(?:tests?|suite|gate|checks?|lint|typecheck|pilot|smoke|e2e|verif\w*|vitest|jest|audit)\b)))\b/;
1146
+ export function isVerificationOnlyRequest(promptText) {
1147
+ const plain = String(promptText ?? '').normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[đĐ]/g, 'd').toLowerCase()
1148
+ .replace(/```[\s\S]*?```/g, ' ').replace(/^[ \t]*>.*$/gm, ' ');
1149
+ const clauses = neutralizeNonOrders(plain).split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/)
1150
+ .map((clause) => clause.split(/\bwithout\b/)[0]);
1151
+ return clauses.some((clause) => VERIFY_LEAD.test(clause)) && !hasExplicitOrder(clauses)
1152
+ && !clauses.some((clause) => CHANGE_LEAD.test(clause));
1153
+ }
1154
+
1109
1155
  export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {}) {
1110
1156
  // A named preflight and both explicit branches are required; a failed arbitrary test is not a release policy.
1111
1157
  const releaseText = String(promptText).normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[đĐ]/g, 'd').toLowerCase();
@@ -1117,6 +1163,7 @@ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {})
1117
1163
  const clauses = text.split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/);
1118
1164
  const actionable = clauses.map((clause) => clause.split(/\bwithout\b/)[0]).filter((clause) => !/\b(?:do not|don't|never|khong|dung)\b/.test(clause)
1119
1165
  && !/^\s*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause));
1166
+ if (declaredReadOnly(promptText, releasePolicy)) return { taskKind: 'READ_ONLY' };
1120
1167
  const sourceOrder = actionable.some((clause) => /\b(?:implement|fix(?:es)?|apply|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
1121
1168
  && /\b(?:source|code|logic|scheduler|implementation|ma nguon)\b|\.[cm]?[jt]sx?\b/.test(clause));
1122
1169
  const codeChangeVerb = actionable.some((clause) => /\b(?:implement|fix(?:es)?|apply|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause));
@@ -1134,10 +1181,15 @@ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {})
1134
1181
  // runtime are separate domains with separate proof; a prompt naming both stays a coding
1135
1182
  // task rather than being half-verified. A code-change verb anywhere keeps write evidence.
1136
1183
  const gitOperation = actionable.some((clause) => /\bgit\s+(?:push|clean|commit|reset|rebase|merge|tag|stash|checkout|restore|cherry-pick)\b|\bpush\s+(?:to\s+)?origin\b/.test(clause));
1137
- const runtimeOperation = actionable.some((clause) => /\b(?:restart|redeploy|deploy|khoi dong lai|trien khai)\b/.test(clause)
1184
+ // 'trien khai' is Vietnamese for both "deploy" and "implement": it orders a runtime operation only
1185
+ // when it names a runtime target; bare it describes work ("Tier 3 truc tiep trien khai thay Tier 2").
1186
+ // "restart"/"deploy" after a noun modifier ("process restart", "after redeploy") is a scenario, not an order.
1187
+ const runtimeOperation = actionable.some((clause) => /(?<!\b(?:process|crash|hard|soft|graceful|after|upon|during|across|following)\s)\b(?:restart|redeploy|deploy|khoi dong lai|trien khai\s+(?:len\s+)?(?:service|server|production|prod|staging|dich vu|may chu|moi truong))\b/.test(clause)
1138
1188
  && !/\.[cm]?[jt]sx?\b/.test(clause) && !/\bgit\b/.test(clause));
1139
1189
  const nativeDomain = cronOperation ? 'scheduled-job' : gitOperation !== runtimeOperation ? (gitOperation ? 'git-integrity' : 'service-runtime') : null;
1140
1190
  const operation = Boolean(nativeDomain);
1191
+ // A verification-only request orders no change; a concrete operation order (cancel/restart/push) stays operational.
1192
+ if (!releasePolicy && !operation && isVerificationOnlyRequest(promptText)) return { taskKind: 'READ_ONLY', verificationOnly: true };
1141
1193
  // A config MENTION is not a config change: the same clause needs a mutation verb, and a
1142
1194
  // request that also changes source/code is a coding task (config is part of its tests/fixtures).
1143
1195
  const config = !sourceOrder && actionable.some((clause) => /\b(?:configuration|config|toggle|cau hinh)\b/.test(clause)
@@ -1148,7 +1200,7 @@ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {})
1148
1200
  const mutationWord = /\b(?:implement|fix|refactor|modify|edit|write|update|add|create|build|make|install|deploy|remove|delete|rename|replace|change|sua|viet|chinh|them|tao|xoa|doi|cai|cap nhat)\b/.test(text);
1149
1201
  const statusReport = !mutationWord && !operation && !config
1150
1202
  && /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua|da len chua)\b/.test(text);
1151
- const readOnly = (/\b(?:audit|inspect|review|analyze|analyse|explain|read|investigate|debug|forensic|root cause|kiem tra|phan tich|dieu tra|doc log|tim hieu)\b/.test(text) || statusReport)
1203
+ const readOnly = (/\b(?:audit|inspect|review|analyze|analyse|explain|read|investigate|debug|forensic|root cause|kiem tra|phan tich|dieu tra|doc log|tim hieu)\b/.test(text.replace(/\bread[\s-]*only\b/g, ' ')) || statusReport)
1152
1204
  && !sourceOrder && !codeChangeVerb && !operation && !config;
1153
1205
  const taskKind = operation ? (sourceOrder || codeChangeVerb ? 'MIXED' : 'OPERATIONAL')
1154
1206
  : config ? 'CONFIGURATION' : documentation ? 'DOCUMENTATION_OUTPUT' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
@@ -1170,5 +1222,5 @@ export function applyTaskSemantics(contract, semantic) {
1170
1222
  completionEvidence: semantic.taskKind === 'DOCUMENTATION_OUTPUT' ? ['report-artifact'] : operationOnly ? ['operation-verification']
1171
1223
  : semantic.taskKind === 'MIXED' ? [...new Set([...evidence, 'write-evidence', 'operation-verification'])]
1172
1224
  : semantic.taskKind === 'READ_ONLY' && semantic.statusReport ? []
1173
- : semantic.taskKind === 'READ_ONLY' ? (evidence.length === 1 && evidence[0] === 'verification-evidence' ? evidence : ['impact-evidence']) : evidence };
1225
+ : semantic.taskKind === 'READ_ONLY' ? (semantic.verificationOnly || (evidence.length === 1 && evidence[0] === 'verification-evidence') ? ['verification-evidence'] : ['impact-evidence']) : evidence };
1174
1226
  }
@@ -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