mandrel 1.88.0 → 1.90.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 (145) hide show
  1. package/.agents/README.md +18 -13
  2. package/.agents/audit-checklists/architecture.md +24 -0
  3. package/.agents/audit-checklists/clean-code.md +24 -0
  4. package/.agents/audit-checklists/dependencies.md +14 -0
  5. package/.agents/audit-checklists/devops.md +17 -0
  6. package/.agents/audit-checklists/documentation.md +22 -0
  7. package/.agents/audit-checklists/lighthouse.md +15 -0
  8. package/.agents/audit-checklists/navigability.md +14 -0
  9. package/.agents/audit-checklists/performance.md +22 -0
  10. package/.agents/audit-checklists/privacy.md +21 -0
  11. package/.agents/audit-checklists/quality.md +18 -0
  12. package/.agents/audit-checklists/security.md +22 -0
  13. package/.agents/audit-checklists/seo.md +16 -0
  14. package/.agents/audit-checklists/sre.md +24 -0
  15. package/.agents/audit-checklists/ux-ui.md +21 -0
  16. package/.agents/docs/SDLC.md +62 -27
  17. package/.agents/docs/configuration.md +5 -4
  18. package/.agents/instructions.md +51 -21
  19. package/.agents/personas/architect.md +10 -7
  20. package/.agents/personas/engineer.md +4 -3
  21. package/.agents/personas/project-manager.md +5 -2
  22. package/.agents/personas/refactorer.md +5 -3
  23. package/.agents/rules/git-conventions.md +77 -0
  24. package/.agents/schemas/agentrc.schema.json +10 -6
  25. package/.agents/schemas/audit-rules.json +16 -2
  26. package/.agents/schemas/audit-rules.schema.json +7 -6
  27. package/.agents/schemas/lifecycle/epic.blocked.schema.json +1 -1
  28. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +39 -0
  29. package/.agents/schemas/signal-event.schema.json +28 -13
  30. package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
  31. package/.agents/scripts/check-context-budget.js +320 -0
  32. package/.agents/scripts/coverage-capture.js +17 -0
  33. package/.agents/scripts/diagnose-friction.js +4 -4
  34. package/.agents/scripts/epic-audit-prepare.js +30 -2
  35. package/.agents/scripts/epic-audit-recheck.js +46 -13
  36. package/.agents/scripts/epic-deliver-prepare.js +80 -8
  37. package/.agents/scripts/epic-plan-spec.js +4 -8
  38. package/.agents/scripts/generate-lens-checklists.js +180 -0
  39. package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
  40. package/.agents/scripts/lib/audit-suite/findings.js +27 -0
  41. package/.agents/scripts/lib/audit-suite/index.js +9 -0
  42. package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
  43. package/.agents/scripts/lib/audit-suite/selector.js +136 -5
  44. package/.agents/scripts/lib/checks/loop-health.js +340 -0
  45. package/.agents/scripts/lib/cli-args.js +8 -0
  46. package/.agents/scripts/lib/close-validation/gates.js +64 -24
  47. package/.agents/scripts/lib/config/ci.js +12 -1
  48. package/.agents/scripts/lib/config/runners.js +13 -5
  49. package/.agents/scripts/lib/config/temp-paths.js +24 -0
  50. package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -8
  51. package/.agents/scripts/lib/doc-tiers.js +291 -0
  52. package/.agents/scripts/lib/epic-body-sections.js +5 -2
  53. package/.agents/scripts/lib/epic-merge-lock.js +83 -0
  54. package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
  55. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +47 -15
  56. package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
  57. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
  58. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
  59. package/.agents/scripts/lib/gates/friction.js +15 -5
  60. package/.agents/scripts/lib/npm-scripts.js +55 -0
  61. package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
  62. package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
  63. package/.agents/scripts/lib/observability/signal-validator.js +204 -0
  64. package/.agents/scripts/lib/observability/signals-writer.js +157 -54
  65. package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
  66. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
  67. package/.agents/scripts/lib/orchestration/code-review.js +74 -4
  68. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
  69. package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
  70. package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
  71. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
  72. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
  73. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +193 -0
  74. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
  75. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +248 -13
  76. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +109 -12
  77. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
  78. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +46 -4
  79. package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
  80. package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
  81. package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
  82. package/.agents/scripts/lib/orchestration/merge-block-class.js +246 -0
  83. package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
  84. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
  85. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
  86. package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
  87. package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
  88. package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
  89. package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -1
  91. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
  92. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
  93. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
  94. package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
  95. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
  96. package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
  97. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
  98. package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
  99. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
  100. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +8 -1
  101. package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
  102. package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
  103. package/.agents/scripts/lib/planning-corpus.js +306 -0
  104. package/.agents/scripts/lib/signals/detectors/common.js +10 -10
  105. package/.agents/scripts/lib/signals/detectors/index.js +4 -4
  106. package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
  107. package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
  108. package/.agents/scripts/lib/signals/schema.js +56 -81
  109. package/.agents/scripts/lib/signals/span-tree.js +6 -5
  110. package/.agents/scripts/lib/story-plan.js +3 -0
  111. package/.agents/scripts/lib/wave-runner/tick.js +10 -2
  112. package/.agents/scripts/lifecycle-emit.js +39 -8
  113. package/.agents/scripts/providers/github/issues.js +12 -1
  114. package/.agents/scripts/resolve-doc-tiers.js +83 -0
  115. package/.agents/scripts/retro-run.js +51 -0
  116. package/.agents/scripts/signals-view.js +1 -1
  117. package/.agents/scripts/single-story-close.js +20 -1
  118. package/.agents/scripts/standalone-feedback-rollup.js +188 -0
  119. package/.agents/scripts/story-close.js +48 -0
  120. package/.agents/scripts/story-plan.js +51 -12
  121. package/.agents/scripts/validate-docs-freshness.js +69 -15
  122. package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
  123. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  124. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
  125. package/.agents/skills/core/scope-triage/SKILL.md +61 -0
  126. package/.agents/skills/skills.index.json +3 -3
  127. package/.agents/workflows/audit-documentation.md +82 -2
  128. package/.agents/workflows/helpers/code-review.md +116 -43
  129. package/.agents/workflows/helpers/deliver-epic.md +123 -54
  130. package/.agents/workflows/helpers/deliver-stories.md +26 -0
  131. package/.agents/workflows/helpers/epic-audit.md +116 -366
  132. package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
  133. package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
  134. package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
  135. package/.agents/workflows/helpers/plan-epic.md +141 -105
  136. package/.agents/workflows/helpers/plan-story.md +32 -0
  137. package/.agents/workflows/helpers/single-story-deliver.md +43 -0
  138. package/.agents/workflows/loops/nightly-audit.md +9 -7
  139. package/docs/CHANGELOG.md +29 -0
  140. package/lib/cli/doctor.js +44 -0
  141. package/package.json +4 -3
  142. package/.agents/scripts/epic-plan-spec-validate.js +0 -111
  143. package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -224
  144. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
  145. package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
