mandrel 2.22.0 → 2.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/.agents/docs/configuration.md +1 -0
  2. package/.agents/schemas/agentrc.schema.json +6 -0
  3. package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
  4. package/.agents/scripts/deliver-light.js +23 -45
  5. package/.agents/scripts/diagnose-friction.js +95 -4
  6. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +10 -25
  7. package/.agents/scripts/lib/baselines/kinds/maintainability.js +20 -32
  8. package/.agents/scripts/lib/config-settings-schema-delivery.js +8 -0
  9. package/.agents/scripts/lib/escomplex-ast-compat.js +360 -0
  10. package/.agents/scripts/lib/maintainability-engine.js +83 -11
  11. package/.agents/scripts/lib/maintainability-unscorable.js +60 -0
  12. package/.agents/scripts/lib/maintainability-utils.js +14 -5
  13. package/.agents/scripts/lib/observability/runtime-friction.js +37 -1
  14. package/.agents/scripts/lib/orchestration/diff-magnitude.js +283 -0
  15. package/.agents/scripts/lib/orchestration/light-backstop.js +107 -0
  16. package/.agents/scripts/lib/orchestration/light-escalation.js +169 -0
  17. package/.agents/scripts/lib/orchestration/light-suitability.js +151 -46
  18. package/.agents/scripts/lib/orchestration/plan-context.js +12 -13
  19. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  20. package/.agents/scripts/lib/orchestration/run-epilogue.js +18 -6
  21. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +70 -2
  22. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +23 -5
  23. package/.agents/scripts/lib/orchestration/story-follow-ups.js +76 -4
  24. package/.agents/scripts/lib/templates/decomposer-prompts.js +1 -1
  25. package/.agents/scripts/lib/workers/maintainability-worker.js +14 -9
  26. package/.agents/workflows/helpers/deliver-light.md +21 -4
  27. package/.agents/workflows/helpers/plan-reference.md +40 -0
  28. package/.agents/workflows/plan.md +21 -16
  29. package/docs/CHANGELOG.md +23 -0
  30. package/package.json +1 -1
