mandrel 1.87.0 → 1.89.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 +18 -13
- package/.agents/audit-checklists/architecture.md +24 -0
- package/.agents/audit-checklists/clean-code.md +24 -0
- package/.agents/audit-checklists/dependencies.md +14 -0
- package/.agents/audit-checklists/devops.md +17 -0
- package/.agents/audit-checklists/documentation.md +22 -0
- package/.agents/audit-checklists/lighthouse.md +15 -0
- package/.agents/audit-checklists/navigability.md +14 -0
- package/.agents/audit-checklists/performance.md +22 -0
- package/.agents/audit-checklists/privacy.md +21 -0
- package/.agents/audit-checklists/quality.md +18 -0
- package/.agents/audit-checklists/security.md +22 -0
- package/.agents/audit-checklists/seo.md +16 -0
- package/.agents/audit-checklists/sre.md +24 -0
- package/.agents/audit-checklists/ux-ui.md +21 -0
- package/.agents/docs/SDLC.md +63 -16
- package/.agents/docs/configuration.md +5 -3
- package/.agents/instructions.md +51 -21
- package/.agents/personas/architect.md +10 -7
- package/.agents/personas/engineer.md +4 -3
- package/.agents/personas/project-manager.md +5 -2
- package/.agents/personas/refactorer.md +5 -3
- package/.agents/rules/git-conventions.md +77 -0
- package/.agents/schemas/agentrc.schema.json +16 -4
- package/.agents/schemas/audit-rules.json +16 -2
- package/.agents/schemas/audit-rules.schema.json +7 -6
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +38 -0
- package/.agents/schemas/signal-event.schema.json +28 -13
- package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
- package/.agents/scripts/check-context-budget.js +320 -0
- package/.agents/scripts/diagnose-friction.js +4 -4
- package/.agents/scripts/epic-audit-prepare.js +30 -2
- package/.agents/scripts/epic-audit-recheck.js +46 -13
- package/.agents/scripts/epic-deliver-prepare.js +80 -8
- package/.agents/scripts/epic-plan-spec.js +4 -8
- package/.agents/scripts/generate-lens-checklists.js +180 -0
- package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
- package/.agents/scripts/lib/audit-suite/findings.js +27 -0
- package/.agents/scripts/lib/audit-suite/index.js +9 -0
- package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
- package/.agents/scripts/lib/audit-suite/selector.js +136 -5
- package/.agents/scripts/lib/checks/loop-health.js +340 -0
- package/.agents/scripts/lib/cli-args.js +8 -0
- package/.agents/scripts/lib/config/explain.js +4 -0
- package/.agents/scripts/lib/config/runners.js +21 -2
- package/.agents/scripts/lib/config/temp-paths.js +24 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +23 -3
- package/.agents/scripts/lib/config-settings-schema-quality.js +7 -0
- package/.agents/scripts/lib/doc-tiers.js +291 -0
- package/.agents/scripts/lib/epic-body-sections.js +5 -2
- package/.agents/scripts/lib/epic-merge-lock.js +83 -0
- package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +66 -20
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
- package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
- package/.agents/scripts/lib/gates/friction.js +15 -5
- package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
- package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
- package/.agents/scripts/lib/observability/signal-validator.js +204 -0
- package/.agents/scripts/lib/observability/signals-writer.js +157 -54
- package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
- package/.agents/scripts/lib/orchestration/code-review.js +74 -4
- package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
- package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
- package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +188 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +69 -8
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +11 -2
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +39 -3
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
- package/.agents/scripts/lib/orchestration/merge-block-class.js +218 -0
- package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
- package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
- package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
- package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
- package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
- package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
- package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
- package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
- package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
- package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
- package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
- package/.agents/scripts/lib/planning-corpus.js +306 -0
- package/.agents/scripts/lib/signals/detectors/common.js +10 -10
- package/.agents/scripts/lib/signals/detectors/index.js +4 -4
- package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
- package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
- package/.agents/scripts/lib/signals/schema.js +56 -81
- package/.agents/scripts/lib/signals/span-tree.js +6 -5
- package/.agents/scripts/lib/story-plan.js +3 -0
- package/.agents/scripts/lib/wave-runner/tick.js +10 -2
- package/.agents/scripts/lifecycle-emit.js +39 -8
- package/.agents/scripts/providers/github/issues.js +12 -1
- package/.agents/scripts/resolve-doc-tiers.js +83 -0
- package/.agents/scripts/retro-run.js +51 -0
- package/.agents/scripts/signals-view.js +1 -1
- package/.agents/scripts/single-story-close.js +20 -1
- package/.agents/scripts/standalone-feedback-rollup.js +188 -0
- package/.agents/scripts/story-close.js +48 -0
- package/.agents/scripts/story-plan.js +51 -12
- package/.agents/scripts/validate-docs-freshness.js +69 -15
- package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
- package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
- package/.agents/skills/core/scope-triage/SKILL.md +61 -0
- package/.agents/skills/skills.index.json +3 -3
- package/.agents/workflows/audit-documentation.md +82 -2
- package/.agents/workflows/helpers/code-review.md +193 -44
- package/.agents/workflows/helpers/deliver-epic.md +128 -39
- package/.agents/workflows/helpers/deliver-stories.md +26 -0
- package/.agents/workflows/helpers/epic-audit.md +116 -283
- package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
- package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
- package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
- package/.agents/workflows/helpers/plan-epic.md +141 -105
- package/.agents/workflows/helpers/plan-story.md +32 -0
- package/.agents/workflows/helpers/single-story-deliver.md +43 -0
- package/.agents/workflows/loops/nightly-audit.md +9 -7
- package/docs/CHANGELOG.md +29 -0
- package/lib/cli/doctor.js +44 -0
- package/package.json +4 -3
- package/.agents/scripts/epic-plan-spec-validate.js +0 -111
- package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -207
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
- package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* phases/confirm-merge.js — headless must-land terminal step (Story #4428,
|
|
3
|
+
* Epic #4425 slice 3: standalone-path must-land terminal step).
|
|
4
|
+
*
|
|
5
|
+
* `runSingleStoryClose` (`../runner.js`) arms GitHub native auto-merge and
|
|
6
|
+
* historically rests the Story at `agent::closing` with the issue OPEN —
|
|
7
|
+
* merge confirmation is a separate manual step
|
|
8
|
+
* (`single-story-confirm-merge.js`) a headless (unattended) run never
|
|
9
|
+
* executes. A hung check or a failed arm then leaves the PR silently open
|
|
10
|
+
* forever with no operator watching.
|
|
11
|
+
*
|
|
12
|
+
* This phase closes that gap for **headless** runs (`--wait-merge`, threaded
|
|
13
|
+
* from `phases/options.js`): instead of returning at `agent::closing`, it
|
|
14
|
+
* polls the armed PR to merge confirmation — reusing the SAME
|
|
15
|
+
* `confirmStoryMerged` flip logic `single-story-confirm-merge.js` calls
|
|
16
|
+
* (Story #4428 AC4: exactly one merged/`agent::done` implementation) — on
|
|
17
|
+
* the `delivery.mergeWatch.intervalSeconds` / `maxBudgetSeconds` cadence
|
|
18
|
+
* (mirroring `MergeWatcher`'s poll/budget shape rather than forking it).
|
|
19
|
+
*
|
|
20
|
+
* Terminal outcomes:
|
|
21
|
+
* - `{ confirmed: true }` — the PR merged; `confirmStoryMerged` already
|
|
22
|
+
* flipped `agent::closing → agent::done` and closed the issue.
|
|
23
|
+
* - `{ confirmed: false, blockClass, reason }` — the arm failed outright,
|
|
24
|
+
* the PR closed without merging, or the poll budget was exhausted
|
|
25
|
+
* first. The block is classified via the shared
|
|
26
|
+
* `classifyMergeBlock` (`../../merge-block-class.js`), a
|
|
27
|
+
* `merge.unlanded` lifecycle event is emitted (`scope: 'story'`), a
|
|
28
|
+
* `friction` comment is posted, and the Story is transitioned to
|
|
29
|
+
* `agent::blocked`. The caller (`runSingleStoryClose`) throws so the
|
|
30
|
+
* CLI process exits non-zero — never a silent `agent::closing` rest.
|
|
31
|
+
*
|
|
32
|
+
* Attended (non-headless) runs never call this phase — `runner.js` only
|
|
33
|
+
* invokes it when `options.waitForMerge` is `true`.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { gh as defaultGh } from '../../../gh-exec.js';
|
|
37
|
+
import {
|
|
38
|
+
confirmStoryMerged as defaultConfirmStoryMerged,
|
|
39
|
+
readPrMergeState as defaultReadPrMergeState,
|
|
40
|
+
} from '../../../single-story/confirm-merge.js';
|
|
41
|
+
import { emitMergeUnlanded as defaultEmitMergeUnlanded } from '../../lifecycle/emit-merge-unlanded.js';
|
|
42
|
+
import {
|
|
43
|
+
DEFAULT_INTERVAL_SECONDS,
|
|
44
|
+
DEFAULT_MAX_BUDGET_SECONDS,
|
|
45
|
+
deriveChecksStatus,
|
|
46
|
+
} from '../../lifecycle/listeners/merge-watcher.js';
|
|
47
|
+
import { classifyMergeBlock as defaultClassifyMergeBlock } from '../../merge-block-class.js';
|
|
48
|
+
import {
|
|
49
|
+
postStructuredComment,
|
|
50
|
+
STATE_LABELS,
|
|
51
|
+
transitionTicketState,
|
|
52
|
+
} from '../../ticketing.js';
|
|
53
|
+
|
|
54
|
+
function defaultSleep(ms) {
|
|
55
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Fresh PR probe for terminal classification. Fetches the fields
|
|
60
|
+
* `classifyMergeBlock` keys on (`mergeStateStatus`, `reviewDecision`,
|
|
61
|
+
* `statusCheckRollup` → derived `checksStatus`) so a budget exhaustion
|
|
62
|
+
* is classified from the REAL PR state instead of a hardcoded
|
|
63
|
+
* `checksStatus: 'pending'` stamp (which mislabeled every timeout as
|
|
64
|
+
* `checks-pending-timeout` — a review-required block was never
|
|
65
|
+
* diagnosable). Returns a degraded `{ checksStatus: 'pending', error }`
|
|
66
|
+
* probe when the read itself fails, preserving the prior conservative
|
|
67
|
+
* classification on probe errors.
|
|
68
|
+
*/
|
|
69
|
+
async function readPrClassificationProbe({ prNumber, gh = defaultGh }) {
|
|
70
|
+
try {
|
|
71
|
+
const view = await gh.pr.view(prNumber, [
|
|
72
|
+
'mergeStateStatus',
|
|
73
|
+
'reviewDecision',
|
|
74
|
+
'statusCheckRollup',
|
|
75
|
+
]);
|
|
76
|
+
return {
|
|
77
|
+
mergeStateStatus:
|
|
78
|
+
typeof view?.mergeStateStatus === 'string'
|
|
79
|
+
? view.mergeStateStatus
|
|
80
|
+
: undefined,
|
|
81
|
+
reviewDecision:
|
|
82
|
+
typeof view?.reviewDecision === 'string'
|
|
83
|
+
? view.reviewDecision
|
|
84
|
+
: undefined,
|
|
85
|
+
checksStatus: deriveChecksStatus(view?.statusCheckRollup),
|
|
86
|
+
};
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return {
|
|
89
|
+
checksStatus: 'pending',
|
|
90
|
+
error: `classification probe failed: ${err?.message ?? err}`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolve the poll cadence from `delivery.mergeWatch.*`, falling back to
|
|
97
|
+
* the same defaults `MergeWatcher` uses when the config key is absent.
|
|
98
|
+
*
|
|
99
|
+
* @param {object} [config]
|
|
100
|
+
* @returns {{ intervalSeconds: number, maxBudgetSeconds: number }}
|
|
101
|
+
*/
|
|
102
|
+
function resolveMergeWatchCadence(config) {
|
|
103
|
+
const mergeWatch = config?.delivery?.mergeWatch ?? {};
|
|
104
|
+
const intervalSeconds =
|
|
105
|
+
Number.isInteger(mergeWatch.intervalSeconds) &&
|
|
106
|
+
mergeWatch.intervalSeconds >= 1
|
|
107
|
+
? mergeWatch.intervalSeconds
|
|
108
|
+
: DEFAULT_INTERVAL_SECONDS;
|
|
109
|
+
const maxBudgetSeconds =
|
|
110
|
+
Number.isInteger(mergeWatch.maxBudgetSeconds) &&
|
|
111
|
+
mergeWatch.maxBudgetSeconds >= 1
|
|
112
|
+
? mergeWatch.maxBudgetSeconds
|
|
113
|
+
: DEFAULT_MAX_BUDGET_SECONDS;
|
|
114
|
+
return { intervalSeconds, maxBudgetSeconds };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Format the `friction` comment body posted alongside the `agent::blocked`
|
|
119
|
+
* transition when a headless close gives up without a confirmed merge.
|
|
120
|
+
*/
|
|
121
|
+
function formatUnlandedFriction({
|
|
122
|
+
storyId,
|
|
123
|
+
prNumber,
|
|
124
|
+
prUrl,
|
|
125
|
+
blockClass,
|
|
126
|
+
reason,
|
|
127
|
+
elapsedSeconds,
|
|
128
|
+
}) {
|
|
129
|
+
const prLabel =
|
|
130
|
+
Number.isInteger(prNumber) && prNumber > 0
|
|
131
|
+
? `PR #${prNumber}${prUrl ? ` (${prUrl})` : ''}`
|
|
132
|
+
: (prUrl ?? 'the PR');
|
|
133
|
+
return (
|
|
134
|
+
`### headless must-land: merge did not land\n\n` +
|
|
135
|
+
`Story #${storyId}: the headless close polled ${prLabel} for merge ` +
|
|
136
|
+
`confirmation and gave up after ${elapsedSeconds}s without observing a ` +
|
|
137
|
+
`confirmed merge.\n\n` +
|
|
138
|
+
`**Block class:** \`${blockClass}\`\n\n` +
|
|
139
|
+
`**Reason:** ${reason}\n\n` +
|
|
140
|
+
`Story transitioned to \`agent::blocked\`. Resolve the underlying ` +
|
|
141
|
+
`condition (branch protection, required checks, or a manual merge), ` +
|
|
142
|
+
`then re-run \`single-story-confirm-merge.js\` or resume delivery.`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Classify the unlanded merge, emit `merge.unlanded`, post a `friction`
|
|
148
|
+
* comment, and transition the Story to `agent::blocked`. Every side effect
|
|
149
|
+
* is best-effort logged rather than thrown — the caller (`runSingleStoryClose`)
|
|
150
|
+
* owns surfacing the non-zero exit via its own throw once this returns.
|
|
151
|
+
*
|
|
152
|
+
* @returns {Promise<{ confirmed: false, blockClass: string, reason: string, elapsedSeconds: number }>}
|
|
153
|
+
*/
|
|
154
|
+
async function blockOnUnlanded({
|
|
155
|
+
storyId,
|
|
156
|
+
prNumber,
|
|
157
|
+
prUrl,
|
|
158
|
+
armResult,
|
|
159
|
+
prProbe,
|
|
160
|
+
budget,
|
|
161
|
+
provider,
|
|
162
|
+
progress,
|
|
163
|
+
classifyMergeBlockFn,
|
|
164
|
+
emitMergeUnlandedFn,
|
|
165
|
+
}) {
|
|
166
|
+
const { blockClass, reason } = classifyMergeBlockFn({
|
|
167
|
+
armResult,
|
|
168
|
+
prProbe,
|
|
169
|
+
budget,
|
|
170
|
+
});
|
|
171
|
+
const elapsedSeconds = budget?.elapsedSeconds ?? 0;
|
|
172
|
+
|
|
173
|
+
if (Number.isInteger(prNumber) && prNumber > 0) {
|
|
174
|
+
try {
|
|
175
|
+
emitMergeUnlandedFn({
|
|
176
|
+
scope: 'story',
|
|
177
|
+
ticketId: storyId,
|
|
178
|
+
prNumber,
|
|
179
|
+
blockClass,
|
|
180
|
+
reason,
|
|
181
|
+
elapsedSeconds,
|
|
182
|
+
});
|
|
183
|
+
} catch (err) {
|
|
184
|
+
progress?.(
|
|
185
|
+
'CONFIRM',
|
|
186
|
+
`⚠️ merge.unlanded emit failed (continuing): ${err?.message ?? err}`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
progress?.(
|
|
191
|
+
'CONFIRM',
|
|
192
|
+
'⚠️ No parseable PR number — skipping merge.unlanded emit (schema requires prNumber).',
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const body = formatUnlandedFriction({
|
|
197
|
+
storyId,
|
|
198
|
+
prNumber,
|
|
199
|
+
prUrl,
|
|
200
|
+
blockClass,
|
|
201
|
+
reason,
|
|
202
|
+
elapsedSeconds,
|
|
203
|
+
});
|
|
204
|
+
try {
|
|
205
|
+
await postStructuredComment(provider, storyId, 'friction', body);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
progress?.(
|
|
208
|
+
'CONFIRM',
|
|
209
|
+
`⚠️ Failed to post merge.unlanded friction comment: ${err?.message ?? err}`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
await transitionTicketState(provider, storyId, STATE_LABELS.BLOCKED, {});
|
|
215
|
+
progress?.(
|
|
216
|
+
'CONFIRM',
|
|
217
|
+
`🛑 Story #${storyId} → agent::blocked (${blockClass}).`,
|
|
218
|
+
);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
progress?.(
|
|
221
|
+
'CONFIRM',
|
|
222
|
+
`⚠️ Failed to flip Story #${storyId} to agent::blocked: ${err?.message ?? err}`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { confirmed: false, blockClass, reason, elapsedSeconds };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Poll an armed standalone-Story PR to merge confirmation, or terminate
|
|
231
|
+
* `agent::blocked` with a classified `merge.unlanded` event.
|
|
232
|
+
*
|
|
233
|
+
* @param {object} args
|
|
234
|
+
* @param {string} args.cwd
|
|
235
|
+
* @param {number} args.storyId
|
|
236
|
+
* @param {number|null} args.prNumber
|
|
237
|
+
* @param {string} args.prUrl
|
|
238
|
+
* @param {boolean} args.autoMergeEnabled
|
|
239
|
+
* @param {string|null} args.autoMergeReason
|
|
240
|
+
* @param {object} args.provider
|
|
241
|
+
* @param {object} [args.config]
|
|
242
|
+
* @param {(tag: string, msg: string) => void} [args.progress]
|
|
243
|
+
* @param {object} [args.injectedGh]
|
|
244
|
+
* @param {Function} [args.injectedNotify]
|
|
245
|
+
* @param {Function} [args.confirmStoryMergedFn] Test seam — defaults to the
|
|
246
|
+
* SAME `confirmStoryMerged` export `single-story-confirm-merge.js` calls
|
|
247
|
+
* (Story #4428 AC4: one merged/`agent::done` implementation).
|
|
248
|
+
* @param {Function} [args.readPrMergeStateFn] Test seam for the PR-state reader.
|
|
249
|
+
* @param {Function} [args.classifyMergeBlockFn] Test seam for the classifier.
|
|
250
|
+
* @param {Function} [args.emitMergeUnlandedFn] Test seam for the lifecycle emitter.
|
|
251
|
+
* @param {(ms: number) => Promise<void>} [args.sleepFn] Test seam so the
|
|
252
|
+
* suite does not actually wait.
|
|
253
|
+
* @param {() => number} [args.nowMsFn] Test seam; returns epoch ms.
|
|
254
|
+
* @returns {Promise<{ confirmed: boolean, action?: string, blockClass?: string, reason?: string, elapsedSeconds?: number }>}
|
|
255
|
+
*/
|
|
256
|
+
export async function runConfirmMergePhase({
|
|
257
|
+
cwd,
|
|
258
|
+
storyId,
|
|
259
|
+
prNumber,
|
|
260
|
+
prUrl,
|
|
261
|
+
autoMergeEnabled,
|
|
262
|
+
autoMergeReason,
|
|
263
|
+
provider,
|
|
264
|
+
config,
|
|
265
|
+
progress,
|
|
266
|
+
injectedGh,
|
|
267
|
+
injectedNotify,
|
|
268
|
+
confirmStoryMergedFn = defaultConfirmStoryMerged,
|
|
269
|
+
readPrMergeStateFn = defaultReadPrMergeState,
|
|
270
|
+
readPrClassificationProbeFn = readPrClassificationProbe,
|
|
271
|
+
classifyMergeBlockFn = defaultClassifyMergeBlock,
|
|
272
|
+
emitMergeUnlandedFn = defaultEmitMergeUnlanded,
|
|
273
|
+
sleepFn = defaultSleep,
|
|
274
|
+
nowMsFn = Date.now,
|
|
275
|
+
}) {
|
|
276
|
+
// The arm itself never succeeded (gh failure, unparseable PR number, or a
|
|
277
|
+
// deliberate disablement) — there is no "armed but unconfirmed" PR to
|
|
278
|
+
// poll. Headless mode still requires an explicit terminal state, so
|
|
279
|
+
// classify and block immediately rather than resting silently.
|
|
280
|
+
if (!autoMergeEnabled) {
|
|
281
|
+
progress?.(
|
|
282
|
+
'CONFIRM',
|
|
283
|
+
`⚠️ Auto-merge not enabled (${autoMergeReason ?? 'unknown'}) — headless close cannot wait for a merge that was never armed.`,
|
|
284
|
+
);
|
|
285
|
+
return blockOnUnlanded({
|
|
286
|
+
storyId,
|
|
287
|
+
prNumber,
|
|
288
|
+
prUrl,
|
|
289
|
+
armResult: { armed: false, reason: autoMergeReason },
|
|
290
|
+
budget: { elapsedSeconds: 0 },
|
|
291
|
+
provider,
|
|
292
|
+
progress,
|
|
293
|
+
classifyMergeBlockFn,
|
|
294
|
+
emitMergeUnlandedFn,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const { intervalSeconds, maxBudgetSeconds } =
|
|
299
|
+
resolveMergeWatchCadence(config);
|
|
300
|
+
const intervalMs = intervalSeconds * 1000;
|
|
301
|
+
const budgetMs = maxBudgetSeconds * 1000;
|
|
302
|
+
const startedAtMs = nowMsFn();
|
|
303
|
+
|
|
304
|
+
progress?.(
|
|
305
|
+
'CONFIRM',
|
|
306
|
+
`⏳ Headless must-land: polling PR #${prNumber} for merge confirmation (budget=${maxBudgetSeconds}s)...`,
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
while (true) {
|
|
310
|
+
const confirmation = await confirmStoryMergedFn({
|
|
311
|
+
provider,
|
|
312
|
+
storyId,
|
|
313
|
+
prNumber,
|
|
314
|
+
prUrl,
|
|
315
|
+
cwd,
|
|
316
|
+
config,
|
|
317
|
+
progress,
|
|
318
|
+
injectedGh,
|
|
319
|
+
injectedNotify,
|
|
320
|
+
readPrMergeStateFn,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
if (confirmation.merged && confirmation.action !== 'flip-failed') {
|
|
324
|
+
progress?.(
|
|
325
|
+
'CONFIRM',
|
|
326
|
+
`✅ Story #${storyId} merge confirmed — agent::done.`,
|
|
327
|
+
);
|
|
328
|
+
return { confirmed: true, action: confirmation.action };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (confirmation.merged && confirmation.action === 'flip-failed') {
|
|
332
|
+
// The PR merged but the agent::closing → agent::done label flip
|
|
333
|
+
// itself threw — reporting confirmed:true here would strand the
|
|
334
|
+
// Story at agent::closing with no notification and no block, the
|
|
335
|
+
// silent-terminal-state gap the Epic exists to close (audit-quality
|
|
336
|
+
// Critical finding, Epic #4425). Route through the same
|
|
337
|
+
// blockOnUnlanded path as an unlanded merge so the run still
|
|
338
|
+
// terminates in an explicit agent::blocked state with a diagnosis.
|
|
339
|
+
progress?.(
|
|
340
|
+
'CONFIRM',
|
|
341
|
+
`⚠️ Story #${storyId} merge confirmed but the agent::done flip failed — blocking explicitly.`,
|
|
342
|
+
);
|
|
343
|
+
return blockOnUnlanded({
|
|
344
|
+
storyId,
|
|
345
|
+
prNumber,
|
|
346
|
+
prUrl,
|
|
347
|
+
prProbe: {
|
|
348
|
+
error: 'merge confirmed but agent::done label flip failed',
|
|
349
|
+
},
|
|
350
|
+
budget: {
|
|
351
|
+
exhausted: true,
|
|
352
|
+
elapsedSeconds: Math.round((nowMsFn() - startedAtMs) / 1000),
|
|
353
|
+
},
|
|
354
|
+
provider,
|
|
355
|
+
progress,
|
|
356
|
+
classifyMergeBlockFn,
|
|
357
|
+
emitMergeUnlandedFn,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (confirmation.reason === 'pr-not-merged') {
|
|
362
|
+
// The PR was closed without merging — a definitive terminal state,
|
|
363
|
+
// not a "still pending" condition the budget should keep waiting
|
|
364
|
+
// on. checksStatus MUST be a non-pending, non-undefined value here
|
|
365
|
+
// (audit-clean-code finding, Epic #4425): classifyMergeBlock's
|
|
366
|
+
// budget-exhausted branch treats an undefined checksStatus as
|
|
367
|
+
// "still pending", which would misclassify this definitive
|
|
368
|
+
// closed-without-merging case as checks-pending-timeout instead
|
|
369
|
+
// of falling through to the api-race-other reason built from
|
|
370
|
+
// prProbe.error below.
|
|
371
|
+
return blockOnUnlanded({
|
|
372
|
+
storyId,
|
|
373
|
+
prNumber,
|
|
374
|
+
prUrl,
|
|
375
|
+
prProbe: {
|
|
376
|
+
checksStatus: 'closed',
|
|
377
|
+
error: 'PR closed without merging (state=CLOSED)',
|
|
378
|
+
},
|
|
379
|
+
budget: {
|
|
380
|
+
exhausted: true,
|
|
381
|
+
elapsedSeconds: Math.round((nowMsFn() - startedAtMs) / 1000),
|
|
382
|
+
},
|
|
383
|
+
provider,
|
|
384
|
+
progress,
|
|
385
|
+
classifyMergeBlockFn,
|
|
386
|
+
emitMergeUnlandedFn,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const elapsedMs = nowMsFn() - startedAtMs;
|
|
391
|
+
if (elapsedMs + intervalMs > budgetMs) {
|
|
392
|
+
// Terminal classification from the REAL PR state — one fresh probe
|
|
393
|
+
// of the fields classifyMergeBlock keys on, instead of stamping
|
|
394
|
+
// every timeout `checksStatus: 'pending'` (which made a
|
|
395
|
+
// review-required block undiagnosable). The probe degrades to the
|
|
396
|
+
// prior conservative pending stamp when the read itself fails.
|
|
397
|
+
const prProbe = await readPrClassificationProbeFn({
|
|
398
|
+
prNumber,
|
|
399
|
+
gh: injectedGh,
|
|
400
|
+
});
|
|
401
|
+
return blockOnUnlanded({
|
|
402
|
+
storyId,
|
|
403
|
+
prNumber,
|
|
404
|
+
prUrl,
|
|
405
|
+
prProbe,
|
|
406
|
+
budget: {
|
|
407
|
+
exhausted: true,
|
|
408
|
+
elapsedSeconds: Math.round(elapsedMs / 1000),
|
|
409
|
+
},
|
|
410
|
+
provider,
|
|
411
|
+
progress,
|
|
412
|
+
classifyMergeBlockFn,
|
|
413
|
+
emitMergeUnlandedFn,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
await sleepFn(intervalMs);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
@@ -26,11 +26,35 @@ function resolveFlag(paramValue, parsedValue, defaultValue) {
|
|
|
26
26
|
return paramValue ?? parsedValue ?? defaultValue;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the `waitForMerge` option: an explicit `--no-wait-merge` opt-out
|
|
31
|
+
* always wins (preserves the pre-Story-#4428 exit shape even if a future
|
|
32
|
+
* headless wrapper defaults to waiting); otherwise the explicit
|
|
33
|
+
* `--wait-merge` / injected value governs, defaulting to `false` so
|
|
34
|
+
* attended (non-headless) invocations are byte-identical to before.
|
|
35
|
+
*
|
|
36
|
+
* @param {{ waitForMergeParam, noWaitForMergeParam, parsed }} raw
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
function resolveWaitForMerge({
|
|
40
|
+
waitForMergeParam,
|
|
41
|
+
noWaitForMergeParam,
|
|
42
|
+
parsed,
|
|
43
|
+
}) {
|
|
44
|
+
const optedOut = resolveFlag(
|
|
45
|
+
noWaitForMergeParam,
|
|
46
|
+
parsed.noWaitForMerge,
|
|
47
|
+
false,
|
|
48
|
+
);
|
|
49
|
+
if (optedOut) return false;
|
|
50
|
+
return resolveFlag(waitForMergeParam, parsed.waitForMerge, false);
|
|
51
|
+
}
|
|
52
|
+
|
|
29
53
|
/**
|
|
30
54
|
* Parse and resolve all CLI / injection options for `runSingleStoryClose`.
|
|
31
55
|
*
|
|
32
|
-
* @param {{ storyIdParam, cwdParam, skipValidationParam, skipSyncParam, noAutoMergeParam, noFullScopeCrapParam }} raw
|
|
33
|
-
* @returns {{ storyId, cwd, skipValidation, skipSync, noAutoMerge, noFullScopeCrap }}
|
|
56
|
+
* @param {{ storyIdParam, cwdParam, skipValidationParam, skipSyncParam, noAutoMergeParam, noFullScopeCrapParam, waitForMergeParam, noWaitForMergeParam }} raw
|
|
57
|
+
* @returns {{ storyId, cwd, skipValidation, skipSync, noAutoMerge, noFullScopeCrap, waitForMerge }}
|
|
34
58
|
*/
|
|
35
59
|
export function parseCloseOptions({
|
|
36
60
|
storyIdParam,
|
|
@@ -39,6 +63,8 @@ export function parseCloseOptions({
|
|
|
39
63
|
skipSyncParam,
|
|
40
64
|
noAutoMergeParam,
|
|
41
65
|
noFullScopeCrapParam,
|
|
66
|
+
waitForMergeParam,
|
|
67
|
+
noWaitForMergeParam,
|
|
42
68
|
}) {
|
|
43
69
|
const parsed =
|
|
44
70
|
storyIdParam !== undefined
|
|
@@ -49,6 +75,8 @@ export function parseCloseOptions({
|
|
|
49
75
|
skipSync: !!skipSyncParam,
|
|
50
76
|
noAutoMerge: !!noAutoMergeParam,
|
|
51
77
|
noFullScopeCrap: !!noFullScopeCrapParam,
|
|
78
|
+
waitForMerge: !!waitForMergeParam,
|
|
79
|
+
noWaitForMerge: !!noWaitForMergeParam,
|
|
52
80
|
}
|
|
53
81
|
: parseSprintArgs();
|
|
54
82
|
return {
|
|
@@ -66,5 +94,10 @@ export function parseCloseOptions({
|
|
|
66
94
|
parsed.noFullScopeCrap,
|
|
67
95
|
false,
|
|
68
96
|
),
|
|
97
|
+
waitForMerge: resolveWaitForMerge({
|
|
98
|
+
waitForMergeParam,
|
|
99
|
+
noWaitForMergeParam,
|
|
100
|
+
parsed,
|
|
101
|
+
}),
|
|
69
102
|
};
|
|
70
103
|
}
|