@@ -0,0 +1,193 @@
1
+ /**
2
+ * emit-merge-unlanded.js — Story #4426 (Epic #4425, slice 1: foundation).
3
+ *
4
+ * Programmatic helper that appends a single `merge.unlanded` NDJSON
5
+ * record to the lifecycle ledger whenever a headless delivery run
6
+ * finishes its work without a confirmed merge. Pattern mirrors
7
+ * `emit-story-heartbeat.js`: direct schema validation via Ajv followed by
8
+ * a synchronous `appendFileSync` — this event is NOT routed through the
9
+ * bus (unlike `emit-loop-tick.js`) because it can fire from either the
10
+ * epic-path finalize flow (which already owns an Epic-scoped bus
11
+ * instance for its own run) or the standalone `single-story-close` flow,
12
+ * which has no bus at all. A bare append keeps both call sites simple
13
+ * and dependency-free.
14
+ *
15
+ * Ledger destination is scope-driven (Story #4426 AC4):
16
+ * - `scope: 'epic'` → `epicLedgerPath(ticketId)` — the same
17
+ * `temp/epic-<id>/lifecycle.ndjson` every other Epic-scoped event
18
+ * lands in. `ticketId` is the epicId.
19
+ * - `scope: 'story'` → `storyLedgerPath(null, ticketId)` — the
20
+ * standalone story-scope destination
21
+ * `temp/standalone/stories/story-<id>/lifecycle.ndjson`. `ticketId`
22
+ * is the storyId. The standalone `single-story-close` path has no
23
+ * parent Epic to anchor a `temp/epic-<id>/` directory to, mirroring
24
+ * the `eid === null` standalone convention `signalsFile` already
25
+ * uses for Story-level signals.
26
+ *
27
+ * A caller may always override the destination via `ledgerPath` (tests,
28
+ * or a future caller with a non-default temp layout).
29
+ *
30
+ * Distinct from:
31
+ * - `epic.merge.blocked` — AutomergePredicate's "not safe to arm yet"
32
+ * signal, evaluated BEFORE arming. `merge.unlanded` fires AFTER a
33
+ * delivery flow has already finished trying and gives up.
34
+ * - `epic.blocked` / `story.blocked` — the generic `agent::blocked`
35
+ * transition signal. `merge.unlanded` is the merge-specific
36
+ * diagnosis a `*.blocked` transition is typically paired with, not a
37
+ * replacement for it.
38
+ *
39
+ * The emit is best-effort in the sense that a failure to append MUST NOT
40
+ * mask the underlying blocked-state transition the caller is already
41
+ * driving — callers should treat this the same way
42
+ * `emitStoryHeartbeat` documents: catch, log, and proceed with the label
43
+ * flip / friction comment regardless.
44
+ *
45
+ * Schema contract (merge.unlanded.schema.json):
46
+ * { event, scope, ticketId, prNumber, blockClass, reason,
47
+ * elapsedSeconds, timestamp? }
48
+ *
49
+ * The schema declares `additionalProperties: false`, so this emitter's
50
+ * signature is deliberately narrow: only the schema-allowed fields are
51
+ * accepted. `blockClass` MUST be a valid `merge.unlanded` attribution from
52
+ * `merge-block-class.js` (`MERGE_UNLANDED_BLOCK_CLASSES` — the four
53
+ * `classifyMergeBlock` outputs plus the directly-emitted `predicate-refused`,
54
+ * Story #4472). For a post-arm poll-exhaustion block, pass the classifier's
55
+ * verdict straight through (`classifyMergeBlock(...)` returns
56
+ * `{ blockClass, reason }`); the predicate/armer refusal paths pass
57
+ * `predicate-refused` / a classified arm failure directly.
58
+ */
59
+
60
+ import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
61
+ import path from 'node:path';
62
+ import { fileURLToPath } from 'node:url';
63
+
64
+ import Ajv2020 from 'ajv/dist/2020.js';
65
+ import addFormats from 'ajv-formats';
66
+
67
+ import { epicLedgerPath, storyLedgerPath } from '../../config/temp-paths.js';
68
+ import { isValidBlockClass } from '../merge-block-class.js';
69
+
70
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
71
+ const SCHEMA_PATH = path.resolve(
72
+ __dirname,
73
+ '..',
74
+ '..',
75
+ '..',
76
+ '..',
77
+ 'schemas',
78
+ 'lifecycle',
79
+ 'merge.unlanded.schema.json',
80
+ );
81
+
82
+ const VALID_SCOPES = new Set(['epic', 'story']);
83
+
84
+ let _validator;
85
+
86
+ function getValidator() {
87
+ if (_validator) return _validator;
88
+ const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8'));
89
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
90
+ addFormats(ajv);
91
+ _validator = ajv.compile(schema);
92
+ return _validator;
93
+ }
94
+
95
+ /**
96
+ * Append exactly one `merge.unlanded` NDJSON record to the resolved
97
+ * lifecycle ledger.
98
+ *
99
+ * @param {object} opts
100
+ * @param {'epic'|'story'} opts.scope Which delivery path is reporting the
101
+ * unlanded merge.
102
+ * @param {number} opts.ticketId epicId when `scope === 'epic'`,
103
+ * storyId when `scope === 'story'`.
104
+ * @param {number} opts.prNumber The PR number that did not land.
105
+ * @param {string} opts.blockClass A valid `merge.unlanded` attribution
106
+ * (`MERGE_UNLANDED_BLOCK_CLASSES` in
107
+ * `merge-block-class.js`).
108
+ * @param {string} opts.reason Free-form diagnosis detail — pass
109
+ * the classifier's `reason`.
110
+ * @param {number} opts.elapsedSeconds Elapsed watch/poll time when the
111
+ * run gave up.
112
+ * @param {string} [opts.timestamp] ISO-8601 wall clock. Defaults to
113
+ * now().
114
+ * @param {object} [opts.config] Optional resolved config for
115
+ * tempRoot.
116
+ * @param {string} [opts.ledgerPath] Override for tests / non-default
117
+ * layouts.
118
+ * @returns {{ ledgerPath: string, record: object }}
119
+ */
120
+ export function emitMergeUnlanded(opts) {
121
+ const {
122
+ scope,
123
+ ticketId,
124
+ prNumber,
125
+ blockClass,
126
+ reason,
127
+ elapsedSeconds,
128
+ timestamp = new Date().toISOString(),
129
+ config,
130
+ ledgerPath: ledgerPathOverride,
131
+ } = opts ?? {};
132
+
133
+ if (!VALID_SCOPES.has(scope)) {
134
+ throw new Error(
135
+ `emitMergeUnlanded: scope "${scope}" must be one of: ${[...VALID_SCOPES].join(', ')}`,
136
+ );
137
+ }
138
+ if (!Number.isInteger(ticketId) || ticketId < 1) {
139
+ throw new Error('emitMergeUnlanded: ticketId must be a positive integer');
140
+ }
141
+ if (!Number.isInteger(prNumber) || prNumber < 1) {
142
+ throw new Error('emitMergeUnlanded: prNumber must be a positive integer');
143
+ }
144
+ if (!isValidBlockClass(blockClass)) {
145
+ throw new Error(
146
+ `emitMergeUnlanded: blockClass "${blockClass}" is not a recognised merge-block-class value`,
147
+ );
148
+ }
149
+ if (typeof reason !== 'string' || reason.length === 0) {
150
+ throw new Error('emitMergeUnlanded: reason must be a non-empty string');
151
+ }
152
+ if (typeof elapsedSeconds !== 'number' || elapsedSeconds < 0) {
153
+ throw new Error(
154
+ 'emitMergeUnlanded: elapsedSeconds must be a non-negative number',
155
+ );
156
+ }
157
+
158
+ const payload = {
159
+ event: 'merge.unlanded',
160
+ scope,
161
+ ticketId,
162
+ prNumber,
163
+ blockClass,
164
+ reason,
165
+ elapsedSeconds,
166
+ timestamp,
167
+ };
168
+
169
+ const validator = getValidator();
170
+ if (!validator(payload)) {
171
+ const detail = (validator.errors ?? [])
172
+ .map((e) => `${e.instancePath || '/'} ${e.message}`)
173
+ .join('; ');
174
+ throw new Error(
175
+ `emitMergeUnlanded: payload failed schema validation: ${detail}`,
176
+ );
177
+ }
178
+
179
+ const ledgerPath =
180
+ ledgerPathOverride ??
181
+ (scope === 'epic'
182
+ ? epicLedgerPath(ticketId, config)
183
+ : storyLedgerPath(null, ticketId, config));
184
+ mkdirSync(path.dirname(ledgerPath), { recursive: true });
185
+ const record = {
186
+ kind: 'emitted',
187
+ ts: timestamp,
188
+ event: 'merge.unlanded',
189
+ payload,
190
+ };
191
+ appendFileSync(ledgerPath, `${JSON.stringify(record)}\n`, 'utf8');
192
+ return { ledgerPath, record };
193
+ }
@@ -22,6 +22,12 @@ entrypoint the standalone `lifecycle-emit.js` CLI shells in for
22
22
  the PR's `mergeCommit` is observed, then emits `epic.merge.confirmed`.
