mandrel 1.84.0 → 1.86.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/agentrc-reference.json +8 -2
  2. package/.agents/docs/configuration.md +7 -2
  3. package/.agents/instructions.md +4 -0
  4. package/.agents/rules/ci-remediation.md +131 -0
  5. package/.agents/rules/git-conventions.md +33 -0
  6. package/.agents/schemas/agentrc.schema.json +29 -6
  7. package/.agents/schemas/lifecycle/epic.watch.end.schema.json +2 -1
  8. package/.agents/scripts/boot-sweep.js +183 -0
  9. package/.agents/scripts/epic-deliver-prepare.js +55 -0
  10. package/.agents/scripts/git-pr-quality-gate.js +7 -5
  11. package/.agents/scripts/lib/config/ci.js +24 -3
  12. package/.agents/scripts/lib/config/explain.js +11 -3
  13. package/.agents/scripts/lib/config/github.js +11 -7
  14. package/.agents/scripts/lib/config-settings-schema-delivery.js +21 -0
  15. package/.agents/scripts/lib/config-settings-schema.js +6 -6
  16. package/.agents/scripts/lib/orchestration/epic-cleanup.js +289 -1
  17. package/.agents/scripts/lib/orchestration/finalize/open-or-locate-pr.js +65 -0
  18. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +83 -30
  19. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +401 -84
  20. package/.agents/scripts/lib/orchestration/lifecycle/listeners/branch-cleaner.js +8 -3
  21. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +48 -3
  22. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +6 -1
  23. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +172 -58
  24. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +19 -0
  25. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -0
  26. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/gate-failure.js +54 -6
  27. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +35 -4
  28. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +17 -16
  29. package/.agents/scripts/lib/single-story-sweep/protection-ctx.js +75 -0
  30. package/.agents/scripts/lib/single-story-sweep.js +181 -54
  31. package/.agents/scripts/lib/templates/decomposer-prompts.js +17 -3
  32. package/.agents/scripts/pr-watch-with-update.js +324 -37
  33. package/.agents/scripts/run-verify.js +18 -3
  34. package/.agents/scripts/single-story-confirm-merge.js +1 -1
  35. package/.agents/scripts/single-story-init.js +7 -51
  36. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +32 -1
  37. package/.agents/skills/core/scope-triage/SKILL.md +5 -4
  38. package/.agents/workflows/git-deliver.md +36 -0
  39. package/.agents/workflows/helpers/deliver-epic-reference.md +41 -21
  40. package/.agents/workflows/helpers/deliver-epic.md +148 -28
  41. package/.agents/workflows/helpers/deliver-stories.md +18 -5
  42. package/.agents/workflows/helpers/single-story-deliver-reference.md +3 -3
  43. package/.agents/workflows/helpers/single-story-deliver.md +56 -19
  44. package/.agents/workflows/plan.md +32 -4
  45. package/docs/CHANGELOG.md +21 -0
  46. package/package.json +1 -1
