mandrel 2.23.0 → 2.25.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 (101) hide show
  1. package/.agents/agents/acceptance-critic.md +13 -19
  2. package/.agents/agents/auditor.md +16 -19
  3. package/.agents/agents/story-worker.md +41 -54
  4. package/.agents/audit-checklists/architecture.md +5 -1
  5. package/.agents/audit-checklists/quality.md +3 -0
  6. package/.agents/docs/agentrc-reference.json +2 -1
  7. package/.agents/docs/configuration.md +2 -1
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/rules/ci-remediation.md +68 -3
  10. package/.agents/schemas/agentrc.schema.json +6 -1
  11. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  12. package/.agents/schemas/baselines/crap.schema.json +5 -1
  13. package/.agents/schemas/crap-report.schema.json +37 -0
  14. package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
  15. package/.agents/scripts/acceptance-eval.js +35 -9
  16. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  17. package/.agents/scripts/audit-to-stories.js +33 -3
  18. package/.agents/scripts/check-context-budget.js +95 -14
  19. package/.agents/scripts/check-test-temp-hygiene.js +83 -0
  20. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  21. package/.agents/scripts/deliver-light.js +22 -45
  22. package/.agents/scripts/deliver-recover.js +13 -0
  23. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +10 -25
  24. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  25. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  26. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  27. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  28. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  29. package/.agents/scripts/lib/baseline-loader.js +0 -0
  30. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  31. package/.agents/scripts/lib/baselines/kinds/crap.js +396 -51
  32. package/.agents/scripts/lib/baselines/kinds/maintainability.js +20 -32
  33. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  34. package/.agents/scripts/lib/baselines/reader.js +10 -0
  35. package/.agents/scripts/lib/baselines/writer.js +7 -3
  36. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  37. package/.agents/scripts/lib/crap-engine.js +97 -19
  38. package/.agents/scripts/lib/crap-utils.js +73 -13
  39. package/.agents/scripts/lib/env-loader.js +46 -16
  40. package/.agents/scripts/lib/escomplex-ast-compat.js +360 -0
  41. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  42. package/.agents/scripts/lib/findings/severity.js +80 -2
  43. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  44. package/.agents/scripts/lib/label-constants.js +17 -0
  45. package/.agents/scripts/lib/maintainability-engine.js +83 -11
  46. package/.agents/scripts/lib/maintainability-unscorable.js +60 -0
  47. package/.agents/scripts/lib/maintainability-utils.js +14 -5
  48. package/.agents/scripts/lib/observability/runtime-friction.js +8 -0
  49. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  50. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  51. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  52. package/.agents/scripts/lib/orchestration/diff-magnitude.js +283 -0
  53. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  54. package/.agents/scripts/lib/orchestration/light-backstop.js +124 -0
  55. package/.agents/scripts/lib/orchestration/light-escalation.js +248 -0
  56. package/.agents/scripts/lib/orchestration/light-suitability.js +254 -51
  57. package/.agents/scripts/lib/orchestration/plan-context.js +12 -13
  58. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +7 -0
  59. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +13 -1
  60. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  61. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  62. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  63. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -34
  64. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +70 -2
  65. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  66. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +73 -18
  67. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  68. package/.agents/scripts/lib/story-plan.js +137 -42
  69. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  70. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  71. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  72. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  73. package/.agents/scripts/lib/wave-runner/ready-set.js +73 -9
  74. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +6 -3
  75. package/.agents/scripts/lib/workers/crap-worker.js +6 -3
  76. package/.agents/scripts/lib/workers/maintainability-worker.js +14 -9
  77. package/.agents/scripts/pr-watch-with-update.js +568 -213
  78. package/.agents/scripts/quality-preview.js +25 -0
  79. package/.agents/scripts/run-tests.js +10 -0
  80. package/.agents/scripts/single-story-close.js +6 -0
  81. package/.agents/scripts/single-story-init.js +7 -0
  82. package/.agents/scripts/stories-wave-tick.js +95 -10
  83. package/.agents/scripts/story-plan.js +65 -9
  84. package/.agents/scripts/update-crap-baseline.js +42 -4
  85. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  86. package/.agents/templates/single-story-body.md +16 -8
  87. package/.agents/workflows/audit-architecture.md +69 -13
  88. package/.agents/workflows/audit-quality.md +26 -0
  89. package/.agents/workflows/audit-to-stories.md +30 -4
  90. package/.agents/workflows/deliver.md +81 -95
  91. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  92. package/.agents/workflows/helpers/deliver-light.md +21 -4
  93. package/.agents/workflows/helpers/deliver-reference.md +18 -3
  94. package/.agents/workflows/helpers/deliver-story-reference.md +127 -46
  95. package/.agents/workflows/helpers/deliver-story.md +104 -119
  96. package/.agents/workflows/helpers/plan-reference.md +34 -0
  97. package/.agents/workflows/plan.md +60 -76
  98. package/docs/CHANGELOG.md +38 -0
  99. package/lib/cli/update.js +14 -11
  100. package/lib/cli/version-check.js +9 -1
  101. package/package.json +1 -1
