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.
- package/.agents/README.md +11 -9
- package/.agents/agents/acceptance-critic.md +24 -43
- package/.agents/agents/story-worker.md +18 -19
- package/.agents/docs/SDLC.md +6 -6
- package/.agents/docs/agentrc-reference.json +1 -2
- package/.agents/docs/configuration.md +29 -46
- package/.agents/docs/quality-gates.md +8 -4
- package/.agents/docs/workflows.md +1 -1
- package/.agents/instructions.md +4 -5
- package/.agents/rules/ci-remediation.md +41 -8
- package/.agents/rules/known-tooling-behavior.md +65 -15
- package/.agents/schemas/acceptance-eval-verdict.schema.json +1 -1
- package/.agents/schemas/agentrc.schema.json +6 -11
- package/.agents/schemas/story-deliver-terminal.schema.json +3 -3
- package/.agents/scripts/README.md +11 -1
- package/.agents/scripts/acceptance-eval.js +25 -27
- package/.agents/scripts/ceremony-derive.js +15 -10
- package/.agents/scripts/check-context-budget.js +148 -228
- package/.agents/scripts/check-schema-references.js +5 -3
- package/.agents/scripts/check-workflow-citations.js +33 -147
- package/.agents/scripts/coverage-capture.js +7 -4
- package/.agents/scripts/deliver-light.js +41 -100
- package/.agents/scripts/deliver-run.js +631 -0
- package/.agents/scripts/file-ci-gap.js +59 -11
- package/.agents/scripts/lib/baselines/crap-preview-incremental.js +6 -2
- package/.agents/scripts/lib/changed-files.js +30 -0
- package/.agents/scripts/lib/config/delivery-routing.js +5 -4
- package/.agents/scripts/lib/config/explain.js +1 -3
- package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +1 -1
- package/.agents/scripts/lib/config-resolver.js +1 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -21
- package/.agents/scripts/lib/coverage-capture-fullscope.js +10 -2
- package/.agents/scripts/lib/coverage-capture-incremental.js +3 -2
- package/.agents/scripts/lib/coverage-capture-usage.js +4 -1
- package/.agents/scripts/lib/doc-tiers.js +4 -2
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +7 -6
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +7 -5
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/gh-exec.js +160 -0
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +74 -132
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +123 -12
- package/.agents/scripts/lib/orchestration/complexity-gate.js +180 -352
- package/.agents/scripts/lib/orchestration/light-suitability.js +71 -136
- package/.agents/scripts/lib/orchestration/plan-context.js +13 -25
- package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +8 -6
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +76 -95
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +35 -18
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +11 -11
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -29
- package/.agents/scripts/lib/orchestration/review-depth.js +14 -11
- package/.agents/scripts/lib/orchestration/run-epilogue.js +260 -182
- package/.agents/scripts/lib/orchestration/run-scoped-config.js +63 -99
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +3 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/graphql-preflight.js +137 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +105 -18
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -3
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +156 -39
- package/.agents/scripts/lib/orchestration/story-init-envelope.js +71 -0
- package/.agents/scripts/lib/orchestration/task-body-validator.js +8 -17
- package/.agents/scripts/lib/orchestration/ticket-validator.js +44 -183
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +14 -25
- package/.agents/scripts/lib/story-body/body-format-lints.js +58 -12
- package/.agents/scripts/lib/story-body/story-body.js +83 -29
- package/.agents/scripts/lib/templates/decomposer-prompts.js +7 -15
- package/.agents/scripts/lib/wave-runner/live-probe.js +31 -5
- package/.agents/scripts/merge-baseline.js +4 -5
- package/.agents/scripts/plan-context.js +117 -28
- package/.agents/scripts/plan-persist.js +79 -28
- package/.agents/scripts/plan-run-epilogue.js +11 -8
- package/.agents/scripts/pr-watch-with-update.js +9 -2
- package/.agents/scripts/run-verify.js +13 -6
- package/.agents/scripts/single-story-init.js +7 -57
- package/.agents/scripts/stories-wave-tick.js +160 -26
- package/.agents/skills/core/gates-and-baselines/reference.md +0 -1
- package/.agents/skills/skills.index.json +2 -2
- package/.agents/skills/stack/qa/playwright/SKILL.md +26 -0
- package/.agents/workflows/helpers/acceptance-self-eval.md +84 -157
- package/.agents/workflows/helpers/code-review.md +4 -2
- package/.agents/workflows/helpers/deliver-digest.md +31 -24
- package/.agents/workflows/helpers/deliver-light.md +92 -101
- package/.agents/workflows/helpers/deliver-reference.md +116 -100
- package/.agents/workflows/helpers/deliver-story-reference.md +58 -124
- package/.agents/workflows/helpers/deliver-story.md +17 -18
- package/.agents/workflows/helpers/plan-reference.md +65 -54
- package/.agents/workflows/mandrel-deliver.md +47 -31
- package/.agents/workflows/mandrel-plan.md +22 -21
- package/.agents/workflows/mandrel-update.md +36 -21
- package/docs/CHANGELOG.md +35 -0
- package/lib/cli/update.js +376 -17
- package/lib/migrations/index.js +2 -0
- package/lib/migrations/steps/2.60.0-retire-audit-results-autofile.js +40 -0
- package/package.json +2 -1
- package/.agents/schemas/model-attribution.schema.json +0 -53
- package/.agents/scripts/lib/orchestration/model-attribution.js +0 -418
- package/.agents/scripts/lib/orchestration/story-plan-state.js +0 -33
- 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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
92
|
+
* pinned: Record<string, unknown>,
|
|
93
|
+
* current: Record<string, unknown>,
|
|
94
|
+
* keys: typeof RUN_SCOPED_CONFIG_KEYS,
|
|
91
95
|
* }} args
|
|
92
|
-
* @returns {
|
|
96
|
+
* @returns {{
|
|
97
|
+
* values: Record<string, unknown>,
|
|
98
|
+
* conflicts: Array<{ label: string, pinned: unknown, current: unknown }>,
|
|
99
|
+
* unpinned: string[],
|
|
100
|
+
* }}
|
|
93
101
|
*/
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
176
|
+
readPinFn = readRunScopedPin,
|
|
194
177
|
progress,
|
|
195
178
|
}) {
|
|
196
179
|
const current = pinRunScopedConfig(config, keys);
|
|
197
|
-
const receipt =
|
|
198
|
-
provider,
|
|
199
|
-
storyId,
|
|
200
|
-
findCommentFn,
|
|
201
|
-
});
|
|
180
|
+
const receipt = readPinFn({ storyId: Number(storyId), config });
|
|
202
181
|
|
|
203
|
-
if (receipt
|
|
204
|
-
// A missing
|
|
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
|
|
208
|
-
`(
|
|
209
|
-
`(${describeValues(current, keys)}). This close
|
|
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:
|
|
194
|
+
receiptStatus: 'absent',
|
|
216
195
|
warning,
|
|
217
196
|
};
|
|
218
197
|
}
|
|
219
198
|
|
|
220
|
-
const values = {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
'
|
|
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
|
|
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
|
|
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
|
-
//
|
|
884
|
-
// before the
|
|
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
|
|
143
|
-
*
|
|
144
|
-
*
|
|
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
|
});
|