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
@@ -263,9 +263,96 @@ export function decideRecovery({ storyId, ticket, branch, pr }) {
263
263
  };
264
264
  }
265
265
 
266
+ /**
267
+ * The shapes a LIVE delivery process actively mutates while it runs. A probe
268
+ * that lands mid-close can read `executing` + `pr=none` seconds before the
269
+ * push and PR-open land, and confidently misdirect the operator to re-init a
270
+ * Story whose close is about to open a PR (observed live on Story #4712: two
271
+ * probes seconds apart flipped `executing-no-pr` → `executing-with-pr`).
272
+ * These shapes therefore earn a stability re-probe before the verdict is
273
+ * trusted; the remaining shapes (`merged-label-stale`, `blocked`,
274
+ * `done-board-drift`, `ready`) describe settled states no live process is
275
+ * racing to change.
276
+ */
277
+ const TRANSIENT_SHAPES = new Set([
278
+ 'executing-no-pr',
279
+ 'executing-with-pr',
280
+ 'closing-no-pr',
281
+ 'closing-pr-pending',
282
+ 'closing-pr-red',
283
+ ]);
284
+
285
+ /**
286
+ * Default settle window between the two probes of the stability pass. Long
287
+ * enough for an in-flight push / `gh pr create` / label flip to land (each is
288
+ * a single network call), short enough that the read-only CLI stays
289
+ * interactive.
290
+ */
291
+ const STABILITY_DELAY_MS = 5000;
292
+
293
+ /**
294
+ * Build the verdict for a state observed mid-mutation: the two probe rounds
295
+ * derived DIFFERENT shapes, so neither is safe to act on — acting on the
296
+ * first misdirects (the #4712 shape), acting on the second may race the same
297
+ * live process again. The one next command is the probe itself, re-run once
298
+ * the live process settles.
299
+ *
300
+ * @param {{ storyId: number, first: object, second: object, delayMs: number }} args
301
+ * @returns {{ shape: string, nextCommand: string, detail: string, evidence: string[] }}
302
+ */
303
+ function buildInTransitionVerdict({ storyId, first, second, delayMs }) {
304
+ return {
305
+ shape: 'in-transition',
306
+ nextCommand: NEXT_COMMANDS.recover(storyId),
307
+ detail:
308
+ `Two probes ${Math.round(delayMs / 1000)}s apart derived different shapes ` +
309
+ `(\`${first.shape}\` → \`${second.shape}\`): a delivery process is actively ` +
310
+ `mutating this Story's state right now (a push, PR open, or label flip landed ` +
311
+ `between the probes). Acting on either verdict risks duplicating or misdirecting ` +
312
+ `the live run. Wait for it to finish, then re-run this probe for a settled verdict.`,
313
+ evidence: [
314
+ `probe1.shape=${first.shape}`,
315
+ `probe2.shape=${second.shape}`,
316
+ ...second.evidence,
317
+ ],
318
+ };
319
+ }
320
+
321
+ /**
322
+ * One full probe round: ticket + branch + PR → decision. Throws only when
323
+ * the ticket itself is unreadable (the probe cannot run without it).
324
+ */
325
+ async function probeAndDecide({
326
+ storyId,
327
+ storyBranch,
328
+ cwd,
329
+ provider,
330
+ config,
331
+ gh,
332
+ gitSpawnFn,
333
+ }) {
334
+ const ticket = await probeTicket({ provider, storyId });
335
+ if (!ticket.ok) {
336
+ throw new Error(
337
+ `deliver-recover: could not read Story #${storyId}: ${ticket.error}`,
338
+ );
339
+ }
340
+ const branch = probeBranch({ cwd, storyBranch, config, gitSpawnFn });
341
+ const pr = await probePr({ storyBranch, gh });
342
+ const decision = decideRecovery({ storyId, ticket, branch, pr });
343
+ return { probes: { ticket, branch, pr }, decision };
344
+ }
345
+
266
346
  /**
267
347
  * Probe live state and resolve the single next command. Read-only.
268
348
  *
349
+ * Transient shapes (`executing-*` / `closing-*`) get a **stability re-probe**
350
+ * (same consecutive-evidence pattern the merge wait's fail-fast uses, Story
351
+ * #4695): a second probe after a short settle window. Matching shapes return
352
+ * the fresher verdict; diverging shapes return `in-transition` instead of a
353
+ * confidently wrong command. Settled shapes skip the second round — their
354
+ * state has no live process racing to change it.
355
+ *
269
356
  * @param {object} args
270
357
  * @param {number} args.storyId
271
358
  * @param {string} args.cwd
@@ -273,6 +360,11 @@ export function decideRecovery({ storyId, ticket, branch, pr }) {
273
360
  * @param {object} [args.config]
274
361
  * @param {object} [args.gh]
275
362
  * @param {Function} [args.gitSpawnFn]
363
+ * @param {boolean} [args.reprobe=true] Disable to skip the stability pass
364
+ * (single-probe legacy behavior — for scripted callers that own their own
365
+ * settling).
366
+ * @param {number} [args.stabilityDelayMs] Settle window between the probes.
367
+ * @param {Function} [args.sleepFn] Test seam for the settle wait.
276
368
  * @returns {Promise<object>}
277
369
  */