@@ -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)
@@ -1,8 +1,9 @@
1
1
  /**
2
- * phases/pull-request.js — open or reuse the PR for a standalone Story.
2
+ * phases/pull-request.js — open, reuse, or decline to open the PR for a
3
+ * standalone Story.
3
4
  *
4
- * Probes for an existing open PR with `head = storyBranch`; creates one if
5
- * none exists. Returns the PR URL.
5
+ * Probes for an existing PR with `head = storyBranch`; creates one if none
6
+ * exists. Returns `{ url, alreadyMerged, created }`.
6
7
  *
7
8
  * `gh pr view --head` is not available on all `gh` versions, so we probe
8
9
  * with `gh pr list --head <branch>` and fall back to `gh pr create`.
@@ -13,17 +14,99 @@
13
14
  * error classification, timeout handling, and JSON parsing surface
14
15
  * instead of carrying its own `execFileSync('gh', …)` plumbing.
15
16
  *
17
+ * ## An already-merged PR is not an absent PR (Story #4873)
18
+ *
19
+ * The probe used to ask for `--state open` only. On the recovery path that is
20
+ * a false negative with teeth: a resumed close pushes the branch, the push
21
+ * turns the checks green, armed auto-merge lands the ORIGINAL PR server-side,
22
+ * and the resumed close then sees no OPEN PR on the head — so it opened a
23
+ * SECOND PR against a branch that is now byte-identical to base and
24
+ * squash-merged a zero-file commit onto `main`. Both halves of that are now
25
+ * closed:
26
+ *
27
+ * 1. The probe reads `--state all` and reports a MERGED PR on this head as
28
+ * the outcome (`alreadyMerged: true`), so the caller lands on the merge
29
+ * that already happened instead of manufacturing a new one. An OPEN PR
30
+ * still wins over a merged one — a re-opened head is a live PR.
31
+ * 2. Creation is REFUSED outright when the head-versus-base diff contains no
32
+ * files. An empty diff means there is nothing to merge, so a PR opened on
33
+ * it can only ever produce an empty commit.
34
+ *
35
+ * The empty-diff guard fails **open**, not closed: `computeChangeSet` returns
36
+ * `files: null` when it cannot enumerate the diff at all, and absence of
37
+ * evidence must never block a legitimate PR — only a positively-observed empty
38
+ * file list refuses.
39
+ *
16
40
  * The function still accepts an injected `gh` facade so tests can wire
17
41
  * a fake without spawning real children.
18
42
  */
19
43
 
20
44
  import { gh as defaultGh } from '../../../gh-exec.js';
21
45
  import { Logger } from '../../../Logger.js';
46
+ import { computeChangeSet as defaultComputeChangeSet } from '../../change-set.js';
22
47
  import { normalizePrTitle } from './normalize-pr-title.js';
23
48
 
