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.
- package/.agents/README.md +18 -13
- package/.agents/audit-checklists/architecture.md +24 -0
- package/.agents/audit-checklists/clean-code.md +24 -0
- package/.agents/audit-checklists/dependencies.md +14 -0
- package/.agents/audit-checklists/devops.md +17 -0
- package/.agents/audit-checklists/documentation.md +22 -0
- package/.agents/audit-checklists/lighthouse.md +15 -0
- package/.agents/audit-checklists/navigability.md +14 -0
- package/.agents/audit-checklists/performance.md +22 -0
- package/.agents/audit-checklists/privacy.md +21 -0
- package/.agents/audit-checklists/quality.md +18 -0
- package/.agents/audit-checklists/security.md +22 -0
- package/.agents/audit-checklists/seo.md +16 -0
- package/.agents/audit-checklists/sre.md +24 -0
- package/.agents/audit-checklists/ux-ui.md +21 -0
- package/.agents/docs/SDLC.md +62 -27
- package/.agents/docs/configuration.md +5 -4
- package/.agents/instructions.md +51 -21
- package/.agents/personas/architect.md +10 -7
- package/.agents/personas/engineer.md +4 -3
- package/.agents/personas/project-manager.md +5 -2
- package/.agents/personas/refactorer.md +5 -3
- package/.agents/rules/git-conventions.md +77 -0
- package/.agents/schemas/agentrc.schema.json +10 -6
- package/.agents/schemas/audit-rules.json +16 -2
- package/.agents/schemas/audit-rules.schema.json +7 -6
- package/.agents/schemas/lifecycle/epic.blocked.schema.json +1 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +39 -0
- package/.agents/schemas/signal-event.schema.json +28 -13
- package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
- package/.agents/scripts/check-context-budget.js +320 -0
- package/.agents/scripts/coverage-capture.js +17 -0
- package/.agents/scripts/diagnose-friction.js +4 -4
- package/.agents/scripts/epic-audit-prepare.js +30 -2
- package/.agents/scripts/epic-audit-recheck.js +46 -13
- package/.agents/scripts/epic-deliver-prepare.js +80 -8
- package/.agents/scripts/epic-plan-spec.js +4 -8
- package/.agents/scripts/generate-lens-checklists.js +180 -0
- package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
- package/.agents/scripts/lib/audit-suite/findings.js +27 -0
- package/.agents/scripts/lib/audit-suite/index.js +9 -0
- package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
- package/.agents/scripts/lib/audit-suite/selector.js +136 -5
- package/.agents/scripts/lib/checks/loop-health.js +340 -0
- package/.agents/scripts/lib/cli-args.js +8 -0
- package/.agents/scripts/lib/close-validation/gates.js +64 -24
- package/.agents/scripts/lib/config/ci.js +12 -1
- package/.agents/scripts/lib/config/runners.js +13 -5
- package/.agents/scripts/lib/config/temp-paths.js +24 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -8
- package/.agents/scripts/lib/doc-tiers.js +291 -0
- package/.agents/scripts/lib/epic-body-sections.js +5 -2
- package/.agents/scripts/lib/epic-merge-lock.js +83 -0
- package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +47 -15
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
- package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
- package/.agents/scripts/lib/gates/friction.js +15 -5
- package/.agents/scripts/lib/npm-scripts.js +55 -0
- package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
- package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
- package/.agents/scripts/lib/observability/signal-validator.js +204 -0
- package/.agents/scripts/lib/observability/signals-writer.js +157 -54
- package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
- package/.agents/scripts/lib/orchestration/code-review.js +74 -4
- package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
- package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
- package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +193 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +248 -13
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +109 -12
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +46 -4
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
- package/.agents/scripts/lib/orchestration/merge-block-class.js +246 -0
- package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
- package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
- package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
- package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
- package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
- package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
- package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
- package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
- package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
- package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +8 -1
- package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
- package/.agents/scripts/lib/planning-corpus.js +306 -0
- package/.agents/scripts/lib/signals/detectors/common.js +10 -10
- package/.agents/scripts/lib/signals/detectors/index.js +4 -4
- package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
- package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
- package/.agents/scripts/lib/signals/schema.js +56 -81
- package/.agents/scripts/lib/signals/span-tree.js +6 -5
- package/.agents/scripts/lib/story-plan.js +3 -0
- package/.agents/scripts/lib/wave-runner/tick.js +10 -2
- package/.agents/scripts/lifecycle-emit.js +39 -8
- package/.agents/scripts/providers/github/issues.js +12 -1
- package/.agents/scripts/resolve-doc-tiers.js +83 -0
- package/.agents/scripts/retro-run.js +51 -0
- package/.agents/scripts/signals-view.js +1 -1
- package/.agents/scripts/single-story-close.js +20 -1
- package/.agents/scripts/standalone-feedback-rollup.js +188 -0
- package/.agents/scripts/story-close.js +48 -0
- package/.agents/scripts/story-plan.js +51 -12
- package/.agents/scripts/validate-docs-freshness.js +69 -15
- package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
- package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
- package/.agents/skills/core/scope-triage/SKILL.md +61 -0
- package/.agents/skills/skills.index.json +3 -3
- package/.agents/workflows/audit-documentation.md +82 -2
- package/.agents/workflows/helpers/code-review.md +116 -43
- package/.agents/workflows/helpers/deliver-epic.md +123 -54
- package/.agents/workflows/helpers/deliver-stories.md +26 -0
- package/.agents/workflows/helpers/epic-audit.md +116 -366
- package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
- package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
- package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
- package/.agents/workflows/helpers/plan-epic.md +141 -105
- package/.agents/workflows/helpers/plan-story.md +32 -0
- package/.agents/workflows/helpers/single-story-deliver.md +43 -0
- package/.agents/workflows/loops/nightly-audit.md +9 -7
- package/docs/CHANGELOG.md +29 -0
- package/lib/cli/doctor.js +44 -0
- package/package.json +4 -3
- package/.agents/scripts/epic-plan-spec-validate.js +0 -111
- package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -224
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
- package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
|
@@ -0,0 +1,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
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
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
|
|
212
|
-
//
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 = [];
|