@ikunin/sprintpilot 2.1.1 → 2.1.3

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.
@@ -1,14 +1,39 @@
1
1
  // git-plan.js — produce an argv sequence for a git_op action.
2
2
  //
3
- // Given (state, profile, action), return:
4
- // { steps: [{ args, description, retry? }] }
3
+ // Given (state, profile, action), return one of:
4
+ // { branch, steps: [{ args, description, retry?, env?, ... }] }
5
+ // { branch, steps: [], halt_action: { type: 'user_prompt', ... } }
5
6
  //
6
- // Pure. Argv-only no shell strings. The CLI edge executes each step in
7
- // order, halting on first failure. Steps are deterministic: same inputs
8
- // always produce the same argv.
7
+ // Step metadata fields honored by _Sprintpilot/scripts/run-step.js (and
8
+ // the LLM contract in workflow.orchestrator.md):
9
+ // - args: string[] argv (no shell interpolation)
10
+ // - description: string human-readable log line
11
+ // - retry: { attempts, ... } transient-failure retry policy
12
+ // - env: { KEY: value } step-scoped env overrides (used for
13
+ // GH_HOST / GITLAB_URI on self-hosted)
14
+ // - tolerate_exit_codes: [N] non-zero codes treated as success
15
+ // - optional: true non-zero exit logged as warning, not
16
+ // halting (used for best-effort fetches)
17
+ //
18
+ // `halt_action`: alternative plan shape used when the orchestrator can't
19
+ // emit executable steps for a given (platform, op) combination — e.g.
20
+ // MERGE_EPIC under bitbucket/gitea. decorateGitOp surfaces it as a
21
+ // top-level user_prompt action so the autopilot pauses for manual work.
22
+ //
23
+ // Argv-only — no shell strings. Mostly pure; the exceptions are:
24
+ // - buildPrBody() reads profile.pr_template_path from disk (file
25
+ // content is data, not control flow, so determinism holds when the
26
+ // file content is fixed).
27
+ // - planCreateBranch() is invoked after decorateGitOp probes git for
28
+ // branch existence (the impurity lives in decorateGitOp, not here).
29
+ // Steps are deterministic given the same inputs.
9
30
 
10
31
  'use strict';
11
32
 
33
+ const crypto = require('node:crypto');
34
+ const fs = require('node:fs');
35
+ const path = require('node:path');
36
+
12
37
  const { STATES } = require('./state-machine');
13
38
 
14
39
  const STORY_BRANCH_RE = /^[a-z0-9][a-z0-9._-]*$/i;
@@ -21,6 +46,28 @@ function sanitizeStoryKey(key) {
21
46
  return s;
22
47
  }
23
48
 
49
+ // Truncate a branch name to `max` chars, appending `-<8-char hash>` so
50
+ // long story keys don't collide after truncation. 8 hex chars = 32 bits
51
+ // of entropy → ~65,000 truncated-stem variants before 50% collision
52
+ // odds per the birthday bound. Honors git's safe branch-name charset
53
+ // (no `/`, no shell metachars in the hash). Returns the input unchanged
54
+ // when under the limit, or when `max` is falsy.
55
+ const BRANCH_HASH_LEN = 8;
56
+ function truncateBranchName(branch, max) {
57
+ if (!max || typeof max !== 'number' || branch.length <= max) return branch;
58
+ const HASH_SUFFIX_LEN = BRANCH_HASH_LEN + 1; // `-` + hash chars
59
+ if (max <= HASH_SUFFIX_LEN) {
60
+ // Pathological config — fall back to the hash alone so we don't
61
+ // produce an empty branch name.
62
+ return crypto.createHash('sha1').update(branch).digest('hex').slice(0, max);
63
+ }
64
+ const hash = crypto.createHash('sha1').update(branch).digest('hex').slice(0, BRANCH_HASH_LEN);
65
+ const keep = max - HASH_SUFFIX_LEN;
66
+ // Trim any trailing separator so the joined name doesn't look like `foo--abc123`.
67
+ const stem = branch.slice(0, keep).replace(/[-._]+$/, '');
68
+ return `${stem}-${hash}`;
69
+ }
70
+
24
71
  // branchName(profile, storyKey, epicKey, state?)
25
72
  // When `state?.user_branch` is set (because git.reuse_user_branch=true and
