mandrel 2.9.0 → 2.11.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 (75) 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 -25
  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 +36 -9
  13. package/.agents/schemas/validation-evidence.schema.json +3 -1
  14. package/.agents/scripts/acceptance-eval.js +10 -6
  15. package/.agents/scripts/apply-quality-bootstrap.js +1 -1
  16. package/.agents/scripts/check-test-temp-hygiene.js +438 -0
  17. package/.agents/scripts/deliver-recover.js +23 -6
  18. package/.agents/scripts/lib/audit-suite/index.js +5 -0
  19. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +179 -0
  20. package/.agents/scripts/lib/audit-suite/selector.js +1 -1
  21. package/.agents/scripts/lib/baselines/env-overrides.js +33 -0
  22. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  23. package/.agents/scripts/lib/baselines/preview-gates.js +5 -0
  24. package/.agents/scripts/lib/config/gates/maintainability.schema.js +10 -1
  25. package/.agents/scripts/lib/config/quality.js +13 -0
  26. package/.agents/scripts/lib/config/temp-paths.js +121 -1
  27. package/.agents/scripts/lib/config-settings-schema-delivery.js +30 -0
  28. package/.agents/scripts/lib/config-settings-schema.js +12 -16
  29. package/.agents/scripts/lib/observability/metrics-ledger.js +217 -0
  30. package/.agents/scripts/lib/observability/runtime-friction.js +7 -0
  31. package/.agents/scripts/lib/orchestration/ceremony-routing.js +45 -0
  32. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +97 -4
  33. package/.agents/scripts/lib/orchestration/check-baselines/phases/parse-args.js +7 -0
  34. package/.agents/scripts/lib/orchestration/complexity-gate.js +533 -93
  35. package/.agents/scripts/lib/orchestration/deliver-recover.js +137 -10
  36. package/.agents/scripts/lib/orchestration/merge-block-class.js +36 -15
  37. package/.agents/scripts/lib/orchestration/merge-poll.js +213 -0
  38. package/.agents/scripts/lib/orchestration/plan-context.js +119 -3
  39. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +182 -9
  40. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +29 -2
  41. package/.agents/scripts/lib/orchestration/plan-metrics.js +31 -82
  42. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +153 -2
  43. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +221 -14
  44. package/.agents/scripts/lib/orchestration/resolve-stories.js +12 -1
  45. package/.agents/scripts/lib/orchestration/review-depth.js +9 -4
  46. package/.agents/scripts/lib/orchestration/review-providers/native.js +34 -16
  47. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -3
  48. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +230 -79
  49. package/.agents/scripts/lib/orchestration/spec-budget.js +78 -0
  50. package/.agents/scripts/lib/orchestration/story-body-gate.js +72 -0
  51. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +89 -1
  52. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +73 -0
  53. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +6 -0
  54. package/.agents/scripts/lib/orchestration/ticket-validator.js +18 -62
  55. package/.agents/scripts/lib/templates/decomposer-prompts.js +13 -6
  56. package/.agents/scripts/lib/test-env.js +65 -0
  57. package/.agents/scripts/plan-context.js +84 -9
  58. package/.agents/scripts/plan-critics.js +115 -3
  59. package/.agents/scripts/plan-persist.js +11 -1
  60. package/.agents/scripts/plan-run-epilogue.js +1 -1
  61. package/.agents/scripts/resolve-stories.js +2 -0
  62. package/.agents/scripts/single-story-confirm-merge.js +65 -5
  63. package/.agents/scripts/stories-wave-tick.js +1 -1
  64. package/.agents/workflows/deliver.md +88 -230
  65. package/.agents/workflows/helpers/acceptance-self-eval.md +16 -5
  66. package/.agents/workflows/helpers/deliver-reference.md +171 -0
  67. package/.agents/workflows/helpers/deliver-story-reference.md +223 -0
  68. package/.agents/workflows/helpers/deliver-story.md +115 -432
  69. package/.agents/workflows/helpers/plan-reference.md +246 -0
  70. package/.agents/workflows/plan.md +108 -304
  71. package/docs/CHANGELOG.md +46 -0
  72. package/lib/cli/registry.js +31 -14
  73. package/lib/migrations/index.js +2 -0
  74. package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +92 -0
  75. 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,
