mandrel 2.25.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 (131) hide show
  1. package/.agents/agents/acceptance-critic.md +10 -6
  2. package/.agents/audit-checklists/baselines.md +21 -0
  3. package/.agents/docs/quality-gates.md +80 -18
  4. package/.agents/docs/workflows.md +3 -1
  5. package/.agents/instructions.md +1 -1
  6. package/.agents/schemas/audit-rules.json +15 -0
  7. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  8. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  9. package/.agents/schemas/baselines/crap.schema.json +8 -0
  10. package/.agents/schemas/model-attribution.schema.json +4 -0
  11. package/.agents/scripts/acceptance-eval.js +89 -6
  12. package/.agents/scripts/audit-baselines.js +136 -0
  13. package/.agents/scripts/check-arch-cycles.js +12 -93
  14. package/.agents/scripts/check-baseline-drift.js +16 -3
  15. package/.agents/scripts/check-baselines.js +19 -3
  16. package/.agents/scripts/check-cyclomatic.js +214 -0
  17. package/.agents/scripts/check-schema-references.js +392 -0
  18. package/.agents/scripts/check-test-temp-hygiene.js +38 -1
  19. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  20. package/.agents/scripts/diagnose-friction.js +85 -19
  21. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  22. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  23. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  24. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  25. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  26. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  27. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  28. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  29. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  30. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  31. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  32. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  33. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  34. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  35. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  36. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  37. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  38. package/.agents/scripts/lib/baselines/git-base.js +26 -4
  39. package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
  40. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  41. package/.agents/scripts/lib/baselines/scope.js +39 -90
  42. package/.agents/scripts/lib/baselines/writer.js +16 -11
  43. package/.agents/scripts/lib/changed-files.js +8 -1
  44. package/.agents/scripts/lib/cli-args.js +115 -1
  45. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  46. package/.agents/scripts/lib/crap-engine.js +32 -13
  47. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  48. package/.agents/scripts/lib/crap-utils.js +13 -0
  49. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  50. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  51. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  52. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  53. package/.agents/scripts/lib/git-utils.js +136 -80
  54. package/.agents/scripts/lib/import-graph.js +156 -0
  55. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  56. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  57. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  58. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  59. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  60. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  61. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  62. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  63. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  64. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  65. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  66. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
  67. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
  68. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  69. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  70. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  71. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  72. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  73. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
  75. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  76. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  77. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  78. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
  79. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  80. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  81. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  82. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  83. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  84. package/.agents/scripts/lib/story-adjacency.js +3 -3
  85. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  86. package/.agents/scripts/lib/test-tiers.js +11 -2
  87. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  88. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
  91. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
  92. package/.agents/scripts/lib/workers/crap-worker.js +2 -10
  93. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  94. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  95. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  96. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  97. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  98. package/.agents/scripts/lib/worktree-manager.js +14 -0
  99. package/.agents/scripts/plan-run-epilogue.js +17 -5
  100. package/.agents/scripts/providers/github/tickets.js +33 -10
  101. package/.agents/scripts/provision-git-hooks.js +85 -0
  102. package/.agents/scripts/quality-preview.js +112 -28
  103. package/.agents/scripts/resolve-stories.js +4 -1
  104. package/.agents/scripts/run-coverage.js +86 -35
  105. package/.agents/scripts/run-lint.js +20 -0
  106. package/.agents/scripts/run-tests.js +26 -36
  107. package/.agents/scripts/single-story-close.js +28 -2
  108. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  109. package/.agents/scripts/stories-wave-tick.js +214 -38
  110. package/.agents/scripts/update-coverage-baseline.js +34 -4
  111. package/.agents/scripts/update-duplication-baseline.js +209 -83
  112. package/.agents/scripts/validate-docs-freshness.js +1 -0
  113. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  114. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  115. package/.agents/skills/skills.index.json +2 -2
  116. package/.agents/workflows/audit-baselines.md +289 -0
  117. package/.agents/workflows/audit-navigability.md +5 -4
  118. package/.agents/workflows/deliver.md +13 -4
  119. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  120. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  121. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  122. package/.agents/workflows/helpers/deliver-reference.md +77 -1
  123. package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
  124. package/.agents/workflows/helpers/plan-reference.md +15 -5
  125. package/.agents/workflows/memory-consolidate.md +116 -0
  126. package/.agents/workflows/plan.md +3 -0
  127. package/README.md +13 -6
  128. package/docs/CHANGELOG.md +64 -0
  129. package/package.json +9 -4
  130. package/.agents/schemas/friction-event.schema.json +0 -56
  131. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -377,6 +377,7 @@ export async function runSingleStoryClose({
377
377
  waitForMerge: waitForMergeParam,
378
378
  noWaitForMerge: noWaitForMergeParam,
379
379
  maxWaitSeconds: maxWaitSecondsParam,
380
+ mergeWatchMode: mergeWatchModeParam,
380
381
  injectedProvider,
381
382
  injectedConfig,
382
383
  injectedNotify,
@@ -395,10 +396,11 @@ export async function runSingleStoryClose({
395
396
  waitForMergeParam,
396
397
  noWaitForMergeParam,
397
398
  maxWaitSecondsParam,
399
+ mergeWatchModeParam,
398
400
  });
399
401
  if (!options.storyId) {
400
402
  throw new Error(
401
- '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>]',
402
404
  );
403
405
  }
404
406
 
@@ -433,6 +435,210 @@ export async function runSingleStoryClose({
433
435
  }
434
436
  }
435
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
+
436
642
  /**
437
643
  * The close pipeline proper. Split out of `runSingleStoryClose` so the
438
644
  * phase-tagging wrapper above stays a thin, obviously-correct boundary rather
@@ -553,31 +759,21 @@ async function runClosePipeline({
553
759
  WorktreeManager,
554
760
  });
555
761
  setPhase('auto-merge');
556
- // An already-merged head PR (Story #4873) has nothing to arm — `gh pr merge`
557
- // against it fails, which would report the arm as a fault and block a Story
558
- // whose work is already on the base branch. Skip the arm and let the confirm
559
- // phase observe the merge that happened.
560
762
  const {
561
763
  autoMergeEnabled,
562
764
  autoMergeReason,
563
765
  localCleanupDeferred,
564
766
  directMerged,
565
- } = alreadyMerged
566
- ? {
567
- autoMergeEnabled: true,
568
- autoMergeReason: null,
569
- localCleanupDeferred: false,
570
- directMerged: false,
571
- }
572
- : await runAutoMergePhase({
573
- cwd: options.cwd,
574
- prNumber,
575
- prUrl,
576
- noAutoMerge: options.noAutoMerge,
577
- autoMergePolicy: getCiDelivery(config).autoMerge,
578
- gh: injectedGh,
579
- progress,
580
- });
767
+ } = await resolveAutoMergeOutcome({
768
+ alreadyMerged,
769
+ cwd: options.cwd,
770
+ prNumber,
771
+ prUrl,
772
+ noAutoMerge: options.noAutoMerge,
773
+ autoMergePolicy: getCiDelivery(config).autoMerge,
774
+ gh: injectedGh,
775
+ progress,
776
+ });
581
777
  await flipLabelAndNotify({
582
778
  provider,
583
779
  notifyFn: injectedNotify,
@@ -619,133 +815,42 @@ async function runClosePipeline({
619
815
  config,
620
816
  autoMergeReason,
621
817
  });
622
- if (waitForMergeReason === 'operator-merge') {
623
- progress(
624
- 'MERGE',
625
- `⏭ Not waiting for merge (${autoMergeReason}) — the operator owns this merge; ` +
626
- `Story #${options.storyId} rests at agent::closing.` +
627
- (options.waitForMergeExplicit === true
628
- ? ' --wait-merge cannot land a PR that was deliberately left un-armed.'
629
- : ''),
630
- );
631
- }
632
- const gates = {
633
- validation: options.skipValidation ? 'skipped' : 'passed',
634
- baseSync: options.skipSync ? 'skipped' : 'passed',
635
- codeReview: 'passed',
636
- };
637
-
638
- if (waitForMerge) {
639
- setPhase('confirm-merge');
640
- const waitOutcome = await runConfirmMergePhase({
641
- cwd: options.cwd,
642
- storyId: options.storyId,
643
- storyBranch,
644
- baseBranch,
645
- prNumber,
646
- prUrl,
647
- autoMergeEnabled,
648
- autoMergeReason,
649
- provider,
650
- config,
651
- maxWaitSeconds: options.maxWaitSeconds,
652
- progress,
653
- injectedGh,
654
- injectedNotify,
655
- });
656
- const terminal = terminalFromWaitOutcome({
657
- waitOutcome,
658
- storyId: options.storyId,
659
- storyBranch,
660
- baseBranch,
661
- prNumber,
662
- prUrl,
663
- autoMergeEnabled,
664
- gates,
665
- elapsedSeconds: Math.round((Date.now() - startedAtMs) / 1000),
666
- });
667
- const result = closeResult({
668
- storyId: options.storyId,
669
- storyBranch,
670
- baseBranch,
671
- prUrl,
672
- prNumber,
673
- autoMergeEnabled,
674
- autoMergeReason,
675
- worktreeReaped,
676
- // Story #4860 — the release is a post-land tail step now, so the tail's
677
- // own per-step boolean IS the answer. A wait that ended anything other
678
- // than landed never ran the tail, and correctly reports `false`: the
679
- // claim is still held, by design.
680
- leaseReleased: waitOutcome.tail?.leaseRelease === true,
681
- localCleanupDeferred,
682
- directMerged,
683
- waitedForMerge: true,
684
- merged: waitOutcome.confirmed === true,
685
- });
686
- await emitTerminal({ terminal, result, config });
687
-
688
- if (terminal.status === 'landed') {
689
- progress('DONE', `✅ Story #${options.storyId}: PR merged → ${prUrl}`);
690
- } else if (terminal.status === 'pending') {
691
- // NOT a failure and NOT a block — the wait reached the edge of its
692
- // host slot with the PR healthy and in flight. The CLI maps this to
693
- // its own exit code so a caller can resume without classifying.
694
- progress(
695
- 'PENDING',
696
- `⏸ Story #${options.storyId}: PR ${prUrl} still in flight — resume with: ${terminal.nextCommand}`,
697
- );
698
- } else {
699
- progress(
700
- 'BLOCKED',
701
- `🛑 Story #${options.storyId}: PR ${prUrl} did not land ` +
702
- `(blockClass=${terminal.blocked?.blockClass}). Story is at agent::blocked. ` +
703
- `Next: ${terminal.nextCommand}`,
704
- );
705
- }
706
- return { success: terminal.status === 'landed', result, terminal };
707
- }
708
-
709
- const result = closeResult({
818
+ reportOperatorMergeSkip({
819
+ waitForMergeReason,
820
+ autoMergeReason,
821
+ storyId: options.storyId,
822
+ waitForMergeExplicit: options.waitForMergeExplicit,
823
+ });
824
+ const prCtx = {
710
825
  storyId: options.storyId,
711
826
  storyBranch,
712
827
  baseBranch,
713
- prUrl,
714
828
  prNumber,
829
+ prUrl,
715
830
  autoMergeEnabled,
716
831
  autoMergeReason,
717
832
  worktreeReaped,
718
- // Story #4860 — this is the no-wait ending: the PR is open and a human
719
- // owns the merge, so the Story stays assigned until the confirm-merge
720
- // surface lands it and runs the tail.
721
- leaseReleased: false,
722
833
  localCleanupDeferred,
723
834
  directMerged,
724
- });
725
- // `--no-wait-merge` / operator-merge: the PR is open and the human owns
726
- // the land. That is a `pending` terminal by definition — the work is not
727
- // done, nothing is broken, and one named command finishes it — rather
728
- // than a fourth status invented for this one case.
729
- const terminal = buildTerminalEnvelope({
730
- storyId: options.storyId,
731
- status: 'pending',
732
- phase: 'auto-merge',
733
- storyBranch,
734
- baseBranch,
735
- pr: {
736
- number: prNumber,
737
- url: prUrl ?? null,
738
- state: 'OPEN',
739
- autoMergeEnabled: Boolean(autoMergeEnabled),
835
+ config,
836
+ startedAtMs,
837
+ gates: {
838
+ validation: options.skipValidation ? 'skipped' : 'passed',
839
+ baseSync: options.skipSync ? 'skipped' : 'passed',
840
+ codeReview: 'passed',
740
841
  },
741
- gates,
742
- nextCommand: NEXT_COMMANDS.confirmMerge(options.storyId),
743
- elapsedSeconds: Math.round((Date.now() - startedAtMs) / 1000),
744
- });
745
- await emitTerminal({ terminal, result, config });
746
- progress(
747
- 'DONE',
748
- `✅ Story #${options.storyId}: PR ready → ${prUrl} (${waitForMergeReason})`,
749
- );
750
- 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);
751
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