@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
@@ -0,0 +1,915 @@
1
+ // routeResolver.js — shared route resolver (C84 TASK-004, BL-006).
2
+ //
3
+ // One resolver computes the rich route fields for both router surfaces: the
4
+ // in-process canonical path (taskRouting.js → this module) and the installed
5
+ // hook path (skill-router.sh → route-resolver.mjs via pathToFileURL). Before
6
+ // this module the hook re-implemented the pipeline and emitted zero
7
+ // rigor/fastPath/riskFloor/resumable/decision-shadow fields — the router
8
+ // split-brain documented in GAP M03 / GTA §2 KEY FINDING.
9
+ //
10
+ // This module is deterministic and performs no IO beyond the config object the
11
+ // caller hands in — run-log/ledger IO (resumable-run emit, decision-plane
12
+ // receipts) stays in the route helpers.
13
+ //
14
+ // Mirror parity: template_project/.claude/ukit/index/route-resolver.mjs carries
15
+ // the identical logic with literal copies of the tables imported below (the
16
+ // mirror cannot import src/). Both twins are locked by
17
+ // tests/consistency/routeResolverParity.test.js.
18
+
19
+ import crypto from 'node:crypto';
20
+
21
+ import { buildRouteSignalText } from './languageTools.js';
22
+ import { normalizeHistorySignals } from './sessionHistoryExtractor.js';
23
+ import { isSharedImpactFile } from './impactCatalog.js';
24
+ import {
25
+ EXECUTION_CONTRACTS,
26
+ EXECUTION_MODE_ORDER,
27
+ MODEL_TIER_BY_CONTRACT,
28
+ ROUTE_EFFORTS,
29
+ buildExecutionContract,
30
+ resolveModelTier,
31
+ } from '../core/executionContracts.js';
32
+
33
+ export {
34
+ buildExecutionContract,
35
+ resolveModelTier,
36
+ isSharedImpactFile,
37
+ ROUTE_EFFORTS,
38
+ };
39
+
40
+ // --- M01.1: additive ResolvedTaskRoute v1 (docs/pstack/CONTRACTS.md C01) -------------------
41
+ // Emitted only when `routing.routeSchema.stage` (runtime config, default "off") is not
42
+ // "off". All fields are additive on top of the existing routeSummary shape — legacy
43
+ // top-level fields are never removed or renamed, so older consumers keep working.
44
+ export const ROUTE_VERSION = 1;
45
+ export const ROUTE_CONTRACT_VERSION = 1;
46
+ export const ROUTE_SCHEMA_STAGES = new Set(['off', 'shadow', 'canary', 'default']);
47
+ export const ROUTE_INTENT_KINDS = new Set(['informational', 'delivery', 'mutation', 'investigation', 'review']);
48
+ export const ROUTE_MUTABILITIES = new Set(['read-only', 'mutating', 'mixed']);
49
+ export const ROUTE_RIGOR_LEVELS = new Set(['R0', 'R1', 'R2', 'R3', 'R4']);
50
+ export const ROUTE_MODEL_TIERS = new Set(['lite', 'code', 'smart']);
51
+ export const ROUTE_RISK_FLOORS = new Set(['none', 'high-risk']);
52
+ // SPEC §5 FR-003: fixed table order — codes are emitted and printed in this order.
53
+ // The first six raise the floor to 'high-risk'; the last two are informational only.
54
+ export const ROUTE_RISK_REASON_CODES = Object.freeze([
55
+ 'security-sensitive',
56
+ 'shared-impact',
57
+ 'public-contract',
58
+ 'schema-change',
59
+ 'destructive-action',
60
+ 'cross-engine',
61
+ 'established-precedent',
62
+ 'explicit-local-target',
63
+ ]);
64
+ const ROUTE_RISK_FLOOR_RAISING_CODES = new Set(ROUTE_RISK_REASON_CODES.slice(0, 6));
65
+ const ROUTE_RISK_ENGINES = ['claude code', 'codex', 'omp'];
66
+
67
+ // FR-004 (M01.3'): Fast Path eligibility vocabulary. The suppressed list is fixed
68
+ // text per SPEC — deliberately not configurable. Reasons are the informational
69
+ // risk codes (the last two ROUTE_RISK_REASON_CODES entries) present on the route.
70
+ const FAST_PATH_MODES = new Set(['tiny-fix', 'local-fix']);
71
+ const FAST_PATH_SUPPRESSED = Object.freeze(['design', 'smart', 'subagents', 'broad-verify']);
72
+ const FAST_PATH_REASON_CODES = new Set(ROUTE_RISK_REASON_CODES.slice(6));
73
+
74
+ // 'informational' is a real router emission (no completion contract) even though it is
75
+ // not part of the seven-lane EXECUTION_MODE_ORDER ladder.
76
+ export const ROUTE_EXECUTION_MODES = [...EXECUTION_MODE_ORDER, 'informational'];
77
+ const ROUTE_ESCALATION_CEILING = 'review-release';
78
+ const ROUTE_GOAL_MAX_LENGTH = 240;
79
+
80
+ // Decision-shadow families — the deterministic descriptor attached to resolver
81
+ // output (and the question list the helper path's shadow hook batches). Kept
82
+ // next to the stage resolvers so hook and helper resolve the same stage map.
83
+ // BL-017 (SPEC FR-005): extended families — playbook-select, review-trigger,
84
+ // cross-check-depth, laneDeepening, verificationDepth. They ship at the
85
+ // inherited decision-plane stage (shadow) via resolveDecisionFamilyStage —
86
+ // no runtimeConfig schema change.
87
+ const DECISION_SHADOW_EXTENDED_FAMILIES = [
88
+ 'playbook-select',
89
+ 'review-trigger',
90
+ 'cross-check-depth',
91
+ 'laneDeepening',
92
+ 'verificationDepth',
93
+ // BL-019 (SPEC FR-001): measured-vs-static tier A/B — same inherited
94
+ // shadow stage via resolveDecisionFamilyStage, no config schema change.
95
+ 'tier-selection',
96
+ ];
97
+ const DECISION_SHADOW_FAMILIES = [
98
+ 'route',
99
+ 'rigor',
100
+ 'resume',
101
+ 'verify',
102
+ ...DECISION_SHADOW_EXTENDED_FAMILIES,
103
+ ];
104
+ // Stage keys treat absence as "off" (MIGRATION_ROLLBACK named-keys table). Unknown or
105
+ // malformed values degrade to "off" — the conservative reading that keeps the route
106
+ // byte-identical to the pre-M01.1 shape.
107
+ export function resolveRouteSchemaStage(config = null) {
108
+ const stage = config?.routing?.routeSchema?.stage;
109
+ return ROUTE_SCHEMA_STAGES.has(stage) ? stage : 'off';
110
+ }
111
+
112
+ // FR-002: generic stage resolver — every routing.<key>.stage shares the same
113
+ // absent/malformed → 'off' contract. resolveRouteSchemaStage stays as the
114
+ // routeSchema-specific spelling of this helper.
115
+ export function resolveRouteStage(config = null, key) {
116
+ const stage = config?.routing?.[key]?.stage;
117
+ return ROUTE_SCHEMA_STAGES.has(stage) ? stage : 'off';
118
+ }
119
+
120
+ // Same absent/malformed → 'off' contract as the routing.* stage resolvers; the
121
+ // stage key lives under continuity.resumableRun (TASK-001 config block).
122
+ export function resolveResumableRunStage(config = null) {
123
+ const stage = config?.continuity?.resumableRun?.stage;
124
+ return ROUTE_SCHEMA_STAGES.has(stage) ? stage : 'off';
125
+ }
126
+
127
+ // decisionPlane stage contract as routing.* stages. `decisionPlane.enabled === false`
128
+ // is the global emergency disable and reads as 'off' regardless of stage keys.
129
+ export function resolveDecisionPlaneStage(config = null) {
130
+ const plane = config?.decisionPlane;
131
+ if (!plane || typeof plane !== 'object' || plane.enabled === false) return 'off';
132
+ return ROUTE_SCHEMA_STAGES.has(plane.stage) ? plane.stage : 'off';
133
+ }
134
+
135
+ // Per-family override wins over the global stage; absence inherits it.
136
+ export function resolveDecisionFamilyStage(config = null, family) {
137
+ const globalStage = resolveDecisionPlaneStage(config);
138
+ const override = config?.decisionPlane?.families?.[family]?.stage;
139
+ return ROUTE_SCHEMA_STAGES.has(override) ? override : globalStage;
140
+ }
141
+
142
+ // Route-side shadow questions — mirror of the DECISION_REGISTRY entries owned
143
+ // by taskRouting outside the preflight bundle (src/decision/registry.js).
144
+ // Candidates are protocol-local labels, not user-facing prose.
145
+ export const ROUTE_SHADOW_QUESTIONS = Object.freeze([
146
+ {
147
+ decisionKey: 'route.intent-kind.v1',
148
+ family: 'route',
149
+ kind: 'choice',
150
+ instruction: 'Intent kind for the route: informational | mutation | investigation | review | delivery.',
151
+ candidates: ['informational', 'mutation', 'investigation', 'review', 'delivery'],
152
+ },
153
+ {
154
+ decisionKey: 'route.rigor.v1',
155
+ family: 'rigor',
156
+ kind: 'score',
157
+ instruction: 'R0-R4 rigor recommendation inside deterministic floors.',
158
+ candidates: ['r0', 'r1', 'r2', 'r3', 'r4'],
159
+ },
160
+ {
161
+ decisionKey: 'resume.next-action.v1',
162
+ family: 'resume',
163
+ kind: 'choice',
164
+ instruction: 'Next resumable action at the continuation boundary.',
165
+ // deriveNextAction vocabulary (read from source); the rescue-bias override
166
+ // 'execute-current-milestone' can overwrite nextActionType post-derivation —
167
+ // baseline compare then scores 'disagree', which is a real signal, not noise.
168
+ candidates: [
169
+ 'ask-user-confirmation',
170
+ 'run-primary-verification',
171
+ 'run-fallback-verification',
172
+ 'pull-indexed-context',
173
+ 'read-skill-instructions',
174
+ 'inspect-structure',
175
+ ],
176
+ },
177
+ {
178
+ decisionKey: 'verify.depth.v1',
179
+ family: 'verify',
180
+ kind: 'choice',
181
+ instruction: 'Verification depth among allowed levels.',
182
+ candidates: ['sanity', 'targeted', 'impact', 'full'],
183
+ },
184
+ // BL-017 (SPEC FR-005): the five adopted question families — each a
185
+ // deterministic candidate set, shipped at the inherited `shadow` stage via
186
+ // resolveDecisionFamilyStage (no runtimeConfig schema change).
187
+ {
188
+ decisionKey: 'playbook.select.v1',
189
+ family: 'playbook-select',
190
+ kind: 'choice',
191
+ instruction: 'Which playbook row should claim this route — a builtin playbookId, or none.',
192
+ // Built-in playbookId vocabulary (playbook-registry rows whose id is
193
+ // non-null) + 'none' for unrouted/ambiguous prompts. Deterministic set;
194
+ // project/user playbooks still resolve via the registry, not this question.
195
+ candidates: [
196
+ 'small-feature',
197
+ 'feature-implementation',
198
+ 'bug-fix',
199
+ 'investigation',
200
+ 'runtime-forensics',
201
+ 'refactor',
202
+ 'performance',
203
+ 'architecture-decision',
204
+ 'prototype',
205
+ 'migration',
206
+ 'verification',
207
+ 'skill-evaluation',
208
+ 'session-pickup',
209
+ 'autonomous-run',
210
+ 'release',
211
+ 'none',
212
+ ],
213
+ },
214
+ {
215
+ decisionKey: 'review.trigger.v1',
216
+ family: 'review-trigger',
217
+ kind: 'choice',
218
+ instruction: 'Pre-Stop review-policy action for this route.',
219
+ // BL-015 five-action enum (evaluateReviewPolicy output vocabulary).
220
+ candidates: [
221
+ 'finish',
222
+ 'run-targeted-check',
223
+ 'independent-review',
224
+ 'escalate',
225
+ 'inconclusive',
226
+ ],
227
+ },
228
+ {
229
+ decisionKey: 'crosscheck.depth.v1',
230
+ family: 'cross-check-depth',
231
+ kind: 'choice',
232
+ instruction: 'Adaptive cross-check depth for this route.',
233
+ // ARCH §Adaptive Cross-Check depth matrix output enum (BL-016).
234
+ candidates: ['none', 'runnable', 'review-round', 'escalate'],
235
+ },
236
+ {
237
+ decisionKey: 'lane.deepening.v1',
238
+ family: 'laneDeepening',
239
+ kind: 'choice',
240
+ instruction: 'Execution lane after fix-loop escalation deepening.',
241
+ // ROUTE_EXECUTION_MODES lane vocabulary — BL-018 consumes this question as
242
+ // the escalation call-out path.
243
+ candidates: [
244
+ 'tiny-fix',
245
+ 'local-fix',
246
+ 'local-build',
247
+ 'shared-edit',
248
+ 'map-impact',
249
+ 'find-cause',
250
+ 'review-release',
251
+ ],
252
+ },
253
+ {
254
+ decisionKey: 'verification.depth.v1',
255
+ family: 'verificationDepth',
256
+ kind: 'choice',
257
+ instruction: 'Verification depth for this route.',
258
+ // ARCH task-contract verificationDepth enum (BL-016 consumer / BL-018
259
+ // escalation verification lane).
260
+ candidates: ['none', 'runnable', 'review-round', 'escalate'],
261
+ },
262
+ // BL-019 (SPEC FR-001): measured-vs-static tier A/B — the shadow model
263
+ // scores its pick against the effective baseline (measuredTier or the
264
+ // static modelTier). 'static' is the "keep the deterministic map" option.
265
+ {
266
+ decisionKey: 'tier.selection.v1',
267
+ family: 'tier-selection',
268
+ kind: 'choice',
269
+ instruction: 'Model tier for this route under the measured-reliability map.',
270
+ candidates: ['lite', 'code', 'smart', 'static'],
271
+ },
272
+ // NOT wired — recorded per migration-backlog review:
273
+ // capability.impact.v1 — noul kind; no threshold baseline exists on
274
+ // routeSummary (capabilityPolicy.recommended is unpopulated upstream), so
275
+ // the question has no comparison value.
276
+ // learn.candidate-class.v1 — reserved for the memoryV2 `decision` plane
277
+ // (memoryFlags.js), not the route boundary.
278
+ ]);
279
+
280
+ // Only families whose resolved stage is not 'off' get a question.
281
+ export function buildShadowDecisionQuestions(config = null) {
282
+ return ROUTE_SHADOW_QUESTIONS
283
+ .filter((q) => resolveDecisionFamilyStage(config, q.family) !== 'off')
284
+ .map(({ family, ...question }) => question);
285
+ }
286
+
287
+ // FR-003 (M01.2'): derive the additive riskFloor from hard signals. Codes are
288
+ // collected in ROUTE_RISK_REASON_CODES table order; the floor is 'high-risk' iff
289
+ // any of the first six (floor-raising) codes fired — informational codes never
290
+ // raise it. Detectors read the normalized signal text (same source the mode
291
+ // ladder uses) plus the raw target path and context preview.
292
+ export function deriveRiskFloor({
293
+ promptText = '',
294
+ commandText = '',
295
+ targetFile = null,
296
+ executionMode = null,
297
+ activeSkillIds = [],
298
+ contextPreview = null,
299
+ riskSignals = [],
300
+ } = {}) {
301
+ const signalText = buildRouteSignalText(promptText, commandText);
302
+ const target = String(targetFile || '');
303
+ const skillIds = Array.isArray(activeSkillIds) ? activeSkillIds : [];
304
+ const codes = [];
305
+ if (
306
+ /\b(auth|security|token|permission|secret|credential|password|vulnerab|exploit|xss|injection)\b/i.test(signalText)
307
+ || skillIds.includes('discover-security')
308
+ ) {
309
+ codes.push('security-sensitive');
310
+ }
311
+ if (isSharedImpactFile(targetFile) || executionMode === 'shared-edit' || executionMode === 'map-impact') {
312
+ codes.push('shared-impact');
313
+ }
314
+ if (
315
+ /(^|\/)(package\.json|manifests\/|.*\.d\.ts$|(^|\/)api\/|openapi|swagger)/i.test(target)
316
+ || /\b(public api|breaking change|api contract|semver)\b/i.test(signalText)
317
+ ) {
318
+ codes.push('public-contract');
319
+ }
320
+ if (
321
+ /(^|\/)(migrations?|db|database|prisma|schema)/i.test(target)
322
+ || /\b(migration|migrate|schema|alter table|add column|drop column)\b/i.test(signalText)
323
+ ) {
324
+ codes.push('schema-change');
325
+ }
326
+ if (/\b(delete|drop|truncate|destroy|wipe|uninstall|rm -rf|purge)\b/i.test(signalText)) {
327
+ codes.push('destructive-action');
328
+ }
329
+ const engineHits = ROUTE_RISK_ENGINES.filter(
330
+ (name) => new RegExp(`\\b${name}\\b`, 'i').test(signalText),
331
+ ).length;
332
+ if (
333
+ /\bcross[- ]engine\b|\ball engines\b/i.test(signalText)
334
+ || engineHits >= 2
335
+ || /^template_project\/\.(claude|codex|omp)\//i.test(target)
336
+ ) {
337
+ codes.push('cross-engine');
338
+ }
339
+ if ((contextPreview?.analogFiles?.length ?? 0) > 0 || (contextPreview?.styleFiles?.length ?? 0) > 0) {
340
+ codes.push('established-precedent');
341
+ }
342
+ if (target && !isSharedImpactFile(targetFile)) {
343
+ codes.push('explicit-local-target');
344
+ }
345
+ // TASK-004: externally supplied risk signals merge into the same table order —
346
+ // invalid codes are ignored, duplicates collapse.
347
+ const merged = [
348
+ ...codes,
349
+ ...(Array.isArray(riskSignals) ? riskSignals : [])
350
+ .filter((code) => ROUTE_RISK_REASON_CODES.includes(code)),
351
+ ];
352
+ const orderedCodes = [...new Set(merged)]
353
+ .sort((a, b) => ROUTE_RISK_REASON_CODES.indexOf(a) - ROUTE_RISK_REASON_CODES.indexOf(b));
354
+ return {
355
+ floor: orderedCodes.some((code) => ROUTE_RISK_FLOOR_RAISING_CODES.has(code)) ? 'high-risk' : 'none',
356
+ codes: orderedCodes,
357
+ };
358
+ }
359
+
360
+ // Route-line segment (FR-003): on 'high-risk' only the floor-raising codes print;
361
+ // on 'none' the informational codes do. Empty list → no segment at all.
362
+ export function formatRiskFloorSegment(riskFloor = null) {
363
+ if (!riskFloor) {
364
+ return null;
365
+ }
366
+ const printed = riskFloor.floor === 'high-risk'
367
+ ? riskFloor.codes.filter((code) => ROUTE_RISK_FLOOR_RAISING_CODES.has(code))
368
+ : riskFloor.codes;
369
+ return printed.length > 0 ? `risk=${riskFloor.floor}(${printed.join(',')})` : null;
370
+ }
371
+
372
+ // FR-001 (M01.2' limits fragment): compile the contract's numeric budget keys
373
+ // into an advisory map. Only finite numbers survive — a non-numeric or missing
374
+ // key is simply absent. Zero is a real budget ("no read passes"), never
375
+ // filtered. Same key set as the ceremonyBudget.limits builder below.
376
+ export function deriveCeremonyLimits(executionContract = null) {
377
+ if (executionContract === null || typeof executionContract !== 'object') {
378
+ return {};
379
+ }
380
+ return Object.fromEntries(
381
+ ['maxReadPasses', 'maxContextPulls', 'maxReadPassesBeforeReassess']
382
+ .filter((key) => Number.isFinite(executionContract[key]))
383
+ .map((key) => [key, executionContract[key]]),
384
+ );
385
+ }
386
+
387
+ // Route-line segment (FR-002): fixed reads,ctx,reassess order; only present
388
+ // keys print. Empty/absent map → null so the segment never appears.
389
+ export function formatLimitsSegment(limits = null) {
390
+ if (limits === null || typeof limits !== 'object') {
391
+ return null;
392
+ }
393
+ const parts = [
394
+ ['reads', 'maxReadPasses'],
395
+ ['ctx', 'maxContextPulls'],
396
+ ['reassess', 'maxReadPassesBeforeReassess'],
397
+ ]
398
+ .filter(([, key]) => Number.isFinite(limits[key]))
399
+ .map(([label, key]) => `${label}:${limits[key]}`);
400
+ return parts.length > 0 ? `limits=${parts.join(',')}` : null;
401
+ }
402
+
403
+ // FR-004 (M01.3'): Fast Path eligibility predicate. Returns null when no riskFloor
404
+ // was supplied — eligibility must never be derived without the floor check, so a
405
+ // missing floor means "not computed", not "none". Eligible iff the lane is
406
+ // tiny-fix/local-fix, exactly one local target is known, the floor is 'none',
407
+ // a bounded verification path exists (targeted commands or the tiny-fix
408
+ // 'minimal-or-targeted' contract policy), and the target is not shared-impact.
409
+ export function deriveFastPath({
410
+ executionMode = null,
411
+ targetFile = null,
412
+ riskFloor = null,
413
+ verificationRecommendation = null,
414
+ contextPreview = null,
415
+ } = {}) {
416
+ if (!riskFloor) {
417
+ return null;
418
+ }
419
+ const reasons = (riskFloor.codes ?? []).filter((code) => FAST_PATH_REASON_CODES.has(code));
420
+ const hasLocalTarget = Boolean(targetFile) || contextPreview?.primaryTargets?.length === 1;
421
+ const hasBoundedVerification = (verificationRecommendation?.commands?.length ?? 0) > 0
422
+ || buildExecutionContract(executionMode)?.verificationPolicy === 'minimal-or-targeted';
423
+ const eligible = FAST_PATH_MODES.has(executionMode)
424
+ && hasLocalTarget
425
+ && riskFloor.floor === 'none'
426
+ && hasBoundedVerification
427
+ && !isSharedImpactFile(targetFile);
428
+ return {
429
+ eligible,
430
+ suppressed: eligible ? [...FAST_PATH_SUPPRESSED] : [],
431
+ reasons,
432
+ };
433
+ }
434
+
435
+ // Route-line segment (FR-004): emitted only for eligible routes — ineligible
436
+ // routes keep the routeSummary.fastPath field for telemetry but stay silent.
437
+ export function formatFastPathSegment(fastPath = null) {
438
+ if (!fastPath?.eligible) {
439
+ return null;
440
+ }
441
+ const reasons = fastPath.reasons?.length ? ` (${fastPath.reasons.join(',')})` : '';
442
+ return `fastPath=on | suppress: ${FAST_PATH_SUPPRESSED.join(',')}${reasons}`;
443
+ }
444
+
445
+ // A bare delivery command ("push this to git", "đẩy bộ này lên git") performs no
446
+ // repository mutation the ledger could ever receipt. With no edit/review/debug/build
447
+ // signal present, routing it to an investigation lane fabricates write debt and the
448
+ // completion gate then demands an edit that cannot exist. Extracted from
449
+ // deriveExecutionMode so the C01 intent.kind mapping can reuse the identical predicate
450
+ // (delivery-only → 'delivery') instead of duplicating the regexes.
451
+ export function isDeliveryOnlyRequest({ signalText = '', scores = {}, targetFile = null } = {}) {
452
+ const signalRaw = String(signalText || '').toLowerCase();
453
+ const deliveryWordSignal = /\bgit\s+push\b/.test(signalRaw)
454
+ || /\bpush\b[^\n]{0,60}\b(?:git|github|gitlab|remote|origin|repo)\b/.test(signalRaw)
455
+ || /\b(?:git|github|gitlab|remote|origin|repo)\b[^\n]{0,60}\bpush\b/.test(signalRaw)
456
+ || /\bday\b(?:\s+\S+){0,3}?\s+len\b/.test(signalRaw);
457
+ return deliveryWordSignal
458
+ && scores.editCertainty === 0
459
+ && !scores.implementSignal
460
+ && !scores.reviewSignal
461
+ && !scores.debugSignal
462
+ && !scores.failureSignal
463
+ && !scores.impactSignal
464
+ && !scores.buildSignal
465
+ && !scores.directTransformSignal
466
+ && !scores.smallFixSignal
467
+ && !scores.sharedRisk
468
+ && !targetFile;
469
+ }
470
+
471
+ // @decision-point route.intent-kind.v1 — intent kind is a registered
472
+ // consequential decision (DECISION_REGISTRY); this deterministic mapping is
473
+ // its fallback policy implementation.
474
+ // CONTRACTS.md "Intent vocabulary mapping": taskType informs mode priors, not
475
+ // intent.kind; intentMode maps to kind as question/explanation → informational,
476
+ // ship/deliver → delivery, code change → mutation, root-cause/diagnosis →
477
+ // investigation, review/audit → review.
478
+ function deriveRouteIntentKind({ executionMode = null, intentMode = null, deliveryOnly = false } = {}) {
479
+ if (executionMode === 'find-cause') return 'investigation';
480
+ if (executionMode === 'review-release') return 'review';
481
+ if (executionMode === 'informational') return deliveryOnly ? 'delivery' : 'informational';
482
+ if (executionMode) return 'mutation';
483
+ if (intentMode === 'review-specific') return 'review';
484
+ if (intentMode === 'debug-specific') return 'investigation';
485
+ if (intentMode === 'implement-specific' || intentMode === 'docs-specific') return 'mutation';
486
+ return 'informational';
487
+ }
488
+
489
+ // Existing read-only vs mutating classification: only the informational lane carries
490
+ // no write debt; every contract lane is mutating.
491
+ function deriveRouteMutability(executionMode = null) {
492
+ return executionMode && executionMode !== 'informational' ? 'mutating' : 'read-only';
493
+ }
494
+
495
+ function compactRouteGoal(text = '') {
496
+ const goal = String(text || '').trim();
497
+ if (!goal) return null;
498
+ return goal.length > ROUTE_GOAL_MAX_LENGTH ? `${goal.slice(0, ROUTE_GOAL_MAX_LENGTH)}…` : goal;
499
+ }
500
+
501
+ function unique(values) {
502
+ return [...new Set(values.filter(Boolean))];
503
+ }
504
+
505
+ // Completion contract for a lane. Informational routes carry no completion debt;
506
+ // every contract lane returns the still-missing evidence classes plus a reason.
507
+ export function buildCompletionState({ executionMode = null, verificationRecommendation = null } = {}) {
508
+ if (!executionMode || executionMode === 'informational') {
509
+ return null;
510
+ }
511
+
512
+ const contract = buildExecutionContract(executionMode);
513
+ const missingEvidence = [...(contract?.completionEvidence ?? [])];
514
+ const requiresVerification = missingEvidence.includes('verification-evidence');
515
+ if (
516
+ requiresVerification
517
+ && verificationRecommendation
518
+ && !(verificationRecommendation.commands?.length || verificationRecommendation.fallbackCommands?.length)
519
+ ) {
520
+ missingEvidence.push('verification-plan');
521
+ }
522
+
523
+ let reason = 'completion evidence is still required';
524
+ if (['tiny-fix', 'local-fix', 'local-build', 'shared-edit'].includes(executionMode)) {
525
+ reason = 'implement request has not produced an edit yet';
526
+ } else if (executionMode === 'review-release') {
527
+ reason = 'review/release evidence is still required before final claim';
528
+ } else if (executionMode === 'map-impact') {
529
+ reason = 'impact evidence is still required before safe completion claim';
530
+ }
531
+
532
+ return {
533
+ claimAllowed: false,
534
+ missingEvidence,
535
+ reason,
536
+ };
537
+ }
538
+
539
+ // Builds the additive C01 groups for one resolved route. rigor stays null until
540
+ // M01.2 derives it; ceremonyBudget/capabilityPolicy are empty shaped objects M02/M01.2
541
+ // populate; escalation.current mirrors the selected mode.
542
+ export function buildResolvedRouteFields({
543
+ routingContext = {},
544
+ activeSkillIds = [],
545
+ executionMode = null,
546
+ executionContract = null,
547
+ completionState = null,
548
+ riskFloor = null,
549
+ escalationTriggers = [],
550
+ } = {}) {
551
+ // Decision table v2 (FR-001/FR-002): tier + effort resolve together from the
552
+ // contract lane and the additive riskFloor. The router cannot observe host
553
+ // binding capabilities, so the emitted pair is advisory text by definition.
554
+ const tierDecision = resolveModelTier({ executionMode, riskFloor });
555
+ const signalText = buildRouteSignalText(routingContext.promptText, routingContext.commandText);
556
+ const deliveryOnly = isDeliveryOnlyRequest({
557
+ signalText,
558
+ scores: routingContext.executionScores ?? {},
559
+ targetFile: routingContext.targetFile ?? null,
560
+ });
561
+ return {
562
+ routeVersion: ROUTE_VERSION,
563
+ intent: {
564
+ kind: deriveRouteIntentKind({
565
+ executionMode,
566
+ intentMode: routingContext.intentMode ?? null,
567
+ deliveryOnly,
568
+ }),
569
+ mutability: deriveRouteMutability(executionMode),
570
+ goal: compactRouteGoal(routingContext.lastExplicitUserPromptText ?? routingContext.promptText),
571
+ doneConditions: [],
572
+ },
573
+ execution: {
574
+ mode: executionMode,
575
+ rigor: null,
576
+ riskFloor: riskFloor?.floor ?? null,
577
+ phase: null,
578
+ contractVersion: ROUTE_CONTRACT_VERSION,
579
+ modelTier: tierDecision.tier,
580
+ effort: tierDecision.effort,
581
+ },
582
+ evidence: {
583
+ observations: [],
584
+ riskSignals: riskFloor?.codes ?? [],
585
+ activationReasons: [],
586
+ suppressionReasons: [],
587
+ completionRequirements: unique(completionState?.missingEvidence ?? []),
588
+ },
589
+ ceremonyBudget: {
590
+ policyVersion: ROUTE_CONTRACT_VERSION,
591
+ rigor: null,
592
+ limits: riskFloor
593
+ ? Object.fromEntries(
594
+ ['maxReadPasses', 'maxContextPulls', 'maxReadPassesBeforeReassess']
595
+ .filter((key) => typeof executionContract?.[key] === 'number')
596
+ .map((key) => [key, executionContract[key]]),
597
+ )
598
+ : {},
599
+ consumed: {},
600
+ exceptions: [],
601
+ },
602
+ capabilityPolicy: {
603
+ policyVersion: ROUTE_CONTRACT_VERSION,
604
+ required: [],
605
+ recommended: [],
606
+ suppressed: [],
607
+ activeSkillIds: unique(activeSkillIds),
608
+ },
609
+ escalation: {
610
+ current: { mode: executionMode, rigor: null },
611
+ ceiling: ROUTE_ESCALATION_CEILING,
612
+ triggers: unique(escalationTriggers),
613
+ history: [],
614
+ },
615
+ };
616
+ }
617
+
618
+ // Plain-JS validator for the additive C01 groups. Returns { valid, errors }; it never
619
+ // throws and never inspects legacy fields — old consumers may carry anything else.
620
+ export function validateResolvedRoute(route = null) {
621
+ const errors = [];
622
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
623
+ if (!isObject(route)) {
624
+ return { valid: false, errors: ['route must be an object.'] };
625
+ }
626
+ if (route.routeVersion !== ROUTE_VERSION) {
627
+ errors.push(`routeVersion must be ${ROUTE_VERSION}.`);
628
+ }
629
+ if (!isObject(route.intent)) {
630
+ errors.push('intent must be an object.');
631
+ } else {
632
+ if (!ROUTE_INTENT_KINDS.has(route.intent.kind)) {
633
+ errors.push(`intent.kind must be one of: ${[...ROUTE_INTENT_KINDS].join(', ')}.`);
634
+ }
635
+ if (!ROUTE_MUTABILITIES.has(route.intent.mutability)) {
636
+ errors.push(`intent.mutability must be one of: ${[...ROUTE_MUTABILITIES].join(', ')}.`);
637
+ }
638
+ if (route.intent.goal !== null && typeof route.intent.goal !== 'string') {
639
+ errors.push('intent.goal must be a string or null.');
640
+ }
641
+ if (!Array.isArray(route.intent.doneConditions)) {
642
+ errors.push('intent.doneConditions must be an array.');
643
+ }
644
+ }
645
+ if (!isObject(route.execution)) {
646
+ errors.push('execution must be an object.');
647
+ } else {
648
+ if (route.execution.mode !== null && !ROUTE_EXECUTION_MODES.includes(route.execution.mode)) {
649
+ errors.push(`execution.mode must be null or one of: ${ROUTE_EXECUTION_MODES.join(', ')}.`);
650
+ }
651
+ if (route.execution.rigor !== null && !ROUTE_RIGOR_LEVELS.has(route.execution.rigor)) {
652
+ errors.push(`execution.rigor must be null or one of: ${[...ROUTE_RIGOR_LEVELS].join(', ')}.`);
653
+ }
654
+ if (route.execution.riskFloor !== null && !ROUTE_RISK_FLOORS.has(route.execution.riskFloor)) {
655
+ errors.push(`execution.riskFloor must be null or one of: ${[...ROUTE_RISK_FLOORS].join(', ')}.`);
656
+ }
657
+ if (route.execution.contractVersion !== ROUTE_CONTRACT_VERSION) {
658
+ errors.push(`execution.contractVersion must be ${ROUTE_CONTRACT_VERSION}.`);
659
+ }
660
+ if (route.execution.modelTier !== null && !ROUTE_MODEL_TIERS.has(route.execution.modelTier)) {
661
+ errors.push(`execution.modelTier must be null or one of: ${[...ROUTE_MODEL_TIERS].join(', ')}.`);
662
+ }
663
+ if (route.execution.effort !== null && route.execution.effort !== undefined
664
+ && !ROUTE_EFFORTS.has(route.execution.effort)) {
665
+ errors.push(`execution.effort must be null or one of: ${[...ROUTE_EFFORTS].join(', ')}.`);
666
+ }
667
+ }
668
+ if (!isObject(route.evidence)) {
669
+ errors.push('evidence must be an object.');
670
+ } else {
671
+ for (const key of ['observations', 'riskSignals', 'activationReasons', 'suppressionReasons', 'completionRequirements']) {
672
+ if (!Array.isArray(route.evidence[key])) {
673
+ errors.push(`evidence.${key} must be an array.`);
674
+ }
675
+ }
676
+ if (Array.isArray(route.evidence.riskSignals)
677
+ && route.evidence.riskSignals.some(
678
+ (code) => typeof code !== 'string' || !ROUTE_RISK_REASON_CODES.includes(code),
679
+ )) {
680
+ errors.push(`evidence.riskSignals entries must be one of: ${ROUTE_RISK_REASON_CODES.join(', ')}.`);
681
+ }
682
+ }
683
+ if (!isObject(route.ceremonyBudget)) {
684
+ errors.push('ceremonyBudget must be an object.');
685
+ } else {
686
+ if (route.ceremonyBudget.rigor !== null && !ROUTE_RIGOR_LEVELS.has(route.ceremonyBudget.rigor)) {
687
+ errors.push(`ceremonyBudget.rigor must be null or one of: ${[...ROUTE_RIGOR_LEVELS].join(', ')}.`);
688
+ }
689
+ if (!isObject(route.ceremonyBudget.limits)) {
690
+ errors.push('ceremonyBudget.limits must be an object.');
691
+ }
692
+ if (!isObject(route.ceremonyBudget.consumed)) {
693
+ errors.push('ceremonyBudget.consumed must be an object.');
694
+ }
695
+ if (!Array.isArray(route.ceremonyBudget.exceptions)) {
696
+ errors.push('ceremonyBudget.exceptions must be an array.');
697
+ }
698
+ }
699
+ if (!isObject(route.capabilityPolicy)) {
700
+ errors.push('capabilityPolicy must be an object.');
701
+ } else {
702
+ for (const key of ['required', 'recommended', 'suppressed', 'activeSkillIds']) {
703
+ if (!Array.isArray(route.capabilityPolicy[key])) {
704
+ errors.push(`capabilityPolicy.${key} must be an array.`);
705
+ }
706
+ }
707
+ }
708
+ if (!isObject(route.escalation)) {
709
+ errors.push('escalation must be an object.');
710
+ } else {
711
+ if (!isObject(route.escalation.current)) {
712
+ errors.push('escalation.current must be an object.');
713
+ } else {
714
+ if (route.escalation.current.mode !== null && !ROUTE_EXECUTION_MODES.includes(route.escalation.current.mode)) {
715
+ errors.push(`escalation.current.mode must be null or one of: ${ROUTE_EXECUTION_MODES.join(', ')}.`);
716
+ }
717
+ if (route.escalation.current.rigor !== null && !ROUTE_RIGOR_LEVELS.has(route.escalation.current.rigor)) {
718
+ errors.push(`escalation.current.rigor must be null or one of: ${[...ROUTE_RIGOR_LEVELS].join(', ')}.`);
719
+ }
720
+ }
721
+ if (!Array.isArray(route.escalation.triggers)) {
722
+ errors.push('escalation.triggers must be an array.');
723
+ }
724
+ if (!Array.isArray(route.escalation.history)) {
725
+ errors.push('escalation.history must be an array.');
726
+ }
727
+ }
728
+ return { valid: errors.length === 0, errors };
729
+ }
730
+
731
+ // Compact serialization whitelist (C01 compatibility rule): adapter/runtime consumers
732
+ // get a bounded view that may omit verbose evidence but never mode, rigor, required
733
+ // completion evidence, or escalation state. Returns null for legacy (stage-off)
734
+ // summaries so compact output stays byte-identical when the schema stage is off.
735
+ export function compactResolvedRoute(routeSummary = null) {
736
+ if (!routeSummary || typeof routeSummary !== 'object' || routeSummary.routeVersion == null) {
737
+ return null;
738
+ }
739
+ return {
740
+ routeVersion: routeSummary.routeVersion,
741
+ intent: routeSummary.intent ?? null,
742
+ execution: routeSummary.execution ?? null,
743
+ evidence: {
744
+ completionRequirements: unique(routeSummary.evidence?.completionRequirements ?? []),
745
+ },
746
+ ceremonyBudget: routeSummary.ceremonyBudget ?? null,
747
+ capabilityPolicy: routeSummary.capabilityPolicy ?? null,
748
+ escalation: routeSummary.escalation ?? null,
749
+ };
750
+ }
751
+
752
+ // The logical task boundary is the explicit user prompt — a new prompt means a
753
+ // new task, so the persisted record resets instead of merging stale plans.
754
+ // Hashed: prompt text never persists (C10 redaction contract).
755
+ export function resumableTaskBoundary(routingContext = {}) {
756
+ const text = String(
757
+ routingContext.lastExplicitUserPromptText ?? routingContext.promptText ?? '',
758
+ ).trim();
759
+ return text
760
+ ? crypto.createHash('sha256').update(text).digest('hex').slice(0, 32)
761
+ : 'no-prompt';
762
+ }
763
+
764
+ // TASK-007 (BL-009): the escalation-trigger projection the playbook registry's
765
+ // discriminators consume — the floor-raising risk codes that fired, in
766
+ // ROUTE_RISK_REASON_CODES table order. Extracted so both the resolver and the
767
+ // registry derive the identical trigger set from one riskFloor result.
768
+ export function deriveEscalationTriggers(riskFloor = null) {
769
+ return Array.isArray(riskFloor?.codes)
770
+ ? riskFloor.codes.filter((code) => ROUTE_RISK_FLOOR_RAISING_CODES.has(code))
771
+ : [];
772
+ }
773
+
774
+ // --- TASK-004 (BL-006): the shared resolver entry point -----------------------
775
+ // One call derives the full deterministic field set both router surfaces emit:
776
+ // the helper path folds it into routeSummary inside buildRouteSummary, the hook
777
+ // path merges it into skill-router-state.json + route-audit entries. Same
778
+ // input → same output on every lane — that is the split-brain fix.
779
+ //
780
+ // rigor stays the deterministic rigor level the route emits — null today (no
781
+ // R-derivation exists yet); both paths now agree on null instead of the hook
782
+ // silently omitting the field. `resolved` carries the C01 groups when
783
+ // routeSchema.stage != 'off'. `decisionShadow` is the resolved stage map — the
784
+ // helper's async receipt still rides routeSummary.decisionPlane separately.
785
+ // `resumable` is the deterministic C10 boundary descriptor; the run-record IO
786
+ // stays in emitResumableRun on the helper path.
787
+ export function deriveRouteFields({
788
+ promptText = '',
789
+ commandText = '',
790
+ targetFile = null,
791
+ intentMode = null,
792
+ taskType = null,
793
+ executionMode = null,
794
+ riskSignals = [],
795
+ activeSkillIds = [],
796
+ verificationRecommendation = null,
797
+ contextPreview = null,
798
+ executionScores = {},
799
+ lastExplicitUserPromptText = null,
800
+ // BL-013: precomputed bounded session-history struct (the caller runs the
801
+ // extractor — resolution itself stays IO-free). Normalized into the
802
+ // counters/enums-only shape; non-struct input stays null so the field is
803
+ // additive-nullable on the route record.
804
+ historySignals = null,
805
+ config = null,
806
+ } = {}) {
807
+ const routingContext = {
808
+ promptText,
809
+ commandText,
810
+ targetFile,
811
+ intentMode,
812
+ taskType,
813
+ executionMode,
814
+ executionScores,
815
+ lastExplicitUserPromptText,
816
+ };
817
+
818
+ // FR-003 (M01.2'): riskFloor is emitted whenever ANY of rigor/fastPath/
819
+ // escalation stages is on — fastPath/escalation consumers read it even when
820
+ // routeSchema is off. All three off → null, byte-identical route.
821
+ const riskStageOn = ['rigor', 'fastPath', 'escalation']
822
+ .some((key) => resolveRouteStage(config, key) !== 'off');
823
+ const riskFloor = riskStageOn
824
+ ? deriveRiskFloor({
825
+ promptText,
826
+ commandText,
827
+ targetFile,
828
+ executionMode,
829
+ activeSkillIds,
830
+ contextPreview,
831
+ riskSignals,
832
+ })
833
+ : null;
834
+
835
+ // FR-004 (M01.3'): fastPath is emitted whenever its own stage is on — the
836
+ // field is always set then (eligible or not) so telemetry/harness can read it.
837
+ const fastPathStage = resolveRouteStage(config, 'fastPath');
838
+ const fastPath = fastPathStage !== 'off'
839
+ ? {
840
+ eligible: false,
841
+ suppressed: [],
842
+ reasons: [],
843
+ ...deriveFastPath({
844
+ executionMode,
845
+ targetFile,
846
+ riskFloor,
847
+ verificationRecommendation,
848
+ contextPreview,
849
+ }),
850
+ stage: fastPathStage,
851
+ }
852
+ : null;
853
+
854
+ // Deterministic escalation triggers: the floor-raising codes that fired.
855
+ // TASK-007's playbook discriminator reads these; history/state-driven
856
+ // escalation stays in the route helpers.
857
+ const escalationTriggers = deriveEscalationTriggers(riskFloor);
858
+
859
+ // M01.1 C01 groups — present only when routing.routeSchema.stage != 'off'.
860
+ const executionContract = buildExecutionContract(executionMode);
861
+ const completionState = buildCompletionState({
862
+ executionMode,
863
+ verificationRecommendation,
864
+ });
865
+ const resolved = resolveRouteSchemaStage(config) !== 'off'
866
+ ? buildResolvedRouteFields({
867
+ routingContext,
868
+ activeSkillIds,
869
+ executionMode,
870
+ executionContract,
871
+ completionState,
872
+ riskFloor,
873
+ escalationTriggers,
874
+ })
875
+ : null;
876
+
877
+ // M04.1: the deterministic half of the resumable-run emit — stage + task
878
+ // boundary. The C10 record IO stays in emitResumableRun; the hook surfaces
879
+ // the identical boundary so ledger rows can correlate both paths.
880
+ const resumable = {
881
+ stage: resolveResumableRunStage(config),
882
+ taskBoundary: resumableTaskBoundary(routingContext),
883
+ };
884
+
885
+ // M07: the resolved decision-plane stage map. The async shadow receipt never
886
+ // travels through the resolver — only the deterministic stage descriptor.
887
+ // SPEC §8 names this output key `decisionShadowFields`; consumers surface it
888
+ // as `decisionShadow`.
889
+ const decisionShadowFields = {
890
+ stage: resolveDecisionPlaneStage(config),
891
+ familyStages: Object.fromEntries(
892
+ DECISION_SHADOW_FAMILIES.map((family) => [
893
+ family,
894
+ resolveDecisionFamilyStage(config, family),
895
+ ]),
896
+ ),
897
+ };
898
+
899
+ // BL-013 merge call-site: the bounded counters/enums struct rides the route
900
+ // record additively — emitted into route state on both surfaces and into the
901
+ // decisions.tsv feature packet by the route helpers. Null when the caller
902
+ // has no transcript or supplies a non-struct value.
903
+ const historySignalsField = normalizeHistorySignals(historySignals);
904
+
905
+ return {
906
+ rigor: resolved?.execution?.rigor ?? null,
907
+ fastPath,
908
+ riskFloor,
909
+ resumable,
910
+ escalationTriggers,
911
+ decisionShadowFields,
912
+ historySignals: historySignalsField,
913
+ resolved,
914
+ };
915
+ }