mandrel 2.24.0 → 2.26.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 (189) hide show
  1. package/.agents/agents/acceptance-critic.md +19 -21
  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/baselines.md +21 -0
  6. package/.agents/audit-checklists/quality.md +3 -0
  7. package/.agents/docs/agentrc-reference.json +2 -1
  8. package/.agents/docs/configuration.md +2 -1
  9. package/.agents/docs/quality-gates.md +80 -18
  10. package/.agents/docs/workflows.md +4 -2
  11. package/.agents/instructions.md +1 -1
  12. package/.agents/rules/ci-remediation.md +68 -3
  13. package/.agents/schemas/agentrc.schema.json +6 -1
  14. package/.agents/schemas/audit-rules.json +15 -0
  15. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  16. package/.agents/schemas/baselines/baseline-envelope.schema.json +8 -0
  17. package/.agents/schemas/baselines/crap.schema.json +13 -1
  18. package/.agents/schemas/crap-report.schema.json +37 -0
  19. package/.agents/schemas/model-attribution.schema.json +4 -0
  20. package/.agents/scripts/acceptance-eval.js +124 -15
  21. package/.agents/scripts/audit-baselines.js +136 -0
  22. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  23. package/.agents/scripts/audit-to-stories.js +33 -3
  24. package/.agents/scripts/check-arch-cycles.js +12 -93
  25. package/.agents/scripts/check-baseline-drift.js +16 -3
  26. package/.agents/scripts/check-baselines.js +19 -3
  27. package/.agents/scripts/check-context-budget.js +95 -14
  28. package/.agents/scripts/check-cyclomatic.js +214 -0
  29. package/.agents/scripts/check-schema-references.js +392 -0
  30. package/.agents/scripts/check-test-temp-hygiene.js +121 -1
  31. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  32. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  33. package/.agents/scripts/deliver-light.js +3 -4
  34. package/.agents/scripts/deliver-recover.js +13 -0
  35. package/.agents/scripts/diagnose-friction.js +85 -19
  36. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  37. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  38. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  39. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  40. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  41. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  42. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  43. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  44. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  45. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  46. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  47. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  48. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  49. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  50. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  51. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  52. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  53. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  54. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  55. package/.agents/scripts/lib/baseline-loader.js +0 -0
  56. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  57. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  58. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  59. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  60. package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
  61. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  62. package/.agents/scripts/lib/baselines/reader.js +10 -0
  63. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  64. package/.agents/scripts/lib/baselines/scope.js +39 -90
  65. package/.agents/scripts/lib/baselines/writer.js +16 -7
  66. package/.agents/scripts/lib/changed-files.js +8 -1
  67. package/.agents/scripts/lib/cli-args.js +115 -1
  68. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  69. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  70. package/.agents/scripts/lib/crap-engine.js +124 -27
  71. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  72. package/.agents/scripts/lib/crap-utils.js +86 -13
  73. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  74. package/.agents/scripts/lib/env-loader.js +46 -16
  75. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  76. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  77. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  78. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  79. package/.agents/scripts/lib/findings/severity.js +80 -2
  80. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  81. package/.agents/scripts/lib/git-utils.js +136 -80
  82. package/.agents/scripts/lib/import-graph.js +156 -0
  83. package/.agents/scripts/lib/label-constants.js +17 -0
  84. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  85. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  86. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  87. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  88. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  89. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  90. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  91. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  92. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  93. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  94. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  95. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  96. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  97. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  98. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  99. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  100. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  101. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  102. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +216 -109
  103. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
  104. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  105. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  106. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  107. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  108. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  109. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  110. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  111. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  112. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  113. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
  114. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  115. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  116. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  117. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  118. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
  119. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  120. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  121. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  122. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  123. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  124. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  125. package/.agents/scripts/lib/story-adjacency.js +3 -3
  126. package/.agents/scripts/lib/story-plan.js +137 -42
  127. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  128. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  129. package/.agents/scripts/lib/test-tiers.js +11 -2
  130. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  131. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  132. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  133. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  134. package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
  135. package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
  136. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
  137. package/.agents/scripts/lib/workers/crap-worker.js +8 -13
  138. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  139. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  140. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  141. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  142. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  143. package/.agents/scripts/lib/worktree-manager.js +14 -0
  144. package/.agents/scripts/plan-run-epilogue.js +17 -5
  145. package/.agents/scripts/pr-watch-with-update.js +568 -213
  146. package/.agents/scripts/providers/github/tickets.js +33 -10
  147. package/.agents/scripts/provision-git-hooks.js +85 -0
  148. package/.agents/scripts/quality-preview.js +137 -28
  149. package/.agents/scripts/resolve-stories.js +4 -1
  150. package/.agents/scripts/run-coverage.js +86 -35
  151. package/.agents/scripts/run-lint.js +20 -0
  152. package/.agents/scripts/run-tests.js +36 -36
  153. package/.agents/scripts/single-story-close.js +34 -2
  154. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  155. package/.agents/scripts/single-story-init.js +7 -0
  156. package/.agents/scripts/stories-wave-tick.js +308 -47
  157. package/.agents/scripts/story-plan.js +65 -9
  158. package/.agents/scripts/update-coverage-baseline.js +34 -4
  159. package/.agents/scripts/update-crap-baseline.js +42 -4
  160. package/.agents/scripts/update-duplication-baseline.js +209 -83
  161. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  162. package/.agents/scripts/validate-docs-freshness.js +1 -0
  163. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  164. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  165. package/.agents/skills/skills.index.json +2 -2
  166. package/.agents/templates/single-story-body.md +16 -8
  167. package/.agents/workflows/audit-architecture.md +69 -13
  168. package/.agents/workflows/audit-baselines.md +289 -0
  169. package/.agents/workflows/audit-navigability.md +5 -4
  170. package/.agents/workflows/audit-quality.md +26 -0
  171. package/.agents/workflows/audit-to-stories.md +30 -4
  172. package/.agents/workflows/deliver.md +92 -97
  173. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  174. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  175. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  176. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  177. package/.agents/workflows/helpers/deliver-reference.md +95 -4
  178. package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
  179. package/.agents/workflows/helpers/deliver-story.md +104 -119
  180. package/.agents/workflows/helpers/plan-reference.md +49 -5
  181. package/.agents/workflows/memory-consolidate.md +116 -0
  182. package/.agents/workflows/plan.md +62 -75
  183. package/README.md +13 -6
  184. package/docs/CHANGELOG.md +93 -0
  185. package/lib/cli/update.js +14 -11
  186. package/lib/cli/version-check.js +9 -1
  187. package/package.json +9 -4
  188. package/.agents/schemas/friction-event.schema.json +0 -56
  189. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -9,23 +9,27 @@
