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