@@ -126,10 +126,8 @@ const KEY_MEANINGS = Object.freeze({
126
126
  'How many recent commits the snapshot summarizes.',
127
127
  'planning.riskHeuristics':
128
128
  'Phrases that flag a Story as high-risk for HITL escalation.',
129
- 'planning.taskSizing.maxAcceptance':
130
- 'Hard ceiling on acceptance criteria per Story.',
131
129
  'planning.taskSizing.softAcceptanceCount':
132
- 'Acceptance-criteria count above which a Story is flagged as large.',
130
+ 'Acceptance-criteria count above which a Story is flagged as large (advisory-only — there is no hard acceptance ceiling).',
133
131
  'planning.taskSizing.softFiles':
134
132
  'Touched-file count above which a Story is flagged as large.',
135
133
  'planning.taskSizing.hardFiles':
@@ -148,6 +146,16 @@ const KEY_MEANINGS = Object.freeze({
148
146
  'Time-to-live for the Epic lease before a stale claim is reclaimable.',
149
147
  'delivery.ci.skipForStoryPushes':
150
148
  'Whether Story-branch pushes carry a [skip ci] trailer.',
149
+ 'delivery.ci.earlyPr':
150
+ 'Whether /deliver opens the Epic PR early so CI warms during later waves.',
151
+ 'delivery.ci.watch.pollIntervalMs':
152
+ 'Poll cadence (ms) for the merge/CI watch loop.',
153
+ 'delivery.ci.watch.maxPolls':
154
+ 'Maximum number of poll probes before the CI watch gives up.',
155
+ 'delivery.ci.watch.maxResumes':
156
+ 'Maximum times the CI watch may resume after a transient stall.',
157
+ 'delivery.ci.autoMerge':
158
+ 'Merge posture: trust-ci merges on green checks; strict also requires a clean review gate.',
151
159
  'delivery.preflight.maxStories':
152
160
  'Pre-dispatch ceiling on estimated Story count (no cap when unset).',
153
161
  'delivery.preflight.maxWaves':
@@ -13,23 +13,27 @@
13
13
  */
14
14
 
15
15
  /**
16
- * Default required-check suite. The framework runs lint + format:check +
17
- * test by default; consumers override via
18
- * `github.branchProtection.requiredChecks` in `.agentrc.json`.
16
+ * Default required-check suite. Mirrors the live CI required-check set
17
+ * (`lint` + `test` + `baselines`); consumers override via
18
+ * `github.branchProtection.requiredChecks` in `.agentrc.json`. Kept in sync
19
+ * with `DEFAULT_CHECKS` in `git-pr-quality-gate.js` and the CI job names in
20
+ * `.github/workflows/ci.yml` — the retired `format:check` folded into `lint`
21
+ * (Story #1829) and `lifecycle-doc-drift` collapsed into `lint`/`docs:check`
22
+ * (Epic #1943), so neither belongs in the default set.
19
23
  */
20
24
  export const DEFAULT_REQUIRED_CHECKS = Object.freeze([
21
25
  Object.freeze({
22
26
  name: 'lint',
23
27
  cmd: Object.freeze(['npm', 'run', 'lint']),
24
28
  }),
25
- Object.freeze({
26
- name: 'format:check',
27
- cmd: Object.freeze(['npm', 'run', 'format:check']),
28
- }),
29
29
  Object.freeze({
30
30
  name: 'test',
31
31
  cmd: Object.freeze(['npm', 'test']),
32
32
  }),
33
+ Object.freeze({
34
+ name: 'baselines',
35
+ cmd: Object.freeze(['node', '.agents/scripts/check-baselines.js']),
36
+ }),
33
37
  ]);
34
38
 
35
39
  export const BRANCH_PROTECTION_DEFAULTS = Object.freeze({
@@ -226,10 +226,31 @@ const EPIC_AUDIT_SCHEMA = {
226
226
  // commit subjects so intermediate pushes do not stampede the CI fleet.
227
227
  // The Epic-branch merge commit produced by story-close.js's merge
228
228
  // runner never carries the marker, regardless of this flag.
229
+ //
230
+ // Story #4356 (Epic #4355) — CI-aware delivery namespace. `earlyPr` gates
231
+ // whether /deliver opens the Epic PR early (before every Story merges) so CI
232
+ // starts warming while later waves run; defaults to `true` via getCiDelivery.
233
+ // `watch.*` tunes the merge/CI watch poll loop (poll cadence, poll cap, and
234
+ // how many times the watcher may resume after a transient stall).
235
+ // `autoMerge` selects the merge posture: `"trust-ci"` (default) merges once
236
+ // required checks pass, `"strict"` additionally requires a clean review gate.
237
+ const CI_WATCH_SCHEMA = {
238
+ type: 'object',
239
+ properties: {
240
+ pollIntervalMs: { type: 'integer', minimum: 1 },
241
+ maxPolls: { type: 'integer', minimum: 1 },
242
+ maxResumes: { type: 'integer', minimum: 0 },
243
+ },
244
+ additionalProperties: false,
245
+ };
246
+
229
247
  const CI_DELIVERY_SCHEMA = {
230
248
  type: 'object',
231
249
  properties: {
232
250
  skipForStoryPushes: { type: 'boolean' },
251
+ earlyPr: { type: 'boolean' },
252
+ watch: CI_WATCH_SCHEMA,
253
+ autoMerge: { type: 'string', enum: ['trust-ci', 'strict'] },
233
254
  },
234
255
  additionalProperties: false,
235
256
  };
@@ -250,18 +250,18 @@ const CODEBASE_SNAPSHOT_SCHEMA = {
250
250
  * `planning.taskSizing` — Story-sizing thresholds consumed by
251
251
  * `ticket-validator-sizing.js`. Operator overrides shallow-merge with
252
252
  * `DEFAULT_TASK_SIZING` defaults (softFiles 15, hardFiles 30,
253
- * maxAcceptance 14, softAcceptanceCount 10 — the uniform relaxed profile
254
- * from Story #3874). Story #3760 collapsed the per-profile matrix and the
255
- * parallel `testSurface` axis into a flat set of knobs; the `sizingProfile`
256
- * enum was replaced by an optional body-level `wide` declaration that lifts
257
- * the `hardFiles` rejection.
253
+ * softAcceptanceCount 10 — the uniform relaxed profile from Story #3874).
254
+ * Story #3760 collapsed the per-profile matrix and the parallel
255
+ * `testSurface` axis into a flat set of knobs; the `sizingProfile` enum was
256
+ * replaced by an optional body-level `wide` declaration that lifts the
257
+ * `hardFiles` rejection. The hard `maxAcceptance` ceiling was removed after
258
+ * the Epic #4355 decomposition experiment — acceptance mass is advisory-only.
258
259
  */
259
260
  const TASK_SIZING_SCHEMA = {
260
261
  type: 'object',
261
262
  properties: {
262
263
  softFiles: { type: 'integer', minimum: 1 },
263
264
  hardFiles: { type: 'integer', minimum: 1 },
264
- maxAcceptance: { type: 'integer', minimum: 1 },
265
265
  softAcceptanceCount: { type: 'integer', minimum: 1 },
266
266
  // Under-size (merge-candidate) thresholds (Story #4312). A Story whose
267
267
  // footprint is at or below BOTH ceilings and that carries a `depends_on`
@@ -29,8 +29,12 @@
29
29
  */
30
30
 
31
31
  import { spawnSync } from 'node:child_process';
32
-
33
32
  import { parseWorktreePorcelain } from '../worktree/inspector.js';
33
+ import {
34
+ executeFastForward,
35
+ planFastForward,
36
+ } from './git-cleanup/phases/fast-forward.js';
37
+ import { makeFfProbes } from './git-cleanup/phases/git-probes-ff.js';
34
38
 
35
39
  const WT_SCRATCH_BRANCH = 'wt-branch';
36
40
 
@@ -328,6 +332,51 @@ export function deleteWtBranchIfPresent(opts) {
328
332
  return { deleted: false, present: true, stderr };
329
333
  }
330
334
 
335
+ /**
336
+ * Fast-forward the base branch to its remote after a confirmed merge, so a
337
+ * post-`/deliver` local checkout converges to `origin/<baseBranch>` without a
338
+ * manual `git pull`. Reuses `planFastForward` + `executeFastForward` from the
339
+ * git-cleanup phase library (the single source of the FF state machine),
340
+ * feeding them probes bound to the injected `gitSpawn`.
341
+ *
342
+ * @param {{
343
+ * cwd: string,
344
+ * baseBranch?: string,
345
+ * remoteName?: string,
346
+ * gitSpawn: (cwd: string, ...args: string[]) => { status: number, stdout: string, stderr: string },
347
+ * logger?: { info?: Function, warn?: Function },
348
+ * }} opts
349
+ * @returns {{ ok: boolean, applied: boolean, skipped: boolean, reason?: string, behind?: number, stderr?: string }}
350
+ */
351
+ export function fastForwardBaseBranch(opts) {
352
+ const {
353
+ cwd,
354
+ baseBranch = 'main',
355
+ remoteName = 'origin',
356
+ gitSpawn,
357
+ logger,
358
+ } = opts;
359
+ const probe = makeFfProbes(gitSpawn);
360
+ const plan = planFastForward({
361
+ cwd,
362
+ baseBranch,
363
+ remoteName,
364
+ isCleanFn: probe.isClean,
365
+ currentBranchFn: probe.currentBranch,
366
+ fetchFn: probe.fetch,
367
+ canFastForwardFn: probe.canFastForward,
368
+ });
369
+ return executeFastForward({
370
+ cwd,
371
+ baseBranch,
372
+ remoteName,
373
+ plan,
374
+ checkoutFn: probe.checkout,
375
+ mergeFn: probe.merge,
376
+ ...(logger ? { logger } : {}),
377
+ });
378
+ }
379
+
331
380
  /**
332
381
  * Reap every branch owned by the Epic. Best-effort — failures aggregate into
333
382
  * the result rather than throwing.
@@ -350,6 +399,7 @@ export function deleteWtBranchIfPresent(opts) {
350
399
  * switched: { switched: boolean, from: string|null, to: string|null, stderr?: string } | null,
351
400
  * pruned: { pruned: string[], stderr?: string } | null,
352
401
  * wtBranch: { deleted: boolean, present: boolean, reason?: string, stderr?: string } | null,
402
+ * fastForward: { ok: boolean, applied: boolean, skipped: boolean, reason?: string, behind?: number, stderr?: string } | null,
353
403
  * epicBranchKept: boolean,
354
404
  * ok: boolean,
355
405
  * }}
@@ -375,6 +425,7 @@ export function reapEpicBranches(opts) {
375
425
  switched: null,
376
426
  pruned: null,
377
427
  wtBranch: null,
428
+ fastForward: null,
378
429
  epicBranchKept: false,
379
430
  ok: true,
380
431
  };
@@ -463,6 +514,26 @@ export function reapEpicBranches(opts) {
463
514
  logger?.info?.(`[epic-cleanup] deleted stale ${WT_SCRATCH_BRANCH} ref`);
464
515
  }
465
516
 
517
+ // After a confirmed merge (epic branch NOT kept), fast-forward the base
518
+ // branch so the local checkout converges to `origin/<baseBranch>` with no
519
+ // manual `git pull`. When the epic branch is kept (open PR), the merge is
520
+ // not confirmed, so the FF is skipped with an explicit reason rather than
521
+ // moving `main` under an in-flight PR.
522
+ const fastForward = epicHasOpenPr
523
+ ? { ok: true, applied: false, skipped: true, reason: 'epic-branch-kept' }
524
+ : fastForwardBaseBranch({
525
+ cwd,
526
+ baseBranch,
527
+ remoteName: remote,
528
+ gitSpawn,
529
+ logger,
530
+ });
531
+ if (fastForward.applied) {
532
+ logger?.info?.(
533
+ `[epic-cleanup] fast-forwarded ${baseBranch} by ${fastForward.behind} commit(s) to ${remote}/${baseBranch}`,
534
+ );
535
+ }
536
+
466
537
  const failures = reaped.filter((r) => !r.branchDeleted);
467
538
  return {
468
539
  epicId: state?.epicId ?? null,
@@ -471,7 +542,224 @@ export function reapEpicBranches(opts) {
471
542
  switched,
472
543
  pruned,
473
544
  wtBranch,
545
+ fastForward,
474
546
  epicBranchKept: epicHasOpenPr,
475
547
  ok: failures.length === 0,
476
548
  };
477
549
  }
550
+
551
+ /**
552
+ * Does a local branch head ref exist? Thin `git rev-parse --verify` wrapper
553
+ * exported for the resume-detect path (and its tests).
554
+ *
555
+ * @param {{ cwd: string, gitSpawn: Function, branch: string }} opts
556
+ * @returns {boolean}
557
+ */
558
+ export function localRefExists({ cwd, gitSpawn, branch }) {
559
+ const res = gitSpawn(
560
+ cwd,
561
+ 'rev-parse',
562
+ '--verify',
563
+ '--quiet',
564
+ `refs/heads/${branch}`,
565
+ );
566
+ return res.status === 0;
567
+ }
568
+
569
+ /**
570
+ * Probe whether the Epic branch's PR is MERGED and, if so, its URL (needed
571
+ * for the `epic.merge.armed` payload). Fails CLOSED to
572
+ * `{ merged: false, prUrl: null }` on any probe error — an indeterminate
573
+ * probe must never auto-arm a destructive reap.
574
+ *
575
+ * @param {{
576
+ * epicBranch: string,
577
+ * cwd: string,
578
+ * spawnFn?: typeof spawnSync,
579
+ * logger?: { warn?: Function },
580
+ * }} opts
581
+ * @returns {{ merged: boolean, prUrl: string|null }}
582
+ */
583
+ export function epicPrMergeState(opts) {
584
+ const { epicBranch, cwd, spawnFn = spawnSync, logger } = opts;
585
+ if (typeof epicBranch !== 'string' || epicBranch.length === 0) {
586
+ return { merged: false, prUrl: null };
587
+ }
588
+ let result;
589
+ try {
590
+ result = spawnFn(
591
+ 'gh',
592
+ [
593
+ 'pr',
594
+ 'list',
595
+ '--head',
596
+ epicBranch,
597
+ '--state',
598
+ 'merged',
599
+ '--json',
600
+ 'number,url,mergedAt',
601
+ '--limit',
602
+ '1',
603
+ ],
604
+ { cwd, encoding: 'utf-8', shell: false },
605
+ );
606
+ } catch (err) {
607
+ logger?.warn?.(
608
+ `[epic-cleanup] merged-PR probe threw for ${epicBranch} (treating as unmerged): ${err?.message ?? err}`,
609
+ );
610
+ return { merged: false, prUrl: null };
611
+ }
612
+ if (!result || result.status !== 0) {
613
+ logger?.warn?.(
614
+ `[epic-cleanup] merged-PR probe failed for ${epicBranch} (status=${result?.status}): ${(result?.stderr ?? '').trim()}`,
615
+ );
616
+ return { merged: false, prUrl: null };
617
+ }
618
+ let parsed;
619
+ try {
620
+ parsed = JSON.parse(String(result.stdout ?? '').trim() || '[]');
621
+ } catch {
622
+ return { merged: false, prUrl: null };
623
+ }
624
+ if (!Array.isArray(parsed) || parsed.length === 0) {
625
+ return { merged: false, prUrl: null };
626
+ }
627
+ const row = parsed[0];
628
+ const prUrl =
629
+ typeof row?.url === 'string' && row.url.length > 0 ? row.url : null;
630
+ // A merged-state row with no usable URL cannot arm (the schema requires a
631
+ // `prUrl`), so treat it as not-armable.
632
+ return { merged: prUrl !== null, prUrl };
633
+ }
634
+
635
+ /**
636
+ * Detect a merged-but-uncleaned Epic: the PR merged but one or more local
637
+ * `epic/<id>` / `story-<id>` refs still linger. This is the signal `/deliver`
638
+ * idempotent resume uses to auto-fire `epic.merge.armed` so Phase 9 reaps
639
+ * without a manual command. Pure given its injected ports.
640
+ *
641
+ * @param {{
642
+ * state: object|null,
643
+ * cwd: string,
644
+ * gitSpawn: Function,
645
+ * spawnFn?: Function,
646
+ * prMergeStateFn?: typeof epicPrMergeState,
647
+ * logger?: { warn?: Function, info?: Function },
648
+ * }} opts
649
+ * @returns {{
650
+ * epicId: number|null,
651
+ * epicBranch: string|null,
652
+ * presentRefs: string[],
653
+ * localRefsPresent: boolean,
654
+ * merged: boolean,
655
+ * prUrl: string|null,
656
+ * shouldArm: boolean,
657
+ * reason: string,
658
+ * }}
659
+ */
660
+ export function detectMergedUncleanedEpic(opts) {
661
+ const {
662
+ state,
663
+ cwd,
664
+ gitSpawn,
665
+ spawnFn,
666
+ prMergeStateFn = epicPrMergeState,
667
+ logger,
668
+ } = opts;
669
+ const { epicBranch, storyBranches } = listEpicBranchesFromState(state);
670
+ if (!epicBranch) {
671
+ return {
672
+ epicId: null,
673
+ epicBranch: null,
674
+ presentRefs: [],
675
+ localRefsPresent: false,
676
+ merged: false,
677
+ prUrl: null,
678
+ shouldArm: false,
679
+ reason: 'no-state',
680
+ };
681
+ }
682
+ const presentRefs = [epicBranch, ...storyBranches].filter((branch) =>
683
+ localRefExists({ cwd, gitSpawn, branch }),
684
+ );
685
+ if (presentRefs.length === 0) {
686
+ // Already clean — nothing to arm. This is the idempotent no-op that
687
+ // makes re-running `/deliver` on an already-reaped Epic safe.
688
+ return {
689
+ epicId: state.epicId,
690
+ epicBranch,
691
+ presentRefs,
692
+ localRefsPresent: false,
693
+ merged: false,
694
+ prUrl: null,
695
+ shouldArm: false,
696
+ reason: 'no-local-refs',
697
+ };
698
+ }
699
+ const { merged, prUrl } = prMergeStateFn({
700
+ epicBranch,
701
+ cwd,
702
+ spawnFn,
703
+ logger,
704
+ });
705
+ const shouldArm = merged && prUrl !== null;
706
+ return {
707
+ epicId: state.epicId,
708
+ epicBranch,
709
+ presentRefs,
710
+ localRefsPresent: true,
711
+ merged,
712
+ prUrl,
713
+ shouldArm,
714
+ reason: shouldArm ? 'merged-uncleaned' : 'not-merged',
715
+ };
716
+ }
717
+
718
+ /**
719
+ * `/deliver` idempotent-resume auto-arm: detect a merged-but-uncleaned Epic
720
+ * and, when found, fire `epic.merge.armed` on the injected lifecycle `bus` so
721
+ * the Cleaner → BranchCleaner chain reaps Phase 9 without an operator command.
722
+ * A no-op (and never throws on a clean/unmerged Epic) so re-running resume is
723
+ * safe.
724
+ *
725
+ * @param {{
726
+ * state: object|null,
727
+ * cwd: string,
728
+ * gitSpawn: Function,
729
+ * spawnFn?: Function,
730
+ * bus: { emit: (event: string, payload: object) => Promise<unknown> },
731
+ * detectFn?: typeof detectMergedUncleanedEpic,
732
+ * logger?: { warn?: Function, info?: Function },
733
+ * }} opts
734
+ * @returns {Promise<{ armed: boolean, reason: string, prUrl: string|null, detection: object }>}
735
+ */
736
+ export async function armCleanupIfMerged(opts) {
737
+ const { state, cwd, gitSpawn, spawnFn, bus, detectFn, logger } = opts;
738
+ if (!bus || typeof bus.emit !== 'function') {
739
+ throw new TypeError('armCleanupIfMerged requires a bus exposing emit()');
740
+ }
741
+ const detect = detectFn ?? detectMergedUncleanedEpic;
742
+ const detection = detect({ state, cwd, gitSpawn, spawnFn, logger });
743
+ if (!detection.shouldArm) {
744
+ return {
745
+ armed: false,
746
+ reason: detection.reason,
747
+ prUrl: detection.prUrl,
748
+ detection,
749
+ };
750
+ }
751
+ const payload = { prUrl: detection.prUrl };
752
+ if (Number.isInteger(detection.epicId) && detection.epicId > 0) {
753
+ payload.epicId = detection.epicId;
754
+ }
755
+ await bus.emit('epic.merge.armed', payload);
756
+ logger?.info?.(
757
+ `[epic-cleanup] resume auto-arm: fired epic.merge.armed for ${detection.epicBranch} (${detection.prUrl})`,
758
+ );
759
+ return {
760
+ armed: true,
761
+ reason: 'merged-uncleaned',
762
+ prUrl: detection.prUrl,
763
+ detection,
764
+ };
765
+ }
@@ -27,6 +27,20 @@
27
27
  * does not attempt to create a duplicate PR. This is the AC-10
28
28
  * idempotency contract the Finalizer relies on for cross-process
29
29
  * re-runs of `/deliver`.
30
+ *
31
+ * Early-PR draft mode — Story #4359 (Epic #4355).
32
+ *
33
+ * When `delivery.ci.earlyPr` is on (the default), `/deliver` opens the
34
+ * Epic PR as a **draft** at wave 1 (`openOrLocatePr({ draft: true })`)
35
+ * so every subsequent per-wave push runs CI attributed to its own wave.
36
+ * The `draft` flag only appends `--draft` to the `gh pr create` shell —
37
+ * the probe/locate path is unchanged, so a re-run on the same head
38
+ * branch still short-circuits without opening a duplicate. Phase 7 then
39
+ * flips that existing draft to ready-for-review via `markPrReady` rather
40
+ * than creating the PR. When `earlyPr` is off, no draft is opened at
41
+ * wave 1 and Phase 7 opens the PR at close time exactly as before
42
+ * (`openOrLocatePr` with no `draft`). The title/body contract
43
+ * (`feat: Epic #<id>` / `Closes #<id>`) is identical in both modes.
30
44
  */
31
45
 
32
46
  import { spawnSync } from 'node:child_process';
@@ -119,6 +133,12 @@ export function parsePrViewResult(stdout) {
119
133
  * or changelog entry).
120
134
  * @param {string} [args.body] — explicit PR body override; defaults to
121
135
  * `Closes #<epicId>`.
136
+ * @param {boolean} [args.draft] — when `true`, open the new PR as a draft
137
+ * (`gh pr create --draft`). Story #4359: the early-PR path opens the
138
+ * Epic PR as a draft at wave 1 so per-wave pushes warm CI; Phase 7 later
139
+ * flips it ready via `markPrReady`. Only affects the create path — the
140
+ * locate short-circuit never inspects draft state, so a re-run stays
141
+ * idempotent. Default `false`.
122
142
  * @param {string} [args.cwd] — working directory for the gh shells.
123
143
  * Default `process.cwd()`.
124
144
  * @param {Function} [args.ghSpawn] — override the gh invocation for
@@ -131,6 +151,7 @@ export async function openOrLocatePr({
131
151
  baseBranch = 'main',
132
152
  title,
133
153
  body,
154
+ draft = false,
134
155
  cwd = process.cwd(),
135
156
  ghSpawn = defaultGhSpawn,
136
157
  } = {}) {
@@ -206,6 +227,11 @@ export async function openOrLocatePr({
206
227
  finalTitle,
207
228
  '--body',
208
229
  finalBody,
230
+ // Story #4359: open as a draft when the early-PR path requests it.
231
+ // The flag is elided entirely when `draft` is false so the
232
+ // close-time (earlyPr=false) create path is byte-identical to the
233
+ // pre-Story behaviour.
234
+ ...(draft ? ['--draft'] : []),
209
235
  ],
210
236
  cwd,
211
237
  });
@@ -239,3 +265,42 @@ export async function openOrLocatePr({
239
265
  }
240
266
  return { prNumber: parsed.number, url: parsed.url, created: true };
241
267
  }
268
+
269
+ /**
270
+ * Mark an existing draft PR as ready-for-review — Story #4359 (Epic
271
+ * #4355). This is the Phase-7 counterpart to the wave-1 draft open: when
272
+ * `delivery.ci.earlyPr` is on, the Epic PR already exists as a draft, so
273
+ * finalize flips it ready rather than creating a PR.
274
+ *
275
+ * Idempotent: `gh pr ready` on an already-ready PR is a no-op that exits
276
+ * 0, so re-running finalize (or replaying after a crash) is safe. When the
277
+ * PR reference does not resolve, `gh` exits non-zero and this helper
278
+ * throws with the stderr detail.
279
+ *
280
+ * @param {object} args
281
+ * @param {string} args.pr — a PR reference `gh pr ready` accepts: the
282
+ * numeric id, the branch name, or the html URL. `openOrLocatePr`
283
+ * returns both `prNumber` and `url`; either is a valid input.
284
+ * @param {string} [args.cwd] — working directory for the gh shell.
285
+ * Default `process.cwd()`.
286
+ * @param {Function} [args.ghSpawn] — override the gh invocation for
287
+ * tests. Same shape as `defaultGhSpawn`.
288
+ * @returns {Promise<{ pr: string, ready: true }>}
289
+ */
290
+ export async function markPrReady({
291
+ pr,
292
+ cwd = process.cwd(),
293
+ ghSpawn = defaultGhSpawn,
294
+ } = {}) {
295
+ const ref = pr == null ? '' : String(pr).trim();
296
+ if (ref.length === 0) {
297
+ throw new TypeError('markPrReady: pr must be a non-empty reference');
298
+ }
299
+ const ready = ghSpawn({ args: ['pr', 'ready', ref], cwd });
300
+ if (ready.status !== 0) {
301
+ throw new Error(
302
+ `markPrReady: gh pr ready failed (status=${ready.status}): ${ready.stderr.trim()}`,
303
+ );
304
+ }
305
+ return { pr: ref, ready: true };
306
+ }
@@ -34,54 +34,107 @@ export function isWorktreeLockFailure(stderr) {
34
34
 
35
35
  /* node:coverage ignore next */
36
36
  export function isWorkingTreeClean(cwd) {
37
- const res = gitSpawn(cwd, 'status', '--porcelain');
38
- if (res.status !== 0) return false;
39
- return res.stdout.trim() === '';
37
+ return defaultFfProbes.isClean(cwd);
40
38
  }
41
39
 
42
40
  /* node:coverage ignore next */
43
41
  export function fetchRef(cwd, remoteName, ref) {
44
- const res = gitSpawn(cwd, 'fetch', '--quiet', remoteName, ref);
45
- if (res.status !== 0) return { ok: false, stderr: res.stderr };
46
- return { ok: true };
42
+ return defaultFfProbes.fetch(cwd, remoteName, ref);
47
43
  }
48
44
 
49
45
  /* node:coverage ignore next */
50
46
  export function canFastForward(cwd, baseBranch, remoteName) {
51
- const ref = `${remoteName}/${baseBranch}`;
52
- const ahead = gitSpawn(
53
- cwd,
54
- 'rev-list',
55
- '--left-right',
56
- '--count',
57
- `${baseBranch}...${ref}`,
58
- );
59
- if (ahead.status !== 0) {
60
- return { ok: false, behind: 0, reason: 'rev-list-failed' };
61
- }
62
- const parts = ahead.stdout.trim().split(/\s+/);
63
- const localAhead = Number(parts[0]) || 0;
64
- const remoteAhead = Number(parts[1]) || 0;
65
- if (localAhead > 0) {
66
- return { ok: false, behind: remoteAhead, reason: 'not-fast-forward' };
67
- }
68
- return { ok: true, behind: remoteAhead };
47
+ return defaultFfProbes.canFastForward(cwd, baseBranch, remoteName);
69
48
  }
70
49
 
71
50
  /* node:coverage ignore next */
72
51
  export function checkoutBranch(cwd, branch) {
73
- const res = gitSpawn(cwd, 'checkout', branch);
74
- if (res.status !== 0) return { ok: false, stderr: res.stderr };
75
- return { ok: true };
52
+ return defaultFfProbes.checkout(cwd, branch);
76
53
  }
77
54
 
78
55
  /* node:coverage ignore next */
79
56
  export function mergeFastForward(cwd, ref) {
80
- const res = gitSpawn(cwd, 'merge', '--ff-only', ref);
81
- if (res.status !== 0) return { ok: false, stderr: res.stderr };
82
- return { ok: true };
57
+ return defaultFfProbes.merge(cwd, ref);
83
58
  }
84
59
 
60
+ /**
61
+ * Build the fast-forward probe bundle bound to a `gitSpawn`.
62
+ *
63
+ * This is the **single implementation** of the FF/base-sync git wrappers.
64
+ * The standalone exports above delegate to a default instance bound to the
65
+ * shared `gitSpawn`; callers that need to inject their own spawn for testing
66
+ * (e.g. the epic-cleanup runner) call this factory directly instead of
67
+ * hand-rolling a parallel copy (framework-gap #4379). The bundle also carries
68
+ * `currentBranch` so an injecting caller gets the whole FF surface from one
69
+ * place.
70
+ *
71
+ * @param {(cwd: string, ...args: string[]) => { status: number, stdout: string, stderr: string }} [spawn]
72
+ * @returns {{
73
+ * isClean: (cwd: string) => boolean,
74
+ * currentBranch: (cwd: string) => string|null,
75
+ * fetch: (cwd: string, remoteName: string, ref: string) => { ok: boolean, stderr?: string },
76
+ * canFastForward: (cwd: string, baseBranch: string, remoteName: string) => { ok: boolean, behind: number, reason?: string },
77
+ * checkout: (cwd: string, branch: string) => { ok: boolean, stderr?: string },
78
+ * merge: (cwd: string, ref: string) => { ok: boolean, stderr?: string },
79
+ * }}
80
+ */
81
+ export function makeFfProbes(spawn = gitSpawn) {
82
+ return {
83
+ isClean: (cwd) => {
84
+ const res = spawn(cwd, 'status', '--porcelain');
85
+ return res.status === 0 && String(res.stdout ?? '').trim() === '';
86
+ },
87
+ currentBranch: (cwd) => {
88
+ const res = spawn(cwd, 'symbolic-ref', '--quiet', '--short', 'HEAD');
89
+ return res.status !== 0 ? null : String(res.stdout ?? '').trim() || null;
90
+ },
91
+ fetch: (cwd, remoteName, ref) => {
92
+ const res = spawn(cwd, 'fetch', '--quiet', remoteName, ref);
93
+ return res.status === 0
94
+ ? { ok: true }
95
+ : { ok: false, stderr: res.stderr };
96
+ },
97
+ canFastForward: (cwd, baseBranch, remoteName) => {
98
+ const ref = `${remoteName}/${baseBranch}`;
99
+ const ahead = spawn(
100
+ cwd,
101
+ 'rev-list',
102
+ '--left-right',
103
+ '--count',
104
+ `${baseBranch}...${ref}`,
105
+ );
106
+ if (ahead.status !== 0) {
107
+ return { ok: false, behind: 0, reason: 'rev-list-failed' };
108
+ }
109
+ const parts = String(ahead.stdout ?? '')
110
+ .trim()
111
+ .split(/\s+/);
112
+ const localAhead = Number(parts[0]) || 0;
113
+ const remoteAhead = Number(parts[1]) || 0;
114
+ if (localAhead > 0) {
115
+ return { ok: false, behind: remoteAhead, reason: 'not-fast-forward' };
116
+ }
117
+ return { ok: true, behind: remoteAhead };
118
+ },
119
+ checkout: (cwd, branch) => {
120
+ const res = spawn(cwd, 'checkout', branch);
121
+ return res.status === 0
122
+ ? { ok: true }
123
+ : { ok: false, stderr: res.stderr };
124
+ },
125
+ merge: (cwd, ref) => {
126
+ const res = spawn(cwd, 'merge', '--ff-only', ref);
127
+ return res.status === 0
128
+ ? { ok: true }
129
+ : { ok: false, stderr: res.stderr };
130
+ },
131
+ };
132
+ }
133
+
134
+ // Default instance bound to the shared gitSpawn; the standalone wrappers
135
+ // above delegate to it so there is exactly one FF-probe implementation.
136
+ const defaultFfProbes = makeFfProbes(gitSpawn);
137
+
85
138
  /* node:coverage ignore next */
86
139
  export function removeWorktree(worktreePath, cwd) {
87
140
  const plain = gitSpawn(cwd, 'worktree', 'remove', worktreePath);