mandrel 2.23.0 → 2.25.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 (101) hide show
  1. package/.agents/agents/acceptance-critic.md +13 -19
  2. package/.agents/agents/auditor.md +16 -19
  3. package/.agents/agents/story-worker.md +41 -54
  4. package/.agents/audit-checklists/architecture.md +5 -1
  5. package/.agents/audit-checklists/quality.md +3 -0
  6. package/.agents/docs/agentrc-reference.json +2 -1
  7. package/.agents/docs/configuration.md +2 -1
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/rules/ci-remediation.md +68 -3
  10. package/.agents/schemas/agentrc.schema.json +6 -1
  11. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  12. package/.agents/schemas/baselines/crap.schema.json +5 -1
  13. package/.agents/schemas/crap-report.schema.json +37 -0
  14. package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
  15. package/.agents/scripts/acceptance-eval.js +35 -9
  16. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  17. package/.agents/scripts/audit-to-stories.js +33 -3
  18. package/.agents/scripts/check-context-budget.js +95 -14
  19. package/.agents/scripts/check-test-temp-hygiene.js +83 -0
  20. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  21. package/.agents/scripts/deliver-light.js +22 -45
  22. package/.agents/scripts/deliver-recover.js +13 -0
  23. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +10 -25
  24. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  25. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  26. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  27. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  28. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  29. package/.agents/scripts/lib/baseline-loader.js +0 -0
  30. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  31. package/.agents/scripts/lib/baselines/kinds/crap.js +396 -51
  32. package/.agents/scripts/lib/baselines/kinds/maintainability.js +20 -32
  33. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  34. package/.agents/scripts/lib/baselines/reader.js +10 -0
  35. package/.agents/scripts/lib/baselines/writer.js +7 -3
  36. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  37. package/.agents/scripts/lib/crap-engine.js +97 -19
  38. package/.agents/scripts/lib/crap-utils.js +73 -13
  39. package/.agents/scripts/lib/env-loader.js +46 -16
  40. package/.agents/scripts/lib/escomplex-ast-compat.js +360 -0
  41. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  42. package/.agents/scripts/lib/findings/severity.js +80 -2
  43. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  44. package/.agents/scripts/lib/label-constants.js +17 -0
  45. package/.agents/scripts/lib/maintainability-engine.js +83 -11
  46. package/.agents/scripts/lib/maintainability-unscorable.js +60 -0
  47. package/.agents/scripts/lib/maintainability-utils.js +14 -5
  48. package/.agents/scripts/lib/observability/runtime-friction.js +8 -0
  49. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  50. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  51. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  52. package/.agents/scripts/lib/orchestration/diff-magnitude.js +283 -0
  53. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  54. package/.agents/scripts/lib/orchestration/light-backstop.js +124 -0
  55. package/.agents/scripts/lib/orchestration/light-escalation.js +248 -0
  56. package/.agents/scripts/lib/orchestration/light-suitability.js +254 -51
  57. package/.agents/scripts/lib/orchestration/plan-context.js +12 -13
  58. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +7 -0
  59. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +13 -1
  60. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  61. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  62. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  63. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -34
  64. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +70 -2
  65. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  66. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +73 -18
  67. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  68. package/.agents/scripts/lib/story-plan.js +137 -42
  69. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  70. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  71. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  72. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  73. package/.agents/scripts/lib/wave-runner/ready-set.js +73 -9
  74. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +6 -3
  75. package/.agents/scripts/lib/workers/crap-worker.js +6 -3
  76. package/.agents/scripts/lib/workers/maintainability-worker.js +14 -9
  77. package/.agents/scripts/pr-watch-with-update.js +568 -213
  78. package/.agents/scripts/quality-preview.js +25 -0
  79. package/.agents/scripts/run-tests.js +10 -0
  80. package/.agents/scripts/single-story-close.js +6 -0
  81. package/.agents/scripts/single-story-init.js +7 -0
  82. package/.agents/scripts/stories-wave-tick.js +95 -10
  83. package/.agents/scripts/story-plan.js +65 -9
  84. package/.agents/scripts/update-crap-baseline.js +42 -4
  85. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  86. package/.agents/templates/single-story-body.md +16 -8
  87. package/.agents/workflows/audit-architecture.md +69 -13
  88. package/.agents/workflows/audit-quality.md +26 -0
  89. package/.agents/workflows/audit-to-stories.md +30 -4
  90. package/.agents/workflows/deliver.md +81 -95
  91. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  92. package/.agents/workflows/helpers/deliver-light.md +21 -4
  93. package/.agents/workflows/helpers/deliver-reference.md +18 -3
  94. package/.agents/workflows/helpers/deliver-story-reference.md +127 -46
  95. package/.agents/workflows/helpers/deliver-story.md +104 -119
  96. package/.agents/workflows/helpers/plan-reference.md +34 -0
  97. package/.agents/workflows/plan.md +60 -76
  98. package/docs/CHANGELOG.md +38 -0
  99. package/lib/cli/update.js +14 -11
  100. package/lib/cli/version-check.js +9 -1
  101. package/package.json +1 -1
