mandrel 1.76.0 → 1.77.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 (46) hide show
  1. package/.agents/docs/configuration.md +2 -2
  2. package/.agents/schemas/agentrc.schema.json +1 -1
  3. package/.agents/schemas/dispatch-manifest.json +1 -1
  4. package/.agents/schemas/validation-evidence.schema.json +2 -1
  5. package/.agents/scripts/audit-to-stories.js +43 -1
  6. package/.agents/scripts/epic-deliver-prepare.js +31 -0
  7. package/.agents/scripts/evidence-gate.js +48 -12
  8. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +141 -34
  9. package/.agents/scripts/lib/cli-args.js +6 -0
  10. package/.agents/scripts/lib/close-validation/runner.js +25 -8
  11. package/.agents/scripts/lib/config/temp-paths.js +1 -1
  12. package/.agents/scripts/lib/config/worktree-isolation.js +18 -3
  13. package/.agents/scripts/lib/config-resolver.js +4 -1
  14. package/.agents/scripts/lib/config-settings-schema-delivery.js +1 -1
  15. package/.agents/scripts/lib/git-branch-lifecycle.js +90 -0
  16. package/.agents/scripts/lib/orchestration/auto-merge-cwd.js +128 -0
  17. package/.agents/scripts/lib/orchestration/column-sync.js +88 -9
  18. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +20 -2
  19. package/.agents/scripts/lib/orchestration/project-meta-cache.js +238 -0
  20. package/.agents/scripts/lib/orchestration/reassert-status-column.js +3 -1
  21. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +25 -2
  22. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +80 -14
  23. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +74 -25
  24. package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +10 -1
  25. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +48 -1
  26. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +148 -4
  27. package/.agents/scripts/lib/orchestration/ticketing/transition.js +8 -1
  28. package/.agents/scripts/lib/story-body/story-body.js +76 -7
  29. package/.agents/scripts/lib/story-init/branch-initializer.js +29 -43
  30. package/.agents/scripts/lib/story-init/hierarchy-tracer.js +25 -4
  31. package/.agents/scripts/lib/story-init/task-graph-builder.js +22 -12
  32. package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -0
  33. package/.agents/scripts/lib/validation-evidence.js +63 -25
  34. package/.agents/scripts/lib/worktree/node-modules-strategy.js +239 -31
  35. package/.agents/scripts/resync-status-column.js +5 -0
  36. package/.agents/scripts/run-coverage.js +85 -45
  37. package/.agents/scripts/single-story-init.js +22 -29
  38. package/.agents/scripts/story-init.js +38 -63
  39. package/.agents/scripts/story-phase.js +46 -4
  40. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  41. package/.agents/workflows/helpers/acceptance-self-eval.md +27 -0
  42. package/.agents/workflows/helpers/deliver-epic.md +19 -2
  43. package/.agents/workflows/helpers/epic-deliver-story.md +50 -14
  44. package/.agents/workflows/helpers/single-story-deliver.md +12 -0
  45. package/docs/CHANGELOG.md +33 -0
  46. package/package.json +1 -1
@@ -10,10 +10,26 @@
10
10
  * silently disabling worktrees when the operator omitted the block).
11
11
  */
12
12
 
