@ngockhoale/ukit 3.3.2 → 3.4.0

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 (89) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/manifests/engineConformance.yaml +17 -1
  3. package/manifests/hostCapabilities.yaml +68 -1
  4. package/manifests/platform.full.yaml +138 -0
  5. package/manifests/platform.user.yaml +255 -3
  6. package/package.json +1 -1
  7. package/scripts/bench/subagent-orchestrator-corpus.mjs +275 -0
  8. package/scripts/bench/subagent-orchestrator-eval.mjs +565 -0
  9. package/scripts/probe/codex-capability-probe.mjs +169 -0
  10. package/src/cli/commands/doctor.js +168 -0
  11. package/src/cli/commands/indexTools.js +7 -0
  12. package/src/cli/commands/metrics.js +66 -2
  13. package/src/cli/commands/playbook.js +4 -4
  14. package/src/cli/commands/vm.js +49 -8
  15. package/src/core/agentRuntime/adapters.js +328 -27
  16. package/src/core/agentRuntime/artifacts.js +89 -0
  17. package/src/core/agentRuntime/context.js +345 -1
  18. package/src/core/agentRuntime/contract.js +296 -0
  19. package/src/core/agentRuntime/eventStore.js +176 -0
  20. package/src/core/agentRuntime/shadowRun.js +481 -5
  21. package/src/core/agentRuntime/telemetry.js +121 -0
  22. package/src/core/observability/emit/lifecycle.js +68 -1
  23. package/src/core/observability/emit/sessionBoot.js +393 -0
  24. package/src/core/observability/privacy/allowlist.js +10 -1
  25. package/src/core/observability/schema/registry.js +10 -0
  26. package/src/core/runtimeConfig.js +133 -0
  27. package/src/core/userPlaybooks.js +18 -3
  28. package/src/decision/registry.js +19 -0
  29. package/src/diagnostics/feedbackEvents.js +7 -4
  30. package/src/diagnostics/routeOutcomes.js +51 -6
  31. package/src/diagnostics/skillAccuracy.js +43 -3
  32. package/src/index/crossCheckMatrix.js +412 -0
  33. package/src/index/fixLoopEscalation.js +453 -0
  34. package/src/index/playbookRegistry.js +691 -0
  35. package/src/index/reviewPolicy.js +368 -0
  36. package/src/index/routeResolver.js +915 -0
  37. package/src/index/sessionHistoryExtractor.js +359 -0
  38. package/src/index/taskRouting.js +764 -581
  39. package/src/index/tierSelection.js +308 -0
  40. package/src/index/verificationMap.js +404 -0
  41. package/template_project/.claude/hooks/observability-emit.mjs +14 -0
  42. package/template_project/.claude/hooks/record-execution.mjs +19 -1
  43. package/template_project/.claude/hooks/skill-router.sh +691 -25
  44. package/template_project/.claude/hooks/verification-guard.sh +230 -1
  45. package/template_project/.claude/settings.json +2 -2
  46. package/template_project/.claude/ukit/index/cross-check-matrix.mjs +415 -0
  47. package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +456 -0
  48. package/template_project/.claude/ukit/index/playbook-registry.mjs +690 -0
  49. package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +20 -2
  50. package/template_project/.claude/ukit/index/review-policy.mjs +376 -0
  51. package/template_project/.claude/ukit/index/route-resolver.mjs +1059 -0
  52. package/template_project/.claude/ukit/index/route-task.mjs +1253 -846
  53. package/template_project/.claude/ukit/index/session-history-extractor.mjs +362 -0
  54. package/template_project/.claude/ukit/index/tier-selection.mjs +309 -0
  55. package/template_project/.claude/ukit/index/verification-map.mjs +403 -0
  56. package/template_project/.claude/ukit/index/worktree-sweep.mjs +195 -0
  57. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +789 -11
  58. package/template_project/.claude/ukit/runtime/observability-emit.mjs +1102 -0
  59. package/template_project/.claude/ukit/runtime/reinject-context.mjs +9 -1
  60. package/template_project/.claude/ukit/runtime/resumable-run.mjs +149 -5
  61. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +323 -6
  62. package/template_project/.codex/README.md +8 -0
  63. package/template_project/.omp/hooks/pre/ukit-bridge.js +8 -1
  64. package/template_project/ukit/README.md +1 -1
  65. package/template_project/ukit/storage/config.json +20 -0
  66. package/template_user/playbooks/architecture-decision.md +28 -0
  67. package/template_user/playbooks/autonomous-run.md +43 -0
  68. package/template_user/playbooks/autopilot-full.md +59 -0
  69. package/template_user/playbooks/autopilot-stack.md +54 -0
  70. package/template_user/playbooks/babysit.md +39 -0
  71. package/template_user/playbooks/bug-fix.md +3 -1
  72. package/template_user/playbooks/{issue-implementation.md → feature-implementation.md} +4 -2
  73. package/template_user/playbooks/hillclimb.md +44 -0
  74. package/template_user/playbooks/investigation.md +21 -0
  75. package/template_user/playbooks/migration.md +21 -0
  76. package/template_user/playbooks/open-pr.md +48 -0
  77. package/template_user/playbooks/orchestrate.md +45 -0
  78. package/template_user/playbooks/performance.md +33 -0
  79. package/template_user/playbooks/prototype.md +28 -0
  80. package/template_user/playbooks/refactor.md +19 -0
  81. package/template_user/playbooks/release.md +28 -0
  82. package/template_user/playbooks/runtime-forensics.md +23 -0
  83. package/template_user/playbooks/session-pickup.md +31 -0
  84. package/template_user/playbooks/shipping.md +53 -0
  85. package/template_user/playbooks/skill-evaluation.md +48 -0
  86. package/template_user/playbooks/small-feature.md +20 -0
  87. package/template_user/playbooks/verification-map.json +153 -0
  88. package/template_user/playbooks/verification.md +22 -0
  89. package/template_user/playbooks/worktree-cleanup.md +37 -0
@@ -33,7 +33,90 @@ import {
33
33
  } from '../runtime/memory-policy.mjs';
34
34
  import { resolveRecordFreshness } from '../runtime/memory-freshness.mjs';
35
35
  import { resolveMemoryStage } from '../runtime/memory-flags.mjs';
36
-
36
+ import {
37
+ ROUTE_VERSION,
38
+ ROUTE_CONTRACT_VERSION,
39
+ ROUTE_SCHEMA_STAGES,
40
+ ROUTE_INTENT_KINDS,
41
+ ROUTE_MUTABILITIES,
42
+ ROUTE_RIGOR_LEVELS,
43
+ ROUTE_MODEL_TIERS,
44
+ ROUTE_RISK_FLOORS,
45
+ ROUTE_RISK_REASON_CODES,
46
+ ROUTE_EXECUTION_MODES,
47
+ ROUTE_EFFORTS,
48
+ resolveRouteSchemaStage,
49
+ resolveRouteStage,
50
+ resolveResumableRunStage,
51
+ resolveDecisionPlaneStage,
52
+ resolveDecisionFamilyStage,
53
+ buildExecutionContract,
54
+ resolveModelTier,
55
+ isSharedImpactFile,
56
+ deriveRiskFloor,
57
+ formatRiskFloorSegment,
58
+ deriveCeremonyLimits,
59
+ formatLimitsSegment,
60
+ deriveFastPath,
61
+ formatFastPathSegment,
62
+ isDeliveryOnlyRequest,
63
+ buildCompletionState,
64
+ buildResolvedRouteFields,
65
+ validateResolvedRoute,
66
+ compactResolvedRoute,
67
+ resumableTaskBoundary,
68
+ buildShadowDecisionQuestions,
69
+ ROUTE_SHADOW_QUESTIONS,
70
+ deriveRouteFields,
71
+ } from './route-resolver.mjs';
72
+ import { applyMeasuredTier, loadTierMap } from './tier-selection.mjs';
73
+ import { resolvePlaybookRoute, loadRegistry } from './playbook-registry.mjs';
74
+ import { extractHistorySignalsAsync } from './session-history-extractor.mjs';
75
+ import {
76
+ applyFixLoopEscalation,
77
+ isFixLoopEscalationDue,
78
+ } from './fix-loop-escalation.mjs';
79
+
80
+ // TASK-004 (BL-006): the derivation primitives this file used to carry inline now
81
+ // live in route-resolver.mjs — the same module skill-router.sh imports, so both
82
+ // router surfaces compute identical fields. Exported names stay identical via
83
+ // re-export (tests and the Codex instruction path import them from here).
84
+ export {
85
+ ROUTE_VERSION,
86
+ ROUTE_CONTRACT_VERSION,
87
+ ROUTE_SCHEMA_STAGES,
88
+ ROUTE_INTENT_KINDS,
89
+ ROUTE_MUTABILITIES,
90
+ ROUTE_RIGOR_LEVELS,
91
+ ROUTE_MODEL_TIERS,
92
+ ROUTE_RISK_FLOORS,
93
+ ROUTE_RISK_REASON_CODES,
94
+ ROUTE_EXECUTION_MODES,
95
+ ROUTE_EFFORTS,
96
+ resolveRouteSchemaStage,
97
+ resolveRouteStage,
98
+ resolveResumableRunStage,
99
+ resolveDecisionPlaneStage,
100
+ resolveDecisionFamilyStage,
101
+ buildExecutionContract,
102
+ resolveModelTier,
103
+ isSharedImpactFile,
104
+ deriveRiskFloor,
105
+ formatRiskFloorSegment,
106
+ deriveCeremonyLimits,
107
+ formatLimitsSegment,
108
+ deriveFastPath,
109
+ formatFastPathSegment,
110
+ isDeliveryOnlyRequest,
111
+ buildCompletionState,
112
+ buildResolvedRouteFields,
113
+ validateResolvedRoute,
114
+ compactResolvedRoute,
115
+ resumableTaskBoundary,
116
+ buildShadowDecisionQuestions,
117
+ ROUTE_SHADOW_QUESTIONS,
118
+ deriveRouteFields,
119
+ };
37
120
  const {
38
121
  resolveContext,
39
122
  buildCodeIndex,
@@ -71,56 +154,6 @@ function deriveContextDocs({ taskType = null, intentMode = null } = {}) {
71
154
  return unique(docs).slice(0, CONTEXT_DOCS_MAX);
72
155
  }
73
156
 
74
- // --- M01.1: additive ResolvedTaskRoute v1 (docs/pstack/CONTRACTS.md C01) -------------------
75
- // Literal mirror of the canonical block in src/index/taskRouting.js — this file cannot
76
- // import from src/, so the copy is locked by tests/consistency/executionContractSync.test.js.
77
- // Emitted only when `routing.routeSchema.stage` (runtime config, default "off") is not
78
- // "off". All fields are additive on top of the existing routeSummary shape — legacy
79
- // top-level fields are never removed or renamed, so older consumers keep working.
80
- export const ROUTE_VERSION = 1;
81
- export const ROUTE_CONTRACT_VERSION = 1;
82
- export const ROUTE_SCHEMA_STAGES = new Set(['off', 'shadow', 'canary', 'default']);
83
- export const ROUTE_INTENT_KINDS = new Set(['informational', 'delivery', 'mutation', 'investigation', 'review']);
84
- export const ROUTE_MUTABILITIES = new Set(['read-only', 'mutating', 'mixed']);
85
- export const ROUTE_RIGOR_LEVELS = new Set(['R0', 'R1', 'R2', 'R3', 'R4']);
86
- export const ROUTE_MODEL_TIERS = new Set(['lite', 'code', 'smart']);
87
- export const ROUTE_RISK_FLOORS = new Set(['none', 'high-risk']);
88
- export const ROUTE_EFFORTS = new Set(['low', 'medium', 'high']);
89
- // SPEC §5 FR-003: fixed table order — codes are emitted and printed in this order.
90
- // The first six raise the floor to 'high-risk'; the last two are informational only.
91
- export const ROUTE_RISK_REASON_CODES = Object.freeze([
92
- 'security-sensitive',
93
- 'shared-impact',
94
- 'public-contract',
95
- 'schema-change',
96
- 'destructive-action',
97
- 'cross-engine',
98
- 'established-precedent',
99
- 'explicit-local-target',
100
- ]);
101
- const ROUTE_RISK_FLOOR_RAISING_CODES = new Set(ROUTE_RISK_REASON_CODES.slice(0, 6));
102
- const ROUTE_RISK_ENGINES = ['claude code', 'codex', 'omp'];
103
-
104
- // FR-004 (M01.3'): Fast Path eligibility vocabulary. The suppressed list is fixed
105
- // text per SPEC — deliberately not configurable. Reasons are the informational
106
- // risk codes (the last two ROUTE_RISK_REASON_CODES entries) present on the route.
107
- const FAST_PATH_MODES = new Set(['tiny-fix', 'local-fix']);
108
- const FAST_PATH_SUPPRESSED = Object.freeze(['design', 'smart', 'subagents', 'broad-verify']);
109
- const FAST_PATH_REASON_CODES = new Set(ROUTE_RISK_REASON_CODES.slice(6));
110
- // 'informational' is a real router emission (no completion contract) even though it is
111
- // not part of the seven-lane execution-mode ladder.
112
- export const ROUTE_EXECUTION_MODES = [
113
- 'tiny-fix',
114
- 'local-fix',
115
- 'local-build',
116
- 'find-cause',
117
- 'shared-edit',
118
- 'map-impact',
119
- 'review-release',
120
- 'informational',
121
- ];
122
- const ROUTE_ESCALATION_CEILING = 'review-release';
123
- const ROUTE_GOAL_MAX_LENGTH = 240;
124
157
 
125
158
  // --- v3 route-side advisory blocks (docs/pstack/SPEC-playbook-todo.md, ------------------
126
159
  // SPEC-principle-index.md, SPEC-model-roles.md). Literal mirror of the canonical block in
@@ -142,29 +175,33 @@ If the user says "new task", re-route — do not treat the message as the next s
142
175
  "might help" is a hypothesis, not a fix; it does not ship.
143
176
  4. Verify on the same surface: the original repro now passes. "Inconclusive" or
144
177
  wrong-surface is not a pass. A unit test shows branch behavior, not bug absence.
145
- 5. Keep the rejected hypotheses — one line each, why ruled out.
178
+ 5. Keep the rejected hypotheses — one line each, why ruled out. When rejections on
179
+ this same defect cross the fix-loop threshold, stop retrying this lane —
180
+ escalate to runtime-forensics (live symptom) or a deeper debug/verify lane.
146
181
  Reply: what was broken, root cause, fix, how verified — paste failing-then-passing
147
182
  repro output verbatim.
148
183
  Ask the human only for: irreversible writes, a genuine preference call no experiment settles, or a real dead end. Everything else: do it, report it.`,
149
- 'issue-implementation': `You own this task. Normalize the goal, build, verify.
184
+ 'feature-implementation': `You own this task. Normalize the goal, build, verify.
150
185
  If the user says "new task", re-route — do not treat the message as the next step.
151
186
  1. State the done condition as a checkable predicate before writing code.
152
187
  2. Find the established analog — follow it unless you name why it does not fit.
188
+ (Skippable only when you can name why no analog exists.)
153
189
  3. Name the data shape and its organizing structure before writing logic.
154
190
  4. Implement the smallest change satisfying the predicate.
155
191
  5. Verify against the predicate on the real artifact — not "it compiles".
156
192
  6. Widen once: check the impact surface the route named, no broader.
157
193
  Reply: what changed, the predicate, the evidence it now holds.
158
- Ask the human only for: irreversible writes, a genuine preference call no experiment settles, or a real dead end. Everything else: do it, report it.`,
194
+ Ask the human only for: irreversible writes, a genuine preference call no experiment
195
+ settles, or a real dead end. Everything else: do it, report it.`,
159
196
  });
160
197
 
161
198
  // Lane → policy (§2.2). tiny-fix/local-fix stay lean (Fast Path); review-release and
162
199
  // informational carry no policy.
163
200
  export const WORKFLOW_POLICY_BY_MODE = Object.freeze({
164
201
  'find-cause': 'bug-fix',
165
- 'local-build': 'issue-implementation',
166
- 'shared-edit': 'issue-implementation',
167
- 'map-impact': 'issue-implementation',
202
+ 'local-build': 'feature-implementation',
203
+ 'shared-edit': 'feature-implementation',
204
+ 'map-impact': 'feature-implementation',
168
205
  });
169
206
 
170
207
  // SPEC-principle-index §2.1: ~16-line index, one line per principle; group headers are
@@ -279,450 +316,6 @@ export function formatModelRolesBlock(roles = null) {
279
316
  fast-worker→${resolved['fast-worker']}, vision→${resolved.vision}. Delegate via role name; inherit-parent = same model as parent.`;
280
317
  }
281
318
 