@@ -0,0 +1,248 @@
1
+ /**
2
+ * lib/orchestration/light-escalation.js — what the light path does when it
3
+ * refuses a scope (Story #4856).
4
+ *
5
+ * Three behaviors, each previously missing, and each about a refusal rather
6
+ * than 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
+ * 3. **Preserving the refused work** ({@link preserveRefusedWork}, Story
29
+ * #4875). A refusal used to leave a finished implementation on a local
30
+ * `story-<id>` branch with no remote ref — the one shape routine branch
31
+ * and worktree cleanup is entitled to delete. The branch is published to
32
+ * `origin` (no PR, no merge) so the recycle command has something to
33
+ * recycle.
34
+ *
35
+ * Telemetry is best-effort by construction: a signals-write failure must never
36
+ * change a gate's verdict.
37
+ *
38
+ * @module lib/orchestration/light-escalation
39
+ */
40
+
41
+ import { getStoryBranch, gitSpawn } from '../git-utils.js';
42
+ import {
43
+ emitRuntimeFriction,
44
+ RUNTIME_FRICTION_CATEGORIES,
45
+ } from '../observability/runtime-friction.js';
46
+
47
+ /**
48
+ * The `/plan` invocation that owns a Story the light path could not land.
49
+ *
50
+ * @param {number} storyId
51
+ * @returns {string}
52
+ */
53
+ function buildRecycleCommand(storyId) {
54
+ return `/plan ${storyId}`;
55
+ }
56
+
57
+ /**
58
+ * Coerce an `--amends` argument (`#123` or `123`) into a positive integer issue
59
+ * number, or `null` when absent/malformed.
60
+ *
61
+ * This is the only Story context a **gate-stage** rejection can legitimately
62
+ * claim: the signals stream is keyed on a Story id, and an `ask-operator` gate
63
+ * has authored no receipt yet — deliberately, since not creating one is the
64
+ * point of that outcome. A bare prompt's rejection therefore has no stream to
65
+ * land in, and attributing it to a fabricated id would be worse than recording
66
+ * nothing.
67
+ *
68
+ * @param {unknown} amends
69
+ * @returns {number|null}
70
+ */
71
+ function normalizeAmendsId(amends) {
72
+ const match = /^#?(\d+)$/.exec(String(amends ?? '').trim());
73
+ if (!match) return null;
74
+ const n = Number.parseInt(match[1], 10);
75
+ return Number.isInteger(n) && n > 0 ? n : null;
76
+ }
77
+
78
+ /**
79
+ * Record a suitability-gate refusal (`ask-operator`) as friction, attributed to
80
+ * the `--amends` target when there is one.
81
+ *
82
+ * @param {{
83
+ * gate: object,
84
+ * amends?: unknown,
85
+ * recordFrictionFn?: typeof recordScopeFriction,
86
+ * }} args
87
+ * @returns {Promise<boolean>}
88
+ */
89
+ export async function recordGateRefusal({
90
+ gate,
91
+ amends,
92
+ emitFn,
93
+ recordFrictionFn = recordScopeFriction,
94
+ } = {}) {
95
+ return recordFrictionFn({
96
+ emitFn,
97
+ storyId: normalizeAmendsId(amends),
98
+ surface: 'suitability-gate',
99
+ reasons: gate?.outcome?.reasons ?? [],
100
+ details: {
101
+ action: gate?.action ?? null,
102
+ code: gate?.suitability?.shape?.code ?? null,
103
+ },
104
+ });
105
+ }
106
+
107
+ /**
108
+ * Handle a blocked diff backstop: record the refusal as friction and return the
109
+ * `/plan` invocation that recycles the receipt.
110
+ *
111
+ * Lives here rather than in the CLI so the shell stays a shell — the backstop
112
+ * mode's job is to branch and print, not to decide what a refusal means.
113
+ *
114
+ * @param {{
115
+ * storyId: number,
116
+ * result: object,
117
+ * preservation?: ReturnType<typeof preserveRefusedWork>,
118
+ * recordFrictionFn?: typeof recordScopeFriction,
119
+ * }} args `result` is a {@link module:lib/orchestration/light-suitability.checkLightDiffBackstop}
120
+ * verdict; `preservation` is the {@link preserveRefusedWork} outcome.
121
+ * @returns {Promise<string>} The recycle command.
122
+ */
123
+ export async function handleBlockedBackstop({
124
+ storyId,
125
+ result,
126
+ preservation,
127
+ emitFn,
128
+ recordFrictionFn = recordScopeFriction,
129
+ } = {}) {
130
+ await recordFrictionFn({
131
+ emitFn,
132
+ storyId,
133
+ surface: 'diff-backstop',
134
+ reasons: result?.reasons ?? [],
135
+ details: {
136
+ fileCount: result?.fileCount ?? null,
137
+ implFiles: result?.magnitude?.implFiles ?? null,
138
+ implLines: result?.magnitude?.implLines ?? null,
139
+ ceilings: result?.ceilings ?? null,
140
+ classes: result?.classes ?? [],
141
+ // A refusal whose work was NOT preserved is a different (worse) event
142
+ // than one whose branch reached origin — the roll-up must be able to
143
+ // tell them apart.
144
+ preserved: preservation?.preserved ?? null,
145
+ },
146
+ });
147
+ return buildRecycleCommand(storyId);
148
+ }
149
+
150
+ /**
151
+ * Publish a refused light run's branch to `origin` so the finished work is
152
+ * recoverable (Story #4875).
153
+ *
154
+ * A blocked backstop refuses the *land*, not the *work*: the implementation is
155
+ * complete and the recycle command hands the receipt to `/plan`, which will
156
+ * want it. Before this, that work existed only as a local `story-<id>` branch
157
+ * with no remote ref — an untracked branch is exactly what the routine merged-
158
+ * branch sweeps and worktree reaping treat as disposable, so the only copy of a
159
+ * finished implementation sat one cleanup away from deletion.
160
+ *
161
+ * Pushing is deliberately **not** a landing: the branch gets a remote ref, no
162
+ * PR is opened, and nothing merges. Total by construction — a push failure
163
+ * (offline, no write access, no such branch) is reported and never changes the
164
+ * refusal verdict, because a preservation attempt must not be able to turn a
165
+ * blocked backstop into a crash.
166
+ *
167
+ * Idempotent: re-running against an already-pushed branch is an up-to-date
168
+ * no-op.
169
+ *
170
+ * @param {{
171
+ * storyId: number,
172
+ * cwd?: string,
173
+ * gitFn?: typeof gitSpawn,
174
+ * }} args
175
+ * @returns {{
176
+ * preserved: boolean,
177
+ * branch: string,
178
+ * remoteRef: string|null,
179
+ * detail: string,
180
+ * }}
181
+ */
182
+ export function preserveRefusedWork({
183
+ storyId,
184
+ cwd = process.cwd(),
185
+ gitFn = gitSpawn,
186
+ } = {}) {
187
+ const branch = getStoryBranch(storyId);
188
+ const unpreserved = (detail) => ({
189
+ preserved: false,
190
+ branch,
191
+ remoteRef: null,
192
+ detail,
193
+ });
194
+ let result;
195
+ try {
196
+ result = gitFn(cwd, 'push', '--set-upstream', 'origin', branch);
197
+ } catch (err) {
198
+ return unpreserved(
199
+ `could not publish ${branch}: ${err?.message ?? err} — the finished work is LOCAL ONLY; push it before any branch cleanup runs`,
200
+ );
201
+ }
202
+ if (result?.status !== 0) {
203
+ return unpreserved(
204
+ `could not publish ${branch}: ${result?.stderr || 'git push failed'} — the finished work is LOCAL ONLY; push it before any branch cleanup runs`,
205
+ );
206
+ }
207
+ return {
208
+ preserved: true,
209
+ branch,
210
+ remoteRef: `origin/${branch}`,
211
+ detail: `refused work preserved on origin/${branch} — the branch is no longer the only copy, and no PR was opened`,
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Record a light-path scope rejection as friction.
217
+ *
218
+ * Total: never throws, and returns `false` rather than propagating when the
219
+ * signals surface is unavailable.
220
+ *
221
+ * @param {{
222
+ * storyId?: number|null,
223
+ * surface: string,
224
+ * reasons?: string[],
225
+ * details?: object,
226
+ * emitFn?: typeof emitRuntimeFriction,
227
+ * }} args
228
+ * @returns {Promise<boolean>}
229
+ */
230
+ async function recordScopeFriction({
231
+ storyId,
232
+ surface,
233
+ reasons = [],
234
+ details = {},
235
+ emitFn,
236
+ } = {}) {
237
+ const emit = emitFn ?? emitRuntimeFriction;
238
+ try {
239
+ return await emit({
240
+ storyId,
241
+ category: RUNTIME_FRICTION_CATEGORIES.LIGHT_SCOPE_REJECTED,
242
+ tool: 'deliver-light',
243
+ details: { surface, reasons, ...details },
244
+ });
245
+ } catch {
246
+ return false;
247
+ }
248
+ }
@@ -36,9 +36,13 @@
36
36
  * Under `--yes` (unattended) it fails closed to recommending `/plan`.
37
37
  * 3. **Diff-derived backstop ({@link checkLightDiffBackstop}).** After
38
38
  * implementation the **actual** change set is re-checked with
39
- * {@link module:lib/orchestration/review-depth.deriveChangeLevel} plus a
40
- * file-count ceiling the diff is the real scope signal and an
41
- * over-ceiling diff is blocked rather than landed silently.
39
+ * {@link module:lib/orchestration/review-depth.deriveChangeLevel} plus the
40
+ * implementation-only magnitude ceilings of {@link LIGHT_DIFF_CEILINGS}
41
+ * the diff is the real scope signal — and an over-ceiling diff is blocked
42
+ * rather than landed silently. Story #4856 moved this from a `maxFiles: 4`
43
+ * cardinality ceiling to changed lines over implementation files, and made
44
+ * a block **recycle** its receipt Story through `/plan` tickets mode
45
+ * instead of orphaning it.
42
46
  * 4. **Minimal receipt Story ({@link buildReceiptStoryTicket}).** A
43
47
  * `type::story` ticket is authored inline so `refs #`, history, telemetry,
44
48
  * and the `agent::executing -> agent::done` state machine survive.
@@ -79,35 +83,135 @@ export const OVERRIDABLE_SHAPE_CODES = Object.freeze([
79
83
  ]);
80
84
 
81
85
  /**
82
- * File-count ceiling for the **actual landed** change set the diff backstop
83
- * ({@link checkLightDiffBackstop}) enforces. This is the light path's **only**
84
- * cardinality ceiling, and deliberately so (Story #4764): the predicted
85
- * footprint is a declaration a guess, and a gameable one — so the gate that
86
- * counts must be the one reading ground truth. A genuinely-light change stays
87
- * small; conservative by construction, since a ceiling an operator could widen
88
- * past what a single session safely absorbs is a ceiling that fails silently.
89
- * A framework constant, not a knob.
86
+ * Detect the **un-waivable** risk rules a predicted footprint trips —
87
+ * `sensitive-path` and `migration-span`, the complement of
88
+ * {@link OVERRIDABLE_SHAPE_CODES} **independent of which rule the shape
89
+ * decision happened to record** (Story #4875).
90
+ *
91
+ * No re-slicing, shrinking, or operator answer satisfies one: a footprint
92
+ * intersecting a sensitive-path class routes `full` however small the change,
93
+ * and the diff backstop refuses the same footprint again at the end. But
94
+ * {@link deriveStoryShape} reports only the **first** rule a shape trips and
95
+ * evaluates the ceiling rules first, so a prompt tripping both `change-kinds`
96
+ * and `sensitive-path` is reported as a size objection — which reads as
97
+ * appealable, is waivable by an attended operator, and sends the work all the
98
+ * way to an implementation the backstop then refuses.
99
+ *
100
+ * The recovery is that the shape decision attaches the built effort shape to
101
+ * every footprint it can judge at all, and that shape carries the risk facts
102
+ * (`sensitiveClasses`, `migrationSpan`) whether or not a risk rule fired.
103
+ * Reading them here surfaces the objection first-hit reporting hides — the
104
+ * difference between a wasted session and a redirected one.
105
+ *
106
+ * Pure and total.
107
+ *
108
+ * @param {{ shape?: { sensitiveClasses?: unknown, migrationSpan?: unknown } }} [decision]
109
+ * A {@link deriveStoryShape} return value.
110
+ * @returns {{
111
+ * present: boolean,
112
+ * code: string|null,
113
+ * classes: string[],
114
+ * reason: string|null,
115
+ * }}
116
+ */
117
+ function deriveUnwaivableRisk(decision) {
118
+ const shape = decision?.shape ?? null;
119
+ const classes = Array.isArray(shape?.sensitiveClasses)
120
+ ? shape.sensitiveClasses.filter(
121
+ (c) => typeof c === 'string' && c.trim() !== '',
122
+ )
123
+ : [];
124
+ if (classes.length > 0) {
125
+ return {
126
+ present: true,
127
+ code: SHAPE_CODES.SENSITIVE_PATH,
128
+ classes,
129
+ reason:
130
+ `un-waivable: the predicted footprint intersects sensitive-path ` +
131
+ `class(es) ${classes.join(', ')} — this is risk, not size, so no ` +
132
+ `re-slicing, shrinking, or operator override satisfies it and the ` +
133
+ `diff backstop would refuse the same footprint after the work is ` +
134
+ `finished; take this to /plan now`,
135
+ };
136
+ }
137
+ if (shape?.migrationSpan === true) {
138
+ return {
139
+ present: true,
140
+ code: SHAPE_CODES.MIGRATION_SPAN,
141
+ classes: [],
142
+ reason:
143
+ `un-waivable: the predicted footprint pairs a migration with its ` +
144
+ `consumers — this is risk, not size, so no re-slicing or operator ` +
145
+ `override satisfies it; take this to /plan now`,
146
+ };
147
+ }
148
+ return { present: false, code: null, classes: [], reason: null };
149
+ }
150
+
151
+ /**
152
+ * Ceilings for the **actual landed** change set the diff backstop
153
+ * ({@link checkLightDiffBackstop}) enforces, measured on the change's
154
+ * implementation half (Story #4856 — see
155
+ * {@link module:lib/orchestration/diff-magnitude} for the measured case and the
156
+ * companion-class boundary).
157
+ *
158
+ * The backstop reads ground truth, so it is where size is genuinely enforced;
159
+ * the prediction gate above it is a declaration and stays coarse (Story #4764).
160
+ * What changed is the **axis**: this used to be `maxFiles: 4`, a cardinality
161
+ * ceiling that rejected 79% of this repository's real merged work while passing
162
+ * a three-file 323-line rewrite.
163
+ *
164
+ * - `maxImplLines` — additions plus deletions across implementation files.
165
+ * Simulated over 41 merges, 1000 admits 83% of real work
166
+ * and rejects exactly the genuinely large changes.
167
+ * - `maxImplFiles` — implementation files touched, a *sprawl* tripwire rather
168
+ * than a size gate. Set to `DEFAULT_DIFF_WIDTH.softFiles`
169
+ * so the light path and `review-depth.js` stop holding two
170
+ * different definitions of a narrow diff.
171
+ *
172
+ * Framework constants, not knobs: a ceiling an operator could widen past what a
173
+ * single session safely absorbs is a ceiling that fails silently.
90
174
  */
91
175
  export const LIGHT_DIFF_CEILINGS = Object.freeze({
92
- maxFiles: 4,
176
+ maxImplLines: 1000,
177
+ maxImplFiles: 15,
93
178
  });
94
179
 
95
180
  /**
96
- * Coerce a candidate `maxFiles` ceiling into a positive integer, falling back
97
- * to the framework default for anything malformed — a stray `0`, `-1`, or `NaN`
98
- * must never widen (or zero out) the light diff ceiling.
181
+ * Coerce a candidate ceiling into a positive integer, falling back to the
182
+ * framework default for anything malformed — a stray `0`, `-1`, or `NaN` must
183
+ * never widen (or zero out) a light diff ceiling.
99
184
  *
100
185
  * @param {unknown} value
101
186
  * @param {number} fallback
102
187
  * @returns {number}
103
188
  */
104
- function normalizeMaxFiles(value, fallback) {
189
+ function normalizeCeiling(value, fallback) {
105
190
  if (typeof value !== 'number' || !Number.isFinite(value) || value < 1) {
106
191
  return fallback;
107
192
  }
108
193
  return Math.floor(value);
109
194
  }
110
195
 
196
+ /**
197
+ * Resolve the effective diff ceilings from a caller-supplied partial override.
198
+ *
199
+ * @param {{ maxImplLines?: unknown, maxImplFiles?: unknown }} [ceilings]
200
+ * @returns {{ maxImplLines: number, maxImplFiles: number }}
201
+ */
202
+ function resolveDiffCeilings(ceilings) {
203
+ return {
204
+ maxImplLines: normalizeCeiling(
205
+ ceilings?.maxImplLines,
206
+ LIGHT_DIFF_CEILINGS.maxImplLines,
207
+ ),
208
+ maxImplFiles: normalizeCeiling(
209
+ ceilings?.maxImplFiles,
210
+ LIGHT_DIFF_CEILINGS.maxImplFiles,
211
+ ),
212
+ };
213
+ }
214
+
111
215
  /**
112
216
  * Resolve the model's trivial-vs-standard verdict, held to the same ledgering
113
217
  * contract the planner's authored verdict is
@@ -184,9 +288,12 @@ export function resolveLedgeredVerdict({ route, reason } = {}) {
184
288
  * route: 'lite'|'full',
185
289
  * shape: ReturnType<typeof deriveStoryShape>,
186
290
  * ledger: ReturnType<typeof resolveLedgeredVerdict>,
291
+ * unwaivable: ReturnType<typeof deriveUnwaivableRisk>,
187
292
  * ceilings: typeof STORY_SHAPE_CEILINGS,
188
293
  * reasons: string[],
189
- * }}
294
+ * }} `unwaivable` names an absolute risk rule the predicted footprint trips
295
+ * even when the recorded `shape.code` is a size prediction (Story #4875), so
296
+ * the operator learns at prediction time that no override can help.
190
297
  */
191
298
  export function deriveLightSuitability({
192
299
  predictedChanges,
@@ -208,14 +315,24 @@ export function deriveLightSuitability({
208
315
  injectedRules,
209
316
  selectSensitivePathClassesFn,
210
317
  });
211
- const suitable = shape.route === 'lite' && ledger.route === 'lite';
318
+ const unwaivable = deriveUnwaivableRisk(shape);
319
+ // A tripped risk rule is decisive on its own: the shape decision may have
320
+ // recorded an earlier ceiling rule, but a sensitive footprint can never be
321
+ // lite, so the conjunction must not be able to read `suitable` from a shape
322
+ // whose recorded code was waived downstream.
323
+ const suitable =
324
+ shape.route === 'lite' && ledger.route === 'lite' && !unwaivable.present;
325
+ const reasons = [`shape: ${shape.reasons[0]}`];
326
+ if (unwaivable.present) reasons.push(unwaivable.reason);
327
+ reasons.push(`verdict: ${ledger.note}`);
212
328
  return {
213
329
  suitable,
214
330
  route: suitable ? 'lite' : 'full',
215
331
  shape,
216
332
  ledger,
333
+ unwaivable,
217
334
  ceilings: STORY_SHAPE_CEILINGS,
218
- reasons: [`shape: ${shape.reasons[0]}`, `verdict: ${ledger.note}`],
335
+ reasons,
219
336
  };
220
337
  }
221
338
 
@@ -236,8 +353,11 @@ export function deriveLightSuitability({
236
353
  * terminal). Checked here as well as at the CLI, so the pure core carries
237
354
  * the guarantee rather than the shell.
238
355
  * 3. **The objection is a size prediction** — a code in
239
- * {@link OVERRIDABLE_SHAPE_CODES}. Sensitivity and migration span are
240
- * risk and stay absolute however small the change.
356
+ * {@link OVERRIDABLE_SHAPE_CODES}, **and** the footprint trips no
357
+ * un-waivable risk rule ({@link deriveUnwaivableRisk}, Story #4875).
358
+ * Sensitivity and migration
359
+ * span are risk and stay absolute however small the change — including
360
+ * when an earlier ceiling rule is the one the shape recorded.
241
361
  * 4. **The ledgered verdict is already `lite`.** The override substitutes for
242
362
  * the *shape* half of the conjunction only; an unaudited "trust me, it's
243
363
  * small" buys nothing it did not buy before.
@@ -279,6 +399,22 @@ export function resolveOperatorOverride({
279
399
  }
280
400
 
281
401
  const code = suitability?.shape?.code ?? null;
402
+ // Checked BEFORE the overridable-code test on purpose (Story #4875): when a
403
+ // footprint trips both a ceiling rule and a risk rule, the shape records the
404
+ // ceiling rule, which IS overridable — so testing the recorded code alone
405
+ // would apply the override and send un-landable work to the backstop.
406
+ const unwaivable = suitability?.unwaivable;
407
+ if (unwaivable?.present === true) {
408
+ return refuse(
409
+ `operator override refused — the predicted footprint also trips the ` +
410
+ `un-waivable "${unwaivable.code}" rule${
411
+ unwaivable.classes.length > 0
412
+ ? ` (${unwaivable.classes.join(', ')})`
413
+ : ''
414
+ }; waiving the size prediction cannot make this land light, and the ` +
415
+ `diff backstop would refuse the finished work. Escalate to /plan.`,
416
+ );
417
+ }
282
418
  if (!OVERRIDABLE_SHAPE_CODES.includes(code)) {
283
419
  return refuse(
284
420
  `operator override refused — "${code ?? 'unknown'}" is not an overridable size prediction (overridable: ${OVERRIDABLE_SHAPE_CODES.join(', ')}); risk rules and unknown footprints are non-negotiable`,
@@ -389,11 +525,23 @@ export function resolveLightGateOutcome({
389
525
  }
390
526
 
391
527
  /**
392
- * Diff-derived backstop (Story #4740 AC-4): re-check the **actual** change set
393
- * after implementation, because the diff not the prompt is the real scope
394
- * signal. Blocks (rather than landing) when the diff intersects a sensitive-
395
- * path class, exceeds the file-count ceiling, or cannot be classified. A clean
396
- * result is the only path that lands light.
528
+ * Diff-derived backstop (Story #4740 AC-4, re-based on magnitude by Story
529
+ * #4856): re-check the **actual** change set after implementation, because the
530
+ * diff — not the prompt — is the real scope signal. Blocks (rather than landing)
531
+ * when the diff intersects a sensitive-path class, exceeds an implementation
532
+ * ceiling, or cannot be measured. A clean result is the only path that lands
533
+ * light.
534
+ *
535
+ * Two inputs, two different scopes, and the difference is load-bearing:
536
+ *
537
+ * - `changedFiles` is the **full** change set, companions included, and is
538
+ * what sensitive-path derivation reads. Exempting a companion from the
539
+ * *count* must never exempt it from *risk* — a test file under a registered
540
+ * sensitive class still blocks.
541
+ * - `magnitude` is the implementation-only summary from
542
+ * {@link module:lib/orchestration/diff-magnitude.summarizeDiffMagnitude}.
543
+ * `null` means the magnitude could not be measured, which blocks: absence
544
+ * of evidence is not evidence the diff is small.
397
545
  *
398
546
  * Reuses close's own {@link module:lib/orchestration/review-depth.deriveChangeLevel}
399
547
  * — one taxonomy, applied to the predicted shape at the gate and the actual
@@ -403,7 +551,8 @@ export function resolveLightGateOutcome({
403
551
  *
404
552
  * @param {{
405
553
  * changedFiles?: unknown,
406
- * ceilings?: { maxFiles?: number },
554
+ * magnitude?: { implFiles?: number, implLines?: number }|null,
555
+ * ceilings?: { maxImplLines?: number, maxImplFiles?: number },
407
556
  * injectedRules?: object,
408
557
  * selectSensitivePathClassesFn?: Function,
409
558
  * }} [args]
@@ -412,20 +561,19 @@ export function resolveLightGateOutcome({
412
561
  * level: 'low'|'high'|null,
413
562
  * classes: string[],
414
563
  * fileCount: number|null,
415
- * ceilings: { maxFiles: number },
564
+ * magnitude: { implFiles: number, implLines: number }|null,
565
+ * ceilings: { maxImplLines: number, maxImplFiles: number },
416
566
  * reasons: string[],
417
567
  * }}
418
568
  */
419
569
  export function checkLightDiffBackstop({
420
570
  changedFiles,
571
+ magnitude,
421
572
  ceilings,
422
573
  injectedRules,
423
574
  selectSensitivePathClassesFn,
424
575
  } = {}) {
425
- const maxFiles = normalizeMaxFiles(
426
- ceilings?.maxFiles,
427
- LIGHT_DIFF_CEILINGS.maxFiles,
428
- );
576
+ const resolved = resolveDiffCeilings(ceilings);
429
577
  const files = Array.isArray(changedFiles)
430
578
  ? changedFiles.filter((f) => typeof f === 'string' && f.trim() !== '')
431
579
  : null;
@@ -436,7 +584,8 @@ export function checkLightDiffBackstop({
436
584
  level: null,
437
585
  classes: [],
438
586
  fileCount: files === null ? null : 0,
439
- ceilings: { maxFiles },
587
+ magnitude: null,
588
+ ceilings: resolved,
440
589
  reasons: [
441
590
  'actual change set is unknown or empty — cannot verify the diff is light; escalate to /plan',
442
591
  ],
@@ -448,23 +597,12 @@ export function checkLightDiffBackstop({
448
597
  injectedRules,
449
598
  selectSensitivePathClassesFn,
450
599
  });
600
+ const measured = normalizeMagnitude(magnitude);
451
601
 
452
- const reasons = [];
453
- if (classes.length > 0) {
454
- reasons.push(
455
- `diff intersects sensitive-path class(es) ${classes.join(', ')} — escalate to /plan (do not land light)`,
456
- );
457
- }
458
- if (files.length > maxFiles) {
459
- reasons.push(
460
- `diff touches ${files.length} file(s) (> maxFiles ${maxFiles}) — escalate to /plan (do not land light)`,
461
- );
462
- }
463
- if (level !== 'low' && classes.length === 0) {
464
- reasons.push(
465
- 'sensitive-path classification unavailable — cannot verify the diff is non-sensitive; escalate to /plan',
466
- );
467
- }
602
+ const reasons = [
603
+ ...describeSensitivity({ level, classes }),
604
+ ...describeMagnitude(measured, resolved),
605
+ ];
468
606
 
469
607
  const blocked = reasons.length > 0;
470
608
  return {
@@ -472,15 +610,80 @@ export function checkLightDiffBackstop({
472
610
  level,
473
611
  classes,
474
612
  fileCount: files.length,
475
- ceilings: { maxFiles },
613
+ magnitude: measured,
614
+ ceilings: resolved,
476
615
  reasons: blocked
477
616
  ? reasons
478
617
  : [
479
- `diff is light: ${files.length} file(s) ≤ ${maxFiles}, no sensitive-path class — safe to land`,
618
+ `diff is light: ${measured.implLines} implementation line(s) ≤ ${resolved.maxImplLines} ` +
619
+ `across ${measured.implFiles} implementation file(s) ≤ ${resolved.maxImplFiles} ` +
620
+ `(${files.length} file(s) total, companions exempt), no sensitive-path class — safe to land`,
480
621
  ],
481
622
  };
482
623
  }
483
624
 
625
+ /**
626
+ * Coerce a magnitude summary into non-negative integer counts, or `null` when
627
+ * it was not measurable. Pure.
628
+ *
629
+ * @param {unknown} magnitude
630
+ * @returns {{ implFiles: number, implLines: number }|null}
631
+ */
632
+ function normalizeMagnitude(magnitude) {
633
+ const implFiles = magnitude?.implFiles;
634
+ const implLines = magnitude?.implLines;
635
+ if (!Number.isFinite(implFiles) || !Number.isFinite(implLines)) return null;
636
+ if (implFiles < 0 || implLines < 0) return null;
637
+ return { implFiles: Math.floor(implFiles), implLines: Math.floor(implLines) };
638
+ }
639
+
640
+ /**
641
+ * Sensitivity objections, over the **full** change set. Pure.
642
+ *
643
+ * @param {{ level: 'low'|'high'|null, classes: string[] }} derived
644
+ * @returns {string[]}
645
+ */
646
+ function describeSensitivity({ level, classes }) {
647
+ if (classes.length > 0) {
648
+ return [
649
+ `diff intersects sensitive-path class(es) ${classes.join(', ')} — escalate to /plan (do not land light)`,
650
+ ];
651
+ }
652
+ if (level !== 'low') {
653
+ return [
654
+ 'sensitive-path classification unavailable — cannot verify the diff is non-sensitive; escalate to /plan',
655
+ ];
656
+ }
657
+ return [];
658
+ }
659
+
660
+ /**
661
+ * Magnitude objections, over the implementation half only. Pure.
662
+ *
663
+ * @param {{ implFiles: number, implLines: number }|null} measured
664
+ * @param {{ maxImplLines: number, maxImplFiles: number }} ceilings
665
+ * @returns {string[]}
666
+ */
667
+ function describeMagnitude(measured, ceilings) {
668
+ if (measured === null) {
669
+ return [
670
+ 'change magnitude could not be measured (unreadable or unparseable numstat) — cannot verify the diff is light; escalate to /plan',
671
+ ];
672
+ }
673
+ const reasons = [];
674
+ if (measured.implLines > ceilings.maxImplLines) {
675
+ reasons.push(
676
+ `diff changes ${measured.implLines} implementation line(s) (> maxImplLines ${ceilings.maxImplLines}) — escalate to /plan (do not land light)`,
677
+ );
678
+ }
679
+ if (measured.implFiles > ceilings.maxImplFiles) {
680
+ reasons.push(
681
+ `diff spans ${measured.implFiles} implementation file(s) (> maxImplFiles ${ceilings.maxImplFiles}) — escalate to /plan (do not land light)`,
682
+ );
683
+ }
684
+ return reasons;
685
+ }
686
+
484
687
  /** Cap on a receipt slug's length — keep the branch/id readable. */
485
688
  const RECEIPT_SLUG_MAX = 48;
486
689