13
+ /**
14
+ * Default `nodeModulesStrategy`, platform-aware (Story #4249).
15
+ *
16
+ * darwin/linux default to `clone` — a copy-on-write (clonefile/reflink) clone
17
+ * of the donor's `node_modules` that is effectively free in time and disk on
18
+ * APFS / reflink-capable filesystems, with a clean fall-back to `per-worktree`
19
+ * on any failure (unsupported fs, cross-volume, etc.). Windows has no reflink
20
+ * equivalent on this path, so it keeps the `per-worktree` install default.
21
+ *
22
+ * @param {NodeJS.Platform} [platform]
23
+ * @returns {'clone' | 'per-worktree'}
24
+ */
25
+ export function defaultNodeModulesStrategy(platform = process.platform) {
26
+ return platform === 'win32' ? 'per-worktree' : 'clone';
27
+ }
28
+
13
29
  export const WORKTREE_ISOLATION_DEFAULTS = Object.freeze({
14
30
  enabled: true,
15
31
  root: '.worktrees',
16
- nodeModulesStrategy: 'per-worktree',
32
+ nodeModulesStrategy: defaultNodeModulesStrategy(),
17
33
  primeFromPath: null,
18
34
  allowSymlinkOnWindows: false,
19
35
  reapOnSuccess: true,
@@ -55,8 +71,7 @@ export function getWorktreeIsolation(config) {
55
71
  ? wi.enabled
56
72
  : WORKTREE_ISOLATION_DEFAULTS.enabled,
57
73
  root: wi.root ?? WORKTREE_ISOLATION_DEFAULTS.root,
58
- nodeModulesStrategy:
59
- wi.nodeModulesStrategy ?? WORKTREE_ISOLATION_DEFAULTS.nodeModulesStrategy,
74
+ nodeModulesStrategy: wi.nodeModulesStrategy ?? defaultNodeModulesStrategy(),
60
75
  primeFromPath:
61
76
  wi.primeFromPath === undefined
62
77
  ? WORKTREE_ISOLATION_DEFAULTS.primeFromPath
@@ -63,7 +63,10 @@ export {
63
63
  } from './config/runtime.js';
64
64
  export { resolveListValue } from './config/shared.js';
65
65
  export { validateOrchestrationConfig } from './config/validate-orchestration.js';
66
- export { WORKTREE_ISOLATION_DEFAULTS } from './config/worktree-isolation.js';
66
+ export {
67
+ defaultNodeModulesStrategy,
68
+ WORKTREE_ISOLATION_DEFAULTS,
69
+ } from './config/worktree-isolation.js';
67
70
  export { PROJECT_ROOT } from './project-root.js';
68
71
 
69
72
  // Cache keyed by absolute root path so callers passing different cwds
@@ -105,7 +105,7 @@ const WORKTREE_ISOLATION_SCHEMA = {
105
105
  root: { type: 'string', minLength: 1 },
106
106
  nodeModulesStrategy: {
107
107
  type: 'string',
108
- enum: ['per-worktree', 'symlink', 'pnpm-store'],
108
+ enum: ['per-worktree', 'clone', 'symlink', 'pnpm-store'],
109
109
  },
110
110
  primeFromPath: { type: ['string', 'null'], minLength: 1 },
111
111
  allowSymlinkOnWindows: { type: 'boolean' },
@@ -114,6 +114,96 @@ export function classifyBranchSeed({ localHas, remoteHas }) {
114
114
  return 'create';
115
115
  }
116
116
 
117
+ /**
118
+ * Single-home for the story-branch seed-action *switch shell* that
119
+ * `single-story-init.js#seedStoryBranch` (standalone path) and
120
+ * `story-init/branch-initializer.js#ensureStoryBranchSeed` (Epic path) had
121
+ * each re-implemented (Story #4255). Both already delegated the (local,
122
+ * remote) decision to `classifyBranchSeed`; only the act-on-the-decision
123
+ * shell (reuse / fetch / create) was duplicated, and that shell was the
124
+ * drift surface for the seed-decision contract.
125
+ *
126
+ * The two callers differ in exactly two behavioural axes, both of which are
127
+ * parameters here — no other conditional branching is introduced:
128
+ * - **`baseRef`** — the ref to branch from on `create` (`main` for the
129
+ * standalone path, the Epic branch for the Epic path).
130
+ * - **`swallowCreateRace`** — when `true`, a `git branch` that exits
131
+ * non-zero with an "already exists" stderr is treated as reuse rather
132
+ * than a fatal error (closes the probe→create race the Epic path runs
133
+ * under concurrent wave dispatch). When `false`, any create failure
134
+ * throws (the standalone path has no concurrent creator to race).
135
+ *
136
+ * The asymmetric surrounding wrappers (merged-sweep, fast-forward,
137
+ * donor-prime, workspace-verify, phase-timer) are deliberately NOT folded
138
+ * in — they stay in their respective callers.
139
+ *
140
+ * Caller-specific log lines and error text are passed in as the `messages`
141
+ * data bag so behaviour stays byte-identical to the pre-extraction switches.
142
+ * The git seams (`spawn`, `existsLocally`, `existsRemotely`) are injected so
143
+ * each caller can bind its own cwd (and tests can mock them).
144
+ *
145
+ * @param {object} opts
146
+ * @param {string} opts.storyBranch
147
+ * @param {string} opts.baseRef Ref to branch from on `create`.
148
+ * @param {boolean} [opts.swallowCreateRace=false]
149
+ * @param {(args: string[]) => { status: number, stdout?: string, stderr?: string }} opts.spawn
150
+ * @param {(branch: string) => boolean} opts.existsLocally
151
+ * @param {(branch: string) => boolean} opts.existsRemotely
152
+ * @param {(level: string, message: string) => void} [opts.progress]
153
+ * @param {object} opts.messages
154
+ * @param {(b: string) => string} opts.messages.reuse
155
+ * @param {(b: string) => string} opts.messages.fetch
156
+ * @param {(b: string, ref: string) => string} opts.messages.create
157
+ * @param {(b: string) => string} [opts.messages.createRace] Used when `swallowCreateRace`.
158
+ * @param {(b: string, ref: string, stderr: string) => string} opts.messages.createError
159
+ * @param {(b: string, stderr: string) => string} [opts.messages.fetchError]
160
+ * When provided, a non-zero `fetch` exit throws with this message; when
161
+ * omitted, the fetch exit status is not inspected.
162
+ */
163
+ export function seedStoryBranchRef({
164
+ storyBranch,
165
+ baseRef,
166
+ swallowCreateRace = false,
167
+ spawn,
168
+ existsLocally,
169
+ existsRemotely,
170
+ progress = () => {},
171
+ messages,
172
+ }) {
173
+ const action = classifyBranchSeed({
174
+ localHas: existsLocally(storyBranch),
175
+ remoteHas: existsRemotely(storyBranch),
176
+ });
177
+
178
+ if (action === 'local') {
179
+ progress('GIT', messages.reuse(storyBranch));
180
+ return;
181
+ }
182
+
183
+ if (action === 'fetch') {
184
+ progress('GIT', messages.fetch(storyBranch));
185
+ const r = spawn(['fetch', 'origin', `${storyBranch}:${storyBranch}`]);
186
+ if (messages.fetchError && r.status !== 0) {
187
+ throw new Error(
188
+ messages.fetchError(storyBranch, r.stderr || '(no stderr)'),
189
+ );
190
+ }
191
+ return;
192
+ }
193
+
194
+ // action === 'create'
195
+ progress('GIT', messages.create(storyBranch, baseRef));
196
+ const r = spawn(['branch', storyBranch, baseRef]);
197
+ if (r.status !== 0) {
198
+ const stderr = r.stderr || r.stdout || '';
199
+ if (swallowCreateRace && /already exists/i.test(stderr)) {
200
+ progress('GIT', messages.createRace(storyBranch));
201
+ return;
202
+ }
203
+ throw new Error(messages.createError(storyBranch, baseRef, stderr));
204
+ }
205
+ }
206
+
117
207
  /**
118
208
  * Ensure an Epic branch exists and is published to `origin`. Handles all
119
209
  * four states of the (local, remote) matrix.
@@ -0,0 +1,128 @@
1
+ /**
2
+ * auto-merge-cwd.js — resolve a worktree-collision-safe cwd for arming
3
+ * GitHub native auto-merge (Story #4282).
4
+ *
5
+ * Root cause this module exists to defeat:
6
+ * Arming auto-merge runs, in effect,
7
+ * `gh pr merge <pr> --auto --squash --delete-branch`. The
8
+ * `--delete-branch` flag makes `gh` shell out to local `git` to leave
9
+ * and delete the PR head branch — including a `git checkout <base>` to
10
+ * switch the working tree off the head branch. When the arm runs from a
11
+ * per-Story worktree cwd (checked out on the head branch `story-<id>`)
12
+ * while the base branch (`main`) is already occupied by the primary
13
+ * worktree, `gh`'s internal `git checkout <base>` collides:
14
+ *
15
+ * fatal: '<base>' is already used by worktree at '<primary>'
16
+ *
17
+ * The arm fails (non-fatally), defeating the unattended auto-merge
18
+ * contract — the operator must re-run the merge manually from a clean cwd.
19
+ *
20
+ * Fix (advisory direction #1 from the Story — "ensure the cwd is already
21
+ * on the base branch so gh's `git checkout <base>` is a no-op"):
22
+ * Re-point the arm at the **primary worktree root** — the working tree
23
+ * that holds the base branch — discovered via `git worktree list
24
+ * --porcelain`. From the primary worktree, `gh`'s `--delete-branch`
25
+ * cleanup never has to `git checkout <base>` (it is already there), so
26
+ * the collision cannot occur. `--delete-branch` is preserved verbatim,
27
+ * so the PR head branch is still removed on merge with no dependency on
28
+ * the consumer's repo-level "auto-delete head branches" toggle.
29
+ *
30
+ * Non-fatal by construction: any failure to resolve the primary worktree
31
+ * (not a git repo, `git` missing, single-worktree layout, parse failure)
32
+ * degrades to returning the original `cwd` unchanged. Worst case is the
33
+ * pre-fix behaviour; this helper never throws and never blocks arming.
34
+ */
35
+
36
+ import { gitSpawn as defaultGitSpawn } from '../git-utils.js';
37
+
38
+ /**
39
+ * Parse `git worktree list --porcelain` output into structured records.
40
+ *
41
+ * The porcelain format emits one stanza per worktree, blank-line
42
+ * separated, e.g.:
43
+ *
44
+ * worktree /abs/path/to/primary
45
+ * HEAD <sha>
46
+ * branch refs/heads/main
47
+ *
48
+ * worktree /abs/path/to/.worktrees/story-4282
49
+ * HEAD <sha>
50
+ * branch refs/heads/story-4282
51
+ *
52
+ * A linked worktree with a detached HEAD emits `detached` instead of a
53
+ * `branch` line. Pure — exported for tests.
54
+ *
55
+ * @param {string} stdout
56
+ * @returns {Array<{ path: string, branch: string|null }>}
57
+ */
58
+ export function parseWorktreeList(stdout) {
59
+ const text = String(stdout ?? '');
60
+ const records = [];
61
+ let current = null;
62
+ for (const rawLine of text.split('\n')) {
63
+ const line = rawLine.replace(/\r$/, '');
64
+ if (line.startsWith('worktree ')) {
65
+ if (current) records.push(current);
66
+ current = { path: line.slice('worktree '.length).trim(), branch: null };
67
+ } else if (line.startsWith('branch ') && current) {
68
+ current.branch = line
69
+ .slice('branch '.length)
70
+ .trim()
71
+ .replace(/^refs\/heads\//, '');
72
+ }
73
+ // `HEAD <sha>`, `detached`, `bare`, `locked`, `prunable` lines carry
74
+ // no field we need; ignored.
75
+ }
76
+ if (current) records.push(current);
77
+ return records;
78
+ }
79
+
80
+ /**
81
+ * Pick the primary worktree from a parsed worktree list. The primary
82
+ * worktree is the first stanza `git worktree list` emits — it is the
83
+ * original (non-linked) working tree and, during delivery, the one that
84
+ * holds the base branch. Returns its absolute path, or `null` when the
85
+ * list is empty / unparseable.
86
+ *
87
+ * Pure — exported for tests.
88
+ *
89
+ * @param {Array<{ path: string, branch: string|null }>} records
90
+ * @returns {string|null}
91
+ */
92
+ export function pickPrimaryWorktreePath(records) {
93
+ if (!Array.isArray(records) || records.length === 0) return null;
94
+ const first = records[0];
95
+ return first && typeof first.path === 'string' && first.path.length > 0
96
+ ? first.path
97
+ : null;
98
+ }
99
+
100
+ /**
101
+ * Resolve a worktree-collision-safe cwd for arming auto-merge.
102
+ *
103
+ * Returns the primary worktree root (which holds the base branch) when it
104
+ * can be discovered AND it differs from `cwd`; otherwise returns `cwd`
105
+ * unchanged. Never throws.
106
+ *
107
+ * @param {string} cwd — the cwd the caller would otherwise arm from
108
+ * (often a per-Story worktree on the head branch).
109
+ * @param {{ gitSpawn?: typeof import('../git-utils.js').gitSpawn }} [deps]
110
+ * @returns {string} a cwd safe to run `gh pr merge --delete-branch` from.
111
+ */
112
+ export function resolveAutoMergeArmCwd(
113
+ cwd,
114
+ { gitSpawn = defaultGitSpawn } = {},
115
+ ) {
116
+ if (typeof cwd !== 'string' || cwd.length === 0) return cwd;
117
+ try {
118
+ const result = gitSpawn(cwd, 'worktree', 'list', '--porcelain');
119
+ if (!result || result.status !== 0) return cwd;
120
+ const primary = pickPrimaryWorktreePath(parseWorktreeList(result.stdout));
121
+ if (!primary) return cwd;
122
+ return primary;
123
+ } catch {
124
+ // Any unexpected failure (git missing, non-repo cwd, etc.) degrades
125
+ // to the original cwd — arming stays best-effort.
126
+ return cwd;
127
+ }
128
+ }
@@ -35,6 +35,11 @@
35
35
  */
36
36
 
37
37
  import { AGENT_LABELS } from '../label-constants.js';
38
+ import {
39
+ invalidateProjectMetaCache,
40
+ readProjectMetaCache,
41
+ writeProjectMetaCache,
42
+ } from './project-meta-cache.js';
38
43
  import { resolveProjectMeta } from './project-meta-resolver.js';
39
44
 
40
45
  export const LABEL_TO_COLUMN = Object.freeze({
@@ -74,6 +79,7 @@ export class ColumnSync {
74
79
  * projectNumber?: number | null,
75
80
  * projectOwner?: string | null,
76
81
  * logger?: { info: Function, warn: Function },
82
+ * config?: object,
77
83
  * ctx?: { provider?: object, config?: { github?: { projectNumber?: number|null } }, logger?: object },
78
84
  * }} opts
79
85
  */
@@ -89,7 +95,16 @@ export class ColumnSync {
89
95
  null;
90
96
  this.projectOwner = opts.projectOwner ?? provider.projectOwner ?? null;
91
97
  this.logger = opts.logger ?? ctx?.logger ?? console;
98
+ // Resolved config bag used to locate the on-disk meta cache's tempRoot.
99
+ // Optional — when omitted, the cache resolves the framework-default
100
+ // `temp` root (Story #4252).
101
+ this.config = opts.config ?? ctx?.config ?? undefined;
92
102
  this._meta = null; // lazy-cached { projectId, fieldId, options: Map<name, id> }
103
+ // Records whether the in-process `_meta` was hydrated from the on-disk
104
+ // cache, so a GraphQL error against possibly-stale cached metadata can
105
+ // invalidate the disk entry and force a fresh resolve on the next flip
106
+ // (Story #4252).
107
+ this._metaFromDiskCache = false;
93
108
  }
94
109
 
95
110
  /**
@@ -117,8 +132,9 @@ export class ColumnSync {
117
132
  const itemId = await this.#getProjectItemId(issueId, meta.projectId);
118
133
  if (!itemId) return { status: 'skipped', reason: 'not-on-project' };
119
134
 
120
- await this.provider.graphql(
121
- `
135
+ try {
136
+ await this.provider.graphql(
137
+ `
122
138
  mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
123
139
  updateProjectV2ItemFieldValue(
124
140
  input: {
@@ -129,18 +145,71 @@ export class ColumnSync {
129
145
  }
130
146
  ) { projectV2Item { id } }
131
147
  }`,
132
- {
133
- projectId: meta.projectId,
134
- itemId,
135
- fieldId: meta.fieldId,
136
- optionId,
137
- },
138
- );
148
+ {
149
+ projectId: meta.projectId,
150
+ itemId,
151
+ fieldId: meta.fieldId,
152
+ optionId,
153
+ },
154
+ );
155
+ } catch (err) {
156
+ // A failed mutation against metadata that came from the disk cache
157
+ // most likely means the board was reconfigured since the entry was
158
+ // written (a stale projectId / fieldId / optionId). Invalidate the
159
+ // disk entry so the next flip re-resolves against the live board and
160
+ // self-heals (Story #4252). Re-throw so the caller's existing error
161
+ // handling (e.g. `syncProjectStatusColumn`'s warn) is preserved.
162
+ this.#invalidateMetaCache();
163
+ throw err;
164
+ }
139
165
  return { status: 'synced', column };
140
166
  }
141
167
 
168
+ /**
169
+ * The `(owner, projectNumber)` pair the disk cache is keyed by. Mirrors
170
+ * the owner that `#loadMeta` resolves the board against so a cache hit and
171
+ * a live resolve agree on the same board identity.
172
+ *
173
+ * @returns {{ owner: string|null, projectNumber: number|null }}
174
+ */
175
+ get #cacheBoard() {
176
+ return {
177
+ owner: this.projectOwner ?? this.provider.owner ?? null,
178
+ projectNumber: this.projectNumber,
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Invalidate the on-disk metadata cache entry for this board and drop the
184
+ * in-process copy, so the next `#loadMeta` re-resolves from the live
185
+ * board. Only fires when the current `_meta` came from the disk cache —
186
+ * a freshly-resolved entry that fails the mutation is a transient/live
187
+ * problem, not a stale-cache problem.
188
+ */
189
+ #invalidateMetaCache() {
190
+ if (!this._metaFromDiskCache) return;
191
+ const { owner, projectNumber } = this.#cacheBoard;
192
+ invalidateProjectMetaCache({ owner, projectNumber, config: this.config });
193
+ this._meta = null;
194
+ this._metaFromDiskCache = false;
195
+ }
196
+
142
197
  async #loadMeta() {
143
198
  if (this._meta !== null) return this._meta || null;
199
+ // Disk cache hit short-circuits the ~2 metadata GraphQL round-trips
200
+ // (resolveProjectMeta) — repo-invariant board metadata persists across
201
+ // the cold CLI processes of a single-story delivery (Story #4252).
202
+ const cachedBoard = this.#cacheBoard;
203
+ const cached = readProjectMetaCache({
204
+ owner: cachedBoard.owner,
205
+ projectNumber: cachedBoard.projectNumber,
206
+ config: this.config,
207
+ });
208
+ if (cached) {
209
+ this._meta = cached;
210
+ this._metaFromDiskCache = true;
211
+ return this._meta;
212
+ }
144
213
  try {
145
214
  // Resolve the board by walking the owner-type ladder
146
215
  // (organization → user → viewer) via the shared resolver so the
@@ -176,6 +245,16 @@ export class ColumnSync {
176
245
  fieldId: field.id,
177
246
  options,
178
247
  };
248
+ // Persist the freshly-resolved, repo-invariant metadata so the next
249
+ // cold flip reads it from disk instead of re-paying the resolve
250
+ // (Story #4252). Best-effort: a write failure never blocks the sync.
251
+ writeProjectMetaCache({
252
+ owner: cachedBoard.owner,
253
+ projectNumber: cachedBoard.projectNumber,
254
+ meta: this._meta,
255
+ config: this.config,
256
+ });
257
+ this._metaFromDiskCache = false;
179
258
  return this._meta;
180
259
  } catch (err) {
181
260
  this.logger.warn?.(
@@ -54,6 +54,8 @@
54
54
 
55
55
  import { spawnSync } from 'node:child_process';
56
56
 
57
+ import { resolveAutoMergeArmCwd } from '../../auto-merge-cwd.js';
58
+
57
59
  /**
58
60
  * Default `gh pr view --json autoMergeRequest` probe. Pure-spawn helper
59
61
  * — exported so tests can stub the shell-out without touching the
@@ -77,12 +79,28 @@ export function ghPrViewAutoMerge({ prUrl, cwd, spawnFn = spawnSync }) {
77
79
  * helper. Exported so tests can stub. The arg list is captured in a
78
80
  * single helper so the merge-lockout lint allow-list narrows to one
79
81
  * literal site.
82
+ *
83
+ * Story #4282: `--delete-branch` makes `gh` shell out to local `git`
84
+ * (including a `git checkout <base>`). When this arm runs from a per-Story
85
+ * worktree cwd checked out on the head branch while the base branch is
86
+ * occupied by the primary worktree, that checkout collides
87
+ * (`fatal: '<base>' is already used by worktree`). We re-point the spawn
88
+ * cwd at the primary worktree root (which holds the base branch) via
89
+ * `resolveAutoMergeArmCwd`, so the local checkout is a no-op while
90
+ * `--delete-branch` (head-branch-removed-on-merge) is preserved. The
91
+ * resolver is non-fatal — it degrades to the original cwd.
80
92
  */
81
- export function ghPrMergeAuto({ prUrl, cwd, spawnFn = spawnSync }) {
93
+ export function ghPrMergeAuto({
94
+ prUrl,
95
+ cwd,
96
+ spawnFn = spawnSync,
97
+ resolveArmCwd = resolveAutoMergeArmCwd,
98
+ }) {
99
+ const armCwd = resolveArmCwd(cwd);
82
100
  const result = spawnFn(
83
101
  'gh',
84
102
  ['pr', 'merge', prUrl, '--auto', '--squash', '--delete-branch'],
85
- { cwd, encoding: 'utf-8', shell: false },
103
+ { cwd: armCwd, encoding: 'utf-8', shell: false },
86
104
  );
87
105
  return {
88
106
  status: result.status ?? 1,