9
9
  */
10
10
 
11
11
  import path from 'node:path';
12
- import { parseSprintArgs } from '../../../cli-args.js';
12
+ import { parseMergeWatchMode, parseSprintArgs } from '../../../cli-args.js';
13
13
  import { getDeliveryRouting } from '../../../config/delivery-routing.js';
14
14
  import { PROJECT_ROOT } from '../../../project-root.js';
15
15
  import { isOperatorMergeReason } from './auto-merge.js';
16
16
 
17
17
  /**
18
- * Resolve a flag value from an explicit override, a parsed CLI arg, or a
19
- * hard default.
18
+ * Resolve a flag value from an explicit override or a parsed CLI arg.
19
+ *
20
+ * Returns `undefined` when neither is supplied — that absence is itself the
21
+ * answer here, letting each caller below apply its own default (a `!!` coerce,
22
+ * a config lookup, or a deliberate `undefined` passed further down). The
23
+ * former third `defaultValue` parameter was dropped in Story #4961: no call
24
+ * site passed it, so it documented a mode nobody used.
20
25
  *
21
26
  * @template T
22
27
  * @param {T|undefined} paramValue
23
28
  * @param {T|undefined} parsedValue
24
- * @param {T} defaultValue
25
- * @returns {T}
29
+ * @returns {T|undefined}
26
30
  */