24
49
  /**
25
- * Probe for an existing open PR with `head = storyBranch`; create one if
26
- * none exists. Returns the PR URL. Exported for testing.
50
+ * Pick the PR this head branch should resolve to from a `gh pr list
51
+ * --state all` projection. A live PR always wins; otherwise the first MERGED
52
+ * PR is the outcome to report. A head whose only PRs were CLOSED without
53
+ * merging resolves to nothing — there is a new PR to open.
54
+ *
55
+ * A row carrying a url but no recognizable `state` reads as live, not as
56
+ * nothing: the projection this phase asks for always includes `state`, so an
57
+ * absent one means an older/other `gh`, and the old `--state open` probe
58
+ * treated every returned row as a reusable open PR. Guessing "no PR" there is
59
+ * the failure mode with teeth — it opens a duplicate.
60
+ *
61
+ * Pure, and module-private on purpose: `ensurePullRequestWith` is the only
62
+ * caller and the only surface worth pinning, so the precedence is asserted
63
+ * through it rather than through a test-only export the production
64
+ * dead-export ratchet would then flag.
65
+ *
66
+ * @param {Array<{url?: string, state?: string, mergedAt?: string}>} rows
67
+ * @returns {{ url: string, state: 'OPEN'|'MERGED' }|null}
68
+ */
69
+ function pickHeadPullRequest(rows) {
70
+ if (!Array.isArray(rows)) return null;
71
+ let merged = null;
72
+ for (const row of rows) {
73
+ const url = String(row?.url ?? '').trim();
74
+ if (!url) continue;
75
+ const state = String(row?.state ?? '').toUpperCase();
76
+ if (state === 'MERGED' || (state !== 'OPEN' && row?.mergedAt)) {
77
+ merged ??= { url, state: 'MERGED' };
78
+ continue;
79
+ }
80
+ if (state === 'CLOSED') continue;
81
+ return { url, state: 'OPEN' };
82
+ }
83
+ return merged;
84
+ }
85
+
86
+ /**
87
+ * Enumerate the head-versus-base diff and report whether it is positively
88
+ * empty. `null` (diff unenumerable) is NOT empty — see the module header.
89
+ *
90
+ * The diff is taken against `origin/<baseBranch>` when that ref resolves,
91
+ * because the local base ref can trail the remote by exactly the merge that
92
+ * makes this diff empty — the very state the guard exists to catch. It falls
93
+ * back to the local ref, and finally to "unknown", when the remote ref cannot
94
+ * be enumerated.
95
+ *
96
+ * @returns {{ empty: boolean, baseRef: string|null }}
97
+ */
98
+ function probeEmptyDiff({ cwd, baseBranch, storyBranch, computeChangeSet }) {
99
+ for (const baseRef of [`origin/${baseBranch}`, baseBranch]) {
100
+ const set = computeChangeSet({ baseRef, headRef: storyBranch, cwd });
101
+ if (!set.enumerated) continue;
102
+ return { empty: (set.files ?? []).length === 0, baseRef };
103
+ }
104
+ return { empty: false, baseRef: null };
105
+ }
106
+
107
+ /**
108
+ * Probe for an existing PR with `head = storyBranch`; create one if none
109
+ * exists. Exported for testing.
27
110
  *
28
111
  * @param {{
29
112
  * cwd: string,
@@ -32,9 +115,10 @@ import { normalizePrTitle } from './normalize-pr-title.js';
32
115
  * storyBranch: string,
33
116
  * baseBranch: string,
34
117
  * gh?: ReturnType<typeof import('../../../gh-exec.js').createGh>,
118
+ * computeChangeSetFn?: typeof defaultComputeChangeSet,
35
119
  * progress?: (tag: string, msg: string) => void,
36
120
  * }} args
37
- * @returns {Promise<string>}
121
+ * @returns {Promise<{ url: string, alreadyMerged: boolean, created: boolean }>}
38
122
  */
