mandrel 2.51.0 → 2.53.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/agents/story-worker.md +4 -5
  2. package/.agents/docs/agentrc-reference.json +3 -1
  3. package/.agents/docs/configuration.md +2 -0
  4. package/.agents/schemas/agentrc.schema.json +15 -0
  5. package/.agents/scripts/check-audit-attribution.js +245 -0
  6. package/.agents/scripts/check-pinned-override-notes.js +102 -0
  7. package/.agents/scripts/coverage-capture.js +36 -21
  8. package/.agents/scripts/lib/audit-attribution.js +112 -0
  9. package/.agents/scripts/lib/close-validation/commands.js +27 -1
  10. package/.agents/scripts/lib/close-validation/gates.js +33 -14
  11. package/.agents/scripts/lib/config/commands.js +14 -12
  12. package/.agents/scripts/lib/config-settings-schema-delivery.js +6 -0
  13. package/.agents/scripts/lib/config-settings-schema.js +9 -2
  14. package/.agents/scripts/lib/coverage-capture.js +70 -0
  15. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  16. package/.agents/scripts/lib/observability/runtime-friction.js +34 -0
  17. package/.agents/scripts/lib/observability/source-classifier.js +2 -0
  18. package/.agents/scripts/lib/orchestration/epic-container.js +27 -0
  19. package/.agents/scripts/lib/orchestration/epic-rollup.js +15 -4
  20. package/.agents/scripts/lib/orchestration/light-backstop.js +62 -6
  21. package/.agents/scripts/lib/orchestration/light-escalation.js +29 -3
  22. package/.agents/scripts/lib/orchestration/light-suitability.js +105 -19
  23. package/.agents/scripts/lib/orchestration/retro-proposals.js +36 -2
  24. package/.agents/scripts/lib/orchestration/worktree-dirty.js +80 -0
  25. package/.agents/scripts/lib/pinned-override-notes.js +100 -0
  26. package/.agents/scripts/resolve-stories.js +9 -3
  27. package/.agents/workflows/helpers/deliver-digest.md +4 -6
  28. package/.agents/workflows/helpers/deliver-light.md +13 -7
  29. package/docs/CHANGELOG.md +20 -0
  30. package/package.json +5 -5
@@ -95,6 +95,40 @@ export const RUNTIME_FRICTION_CATEGORIES = Object.freeze({
95
95
  REVIEW_BLOCK_OVERRIDDEN: 'review-block-overridden',
96
96
  });
97
97
 
98
+ /**
99
+ * The friction category one light-path refusal files under.
100
+ *
101
+ * `LIGHT_SCOPE_REJECTED` alone used to be the category for every refusal, and
102
+ * the retro composer aggregates, de-duplicates and labels on the category
103
+ * string ALONE — so an empty-diff refusal and a `public-api` sensitive-path
104
+ * refusal landed in one bucket and filed one "recurred 2 times across 2
105
+ * Stories" follow-up whose two occurrences had nothing in common but the
106
+ * category (issue #5237; consumer Beestera/swarm-os#2408). The roll-up's shape
107
+ * fingerprint could not separate them either: it hashes detail KEYS, and every
108
+ * refusal carries an identical key set.
109
+ *
110
+ * Encoding the refusal class in the category is what splits them, and it
111
+ * splits them in every consumer at once — bucket aggregation, the graduator's
112
+ * idempotency marker and the `friction::<category>` label all read this one
113
+ * string. N refusals of the SAME class still coalesce, which is the recurrence
114
+ * evidence the light ceilings are recalibrated from. New categories need no
115
+ * seeding: the graduator mints missing `friction::*` labels before filing.
116
+ *
117
+ * A class-less refusal keeps the bare category unchanged — that is the
118
+ * suitability gate, which refuses a prompt before any diff exists and so has
119
+ * no diff-derived class to carry.
120
+ *
121
+ * @param {string|null} [refusalClass] A
122
+ * {@link module:lib/orchestration/light-suitability.LIGHT_REFUSAL_CLASSES}
123
+ * value, or nullish for the unclassified refusal.
124
+ * @returns {string}
125
+ */
126
+ export function lightScopeRejectedCategory(refusalClass) {
127
+ const suffix = typeof refusalClass === 'string' ? refusalClass.trim() : '';
128
+ const base = RUNTIME_FRICTION_CATEGORIES.LIGHT_SCOPE_REJECTED;
129
+ return suffix === '' ? base : `${base}-${suffix}`;
130
+ }
131
+
98
132
  /** Cap on free-form reason text copied into a signal's `details`. */
