mandrel 2.59.0 → 2.60.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 (97) hide show
  1. package/.agents/README.md +11 -9
  2. package/.agents/agents/acceptance-critic.md +24 -43
  3. package/.agents/agents/story-worker.md +18 -19
  4. package/.agents/docs/SDLC.md +6 -6
  5. package/.agents/docs/agentrc-reference.json +1 -2
  6. package/.agents/docs/configuration.md +29 -46
  7. package/.agents/docs/quality-gates.md +8 -4
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/instructions.md +4 -5
  10. package/.agents/rules/ci-remediation.md +41 -8
  11. package/.agents/rules/known-tooling-behavior.md +65 -15
  12. package/.agents/schemas/acceptance-eval-verdict.schema.json +1 -1
  13. package/.agents/schemas/agentrc.schema.json +6 -11
  14. package/.agents/schemas/story-deliver-terminal.schema.json +3 -3
  15. package/.agents/scripts/README.md +11 -1
  16. package/.agents/scripts/acceptance-eval.js +25 -27
  17. package/.agents/scripts/ceremony-derive.js +15 -10
  18. package/.agents/scripts/check-context-budget.js +148 -228
  19. package/.agents/scripts/check-schema-references.js +5 -3
  20. package/.agents/scripts/check-workflow-citations.js +33 -147
  21. package/.agents/scripts/coverage-capture.js +7 -4
  22. package/.agents/scripts/deliver-light.js +41 -100
  23. package/.agents/scripts/deliver-run.js +631 -0
  24. package/.agents/scripts/file-ci-gap.js +59 -11
  25. package/.agents/scripts/lib/baselines/crap-preview-incremental.js +6 -2
  26. package/.agents/scripts/lib/changed-files.js +30 -0
  27. package/.agents/scripts/lib/config/delivery-routing.js +5 -4
  28. package/.agents/scripts/lib/config/explain.js +1 -3
  29. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +1 -1
  30. package/.agents/scripts/lib/config-resolver.js +1 -0
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -21
  32. package/.agents/scripts/lib/coverage-capture-fullscope.js +10 -2
  33. package/.agents/scripts/lib/coverage-capture-incremental.js +3 -2
  34. package/.agents/scripts/lib/coverage-capture-usage.js +4 -1
  35. package/.agents/scripts/lib/doc-tiers.js +4 -2
  36. package/.agents/scripts/lib/feedback-loop/graduator-core.js +7 -6
  37. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +7 -5
  38. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  39. package/.agents/scripts/lib/gh-exec.js +160 -0
  40. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  41. package/.agents/scripts/lib/orchestration/ceremony-routing.js +74 -132
  42. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +123 -12
  43. package/.agents/scripts/lib/orchestration/complexity-gate.js +180 -352
  44. package/.agents/scripts/lib/orchestration/light-suitability.js +71 -136
  45. package/.agents/scripts/lib/orchestration/plan-context.js +13 -25
  46. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +8 -6
  47. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +76 -95
  48. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +35 -18
  49. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +11 -11
  50. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -29
  51. package/.agents/scripts/lib/orchestration/review-depth.js +14 -11
  52. package/.agents/scripts/lib/orchestration/run-epilogue.js +260 -182
  53. package/.agents/scripts/lib/orchestration/run-scoped-config.js +63 -99
  54. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +3 -3
  55. package/.agents/scripts/lib/orchestration/single-story-close/phases/graphql-preflight.js +137 -0
  56. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +105 -18
  57. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -3
  58. package/.agents/scripts/lib/orchestration/story-follow-ups.js +156 -39
  59. package/.agents/scripts/lib/orchestration/story-init-envelope.js +71 -0
  60. package/.agents/scripts/lib/orchestration/task-body-validator.js +8 -17
  61. package/.agents/scripts/lib/orchestration/ticket-validator.js +44 -183
  62. package/.agents/scripts/lib/orchestration/ticketing/reads.js +14 -25
  63. package/.agents/scripts/lib/story-body/body-format-lints.js +58 -12
  64. package/.agents/scripts/lib/story-body/story-body.js +83 -29
  65. package/.agents/scripts/lib/templates/decomposer-prompts.js +7 -15
  66. package/.agents/scripts/lib/wave-runner/live-probe.js +31 -5
  67. package/.agents/scripts/merge-baseline.js +4 -5
  68. package/.agents/scripts/plan-context.js +117 -28
  69. package/.agents/scripts/plan-persist.js +79 -28
  70. package/.agents/scripts/plan-run-epilogue.js +11 -8
  71. package/.agents/scripts/pr-watch-with-update.js +9 -2
  72. package/.agents/scripts/run-verify.js +13 -6
  73. package/.agents/scripts/single-story-init.js +7 -57
  74. package/.agents/scripts/stories-wave-tick.js +160 -26
  75. package/.agents/skills/core/gates-and-baselines/reference.md +0 -1
  76. package/.agents/skills/skills.index.json +2 -2
  77. package/.agents/skills/stack/qa/playwright/SKILL.md +26 -0
  78. package/.agents/workflows/helpers/acceptance-self-eval.md +84 -157
  79. package/.agents/workflows/helpers/code-review.md +4 -2
  80. package/.agents/workflows/helpers/deliver-digest.md +31 -24
  81. package/.agents/workflows/helpers/deliver-light.md +92 -101
  82. package/.agents/workflows/helpers/deliver-reference.md +116 -100
  83. package/.agents/workflows/helpers/deliver-story-reference.md +58 -124
  84. package/.agents/workflows/helpers/deliver-story.md +17 -18
  85. package/.agents/workflows/helpers/plan-reference.md +65 -54
  86. package/.agents/workflows/mandrel-deliver.md +47 -31
  87. package/.agents/workflows/mandrel-plan.md +22 -21
  88. package/.agents/workflows/mandrel-update.md +36 -21
  89. package/docs/CHANGELOG.md +35 -0
  90. package/lib/cli/update.js +376 -17
  91. package/lib/migrations/index.js +2 -0
  92. package/lib/migrations/steps/2.60.0-retire-audit-results-autofile.js +40 -0
  93. package/package.json +2 -1
  94. package/.agents/schemas/model-attribution.schema.json +0 -53
  95. package/.agents/scripts/lib/orchestration/model-attribution.js +0 -418
  96. package/.agents/scripts/lib/orchestration/story-plan-state.js +0 -33
  97. package/.agents/scripts/lib/orchestration/structured-comment-parser.js +0 -67
