mandrel 2.10.0 → 2.12.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/.agents/docs/configuration.md +35 -33
- package/.agents/rules/orchestration-error-handling.md +9 -1
- package/.agents/schemas/agentrc.schema.json +13 -8
- package/.agents/scripts/acceptance-eval.js +9 -5
- package/.agents/scripts/lib/audit-suite/audit-rules-reader.js +48 -0
- package/.agents/scripts/lib/audit-suite/selector.js +1 -26
- package/.agents/scripts/lib/baselines/env-overrides.js +33 -0
- package/.agents/scripts/lib/baselines/git-base.js +0 -0
- package/.agents/scripts/lib/baselines/preview-gates.js +5 -0
- package/.agents/scripts/lib/config/gates/maintainability.schema.js +10 -1
- package/.agents/scripts/lib/config/quality.js +13 -0
- package/.agents/scripts/lib/config-settings-schema.js +12 -16
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +45 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +97 -4
- package/.agents/scripts/lib/orchestration/check-baselines/phases/parse-args.js +7 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +561 -184
- package/.agents/scripts/lib/orchestration/plan-context.js +69 -10
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +117 -60
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +21 -15
- package/.agents/scripts/lib/orchestration/resolve-stories.js +28 -7
- package/.agents/scripts/lib/orchestration/review-depth.js +9 -4
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +186 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +21 -3
- package/.agents/scripts/lib/orchestration/spec-budget.js +78 -0
- package/.agents/scripts/lib/orchestration/story-body-gate.js +72 -0
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +6 -0
- package/.agents/scripts/lib/orchestration/ticket-validator.js +18 -62
- package/.agents/scripts/plan-context.js +23 -5
- package/.agents/scripts/resolve-stories.js +2 -0
- package/.agents/workflows/deliver.md +28 -28
- package/.agents/workflows/helpers/acceptance-self-eval.md +16 -5
- package/.agents/workflows/helpers/deliver-digest.md +126 -0
- package/.agents/workflows/helpers/deliver-reference.md +30 -5
- package/.agents/workflows/helpers/deliver-story-reference.md +38 -12
- package/.agents/workflows/helpers/deliver-story.md +34 -37
- package/.agents/workflows/helpers/plan-reference.md +79 -44
- package/.agents/workflows/plan.md +11 -10
- package/docs/CHANGELOG.md +31 -0
- package/lib/cli/registry.js +31 -14
- package/lib/migrations/index.js +2 -0
- package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +92 -0
- package/package.json +1 -1
|
@@ -1,99 +1,141 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lib/orchestration/complexity-gate.js —
|
|
2
|
+
* lib/orchestration/complexity-gate.js — shape-derived complexity routing
|
|
3
|
+
* (Story #4722, superseding the word-count gate of Stories #4683/#4707).
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* ## Route on the work, not the words
|
|
6
|
+
*
|
|
7
|
+
* The original gate routed a planning seed on its **word count**
|
|
8
|
+
* (`maxSeedWords`), which is the wrong proxy in both directions: a detailed
|
|
9
|
+
* prompt can describe trivial work, a terse one complex work. The bench
|
|
10
|
+
* cohort (mandrel-bench 2.10.0) observed both failure modes — a lite verdict
|
|
11
|
+
* fired at plan time and was then lost (a swallowed label write) or ignored
|
|
12
|
+
* (deliver spawned a full story-worker anyway). This module now routes on the
|
|
13
|
+
* **objective shape of the authored work**, staged across the pipeline:
|
|
14
|
+
*
|
|
15
|
+
* 1. **Plan time — signals, not routing.** {@link buildComplexitySignals}
|
|
16
|
+
* emits advisory complexity *signals* (enumerated-artifact count,
|
|
17
|
+
* risk-heuristic hits, repo state of predicted paths, sensitive-path
|
|
18
|
+
* classes) carrying **no routing authority**. There is no word ceiling.
|
|
19
|
+
* 2. **Planner judgment, ledgered.** The planner owns the
|
|
20
|
+
* trivial-vs-standard verdict ({@link resolvePlannerRouteVerdict}) —
|
|
21
|
+
* `lite` only with a recorded reason, persisted on plan state. This
|
|
22
|
+
* generalizes the former one-way `applyPlannerDowngrade` seam into the
|
|
23
|
+
* authored verdict itself; the conservative default without a recorded
|
|
24
|
+
* reason is `full`.
|
|
25
|
+
* 3. **Deterministic backstop at persist.** After authoring, the work has
|
|
26
|
+
* measurable shape: {@link deriveStoryShape} reads the Story's own
|
|
27
|
+
* `changes[]` count, acceptance-criteria count, creates-vs-refactors
|
|
28
|
+
* mix, and sensitive-path classes against {@link STORY_SHAPE_CEILINGS}.
|
|
29
|
+
* A `lite` claim whose shape exceeds the ceilings **fails closed to
|
|
30
|
+
* `full`** (`run-plan-persist.js`).
|
|
31
|
+
* 4. **Deliver re-derives.** `/deliver` computes the route from the fetched
|
|
32
|
+
* Story body via the **same** shape function at dispatch
|
|
33
|
+
* ({@link resolveStoryDispatchMode}) and honors it: a lite-shaped Story
|
|
34
|
+
* executes inline — no story-worker sub-agent boot, no fresh
|
|
35
|
+
* acceptance-critic dispatch — while every `single-story-close.js` gate
|
|
36
|
+
* runs unchanged. The `route::lite` label is a **human-visible hint
|
|
37
|
+
* only**, never the control signal: a lost label or an unread marker can
|
|
38
|
+
* no longer misroute delivery. Ahead of the shape read sits one
|
|
39
|
+
* shape-independent rule (Story #4736): a **single-Story run** is inline
|
|
40
|
+
* whatever its shape, because sub-agent isolation buys nothing when
|
|
41
|
+
* there is no concurrent sibling to isolate from.
|
|
42
|
+
*
|
|
43
|
+
* The shape taxonomy is deliberately the one `review-depth.js` already
|
|
44
|
+
* applies to the landed diff at close (`deriveChangeLevel` over the
|
|
45
|
+
* `audit-rules.json` sensitive-path classes): **predicted shape at dispatch,
|
|
46
|
+
* actual diff at close** — one taxonomy, two read points. And sensitivity
|
|
47
|
+
* always wins: a small change whose footprint intersects a sensitive-path
|
|
48
|
+
* class routes `full`, which keeps its fresh acceptance critic
|
|
49
|
+
* (`ceremony-routing.js` routes a high derived level to a fresh spawn).
|
|
11
50
|
*
|
|
12
51
|
* ## What "lite" changes and — critically — what it never changes
|
|
13
52
|
*
|
|
14
|
-
* The lite route collapses the **advisory ceremony** only: the
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* ## Threshold + operator override
|
|
35
|
-
*
|
|
36
|
-
* {@link DEFAULT_COMPLEXITY_GATE} is the single source of truth for the
|
|
37
|
-
* threshold. Operators tune it (or disable the gate entirely) via
|
|
38
|
-
* `planning.complexityGate` in `.agentrc.json`:
|
|
39
|
-
*
|
|
40
|
-
* - `enabled` (default `true`) — `false` forces every seed to `full`.
|
|
41
|
-
* - `maxSeedWords` (default `150`) — seed prose word ceiling for `lite`.
|
|
42
|
-
* - `maxArtifacts` (default `1`) — enumerated-artifact ceiling for `lite`.
|
|
43
|
-
*
|
|
44
|
-
* Resolution clamps every field toward the conservative default: a malformed or
|
|
45
|
-
* negative ceiling falls back to the framework default rather than widening the
|
|
46
|
-
* lite path.
|
|
47
|
-
*
|
|
48
|
-
* ## Planner downgrade + the persisted route marker (Story #4707)
|
|
49
|
-
*
|
|
50
|
-
* Seed word count is a poor complexity proxy: a well-written 70-word trivial
|
|
51
|
-
* seed is no less trivial than a terse 40-word one, which is why the ceiling
|
|
52
|
-
* sits at 150 rather than 60. Two adjacent surfaces live here with the gate so
|
|
53
|
-
* the whole lite-routing contract has one home:
|
|
54
|
-
*
|
|
55
|
-
* - {@link applyPlannerDowngrade} — the planner may downgrade a `full`
|
|
56
|
-
* verdict to `lite` **only** with a recorded reason. The deterministic
|
|
57
|
-
* gate itself is unchanged (it still fails toward `full`); the downgrade
|
|
58
|
-
* is an auditable model judgment layered on top, never a silent gate
|
|
59
|
-
* change. Absent a non-empty reason the deterministic verdict stands.
|
|
60
|
-
* - {@link resolveStoryDispatchMode} — the deliver-side reader of the
|
|
61
|
-
* persisted {@link LITE_ROUTE_LABEL} marker. A lite-routed Story executes
|
|
62
|
-
* inline in the deliver session (no story-worker or acceptance-critic
|
|
63
|
-
* sub-agent boots); everything else dispatches as before. Model-side
|
|
64
|
-
* fan-out only — never a deterministic close gate.
|
|
53
|
+
* The lite route collapses the **advisory ceremony** only: the story-worker
|
|
54
|
+
* sub-agent boot and the fresh acceptance-critic spawn. It **never** relaxes
|
|
55
|
+
* a non-negotiable. {@link LITE_PATH_INVARIANTS} is the machine-readable
|
|
56
|
+
* contract that the lite path still produces a Story ticket, still lands via
|
|
57
|
+
* a PR to `main`, still runs every repo quality gate, and still honours
|
|
58
|
+
* `rules/security-baseline.md`. Those gates run in `single-story-close.js`
|
|
59
|
+
* regardless of route; the router cannot and does not switch them off.
|
|
60
|
+
*
|
|
61
|
+
* ## Configuration
|
|
62
|
+
*
|
|
63
|
+
* Operators tune the surface via `planning.complexityGate` in `.agentrc.json`:
|
|
64
|
+
*
|
|
65
|
+
* - `enabled` (default `true`) — `false` disables lite routing
|
|
66
|
+
* everywhere: persist refuses lite claims and dispatch always takes the
|
|
67
|
+
* sub-agent path.
|
|
68
|
+
* - `maxArtifacts` (default `1`) — enumerated-artifact signal threshold;
|
|
69
|
+
* an **input signal** for the planner, no longer a deterministic router.
|
|
70
|
+
*
|
|
71
|
+
* `maxSeedWords` is **removed** (hard cutover): word count routes nothing.
|
|
65
72
|
*
|
|
66
73
|
* @typedef {'lite'|'full'} ComplexityRoute
|
|
67
74
|
*/
|
|
68
75
|
|
|
76
|
+
import { existsSync } from 'node:fs';
|
|
77
|
+
import path from 'node:path';
|
|
78
|
+
import {
|
|
79
|
+
extractChangePaths,
|
|
80
|
+
parse as parseStoryBody,
|
|
81
|
+
} from '../story-body/story-body.js';
|
|
82
|
+
import { deriveChangeLevel } from './review-depth.js';
|
|
83
|
+
|
|
69
84
|
/**
|
|
70
|
-
* Framework defaults for the
|
|
71
|
-
*
|
|
72
|
-
*
|
|
85
|
+
* Framework defaults for the complexity-routing surface. The SSOT the config
|
|
86
|
+
* schema mirror and the configuration reference both cite. `maxSeedWords` is
|
|
87
|
+
* gone: seed word count carries no routing authority (Story #4722).
|
|
73
88
|
*/
|
|
74
89
|
const DEFAULT_COMPLEXITY_GATE = Object.freeze({
|
|
75
90
|
enabled: true,
|
|
76
|
-
maxSeedWords: 150,
|
|
77
91
|
maxArtifacts: 1,
|
|
78
92
|
});
|
|
79
93
|
|
|
80
94
|
/**
|
|
81
|
-
* The persisted route marker for a lite-routed Story
|
|
95
|
+
* The persisted route marker for a lite-routed Story.
|
|
82
96
|
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
97
|
+
* **A human-visible hint only (Story #4722)** — never the control signal.
|
|
98
|
+
* Persist still applies it so a lite cohort is filterable in the GitHub UI,
|
|
99
|
+
* but `/deliver` derives the route from the Story body's own shape
|
|
100
|
+
* ({@link resolveStoryDispatchMode}); a Story with the label whose shape
|
|
101
|
+
* derives `full` dispatches as a sub-agent, and a lite-shaped Story with the
|
|
102
|
+
* label absent (or its write failed) still executes inline.
|
|
88
103
|
*/
|
|
89
104
|
export const LITE_ROUTE_LABEL = 'route::lite';
|
|
90
105
|
|
|
91
106
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
107
|
+
* Shape ceilings a Story must fit for the `lite` route
|
|
108
|
+
* ({@link deriveStoryShape}). Framework constants, not operator knobs — a
|
|
109
|
+
* ceiling an operator can widen past what the inline path can safely absorb
|
|
110
|
+
* is a ceiling that fails silently. Conservative by construction: `lite` is
|
|
111
|
+
* for genuinely trivial, mostly-additive, non-sensitive scopes.
|
|
112
|
+
*
|
|
113
|
+
* - `maxChanges` — total `changes[]` entries (e.g. one artifact
|
|
114
|
+
* plus its test).
|
|
115
|
+
* - `maxAcceptance` — acceptance-criteria count; more criteria means
|
|
116
|
+
* more contract than a trivial scope carries.
|
|
117
|
+
* - `maxNonCreateChanges` — entries whose assumption is not `creates`
|
|
118
|
+
* (refactors-existing / deletes / exists). A lite
|
|
119
|
+
* change is mostly additive; touching existing
|
|
120
|
+
* surfaces is where trivial-looking work stops
|
|
121
|
+
* being trivial.
|
|
122
|
+
*
|
|
123
|
+
* Module-private, exposed as the `ceilings` field on every
|
|
124
|
+
* {@link deriveStoryShape} decision — so there is no test-only export to
|
|
125
|
+
* leave production-dead.
|
|
126
|
+
*/
|
|
127
|
+
const STORY_SHAPE_CEILINGS = Object.freeze({
|
|
128
|
+
maxChanges: 2,
|
|
129
|
+
maxAcceptance: 3,
|
|
130
|
+
maxNonCreateChanges: 1,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The non-negotiables the ceremony-lite path preserves (Story #4683 AC-2):
|
|
135
|
+
* collapsing ceremony never means dropping the Story ticket, the PR-to-`main`
|
|
136
|
+
* landing, the repo quality gates, or the security baseline. Attached
|
|
137
|
+
* verbatim to every route decision's `preserves` field so a downstream reader
|
|
138
|
+
* (or contract test) can assert the invariants held on either route.
|
|
97
139
|
*/
|
|
98
140
|
const LITE_PATH_INVARIANTS = Object.freeze({
|
|
99
141
|
storyTicket: true,
|
|
@@ -104,9 +146,8 @@ const LITE_PATH_INVARIANTS = Object.freeze({
|
|
|
104
146
|
|
|
105
147
|
/**
|
|
106
148
|
* Coerce a candidate ceiling into a non-negative integer, falling back to the
|
|
107
|
-
* framework default for anything malformed
|
|
108
|
-
*
|
|
109
|
-
* (the gate fails conservative, toward `full`).
|
|
149
|
+
* framework default for anything malformed — a stray `-1` or `NaN` must never
|
|
150
|
+
* widen the lite path (fail conservative).
|
|
110
151
|
*
|
|
111
152
|
* @param {unknown} value
|
|
112
153
|
* @param {number} fallback
|
|
@@ -120,18 +161,22 @@ function normalizeCeiling(value, fallback) {
|
|
|
120
161
|
}
|
|
121
162
|
|
|
122
163
|
/**
|
|
123
|
-
* Resolve the effective complexity-gate config, shallow-overlaying an
|
|
124
|
-
* `planning.complexityGate` block onto
|
|
125
|
-
* the full resolved config, the bare
|
|
126
|
-
* `
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
164
|
+
* Resolve the effective complexity-gate config, shallow-overlaying an
|
|
165
|
+
* operator `planning.complexityGate` block onto
|
|
166
|
+
* {@link DEFAULT_COMPLEXITY_GATE}. Accepts the full resolved config, the bare
|
|
167
|
+
* `planning` bag, or the bare `complexityGate` bag, mirroring the tolerant
|
|
168
|
+
* unwrap the other routing accessors use.
|
|
169
|
+
*
|
|
170
|
+
* Exported for persist (`run-plan-persist.js#resolveEffectiveRoute`), which
|
|
171
|
+
* consults `enabled` to refuse a planner lite claim when the gate is off —
|
|
172
|
+
* the schema's documented contract, and the same switch dispatch reads in
|
|
173
|
+
* {@link resolveStoryDispatchMode}, so the two read points cannot disagree
|
|
174
|
+
* about whether lite routing is live.
|
|
130
175
|
*
|
|
131
176
|
* @param {object | null | undefined} config
|
|
132
|
-
* @returns {{ enabled: boolean,
|
|
177
|
+
* @returns {{ enabled: boolean, maxArtifacts: number }}
|
|
133
178
|
*/
|
|
134
|
-
function resolveComplexityGate(config) {
|
|
179
|
+
export function resolveComplexityGate(config) {
|
|
135
180
|
const raw =
|
|
136
181
|
config?.planning?.complexityGate ?? config?.complexityGate ?? config ?? {};
|
|
137
182
|
const bag = raw && typeof raw === 'object' ? raw : {};
|
|
@@ -140,10 +185,6 @@ function resolveComplexityGate(config) {
|
|
|
140
185
|
typeof bag.enabled === 'boolean'
|
|
141
186
|
? bag.enabled
|
|
142
187
|
: DEFAULT_COMPLEXITY_GATE.enabled,
|
|
143
|
-
maxSeedWords: normalizeCeiling(
|
|
144
|
-
bag.maxSeedWords,
|
|
145
|
-
DEFAULT_COMPLEXITY_GATE.maxSeedWords,
|
|
146
|
-
),
|
|
147
188
|
maxArtifacts: normalizeCeiling(
|
|
148
189
|
bag.maxArtifacts,
|
|
149
190
|
DEFAULT_COMPLEXITY_GATE.maxArtifacts,
|
|
@@ -153,9 +194,7 @@ function resolveComplexityGate(config) {
|
|
|
153
194
|
|
|
154
195
|
/**
|
|
155
196
|
* Count top-level enumerated items (`- `, `* `, `1. `) in a free-form seed —
|
|
156
|
-
*
|
|
157
|
-
* capabilities. Each enumerated line is one predicted artifact; a seed with two
|
|
158
|
-
* or more is a multi-capability scope that must take the full path.
|
|
197
|
+
* each enumerated line is one predicted artifact.
|
|
159
198
|
*
|
|
160
199
|
* @param {string} text
|
|
161
200
|
* @returns {number}
|
|
@@ -167,152 +206,490 @@ function countSeedArtifacts(text) {
|
|
|
167
206
|
.filter((line) => /^\s*(?:[-*]|\d+\.)\s+\S/.test(line)).length;
|
|
168
207
|
}
|
|
169
208
|
|
|
209
|
+
/** Cap on predicted-path extraction, to bound pathological seeds. */
|
|
210
|
+
const MAX_PREDICTED_PATHS = 50;
|
|
211
|
+
|
|
170
212
|
/**
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
213
|
+
* Extract path-like tokens (at least one `/` plus a dotted extension) from a
|
|
214
|
+
* free-form seed — the predicted footprint the sensitive-path and repo-state
|
|
215
|
+
* signals classify.
|
|
174
216
|
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
|
|
217
|
+
* @param {string} text
|
|
218
|
+
* @returns {string[]} Deduplicated, in order of first appearance.
|
|
219
|
+
*/
|
|
220
|
+
function extractPredictedPaths(text) {
|
|
221
|
+
if (typeof text !== 'string' || text.length === 0) return [];
|
|
222
|
+
const re = /(?:^|[\s`'"([])((?:[\w@.-]+\/)+[\w@.-]+\.[A-Za-z0-9]{1,8})/gm;
|
|
223
|
+
const seen = new Set();
|
|
224
|
+
let match = re.exec(text);
|
|
225
|
+
while (match !== null && seen.size < MAX_PREDICTED_PATHS) {
|
|
226
|
+
seen.add(match[1]);
|
|
227
|
+
match = re.exec(text);
|
|
228
|
+
}
|
|
229
|
+
return [...seen];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Build the advisory complexity **signals** for a planning seed
|
|
234
|
+
* (Story #4722 AC-2). Signals, not routing: the result carries
|
|
235
|
+
* `routingAuthority: false` and no `route` field — the planner reads these
|
|
236
|
+
* alongside its own judgment ({@link resolvePlannerRouteVerdict}) and the
|
|
237
|
+
* deterministic shape backstop validates the authored Story at persist.
|
|
178
238
|
*
|
|
179
|
-
*
|
|
239
|
+
* - `artifactCount` — enumerated items in the seed, with the
|
|
240
|
+
* configured `maxArtifacts` threshold beside it
|
|
241
|
+
* as one input signal.
|
|
242
|
+
* - `riskHeuristicHits` — `planning.riskHeuristics` phrases present in
|
|
243
|
+
* the seed (same substring matcher the
|
|
244
|
+
* pre-mortem critic uses).
|
|
245
|
+
* - `predictedPaths` / `repoState` — path-like tokens in the seed and
|
|
246
|
+
* which of them exist in the repo (existing
|
|
247
|
+
* paths predict refactors; missing predict
|
|
248
|
+
* creates).
|
|
249
|
+
* - `sensitivePathClasses` — `audit-rules.json` sensitive-path classes the
|
|
250
|
+
* predicted footprint intersects (the same
|
|
251
|
+
* taxonomy close applies to the landed diff).
|
|
252
|
+
*
|
|
253
|
+
* Total: never throws; a failed classification degrades to an empty class
|
|
254
|
+
* list (the honest "no signal", never a verdict).
|
|
255
|
+
*
|
|
256
|
+
* @param {{
|
|
257
|
+
* seedText?: string,
|
|
258
|
+
* config?: object,
|
|
259
|
+
* riskHeuristics?: string[],
|
|
260
|
+
* cwd?: string,
|
|
261
|
+
* pathExistsFn?: (absPath: string) => boolean,
|
|
262
|
+
* injectedRules?: object,
|
|
263
|
+
* selectSensitivePathClassesFn?: Function,
|
|
264
|
+
* }} [args]
|
|
265
|
+
* @returns {{
|
|
266
|
+
* artifactCount: number,
|
|
267
|
+
* maxArtifacts: number,
|
|
268
|
+
* riskHeuristicHits: string[],
|
|
269
|
+
* predictedPaths: string[],
|
|
270
|
+
* repoState: { existingPaths: string[], missingPaths: string[] },
|
|
271
|
+
* sensitivePathClasses: string[],
|
|
272
|
+
* gate: { enabled: boolean },
|
|
273
|
+
* advisory: true,
|
|
274
|
+
* routingAuthority: false,
|
|
275
|
+
* }}
|
|
276
|
+
*/
|
|
277
|
+
export function buildComplexitySignals({
|
|
278
|
+
seedText = '',
|
|
279
|
+
config,
|
|
280
|
+
riskHeuristics = [],
|
|
281
|
+
cwd,
|
|
282
|
+
pathExistsFn = existsSync,
|
|
283
|
+
injectedRules,
|
|
284
|
+
selectSensitivePathClassesFn,
|
|
285
|
+
} = {}) {
|
|
286
|
+
const gate = resolveComplexityGate(config);
|
|
287
|
+
const text = typeof seedText === 'string' ? seedText : '';
|
|
288
|
+
const haystack = text.toLowerCase();
|
|
289
|
+
|
|
290
|
+
const riskHeuristicHits = (
|
|
291
|
+
Array.isArray(riskHeuristics) ? riskHeuristics : []
|
|
292
|
+
).filter(
|
|
293
|
+
(phrase) =>
|
|
294
|
+
typeof phrase === 'string' &&
|
|
295
|
+
phrase.trim().length > 0 &&
|
|
296
|
+
haystack.includes(phrase.trim().toLowerCase()),
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const predictedPaths = extractPredictedPaths(text);
|
|
300
|
+
const root = typeof cwd === 'string' && cwd !== '' ? cwd : process.cwd();
|
|
301
|
+
const existingPaths = [];
|
|
302
|
+
const missingPaths = [];
|
|
303
|
+
for (const p of predictedPaths) {
|
|
304
|
+
let exists = false;
|
|
305
|
+
try {
|
|
306
|
+
exists = pathExistsFn(path.resolve(root, p)) === true;
|
|
307
|
+
} catch {
|
|
308
|
+
exists = false;
|
|
309
|
+
}
|
|
310
|
+
(exists ? existingPaths : missingPaths).push(p);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const { classes } = deriveChangeLevel({
|
|
314
|
+
changedFiles: predictedPaths,
|
|
315
|
+
injectedRules,
|
|
316
|
+
selectSensitivePathClassesFn,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
artifactCount: countSeedArtifacts(text),
|
|
321
|
+
maxArtifacts: gate.maxArtifacts,
|
|
322
|
+
riskHeuristicHits,
|
|
323
|
+
predictedPaths,
|
|
324
|
+
repoState: { existingPaths, missingPaths },
|
|
325
|
+
sensitivePathClasses: classes,
|
|
326
|
+
gate: { enabled: gate.enabled },
|
|
327
|
+
advisory: /** @type {const} */ (true),
|
|
328
|
+
routingAuthority: /** @type {const} */ (false),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Resolve the planner's authored trivial-vs-standard verdict
|
|
334
|
+
* (Story #4722 AC-2, generalizing the former one-way `applyPlannerDowngrade`
|
|
335
|
+
* seam into the verdict itself).
|
|
336
|
+
*
|
|
337
|
+
* The planner — not a word count — owns the judgment, and the contract keeps
|
|
338
|
+
* it auditable: `lite` **only** with a non-empty recorded reason (carried on
|
|
339
|
+
* `authored` and ledgered on every created Story's `story-plan-state`
|
|
340
|
+
* checkpoint by persist). Absent a recorded reason the conservative default
|
|
341
|
+
* stands: `full`, with `authored: null`. Pure and total.
|
|
342
|
+
*
|
|
343
|
+
* The verdict is a **claim**, not the decision — persist validates it against
|
|
344
|
+
* the authored Story's shape ({@link deriveStoryShape}) and fails closed to
|
|
345
|
+
* `full` when the shape exceeds the ceilings.
|
|
346
|
+
*
|
|
347
|
+
* @param {{ reason?: unknown }} [args]
|
|
180
348
|
* @returns {{
|
|
181
349
|
* route: ComplexityRoute,
|
|
182
350
|
* reasons: string[],
|
|
183
|
-
*
|
|
351
|
+
* authored: Readonly<{ route: 'lite', reason: string }>|null,
|
|
352
|
+
* preserves: typeof LITE_PATH_INVARIANTS,
|
|
353
|
+
* }}
|
|
354
|
+
*/
|
|
355
|
+
export function resolvePlannerRouteVerdict({ reason } = {}) {
|
|
356
|
+
const recorded = typeof reason === 'string' ? reason.trim() : '';
|
|
357
|
+
if (recorded === '') {
|
|
358
|
+
return {
|
|
359
|
+
route: 'full',
|
|
360
|
+
reasons: [
|
|
361
|
+
'no authored lite verdict (no recorded reason) — standard full route',
|
|
362
|
+
],
|
|
363
|
+
authored: null,
|
|
364
|
+
preserves: LITE_PATH_INVARIANTS,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
route: 'lite',
|
|
369
|
+
reasons: [`planner verdict: lite (recorded reason): ${recorded}`],
|
|
370
|
+
authored: Object.freeze({ route: 'lite', reason: recorded }),
|
|
371
|
+
preserves: LITE_PATH_INVARIANTS,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Derive the complexity route from an authored Story's **objective shape**
|
|
377
|
+
* (Story #4722 AC-3/AC-4) — the single shape function persist's backstop and
|
|
378
|
+
* `/deliver`'s dispatch derivation both read, so the two can never disagree
|
|
379
|
+
* about the same body.
|
|
380
|
+
*
|
|
381
|
+
* `lite` requires **every** signal to agree, against
|
|
382
|
+
* {@link STORY_SHAPE_CEILINGS}:
|
|
383
|
+
*
|
|
384
|
+
* - a declared, parseable, glob-free `changes[]` footprint of at most
|
|
385
|
+
* `maxChanges` entries, at most `maxNonCreateChanges` of which touch
|
|
386
|
+
* existing surfaces (creates-vs-refactors mix);
|
|
387
|
+
* - at most `maxAcceptance` acceptance criteria (and at least one — a Story
|
|
388
|
+
* with no contract cannot be judged trivial);
|
|
389
|
+
* - a footprint intersecting **no** sensitive-path class
|
|
390
|
+
* (`deriveChangeLevel`, the taxonomy close applies to the landed diff).
|
|
391
|
+
* Sensitivity always wins (AC-6): a sensitive footprint routes `full`,
|
|
392
|
+
* which keeps the fresh acceptance critic via `ceremony-routing.js`.
|
|
393
|
+
*
|
|
394
|
+
* Everything else — including an unknown/undeclared footprint or an
|
|
395
|
+
* unreadable sensitive-path manifest — fails toward `full`. Total: never
|
|
396
|
+
* throws.
|
|
397
|
+
*
|
|
398
|
+
* @param {{
|
|
399
|
+
* changes?: unknown,
|
|
400
|
+
* acceptance?: unknown,
|
|
401
|
+
* injectedRules?: object,
|
|
402
|
+
* selectSensitivePathClassesFn?: Function,
|
|
403
|
+
* }} [args]
|
|
404
|
+
* @returns {{
|
|
405
|
+
* route: ComplexityRoute,
|
|
406
|
+
* reasons: string[],
|
|
407
|
+
* shape: {
|
|
408
|
+
* changeCount: number,
|
|
409
|
+
* acceptanceCount: number,
|
|
410
|
+
* createCount: number,
|
|
411
|
+
* nonCreateCount: number,
|
|
412
|
+
* sensitiveClasses: string[],
|
|
413
|
+
* }|null,
|
|
414
|
+
* ceilings: typeof STORY_SHAPE_CEILINGS,
|
|
184
415
|
* preserves: typeof LITE_PATH_INVARIANTS,
|
|
185
|
-
* advisory: true,
|
|
186
416
|
* }}
|
|
187
417
|
*/
|
|
188
|
-
export function
|
|
189
|
-
|
|
190
|
-
|
|
418
|
+
export function deriveStoryShape({
|
|
419
|
+
changes,
|
|
420
|
+
acceptance,
|
|
421
|
+
injectedRules,
|
|
422
|
+
selectSensitivePathClassesFn,
|
|
423
|
+
} = {}) {
|
|
424
|
+
const ceilings = STORY_SHAPE_CEILINGS;
|
|
191
425
|
const preserves = LITE_PATH_INVARIANTS;
|
|
192
|
-
const decide = (route, reason) => ({
|
|
426
|
+
const decide = (route, reason, shape = null) => ({
|
|
193
427
|
route,
|
|
194
428
|
reasons: [reason],
|
|
195
|
-
|
|
429
|
+
shape,
|
|
430
|
+
ceilings,
|
|
196
431
|
preserves,
|
|
197
|
-
advisory,
|
|
198
432
|
});
|
|
199
433
|
|
|
200
|
-
if (!
|
|
434
|
+
if (!Array.isArray(changes) || changes.length === 0) {
|
|
201
435
|
return decide(
|
|
202
436
|
'full',
|
|
203
|
-
'
|
|
437
|
+
'no changes[] declared — the footprint is unknown, so the shape cannot be judged trivial; conservative full route',
|
|
204
438
|
);
|
|
205
439
|
}
|
|
206
440
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
441
|
+
let entries;
|
|
442
|
+
try {
|
|
443
|
+
entries = extractChangePaths(changes);
|
|
444
|
+
} catch (err) {
|
|
210
445
|
return decide(
|
|
211
446
|
'full',
|
|
212
|
-
|
|
447
|
+
`changes[] could not be read (${err?.message ?? err}) — unknown footprint; conservative full route`,
|
|
213
448
|
);
|
|
214
449
|
}
|
|
215
450
|
|
|
216
|
-
const
|
|
217
|
-
|
|
451
|
+
const acceptanceList = Array.isArray(acceptance) ? acceptance : [];
|
|
452
|
+
const nonCreateCount = changes.filter(
|
|
453
|
+
(entry) =>
|
|
454
|
+
!(entry && typeof entry === 'object' && entry.assumption === 'creates'),
|
|
455
|
+
).length;
|
|
456
|
+
const { level, classes } = deriveChangeLevel({
|
|
457
|
+
changedFiles: entries.map((e) => e.path),
|
|
458
|
+
injectedRules,
|
|
459
|
+
selectSensitivePathClassesFn,
|
|
460
|
+
});
|
|
461
|
+
const shape = {
|
|
462
|
+
changeCount: changes.length,
|
|
463
|
+
acceptanceCount: acceptanceList.length,
|
|
464
|
+
createCount: changes.length - nonCreateCount,
|
|
465
|
+
nonCreateCount,
|
|
466
|
+
sensitiveClasses: classes,
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
if (entries.some((e) => e.isGlob)) {
|
|
218
470
|
return decide(
|
|
219
471
|
'full',
|
|
220
|
-
|
|
472
|
+
'changes[] contains a glob path — unknown footprint width; conservative full route',
|
|
473
|
+
shape,
|
|
221
474
|
);
|
|
222
475
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
476
|
+
if (shape.changeCount > ceilings.maxChanges) {
|
|
477
|
+
return decide(
|
|
478
|
+
'full',
|
|
479
|
+
`changes[] declares ${shape.changeCount} entries (> maxChanges ${ceilings.maxChanges}) — not a trivial footprint; full route`,
|
|
480
|
+
shape,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
if (shape.acceptanceCount === 0) {
|
|
226
484
|
return decide(
|
|
227
485
|
'full',
|
|
228
|
-
|
|
486
|
+
'no acceptance criteria — the contract cannot be judged trivial; conservative full route',
|
|
487
|
+
shape,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
if (shape.acceptanceCount > ceilings.maxAcceptance) {
|
|
491
|
+
return decide(
|
|
492
|
+
'full',
|
|
493
|
+
`${shape.acceptanceCount} acceptance criteria (> maxAcceptance ${ceilings.maxAcceptance}) — more contract than a trivial scope carries; full route`,
|
|
494
|
+
shape,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
if (shape.nonCreateCount > ceilings.maxNonCreateChanges) {
|
|
498
|
+
return decide(
|
|
499
|
+
'full',
|
|
500
|
+
`${shape.nonCreateCount} non-create change(s) (> maxNonCreateChanges ${ceilings.maxNonCreateChanges}) — a mostly-refactoring mix is not a trivial additive scope; full route`,
|
|
501
|
+
shape,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
if (shape.sensitiveClasses.length > 0) {
|
|
505
|
+
return decide(
|
|
506
|
+
'full',
|
|
507
|
+
`footprint intersects sensitive-path class(es) ${shape.sensitiveClasses.join(', ')} — sensitivity wins over a small shape; full route (fresh acceptance critic retained)`,
|
|
508
|
+
shape,
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
if (level !== 'low') {
|
|
512
|
+
// `deriveChangeLevel` degraded to its null fail-safe (unreadable
|
|
513
|
+
// manifest / failed selector): there is no evidence the footprint is
|
|
514
|
+
// non-sensitive, and a classification failure must never buy lite.
|
|
515
|
+
return decide(
|
|
516
|
+
'full',
|
|
517
|
+
'sensitive-path classification unavailable — cannot verify the footprint is non-sensitive; conservative full route',
|
|
518
|
+
shape,
|
|
229
519
|
);
|
|
230
520
|
}
|
|
231
521
|
|
|
232
522
|
return decide(
|
|
233
523
|
'lite',
|
|
234
|
-
`trivial
|
|
524
|
+
`trivial shape: ${shape.changeCount} change(s) ≤ ${ceilings.maxChanges}, ${shape.acceptanceCount} acceptance criteria ≤ ${ceilings.maxAcceptance}, ${shape.nonCreateCount} non-create ≤ ${ceilings.maxNonCreateChanges}, no sensitive-path class — inline-eligible; non-negotiables preserved`,
|
|
525
|
+
shape,
|
|
235
526
|
);
|
|
236
527
|
}
|
|
237
528
|
|
|
238
529
|
/**
|
|
239
|
-
*
|
|
240
|
-
* (
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
* - Otherwise the returned signal routes `lite`, appends the reason to
|
|
252
|
-
* `reasons`, and carries a frozen `downgraded: { from: 'full', reason }`
|
|
253
|
-
* record so the judgment is ledgerable on plan state (plan-persist writes
|
|
254
|
-
* it into every created Story's `story-plan-state` checkpoint).
|
|
255
|
-
*
|
|
256
|
-
* Pure and total: never mutates `signal`, never throws on malformed input.
|
|
257
|
-
* The gate itself ({@link buildComplexityRouteSignal}) is untouched — it
|
|
258
|
-
* still fails toward `full` on any doubt.
|
|
259
|
-
*
|
|
260
|
-
* @param {ReturnType<typeof buildComplexityRouteSignal>|null|undefined} signal
|
|
261
|
-
* @param {{ reason?: unknown }} [args]
|
|
262
|
-
* @returns {object|null|undefined} The (possibly downgraded) signal.
|
|
530
|
+
* Derive the complexity route from a Story's **serialized body markdown** —
|
|
531
|
+
* the deliver-side entry to {@link deriveStoryShape} (`/deliver` already
|
|
532
|
+
* fetches the body; the route is computed from it, never from a label). An
|
|
533
|
+
* unparseable body degrades to `full`: unknown shape is not trivial shape.
|
|
534
|
+
*
|
|
535
|
+
* Module-private, reachable end to end through
|
|
536
|
+
* {@link resolveStoryDispatchMode} (which returns the derived route) — so
|
|
537
|
+
* there is no test-only export to leave production-dead.
|
|
538
|
+
*
|
|
539
|
+
* @param {string} body Serialized Story-body markdown.
|
|
540
|
+
* @param {{ injectedRules?: object, selectSensitivePathClassesFn?: Function }} [opts]
|
|
541
|
+
* @returns {ReturnType<typeof deriveStoryShape>}
|
|
263
542
|
*/
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
543
|
+
function deriveStoryRouteFromBody(body, opts = {}) {
|
|
544
|
+
let parsed;
|
|
545
|
+
try {
|
|
546
|
+
parsed = parseStoryBody(String(body ?? '')).body;
|
|
547
|
+
} catch (err) {
|
|
548
|
+
return {
|
|
549
|
+
route: 'full',
|
|
550
|
+
reasons: [
|
|
551
|
+
`Story body is unparseable (${err?.message ?? err}) — shape unknown; conservative full route`,
|
|
552
|
+
],
|
|
553
|
+
shape: null,
|
|
554
|
+
ceilings: STORY_SHAPE_CEILINGS,
|
|
555
|
+
preserves: LITE_PATH_INVARIANTS,
|
|
556
|
+
};
|
|
267
557
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
...(Array.isArray(signal.reasons) ? signal.reasons : []),
|
|
275
|
-
`planner downgrade full → lite (recorded reason): ${recorded}`,
|
|
276
|
-
],
|
|
277
|
-
downgraded: Object.freeze({ from: 'full', reason: recorded }),
|
|
278
|
-
};
|
|
558
|
+
return deriveStoryShape({
|
|
559
|
+
changes: parsed?.changes,
|
|
560
|
+
acceptance: parsed?.acceptance,
|
|
561
|
+
injectedRules: opts.injectedRules,
|
|
562
|
+
selectSensitivePathClassesFn: opts.selectSensitivePathClassesFn,
|
|
563
|
+
});
|
|
279
564
|
}
|
|
280
565
|
|
|
281
566
|
/**
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
* malformed labels — dispatches as a sub-agent: absence of the marker is the
|
|
291
|
-
* conservative default, mirroring the gate's fail-toward-`full` posture.
|
|
292
|
-
*
|
|
293
|
-
* Inline execution removes model-side fan-out only. Every deterministic
|
|
294
|
-
* `single-story-close.js` gate (validation, security baseline, PR-to-`main`)
|
|
295
|
-
* runs unchanged regardless of mode — see {@link LITE_PATH_INVARIANTS}.
|
|
296
|
-
*
|
|
297
|
-
* @param {{ labels?: unknown }} [args]
|
|
298
|
-
* @returns {{ mode: 'inline'|'subagent', reasons: string[] }}
|
|
567
|
+
* Best-effort route derivation for reporting, when the *mode* is already
|
|
568
|
+
* pinned by run topology and only `route` remains to be filled in. A body
|
|
569
|
+
* that will not parse yields `null` rather than throwing — the caller is not
|
|
570
|
+
* asking the shape to decide anything.
|
|
571
|
+
*
|
|
572
|
+
* @param {unknown} body
|
|
573
|
+
* @param {{ injectedRules?: object, selectSensitivePathClassesFn?: Function }} opts
|
|
574
|
+
* @returns {ReturnType<typeof deriveStoryShape>|null}
|
|
299
575
|
*/
|
|
300
|
-
|
|
301
|
-
|
|
576
|
+
function routeForReporting(body, opts) {
|
|
577
|
+
if (typeof body !== 'string' || body.trim() === '') return null;
|
|
578
|
+
return deriveStoryRouteFromBody(body, opts);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Decide how `/deliver` executes a Story.
|
|
583
|
+
*
|
|
584
|
+
* Two independent premises, checked in this order:
|
|
585
|
+
*
|
|
586
|
+
* 1. **Run topology (Story #4736).** A run delivering a *single* Story
|
|
587
|
+
* executes **inline**, whatever its shape. Sub-agent isolation is
|
|
588
|
+
* load-bearing only for CONCURRENT dispatch — two workers sharing a
|
|
589
|
+
* checkout would race on worktrees and branch refs — and a one-Story run
|
|
590
|
+
* has no sibling to race. It therefore pays the spawn premium (a boot is
|
|
591
|
+
* a cache WRITE at full rate, where an inline continuation is a cache read
|
|
592
|
+
* at ~10%; ~$1.43/M vs ~$1.07/M on comparable bench work) for nothing.
|
|
593
|
+
* This is a fact about the run, not about the work, so the shape gate's
|
|
594
|
+
* `enabled` switch — which governs *shape derivation* — does not reach it.
|
|
595
|
+
* 2. **Shape (Story #4722 AC-4/AC-5).** For a multi-Story run, the decision
|
|
596
|
+
* comes **from the Story body's own shape**, never from the `route::lite`
|
|
597
|
+
* label: a lite-shaped Story executes inline; everything else — a
|
|
598
|
+
* full-shaped body, a missing/unparseable body, or the gate disabled via
|
|
599
|
+
* `planning.complexityGate.enabled=false` — dispatches as a sub-agent,
|
|
600
|
+
* the conservative default.
|
|
601
|
+
*
|
|
602
|
+
* The label is read only to report hint consistency in `reasons`: with the
|
|
603
|
+
* label absent (or its write failed) a lite-shaped Story still runs inline,
|
|
604
|
+
* and with the label present on a full-shaped Story the shape wins.
|
|
605
|
+
*
|
|
606
|
+
* Inline execution removes model-side fan-out only — it changes **where** the
|
|
607
|
+
* engine runs, never **what** runs. Every deterministic
|
|
608
|
+
* `single-story-close.js` gate, the PR to `main`, and the
|
|
609
|
+
* `story-deliver-terminal` envelope are identical in both modes; see the
|
|
610
|
+
* module header's non-negotiables.
|
|
611
|
+
*
|
|
612
|
+
* @param {{
|
|
613
|
+
* body?: unknown,
|
|
614
|
+
* labels?: unknown,
|
|
615
|
+
* config?: object,
|
|
616
|
+
* storyCount?: unknown,
|
|
617
|
+
* injectedRules?: object,
|
|
618
|
+
* selectSensitivePathClassesFn?: Function,
|
|
619
|
+
* }} [args] `storyCount` is the number of Stories the invoking `/deliver` run
|
|
620
|
+
* resolved. Omitted (or not a positive integer) means "unknown run size",
|
|
621
|
+
* which falls through to the shape decision — never to an assumed 1.
|
|
622
|
+
* @returns {{ mode: 'inline'|'subagent', reasons: string[], route: ReturnType<typeof deriveStoryShape>|null }}
|
|
623
|
+
*/
|
|
624
|
+
export function resolveStoryDispatchMode({
|
|
625
|
+
body,
|
|
626
|
+
labels,
|
|
627
|
+
config,
|
|
628
|
+
storyCount,
|
|
629
|
+
injectedRules,
|
|
630
|
+
selectSensitivePathClassesFn,
|
|
631
|
+
} = {}) {
|
|
632
|
+
const labelList = Array.isArray(labels)
|
|
302
633
|
? labels.filter((l) => typeof l === 'string')
|
|
303
634
|
: [];
|
|
304
|
-
|
|
635
|
+
const hasHint = labelList.includes(LITE_ROUTE_LABEL);
|
|
636
|
+
const hintNote = hasHint
|
|
637
|
+
? `the ${LITE_ROUTE_LABEL} label is present (hint only — the derived shape is the control signal)`
|
|
638
|
+
: `the ${LITE_ROUTE_LABEL} label is absent (hint only — the derived shape is the control signal)`;
|
|
639
|
+
|
|
640
|
+
if (storyCount === 1) {
|
|
641
|
+
return {
|
|
642
|
+
mode: 'inline',
|
|
643
|
+
reasons: [
|
|
644
|
+
'single-Story run — execute deliver-story inline; sub-agent isolation is load-bearing only for concurrent dispatch, and a one-Story run has no sibling to race (close gates, PR, and terminal envelope unchanged)',
|
|
645
|
+
hintNote,
|
|
646
|
+
],
|
|
647
|
+
route: routeForReporting(body, {
|
|
648
|
+
injectedRules,
|
|
649
|
+
selectSensitivePathClassesFn,
|
|
650
|
+
}),
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const gate = resolveComplexityGate(config);
|
|
655
|
+
if (!gate.enabled) {
|
|
656
|
+
return {
|
|
657
|
+
mode: 'subagent',
|
|
658
|
+
reasons: [
|
|
659
|
+
'complexity routing disabled (planning.complexityGate.enabled=false) — standard sub-agent dispatch',
|
|
660
|
+
],
|
|
661
|
+
route: null,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (typeof body !== 'string' || body.trim() === '') {
|
|
666
|
+
return {
|
|
667
|
+
mode: 'subagent',
|
|
668
|
+
reasons: [
|
|
669
|
+
'no Story body to derive shape from — conservative sub-agent dispatch',
|
|
670
|
+
hintNote,
|
|
671
|
+
],
|
|
672
|
+
route: null,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const route = deriveStoryRouteFromBody(body, {
|
|
677
|
+
injectedRules,
|
|
678
|
+
selectSensitivePathClassesFn,
|
|
679
|
+
});
|
|
680
|
+
if (route.route === 'lite') {
|
|
305
681
|
return {
|
|
306
682
|
mode: 'inline',
|
|
307
683
|
reasons: [
|
|
308
|
-
`Story
|
|
684
|
+
`lite-shaped Story — execute deliver-story inline; no story-worker or acceptance-critic sub-agent dispatch (close gates unchanged): ${route.reasons[0]}`,
|
|
685
|
+
hintNote,
|
|
309
686
|
],
|
|
687
|
+
route,
|
|
310
688
|
};
|
|
311
689
|
}
|
|
312
690
|
return {
|
|
313
691
|
mode: 'subagent',
|
|
314
|
-
reasons: [
|
|
315
|
-
|
|
316
|
-
],
|
|
692
|
+
reasons: [`full-shaped Story — ${route.reasons[0]}`, hintNote],
|
|
693
|
+
route,
|
|
317
694
|
};
|
|
318
695
|
}
|