26
73
  // the user pre-created a working branch), every story commits to that
@@ -33,14 +80,24 @@ function sanitizeStoryKey(key) {
33
80
  // is "story/", so under nano you get `story/epic-1` (not `epic/1`) — that's
34
81
  // what the existing tooling and e2e tests expect.
35
82
  function branchName(profile, storyKey, epicKey, state) {
83
+ // User-supplied branch: return verbatim. The user named that branch
84
+ // deliberately and silently truncating it would break their mental
85
+ // model + their `git push -u origin <name>` invocations.
36
86
  if (state && state.user_branch) return state.user_branch;
37
87
  const prefix = profile.branch_prefix || 'story/';
38
88
  // git.granularity: 'story' (default) or 'epic'. Nano + large can be epic.
89
+ let name;
39
90
  if (profile.granularity === 'epic' && epicKey) {
40
- return `${prefix}epic-${sanitizeStoryKey(epicKey) || 'unknown'}`;
91
+ name = `${prefix}epic-${sanitizeStoryKey(epicKey) || 'unknown'}`;
92
+ } else {
93
+ const safe = sanitizeStoryKey(storyKey) || 'unknown';
94
+ name = `${prefix}${safe}`;
41
95
  }
42
- const safe = sanitizeStoryKey(storyKey) || 'unknown';
43
- return `${prefix}${safe}`;
96
+ // Honor git.max_branch_length (default 60). When the full branch name
97
+ // exceeds the limit, truncate and append an 8-char hash so collisions
98
+ // between similar story keys remain unique (32 bits of entropy).
99
+ const max = profile.max_branch_length || 60;
100
+ return truncateBranchName(name, max);
44
101
  }
45
102
 
46
103
  // commit_and_push_story — full sequence for STORY_DONE.
@@ -98,30 +155,93 @@ function planCreateBranch(state, profile, branch) {
98
155
  };
99
156
  }
100
157
  const baseBranch = profile.base_branch || 'main';
101
- return {
102
- branch,
103
- steps: [
104
- // Create branch from base; -B is idempotent (creates or resets).
105
- // But for new-story creation we want to fail if it already exists,
106
- // so use -b instead. The CLI edge can downgrade to -B on retry.
107
- {
108
- args: ['git', 'switch', '-c', branch, baseBranch],
109
- description: `create story branch ${branch} from ${baseBranch}`,
110
- },
111
- ],
112
- };
158
+ const hasOrigin = profile.has_origin !== false;
159
+ const steps = [];
160
+
161
+ // Best-effort fetch so the new branch forks from the freshest base.
162
+ // Non-blocking `retry.on: 'network'` lets the runner ignore transient
163
+ // failures. Skipped when there's no origin (local-only repo / tests).
164
+ if (hasOrigin) {
165
+ steps.push({
166
+ args: ['git', 'fetch', 'origin', baseBranch],
167
+ description: `fetch origin/${baseBranch} before branching`,
168
+ retry: { attempts: 2, backoff_ms: [2000, 4000], on: 'network' },
169
+ optional: true,
170
+ });
171
+ }
172
+
173
+ // Branch already on disk (resume after partial failure, or second story
174
+ // on an epic branch). Idempotent switch — never reset the branch from
175
+ // base, since that would discard prior story work.
176
+ if (state && state.branch_exists) {
177
+ steps.push({
178
+ args: ['git', 'switch', branch],
179
+ description: `switch to existing branch ${branch} (already on disk)`,
180
+ });
181
+ return { branch, steps };
182
+ }
183
+
184
+ // Fresh story branch. `switch -c` fails if the branch exists; that is
185
+ // intentional — the edge layer is responsible for probing and setting
186
+ // `state.branch_exists` before we get here, so a collision here means
187
+ // an unexpected race / stale state.
188
+ steps.push({
189
+ args: ['git', 'switch', '-c', branch, baseBranch],
190
+ description: `create story branch ${branch} from ${baseBranch}`,
191
+ });
192
+ return { branch, steps };
113
193
  }
114
194
 