@@ -18,12 +18,18 @@
18
18
  *
19
19
  * ## Where the pin lives
20
20
  *
21
- * The `story-init` structured comment, not a temp log: the post-land tail
22
- * purges the temp tree, while the ticket comment survives every close re-run
23
- * and every recovery path. `single-story-init.js` already upserts that
24
- * comment; `pinRunScopedConfig` supplies the block it records, and
25
- * `resolveRunScopedConfig` reads it back through the existing
26
- * `findStructuredComment` seam.
21
+ * The init envelope on disk — `<tempRoot>/orchestration/story-init-result-<id>.log`,
22
+ * written by `single-story-init.js` via `emitTerseResult`. Story #5343 retired
23
+ * the `story-init` ticket comment (the delivery-comment diet), so the envelope
24
+ * is the pin's home; it is written before init returns and read at close, well
25
+ * before the post-land tail purges the temp tree.
26
+ *
27
+ * That is a hard cutover, not a dual read. The envelope is written by the same
28
+ * process, in the same run, that seeds the branch — so if it is gone, the temp
29
+ * tree was reaped and this is a much later close, exactly the case a stale
30
+ * `story-init` comment would have answered with equal uncertainty. A missing
31
+ * pin is never a refusal: it degrades to the currently-resolved config with a
32
+ * loud, announced warning, and close simply cannot confirm the base.
27
33
  *
28
34
  * ## Adding another run-scoped key
29
35
  *
@@ -36,8 +42,7 @@
36
42
  * concurrency cap are deliberately NOT pinned here.
37
43
  */
38
44
 
39
- import { parseFencedJsonComment } from './structured-comment-parser.js';
40
- import { findStructuredComment } from './ticketing.js';
45
+ import { readRunScopedPin } from './story-init-envelope.js';
41
46
 
