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