mandrel 2.51.0 → 2.52.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.
@@ -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
 
@@ -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
@@ -0,0 +1,80 @@
1
+ /**
2
+ * lib/orchestration/worktree-dirty.js — "does this branch's checkout carry
3
+ * uncommitted changes?" (Story #5238).
4
+ *
5
+ * Its own module because the light path's diff backstop is otherwise a pure
6
+ * join over two committed-state git reads, and this is the one question there
7
+ * that needs a third read of a *working tree*. Keeping it here leaves
8
+ * {@link module:lib/orchestration/light-backstop} the thin join it claims to
9
+ * be, and gives the probe its own place to be tested against every way a git
10
+ * read can fail.
11
+ *
12
+ * @module lib/orchestration/worktree-dirty
13
+ */
14
+
15
+ import { gitSpawn } from '../git-utils.js';
16
+ import { parseWorktreePorcelain } from '../worktree/inspector.js';
17
+
18
+ /**
19
+ * The stdout of a successful git read, or `null` when it cannot be trusted.
20
+ *
21
+ * @param {{ status?: number, stdout?: unknown }|null|undefined} result
22
+ * @returns {string|null}
23
+ */
24
+ function readableStdout(result) {
25
+ if (result?.status !== 0) return null;
26
+ return typeof result.stdout === 'string' ? result.stdout : null;
27
+ }
28
+
29
+ /**
30
+ * Locate the checkout that has `branch` checked out, or `null` when no
31
+ * checkout does.
32
+ *
33
+ * `git worktree list --porcelain` enumerates **every** checkout including the
34
+ * main one, so a repository working on the branch directly (no separate
35
+ * worktree) is found by this same lookup rather than by a second fallback path.
36
+ *
37
+ * @param {{ branch: string, cwd: string, gitFn: typeof gitSpawn }} args
38
+ * @returns {string|null}
39
+ */
40
+ function resolveBranchCheckout({ branch, cwd, gitFn }) {
41
+ const listed = readableStdout(gitFn(cwd, 'worktree', 'list', '--porcelain'));
42
+ if (listed === null) return null;
43
+ const match = parseWorktreePorcelain(listed).find(
44
+ (record) => record.branch === branch,
45
+ );
46
+ return match ? match.path : null;
47
+ }
48
+
49
+ /**
50
+ * Does the checkout holding `branch` have uncommitted changes?
51
+ *
52
+ * **Why the light path asks.** Its diff backstop measures `base...head`, which
53
+ * is committed state, so a run that implemented and did not commit measures an
54
+ * empty change set — and the refusal then told the agent its scope was
55
+ * unverifiable and to escalate, when the actual fix was `git commit`. Measured
56
+ * in the consumer: the refusal signal is stamped 12:14:06Z and the branch's
57
+ * only commit 12:15:19Z (issue #5237).
58
+ *
59
+ * Total, and deliberately asymmetric: every unreadable surface — a failed
60
+ * `worktree list`, a branch no checkout holds, a failed `status`, a throwing
61
+ * git — answers `false`. A probe that cannot see the tree must not be able to
62
+ * talk a refusal into friendlier guidance than the evidence supports.
63
+ *
64
+ * @param {{ branch: string, cwd?: string, gitFn?: typeof gitSpawn }} args
65
+ * @returns {boolean}
66
+ */
67
+ export function hasUncommittedWork({
68
+ branch,
69
+ cwd = process.cwd(),
70
+ gitFn = gitSpawn,
71
+ } = {}) {
72
+ try {
73
+ const checkout = resolveBranchCheckout({ branch, cwd, gitFn });
74
+ if (checkout === null) return false;
75
+ const status = readableStdout(gitFn(checkout, 'status', '--porcelain'));
76
+ return status !== null && status.trim() !== '';
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
@@ -153,13 +153,19 @@ answer).
153
153
  number that then rejects the change. They are **not** exempt from
154
154
  sensitive-path matching, which runs over the full change set.
155
155
 
156
- Exit `3` (`blocked: true`) means the diff exceeds a light ceiling or touches a
157
- sensitive-path class. STOP, flip `agent::blocked`, and **recycle the receipt**
158
- through the envelope's `nextCommand` (`/mandrel-plan <storyId>`) — tickets mode
159
- rewrites it into properly-planned Stories and closes it as superseded. Do not
160
- land, and do not leave the receipt open with no successor: it already carries
161
- the branch, the worktree, and the implementation, all of which are evidence
162
- the plan should read.
156
+ **Commit before you run it.** The backstop measures **committed** state, so
157
+ a run that implemented but has not committed measures an empty diff and is
158
+ refused for a scope it never had. That refusal names the real fix — commit
159
+ on `story-<id>`, then re-run the backstop — and its `nextCommand` is that
160
+ re-run, not an escalation.
161
+
162
+ Exit `3` (`blocked: true`) otherwise means the diff exceeds a light ceiling or
163
+ touches a sensitive-path class. STOP, flip `agent::blocked`, and **recycle the
164
+ receipt** through the envelope's `nextCommand` (`/mandrel-plan <storyId>`) —
165
+ tickets mode rewrites it into properly-planned Stories and closes it as
166
+ superseded. Do not land, and do not leave the receipt open with no successor:
167
+ it already carries the branch, the worktree, and the implementation, all of
168
+ which are evidence the plan should read.
163
169
 
164
170
  5. **Close and land (same engine).** Exactly [`/mandrel-deliver`](../mandrel-deliver.md)'s close:
165
171
 
package/docs/CHANGELOG.md CHANGED
@@ -15,6 +15,13 @@ All notable changes to this project will be documented in this file.
15
15
  -->
16
16
  <!-- markdownlint-disable-file MD004 MD012 MD037 -->
17
17
 
18
+ ## [2.52.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.51.0...mandrel-v2.52.0) (2026-09-08)
19
+
20
+
21
+ ### Fixed
22
+
23
+ * render deliver-light refusal reasons in retro follow-ups and file each backstop refusal class as its own friction category ([#5238](https://github.com/dsj1984/mandrel/issues/5238)) ([#5239](https://github.com/dsj1984/mandrel/issues/5239)) ([65ff0ff](https://github.com/dsj1984/mandrel/commit/65ff0ff4252ddc4fe34d4d8453159a80afae6223))
24
+
18
25
  ## [2.51.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.50.0...mandrel-v2.51.0) (2026-09-08)
19
26
 
20
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.51.0",
3
+ "version": "2.52.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, skills, rules, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",