mandrel 2.8.0 → 2.10.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 (73) hide show
  1. package/.agents/agents/.markdownlint.json +4 -0
  2. package/.agents/agents/acceptance-critic.md +30 -5
  3. package/.agents/agents/auditor.md +36 -19
  4. package/.agents/agents/plan-critic.md +31 -5
  5. package/.agents/agents/story-worker.md +91 -100
  6. package/.agents/docs/configuration.md +39 -1
  7. package/.agents/docs/execution-reference.md +13 -0
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/instructions.md +131 -265
  10. package/.agents/rules/git-conventions.md +47 -83
  11. package/.agents/rules/orchestration-error-handling.md +28 -0
  12. package/.agents/schemas/agentrc.schema.json +44 -1
  13. package/.agents/schemas/validation-evidence.schema.json +3 -1
  14. package/.agents/scripts/acceptance-eval.js +1 -1
  15. package/.agents/scripts/apply-quality-bootstrap.js +1 -1
  16. package/.agents/scripts/audit-to-stories.js +51 -0
  17. package/.agents/scripts/check-test-temp-hygiene.js +438 -0
  18. package/.agents/scripts/deliver-recover.js +23 -6
  19. package/.agents/scripts/lib/audit-suite/index.js +5 -0
  20. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +179 -0
  21. package/.agents/scripts/lib/audit-suite/selector.js +1 -1
  22. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +120 -55
  23. package/.agents/scripts/lib/config/temp-paths.js +121 -1
  24. package/.agents/scripts/lib/config-settings-schema-delivery.js +30 -0
  25. package/.agents/scripts/lib/config-settings-schema.js +32 -0
  26. package/.agents/scripts/lib/findings/semantic-issue-search.js +43 -5
  27. package/.agents/scripts/lib/observability/metrics-ledger.js +217 -0
  28. package/.agents/scripts/lib/observability/runtime-friction.js +7 -0
  29. package/.agents/scripts/lib/observability/terse-result.js +114 -0
  30. package/.agents/scripts/lib/orchestration/complexity-gate.js +318 -0
  31. package/.agents/scripts/lib/orchestration/deliver-recover.js +137 -10
  32. package/.agents/scripts/lib/orchestration/merge-block-class.js +36 -15
  33. package/.agents/scripts/lib/orchestration/merge-poll.js +213 -0
  34. package/.agents/scripts/lib/orchestration/plan-context.js +60 -0
  35. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +182 -9
  36. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +29 -2
  37. package/.agents/scripts/lib/orchestration/plan-metrics.js +31 -82
  38. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +102 -2
  39. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +215 -14
  40. package/.agents/scripts/lib/orchestration/resolve-stories.js +7 -0
  41. package/.agents/scripts/lib/orchestration/review-providers/native.js +34 -16
  42. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +221 -8
  43. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -3
  44. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +230 -79
  45. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +55 -14
  46. package/.agents/scripts/lib/orchestration/story-close/emit-blocked.js +9 -3
  47. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +89 -1
  48. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +73 -0
  49. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -1
  50. package/.agents/scripts/lib/orchestration/task-body-validator.js +13 -40
  51. package/.agents/scripts/lib/story-body/body-format-lints.js +215 -0
  52. package/.agents/scripts/lib/story-body/story-body.js +18 -2
  53. package/.agents/scripts/lib/templates/decomposer-prompts.js +29 -6
  54. package/.agents/scripts/lib/test-env.js +65 -0
  55. package/.agents/scripts/plan-context.js +66 -9
  56. package/.agents/scripts/plan-critics.js +115 -3
  57. package/.agents/scripts/plan-persist.js +11 -1
  58. package/.agents/scripts/plan-run-epilogue.js +1 -1
  59. package/.agents/scripts/providers/github/issues.js +54 -7
  60. package/.agents/scripts/providers/github/search-budget.js +124 -0
  61. package/.agents/scripts/providers/github/search-query.js +71 -0
  62. package/.agents/scripts/single-story-confirm-merge.js +79 -10
  63. package/.agents/scripts/single-story-init.js +19 -3
  64. package/.agents/scripts/stories-wave-tick.js +1 -1
  65. package/.agents/scripts/sync-branch-from-base.js +9 -3
  66. package/.agents/workflows/deliver.md +86 -230
  67. package/.agents/workflows/helpers/deliver-reference.md +167 -0
  68. package/.agents/workflows/helpers/deliver-story-reference.md +203 -0
  69. package/.agents/workflows/helpers/deliver-story.md +114 -422
  70. package/.agents/workflows/helpers/plan-reference.md +211 -0
  71. package/.agents/workflows/plan.md +107 -279
  72. package/docs/CHANGELOG.md +47 -0
  73. package/package.json +1 -1
