mandrel 2.22.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/docs/configuration.md +1 -0
- package/.agents/schemas/agentrc.schema.json +6 -0
- package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
- package/.agents/scripts/deliver-light.js +23 -45
- package/.agents/scripts/diagnose-friction.js +95 -4
- 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/config-settings-schema-delivery.js +8 -0
- 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 +37 -1
- 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/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +18 -6
- 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/orchestration/story-follow-ups.js +76 -4
- package/.agents/scripts/lib/templates/decomposer-prompts.js +1 -1
- package/.agents/scripts/lib/workers/maintainability-worker.js +14 -9
- package/.agents/workflows/helpers/deliver-light.md +21 -4
- package/.agents/workflows/helpers/plan-reference.md +40 -0
- package/.agents/workflows/plan.md +21 -16
- package/docs/CHANGELOG.md +23 -0
- package/package.json +1 -1
|
@@ -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
|
};
|
|
Binary file
|
|
@@ -557,22 +557,29 @@ async function executeFollowUpRollup({
|
|
|
557
557
|
// Shared with the story-scoped gather (Story #4649): `storyId` + `details`
|
|
558
558
|
// are what the composer's recovery-netting keys on, and two hand-rolled
|
|
559
559
|
// copies of this loop are how they got dropped in the first place.
|
|
560
|
-
const signals = await gatherRunFrictionSignals(
|
|
560
|
+
const { signals, window: frictionWindow } = await gatherRunFrictionSignals(
|
|
561
|
+
stories,
|
|
562
|
+
config,
|
|
563
|
+
);
|
|
561
564
|
const repos = resolveFollowUpRepos(config);
|
|
562
565
|
const primaryId = Number(stories[0]);
|
|
566
|
+
// Story #4850 — `runToken` and `anchorStoryIds` are INPUTS. This used to
|
|
567
|
+
// compose with the primary Story's numeric id standing in for the run and
|
|
568
|
+
// then rewrite the rendered title/body by regex over a `plan-run \d+`
|
|
569
|
+
// substring, which meant the composer's own wording could not be changed
|
|
570
|
+
// without silently breaking the patch. `anchorStoryIds` is what lets the
|
|
571
|
+
// composer tell a corpus confined to this run from one spanning the whole
|
|
572
|
+
// surviving window, so it never titles the latter as if it were the former.
|
|
563
573
|
const proposals = composeRoutedProposals({
|
|
564
574
|
anchorId: Number.isInteger(primaryId) ? primaryId : 1,
|
|
565
575
|
anchorKind: 'run',
|
|
576
|
+
runToken: String(planRunId ?? ''),
|
|
577
|
+
anchorStoryIds: stories,
|
|
566
578
|
frameworkRepo: repos.frameworkRepo,
|
|
567
579
|
consumerRepo: repos.consumerRepo,
|
|
568
580
|
signals,
|
|
569
581
|
unresolvedBlockedEvents: [],
|
|
570
582
|
});
|
|
571
|
-
// Patch titles to mention the plan-run token (anchorKind run uses numeric id).
|
|
572
|
-
for (const item of [...proposals.framework, ...proposals.consumer]) {
|
|
573
|
-
item.title = item.title.replace(/plan-run \d+/, `plan-run ${planRunId}`);
|
|
574
|
-
item.body = item.body.replace(/plan-run \d+/g, `plan-run ${planRunId}`);
|
|
575
|
-
}
|
|
576
583
|
const graduated = await graduateFn({
|
|
577
584
|
epicId: primaryId,
|
|
578
585
|
provider,
|
|
@@ -619,6 +626,11 @@ async function executeFollowUpRollup({
|
|
|
619
626
|
signalCount: signals.length,
|
|
620
627
|
storyCount: stories.length,
|
|
621
628
|
filed: graduated.filed?.length ?? 0,
|
|
629
|
+
// Story #4850 — the recurrence window the gather actually applied, and what
|
|
630
|
+
// it dropped. `signalCount` alone cannot distinguish "the window is bounded
|
|
631
|
+
// at 30 days and 40 rows aged out" from "nothing older exists", and an
|
|
632
|
+
// operator triaging a roll-up needs to know which corpus produced it.
|
|
633
|
+
frictionWindow,
|
|
622
634
|
// Story #4828 — everything below is what the roll-up saw and what became
|
|
623
635
|
// of it. The pre-#4828 result reported `signalCount` and `filed` and
|
|
624
636
|
// nothing in between, so nine signals routing into one proposal whose
|
|
@@ -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)
|
|
@@ -353,8 +353,8 @@ function closeResult({
|
|
|
353
353
|
note: waitedForMerge
|
|
354
354
|
? 'Close-and-land: PR merge confirmed. Story flipped agent::closing → agent::done, the issue closed (confirmStoryMerged), and the post-land tail ran.'
|
|
355
355
|
: autoMergeEnabled
|
|
356
|
-
? 'PR open against baseBranch with auto-merge enabled. Story rests at agent::closing (issue stays OPEN). GitHub will squash-merge when required checks pass; run single-story-confirm-merge.js after the merge confirms to flip agent::done and close the issue (the Closes #<id> footer also auto-closes it).'
|
|
357
|
-
: 'PR open against baseBranch. Story rests at agent::closing (issue stays OPEN). Operator merges via GitHub UI; run single-story-confirm-merge.js after the merge confirms to flip agent::done (the Closes #<id> footer also auto-closes the issue).',
|
|
356
|
+
? 'PR open against baseBranch with auto-merge enabled. Story rests at agent::closing (issue stays OPEN and assigned to the operator). GitHub will squash-merge when required checks pass; run single-story-confirm-merge.js after the merge confirms to flip agent::done, release the lease, and close the issue (the Closes #<id> footer also auto-closes it).'
|
|
357
|
+
: 'PR open against baseBranch. Story rests at agent::closing (issue stays OPEN and assigned to the operator). Operator merges via GitHub UI; run single-story-confirm-merge.js after the merge confirms to flip agent::done and release the lease (the Closes #<id> footer also auto-closes the issue).',
|
|
358
358
|
};
|
|
359
359
|
}
|
|
360
360
|
|
|
@@ -552,7 +552,18 @@ async function runClosePipeline({
|
|
|
552
552
|
config,
|
|
553
553
|
progress,
|
|
554
554
|
});
|
|
555
|
-
|
|
555
|
+
// Story #4860 — the clean-path lease release USED to sit here, immediately
|
|
556
|
+
// after the arm and the `agent::closing` flip. That dropped the operator's
|
|
557
|
+
// claim the moment the PR opened, so a ticket read unassigned for the whole
|
|
558
|
+
// time its PR was in flight — and forever on the operator-merge path, where
|
|
559
|
+
// nothing downstream ever re-claimed it. The release now belongs to the
|
|
560
|
+
// post-land tail, which runs only on a CONFIRMED merge and which both
|
|
561
|
+
// landing surfaces reach. Every non-merged ending below — the
|
|
562
|
+
// `merge.unlanded` block, an exhausted wait budget, `--no-wait-merge`,
|
|
563
|
+
// `--no-auto-merge` — deliberately RETAINS the claim: the PR is open and the
|
|
564
|
+
// work still has an owner. The only releases that survive here are
|
|
565
|
+
// `releaseLeaseOnBlock`'s two throwing exits above, which fire before the PR
|
|
566
|
+
// is ever armed (Story #4257's hand-off property).
|
|
556
567
|
|
|
557
568
|
// Close-and-land (Story #4428; default since `delivery.routing.closeAndLand`
|
|
558
569
|
// — Story #4539): poll the just-armed PR to merge confirmation, or block
|
|
@@ -625,7 +636,11 @@ async function runClosePipeline({
|
|
|
625
636
|
autoMergeEnabled,
|
|
626
637
|
autoMergeReason,
|
|
627
638
|
worktreeReaped,
|
|
628
|
-
|
|
639
|
+
// Story #4860 — the release is a post-land tail step now, so the tail's
|
|
640
|
+
// own per-step boolean IS the answer. A wait that ended anything other
|
|
641
|
+
// than landed never ran the tail, and correctly reports `false`: the
|
|
642
|
+
// claim is still held, by design.
|
|
643
|
+
leaseReleased: waitOutcome.tail?.leaseRelease === true,
|
|
629
644
|
localCleanupDeferred,
|
|
630
645
|
directMerged,
|
|
631
646
|
waitedForMerge: true,
|
|
@@ -663,7 +678,10 @@ async function runClosePipeline({
|
|
|
663
678
|
autoMergeEnabled,
|
|
664
679
|
autoMergeReason,
|
|
665
680
|
worktreeReaped,
|
|
666
|
-
|
|
681
|
+
// Story #4860 — this is the no-wait ending: the PR is open and a human
|
|
682
|
+
// owns the merge, so the Story stays assigned until the confirm-merge
|
|
683
|
+
// surface lands it and runs the tail.
|
|
684
|
+
leaseReleased: false,
|
|
667
685
|
localCleanupDeferred,
|
|
668
686
|
directMerged,
|
|
669
687
|
});
|