115
195
  function planCommitAndPush(state, profile, action, branch) {
116
196
  const files = Array.isArray(action.files) && action.files.length > 0 ? action.files : null;
117
- const message =
118
- action.message ||
119
- (state.story_key
120
- ? `feat(${state.story_key}): ${state.ac_summary || 'story done'}`
121
- : 'feat: story done');
197
+ const message = action.message || buildStoryCommitMessage(state, profile);
122
198
  const baseBranch = profile.base_branch || 'main';
123
199
  const storyKey = state.story_key || 'sprint';
124
200
  const hasOrigin = profile.has_origin !== false;
201
+ // Collected by buildPrBody when pr_template_path is configured but
202
+ // unreadable. The edge layer (decorateGitOp) surfaces these via the
203
+ // ledger so the failure is visible without writing to stderr from
204
+ // inside a pure-ish plan function.
205
+ const warnings = [];
206
+ // push.auto: false → branches stay local. Both the story-branch push
207
+ // and the base-branch push are suppressed; commits to base still happen
208
+ // so `_bmad-output/` stays in sync locally, the user opts back in by
209
+ // pushing manually.
210
+ const pushAuto = profile.push_auto !== false;
211
+ // push.create_pr: true + merge_strategy=stacked → open one PR per push.
212
+ // Under granularity=story each push targets a unique branch → one PR per
213
+ // story. Under granularity=epic every story pushes to the same epic
214
+ // branch, so this step is called repeatedly but `create-pr.js` is
215
+ // idempotent (it pre-checks `gh pr list --head` and short-circuits when
216
+ // a PR already exists), giving us the documented "one PR per epic"
217
+ // contract for free.
218
+ //
219
+ // The epic PR is later closed by MERGE_EPIC at the epic boundary; the
220
+ // per-story PR (granularity=story) is closed by the user / external
221
+ // automation. `land_as_you_go` opens its own PRs via land.js, so this
222
+ // step is suppressed there.
223
+ const granularity = profile.granularity || 'story';
224
+ const createPr =
225
+ profile.push_create_pr !== false &&
226
+ (profile.merge_strategy || 'stacked') === 'stacked' &&
227
+ hasOrigin &&
228
+ pushAuto &&
229
+ !profile.reuse_user_branch;
230
+
231
+ // push.create_pr: false + merge_strategy=stacked + granularity=story →
232
+ // direct-merge the story branch into base after pushing. Honors the
233
+ // documented behavior in config.yaml#push.create_pr ("merge directly
234
+ // to base after push"). Under granularity=epic the merge happens at
235
+ // MERGE_EPIC instead — direct merge per story would merge incomplete
236
+ // epic work to base on every story, which contradicts the epic
237
+ // granularity contract.
238
+ const directMerge =
239
+ profile.push_create_pr === false &&
240
+ (profile.merge_strategy || 'stacked') === 'stacked' &&
241
+ granularity === 'story' &&
242
+ hasOrigin &&
243
+ pushAuto &&
244
+ !profile.reuse_user_branch;
125
245
 
126
246
  const steps = [];
127
247
 
@@ -142,10 +262,12 @@ function planCommitAndPush(state, profile, action, branch) {
142
262
  });
143
263
  }
144
264
  steps.push({
145
- args: ['git', 'commit', '-m', message],
265
+ // `--message=<msg>` form so a user-customized template producing a
266
+ // leading `-` doesn't get parsed as a flag.
267
+ args: ['git', 'commit', `--message=${message}`],
146
268
  description: `commit on ${branch}`,
147
269
  });