27
- function resolveFlag(paramValue, parsedValue, defaultValue) {
28
- return paramValue ?? parsedValue ?? defaultValue;
31
+ function resolveFlag(paramValue, parsedValue) {
32
+ return paramValue ?? parsedValue;
29
33
  }
30
34
 
31
35
  /**
@@ -87,8 +91,8 @@ export function resolveWaitForMerge({
87
91
  * (`waitForMergeExplicit` / `noWaitForMerge`) for the runner to resolve once
88
92
  * the config and the arm outcome exist.
89
93
  *
90
- * @param {{ storyIdParam, cwdParam, skipValidationParam, skipSyncParam, noAutoMergeParam, waitForMergeParam, noWaitForMergeParam, maxWaitSecondsParam }} raw
91
- * @returns {{ storyId, cwd, skipValidation, skipSync, noAutoMerge, waitForMergeExplicit, noWaitForMerge, maxWaitSeconds }}
94
+ * @param {{ storyIdParam, cwdParam, skipValidationParam, skipSyncParam, noAutoMergeParam, waitForMergeParam, noWaitForMergeParam, maxWaitSecondsParam, mergeWatchModeParam }} raw
95
+ * @returns {{ storyId, cwd, skipValidation, skipSync, noAutoMerge, waitForMergeExplicit, noWaitForMerge, maxWaitSeconds, mergeWatchMode }}
92
96
  */
93
97
  export function parseCloseOptions({
94
98
  storyIdParam,
@@ -99,26 +103,27 @@ export function parseCloseOptions({
99
103
  waitForMergeParam,
100
104
  noWaitForMergeParam,
101
105
  maxWaitSecondsParam,
106
+ mergeWatchModeParam,
102
107
  }) {
103
- const parsed =
104
- storyIdParam !== undefined
105
- ? {
106
- storyId: storyIdParam,
107
- cwd: cwdParam ?? null,
108
- skipValidation: !!skipValidationParam,
109
- skipSync: !!skipSyncParam,
110
- noAutoMerge: !!noAutoMergeParam,
111
- // Preserve undefined so resolveWaitForMerge can apply the
112
- // closeAndLand config default when neither flag was injected.
113
- waitForMerge: waitForMergeParam,
114
- noWaitForMerge: !!noWaitForMergeParam,
115
- maxWaitSeconds: maxWaitSecondsParam,
116
- }
117
- : parseSprintArgs();
118
- const waitForMergeExplicit = waitForMergeParam ?? parsed.waitForMerge;
119
- const maxWaitSeconds = maxWaitSecondsParam ?? parsed.maxWaitSeconds;
108
+ // An injecting caller (`storyIdParam` supplied) is not reading argv at all,
109
+ // so there is nothing to parse and `parsed` stays empty. This used to build a
110
+ // stand-in object that copied every `*Param` into the slot of the same name —
111
+ // which is precisely what `resolveFlag` already does below, preferring the
112
+ // param over the parsed slot. One expression per flag now serves both
113
+ // callers, so a new flag is added in one place instead of two that can drift.
114
+ const parsed = storyIdParam === undefined ? parseSprintArgs() : {};
115
+ // Preserve undefined so resolveWaitForMerge can apply the closeAndLand
116
+ // config default when neither flag was supplied.
117
+ const waitForMergeExplicit = resolveFlag(
118
+ waitForMergeParam,
119
+ parsed.waitForMerge,
120
+ );
121
+ const maxWaitSeconds = resolveFlag(
122
+ maxWaitSecondsParam,
123
+ parsed.maxWaitSeconds,
124
+ );
120
125
  return {
121
- storyId: parsed.storyId,
126
+ storyId: resolveFlag(storyIdParam, parsed.storyId),
122
127
  cwd: path.resolve(cwdParam ?? parsed.cwd ?? PROJECT_ROOT),
123
128
  // `undefined` when unsupplied — the merge wait then reads
124
129
  // `delivery.mergeWatch.maxWaitSeconds`. A per-run override exists so a
@@ -128,21 +133,20 @@ export function parseCloseOptions({
128
133
  Number.isInteger(maxWaitSeconds) && maxWaitSeconds > 0
129
134
  ? maxWaitSeconds
130
135
  : undefined,
131
- skipValidation: resolveFlag(
132
- skipValidationParam,
133
- parsed.skipValidation,
134
- false,
136
+ // `undefined` when unsupplied — the merge wait then reads
137
+ // `delivery.mergeWatch.mode`. The two merge-watch flags stay composable and
138
+ // mode-agnostic: `--merge-watch-mode async` picks the posture, and an
139
+ // explicit `--max-wait-seconds` still wins over that posture's probe cap.
140
+ mergeWatchMode: parseMergeWatchMode(
141
+ resolveFlag(mergeWatchModeParam, parsed.mergeWatchMode),
135
142
  ),
136
- skipSync: resolveFlag(skipSyncParam, parsed.skipSync, false),
137
- noAutoMerge: resolveFlag(noAutoMergeParam, parsed.noAutoMerge, false),
143
+ skipValidation: !!resolveFlag(skipValidationParam, parsed.skipValidation),
144
+ skipSync: !!resolveFlag(skipSyncParam, parsed.skipSync),
145
+ noAutoMerge: !!resolveFlag(noAutoMergeParam, parsed.noAutoMerge),
138
146
  waitForMergeExplicit:
139
147
  typeof waitForMergeExplicit === 'boolean'
140
148
  ? waitForMergeExplicit
141
149
  : undefined,
142
- noWaitForMerge: resolveFlag(
143
- noWaitForMergeParam,
144
- parsed.noWaitForMerge,
145
- false,
146
- ),
150
+ noWaitForMerge: !!resolveFlag(noWaitForMergeParam, parsed.noWaitForMerge),
147
151
  };
148
152
  }
@@ -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}`,
@@ -42,7 +42,12 @@ export function pushStoryBranch({
42
42
  }) {
43
43
  progress('GIT', `Pushing ${storyBranch} to origin...`);
44
44
  try {
45
- gitSync(cwd, 'push', '--no-verify', '-u', 'origin', storyBranch);
45
+ // No hook-bypass flag here, deliberately. Close runs its own gate chain
46
+ // before this point, but `--skip-validation` skips that chain, and the
47
+ // bypass then left nothing running at all. `pre-push` is the backstop,
48
+ // and it only became reachable once hooks were materialized into
49
+ // worktrees — which is where every Story branch is built.
50
+ gitSync(cwd, 'push', '-u', 'origin', storyBranch);
46
51
  progress('GIT', `✅ Pushed ${storyBranch}.`);
47
52
  } catch (err) {
48
53
  throw new Error(