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
@@ -12,6 +12,7 @@ import { createProvider } from '../../provider-factory.js';
12
12
  import { flipLabelAndNotify } from '../../single-story/story-merged-notify.js';
13
13
  import { WorktreeManager } from '../../worktree-manager.js';
14
14
  import { runCodeReview as runCodeReviewDefault } from '../code-review.js';
15
+ import { resolveRunScopedConfig } from '../run-scoped-config.js';
15
16
  import { releaseStoryLease } from '../single-story-lease-guard.js';
16
17
  import {
17
18
  buildTerminalEnvelope,
@@ -144,6 +145,7 @@ async function runPrePushPhases({
144
145
  worktreePath,
145
146
  config,
146
147
  baseBranch,
148
+ baseConfirmed,
147
149
  storyBranch,
148
150
  storyId,
149
151
  provider,
@@ -185,6 +187,7 @@ async function runPrePushPhases({
185
187
  cwd,
186
188
  worktreePath,
187
189
  baseBranch,
190
+ baseConfirmed,
188
191
  storyBranch,
189
192
  storyId,
190
193
  provider,
@@ -210,7 +213,7 @@ async function openAndReviewPr({
210
213
  setPhase('push');
211
214
  pushStoryBranch({ cwd, storyBranch, gitSync, progress });
212
215
  setPhase('pull-request');
213
- const prUrl = await ensurePullRequestWith({
216
+ const { url: prUrl, alreadyMerged } = await ensurePullRequestWith({
214
217
  cwd,
215
218
  storyId,
216
219
  storyTitle: story.title,
@@ -220,6 +223,13 @@ async function openAndReviewPr({
220
223
  progress,
221
224
  });
222
225
  const prNumber = parsePrNumber(prUrl);
226
+ // Story #4873 — the head's PR already merged (an armed PR that landed while
227
+ // a previous close invocation was between phases). There is nothing left to
228
+ // review and nothing left to arm; the confirm phase probes the PR, observes
229
+ // MERGED, and lands the Story on the merge that actually happened.
230
+ if (alreadyMerged) {
231
+ return { prUrl, prNumber, alreadyMerged: true };
232
+ }
223
233
  setPhase('code-review');
224
234
  const reviewOutcome = await runStoryScopeReview({
225
235
  cwd,
@@ -245,7 +255,7 @@ async function openAndReviewPr({
245
255
  'Auto-merge was not enabled. Remediate the findings posted to the PR and re-run `/deliver`.',
246
256
  );
247
257
  }
248
- return { prUrl, prNumber };
258
+ return { prUrl, prNumber, alreadyMerged: false };
249
259
  }
250
260
 
251
261
  async function releaseLease({
@@ -367,6 +377,7 @@ export async function runSingleStoryClose({
367
377
  waitForMerge: waitForMergeParam,
368
378
  noWaitForMerge: noWaitForMergeParam,
369
379
  maxWaitSeconds: maxWaitSecondsParam,
380
+ mergeWatchMode: mergeWatchModeParam,
370
381
  injectedProvider,
371
382
  injectedConfig,
372
383
  injectedNotify,
@@ -385,10 +396,11 @@ export async function runSingleStoryClose({
385
396
  waitForMergeParam,
386
397
  noWaitForMergeParam,
387
398
  maxWaitSecondsParam,
399
+ mergeWatchModeParam,
388
400
  });
389
401
  if (!options.storyId) {
390
402
  throw new Error(
391
- 'Usage: node single-story-close.js --story <STORY_ID> [--cwd <main-repo>] [--skip-validation] [--skip-sync] [--no-auto-merge] [--wait-merge|--no-wait-merge] [--max-wait-seconds <n>]',
403
+ 'Usage: node single-story-close.js --story <STORY_ID> [--cwd <main-repo>] [--skip-validation] [--skip-sync] [--no-auto-merge] [--wait-merge|--no-wait-merge] [--max-wait-seconds <n>] [--merge-watch-mode <sync|async>]',
392
404
  );
393
405
  }
394
406
 
@@ -423,6 +435,210 @@ export async function runSingleStoryClose({
423
435
  }
424
436
  }
425
437
 
438
+ /**
439
+ * Resolve the arm outcome. An already-merged head PR (Story #4873) has nothing
440
+ * to arm — `gh pr merge` against it fails, which would report the arm as a
441
+ * fault and block a Story whose work is already on the base branch. Skip the
442
+ * arm and let the confirm phase observe the merge that happened.
443
+ *
444
+ * @param {object} args
445
+ * @returns {Promise<{ autoMergeEnabled: boolean, autoMergeReason: string|null,
446
+ * localCleanupDeferred: boolean, directMerged: boolean }>}
447
+ */
448
+ async function resolveAutoMergeOutcome({ alreadyMerged, ...phaseArgs }) {
449
+ if (alreadyMerged) {
450
+ return {
451
+ autoMergeEnabled: true,
452
+ autoMergeReason: null,
453
+ localCleanupDeferred: false,
454
+ directMerged: false,
455
+ };
456
+ }
457
+ return await runAutoMergePhase(phaseArgs);
458
+ }
459
+
460
+ /**
461
+ * Report the merge-wait terminal on the progress channel. Three endings, each
462
+ * with its own operator-facing shape — `landed` is done, `pending` is
463
+ * resumable and NOT a failure, anything else is a block naming its class.
464
+ *
465
+ * @param {{ status: string, nextCommand: string, blocked?: {blockClass?: string} }} terminal
466
+ * @param {{ storyId: number, prUrl: string|null }} ctx
467
+ * @returns {void}
468
+ */
469
+ function reportWaitTerminal(terminal, { storyId, prUrl }) {
470
+ if (terminal.status === 'landed') {
471
+ progress('DONE', `✅ Story #${storyId}: PR merged → ${prUrl}`);
472
+ return;
473
+ }
474
+ if (terminal.status === 'pending') {
475
+ // NOT a failure and NOT a block — the wait reached the edge of its
476
+ // host slot with the PR healthy and in flight. The CLI maps this to
477
+ // its own exit code so a caller can resume without classifying.
478
+ progress(
479
+ 'PENDING',
480
+ `⏸ Story #${storyId}: PR ${prUrl} still in flight — resume with: ${terminal.nextCommand}`,
481
+ );
482
+ return;
483
+ }
484
+ progress(
485
+ 'BLOCKED',
486
+ `🛑 Story #${storyId}: PR ${prUrl} did not land ` +
487
+ `(blockClass=${terminal.blocked?.blockClass}). Story is at agent::blocked. ` +
488
+ `Next: ${terminal.nextCommand}`,
489
+ );
490
+ }
491
+
492
+ /**
493
+ * The close-and-land ending (Story #4428): poll the just-armed PR to merge
494
+ * confirmation and emit the terminal the outcome names.
495
+ *
496
+ * @param {object} prCtx The shared PR/gate context built by the pipeline.
497
+ * @param {object} deps Runtime seams the confirm phase needs.
498
+ * @returns {Promise<{ success: boolean, result: object, terminal: object }>}
499
+ */
500
+ async function finishWithMergeWait(prCtx, deps) {
501
+ deps.setPhase('confirm-merge');
502
+ const waitOutcome = await runConfirmMergePhase({
503
+ cwd: deps.cwd,
504
+ storyId: prCtx.storyId,
505
+ storyBranch: prCtx.storyBranch,
506
+ baseBranch: prCtx.baseBranch,
507
+ prNumber: prCtx.prNumber,
508
+ prUrl: prCtx.prUrl,
509
+ autoMergeEnabled: prCtx.autoMergeEnabled,
510
+ autoMergeReason: prCtx.autoMergeReason,
511
+ provider: deps.provider,
512
+ config: prCtx.config,
513
+ maxWaitSeconds: deps.maxWaitSeconds,
514
+ mergeWatchMode: deps.mergeWatchMode,
515
+ progress,
516
+ injectedGh: deps.injectedGh,
517
+ injectedNotify: deps.injectedNotify,
518
+ });
519
+ const terminal = terminalFromWaitOutcome({
520
+ waitOutcome,
521
+ storyId: prCtx.storyId,
522
+ storyBranch: prCtx.storyBranch,
523
+ baseBranch: prCtx.baseBranch,
524
+ prNumber: prCtx.prNumber,
525
+ prUrl: prCtx.prUrl,
526
+ autoMergeEnabled: prCtx.autoMergeEnabled,
527
+ gates: prCtx.gates,
528
+ elapsedSeconds: elapsedSecondsSince(prCtx.startedAtMs),
529
+ });
530
+ const result = closeResult({
531
+ storyId: prCtx.storyId,
532
+ storyBranch: prCtx.storyBranch,
533
+ baseBranch: prCtx.baseBranch,
534
+ prUrl: prCtx.prUrl,
535
+ prNumber: prCtx.prNumber,
536
+ autoMergeEnabled: prCtx.autoMergeEnabled,
537
+ autoMergeReason: prCtx.autoMergeReason,
538
+ worktreeReaped: prCtx.worktreeReaped,
539
+ // Story #4860 — the release is a post-land tail step now, so the tail's
540
+ // own per-step boolean IS the answer. A wait that ended anything other
541
+ // than landed never ran the tail, and correctly reports `false`: the
542
+ // claim is still held, by design.
543
+ leaseReleased: waitOutcome.tail?.leaseRelease === true,
544
+ localCleanupDeferred: prCtx.localCleanupDeferred,
545
+ directMerged: prCtx.directMerged,
546
+ waitedForMerge: true,
547
+ merged: waitOutcome.confirmed === true,
548
+ });
549
+ await emitTerminal({ terminal, result, config: prCtx.config });
550
+ reportWaitTerminal(terminal, { storyId: prCtx.storyId, prUrl: prCtx.prUrl });
551
+ return { success: terminal.status === 'landed', result, terminal };
552
+ }
553
+
554
+ /**
555
+ * The no-wait ending — `--no-wait-merge` / operator-merge. The PR is open and
556
+ * a human owns the land. That is a `pending` terminal by definition: the work
557
+ * is not done, nothing is broken, and one named command finishes it — rather
558
+ * than a fourth status invented for this one case.
559
+ *
560
+ * @param {object} prCtx
561
+ * @param {string} waitForMergeReason
562
+ * @returns {Promise<{ success: boolean, result: object, terminal: object }>}
563
+ */
564
+ async function finishWithoutMergeWait(prCtx, waitForMergeReason) {
565
+ const result = closeResult({
566
+ storyId: prCtx.storyId,
567
+ storyBranch: prCtx.storyBranch,
568
+ baseBranch: prCtx.baseBranch,
569
+ prUrl: prCtx.prUrl,
570
+ prNumber: prCtx.prNumber,
571
+ autoMergeEnabled: prCtx.autoMergeEnabled,
572
+ autoMergeReason: prCtx.autoMergeReason,
573
+ worktreeReaped: prCtx.worktreeReaped,
574
+ // Story #4860 — this is the no-wait ending: the PR is open and a human
575
+ // owns the merge, so the Story stays assigned until the confirm-merge
576
+ // surface lands it and runs the tail.
577
+ leaseReleased: false,
578
+ localCleanupDeferred: prCtx.localCleanupDeferred,
579
+ directMerged: prCtx.directMerged,
580
+ });
581
+ const terminal = buildTerminalEnvelope({
582
+ storyId: prCtx.storyId,
583
+ status: 'pending',
584
+ phase: 'auto-merge',
585
+ storyBranch: prCtx.storyBranch,
586
+ baseBranch: prCtx.baseBranch,
587
+ pr: {
588
+ number: prCtx.prNumber,
589
+ url: prCtx.prUrl ?? null,
590
+ state: 'OPEN',
591
+ autoMergeEnabled: Boolean(prCtx.autoMergeEnabled),
592
+ },
593
+ gates: prCtx.gates,
594
+ nextCommand: NEXT_COMMANDS.confirmMerge(prCtx.storyId),
595
+ elapsedSeconds: elapsedSecondsSince(prCtx.startedAtMs),
596
+ });
597
+ await emitTerminal({ terminal, result, config: prCtx.config });
598
+ progress(
599
+ 'DONE',
600
+ `✅ Story #${prCtx.storyId}: PR ready → ${prCtx.prUrl} (${waitForMergeReason})`,
601
+ );
602
+ return { success: true, result, terminal };
603
+ }
604
+
605
+ /**
606
+ * Wall-clock seconds since the close started, rounded — the terminal
607
+ * envelope's `elapsedSeconds`.
608
+ *
609
+ * @param {number} startedAtMs
610
+ * @returns {number}
611
+ */
612
+ function elapsedSecondsSince(startedAtMs) {
613
+ return Math.round((Date.now() - startedAtMs) / 1000);
614
+ }
615
+
616
+ /**
617
+ * Announce the operator-merge skip: the PR was deliberately left un-armed, so
618
+ * nothing here can land it and the Story rests at `agent::closing`. No-op on
619
+ * every other wait reason.
620
+ *
621
+ * @param {{ waitForMergeReason: string, autoMergeReason: string|null,
622
+ * storyId: number, waitForMergeExplicit?: boolean }} args
623
+ * @returns {void}
624
+ */
625
+ function reportOperatorMergeSkip({
626
+ waitForMergeReason,
627
+ autoMergeReason,
628
+ storyId,
629
+ waitForMergeExplicit,
630
+ }) {
631
+ if (waitForMergeReason !== 'operator-merge') return;
632
+ progress(
633
+ 'MERGE',
634
+ `⏭ Not waiting for merge (${autoMergeReason}) — the operator owns this merge; ` +
635
+ `Story #${storyId} rests at agent::closing.` +
636
+ (waitForMergeExplicit === true
637
+ ? ' --wait-merge cannot land a PR that was deliberately left un-armed.'
638
+ : ''),
639
+ );
640
+ }
641
+
426
642
  /**
427
643
  * The close pipeline proper. Split out of `runSingleStoryClose` so the
428
644
  * phase-tagging wrapper above stays a thin, obviously-correct boundary rather
@@ -443,7 +659,6 @@ async function runClosePipeline({
443
659
  const startedAtMs = Date.now();
444
660
  const config = injectedConfig || resolveConfig({ cwd: options.cwd });
445
661
  const provider = injectedProvider || createProvider(config);
446
- const baseBranch = config.project?.baseBranch ?? 'main';
447
662
  const storyBranch = getStoryBranch(options.storyId);
448
663
 
449
664
  progress('INIT', `Closing standalone Story #${options.storyId}...`);
@@ -462,6 +677,22 @@ async function runClosePipeline({
462
677
  );
463
678
  }
464
679
 
680
+ // Story #4891 — the base branch this run was SEEDED from, read back off the
681
+ // run's `story-init` receipt rather than re-resolved from a config file that
682
+ // may have changed during the whole implementation window. Throws (fail
683
+ // closed, naming both values) when the pin and current config disagree —
684
+ // deliberately here, before the gate chain, format-autofix and base-sync,
685
+ // so a wrong base is never merged into the Story branch. `baseConfirmed`
686
+ // gates the base-merge remediation advice further down.
687
+ const { values: runScoped, confirmed: baseConfirmed } =
688
+ await resolveRunScopedConfig({
689
+ provider,
690
+ storyId: options.storyId,
691
+ config,
692
+ progress,
693
+ });
694
+ const baseBranch = runScoped.baseBranch;
695
+
465
696
  const worktreePath = resolveWorktreePath({
466
697
  cwd: options.cwd,
467
698
  config,
@@ -483,6 +714,7 @@ async function runClosePipeline({
483
714
  ...options,
484
715
  config,
485
716
  baseBranch,
717
+ baseConfirmed,
486
718
  storyBranch,
487
719
  provider,
488
720
  worktreePath,
@@ -493,7 +725,7 @@ async function runClosePipeline({
493
725
  leaseArgs,
494
726
  );
495
727
 
496
- const { prUrl, prNumber } = await releaseLeaseOnBlock(
728
+ const { prUrl, prNumber, alreadyMerged } = await releaseLeaseOnBlock(
497
729
  () =>
498
730
  openAndReviewPr({
499
731
  cwd: options.cwd,
@@ -532,7 +764,8 @@ async function runClosePipeline({
532
764
  autoMergeReason,
533
765
  localCleanupDeferred,
534
766
  directMerged,
535
- } = await runAutoMergePhase({
767
+ } = await resolveAutoMergeOutcome({
768
+ alreadyMerged,
536
769
  cwd: options.cwd,
537
770
  prNumber,
538
771
  prUrl,
@@ -582,133 +815,42 @@ async function runClosePipeline({
582
815
  config,
583
816
  autoMergeReason,
584
817
  });
585
- if (waitForMergeReason === 'operator-merge') {
586
- progress(
587
- 'MERGE',
588
- `⏭ Not waiting for merge (${autoMergeReason}) — the operator owns this merge; ` +
589
- `Story #${options.storyId} rests at agent::closing.` +
590
- (options.waitForMergeExplicit === true
591
- ? ' --wait-merge cannot land a PR that was deliberately left un-armed.'
592
- : ''),
593
- );
594
- }
595
- const gates = {
596
- validation: options.skipValidation ? 'skipped' : 'passed',
597
- baseSync: options.skipSync ? 'skipped' : 'passed',
598
- codeReview: 'passed',
599
- };
600
-
601
- if (waitForMerge) {
602
- setPhase('confirm-merge');
603
- const waitOutcome = await runConfirmMergePhase({
604
- cwd: options.cwd,
605
- storyId: options.storyId,
606
- storyBranch,
607
- baseBranch,
608
- prNumber,
609
- prUrl,
610
- autoMergeEnabled,
611
- autoMergeReason,
612
- provider,
613
- config,
614
- maxWaitSeconds: options.maxWaitSeconds,
615
- progress,
616
- injectedGh,
617
- injectedNotify,
618
- });
619
- const terminal = terminalFromWaitOutcome({
620
- waitOutcome,
621
- storyId: options.storyId,
622
- storyBranch,
623
- baseBranch,
624
- prNumber,
625
- prUrl,
626
- autoMergeEnabled,
627
- gates,
628
- elapsedSeconds: Math.round((Date.now() - startedAtMs) / 1000),
629
- });
630
- const result = closeResult({
631
- storyId: options.storyId,
632
- storyBranch,
633
- baseBranch,
634
- prUrl,
635
- prNumber,
636
- autoMergeEnabled,
637
- autoMergeReason,
638
- worktreeReaped,
639
- // Story #4860 — the release is a post-land tail step now, so the tail's
640
- // own per-step boolean IS the answer. A wait that ended anything other
641
- // than landed never ran the tail, and correctly reports `false`: the
642
- // claim is still held, by design.
643
- leaseReleased: waitOutcome.tail?.leaseRelease === true,
644
- localCleanupDeferred,
645
- directMerged,
646
- waitedForMerge: true,
647
- merged: waitOutcome.confirmed === true,
648
- });
649
- await emitTerminal({ terminal, result, config });
650
-
651
- if (terminal.status === 'landed') {
652
- progress('DONE', `✅ Story #${options.storyId}: PR merged → ${prUrl}`);
653
- } else if (terminal.status === 'pending') {
654
- // NOT a failure and NOT a block — the wait reached the edge of its
655
- // host slot with the PR healthy and in flight. The CLI maps this to
656
- // its own exit code so a caller can resume without classifying.
657
- progress(
658
- 'PENDING',
659
- `⏸ Story #${options.storyId}: PR ${prUrl} still in flight — resume with: ${terminal.nextCommand}`,
660
- );
661
- } else {
662
- progress(
663
- 'BLOCKED',
664
- `🛑 Story #${options.storyId}: PR ${prUrl} did not land ` +
665
- `(blockClass=${terminal.blocked?.blockClass}). Story is at agent::blocked. ` +
666
- `Next: ${terminal.nextCommand}`,
667
- );
668
- }
669
- return { success: terminal.status === 'landed', result, terminal };
670
- }
671
-
672
- const result = closeResult({
818
+ reportOperatorMergeSkip({
819
+ waitForMergeReason,
820
+ autoMergeReason,
821
+ storyId: options.storyId,
822
+ waitForMergeExplicit: options.waitForMergeExplicit,
823
+ });
824
+ const prCtx = {
673
825
  storyId: options.storyId,
674
826
  storyBranch,
675
827
  baseBranch,
676
- prUrl,
677
828
  prNumber,
829
+ prUrl,
678
830
  autoMergeEnabled,
679
831
  autoMergeReason,
680
832
  worktreeReaped,
681
- // Story #4860 — this is the no-wait ending: the PR is open and a human
682
- // owns the merge, so the Story stays assigned until the confirm-merge
683
- // surface lands it and runs the tail.
684
- leaseReleased: false,
685
833
  localCleanupDeferred,
686
834
  directMerged,
687
- });
688
- // `--no-wait-merge` / operator-merge: the PR is open and the human owns
689
- // the land. That is a `pending` terminal by definition — the work is not
690
- // done, nothing is broken, and one named command finishes it — rather
691
- // than a fourth status invented for this one case.
692
- const terminal = buildTerminalEnvelope({
693
- storyId: options.storyId,
694
- status: 'pending',
695
- phase: 'auto-merge',
696
- storyBranch,
697
- baseBranch,
698
- pr: {
699
- number: prNumber,
700
- url: prUrl ?? null,
701
- state: 'OPEN',
702
- autoMergeEnabled: Boolean(autoMergeEnabled),
835
+ config,
836
+ startedAtMs,
837
+ gates: {
838
+ validation: options.skipValidation ? 'skipped' : 'passed',
839
+ baseSync: options.skipSync ? 'skipped' : 'passed',
840
+ codeReview: 'passed',
703
841
  },
704
- gates,
705
- nextCommand: NEXT_COMMANDS.confirmMerge(options.storyId),
706
- elapsedSeconds: Math.round((Date.now() - startedAtMs) / 1000),
707
- });
708
- await emitTerminal({ terminal, result, config });
709
- progress(
710
- 'DONE',
711
- `✅ Story #${options.storyId}: PR ready → ${prUrl} (${waitForMergeReason})`,
712
- );
713
- return { success: true, result, terminal };
842
+ };
843
+
844
+ if (waitForMerge) {
845
+ return await finishWithMergeWait(prCtx, {
846
+ cwd: options.cwd,
847
+ provider,
848
+ maxWaitSeconds: options.maxWaitSeconds,
849
+ mergeWatchMode: options.mergeWatchMode,
850
+ setPhase,
851
+ injectedGh,
852
+ injectedNotify,
853
+ });
854
+ }
855
+ return await finishWithoutMergeWait(prCtx, waitForMergeReason);
714
856
  }
@@ -9,13 +9,24 @@
9
9
  import { parse as parseStoryBody } from '../story-body/story-body.js';
10
10
 
11
11
  /**
12
- * Soft advisory word budget for a Story's inline `## Spec` (Story #4723).
13
- * ~250 words is the #4707 contract-level-prose target: interfaces,
14
- * invariants, and load-bearing constraints not route-by-route behavior
15
- * narration. Distinct from the hard ~1500-token fail-closed ceiling in
12
+ * Warn threshold (words) for a Story's inline `## Spec` (Story #4723,
13
+ * retuned by Story #4907). The budget carries **two distinct numbers**, and
14
+ * both belong in any surface that documents it:
15
+ *
16
+ * - **~250 words — the authoring target.** The #4707 contract-level-prose
17
+ * aim: interfaces, invariants, and load-bearing constraints, not
18
+ * route-by-route behavior narration. It lives in the story-author template
19
+ * placeholder (`plan-context.js`), which is what an author actually reads.
20
+ * - **350 words — this warn threshold.** Deliberately slack against the
21
+ * target so the warning marks a real outlier rather than ordinary
22
+ * authoring variance. Measured over the 45 most recent Stories carrying a
23
+ * Spec, a 250-word threshold fired on 22% of *accepted* work (median 233,
24
+ * p75 249) — a warning that common trains its readers to ignore it.
25
+ *
26
+ * Distinct again from the hard ~1500-token fail-closed ceiling in
16
27
  * `spec-spill.js`: this budget only warns; it never fails the persist.
17
28
  */
18
- export const SPEC_SOFT_WORD_BUDGET = 250;
29
+ export const SPEC_SOFT_WORD_BUDGET = 350;
19
30
 
20
31
  /**
21
32
  * Resolve a Story's Spec prose across both authoring shapes: the canonical