mandrel 2.8.0 → 2.9.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/docs/configuration.md +26 -0
- package/.agents/schemas/agentrc.schema.json +21 -0
- package/.agents/scripts/audit-to-stories.js +51 -0
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +120 -55
- package/.agents/scripts/lib/config-settings-schema.js +32 -0
- package/.agents/scripts/lib/findings/semantic-issue-search.js +43 -5
- package/.agents/scripts/lib/observability/terse-result.js +114 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +207 -0
- package/.agents/scripts/lib/orchestration/plan-context.js +3 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +221 -8
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +55 -14
- package/.agents/scripts/lib/orchestration/story-close/emit-blocked.js +9 -3
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -1
- package/.agents/scripts/lib/orchestration/task-body-validator.js +13 -40
- package/.agents/scripts/lib/story-body/body-format-lints.js +215 -0
- package/.agents/scripts/lib/story-body/story-body.js +18 -2
- package/.agents/scripts/lib/templates/decomposer-prompts.js +16 -0
- package/.agents/scripts/providers/github/issues.js +54 -7
- package/.agents/scripts/providers/github/search-budget.js +124 -0
- package/.agents/scripts/providers/github/search-query.js +71 -0
- package/.agents/scripts/single-story-confirm-merge.js +14 -5
- package/.agents/scripts/single-story-init.js +19 -3
- package/.agents/scripts/sync-branch-from-base.js +9 -3
- package/.agents/workflows/helpers/deliver-story.md +10 -0
- package/.agents/workflows/plan.md +27 -2
- package/docs/CHANGELOG.md +20 -0
- package/package.json +1 -1
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/complexity-gate.js — plan-time ceremony-lite routing gate.
|
|
3
|
+
*
|
|
4
|
+
* A **deterministic, conservative** complexity gate that routes a planning seed
|
|
5
|
+
* onto either the full two-session plan/deliver ceremony (`full`) or a collapsed
|
|
6
|
+
* ceremony-lite path (`lite`). It exists because the full ceremony imposes a
|
|
7
|
+
* large fixed cost premium on genuinely trivial single-artifact scopes with no
|
|
8
|
+
* measured quality gain (Story #4683): the bench cohort spent ~52 turns on a
|
|
9
|
+
* hello-world scope a bare control delivered in ~6, and no path existed to opt
|
|
10
|
+
* trivial scopes out.
|
|
11
|
+
*
|
|
12
|
+
* ## What "lite" changes and — critically — what it never changes
|
|
13
|
+
*
|
|
14
|
+
* The lite route collapses the **advisory ceremony** only: the plan/deliver
|
|
15
|
+
* session split, the fresh-context critic ceremony, and the Tech-Spec authoring
|
|
16
|
+
* that a one-artifact scope does not earn. It **never** relaxes a non-negotiable.
|
|
17
|
+
* {@link LITE_PATH_INVARIANTS} is the machine-readable contract that the lite
|
|
18
|
+
* path still produces a Story ticket, still lands via a PR to `main`, still runs
|
|
19
|
+
* every repo quality gate, and still honours `rules/security-baseline.md`. Those
|
|
20
|
+
* gates run in `single-story-close.js` regardless of route; the gate cannot and
|
|
21
|
+
* does not switch them off. Every `lite` decision carries this frozen object on
|
|
22
|
+
* its `preserves` field so a downstream reader can assert the invariants held.
|
|
23
|
+
*
|
|
24
|
+
* ## Conservative by construction — full on any doubt
|
|
25
|
+
*
|
|
26
|
+
* The gate is total and pure: seed text + resolved config in, decision out. It
|
|
27
|
+
* routes `lite` **only** when every trivial-scope signal agrees; every other
|
|
28
|
+
* case — an empty/unreadable seed, a seed above the word ceiling, a seed
|
|
29
|
+
* enumerating more than one candidate artifact, or the gate disabled by config —
|
|
30
|
+
* falls to `full`. Being wrong toward `full` costs a session; being wrong toward
|
|
31
|
+
* `lite` would skip ceremony a real capability slice needs, so the tie always
|
|
32
|
+
* breaks to `full`.
|
|
33
|
+
*
|
|
34
|
+
* ## Threshold + operator override
|
|
35
|
+
*
|
|
36
|
+
* {@link DEFAULT_COMPLEXITY_GATE} is the single source of truth for the
|
|
37
|
+
* threshold. Operators tune it (or disable the gate entirely) via
|
|
38
|
+
* `planning.complexityGate` in `.agentrc.json`:
|
|
39
|
+
*
|
|
40
|
+
* - `enabled` (default `true`) — `false` forces every seed to `full`.
|
|
41
|
+
* - `maxSeedWords` (default `60`) — seed prose word ceiling for `lite`.
|
|
42
|
+
* - `maxArtifacts` (default `1`) — enumerated-artifact ceiling for `lite`.
|
|
43
|
+
*
|
|
44
|
+
* Resolution clamps every field toward the conservative default: a malformed or
|
|
45
|
+
* negative ceiling falls back to the framework default rather than widening the
|
|
46
|
+
* lite path.
|
|
47
|
+
*
|
|
48
|
+
* @typedef {'lite'|'full'} ComplexityRoute
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Framework defaults for the plan-time complexity gate. The threshold SSOT —
|
|
53
|
+
* the config schema mirror and the configuration reference both cite these
|
|
54
|
+
* numbers rather than restating divergent ones.
|
|
55
|
+
*/
|
|
56
|
+
const DEFAULT_COMPLEXITY_GATE = Object.freeze({
|
|
57
|
+
enabled: true,
|
|
58
|
+
maxSeedWords: 60,
|
|
59
|
+
maxArtifacts: 1,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The non-negotiables the ceremony-lite path preserves. This is the
|
|
64
|
+
* contract behind Story #4683 AC-2: collapsing ceremony never means dropping
|
|
65
|
+
* the Story ticket, the PR-to-`main` landing, the repo quality gates, or the
|
|
66
|
+
* security baseline. Attached verbatim to every `lite` decision's `preserves`
|
|
67
|
+
* field; a downstream consumer (or contract test) asserts against it.
|
|
68
|
+
*/
|
|
69
|
+
const LITE_PATH_INVARIANTS = Object.freeze({
|
|
70
|
+
storyTicket: true,
|
|
71
|
+
prToMain: true,
|
|
72
|
+
repoGates: true,
|
|
73
|
+
securityBaseline: true,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Coerce a candidate ceiling into a non-negative integer, falling back to the
|
|
78
|
+
* framework default for anything malformed. Non-numbers, non-finite values, and
|
|
79
|
+
* negatives all fall back — a stray `-1` or `NaN` must never widen the lite path
|
|
80
|
+
* (the gate fails conservative, toward `full`).
|
|
81
|
+
*
|
|
82
|
+
* @param {unknown} value
|
|
83
|
+
* @param {number} fallback
|
|
84
|
+
* @returns {number}
|
|
85
|
+
*/
|
|
86
|
+
function normalizeCeiling(value, fallback) {
|
|
87
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
|
88
|
+
return fallback;
|
|
89
|
+
}
|
|
90
|
+
return Math.floor(value);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolve the effective complexity-gate config, shallow-overlaying an operator
|
|
95
|
+
* `planning.complexityGate` block onto {@link DEFAULT_COMPLEXITY_GATE}. Accepts
|
|
96
|
+
* the full resolved config, the bare `planning` bag, or the bare
|
|
97
|
+
* `complexityGate` bag, mirroring the tolerant unwrap the other routing
|
|
98
|
+
* accessors use. Module-private: exposed only through the resolved `threshold`
|
|
99
|
+
* on {@link buildComplexityRouteSignal}'s output, so there is no test-only
|
|
100
|
+
* export to leave production-dead.
|
|
101
|
+
*
|
|
102
|
+
* @param {object | null | undefined} config
|
|
103
|
+
* @returns {{ enabled: boolean, maxSeedWords: number, maxArtifacts: number }}
|
|
104
|
+
*/
|
|
105
|
+
function resolveComplexityGate(config) {
|
|
106
|
+
const raw =
|
|
107
|
+
config?.planning?.complexityGate ?? config?.complexityGate ?? config ?? {};
|
|
108
|
+
const bag = raw && typeof raw === 'object' ? raw : {};
|
|
109
|
+
return {
|
|
110
|
+
enabled:
|
|
111
|
+
typeof bag.enabled === 'boolean'
|
|
112
|
+
? bag.enabled
|
|
113
|
+
: DEFAULT_COMPLEXITY_GATE.enabled,
|
|
114
|
+
maxSeedWords: normalizeCeiling(
|
|
115
|
+
bag.maxSeedWords,
|
|
116
|
+
DEFAULT_COMPLEXITY_GATE.maxSeedWords,
|
|
117
|
+
),
|
|
118
|
+
maxArtifacts: normalizeCeiling(
|
|
119
|
+
bag.maxArtifacts,
|
|
120
|
+
DEFAULT_COMPLEXITY_GATE.maxArtifacts,
|
|
121
|
+
),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Count top-level enumerated items (`- `, `* `, `1. `) in a free-form seed —
|
|
127
|
+
* the same shape the scope-triage and delivery-shape signals read as candidate
|
|
128
|
+
* capabilities. Each enumerated line is one predicted artifact; a seed with two
|
|
129
|
+
* or more is a multi-capability scope that must take the full path.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} text
|
|
132
|
+
* @returns {number}
|
|
133
|
+
*/
|
|
134
|
+
function countSeedArtifacts(text) {
|
|
135
|
+
if (typeof text !== 'string' || text.length === 0) return 0;
|
|
136
|
+
return text
|
|
137
|
+
.split(/\r?\n/)
|
|
138
|
+
.filter((line) => /^\s*(?:[-*]|\d+\.)\s+\S/.test(line)).length;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Build the advisory complexity-route signal for a planning seed. Deterministic,
|
|
143
|
+
* total, and conservative (see the module header): every trivial-scope signal
|
|
144
|
+
* must agree for a `lite` decision; everything else routes `full`.
|
|
145
|
+
*
|
|
146
|
+
* The result is folded into the `/plan` context envelope as `complexityRoute`,
|
|
147
|
+
* so the workflow reads one field instead of re-deriving the decision. Every
|
|
148
|
+
* `lite` decision carries {@link LITE_PATH_INVARIANTS} on `preserves`.
|
|
149
|
+
*
|
|
150
|
+
* @param {{ seedText?: string, config?: object }} [args]
|
|
151
|
+
* @returns {{
|
|
152
|
+
* route: ComplexityRoute,
|
|
153
|
+
* reasons: string[],
|
|
154
|
+
* threshold: { enabled: boolean, maxSeedWords: number, maxArtifacts: number },
|
|
155
|
+
* preserves: typeof LITE_PATH_INVARIANTS,
|
|
156
|
+
* advisory: true,
|
|
157
|
+
* }}
|
|
158
|
+
*/
|
|
159
|
+
export function buildComplexityRouteSignal({ seedText = '', config } = {}) {
|
|
160
|
+
const threshold = resolveComplexityGate(config);
|
|
161
|
+
const advisory = /** @type {const} */ (true);
|
|
162
|
+
const preserves = LITE_PATH_INVARIANTS;
|
|
163
|
+
const decide = (route, reason) => ({
|
|
164
|
+
route,
|
|
165
|
+
reasons: [reason],
|
|
166
|
+
threshold,
|
|
167
|
+
preserves,
|
|
168
|
+
advisory,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
if (!threshold.enabled) {
|
|
172
|
+
return decide(
|
|
173
|
+
'full',
|
|
174
|
+
'complexity gate disabled (planning.complexityGate.enabled=false) — full plan/deliver ceremony',
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const text = typeof seedText === 'string' ? seedText : '';
|
|
179
|
+
const trimmed = text.trim();
|
|
180
|
+
if (trimmed.length === 0) {
|
|
181
|
+
return decide(
|
|
182
|
+
'full',
|
|
183
|
+
'empty seed — triviality cannot be judged; conservative full path',
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const artifactCount = countSeedArtifacts(text);
|
|
188
|
+
if (artifactCount > threshold.maxArtifacts) {
|
|
189
|
+
return decide(
|
|
190
|
+
'full',
|
|
191
|
+
`seed enumerates ${artifactCount} candidate artifacts (> maxArtifacts ${threshold.maxArtifacts}) — multi-capability scope takes the full path`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const wordCount = trimmed.split(/\s+/).filter(Boolean).length;
|
|
196
|
+
if (wordCount > threshold.maxSeedWords) {
|
|
197
|
+
return decide(
|
|
198
|
+
'full',
|
|
199
|
+
`seed is ${wordCount} words (> maxSeedWords ${threshold.maxSeedWords}) — not a trivial scope; full path`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return decide(
|
|
204
|
+
'lite',
|
|
205
|
+
`trivial single-artifact scope (${wordCount} words ≤ ${threshold.maxSeedWords}, ${artifactCount} enumerated artifact(s) ≤ ${threshold.maxArtifacts}) — collapsed ceremony-lite path; non-negotiables preserved`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
renderTechSpecSystemPrompt,
|
|
27
27
|
} from '../templates/spec-author-prompts.js';
|
|
28
28
|
import { concurrentMap } from '../util/concurrent-map.js';
|
|
29
|
+
import { buildComplexityRouteSignal } from './complexity-gate.js';
|
|
29
30
|
import { parseDeliverySlicingTable } from './consolidation-precondition.js';
|
|
30
31
|
import { buildDocsDigest } from './docs-digest.js';
|
|
31
32
|
import { buildAuthoringContext } from './planning/authoring-context.js';
|
|
@@ -482,6 +483,7 @@ async function buildSeedFileModeEnvelope({
|
|
|
482
483
|
return {
|
|
483
484
|
mode: modeLabel,
|
|
484
485
|
seed: { path: seedFilePath ?? null, content },
|
|
486
|
+
complexityRoute: buildComplexityRouteSignal({ seedText: content, config }),
|
|
485
487
|
duplicates,
|
|
486
488
|
docsContext,
|
|
487
489
|
codebaseSnapshot: authoring.codebaseSnapshot,
|
|
@@ -635,6 +637,7 @@ async function buildTicketsModeEnvelope({
|
|
|
635
637
|
mode: 'tickets',
|
|
636
638
|
sourceTickets,
|
|
637
639
|
seed: { text: seed, path: null },
|
|
640
|
+
complexityRoute: buildComplexityRouteSignal({ seedText: seed, config }),
|
|
638
641
|
duplicates,
|
|
639
642
|
docsContext,
|
|
640
643
|
codebaseSnapshot: authoring.codebaseSnapshot,
|
|
@@ -23,6 +23,42 @@
|
|
|
23
23
|
* no-op. `--delete-branch` is preserved verbatim, so the PR head branch is
|
|
24
24
|
* still deleted on merge without depending on the repo's auto-delete
|
|
25
25
|
* setting. Resolution is non-fatal — it degrades to the original cwd.
|
|
26
|
+
*
|
|
27
|
+
* Story #4681 made the arm survive a LOCAL-ONLY cleanup failure. Against an
|
|
28
|
+
* already-mergeable PR, `gh pr merge --auto --squash --delete-branch` merges
|
|
29
|
+
* immediately and then shells out to local `git` to drop the head branch.
|
|
30
|
+
* When the per-Story worktree still holds `story-<id>`, that local delete
|
|
31
|
+
* fails (`Cannot delete branch 'story-<id>' used by worktree at …`) and `gh`
|
|
32
|
+
* exits non-zero — even though the REMOTE merge already landed. Reporting
|
|
33
|
+
* that as an arm failure sent close's confirm phase straight to
|
|
34
|
+
* `blockOnUnlanded`, flipping a merged Story to a stale `agent::blocked` that
|
|
35
|
+
* only a hand-run `single-story-confirm-merge.js` could undo. The failure is
|
|
36
|
+
* now classified: a local-cleanup-only signature reports the arm as ENABLED
|
|
37
|
+
* with `localCleanupDeferred: true`, so the confirm phase polls the PR
|
|
38
|
+
* (observes MERGED) and the post-land tail reaps the local ref. Every other
|
|
39
|
+
* non-zero exit — a genuinely refused REMOTE merge — keeps the pre-existing
|
|
40
|
+
* `enabled: false` → blocked behaviour verbatim.
|
|
41
|
+
*
|
|
42
|
+
* Story #4682 restored the direct-merge fallback the v2.0.0 Story-only cutover
|
|
43
|
+
* dropped (originally PR #4480 / Story #4472, in the retired `AutomergeArmer`).
|
|
44
|
+
* GitHub native auto-merge (`gh pr merge --auto`) can only be QUEUED on a repo
|
|
45
|
+
* that has the "Allow auto-merge" setting enabled — which in practice requires
|
|
46
|
+
* branch protection. A repo with NO required checks and NO branch protection
|
|
47
|
+
* (every mandrel-bench sandbox, many real consumer repos) refuses the `--auto`
|
|
48
|
+
* arm: either "auto-merge is not allowed for this repository", or — once the
|
|
49
|
+
* PR has settled to an immediately-mergeable state, which the SECOND delivery
|
|
50
|
+
* into a warm repo reaches faster than the first into a cold one — the
|
|
51
|
+
* `enablePullRequestAutoMerge` "Pull request is in clean status" refusal. The
|
|
52
|
+
* close gates and Story-scope review have already cleared the merge by the
|
|
53
|
+
* time the arm runs, so the safe, must-land-satisfying response is a direct
|
|
54
|
+
* immediate squash-merge (no `--auto`). When the `--auto` failure matches the
|
|
55
|
+
* narrow {@link isAutoMergeUnavailable} signature, `enableAutoMergeWith`
|
|
56
|
+
* retries `gh pr merge --squash --delete-branch` and reports
|
|
57
|
+
* `{ enabled: true, directMerged: true }` on success — so the confirm phase
|
|
58
|
+
* polls the PR, observes MERGED, and lands it instead of blocking a PR that
|
|
59
|
+
* would never merge on its own. Every OTHER `--auto` failure — a genuine
|
|
60
|
+
* conflict, a red required check, an auth fault — matches neither the
|
|
61
|
+
* local-cleanup nor the unavailable signature and keeps blocking verbatim.
|
|
26
62
|
*/
|
|
27
63
|
|
|
28
64
|
import { gh as defaultGh } from '../../../gh-exec.js';
|
|
@@ -52,6 +88,123 @@ export function isOperatorMergeReason(reason) {
|
|
|
52
88
|
return OPERATOR_MERGE_ARM_REASONS.includes(reason);
|
|
53
89
|
}
|
|
54
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Signatures of a `gh pr merge --delete-branch` failure whose ONLY casualty
|
|
93
|
+
* is the LOCAL head-branch cleanup that runs *after* the remote merge has
|
|
94
|
+
* already been performed (or auto-merge already armed).
|
|
95
|
+
*
|
|
96
|
+
* Each pattern is emitted by local `git` (or `gh`'s wrapper around it) and
|
|
97
|
+
* names branch DELETION specifically:
|
|
98
|
+
* - `Cannot delete branch '<name>' used by worktree at …` — `git branch -D`
|
|
99
|
+
* refusing a ref another worktree has checked out (the Story #4681 report).
|
|
100
|
+
* - `failed to delete local branch …` — `gh`'s own wrapper wording.
|
|
101
|
+
*
|
|
102
|
+
* Deliberately narrow on two fronts. A genuinely refused REMOTE merge ("Pull
|
|
103
|
+
* request is not mergeable", a required status check, branch protection)
|
|
104
|
+
* matches neither pattern and keeps the existing blocked path. Nor does the
|
|
105
|
+
* bare `fatal: '<base>' is already used by worktree` checkout collision Story
|
|
106
|
+
* #4282 defends against: that one aborts `gh` *before* the branch delete and
|
|
107
|
+
* carries no evidence the merge stands, so it must keep failing the arm.
|
|
108
|
+
*/
|
|
109
|
+
const LOCAL_CLEANUP_FAILURE =
|
|
110
|
+
/cannot delete branch[^\n]*used by worktree|failed to delete (?:the )?local branch/i;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Whether a non-zero `gh pr merge` exit is attributable solely to local
|
|
114
|
+
* branch cleanup, leaving the remote merge/arm itself intact.
|
|
115
|
+
*
|
|
116
|
+
* Module-private on purpose: `enableAutoMergeWith` is the only caller and the
|
|
117
|
+
* only surface worth pinning, so the classification is asserted through it
|
|
118
|
+
* rather than through a test-only export.
|
|
119
|
+
*
|
|
120
|
+
* @param {string|undefined|null} stderr
|
|
121
|
+
* @returns {boolean}
|
|
122
|
+
*/
|
|
123
|
+
function isLocalCleanupOnlyFailure(stderr) {
|
|
124
|
+
return LOCAL_CLEANUP_FAILURE.test(String(stderr ?? ''));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Pure: does a `gh pr merge --auto` stderr indicate that GitHub native
|
|
129
|
+
* auto-merge is UNAVAILABLE on this repository / PR — as opposed to a genuine
|
|
130
|
+
* arm failure (a merge conflict, a red required check, an auth fault)?
|
|
131
|
+
*
|
|
132
|
+
* Two distinct refusals both mean "there is no queued auto-merge for this repo,
|
|
133
|
+
* merge it directly instead", and both are safe to retry as an immediate
|
|
134
|
+
* squash-merge:
|
|
135
|
+
*
|
|
136
|
+
* - **"auto-merge is not allowed for this repository"** — the repo has no
|
|
137
|
+
* "Allow auto-merge" setting (no branch protection). Constant per repo.
|
|
138
|
+
* - **"Pull request is in clean status"** — the `enablePullRequestAutoMerge`
|
|
139
|
+
* GraphQL mutation refuses to queue a merge on a PR that is ALREADY
|
|
140
|
+
* immediately mergeable with nothing to wait for (no required checks
|
|
141
|
+
* pending). This is the second-delivery wedge (Story #4682): the first
|
|
142
|
+
* delivery into a cold sandbox arms while GitHub is still computing the
|
|
143
|
+
* fresh PR's mergeability (the arm queues, then merges); the second
|
|
144
|
+
* delivery into the now-warm repo hits an instantly-clean PR, so the arm
|
|
145
|
+
* is refused here.
|
|
146
|
+
*
|
|
147
|
+
* Only these classes fall through to the direct-merge fallback; everything
|
|
148
|
+
* else (an unmatched non-zero exit) keeps the `enabled: false` → blocked path.
|
|
149
|
+
* Matched case-insensitively.
|
|
150
|
+
*
|
|
151
|
+
* Module-private on purpose (mirroring {@link isLocalCleanupOnlyFailure}):
|
|
152
|
+
* `enableAutoMergeWith` is the only caller, so the marker set is asserted
|
|
153
|
+
* through it rather than through a test-only export the production dead-export
|
|
154
|
+
* ratchet would then flag.
|
|
155
|
+
*
|
|
156
|
+
* @param {string|undefined|null} stderr
|
|
157
|
+
* @returns {boolean}
|
|
158
|
+
*/
|
|
159
|
+
function isAutoMergeUnavailable(stderr) {
|
|
160
|
+
const text = String(stderr ?? '').toLowerCase();
|
|
161
|
+
return (
|
|
162
|
+
text.includes('auto merge is not allowed') ||
|
|
163
|
+
text.includes('auto-merge is not allowed') ||
|
|
164
|
+
text.includes('enablepullrequestautomerge') ||
|
|
165
|
+
text.includes('clean status') ||
|
|
166
|
+
(text.includes('auto') && text.includes('not enabled'))
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Direct (non-`--auto`) squash-merge fallback (Story #4682, restoring PR
|
|
172
|
+
* #4480 / Story #4472). Reached only when the `--auto` arm was refused with
|
|
173
|
+
* the {@link isAutoMergeUnavailable} signature — a repo with no native
|
|
174
|
+
* auto-merge, or an already-clean PR with nothing to queue behind. Omitting
|
|
175
|
+
* `--auto` makes `gh` merge synchronously; the same `--squash --delete-branch`
|
|
176
|
+
* shape and the same `armCwd` re-point are preserved so the trailing local
|
|
177
|
+
* `--delete-branch` housekeeping runs from the primary worktree (Story #4282).
|
|
178
|
+
*
|
|
179
|
+
* A local-cleanup-only grumble on the direct merge (Story #4681) still means
|
|
180
|
+
* the REMOTE merge landed, so it reports `directMerged` with
|
|
181
|
+
* `localCleanupDeferred`. Any other non-zero exit is a genuine failure the
|
|
182
|
+
* caller escalates.
|
|
183
|
+
*
|
|
184
|
+
* @returns {Promise<{ enabled: boolean, directMerged?: boolean, localCleanupDeferred?: boolean, reason?: string }>}
|
|
185
|
+
*/
|
|
186
|
+
async function directMergeFallback({ exec, prNumber, armCwd, autoReason }) {
|
|
187
|
+
const direct = await exec(
|
|
188
|
+
['pr', 'merge', String(prNumber), '--squash', '--delete-branch'],
|
|
189
|
+
{ cwd: armCwd },
|
|
190
|
+
);
|
|
191
|
+
if (direct.status === 0) {
|
|
192
|
+
return { enabled: true, directMerged: true, reason: autoReason };
|
|
193
|
+
}
|
|
194
|
+
if (isLocalCleanupOnlyFailure(direct.stderr)) {
|
|
195
|
+
return {
|
|
196
|
+
enabled: true,
|
|
197
|
+
directMerged: true,
|
|
198
|
+
localCleanupDeferred: true,
|
|
199
|
+
reason: autoReason,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
enabled: false,
|
|
204
|
+
reason: `direct-merge fallback failed after auto-merge unavailable (${autoReason}); gh-exit-${direct.status}: ${(direct.stderr ?? '').trim().slice(0, 160)}`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
55
208
|
/**
|
|
56
209
|
* Enable GitHub native auto-merge on the PR. Non-fatal.
|
|
57
210
|
*
|
|
@@ -62,7 +215,7 @@ export function isOperatorMergeReason(reason) {
|
|
|
62
215
|
* runner?: (args: string[], opts: object) => ({ status: number, stdout?: string, stderr?: string } | Promise<{ status: number, stdout?: string, stderr?: string }>),
|
|
63
216
|
* resolveArmCwd?: (cwd: string) => string,
|
|
64
217
|
* }} opts
|
|
65
|
-
* @returns {Promise<{ enabled: boolean, reason?: string }>}
|
|
218
|
+
* @returns {Promise<{ enabled: boolean, reason?: string, localCleanupDeferred?: boolean, directMerged?: boolean }>}
|
|
66
219
|
*/
|
|
67
220
|
export async function enableAutoMergeWith({
|
|
68
221
|
cwd,
|
|
@@ -89,10 +242,25 @@ export async function enableAutoMergeWith({
|
|
|
89
242
|
{ cwd: armCwd },
|
|
90
243
|
);
|
|
91
244
|
if (result.status === 0) return { enabled: true };
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
245
|
+
const detail = `gh-exit-${result.status}: ${(result.stderr ?? '').trim().slice(0, 200)}`;
|
|
246
|
+
if (isLocalCleanupOnlyFailure(result.stderr)) {
|
|
247
|
+
// The remote side stands; only the local head-branch cleanup failed.
|
|
248
|
+
// Report ENABLED so the confirm phase polls the real PR state instead
|
|
249
|
+
// of blocking a merge that already landed, and flag the deferred
|
|
250
|
+
// cleanup for the land tail's `git branch -D` to finish.
|
|
251
|
+
return { enabled: true, localCleanupDeferred: true, reason: detail };
|
|
252
|
+
}
|
|
253
|
+
if (isAutoMergeUnavailable(result.stderr)) {
|
|
254
|
+
// No native auto-merge on this repo (or nothing to queue behind an
|
|
255
|
+
// already-clean PR): merge directly so the PR still lands (Story #4682).
|
|
256
|
+
return directMergeFallback({
|
|
257
|
+
exec,
|
|
258
|
+
prNumber,
|
|
259
|
+
armCwd,
|
|
260
|
+
autoReason: detail,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return { enabled: false, reason: detail };
|
|
96
264
|
} catch (err) {
|
|
97
265
|
return { enabled: false, reason: `gh-spawn-error: ${err?.message ?? err}` };
|
|
98
266
|
}
|
|
@@ -167,7 +335,11 @@ function makeDefaultGhAutoMergeRunner(gh) {
|
|
|
167
335
|
* gh?: ReturnType<typeof import('../../../gh-exec.js').createGh>,
|
|
168
336
|
* progress: (tag: string, msg: string) => void,
|
|
169
337
|
* }} args
|
|
170
|
-
* @returns {Promise<{ autoMergeEnabled: boolean, autoMergeReason: string|null }>}
|
|
338
|
+
* @returns {Promise<{ autoMergeEnabled: boolean, autoMergeReason: string|null, localCleanupDeferred?: boolean, directMerged?: boolean }>}
|
|
339
|
+
* `localCleanupDeferred` is true when the arm stands but `gh`'s local
|
|
340
|
+
* head-branch delete failed (Story #4681) — the land tail owns the reap.
|
|
341
|
+
* `directMerged` is true when native auto-merge was unavailable and the PR
|
|
342
|
+
* was landed by a direct squash-merge instead (Story #4682).
|
|
171
343
|
*/
|
|
172
344
|
export async function runAutoMergePhase({
|
|
173
345
|
cwd,
|
|
@@ -211,15 +383,56 @@ export async function runAutoMergePhase({
|
|
|
211
383
|
}
|
|
212
384
|
const result = await enableAutoMergeWith({ cwd, prNumber, gh });
|
|
213
385
|
if (result.enabled) {
|
|
386
|
+
if (result.directMerged) {
|
|
387
|
+
// No native auto-merge on this repo — the PR was merged directly
|
|
388
|
+
// instead of queued (Story #4682). The confirm phase polls the PR,
|
|
389
|
+
// observes MERGED, and runs the land tail; `localCleanupDeferred`
|
|
390
|
+
// still defers a local-ref reap when gh's `--delete-branch` grumbled.
|
|
391
|
+
progress(
|
|
392
|
+
'PR',
|
|
393
|
+
`✅ Native auto-merge unavailable on PR #${prNumber} — direct squash-merge landed it` +
|
|
394
|
+
(result.localCleanupDeferred
|
|
395
|
+
? " (gh's LOCAL branch cleanup deferred to the land tail; the merge stands)."
|
|
396
|
+
: '.'),
|
|
397
|
+
);
|
|
398
|
+
return {
|
|
399
|
+
autoMergeEnabled: true,
|
|
400
|
+
autoMergeReason: null,
|
|
401
|
+
directMerged: true,
|
|
402
|
+
localCleanupDeferred: Boolean(result.localCleanupDeferred),
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
if (result.localCleanupDeferred) {
|
|
406
|
+
// Warning, never a block (Story #4681): the merge/arm stands and the
|
|
407
|
+
// land tail reaps the local ref once the worktree releases it.
|
|
408
|
+
progress(
|
|
409
|
+
'PR',
|
|
410
|
+
`⚠️ Auto-merge armed on PR #${prNumber}, but gh's LOCAL branch cleanup failed ` +
|
|
411
|
+
`(${result.reason}) — deferring the local ref reap to the land tail; the merge stands.`,
|
|
412
|
+
);
|
|
413
|
+
return {
|
|
414
|
+
autoMergeEnabled: true,
|
|
415
|
+
autoMergeReason: null,
|
|
416
|
+
localCleanupDeferred: true,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
214
419
|
progress(
|
|
215
420
|
'PR',
|
|
216
421
|
`✅ Auto-merge enabled on PR #${prNumber} (squash, delete-branch).`,
|
|
217
422
|
);
|
|
218
|
-
return {
|
|
423
|
+
return {
|
|
424
|
+
autoMergeEnabled: true,
|
|
425
|
+
autoMergeReason: null,
|
|
426
|
+
localCleanupDeferred: false,
|
|
427
|
+
};
|
|
219
428
|
}
|
|
220
429
|
progress(
|
|
221
430
|
'PR',
|
|
222
431
|
`⚠️ Auto-merge enablement failed (${result.reason}) — operator can merge manually.`,
|
|
223
432
|
);
|
|
224
|
-
return {
|
|
433
|
+
return {
|
|
434
|
+
autoMergeEnabled: false,
|
|
435
|
+
autoMergeReason: result.reason,
|
|
436
|
+
localCleanupDeferred: false,
|
|
437
|
+
};
|
|
225
438
|
}
|
|
@@ -7,6 +7,7 @@ import { resolveConfig } from '../../config-resolver.js';
|
|
|
7
7
|
import { getStoryBranch, gitSync } from '../../git-utils.js';
|
|
8
8
|
import { Logger } from '../../Logger.js';
|
|
9
9
|
import { emitTerminalFriction } from '../../observability/runtime-friction.js';
|
|
10
|
+
import { emitTerseResult } from '../../observability/terse-result.js';
|
|
10
11
|
import { createProvider } from '../../provider-factory.js';
|
|
11
12
|
import { flipLabelAndNotify } from '../../single-story/story-merged-notify.js';
|
|
12
13
|
import { WorktreeManager } from '../../worktree-manager.js';
|
|
@@ -53,12 +54,22 @@ const progress = Logger.createProgress('single-story-close', { stderr: true });
|
|
|
53
54
|
* The emit is best-effort internally and cannot throw.
|
|
54
55
|
*/
|
|
55
56
|
async function emitTerminal({ terminal, result, config }) {
|
|
56
|
-
//
|
|
57
|
-
// the
|
|
57
|
+
// Story #4685 — the human-facing result dump goes to a temp log; the agent
|
|
58
|
+
// acts on the (separate, unsuppressible) terminal envelope emitted below.
|
|
59
|
+
// The single summary line keeps the fields worth an at-a-glance read.
|
|
58
60
|
if (result) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
emitTerseResult({
|
|
62
|
+
label: 'STORY CLOSE RESULT',
|
|
63
|
+
result,
|
|
64
|
+
scope: result.storyId,
|
|
65
|
+
summary: {
|
|
66
|
+
storyId: result.storyId,
|
|
67
|
+
action: result.action,
|
|
68
|
+
reason: result.reason,
|
|
69
|
+
prNumber: result.prNumber,
|
|
70
|
+
status: terminal?.status,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
62
73
|
}
|
|
63
74
|
emitTerminalEnvelope(terminal);
|
|
64
75
|
await emitTerminalFriction({ envelope: terminal, config });
|
|
@@ -311,6 +322,8 @@ function closeResult({
|
|
|
311
322
|
autoMergeReason,
|
|
312
323
|
worktreeReaped,
|
|
313
324
|
leaseReleased,
|
|
325
|
+
localCleanupDeferred = false,
|
|
326
|
+
directMerged = false,
|
|
314
327
|
waitedForMerge = false,
|
|
315
328
|
merged = false,
|
|
316
329
|
}) {
|
|
@@ -326,6 +339,15 @@ function closeResult({
|
|
|
326
339
|
autoMergeReason,
|
|
327
340
|
worktreeReaped,
|
|
328
341
|
leaseReleased,
|
|
342
|
+
// Story #4681 — `gh`'s local head-branch delete failed while the remote
|
|
343
|
+
// merge/arm stood. Surfaced so the land is auditable as
|
|
344
|
+
// merged-with-deferred-cleanup rather than silently degraded.
|
|
345
|
+
localCleanupDeferred,
|
|
346
|
+
// Story #4682 — native auto-merge was unavailable (no branch protection /
|
|
347
|
+
// an already-clean PR), so the PR was landed by a direct squash-merge.
|
|
348
|
+
// Surfaced so a checks-less land is auditable rather than looking like a
|
|
349
|
+
// queued auto-merge that never fired.
|
|
350
|
+
directMerged,
|
|
329
351
|
waitedForMerge,
|
|
330
352
|
merged,
|
|
331
353
|
note: waitedForMerge
|
|
@@ -486,8 +508,31 @@ async function runClosePipeline({
|
|
|
486
508
|
}),
|
|
487
509
|
leaseArgs,
|
|
488
510
|
);
|
|
511
|
+
// Reap the per-Story worktree BEFORE the arm (Story #4681). Arming runs
|
|
512
|
+
// `gh pr merge --auto --squash --delete-branch`, which — against an
|
|
513
|
+
// already-mergeable PR — merges immediately and then shells out to local
|
|
514
|
+
// `git` to drop `story-<id>`. A live worktree still holding that ref makes
|
|
515
|
+
// the local delete fail, `gh` exit non-zero, and the arm read as failed,
|
|
516
|
+
// which used to strand a genuinely merged PR at `agent::blocked`.
|
|
517
|
+
// Pre-empting the hold is the ordering half of the fix (the tolerate half
|
|
518
|
+
// lives in `phases/auto-merge.js`); it is safe here because push and PR
|
|
519
|
+
// creation already made the work durable off-machine, and `isSafeToRemove`
|
|
520
|
+
// still refuses a dirty tree.
|
|
521
|
+
const worktreeReaped = await reapWorktreePhase({
|
|
522
|
+
cwd: options.cwd,
|
|
523
|
+
storyId: options.storyId,
|
|
524
|
+
worktreePath,
|
|
525
|
+
wtIsolation: config.delivery?.worktreeIsolation,
|
|
526
|
+
progress,
|
|
527
|
+
WorktreeManager,
|
|
528
|
+
});
|
|
489
529
|
setPhase('auto-merge');
|
|
490
|
-
const {
|
|
530
|
+
const {
|
|
531
|
+
autoMergeEnabled,
|
|
532
|
+
autoMergeReason,
|
|
533
|
+
localCleanupDeferred,
|
|
534
|
+
directMerged,
|
|
535
|
+
} = await runAutoMergePhase({
|
|
491
536
|
cwd: options.cwd,
|
|
492
537
|
prNumber,
|
|
493
538
|
prUrl,
|
|
@@ -507,14 +552,6 @@ async function runClosePipeline({
|
|
|
507
552
|
config,
|
|
508
553
|
progress,
|
|
509
554
|
});
|
|
510
|
-
const worktreeReaped = await reapWorktreePhase({
|
|
511
|
-
cwd: options.cwd,
|
|
512
|
-
storyId: options.storyId,
|
|
513
|
-
worktreePath,
|
|
514
|
-
wtIsolation: config.delivery?.worktreeIsolation,
|
|
515
|
-
progress,
|
|
516
|
-
WorktreeManager,
|
|
517
|
-
});
|
|
518
555
|
const leaseReleased = await releaseLease(leaseArgs);
|
|
519
556
|
|
|
520
557
|
// Close-and-land (Story #4428; default since `delivery.routing.closeAndLand`
|
|
@@ -589,6 +626,8 @@ async function runClosePipeline({
|
|
|
589
626
|
autoMergeReason,
|
|
590
627
|
worktreeReaped,
|
|
591
628
|
leaseReleased,
|
|
629
|
+
localCleanupDeferred,
|
|
630
|
+
directMerged,
|
|
592
631
|
waitedForMerge: true,
|
|
593
632
|
merged: waitOutcome.confirmed === true,
|
|
594
633
|
});
|
|
@@ -625,6 +664,8 @@ async function runClosePipeline({
|
|
|
625
664
|
autoMergeReason,
|
|
626
665
|
worktreeReaped,
|
|
627
666
|
leaseReleased,
|
|
667
|
+
localCleanupDeferred,
|
|
668
|
+
directMerged,
|
|
628
669
|
});
|
|
629
670
|
// `--no-wait-merge` / operator-merge: the PR is open and the human owns
|
|
630
671
|
// the land. That is a `pending` terminal by definition — the work is not
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { Logger } from '../../Logger.js';
|
|
8
|
+
import { emitTerseResult } from '../../observability/terse-result.js';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Best-effort `story.blocked` lifecycle emit. The bus is optional and emit
|
|
@@ -41,9 +42,14 @@ export async function emitBlockedCloseResult({
|
|
|
41
42
|
}) {
|
|
42
43
|
const result = { success: false, status: 'blocked', phase, reason, ...extra };
|
|
43
44
|
await emitStoryBlockedSafe({ bus, storyId, reason, logger });
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
// Story #4685 — full detail to a temp log; single summary line in its place.
|
|
46
|
+
emitTerseResult({
|
|
47
|
+
label: 'STORY CLOSE RESULT',
|
|
48
|
+
result,
|
|
49
|
+
scope: storyId,
|
|
50
|
+
summary: { storyId, status: 'blocked', phase, reason },
|
|
51
|
+
log: logger,
|
|
52
|
+
});
|
|
47
53
|
progress('BLOCKED', blockedMessage);
|
|
48
54
|
return result;
|
|
49
55
|
}
|
|
@@ -263,8 +263,11 @@ export function emitTerminalEnvelope(
|
|
|
263
263
|
envelope,
|
|
264
264
|
{ write = (s) => process.stdout.write(s) } = {},
|
|
265
265
|
) {
|
|
266
|
+
// Story #4685 — compact (not 2-space pretty) JSON. The envelope is a
|
|
267
|
+
// machine contract callers recover with `JSON.parse`, so pretty-printing
|
|
268
|
+
// only adds turn-resident bytes without helping any consumer.
|
|
266
269
|
write(
|
|
267
|
-
`\n${TERMINAL_BEGIN_MARKER}\n${JSON.stringify(envelope
|
|
270
|
+
`\n${TERMINAL_BEGIN_MARKER}\n${JSON.stringify(envelope)}\n${TERMINAL_END_MARKER}\n`,
|
|
268
271
|
);
|
|
269
272
|
}
|
|
270
273
|
|