99
133
  const REASON_PREVIEW_LIMIT = 500;
100
134
 
@@ -87,6 +87,7 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
87
87
  'bootstrap.js',
88
88
  'check-action-pinning.js',
89
89
  'check-arch-cycles.js',
90
+ 'check-audit-attribution.js',
90
91
  'check-baseline-drift.js',
91
92
  'check-baseline-scope.js',
92
93
  'check-baselines.js',
@@ -98,6 +99,7 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
98
99
  'check-gherkin-corpus.js',
99
100
  'check-knip-entries.js',
100
101
  'check-lifecycle-lint.js',
102
+ 'check-pinned-override-notes.js',
101
103
  'check-schema-references.js',
102
104
  'check-test-temp-hygiene.js',
103
105
  'check-windows-git-perf.js',
@@ -110,6 +110,33 @@ export function isEpicTicket(issue) {
110
110
  return normalizeLabels(issue?.labels).includes(TYPE_LABELS.EPIC);
111
111
  }
112
112
 
113
+ /**
114
+ * Resolve the GraphQL node id an Epic's native sub-issue read addresses it by.
115
+ *
116
+ * Both casings are accepted because the field name depends on which provider
117
+ * method produced the object, and neither caller can tell from the value it
118
+ * holds: `getTicket` (and every other single-issue read) runs through
119
+ * `issueToTicket`, which renames `node_id` to `nodeId`, while
120
+ * `listIssuesByLabel` returns the REST payload **verbatim** — six consumers
121
+ * read its raw shape, so mapping it there would be a far wider change than
122
+ * the two reads that actually need the id.
123
+ *
124
+ * Returns `null` when neither name carries one. That is the load-bearing
125
+ * half: an absent id reaches GraphQL as `$id: ID!` = `undefined`, which the
126
+ * API rejects and `classifyGithubError` calls `permanent` — so the gateway
127
+ * rethrows with no retry and no feature-disabled fallback, and the caller
128
+ * degrades to the body checklist while reporting a hard API failure it never
129
+ * really had. Callers skip the read on a `null` instead, the same clean
130
+ * no-op `providers/github/board-add.js` makes with `reason: 'no-node-id'`.
131
+ *
132
+ * @param {{ nodeId?: unknown, node_id?: unknown }} epic
133
+ * @returns {string|null}
134
+ */
135
+ export function resolveEpicNodeId(epic) {
136
+ const nodeId = epic?.nodeId ?? epic?.node_id;
137
+ return typeof nodeId === 'string' && nodeId !== '' ? nodeId : null;
138
+ }
139
+
113
140
  /**
114
141
  * Render a container Epic's body.
115
142
  *
@@ -45,7 +45,11 @@
45
45
  import { Logger } from '../Logger.js';
46
46
  import { AGENT_LABELS, TYPE_LABELS } from '../label-constants.js';
47
47
  import { ColumnSync, LABEL_TO_COLUMN } from './column-sync.js';
48
- import { isEpicTicket, readEpicChildIdsFrom } from './epic-container.js';
48
+ import {
49
+ isEpicTicket,
50
+ readEpicChildIdsFrom,
51
+ resolveEpicNodeId,
52
+ } from './epic-container.js';
49
53
  import { resolveOperatorFromCandidates } from './lease-guard-shared.js';
50
54
  import { deriveParentState } from './ticketing/bulk.js';
51
55
 
@@ -68,15 +72,22 @@ const IN_FLIGHT_STATES = new Set([
68
72
  * borrow four lines would invert the dependency direction for no gain. What
69
73
  * matters is that the *reader* handed to `readEpicChildIdsFrom` behaves the
70
74
  * same on both paths, which is what keeps an Epic from being expandable but
71
- * unclosable.
75
+ * unclosable. The shared `resolveEpicNodeId` is what makes "the same" true of
76
+ * the id itself: the Epics reaching this reader come from
77
+ * `listIssuesByLabel`, whose raw REST payload carries `node_id`, while the
78
+ * expansion path's come from `getTicket`, whose mapped ticket carries
79
+ * `nodeId`.
72
80
  *
73
81
  * @param {object} provider
74
82
  * @returns {(epic: object) => Promise<number[]>}
75
83
  */