39
123
  export async function ensurePullRequestWith({
40
124
  cwd: _cwd,
@@ -43,6 +127,7 @@ export async function ensurePullRequestWith({
43
127
  storyBranch,
44
128
  baseBranch,
45
129
  gh = defaultGh,
130
+ computeChangeSetFn = defaultComputeChangeSet,
46
131
  progress = () => {},
47
132
  }) {
48
133
  // `cwd` is preserved on the call signature for backwards compatibility
@@ -51,22 +136,26 @@ export async function ensurePullRequestWith({
51
136
  // before invoking the phase, so the effective cwd matches the legacy
52
137
  // `execFileSync('gh', …, { cwd })` shape.
53
138
  try {
54
- // `gh pr list --head <branch> --state open --json url` returns a
55
- // JSON array of `{ url }` rows; an empty array means no open PR on
56
- // this head. We pick the first entry's URL (matching the legacy
57
- // `-q '.[0].url // empty'` projection on our side) so the typed
58
- // `gh.pr.list` facade can stay generic.
139
+ // `gh pr list --head <branch> --state all --json url,state,mergedAt`
140
+ // returns a JSON array of rows; an empty array means this head has never
141
+ // had a PR. `--state all` (not `open`) is load-bearing see the module
142
+ // header's duplicate-empty-PR note.
59
143
  const rows = await gh.pr.list(
60
- ['--head', storyBranch, '--state', 'open'],
61
- ['url'],
144
+ ['--head', storyBranch, '--state', 'all'],
145
+ ['url', 'state', 'mergedAt'],
62
146
  );
63
- const existing =
64
- Array.isArray(rows) && rows.length > 0
65
- ? String(rows[0]?.url ?? '').trim()
66
- : '';
67
- if (existing) {
68
- progress('PR', `Reusing existing PR: ${existing}`);
69
- return existing;
147
+ const existing = pickHeadPullRequest(rows);
148
+ if (existing?.state === 'OPEN') {
149
+ progress('PR', `Reusing existing PR: ${existing.url}`);
150
+ return { url: existing.url, alreadyMerged: false, created: false };
151
+ }
152
+ if (existing?.state === 'MERGED') {
153
+ progress(
154
+ 'PR',
155
+ `✅ PR for ${storyBranch} is already MERGED: ${existing.url} — ` +
156
+ 'reporting that outcome instead of opening a second PR.',
157
+ );
158
+ return { url: existing.url, alreadyMerged: true, created: false };
70
159
  }
71
160
  } catch (err) {
72
161
  // `gh pr list` failure is recoverable — fall through to create. Log
@@ -76,6 +165,25 @@ export async function ensurePullRequestWith({
76
165
  );
77
166
  }
78
167
 
168
+ // Nothing to merge → nothing to open. Refused before `gh pr create` so the
169
+ // failure names the empty diff rather than surfacing later as a zero-file
170
+ // squash commit on the base branch.
171
+ const emptyDiff = probeEmptyDiff({
172
+ cwd: _cwd ?? process.cwd(),
173
+ baseBranch,
174
+ storyBranch,
175
+ computeChangeSet: computeChangeSetFn,
176
+ });
177
+ if (emptyDiff.empty) {
178
+ throw new Error(
179
+ `[single-story-close] refusing to open a pull request for ${storyBranch}: ` +
180
+ `the head-versus-base diff (${emptyDiff.baseRef}...${storyBranch}) contains no files. ` +
181
+ 'An empty diff has nothing to merge, and a PR opened on it can only squash an empty ' +
182
+ 'commit onto the base branch. If the work already landed, confirm the merge instead: ' +
183
+ `node .agents/scripts/single-story-confirm-merge.js --story ${storyId}`,
184
+ );
185
+ }
186
+
79
187
  progress('PR', `Opening PR for ${storyBranch} → ${baseBranch}...`);
80
188
  // The repo squash-merges and GitHub uses the PR title as the squash
81
189
  // subject on `main`. A raw human issue title is not a Conventional
@@ -110,7 +218,7 @@ export async function ensurePullRequestWith({
110
218
  ]);
111
219
  const url = (createResult?.stdout ?? '').trim();
112
220
  progress('PR', `✅ Opened: ${url}`);
113
- return url;
221
+ return { url, alreadyMerged: false, created: true };
114
222
  } catch (err) {
115
223
  throw new Error(
116
224
  `[single-story-close] \`gh pr create\` failed: ${err?.message ?? err}`,
@@ -12,6 +12,7 @@ import { createProvider } from '../../provider-factory.js';
12
12
  import { flipLabelAndNotify } from '../../single-story/story-merged-notify.js';
13
13
  import { WorktreeManager } from '../../worktree-manager.js';
14
14
  import { runCodeReview as runCodeReviewDefault } from '../code-review.js';
15
+ import { resolveRunScopedConfig } from '../run-scoped-config.js';
15
16
  import { releaseStoryLease } from '../single-story-lease-guard.js';
16
17
  import {
17
18
  buildTerminalEnvelope,
@@ -144,6 +145,7 @@ async function runPrePushPhases({
144
145
  worktreePath,
145
146
  config,
146
147
  baseBranch,
148
+ baseConfirmed,
147
149
  storyBranch,
148
150
  storyId,
149
151
  provider,
@@ -185,6 +187,7 @@ async function runPrePushPhases({
185
187
  cwd,
186
188
  worktreePath,
187
189
  baseBranch,
190
+ baseConfirmed,
188
191
  storyBranch,
189
192
  storyId,
190
193
  provider,
@@ -210,7 +213,7 @@ async function openAndReviewPr({
210
213
  setPhase('push');
211
214
  pushStoryBranch({ cwd, storyBranch, gitSync, progress });
212
215
  setPhase('pull-request');
213
- const prUrl = await ensurePullRequestWith({
216
+ const { url: prUrl, alreadyMerged } = await ensurePullRequestWith({
214
217
  cwd,
215
218
  storyId,
216
219
  storyTitle: story.title,
@@ -220,6 +223,13 @@ async function openAndReviewPr({
220
223
  progress,
221
224
  });
222
225
  const prNumber = parsePrNumber(prUrl);
226
+ // Story #4873 — the head's PR already merged (an armed PR that landed while
227
+ // a previous close invocation was between phases). There is nothing left to
228
+ // review and nothing left to arm; the confirm phase probes the PR, observes
229
+ // MERGED, and lands the Story on the merge that actually happened.
230
+ if (alreadyMerged) {
231
+ return { prUrl, prNumber, alreadyMerged: true };
232
+ }
223
233
  setPhase('code-review');
224
234
  const reviewOutcome = await runStoryScopeReview({
225
235
  cwd,
@@ -245,7 +255,7 @@ async function openAndReviewPr({
245
255
  'Auto-merge was not enabled. Remediate the findings posted to the PR and re-run `/deliver`.',
246
256
  );
247
257
  }
248
- return { prUrl, prNumber };
258
+ return { prUrl, prNumber, alreadyMerged: false };
249
259
  }
250
260
 
251
261
  async function releaseLease({
@@ -353,8 +363,8 @@ function closeResult({
353
363
  note: waitedForMerge
354
364
  ? 'Close-and-land: PR merge confirmed. Story flipped agent::closing → agent::done, the issue closed (confirmStoryMerged), and the post-land tail ran.'
355
365
  : 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).',
366
+ ? '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).'
367
+ : '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
368
  };
359
369
  }
360
370
 
@@ -443,7 +453,6 @@ async function runClosePipeline({
443
453
  const startedAtMs = Date.now();
444
454
  const config = injectedConfig || resolveConfig({ cwd: options.cwd });
445
455
  const provider = injectedProvider || createProvider(config);
446
- const baseBranch = config.project?.baseBranch ?? 'main';
447
456
  const storyBranch = getStoryBranch(options.storyId);
448
457
 
449
458
  progress('INIT', `Closing standalone Story #${options.storyId}...`);
@@ -462,6 +471,22 @@ async function runClosePipeline({
462
471
  );
463
472
  }
464
473
 
474
+ // Story #4891 — the base branch this run was SEEDED from, read back off the
475
+ // run's `story-init` receipt rather than re-resolved from a config file that
476
+ // may have changed during the whole implementation window. Throws (fail
477
+ // closed, naming both values) when the pin and current config disagree —
478
+ // deliberately here, before the gate chain, format-autofix and base-sync,
479
+ // so a wrong base is never merged into the Story branch. `baseConfirmed`
480
+ // gates the base-merge remediation advice further down.
481
+ const { values: runScoped, confirmed: baseConfirmed } =
482
+ await resolveRunScopedConfig({
483
+ provider,
484
+ storyId: options.storyId,
485
+ config,
486
+ progress,
487
+ });
488
+ const baseBranch = runScoped.baseBranch;
489
+
465
490
  const worktreePath = resolveWorktreePath({
466
491
  cwd: options.cwd,
467
492
  config,
@@ -483,6 +508,7 @@ async function runClosePipeline({
483
508
  ...options,
484
509
  config,
485
510
  baseBranch,
511
+ baseConfirmed,
486
512
  storyBranch,
487
513
  provider,
488
514
  worktreePath,
@@ -493,7 +519,7 @@ async function runClosePipeline({
493
519
  leaseArgs,
494
520
  );
495
521
 
496
- const { prUrl, prNumber } = await releaseLeaseOnBlock(
522
+ const { prUrl, prNumber, alreadyMerged } = await releaseLeaseOnBlock(
497
523
  () =>
498
524
  openAndReviewPr({
499
525
  cwd: options.cwd,
@@ -527,20 +553,31 @@ async function runClosePipeline({
527
553
  WorktreeManager,
528
554
  });
529
555
  setPhase('auto-merge');
556
+ // An already-merged head PR (Story #4873) has nothing to arm — `gh pr merge`
557
+ // against it fails, which would report the arm as a fault and block a Story
558
+ // whose work is already on the base branch. Skip the arm and let the confirm
559
+ // phase observe the merge that happened.
530
560
  const {
531
561
  autoMergeEnabled,
532
562
  autoMergeReason,
533
563
  localCleanupDeferred,
534
564
  directMerged,
535
- } = await runAutoMergePhase({
536
- cwd: options.cwd,
537
- prNumber,
538
- prUrl,
539
- noAutoMerge: options.noAutoMerge,
540
- autoMergePolicy: getCiDelivery(config).autoMerge,
541
- gh: injectedGh,
542
- progress,
543
- });
565
+ } = alreadyMerged
566
+ ? {
567
+ autoMergeEnabled: true,
568
+ autoMergeReason: null,
569
+ localCleanupDeferred: false,
570
+ directMerged: false,
571
+ }
572
+ : await runAutoMergePhase({
573
+ cwd: options.cwd,
574
+ prNumber,
575
+ prUrl,
576
+ noAutoMerge: options.noAutoMerge,
577
+ autoMergePolicy: getCiDelivery(config).autoMerge,
578
+ gh: injectedGh,
579
+ progress,
580
+ });
544
581
  await flipLabelAndNotify({
545
582
  provider,
546
583
  notifyFn: injectedNotify,
@@ -552,7 +589,18 @@ async function runClosePipeline({
552
589
  config,
553
590
  progress,
554
591
  });
555
- const leaseReleased = await releaseLease(leaseArgs);
592
+ // Story #4860 the clean-path lease release USED to sit here, immediately
593
+ // after the arm and the `agent::closing` flip. That dropped the operator's
594
+ // claim the moment the PR opened, so a ticket read unassigned for the whole
595
+ // time its PR was in flight — and forever on the operator-merge path, where
596
+ // nothing downstream ever re-claimed it. The release now belongs to the
597
+ // post-land tail, which runs only on a CONFIRMED merge and which both
598
+ // landing surfaces reach. Every non-merged ending below — the
599
+ // `merge.unlanded` block, an exhausted wait budget, `--no-wait-merge`,
600
+ // `--no-auto-merge` — deliberately RETAINS the claim: the PR is open and the
601
+ // work still has an owner. The only releases that survive here are
602
+ // `releaseLeaseOnBlock`'s two throwing exits above, which fire before the PR
603
+ // is ever armed (Story #4257's hand-off property).
556
604
 
557
605
  // Close-and-land (Story #4428; default since `delivery.routing.closeAndLand`
558
606
  // — Story #4539): poll the just-armed PR to merge confirmation, or block
@@ -625,7 +673,11 @@ async function runClosePipeline({
625
673
  autoMergeEnabled,
626
674
  autoMergeReason,
627
675
  worktreeReaped,
628
- leaseReleased,
676
+ // Story #4860 — the release is a post-land tail step now, so the tail's
677
+ // own per-step boolean IS the answer. A wait that ended anything other
678
+ // than landed never ran the tail, and correctly reports `false`: the
679
+ // claim is still held, by design.
680
+ leaseReleased: waitOutcome.tail?.leaseRelease === true,
629
681
  localCleanupDeferred,
630
682
  directMerged,
631
683
  waitedForMerge: true,
@@ -663,7 +715,10 @@ async function runClosePipeline({
663
715
  autoMergeEnabled,
664
716
  autoMergeReason,
665
717
  worktreeReaped,
666
- leaseReleased,
718
+ // Story #4860 — this is the no-wait ending: the PR is open and a human
719
+ // owns the merge, so the Story stays assigned until the confirm-merge
720
+ // surface lands it and runs the tail.
721
+ leaseReleased: false,
667
722
  localCleanupDeferred,
668
723
  directMerged,
669
724
  });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * lib/reserved-test-ids.js — the framework's one declaration of which
3
+ * Epic / Story ids are **synthetic** (Story #4892).
4
+ *
5
+ * The band `999000–999999` has been reserved for test fixtures since the
6
+ * post-test temp reaper shipped, but it lived as a private regexp inside
7
+ * `cleanup-repo-test-temp.js` (`/^epic-999\d{3}$/`) and nothing else could
8
+ * consult it. Two independent surfaces need the same rule, and spelling it
9
+ * twice is how they would drift:
10
+ *
11
+ * 1. **Write side.** A test that spawns a real CLI at the repository root
12
+ * inherits the real state directory, so its fixture telemetry can land
13
+ * in the operator's live `temp/` signals tree. The post-run guard in
14
+ * `check-test-temp-hygiene.js` fails the run when a stream file owned by
15
+ * a reserved id survives there.
16
+ * 2. **Read side.** The retro composer renders the distinct Stories a
17
+ * friction bucket spans as recurrence evidence in a body that is filed
18
+ * as a real GitHub issue. A reserved id is synthetic by construction, so
19
+ * it can never be resolved to a real issue and must never be published
20
+ * as evidence (issue #4870 named `#999999` as a contributing Story).
21
+ *
22
+ * Deliberately dependency-free: the write side is a CLI guard and the read
23
+ * side is a pure composer, so the shared rule must not drag config, fs, or
24
+ * git resolution into either.
25
+ */
26
+
27
+ /** First id in the reserved test-fixture band. */
28
+ const RESERVED_TEST_ID_MIN = 999000;
29
+
30
+ /** Last id in the reserved test-fixture band. */
31
+ const RESERVED_TEST_ID_MAX = 999999;
32
+
33
+ /**
34
+ * Human-readable band, for guard failure messages that have to tell an
35
+ * operator which ids they may not use for real work.
36
+ */
37
+ export const RESERVED_TEST_ID_BAND = `${RESERVED_TEST_ID_MIN}–${RESERVED_TEST_ID_MAX}`;
38
+
39
+ /**
40
+ * Is `id` inside the reserved test-fixture band?
41
+ *
42
+ * Exact-band membership, because this is the predicate the temp reaper and
43
+ * the pollution guard share: both classify an on-disk directory that a test
44
+ * created, and over-reaching (treating every large id as reserved) would let
45
+ * one of them delete or condemn a directory the band never claimed.
46
+ *
47
+ * @param {unknown} id
48
+ * @returns {boolean}
49
+ */
50
+ export function isReservedTestId(id) {
51
+ return (
52
+ Number.isInteger(id) &&
53
+ id >= RESERVED_TEST_ID_MIN &&
54
+ id <= RESERVED_TEST_ID_MAX
55
+ );
56
+ }
57
+
58
+ /**
59
+ * May `id` be published as ticket evidence in operator-visible output?
60
+ *
61
+ * The floor is the reserved band, not the band itself: an id at or above
62
+ * `RESERVED_TEST_ID_MIN` is synthetic by construction (the framework reserves
63
+ * that space for fixtures, and no repository has minted that many issues), so
64
+ * nothing at or beyond the floor can be resolved to a real issue. A
65
+ * non-integer or non-positive id is not a ticket reference at all.
66
+ *
67
+ * This is a plausibility bound, not an existence probe: it is the strongest
68
+ * statement a caller with no ticket-provider access can make, and it is what
69
+ * keeps the invariant true at every call site instead of only where somebody
70
+ * remembered to wire a probe.
71
+ *
72
+ * @param {unknown} id
73
+ * @returns {boolean}
74
+ */
75
+ export function isPublishableTicketId(id) {
76
+ return Number.isInteger(id) && id > 0 && id < RESERVED_TEST_ID_MIN;
77
+ }