@@ -0,0 +1,283 @@
1
+ /**
2
+ * lib/orchestration/diff-magnitude.js — the changed-line magnitude of a diff,
3
+ * split into implementation and mandated-companion halves (Story #4856).
4
+ *
5
+ * ## Why magnitude, and why the split
6
+ *
7
+ * The light path's diff backstop used to bound scope with a single
8
+ * `maxFiles: 4` ceiling. Measured against this repository's own history that
9
+ * ceiling was wrong in both directions:
10
+ *
11
+ * - **Too tight.** Of 33 real-work squash merges on `main` (excluding
12
+ * release-please and `chore(baselines)` automation) only 7 — 21% — touch
13
+ * four files or fewer; the median is 8. The framework's *own* narrow-diff
14
+ * scale, `DEFAULT_DIFF_WIDTH.softFiles` in `review-depth.js`, is 15.
15
+ * - **Blind.** A three-file, 323-line rewrite passed while a 190-file change
16
+ * was rejected 47× over — even though 186 of those files were tests and its
17
+ * implementation was 7 files.
18
+ *
19
+ * So the axis is **changed lines over implementation files**, and the companion
20
+ * classes the framework itself mandates are exempt from the count: obeying
21
+ * `rules/testing-standards.md` (test-first), the `delivery.docsFreshness` gate,
22
+ * and the baseline ratchets must not inflate the number that then rejects the
23
+ * change. Re-counting those same 33 merges on implementation files alone moves
24
+ * the four-file pass rate from 21% to 58%.
25
+ *
26
+ * ## Three contracts worth not rediscovering
27
+ *
28
+ * 1. **Additions plus deletions, never net.** A modified line counts twice
29
+ * (one `+`, one `-`), which is the intended weighting. Net is actively
30
+ * broken as a size signal: the merge retiring the planner snapshot is
31
+ * 1119 add+del but **−803** net, so a large deletion would measure as
32
+ * trivial.
33
+ * 2. **A pure rename is free.** `git diff --numstat` reports `0\t0` for one,
34
+ * and its path arrives in `old => new` form — normalized here to the
35
+ * destination so the file still counts toward the implementation tally.
36
+ * 3. **Exemption is from *counting*, never from *risk*.** Nothing here
37
+ * touches sensitive-path derivation, which every caller runs over the
38
+ * full changed set including companions.
39
+ *
40
+ * Companion matching runs through the audit suite's picomatch seam as a
41
+ * **positive** glob list negated by the caller. A `!`-prefixed picomatch
42
+ * pattern widens a match rather than narrowing it, so expressing the
43
+ * behavior-bearing exceptions as negations would silently exempt more than
44
+ * intended.
45
+ *
46
+ * Every export is total: no throws. {@link readNumstatRows} owns the one git
47
+ * read; everything else is pure.
48
+ *
49
+ * The public surface is deliberately just those two functions. The numstat
50
+ * parse, the rename normalization, and the companion classifier are internal:
51
+ * each is fully observable through them (a stubbed `gitSpawnFn` drives the
52
+ * parse, an `isCompanionFn` seam drives the classification), so exporting them
53
+ * would widen the module's contract for no caller.
54
+ *
55
+ * @module lib/orchestration/diff-magnitude
56
+ */
57
+
58
+ import { matchesAnyFilePattern } from '../audit-suite/selector.js';
59
+ import { gitSpawn } from '../git-utils.js';
60
+
61
+ /**
62
+ * Paths whose churn is a **mandated companion** of a change rather than the
63
+ * change itself, exempt from the implementation line and file counts.
64
+ *
65
+ * Deliberately absent, and load-bearing in their absence: `.agentrc.json`,
66
+ * `.agents/schemas/**`, `.github/workflows/**`, and `package.json`. Those are
67
+ * "config" by file type but behavior by effect, and `.agents/schemas/audit-rules.json`
68
+ * is the sensitive-path SSOT — exempting it would let a change widen the very
69
+ * allowlist that decides whether it is risky.
70
+ *
71
+ * Note the anchoring: `baselines/**` is the generated baseline **data** at the
72
+ * repository root. The baseline *schemas* under `.agents/schemas/baselines/`
73
+ * are unanchored by this pattern and therefore still count as implementation.
74
+ *
75
+ * Markdown is exempt wholesale, which includes `.agents/workflows/**` and
76
+ * `.agents/rules/**`. That is intended: rewriting a workflow contract is not
77
+ * *effort* the way a module rewrite is, and its real guards are the close-time
78
+ * context-budget, doc-link, and docs-reference-sync gates — none of which this
79
+ * ceiling replaces.
80
+ */
81
+ const COMPANION_PATH_GLOBS = Object.freeze([
82
+ // Tests — mandated by rules/testing-standards.md's test-first discipline.
83
+ '**/__tests__/**',
84
+ '**/*.test.js',
85
+ '**/*.test.mjs',
86
+ '**/*.test.cjs',
87
+ '**/*.test.ts',
88
+ '**/*.test.tsx',
89
+ 'tests/**',
90
+ 'features/**',
91
+ // Documentation — mandated by the delivery.docsFreshness gate.
92
+ 'docs/**',
93
+ '**/*.md',
94
+ // Generated baseline data — written by the ratchets, not hand-authored.
95
+ 'baselines/**',
96
+ // Lockfiles — regenerated wholesale; their line count means nothing.
97
+ 'package-lock.json',
98
+ 'pnpm-lock.yaml',
99
+ 'yarn.lock',
100
+ ]);
101
+
102
+ /**
103
+ * Normalize a `git diff --numstat` path field to the single file it names.
104
+ * Rename rows arrive as `old => new` or with a braced infix
105
+ * (`dir/{old => new}/file.js`); both resolve to the destination, so a renamed
106
+ * implementation file still counts as one implementation file.
107
+ *
108
+ * Pure and total.
109
+ *
110
+ * @param {string} raw
111
+ * @returns {string}
112
+ */
113
+ function normalizeNumstatPath(raw) {
114
+ const value = typeof raw === 'string' ? raw.trim() : '';
115
+ if (value === '') return '';
116
+ const braced = /^(.*)\{(.*) => (.*)\}(.*)$/.exec(value);
117
+ if (braced) {
118
+ const [, prefix, , to, suffix] = braced;
119
+ return `${prefix}${to}${suffix}`.replace(/\/{2,}/g, '/');
120
+ }
121
+ const arrow = value.split(' => ');
122
+ return (arrow.length > 1 ? arrow[arrow.length - 1] : value).trim();
123
+ }
124
+
125
+ /**
126
+ * Parse `git diff --numstat` output into per-file rows.
127
+ *
128
+ * Binary rows (`-\t-\tpath`) contribute zero text lines without poisoning the
129
+ * parse. Any line that does not match the numstat shape makes the whole result
130
+ * untrustworthy, so the function returns `null` — the "magnitude unknown"
131
+ * signal every caller fails closed (or fails open) on deliberately.
132
+ *
133
+ * Pure and total.
134
+ *
135
+ * @param {unknown} stdout
136
+ * @returns {Array<{ additions: number, deletions: number, path: string }>|null}
137
+ */
138
+ function parseNumstatRows(stdout) {
139
+ if (typeof stdout !== 'string') return null;
140
+ const rows = [];
141
+ for (const line of stdout.split('\n')) {
142
+ const trimmedEnd = line.replace(/\s+$/, '');
143
+ if (trimmedEnd.length === 0) continue;
144
+ const match = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(trimmedEnd);
145
+ if (!match) return null;
146
+ rows.push({
147
+ additions: match[1] === '-' ? 0 : Number(match[1]),
148
+ deletions: match[2] === '-' ? 0 : Number(match[2]),
149
+ path: normalizeNumstatPath(match[3]),
150
+ });
151
+ }
152
+ return rows;
153
+ }
154
+
155
+ /**
156
+ * True when `file` is a mandated companion rather than implementation.
157
+ *
158
+ * Pure and total — a throwing matcher resolves to `false`, which counts the
159
+ * file as implementation. That is the conservative direction: a
160
+ * classification failure must never shrink the measured magnitude.
161
+ *
162
+ * @param {unknown} file
163
+ * @param {{ matchFn?: typeof matchesAnyFilePattern }} [deps]
164
+ * @returns {boolean}
165
+ */
166
+ function isCompanionPath(file, { matchFn = matchesAnyFilePattern } = {}) {
167
+ if (typeof file !== 'string' || file.trim() === '') return false;
168
+ try {
169
+ return matchFn(COMPANION_PATH_GLOBS, [file.trim()]) === true;
170
+ } catch {
171
+ return false;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Read the per-file numstat rows for the `baseRef...headRef` diff. The one
177
+ * side-effecting function in this module.
178
+ *
179
+ * Total — never throws; returns `null` on any git failure or unparseable
180
+ * output.
181
+ *
182
+ * @param {{
183
+ * baseRef?: string,
184
+ * headRef?: string,
185
+ * cwd?: string,
186
+ * gitSpawnFn?: typeof gitSpawn,
187
+ * }} [args]
188
+ * @returns {Array<{ additions: number, deletions: number, path: string }>|null}
189
+ */
190
+ export function readNumstatRows({
191
+ baseRef,
192
+ headRef,
193
+ cwd = process.cwd(),
194
+ gitSpawnFn = gitSpawn,
195
+ } = {}) {
196
+ if (typeof baseRef !== 'string' || baseRef.length === 0) return null;
197
+ if (typeof headRef !== 'string' || headRef.length === 0) return null;
198
+ try {
199
+ const result = gitSpawnFn(
200
+ cwd,
201
+ 'diff',
202
+ '--numstat',
203
+ `${baseRef}...${headRef}`,
204
+ );
205
+ if (!result || result.status !== 0) return null;
206
+ return parseNumstatRows(result.stdout);
207
+ } catch {
208
+ return null;
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Summarize a diff's magnitude, splitting implementation from mandated
214
+ * companions.
215
+ *
216
+ * `implFiles` is counted from `changedFiles` — the canonical
217
+ * `git diff --name-only` enumeration produced by `change-set.js` — rather than
218
+ * from the numstat rows, so file counting uses clean paths from the one
219
+ * enumerator every other consumer reads. `implLines` comes from the numstat
220
+ * rows, which is the only surface carrying line counts.
221
+ *
222
+ * Returns `null` when either input is unusable: the magnitude is then *unknown*,
223
+ * which is deliberately distinct from *zero* so a caller can fail closed on the
224
+ * absence of evidence.
225
+ *
226
+ * Pure and total.
227
+ *
228
+ * @param {{
229
+ * changedFiles?: unknown,
230
+ * rows?: unknown,
231
+ * isCompanionFn?: typeof isCompanionPath,
232
+ * }} [args]
233
+ * @returns {{
234
+ * implFiles: number,
235
+ * implLines: number,
236
+ * companionFiles: number,
237
+ * companionLines: number,
238
+ * totalFiles: number,
239
+ * }|null}
240
+ */
241
+ export function summarizeDiffMagnitude({
242
+ changedFiles,
243
+ rows,
244
+ isCompanionFn = isCompanionPath,
245
+ } = {}) {
246
+ if (!Array.isArray(changedFiles) || !Array.isArray(rows)) return null;
247
+ const files = changedFiles.filter(
248
+ (f) => typeof f === 'string' && f.trim() !== '',
249
+ );
250
+
251
+ // A classification failure resolves to "implementation" — the conservative
252
+ // direction, since counting a companion as implementation can only ever make
253
+ // the measured magnitude larger. Guarded here as well as inside the default
254
+ // classifier so an injected one cannot break this function's totality.
255
+ const isCompanion = (file) => {
256
+ try {
257
+ return isCompanionFn(file) === true;
258
+ } catch {
259
+ return false;
260
+ }
261
+ };
262
+
263
+ let implFiles = 0;
264
+ for (const file of files) {
265
+ if (!isCompanion(file)) implFiles += 1;
266
+ }
267
+
268
+ let implLines = 0;
269
+ let companionLines = 0;
270
+ for (const row of rows) {
271
+ const lines = (row?.additions ?? 0) + (row?.deletions ?? 0);
272
+ if (isCompanion(row?.path)) companionLines += lines;
273
+ else implLines += lines;
274
+ }
275
+
276
+ return {
277
+ implFiles,
278
+ implLines,
279
+ companionFiles: files.length - implFiles,
280
+ companionLines,
281
+ totalFiles: files.length,
282
+ };
283
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * lib/orchestration/light-backstop.js — the light path's diff-backstop pass
3
+ * (Story #4856).
4
+ *
5
+ * The backstop is invariant 3 of the light path: after implementation the
6
+ * **actual** change set is re-checked, because the diff — not the prompt — is
7
+ * the real scope signal. This module owns that pass end to end so
8
+ * `deliver-light.js` stays the thin CLI shell it claims to be: it reads the two
9
+ * git surfaces, applies
10
+ * {@link module:lib/orchestration/light-suitability.checkLightDiffBackstop},
11
+ * and resolves what a refusal means.
12
+ *
13
+ * ## Two git surfaces, each used for what it reports reliably
14
+ *
15
+ * - `--name-only`, via the one canonical `computeChangeSet` enumerator, gives
16
+ * the clean full file list. Sensitive-path derivation and
17
+ * implementation-file counting both read it, so the backstop and every
18
+ * other consumer are looking at the same change set.
19
+ * - `--numstat` gives per-file line counts, the only surface carrying them.
20
+ *
21
+ * @module lib/orchestration/light-backstop
22
+ */
23
+
24
+ import { computeChangeSet } from './change-set.js';
25
+ import { readNumstatRows, summarizeDiffMagnitude } from './diff-magnitude.js';
26
+ import { handleBlockedBackstop } from './light-escalation.js';
27
+ import { checkLightDiffBackstop } from './light-suitability.js';
28
+
29
+ /** Exit code when the diff backstop blocked the land. */
30
+ const EXIT_BACKSTOP_BLOCKED = 3;
31
+
32
+ /**
33
+ * Run the diff backstop against a Story branch's actual change set.
34
+ *
35
+ * @param {{
36
+ * storyId: number,
37
+ * baseRef?: string,
38
+ * cwd?: string,
39
+ * computeFn?: typeof computeChangeSet,
40
+ * readRowsFn?: typeof readNumstatRows,
41
+ * injectedRules?: object,
42
+ * }} args
43
+ * @returns {ReturnType<typeof checkLightDiffBackstop>}
44
+ */
45
+ function runDiffBackstop({
46
+ storyId,
47
+ baseRef = 'main',
48
+ cwd = process.cwd(),
49
+ computeFn = computeChangeSet,
50
+ readRowsFn = readNumstatRows,
51
+ injectedRules,
52
+ } = {}) {
53
+ const headRef = `story-${storyId}`;
54
+ const { files } = computeFn({ baseRef, headRef, cwd });
55
+ const rows = readRowsFn({ baseRef, headRef, cwd });
56
+ const magnitude = summarizeDiffMagnitude({ changedFiles: files, rows });
57
+ return checkLightDiffBackstop({
58
+ changedFiles: files,
59
+ magnitude,
60
+ injectedRules,
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Resolve the backstop pass into everything the CLI needs to print and exit
66
+ * with: the verdict, the recycle command on a refusal (`null` when clean), the
67
+ * exit code, and the log line.
68
+ *
69
+ * @param {{
70
+ * storyId: number,
71
+ * runFn?: typeof runDiffBackstop,
72
+ * handleBlockedFn?: typeof handleBlockedBackstop,
73
+ * }} args Any further keys (`baseRef`, `cwd`, `computeFn`, `readRowsFn`,
74
+ * `injectedRules`) forward to the backstop run, so the git-surface join is
75
+ * drivable through this one entry point.
76
+ * @returns {Promise<{
77
+ * result: ReturnType<typeof checkLightDiffBackstop>,
78
+ * nextCommand: string|null,
79
+ * exitCode: number,
80
+ * message: string,
81
+ * }>}
82
+ */
83
+ export async function resolveBackstopOutcome({
84
+ storyId,
85
+ runFn = runDiffBackstop,
86
+ handleBlockedFn = handleBlockedBackstop,
87
+ ...seams
88
+ } = {}) {
89
+ const result = runFn({ storyId, ...seams });
90
+ if (!result.blocked) {
91
+ return {
92
+ result,
93
+ nextCommand: null,
94
+ exitCode: 0,
95
+ message: `[deliver-light] diff backstop clean for Story #${storyId}.`,
96
+ };
97
+ }
98
+ const nextCommand = await handleBlockedFn({ storyId, result });
99
+ return {
100
+ result,
101
+ nextCommand,
102
+ exitCode: EXIT_BACKSTOP_BLOCKED,
103
+ message:
104
+ `[deliver-light] diff backstop BLOCKED Story #${storyId}: ` +
105
+ `${result.reasons.join('; ')} — recycle the receipt with "${nextCommand}"`,
106
+ };
107
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * lib/orchestration/light-escalation.js — what the light path does when it
3
+ * refuses a scope (Story #4856).
4
+ *
5
+ * Two behaviors, both previously missing, and both about a refusal rather than
6
+ * a verdict — the verdicts live in
7
+ * {@link module:lib/orchestration/light-suitability}:
8
+ *
9
+ * 1. **Recycling the receipt.** A blocked diff backstop used to tell the
10
+ * operator to "escalate to `/plan`", which authored a brand-new Story and
11
+ * left the receipt open with no successor — orphaning its branch, its
12
+ * worktree, and a finished implementation. Naming the receipt as `/plan`'s
13
+ * *input* recycles it instead: tickets mode already fetches a ticket,
14
+ * rewrites it into properly-planned Stories, and closes the source as
15
+ * superseded.
16
+ *
17
+ * Deferring receipt creation until after the backstop would be the other
18
+ * fix, and is deliberately not taken: the issue id is load-bearing in
19
+ * `single-story-init.js` (the assignee lease, the `story-<id>` branch, the
20
+ * label state machine) and in the `(refs #<id>)` commit subject.
21
+ *
22
+ * 2. **Telemetering the refusal.** Neither light-path rejection emitted any
23
+ * signal, so an over-tight ceiling could only reach the framework as
24
+ * anecdote — which is exactly how the `maxFiles: 4` defect surfaced. The
25
+ * roll-up aggregates by category, so recording these makes the ceilings
26
+ * recalibratable from evidence.
27
+ *
28
+ * Telemetry is best-effort by construction: a signals-write failure must never
29
+ * change a gate's verdict.
30
+ *
31
+ * @module lib/orchestration/light-escalation
32
+ */
33
+
34
+ import {
35
+ emitRuntimeFriction,
36
+ RUNTIME_FRICTION_CATEGORIES,
37
+ } from '../observability/runtime-friction.js';
38
+
39
+ /**
40
+ * The `/plan` invocation that owns a Story the light path could not land.
41
+ *
42
+ * @param {number} storyId
43
+ * @returns {string}
44
+ */
45
+ function buildRecycleCommand(storyId) {
46
+ return `/plan ${storyId}`;
47
+ }
48
+
49
+ /**
50
+ * Coerce an `--amends` argument (`#123` or `123`) into a positive integer issue
51
+ * number, or `null` when absent/malformed.
52
+ *
53
+ * This is the only Story context a **gate-stage** rejection can legitimately
54
+ * claim: the signals stream is keyed on a Story id, and an `ask-operator` gate
55
+ * has authored no receipt yet — deliberately, since not creating one is the
56
+ * point of that outcome. A bare prompt's rejection therefore has no stream to
57
+ * land in, and attributing it to a fabricated id would be worse than recording
58
+ * nothing.
59
+ *
60
+ * @param {unknown} amends
61
+ * @returns {number|null}
62
+ */
63
+ function normalizeAmendsId(amends) {
64
+ const match = /^#?(\d+)$/.exec(String(amends ?? '').trim());
65
+ if (!match) return null;
66
+ const n = Number.parseInt(match[1], 10);
67
+ return Number.isInteger(n) && n > 0 ? n : null;
68
+ }
69
+
70
+ /**
71
+ * Record a suitability-gate refusal (`ask-operator`) as friction, attributed to
72
+ * the `--amends` target when there is one.
73
+ *
74
+ * @param {{
75
+ * gate: object,
76
+ * amends?: unknown,
77
+ * recordFrictionFn?: typeof recordScopeFriction,
78
+ * }} args
79
+ * @returns {Promise<boolean>}
80
+ */
81
+ export async function recordGateRefusal({
82
+ gate,
83
+ amends,
84
+ emitFn,
85
+ recordFrictionFn = recordScopeFriction,
86
+ } = {}) {
87
+ return recordFrictionFn({
88
+ emitFn,
89
+ storyId: normalizeAmendsId(amends),
90
+ surface: 'suitability-gate',
91
+ reasons: gate?.outcome?.reasons ?? [],
92
+ details: {
93
+ action: gate?.action ?? null,
94
+ code: gate?.suitability?.shape?.code ?? null,
95
+ },
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Handle a blocked diff backstop: record the refusal as friction and return the
101
+ * `/plan` invocation that recycles the receipt.
102
+ *
103
+ * Lives here rather than in the CLI so the shell stays a shell — the backstop
104
+ * mode's job is to branch and print, not to decide what a refusal means.
105
+ *
106
+ * @param {{
107
+ * storyId: number,
108
+ * result: object,
109
+ * recordFrictionFn?: typeof recordScopeFriction,
110
+ * }} args `result` is a {@link module:lib/orchestration/light-suitability.checkLightDiffBackstop}
111
+ * verdict.
112
+ * @returns {Promise<string>} The recycle command.
113
+ */
114
+ export async function handleBlockedBackstop({
115
+ storyId,
116
+ result,
117
+ emitFn,
118
+ recordFrictionFn = recordScopeFriction,
119
+ } = {}) {
120
+ await recordFrictionFn({
121
+ emitFn,
122
+ storyId,
123
+ surface: 'diff-backstop',
124
+ reasons: result?.reasons ?? [],
125
+ details: {
126
+ fileCount: result?.fileCount ?? null,
127
+ implFiles: result?.magnitude?.implFiles ?? null,
128
+ implLines: result?.magnitude?.implLines ?? null,
129
+ ceilings: result?.ceilings ?? null,
130
+ classes: result?.classes ?? [],
131
+ },
132
+ });
133
+ return buildRecycleCommand(storyId);
134
+ }
135
+
136
+ /**
137
+ * Record a light-path scope rejection as friction.
138
+ *
139
+ * Total: never throws, and returns `false` rather than propagating when the
140
+ * signals surface is unavailable.
141
+ *
142
+ * @param {{
143
+ * storyId?: number|null,
144
+ * surface: string,
145
+ * reasons?: string[],
146
+ * details?: object,
147
+ * emitFn?: typeof emitRuntimeFriction,
148
+ * }} args
149
+ * @returns {Promise<boolean>}
150
+ */
151
+ async function recordScopeFriction({
152
+ storyId,
153
+ surface,
154
+ reasons = [],
155
+ details = {},
156
+ emitFn,
157
+ } = {}) {
158
+ const emit = emitFn ?? emitRuntimeFriction;
159
+ try {
160
+ return await emit({
161
+ storyId,
162
+ category: RUNTIME_FRICTION_CATEGORIES.LIGHT_SCOPE_REJECTED,
163
+ tool: 'deliver-light',
164
+ details: { surface, reasons, ...details },
165
+ });
166
+ } catch {
167
+ return false;
168
+ }
169
+ }