76
84
  function nativeChildReader(provider) {
77
85
  return async (epic) => {
78
- if (typeof provider?._getNativeSubIssues !== 'function') return [];
79
- return provider._getNativeSubIssues(epic?.nodeId, epic?.number ?? epic?.id);
86
+ const nodeId = resolveEpicNodeId(epic);
87
+ if (nodeId === null) return [];
88
+ return (
89
+ provider?._getNativeSubIssues?.(nodeId, epic?.number ?? epic?.id) ?? []
90
+ );
80
91
  };
81
92
  }
82
93
 
@@ -18,16 +18,26 @@
18
18
  * other consumer are looking at the same change set.
19
19
  * - `--numstat` gives per-file line counts, the only surface carrying them.
20
20
  *
21
+ * Both read committed state, which is why a third read exists
22
+ * ({@link module:lib/orchestration/worktree-dirty.hasUncommittedWork}): an
23
+ * empty diff is ambiguous between "no work" and "work not committed yet", and
24
+ * only one of those is about scope.
25
+ *
21
26
  * @module lib/orchestration/light-backstop
22
27
  */
23
28
 
29
+ import { gitSpawn } from '../git-utils.js';
24
30
  import { computeChangeSet } from './change-set.js';
25
31
  import { readNumstatRows, summarizeDiffMagnitude } from './diff-magnitude.js';
26
32
  import {
27
33
  handleBlockedBackstop,
28
34
  preserveRefusedWork,
29
35
  } from './light-escalation.js';
30
- import { checkLightDiffBackstop } from './light-suitability.js';
36
+ import {
37
+ checkLightDiffBackstop,
38
+ LIGHT_REFUSAL_CLASSES,
39
+ } from './light-suitability.js';
40
+ import { hasUncommittedWork } from './worktree-dirty.js';
31
41
 
32
42
  /** Exit code when the diff backstop blocked the land. */
33
43
  const EXIT_BACKSTOP_BLOCKED = 3;
@@ -41,6 +51,8 @@ const EXIT_BACKSTOP_BLOCKED = 3;
41
51
  * cwd?: string,
42
52
  * computeFn?: typeof computeChangeSet,
43
53
  * readRowsFn?: typeof readNumstatRows,
54
+ * dirtyProbeFn?: typeof hasUncommittedWork,
55
+ * gitFn?: typeof gitSpawn,
44
56
  * injectedRules?: object,
45
57
  * }} args
46
58
  * @returns {ReturnType<typeof checkLightDiffBackstop>}
@@ -51,6 +63,8 @@ function runDiffBackstop({
51
63
  cwd = process.cwd(),
52
64
  computeFn = computeChangeSet,
53
65
  readRowsFn = readNumstatRows,
66
+ dirtyProbeFn = hasUncommittedWork,
67
+ gitFn = gitSpawn,
54
68
  injectedRules,
55
69
  } = {}) {
56
70
  const headRef = `story-${storyId}`;
@@ -61,9 +75,49 @@ function runDiffBackstop({
61
75
  changedFiles: files,
62
76
  magnitude,
63
77
  injectedRules,
78
+ storyBranch: headRef,
79
+ // Only an ENUMERATED-empty diff can be explained by uncommitted work, so
80
+ // the probe's two git calls are spent only where they can change what the
81
+ // refusal tells the agent to do.
82
+ uncommittedWork:
83
+ Array.isArray(files) && files.length === 0
84
+ ? dirtyProbeFn({ branch: headRef, cwd, gitFn })
85
+ : false,
64
86
  });
65
87
  }
66
88
 
