mandrel 2.23.0 → 2.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
- package/.agents/scripts/deliver-light.js +23 -45
- package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +10 -25
- package/.agents/scripts/lib/baselines/kinds/maintainability.js +20 -32
- package/.agents/scripts/lib/escomplex-ast-compat.js +360 -0
- package/.agents/scripts/lib/maintainability-engine.js +83 -11
- package/.agents/scripts/lib/maintainability-unscorable.js +60 -0
- package/.agents/scripts/lib/maintainability-utils.js +14 -5
- package/.agents/scripts/lib/observability/runtime-friction.js +8 -0
- package/.agents/scripts/lib/orchestration/diff-magnitude.js +283 -0
- package/.agents/scripts/lib/orchestration/light-backstop.js +107 -0
- package/.agents/scripts/lib/orchestration/light-escalation.js +169 -0
- package/.agents/scripts/lib/orchestration/light-suitability.js +151 -46
- package/.agents/scripts/lib/orchestration/plan-context.js +12 -13
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +70 -2
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +23 -5
- package/.agents/scripts/lib/workers/maintainability-worker.js +14 -9
- package/.agents/workflows/helpers/deliver-light.md +21 -4
- package/docs/CHANGELOG.md +9 -0
- package/package.json +1 -1
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/light-escalation.js — what the light path does when it
|
|
3
|
+
* refuses a scope (Story #4856).
|
|
4
|
+
*
|
|
5
|
+
* Two behaviors, both previously missing, and both about a refusal rather than
|
|
6
|
+
* a verdict — the verdicts live in
|
|
7
|
+
* {@link module:lib/orchestration/light-suitability}:
|
|
8
|
+
*
|
|
9
|
+
* 1. **Recycling the receipt.** A blocked diff backstop used to tell the
|
|
10
|
+
* operator to "escalate to `/plan`", which authored a brand-new Story and
|
|
11
|
+
* left the receipt open with no successor — orphaning its branch, its
|
|
12
|
+
* worktree, and a finished implementation. Naming the receipt as `/plan`'s
|
|
13
|
+
* *input* recycles it instead: tickets mode already fetches a ticket,
|
|
14
|
+
* rewrites it into properly-planned Stories, and closes the source as
|
|
15
|
+
* superseded.
|
|
16
|
+
*
|
|
17
|
+
* Deferring receipt creation until after the backstop would be the other
|
|
18
|
+
* fix, and is deliberately not taken: the issue id is load-bearing in
|
|
19
|
+
* `single-story-init.js` (the assignee lease, the `story-<id>` branch, the
|
|
20
|
+
* label state machine) and in the `(refs #<id>)` commit subject.
|
|
21
|
+
*
|
|
22
|
+
* 2. **Telemetering the refusal.** Neither light-path rejection emitted any
|
|
23
|
+
* signal, so an over-tight ceiling could only reach the framework as
|
|
24
|
+
* anecdote — which is exactly how the `maxFiles: 4` defect surfaced. The
|
|
25
|
+
* roll-up aggregates by category, so recording these makes the ceilings
|
|
26
|
+
* recalibratable from evidence.
|
|
27
|
+
*
|
|
28
|
+
* Telemetry is best-effort by construction: a signals-write failure must never
|
|
29
|
+
* change a gate's verdict.
|
|
30
|
+
*
|
|
31
|
+
* @module lib/orchestration/light-escalation
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
emitRuntimeFriction,
|
|
36
|
+
RUNTIME_FRICTION_CATEGORIES,
|
|
37
|
+
} from '../observability/runtime-friction.js';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The `/plan` invocation that owns a Story the light path could not land.
|
|
41
|
+
*
|
|
42
|
+
* @param {number} storyId
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
function buildRecycleCommand(storyId) {
|
|
46
|
+
return `/plan ${storyId}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Coerce an `--amends` argument (`#123` or `123`) into a positive integer issue
|
|
51
|
+
* number, or `null` when absent/malformed.
|
|
52
|
+
*
|
|
53
|
+
* This is the only Story context a **gate-stage** rejection can legitimately
|
|
54
|
+
* claim: the signals stream is keyed on a Story id, and an `ask-operator` gate
|
|
55
|
+
* has authored no receipt yet — deliberately, since not creating one is the
|
|
56
|
+
* point of that outcome. A bare prompt's rejection therefore has no stream to
|
|
57
|
+
* land in, and attributing it to a fabricated id would be worse than recording
|
|
58
|
+
* nothing.
|
|
59
|
+
*
|
|
60
|
+
* @param {unknown} amends
|
|
61
|
+
* @returns {number|null}
|
|
62
|
+
*/
|
|
63
|
+
function normalizeAmendsId(amends) {
|
|
64
|
+
const match = /^#?(\d+)$/.exec(String(amends ?? '').trim());
|
|
65
|
+
if (!match) return null;
|
|
66
|
+
const n = Number.parseInt(match[1], 10);
|
|
67
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Record a suitability-gate refusal (`ask-operator`) as friction, attributed to
|
|
72
|
+
* the `--amends` target when there is one.
|
|
73
|
+
*
|
|
74
|
+
* @param {{
|
|
75
|
+
* gate: object,
|
|
76
|
+
* amends?: unknown,
|
|
77
|
+
* recordFrictionFn?: typeof recordScopeFriction,
|
|
78
|
+
* }} args
|
|
79
|
+
* @returns {Promise<boolean>}
|
|
80
|
+
*/
|
|
81
|
+
export async function recordGateRefusal({
|
|
82
|
+
gate,
|
|
83
|
+
amends,
|
|
84
|
+
emitFn,
|
|
85
|
+
recordFrictionFn = recordScopeFriction,
|
|
86
|
+
} = {}) {
|
|
87
|
+
return recordFrictionFn({
|
|
88
|
+
emitFn,
|
|
89
|
+
storyId: normalizeAmendsId(amends),
|
|
90
|
+
surface: 'suitability-gate',
|
|
91
|
+
reasons: gate?.outcome?.reasons ?? [],
|
|
92
|
+
details: {
|
|
93
|
+
action: gate?.action ?? null,
|
|
94
|
+
code: gate?.suitability?.shape?.code ?? null,
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Handle a blocked diff backstop: record the refusal as friction and return the
|
|
101
|
+
* `/plan` invocation that recycles the receipt.
|
|
102
|
+
*
|
|
103
|
+
* Lives here rather than in the CLI so the shell stays a shell — the backstop
|
|
104
|
+
* mode's job is to branch and print, not to decide what a refusal means.
|
|
105
|
+
*
|
|
106
|
+
* @param {{
|
|
107
|
+
* storyId: number,
|
|
108
|
+
* result: object,
|
|
109
|
+
* recordFrictionFn?: typeof recordScopeFriction,
|
|
110
|
+
* }} args `result` is a {@link module:lib/orchestration/light-suitability.checkLightDiffBackstop}
|
|
111
|
+
* verdict.
|
|
112
|
+
* @returns {Promise<string>} The recycle command.
|
|
113
|
+
*/
|
|
114
|
+
export async function handleBlockedBackstop({
|
|
115
|
+
storyId,
|
|
116
|
+
result,
|
|
117
|
+
emitFn,
|
|
118
|
+
recordFrictionFn = recordScopeFriction,
|
|
119
|
+
} = {}) {
|
|
120
|
+
await recordFrictionFn({
|
|
121
|
+
emitFn,
|
|
122
|
+
storyId,
|
|
123
|
+
surface: 'diff-backstop',
|
|
124
|
+
reasons: result?.reasons ?? [],
|
|
125
|
+
details: {
|
|
126
|
+
fileCount: result?.fileCount ?? null,
|
|
127
|
+
implFiles: result?.magnitude?.implFiles ?? null,
|
|
128
|
+
implLines: result?.magnitude?.implLines ?? null,
|
|
129
|
+
ceilings: result?.ceilings ?? null,
|
|
130
|
+
classes: result?.classes ?? [],
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
return buildRecycleCommand(storyId);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Record a light-path scope rejection as friction.
|
|
138
|
+
*
|
|
139
|
+
* Total: never throws, and returns `false` rather than propagating when the
|
|
140
|
+
* signals surface is unavailable.
|
|
141
|
+
*
|
|
142
|
+
* @param {{
|
|
143
|
+
* storyId?: number|null,
|
|
144
|
+
* surface: string,
|
|
145
|
+
* reasons?: string[],
|
|
146
|
+
* details?: object,
|
|
147
|
+
* emitFn?: typeof emitRuntimeFriction,
|
|
148
|
+
* }} args
|
|
149
|
+
* @returns {Promise<boolean>}
|
|
150
|
+
*/
|
|
151
|
+
async function recordScopeFriction({
|
|
152
|
+
storyId,
|
|
153
|
+
surface,
|
|
154
|
+
reasons = [],
|
|
155
|
+
details = {},
|
|
156
|
+
emitFn,
|
|
157
|
+
} = {}) {
|
|
158
|
+
const emit = emitFn ?? emitRuntimeFriction;
|
|
159
|
+
try {
|
|
160
|
+
return await emit({
|
|
161
|
+
storyId,
|
|
162
|
+
category: RUNTIME_FRICTION_CATEGORIES.LIGHT_SCOPE_REJECTED,
|
|
163
|
+
tool: 'deliver-light',
|
|
164
|
+
details: { surface, reasons, ...details },
|
|
165
|
+
});
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -36,9 +36,13 @@
|
|
|
36
36
|
* Under `--yes` (unattended) it fails closed to recommending `/plan`.
|
|
37
37
|
* 3. **Diff-derived backstop ({@link checkLightDiffBackstop}).** After
|
|
38
38
|
* implementation the **actual** change set is re-checked with
|
|
39
|
-
* {@link module:lib/orchestration/review-depth.deriveChangeLevel} plus
|
|
40
|
-
*
|
|
41
|
-
* over-ceiling diff is blocked
|
|
39
|
+
* {@link module:lib/orchestration/review-depth.deriveChangeLevel} plus the
|
|
40
|
+
* implementation-only magnitude ceilings of {@link LIGHT_DIFF_CEILINGS} —
|
|
41
|
+
* the diff is the real scope signal — and an over-ceiling diff is blocked
|
|
42
|
+
* rather than landed silently. Story #4856 moved this from a `maxFiles: 4`
|
|
43
|
+
* cardinality ceiling to changed lines over implementation files, and made
|
|
44
|
+
* a block **recycle** its receipt Story through `/plan` tickets mode
|
|
45
|
+
* instead of orphaning it.
|
|
42
46
|
* 4. **Minimal receipt Story ({@link buildReceiptStoryTicket}).** A
|
|
43
47
|
* `type::story` ticket is authored inline so `refs #`, history, telemetry,
|
|
44
48
|
* and the `agent::executing -> agent::done` state machine survive.
|
|
@@ -79,35 +83,69 @@ export const OVERRIDABLE_SHAPE_CODES = Object.freeze([
|
|
|
79
83
|
]);
|
|
80
84
|
|
|
81
85
|
/**
|
|
82
|
-
*
|
|
83
|
-
* ({@link checkLightDiffBackstop}) enforces
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
86
|
+
* Ceilings for the **actual landed** change set the diff backstop
|
|
87
|
+
* ({@link checkLightDiffBackstop}) enforces, measured on the change's
|
|
88
|
+
* implementation half (Story #4856 — see
|
|
89
|
+
* {@link module:lib/orchestration/diff-magnitude} for the measured case and the
|
|
90
|
+
* companion-class boundary).
|
|
91
|
+
*
|
|
92
|
+
* The backstop reads ground truth, so it is where size is genuinely enforced;
|
|
93
|
+
* the prediction gate above it is a declaration and stays coarse (Story #4764).
|
|
94
|
+
* What changed is the **axis**: this used to be `maxFiles: 4`, a cardinality
|
|
95
|
+
* ceiling that rejected 79% of this repository's real merged work while passing
|
|
96
|
+
* a three-file 323-line rewrite.
|
|
97
|
+
*
|
|
98
|
+
* - `maxImplLines` — additions plus deletions across implementation files.
|
|
99
|
+
* Simulated over 41 merges, 1000 admits 83% of real work
|
|
100
|
+
* and rejects exactly the genuinely large changes.
|
|
101
|
+
* - `maxImplFiles` — implementation files touched, a *sprawl* tripwire rather
|
|
102
|
+
* than a size gate. Set to `DEFAULT_DIFF_WIDTH.softFiles`
|
|
103
|
+
* so the light path and `review-depth.js` stop holding two
|
|
104
|
+
* different definitions of a narrow diff.
|
|
105
|
+
*
|
|
106
|
+
* Framework constants, not knobs: a ceiling an operator could widen past what a
|
|
107
|
+
* single session safely absorbs is a ceiling that fails silently.
|
|
90
108
|
*/
|
|
91
109
|
export const LIGHT_DIFF_CEILINGS = Object.freeze({
|
|
92
|
-
|
|
110
|
+
maxImplLines: 1000,
|
|
111
|
+
maxImplFiles: 15,
|
|
93
112
|
});
|
|
94
113
|
|
|
95
114
|
/**
|
|
96
|
-
* Coerce a candidate
|
|
97
|
-
*
|
|
98
|
-
*
|
|
115
|
+
* Coerce a candidate ceiling into a positive integer, falling back to the
|
|
116
|
+
* framework default for anything malformed — a stray `0`, `-1`, or `NaN` must
|
|
117
|
+
* never widen (or zero out) a light diff ceiling.
|
|
99
118
|
*
|
|
100
119
|
* @param {unknown} value
|
|
101
120
|
* @param {number} fallback
|
|
102
121
|
* @returns {number}
|
|
103
122
|
*/
|
|
104
|
-
function
|
|
123
|
+
function normalizeCeiling(value, fallback) {
|
|
105
124
|
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1) {
|
|
106
125
|
return fallback;
|
|
107
126
|
}
|
|
108
127
|
return Math.floor(value);
|
|
109
128
|
}
|
|
110
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Resolve the effective diff ceilings from a caller-supplied partial override.
|
|
132
|
+
*
|
|
133
|
+
* @param {{ maxImplLines?: unknown, maxImplFiles?: unknown }} [ceilings]
|
|
134
|
+
* @returns {{ maxImplLines: number, maxImplFiles: number }}
|
|
135
|
+
*/
|
|
136
|
+
function resolveDiffCeilings(ceilings) {
|
|
137
|
+
return {
|
|
138
|
+
maxImplLines: normalizeCeiling(
|
|
139
|
+
ceilings?.maxImplLines,
|
|
140
|
+
LIGHT_DIFF_CEILINGS.maxImplLines,
|
|
141
|
+
),
|
|
142
|
+
maxImplFiles: normalizeCeiling(
|
|
143
|
+
ceilings?.maxImplFiles,
|
|
144
|
+
LIGHT_DIFF_CEILINGS.maxImplFiles,
|
|
145
|
+
),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
111
149
|
/**
|
|
112
150
|
* Resolve the model's trivial-vs-standard verdict, held to the same ledgering
|
|
113
151
|
* contract the planner's authored verdict is
|
|
@@ -389,11 +427,23 @@ export function resolveLightGateOutcome({
|
|
|
389
427
|
}
|
|
390
428
|
|
|
391
429
|
/**
|
|
392
|
-
* Diff-derived backstop (Story #4740 AC-4
|
|
393
|
-
*
|
|
394
|
-
* signal. Blocks (rather than landing)
|
|
395
|
-
*
|
|
396
|
-
* result is the only path that lands
|
|
430
|
+
* Diff-derived backstop (Story #4740 AC-4, re-based on magnitude by Story
|
|
431
|
+
* #4856): re-check the **actual** change set after implementation, because the
|
|
432
|
+
* diff — not the prompt — is the real scope signal. Blocks (rather than landing)
|
|
433
|
+
* when the diff intersects a sensitive-path class, exceeds an implementation
|
|
434
|
+
* ceiling, or cannot be measured. A clean result is the only path that lands
|
|
435
|
+
* light.
|
|
436
|
+
*
|
|
437
|
+
* Two inputs, two different scopes, and the difference is load-bearing:
|
|
438
|
+
*
|
|
439
|
+
* - `changedFiles` is the **full** change set, companions included, and is
|
|
440
|
+
* what sensitive-path derivation reads. Exempting a companion from the
|
|
441
|
+
* *count* must never exempt it from *risk* — a test file under a registered
|
|
442
|
+
* sensitive class still blocks.
|
|
443
|
+
* - `magnitude` is the implementation-only summary from
|
|
444
|
+
* {@link module:lib/orchestration/diff-magnitude.summarizeDiffMagnitude}.
|
|
445
|
+
* `null` means the magnitude could not be measured, which blocks: absence
|
|
446
|
+
* of evidence is not evidence the diff is small.
|
|
397
447
|
*
|
|
398
448
|
* Reuses close's own {@link module:lib/orchestration/review-depth.deriveChangeLevel}
|
|
399
449
|
* — one taxonomy, applied to the predicted shape at the gate and the actual
|
|
@@ -403,7 +453,8 @@ export function resolveLightGateOutcome({
|
|
|
403
453
|
*
|
|
404
454
|
* @param {{
|
|
405
455
|
* changedFiles?: unknown,
|
|
406
|
-
*
|
|
456
|
+
* magnitude?: { implFiles?: number, implLines?: number }|null,
|
|
457
|
+
* ceilings?: { maxImplLines?: number, maxImplFiles?: number },
|
|
407
458
|
* injectedRules?: object,
|
|
408
459
|
* selectSensitivePathClassesFn?: Function,
|
|
409
460
|
* }} [args]
|
|
@@ -412,20 +463,19 @@ export function resolveLightGateOutcome({
|
|
|
412
463
|
* level: 'low'|'high'|null,
|
|
413
464
|
* classes: string[],
|
|
414
465
|
* fileCount: number|null,
|
|
415
|
-
*
|
|
466
|
+
* magnitude: { implFiles: number, implLines: number }|null,
|
|
467
|
+
* ceilings: { maxImplLines: number, maxImplFiles: number },
|
|
416
468
|
* reasons: string[],
|
|
417
469
|
* }}
|
|
418
470
|
*/
|
|
419
471
|
export function checkLightDiffBackstop({
|
|
420
472
|
changedFiles,
|
|
473
|
+
magnitude,
|
|
421
474
|
ceilings,
|
|
422
475
|
injectedRules,
|
|
423
476
|
selectSensitivePathClassesFn,
|
|
424
477
|
} = {}) {
|
|
425
|
-
const
|
|
426
|
-
ceilings?.maxFiles,
|
|
427
|
-
LIGHT_DIFF_CEILINGS.maxFiles,
|
|
428
|
-
);
|
|
478
|
+
const resolved = resolveDiffCeilings(ceilings);
|
|
429
479
|
const files = Array.isArray(changedFiles)
|
|
430
480
|
? changedFiles.filter((f) => typeof f === 'string' && f.trim() !== '')
|
|
431
481
|
: null;
|
|
@@ -436,7 +486,8 @@ export function checkLightDiffBackstop({
|
|
|
436
486
|
level: null,
|
|
437
487
|
classes: [],
|
|
438
488
|
fileCount: files === null ? null : 0,
|
|
439
|
-
|
|
489
|
+
magnitude: null,
|
|
490
|
+
ceilings: resolved,
|
|
440
491
|
reasons: [
|
|
441
492
|
'actual change set is unknown or empty — cannot verify the diff is light; escalate to /plan',
|
|
442
493
|
],
|
|
@@ -448,23 +499,12 @@ export function checkLightDiffBackstop({
|
|
|
448
499
|
injectedRules,
|
|
449
500
|
selectSensitivePathClassesFn,
|
|
450
501
|
});
|
|
502
|
+
const measured = normalizeMagnitude(magnitude);
|
|
451
503
|
|
|
452
|
-
const reasons = [
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
);
|
|
457
|
-
}
|
|
458
|
-
if (files.length > maxFiles) {
|
|
459
|
-
reasons.push(
|
|
460
|
-
`diff touches ${files.length} file(s) (> maxFiles ${maxFiles}) — escalate to /plan (do not land light)`,
|
|
461
|
-
);
|
|
462
|
-
}
|
|
463
|
-
if (level !== 'low' && classes.length === 0) {
|
|
464
|
-
reasons.push(
|
|
465
|
-
'sensitive-path classification unavailable — cannot verify the diff is non-sensitive; escalate to /plan',
|
|
466
|
-
);
|
|
467
|
-
}
|
|
504
|
+
const reasons = [
|
|
505
|
+
...describeSensitivity({ level, classes }),
|
|
506
|
+
...describeMagnitude(measured, resolved),
|
|
507
|
+
];
|
|
468
508
|
|
|
469
509
|
const blocked = reasons.length > 0;
|
|
470
510
|
return {
|
|
@@ -472,15 +512,80 @@ export function checkLightDiffBackstop({
|
|
|
472
512
|
level,
|
|
473
513
|
classes,
|
|
474
514
|
fileCount: files.length,
|
|
475
|
-
|
|
515
|
+
magnitude: measured,
|
|
516
|
+
ceilings: resolved,
|
|
476
517
|
reasons: blocked
|
|
477
518
|
? reasons
|
|
478
519
|
: [
|
|
479
|
-
`diff is light: ${
|
|
520
|
+
`diff is light: ${measured.implLines} implementation line(s) ≤ ${resolved.maxImplLines} ` +
|
|
521
|
+
`across ${measured.implFiles} implementation file(s) ≤ ${resolved.maxImplFiles} ` +
|
|
522
|
+
`(${files.length} file(s) total, companions exempt), no sensitive-path class — safe to land`,
|
|
480
523
|
],
|
|
481
524
|
};
|
|
482
525
|
}
|
|
483
526
|
|
|
527
|
+
/**
|
|
528
|
+
* Coerce a magnitude summary into non-negative integer counts, or `null` when
|
|
529
|
+
* it was not measurable. Pure.
|
|
530
|
+
*
|
|
531
|
+
* @param {unknown} magnitude
|
|
532
|
+
* @returns {{ implFiles: number, implLines: number }|null}
|
|
533
|
+
*/
|
|
534
|
+
function normalizeMagnitude(magnitude) {
|
|
535
|
+
const implFiles = magnitude?.implFiles;
|
|
536
|
+
const implLines = magnitude?.implLines;
|
|
537
|
+
if (!Number.isFinite(implFiles) || !Number.isFinite(implLines)) return null;
|
|
538
|
+
if (implFiles < 0 || implLines < 0) return null;
|
|
539
|
+
return { implFiles: Math.floor(implFiles), implLines: Math.floor(implLines) };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Sensitivity objections, over the **full** change set. Pure.
|
|
544
|
+
*
|
|
545
|
+
* @param {{ level: 'low'|'high'|null, classes: string[] }} derived
|
|
546
|
+
* @returns {string[]}
|
|
547
|
+
*/
|
|
548
|
+
function describeSensitivity({ level, classes }) {
|
|
549
|
+
if (classes.length > 0) {
|
|
550
|
+
return [
|
|
551
|
+
`diff intersects sensitive-path class(es) ${classes.join(', ')} — escalate to /plan (do not land light)`,
|
|
552
|
+
];
|
|
553
|
+
}
|
|
554
|
+
if (level !== 'low') {
|
|
555
|
+
return [
|
|
556
|
+
'sensitive-path classification unavailable — cannot verify the diff is non-sensitive; escalate to /plan',
|
|
557
|
+
];
|
|
558
|
+
}
|
|
559
|
+
return [];
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Magnitude objections, over the implementation half only. Pure.
|
|
564
|
+
*
|
|
565
|
+
* @param {{ implFiles: number, implLines: number }|null} measured
|
|
566
|
+
* @param {{ maxImplLines: number, maxImplFiles: number }} ceilings
|
|
567
|
+
* @returns {string[]}
|
|
568
|
+
*/
|
|
569
|
+
function describeMagnitude(measured, ceilings) {
|
|
570
|
+
if (measured === null) {
|
|
571
|
+
return [
|
|
572
|
+
'change magnitude could not be measured (unreadable or unparseable numstat) — cannot verify the diff is light; escalate to /plan',
|
|
573
|
+
];
|
|
574
|
+
}
|
|
575
|
+
const reasons = [];
|
|
576
|
+
if (measured.implLines > ceilings.maxImplLines) {
|
|
577
|
+
reasons.push(
|
|
578
|
+
`diff changes ${measured.implLines} implementation line(s) (> maxImplLines ${ceilings.maxImplLines}) — escalate to /plan (do not land light)`,
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
if (measured.implFiles > ceilings.maxImplFiles) {
|
|
582
|
+
reasons.push(
|
|
583
|
+
`diff spans ${measured.implFiles} implementation file(s) (> maxImplFiles ${ceilings.maxImplFiles}) — escalate to /plan (do not land light)`,
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
return reasons;
|
|
587
|
+
}
|
|
588
|
+
|
|
484
589
|
/** Cap on a receipt slug's length — keep the branch/id readable. */
|
|
485
590
|
const RECEIPT_SLUG_MAX = 48;
|
|
486
591
|
|
|
@@ -379,14 +379,21 @@ function resolveRiskHeuristics(config = {}) {
|
|
|
379
379
|
* this one screens a seed, that one decides. Collapsing them would make a
|
|
380
380
|
* confirm a bypass.
|
|
381
381
|
*
|
|
382
|
-
*
|
|
382
|
+
* **Risk only, never cardinality (Story #4856).** This carried a
|
|
383
|
+
* `maxArtifacts: 2` ceiling — the second surviving artifact count after Story
|
|
384
|
+
* #4764 retired the axis from the routing gate, and the more misleading of the
|
|
385
|
+
* two, because the artifacts it counted were **paths scraped from seed prose**
|
|
386
|
+
* rather than a measured footprint. Observed on the seed that produced Story
|
|
387
|
+
* #4856: a change spanning four framework modules was suggested as light off
|
|
388
|
+
* two scraped paths, one of which did not exist at the scraped location. A count
|
|
389
|
+
* of guesses is not a size signal, so the screen now keys on risk alone.
|
|
390
|
+
*
|
|
383
391
|
* - `maxRiskHeuristicHits` — any risk-heuristic hit disqualifies: risk
|
|
384
392
|
* is exactly what a light path should not carry.
|
|
385
393
|
* - `maxSensitivePathClasses`— any sensitive-path class disqualifies, the
|
|
386
394
|
* same taxonomy close applies to a landed diff.
|
|
387
395
|
*/
|
|
388
396
|
const DELIVER_LIGHT_SUGGESTION_CEILINGS = Object.freeze({
|
|
389
|
-
maxArtifacts: 2,
|
|
390
397
|
maxRiskHeuristicHits: 0,
|
|
391
398
|
maxSensitivePathClasses: 0,
|
|
392
399
|
});
|
|
@@ -413,9 +420,6 @@ export function buildDeliverLightSuggestion(complexitySignals) {
|
|
|
413
420
|
const advisory = /** @type {const} */ (true);
|
|
414
421
|
const automatic = /** @type {const} */ (false);
|
|
415
422
|
const s = complexitySignals ?? {};
|
|
416
|
-
const artifactCount = Number.isInteger(s.artifactCount)
|
|
417
|
-
? s.artifactCount
|
|
418
|
-
: Number.POSITIVE_INFINITY;
|
|
419
423
|
const riskHits = Array.isArray(s.riskHeuristicHits)
|
|
420
424
|
? s.riskHeuristicHits.length
|
|
421
425
|
: Number.POSITIVE_INFINITY;
|
|
@@ -424,11 +428,6 @@ export function buildDeliverLightSuggestion(complexitySignals) {
|
|
|
424
428
|
: Number.POSITIVE_INFINITY;
|
|
425
429
|
|
|
426
430
|
const reasons = [];
|
|
427
|
-
if (artifactCount > ceilings.maxArtifacts) {
|
|
428
|
-
reasons.push(
|
|
429
|
-
`seed enumerates ${artifactCount} artifacts (> ${ceilings.maxArtifacts})`,
|
|
430
|
-
);
|
|
431
|
-
}
|
|
432
431
|
if (riskHits > ceilings.maxRiskHeuristicHits) {
|
|
433
432
|
reasons.push(`seed hits ${riskHits} risk-heuristic phrase(s)`);
|
|
434
433
|
}
|
|
@@ -446,9 +445,9 @@ export function buildDeliverLightSuggestion(complexitySignals) {
|
|
|
446
445
|
ceilings,
|
|
447
446
|
reasons: suggested
|
|
448
447
|
? [
|
|
449
|
-
|
|
450
|
-
'
|
|
451
|
-
'
|
|
448
|
+
'seed carries no risk signal (no risk-heuristic hits, no ' +
|
|
449
|
+
'sensitive-path classes) — the operator may prefer /deliver for ' +
|
|
450
|
+
"this scope; the light path's own gate and diff backstop decide size",
|
|
452
451
|
]
|
|
453
452
|
: reasons,
|
|
454
453
|
};
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Story is already `agent::done`, so confirm short-circuits `noop` and the
|
|
12
12
|
* capture's `action === 'done'` gate never opens).
|
|
13
13
|
*
|
|
14
|
-
* This module folds
|
|
14
|
+
* This module folds every such step into one phase both landing surfaces
|
|
15
15
|
* reach — the in-close wait (`phases/confirm-merge.js`) and the standalone
|
|
16
16
|
* `single-story-confirm-merge.js` CLI — so the two paths cannot diverge and
|
|
17
17
|
* "landed" means the whole tail ran.
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
planFastForward as defaultPlanFastForward,
|
|
46
46
|
} from '../../git-cleanup/phases/fast-forward.js';
|
|
47
47
|
import { reassertStatusColumn as defaultReassertStatusColumn } from '../../reassert-status-column.js';
|
|
48
|
+
import { releaseStoryLease as defaultReleaseStoryLease } from '../../single-story-lease-guard.js';
|
|
48
49
|
import { captureStoryFollowUps as defaultCaptureStoryFollowUps } from '../../story-follow-ups.js';
|
|
49
50
|
|
|
50
51
|
/**
|
|
@@ -241,6 +242,54 @@ async function stepTempPurge({ storyId, config, purgeStoryTempArtifactsFn }) {
|
|
|
241
242
|
return { ok: errors.length === 0, detail: errors.join('; ') || null };
|
|
242
243
|
}
|
|
243
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Release the Story's assignee-lease now that the merge is confirmed
|
|
247
|
+
* (Story #4860).
|
|
248
|
+
*
|
|
249
|
+
* The close used to release here-ish — immediately after the PR was opened
|
|
250
|
+
* and armed, before the merge wait ran — so a Story's ticket read *unassigned*
|
|
251
|
+
* for the entire window its PR was open, and indefinitely on the
|
|
252
|
+
* operator-merge path where a human owns the land. The claim is the only
|
|
253
|
+
* ticket-visible record of who owns in-flight work, so dropping it at PR
|
|
254
|
+
* creation is dropping it at exactly the wrong moment.
|
|
255
|
+
*
|
|
256
|
+
* It lives in the tail rather than in either landing surface because the tail
|
|
257
|
+
* is the one seam **both** reach — the in-close merge wait
|
|
258
|
+
* (`phases/confirm-merge.js`) and the standalone
|
|
259
|
+
* `single-story-confirm-merge.js` CLI. Homing it anywhere else re-opens the
|
|
260
|
+
* surface divergence this module exists to close.
|
|
261
|
+
*
|
|
262
|
+
* Idempotent by construction: `releaseStoryLease` no-ops when the resolved
|
|
263
|
+
* operator is no longer the recorded owner, so a re-run (or the belated
|
|
264
|
+
* manual confirm that backfills an already-`agent::done` Story) reports the
|
|
265
|
+
* no-op reason rather than yanking a claim someone else has since taken.
|
|
266
|
+
*
|
|
267
|
+
* A `released: false` no-op is **not** a step failure: an already-released
|
|
268
|
+
* ticket is the desired end state, and reporting it as `false` would train
|
|
269
|
+
* readers to ignore the field. Only a throw — an unreachable API, an
|
|
270
|
+
* unresolvable operator identity — degrades the step, and like every tail
|
|
271
|
+
* step that degrades the report, never the land.
|
|
272
|
+
*/
|
|
273
|
+
async function stepLeaseRelease({
|
|
274
|
+
storyId,
|
|
275
|
+
provider,
|
|
276
|
+
config,
|
|
277
|
+
progress,
|
|
278
|
+
releaseStoryLeaseFn,
|
|
279
|
+
}) {
|
|
280
|
+
const outcome = await releaseStoryLeaseFn({ provider, storyId, config });
|
|
281
|
+
progress?.(
|
|
282
|
+
'POST-LAND',
|
|
283
|
+
outcome?.released
|
|
284
|
+
? `🔓 Story #${storyId} lease released (merge confirmed).`
|
|
285
|
+
: `⏭ Story #${storyId} lease not released (${outcome?.reason ?? 'unknown'}).`,
|
|
286
|
+
);
|
|
287
|
+
return {
|
|
288
|
+
ok: true,
|
|
289
|
+
detail: outcome?.released ? null : (outcome?.reason ?? null),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
244
293
|
/**
|
|
245
294
|
* Run the whole post-land tail. Never throws.
|
|
246
295
|
*
|
|
@@ -280,7 +329,8 @@ async function stepTempPurge({ storyId, config, purgeStoryTempArtifactsFn }) {
|
|
|
280
329
|
* @param {Function} [args.executeFastForwardFn] Test seam.
|
|
281
330
|
* @param {Function} [args.acquireLockWithWaitFn] Test seam.
|
|
282
331
|
* @param {Function} [args.purgeStoryTempArtifactsFn] Test seam.
|
|
283
|
-
* @
|
|
332
|
+
* @param {Function} [args.releaseStoryLeaseFn] Test seam.
|
|
333
|
+
* @returns {Promise<{ followUps: boolean, statusResync: boolean, refCleanup: boolean, baseFastForward: boolean, tempPurge: boolean, leaseRelease: boolean, details: Record<string, string|null> }>}
|
|
284
334
|
*/
|
|
285
335
|
export async function runPostLandTail({
|
|
286
336
|
storyId,
|
|
@@ -299,6 +349,7 @@ export async function runPostLandTail({
|
|
|
299
349
|
executeFastForwardFn = defaultExecuteFastForward,
|
|
300
350
|
acquireLockWithWaitFn = defaultAcquireLockWithWait,
|
|
301
351
|
purgeStoryTempArtifactsFn = defaultPurgeStoryTempArtifacts,
|
|
352
|
+
releaseStoryLeaseFn = defaultReleaseStoryLease,
|
|
302
353
|
}) {
|
|
303
354
|
progress?.('POST-LAND', `🧾 Running land tail for Story #${storyId}...`);
|
|
304
355
|
|
|
@@ -401,18 +452,35 @@ export async function runPostLandTail({
|
|
|
401
452
|
{ name: 'temp purge', progress },
|
|
402
453
|
);
|
|
403
454
|
|
|
455
|
+
// Story #4860 — the merge is confirmed, so the operator's claim on this
|
|
456
|
+
// Story has finally done its job. Released here rather than at PR creation
|
|
457
|
+
// so the ticket stays assigned for the whole time its PR is open.
|
|
458
|
+
const leaseRelease = await step(
|
|
459
|
+
() =>
|
|
460
|
+
stepLeaseRelease({
|
|
461
|
+
storyId,
|
|
462
|
+
provider,
|
|
463
|
+
config,
|
|
464
|
+
progress,
|
|
465
|
+
releaseStoryLeaseFn,
|
|
466
|
+
}),
|
|
467
|
+
{ name: 'lease release', progress },
|
|
468
|
+
);
|
|
469
|
+
|
|
404
470
|
const tail = {
|
|
405
471
|
followUps: followUps.ok,
|
|
406
472
|
statusResync: statusResync.ok,
|
|
407
473
|
refCleanup: refCleanup.ok,
|
|
408
474
|
baseFastForward: baseFastForward.ok,
|
|
409
475
|
tempPurge: tempPurge.ok,
|
|
476
|
+
leaseRelease: leaseRelease.ok,
|
|
410
477
|
details: {
|
|
411
478
|
followUps: followUps.detail,
|
|
412
479
|
statusResync: statusResync.detail,
|
|
413
480
|
refCleanup: refCleanup.detail,
|
|
414
481
|
baseFastForward: baseFastForward.detail,
|
|
415
482
|
tempPurge: tempPurge.detail,
|
|
483
|
+
leaseRelease: leaseRelease.detail,
|
|
416
484
|
},
|
|
417
485
|
};
|
|
418
486
|
const degraded = Object.entries(tail)
|