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.
Files changed (30) hide show
  1. package/.agents/docs/configuration.md +1 -0
  2. package/.agents/schemas/agentrc.schema.json +6 -0
  3. package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
  4. package/.agents/scripts/deliver-light.js +23 -45
  5. package/.agents/scripts/diagnose-friction.js +95 -4
  6. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +10 -25
  7. package/.agents/scripts/lib/baselines/kinds/maintainability.js +20 -32
  8. package/.agents/scripts/lib/config-settings-schema-delivery.js +8 -0
  9. package/.agents/scripts/lib/escomplex-ast-compat.js +360 -0
  10. package/.agents/scripts/lib/maintainability-engine.js +83 -11
  11. package/.agents/scripts/lib/maintainability-unscorable.js +60 -0
  12. package/.agents/scripts/lib/maintainability-utils.js +14 -5
  13. package/.agents/scripts/lib/observability/runtime-friction.js +37 -1
  14. package/.agents/scripts/lib/orchestration/diff-magnitude.js +283 -0
  15. package/.agents/scripts/lib/orchestration/light-backstop.js +107 -0
  16. package/.agents/scripts/lib/orchestration/light-escalation.js +169 -0
  17. package/.agents/scripts/lib/orchestration/light-suitability.js +151 -46
  18. package/.agents/scripts/lib/orchestration/plan-context.js +12 -13
  19. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  20. package/.agents/scripts/lib/orchestration/run-epilogue.js +18 -6
  21. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +70 -2
  22. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +23 -5
  23. package/.agents/scripts/lib/orchestration/story-follow-ups.js +76 -4
  24. package/.agents/scripts/lib/templates/decomposer-prompts.js +1 -1
  25. package/.agents/scripts/lib/workers/maintainability-worker.js +14 -9
  26. package/.agents/workflows/helpers/deliver-light.md +21 -4
  27. package/.agents/workflows/helpers/plan-reference.md +40 -0
  28. package/.agents/workflows/plan.md +21 -16
  29. package/docs/CHANGELOG.md +23 -0
  30. 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 a
40
- * file-count ceiling the diff is the real scope signal and an
41
- * over-ceiling diff is blocked rather than landed silently.
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
- * File-count ceiling for the **actual landed** change set the diff backstop
83
- * ({@link checkLightDiffBackstop}) enforces. This is the light path's **only**
84
- * cardinality ceiling, and deliberately so (Story #4764): the predicted
85
- * footprint is a declaration a guess, and a gameable one — so the gate that
86
- * counts must be the one reading ground truth. A genuinely-light change stays
87
- * small; conservative by construction, since a ceiling an operator could widen
88
- * past what a single session safely absorbs is a ceiling that fails silently.
89
- * A framework constant, not a knob.
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
- maxFiles: 4,
110
+ maxImplLines: 1000,
111
+ maxImplFiles: 15,
93
112
  });
94
113
 
95
114
  /**
96
- * Coerce a candidate `maxFiles` ceiling into a positive integer, falling back
97
- * to the framework default for anything malformed — a stray `0`, `-1`, or `NaN`
98
- * must never widen (or zero out) the light diff ceiling.
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 normalizeMaxFiles(value, fallback) {
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): re-check the **actual** change set
393
- * after implementation, because the diff not the prompt is the real scope
394
- * signal. Blocks (rather than landing) when the diff intersects a sensitive-
395
- * path class, exceeds the file-count ceiling, or cannot be classified. A clean
396
- * result is the only path that lands light.
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
- * ceilings?: { maxFiles?: number },
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
- * ceilings: { maxFiles: number },
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 maxFiles = normalizeMaxFiles(
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
- ceilings: { maxFiles },
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
- if (classes.length > 0) {
454
- reasons.push(
455
- `diff intersects sensitive-path class(es) ${classes.join(', ')} — escalate to /plan (do not land light)`,
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
- ceilings: { maxFiles },
515
+ magnitude: measured,
516
+ ceilings: resolved,
476
517
  reasons: blocked
477
518
  ? reasons
478
519
  : [
479
- `diff is light: ${files.length} file(s) ≤ ${maxFiles}, no sensitive-path class — safe to land`,
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
- * - `maxArtifacts` — enumerated seed items (one artifact each).
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
- `seed fits the light-path ceilings (≤${ceilings.maxArtifacts} artifacts, ` +
450
- 'no risk-heuristic hits, no sensitive-path classes) — the operator ' +
451
- 'may prefer /deliver for this scope',
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
  };
@@ -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(stories, config);
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 all four steps into one phase both landing surfaces
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
- * @returns {Promise<{ followUps: boolean, statusResync: boolean, refCleanup: boolean, baseFastForward: boolean, tempPurge: boolean, details: Record<string, string|null> }>}
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
- const leaseReleased = await releaseLease(leaseArgs);
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
- leaseReleased,
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
- leaseReleased,
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
  });