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.
Files changed (27) hide show
  1. package/.agents/docs/configuration.md +26 -0
  2. package/.agents/schemas/agentrc.schema.json +21 -0
  3. package/.agents/scripts/audit-to-stories.js +51 -0
  4. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +120 -55
  5. package/.agents/scripts/lib/config-settings-schema.js +32 -0
  6. package/.agents/scripts/lib/findings/semantic-issue-search.js +43 -5
  7. package/.agents/scripts/lib/observability/terse-result.js +114 -0
  8. package/.agents/scripts/lib/orchestration/complexity-gate.js +207 -0
  9. package/.agents/scripts/lib/orchestration/plan-context.js +3 -0
  10. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +221 -8
  11. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +55 -14
  12. package/.agents/scripts/lib/orchestration/story-close/emit-blocked.js +9 -3
  13. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -1
  14. package/.agents/scripts/lib/orchestration/task-body-validator.js +13 -40
  15. package/.agents/scripts/lib/story-body/body-format-lints.js +215 -0
  16. package/.agents/scripts/lib/story-body/story-body.js +18 -2
  17. package/.agents/scripts/lib/templates/decomposer-prompts.js +16 -0
  18. package/.agents/scripts/providers/github/issues.js +54 -7
  19. package/.agents/scripts/providers/github/search-budget.js +124 -0
  20. package/.agents/scripts/providers/github/search-query.js +71 -0
  21. package/.agents/scripts/single-story-confirm-merge.js +14 -5
  22. package/.agents/scripts/single-story-init.js +19 -3
  23. package/.agents/scripts/sync-branch-from-base.js +9 -3
  24. package/.agents/workflows/helpers/deliver-story.md +10 -0
  25. package/.agents/workflows/plan.md +27 -2
  26. package/docs/CHANGELOG.md +20 -0
  27. 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
- return {
93
- enabled: false,
94
- reason: `gh-exit-${result.status}: ${(result.stderr ?? '').trim().slice(0, 200)}`,
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 { autoMergeEnabled: true, autoMergeReason: null };
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 { autoMergeEnabled: false, autoMergeReason: result.reason };
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
- // The human-facing result dump stays level-gated; the terminal envelope is
57
- // the machine contract and must survive AGENT_LOG_LEVEL=silent.
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
- Logger.info(
60
- `\n--- STORY CLOSE RESULT ---\n${JSON.stringify(result, null, 2)}\n--- END RESULT ---\n`,
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 { autoMergeEnabled, autoMergeReason } = await runAutoMergePhase({
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
- logger.info?.(
45
- `\n--- STORY CLOSE RESULT ---\n${JSON.stringify(result, null, 2)}\n--- END RESULT ---\n`,
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, null, 2)}\n${TERMINAL_END_MARKER}\n`,
270
+ `\n${TERMINAL_BEGIN_MARKER}\n${JSON.stringify(envelope)}\n${TERMINAL_END_MARKER}\n`,
268
271
  );
269
272
  }
270
273