89
+ /**
90
+ * Is this refusal the one that is NOT about scope?
91
+ *
92
+ * @param {{ refusalClass?: string|null }} result
93
+ * @returns {boolean}
94
+ */
95
+ function isUncommittedRefusal(result) {
96
+ return result.refusalClass === LIGHT_REFUSAL_CLASSES.UNCOMMITTED_WORK;
97
+ }
98
+
99
+ /**
100
+ * Close the refusal log line: what became of the work, and what to run next.
101
+ *
102
+ * A `null` preservation is the uncommitted-work refusal by construction — that
103
+ * is the one path that does not push, because there is nothing a push could
104
+ * preserve: it would publish a branch at its base and then report uncommitted
105
+ * work as safe on `origin`, which is the opposite of true.
106
+ *
107
+ * @param {{
108
+ * storyId: number,
109
+ * preservation: { detail: string }|null,
110
+ * nextCommand: string,
111
+ * }} args
112
+ * @returns {string}
113
+ */
114
+ function describeBlockedTail({ storyId, preservation, nextCommand }) {
115
+ return preservation === null
116
+ ? `nothing is committed yet, so there is no work to preserve; ` +
117
+ `commit on story-${storyId}, then re-run: "${nextCommand}"`
118
+ : `${preservation.detail}; recycle the receipt with "${nextCommand}"`;
119
+ }
120
+
67
121
  /**
68
122
  * Resolve the backstop pass into everything the CLI needs to print and exit
69
123
  * with: the verdict, the recycle command on a refusal (`null` when clean), the
@@ -82,8 +136,8 @@ function runDiffBackstop({
82
136
  * handleBlockedFn?: typeof handleBlockedBackstop,
83
137
  * preserveFn?: typeof preserveRefusedWork,
84
138
  * }} args Any further keys (`baseRef`, `cwd`, `computeFn`, `readRowsFn`,
85
- * `injectedRules`) forward to the backstop run, so the git-surface join is
86
- * drivable through this one entry point.
139
+ * `dirtyProbeFn`, `gitFn`, `injectedRules`) forward to the backstop run, so
140
+ * the git-surface join is drivable through this one entry point.
87
141
  * @returns {Promise<{
88
142
  * result: ReturnType<typeof checkLightDiffBackstop>,
89
143
  * nextCommand: string|null,
@@ -109,7 +163,9 @@ export async function resolveBackstopOutcome({
109
163
  message: `[deliver-light] diff backstop clean for Story #${storyId}.`,
110
164
  };
111
165
  }
112
- const preservation = preserveFn({ storyId, cwd: seams.cwd });
166
+ const preservation = isUncommittedRefusal(result)
167
+ ? null
168
+ : preserveFn({ storyId, cwd: seams.cwd });
113
169
  const nextCommand = await handleBlockedFn({ storyId, result, preservation });
114
170
  return {
115
171
  result,
@@ -118,7 +174,7 @@ export async function resolveBackstopOutcome({
118
174
  exitCode: EXIT_BACKSTOP_BLOCKED,
119
175
  message:
120
176
  `[deliver-light] diff backstop BLOCKED Story #${storyId}: ` +
121
- `${result.reasons.join('; ')} — ${preservation.detail}; ` +
122
- `recycle the receipt with "${nextCommand}"`,
177
+ `${result.reasons.join('; ')} — ` +
178
+ describeBlockedTail({ storyId, preservation, nextCommand }),
123
179
  };
124
180
  }
@@ -41,8 +41,10 @@
41
41
  import { getStoryBranch, gitSpawn } from '../git-utils.js';
42
42
  import {
43
43
  emitRuntimeFriction,
44
+ lightScopeRejectedCategory,
44
45
  RUNTIME_FRICTION_CATEGORIES,
45
46
  } from '../observability/runtime-friction.js';
47
+ import { LIGHT_REFUSAL_CLASSES } from './light-suitability.js';
46
48
 
47
49
  /**
48
50
  * The `/mandrel-plan` invocation that owns a Story the light path could not land.
@@ -54,6 +56,21 @@ function buildRecycleCommand(storyId) {
54
56
  return `/mandrel-plan ${storyId}`;
55
57
  }
56
58
 
59
+ /**
60
+ * The command that re-runs the backstop once the work is committed.
61
+ *
62
+ * The one refusal that is NOT about scope gets its own next step: an empty diff
63
+ * over a dirty worktree means the backstop ran before the commit, and handing
64
+ * that run to `/mandrel-plan` recycles a receipt whose implementation is fine
65
+ * — the wrong door, dressed as an escalation (issue #5237).
66
+ *
67
+ * @param {number} storyId
68
+ * @returns {string}
69
+ */
70
+ function buildRerunBackstopCommand(storyId) {
71
+ return `node .agents/scripts/deliver-light.js --backstop --story ${storyId}`;
72
+ }
73
+
57
74
  /**
58
75
  * Coerce an `--amends` argument (`#123` or `123`) into a positive integer issue
59
76
  * number, or `null` when absent/malformed.
@@ -127,10 +144,12 @@ export async function handleBlockedBackstop({
127
144
  emitFn,
128
145
  recordFrictionFn = recordScopeFriction,
129
146
  } = {}) {
147
+ const refusalClass = result?.refusalClass ?? null;
130
148
  await recordFrictionFn({
131
149
  emitFn,
132
150
  storyId,
133
151
  surface: 'diff-backstop',
152
+ category: lightScopeRejectedCategory(refusalClass),
134
153
  reasons: result?.reasons ?? [],
135
154
  details: {
136
155
  fileCount: result?.fileCount ?? null,
@@ -142,9 +161,12 @@ export async function handleBlockedBackstop({
142
161
  // than one whose branch reached origin — the roll-up must be able to
143
162
  // tell them apart.
144
163
  preserved: preservation?.preserved ?? null,
164
+ refusalClass,
145
165
  },
146
166
  });
147
- return buildRecycleCommand(storyId);
167
+ return refusalClass === LIGHT_REFUSAL_CLASSES.UNCOMMITTED_WORK
168
+ ? buildRerunBackstopCommand(storyId)
169
+ : buildRecycleCommand(storyId);
148
170
  }
149
171
 
150
172
  /**
@@ -221,15 +243,19 @@ export function preserveRefusedWork({
221
243
  * @param {{
222
244
  * storyId?: number|null,
223
245
  * surface: string,
246
+ * category?: string,
224
247
  * reasons?: string[],
225
248
  * details?: object,
226
249
  * emitFn?: typeof emitRuntimeFriction,
227
- * }} args
250
+ * }} args `category` defaults to the unclassified light-refusal bucket, which
251
+ * is what the suitability gate emits: it refuses a prompt before any diff
252
+ * exists, so it carries no refusal class to encode.
228
253
  * @returns {Promise<boolean>}
229
254
  */