282
- // Stage keys treat absence as "off" (MIGRATION_ROLLBACK named-keys table). Unknown or
283
- // malformed values degrade to "off" — the conservative reading that keeps the route
284
- // byte-identical to the pre-M01.1 shape.
285
- export function resolveRouteSchemaStage(config = null) {
286
- const stage = config?.routing?.routeSchema?.stage;
287
- return ROUTE_SCHEMA_STAGES.has(stage) ? stage : 'off';
288
- }
289
-
290
- // FR-002: generic stage resolver — every routing.<key>.stage shares the same
291
- // absent/malformed → 'off' contract. resolveRouteSchemaStage stays as the
292
- // routeSchema-specific spelling of this helper.
293
- export function resolveRouteStage(config = null, key) {
294
- const stage = config?.routing?.[key]?.stage;
295
- return ROUTE_SCHEMA_STAGES.has(stage) ? stage : 'off';
296
- }
297
-
298
- // FR-008 (M07): decision-plane stage resolvers — same absent/malformed → 'off'
299
- // contract as routing.* stages. `decisionPlane.enabled === false` is the global
300
- // emergency disable and reads as 'off' regardless of stage keys.
301
- export function resolveDecisionPlaneStage(config = null) {
302
- const plane = config?.decisionPlane;
303
- if (!plane || typeof plane !== 'object' || plane.enabled === false) return 'off';
304
- return ROUTE_SCHEMA_STAGES.has(plane.stage) ? plane.stage : 'off';
305
- }
306
-
307
- // Per-family override wins over the global stage; absence inherits it.
308
- export function resolveDecisionFamilyStage(config = null, family) {
309
- const globalStage = resolveDecisionPlaneStage(config);
310
- const override = config?.decisionPlane?.families?.[family]?.stage;
311
- return ROUTE_SCHEMA_STAGES.has(override) ? override : globalStage;
312
- }
313
-
314
- // FR-003 (M01.2'): derive the additive riskFloor from hard signals. Codes are
315
- // collected in ROUTE_RISK_REASON_CODES table order; the floor is 'high-risk' iff
316
- // any of the first six (floor-raising) codes fired — informational codes never
317
- // raise it. Detectors read the normalized signal text (same source the mode
318
- // ladder uses) plus the raw target path and context preview.
319
- export function deriveRiskFloor({
320
- promptText = '',
321
- commandText = '',
322
- targetFile = null,
323
- executionMode = null,
324
- activeSkillIds = [],
325
- contextPreview = null,
326
- } = {}) {
327
- const signalText = buildNormalizedRouteSignalText(promptText, commandText);
328
- const target = String(targetFile || '');
329
- const skillIds = Array.isArray(activeSkillIds) ? activeSkillIds : [];
330
- const codes = [];
331
- if (
332
- /\b(auth|security|token|permission|secret|credential|password|vulnerab|exploit|xss|injection)\b/i.test(signalText)
333
- || skillIds.includes('discover-security')
334
- ) {
335
- codes.push('security-sensitive');
336
- }
337
- if (isSharedImpactFile(targetFile) || executionMode === 'shared-edit' || executionMode === 'map-impact') {
338
- codes.push('shared-impact');
339
- }
340
- if (
341
- /(^|\/)(package\.json|manifests\/|.*\.d\.ts$|(^|\/)api\/|openapi|swagger)/i.test(target)
342
- || /\b(public api|breaking change|api contract|semver)\b/i.test(signalText)
343
- ) {
344
- codes.push('public-contract');
345
- }
346
- if (
347
- /(^|\/)(migrations?|db|database|prisma|schema)/i.test(target)
348
- || /\b(migration|migrate|schema|alter table|add column|drop column)\b/i.test(signalText)
349
- ) {
350
- codes.push('schema-change');
351
- }
352
- if (/\b(delete|drop|truncate|destroy|wipe|uninstall|rm -rf|purge)\b/i.test(signalText)) {
353
- codes.push('destructive-action');
354
- }
355
- const engineHits = ROUTE_RISK_ENGINES.filter(
356
- (name) => new RegExp(`\\b${name}\\b`, 'i').test(signalText),
357
- ).length;
358
- if (
359
- /\bcross[- ]engine\b|\ball engines\b/i.test(signalText)
360
- || engineHits >= 2
361
- || /^template_project\/\.(claude|codex|omp)\//i.test(target)
362
- ) {
363
- codes.push('cross-engine');
364
- }
365
- if ((contextPreview?.analogFiles?.length ?? 0) > 0 || (contextPreview?.styleFiles?.length ?? 0) > 0) {
366
- codes.push('established-precedent');
367
- }
368
- if (target && !isSharedImpactFile(targetFile)) {
369
- codes.push('explicit-local-target');
370
- }
371
- return {
372
- floor: codes.some((code) => ROUTE_RISK_FLOOR_RAISING_CODES.has(code)) ? 'high-risk' : 'none',
373
- codes,
374
- };
375
- }
376
-
377
- // Route-line segment (FR-003): on 'high-risk' only the floor-raising codes print;
378
- // on 'none' the informational codes do. Empty list → no segment at all.
379
- function formatRiskFloorSegment(riskFloor = null) {
380
- if (!riskFloor) {
381
- return null;
382
- }
383
- const printed = riskFloor.floor === 'high-risk'
384
- ? riskFloor.codes.filter((code) => ROUTE_RISK_FLOOR_RAISING_CODES.has(code))
385
- : riskFloor.codes;
386
- return printed.length > 0 ? `risk=${riskFloor.floor}(${printed.join(',')})` : null;
387
- }
388
-
389
- // FR-001 (M01.2' limits fragment): compile the contract's numeric budget keys
390
- // into an advisory map. Only finite numbers survive — a non-numeric or missing
391
- // key is simply absent. Zero is a real budget ("no read passes"), never
392
- // filtered. Same key set as the ceremonyBudget.limits builder below.
393
- export function deriveCeremonyLimits(executionContract = null) {
394
- if (executionContract === null || typeof executionContract !== 'object') {
395
- return {};
396
- }
397
- return Object.fromEntries(
398
- ['maxReadPasses', 'maxContextPulls', 'maxReadPassesBeforeReassess']
399
- .filter((key) => Number.isFinite(executionContract[key]))
400
- .map((key) => [key, executionContract[key]]),
401
- );
402
- }
403
-
404
- // Route-line segment (FR-002): fixed reads,ctx,reassess order; only present
405
- // keys print. Empty/absent map → null so the segment never appears.
406
- export function formatLimitsSegment(limits = null) {
407
- if (limits === null || typeof limits !== 'object') {
408
- return null;
409
- }
410
- const parts = [
411
- ['reads', 'maxReadPasses'],
412
- ['ctx', 'maxContextPulls'],
413
- ['reassess', 'maxReadPassesBeforeReassess'],
414
- ]
415
- .filter(([, key]) => Number.isFinite(limits[key]))
416
- .map(([label, key]) => `${label}:${limits[key]}`);
417
- return parts.length > 0 ? `limits=${parts.join(',')}` : null;
418
- }
419
-
420
- // FR-004 (M01.3'): Fast Path eligibility predicate. Returns null when no riskFloor
421
- // was supplied — eligibility must never be derived without the floor check, so a
422
- // missing floor means "not computed", not "none". Eligible iff the lane is
423
- // tiny-fix/local-fix, exactly one local target is known, the floor is 'none',
424
- // a bounded verification path exists (targeted commands or the tiny-fix
425
- // 'minimal-or-targeted' contract policy), and the target is not shared-impact.
426
- export function deriveFastPath({
427
- executionMode = null,
428
- targetFile = null,
429
- riskFloor = null,
430
- verificationRecommendation = null,
431
- contextPreview = null,
432
- } = {}) {
433
- if (!riskFloor) {
434
- return null;
435
- }
436
- const reasons = (riskFloor.codes ?? []).filter((code) => FAST_PATH_REASON_CODES.has(code));
437
- const hasLocalTarget = Boolean(targetFile) || contextPreview?.primaryTargets?.length === 1;
438
- const hasBoundedVerification = (verificationRecommendation?.commands?.length ?? 0) > 0
439
- || buildExecutionContract(executionMode)?.verificationPolicy === 'minimal-or-targeted';
440
- const eligible = FAST_PATH_MODES.has(executionMode)
441
- && hasLocalTarget
442
- && riskFloor.floor === 'none'
443
- && hasBoundedVerification
444
- && !isSharedImpactFile(targetFile);
445
- return {
446
- eligible,
447
- suppressed: eligible ? [...FAST_PATH_SUPPRESSED] : [],
448
- reasons,
449
- };
450
- }
451
-
452
- // Route-line segment (FR-004): emitted only for eligible routes — ineligible
453
- // routes keep the routeSummary.fastPath field for telemetry but stay silent.
454
- function formatFastPathSegment(fastPath = null) {
455
- if (!fastPath?.eligible) {
456
- return null;
457
- }
458
- const reasons = fastPath.reasons?.length ? ` (${fastPath.reasons.join(',')})` : '';
459
- return `fastPath=on | suppress: ${FAST_PATH_SUPPRESSED.join(',')}${reasons}`;
460
- }
461
-
462
- // A bare delivery command ("push this to git", "đẩy bộ này lên git") performs no
463
- // repository mutation the ledger could ever receipt. With no edit/review/debug/build
464
- // signal present, routing it to an investigation lane fabricates write debt and the
465
- // completion gate then demands an edit that cannot exist. Extracted from
466
- // deriveExecutionMode so the C01 intent.kind mapping can reuse the identical predicate
467
- // (delivery-only → 'delivery') instead of duplicating the regexes.
468
- function isDeliveryOnlyRequest({ signalText = '', scores = {}, targetFile = null } = {}) {
469
- const signalRaw = String(signalText || '').toLowerCase();
470
- const deliveryWordSignal = /\bgit\s+push\b/.test(signalRaw)
471
- || /\bpush\b[^\n]{0,60}\b(?:git|github|gitlab|remote|origin|repo)\b/.test(signalRaw)
472
- || /\b(?:git|github|gitlab|remote|origin|repo)\b[^\n]{0,60}\bpush\b/.test(signalRaw)
473
- || /\bday\b(?:\s+\S+){0,3}?\s+len\b/.test(signalRaw);
474
- return deliveryWordSignal
475
- && scores.editCertainty === 0
476
- && !scores.implementSignal
477
- && !scores.reviewSignal
478
- && !scores.debugSignal
479
- && !scores.failureSignal
480
- && !scores.impactSignal
481
- && !scores.buildSignal
482
- && !scores.directTransformSignal
483
- && !scores.smallFixSignal
484
- && !scores.sharedRisk
485
- && !targetFile;
486
- }
487
-
488
- // CONTRACTS.md "Intent vocabulary mapping": taskType informs mode priors, not
489
- // intent.kind; intentMode maps to kind as question/explanation → informational,
490
- // ship/deliver → delivery, code change → mutation, root-cause/diagnosis →
491
- // investigation, review/audit → review.
492
- function deriveRouteIntentKind({ executionMode = null, intentMode = null, deliveryOnly = false } = {}) {
493
- if (executionMode === 'find-cause') return 'investigation';
494
- if (executionMode === 'review-release') return 'review';
495
- if (executionMode === 'informational') return deliveryOnly ? 'delivery' : 'informational';
496
- if (executionMode) return 'mutation';
497
- if (intentMode === 'review-specific') return 'review';
498
- if (intentMode === 'debug-specific') return 'investigation';
499
- if (intentMode === 'implement-specific' || intentMode === 'docs-specific') return 'mutation';
500
- return 'informational';
501
- }
502
-
503
- // Existing read-only vs mutating classification: only the informational lane carries
504
- // no write debt; every contract lane is mutating.
505
- function deriveRouteMutability(executionMode = null) {
506
- return executionMode && executionMode !== 'informational' ? 'mutating' : 'read-only';
507
- }
508
-
509
- function compactRouteGoal(text = '') {
510
- const goal = String(text || '').trim();
511
- if (!goal) return null;
512
- return goal.length > ROUTE_GOAL_MAX_LENGTH ? `${goal.slice(0, ROUTE_GOAL_MAX_LENGTH)}…` : goal;
513
- }
514
-
515
- // Builds the additive C01 groups for one resolved route. rigor stays null until
516
- // M01.2 derives it; ceremonyBudget/capabilityPolicy are empty shaped objects M02/M01.2
517
- // populate; escalation.current mirrors the selected mode.
518
- function buildResolvedRouteFields({
519
- routingContext = {},
520
- activeSkillIds = [],
521
- executionMode = null,
522
- executionContract = null,
523
- completionState = null,
524
- riskFloor = null,
525
- } = {}) {
526
- // Decision table v2 (FR-001/FR-002): tier + effort resolve together from the
527
- // contract lane and the additive riskFloor. The router cannot observe host
528
- // binding capabilities, so the emitted pair is advisory text by definition.
529
- const tierDecision = resolveModelTier({ executionMode, riskFloor });
530
- const signalText = buildNormalizedRouteSignalText(routingContext.promptText, routingContext.commandText);
531
- const deliveryOnly = isDeliveryOnlyRequest({
532
- signalText,
533
- scores: routingContext.executionScores ?? {},
534
- targetFile: routingContext.targetFile ?? null,
535
- });
536
- return {
537
- routeVersion: ROUTE_VERSION,
538
- intent: {
539
- kind: deriveRouteIntentKind({
540
- executionMode,
541
- intentMode: routingContext.intentMode ?? null,
542
- deliveryOnly,
543
- }),
544
- mutability: deriveRouteMutability(executionMode),
545
- goal: compactRouteGoal(routingContext.lastExplicitUserPromptText ?? routingContext.promptText),
546
- doneConditions: [],
547
- },
548
- execution: {
549
- mode: executionMode,
550
- rigor: null,
551
- riskFloor: riskFloor?.floor ?? null,
552
- phase: null,
553
- contractVersion: ROUTE_CONTRACT_VERSION,
554
- modelTier: tierDecision.tier,
555
- effort: tierDecision.effort,
556
- },
557
- evidence: {
558
- observations: [],
559
- riskSignals: riskFloor?.codes ?? [],
560
- activationReasons: [],
561
- suppressionReasons: [],
562
- completionRequirements: unique(completionState?.missingEvidence ?? []),
563
- },
564
- ceremonyBudget: {
565
- policyVersion: ROUTE_CONTRACT_VERSION,
566
- rigor: null,
567
- limits: riskFloor
568
- ? Object.fromEntries(
569
- ['maxReadPasses', 'maxContextPulls', 'maxReadPassesBeforeReassess']
570
- .filter((key) => typeof executionContract?.[key] === 'number')
571
- .map((key) => [key, executionContract[key]]),
572
- )
573
- : {},
574
- consumed: {},
575
- exceptions: [],
576
- },
577
- capabilityPolicy: {
578
- policyVersion: ROUTE_CONTRACT_VERSION,
579
- required: [],
580
- recommended: [],
581
- suppressed: [],
582
- activeSkillIds: unique(activeSkillIds),
583
- },
584
- escalation: {
585
- current: { mode: executionMode, rigor: null },
586
- ceiling: ROUTE_ESCALATION_CEILING,
587
- triggers: [],
588
- history: [],
589
- },
590
- };
591
- }
592
-
593
- // Plain-JS validator for the additive C01 groups. Returns { valid, errors }; it never
594
- // throws and never inspects legacy fields — old consumers may carry anything else.
595
- export function validateResolvedRoute(route = null) {
596
- const errors = [];
597
- const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
598
- if (!isObject(route)) {
599
- return { valid: false, errors: ['route must be an object.'] };
600
- }
601
- if (route.routeVersion !== ROUTE_VERSION) {
602
- errors.push(`routeVersion must be ${ROUTE_VERSION}.`);
603
- }
604
- if (!isObject(route.intent)) {
605
- errors.push('intent must be an object.');
606
- } else {
607
- if (!ROUTE_INTENT_KINDS.has(route.intent.kind)) {
608
- errors.push(`intent.kind must be one of: ${[...ROUTE_INTENT_KINDS].join(', ')}.`);
609
- }
610
- if (!ROUTE_MUTABILITIES.has(route.intent.mutability)) {
611
- errors.push(`intent.mutability must be one of: ${[...ROUTE_MUTABILITIES].join(', ')}.`);
612
- }
613
- if (route.intent.goal !== null && typeof route.intent.goal !== 'string') {
614
- errors.push('intent.goal must be a string or null.');
615
- }
616
- if (!Array.isArray(route.intent.doneConditions)) {
617
- errors.push('intent.doneConditions must be an array.');
618
- }
619
- }
620
- if (!isObject(route.execution)) {
621
- errors.push('execution must be an object.');
622
- } else {
623
- if (route.execution.mode !== null && !ROUTE_EXECUTION_MODES.includes(route.execution.mode)) {
624
- errors.push(`execution.mode must be null or one of: ${ROUTE_EXECUTION_MODES.join(', ')}.`);
625
- }
626
- if (route.execution.rigor !== null && !ROUTE_RIGOR_LEVELS.has(route.execution.rigor)) {
627
- errors.push(`execution.rigor must be null or one of: ${[...ROUTE_RIGOR_LEVELS].join(', ')}.`);
628
- }
629
- if (route.execution.riskFloor !== null && !ROUTE_RISK_FLOORS.has(route.execution.riskFloor)) {
630
- errors.push(`execution.riskFloor must be null or one of: ${[...ROUTE_RISK_FLOORS].join(', ')}.`);
631
- }
632
- if (route.execution.contractVersion !== ROUTE_CONTRACT_VERSION) {
633
- errors.push(`execution.contractVersion must be ${ROUTE_CONTRACT_VERSION}.`);
634
- }
635
- if (route.execution.modelTier !== null && !ROUTE_MODEL_TIERS.has(route.execution.modelTier)) {
636
- errors.push(`execution.modelTier must be null or one of: ${[...ROUTE_MODEL_TIERS].join(', ')}.`);
637
- }
638
- if (route.execution.effort !== null && route.execution.effort !== undefined
639
- && !ROUTE_EFFORTS.has(route.execution.effort)) {
640
- errors.push(`execution.effort must be null or one of: ${[...ROUTE_EFFORTS].join(', ')}.`);
641
- }
642
- }
643
- if (!isObject(route.evidence)) {
644
- errors.push('evidence must be an object.');
645
- } else {
646
- for (const key of ['observations', 'riskSignals', 'activationReasons', 'suppressionReasons', 'completionRequirements']) {
647
- if (!Array.isArray(route.evidence[key])) {
648
- errors.push(`evidence.${key} must be an array.`);
649
- }
650
- }
651
- if (Array.isArray(route.evidence.riskSignals)
652
- && route.evidence.riskSignals.some(
653
- (code) => typeof code !== 'string' || !ROUTE_RISK_REASON_CODES.includes(code),
654
- )) {
655
- errors.push(`evidence.riskSignals entries must be one of: ${ROUTE_RISK_REASON_CODES.join(', ')}.`);
656
- }
657
- }
658
- if (!isObject(route.ceremonyBudget)) {
659
- errors.push('ceremonyBudget must be an object.');
660
- } else {
661
- if (route.ceremonyBudget.rigor !== null && !ROUTE_RIGOR_LEVELS.has(route.ceremonyBudget.rigor)) {
662
- errors.push(`ceremonyBudget.rigor must be null or one of: ${[...ROUTE_RIGOR_LEVELS].join(', ')}.`);
663
- }
664
- if (!isObject(route.ceremonyBudget.limits)) {
665
- errors.push('ceremonyBudget.limits must be an object.');
666
- }
667
- if (!isObject(route.ceremonyBudget.consumed)) {
668
- errors.push('ceremonyBudget.consumed must be an object.');
669
- }
670
- if (!Array.isArray(route.ceremonyBudget.exceptions)) {
671
- errors.push('ceremonyBudget.exceptions must be an array.');
672
- }
673
- }
674
- if (!isObject(route.capabilityPolicy)) {
675
- errors.push('capabilityPolicy must be an object.');
676
- } else {
677
- for (const key of ['required', 'recommended', 'suppressed', 'activeSkillIds']) {
678
- if (!Array.isArray(route.capabilityPolicy[key])) {
679
- errors.push(`capabilityPolicy.${key} must be an array.`);
680
- }
681
- }
682
- }
683
- if (!isObject(route.escalation)) {
684
- errors.push('escalation must be an object.');
685
- } else {
686
- if (!isObject(route.escalation.current)) {
687
- errors.push('escalation.current must be an object.');
688
- } else {
689
- if (route.escalation.current.mode !== null && !ROUTE_EXECUTION_MODES.includes(route.escalation.current.mode)) {
690
- errors.push(`escalation.current.mode must be null or one of: ${ROUTE_EXECUTION_MODES.join(', ')}.`);
691
- }
692
- if (route.escalation.current.rigor !== null && !ROUTE_RIGOR_LEVELS.has(route.escalation.current.rigor)) {
693
- errors.push(`escalation.current.rigor must be null or one of: ${[...ROUTE_RIGOR_LEVELS].join(', ')}.`);
694
- }
695
- }
696
- if (!Array.isArray(route.escalation.triggers)) {
697
- errors.push('escalation.triggers must be an array.');
698
- }
699
- if (!Array.isArray(route.escalation.history)) {
700
- errors.push('escalation.history must be an array.');
701
- }
702
- }
703
- return { valid: errors.length === 0, errors };
704
- }
705
-
706
- // Compact serialization whitelist (C01 compatibility rule): adapter/runtime consumers
707
- // get a bounded view that may omit verbose evidence but never mode, rigor, required
708
- // completion evidence, or escalation state. Returns null for legacy (stage-off)
709
- // summaries so compact output stays byte-identical when the schema stage is off.
710
- export function compactResolvedRoute(routeSummary = null) {
711
- if (!routeSummary || typeof routeSummary !== 'object' || routeSummary.routeVersion == null) {
712
- return null;
713
- }
714
- return {
715
- routeVersion: routeSummary.routeVersion,
716
- intent: routeSummary.intent ?? null,
717
- execution: routeSummary.execution ?? null,
718
- evidence: {
719
- completionRequirements: unique(routeSummary.evidence?.completionRequirements ?? []),
720
- },
721
- ceremonyBudget: routeSummary.ceremonyBudget ?? null,
722
- capabilityPolicy: routeSummary.capabilityPolicy ?? null,
723
- escalation: routeSummary.escalation ?? null,
724
- };
725
- }
726
319
  const STOPWORDS = new Set([
727
320
  'the', 'a', 'an', 'and', 'or', 'to', 'for', 'of', 'with', 'in', 'on', 'is', 'are',
728
321
  'this', 'that', 'it', 'as', 'by', 'be', 'use', 'using', 'implement', 'fix', 'task',
@@ -1432,6 +1025,16 @@ export async function routeTask(input = {}, { signal = null, deadlineMs = null,
1432
1025
  previousContextSnapshot,
1433
1026
  recentOutputSnapshot,
1434
1027
  });
1028
+ // BL-013: bounded history signals from the caller-supplied transcript
1029
+ // pointer (input.transcriptPath / UKIT_TRANSCRIPT_PATH). Degrades to the
1030
+ // zeroed struct when absent — never throws, never extends the budget.
1031
+ const historySignals = await extractHistorySignalsAsync({
1032
+ transcriptPath: normalizedInput.transcriptPath
1033
+ ?? process.env.UKIT_TRANSCRIPT_PATH
1034
+ ?? null,
1035
+ routeFingerprint: normalizedInput.routeFingerprint ?? null,
1036
+ requestKey,
1037
+ });
1435
1038
  const helperCacheSnapshot = await readHelperCacheSnapshot({
1436
1039
  rootDir: absoluteRoot,
1437
1040
  indexGeneratedAtMs,
@@ -1449,10 +1052,17 @@ export async function routeTask(input = {}, { signal = null, deadlineMs = null,
1449
1052
  useIndexedContext,
1450
1053
  previousContextSnapshot,
1451
1054
  recentOutputSnapshot,
1452
- previousRouteSummary: null,
1055
+ previousRouteSummary: normalizedInput.previousRouteSummary ?? null,
1453
1056
  escalationConfig,
1057
+ // BL-018: caller-injected seams — the in-process entry accepts a prior
1058
+ // emission (exhaustion check) and a stubbed decision/registrar for tests.
1059
+ escalationDecision: normalizedInput.escalationDecision ?? null,
1060
+ // C89 TASK-005: typed delegation-advice consult seam (same {ask,
1061
+ // appendReceipt, override} contract as src deriveTaskRoute).
1062
+ roleAdviceDecision: normalizedInput.roleAdviceDecision ?? null,
1454
1063
  sessionId: normalizedInput.sessionId ?? null,
1455
1064
  indexGeneratedAtMs,
1065
+ historySignals,
1456
1066
  });
1457
1067
  const sharedState = createSharedRouteState({
1458
1068
  route,
@@ -1491,6 +1101,19 @@ async function main() {
1491
1101
  const routeCachePath = path.join(rootDir, '.claude', 'ukit', 'route-cache.json');
1492
1102
  const routeAuditPath = path.join(rootDir, '.ukit', 'storage', 'cache', 'route-audit.json');
1493
1103
  const persistedPreviousState = await readJson(sharedStatePath, {});
1104
+ // BL-017 (SPEC FR-005): detached hook-path shadow pass — skill-router.sh
1105
+ // spawns this fire-and-forget per emitted route. Self-deadline so a wedged
1106
+ // import/read can never leave an orphan (same watchdog posture as the hook).
1107
+ if (args.includes('--decision-shadow')) {
1108
+ setTimeout(() => process.exit(0), DECISION_SHADOW_TIMEOUT_CAP_MS * 3).unref();
1109
+ await runHookPathDecisionShadow({
1110
+ rootDir,
1111
+ statePath: sharedStatePath,
1112
+ targetFile: readFlagValue(args, '--target-file') ?? null,
1113
+ });
1114
+ return;
1115
+ }
1116
+
1494
1117
  const sessionId = normalizeSessionId(readFlagValue(args, '--session-id'));
1495
1118
  const previousStateOwnershipBlocked = hasRouteStateOwner(persistedPreviousState)
1496
1119
  && !isRouteStateCompatible(persistedPreviousState, sessionId);
@@ -1574,6 +1197,20 @@ async function main() {
1574
1197
  previousContextSnapshot,
1575
1198
  recentOutputSnapshot,
1576
1199
  });
1200
+ // BL-013: bounded session-history signals from the transcript pointer
1201
+ // (--transcript flag or UKIT_TRANSCRIPT_PATH env; absent → degraded zeroed
1202
+ // struct). Extracted once here so provisional/cached/final emissions carry
1203
+ // the identical counters/enums — never raw transcript text.
1204
+ const transcriptPath = readFlagValue(args, '--transcript')
1205
+ ?? process.env.UKIT_TRANSCRIPT_PATH
1206
+ ?? null;
1207
+ const historySignals = await extractHistorySignalsAsync({
1208
+ transcriptPath,
1209
+ routeFingerprint: previousState?.requestKey === requestKey
1210
+ ? previousState?.fingerprint ?? null
1211
+ : null,
1212
+ requestKey,
1213
+ });
1577
1214
  const reusableState = reuseSharedRouteState({
1578
1215
  previousState,
1579
1216
  requestKey,
@@ -1594,6 +1231,15 @@ async function main() {
1594
1231
  const canPersistCachedRouteState = !cachedRouteStateOwnershipBlocked;
1595
1232
 
1596
1233
  if (reusableState) {
1234
+ // BL-013: refresh the bounded counters on a same-requestKey reuse — the
1235
+ // emitted state always carries current signals even when the route body
1236
+ // is reused verbatim.
1237
+ if (historySignals && reusableState.routeSummary) {
1238
+ reusableState.routeSummary = {
1239
+ ...reusableState.routeSummary,
1240
+ historySignals,
1241
+ };
1242
+ }
1597
1243
  printRouteState(reusableState);
1598
1244
  return;
1599
1245
  }
@@ -1635,11 +1281,43 @@ async function main() {
1635
1281
  } else {
1636
1282
  delete rescuedRouteSummary.riskEscalation;
1637
1283
  }
1284
+ // BL-013: refresh the bounded counters on a cached-route reuse — identical
1285
+ // merge rule as the prior-state reuse above.
1286
+ if (historySignals) {
1287
+ rescuedRouteSummary.historySignals = historySignals;
1288
+ }
1638
1289
  applyRescueBiasToRouteSummary(rescuedRouteSummary);
1639
1290
  applyEscalationToRouteSummary(rescuedRouteSummary, {
1640
1291
  config: escalationConfig,
1641
1292
  targetFile: cachedRouteState.routingContext?.targetFile ?? null,
1642
1293
  });
1294
+ // BL-018: re-run the fix-loop transition on the rescued summary — a
1295
+ // refreshed fixLoopCount may trip, escalate, or exhaust (BLOCKED); the
1296
+ // cached summary doubles as the previous emission for exhaustion.
1297
+ if (isFixLoopEscalationDue({ historySignals: rescuedRouteSummary.historySignals, config: escalationConfig })) {
1298
+ // Same fingerprint the persisted sharedState carries below — the
1299
+ // escalation record stamps it so the next emission's same-loop join
1300
+ // (exhaustion/blocked-terminal/preserve) binds only this route.
1301
+ const rescuedRouteFingerprint = buildRouteStateFingerprint({
1302
+ ...cachedRouteState,
1303
+ requestKey,
1304
+ routeSummary: rescuedRouteSummary,
1305
+ });
1306
+ await applyFixLoopEscalation({
1307
+ routeSummary: rescuedRouteSummary,
1308
+ routingContext: cachedRouteState.routingContext ?? {},
1309
+ previousRouteSummary: cachedRouteState.routeSummary,
1310
+ routeFingerprint: rescuedRouteFingerprint,
1311
+ config: escalationConfig,
1312
+ ask: ({ batch }) => askFixLoopEscalationDecision({
1313
+ rootDir,
1314
+ config: escalationConfig,
1315
+ batch,
1316
+ }),
1317
+ appendReceipt: (receipt) => appendDecisionReceipt(rootDir, receipt),
1318
+ runtimeForensicsPresent: await runtimeForensicsRegistered({ projectRoot: rootDir }),
1319
+ });
1320
+ }
1643
1321
  appendRiskEscalationSegment(rescuedRouteSummary);
1644
1322
  }
1645
1323
  const sharedState = {
@@ -1688,6 +1366,7 @@ async function main() {
1688
1366
  escalationConfig,
1689
1367
  sessionId,
1690
1368
  indexGeneratedAtMs,
1369
+ historySignals,
1691
1370
  });
1692
1371
  await seedHelperCaches({
1693
1372
  rootDir,
@@ -1878,62 +1557,6 @@ async function prepareTaskRoute({
1878
1557
  const UNIC_DECISION_CLI_PATH = path.join(__routeTaskDir, 'unic-decision.mjs');
1879
1558
  const DECISION_SHADOW_TIMEOUT_CAP_MS = 3000;
1880
1559
 
1881
- // Route-side shadow questions — mirror of the DECISION_REGISTRY entries owned
1882
- // by taskRouting outside the preflight bundle (src/decision/registry.js).
1883
- // Candidates are protocol-local labels, not user-facing prose.
1884
- const ROUTE_SHADOW_QUESTIONS = Object.freeze([
1885
- {
1886
- decisionKey: 'route.intent-kind.v1',
1887
- family: 'route',
1888
- kind: 'choice',
1889
- instruction: 'Intent kind for the route: informational | mutation | investigation | review | delivery.',
1890
- candidates: ['informational', 'mutation', 'investigation', 'review', 'delivery'],
1891
- },
1892
- {
1893
- decisionKey: 'route.rigor.v1',
1894
- family: 'rigor',
1895
- kind: 'score',
1896
- instruction: 'R0-R4 rigor recommendation inside deterministic floors.',
1897
- candidates: ['r0', 'r1', 'r2', 'r3', 'r4'],
1898
- },
1899
- {
1900
- decisionKey: 'resume.next-action.v1',
1901
- family: 'resume',
1902
- kind: 'choice',
1903
- instruction: 'Next resumable action at the continuation boundary.',
1904
- // deriveNextAction vocabulary (read from source); the rescue-bias override
1905
- // 'execute-current-milestone' can overwrite nextActionType post-derivation —
1906
- // baseline compare then scores 'disagree', which is a real signal, not noise.
1907
- candidates: [
1908
- 'ask-user-confirmation',
1909
- 'run-primary-verification',
1910
- 'run-fallback-verification',
1911
- 'pull-indexed-context',
1912
- 'read-skill-instructions',
1913
- 'inspect-structure',
1914
- ],
1915
- },
1916
- {
1917
- decisionKey: 'verify.depth.v1',
1918
- family: 'verify',
1919
- kind: 'choice',
1920
- instruction: 'Verification depth among allowed levels.',
1921
- candidates: ['sanity', 'targeted', 'impact', 'full'],
1922
- },
1923
- // NOT wired — recorded per migration-backlog review:
1924
- // capability.impact.v1 — noul kind; no threshold baseline exists on
1925
- // routeSummary (capabilityPolicy.recommended is unpopulated upstream), so
1926
- // the question has no comparison value.
1927
- // learn.candidate-class.v1 — reserved for the memoryV2 `decision` plane
1928
- // (memoryFlags.js), not the route boundary.
1929
- ]);
1930
-
1931
- // Only families whose resolved stage is not 'off' get a question.
1932
- export function buildShadowDecisionQuestions(config = null) {
1933
- return ROUTE_SHADOW_QUESTIONS
1934
- .filter((q) => resolveDecisionFamilyStage(config, q.family) !== 'off')
1935
- .map(({ family, ...question }) => question);
1936
- }
1937
1560
 
1938
1561
  // Probability → band for receipts. Bands only; raw probabilities never leave
1939
1562
  // the adapter result.
@@ -1946,6 +1569,11 @@ function probabilityBand(value) {
1946
1569
 
1947
1570
  // Deterministic baselines the shadow answers are compared against. Only fields
1948
1571
  // the deterministic route already computes; null baseline → 'unknown'.
1572
+ // BL-017: the adopted families map to the fields the route already resolved —
1573
+ // playbook-select → playbookId ('none' when unrouted), laneDeepening →
1574
+ // executionMode. The review/cross-check/verification-depth baselines are
1575
+ // produced by later wave modules; until then their baseline stays null and
1576
+ // receipts record bands + agreement 'unknown' — real data, not fabricated.
1949
1577
  function shadowBaselineFor(decisionKey, routeSummary) {
1950
1578
  if (decisionKey === 'route.intent-kind.v1') return routeSummary?.intent?.kind ?? null;
1951
1579
  if (decisionKey === 'route.rigor.v1') return routeSummary?.execution?.rigor ?? null;
@@ -1960,6 +1588,17 @@ function shadowBaselineFor(decisionKey, routeSummary) {
1960
1588
  ?? routeSummary?.resolvedVerificationPlan?.depth
1961
1589
  ?? null;
1962
1590
  }
1591
+ if (decisionKey === 'playbook.select.v1') {
1592
+ return routeSummary?.playbookId ?? 'none';
1593
+ }
1594
+ if (decisionKey === 'lane.deepening.v1') {
1595
+ return routeSummary?.executionMode ?? null;
1596
+ }
1597
+ // BL-019: the deterministic pick is the effective tier — measuredTier when
1598
+ // the promoted map already owns the route, else the static modelTier.
1599
+ if (decisionKey === 'tier.selection.v1') {
1600
+ return routeSummary?.measuredTier ?? routeSummary?.executionContract?.modelTier ?? null;
1601
+ }
1963
1602
  return null;
1964
1603
  }
1965
1604
 
@@ -1976,8 +1615,9 @@ function answerCandidateLabel(answer, question) {
1976
1615
 
1977
1616
  // Fold a CLI batch result into the redacted receipt shape (FR-009):
1978
1617
  // {decisionKeys, probabilityBands, outcomeClass, checkpoint, latencyClass,
1979
- // fallbackCode, agreement}. Never carries state text, prompts, or secrets.
1980
- function buildDecisionShadowReceipt({ stage, result, routeSummary, questions = [] }) {
1618
+ // fallbackCode, agreement, batchId}. Never carries state text, prompts, or
1619
+ // secrets.
1620
+ function buildDecisionShadowReceipt({ stage, result, routeSummary, questions = [], batchId = null }) {
1981
1621
  const questionByKey = new Map(questions.map((q) => [q.decisionKey, q]));
1982
1622
  const answers = Array.isArray(result?.answers) ? result.answers : [];
1983
1623
  const probabilityBands = {};
@@ -2006,9 +1646,69 @@ function buildDecisionShadowReceipt({ stage, result, routeSummary, questions = [
2006
1646
  latencyClass: result?.latencyClass ?? 'unknown',
2007
1647
  fallbackCode: result?.fallbackCode ?? null,
2008
1648
  agreement,
1649
+ batchId,
2009
1650
  };
2010
1651
  }
2011
1652
 
1653
+ // BL-017 (SPEC FR-005): per-question receipt rows — one decisions.tsv append
1654
+ // per enabled question in the batch, all sharing `batchId` (carried inside the
1655
+ // checkpoint cell: the receipt signature is consumed unchanged). An answered
1656
+ // question scores its own agreement against the deterministic baseline; an
1657
+ // adapter miss keeps fallbackCode 'no-answer' rather than dropping the row —
1658
+ // a silent gap is how coverage numbers go stale. Typed failures ride kind
1659
+ // 'fallback' so the deterministic policy visibly stayed in force.
1660
+ async function appendPerQuestionShadowReceipts({
1661
+ rootDir,
1662
+ stage,
1663
+ result,
1664
+ routeSummary,
1665
+ questions,
1666
+ batchId,
1667
+ appendDecisionImpl,
1668
+ now,
1669
+ }) {
1670
+ const append = appendDecisionImpl ?? appendDecisionReceipt;
1671
+ const answers = Array.isArray(result?.answers) ? result.answers : [];
1672
+ const answerByKey = new Map(
1673
+ answers
1674
+ .filter((a) => a?.decisionKey && a?.validationStatus === 'valid')
1675
+ .map((a) => [a.decisionKey, a]),
1676
+ );
1677
+ const outcomeClass = result?.status ?? 'unavailable';
1678
+ const kind = outcomeClass === 'unavailable' ? 'fallback' : 'shadow';
1679
+ const checkpointCell = `${result?.checkpoint ?? 'no-checkpoint'} batch=${batchId}`;
1680
+ for (const question of questions) {
1681
+ const answer = answerByKey.get(question.decisionKey) ?? null;
1682
+ let agreement = 'unknown';
1683
+ if (answer) {
1684
+ const label = answerCandidateLabel(answer, question);
1685
+ const baseline = shadowBaselineFor(question.decisionKey, routeSummary);
1686
+ if (label !== null && baseline !== null && baseline !== undefined) {
1687
+ agreement = label.toLowerCase() === String(baseline).toLowerCase()
1688
+ ? 'agree'
1689
+ : 'disagree';
1690
+ }
1691
+ }
1692
+ const fallbackCode = result?.fallbackCode ?? (answer ? null : 'no-answer');
1693
+ try {
1694
+ await append(rootDir, {
1695
+ kind,
1696
+ boundary: 'route',
1697
+ stage,
1698
+ outcomeClass,
1699
+ checkpoint: checkpointCell,
1700
+ latencyClass: result?.latencyClass ?? 'unknown',
1701
+ decisionKeys: [question.decisionKey],
1702
+ agreement,
1703
+ fallbackCode,
1704
+ now: now(),
1705
+ });
1706
+ } catch {
1707
+ // Receipt loss is advisory — never fail the route over telemetry.
1708
+ }
1709
+ }
1710
+ }
1711
+
2012
1712
  /**
2013
1713
  * Run the decision-plane shadow adapter for a finalized route. Returns the
2014
1714
  * redacted receipt, or null when the plane is off / no family is enabled.
@@ -2041,8 +1741,12 @@ export async function runRouteDecisionShadow({
2041
1741
  : DECISION_SHADOW_TIMEOUT_CAP_MS) + 1000,
2042
1742
  DECISION_SHADOW_TIMEOUT_CAP_MS + 1000,
2043
1743
  );
1744
+ const batchId = `route-shadow-${now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
1745
+ // BL-017: historySignals feed laneDeepening/verificationDepth features —
1746
+ // bounded counters only (BL-013 contract: never transcript text).
1747
+ const hist = routeSummary?.historySignals ?? null;
2044
1748
  const batch = {
2045
- batchId: `route-shadow-${now().toString(36)}`,
1749
+ batchId,
2046
1750
  boundary: 'route',
2047
1751
  deadlineMs: Math.max(500, timeoutMs - 1000),
2048
1752
  statePacket: {
@@ -2052,6 +1756,15 @@ export async function runRouteDecisionShadow({
2052
1756
  targetClass: routingContext.targetFile ? 'file-target' : 'no-target',
2053
1757
  execution: routeSummary?.executionMode ? { mode: routeSummary.executionMode } : {},
2054
1758
  riskSignals: routeSummary?.riskFloor?.codes ?? [],
1759
+ history: hist
1760
+ ? {
1761
+ priorAttemptCount: Math.max(0, Math.trunc(hist.priorAttemptCount ?? 0) || 0),
1762
+ fixLoopCount: Math.max(0, Math.trunc(hist.fixLoopCount ?? 0) || 0),
1763
+ sameSymptomReask: hist.sameSymptomReask === true,
1764
+ correctionEvents: Math.max(0, Math.trunc(hist.correctionEvents ?? hist.correctionCount ?? 0) || 0),
1765
+ degraded: hist.degraded === true,
1766
+ }
1767
+ : null,
2055
1768
  },
2056
1769
  questions,
2057
1770
  };
@@ -2088,27 +1801,145 @@ export async function runRouteDecisionShadow({
2088
1801
  };
2089
1802
  }
2090
1803
 
2091
- const receipt = buildDecisionShadowReceipt({ stage, result, routeSummary, questions });
2092
- // Append-only receipt row (decisions.tsv): codes/bands only via the ledger's
2093
- // typed receipt writer (statically imported above alongside recordLedgerEvent).
1804
+ const receipt = buildDecisionShadowReceipt({
1805
+ stage,
1806
+ result,
1807
+ routeSummary,
1808
+ questions,
1809
+ batchId,
1810
+ });
1811
+ // BL-017: batched emission — one append-only decisions.tsv row per enabled
1812
+ // question sharing batchId; codes/bands only via the ledger's typed receipt
1813
+ // writer (statically imported above alongside recordLedgerEvent). Receipt
1814
+
1815
+ // append failures stay advisory inside the helper.
1816
+ await appendPerQuestionShadowReceipts({
1817
+ rootDir,
1818
+ stage,
1819
+ result,
1820
+ routeSummary,
1821
+ questions,
1822
+ batchId,
1823
+ appendDecisionImpl,
1824
+ now,
1825
+ });
1826
+ // BL-019 (SPEC FR-001): the measured-vs-static A/B row — logged once per
1827
+ // route whenever a derived tier-map.json exists and the tier-selection
1828
+ // family isn't stage-off (inherited shadow stage). The raw map pick is
1829
+ // logged even on 'hold' (the A/B collection is the point); the static tier
1830
+ // is rebuilt from the contract literal so a promoted modelTier can't fake
1831
+ // its own baseline. Bounded cells only — tier names + counts + promotion.
1832
+ if (resolveDecisionFamilyStage(config, 'tier-selection') !== 'off') {
1833
+ try {
1834
+ const tierMap = await loadTierMap(rootDir);
1835
+ if (tierMap) {
1836
+ const staticTier = buildExecutionContract(routeSummary?.executionMode)?.modelTier
1837
+ ?? routeSummary?.executionContract?.modelTier
1838
+ ?? null;
1839
+ const measuredPick = tierMap.byMode?.[routeSummary?.executionMode] ?? null;
1840
+ const append = appendDecisionImpl ?? appendDecisionReceipt;
1841
+ await append(rootDir, {
1842
+ kind: 'shadow',
1843
+ boundary: 'tier-selection',
1844
+ stage,
1845
+ outcomeClass: 'tier-ab',
1846
+ checkpoint: `batch=${batchId} promotion=${tierMap.decision} joined=${tierMap.joinedRoutes} agreement=${(tierMap.agreement ?? 0).toFixed(2)} static=${staticTier ?? 'unknown'} measured=${measuredPick ?? 'none'}`,
1847
+ latencyClass: 'fast',
1848
+ decisionKeys: ['tier.selection.v1'],
1849
+ agreement: staticTier && measuredPick
1850
+ ? (measuredPick === staticTier ? 'agree' : 'disagree')
1851
+ : 'unknown',
1852
+ fallbackCode: measuredPick ? null : 'no-measured-pick',
1853
+ now: now(),
1854
+ });
1855
+ }
1856
+ } catch {
1857
+ // Receipt loss is advisory — never fail the route over telemetry.
1858
+ }
1859
+ }
1860
+ return receipt;
1861
+ }
1862
+
1863
+ // BL-018 (SPEC FR-006): the bounded-question `ask` seam — one batch through
1864
+ // the installed unic-decision CLI (the same adapter the shadow pass spawns).
1865
+ // Shared by the fix-loop escalation (answer consumed) and the C89-005
1866
+ // role-advice consult (answer recorded as suggest-only shadow). Every failure
1867
+ // degrades to a typed 'unavailable' result → deterministic fallback, never a
1868
+ // throw.
1869
+ async function askUnicDecisionBatch({
1870
+ rootDir = process.cwd(),
1871
+ config = null,
1872
+ batch = null,
1873
+ spawnImpl = spawnSync,
1874
+ } = {}) {
2094
1875
  try {
2095
- const append = appendDecisionImpl ?? appendDecisionReceipt;
2096
- await append(rootDir, {
2097
- kind: 'shadow',
2098
- boundary: 'route',
2099
- stage,
2100
- outcomeClass: receipt.outcomeClass,
2101
- checkpoint: receipt.checkpoint,
2102
- latencyClass: receipt.latencyClass,
2103
- decisionKeys: receipt.decisionKeys,
2104
- agreement: receipt.agreement,
2105
- fallbackCode: receipt.fallbackCode,
2106
- now: now(),
1876
+ const cliPath = process.env.UKIT_DECISION_CLI_PATH || UNIC_DECISION_CLI_PATH;
1877
+ const timeoutMs = Math.min(
1878
+ Math.max(500, (Number.isFinite(batch?.deadlineMs) ? batch.deadlineMs : 2000) + 1000),
1879
+ DECISION_SHADOW_TIMEOUT_CAP_MS + 1000,
1880
+ );
1881
+ const spawned = spawnImpl(
1882
+ process.execPath,
1883
+ [cliPath, '--root', rootDir],
1884
+ {
1885
+ cwd: rootDir,
1886
+ input: JSON.stringify(batch),
1887
+ encoding: 'utf8',
1888
+ timeout: timeoutMs,
1889
+ },
1890
+ );
1891
+ if (spawned && !spawned.error && spawned.status === 0 && spawned.stdout) {
1892
+ return JSON.parse(spawned.stdout);
1893
+ }
1894
+ return { status: 'unavailable', fallbackCode: 'adapter-spawn-failed', answers: [] };
1895
+ } catch {
1896
+ return { status: 'unavailable', fallbackCode: 'adapter-spawn-failed', answers: [] };
1897
+ }
1898
+ }
1899
+
1900
+ // Whether a `runtime-forensics` playbook file resolves through the registry —
1901
+ // BL-023 owns the file; until it lands every install degrades to 'missing'
1902
+ // and the lane-deepening still applies (SPEC §14).
1903
+ async function runtimeForensicsRegistered({ projectRoot = null } = {}) {
1904
+ try {
1905
+ const records = await loadRegistry({ projectRoot, homeDir: os.homedir() });
1906
+ return records.some((record) => record?.frontmatterValid && record?.playbookId === 'runtime-forensics');
1907
+ } catch {
1908
+ return false;
1909
+ }
1910
+ }
1911
+ // BL-017 (SPEC FR-005, hook path): the detached shadow pass skill-router.sh
1912
+ // spawns per emitted route (`node route-task.mjs --decision-shadow --root R`).
1913
+ // It re-reads the persisted route state + merged config and replays the same
1914
+ // stage-gated adapter batch the helper path runs inline — receipts land via
1915
+ // appendDecisionReceipt; the deterministic route was already written by the
1916
+ // parent so nothing here can rewrite it. Fire-and-forget: swallows every
1917
+ // failure (typed fallback rows still land through runRouteDecisionShadow),
1918
+ // prints nothing, exits 0.
1919
+ async function runHookPathDecisionShadow({
1920
+ rootDir,
1921
+ statePath,
1922
+ targetFile = null,
1923
+ } = {}) {
1924
+ try {
1925
+ const state = await readJson(statePath, null);
1926
+ const routeSummary = state?.routeSummary ?? null;
1927
+ if (!routeSummary || typeof routeSummary !== 'object') return;
1928
+ const config = await readMergedConfig(rootDir);
1929
+ const routingContext = {
1930
+ ...(state?.routingContext ?? {}),
1931
+ targetFile,
1932
+ };
1933
+ await runRouteDecisionShadow({
1934
+ rootDir,
1935
+ config,
1936
+ routeSummary,
1937
+ routingContext,
2107
1938
  });
2108
1939
  } catch {
2109
- // Receipt loss is advisory — never fail the route over telemetry.
1940
+ // Detached child: a failed shadow pass is observable via decisions.tsv
1941
+ // fallback receipts or absence — never propagated.
2110
1942
  }
2111
- return receipt;
2112
1943
  }
2113
1944
 
2114
1945
  // --- M04.1 resumable run emit (TASK-007, SPEC §5 FR-013..FR-015) --------------
@@ -2119,24 +1950,6 @@ export async function runRouteDecisionShadow({
2119
1950
  // I/O — the route stays byte-identical. Every failure is advisory: routing never
2120
1951
  // fails over run-state persistence.
2121
1952
 
2122
- // Same absent/malformed → 'off' contract as the routing.* stage resolvers; the
2123
- // stage key lives under continuity.resumableRun (TASK-001 config block).
2124
- export function resolveResumableRunStage(config = null) {
2125
- const stage = config?.continuity?.resumableRun?.stage;
2126
- return ROUTE_SCHEMA_STAGES.has(stage) ? stage : 'off';
2127
- }
2128
-
2129
- // The logical task boundary is the explicit user prompt — a new prompt means a
2130
- // new task, so the persisted record resets instead of merging stale plans.
2131
- // Hashed: prompt text never persists (C10 redaction contract).
2132
- function resumableTaskBoundary(routingContext = {}) {
2133
- const text = String(
2134
- routingContext.lastExplicitUserPromptText ?? routingContext.promptText ?? '',
2135
- ).trim();
2136
- return text
2137
- ? crypto.createHash('sha256').update(text).digest('hex').slice(0, 32)
2138
- : 'no-prompt';
2139
- }
2140
1953
 
2141
1954
  // Session-scoped when a session id exists (one record file per session, reset by
2142
1955
  // taskBoundary); prompt-scoped otherwise so concurrent anonymous routes never
@@ -2251,7 +2064,16 @@ export async function emitResumableRun({
2251
2064
  const status = write.ok
2252
2065
  ? 'written'
2253
2066
  : (write.journaled ? 'journaled' : `skipped-${write.reason ?? 'unknown'}`);
2254
- const pointer = { taskId, taskBoundary, stage, status };
2067
+ // TASK-007 (BL-009): playbook route fields ride the ledger row so
2068
+ // outcomes/audit can join the routed playbook for this run.
2069
+ const pointer = {
2070
+ taskId,
2071
+ taskBoundary,
2072
+ stage,
2073
+ status,
2074
+ playbookId: routeSummary?.playbookId ?? null,
2075
+ workGroup: routeSummary?.workGroup ?? null,
2076
+ };
2255
2077
  // Ledger pointer event — same fail-closed journal discipline; advisory only.
2256
2078
  try {
2257
2079
  await recordLedgerEvent(
@@ -2279,6 +2101,13 @@ async function finalizeTaskRoute({
2279
2101
  projectVerification = null,
2280
2102
  cachedContextResult = null,
2281
2103
  cachedVerificationPlan = null,
2104
+ // BL-018: injected decision/registrar seams — hook callers leave both null
2105
+ // (real adapter + real registry); tests stub them for deterministic lanes.
2106
+ escalationDecision = null,
2107
+ // C89 TASK-005: injected seam for the typed delegation-advice consult
2108
+ // ({ask, appendReceipt, override}); absent → the real unic-decision CLI
2109
+ // spawn when the decision plane is enabled, else a typed local outcome.
2110
+ roleAdviceDecision = null,
2282
2111
  useIndexedContext = true,
2283
2112
  previousContextSnapshot = null,
2284
2113
  recentOutputSnapshot = null,
@@ -2286,6 +2115,9 @@ async function finalizeTaskRoute({
2286
2115
  escalationConfig = null,
2287
2116
  sessionId = null,
2288
2117
  indexGeneratedAtMs = null,
2118
+ // BL-013: precomputed bounded history struct from the caller's transcript
2119
+ // extraction — merged into the route record additively + decisions.tsv.
2120
+ historySignals = null,
2289
2121
  } = {}) {
2290
2122
  const absoluteRoot = path.resolve(rootDir);
2291
2123
  const activeSkills = preparedRoute?.activeSkills ?? [];
@@ -2358,7 +2190,47 @@ async function finalizeTaskRoute({
2358
2190
  routeSchemaStage: resolveRouteSchemaStage(escalationConfig),
2359
2191
  runtimeConfig: escalationConfig,
2360
2192
  resolvedWorkflowPolicy,
2193
+ historySignals,
2361
2194
  });
2195
+ // TASK-007 (BL-009): playbook resolution rides the same route signals —
2196
+ // the registry degrades to playbookId: null + reason and never blocks the
2197
+ // route. Same merge the hook performs via mergeResolverFields.
2198
+ try {
2199
+ const playbookResolution = await resolvePlaybookRoute({
2200
+ promptText: routingContext.promptText,
2201
+ commandText: routingContext.commandText,
2202
+ targetFile: routingContext.targetFile ?? null,
2203
+ executionMode: routingContext.executionMode ?? null,
2204
+ intentMode: routingContext.intentMode ?? null,
2205
+ escalationTriggers: routeSummary?.escalationTriggers ?? [],
2206
+ projectRoot: absoluteRoot,
2207
+ homeDir: os.homedir(),
2208
+ });
2209
+ routeSummary.playbookId = playbookResolution.playbookId;
2210
+ routeSummary.workGroup = playbookResolution.workGroup;
2211
+ routeSummary.playbookReason = playbookResolution.reason;
2212
+ routeSummary.playbookLoad = playbookResolution.playbookLoad;
2213
+ // BL-010 (ARCH Task Contract): `path` = direct|handoff; `fastPathKind` is
2214
+ // the ARCH `none|small-feature|bug-fix` enum — named Kind because
2215
+ // routeSummary.fastPath already carries the FR-004 eligibility object.
2216
+ // Keys land only when a routing-table row claimed the prompt (workGroup
2217
+ // non-null) — identical key-presence to deriveTaskRoute so schema-off /
2218
+ // no-match routes never grow stray keys.
2219
+ if (playbookResolution.workGroup != null) {
2220
+ routeSummary.path = playbookResolution.path;
2221
+ routeSummary.fastPathKind = playbookResolution.fastPath;
2222
+ } else {
2223
+ delete routeSummary.path;
2224
+ delete routeSummary.fastPathKind;
2225
+ }
2226
+ } catch {
2227
+ routeSummary.playbookId = null;
2228
+ routeSummary.workGroup = null;
2229
+ routeSummary.playbookReason = 'registry-error';
2230
+ routeSummary.playbookLoad = null;
2231
+ delete routeSummary.path;
2232
+ delete routeSummary.fastPathKind;
2233
+ }
2362
2234
  routeSummary.continuationState = advanceContinuationState(
2363
2235
  routeSummary.continuationState,
2364
2236
  previousRouteSummary?.continuationState ?? null,
@@ -2378,10 +2250,79 @@ async function finalizeTaskRoute({
2378
2250
  delete routeSummary.riskEscalation;
2379
2251
  }
2380
2252
  applyRescueBiasToRouteSummary(routeSummary);
2253
+ // BL-019 (SPEC FR-001): fold the persisted measured map into the route —
2254
+ // measuredTier is always emitted (nullable); the authoritative modelTier
2255
+ // follows the map ONLY when tier-map.json says 'promote'. Runs before the
2256
+ // escalation re-derives tierLane so the printed lane reflects the final
2257
+ // tier; never throws (absent/corrupt artifact → static stays).
2258
+ await applyMeasuredTier({ projectRoot: absoluteRoot, routeSummary });
2381
2259
  applyEscalationToRouteSummary(routeSummary, {
2382
2260
  config: escalationConfig,
2383
2261
  targetFile: routingContext.targetFile ?? null,
2384
2262
  });
2263
+ // BL-018 (SPEC FR-006): fix-loop escalation. historySignals.fixLoopCount is
2264
+ // fingerprint-joined (same routeFingerprint/requestKey, never session-wide);
2265
+ // at debugLoopThreshold the lane deepens via the laneDeepening/
2266
+ // verificationDepth question, endpoint-down → deterministic fallback
2267
+ // (fallback:tier-bump), deeper-lane exhaustion → BLOCKED. Runs after the
2268
+ // tier-bump application so both fallback surfaces coexist honestly.
2269
+ // Computed now — BEFORE the escalation writes its additive fields — so the
2270
+ // stamped escalatedRouteFingerprint is exactly the fingerprint
2271
+ // createSharedRouteState persists on this emission (the fingerprint payload
2272
+ // excludes the escalation fields, so pre- and post-escalation agree).
2273
+ const routeFingerprint = buildRouteStateFingerprint({
2274
+ activeSkills,
2275
+ routingContext,
2276
+ previousContext: previousContextSnapshot,
2277
+ recentOutput: recentOutputSnapshot,
2278
+ routeSummary,
2279
+ });
2280
+ const laneEscalationDue = isFixLoopEscalationDue({
2281
+ historySignals: routeSummary.historySignals,
2282
+ config: escalationConfig,
2283
+ });
2284
+ if (laneEscalationDue) {
2285
+ await applyFixLoopEscalation({
2286
+ routeSummary,
2287
+ routingContext,
2288
+ previousRouteSummary,
2289
+ routeFingerprint,
2290
+ config: escalationConfig,
2291
+ ask: escalationDecision?.ask
2292
+ ?? (({ batch }) => askUnicDecisionBatch({
2293
+ rootDir: absoluteRoot,
2294
+ config: escalationConfig,
2295
+ batch,
2296
+ })),
2297
+ appendReceipt: escalationDecision?.appendReceipt
2298
+ ?? ((receipt) => appendDecisionReceipt(absoluteRoot, receipt)),
2299
+ runtimeForensicsPresent: escalationDecision?.runtimeForensicsPresent
2300
+ ?? await runtimeForensicsRegistered({ projectRoot: absoluteRoot }),
2301
+ });
2302
+ }
2303
+ // ledger's typed receipt writer (consumed signature, ledger unchanged).
2304
+ // Emitted only when extraction actually ran on a transcript pointer — a
2305
+ // route with no transcript (missing-transcript-path) stays byte-identical:
2306
+ // no receipt, no history row. A real but unreadable transcript still lands
2307
+ // a degraded receipt so coverage is measurable. Never carries text.
2308
+ if (routeSummary.historySignals && routeSummary.historySignals.reason !== 'missing-transcript-path') {
2309
+ try {
2310
+ const hist = routeSummary.historySignals;
2311
+ await appendDecisionReceipt(absoluteRoot, {
2312
+ kind: 'shadow',
2313
+ boundary: 'route-history',
2314
+ stage: 'deterministic',
2315
+ outcomeClass: hist.degraded ? 'degraded' : 'ok',
2316
+ checkpoint: `hist prior=${hist.priorAttemptCount ?? 0} loop=${hist.fixLoopCount ?? 0} reask=${hist.sameSymptomReask ? 'yes' : 'no'} corrections=${hist.correctionEvents ?? 0}`,
2317
+ latencyClass: 'fast',
2318
+ decisionKeys: ['history.signals'],
2319
+ agreement: 'n/a',
2320
+ fallbackCode: hist.degraded ? (hist.reason ?? 'degraded') : null,
2321
+ });
2322
+ } catch {
2323
+ // Receipt loss is advisory — never fail the route over telemetry.
2324
+ }
2325
+ }
2385
2326
  appendRiskEscalationSegment(routeSummary);
2386
2327
  // M07 (TASK-005): stage-gated decision-plane shadow call. 'off' → null and
2387
2328
  // nothing else happens (byte-identical route); any other stage → bounded
@@ -2395,6 +2336,34 @@ async function finalizeTaskRoute({
2395
2336
  if (decisionShadow) {
2396
2337
  routeSummary.decisionPlane = decisionShadow;
2397
2338
  }
2339
+ // C89 TASK-005 (SPEC §6 FR-04): typed delegation advice consult — the
2340
+ // optional unic-decision round only ever answers the owner-bounded role
2341
+ // question through the installed CLI; the deterministic fields the route
2342
+ // already carries stay authoritative, every failure records a typed
2343
+ // advice.model outcome, and the decisions.tsv row lands via
2344
+ // appendDecisionReceipt (never silent).
2345
+ if (routeSummary?.delegationAdvice) {
2346
+ try {
2347
+ const roleAdviceAsk = roleAdviceDecision?.ask
2348
+ ?? (resolveDecisionPlaneStage(escalationConfig) !== 'off'
2349
+ ? (({ batch }) => askUnicDecisionBatch({
2350
+ rootDir: absoluteRoot,
2351
+ config: escalationConfig,
2352
+ batch,
2353
+ }))
2354
+ : null);
2355
+ await applyRoleAdviceConsult({
2356
+ routeSummary,
2357
+ config: escalationConfig,
2358
+ ask: roleAdviceAsk,
2359
+ appendReceipt: roleAdviceDecision?.appendReceipt
2360
+ ?? ((receipt) => appendDecisionReceipt(absoluteRoot, receipt)),
2361
+ override: roleAdviceDecision?.override ?? null,
2362
+ });
2363
+ } catch {
2364
+ // Advisory consult — never fail or mutate the route over a shadow ask.
2365
+ }
2366
+ }
2398
2367
  // M04.1 (TASK-007): stage-gated resumable-run emit. 'off' → null and nothing
2399
2368
  // else happens (byte-identical route); any other stage → the C10 record is
2400
2369
  // persisted/updated under .ukit/storage/runs/ and a bounded pointer lands on
@@ -2443,6 +2412,18 @@ function printRouteState(state) {
2443
2412
  if (recentOutputDisplay) {
2444
2413
  console.log(`recent-output: ${recentOutputDisplay}`);
2445
2414
  }
2415
+ // BL-013: bounded history line — counters/enums only, never transcript text.
2416
+ // Printed whenever extraction ran (a degraded struct still reports honestly).
2417
+ if (state.routeSummary?.historySignals) {
2418
+ const hist = state.routeSummary.historySignals;
2419
+ console.log(
2420
+ `history: prior=${hist.priorAttemptCount ?? 0}`
2421
+ + ` loop=${hist.fixLoopCount ?? 0}`
2422
+ + ` reask=${hist.sameSymptomReask ? 'yes' : 'no'}`
2423
+ + ` corrections=${hist.correctionEvents ?? 0}`
2424
+ + (hist.degraded ? ` degraded=${hist.reason ?? 'unknown'}` : ''),
2425
+ );
2426
+ }
2446
2427
  console.log(`summary: ${compactSummary}`);
2447
2428
  if (state.routeSummary?.executionContract?.modelTier) {
2448
2429
  console.log(`modelTier: ${state.routeSummary.executionContract.modelTier}`);
@@ -2456,6 +2437,14 @@ function printRouteState(state) {
2456
2437
  console.log(`escalationReason: ${state.routeSummary.escalationReason}`);
2457
2438
  }
2458
2439
  }
2440
+ // BL-018: the deeper lane surface — 'blocked' is the exhaustion verdict,
2441
+ // otherwise a lane from FIX_LOOP_LANE_CANDIDATES.
2442
+ if (state.routeSummary?.escalatedLane) {
2443
+ console.log(`escalatedLane: ${state.routeSummary.escalatedLane}`);
2444
+ if (state.routeSummary.escalateReason) {
2445
+ console.log(`escalateReason: ${state.routeSummary.escalateReason}`);
2446
+ }
2447
+ }
2459
2448
  if (state.routeSummary?.visionLane) {
2460
2449
  console.log(`visionLane: ${state.routeSummary.visionLane}`);
2461
2450
  if (state.routeSummary.visionModel) {
@@ -2479,6 +2468,12 @@ function printRouteState(state) {
2479
2468
  }
2480
2469
  // v3 advisory blocks (SPEC-playbook-todo / SPEC-principle-index / SPEC-model-roles):
2481
2470
  // additive stdout text, byte-stable for unchanged config.
2471
+ // TASK-007 (BL-009): playbook surface — the instruction-mediated load cue
2472
+ // Codex and print consumers read. playbookId only prints when a playbook
2473
+ // resolved; the degrade reason stays machine-side on the route record.
2474
+ if (state.routeSummary?.playbookId) {
2475
+ console.log(`playbook: ${state.routeSummary.playbookId} — read playbooks/${state.routeSummary.playbookId}.md and run its steps verbatim`);
2476
+ }
2482
2477
  if (state.routeSummary?.workflowPolicyName) {
2483
2478
  console.log(`workflow: ${state.routeSummary.workflowPolicyName} — copy steps verbatim into your todo list; skipped steps keep skip:<reason>`);
2484
2479
  }
@@ -3352,6 +3347,265 @@ function deriveDelegationRecommendation({
3352
3347
 
3353
3348
  return null;
3354
3349
  }
3350
+ // --- C89 TASK-005 (SPEC §6 FR-04, §11): typed delegation advice (shadow) ------
3351
+ // Lockstep mirror of src/index/taskRouting.js — wraps the single route owner
3352
+ // (deriveDelegationRecommendation above); never a second router, never a
3353
+ // spawn path. Gated on `subagentOrchestrator.roleAdvice.stage`
3354
+ // ('off'/malformed → null, zero cost); any other stage emits the typed
3355
+ // advisory struct. The optional unic-decision consult
3356
+ // (applyRoleAdviceConsult) only ever answers an owner-bounded enum question;
3357
+ // deterministic fields stay authoritative and every failure records a typed
3358
+ // outcome on advice.model — never silent.
3359
+ const DELEGATION_ADVICE_POLICY_VERSION = 'role-advice.v1';
3360
+ const DELEGATION_ADVICE_DECISION_KEY = 'route.delegation-role.v1';
3361
+ // SPEC §6 initial role vocabulary — maps onto existing frontmatter agents,
3362
+ // no new agent file (retriever/reviewer reserved for role-bearing lanes;
3363
+ // C89-006 consumes role+contextMode only as advisory unless host-verified).
3364
+ const DELEGATION_ADVICE_ROLES = Object.freeze(['retriever', 'reasoner', 'reviewer']);
3365
+ const DELEGATION_ADVICE_CONTEXT_MODES = Object.freeze(['isolated', 'curated']);
3366
+
3367
+ // Delegation hint → {role, contextMode, reasonCode}. The owner hints all carry
3368
+ // reasoning work; the noisy debug lane needs isolation from the noisy loop,
3369
+ // the plan/feature lanes need the curated indexed bundle.
3370
+ const DELEGATION_HINT_ROLES = Object.freeze({
3371
+ 'ukit-small-task-maintainer': { role: 'reasoner', contextMode: 'isolated', reasonCode: 'small-task-maintenance' },
3372
+ 'subagent-driven-development': { role: 'reasoner', contextMode: 'curated', reasonCode: 'planned-batch-execution' },
3373
+ 'bug-debugger': { role: 'reasoner', contextMode: 'isolated', reasonCode: 'noisy-debug-loop' },
3374
+ 'feature-implementer': { role: 'reasoner', contextMode: 'curated', reasonCode: 'broad-implementation-lane' },
3375
+ });
3376
+
3377
+ // Mirror of runtimeConfig.js resolveSubagentOrchestratorStage — resolves
3378
+ // `subagentOrchestrator.<key>.stage`; absent/malformed config or a bad key
3379
+ // resolves 'off' so a bad config can never promote a slice.
3380
+ function resolveSubagentOrchestratorStage(config = null, key = 'telemetry') {
3381
+ if (typeof key !== 'string' || key.trim() === '') return 'off';
3382
+ const node = config?.subagentOrchestrator?.[key];
3383
+ const stage = node?.stage;
3384
+ return ROUTE_SCHEMA_STAGES.has(stage) ? stage : 'off';
3385
+ }
3386
+
3387
+ /**
3388
+ * Typed delegation advice (SPEC §6 FR-04). Reads the deterministic
3389
+ * `deriveDelegationRecommendation` verdict and annotates it as the typed
3390
+ * shadow receipt {delegate, role, contextMode, confidence, reasonCodes,
3391
+ * policyVersion, enforcement} — plus the audit fields hint/stage.
3392
+ *
3393
+ * `enforcement` is 'advisory' on every shipped host: this lane never spawns
3394
+ * and C89-006 owns the host-capability check that could one day mark a lane
3395
+ * 'enforced'. Trivial work resolves local by the deterministic fast path —
3396
+ * the same `null` the owner returns, typed.
3397
+ *
3398
+ * @returns {object|null} null when the roleAdvice stage is off/malformed.
3399
+ */
3400
+ export function deriveTypedDelegationAdvice({
3401
+ activeSkills = [],
3402
+ routingContext = {},
3403
+ contextRecommendation = null,
3404
+ verificationRecommendation = null,
3405
+ autonomyLevel = 'balanced',
3406
+ riskFloor = null,
3407
+ config = null,
3408
+ } = {}) {
3409
+ const stage = resolveSubagentOrchestratorStage(config, 'roleAdvice');
3410
+ if (stage === 'off') {
3411
+ return null;
3412
+ }
3413
+
3414
+ const recommendation = deriveDelegationRecommendation({
3415
+ activeSkills,
3416
+ routingContext,
3417
+ contextRecommendation,
3418
+ verificationRecommendation,
3419
+ autonomyLevel,
3420
+ });
3421
+
3422
+ const hint = recommendation?.hint ?? null;
3423
+ const reasonCodes = [];
3424
+ if (routingContext.taskType === 'trivial') {
3425
+ reasonCodes.push('trivial-fast-path');
3426
+ } else if (hint && DELEGATION_HINT_ROLES[hint]) {
3427
+ reasonCodes.push(DELEGATION_HINT_ROLES[hint].reasonCode);
3428
+ } else if (recommendation) {
3429
+ reasonCodes.push('owner-recommendation');
3430
+ } else {
3431
+ reasonCodes.push('no-delegation-signal');
3432
+ }
3433
+ if (riskFloor?.floor === 'high-risk') {
3434
+ reasonCodes.push('risk-floor-high-risk');
3435
+ }
3436
+
3437
+ const delegate = hint !== null;
3438
+ let role = null;
3439
+ let contextMode = null;
3440
+ if (delegate) {
3441
+ if (routingContext.executionMode === 'review-release') {
3442
+ // SPEC §6: reviewer lanes default isolated — the reviewer receives the
3443
+ // task requirements/diff/tests/evidence, not implementer narrative.
3444
+ role = 'reviewer';
3445
+ contextMode = 'isolated';
3446
+ reasonCodes.push('review-release-lane');
3447
+ } else {
3448
+ const mapped = DELEGATION_HINT_ROLES[hint] ?? { role: 'reasoner', contextMode: 'curated' };
3449
+ role = mapped.role;
3450
+ contextMode = mapped.contextMode;
3451
+ }
3452
+ }
3453
+
3454
+ const confidence = routingContext.taskType === 'trivial'
3455
+ ? 0.95
3456
+ : delegate ? 0.9 : 0.6;
3457
+
3458
+ return {
3459
+ delegate,
3460
+ role,
3461
+ contextMode,
3462
+ confidence,
3463
+ reasonCodes,
3464
+ policyVersion: DELEGATION_ADVICE_POLICY_VERSION,
3465
+ enforcement: 'advisory',
3466
+ stage,
3467
+ hint,
3468
+ };
3469
+ }
3470
+
3471
+ function delegationRiskFloorSignals(routeSummary) {
3472
+ const codes = routeSummary?.riskFloor?.codes;
3473
+ return Array.isArray(codes) ? codes : [];
3474
+ }
3475
+
3476
+ /**
3477
+ * Optional bounded consult + recorded override for the typed advice (SPEC §6).
3478
+ * The unic-decision question is one owner-bounded choice over the role
3479
+ * shortlist — enum-only, redacted, never tools/spawn. Any auth rejection,
3480
+ * timeout, invalid or off-shortlist answer lands on `advice.model` as a typed
3481
+ * outcome; the deterministic fields are never rewritten and no receipt write
3482
+ * is silent (appendReceipt seam fires whenever a consult ran).
3483
+ *
3484
+ * The main-agent override is recorded verbatim (role + reason) when the role
3485
+ * is in the enum — stored for the later accepting lane; it never mutates the
3486
+ * deterministic advice. The hard risk floor is deterministic state on the
3487
+ * advice itself, so conflicting model noise can never flip a local route.
3488
+ *
3489
+ * Returns true when the route was annotated (a consult ran or an override was
3490
+ * recorded); false when no advice exists or the consult never ran.
3491
+ */
3492
+ export async function applyRoleAdviceConsult({
3493
+ routeSummary = null,
3494
+ config = null,
3495
+ ask = null,
3496
+ appendReceipt = null,
3497
+ override = null,
3498
+ now = () => Date.now(),
3499
+ } = {}) {
3500
+ const advice = routeSummary?.delegationAdvice;
3501
+ if (!advice || typeof advice !== 'object') {
3502
+ return false;
3503
+ }
3504
+
3505
+ if (override && typeof override === 'object'
3506
+ && DELEGATION_ADVICE_ROLES.includes(override.role)
3507
+ && typeof override.reason === 'string' && override.reason.trim()) {
3508
+ advice.override = { role: override.role, reason: override.reason.trim() };
3509
+ }
3510
+
3511
+ const emit = (receipt) => {
3512
+ if (typeof appendReceipt !== 'function') return;
3513
+ try {
3514
+ const pending = appendReceipt(receipt);
3515
+ if (pending?.catch) pending.catch(() => {});
3516
+ } catch {
3517
+ // Receipt loss is advisory — the typed advice stands regardless.
3518
+ }
3519
+ };
3520
+
3521
+ // Local routes never consult — there is no role to suggest and the
3522
+ // deterministic fast path needs no shadow annotation.
3523
+ if (advice.delegate !== true || typeof advice.role !== 'string') {
3524
+ return true;
3525
+ }
3526
+
3527
+ // An explicit ask seam is the consult consent; the ambient path only builds
3528
+ // a client when the decision plane itself is enabled, so a role-advice-only
3529
+ // config never triggers endpoint work.
3530
+ const explicitAsk = typeof ask === 'function';
3531
+ if (!explicitAsk && resolveDecisionPlaneStage(config) === 'off') {
3532
+ return true;
3533
+ }
3534
+
3535
+ // Owner-bounded shortlist: the model may only answer inside the
3536
+ // deterministic candidate set — a singleton when the owner has one role.
3537
+ const candidates = [advice.role];
3538
+ const batch = {
3539
+ batchId: `role-advice-${now().toString(36)}`,
3540
+ boundary: 'role-advice',
3541
+ deadlineMs: Math.max(
3542
+ 500,
3543
+ Number.isFinite(config?.decisionPlane?.timeoutMs) ? config.decisionPlane.timeoutMs : 2000,
3544
+ ),
3545
+ statePacket: {
3546
+ stateVersion: 1,
3547
+ taskClass: routeSummary?.routingContext?.taskType ?? 'unknown',
3548
+ execution: routeSummary?.executionMode ? { mode: routeSummary.executionMode } : {},
3549
+ riskSignals: delegationRiskFloorSignals(routeSummary),
3550
+ },
3551
+ questions: [
3552
+ {
3553
+ decisionKey: DELEGATION_ADVICE_DECISION_KEY,
3554
+ kind: 'choice',
3555
+ instruction: 'Pick the delegation role from the owner-bounded shortlist.',
3556
+ candidates,
3557
+ },
3558
+ ],
3559
+ };
3560
+
3561
+ let result = null;
3562
+ try {
3563
+ result = await ask({ batch });
3564
+ } catch {
3565
+ result = null;
3566
+ }
3567
+ if (!result) {
3568
+ result = { status: 'unavailable', fallbackCode: 'ask-failed', answers: [] };
3569
+ }
3570
+
3571
+ // Fold the typed outcome: only a valid in-shortlist answer becomes a
3572
+ // suggestion; everything else keeps suggestedRole null with a fallback code.
3573
+ const answers = Array.isArray(result.answers) ? result.answers : [];
3574
+ let suggestedRole = null;
3575
+ for (const answer of answers) {
3576
+ if (answer?.decisionKey !== DELEGATION_ADVICE_DECISION_KEY) continue;
3577
+ if (answer?.validationStatus !== 'valid') continue;
3578
+ const value = typeof answer?.value === 'string' ? answer.value.trim() : null;
3579
+ if (value && candidates.includes(value)) {
3580
+ suggestedRole = value;
3581
+ }
3582
+ }
3583
+ const outcomeClass = typeof result.status === 'string' ? result.status : 'unavailable';
3584
+ advice.model = {
3585
+ status: outcomeClass,
3586
+ suggestedRole,
3587
+ agreement: suggestedRole === null
3588
+ ? 'unknown'
3589
+ : suggestedRole === advice.role ? 'agree' : 'disagree',
3590
+ fallbackCode: result.fallbackCode
3591
+ ?? (suggestedRole === null && outcomeClass === 'accepted' ? 'off-shortlist' : null),
3592
+ };
3593
+
3594
+ emit({
3595
+ kind: outcomeClass === 'unavailable' ? 'fallback' : 'shadow',
3596
+ boundary: 'role-advice',
3597
+ stage: advice.stage,
3598
+ outcomeClass,
3599
+ checkpoint: `role-advice batch=${batch.batchId}`,
3600
+ latencyClass: result.latencyClass ?? 'unknown',
3601
+ decisionKeys: [DELEGATION_ADVICE_DECISION_KEY],
3602
+ agreement: advice.model.agreement,
3603
+ fallbackCode: advice.model.fallbackCode,
3604
+ now: now(),
3605
+ });
3606
+ return true;
3607
+ }
3608
+
3355
3609
 
3356
3610
  function buildRouteSummary({
3357
3611
  activeSkills = [],
@@ -3363,6 +3617,9 @@ function buildRouteSummary({
3363
3617
  routeSchemaStage = 'off',
3364
3618
  runtimeConfig = null,
3365
3619
  resolvedWorkflowPolicy = null,
3620
+ // BL-013: precomputed bounded history struct — merged additively via the
3621
+ // shared resolver below. Absent/null → no field, byte-identical route.
3622
+ historySignals = null,
3366
3623
  } = {}) {
3367
3624
  const autonomyLevel = routingContext.autonomyLevel ?? 'balanced';
3368
3625
  const delegationRecommendation = deriveDelegationRecommendation({
@@ -3417,21 +3674,51 @@ function buildRouteSummary({
3417
3674
  nextActionType: nextAction?.type ?? null,
3418
3675
  completionState,
3419
3676
  });
3420
- // FR-003 (M01.2'): riskFloor is a top-level additive field emitted whenever ANY
3421
- // of rigor/fastPath/escalation stages is on — fastPath/escalation consumers read
3422
- // it even when routeSchema is off. All three off → absent, byte-identical route.
3423
- const riskStageOn = ['rigor', 'fastPath', 'escalation']
3424
- .some((key) => resolveRouteStage(runtimeConfig, key) !== 'off');
3425
- const riskFloor = riskStageOn
3426
- ? deriveRiskFloor({
3427
- promptText: routingContext.promptText,
3428
- commandText: routingContext.commandText,
3429
- targetFile: routingContext.targetFile ?? null,
3430
- executionMode,
3431
- activeSkillIds: activeSkills.map((entry) => entry.id),
3432
- contextPreview: contextRecommendation?.preview ?? null,
3433
- })
3434
- : null;
3677
+ // TASK-004 (BL-006): one shared resolver derives the deterministic route
3678
+ // fields — the identical object skill-router.sh merges into hook state.
3679
+ // The explicit routeSchemaStage param remains authoritative for the resolved
3680
+ // groups (callers may force it independent of runtimeConfig); every other
3681
+ // stage read comes from runtimeConfig inside deriveRouteFields.
3682
+ const routeFields = deriveRouteFields({
3683
+ promptText: routingContext.promptText,
3684
+ commandText: routingContext.commandText,
3685
+ targetFile: routingContext.targetFile ?? null,
3686
+ intentMode: routingContext.intentMode ?? null,
3687
+ taskType,
3688
+ executionMode,
3689
+ activeSkillIds: activeSkills.map((entry) => entry.id),
3690
+ verificationRecommendation,
3691
+ contextPreview: contextRecommendation?.preview ?? null,
3692
+ executionScores: routingContext.executionScores ?? {},
3693
+ lastExplicitUserPromptText: routingContext.lastExplicitUserPromptText ?? null,
3694
+ historySignals,
3695
+ config: routeSchemaStage === 'off'
3696
+ ? runtimeConfig
3697
+ : {
3698
+ ...(runtimeConfig && typeof runtimeConfig === 'object' ? runtimeConfig : {}),
3699
+ routing: {
3700
+ ...(runtimeConfig?.routing && typeof runtimeConfig.routing === 'object'
3701
+ ? runtimeConfig.routing
3702
+ : {}),
3703
+ routeSchema: { stage: routeSchemaStage },
3704
+ },
3705
+ },
3706
+ });
3707
+ const riskFloor = routeFields.riskFloor;
3708
+ const fastPath = routeFields.fastPath;
3709
+ const resolvedRouteFields = routeFields.resolved;
3710
+ // C89 TASK-005 (SPEC §6/§11): typed delegation advice shadow — additive,
3711
+ // gated on subagentOrchestrator.roleAdvice.stage ('off' → null, route
3712
+ // byte-identical). Wraps the deterministic owner verdict; never spawns.
3713
+ const delegationAdvice = deriveTypedDelegationAdvice({
3714
+ activeSkills,
3715
+ routingContext,
3716
+ contextRecommendation,
3717
+ verificationRecommendation,
3718
+ autonomyLevel,
3719
+ riskFloor,
3720
+ config: runtimeConfig,
3721
+ });
3435
3722
  const riskSegment = formatRiskFloorSegment(riskFloor);
3436
3723
  // FR-003 (M01.2' limits fragment): the rigor advisory — emitted whenever
3437
3724
  // rigor.stage is on, independent of fastPath/escalation. Stage off → no
@@ -3439,36 +3726,7 @@ function buildRouteSummary({
3439
3726
  const limitsSegment = resolveRouteStage(runtimeConfig, 'rigor') !== 'off'
3440
3727
  ? formatLimitsSegment(deriveCeremonyLimits(executionContract))
3441
3728
  : null;
3442
- // FR-004 (M01.3'): fastPath is emitted whenever its own stage is on — the field
3443
- // is always set then (eligible or not) so telemetry/harness can read it; the
3444
- // route-line segment prints only for eligible routes.
3445
- const fastPathStage = resolveRouteStage(runtimeConfig, 'fastPath');
3446
- const fastPath = fastPathStage !== 'off'
3447
- ? {
3448
- eligible: false,
3449
- suppressed: [],
3450
- reasons: [],
3451
- ...deriveFastPath({
3452
- executionMode,
3453
- targetFile: routingContext.targetFile ?? null,
3454
- riskFloor,
3455
- verificationRecommendation,
3456
- contextPreview: contextRecommendation?.preview ?? null,
3457
- }),
3458
- stage: fastPathStage,
3459
- }
3460
- : null;
3461
3729
  const fastPathSegment = formatFastPathSegment(fastPath);
3462
- const resolvedRouteFields = routeSchemaStage !== 'off'
3463
- ? buildResolvedRouteFields({
3464
- routingContext,
3465
- activeSkillIds: activeSkills.map((entry) => entry.id),
3466
- executionMode,
3467
- executionContract,
3468
- completionState,
3469
- riskFloor,
3470
- })
3471
- : null;
3472
3730
  const helperHint = compactHelperHint(
3473
3731
  compactHelperLane
3474
3732
  ? contextRecommendation?.command
@@ -3527,6 +3785,16 @@ function buildRouteSummary({
3527
3785
  executionMode,
3528
3786
  executionScores,
3529
3787
  executionCandidates,
3788
+ // TASK-004 resolver fields — the schema-off contract requires these keys be
3789
+ // absent entirely, so they ride the same `resolved` gate (schema stage != off).
3790
+ ...(resolvedRouteFields
3791
+ ? {
3792
+ rigor: routeFields.rigor,
3793
+ resumable: routeFields.resumable,
3794
+ escalationTriggers: routeFields.escalationTriggers,
3795
+ decisionShadow: routeFields.decisionShadowFields,
3796
+ }
3797
+ : {}),
3530
3798
  approachSelector,
3531
3799
  executionContract,
3532
3800
  tierLane,
@@ -3537,6 +3805,10 @@ function buildRouteSummary({
3537
3805
  ...(riskFloor ? { riskFloor } : {}),
3538
3806
  // FR-004 additive top-level field — present only when fastPath stage is on.
3539
3807
  ...(fastPath ? { fastPath } : {}),
3808
+ // BL-013 additive top-level field — present only when the caller supplied
3809
+ // a history struct (extraction ran, possibly degraded). Null/absent keeps
3810
+ // the route byte-identical for callers without a transcript.
3811
+ ...(routeFields.historySignals ? { historySignals: routeFields.historySignals } : {}),
3540
3812
  continuationState,
3541
3813
  autonomyLevel,
3542
3814
  continuousExecution,
@@ -3562,6 +3834,10 @@ function buildRouteSummary({
3562
3834
  ...(routingContext.visionModel ? { visionModel: routingContext.visionModel } : {}),
3563
3835
  ...(routingContext.visionAdvisory ? { visionAdvisory: routingContext.visionAdvisory } : {}),
3564
3836
  } : {}),
3837
+ // C89 TASK-005 additive field — typed delegation advice shadow; present
3838
+ // only when subagentOrchestrator.roleAdvice.stage != 'off' (conditional
3839
+ // spread keeps the stage-off route byte-identical).
3840
+ ...(delegationAdvice ? { delegationAdvice } : {}),
3565
3841
  line: line || 'task=unknown',
3566
3842
  };
3567
3843
  }
@@ -3680,33 +3956,225 @@ function isInformationalPrompt({
3680
3956
  const strongQuestion = raw.includes('?')
3681
3957
  || /^(what|how|when|which|where|who|is|are|does|do|did|can|could|would|should|will)\b/.test(raw)
3682
3958
  || /(là\s+(?:[^\s]+\s+){0,2}gì|thế nào|như thế nào|bao nhiêu|khi nào|bao giờ|ở đâu)/.test(raw);
3959
+ // C85-019: consult-shaped questions overrule subject-matter signal scores.
3960
+ // "should I fix X?", "X chưa nhỉ?", "có nên/có cần sửa không?" mention
3961
+ // mutation vocabulary as SUBJECT — the ask is a decision/answer, not an
3962
+ // order. Tight on purpose: modal-subject inversion restricted to
3963
+ // should/shall (decision asks) — "how do I fix…?" and "bạn có thể sửa…
3964
+ // giúp tôi không?" are work-shaped questions, not consults.
3965
+ const folded = raw.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/\u0111/g, 'd');
3966
+ const politeAsk = /\b(?:giup|giuong|ho|dum)\b[^\n?,;]{0,60}\b(?:khong|không)\s*[?!.]?\s*$/.test(folded)
3967
+ || /\bcho\b[^\n?,;]{0,60}\b(?:khong|không)\s*[?!.]?\s*$/.test(folded);
3968
+ const consultShape = /(?:^|[.!;\n]\s*)(?:should|shall)\s+(?:i|we)\s+(?!(?:i|we)\s)/.test(folded)
3969
+ || /\b(?:co|có)\s+(?:nen|can|phai)\s+/.test(folded)
3970
+ || /\b(?:chua|duoc khong|dung khong|khong nhi|nhi|roi)\s*[.!?]*\s*$/.test(folded)
3971
+ || (!politeAsk && /\b(?:khong|không)\s*[?.!]?\s*$/.test(folded))
3972
+ || /\bchi\s+(?:hoi|tu\s+van|giai\s+thich|xem)\b/.test(folded);
3683
3973
  if (scores) {
3684
- if (
3974
+ if (!consultShape && (
3685
3975
  scores.implementSignal
3686
3976
  || scores.reviewSignal
3687
3977
  || scores.debugSignal
3688
3978
  || scores.impactSignal
3689
3979
  || scores.smallFixSignal
3690
3980
  || scores.directTransformSignal
3691
- ) {
3981
+ )) {
3692
3982
  return false;
3693
3983
  }
3694
- if (scores.failureSignal && !strongQuestion) {
3984
+ if (scores.failureSignal && !strongQuestion && !consultShape) {
3695
3985
  return false;
3696
3986
  }
3697
3987
  }
3988
+ // C85-019: consult-shape also overrides the vocabulary vetoes — the
3989
+ // mutation/debug/lỗi words are the question's SUBJECT, not the ask.
3698
3990
  const implementWords = /(?<![A-Za-z0-9_])(implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|sửa|thêm|tạo|xóa|đổi|thay thế|cập nhật|viết|build|chạy|cài)(?![A-Za-z0-9_])/.test(raw);
3699
- if (implementWords) return false;
3991
+ if (implementWords && !consultShape) return false;
3700
3992
  const investigationWords = /\b(why|debug|triage|root cause|investigate|tại sao)\b/.test(raw);
3701
- if (investigationWords) return false;
3993
+ if (investigationWords && !consultShape) return false;
3702
3994
  // \b never matches around 'lỗi' (diacritics are not \w), so test it as a plain
3703
3995
  // substring: an error report is informational only when phrased as a question.
3704
- if (raw.includes('lỗi') && !strongQuestion) return false;
3996
+ if (raw.includes('lỗi') && !strongQuestion && !consultShape) return false;
3705
3997
  const reviewWords = /\b(review|audit|verify)\b/.test(raw);
3706
- if (reviewWords) return false;
3707
- return questionSignal;
3998
+ if (reviewWords && !consultShape) return false;
3999
+ // C85-019: consult shape is itself a question signal (Vietnamese tails like
4000
+ // "chưa"/"rồi" never match the Latin interrogative regex above).
4001
+ return questionSignal || consultShape;
4002
+ }
4003
+
4004
+ // TASK-C85-019: an explicit no-change/advisory assertion overrides the signal
4005
+ // scores. A report or advisory question carrying fix vocabulary as SUBJECT
4006
+ // MATTER ("không tạo thay đổi", "giữ nguyên mã", "do not change anything",
4007
+ // "chỉ tư vấn") still fires implementSignal/smallFixSignal/failureSignal, which
4008
+ // used to route the turn mutating so the completion gate demanded an Edit the
4009
+ // user explicitly declined. The override fires only when an assertion phrase is
4010
+ // present AND no unambiguous implement order survives outside the
4011
+ // negated/advisory/quoted/report clauses: "sửa file X nhưng giữ nguyên Y"
4012
+ // keeps its mutation lane because 'sửa file X' is outside the keep-clause,
4013
+ // while "hook cần được cấu hình để chỉ yêu cầu bằng chứng sửa file" is a
4014
+ // description of the hook's behavior, not an order. Folded (diacritic-free)
4015
+ // text is matched so both Vietnamese spellings work. Interrogative-deontic
4016
+ // forms ("should I…?", "có cần sửa…?") count as advisory requests, not orders;
4017
+ // a bare "không biết…chưa" uncertainty is NOT an assertion and stays on the
4018
+ // plain question path.
4019
+ function hasAdvisoryOnlyAssertion({ promptText = '', commandText = '' } = {}) {
4020
+ const folded = `${promptText ?? ''}\n${commandText ?? ''}`
4021
+ .toLowerCase()
4022
+ .normalize('NFD')
4023
+ .replace(/[\u0300-\u036f]/g, '')
4024
+ .replace(/\u0111/g, 'd');
4025
+ const trimmed = folded.trim();
4026
+ if (!trimmed) return false;
4027
+ const advisoryAssertion = /\b(?:do\s+not|don't|dont)\s+(?:change|edit|fix|modify|alter|touch)\b/
4028
+ .test(trimmed)
4029
+ || /\bno\s+(?:changes?|edits?|updates?|modifications?)\b/.test(trimmed)
4030
+ || /\bkeep\s+[^\n.,;]{0,60}\b(?:unchanged|as\s+is|the\s+same|intact)\b/.test(trimmed)
4031
+ || /\badvisory\s+only\b/.test(trimmed)
4032
+ || /\bonly\s+(?:advis|ask|question|answer|explain|consult|report)/.test(trimmed)
4033
+ || /\bjust\s+(?:advis|ask|question|answer|explain|consult|report)/.test(trimmed)
4034
+ || /(?:^|[.,;\n]\s*)(?:should|shall)\s+(?:i|we|you)\b/.test(trimmed)
4035
+ || /\bgiu\s+nguyen\b/.test(trimmed)
4036
+ || /\bmuon\s+giu\b/.test(trimmed)
4037
+ || /\bkhong\s+(?:can\s+|muon\s+|phai\s+)?(?:sua|thay\s+doi|tao\s+thay\s+doi|edit|fix|chinh\s+sua|trien\s+khai|cap\s+nhat)\b/
4038
+ .test(trimmed)
4039
+ || /\bdung\s+(?:sua|thay\s+doi|edit|chinh)\b/.test(trimmed)
4040
+ || /\bchi\s+(?:tu\s+van|hoi|giai\s+thich|xem|la\s+cau\s+hoi)\b/.test(trimmed)
4041
+ || /\bco\s+(?:can|nen)\s+sua\b/.test(trimmed)
4042
+ // C89-008 (user-reported 9-stop loop): a waiting/no-change turn asserts the
4043
+ // answer is advisory — "đang chờ kết quả test QAS, không có gì cần sửa" —
4044
+ // but matches none of the shapes above and is not question-shaped, so it
4045
+ // escaped every informational gate and the stop hook demanded an Edit the
4046
+ // honest turn could never produce. 'không có gì' needs its own trigger —
4047
+ // the generic 'không (cần) sửa' pattern cannot skip the 'có gì' gap.
4048
+ || /\bkhong\s+co\s+gi\s+(?:can|phai|de|dang|con)\s+(?:sua|thay\s+doi|lam|chinh|edit|fix)\b/.test(trimmed)
4049
+ || /\bnothing\s+(?:to\s+(?:fix|edit|change|update|modify|do)|needs?\s+(?:fixing|editing|changing|updating))\b/.test(trimmed)
4050
+ || /\bdang\s+(?:cho|doi)\b/.test(trimmed)
4051
+ || /\b(?:waiting|awaiting)\s+(?:for|on)\b/.test(trimmed)
4052
+ || /\bchua\s+(?:can|nen|phai)\s+(?:sua|thay\s+doi|lam|chinh|edit|fix)\b/.test(trimmed)
4053
+ || /\b(?:cho|doi)\s+(?:ket\s+qua|bao\s+cao|feedback|phe\s+duyet|duyet|phien\s+ban|ci|test|qas|review|build|deploy|approval|xong)\b/.test(trimmed);
4054
+ if (!advisoryAssertion) return false;
4055
+ // Strip quoted spans and every clause class that can carry mutation
4056
+ // vocabulary without being an order: the advisory/negated assertions
4057
+ // themselves, conditional or purpose clauses (nếu/để/cho/if), reported
4058
+ // demands of another agent (yêu cầu/ép/bắt/requires/forced), passive
4059
+ // necessity descriptions (cần được cấu hình/must be configured), and the
4060
+ // ledger's own evidence-class names. Whatever mutation verb remains after
4061
+ // the strip is an actual implement order and vetoes the advisory route.
4062
+ // C85-019c: clause strips must stop BEFORE a conjunction-joined imperative
4063
+ // — "giữ nguyên header và sửa lỗi login" keeps 'và sửa lỗi' as a surviving
4064
+ // order; an unbounded [^.,;\n]* swallows it and the order escapes the gate.
4065
+ // TAIL = lazy strip ending before 'và'/'and' (folded va|and) or EOS.
4066
+ const TAIL = '[^.,;\\n]*?(?=\\s+(?:va|and)\\s+(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;]|\\s*$)';
4067
+ // C89-008: WAIT_TAIL ends a waiting-clause strip before any coordinating
4068
+ // conjunction ('và/and/nhưng/rồi/then/later/sau đó') followed by anything up
4069
+ // to a mutation verb, or at punctuation/EOS — "đang chờ kết quả nhưng sửa
4070
+ // file này" keeps 'sửa file này' as a surviving order.
4071
+ const WAIT_TAIL = '[^.,;\\n]*?(?=\\s+(?:va|and|nhung|roi|then|later|sau\\s+do)\\s+[^.,;\\n]*?\\b(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;]|\\s*$)';
4072
+ const residue = trimmed
4073
+ .replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
4074
+ .replace(/\bwrite-evidence\b|\bverification-evidence\b|\bimpact-evidence\b|\broot-cause\b/g, ' ')
4075
+ .replace(new RegExp(`\\bgiu\\s+nguyen\\b${TAIL}`, 'g'), ' ')
4076
+ .replace(new RegExp(`\\bmuon\\s+giu\\b${TAIL}`, 'g'), ' ')
4077
+ .replace(new RegExp(`\\bkhong\\s+(?!chi\\b)${TAIL}`, 'g'), ' ')
4078
+ .replace(new RegExp(`\\bdung\\s+${TAIL}`, 'g'), ' ')
4079
+ // C89-008: a waiting clause narrates what the TURN waits on, not what the
4080
+ // request orders — verbs inside it ("chờ sửa xong", "waiting for the fix")
4081
+ // belong to the wait, not to the implement order.
4082
+ .replace(new RegExp(`\\bdang\\s+(?:cho|doi)\\b${WAIT_TAIL}`, 'g'), ' ')
4083
+ .replace(new RegExp(`\\b(?:waiting|awaiting)\\s+(?:for|on)\\b${WAIT_TAIL}`, 'g'), ' ')
4084
+ .replace(new RegExp(`\\b(?:cho|doi)\\s+(?:ket\\s+qua|bao\\s+cao|feedback|phe\\s+duyet|duyet|phien\\s+ban|ci|test|qas|review|build|deploy|approval|xong)\\b${WAIT_TAIL}`, 'g'), ' ')
4085
+ .replace(new RegExp(`\\bnothing\\s+(?:to|needs?)\\b${TAIL}`, 'g'), ' ')
4086
+ // 'chưa cần/nên/phải sửa' defers the change — same class as 'không cần
4087
+ // sửa' but 'chưa' is not covered by the 'không' strip above. WAIT_TAIL
4088
+ // bounds it so '…nhưng sửa phần này' keeps the surviving order.
4089
+ .replace(new RegExp(`\\bchua\\s+(?:can|nen|phai)\\s+${WAIT_TAIL}`, 'g'), ' ')
4090
+ .replace(new RegExp(`\\b(?:neu|if)\\s+${TAIL}`, 'g'), ' ')
4091
+ .replace(new RegExp(`\\b(?:de|cho)\\s+${TAIL}`, 'g'), ' ')
4092
+ .replace(new RegExp(`\\b(?:yeu\\s+cau|doi\\s+hoi|ep\\s+phai|bat\\s+buoc|bat\\s+phai)\\b${TAIL}`, 'g'), ' ')
4093
+ .replace(new RegExp(`\\b(?:demand(?:ed|s)?|require(?:d|s)?|forcing|forced|forcibly|must|has\\s+to|have\\s+to)\\b${TAIL}`, 'g'), ' ')
4094
+ .replace(new RegExp(`\\b(?:do\\s+not|don't|dont|not|never|no)\\s+(?!only\\b)${TAIL}`, 'g'), ' ')
4095
+ .replace(new RegExp(`\\b(?:can|nen|phai|duoc)\\s+(?:duoc\\s+)?(?:sua|cau\\s+hinh|thay\\s+doi|cap\\s+nhat|edit|fix|config)\\b${TAIL}`, 'g'), ' ')
4096
+ .replace(/\bco\s+(?:can|nen)\s+sua\b[^.,;\n?]*/g, ' ')
4097
+ .replace(/(?:^|[.,;\n]\s*)(?:should|shall)\s+(?:i|we|you)\b[^.,;\n?]*/g, ' ')
4098
+ .replace(/\bchi\s+(?:tu\s+van|hoi|giai\s+thich|xem|la\s+cau\s+hoi|can|la)\b[^.,;\n]*/g, ' ')
4099
+ .replace(/\b(?:just|only|simply)\s+(?:advis\w*|asking?|a\s+question|answering?|explaining?|consulting|reporting)\b[^.,;\n]*/g, ' ')
4100
+ .replace(/\badvisory\s+only\b[^.,;\n]*/g, ' ')
4101
+ .replace(new RegExp(`\\bkeep\\s+[^\\n.,;]{0,60}\\b(?:unchanged|as\\s+is|the\\s+same|intact)\\b${TAIL}`, 'g'), ' ');
4102
+ const mutationOrder = /(?<![A-Za-z0-9_])(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\s+the|cap\s+nhat|viet|chay|cai|chinh|trien\s+khai|cau\s+hinh)(?![A-Za-z0-9_])/.test(residue);
4103
+ return !mutationOrder;
4104
+ }
4105
+ // C85-019: mutation vocabulary ≠ mutation order. The same residue logic the
4106
+ // advisory gate uses — quotes, negated clauses, conditionals (nếu/để/cho/if),
4107
+ // reported demands (yêu cầu/ép/requires), necessity phrases (cần được/must be),
4108
+ // evidence-class names — decides whether ANY surviving verb is an imperative.
4109
+ // Used by hasAdvisoryOnlyAssertion (assertion + no order → advisory) and by
4110
+ // the question-shape gate below (question + no order → consult, even when the
4111
+ // subject vocabulary fires debug/failure/implement signal scores).
4112
+ function hasMutationOrder({ promptText = '', commandText = '' } = {}) {
4113
+ const folded = `${promptText ?? ''}\n${commandText ?? ''}`
4114
+ .toLowerCase()
4115
+ .normalize('NFD')
4116
+ .replace(/[\u0300-\u036f]/g, '')
4117
+ .replace(/\u0111/g, 'd');
4118
+ const trimmed = folded.trim();
4119
+ if (!trimmed) return false;
4120
+ // C85-019c: clause strips must stop BEFORE a conjunction-joined imperative
4121
+ // — "giữ nguyên header và sửa lỗi login" keeps 'và sửa lỗi' as a surviving
4122
+ // order; an unbounded [^.,;\n]* swallows it and the order escapes the gate.
4123
+ // TAIL = lazy strip ending before 'và'/'and' (folded va|and) or EOS.
4124
+ const TAIL = '[^.,;\\n]*?(?=\\s+(?:va|and)\\s+(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;]|\\s*$)';
4125
+ // C89-008: WAIT_TAIL ends a waiting-clause strip before a coordinating
4126
+ // conjunction ('và/and/nhưng/rồi/then/later/sau đó') followed by anything up
4127
+ // to a mutation verb, or at punctuation/EOS.
4128
+ const WAIT_TAIL = '[^.,;\\n]*?(?=\\s+(?:va|and|nhung|roi|then|later|sau\\s+do)\\s+[^.,;\\n]*?\\b(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;]|\\s*$)';
4129
+ const residue = trimmed
4130
+ .replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
4131
+ .replace(/\bwrite-evidence\b|\bverification-evidence\b|\bimpact-evidence\b|\broot-cause\b/g, ' ')
4132
+ .replace(new RegExp(`\\bgiu\\s+nguyen\\b${TAIL}`, 'g'), ' ')
4133
+ .replace(new RegExp(`\\bmuon\\s+giu\\b${TAIL}`, 'g'), ' ')
4134
+ .replace(new RegExp(`\\bkhong\\s+(?!chi\\b)${TAIL}`, 'g'), ' ')
4135
+ .replace(new RegExp(`\\bdung\\s+${TAIL}`, 'g'), ' ')
4136
+ // C89-008: a waiting clause narrates what the TURN waits on, not what the
4137
+ // request orders — verbs inside it belong to the wait.
4138
+ .replace(new RegExp(`\\bdang\\s+(?:cho|doi)\\b${WAIT_TAIL}`, 'g'), ' ')
4139
+ .replace(new RegExp(`\\b(?:waiting|awaiting)\\s+(?:for|on)\\b${WAIT_TAIL}`, 'g'), ' ')
4140
+ .replace(new RegExp(`\\b(?:cho|doi)\\s+(?:ket\\s+qua|bao\\s+cao|feedback|phe\\s+duyet|duyet|phien\\s+ban|ci|test|qas|review|build|deploy|approval|xong)\\b${WAIT_TAIL}`, 'g'), ' ')
4141
+ .replace(new RegExp(`\\bnothing\\s+(?:to|needs?)\\b${TAIL}`, 'g'), ' ')
4142
+ // 'chưa cần/nên/phải sửa' defers the change — WAIT_TAIL bounds it so a
4143
+ // '…nhưng sửa phần này' order survives.
4144
+ .replace(new RegExp(`\\bchua\\s+(?:can|nen|phai)\\s+${WAIT_TAIL}`, 'g'), ' ')
4145
+ .replace(new RegExp(`\\b(?:neu|if)\\s+${TAIL}`, 'g'), ' ')
4146
+ // C85-019b: a "khi <subject> <verb>" when-clause is temporal context —
4147
+ // "khi tôi cài hệ thống này" narrates WHEN something happens; the verb
4148
+ // inside it is never the order.
4149
+ .replace(new RegExp(`\\bkhi\\s+(?:toi|ta|ban|no|anh|chi|he|she|we)\\s+[^.,;\\n?]*?(?=\\s+(?:va|and)\\b|\\s*$)`, 'g'), ' ')
4150
+ .replace(new RegExp(`\\b(?:de|cho)\\s+${TAIL}`, 'g'), ' ')
4151
+ .replace(new RegExp(`\\b(?:yeu\\s+cau|doi\\s+hoi|ep\\s+phai|bat\\s+buoc|bat\\s+phai)\\b${TAIL}`, 'g'), ' ')
4152
+ .replace(new RegExp(`\\b(?:demand(?:ed|s)?|require(?:d|s)?|forcing|forced|forcibly|must|has\\s+to|have\\s+to)\\b${TAIL}`, 'g'), ' ')
4153
+ .replace(new RegExp(`\\b(?:do\\s+not|don't|dont|not|never|no)\\s+(?!only\\b)${TAIL}`, 'g'), ' ')
4154
+ .replace(new RegExp(`\\b(?:can|nen|phai|duoc)\\s+(?:duoc\\s+)?(?:sua|cau\\s+hinh|thay\\s+doi|cap\\s+nhat|edit|fix|config)\\b${TAIL}`, 'g'), ' ')
4155
+ .replace(/\bco\s+(?:can|nen)\s+sua\b[^.,;\n?]*/g, ' ')
4156
+ .replace(/(?:^|[.,;\n]\s*)(?:should|shall)\s+(?:i|we|you)\b[^.,;\n?]*/g, ' ')
4157
+ .replace(/\bchi\s+(?:tu\s+van|hoi|giai\s+thich|xem|la\s+cau\s+hoi|can|la)\b[^.,;\n]*/g, ' ')
4158
+ .replace(/\b(?:just|only|simply)\s+(?:advis\w*|asking?|a\s+question|answering?|explaining?|consulting|reporting)\b[^.,;\n]*/g, ' ')
4159
+ .replace(/\badvisory\s+only\b[^.,;\n]*/g, ' ')
4160
+ .replace(new RegExp(`\\bkeep\\s+[^\\n.,;]{0,60}\\b(?:unchanged|as\\s+is|the\\s+same|intact)\\b${TAIL}`, 'g'), ' ')
4161
+ // C85-019b: question-tagged mutation verbs are asks, not orders. English:
4162
+ // "<modal> I/we <verb>" (should I fix). Vietnamese: S-V-order status
4163
+ // questions — "<verb> … (chưa|được không|rồi|nhỉ)?" means "has it been
4164
+ // done", never "do it". EXCLUDED: "… giúp/cho/hộ/dùm tôi không?" — the
4165
+ // 'không' there is a polite request marker, not a status tag, and the
4166
+ // mutation verb is a real order; also EXCLUDED are wh-led modals
4167
+ // ("which files should I update") — a selection ask is a plan, not a
4168
+ // yes/no consult. These strips run LAST so a real imperative
4169
+ // mid-sentence ("sửa file X cho tôi") still survives.
4170
+ .replace(/\b(?<!\b(?:which|what|how)\s[^.,;?]{0,30})(?:should|shall)\s+(?:i|we)\s+[^\n?]*[?]?/g, ' ')
4171
+ .replace(/[^\n.,;?]{0,80}\b(?:chua|duoc khong|dung khong|khong nhi|nhi|roi|a)\s*[?]?\s*$/g, ' ')
4172
+ .replace(/^(?!.*\b(?:giup|giuong|cho|ho|dum)\b)[^\n.,;?]{0,80}\b(?:khong|không)\s*[?]?\s*$/g, ' ')
4173
+ .replace(/\b(?:co|có)\s+(?:nen|can|phai)\s+[^\n?]*[?]?/g, ' ');
4174
+ return /(?<![A-Za-z0-9_])(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\s+the|cap\s+nhat|viet|chay|cai|chinh|trien\s+khai|cau\s+hinh)(?![A-Za-z0-9_])/.test(residue);
3708
4175
  }
3709
4176
 
4177
+
3710
4178
  function deriveExecutionMode({
3711
4179
  promptText = '',
3712
4180
  commandText = '',
@@ -3739,6 +4207,15 @@ function deriveExecutionMode({
3739
4207
  return 'informational';
3740
4208
  }
3741
4209
 
4210
+ // TASK-C85-019: an explicit no-change/advisory assertion beats every signal
4211
+ // score — the prompt mentions mutation vocabulary as subject matter, not as
4212
+ // an order. Checked before the review/delivery/consultation gates so an
4213
+ // advisory turn never mints write or verification debt; a surviving
4214
+ // imperative outside the negated clause still routes mutating.
4215
+ if (hasAdvisoryOnlyAssertion({ promptText, commandText })) {
4216
+ return 'informational';
4217
+ }
4218
+
3742
4219
  if (
3743
4220
  releaseVerificationContinuation
3744
4221
  || ((intentMode === 'review-specific' || explicitReviewLead) && !scores.implementSignal)
@@ -3756,25 +4233,27 @@ function deriveExecutionMode({
3756
4233
  || /^(?:lam sao|the nao|nhu the nao|nhu nao|vi sao|tai sao|khi nao|bao gio|bao nhieu|o dau|phai lam gi|lam gi|lam cach nao)\b/.test(foldedPromptText);
3757
4234
  const questionShapedPrompt = /\?\s*$/.test(trimmedPromptText)
3758
4235
  || leadingInterrogative
3759
- || /\b(?:la gi|duoc khong)\s*$/.test(foldedPromptText);
4236
+ || /\b(?:la gi|duoc khong)\s*$/.test(foldedPromptText)
4237
+ // C85-019: Vietnamese status/consult tails without a '?' — "… chưa",
4238
+ // "… chưa nhỉ", "… không nhỉ", "… đúng không", "… à". A question about a
4239
+ // defect must not mint the bug-fix floor's write/repro debt (user-reported
4240
+ // lock on "Không biết … chưa nhỉ").
4241
+ || /\b(?:chua|duoc khong|dung khong|khong nhi|khong|nhi)\s*[.!?]*\s*$/.test(foldedPromptText);
3760
4242
  // Implement verbs (Vietnamese included — the English-only scores above cannot see
3761
4243
  // them) mark an action order even when phrased as a question ("ban sua giup toi…?" is
3762
4244
  // an edit order, not a consultation). A leading interrogative overrides them:
3763
4245
  // "Lam sao ma do duoc khi toi cai…?" asks HOW something is done, it does not order
3764
4246
  // the action performed.
3765
4247
  const consultationImplementWords = /(?<![A-Za-z0-9_])(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|sửa|thêm|tạo|xóa|đổi|thay thế|cập nhật|viết|chạy|cài)(?![A-Za-z0-9_])/.test(trimmedPromptText);
4248
+ // C85-019: question shape + no surviving imperative = consult. The
4249
+ // pre-C85-019 all-zero-scores clause consulted only when NOTHING in the
4250
+ // prompt resembled work — impossible for a question about code. Replacing
4251
+ // it with hasMutationOrder keeps "sửa file cho tôi?" (an order wearing a
4252
+ // question mark) mutating while "lỗi này chưa nhỉ" / "should I fix the
4253
+ // hook?" / "Không biết … đã khắc phục chưa" consult. A targetFile still
4254
+ // vetoes: a named artifact is work, not a question.
3766
4255
  const consultationOnlySignal = questionShapedPrompt
3767
- && (leadingInterrogative || !consultationImplementWords)
3768
- && scores.editCertainty === 0
3769
- && !scores.implementSignal
3770
- && !scores.reviewSignal
3771
- && !scores.debugSignal
3772
- && !scores.failureSignal
3773
- && !scores.impactSignal
3774
- && !scores.buildSignal
3775
- && !scores.directTransformSignal
3776
- && !scores.smallFixSignal
3777
- && !scores.sharedRisk
4256
+ && !hasMutationOrder({ promptText, commandText })
3778
4257
  && !targetFile;
3779
4258
 
3780
4259
  if (deliveryOnlySignal || consultationOnlySignal) {
@@ -3930,117 +4409,6 @@ function executionModeRank(mode = '') {
3930
4409
  return index >= 0 ? index : orderedModes.length;
3931
4410
  }
3932
4411
 
3933
- function buildExecutionContract(executionMode = null) {
3934
- if (!executionMode) {
3935
- return null;
3936
- }
3937
-
3938
- const contracts = {
3939
- 'tiny-fix': {
3940
- modelTier: 'lite',
3941
- maxReadPasses: 0,
3942
- maxContextPulls: 0,
3943
- verificationPolicy: 'minimal-or-targeted',
3944
- completionRule: 'never-claim-done-without-write',
3945
- delegationPolicy: 'disallow',
3946
- completionEvidence: ['write-evidence'],
3947
- },
3948
- 'local-fix': {
3949
- modelTier: 'code',
3950
- maxReadPasses: 1,
3951
- maxContextPulls: 1,
3952
- verificationPolicy: 'targeted-if-covered',
3953
- completionRule: 'require-write',
3954
- delegationPolicy: 'disallow',
3955
- completionEvidence: ['write-evidence'],
3956
- },
3957
- 'local-build': {
3958
- modelTier: 'code',
3959
- maxReadPasses: 2,
3960
- maxContextPulls: 1,
3961
- verificationPolicy: 'targeted-if-covered',
3962
- completionRule: 'require-write-and-verification',
3963
- delegationPolicy: 'disallow-by-default',
3964
- completionEvidence: ['write-evidence', 'verification-evidence'],
3965
- },
3966
- 'find-cause': {
3967
- modelTier: 'code',
3968
- maxReadPassesBeforeReassess: 3,
3969
- verificationPolicy: 'root-cause-then-targeted',
3970
- completionRule: 'never-claim-fixed-without-write-and-verification',
3971
- delegationPolicy: 'allow-specialized-debug-lane',
3972
- completionEvidence: ['write-evidence', 'verification-evidence'],
3973
- },
3974
- 'shared-edit': {
3975
- modelTier: 'code',
3976
- maxReadPasses: 2,
3977
- maxContextPulls: 2,
3978
- verificationPolicy: 'targeted-then-widen-on-risk',
3979
- completionRule: 'require-write-and-verification',
3980
- delegationPolicy: 'allow-qualified-sidecar',
3981
- completionEvidence: ['write-evidence', 'verification-evidence'],
3982
- mirrorConsistencyRequired: true,
3983
- },
3984
- 'map-impact': {
3985
- modelTier: 'code',
3986
- maxReadPasses: 3,
3987
- maxContextPulls: 3,
3988
- verificationPolicy: 'impact-first-then-targeted-then-widen-on-risk',
3989
- completionRule: 'require-impact-evidence-before-edit-claim',
3990
- delegationPolicy: 'allow-impact-sidecar',
3991
- completionEvidence: ['impact-evidence', 'write-evidence', 'verification-evidence'],
3992
- mirrorConsistencyRequired: true,
3993
- },
3994
- 'review-release': {
3995
- modelTier: 'smart',
3996
- verificationPolicy: 'evidence-first',
3997
- completionRule: 'report-findings-not-implementation',
3998
- delegationPolicy: 'allow-review-sidecar',
3999
- completionEvidence: ['verification-evidence'],
4000
- },
4001
- };
4002
-
4003
- return contracts[executionMode] ? { ...contracts[executionMode] } : null;
4004
- }
4005
-
4006
- // Decision table v2 (V3_RESHAPE §5): the contracts table's modelTier column is the
4007
- // base row; resolveModelTier adds the (riskFloor, hostCapabilities) columns.
4008
- // Deterministic — no registry, no leases, no outbound capability calls.
4009
- // Literal mirror of src/core/executionContracts.js resolveModelTier; parity is
4010
- // locked by tests/consistency/executionContractSync.test.js.
4011
- const MODEL_TIER_ORDER = ['lite', 'code', 'smart'];
4012
- const MODEL_TIER_EFFORT = {
4013
- lite: 'low',
4014
- code: 'medium',
4015
- smart: 'high',
4016
- };
4017
-
4018
- /**
4019
- * Resolves {tier, effort, advisoryOnly} for a route. A 'high-risk' floor escalates
4020
- * the tier one band (lite→code→smart, capped at smart) and forces effort 'high'.
4021
- * Unknown mode → {tier:null, effort:null, advisoryOnly:true}. Missing/unknown
4022
- * riskFloor → 'none'. Missing hostCapabilities → advisoryOnly:true (the route text
4023
- * is all the host gets when it cannot bind model and effort).
4024
- */
4025
- export function resolveModelTier({
4026
- executionMode = null,
4027
- riskFloor = null,
4028
- hostCapabilities = null,
4029
- } = {}) {
4030
- const baseTier = buildExecutionContract(executionMode)?.modelTier ?? null;
4031
- if (!baseTier) {
4032
- return { tier: null, effort: null, advisoryOnly: true };
4033
- }
4034
- const highRisk = riskFloor?.floor === 'high-risk';
4035
- const tier = highRisk
4036
- ? MODEL_TIER_ORDER[Math.min(MODEL_TIER_ORDER.indexOf(baseTier) + 1, MODEL_TIER_ORDER.length - 1)]
4037
- : baseTier;
4038
- return {
4039
- tier,
4040
- effort: highRisk ? 'high' : MODEL_TIER_EFFORT[tier],
4041
- advisoryOnly: !(hostCapabilities?.canBindModel === true && hostCapabilities?.canBindEffort === true),
4042
- };
4043
- }
4044
4412
 
4045
4413
  function buildApproachSelectorResult({
4046
4414
  executionMode = null,
@@ -4105,37 +4473,6 @@ function buildApproachSelectorResult({
4105
4473
  };
4106
4474
  }
4107
4475
 
4108
- function buildCompletionState({ executionMode = null, verificationRecommendation = null } = {}) {
4109
- if (!executionMode || executionMode === 'informational') {
4110
- return null;
4111
- }
4112
-
4113
- const contract = buildExecutionContract(executionMode);
4114
- const missingEvidence = [...(contract?.completionEvidence ?? [])];
4115
- const requiresVerification = missingEvidence.includes('verification-evidence');
4116
- if (
4117
- requiresVerification
4118
- && verificationRecommendation
4119
- && !(verificationRecommendation.commands?.length || verificationRecommendation.fallbackCommands?.length)
4120
- ) {
4121
- missingEvidence.push('verification-plan');
4122
- }
4123
-
4124
- let reason = 'completion evidence is still required';
4125
- if (['tiny-fix', 'local-fix', 'local-build', 'shared-edit'].includes(executionMode)) {
4126
- reason = 'implement request has not produced an edit yet';
4127
- } else if (executionMode === 'review-release') {
4128
- reason = 'review/release evidence is still required before final claim';
4129
- } else if (executionMode === 'map-impact') {
4130
- reason = 'impact evidence is still required before safe completion claim';
4131
- }
4132
-
4133
- return {
4134
- claimAllowed: false,
4135
- missingEvidence,
4136
- reason,
4137
- };
4138
- }
4139
4476
 
4140
4477
  function buildContinuationState({
4141
4478
  nextActionType = null,
@@ -4588,26 +4925,6 @@ function buildHelperCommand({ commandNamespace = '.claude', scriptName, intent =
4588
4925
  return parts.join(' ');
4589
4926
  }
4590
4927
 
4591
- const SHARED_IMPACT_PATTERNS = [
4592
- /^\.claude\/hooks\//,
4593
- /^\.claude\/ukit\//,
4594
- /^\.codex\//,
4595
- /^src\/index\//,
4596
- /^src\/core\/(runInstallPipeline|applyPlan|buildPlan|diffPlan|metadata|migrateLegacy|uninstall|runtimeConfig|runtimePaths)\.js$/,
4597
- /^src\/core\/(output|token|compact)\//,
4598
- /^template_project\/\.claude\/hooks\//,
4599
- /^template_project\/\.claude\/ukit\//,
4600
- /^manifests\/platform\.full\.yaml$/,
4601
- /^template_project\//,
4602
- ];
4603
-
4604
- function isSharedImpactFile(filePath) {
4605
- const normalized = String(filePath ?? '').trim().replace(/\\/g, '/').replace(/^\.\//, '');
4606
- if (!normalized) {
4607
- return false;
4608
- }
4609
- return SHARED_IMPACT_PATTERNS.some((pattern) => pattern.test(normalized));
4610
- }
4611
4928
 
4612
4929
  function isTestLikeFile(filePath) {
4613
4930
  return /\.(test|spec)\.[a-z0-9]+$/i.test(filePath)
@@ -4851,7 +5168,7 @@ function isFlagOrValue(argv, index) {
4851
5168
  const arg = argv[index];
4852
5169
  if (!arg.startsWith('--')) {
4853
5170
  const prev = argv[index - 1];
4854
- return Boolean(prev && ['--root', '--target', '--type', '--tool-command', '--last-prompt', '--session-id', '--adapter'].includes(prev));
5171
+ return Boolean(prev && ['--root', '--target', '--type', '--tool-command', '--last-prompt', '--session-id', '--adapter', '--transcript'].includes(prev));
4855
5172
  }
4856
5173
  return true;
4857
5174
  }
@@ -4942,21 +5259,17 @@ function buildStructuredRouteFingerprintPayload(route = {}) {
4942
5259
  approachContextPolicy: routeSummary?.approachSelector?.contextPolicy ?? null,
4943
5260
  approachVerificationPolicy: routeSummary?.approachSelector?.verificationPolicy ?? null,
4944
5261
  approachCompletionRule: routeSummary?.approachSelector?.completionRule ?? null,
4945
- continuationRequired: routeSummary?.continuationState?.required ?? null,
4946
- continuationReasons: unique(routeSummary?.continuationState?.reasons ?? []),
4947
- continuationMilestone: routeSummary?.continuationState?.nextMilestone ?? null,
4948
- continuationRepeatCount: routeSummary?.continuationState?.repeatCount ?? null,
4949
- continuationStuckRisk: routeSummary?.continuationState?.stuckRisk ?? null,
4950
- continuationRescueMode: routeSummary?.continuationState?.rescueMode ?? null,
4951
- delegateHint: routeSummary?.delegateHint ?? null,
4952
- nextActionType: routeSummary?.nextActionType ?? null,
4953
- nextActionCommand: routeSummary?.nextActionCommand ?? null,
4954
- helperHint: routeSummary?.helperHint ?? null,
4955
5262
  completionRule: routeSummary?.executionContract?.completionRule ?? null,
4956
- completionMissingEvidence: unique(routeSummary?.completionState?.missingEvidence ?? []),
4957
- primaryCommands: unique(routeSummary?.primaryCommands ?? []),
4958
- fallbackCommands: unique(routeSummary?.fallbackCommands ?? []),
4959
5263
  preferredOrder: unique(routeSummary?.preferredOrder ?? []),
5264
+ // Fingerprint payload ends here — everything below was REMOVED (C85 fix
5265
+ // round): continuation/next-action/helper/command fields derive from the
5266
+ // LIVE session (prior context, ledger state, previousRouteSummary), so the
5267
+ // same re-asked prompt fingerprinted differently every emission. An
5268
+ // unstable routefp-v3 breaks every downstream join keyed on it —
5269
+ // historySignals.fixLoopCount (BL-013), ledger routeFingerprint joins
5270
+ // (BL-003/004), feedbackEvents re-route joins (BL-018), and the escalation
5271
+ // same-loop key itself. The fingerprint is a "same-symptom" join key, not
5272
+ // a dedupe key — stable request features only.
4960
5273
  };
4961
5274
  }
4962
5275
 
@@ -5113,6 +5426,17 @@ export function compactRouteSummary(routeSummary = null) {
5113
5426
  nextActionType: routeSummary.nextActionType ?? null,
5114
5427
  nextActionCommand: routeSummary.nextActionCommand ?? null,
5115
5428
  helperHint: routeSummary.helperHint ?? null,
5429
+ // TASK-007 (BL-009): playbook fields survive compaction so
5430
+ // skill-router-state.json and cached route states carry the identical
5431
+ // playbookId/workGroup the helper emitted. BL-010 adds the direct/handoff
5432
+ // path + the ARCH fast-path enum — the guard + eval fixtures read them
5433
+ // off persisted route state.
5434
+ playbookId: routeSummary.playbookId ?? null,
5435
+ workGroup: routeSummary.workGroup ?? null,
5436
+ path: routeSummary.path ?? null,
5437
+ fastPathKind: routeSummary.fastPathKind ?? null,
5438
+ ...(routeSummary.playbookReason ? { playbookReason: routeSummary.playbookReason } : {}),
5439
+ ...(routeSummary.playbookLoad ? { playbookLoad: routeSummary.playbookLoad } : {}),
5116
5440
  // M04.1 (TASK-007): the resumable-run pointer survives compaction so
5117
5441
  // reinject-context resumes from the same taskId/boundary. Conditional
5118
5442
  // spread — stage-off output stays byte-identical (no key when absent).
@@ -5130,6 +5454,24 @@ export function compactRouteSummary(routeSummary = null) {
5130
5454
  escalatedTier: routeSummary.escalatedTier,
5131
5455
  escalationReason: routeSummary.escalationReason ?? null,
5132
5456
  } : {}),
5457
+ // BL-019: the applied measured tier survives compaction — additive so
5458
+ // stage-off/no-map routes keep the pre-BL-019 compact shape.
5459
+ ...(routeSummary.measuredTier != null ? { measuredTier: routeSummary.measuredTier } : {}),
5460
+ // BL-018 (SPEC FR-006): the fix-loop escalation fields survive compaction
5461
+ // so persisted route state carries the deeper lane — the next emission's
5462
+ // exhaustion check and the stop gate read them off this record.
5463
+ // escalatedRouteFingerprint is the same-loop join key: the prior record
5464
+ // only binds when its stamped fingerprint equals the next emission's.
5465
+ ...(routeSummary.escalatedLane ? {
5466
+ escalatedLane: routeSummary.escalatedLane,
5467
+ escalateReason: routeSummary.escalateReason ?? null,
5468
+ ...(routeSummary.escalatedRouteFingerprint != null ? {
5469
+ escalatedRouteFingerprint: routeSummary.escalatedRouteFingerprint,
5470
+ } : {}),
5471
+ ...(routeSummary.escalatedVerificationDepth != null ? {
5472
+ escalatedVerificationDepth: routeSummary.escalatedVerificationDepth,
5473
+ } : {}),
5474
+ } : {}),
5133
5475
  // FR-005: riskEscalation is the Stop-gate carrier (FR-006) — it must survive
5134
5476
  // skill-router-state.json compaction. Conditional spread keeps stage-off
5135
5477
  // output byte-identical (no key emitted when the field is absent).
@@ -5138,6 +5480,14 @@ export function compactRouteSummary(routeSummary = null) {
5138
5480
  // persisted route state keeps the redacted outcome. Conditional spread —
5139
5481
  // stage-off output stays byte-identical (no key emitted when absent).
5140
5482
  ...(routeSummary.decisionPlane ? { decisionPlane: routeSummary.decisionPlane } : {}),
5483
+ // C89 TASK-005: the typed delegation advice survives compaction so
5484
+ // persisted route state keeps the shadow verdict. Conditional spread —
5485
+ // stage-off output stays byte-identical (no key emitted when absent).
5486
+ ...(routeSummary.delegationAdvice ? { delegationAdvice: routeSummary.delegationAdvice } : {}),
5487
+ // BL-013: bounded history counters survive compaction so persisted route
5488
+ // state + cached routes keep the identical struct. Conditional spread —
5489
+ // absent keeps output byte-identical.
5490
+ ...(routeSummary.historySignals ? { historySignals: routeSummary.historySignals } : {}),
5141
5491
  // M01.1 compact whitelist (C01): grouped fields survive compaction minus verbose
5142
5492
  // evidence; absent entirely when the route schema stage is off.
5143
5493
  ...(compactResolvedRoute(routeSummary) ?? {}),
@@ -5204,6 +5554,15 @@ function buildRouteAuditEntry({ route = null, state = null } = {}) {
5204
5554
 
5205
5555
  return {
5206
5556
  ts: Date.now(),
5557
+ // TASK-007 (BL-009): playbook route fields — join key for the playbook
5558
+ // spine (BL-010/011/012 read playbookId off the route row). BL-010 adds
5559
+ // the direct/handoff path + fast-path enum so the eval join can assert
5560
+ // `direct, fastPath=small-feature` (E-01).
5561
+ playbookId: routeSummary.playbookId ?? null,
5562
+ workGroup: routeSummary.workGroup ?? null,
5563
+ playbookLoad: routeSummary.playbookLoad ?? null,
5564
+ path: routeSummary.path ?? null,
5565
+ fastPathKind: routeSummary.fastPathKind ?? null,
5207
5566
  source: state?.source ?? 'task-route',
5208
5567
  requestKey: state?.requestKey ?? null,
5209
5568
  adapter: routingContext.adapter ?? 'claude',
@@ -5213,13 +5572,21 @@ function buildRouteAuditEntry({ route = null, state = null } = {}) {
5213
5572
  }),
5214
5573
  targetFile: route?.routingContext?.targetFile ?? null,
5215
5574
  taskType: routingContext.taskType ?? null,
5575
+ // BL-003: the ledger-visible join key — execution-ledger.mjs stamps
5576
+ // `routeFingerprint: routeState.fingerprint`, the same routefp-v3 digest
5577
+ // this state's `fingerprint` field carries.
5578
+ routeFingerprint: state?.fingerprint ?? null,
5216
5579
  // FR-202a (TASK-228): active-skill ids for phase-2 skill-accuracy roll-ups.
5217
5580
  // Telemetry only — deliberately excluded from dedupeKey/fingerprint inputs.
5218
5581
  skillIds: (route?.activeSkills ?? state?.activeSkills ?? [])
5219
- .map((s) => s?.id)
5220
- .filter(Boolean)
5582
+ .map((s) => (s && typeof s === 'object' ? s.id : s))
5583
+ .filter((id) => typeof id === 'string' && id)
5221
5584
  .slice(0, 8),
5222
5585
  executionMode: routeSummary.executionMode ?? null,
5586
+ // BL-019: the effective tier the route ran under — the join buckets
5587
+ // per-(mode,tier) reliability off this column. Telemetry only.
5588
+ modelTier: routeSummary?.executionContract?.modelTier ?? null,
5589
+ measuredTier: routeSummary?.measuredTier ?? null,
5223
5590
  competingMode: routeSummary?.approachSelector?.competingMode ?? null,
5224
5591
  competingScoreGap: routeSummary?.approachSelector?.competingScoreGap ?? null,
5225
5592
  candidateModes: candidateModes.slice(0, 3),
@@ -5228,6 +5595,14 @@ function buildRouteAuditEntry({ route = null, state = null } = {}) {
5228
5595
  repeatCount: routeSummary?.continuationState?.repeatCount ?? null,
5229
5596
  rescueMode: routeSummary?.continuationState?.rescueMode ?? null,
5230
5597
  wideningBlocked: routeSummary?.continuationState?.wideningBlocked ?? null,
5598
+ // BL-018: the deeper lane the fix-loop escalation chose (or 'blocked') —
5599
+ // the re-route feedback join keys on routeFingerprint, so a lane change
5600
+ // like this one is exactly the event that collector was built to see.
5601
+ escalatedLane: routeSummary?.escalatedLane ?? null,
5602
+ escalateReason: routeSummary?.escalateReason ?? null,
5603
+ // BL-013: bounded history counters on the audit row — same struct as the
5604
+ // route record, identical to the hook's audit emission.
5605
+ historySignals: routeSummary?.historySignals ?? null,
5231
5606
  };
5232
5607
  }
5233
5608
 
@@ -5296,7 +5671,39 @@ function mergeRouteAuditEntries(staged, parsed, newEntry) {
5296
5671
  seen.add(key);
5297
5672
  merged.push(item);
5298
5673
  }
5299
- return merged.slice(0, 40);
5674
+ // BL-004: rows past the 40-entry cap are spilled to
5675
+ // route-audit.segments.jsonl (append-only sidecar) instead of being dropped —
5676
+ // consumers read ring + segments with requestKey dedupe.
5677
+ return { entries: merged.slice(0, 40), evicted: merged.slice(40) };
5678
+ }
5679
+
5680
+ function routeAuditSegmentsPath(filePath) {
5681
+ return path.join(path.dirname(filePath), 'route-audit.segments.jsonl');
5682
+ }
5683
+
5684
+ async function spillRouteAuditEntries(filePath, evicted) {
5685
+ if (!evicted?.length) return;
5686
+ try {
5687
+ await fs.appendFile(
5688
+ routeAuditSegmentsPath(filePath),
5689
+ evicted.map((item) => JSON.stringify(item)).join('\n') + '\n',
5690
+ );
5691
+ } catch {
5692
+ // Spill write failure degrades to the pre-BL-004 drop — the audit merge
5693
+ // itself must never fail on telemetry sidecar I/O.
5694
+ }
5695
+ }
5696
+
5697
+ function spillRouteAuditEntriesSync(filePath, evicted) {
5698
+ if (!evicted?.length) return;
5699
+ try {
5700
+ fsSync.appendFileSync(
5701
+ routeAuditSegmentsPath(filePath),
5702
+ evicted.map((item) => JSON.stringify(item)).join('\n') + '\n',
5703
+ );
5704
+ } catch {
5705
+ // same degrade posture as the async spill above
5706
+ }
5300
5707
  }
5301
5708
 
5302
5709
  function parseRouteAuditSidecar(raw) {
@@ -5391,10 +5798,10 @@ function foldRouteAuditSidecarSync(filePath) {
5391
5798
  } catch {
5392
5799
  parsed = { entries: [] };
5393
5800
  }
5801
+ const { entries: folded, evicted } = mergeRouteAuditEntries(staged, parsed, null);
5802
+ spillRouteAuditEntriesSync(filePath, evicted);
5394
5803
  const tmpPath = `${filePath}.exitfold-${process.pid}`;
5395
- fsSync.writeFileSync(tmpPath, JSON.stringify({
5396
- entries: mergeRouteAuditEntries(staged, parsed, null),
5397
- }));
5804
+ fsSync.writeFileSync(tmpPath, JSON.stringify({ entries: folded }));
5398
5805
  fsSync.renameSync(tmpPath, filePath);
5399
5806
  } finally {
5400
5807
  try {
@@ -5456,9 +5863,9 @@ export async function appendRouteAuditEntry(filePath, entry) {
5456
5863
  }
5457
5864
 
5458
5865
  const parsed = await readJson(filePath, { entries: [] });
5459
- await writeJsonAtomic(filePath, {
5460
- entries: mergeRouteAuditEntries(sidecarEntries, parsed, entry),
5461
- });
5866
+ const { entries: mergedEntries, evicted } = mergeRouteAuditEntries(sidecarEntries, parsed, entry);
5867
+ await spillRouteAuditEntries(filePath, evicted);
5868
+ await writeJsonAtomic(filePath, { entries: mergedEntries });
5462
5869
  });
5463
5870
  if (!result.ok) {
5464
5871
  // Fail-closed on the merge (CX-8): the entry stays staged in the sidecar —