42
47
  /**
43
48
  * The run-scoped config registry. One row per key whose value belongs to the
@@ -57,7 +62,7 @@ const RUN_SCOPED_CONFIG_KEYS = {
57
62
  /**
58
63
  * Snapshot the run-scoped config values from a resolved config. This is the
59
64
  * write half of the pin: `single-story-init.js` records the returned object
60
- * in the `story-init` receipt so close can compare against it later.
65
+ * on its init envelope so close can compare against it later.
61
66
  *
62
67
  * @param {object} config Resolved config (`resolveConfig` output).
63
68
  * @param {typeof RUN_SCOPED_CONFIG_KEYS} [keys] Registry override — the
@@ -74,63 +79,43 @@ export function pinRunScopedConfig(config, keys = RUN_SCOPED_CONFIG_KEYS) {
74
79
  }
75
80
 
76
81
  /**
77
- * Read the pinned block out of the run's `story-init` receipt.
82
+ * Compare the receipt's pinned values against the currently-resolved ones,
83
+ * one registry row at a time. Split out of `resolveRunScopedConfig` so the
84
+ * row walk and the three outcomes it feeds (confirmed / conflict / unpinned)
85
+ * read separately.
78
86
  *
79
- * Three distinct non-success states, all reported rather than collapsed —
80
- * the whole point of this module is that a fallback is never silent:
81
- * - `absent`: no `story-init` comment on the ticket. Real and expected —
82
- * the upsert is best-effort (init logs and continues on failure), and a
83
- * recovery path may close a Story whose init predates this receipt.
84
- * - `unreadable`: the comment exists but carries no parseable JSON payload.
85
- * - `provider-error`: the comment read itself failed.
87
+ * A row the receipt pins nothing for is `unpinned`, not a conflict: an older
88
+ * receipt simply predates that registry row, and falling back to current
89
+ * config for it is correct as long as the fallback is announced.
86
90
  *
87
91
  * @param {{
88
- * provider: object,
89
- * storyId: number,
90
- * findCommentFn?: typeof findStructuredComment,
92
+ * pinned: Record<string, unknown>,
93
+ * current: Record<string, unknown>,
94
+ * keys: typeof RUN_SCOPED_CONFIG_KEYS,
91
95
  * }} args
92
- * @returns {Promise<{ status: string, values: Record<string, unknown>|null, detail: string|null }>}
96
+ * @returns {{
97
+ * values: Record<string, unknown>,
98
+ * conflicts: Array<{ label: string, pinned: unknown, current: unknown }>,
99
+ * unpinned: string[],
100
+ * }}
93
101
  */