148
- if (hasOrigin) {
270
+ if (hasOrigin && pushAuto) {
149
271
  steps.push({
150
272
  args: ['git', 'push', '-u', 'origin', branch],
151
273
  description: `push ${branch} (retry 4× exponential backoff on network)`,
@@ -153,11 +275,114 @@ function planCommitAndPush(state, profile, action, branch) {
153
275
  });
154
276
  }
155
277
 
278
+ // Open a PR for this story branch (one per branch under granularity=
279
+ // story; idempotent under granularity=epic — see create-pr.js). Skipped
280
+ // when push_create_pr=false, when push.auto=false (no remote ref to PR
281
+ // from), when running land_as_you_go (land.js handles its own PR), or
282
+ // when reuse_user_branch=true (one sprint-end PR, opened externally).
283
+ if (createPr) {
284
+ const prTitle = action.pr_title || message;
285
+ const prBody = action.pr_body || buildPrBody(state, profile, state.project_root, warnings);
286
+ const platform = profile.platform_provider || 'auto';
287
+ const baseUrl = profile.platform_base_url || null;
288
+ const prArgs = [
289
+ 'node',
290
+ '_Sprintpilot/scripts/create-pr.js',
291
+ '--platform',
292
+ platform,
293
+ '--branch',
294
+ branch,
295
+ '--base',
296
+ baseBranch,
297
+ '--title',
298
+ prTitle,
299
+ '--body',
300
+ prBody,
301
+ ];
302
+ if (baseUrl) prArgs.push('--base-url', baseUrl);
303
+ steps.push({
304
+ args: prArgs,
305
+ description: `open PR for ${branch} → ${baseBranch} (idempotent — exits 0 if PR already exists)`,
306
+ // create-pr.js may return exit 2 (SKIPPED — no CLI / git_only). Treat
307
+ // that as success: the user opted into auto-PR but the platform CLI
308
+ // isn't available, which is recoverable manually.
309
+ tolerate_exit_codes: [0, 2],
310
+ });
311
+ }
312
+
313
+ // Direct-merge mode (push.create_pr: false): merge the story branch
314
+ // straight into base instead of opening a PR. The merge brings the
315
+ // full story contents (code + _bmad-output) along, so Phase 2's
316
+ // bmad-output-only sync is skipped. Returns to the story branch at
317
+ // the end so subsequent stories under stacked can push again — even
318
+ // though under granularity=story each story has its own branch and
319
+ // won't be revisited.
320
+ //
321
+ // Conflict recovery: we use `git fetch` + `git merge --ff-only` rather
322
+ // than `git pull --ff-only` so the failure mode is two distinct steps
323
+ // (network vs. divergence) — the runner can retry the fetch but
324
+ // surface the divergence as a user_prompt for manual rebase. There is
325
+ // no automatic rebase recovery in direct-merge mode today; the user
326
+ // owns reconciling a diverged base.
327
+ if (directMerge) {
328
+ const squash = !!profile.squash_on_merge;
329
+ steps.push({
330
+ args: ['git', 'switch', baseBranch],
331
+ description: `switch to ${baseBranch} for direct merge`,
332
+ });
333
+ if (hasOrigin) {
334
+ steps.push({
335
+ args: ['git', 'fetch', 'origin', baseBranch],
336
+ description: `fetch origin/${baseBranch} before merging`,
337
+ retry: { attempts: 2, backoff_ms: [2000, 4000], on: 'network' },
338
+ optional: true,
339
+ });
340
+ steps.push({
341
+ args: ['git', 'merge', '--ff-only', `origin/${baseBranch}`],
342
+ description: `fast-forward ${baseBranch} to origin (halts if base has diverged — manual rebase required)`,
343
+ });
344
+ }
345
+ if (squash) {
346
+ steps.push({
347
+ args: ['git', 'merge', '--squash', branch],
348
+ description: `squash-merge ${branch} into ${baseBranch}`,
349
+ });
350
+ // `--message=<msg>` form so a user-customized commit_template_story
351
+ // that produces a leading `-` doesn't get interpreted as a flag.
352
+ steps.push({
353
+ args: ['git', 'commit', `--message=${message}`],
354
+ description: `commit squash merge on ${baseBranch}`,
355
+ });
356
+ } else {
357
+ // Same `--message=` form for the merge commit.
358
+ steps.push({
359
+ args: ['git', 'merge', '--no-ff', `--message=Merge ${branch}`, branch],
360
+ description: `merge ${branch} into ${baseBranch} (no-ff)`,
361
+ });
362
+ }
363
+ steps.push({
364
+ args: ['git', 'push', 'origin', baseBranch],
365
+ description: `push ${baseBranch}`,
366
+ retry: { attempts: 4, backoff_ms: [2000, 4000, 8000, 16000], on: 'network' },
367
+ });
368
+ steps.push({
369
+ args: ['git', 'switch', branch],
370
+ description: `return to ${branch}`,
371
+ });
372
+ return { branch, steps, warnings: warnings.length ? warnings : undefined };
373
+ }
374
+
156
375
  // Phase 2 — sync `_bmad-output/` to `<base_branch>`. BMad planning +
157
376
  // bookkeeping artifacts must land on main per story so `git log main`
158
377
  // is the canonical sprint audit trail. Without this, planning
159
378
  // artifacts, sprint-status, story files, and reviews exist only on
160
379
  // the story branch.
380
+ //
381
+ // Skipped entirely under direct-merge mode (push_create_pr=false): the
382
+ // full merge into base already carries _bmad-output along, so a
383
+ // dedicated docs-only commit would be redundant noise. Likewise
384
+ // skipped under merge_strategy=land_as_you_go where land.js handles
385
+ // the full merge.
161
386
  steps.push({
162
387
  args: ['git', 'switch', baseBranch],
163
388
  description: `switch to ${baseBranch} to sync BMad artifacts`,
@@ -171,10 +396,10 @@ function planCommitAndPush(state, profile, action, branch) {
171
396
  description: 'stage BMad artifacts',
172
397
  });
173
398
  steps.push({
174
- args: ['git', 'commit', '--allow-empty', '-m', `docs(${storyKey}): BMad artifacts`],
399
+ args: ['git', 'commit', '--allow-empty', `--message=docs(${storyKey}): BMad artifacts`],
175
400
  description: `commit BMad artifacts to ${baseBranch} (--allow-empty for no-diff stories)`,
176
401
  });
177
- if (hasOrigin) {
402
+ if (hasOrigin && pushAuto) {
178
403
  steps.push({
179
404
  args: ['git', 'push', 'origin', baseBranch],
180
405
  description: `push ${baseBranch}`,
@@ -186,19 +411,275 @@ function planCommitAndPush(state, profile, action, branch) {
186
411
  description: `return to ${branch} for the next phase`,
187
412
  });
188
413
 
189
- return { branch, steps };
414
+ return { branch, steps, warnings: warnings.length ? warnings : undefined };
190
415
  }
191
416
 
417
+ // Build the story commit message from profile.commit_template_story with
418
+ // placeholders expanded. Placeholders:
419
+ // {story-key} — state.story_key
420
+ // {epic} — state.current_epic (derived from story_key when absent)
421
+ // {story-title} — state.ac_summary (BMad's acceptance-criteria summary)
422
+ // fallback to story_key
423
+ // Honors the config.yaml#commit_templates.story contract.
424
+ function buildStoryCommitMessage(state, profile) {
425
+ const tmpl = (profile && profile.commit_template_story) || 'feat({epic}): {story-title} ({story-key})';
426
+ const storyKey = state.story_key || 'sprint';
427
+ const epic = state.current_epic || deriveEpicFromStoryKey(storyKey) || 'sprint';
428
+ const title = state.ac_summary || state.story_title || storyKey;
429
+ return expandTemplate(tmpl, { 'story-key': storyKey, epic, 'story-title': title });
430
+ }
431
+
432
+ // Expand `{placeholder}` tokens in `template` from `vars`. Function-form
433
+ // replacement so `$1`/`$&`/`$$` etc. in substituted values are treated
434
+ // as literal characters (string-form `.replace` would interpret them
435
+ // as regex backreferences and corrupt LLM-authored text like
436
+ // "Add $1 button"). Unknown placeholders are left as-is.
437
+ //
438
+ // Case-sensitive: `{story-key}` matches but `{Story-Key}` does not.
439
+ // vars is case-sensitive too, so mixed-case in templates would silently
440
+ // produce no expansion — make it explicit at the regex level instead.
441
+ function expandTemplate(template, vars) {
442
+ if (typeof template !== 'string') return '';
443
+ return template.replace(/\{([a-z][a-z0-9-]*)\}/g, (match, key) => {
444
+ if (Object.prototype.hasOwnProperty.call(vars, key)) {
445
+ return String(vars[key]);
446
+ }
447
+ return match;
448
+ });
449
+ }
450
+
451
+ function deriveEpicFromStoryKey(storyKey) {
452
+ if (typeof storyKey !== 'string') return null;
453
+ const m = storyKey.match(/^(epic-[A-Za-z0-9_]+|[A-Za-z0-9_]+)-/);
454
+ return m ? m[1] : null;
455
+ }
456
+
457
+ // Build the PR body. Precedence:
458
+ // 1. action.pr_body (caller override) [handled by caller, not here]
459
+ // 2. profile.pr_template_path file contents (with placeholders expanded)
460
+ // 3. Minimal one-line default derived from state.
461
+ //
462
+ // The template file path is resolved relative to the project root (the
463
+ // directory git operates in). pr_template_path is configured in
464
+ // modules/git/config.yaml#push.pr_template (defaults to
465
+ // `modules/git/templates/pr-body.md`).
466
+ //
467
+ // Plain-text fallback only — argv is shell-safe today but downstream
468
+ // wrappers might pipe; defense in depth.
469
+ function buildPrBody(state, profile, projectRoot, warnings) {
470
+ const storyKey = state.story_key || 'sprint';
471
+ const title = state.ac_summary || storyKey;
472
+ const profileName = (profile && profile.name) || 'medium';
473
+
474
+ const tmplPath = profile && profile.pr_template_path;
475
+ if (tmplPath && projectRoot) {
476
+ // Resolve relative paths against the _Sprintpilot/ subtree per the
477
+ // config convention. Absolute paths are used verbatim.
478
+ const candidates = [];
479
+ if (path.isAbsolute(tmplPath)) {
480
+ candidates.push(tmplPath);
481
+ } else {
482
+ candidates.push(path.join(projectRoot, '_Sprintpilot', tmplPath));
483
+ candidates.push(path.join(projectRoot, tmplPath));
484
+ }
485
+ for (const p of candidates) {
486
+ try {
487
+ const raw = fs.readFileSync(p, 'utf8');
488
+ return expandTemplate(raw, {
489
+ 'story-key': storyKey,
490
+ epic: state.current_epic || 'sprint',
491
+ 'story-title': title,
492
+ });
493
+ } catch (_e) {
494
+ /* try next candidate */
495
+ }
496
+ }
497
+ // Template configured but unreadable — record on the plan so the
498
+ // edge layer can surface it (e.g. via ledger or stderr) without
499
+ // git-plan.js itself touching stderr from a pure-ish function.
500
+ // Tests can opt into asserting on these warnings; production
501
+ // callers see them in the plan return value.
502
+ if (warnings && Array.isArray(warnings)) {
503
+ warnings.push(`pr_template_path "${tmplPath}" not found; using default body`);
504
+ }
505
+ }
506
+
507
+ return `Story ${storyKey} — ${title}\n\nGenerated by Sprintpilot autopilot (profile: ${profileName}).`;
508
+ }
509
+
510
+ // planMergeEpic — closes out an epic branch at MERGE_EPIC.
511
+ //
512
+ // Two modes, chosen by profile.push_create_pr:
513
+ // • push_create_pr=true (default): merge via the platform PR using
514
+ // `gh pr merge <branch> --squash|--merge --delete-branch`. The PR
515
+ // must already exist (planCommitAndPush opens one per push under
516
+ // stacked + granularity=epic and the idempotency in create-pr.js
517
+ // means subsequent pushes re-use it). Squash governed by
518
+ // profile.squash_on_merge — true under nano + epic per config.yaml.
519
+ // • push_create_pr=false: local merge sequence (existing behavior),
520
+ // fast-forward base from remote first, then merge the epic branch
521
+ // and push base.
522
+ //
523
+ // Both paths honor profile.squash_on_merge. The PR-merge path also
524
+ // requires profile.platform_provider; non-github platforms fall through
525
+ // to the local-merge sequence with a description note.
192
526
  function planMergeEpic(state, profile, _action, branch) {
193
527
  const baseBranch = profile.base_branch || 'main';
194
528
  const squash = !!profile.squash_on_merge;
195
- const steps = [];
529
+ const hasOrigin = profile.has_origin !== false;
530
+ const usePr = profile.push_create_pr !== false;
531
+ const platform = profile.platform_provider || 'auto';
532
+ // Prefer the epic-specific knob; fall back to land_wait_minutes for
533
+ // legacy configs that only set the land-as-you-go value.
534
+ const waitMinutes = Number.isFinite(profile.epic_merge_wait_minutes)
535
+ ? profile.epic_merge_wait_minutes
536
+ : Number.isFinite(profile.land_wait_minutes)
537
+ ? profile.land_wait_minutes
538
+ : 30;
539
+
540
+ // PR-merge path. Two sub-paths by platform.
541
+ if (usePr && hasOrigin) {
542
+ // Self-hosted instances: thread platform_base_url onto the merge
543
+ // step as an `env` map so the platform CLI targets the right host.
544
+ // gh reads `GH_HOST` and uses a per-host token from `~/.config/gh/`.
545
+ // glab reads `GITLAB_URI` (also `GITLAB_HOST` for older versions).
546
+ // The step runner (run-step.js + the LLM contract in
547
+ // workflow.orchestrator.md) merges `env` into `process.env` for the
548
+ // step's lifetime. Empty `env` is a no-op.
549
+ const env = {};
550
+ if (profile.platform_base_url) {
551
+ try {
552
+ const url = new URL(profile.platform_base_url);
553
+ env.GH_HOST = url.host;
554
+ env.GITLAB_URI = profile.platform_base_url;
555
+ } catch (_e) {
556
+ // Malformed base_url — leave env empty; the merge will use the
557
+ // default host and the user will see a clear "wrong host" error.
558
+ }
559
+ }
560
+
561
+ // github / auto: gh pr merge. The autopilot can't safely merge
562
+ // before CI checks complete (branch protection would reject the
563
+ // merge), so we prepend a `create-pr.js --mode checks` wait step
564
+ // when CI gating is plausible. epic_merge_wait_minutes (default 30)
565
+ // is the wait budget; falls back to land_wait_minutes for older
566
+ // configs that only set the land knob.
567
+ if (platform === 'github' || platform === 'auto') {
568
+ const scriptPath = '_Sprintpilot/scripts/create-pr.js';
569
+ const mergeFlag = squash ? '--squash' : '--merge';
570
+ return {
571
+ branch,
572
+ steps: [
573
+ {
574
+ args: [
575
+ 'node',
576
+ scriptPath,
577
+ '--mode',
578
+ 'checks',
579
+ '--platform',
580
+ platform,
581
+ '--branch',
582
+ branch,
583
+ '--base',
584
+ baseBranch,
585
+ '--wait-minutes',
586
+ String(waitMinutes),
587
+ ],
588
+ description: `wait for CI green on epic ${branch} (max ${waitMinutes}m)`,
589
+ // SKIPPED (exit 2 — no gh CLI / no checks configured) is OK:
590
+ // gh pr merge will surface any real blocker.
591
+ tolerate_exit_codes: [0, 2],
592
+ retry: { attempts: 1, on: 'never' },
593
+ env: Object.keys(env).length ? env : undefined,
594
+ },
595
+ // Switch to base before delete-branch so gh isn't asked to
596
+ // delete the currently-checked-out branch (which fails on
597
+ // worktree setups). Safe under any setup: a switch back isn't
598
+ // needed because the epic is done — next state is RETROSPECTIVE
599
+ // or sprint finalize.
600
+ {
601
+ args: ['git', 'switch', baseBranch],
602
+ description:
603
+ `switch to ${baseBranch} so gh can delete the merged branch.` +
604
+ ` Halts on dirty working tree or missing local ${baseBranch} — recover with` +
605
+ ` \`git stash\` (uncommitted changes) or \`git fetch origin ${baseBranch}:${baseBranch}\`` +
606
+ ` (no local copy), then resume autopilot.`,
607
+ },
608
+ {
609
+ args: ['gh', 'pr', 'merge', branch, mergeFlag, '--delete-branch'],
610
+ description: `merge epic PR for ${branch} via gh (${squash ? 'squash' : 'merge'}, delete branch)`,
611
+ env: Object.keys(env).length ? env : undefined,
612
+ },
613
+ ],
614
+ };
615
+ }
616
+ // gitlab: glab mr merge. The autopilot follows the same wait-then-
617
+ // merge structure but skips the wait step (create-pr.js --mode
618
+ // checks returns SKIPPED for gitlab today).
619
+ if (platform === 'gitlab') {
620
+ const glabArgs = ['glab', 'mr', 'merge', branch];
621
+ if (squash) glabArgs.push('--squash');
622
+ glabArgs.push('--remove-source-branch', '--yes');
623
+ return {
624
+ branch,
625
+ steps: [
626
+ {
627
+ args: ['git', 'switch', baseBranch],
628
+ description:
629
+ `switch to ${baseBranch} before MR merge.` +
630
+ ` Halts on dirty working tree or missing local ${baseBranch} — recover with` +
631
+ ` \`git stash\` or \`git fetch origin ${baseBranch}:${baseBranch}\`, then resume.`,
632
+ },
633
+ {
634
+ args: glabArgs,
635
+ description: `merge epic MR for ${branch} via glab${squash ? ' (squash)' : ''}`,
636
+ env: Object.keys(env).length ? env : undefined,
637
+ },
638
+ ],
639
+ };
640
+ }
641
+ // bitbucket / gitea: no autopilot-supported merge CLI today. Emit a
642
+ // user_prompt halt so the user can close the PR manually rather
643
+ // than silently dropping back to local merge (which would bypass
644
+ // the platform's review state).
645
+ if (platform === 'bitbucket' || platform === 'gitea') {
646
+ return {
647
+ branch,
648
+ // Special-cased: the runner sees a plan with `halt_action` and
649
+ // empty `steps`. autopilot.js#decorateGitOp converts the
650
+ // halt_action into a top-level user_prompt action; the LLM
651
+ // gets the prompt and pauses.
652
+ halt_action: {
653
+ type: 'user_prompt',
654
+ reason: 'epic_merge_unsupported_platform',
655
+ // Resume guidance: the orchestrator's adapt.js for MERGE_EPIC
656
+ // phase advances unconditionally on `status: success`, so
657
+ // there's no structured output to include here. Keeping the
658
+ // resume command minimal avoids implying any specific schema
659
+ // contract that doesn't exist.
660
+ prompt:
661
+ `Auto-merging epic PRs on ${platform} is not yet supported.\n\n` +
662
+ `Manual steps:\n` +
663
+ ` 1. Merge the PR for branch '${branch}' into '${baseBranch}' via your ${platform} UI.\n` +
664
+ ` 2. Delete the source branch (if your workflow normally does so).\n` +
665
+ ` 3. Resume by running: autopilot record --signal '{"status":"success"}'`,
666
+ platform,
667
+ branch,
668
+ base_branch: baseBranch,
669
+ },
670
+ steps: [],
671
+ };
672
+ }
673
+ }
196
674
 
197
- if (profile.has_origin !== false) {
675
+ // Local-merge fallback. Used when push_create_pr=false, has_origin is
676
+ // false (local-only repo), or platform is git_only.
677
+ const steps = [];
678
+ if (hasOrigin) {
198
679
  steps.push({ args: ['git', 'fetch', 'origin'], description: 'sync with remote' });
199
680
  }
200
681
  steps.push({ args: ['git', 'switch', baseBranch], description: `switch to ${baseBranch}` });
201
- if (profile.has_origin !== false) {
682
+ if (hasOrigin) {
202
683
  steps.push({
203
684
  args: ['git', 'merge', '--ff-only', `origin/${baseBranch}`],
204
685
  description: 'fast-forward base to remote',
@@ -209,17 +690,22 @@ function planMergeEpic(state, profile, _action, branch) {
209
690
  args: ['git', 'merge', '--squash', branch],
210
691
  description: `squash-merge ${branch}`,
211
692
  });
693
+ // current_epic may originate from sprint-status.yaml / LLM signals —
694
+ // sanitize before interpolating into a commit message argv element.
695
+ // The sanitizer enforces the same [a-z0-9._-] charset used for
696
+ // branch names; non-matching segments fall back to 'epic'.
697
+ const safeEpic = sanitizeStoryKey(state.current_epic) || 'epic';
212
698
  steps.push({
213
- args: ['git', 'commit', '-m', `feat(${state.current_epic || 'epic'}): squash merge`],
699
+ args: ['git', 'commit', `--message=feat(${safeEpic}): squash merge`],
214
700
  description: 'squash commit',
215
701
  });
216
702
  } else {
217
703
  steps.push({
218
- args: ['git', 'merge', '--no-ff', '-m', `Merge ${branch}`, branch],
704
+ args: ['git', 'merge', '--no-ff', `--message=Merge ${branch}`, branch],
219
705
  description: `non-ff merge ${branch}`,
220
706
  });
221
707
  }
222
- if (profile.has_origin !== false) {
708
+ if (hasOrigin) {
223
709
  steps.push({
224
710
  args: ['git', 'push', 'origin', baseBranch],
225
711
  description: `push ${baseBranch}`,