@@ -0,0 +1,78 @@
1
+ /**
2
+ * lib/orchestration/spec-budget.js — the soft `## Spec` word-budget pass
3
+ * (Story #4723), extracted from `ticket-validator.js` so the advisory
4
+ * length nudge lives beside neither the hard validators nor their error
5
+ * channel: everything here is `'soft'` by construction and can never fail
6
+ * a persist.
7
+ */
8
+
9
+ import { parse as parseStoryBody } from '../story-body/story-body.js';
10
+
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
16
+ * `spec-spill.js`: this budget only warns; it never fails the persist.
17
+ */
18
+ export const SPEC_SOFT_WORD_BUDGET = 250;
19
+
20
+ /**
21
+ * Resolve a Story's Spec prose across both authoring shapes: the canonical
22
+ * serialized string body (parsed; `## Spec` text block) and the
23
+ * pre-serialize structured object body (`body.spec`). Returns `''` when
24
+ * absent — or when a string body does not parse: this pass is advisory, so
25
+ * an unreadable body contributes no finding here and is left to the hard
26
+ * parse gate (`assertStoryBodiesParse`) to reject.
27
+ *
28
+ * @param {object} story
29
+ * @returns {string}
30
+ */
31
+ function resolveSpecText(story) {
32
+ const body = story?.body;
33
+ if (typeof body === 'string' && body.trim().length > 0) {
34
+ let spec;
35
+ try {
36
+ spec = parseStoryBody(body).body.spec;
37
+ } catch {
38
+ return '';
39
+ }
40
+ return typeof spec === 'string' ? spec : '';
41
+ }
42
+ if (body !== null && typeof body === 'object') {
43
+ return typeof body?.spec === 'string' ? body.spec : '';
44
+ }
45
+ return '';
46
+ }
47
+
48
+ /**
49
+ * Advisory `## Spec` length pass (Story #4723). Emits one `'soft'` finding
50
+ * per Story whose Spec prose exceeds {@link SPEC_SOFT_WORD_BUDGET} words,
51
+ * nudging the author toward contract-level prose (#4707). Soft only — the
52
+ * findings never reach the validator's `errors[]` channel, so an
53
+ * over-budget Spec never fails the persist.
54
+ *
55
+ * @param {{ stories: object[] }} opts
56
+ * @returns {object[]} Zero or more `spec-word-budget` findings.
57
+ */
58
+ export function computeSpecBudgetFindings({ stories }) {
59
+ const findings = [];
60
+ for (const story of stories ?? []) {
61
+ const words = resolveSpecText(story).split(/\s+/).filter(Boolean).length;
62
+ if (words <= SPEC_SOFT_WORD_BUDGET) continue;
63
+ findings.push({
64
+ kind: 'spec-word-budget',
65
+ severity: 'soft',
66
+ ticketSlug: story.slug ?? '<unknown>',
67
+ words,
68
+ budget: SPEC_SOFT_WORD_BUDGET,
69
+ message:
70
+ `Story "${story.slug ?? '<unknown>'}" ## Spec is ~${words} words ` +
71
+ `(soft budget ${SPEC_SOFT_WORD_BUDGET}). Prefer contract-level prose ` +
72
+ '(interfaces, invariants, load-bearing constraints with their why) ' +
73
+ 'over per-file behavior narration — advisory only; the persist ' +
74
+ 'proceeds.',
75
+ });
76
+ }
77
+ return findings;
78
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * lib/orchestration/story-body-gate.js — the Story-body parse gate
3
+ * (Story #4541), extracted from `ticket-validator.js`: the one place a
4
+ * serialized Story body is parsed with parse failures translated into the
5
+ * validator's operator-legible `ValidationError` shape. Both the gate that
6
+ * refuses a plan up front (`assertStoryBodiesParse`) and the per-call
7
+ * translating parser (`parseStoryBodyOrThrow`) the downstream validators
8
+ * lean on live here.
9
+ */
10
+
11
+ import { ValidationError } from '../errors/index.js';
12
+ import {
13
+ parse as parseStoryBody,
14
+ StoryBodyParseError,
15
+ } from '../story-body/story-body.js';
16
+
17
+ /**
18
+ * Parse a Story's serialized markdown body, translating a
19
+ * `StoryBodyParseError` into a `ValidationError` that names the offending
20
+ * **section** and **entry** (Story #4541).
21
+ *
22
+ * `StoryBodyParseError` already carries `field` (the section the parser was
23
+ * reading) and `raw` (the entry text that failed); this lifts both into an
24
+ * operator-legible message and a structured `violation` payload so an
25
+ * authoring loop can point at the exact bullet instead of re-deriving it
26
+ * from a downstream freshness miss.
27
+ *
28
+ * @param {object} story Story whose `body` is a non-empty markdown string.
29
+ * @returns {object} The structured body.
30
+ * @throws {ValidationError} `code: 'story-body-unparseable'`.
31
+ */
32
+ export function parseStoryBodyOrThrow(story) {
33
+ try {
34
+ return parseStoryBody(story.body).body;
35
+ } catch (err) {
36
+ if (!(err instanceof StoryBodyParseError)) throw err;
37
+ const slug = story.slug ?? '<unknown>';
38
+ const section = err.field ?? 'body';
39
+ const entry = err.raw ?? null;
40
+ const entryLine = entry === null ? '' : `\n entry: ${entry}`;
41
+ const violation = { slug, section, entry, reason: err.message };
42
+ const error = new ValidationError(
43
+ `Cross-Validation Failed: Story "${slug}" has an unparseable body — ` +
44
+ `the ## ${section} section could not be read: ${err.message}` +
45
+ `${entryLine}\n\nFix the offending entry; this is a malformed body, ` +
46
+ 'not a stale path reference.',
47
+ { violations: [violation] },
48
+ );
49
+ error.code = 'story-body-unparseable';
50
+ error.violations = [violation];
51
+ throw error;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Refuse the plan when any Story's serialized body cannot be parsed, before
57
+ * either git-probe gate runs (Story #4541). Ordering matters: the freshness
58
+ * gate consults `body.changes` for its net-new whitelist, so an unparseable
59
+ * body used to reach the operator as a freshness miss naming declared paths.
60
+ *
61
+ * @param {{ tickets: object[] }} opts
62
+ * @throws {ValidationError} `code: 'story-body-unparseable'` on the first
63
+ * offending Story.
64
+ */
65
+ export function assertStoryBodiesParse({ tickets }) {
66
+ for (const story of (tickets ?? []).filter((t) => t.type === 'story')) {
67
+ if (typeof story.body !== 'string' || story.body.trim().length === 0) {
68
+ continue;
69
+ }
70
+ parseStoryBodyOrThrow(story);
71
+ }
72
+ }