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.
- package/.agents/scripts/lib/observability/runtime-friction.js +34 -0
- package/.agents/scripts/lib/orchestration/light-backstop.js +62 -6
- package/.agents/scripts/lib/orchestration/light-escalation.js +29 -3
- package/.agents/scripts/lib/orchestration/light-suitability.js +105 -19
- package/.agents/scripts/lib/orchestration/retro-proposals.js +36 -2
- package/.agents/scripts/lib/orchestration/worktree-dirty.js +80 -0
- package/.agents/workflows/helpers/deliver-light.md +13 -7
- package/docs/CHANGELOG.md +7 -0
- package/package.json +1 -1
|
@@ -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 {
|
|
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
|
|
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 =
|
|
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('; ')} —
|
|
122
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
670
|
+
const objections = [
|
|
603
671
|
...describeSensitivity({ level, classes }),
|
|
604
672
|
...describeMagnitude(measured, resolved),
|
|
605
673
|
];
|
|
606
674
|
|
|
607
|
-
const blocked =
|
|
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
|
-
?
|
|
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 {
|
|
717
|
+
* @returns {Objection[]}
|
|
645
718
|
*/
|
|
646
719
|
function describeSensitivity({ level, classes }) {
|
|
647
720
|
if (classes.length > 0) {
|
|
648
721
|
return [
|
|
649
|
-
|
|
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
|
-
|
|
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 {
|
|
745
|
+
* @returns {Objection[]}
|
|
666
746
|
*/
|
|
667
747
|
function describeMagnitude(measured, ceilings) {
|
|
668
748
|
if (measured === null) {
|
|
669
749
|
return [
|
|
670
|
-
|
|
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
|
|
757
|
+
const objections = [];
|
|
674
758
|
if (measured.implLines > ceilings.maxImplLines) {
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
681
|
-
|
|
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
|
|
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
|
|
395
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
the
|
|
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