23
23
  - `cleaner.js` — archives `temp/epic-<id>/` and emits the terminal
24
24
  `epic.cleanup.* → epic.complete` sequence on `epic.merge.confirmed`.
25
+ - `label-transitioner.js` — flips the Epic ticket to `agent::done` (and
26
+ closes it as completed, idempotently) on `epic.complete`. Skipped when
27
+ no provider is wired. Re-homed onto this chain after the 2026-07-11
28
+ incident (merged Epics stranding at `agent::executing`): the original
29
+ LabelTransitioner died with the in-process runner stratum (#3936) and
30
+ the flip had no owner.
25
31
  - `checkpoint-pointer-writer.js` — persists `{ lastCompletedSeqId, phase }`
26
32
  on every `*.end` event.
27
33
 
@@ -54,7 +54,10 @@
54
54
 
55
55
  import { spawnSync } from 'node:child_process';
56
56
 
57
+ import { parsePrNumberFromUrl } from '../../../github-url.js';
57
58
  import { resolveAutoMergeArmCwd } from '../../auto-merge-cwd.js';
59
+ import { classifyMergeBlock } from '../../merge-block-class.js';
60
+ import { emitMergeUnlanded } from '../emit-merge-unlanded.js';
58
61
 
59
62
  /**
60
63
  * Default `gh pr view --json autoMergeRequest` probe. Pure-spawn helper
@@ -64,7 +67,7 @@ import { resolveAutoMergeArmCwd } from '../../auto-merge-cwd.js';
64
67
  export function ghPrViewAutoMerge({ prUrl, cwd, spawnFn = spawnSync }) {
65
68
  const result = spawnFn(
66
69
  'gh',
67
- ['pr', 'view', prUrl, '--json', 'autoMergeRequest'],
70
+ ['pr', 'view', prUrl, '--json', 'autoMergeRequest,mergeCommit'],
68
71
  { cwd, encoding: 'utf-8', shell: false },
69
72
  );
70
73
  return {
@@ -110,9 +113,68 @@ export function ghPrMergeAuto({
110
113
  }
111
114
 
112
115
  /**
113
- * Pure: parse `gh pr view --json autoMergeRequest` output. The field
114
- * is `null` when auto-merge is NOT armed; a non-null object means a
115
- * prior arm is in place. Returns `true` iff already armed.
116
+ * Story #4472 — direct (non-`--auto`) squash-merge fallback.
117
+ *
118
+ * GitHub's native auto-merge (`gh pr merge --auto`) can only be QUEUED on a
119
+ * repository that has the "Allow auto-merge" setting enabled — which in
120
+ * practice requires branch protection. A repo with zero required checks and
121
+ * no branch protection (every mandrel-bench sandbox, many real consumer
122
+ * repos) rejects the `--auto` arm outright with `Auto merge is not allowed
123
+ * for this repository`. The AutomergePredicate has already cleared the merge
124
+ * (green/absent required checks + a clean structured-signal verdict) by the
125
+ * time the armer runs, so the safe, must-land-satisfying fallback is a
126
+ * direct immediate squash-merge — the epic path's observed de-facto manual
127
+ * fallback, made legal and kept inside the sole authorized `gh pr merge`
128
+ * call site.
129
+ *
130
+ * Same `--squash --delete-branch` shape and same `resolveArmCwd` re-point as
131
+ * `ghPrMergeAuto` (so the trailing local `--delete-branch` housekeeping runs
132
+ * from the primary worktree, not a head-branch worktree). Omitting `--auto`
133
+ * makes `gh` merge synchronously.
134
+ */
135
+ export function ghPrMergeDirect({
136
+ prUrl,
137
+ cwd,
138
+ spawnFn = spawnSync,
139
+ resolveArmCwd = resolveAutoMergeArmCwd,
140
+ }) {
141
+ const armCwd = resolveArmCwd(cwd);
142
+ const result = spawnFn(
143
+ 'gh',
144
+ ['pr', 'merge', prUrl, '--squash', '--delete-branch'],
145
+ { cwd: armCwd, encoding: 'utf-8', shell: false },
146
+ );
147
+ return {
148
+ status: result.status ?? 1,
149
+ stdout: result.stdout ?? '',
150
+ stderr: result.stderr ?? '',
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Pure: does this `gh pr merge --auto` stderr indicate that native
156
+ * auto-merge is unavailable on the repository (as opposed to a genuine arm
157
+ * failure — auth, a merge conflict, an already-merged race)? Only this
158
+ * specific class of failure is safe to retry as a direct merge; everything
159
+ * else must surface as a real failure. Matched case-insensitively.
160
+ *
161
+ * Exported so the marker set is reviewable and testable in isolation.
162
+ */
163
+ export function isAutoMergeUnavailable(stderr) {
164
+ const text = String(stderr ?? '').toLowerCase();
165
+ return (
166
+ text.includes('auto merge is not allowed') ||
167
+ text.includes('auto-merge is not allowed') ||
168
+ text.includes('enablepullrequestautomerge') ||
169
+ (text.includes('auto') && text.includes('not enabled'))
170
+ );
171
+ }
172
+
173
+ /**
174
+ * Pure: parse `gh pr view --json autoMergeRequest,mergeCommit` output.
175
+ * `autoMergeRequest` is `null` when auto-merge is NOT armed; a non-null
176
+ * object means a prior arm is in place. Returns `true` iff already
177
+ * armed.
116
178
  *
117
179
  * Exported for tests so the JSON shape pin is reviewable.
118
180
  */
@@ -134,6 +196,36 @@ export function parseAutoMergeArmed(stdout) {
134
196
  }
135
197
  }
136
198
 
199
+ /**
200
+ * Pure: parse the same probe output for a non-null `mergeCommit` — the
201
+ * PR has ALREADY merged. `gh pr merge --auto` on an already-green PR
202
+ * merges immediately (there is nothing to queue behind), and its exit
203
+ * code then reflects the post-merge local housekeeping: on 2026-07-11
204
+ * (Epic #4454, PR #4459) the merge itself succeeded but the trailing
205
+ * local `--delete-branch` failed because a harness worktree held
206
+ * `epic/4454`, so `gh` exited 1 and the armer misreported a successful
207
+ * merge as `arm-failed` — stranding the run at `agent::blocked` with
208
+ * the whole must-land chain (MergeWatcher → Cleaner → LabelTransitioner)
209
+ * never engaging. The arm-failure re-probe below uses this parser to
210
+ * distinguish "merge landed, housekeeping grumbled" from a genuine arm
211
+ * failure.
212
+ */
213
+ export function parsePrMerged(stdout) {
214
+ const trimmed = String(stdout ?? '').trim();
215
+ if (trimmed.length === 0) return false;
216
+ try {
217
+ const parsed = JSON.parse(trimmed);
218
+ if (!parsed || typeof parsed !== 'object') return false;
219
+ return (
220
+ parsed.mergeCommit !== null &&
221
+ parsed.mergeCommit !== undefined &&
222
+ typeof parsed.mergeCommit === 'object'
223
+ );
224
+ } catch {
225
+ return false;
226
+ }
227
+ }
228
+
137
229
  /**
138
230
  * AutomergeArmer listener.
139
231
  */
@@ -141,9 +233,17 @@ export class AutomergeArmer {
141
233
  /**
142
234
  * @param {object} opts
143
235
  * @param {object} opts.bus
236
+ * @param {number} [opts.epicId] Epic id — required for the headless
237
+ * `merge.unlanded` attribution on a genuine arm failure (Story #4472).
238
+ * @param {boolean} [opts.headless] When true (a `/deliver --yes` run), a
239
+ * genuine (non-fallback) arm failure escalates to an explicit
240
+ * `merge.unlanded` + `epic.blocked` terminal instead of returning
241
+ * silently (Story #4472). Defaults to `false` (attended).
144
242
  * @param {string} [opts.cwd]
145
243
  * @param {Function} [opts.ghPrViewAutoMergeFn] override for tests.
146
244
  * @param {Function} [opts.ghPrMergeAutoFn] override for tests.
245
+ * @param {Function} [opts.ghPrMergeDirectFn] override for tests.
246
+ * @param {Function} [opts.emitMergeUnlandedFn] override for tests.
147
247
  * @param {{ info?: Function, warn?: Function, debug?: Function }} [opts.logger]
148
248
  */
149
249
  constructor(opts = {}) {
@@ -155,9 +255,13 @@ export class AutomergeArmer {
155
255
  throw new TypeError('AutomergeArmer requires a bus with on() and emit()');
156
256
  }
157
257
  this.bus = opts.bus;
258
+ this.epicId = Number.isInteger(opts.epicId) ? opts.epicId : null;
259
+ this.headless = opts.headless === true;
158
260
  this.cwd = opts.cwd ?? process.cwd();
159
261
  this.ghPrViewAutoMergeFn = opts.ghPrViewAutoMergeFn ?? ghPrViewAutoMerge;
160
262
  this.ghPrMergeAutoFn = opts.ghPrMergeAutoFn ?? ghPrMergeAuto;
263
+ this.ghPrMergeDirectFn = opts.ghPrMergeDirectFn ?? ghPrMergeDirect;
264
+ this.emitMergeUnlandedFn = opts.emitMergeUnlandedFn ?? emitMergeUnlanded;
161
265
  this.logger = opts.logger ?? console;
162
266
  /** @type {Set<string>} `${event}:${seqId}` idempotency cache. */
163
267
  this._seen = new Set();
@@ -208,13 +312,17 @@ export class AutomergeArmer {
208
312
  }
209
313
 
210
314
  // Layer 2 idempotency — cross-process probe. If auto-merge is
211
- // already armed on the PR, emit `epic.merge.armed` and bail
212
- // without re-issuing the merge command.
315
+ // already armed on the PR (or the PR already merged — a prior arm
316
+ // completed the merge before this run re-entered), emit
317
+ // `epic.merge.armed` and bail without re-issuing the merge command.
213
318
  const probe = this.ghPrViewAutoMergeFn({ prUrl, cwd: this.cwd });
214
- if (probe.status === 0 && parseAutoMergeArmed(probe.stdout)) {
319
+ if (
320
+ probe.status === 0 &&
321
+ (parseAutoMergeArmed(probe.stdout) || parsePrMerged(probe.stdout))
322
+ ) {
215
323
  this.classifications.push({ event, seqId, outcome: 'existing', prUrl });
216
324
  this.logger.info?.(
217
- `[AutomergeArmer] auto-merge already armed on ${prUrl} — short-circuiting.`,
325
+ `[AutomergeArmer] auto-merge already armed (or PR already merged) on ${prUrl} — short-circuiting.`,
218
326
  );
219
327
  await this._emitArmed(prUrl);
220
328
  return;
@@ -232,16 +340,52 @@ export class AutomergeArmer {
232
340
  // entire codebase (see check-lifecycle-lint.js).
233
341
  const arm = this.ghPrMergeAutoFn({ prUrl, cwd: this.cwd });
234
342
  if (arm.status !== 0) {
235
- this.classifications.push({
343
+ // A non-zero arm exit does NOT necessarily mean the arm failed:
344
+ // `gh pr merge --auto --squash --delete-branch` on an
345
+ // already-green PR merges immediately and then runs local
346
+ // branch-delete housekeeping whose failure (e.g. the epic branch
347
+ // held by another worktree) surfaces as exit 1 AFTER the merge
348
+ // landed (2026-07-11 incident, Epic #4454 / PR #4459). Re-probe
349
+ // before classifying: merged or armed → proceed as success so the
350
+ // MergeWatcher → Cleaner → LabelTransitioner chain engages.
351
+ const recheck = this.ghPrViewAutoMergeFn({ prUrl, cwd: this.cwd });
352
+ if (
353
+ recheck.status === 0 &&
354
+ (parsePrMerged(recheck.stdout) || parseAutoMergeArmed(recheck.stdout))
355
+ ) {
356
+ this.classifications.push({
357
+ event,
358
+ seqId,
359
+ outcome: 'armed',
360
+ prUrl,
361
+ note: `arm exit ${arm.status} but re-probe shows merged/armed (post-merge housekeeping failure): ${arm.stderr}`,
362
+ });
363
+ this.logger.warn?.(
364
+ `[AutomergeArmer] gh pr merge exited ${arm.status} but the PR is merged/armed — treating as success (housekeeping stderr: ${arm.stderr})`,
365
+ );
366
+ await this._emitArmed(prUrl);
367
+ return;
368
+ }
369
+
370
+ // Story #4472 — native auto-merge is unavailable on this repository
371
+ // (no branch protection / "Allow auto-merge" disabled). The predicate
372
+ // already cleared the merge, so fall back to a direct immediate
373
+ // squash-merge instead of stranding a landable PR on the
374
+ // operator-merges path.
375
+ if (isAutoMergeUnavailable(arm.stderr)) {
376
+ const armed = await this._tryDirectMerge({ event, seqId, prUrl, arm });
377
+ if (armed) return;
378
+ }
379
+
380
+ // Genuine arm failure (auth, conflict, an unresolved direct-merge
381
+ // fallback, …). Classify + escalate.
382
+ await this._emitArmFailure({
236
383
  event,
237
384
  seqId,
238
- outcome: 'failed',
385
+ prUrl,
239
386
  reason: `arm-failed:status=${arm.status}`,
240
387
  ghStderr: arm.stderr,
241
388
  });
242
- this.logger.warn?.(
243
- `[AutomergeArmer] gh pr merge --auto failed (status=${arm.status}): ${arm.stderr}`,
244
- );
245
389
  return;
246
390
  }
247
391
 
@@ -259,6 +403,97 @@ export class AutomergeArmer {
259
403
  }
260
404
  }
261
405
 
406
+ /**
407
+ * Story #4472 — direct-merge fallback when native auto-merge is
408
+ * unavailable. Runs an immediate `gh pr merge --squash --delete-branch`
409
+ * (no `--auto`) then re-probes; on a confirmed merge (or the same
410
+ * post-merge `--delete-branch` housekeeping grumble the `--auto` path
411
+ * already tolerates) it emits `epic.merge.armed` so the
412
+ * MergeWatcher → Cleaner → LabelTransitioner chain engages and confirms
413
+ * the merge on its first poll.
414
+ *
415
+ * @returns {Promise<boolean>} `true` when the fallback landed the PR (an
416
+ * `epic.merge.armed` was emitted); `false` when the direct merge did
417
+ * not land, so the caller escalates the original arm failure.
418
+ */
419
+ async _tryDirectMerge({ event, seqId, prUrl, arm }) {
420
+ this.logger.info?.(
421
+ `[AutomergeArmer] native auto-merge unavailable (${arm.stderr?.trim?.() ?? arm.stderr}); falling back to a direct squash-merge on ${prUrl}.`,
422
+ );
423
+ const direct = this.ghPrMergeDirectFn({ prUrl, cwd: this.cwd });
424
+ const recheck = this.ghPrViewAutoMergeFn({ prUrl, cwd: this.cwd });
425
+ const merged =
426
+ recheck.status === 0 &&
427
+ (parsePrMerged(recheck.stdout) || parseAutoMergeArmed(recheck.stdout));
428
+ if (direct.status === 0 || merged) {
429
+ this.classifications.push({
430
+ event,
431
+ seqId,
432
+ outcome: 'armed',
433
+ prUrl,
434
+ note: `direct-merge fallback (native auto-merge unavailable); direct exit ${direct.status}${direct.status !== 0 ? ` but re-probe shows merged/armed (housekeeping stderr: ${direct.stderr})` : ''}`,
435
+ });
436
+ await this._emitArmed(prUrl);
437
+ return true;
438
+ }
439
+ this.logger.warn?.(
440
+ `[AutomergeArmer] direct-merge fallback failed (status=${direct.status}): ${direct.stderr}`,
441
+ );
442
+ return false;
443
+ }
444
+
445
+ /**
446
+ * Classify + (in headless) escalate a genuine arm failure. The `--auto`
447
+ * path historically returned silently here; a `/deliver --yes` run has no
448
+ * operator to notice, so we mirror the MergeWatcher's terminal:
449
+ * `merge.unlanded` ledger attribution + an explicit `epic.blocked`
450
+ * transition. Attended runs keep the classify-and-return behaviour.
451
+ */
452
+ async _emitArmFailure({ event, seqId, prUrl, reason, ghStderr }) {
453
+ this.classifications.push({
454
+ event,
455
+ seqId,
456
+ outcome: 'failed',
457
+ reason,
458
+ ghStderr,
459
+ });
460
+ this.logger.warn?.(
461
+ `[AutomergeArmer] gh pr merge --auto failed (${reason}): ${ghStderr}`,
462
+ );
463
+ if (!this.headless) return;
464
+ const classification = classifyMergeBlock({
465
+ armResult: { armed: false, reason: ghStderr },
466
+ });
467
+ const prNumber = parsePrNumberFromUrl(prUrl);
468
+ if (
469
+ Number.isInteger(this.epicId) &&
470
+ Number.isInteger(prNumber) &&
471
+ prNumber > 0
472
+ ) {
473
+ try {
474
+ this.emitMergeUnlandedFn({
475
+ scope: 'epic',
476
+ ticketId: this.epicId,
477
+ prNumber,
478
+ blockClass: classification.blockClass,
479
+ reason: classification.reason,
480
+ elapsedSeconds: 0,
481
+ });
482
+ } catch (err) {
483
+ this.logger.warn?.(
484
+ `[AutomergeArmer] emitMergeUnlanded failed (swallowed): ${err?.message ?? err}`,
485
+ );
486
+ }
487
+ }
488
+ try {
489
+ await this.bus.emit('epic.blocked', { reason: `merge-arm:failed` });
490
+ } catch (err) {
491
+ this.logger.warn?.(
492
+ `[AutomergeArmer] epic.blocked emit on arm failure failed (swallowed): ${err?.message ?? err}`,
493
+ );
494
+ }
495
+ }
496
+
262
497
  reset() {
263
498
  this._seen.clear();
264
499
  this.classifications = [];