94
- async function readRunScopedConfigReceipt({
95
- provider,
96
- storyId,
97
- findCommentFn = findStructuredComment,
98
- }) {
99
- let comment;
100
- try {
101
- comment = await findCommentFn(provider, Number(storyId), 'story-init');
102
- } catch (err) {
103
- return {
104
- status: 'provider-error',
105
- values: null,
106
- detail: `story-init comment could not be read (${err?.message ?? err})`,
107
- };
108
- }
109
- if (!comment) {
110
- return {
111
- status: 'absent',
112
- values: null,
113
- detail: 'no story-init comment on the ticket',
114
- };
115
- }
116
- const payload = parseFencedJsonComment(comment);
117
- if (!payload || typeof payload !== 'object') {
118
- return {
119
- status: 'unreadable',
120
- values: null,
121
- detail: 'story-init comment carries no parseable JSON payload',
122
- };
102
+ function comparePinToCurrent({ pinned: pin, current, keys }) {
103
+ const values = {};
104
+ const conflicts = [];
105
+ const unpinned = [];
106
+ for (const [key, spec] of Object.entries(keys)) {
107
+ const pinned = pin?.[key];
108
+ if (pinned === undefined || pinned === null) {
109
+ unpinned.push(spec.label);
110
+ values[key] = current[key];
111
+ continue;
112
+ }
113
+ values[key] = pinned;
114
+ if (pinned !== current[key]) {
115
+ conflicts.push({ label: spec.label, pinned, current: current[key] });
116
+ }
123
117
  }
124
- // Receipts written before the `runScopedConfig` block existed carry the
125
- // pinned values as top-level payload fields (`baseBranch` has been recorded
126
- // there since Story #831). Reading the payload itself as the fallback block
127
- // is what lets a Story initialized by an older init still close against its
128
- // own pinned base instead of degrading to the fallback warning.
129
- const block =
130
- payload.runScopedConfig && typeof payload.runScopedConfig === 'object'
131
- ? payload.runScopedConfig
132
- : payload;
133
- return { status: 'found', values: block, detail: null };
118
+ return { values, conflicts, unpinned };
134
119
  }
135
120
 
136
121
  /**
@@ -169,11 +154,10 @@ function formatRunScopedConflict({ storyId, conflicts }) {
169
154
  * this module exists to close.
170
155
  *
171
156
  * @param {{
172
- * provider: object,
173
157
  * storyId: number,
174
158
  * config: object,
175
159
  * keys?: typeof RUN_SCOPED_CONFIG_KEYS,
176
- * findCommentFn?: typeof findStructuredComment,
160
+ * readPinFn?: typeof readRunScopedPin,
177
161
  * progress?: (tag: string, msg: string) => void,
178
162
  * }} args
179
163
  * @returns {Promise<{
@@ -186,52 +170,37 @@ function formatRunScopedConflict({ storyId, conflicts }) {
186
170
  * assume the pinned value is the one in play.
187
171
  */
188
172
  export async function resolveRunScopedConfig({
189
- provider,
190
173
  storyId,
191
174
  config,
192
175
  keys = RUN_SCOPED_CONFIG_KEYS,
193
- findCommentFn,
176
+ readPinFn = readRunScopedPin,
194
177
  progress,
195
178
  }) {
196
179
  const current = pinRunScopedConfig(config, keys);
197
- const receipt = await readRunScopedConfigReceipt({
198
- provider,
199
- storyId,
200
- findCommentFn,
201
- });
180
+ const receipt = readPinFn({ storyId: Number(storyId), config });
202
181
 
203
- if (receipt.status !== 'found') {
204
- // A missing receipt is a real state, not an error — but the fallback is
182
+ if (!receipt) {
183
+ // A missing pin is a real state, not an error — but the fallback is
205
184
  // announced, because a silent one reintroduces exactly the bug above.
206
185
  const warning =
207
- `Run-scoped config could not be read from the run's init receipt ` +
208
- `(${receipt.detail}); falling back to the currently-resolved config ` +
209
- `(${describeValues(current, keys)}). This close cannot confirm the Story ` +
210
- 'was seeded from these values.';
186
+ `Run-scoped config could not be read from the run's init envelope ` +
187
+ `(no runScopedConfig pin for Story #${storyId}); falling back to the ` +
188
+ `currently-resolved config (${describeValues(current, keys)}). This close ` +
189
+ 'cannot confirm the Story was seeded from these values.';
211
190
  progress?.('PIN', `⚠️ ${warning}`);
212
191
  return {
213
192
  values: current,
214
193
  confirmed: false,
215
- receiptStatus: receipt.status,
194
+ receiptStatus: 'absent',
216
195
  warning,
217
196
  };
218
197
  }
219
198
 
220
- const values = {};
221
- const conflicts = [];
222
- const unpinned = [];
223
- for (const [key, spec] of Object.entries(keys)) {
224
- const pinned = receipt.values?.[key];
225
- if (pinned === undefined || pinned === null) {
226
- unpinned.push(spec.label);
227
- values[key] = current[key];
228
- continue;
229
- }
230
- values[key] = pinned;
231
- if (pinned !== current[key]) {
232
- conflicts.push({ label: spec.label, pinned, current: current[key] });
233
- }
234
- }
199
+ const { values, conflicts, unpinned } = comparePinToCurrent({
200
+ pinned: receipt,
201
+ current,
202
+ keys,
203
+ });
235
204
 
236
205
  if (conflicts.length > 0) {
237
206
  throw new Error(formatRunScopedConflict({ storyId, conflicts }));
@@ -245,21 +214,16 @@ export async function resolveRunScopedConfig({
245
214
  return {
246
215
  values,
247
216
  confirmed: false,
248
- receiptStatus: receipt.status,
217
+ receiptStatus: 'found',
249
218
  warning,
250
219
  };
251
220
  }
252
221
 
253
222
  progress?.(
254
223
  'PIN',
255
- `📌 Run-scoped config pinned by the story-init receipt (${describeValues(values, keys)}).`,
224
+ `📌 Run-scoped config pinned by the init envelope (${describeValues(values, keys)}).`,
256
225
  );
257
- return {
258
- values,
259
- confirmed: true,
260
- receiptStatus: receipt.status,
261
- warning: null,
262
- };
226
+ return { values, confirmed: true, receiptStatus: 'found', warning: null };
263
227
  }
264
228
 
265
229
  /**
@@ -259,7 +259,7 @@ export async function handleSyncFailure({
259
259
  * `git merge origin/<baseBranch>` is actively harmful when `<baseBranch>` is
260
260
  * not the base the Story was seeded from: it permanently contaminates the
261
261
  * branch and its PR diff with an unrelated base. Close confirms the base
262
- * against the run's `story-init` receipt before that advice is emitted; when
262
+ * against the run's init receipt before that advice is emitted; when
263
263
  * it could not (receipt absent, unreadable, or unpinned), the operator is
264
264
  * told to establish the real base first instead.
265
265
  *
@@ -302,9 +302,9 @@ export function buildSyncFailureCommentBody({
302
302
  : [
303
303
  `⚠️ **No merge advice: \`${baseBranch}\` is unconfirmed.** This close could not`,
304
304
  `read the base branch \`${storyBranch}\` was seeded from off the run's`,
305
- '`story-init` receipt, so merging that base in could contaminate the branch',
305
+ 'init receipt, so merging that base in could contaminate the branch',
306
306
  'and its PR diff with an unrelated base. Establish the real base first —',
307
- `check the \`story-init\` comment on this issue and \`project.baseBranch\` in`,
307
+ `check \`temp/orchestration/story-init-result-${storyId}.log\` and \`project.baseBranch\` in`,
308
308
  '`.agentrc.json` / `.agentrc.local.json` — then merge that base and re-run:',
309
309
  '',
310
310
  '```bash',
@@ -0,0 +1,137 @@
1
+ /**
2
+ * phases/graphql-preflight.js — the one precondition a standalone close
3
+ * checks before it spends anything (Story #5355).
4
+ *
5
+ * A Story delivered from a Claude Code web session ran the entire
6
+ * close-validation gate chain, synced from the base branch and pushed, then
7
+ * died in the `pull-request` phase on `gh pr create` with a raw HTTP 403:
8
+ * GitHub's GraphQL API is unreachable from that session, and `gh` routes the
9
+ * whole `gh pr` surface through it. That 403 was a fact about the session,
10
+ * knowable before the first gate ran, that the operator paid roughly five
11
+ * minutes of gates to learn.
12
+ *
13
+ * ## Why this is not a pre-gate step
14
+ *
15
+ * It sits beside `pre-gate-steps.js` and inverts every clause of that
16
+ * module's contract, which is why it does not live inside it. Those steps
17
+ * commit to `story-<id>`, run from the gate phase, and may **never** fail the
18
+ * close, because each is the refresh half of a loop whose enforcement half
19
+ * runs immediately afterwards. This one commits nothing, runs from the runner
20
+ * during `init`, and exists precisely to fail the close — before a single
21
+ * gate is spawned and before anything is pushed. One module, one contract.
22
+ *
23
+ * What it must never do is fail a *healthy* close, which is why an ambiguous
24
+ * probe is fail-open at its source (`probeGraphqlAvailability`) and a
25
+ * throwing probe is fail-open here.
26
+ */
27
+
28
+ import {
29
+ describeGraphqlPreflight,
30
+ probeGraphqlAvailability,
31
+ } from '../../../gh-exec.js';
32
+ import { Logger } from '../../../Logger.js';
33
+ import {
34
+ STATE_LABELS,
35
+ transitionTicketState,
36
+ upsertStructuredComment,
37
+ } from '../../ticketing.js';
38
+
39
+ /**
40
+ * Announce a refused preflight on the Story: a `friction` comment carrying
41
+ * the blocker and its remedy, then the `agent::blocked` transition the
42
+ * terminal envelope's `blocked` status promises the operator.
43
+ *
44
+ * Both writes are best-effort and warn rather than throw, for the reason
45
+ * `handleSyncFailure` gives: a notification-side failure must not replace the
46
+ * real blocker with a secondary one — and here the notification travels the
47
+ * very API surface that is already suspect. Both go through the canonical
48
+ * mutators; a bare `updateTicket` would skip the Projects v2 column sync and
49
+ * strand the board on the Story's prior status.
50
+ *
51
+ * @param {{ provider: object, storyId: number, reason: string,
52
+ * progress: (tag: string, msg: string) => void }} args
53
+ * @returns {Promise<void>}
54
+ */
55
+ async function announcePreflightBlock({ provider, storyId, reason, progress }) {
56
+ const body = [
57
+ '### Close refused during `init`: GitHub GraphQL preflight',
58
+ '',
59
+ reason,
60
+ '',
61
+ 'Nothing was validated, committed, or pushed — the preflight runs before the',
62
+ 'gate chain, so re-running close from a session that can reach GraphQL starts',
63
+ 'from exactly where this Story stands now:',
64
+ '',
65
+ '```bash',
66
+ `node .agents/scripts/single-story-close.js --story ${storyId}`,
67
+ '```',
68
+ ].join('\n');
69
+ try {
70
+ await upsertStructuredComment(provider, storyId, 'friction', body);
71
+ progress('PREFLIGHT', `📝 Posted friction comment on #${storyId}.`);
72
+ } catch (err) {
73
+ Logger.warn?.(
74
+ `[single-story-close] ⚠️ Failed to post preflight friction comment on #${storyId}: ${err?.message ?? err}`,
75
+ );
76
+ }
77
+ try {
78
+ await transitionTicketState(provider, storyId, STATE_LABELS.BLOCKED, {});
79
+ progress('PREFLIGHT', `🚧 Flipped Story #${storyId} → agent::blocked.`);
80
+ } catch (err) {
81
+ Logger.warn?.(
82
+ `[single-story-close] ⚠️ Failed to flip Story #${storyId} to agent::blocked: ${err?.message ?? err}`,
83
+ );
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Preflight GitHub's GraphQL reachability before the close spends anything.
89
+ *
90
+ * One cheap API read decides it. An `available` verdict returns null and the
91
+ * close proceeds exactly as a run with no preflight would; a refusing verdict
92
+ * announces the block on the Story and returns the descriptor the runner
93
+ * turns into a `blocked` terminal envelope at phase `init`.
94
+ *
95
+ * The probe cannot throw, but a caller-injected one can, and a preflight that
96
+ * takes down a close it was meant to protect is the one outcome worse than
97
+ * the late 403 — so a throwing probe is logged and treated as available.
98
+ *
99
+ * @param {{
100
+ * storyId: number,
101
+ * provider: object,
102
+ * progress: (tag: string, msg: string) => void,
103
+ * ghFacade?: { api: Function },
104
+ * probe?: typeof probeGraphqlAvailability,
105
+ * }} args
106
+ * `ghFacade` is the run's own `gh` facade, so the preflight asks the same
107
+ * boundary the pull-request phase will later ask rather than a second one
108
+ * that could answer differently.
109
+ * @returns {Promise<{ verdict: string, reason: string }|null>} null when the
110
+ * close may proceed.
111
+ */
112
+ export async function runGraphqlPreflight({
113
+ storyId,
114
+ provider,
115
+ progress,
116
+ ghFacade,
117
+ probe = probeGraphqlAvailability,
118
+ }) {
119
+ let verdict;
120
+ try {
121
+ verdict = await probe(ghFacade ? { ghFacade } : undefined);
122
+ } catch (err) {
123
+ progress(
124
+ 'PREFLIGHT',
125
+ `⚠️ GraphQL preflight could not run (close continues): ${err?.message ?? err}`,
126
+ );
127
+ return null;
128
+ }
129
+ if (verdict?.available !== false) {
130
+ progress('PREFLIGHT', `✅ GitHub GraphQL reachable (${verdict?.reason}).`);
131
+ return null;
132
+ }
133
+ const reason = describeGraphqlPreflight(verdict);
134
+ progress('PREFLIGHT', `🛑 ${reason}`);
135
+ await announcePreflightBlock({ provider, storyId, reason, progress });
136
+ return { verdict: verdict.verdict, reason };
137
+ }
@@ -30,6 +30,7 @@ import { runBaseSyncPhase } from './phases/base-sync.js';
30
30
  import { runCloseValidationPhase } from './phases/close-validation.js';
31
31
  import { parsePrNumber, runStoryScopeReview } from './phases/code-review.js';
32
32
  import { runConfirmMergePhase } from './phases/confirm-merge.js';
33
+ import { runGraphqlPreflight } from './phases/graphql-preflight.js';
33
34
  import { parseCloseOptions, resolveWaitForMerge } from './phases/options.js';
34
35
  import { ensurePullRequestWith } from './phases/pull-request.js';
35
36
  import { pushStoryBranch } from './phases/push.js';
@@ -95,9 +96,18 @@ async function emitTerminal({ terminal, result, config }) {
95
96
  *
96
97
  * A null `stateReason` keeps the `completed` reading — GitHub defaults to it,
97
98
  * and issues closed before the field existed carry null.
99
+ *
100
+ * The operator-facing `NOOP` line is emitted here rather than at the call
101
+ * site: both halves read the same field, and deciding what `not_planned`
102
+ * means in two places is how the log and the envelope come to disagree about
103
+ * one Story.
98
104
  */
99
105
  async function alreadyClosedResult(storyId, stateReason = null, config) {
100
106
  if (stateReason === 'not_planned') {
107
+ progress(
108
+ 'NOOP',
109
+ `Story #${storyId} is closed as not planned — nothing to land.`,
110
+ );
101
111
  const result = {
102
112
  storyId,
103
113
  standalone: true,
@@ -120,6 +130,7 @@ async function alreadyClosedResult(storyId, stateReason = null, config) {
120
130
  return { success: false, result, terminal };
121
131
  }
122
132
 
133
+ progress('NOOP', `Story #${storyId} is already closed. Nothing to do.`);
123
134
  const result = {
124
135
  storyId,
125
136
  standalone: true,
@@ -140,6 +151,61 @@ async function alreadyClosedResult(storyId, stateReason = null, config) {
140
151
  return { success: true, result, terminal };
141
152
  }
142
153
 
154
+ /**
155
+ * The block class a refused GraphQL preflight reports (Story #5355).
156
+ *
157
+ * `api-race-other` is the shared classifier's documented fallback for "a
158
+ * transient GraphQL/API error, an ambiguous probe result, or a genuinely
159
+ * novel condition", and a preflight refusal is the third of those. It is
160
+ * reused rather than joined by a new class on purpose: the class vocabulary
161
+ * is pinned by `story-deliver-terminal.schema.json`, and this refusal is
162
+ * required to validate against the shipped schema unchanged. The `reason`
163
+ * string — not the class — is what names the blocker and its remedy.
164
+ */
165
+ const PREFLIGHT_BLOCK_CLASS = 'api-race-other';
166
+
167
+ /**
168
+ * Terminal for a close the GraphQL preflight refused during `init`.
169
+ *
170
+ * Returned rather than thrown: a throw would surface as `failed` at the CLI
171
+ * boundary, and this is not a crash — it is a classified block detected
172
+ * before the pipeline spent anything, with the Story already flipped to
173
+ * `agent::blocked` and a friction comment carrying the remedy. The next
174
+ * command is the same close, which is exactly right: from a session that can
175
+ * reach GraphQL it runs to completion from where the Story stands now.
176
+ *
177
+ * @param {{ storyId: number, preflight: { verdict: string, reason: string },
178
+ * config: object, startedAtMs: number }} args
179
+ * @returns {Promise<{ success: false, result: object, terminal: object }>}
180
+ */
181
+ async function preflightBlockedResult({
182
+ storyId,
183
+ preflight,
184
+ config,
185
+ startedAtMs,
186
+ }) {
187
+ const result = {
188
+ storyId,
189
+ standalone: true,
190
+ action: 'noop',
191
+ reason: `graphql-preflight-${preflight.verdict}`,
192
+ };
193
+ const terminal = buildTerminalEnvelope({
194
+ storyId,
195
+ status: 'blocked',
196
+ phase: 'init',
197
+ blocked: {
198
+ blockClass: PREFLIGHT_BLOCK_CLASS,
199
+ reason: preflight.reason,
200
+ frictionCommentId: null,
201
+ },
202
+ nextCommand: NEXT_COMMANDS.close(storyId),
203
+ elapsedSeconds: elapsedSecondsSince(startedAtMs),
204
+ });
205
+ await emitTerminal({ terminal, result, config });
206
+ return { success: false, result, terminal };
207
+ }
208
+
143
209
  /**
144
210
  * Project the baselines entries out of close-validation's per-gate outcomes,
145
211
  * keyed by the gate's own name (Story #5172).
@@ -534,6 +600,7 @@ export async function runSingleStoryClose({
534
600
  injectedGh,
535
601
  injectedGitSpawn,
536
602
  injectedReleaseLease,
603
+ injectedGraphqlProbe,
537
604
  } = {}) {
538
605
  const options = parseCloseOptions({
539
606
  storyIdParam,
@@ -586,6 +653,7 @@ export async function runSingleStoryClose({
586
653
  injectedGh,
587
654
  injectedGitSpawn,
588
655
  injectedReleaseLease,
656
+ injectedGraphqlProbe,
589
657
  });
590
658
  } catch (err) {
591
659
  if (err && typeof err === 'object') {
@@ -837,6 +905,7 @@ async function runClosePipeline({
837
905
  injectedGh,
838
906
  injectedGitSpawn,
839
907
  injectedReleaseLease,
908
+ injectedGraphqlProbe,
840
909
  }) {
841
910
  const startedAtMs = Date.now();
842
911
  const config = injectedConfig || resolveConfig({ cwd: options.cwd });
@@ -846,12 +915,6 @@ async function runClosePipeline({
846
915
  progress('INIT', `Closing standalone Story #${options.storyId}...`);
847
916
  const story = await provider.getTicket(options.storyId);
848
917
  if (story.state === 'closed') {
849
- progress(
850
- 'NOOP',
851
- story.stateReason === 'not_planned'
852
- ? `Story #${options.storyId} is closed as not planned — nothing to land.`
853
- : `Story #${options.storyId} is already closed. Nothing to do.`,
854
- );
855
918
  return await alreadyClosedResult(
856
919
  options.storyId,
857
920
  story.stateReason,
@@ -859,8 +922,41 @@ async function runClosePipeline({
859
922
  );
860
923
  }
861
924
 
925
+ // Story #4257 — the base-sync conflict and review-critical exits throw
926
+ // before the clean-close lease release at the tail of this function.
927
+ // Built here, ahead of the first blocked-prone step, so the preflight
928
+ // refusal below releases the lease on the same terms those exits do.
929
+ const leaseArgs = {
930
+ provider,
931
+ storyId: options.storyId,
932
+ config,
933
+ injectedReleaseLease,
934
+ };
935
+
936
+ // Story #5355 — one cheap GraphQL read, before anything is resolved,
937
+ // validated, committed or pushed. `gh` routes the whole `gh pr` surface
938
+ // through GraphQL, so a session that cannot reach it cannot land this
939
+ // Story however green its gates are; discovering that here costs one API
940
+ // call instead of the entire close-validation chain plus a push.
941
+ const preflight = await runGraphqlPreflight({
942
+ storyId: options.storyId,
943
+ provider,
944
+ progress,
945
+ ghFacade: injectedGh,
946
+ probe: injectedGraphqlProbe,
947
+ });
948
+ if (preflight) {
949
+ await releaseLease(leaseArgs);
950
+ return await preflightBlockedResult({
951
+ storyId: options.storyId,
952
+ preflight,
953
+ config,
954
+ startedAtMs,
955
+ });
956
+ }
957
+
862
958
  // Story #4891 — the base branch this run was SEEDED from, read back off the
863
- // run's `story-init` receipt rather than re-resolved from a config file that
959
+ // run's init receipt on disk rather than re-resolved from a config file that
864
960
  // may have changed during the whole implementation window. Throws (fail
865
961
  // closed, naming both values) when the pin and current config disagree —
866
962
  // deliberately here, before the gate chain, format-autofix and base-sync,
@@ -868,7 +964,6 @@ async function runClosePipeline({
868
964
  // gates the base-merge remediation advice further down.
869
965
  const { values: runScoped, confirmed: baseConfirmed } =
870
966
  await resolveRunScopedConfig({
871
- provider,
872
967
  storyId: options.storyId,
873
968
  config,
874
969
  progress,
@@ -880,16 +975,8 @@ async function runClosePipeline({
880
975
  config,
881
976
  storyId: options.storyId,
882
977
  });
883
- // Story #4257 — the base-sync conflict and review-critical exits throw
884
- // before the clean-close lease release at the tail of this function.
885
- // Wrap both blocked-prone phases so the lease is released best-effort
886
- // before the throw propagates; the original error is preserved.
887
- const leaseArgs = {
888
- provider,
889
- storyId: options.storyId,
890
- config,
891
- injectedReleaseLease,
892
- };
978
+ // Both blocked-prone phases are wrapped so the lease is released
979
+ // best-effort before the throw propagates; the original error is preserved.
893
980
  const { validationGates } = await releaseLeaseOnBlock(
894
981
  () =>
895
982
  runPrePushPhases({
@@ -139,9 +139,10 @@ export const NEXT_COMMANDS = Object.freeze({
139
139
  * The only entry here that is a slash command rather than a script, and
140
140
  * deliberately so: the other entries resume a Story that exists, while this
141
141
  * one names work that has no Story yet and needs planning before it can have
142
- * one. It is quoted for a shell but addressed to a **fresh session** — see
143
- * the workflow's escalation section for why running it in the escalating
144
- * session is forbidden.
142
+ * one. It is quoted for a shell. Story #4746 addressed it to a **fresh
143
+ * session**; Story #5344 permits the escalating session to run it too, seeded
144
+ * with `escalation.reasons` — see the workflow's escalation section for the
145
+ * seeding contract and for the observation that motivated the old ban.
145
146
  */
146
147
  escalateToPlan: (prompt) => `/mandrel-plan "${quoteForPlan(prompt)}"`,
147
148
  });