278
370
  export async function recoverStory({
@@ -282,22 +374,57 @@ export async function recoverStory({
282
374
  config,
283
375
  gh = defaultGh,
284
376
  gitSpawnFn,
377
+ reprobe = true,
378
+ stabilityDelayMs = STABILITY_DELAY_MS,
379
+ sleepFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
285
380
  }) {
286
381
  const storyBranch = getStoryBranch(storyId);
287
- const ticket = await probeTicket({ provider, storyId });
288
- if (!ticket.ok) {
289
- throw new Error(
290
- `deliver-recover: could not read Story #${storyId}: ${ticket.error}`,
291
- );
382
+ const probeArgs = {
383
+ storyId,
384
+ storyBranch,
385
+ cwd,
386
+ provider,
387
+ config,
388
+ gh,
389
+ gitSpawnFn,
390
+ };
391
+
392
+ const first = await probeAndDecide(probeArgs);
393
+ if (!reprobe || !TRANSIENT_SHAPES.has(first.decision.shape)) {
394
+ return {
395
+ storyId,
396
+ storyBranch,
397
+ probes: first.probes,
398
+ stability: { reprobed: false },
399
+ ...first.decision,
400
+ };
292
401
  }
293
- const branch = probeBranch({ cwd, storyBranch, config, gitSpawnFn });
294
- const pr = await probePr({ storyBranch, gh });
295
- const decision = decideRecovery({ storyId, ticket, branch, pr });
402
+
403
+ await sleepFn(stabilityDelayMs);
404
+ const second = await probeAndDecide(probeArgs);
405
+
406
+ if (second.decision.shape === first.decision.shape) {
407
+ // Stable across the settle window — trust the fresher evidence.
408
+ return {
409
+ storyId,
410
+ storyBranch,
411
+ probes: second.probes,
412
+ stability: { reprobed: true, stable: true, delayMs: stabilityDelayMs },
413
+ ...second.decision,
414
+ };
415
+ }
416
+
296
417
  return {
297
418
  storyId,
298
419
  storyBranch,
299
- probes: { ticket, branch, pr },
300
- ...decision,
420
+ probes: second.probes,
421
+ stability: { reprobed: true, stable: false, delayMs: stabilityDelayMs },
422
+ ...buildInTransitionVerdict({
423
+ storyId,
424
+ first: first.decision,
425
+ second: second.decision,
426
+ delayMs: stabilityDelayMs,
427
+ }),
301
428
  };
302
429
  }
303
430
 
@@ -71,7 +71,7 @@
71
71
  * (`emit-merge-unlanded.js`).
72
72
  */
73
73
 
74
- import { failingChecksBlockMerge } from './merge-poll.js';
74
+ import { requiredCheckFailedBlocksMerge } from './merge-poll.js';
75
75
 
76
76
  /**
77
77
  * Every class `classifyMergeBlock` can return. Order is the evaluation
@@ -169,12 +169,21 @@ function describeApiRaceFallback(prProbe, budget) {
169
169
  * rejection surfaced AT arm time still routes to
170
170
  * `branch-protection-human-required` rather than the generic
171
171
  * `arm-failure`.
172
- * 1b. A red required check — `checks-failed` (Story #4543). Evaluated
173
- * before every budget and probe signal because it is *definitive*:
174
- * no amount of remaining budget turns a failed check green, and on a
175
- * protected branch it also presents as `mergeStateStatus: 'BLOCKED'`,
176
- * so leaving it to step 3 would attribute the operator's red test run
177
- * to branch protection.
172
+ * 1b. A genuinely red required check — `checks-failed` (Story #4543,
173
+ * head-anchored by Story #4695). Evaluated before every budget and
174
+ * probe signal because it is *definitive*: no amount of remaining
175
+ * budget turns a failed check green, and on a protected branch it also
176
+ * presents as `mergeStateStatus: 'BLOCKED'`, so leaving it to step 3
177
+ * would attribute the operator's red test run to branch protection.
178
+ * Gated on `requiredCheckFailedBlocksMerge`: a run must have concluded
179
+ * failure with none in flight — a red rollup while a required run is
180
+ * merely queued is the protected-branch pending state, not this class.
181
+ * The predicate also declines the verdict when the probe reports
182
+ * `reviewDecision: 'REVIEW_REQUIRED'` (Story #4710): the rollup cannot
183
+ * prove the red run is required, and a missing required review already
184
+ * explains the BLOCKED merge state, so classification falls through to
185
+ * the step-3 human-required verdict instead of misdirecting the
186
+ * operator at a possibly-optional red check.
178
187
  * 2. Budget exhaustion while checks were still in flight —
179
188
  * `checks-pending-timeout`. Evaluated BEFORE the human-required
180
189
  * probe signals because on a protected branch GitHub reports
@@ -241,23 +250,35 @@ export function classifyMergeBlock(input) {
241
250
  // `undefined` (no probe at all) keeps its budget-timeout mapping in
242
251
  // step 2 without suppressing the step-3 human-required verdict.
243
252
  const checksStatus = prProbe?.checksStatus;
253
+ // A required run still queued/in-progress on the head is the
254
+ // protected-branch pending steady state (Story #4695), so it counts as
255
+ // in-flight evidence exactly like a `pending`/`still-running` aggregate —
256
+ // keeping a red-rollup-but-required-run-in-flight probe out of the
257
+ // human-required verdict below and into the timeout branch on budget expiry.
244
258
  const checksPendingEvidence =
245
- checksStatus === 'pending' || checksStatus === 'still-running';
259
+ checksStatus === 'pending' ||
260
+ checksStatus === 'still-running' ||
261
+ prProbe?.requiredRunEvidence?.requiredRunInFlight === true;
246
262
 
247
263
  // 1b. A required check is RED. Definitive — no remaining budget makes a
248
264
  // failed check pass — so this precedes both the budget branch and the
249
265
  // BLOCKED-merge-state heuristic, which would otherwise attribute the red
250
266
  // check to branch protection on any protected base.
251
267
  //
252
- // Gated on `failingChecksBlockMerge` rather than the raw rollup status:
253
- // `checksStatus: 'failure'` covers optional checks too, and naming an
254
- // optional red check as THE block sends the operator to fix a check that
255
- // was never gating the merge. A red-but-not-gating PR that still failed to
256
- // land falls through to the fallback, whose reason says exactly that.
257
- if (failingChecksBlockMerge(prProbe)) {
268
+ // Gated on `requiredCheckFailedBlocksMerge` (Story #4695), not the raw
269
+ // rollup status: `checksStatus: 'failure'` covers optional checks AND the
270
+ // protected-branch pending state where a required run is merely queued (the
271
+ // rollup counts a cancelled superseded run as failure). Naming either as THE
272
+ // block sends the operator to fix a check that was never gating the merge —
273
+ // on a PR that merges on its own. Only head-anchored evidence of a genuinely
274
+ // red required run with none in flight classifies here; anything short of
275
+ // that falls through, keeps polling, and — on budget expiry with checks in
276
+ // flight — classifies `checks-pending-timeout` as before.
277
+ if (requiredCheckFailedBlocksMerge(prProbe)) {
278
+ const evidencePath = prProbe?.evidencePath;
258
279
  return {
259
280
  blockClass: 'checks-failed',
260
- reason: `a required check failed (mergeStateStatus=${prProbe?.mergeStateStatus ?? 'n/a'})`,
281
+ reason: `a required check failed (mergeStateStatus=${prProbe?.mergeStateStatus ?? 'n/a'}${evidencePath ? `, evidence=${evidencePath}` : ''})`,
261
282
  };
262
283
  }
263
284
 
@@ -27,6 +27,20 @@
27
27
  export const DEFAULT_INTERVAL_SECONDS = 30;
28
28
  export const DEFAULT_MAX_BUDGET_SECONDS = 3600;
29
29
 
30
+ /**
31
+ * Wall-clock bound for every `gh` subprocess the merge wait spawns (Story
32
+ * #4710). The wait is now routinely unattended (`delivery.mergeWatch.mode:
33
+ * "async"` runs it in a background invocation with no host tool ceiling), so
34
+ * a hung `gh pr view` / `gh pr update-branch` used to strand the wait with no
35
+ * terminal envelope, no label flip, and no friction record. Sixty seconds is
36
+ * generous for a single API round-trip while staying inside the async probe
37
+ * window; a timeout maps to the existing probe-error path, so the wait
38
+ * degrades to conservative-pending / `api-race-other` semantics instead of
39
+ * hanging. A framework constant by design — not config (Story #4710
40
+ * Non-Goals).
41
+ */
42
+ export const MERGE_WAIT_GH_TIMEOUT_MS = 60_000;
43
+
30
44
  /**
31
45
  * Pure: derive an aggregate `checksStatus` (`success` | `still-running` |
32
46
  * `failure` | `unknown`) from a `statusCheckRollup` array (`gh pr view --json
@@ -57,6 +71,80 @@ export function deriveChecksStatus(statusCheckRollup) {
57
71
  return anyPending ? 'still-running' : 'success';
58
72
  }
59
73
 
74
+ /**
75
+ * Pure: derive HEAD-ANCHORED per-run evidence from a `statusCheckRollup`
76
+ * array, distinguishing a genuinely red required run from the pending /
77
+ * superseded noise the aggregate {@link deriveChecksStatus} folds together.
78
+ *
79
+ * {@link deriveChecksStatus} returns `failure` the instant it sees ANY
80
+ * non-passing conclusion — including a `CANCELLED` superseded-push run or a
81
+ * sibling-invalidated run — even while the real required check is still
82
+ * queued. Paired with `mergeStateStatus: BLOCKED` (the protected-branch steady
83
+ * state while required checks run), that matched a merely *pending* PR and
84
+ * hard-blocked Stories whose PRs merged untouched. This derivation reads the
85
+ * two signals the fail-fast decision actually needs:
86
+ *
87
+ * - `requiredRunFailed` — a run on the head concluded `FAILURE` (or a
88
+ * legacy status context is `FAILURE`/`ERROR`).
89
+ * Deliberately NOT `CANCELLED`/`TIMED_OUT`/
90
+ * `SKIPPED`: those are the superseded-push and
91
+ * sibling-invalidated runs, not a red required
92
+ * check.
93
+ * - `requiredRunInFlight` — any run on the head is still QUEUED /
94
+ * IN_PROGRESS (a CheckRun whose status is not
95
+ * `COMPLETED`, or a legacy status context still
96
+ * `PENDING`/`EXPECTED`).
97
+ *
98
+ * Returns `null` when the rollup is absent or empty — the evidence is
99
+ * unavailable and the caller must fall back to the consecutive-probe path
100
+ * (a single evidence-free failing snapshot must never fail-fast).
101
+ *
102
+ * **Contract honesty (Story #4710).** The `requiredRun*` field names describe
103
+ * what the evidence is USED to establish, not what this function reads: the
104
+ * `gh pr view` rollup projection carries no `isRequired` discriminator, so
105
+ * this derivation reads EVERY run on the head, required or not. On its own,
106
+ * `requiredRunFailed: true` therefore means "a head run genuinely concluded
107
+ * failure", and required-ness attribution is supplied downstream by
108
+ * {@link requiredCheckFailedBlocksMerge}, which admits the verdict only when
109
+ * `mergeStateStatus: BLOCKED` says GitHub itself gates the merge AND no
110
+ * review-required signal offers a competing explanation for that BLOCKED
111
+ * state. Do not treat this function's output as a required-only reading.
112
+ *
113
+ * @param {Array<{status?: string, conclusion?: string, state?: string}>} statusCheckRollup
114
+ * @returns {{ requiredRunFailed: boolean, requiredRunInFlight: boolean } | null}
115
+ */
116
+ export function deriveRequiredRunEvidence(statusCheckRollup) {
117
+ if (!Array.isArray(statusCheckRollup) || statusCheckRollup.length === 0) {
118
+ return null;
119
+ }
120
+ let requiredRunFailed = false;
121
+ let requiredRunInFlight = false;
122
+ for (const check of statusCheckRollup) {
123
+ const conclusion = String(check?.conclusion ?? '').toUpperCase();
124
+ const status = String(check?.status ?? '').toUpperCase();
125
+ const state = String(check?.state ?? '').toUpperCase();
126
+ // In flight: a CheckRun not yet COMPLETED, or a legacy StatusContext still
127
+ // PENDING/EXPECTED. `status` is empty on a StatusContext, so it degrades to
128
+ // the `state` branch rather than counting as in-flight.
129
+ if (status && status !== 'COMPLETED') {
130
+ requiredRunInFlight = true;
131
+ } else if (state === 'PENDING' || state === 'EXPECTED') {
132
+ requiredRunInFlight = true;
133
+ }
134
+ // Genuinely red: FAILURE / ERROR only. CANCELLED / TIMED_OUT / SKIPPED are
135
+ // the superseded / sibling-invalidated noise a bare rollup miscounts.
136
+ if (
137
+ conclusion === 'FAILURE' ||
138
+ conclusion === 'ERROR' ||
139
+ state === 'FAILURE' ||
140
+ state === 'ERROR'
141
+ ) {
142
+ requiredRunFailed = true;
143
+ }
144
+ }
145
+ return { requiredRunFailed, requiredRunInFlight };
146
+ }
147
+
60
148
  /**
61
149
  * The one `mergeStateStatus` value that means GitHub itself is gating the
62
150
  * merge. See {@link failingChecksBlockMerge}.
@@ -102,3 +190,128 @@ export function failingChecksBlockMerge(prProbe) {
102
190
  String(prProbe?.mergeStateStatus ?? '').toUpperCase() === MERGE_GATED_STATE
103
191
  );
104
192
  }
193
+
194
+ /**
195
+ * Pure: does HEAD-ANCHORED evidence establish that a REQUIRED check is
196
+ * genuinely red — enough to fail-fast the merge wait as `checks-failed`?
197
+ *
198
+ * This is the single gated decision Story #4695 adds, and the named predicate
199
+ * a downstream async-confirm Story imports rather than reopening the poll
200
+ * loop's classification internals. It layers on {@link failingChecksBlockMerge}
201
+ * (the rollup-`failure` + `mergeStateStatus: BLOCKED` gate) the head-anchored
202
+ * refinement the raw gate lacked: classify `checks-failed` ONLY when a run
203
+ * genuinely concluded failure AND none is still in flight. A red rollup while
204
+ * a required run is queued/in-progress is the protected-branch pending steady
205
+ * state, not a failure.
206
+ *
207
+ * The evidence is read from `prProbe.requiredRunEvidence` (the
208
+ * {@link deriveRequiredRunEvidence} output threaded through the probe). When it
209
+ * is absent — older `gh`, an API error, or a probe that never carried a rollup
210
+ * — this returns `false`: the caller's consecutive-probe fallback owns that
211
+ * path, because a single evidence-free failing snapshot must never fail-fast.
212
+ *
213
+ * **Review-required softening (Story #4710).** The rollup evidence cannot
214
+ * prove the red run is a REQUIRED check (see
215
+ * {@link deriveRequiredRunEvidence}), so when the probe carries a competing
216
+ * explanation for the `BLOCKED` merge state — `reviewDecision:
217
+ * 'REVIEW_REQUIRED'`, i.e. a required approval is missing — this predicate
218
+ * declines the `checks-failed` verdict. A red *optional* check beside a
219
+ * missing required review used to fail-fast as `checks-failed` and send the
220
+ * operator to fix a check that was never gating the merge; with the review
221
+ * signal present, classification falls through to the
222
+ * `branch-protection-human-required` branch, which names the gate GitHub
223
+ * actually attributes. When a genuinely red required check coexists with a
224
+ * missing review, both are true blocks and the human-required verdict is
225
+ * still an honest one — the conservative direction (see
226
+ * {@link failingChecksBlockMerge} on why failing to fail fast is the cheap
227
+ * error).
228
+ *
229
+ * @param {{ checksStatus?: string, mergeStateStatus?: string,
230
+ * reviewDecision?: string,
231
+ * requiredRunEvidence?: { requiredRunFailed?: boolean, requiredRunInFlight?: boolean } }} [prProbe]
232
+ * @returns {boolean}
233
+ */
234
+ export function requiredCheckFailedBlocksMerge(prProbe) {
235
+ if (!failingChecksBlockMerge(prProbe)) return false;
236
+ if (prProbe?.reviewDecision === 'REVIEW_REQUIRED') return false;
237
+ const evidence = prProbe?.requiredRunEvidence;
238
+ if (!evidence || typeof evidence.requiredRunFailed !== 'boolean') {
239
+ return false;
240
+ }
241
+ return (
242
+ evidence.requiredRunFailed === true && evidence.requiredRunInFlight !== true
243
+ );
244
+ }
245
+
246
+ /**
247
+ * Pure: the merge wait's single fail-fast decision (Story #4710 — extracted
248
+ * from the two near-verbatim inline blocks in `runConfirmMergePhase`'s poll
249
+ * loop, beside its sibling predicates).
250
+ *
251
+ * Encapsulates the Story #4695 evidence policy in one place:
252
+ *
253
+ * - **Per-run evidence available** — decide on this single probe via
254
+ * {@link requiredCheckFailedBlocksMerge}; a required run still in flight
255
+ * (or only superseded / non-required noise red) resets the counter and
256
+ * keeps polling.
257
+ * - **Evidence unavailable** (older `gh`, API error, empty rollup) — require
258
+ * TWO consecutive failing probes at least one poll interval apart, then
259
+ * synthesize the evidence shape the classifier's gate reads so both paths
260
+ * classify `checks-failed` through the same predicate.
261
+ *
262
+ * Returns the next counter value alongside the verdict; the caller owns the
263
+ * mutable counter and the terminal side effects. When `failFast` is `true`,
264
+ * `prProbe` is the evidence-stamped probe to hand to the classifier and
265
+ * `evidencePath` names which path fired (`per-run` | `consecutive-probe`) for
266
+ * the `merge.unlanded` telemetry.
267
+ *
268
+ * @param {object} args
269
+ * @param {object} args.probe The current poll's {@code readPrWaitProbe} result.
270
+ * @param {number} args.consecutiveRequiredFailSnapshots Evidence-free failing
271
+ * probes observed so far.
272
+ * @returns {{ failFast: boolean, consecutiveRequiredFailSnapshots: number,
273
+ * prProbe?: object, evidencePath?: 'per-run'|'consecutive-probe' }}
274
+ */
275
+ export function decideMergeWaitFailFast({
276
+ probe,
277
+ consecutiveRequiredFailSnapshots,
278
+ }) {
279
+ if (!failingChecksBlockMerge(probe)) {
280
+ return { failFast: false, consecutiveRequiredFailSnapshots: 0 };
281
+ }
282
+ if (probe?.requiredRunEvidence) {
283
+ if (requiredCheckFailedBlocksMerge(probe)) {
284
+ return {
285
+ failFast: true,
286
+ consecutiveRequiredFailSnapshots: 0,
287
+ evidencePath: 'per-run',
288
+ prProbe: { ...probe, evidencePath: 'per-run' },
289
+ };
290
+ }
291
+ // A required run is still in flight, only non-required / superseded runs
292
+ // are red, or a missing required review owns the BLOCKED state: the
293
+ // protected-branch steady state, not a failure. Keep polling.
294
+ return { failFast: false, consecutiveRequiredFailSnapshots: 0 };
295
+ }
296
+ const next = consecutiveRequiredFailSnapshots + 1;
297
+ // Synthesize the evidence the classifier's gate reads, so the
298
+ // consecutive-probe path classifies `checks-failed` through the SAME
299
+ // predicate as the per-run path (including its review-required softening).
300
+ const synthesized = {
301
+ ...probe,
302
+ requiredRunEvidence: {
303
+ requiredRunFailed: true,
304
+ requiredRunInFlight: false,
305
+ },
306
+ evidencePath: 'consecutive-probe',
307
+ };
308
+ if (next >= 2 && requiredCheckFailedBlocksMerge(synthesized)) {
309
+ return {
310
+ failFast: true,
311
+ consecutiveRequiredFailSnapshots: next,
312
+ evidencePath: 'consecutive-probe',
313
+ prProbe: synthesized,
314
+ };
315
+ }
316
+ return { failFast: false, consecutiveRequiredFailSnapshots: next };
317
+ }
@@ -26,6 +26,7 @@ import {
26
26
  renderTechSpecSystemPrompt,
27
27
  } from '../templates/spec-author-prompts.js';
28
28
  import { concurrentMap } from '../util/concurrent-map.js';
29
+ import { buildComplexityRouteSignal } from './complexity-gate.js';
29
30
  import { parseDeliverySlicingTable } from './consolidation-precondition.js';
30
31
  import { buildDocsDigest } from './docs-digest.js';
31
32
  import { buildAuthoringContext } from './planning/authoring-context.js';
@@ -137,6 +138,63 @@ export const TICKET_SCHEMA_DESCRIPTOR = Object.freeze({
137
138
  'validateAndNormalizeTickets (lib/orchestration/ticket-validator.js) at persist time',
138
139
  });
139
140
 
141
+ /**
142
+ * Filename of the ready-to-fill Story authoring template `plan-context.js`
143
+ * writes next to the captured envelope (Story #4707).
144
+ */
145
+ export const STORIES_TEMPLATE_FILENAME = 'stories.template.json';
146
+
147
+ /**
148
+ * Render the ready-to-fill `stories.json` authoring template (Story #4707).
149
+ *
150
+ * One-shot authoring: the planner copies this file to `stories.json`, fills
151
+ * the placeholder values, and runs persist — no step requires reading
152
+ * `story-body.js` source or re-poking the envelope for format discovery
153
+ * (bench: ~7 of 17 plan turns were format discovery). The template uses the
154
+ * **structured-object body** shape, which persist parses and serializes to
155
+ * the canonical markdown itself (`parse` accepts an object;
156
+ * `assembleOnePlanStory` re-serializes canonically), so the serializer
157
+ * contract never has to be reverse-engineered by the author. `acceptance[]`
158
+ * / `verify[]` live at the ticket's top level — the machine contract persist
159
+ * syncs into the body.
160
+ *
161
+ * Pure and deterministic; the output is valid JSON (parseable as-is), with
162
+ * instructive placeholder values rather than comments.
163
+ *
164
+ * @returns {string} Pretty-printed JSON template content.
165
+ */
166
+ export function renderStoriesTemplate() {
167
+ const template = [
168
+ {
169
+ slug: 'fill-hyphen-case-slug',
170
+ type: 'story',
171
+ title: 'Fill: short descriptive title',
172
+ body: {
173
+ goal: 'Fill: one sentence stating why this Story exists.',
174
+ spec:
175
+ 'Optional — contract and invariants only: interfaces, status ' +
176
+ 'codes, security invariants, and load-bearing constraints with ' +
177
+ 'their why. Implementation choices belong to the deliverer unless ' +
178
+ 'load-bearing. No per-file behavior paragraphs, no current-state ' +
179
+ 'narration. Delete this field when acceptance[] carries the whole ' +
180
+ 'contract.',
181
+ changes: [
182
+ { path: 'path/to/file.ext', assumption: 'refactors-existing' },
183
+ ],
184
+ non_goals: [],
185
+ reason_to_exist:
186
+ 'Fill: the single coherent reason this Story exists (one sentence).',
187
+ },
188
+ acceptance: [
189
+ 'Fill: a testable, observable criterion (a command exits 0, a file exists, a test matches)',
190
+ ],
191
+ verify: ['Fill: exact command or test path (unit|contract|e2e|validate)'],
192
+ depends_on: [],
193
+ },
194
+ ];
195
+ return `${JSON.stringify(template, null, 2)}\n`;
196
+ }
197
+
140
198
  /**
141
199
  * Count top-level enumerated items (`- `, `* `, `1. `) anywhere in a
142
200
  * free-form seed text. Unlike {@link countScopeItems} this does not require
@@ -482,6 +540,7 @@ async function buildSeedFileModeEnvelope({
482
540
  return {
483
541
  mode: modeLabel,
484
542
  seed: { path: seedFilePath ?? null, content },
543
+ complexityRoute: buildComplexityRouteSignal({ seedText: content, config }),
485
544
  duplicates,
486
545
  docsContext,
487
546
  codebaseSnapshot: authoring.codebaseSnapshot,
@@ -635,6 +694,7 @@ async function buildTicketsModeEnvelope({
635
694
  mode: 'tickets',
636
695
  sourceTickets,
637
696
  seed: { text: seed, path: null },
697
+ complexityRoute: buildComplexityRouteSignal({ seedText: seed, config }),
638
698
  duplicates,
639
699
  docsContext,
640
700
  codebaseSnapshot: authoring.codebaseSnapshot,