@@ -36,6 +36,24 @@
36
36
  * Backgrounding is not a workaround here and does not need to be: an
37
37
  * interrupted poll is stateless and re-entrant by construction.
38
38
  *
39
+ * ## Async mode (Story #4698 — a designed short probe window, not an accident)
40
+ *
41
+ * `maxWaitSeconds` (default 300s) still routinely EXPIRES on a slow-CI
42
+ * consumer: the median PR-create→merge time can be minutes, so nearly every
43
+ * close burns its whole foreground slot polling and then returns `pending`
44
+ * anyway. `delivery.mergeWatch.mode: "async"` makes that async confirm a
45
+ * designed mode rather than an expiry accident. It caps the per-invocation
46
+ * wait to a short probe window (`ASYNC_PROBE_WINDOW_SECONDS`, ~60s) — long
47
+ * enough for the loop's existing checks to catch an instant merge and, via the
48
+ * imported {@link decideMergeWaitFailFast} decision (Story #4695/#4710), an
49
+ * instantly-red required check — then returns the SAME resumable `pending`
50
+ * terminal, whose `nextCommand` the worker launches in the background. Nothing
51
+ * else changes: the cumulative `maxBudgetSeconds` anchor is untouched, and
52
+ * `sync` mode (the default) is byte-compatible. An explicit `--max-wait-seconds`
53
+ * override wins over the async cap so a headless caller can still land in one
54
+ * block. The clamp lives entirely in `resolveMergeWaitConfig`; the poll loop is
55
+ * mode-agnostic.
56
+ *
39
57
  * ## The wait is not weaker than the watch it displaced
40
58
  *
41
59
  * The pre-#4543 poll read only `state` / `mergedAt`. A check that went red
@@ -72,7 +90,7 @@
72
90
  * confirm.
73
91
  */
74
92
 
75
- import { gh as defaultGh } from '../../../gh-exec.js';
93
+ import { createGh } from '../../../gh-exec.js';
76
94
  import {
77
95
  confirmStoryMerged as defaultConfirmStoryMerged,
78
96
  readPrMergeState as defaultReadPrMergeState,
@@ -86,8 +104,10 @@ import { classifyMergeBlock as defaultClassifyMergeBlock } from '../../merge-blo
86
104
  import {
87
105
  DEFAULT_INTERVAL_SECONDS,
88
106
  DEFAULT_MAX_BUDGET_SECONDS,
107
+ decideMergeWaitFailFast,
89
108
  deriveChecksStatus,
90
- failingChecksBlockMerge,
109
+ deriveRequiredRunEvidence,
110
+ MERGE_WAIT_GH_TIMEOUT_MS,
91
111
  } from '../../merge-poll.js';
92
112
  import { NEXT_COMMANDS } from '../../story-deliver-terminal.js';
93
113
  import {
@@ -105,6 +125,17 @@ import { runPostLandTail as defaultRunPostLandTail } from './post-land.js';
105
125
  */
106
126
  export const DEFAULT_MAX_WAIT_SECONDS = 300;
107
127
 
128
+ /**
129
+ * Async-mode per-invocation probe window (Story #4698). When
130
+ * `delivery.mergeWatch.mode` is `"async"`, `resolveMergeWaitConfig` caps the
131
+ * per-invocation wait to this many seconds so close returns the resumable
132
+ * `pending` terminal fast instead of burning the foreground host slot. Sized
133
+ * to catch an instant merge and — via the head-anchored required-check
134
+ * predicate — an instantly-red required check, while staying far inside the
135
+ * cumulative `maxBudgetSeconds` give-up bound.
136
+ */
137
+ export const ASYNC_PROBE_WINDOW_SECONDS = 60;
138
+
108
139
  /** Bounded `gh pr update-branch` attempts for a BEHIND PR. */
109
140
  export const DEFAULT_UPDATE_ATTEMPTS = 3;
110
141
 
@@ -129,6 +160,49 @@ function defaultSleep(ms) {
129
160
  return new Promise((resolve) => setTimeout(resolve, ms));
130
161
  }
131
162
 
163
+ /**
164
+ * The wait's default `gh` facade, bound to a spawn-level timeout (Story
165
+ * #4710): every subprocess the wait launches through it carries
166
+ * `MERGE_WAIT_GH_TIMEOUT_MS`, so a wedged `gh` child is killed rather than
167
+ * stranding an unattended async-mode wait forever. Callers that inject their
168
+ * own `gh` (tests, the resume CLI) are bounded by {@link withGhTimeout} at
169
+ * the call sites instead.
170
+ */
171
+ const defaultGh = createGh(undefined, { timeoutMs: MERGE_WAIT_GH_TIMEOUT_MS });
172
+
173
+ /**
174
+ * Bound an arbitrary `gh` call with a wall-clock timeout (Story #4710). The
175
+ * spawn-level `timeoutMs` on {@link defaultGh} already kills a wedged real
176
+ * subprocess, but an injected `gh` implementation (a test stub, a facade
177
+ * built without defaults) can still return a promise that never settles —
178
+ * and the merge wait must never hang on any of them. Rejection maps to the
179
+ * caller's existing error handling: the probe degrades to its conservative
180
+ * pending shape, the update-branch attempt logs and continues.
181
+ *
182
+ * A late settlement of the losing promise is explicitly absorbed so a
183
+ * post-timeout rejection cannot surface as an unhandled rejection.
184
+ *
185
+ * The timeout timer is deliberately NOT `unref`'d: when the awaited call is a
186
+ * promise that never settles (a hung stub, or a real gh child whose I/O has
187
+ * gone quiet), the timer is the ONLY handle keeping the event loop alive, so
188
+ * unref'ing it would let the process/test exit before the timeout ever fires —
189
+ * exactly the hang this guard exists to prevent. It is short-lived and always
190
+ * cleared in `finally`, so keeping it referenced costs nothing.
191
+ */
192
+ function withGhTimeout(promise, timeoutMs, label) {
193
+ let timer;
194
+ const bounded = new Promise((resolve, reject) => {
195
+ timer = setTimeout(() => {
196
+ reject(
197
+ new Error(`${label} did not return within ${timeoutMs}ms (timeout)`),
198
+ );
199
+ }, timeoutMs);
200
+ promise.then(resolve, reject);
201
+ }).finally(() => clearTimeout(timer));
202
+ promise.catch(() => {});
203
+ return bounded;
204
+ }
205
+
132
206
  /**
133
207
  * One probe per poll iteration, carrying every field the loop and the
134
208
  * terminal classifier need: merge state, the checks rollup, the merge-state
@@ -137,20 +211,31 @@ function defaultSleep(ms) {
137
211
  *
138
212
  * Returns a degraded `{ checksStatus: 'pending', error }` probe when the read
139
213
  * itself fails, preserving the conservative classification on probe errors —
140
- * a flaky API read must not be mistaken for a definitive verdict.
214
+ * a flaky API read must not be mistaken for a definitive verdict. A probe
215
+ * that exceeds `ghTimeoutMs` (Story #4710) takes the SAME degraded path: a
216
+ * hung subprocess must surface as a probe error within the bound, never
217
+ * strand the wait.
141
218
  *
142
219
  * @returns {Promise<object>}
143
220
  */
144
- export async function readPrWaitProbe({ prNumber, gh = defaultGh }) {
221
+ export async function readPrWaitProbe({
222
+ prNumber,
223
+ gh = defaultGh,
224
+ ghTimeoutMs = MERGE_WAIT_GH_TIMEOUT_MS,
225
+ }) {
145
226
  try {
146
- const view = await gh.pr.view(prNumber, [
147
- 'state',
148
- 'mergedAt',
149
- 'createdAt',
150
- 'mergeStateStatus',
151
- 'reviewDecision',
152
- 'statusCheckRollup',
153
- ]);
227
+ const view = await withGhTimeout(
228
+ gh.pr.view(prNumber, [
229
+ 'state',
230
+ 'mergedAt',
231
+ 'createdAt',
232
+ 'mergeStateStatus',
233
+ 'reviewDecision',
234
+ 'statusCheckRollup',
235
+ ]),
236
+ ghTimeoutMs,
237
+ `gh pr view ${prNumber}`,
238
+ );
154
239
  return {
155
240
  state: typeof view?.state === 'string' ? view.state : null,
156
241
  mergedAt: typeof view?.mergedAt === 'string' ? view.mergedAt : null,
@@ -164,6 +249,11 @@ export async function readPrWaitProbe({ prNumber, gh = defaultGh }) {
164
249
  ? view.reviewDecision
165
250
  : undefined,
166
251
  checksStatus: deriveChecksStatus(view?.statusCheckRollup),
252
+ // Head-anchored per-run evidence (Story #4695): distinguishes a
253
+ // genuinely red required run from the superseded / still-pending noise
254
+ // the aggregate `checksStatus` folds together. `null` when the rollup is
255
+ // absent/empty — the loop's consecutive-probe fallback owns that path.
256
+ requiredRunEvidence: deriveRequiredRunEvidence(view?.statusCheckRollup),
167
257
  };
168
258
  } catch (err) {
169
259
  return {
@@ -185,18 +275,36 @@ export async function readPrWaitProbe({ prNumber, gh = defaultGh }) {
185
275
  * raises the per-invocation bound to keep single-block semantics without
186
276
  * editing the consumer's config.
187
277
  *
278
+ * `mode` (Story #4698) selects the close-time merge posture. `async` caps the
279
+ * per-invocation wait to `ASYNC_PROBE_WINDOW_SECONDS` so close returns the
280
+ * resumable `pending` terminal fast; `sync` (the default) is unchanged. An
281
+ * explicit `maxWaitSecondsOverride` still wins over the async cap — a headless
282
+ * caller with no host ceiling opts back into single-block waiting.
283
+ *
188
284
  * @param {object} [config]
189
285
  * @param {number} [maxWaitSecondsOverride]
190
- * @returns {{ intervalSeconds: number, maxWaitSeconds: number, maxBudgetSeconds: number, updateAttempts: number }}
286
+ * @returns {{ mode: 'sync'|'async', intervalSeconds: number, maxWaitSeconds: number, maxBudgetSeconds: number, updateAttempts: number }}
191
287
  */
192
288
  export function resolveMergeWaitConfig(config, maxWaitSecondsOverride) {
193
289
  const mergeWatch = config?.delivery?.mergeWatch ?? {};
194
290
  const int = (value, fallback, min = 1) =>
195
291
  Number.isInteger(value) && value >= min ? value : fallback;
196
- const maxWaitSeconds = int(
292
+ const mode = mergeWatch.mode === 'async' ? 'async' : 'sync';
293
+ const configuredMaxWait = int(
197
294
  maxWaitSecondsOverride,
198
295
  int(mergeWatch.maxWaitSeconds, DEFAULT_MAX_WAIT_SECONDS),
199
296
  );
297
+ // Async mode caps the per-invocation wait to a short probe window so close
298
+ // returns `pending` fast instead of burning the foreground host slot on a
299
+ // merge that lands after the wait would have expired anyway. The window is
300
+ // long enough for the loop's existing checks to catch an instant merge and —
301
+ // via the imported `decideMergeWaitFailFast` decision — an instantly
302
+ // red required check. An explicit `--max-wait-seconds` override still wins so
303
+ // a headless caller with no host ceiling opts back into single-block waiting.
304
+ const maxWaitSeconds =
305
+ mode === 'async' && maxWaitSecondsOverride == null
306
+ ? Math.min(configuredMaxWait, ASYNC_PROBE_WINDOW_SECONDS)
307
+ : configuredMaxWait;
200
308
  // A poll interval longer than the wait bound is incoherent, and silently
201
309
  // harmful: the pending check would fire on poll 1 every time, so the wait
202
310
  // could never sleep, `polls` could never reach
@@ -210,6 +318,7 @@ export function resolveMergeWaitConfig(config, maxWaitSecondsOverride) {
210
318
  maxWaitSeconds,
211
319
  );
212
320
  return {
321
+ mode,
213
322
  intervalSeconds,
214
323
  maxWaitSeconds,
215
324
  maxBudgetSeconds: int(
@@ -429,6 +538,12 @@ async function blockOnUnlanded({
429
538
  budget,
430
539
  });
431
540
  const elapsedSeconds = budget?.elapsedSeconds ?? 0;
541
+ // Which evidence path produced a `checks-failed` verdict (Story #4695):
542
+ // `per-run` (head-anchored required-run evidence) or `consecutive-probe`
543
+ // (the evidence-unavailable fallback). Named on the emitted record so the
544
+ // `merge.unlanded` telemetry attributes the fail-fast to the path that
545
+ // fired it. Absent for every other block class.
546
+ const evidencePath = prProbe?.evidencePath;
432
547
 
433
548
  if (Number.isInteger(prNumber) && prNumber > 0) {
434
549
  try {
@@ -439,6 +554,7 @@ async function blockOnUnlanded({
439
554
  blockClass,
440
555
  reason,
441
556
  elapsedSeconds,
557
+ ...(evidencePath ? { evidencePath } : {}),
442
558
  });
443
559
  } catch (err) {
444
560
  progress?.(
@@ -509,6 +625,7 @@ async function maybeUpdateBehindPr({
509
625
  updatesUsed,
510
626
  updateAttempts,
511
627
  gh,
628
+ ghTimeoutMs = MERGE_WAIT_GH_TIMEOUT_MS,
512
629
  progress,
513
630
  }) {
514
631
  if (probe.mergeStateStatus !== 'BEHIND') return false;
@@ -520,7 +637,11 @@ async function maybeUpdateBehindPr({
520
637
  return false;
521
638
  }
522
639
  try {
523
- await (gh ?? defaultGh).pr.updateBranch(prNumber);
640
+ await withGhTimeout(
641
+ (gh ?? defaultGh).pr.updateBranch(prNumber),
642
+ ghTimeoutMs,
643
+ `gh pr update-branch ${prNumber}`,
644
+ );
524
645
  progress?.(
525
646
  'CONFIRM',
526
647
  `⏫ PR #${prNumber} was BEHIND its base — updated (attempt ${updatesUsed + 1}/${updateAttempts}).`,
@@ -650,6 +771,10 @@ async function onMergeObserved({
650
771
  * @param {(ms: number) => Promise<void>} [args.sleepFn] Test seam so the
651
772
  * suite does not actually wait.
652
773
  * @param {() => number} [args.nowMsFn] Test seam; returns epoch ms.
774
+ * @param {number} [args.ghTimeoutMs] Wall-clock bound for each `gh` call the
775
+ * wait makes (Story #4710). A framework constant
776
+ * (`MERGE_WAIT_GH_TIMEOUT_MS`), overridable only as a test seam — not
777
+ * config.
653
778
  * @returns {Promise<object>}
654
779
  */
655
780
  export async function runConfirmMergePhase({
@@ -676,6 +801,7 @@ export async function runConfirmMergePhase({
676
801
  runPostLandTailFn = defaultRunPostLandTail,
677
802
  sleepFn = defaultSleep,
678
803
  nowMsFn = Date.now,
804
+ ghTimeoutMs = MERGE_WAIT_GH_TIMEOUT_MS,
679
805
  }) {
680
806
  // The arm itself never succeeded (gh failure, unparseable PR number, or a
681
807
  // deliberate disablement) — there is no "armed but unconfirmed" PR to
@@ -699,22 +825,38 @@ export async function runConfirmMergePhase({
699
825
  });
700
826
  }
701
827
 
702
- const { intervalSeconds, maxWaitSeconds, maxBudgetSeconds, updateAttempts } =
703
- resolveMergeWaitConfig(config, maxWaitSecondsOverride);
828
+ const {
829
+ mode,
830
+ intervalSeconds,
831
+ maxWaitSeconds,
832
+ maxBudgetSeconds,
833
+ updateAttempts,
834
+ } = resolveMergeWaitConfig(config, maxWaitSecondsOverride);
704
835
  const intervalMs = intervalSeconds * 1000;
705
836
  const startedAtMs = nowMsFn();
706
837
  let anchorMs = startedAtMs;
707
838
  let updatesUsed = 0;
708
839
  let polls = 0;
840
+ // Consecutive failing check probes observed WITHOUT per-run evidence
841
+ // (Story #4695). The evidence-unavailable fallback: a single failing rollup
842
+ // snapshot never fail-fasts — two consecutive failing probes at least one
843
+ // poll interval apart are required. Reset on any non-failing (or genuinely
844
+ // evidenced) probe.
845
+ let consecutiveRequiredFailSnapshots = 0;
709
846
 
710
847
  progress?.(
711
848
  'CONFIRM',
712
849
  `⏳ Close-and-land: polling PR #${prNumber} for merge confirmation ` +
713
- `(wait=${maxWaitSeconds}s this invocation, cumulative budget=${maxBudgetSeconds}s)...`,
850
+ `(mode=${mode}, wait=${maxWaitSeconds}s this invocation, ` +
851
+ `cumulative budget=${maxBudgetSeconds}s)...`,
714
852
  );
715
853
 
716
854
  while (true) {
717
- const probe = await readPrWaitProbeFn({ prNumber, gh: injectedGh });
855
+ const probe = await readPrWaitProbeFn({
856
+ prNumber,
857
+ gh: injectedGh,
858
+ ghTimeoutMs,
859
+ });
718
860
  polls += 1;
719
861
 
720
862
  // Anchor the cumulative budget at the PR's creation the first time we
@@ -755,6 +897,12 @@ export async function runConfirmMergePhase({
755
897
  });
756
898
  }
757
899
 
900
+ // Everything below funnels into ONE terminal exit (Story #4710): each
901
+ // definitive condition fills `unlanded` and the single call site at the
902
+ // bottom classifies, emits, and blocks — the fail-fast tree used to
903
+ // duplicate that block twice inline.
904
+ let unlanded = null;
905
+
758
906
  if (probe.state === 'CLOSED') {
759
907
  // Closed without merging — a definitive terminal, not a "still
760
908
  // pending" condition the budget should keep waiting on. checksStatus
@@ -763,10 +911,7 @@ export async function runConfirmMergePhase({
763
911
  // pending", which would misclassify this definitive case as
764
912
  // checks-pending-timeout instead of reaching the api-race-other
765
913
  // reason built from prProbe.error.
766
- return blockOnUnlanded({
767
- storyId,
768
- prNumber,
769
- prUrl,
914
+ unlanded = {
770
915
  prProbe: {
771
916
  checksStatus: 'closed',
772
917
  error: 'PR closed without merging (state=CLOSED)',
@@ -775,72 +920,78 @@ export async function runConfirmMergePhase({
775
920
  exhausted: true,
776
921
  elapsedSeconds: Math.round(waitedMs / 1000),
777
922
  },
778
- provider,
779
- progress,
780
- classifyMergeBlockFn,
781
- emitMergeUnlandedFn,
923
+ };
924
+ } else {
925
+ // Fail fast on a GENUINELY red REQUIRED check — head-anchored (Story
926
+ // #4695), decided by the extracted `decideMergeWaitFailFast` (Story
927
+ // #4710): per-run evidence decides on a single probe; without evidence
928
+ // two consecutive failing probes are required. No remaining budget
929
+ // turns a failed check green, and waiting it out is what made the
930
+ // pre-#4543 wait report the operator's red test run as a
931
+ // branch-protection block.
932
+ const decision = decideMergeWaitFailFast({
933
+ probe,
934
+ consecutiveRequiredFailSnapshots,
782
935
  });
936
+ consecutiveRequiredFailSnapshots =
937
+ decision.consecutiveRequiredFailSnapshots;
938
+ if (decision.failFast) {
939
+ progress?.(
940
+ 'CONFIRM',
941
+ decision.evidencePath === 'per-run'
942
+ ? `🛑 PR #${prNumber}: a required check concluded failure with none in flight — failing fast (evidence=per-run).`
943
+ : `🛑 PR #${prNumber}: two consecutive failing check probes without per-run evidence — failing fast (evidence=consecutive-probe).`,
944
+ );
945
+ unlanded = {
946
+ prProbe: decision.prProbe,
947
+ budget: {
948
+ exhausted: false,
949
+ elapsedSeconds: Math.round(waitedMs / 1000),
950
+ },
951
+ };
952
+ }
783
953
  }
784
954
 
785
- // Fail fast on a red REQUIRED check. No remaining budget turns a failed
786
- // check green, and waiting it out is what made the pre-#4543 wait report
787
- // the operator's red test run as a branch-protection block.
788
- //
789
- // Gated on `failingChecksBlockMerge`, not on the raw rollup: a red
790
- // OPTIONAL check does not stop native auto-merge, so failing fast on it
791
- // would block the Story while the PR lands anyway.
792
- if (failingChecksBlockMerge(probe)) {
793
- progress?.(
794
- 'CONFIRM',
795
- `🛑 PR #${prNumber}: a required check went red — failing fast rather than burning the budget.`,
796
- );
797
- return blockOnUnlanded({
798
- storyId,
799
- prNumber,
800
- prUrl,
801
- prProbe: probe,
802
- budget: {
803
- exhausted: false,
804
- elapsedSeconds: Math.round(waitedMs / 1000),
805
- },
806
- provider,
807
- progress,
808
- classifyMergeBlockFn,
809
- emitMergeUnlandedFn,
810
- });
811
- }
955
+ if (!unlanded) {
956
+ if (
957
+ await maybeUpdateBehindPr({
958
+ probe,
959
+ prNumber,
960
+ updatesUsed,
961
+ updateAttempts,
962
+ gh: injectedGh,
963
+ ghTimeoutMs,
964
+ progress,
965
+ })
966
+ ) {
967
+ updatesUsed += 1;
968
+ }
812
969
 
813
- if (
814
- await maybeUpdateBehindPr({
815
- probe,
816
- prNumber,
817
- updatesUsed,
818
- updateAttempts,
819
- gh: injectedGh,
820
- progress,
821
- })
822
- ) {
823
- updatesUsed += 1;
970
+ // Cumulative budget exhausted → the genuine give-up. Classify from the
971
+ // probe we already hold. Gated behind the poll floor so an
972
+ // already-over-budget PR (anchored at a createdAt older than the budget
973
+ // — a resume the next day, or a long-open PR) still gets a real poll
974
+ // cycle instead of being blocked before this invocation waited at all.
975
+ if (
976
+ polls >= MIN_POLLS_BEFORE_BUDGET_BLOCK &&
977
+ cumulativeMs + intervalMs > maxBudgetSeconds * 1000
978
+ ) {
979
+ unlanded = {
980
+ prProbe: probe,
981
+ budget: {
982
+ exhausted: true,
983
+ elapsedSeconds: Math.round(cumulativeMs / 1000),
984
+ },
985
+ };
986
+ }
824
987
  }
825
988
 
826
- // Cumulative budget exhausted → the genuine give-up. Classify from the
827
- // probe we already hold. Gated behind the poll floor so an
828
- // already-over-budget PR (anchored at a createdAt older than the budget —
829
- // a resume the next day, or a long-open PR) still gets a real poll cycle
830
- // instead of being blocked before this invocation waited at all.
831
- if (
832
- polls >= MIN_POLLS_BEFORE_BUDGET_BLOCK &&
833
- cumulativeMs + intervalMs > maxBudgetSeconds * 1000
834
- ) {
989
+ if (unlanded) {
835
990
  return blockOnUnlanded({
836
991
  storyId,
837
992
  prNumber,
838
993
  prUrl,
839
- prProbe: probe,
840
- budget: {
841
- exhausted: true,
842
- elapsedSeconds: Math.round(cumulativeMs / 1000),
843
- },
994
+ ...unlanded,
844
995
  provider,
845
996
  progress,
846
997
  classifyMergeBlockFn,
@@ -7,6 +7,7 @@ import { resolveConfig } from '../../config-resolver.js';
7
7
  import { getStoryBranch, gitSync } from '../../git-utils.js';
8
8
  import { Logger } from '../../Logger.js';
9
9
  import { emitTerminalFriction } from '../../observability/runtime-friction.js';
10
+ import { emitTerseResult } from '../../observability/terse-result.js';
10
11
  import { createProvider } from '../../provider-factory.js';
11
12
  import { flipLabelAndNotify } from '../../single-story/story-merged-notify.js';
12
13
  import { WorktreeManager } from '../../worktree-manager.js';
@@ -53,12 +54,22 @@ const progress = Logger.createProgress('single-story-close', { stderr: true });
53
54
  * The emit is best-effort internally and cannot throw.
54
55
  */
55
56
  async function emitTerminal({ terminal, result, config }) {
56
- // The human-facing result dump stays level-gated; the terminal envelope is
57
- // the machine contract and must survive AGENT_LOG_LEVEL=silent.
57
+ // Story #4685 — the human-facing result dump goes to a temp log; the agent
58
+ // acts on the (separate, unsuppressible) terminal envelope emitted below.
59
+ // The single summary line keeps the fields worth an at-a-glance read.
58
60
  if (result) {
59
- Logger.info(
60
- `\n--- STORY CLOSE RESULT ---\n${JSON.stringify(result, null, 2)}\n--- END RESULT ---\n`,
61
- );
61
+ emitTerseResult({
62
+ label: 'STORY CLOSE RESULT',
63
+ result,
64
+ scope: result.storyId,
65
+ summary: {
66
+ storyId: result.storyId,
67
+ action: result.action,
68
+ reason: result.reason,
69
+ prNumber: result.prNumber,
70
+ status: terminal?.status,
71
+ },
72
+ });
62
73
  }
63
74
  emitTerminalEnvelope(terminal);
64
75
  await emitTerminalFriction({ envelope: terminal, config });
@@ -311,6 +322,8 @@ function closeResult({
311
322
  autoMergeReason,
312
323
  worktreeReaped,
313
324
  leaseReleased,
325
+ localCleanupDeferred = false,
326
+ directMerged = false,
314
327
  waitedForMerge = false,
315
328
  merged = false,
316
329
  }) {
@@ -326,6 +339,15 @@ function closeResult({
326
339
  autoMergeReason,
327
340
  worktreeReaped,
328
341
  leaseReleased,
342
+ // Story #4681 — `gh`'s local head-branch delete failed while the remote
343
+ // merge/arm stood. Surfaced so the land is auditable as
344
+ // merged-with-deferred-cleanup rather than silently degraded.
345
+ localCleanupDeferred,
346
+ // Story #4682 — native auto-merge was unavailable (no branch protection /
347
+ // an already-clean PR), so the PR was landed by a direct squash-merge.
348
+ // Surfaced so a checks-less land is auditable rather than looking like a
349
+ // queued auto-merge that never fired.
350
+ directMerged,
329
351
  waitedForMerge,
330
352
  merged,
331
353
  note: waitedForMerge
@@ -486,8 +508,31 @@ async function runClosePipeline({
486
508
  }),
487
509
  leaseArgs,
488
510
  );
511
+ // Reap the per-Story worktree BEFORE the arm (Story #4681). Arming runs
512
+ // `gh pr merge --auto --squash --delete-branch`, which — against an
513
+ // already-mergeable PR — merges immediately and then shells out to local
514
+ // `git` to drop `story-<id>`. A live worktree still holding that ref makes
515
+ // the local delete fail, `gh` exit non-zero, and the arm read as failed,
516
+ // which used to strand a genuinely merged PR at `agent::blocked`.
517
+ // Pre-empting the hold is the ordering half of the fix (the tolerate half
518
+ // lives in `phases/auto-merge.js`); it is safe here because push and PR
519
+ // creation already made the work durable off-machine, and `isSafeToRemove`
520
+ // still refuses a dirty tree.
521
+ const worktreeReaped = await reapWorktreePhase({
522
+ cwd: options.cwd,
523
+ storyId: options.storyId,
524
+ worktreePath,
525
+ wtIsolation: config.delivery?.worktreeIsolation,
526
+ progress,
527
+ WorktreeManager,
528
+ });
489
529
  setPhase('auto-merge');
490
- const { autoMergeEnabled, autoMergeReason } = await runAutoMergePhase({
530
+ const {
531
+ autoMergeEnabled,
532
+ autoMergeReason,
533
+ localCleanupDeferred,
534
+ directMerged,
535
+ } = await runAutoMergePhase({
491
536
  cwd: options.cwd,
492
537
  prNumber,
493
538
  prUrl,
@@ -507,14 +552,6 @@ async function runClosePipeline({
507
552
  config,
508
553
  progress,
509
554
  });
510
- const worktreeReaped = await reapWorktreePhase({
511
- cwd: options.cwd,
512
- storyId: options.storyId,
513
- worktreePath,
514
- wtIsolation: config.delivery?.worktreeIsolation,
515
- progress,
516
- WorktreeManager,
517
- });
518
555
  const leaseReleased = await releaseLease(leaseArgs);
519
556
 
520
557
  // Close-and-land (Story #4428; default since `delivery.routing.closeAndLand`
@@ -589,6 +626,8 @@ async function runClosePipeline({
589
626
  autoMergeReason,
590
627
  worktreeReaped,
591
628
  leaseReleased,
629
+ localCleanupDeferred,
630
+ directMerged,
592
631
  waitedForMerge: true,
593
632
  merged: waitOutcome.confirmed === true,
594
633
  });
@@ -625,6 +664,8 @@ async function runClosePipeline({
625
664
  autoMergeReason,
626
665
  worktreeReaped,
627
666
  leaseReleased,
667
+ localCleanupDeferred,
668
+ directMerged,
628
669
  });
629
670
  // `--no-wait-merge` / operator-merge: the PR is open and the human owns
630
671
  // the land. That is a `pending` terminal by definition — the work is not
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { Logger } from '../../Logger.js';
8
+ import { emitTerseResult } from '../../observability/terse-result.js';
8
9
 
9
10
  /**
10
11
  * Best-effort `story.blocked` lifecycle emit. The bus is optional and emit
@@ -41,9 +42,14 @@ export async function emitBlockedCloseResult({
41
42
  }) {
42
43
  const result = { success: false, status: 'blocked', phase, reason, ...extra };
43
44
  await emitStoryBlockedSafe({ bus, storyId, reason, logger });
44
- logger.info?.(
45
- `\n--- STORY CLOSE RESULT ---\n${JSON.stringify(result, null, 2)}\n--- END RESULT ---\n`,
46
- );
45
+ // Story #4685 — full detail to a temp log; single summary line in its place.
46
+ emitTerseResult({
47
+ label: 'STORY CLOSE RESULT',
48
+ result,
49
+ scope: storyId,
50
+ summary: { storyId, status: 'blocked', phase, reason },
51
+ log: logger,
52
+ });
47
53
  progress('BLOCKED', blockedMessage);
48
54
  return result;
49
55
  }