mandrel 2.15.0 → 2.17.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 +1 -0
- package/.agents/docs/quality-gates.md +137 -0
- package/.agents/docs/workflows.md +2 -1
- package/.agents/schemas/agentrc.schema.json +6 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +52 -12
- package/.agents/scripts/audit-to-stories.js +92 -25
- package/.agents/scripts/boot-sweep.js +28 -6
- package/.agents/scripts/check-baseline-drift.js +138 -0
- package/.agents/scripts/coverage-capture.js +74 -25
- package/.agents/scripts/deliver-light.js +31 -3
- package/.agents/scripts/deliver-recover.js +45 -18
- package/.agents/scripts/drain-pending-cleanup.js +67 -23
- package/.agents/scripts/generate-lens-checklists.js +81 -30
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
- package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
- package/.agents/scripts/lib/baselines/envelope.js +7 -0
- package/.agents/scripts/lib/baselines/kernel.js +31 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
- package/.agents/scripts/lib/baselines/reader.js +12 -1
- package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
- package/.agents/scripts/lib/baselines/writer.js +10 -0
- package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
- package/.agents/scripts/lib/cli-utils.js +48 -13
- package/.agents/scripts/lib/close-validation/process.js +61 -15
- package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
- package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
- package/.agents/scripts/lib/close-validation/runner.js +68 -0
- package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
- package/.agents/scripts/lib/config/quality.js +40 -0
- package/.agents/scripts/lib/coverage-utils.js +92 -9
- package/.agents/scripts/lib/crap-engine.js +113 -23
- package/.agents/scripts/lib/crap-utils.js +159 -93
- package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
- package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
- package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +307 -89
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
- package/.agents/scripts/lib/orchestration/light-suitability.js +31 -9
- package/.agents/scripts/lib/orchestration/plan-context.js +190 -10
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +87 -13
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +38 -15
- package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
- package/.agents/scripts/lib/stdio-flush.js +71 -0
- package/.agents/scripts/lib/transpile.js +133 -6
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
- package/.agents/scripts/lib/workers/crap-worker.js +49 -76
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
- package/.agents/scripts/nav-registry-diff.js +30 -8
- package/.agents/scripts/plan-context.js +4 -1
- package/.agents/scripts/plan-run-epilogue.js +27 -11
- package/.agents/scripts/resolve-doc-tiers.js +18 -8
- package/.agents/scripts/single-story-close.js +9 -92
- package/.agents/scripts/update-crap-baseline.js +13 -0
- package/.agents/workflows/helpers/deliver-light.md +34 -8
- package/.agents/workflows/helpers/plan-reference.md +27 -6
- package/.agents/workflows/plan.md +4 -2
- package/.agents/workflows/prototype.md +104 -0
- package/README.md +14 -6
- package/docs/CHANGELOG.md +41 -0
- package/lib/cli/version-helpers.js +7 -0
- package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
- package/package.json +5 -1
|
@@ -27,9 +27,23 @@ const CLI_OPTIONS = {
|
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* @param {string[]} [argv]
|
|
30
|
+
* @param {{
|
|
31
|
+
* resolveConfigImpl?: typeof resolveConfig,
|
|
32
|
+
* createProviderImpl?: typeof createProvider,
|
|
33
|
+
* runPlanRunEpilogueImpl?: typeof runPlanRunEpilogue,
|
|
34
|
+
* logger?: { info: Function, warn: Function },
|
|
35
|
+
* }} [deps] Injectable seams; every entry defaults to the real
|
|
36
|
+
* implementation (`.agents/rules/test-seams.md` rules 1-2), so the CLI path
|
|
37
|
+
* and every production caller are unchanged.
|
|
30
38
|
* @returns {Promise<object>}
|
|
31
39
|
*/
|
|
32
|
-
export async function main(argv = process.argv.slice(2)) {
|
|
40
|
+
export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
41
|
+
const {
|
|
42
|
+
resolveConfigImpl = resolveConfig,
|
|
43
|
+
createProviderImpl = createProvider,
|
|
44
|
+
runPlanRunEpilogueImpl = runPlanRunEpilogue,
|
|
45
|
+
logger = Logger,
|
|
46
|
+
} = deps;
|
|
33
47
|
const { values } = parseArgs({
|
|
34
48
|
args: argv,
|
|
35
49
|
options: CLI_OPTIONS,
|
|
@@ -44,8 +58,8 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
44
58
|
typeof values.cwd === 'string' && values.cwd.trim()
|
|
45
59
|
? values.cwd.trim()
|
|
46
60
|
: process.cwd();
|
|
47
|
-
const config =
|
|
48
|
-
const provider =
|
|
61
|
+
const config = resolveConfigImpl({ cwd });
|
|
62
|
+
const provider = createProviderImpl(config);
|
|
49
63
|
|
|
50
64
|
// Story #4540 retired the `--run <planRunId>` label-resolution branch
|
|
51
65
|
// along with the label itself. The epilogue is keyed on the delivered id
|
|
@@ -58,16 +72,16 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
58
72
|
|
|
59
73
|
const planRunId = `adhoc-${[...stories].sort((a, b) => a - b).join('-')}`;
|
|
60
74
|
|
|
61
|
-
const result = await
|
|
75
|
+
const result = await runPlanRunEpilogueImpl({
|
|
62
76
|
planRunId,
|
|
63
77
|
stories,
|
|
64
78
|
provider,
|
|
65
79
|
config,
|
|
66
80
|
cwd,
|
|
67
81
|
});
|
|
68
|
-
warnOnUnresolvedBase(result);
|
|
69
|
-
warnOnEmptyRollup(result);
|
|
70
|
-
|
|
82
|
+
warnOnUnresolvedBase(result, logger);
|
|
83
|
+
warnOnEmptyRollup(result, logger);
|
|
84
|
+
logger.info(JSON.stringify(result));
|
|
71
85
|
if (result.errors?.length) {
|
|
72
86
|
process.exitCode = 1;
|
|
73
87
|
}
|
|
@@ -84,15 +98,16 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
84
98
|
* roster is still useful.
|
|
85
99
|
*
|
|
86
100
|
* @param {object} result - `runPlanRunEpilogue` envelope.
|
|
101
|
+
* @param {{ warn: Function }} [logger]
|
|
87
102
|
* @returns {void}
|
|
88
103
|
*/
|
|
89
|
-
function warnOnUnresolvedBase(result) {
|
|
104
|
+
function warnOnUnresolvedBase(result, logger = Logger) {
|
|
90
105
|
const roster = (result?.results ?? []).find(
|
|
91
106
|
(r) => r?.kind === 'audit-roster',
|
|
92
107
|
);
|
|
93
108
|
const base = roster?.baseResolution;
|
|
94
109
|
if (base?.resolved !== false) return;
|
|
95
|
-
|
|
110
|
+
logger.warn(
|
|
96
111
|
`⚠️ Combined landed diff unavailable — the pre-run base sha could not be ` +
|
|
97
112
|
`resolved against \`${base.baseRef}\`: ${base.reason}\n` +
|
|
98
113
|
` changedFiles is null (NOT an empty set). Determine the run diff by ` +
|
|
@@ -117,14 +132,15 @@ function warnOnUnresolvedBase(result) {
|
|
|
117
132
|
* rather than asserting either reading.
|
|
118
133
|
*
|
|
119
134
|
* @param {object} result - `runPlanRunEpilogue` envelope.
|
|
135
|
+
* @param {{ warn: Function }} [logger]
|
|
120
136
|
* @returns {void}
|
|
121
137
|
*/
|
|
122
|
-
function warnOnEmptyRollup(result) {
|
|
138
|
+
function warnOnEmptyRollup(result, logger = Logger) {
|
|
123
139
|
const rollup = (result?.results ?? []).find(
|
|
124
140
|
(r) => r?.kind === 'follow-up-rollup',
|
|
125
141
|
);
|
|
126
142
|
if (!rollup?.emptyRollupSuspect) return;
|
|
127
|
-
|
|
143
|
+
logger.warn(
|
|
128
144
|
`⚠️ 0 friction signals across ${rollup.storyCount} Stories — telemetry may not ` +
|
|
129
145
|
`have fired.\n` +
|
|
130
146
|
` An empty roll-up is NOT evidence of a clean run: it is the same output a ` +
|
|
@@ -50,24 +50,34 @@ export function parseArgv(argv = []) {
|
|
|
50
50
|
* Top-level CLI entry. Exported so tests can drive it against a fixture root
|
|
51
51
|
* with an injected sink and config.
|
|
52
52
|
*
|
|
53
|
+
* The optional final `deps` parameter is the module's injectable seam
|
|
54
|
+
* (`.agents/rules/test-seams.md` rules 1-2): every entry defaults to the real
|
|
55
|
+
* implementation, so the CLI path below — and any production caller — needs no
|
|
56
|
+
* configuration change.
|
|
57
|
+
*
|
|
53
58
|
* @param {{
|
|
54
59
|
* argv?: string[],
|
|
55
60
|
* config?: object,
|
|
56
61
|
* root?: string,
|
|
57
62
|
* stdout?: { write: (s: string) => void },
|
|
58
63
|
* }} [opts]
|
|
64
|
+
* @param {{
|
|
65
|
+
* resolveConfigImpl?: typeof resolveConfig,
|
|
66
|
+
* resolveDocTiersImpl?: typeof resolveDocTiers,
|
|
67
|
+
* }} [deps]
|
|
59
68
|
* @returns {Promise<number>} always 0
|
|
60
69
|
*/
|
|
61
|
-
export async function runCli(
|
|
62
|
-
argv = process.argv.slice(2),
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
} = {}
|
|
70
|
+
export async function runCli(
|
|
71
|
+
{ argv = process.argv.slice(2), config, root, stdout = process.stdout } = {},
|
|
72
|
+
{
|
|
73
|
+
resolveConfigImpl = resolveConfig,
|
|
74
|
+
resolveDocTiersImpl = resolveDocTiers,
|
|
75
|
+
} = {},
|
|
76
|
+
) {
|
|
67
77
|
const { rootPath } = parseArgv(argv);
|
|
68
|
-
const resolvedConfig = config ??
|
|
78
|
+
const resolvedConfig = config ?? resolveConfigImpl();
|
|
69
79
|
const resolvedRoot = root ?? rootPath ?? PROJECT_ROOT;
|
|
70
|
-
const result =
|
|
80
|
+
const result = resolveDocTiersImpl(resolvedConfig, { root: resolvedRoot });
|
|
71
81
|
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
72
82
|
return 0;
|
|
73
83
|
}
|
|
@@ -90,6 +90,10 @@ import { runAsCli } from './lib/cli-utils.js';
|
|
|
90
90
|
import { formatCliError } from './lib/error-redactor.js';
|
|
91
91
|
import { Logger } from './lib/Logger.js';
|
|
92
92
|
import { emitTerminalFriction } from './lib/observability/runtime-friction.js';
|
|
93
|
+
import {
|
|
94
|
+
failedTerminalFor,
|
|
95
|
+
gatesForFailedPhase,
|
|
96
|
+
} from './lib/orchestration/single-story-close/failed-terminal.js';
|
|
93
97
|
import { enableAutoMergeWith } from './lib/orchestration/single-story-close/phases/auto-merge.js';
|
|
94
98
|
import {
|
|
95
99
|
buildSyncFailureCommentBody,
|
|
@@ -102,10 +106,8 @@ import {
|
|
|
102
106
|
} from './lib/orchestration/single-story-close/phases/code-review.js';
|
|
103
107
|
import { ensurePullRequestWith } from './lib/orchestration/single-story-close/phases/pull-request.js';
|
|
104
108
|
import {
|
|
105
|
-
buildTerminalEnvelope,
|
|
106
109
|
emitTerminalEnvelope,
|
|
107
110
|
exitCodeForTerminal,
|
|
108
|
-
NEXT_COMMANDS,
|
|
109
111
|
} from './lib/orchestration/story-deliver-terminal.js';
|
|
110
112
|
|
|
111
113
|
// Story #2990 moved the `gh`-spawn boundary into the `lib/gh-exec.js`
|
|
@@ -118,9 +120,13 @@ export const enableAutoMerge = enableAutoMergeWith;
|
|
|
118
120
|
|
|
119
121
|
// Re-export pure helpers verbatim — they don't touch `execFileSync`
|
|
120
122
|
// or any URL-mocked module, so the phase exports work unmodified.
|
|
123
|
+
// `gatesForFailedPhase` now lives beside the envelope it feeds
|
|
124
|
+
// (`single-story-close/failed-terminal.js`); it is re-exported here so the
|
|
125
|
+
// CLI's public surface is unchanged by that move.
|
|
121
126
|
export {
|
|
122
127
|
buildStoryReviewCrossRefBody,
|
|
123
128
|
buildSyncFailureCommentBody,
|
|
129
|
+
gatesForFailedPhase,
|
|
124
130
|
handleSyncFailure,
|
|
125
131
|
parsePrNumber,
|
|
126
132
|
runStoryScopeReview,
|
|
@@ -134,95 +140,6 @@ export async function runSingleStoryClose(opts) {
|
|
|
134
140
|
return mod.runSingleStoryClose(opts);
|
|
135
141
|
}
|
|
136
142
|
|
|
137
|
-
/**
|
|
138
|
-
* The close pipeline's phase order, as `setPhase` walks it. Only used to
|
|
139
|
-
* decide whether a gate had already run when a later phase died.
|
|
140
|
-
*/
|
|
141
|
-
const PHASE_ORDER = Object.freeze([
|
|
142
|
-
'init',
|
|
143
|
-
'wrong-tree-guard',
|
|
144
|
-
'close-validation',
|
|
145
|
-
'base-sync',
|
|
146
|
-
'push',
|
|
147
|
-
'pull-request',
|
|
148
|
-
'code-review',
|
|
149
|
-
'auto-merge',
|
|
150
|
-
'confirm-merge',
|
|
151
|
-
'post-land',
|
|
152
|
-
'done',
|
|
153
|
-
]);
|
|
154
|
-
|
|
155
|
-
/** Each reported gate and the pipeline phase that decides it. */
|
|
156
|
-
const GATE_PHASES = Object.freeze([
|
|
157
|
-
['validation', 'close-validation'],
|
|
158
|
-
['baseSync', 'base-sync'],
|
|
159
|
-
['codeReview', 'code-review'],
|
|
160
|
-
]);
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Report every gate's outcome for a run that died at `phase`.
|
|
164
|
-
*
|
|
165
|
-
* The schema's contract: "A gate the run skipped … reports `skipped` rather
|
|
166
|
-
* than being omitted, so a missing gate is never mistaken for a passing one."
|
|
167
|
-
* The previous shape named only the gate that died and omitted the rest
|
|
168
|
-
* entirely — exactly the ambiguity the contract forbids.
|
|
169
|
-
*
|
|
170
|
-
* Reconstructed from the phase order, which is sound because the pipeline is
|
|
171
|
-
* strictly sequential: reaching phase N means every gate before it completed.
|
|
172
|
-
* A gate whose phase the run never reached is `skipped`; one the operator
|
|
173
|
-
* turned off via `--skip-validation` / `--skip-sync` is `skipped` too (it did
|
|
174
|
-
* not pass — it never ran).
|
|
175
|
-
*
|
|
176
|
-
* @param {string} phase The phase the run died in.
|
|
177
|
-
* @param {{ skipValidation?: boolean, skipSync?: boolean }} args Parsed CLI args.
|
|
178
|
-
* @returns {Record<string, 'passed'|'failed'|'skipped'>}
|
|
179
|
-
*/
|
|
180
|
-
export function gatesForFailedPhase(phase, args = {}) {
|
|
181
|
-
const skipped = { validation: args.skipValidation, baseSync: args.skipSync };
|
|
182
|
-
const failedAt = PHASE_ORDER.indexOf(phase);
|
|
183
|
-
const gates = {};
|
|
184
|
-
for (const [gate, gatePhase] of GATE_PHASES) {
|
|
185
|
-
const at = PHASE_ORDER.indexOf(gatePhase);
|
|
186
|
-
if (gatePhase === phase) gates[gate] = 'failed';
|
|
187
|
-
else if (failedAt < 0 || at > failedAt) gates[gate] = 'skipped';
|
|
188
|
-
else gates[gate] = skipped[gate] ? 'skipped' : 'passed';
|
|
189
|
-
}
|
|
190
|
-
return gates;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Build the `failed` terminal for a phase that crashed.
|
|
195
|
-
*
|
|
196
|
-
* The runner deliberately throws rather than returning a failure (a red gate
|
|
197
|
-
* must not look like a return value), so without this the most common
|
|
198
|
-
* non-happy ending — a failing close-validation gate — would emit **no
|
|
199
|
-
* envelope at all**, exiting 1 with only a stderr line while the workflow
|
|
200
|
-
* docs promise the agent a `failed` envelope naming the phase. Every close
|
|
201
|
-
* invocation emits exactly one envelope; this is the path that keeps that
|
|
202
|
-
* true when a phase dies.
|
|
203
|
-
*
|
|
204
|
-
* `err.closePhase` is tagged by the runner's phase tracker.
|
|
205
|
-
*
|
|
206
|
-
* @param {unknown} err
|
|
207
|
-
* @returns {object|null} A validated envelope, or null when even the story id
|
|
208
|
-
* is unknown (a usage error — there is nothing to report an envelope about).
|
|
209
|
-
*/
|
|
210
|
-
function failedTerminalFor(err) {
|
|
211
|
-
const phase = err?.closePhase ?? 'init';
|
|
212
|
-
const args = parseSprintArgs();
|
|
213
|
-
const storyId = Number(args.storyId);
|
|
214
|
-
if (!Number.isInteger(storyId) || storyId <= 0) return null;
|
|
215
|
-
return buildTerminalEnvelope({
|
|
216
|
-
storyId,
|
|
217
|
-
status: 'failed',
|
|
218
|
-
phase,
|
|
219
|
-
gates: gatesForFailedPhase(phase, args),
|
|
220
|
-
failure: { reason: String(err?.message ?? err) },
|
|
221
|
-
nextCommand: NEXT_COMMANDS.recover(storyId),
|
|
222
|
-
elapsedSeconds: 0,
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
|
|
226
143
|
/**
|
|
227
144
|
* CLI entry — resolves the process exit code from the terminal envelope's
|
|
228
145
|
* status rather than from a thrown/not-thrown distinction, so `pending`
|
|
@@ -234,7 +151,7 @@ async function main() {
|
|
|
234
151
|
const outcome = await runSingleStoryClose();
|
|
235
152
|
return exitCodeForTerminal(outcome?.terminal ?? { status: 'failed' });
|
|
236
153
|
} catch (err) {
|
|
237
|
-
const terminal = failedTerminalFor(err);
|
|
154
|
+
const terminal = failedTerminalFor(err, parseSprintArgs());
|
|
238
155
|
if (!terminal) throw err;
|
|
239
156
|
// Mirror runAsCli's default error line (which this catch pre-empts) so the
|
|
240
157
|
// human-facing failure text is unchanged, then emit the envelope.
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from './lib/config-resolver.js';
|
|
14
14
|
import { loadCoverage } from './lib/coverage-utils.js';
|
|
15
15
|
import {
|
|
16
|
+
checkResolutionFloor,
|
|
16
17
|
resolveEscomplexVersion,
|
|
17
18
|
resolveTsTranspilerVersion,
|
|
18
19
|
scanAndScore,
|
|
@@ -71,6 +72,7 @@ async function main() {
|
|
|
71
72
|
const crap = getQuality(config).crap;
|
|
72
73
|
const targetDirs = Array.isArray(crap.targetDirs) ? crap.targetDirs : [];
|
|
73
74
|
const requireCoverage = crap.requireCoverage !== false;
|
|
75
|
+
const minMethodResolutionRate = crap.minMethodResolutionRate ?? 0.75;
|
|
74
76
|
const coveragePath =
|
|
75
77
|
args.coveragePath ?? crap.coveragePath ?? 'coverage/coverage-final.json';
|
|
76
78
|
const baselinePath = args.baselinePath ?? getBaselines(config).crap.path;
|
|
@@ -117,6 +119,7 @@ async function main() {
|
|
|
117
119
|
scannedFiles,
|
|
118
120
|
skippedFilesNoCoverage,
|
|
119
121
|
skippedMethodsNoCoverage,
|
|
122
|
+
resolution,
|
|
120
123
|
} = await scanAndScore({
|
|
121
124
|
targetDirs,
|
|
122
125
|
coverage,
|
|
@@ -137,6 +140,16 @@ async function main() {
|
|
|
137
140
|
`[CRAP] Skipped ${skippedMethodsNoCoverage} method(s) whose per-method coverage was unresolved.`,
|
|
138
141
|
);
|
|
139
142
|
}
|
|
143
|
+
if (resolution) {
|
|
144
|
+
Logger.info(
|
|
145
|
+
`[CRAP] Method resolution: ${resolution.resolvedMethods}/${resolution.joinableMethods} ` +
|
|
146
|
+
`(${(resolution.rate * 100).toFixed(1)}%) in files with coverage.`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
// Fail closed BEFORE the service persists anything — a thin baseline is
|
|
150
|
+
// never written and then apologised for.
|
|
151
|
+
const refusal = checkResolutionFloor(resolution, minMethodResolutionRate);
|
|
152
|
+
if (refusal) throw new Error(refusal);
|
|
140
153
|
|
|
141
154
|
return (rows ?? []).filter(
|
|
142
155
|
(r) => typeof r?.crap === 'number' && Number.isFinite(r.crap),
|
|
@@ -27,15 +27,36 @@ over-scope work silently.
|
|
|
27
27
|
Two callers, one gate: whichever door you arrived through, the suitability gate
|
|
28
28
|
below is the decision. A `/plan` Gate #1 suggestion is a *suggestion* — it is
|
|
29
29
|
read against seed-time signals (`DELIVER_LIGHT_SUGGESTION_CEILINGS`: artifacts,
|
|
30
|
-
risk hits, sensitive-path classes), while the gate here is read against
|
|
31
|
-
predicted *
|
|
32
|
-
|
|
30
|
+
risk hits, sensitive-path classes), while the gate here is read against the
|
|
31
|
+
predicted work's *effort and risk* (`STORY_SHAPE_CEILINGS`: change kinds,
|
|
32
|
+
magnitude, uncertainty, deployable span). They are deliberately two different
|
|
33
|
+
checks, so the gate still runs after a confirm.
|
|
34
|
+
|
|
35
|
+
## Scope by effort, not by artifact count {#scope-by-effort}
|
|
36
|
+
|
|
37
|
+
**Counting the footprint is the wrong axis.** Three identical one-line edits
|
|
38
|
+
across three files is trivial work with a high count; a 200-line rewrite of one
|
|
39
|
+
module is a single change. The axes are therefore effort and risk: distinct
|
|
40
|
+
change **kinds** (N instances of one mechanical edit is one kind at N sites), a
|
|
41
|
+
coarse **magnitude** bucket, and **uncertainty** — is the shape determined by
|
|
42
|
+
the request, or does it still need the design decisions `/plan` exists to
|
|
43
|
+
resolve?
|
|
44
|
+
|
|
45
|
+
Because the predicted footprint is a *declaration* — a guess, and a gameable one
|
|
46
|
+
— this gate is deliberately **coarse**: it rejects clearly-epic work only
|
|
47
|
+
(multiple deployables, a migration plus its consumers, an explicit
|
|
48
|
+
multi-capability enumeration). Size is enforced where ground truth is available:
|
|
49
|
+
the diff backstop in step 4. Do not talk yourself past that one.
|
|
50
|
+
|
|
51
|
+
Sensitivity is the exception and stays absolute: a footprint touching an auth,
|
|
52
|
+
crypto, billing, or migration class routes `full` however small or mechanical.
|
|
33
53
|
|
|
34
54
|
## Four invariants (do not skip one)
|
|
35
55
|
|
|
36
56
|
1. **Suitability gate.** The prompt's predicted footprint is judged by the
|
|
37
|
-
shared
|
|
38
|
-
ledgered model verdict with a recorded reason. Both must agree on
|
|
57
|
+
shared effort/risk machinery (`deriveStoryShape` / `deriveChangeLevel`)
|
|
58
|
+
**and** a ledgered model verdict with a recorded reason. Both must agree on
|
|
59
|
+
`lite`.
|
|
39
60
|
2. **Over-scope stops — it never hard-fails.** An over-ceiling prompt STOPS and
|
|
40
61
|
asks the operator to escalate to `/plan` or proceed light. Under `--yes` it
|
|
41
62
|
fails closed to an **`escalated` terminal envelope** that ends the session
|
|
@@ -50,12 +71,16 @@ are deliberately two different checks, so the gate still runs after a confirm.
|
|
|
50
71
|
## Procedure
|
|
51
72
|
|
|
52
73
|
1. **Predict + gate.** Form the predicted footprint (new files, edited files,
|
|
53
|
-
acceptance count)
|
|
54
|
-
|
|
74
|
+
acceptance count), judge its effort honestly (`--kinds` / `--magnitude` /
|
|
75
|
+
`--uncertainty`, per § Scope by effort), and record your ledgered verdict (a
|
|
76
|
+
recorded reason for `lite`), then run the gate — it documents every flag
|
|
77
|
+
itself, so run it with `--help` rather than guessing:
|
|
55
78
|
|
|
56
79
|
```bash
|
|
57
80
|
node .agents/scripts/deliver-light.js --prompt "<prompt>" \
|
|
58
81
|
--creates <csv> --refactors <csv> --acceptance <n> \
|
|
82
|
+
--kinds <csv> --magnitude trivial|moderate|substantial \
|
|
83
|
+
--uncertainty determined|needs-design \
|
|
59
84
|
--route lite --reason "<why this is trivial>" [--amends '#<id>'] [--yes]
|
|
60
85
|
```
|
|
61
86
|
|
|
@@ -106,7 +131,8 @@ are deliberately two different checks, so the gate still runs after a confirm.
|
|
|
106
131
|
|
|
107
132
|
Exit `3` (`blocked: true`) means the landed diff exceeds the light ceilings
|
|
108
133
|
(file count or a sensitive-path class). STOP, flip `agent::blocked`, and
|
|
109
|
-
escalate to `/plan` — do not land.
|
|
134
|
+
escalate to `/plan` — do not land. This is the pass that actually bounds
|
|
135
|
+
size, which is why the prediction gate above can afford to be coarse.
|
|
110
136
|
|
|
111
137
|
5. **Close and land (same engine).** Exactly [`/deliver`](../deliver.md)'s close:
|
|
112
138
|
|
|
@@ -49,9 +49,9 @@ things make that safe, and both are worth understanding before changing it:
|
|
|
49
49
|
suggestion that routed you.
|
|
50
50
|
2. **The gate still runs.** The suggestion is read against seed-time ceilings
|
|
51
51
|
(`DELIVER_LIGHT_SUGGESTION_CEILINGS` — artifacts, risk hits, sensitive-path
|
|
52
|
-
classes); the light gate is read against
|
|
53
|
-
(`STORY_SHAPE_CEILINGS` —
|
|
54
|
-
checks on purpose, so a confirm is not a bypass.
|
|
52
|
+
classes); the light gate is read against the predicted work's effort and risk
|
|
53
|
+
(`STORY_SHAPE_CEILINGS` — change kinds, magnitude, uncertainty, deployable
|
|
54
|
+
span). Two different checks on purpose, so a confirm is not a bypass.
|
|
55
55
|
|
|
56
56
|
**When the light gate answers `ask-operator`**, the two ceiling sets disagreed.
|
|
57
57
|
Resume `/plan` at step 2 (Author) **in this same session** — the interrogation
|
|
@@ -63,6 +63,26 @@ is terminal and requires a fresh session. The rule that separates the two, and
|
|
|
63
63
|
why it must not be flattened into symmetry:
|
|
64
64
|
[`deliver-light.md` § Why the two directions differ](deliver-light.md).
|
|
65
65
|
|
|
66
|
+
## Gate #1 → the `/prototype` offer (`uiSurface`)
|
|
67
|
+
|
|
68
|
+
`complexitySignals.uiSurface` is the second advisory Gate #1 offer, and the
|
|
69
|
+
weaker of the two on purpose: it carries **no routing authority and adds no
|
|
70
|
+
gate**. Both halves are derived from observables already in the checkout — the
|
|
71
|
+
`hasWebSurface` applicability predicate the `target: "web"` audit lenses gate
|
|
72
|
+
on, and whether any predicted path matches a web lens `filePattern` registered
|
|
73
|
+
in `audit-rules.json`. There is no configuration key to set: a project with no
|
|
74
|
+
rendered frontend resolves falsey and the offer never fires.
|
|
75
|
+
|
|
76
|
+
When it does fire, **name [`/prototype`](../prototype.md) and stop there.**
|
|
77
|
+
`/plan` must never invoke it — operator invocation is the entire design, because
|
|
78
|
+
the value is a human looking at a layout before its UI acceptance criteria are
|
|
79
|
+
frozen.
|
|
80
|
+
|
|
81
|
+
**Under `--yes` the offer is recorded and planning proceeds** — no reroute, no
|
|
82
|
+
prototype written, no gate raised. This is exactly how `deliverLightSuggestion`
|
|
83
|
+
behaves unattended, and for the same reason: an unattended run has nobody to
|
|
84
|
+
review an artifact, so recording the offer is the whole of the right behaviour.
|
|
85
|
+
|
|
66
86
|
## Shape-derived complexity routing (`complexitySignals`)
|
|
67
87
|
|
|
68
88
|
Complexity routes on the **objective shape of the authored work**, never on
|
|
@@ -85,9 +105,10 @@ decision:
|
|
|
85
105
|
(`full`) stands.
|
|
86
106
|
- **Persist backstops the claim deterministically.** After authoring, the
|
|
87
107
|
work has measurable shape, so persist validates the `lite` claim against
|
|
88
|
-
each Story's own shape —
|
|
89
|
-
|
|
90
|
-
against the framework `STORY_SHAPE_CEILINGS`
|
|
108
|
+
each Story's own shape — distinct change kinds, declared magnitude,
|
|
109
|
+
uncertainty, deployable/migration span, glob-free footprint, and
|
|
110
|
+
sensitive-path classes, against the framework `STORY_SHAPE_CEILINGS` (effort
|
|
111
|
+
and risk, never artifact counts) — and **fails closed to
|
|
91
112
|
`full`** when any Story exceeds them (the refusal is ledgered on the
|
|
92
113
|
checkpoint too). The lite route is **not** licence to drop a
|
|
93
114
|
non-negotiable — every decision's `preserves` field enumerates what still
|
|
@@ -82,6 +82,9 @@ this envelope, not the raw seed; an `ask-operator` verdict returns here to
|
|
|
82
82
|
step 2 with the interrogation intact. Under `--yes` it is recorded and planning
|
|
83
83
|
proceeds — never auto-downgraded to light.
|
|
84
84
|
|
|
85
|
+
A truthy `complexitySignals.uiSurface` marks a UI-touching plan: name
|
|
86
|
+
[`/prototype`](prototype.md) as an operator option — never invoke it here.
|
|
87
|
+
|
|
85
88
|
### 2. Author
|
|
86
89
|
|
|
87
90
|
**One-shot authoring.** Start from `stories.template.json`; author
|
|
@@ -156,8 +159,7 @@ JSON.
|
|
|
156
159
|
|
|
157
160
|
In tickets mode persist resolves source ids **envelope-first** and closes each
|
|
158
161
|
as `not_planned` with a comment (default on;
|
|
159
|
-
[detail](helpers/plan-reference.md)).
|
|
160
|
-
command — never hand-delete issues.
|
|
162
|
+
[detail](helpers/plan-reference.md)).
|
|
161
163
|
|
|
162
164
|
## Constraints
|
|
163
165
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: >-
|
|
3
|
+
Operator-invoked UI prototype pass. Discovers the consumer's design-system
|
|
4
|
+
SSOT first, then — only after the operator confirms — writes exactly one
|
|
5
|
+
self-contained HTML file under the gitignored workspace-root temp tree, so a
|
|
6
|
+
layout can be reviewed before its UI acceptance criteria are authored.
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# /prototype [what to prototype]
|
|
10
|
+
|
|
11
|
+
UI acceptance criteria are otherwise authored blind: "the dashboard shows the
|
|
12
|
+
active runs" says nothing about layout, density, or interaction, so delivery
|
|
13
|
+
resolves those by taste. `/prototype` puts a reviewable artifact in front of the
|
|
14
|
+
operator before the criteria are written.
|
|
15
|
+
|
|
16
|
+
**Operator-invoked only.** `/plan` may report that a plan touches UI and that
|
|
17
|
+
this command exists; it must never run it. No workflow, gate, or script invokes
|
|
18
|
+
`/prototype` — the whole design rests on the operator asking for it.
|
|
19
|
+
|
|
20
|
+
## Procedure
|
|
21
|
+
|
|
22
|
+
### Step 0 — Discover the design-system SSOT (first, before anything is drawn)
|
|
23
|
+
|
|
24
|
+
You cannot prototype *in the project's visual language* until you have found the
|
|
25
|
+
language. Locate and read the consumer's sources of truth — the same set
|
|
26
|
+
[`/audit-ux-ui`](audit-ux-ui.md) Step 0 mandates:
|
|
27
|
+
|
|
28
|
+
- **Design tokens / theme** — a `tailwind.config.{js,ts}`, CSS custom properties
|
|
29
|
+
(`:root { --color-*, --space-* }`), a `theme/` / `tokens/` /
|
|
30
|
+
`design-system/` directory, or a CSS-in-JS theme object.
|
|
31
|
+
- **Component roster** — the shared component directory (`components/ui/**`, a
|
|
32
|
+
published design-system package) that raw elements are expected to defer to.
|
|
33
|
+
- **Documented conventions** — `docs/style-guide.md`, plus `docs/web-routes.md`
|
|
34
|
+
when the surface is a route, whenever they exist in the consumer checkout.
|
|
35
|
+
|
|
36
|
+
Report what you found — token names, the component roster, the style-guide rules
|
|
37
|
+
— and draw only against that discovered baseline. **No artifact is drawn until
|
|
38
|
+
this step has run.**
|
|
39
|
+
|
|
40
|
+
### Step 0a — When no design-system SSOT is discoverable
|
|
41
|
+
|
|
42
|
+
Report the absence explicitly, then emit a **low-fidelity frame**: boxes,
|
|
43
|
+
labels, and hierarchy, in the host's default typography with no colour system.
|
|
44
|
+
Do **not** invent a visual language. A prototype in a palette the project never
|
|
45
|
+
adopted reviews the invention rather than the layout, and the operator cannot
|
|
46
|
+
tell which of the two they are approving.
|
|
47
|
+
|
|
48
|
+
### Step 1 — Confirm before writing (**hard gate**)
|
|
49
|
+
|
|
50
|
+
Describe the layout you intend — surfaces, hierarchy, states, and which
|
|
51
|
+
discovered tokens and components it reuses — and **STOP**. Nothing is written to
|
|
52
|
+
disk until the operator confirms; never write silently. This is the disk-write
|
|
53
|
+
policy [`core/idea-refinement`](../skills/core/idea-refinement/SKILL.md) already
|
|
54
|
+
applies to its one-pager.
|
|
55
|
+
|
|
56
|
+
### Step 2 — Write exactly one self-contained HTML file
|
|
57
|
+
|
|
58
|
+
On confirm, write **exactly one** self-contained `.html` file — inline CSS, no
|
|
59
|
+
build step, no fetched external assets — under the **gitignored workspace-root
|
|
60
|
+
temp tree** (`temp/prototypes/<slug>.html`). One file, because a prototype is a
|
|
61
|
+
thing to look at, not a codebase to maintain; self-contained, because it has to
|
|
62
|
+
open from disk with no server and no install.
|
|
63
|
+
|
|
64
|
+
Report the path, and iterate in place on that same file.
|
|
65
|
+
|
|
66
|
+
### Step 3 — Optional: publish to a host
|
|
67
|
+
|
|
68
|
+
Host publishing is an **optional upgrade of that same file** and never the
|
|
69
|
+
artifact of record — the file under the temp tree stays authoritative. Publish
|
|
70
|
+
only when the operator asks, and keep the two identical by re-publishing the
|
|
71
|
+
file rather than editing a published copy.
|
|
72
|
+
|
|
73
|
+
### Step 4 — Carry the review through to the Story
|
|
74
|
+
|
|
75
|
+
The **default carry-through is a fold into the Story's `## Spec`.** Delivery
|
|
76
|
+
reads the Story body and never the temp tree, so a layout that exists only as a
|
|
77
|
+
temp artifact is a layout delivery cannot see. Record the reviewed decisions —
|
|
78
|
+
surfaces, hierarchy, states, and the named tokens and components — as contract
|
|
79
|
+
prose in `## Spec`, and turn the observable ones into UI acceptance criteria.
|
|
80
|
+
|
|
81
|
+
**Committing a prototype is opt-in, per Story.** Ask; never default to it. A
|
|
82
|
+
prototype is wrong the moment the real UI ships, and a repository with a
|
|
83
|
+
documentation-freshness gate already carries that failure mode.
|
|
84
|
+
|
|
85
|
+
## Constraint
|
|
86
|
+
|
|
87
|
+
- **Nothing reaches disk without a confirm.** Step 1 is a hard gate, not a
|
|
88
|
+
courtesy.
|
|
89
|
+
- **One file, under the temp tree.** Never a second artifact, never outside the
|
|
90
|
+
gitignored workspace-root temp tree, and never a committed prototype
|
|
91
|
+
directory by default.
|
|
92
|
+
- **Never invoked automatically.** `/plan` records the offer and proceeds with
|
|
93
|
+
planning; no workflow, gate, or script may call this command.
|
|
94
|
+
- **Read-only over the codebase.** The prototype file is the only write. Do not
|
|
95
|
+
edit application source, tokens, or components to make a prototype render.
|
|
96
|
+
- **Discovered baseline only.** No invented palette, type scale, or component
|
|
97
|
+
vocabulary when the project defines none.
|
|
98
|
+
|
|
99
|
+
## See also
|
|
100
|
+
|
|
101
|
+
- [`/audit-ux-ui`](audit-ux-ui.md) — the same design-system SSOT discovery,
|
|
102
|
+
applied as a review lens after the UI ships.
|
|
103
|
+
- [`/plan`](plan.md) — where the advisory `complexitySignals.uiSurface` offer
|
|
104
|
+
surfaces. It names this command; it never runs it.
|
package/README.md
CHANGED
|
@@ -181,10 +181,14 @@ dimensions, run model, and how to benchmark a new version.
|
|
|
181
181
|
|
|
182
182
|
## Contributors
|
|
183
183
|
|
|
184
|
-
|
|
185
|
-
`
|
|
186
|
-
|
|
187
|
-
|
|
184
|
+
The published `mandrel` package ships three directories — `.agents/`, `bin/`,
|
|
185
|
+
and `lib/` (see the `files` array in [`package.json`](package.json)).
|
|
186
|
+
`.agents/` is the payload `mandrel sync` materializes into a consumer's
|
|
187
|
+
`./.agents/` directory; `bin/mandrel.js` and its `lib/` implementation stay
|
|
188
|
+
inside `node_modules/mandrel/` and back the `npx mandrel …` CLI used
|
|
189
|
+
throughout this README. Everything else in this repository — `docs/`,
|
|
190
|
+
`tests/`, `.github/`, the root tooling configs — is internal development
|
|
191
|
+
tooling and is not published.
|
|
188
192
|
|
|
189
193
|
Common commands while developing the framework itself:
|
|
190
194
|
|
|
@@ -204,8 +208,12 @@ Deeper reference material lives in `docs/` rather than inline here:
|
|
|
204
208
|
- [`.agents/docs/workflows.md`](.agents/docs/workflows.md) — slash-command
|
|
205
209
|
index (auto-generated from the workflow set).
|
|
206
210
|
- [`docs/CHANGELOG.md`](docs/CHANGELOG.md) — release history.
|
|
207
|
-
- [`AGENTS.md`](AGENTS.md) — repository
|
|
208
|
-
|
|
211
|
+
- [`AGENTS.md`](AGENTS.md) — the repository-level orientation pointer; it
|
|
212
|
+
links on to [`docs/onboarding.md`](docs/onboarding.md) for the layout,
|
|
213
|
+
commands, and development standards.
|
|
214
|
+
- [`docs/release-operations.md`](docs/release-operations.md) — the Release
|
|
215
|
+
Checklist, the Install Matrix release gate, the single-package release
|
|
216
|
+
topology, PAT / npm-token setup, and the major-version policy. Releases are
|
|
209
217
|
automated by `release-please`: land Conventional Commits on `main` and it
|
|
210
218
|
opens a combined `chore: release main` PR that squash-merges itself once
|
|
211
219
|
CI is green, tags `main`, and publishes `mandrel` to npm.
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,47 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [2.17.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.16.0...mandrel-v2.17.0) (2026-07-26)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
* **crap:** wire the baseline-refresh projection layer and add CRAP freshness + full-scope drift detection ([#4776](https://github.com/dsj1984/mandrel/issues/4776)) ([#4778](https://github.com/dsj1984/mandrel/issues/4778)) ([8c49a12](https://github.com/dsj1984/mandrel/commit/8c49a12be6ca5ecaca60d8f0bc868f47c9a02534))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
* **audit-to-stories:** anchor findings to their real primary file (refs [#4781](https://github.com/dsj1984/mandrel/issues/4781)) ([#4782](https://github.com/dsj1984/mandrel/issues/4782)) ([a5794c0](https://github.com/dsj1984/mandrel/commit/a5794c02c3443c39ce116699c311b4cdd8001788))
|
|
16
|
+
* **cli:** drain stdio before exit and settle the audit fan-out per dimension (refs [#4783](https://github.com/dsj1984/mandrel/issues/4783)) ([#4787](https://github.com/dsj1984/mandrel/issues/4787)) ([3422fd5](https://github.com/dsj1984/mandrel/commit/3422fd564773d0e79ac4d16af5bbc5414d06c65b))
|
|
17
|
+
* **crap:** join per-method coverage in original-source coordinates — TS scoring resolves ~5% of methods ([#4775](https://github.com/dsj1984/mandrel/issues/4775)) ([#4777](https://github.com/dsj1984/mandrel/issues/4777)) ([1464c75](https://github.com/dsj1984/mandrel/commit/1464c75c061960aaa9ee1af88175d9706b044c26))
|
|
18
|
+
* **deliver:** keep the terminal envelope alive after the worktree reap (refs [#4784](https://github.com/dsj1984/mandrel/issues/4784)) ([#4791](https://github.com/dsj1984/mandrel/issues/4791)) ([907b7bd](https://github.com/dsj1984/mandrel/commit/907b7bdb951a70a7155e5a9a50afdbea7a81d6a6))
|
|
19
|
+
* **deps:** decouple the js-yaml override from markdownlint-cli2, close Node-engine drift, and re-enable knip dependency rules ([#4784](https://github.com/dsj1984/mandrel/issues/4784)) ([#4788](https://github.com/dsj1984/mandrel/issues/4788)) ([9ed687b](https://github.com/dsj1984/mandrel/commit/9ed687bead90d3e0edf76e7d0e700b0f51e8cb5d))
|
|
20
|
+
* **docs:** re-sync the reference docs to the contracts the code actually implements ([#4785](https://github.com/dsj1984/mandrel/issues/4785)) ([#4790](https://github.com/dsj1984/mandrel/issues/4790)) ([00d9270](https://github.com/dsj1984/mandrel/commit/00d92708b3d3ac0978c073c5a31fd6a80a25767e))
|
|
21
|
+
* **docs:** supersede the stale ADR chain in place and archive the retired pattern history ([#4786](https://github.com/dsj1984/mandrel/issues/4786)) ([#4789](https://github.com/dsj1984/mandrel/issues/4789)) ([5ba2e34](https://github.com/dsj1984/mandrel/commit/5ba2e3486973b87603fd3f2f23f750f18e75e240))
|
|
22
|
+
* **git-cleanup:** report the refs the prune phase actually dropped (refs [#4772](https://github.com/dsj1984/mandrel/issues/4772)) ([#4773](https://github.com/dsj1984/mandrel/issues/4773)) ([16424d5](https://github.com/dsj1984/mandrel/commit/16424d53305549841b6294ceb1d7651baf1f98e7))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
* **quality:** floor CRAP on methodsAbove20 instead of a fitted max ceiling ([#4779](https://github.com/dsj1984/mandrel/issues/4779)) ([647130b](https://github.com/dsj1984/mandrel/commit/647130b313a85add64978205d4bb300e90e3c9a3)), closes [#4775](https://github.com/dsj1984/mandrel/issues/4775)
|
|
28
|
+
|
|
29
|
+
## [2.16.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.15.0...mandrel-v2.16.0) (2026-07-25)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
* **workflows:** add operator-invoked /prototype and an advisory uiSurface offer (refs [#4765](https://github.com/dsj1984/mandrel/issues/4765)) ([#4769](https://github.com/dsj1984/mandrel/issues/4769)) ([d3f7173](https://github.com/dsj1984/mandrel/commit/d3f71734ef8573ffa40187b5f4f8a97be6e32041))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
* **close-validation:** drain gate output without stalling the child's pipe (refs [#4766](https://github.com/dsj1984/mandrel/issues/4766)) ([#4770](https://github.com/dsj1984/mandrel/issues/4770)) ([fd75550](https://github.com/dsj1984/mandrel/commit/fd75550288411a4c92831ac7c9b946e2cce5a686))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
* **routing:** scope the light path by effort and risk (refs [#4764](https://github.com/dsj1984/mandrel/issues/4764)) ([#4767](https://github.com/dsj1984/mandrel/issues/4767)) ([c4851ab](https://github.com/dsj1984/mandrel/commit/c4851abe218721145daaeb4353af400223b588e6))
|
|
45
|
+
|
|
5
46
|
## [2.15.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.14.0...mandrel-v2.15.0) (2026-07-25)
|
|
6
47
|
|
|
7
48
|
|