230
255
  async function recordScopeFriction({
231
256
  storyId,
232
257
  surface,
258
+ category = RUNTIME_FRICTION_CATEGORIES.LIGHT_SCOPE_REJECTED,
233
259
  reasons = [],
234
260
  details = {},
235
261
  emitFn,
@@ -238,7 +264,7 @@ async function recordScopeFriction({
238
264
  try {
239
265
  return await emit({
240
266
  storyId,
241
- category: RUNTIME_FRICTION_CATEGORIES.LIGHT_SCOPE_REJECTED,
267
+ category,
242
268
  tool: 'deliver-light',
243
269
  details: { surface, reasons, ...details },
244
270
  });
@@ -524,6 +524,52 @@ export function resolveLightGateOutcome({
524
524
  };
525
525
  }
526
526
 
527
+ /**
528
+ * The refusal classes a blocked diff backstop can carry — the machine-readable
529
+ * half of a verdict whose `reasons[]` are prose (Story #5238).
530
+ *
531
+ * One value per blocked verdict, and the reason it exists is downstream: the
532
+ * refusal's friction category is derived from it
533
+ * ({@link module:lib/observability/runtime-friction.lightScopeRejectedCategory}),
534
+ * and the category is the ONLY key the retro composer separates buckets on.
535
+ * Under one bare category an empty-diff refusal and a `public-api` refusal
536
+ * aggregated into a single "recurred 2 times" follow-up with nothing in common
537
+ * (issue #5237) — the roll-up's shape fingerprint could not tell them apart
538
+ * either, because it hashes detail keys and every refusal carries the same set.
539
+ *
540
+ * Kept coarse on purpose: a class must be stable enough that N refusals of one
541
+ * cause still coalesce into the recurrence evidence the ceilings are
542
+ * recalibrated from.
543
+ *
544
+ * @typedef {{ reason: string, refusalClass: string }} Objection
545
+ */
546
+ export const LIGHT_REFUSAL_CLASSES = Object.freeze({
547
+ /** The diff could not be enumerated, or enumerated to nothing. */
548
+ CHANGE_SET_UNKNOWN: 'change-set-unknown',
549
+ /** Enumerated-empty, but the worktree carries uncommitted changes. */
550
+ UNCOMMITTED_WORK: 'uncommitted-work',
551
+ /** The change set intersects a registered sensitive-path class. */
552
+ SENSITIVE_PATH: 'sensitive-path',
553
+ /** Sensitivity could not be classified, so non-sensitivity is unproven. */
554
+ SENSITIVITY_UNKNOWN: 'sensitivity-unknown',
555
+ /** The implementation magnitude could not be measured. */
556
+ MAGNITUDE_UNKNOWN: 'magnitude-unknown',
557
+ /** Measured magnitude exceeded a light ceiling. */
558
+ OVER_CEILING: 'over-ceiling',
559
+ });
560
+
561
+ /**
562
+ * Name the branch a commit-first refusal tells the agent to commit on, with a
563
+ * generic stand-in when the caller supplied none. Pure.
564
+ *
565
+ * @param {unknown} storyBranch
566
+ * @returns {string}
567
+ */
568
+ function describeStoryBranch(storyBranch) {
569
+ const name = typeof storyBranch === 'string' ? storyBranch.trim() : '';
570
+ return name === '' ? 'the Story branch' : name;
571
+ }
572
+
527
573
  /**
528
574
  * Diff-derived backstop (Story #4740 AC-4, re-based on magnitude by Story
529
575
  * #4856): re-check the **actual** change set after implementation, because the
@@ -555,7 +601,14 @@ export function resolveLightGateOutcome({
555
601
  * ceilings?: { maxImplLines?: number, maxImplFiles?: number },
556
602
  * injectedRules?: object,
557
603
  * selectSensitivePathClassesFn?: Function,
558
- * }} [args]
604
+ * storyBranch?: string,
605
+ * uncommittedWork?: boolean,
606
+ * }} [args] `uncommittedWork` is the caller's dirty-worktree probe result: the
607
+ * backstop reads COMMITTED state, so an implemented-but-uncommitted run
608
+ * measures an empty diff, and the door for that is `git commit` — not an
609
+ * escalation. It only ever refines an enumerated-empty verdict's guidance;
610
+ * the verdict itself still blocks. `storyBranch` names the branch that
611
+ * guidance points at.
559
612
  * @returns {{
560
613
  * blocked: boolean,
561
614
  * level: 'low'|'high'|null,
@@ -563,8 +616,10 @@ export function resolveLightGateOutcome({
563
616
  * fileCount: number|null,
564
617
  * magnitude: { implFiles: number, implLines: number }|null,
565
618
  * ceilings: { maxImplLines: number, maxImplFiles: number },
619
+ * refusalClass: string|null,
566
620
  * reasons: string[],
567
- * }}
621
+ * }} `refusalClass` is `null` on a clean verdict and exactly one
622
+ * {@link LIGHT_REFUSAL_CLASSES} value on every blocked one.
568
623
  */
569
624
  export function checkLightDiffBackstop({
570
625
  changedFiles,
@@ -572,6 +627,8 @@ export function checkLightDiffBackstop({
572
627
  ceilings,
573
628
  injectedRules,
574
629
  selectSensitivePathClassesFn,
630
+ storyBranch,
631
+ uncommittedWork = false,
575
632
  } = {}) {
576
633
  const resolved = resolveDiffCeilings(ceilings);
577
634
  const files = Array.isArray(changedFiles)
@@ -579,6 +636,12 @@ export function checkLightDiffBackstop({
579
636
  : null;
580
637
 
581
638
  if (files === null || files.length === 0) {
639
+ // An ENUMERATED-empty diff over a dirty worktree is a different event from
640
+ // an unverifiable one, and blocking is right for both — but only one of
641
+ // them is about scope. The caller's probe distinguishes them; `files ===
642
+ // null` never can, because a `git diff` that failed outright is exactly
643
+ // the case where nothing about the change is known.
644
+ const uncommitted = files !== null && uncommittedWork === true;
582
645
  return {
583
646
  blocked: true,
584
647
  level: null,
@@ -586,8 +649,13 @@ export function checkLightDiffBackstop({
586
649
  fileCount: files === null ? null : 0,
587
650
  magnitude: null,
588
651
  ceilings: resolved,
652
+ refusalClass: uncommitted
653
+ ? LIGHT_REFUSAL_CLASSES.UNCOMMITTED_WORK
654
+ : LIGHT_REFUSAL_CLASSES.CHANGE_SET_UNKNOWN,
589
655
  reasons: [
590
- 'actual change set is unknown or empty — cannot verify the diff is light; escalate to /mandrel-plan',
656
+ uncommitted
657
+ ? `the change set is empty but the worktree has uncommitted changes — commit them on ${describeStoryBranch(storyBranch)}, then re-run the backstop; nothing here is over-scope, so do NOT escalate to /mandrel-plan`
658
+ : 'actual change set is unknown or empty — cannot verify the diff is light; escalate to /mandrel-plan',
591
659
  ],
592
660
  };
593
661
  }
@@ -599,12 +667,12 @@ export function checkLightDiffBackstop({
599
667
  });
600
668
  const measured = normalizeMagnitude(magnitude);
601
669
 
602
- const reasons = [
670
+ const objections = [
603
671
  ...describeSensitivity({ level, classes }),
604
672
  ...describeMagnitude(measured, resolved),
605
673
  ];
606
674
 
607
- const blocked = reasons.length > 0;
675
+ const blocked = objections.length > 0;
608
676
  return {
609
677
  blocked,
610
678
  level,
@@ -612,8 +680,13 @@ export function checkLightDiffBackstop({
612
680
  fileCount: files.length,
613
681
  magnitude: measured,
614
682
  ceilings: resolved,
683
+ // Objection ORDER is the class precedence: sensitivity is derived before
684
+ // magnitude, so a diff that is both sensitive and over-ceiling files as a
685
+ // sensitive-path refusal. That is the right way round — the ceiling is
686
+ // recalibratable, the sensitive path is not.
687
+ refusalClass: blocked ? objections[0].refusalClass : null,
615
688
  reasons: blocked
616
- ? reasons
689
+ ? objections.map((objection) => objection.reason)
617
690
  : [
618
691
  `diff is light: ${measured.implLines} implementation line(s) ≤ ${resolved.maxImplLines} ` +
619
692
  `across ${measured.implFiles} implementation file(s) ≤ ${resolved.maxImplFiles} ` +
@@ -641,17 +714,24 @@ function normalizeMagnitude(magnitude) {
641
714
  * Sensitivity objections, over the **full** change set. Pure.
642
715
  *
643
716
  * @param {{ level: 'low'|'high'|null, classes: string[] }} derived
644
- * @returns {string[]}
717
+ * @returns {Objection[]}
645
718
  */
646
719
  function describeSensitivity({ level, classes }) {
647
720
  if (classes.length > 0) {
648
721
  return [
649
- `diff intersects sensitive-path class(es) ${classes.join(', ')} — escalate to /mandrel-plan (do not land light)`,
722
+ {
723
+ refusalClass: LIGHT_REFUSAL_CLASSES.SENSITIVE_PATH,
724
+ reason: `diff intersects sensitive-path class(es) ${classes.join(', ')} — escalate to /mandrel-plan (do not land light)`,
725
+ },
650
726
  ];
651
727
  }
652
728
  if (level !== 'low') {
653
729
  return [
654
- 'sensitive-path classification unavailable — cannot verify the diff is non-sensitive; escalate to /mandrel-plan',
730
+ {
731
+ refusalClass: LIGHT_REFUSAL_CLASSES.SENSITIVITY_UNKNOWN,
732
+ reason:
733
+ 'sensitive-path classification unavailable — cannot verify the diff is non-sensitive; escalate to /mandrel-plan',
734
+ },
655
735
  ];
656
736
  }
657
737
  return [];
@@ -662,26 +742,32 @@ function describeSensitivity({ level, classes }) {
662
742
  *
663
743
  * @param {{ implFiles: number, implLines: number }|null} measured
664
744
  * @param {{ maxImplLines: number, maxImplFiles: number }} ceilings
665
- * @returns {string[]}
745
+ * @returns {Objection[]}
666
746
  */
667
747
  function describeMagnitude(measured, ceilings) {
668
748
  if (measured === null) {
669
749
  return [
670
- 'change magnitude could not be measured (unreadable or unparseable numstat) — cannot verify the diff is light; escalate to /mandrel-plan',
750
+ {
751
+ refusalClass: LIGHT_REFUSAL_CLASSES.MAGNITUDE_UNKNOWN,
752
+ reason:
753
+ 'change magnitude could not be measured (unreadable or unparseable numstat) — cannot verify the diff is light; escalate to /mandrel-plan',
754
+ },
671
755
  ];
672
756
  }
673
- const reasons = [];
757
+ const objections = [];
674
758
  if (measured.implLines > ceilings.maxImplLines) {
675
- reasons.push(
676
- `diff changes ${measured.implLines} implementation line(s) (> maxImplLines ${ceilings.maxImplLines}) — escalate to /mandrel-plan (do not land light)`,
677
- );
759
+ objections.push({
760
+ refusalClass: LIGHT_REFUSAL_CLASSES.OVER_CEILING,
761
+ reason: `diff changes ${measured.implLines} implementation line(s) (> maxImplLines ${ceilings.maxImplLines}) — escalate to /mandrel-plan (do not land light)`,
762
+ });
678
763
  }
679
764
  if (measured.implFiles > ceilings.maxImplFiles) {
680
- reasons.push(
681
- `diff spans ${measured.implFiles} implementation file(s) (> maxImplFiles ${ceilings.maxImplFiles}) — escalate to /mandrel-plan (do not land light)`,
682
- );
765
+ objections.push({
766
+ refusalClass: LIGHT_REFUSAL_CLASSES.OVER_CEILING,
767
+ reason: `diff spans ${measured.implFiles} implementation file(s) (> maxImplFiles ${ceilings.maxImplFiles}) — escalate to /mandrel-plan (do not land light)`,
768
+ });
683
769
  }
684
- return reasons;
770
+ return objections;
685
771
  }
686
772
 
687
773
  /** Cap on a receipt slug's length — keep the branch/id readable. */
@@ -305,6 +305,39 @@ function fingerprintBucket(category, tools, detailKeys) {
305
305
  .slice(0, 8);
306
306
  }
307
307
 
308
+ /**
309
+ * Every reason text one signal's `details` carries, read from BOTH shapes the
310
+ * emitters actually write.
311
+ *
312
+ * Two keys because two emitter conventions, and the divergence was silent: the
313
+ * degradation emitters write a singular `details.reason` string, while the
314
+ * light path's refusal emitter (`light-escalation.recordScopeFriction`) writes
315
+ * `details.reasons` — an **array**, because one backstop verdict can object on
316
+ * sensitivity and magnitude in the same pass. Reading only the singular key is
317
+ * why every `light-scope-rejected` follow-up rendered with no `Reason:` line
318
+ * at all (issue #5237), which defeated Story #4837's whole intent for that
319
+ * emitter: the filed issue named a count and a category, and the refusal text
320
+ * that would have told a reader which ceiling fired stayed in the ledger.
321
+ *
322
+ * Non-string members are skipped rather than coerced — `String(value)` would
323
+ * put `[object Object]` in a live issue body.
324
+ *
325
+ * @param {object} details
326
+ * @returns {string[]}
327
+ */
328
+ function collectReasons(details) {
329
+ const out = [];
330
+ const single = asString(details.reason);
331
+ if (single.length > 0) out.push(single);
332
+ if (Array.isArray(details.reasons)) {
333
+ for (const raw of details.reasons) {
334
+ const text = asString(raw);
335
+ if (text.length > 0) out.push(text);
336
+ }
337
+ }
338
+ return out;
339
+ }
340
+
308
341
  /**
309
342
  * Widen a bucket's first-to-last window to include one instant. A row with no
310
343
  * usable `ts` widens nothing — it is counted in `total` but cannot date the
@@ -391,8 +424,9 @@ function aggregateByCategory(signals) {
391
424
  // over keys, deliberately) is untouched by them.
392
425
  const surface = asString(sig.details.surface);
393
426
  if (surface.length > 0) entry.surfaces.add(surface);
394
- const reason = asString(sig.details.reason);
395
- if (reason.length > 0) entry.reasons.add(reason);
427
+ for (const reason of collectReasons(sig.details)) {
428
+ entry.reasons.add(reason);
429
+ }
396
430
  }
397
431
  // An id that cannot be resolved to a real issue is tracked separately
398
432
  // rather than dropped: it must never be counted or printed as recurrence