mandrel 1.85.0 → 1.87.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/instructions.md +7 -0
- package/.agents/rules/git-conventions.md +45 -0
- package/.agents/scripts/boot-sweep.js +215 -0
- package/.agents/scripts/epic-deliver-prepare.js +55 -0
- package/.agents/scripts/git-cleanup.js +8 -0
- package/.agents/scripts/lib/checks/subagent-agent-tool-required.js +107 -30
- package/.agents/scripts/lib/epic-plan-ideation.js +24 -3
- package/.agents/scripts/lib/framework-version.js +210 -0
- package/.agents/scripts/lib/orchestration/context-hydration-engine.js +7 -22
- package/.agents/scripts/lib/orchestration/epic-cleanup.js +330 -6
- package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +34 -3
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +102 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +83 -30
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +85 -1
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +34 -3
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +71 -4
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/branch-cleaner.js +8 -3
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/gate-failure.js +54 -6
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +35 -4
- package/.agents/scripts/lib/single-story-sweep/protection-ctx.js +75 -0
- package/.agents/scripts/lib/single-story-sweep.js +239 -57
- package/.agents/scripts/lib/story-body/story-body.js +81 -4
- package/.agents/scripts/providers/github/tickets.js +18 -1
- package/.agents/scripts/single-story-init.js +7 -51
- package/.agents/skills/core/epic-plan-consolidate/SKILL.md +7 -2
- package/.agents/skills/core/epic-plan-premortem/SKILL.md +8 -2
- package/.agents/skills/skills.index.json +3 -3
- package/.agents/skills/stack/architecture/subagent-orchestration/SKILL.md +36 -8
- package/.agents/workflows/git-cleanup.md +72 -18
- package/.agents/workflows/git-deliver.md +36 -0
- package/.agents/workflows/helpers/acceptance-self-eval.md +23 -1
- package/.agents/workflows/helpers/deliver-epic-reference.md +19 -13
- package/.agents/workflows/helpers/deliver-epic.md +47 -3
- package/.agents/workflows/helpers/deliver-stories.md +16 -3
- package/.agents/workflows/helpers/epic-audit.md +60 -2
- package/.agents/workflows/helpers/parallel-tooling.md +9 -2
- package/.agents/workflows/helpers/plan-epic.md +32 -14
- package/.agents/workflows/loops/nightly-audit.md +9 -1
- package/.agents/workflows/plan.md +32 -4
- package/docs/CHANGELOG.md +27 -0
- package/package.json +1 -1
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// .agents/scripts/lib/framework-version.js
|
|
2
|
+
/**
|
|
3
|
+
* framework-version.js — single source of truth for the running Mandrel
|
|
4
|
+
* framework version and the ticket-body authoring stamp.
|
|
5
|
+
*
|
|
6
|
+
* Two concerns live here:
|
|
7
|
+
*
|
|
8
|
+
* 1. **Version resolution.** Under npm distribution the root `package.json`
|
|
9
|
+
* is the canonical version marker. {@link resolveFrameworkVersion} reads it
|
|
10
|
+
* and degrades to `'unknown'` (never throws) so a missing/unreadable
|
|
11
|
+
* manifest can never crash an authoring or hydration path. The private
|
|
12
|
+
* `getVersion()` in `lib/orchestration/context-hydration-engine.js`
|
|
13
|
+
* delegates here (DRY — one manifest reader).
|
|
14
|
+
*
|
|
15
|
+
* 2. **Ticket-body stamp.** Epics and Stories are stamped **once at authoring
|
|
16
|
+
* time** with the running version and the authoring date, via a hybrid
|
|
17
|
+
* surface:
|
|
18
|
+
* - a hidden machine-readable field in the trailing
|
|
19
|
+
* `<!-- meta: {"mandrel_version":"…","authored_at":"…"} -->` block
|
|
20
|
+
* (the source of truth, queryable by tooling), and
|
|
21
|
+
* - a single visible footer line
|
|
22
|
+
* `> 🏷️ Authored with Mandrel v<version> · <YYYY-MM-DD>` so a human
|
|
23
|
+
* reading the raw GitHub issue sees the provenance without any tooling.
|
|
24
|
+
*
|
|
25
|
+
* The stamp is **immutable**: {@link stampFrameworkVersion} is a no-op when
|
|
26
|
+
* the body already carries a `mandrel_version`, so a later re-render or
|
|
27
|
+
* Epic-body edit preserves the originally-authored version verbatim rather
|
|
28
|
+
* than bumping it to whatever version happens to be running.
|
|
29
|
+
*
|
|
30
|
+
* This module imports only Node builtins so it can be pulled in from the
|
|
31
|
+
* story-body serializer, the ticket provider, and the Epic ideation renderer
|
|
32
|
+
* without risking an import cycle.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import fs from 'node:fs';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
import { fileURLToPath } from 'node:url';
|
|
38
|
+
|
|
39
|
+
/** Returned when the package manifest is absent or unreadable. */
|
|
40
|
+
export const FALLBACK_VERSION = 'unknown';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Trailing machine-metadata comment block: `<!-- meta: {...} -->`. Mirrors the
|
|
44
|
+
* regex the Story-body parser uses so both surfaces recognise the same block.
|
|
45
|
+
*/
|
|
46
|
+
const META_BLOCK_RE = /<!--\s*meta:\s*(\{[\s\S]*?\})\s*-->/;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The visible authoring marker line. A blockquote so GitHub renders it as a
|
|
50
|
+
* callout. Used both to detect an already-emitted marker (for the strip step)
|
|
51
|
+
* and, in the Story-body parser, to skip the line during section parsing so it
|
|
52
|
+
* never pollutes the last structured section.
|
|
53
|
+
*/
|
|
54
|
+
export const AUTHORED_MARKER_LINE_RE = /^\s*>\s*🏷️\s+Authored with Mandrel\b/;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Compute the default path to the root `package.json`. This module ships inside
|
|
58
|
+
* the `mandrel` package at `<pkgRoot>/.agents/scripts/lib/framework-version.js`,
|
|
59
|
+
* so the manifest sits three directories up — the same layout in the dev repo
|
|
60
|
+
* and the published tarball.
|
|
61
|
+
*
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
function defaultPkgPath() {
|
|
65
|
+
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
66
|
+
return path.resolve(moduleDir, '../../..', 'package.json');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolve the running framework version from the root `package.json`. Degrades
|
|
71
|
+
* to {@link FALLBACK_VERSION} (never throws) on any read or parse failure so a
|
|
72
|
+
* missing/unreadable manifest can never crash an authoring or hydration path.
|
|
73
|
+
*
|
|
74
|
+
* @param {{ pkgPath?: string }} [opts] - `pkgPath` override (test seam).
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
export function resolveFrameworkVersion({ pkgPath } = {}) {
|
|
78
|
+
try {
|
|
79
|
+
const resolved = typeof pkgPath === 'string' ? pkgPath : defaultPkgPath();
|
|
80
|
+
const parsed = JSON.parse(fs.readFileSync(resolved, 'utf8'));
|
|
81
|
+
return typeof parsed.version === 'string' && parsed.version.trim()
|
|
82
|
+
? parsed.version.trim()
|
|
83
|
+
: FALLBACK_VERSION;
|
|
84
|
+
} catch {
|
|
85
|
+
return FALLBACK_VERSION;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Format an authoring date as `YYYY-MM-DD` (UTC). Matches the date shape the
|
|
91
|
+
* rest of the authoring path uses (e.g. `qa-session`).
|
|
92
|
+
*
|
|
93
|
+
* @param {Date} [date=new Date()]
|
|
94
|
+
* @returns {string}
|
|
95
|
+
*/
|
|
96
|
+
export function formatAuthoredDate(date = new Date()) {
|
|
97
|
+
const d =
|
|
98
|
+
date instanceof Date && !Number.isNaN(date.getTime()) ? date : new Date();
|
|
99
|
+
return d.toISOString().slice(0, 10);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Build the visible authoring marker line for a given stamp. Centralised so
|
|
104
|
+
* the string is byte-identical across the two producers (the Story-body
|
|
105
|
+
* serializer and {@link stampFrameworkVersion}).
|
|
106
|
+
*
|
|
107
|
+
* @param {{ version: string, authoredAt: string }} stamp
|
|
108
|
+
* @returns {string}
|
|
109
|
+
*/
|
|
110
|
+
export function authoredMarkerLine({ version, authoredAt }) {
|
|
111
|
+
return `> 🏷️ Authored with Mandrel v${version} · ${authoredAt}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Read the framework stamp from a body's trailing meta block. Returns
|
|
116
|
+
* `{ version, authoredAt }` when a non-empty `mandrel_version` is present, or
|
|
117
|
+
* `null` when the body carries no stamp (or the meta block is malformed).
|
|
118
|
+
* `authoredAt` is `null` when the version is present but the date is absent.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} markdown
|
|
121
|
+
* @returns {{ version: string, authoredAt: string|null }|null}
|
|
122
|
+
*/
|
|
123
|
+
export function extractFrameworkStamp(markdown) {
|
|
124
|
+
if (typeof markdown !== 'string') return null;
|
|
125
|
+
const match = markdown.match(META_BLOCK_RE);
|
|
126
|
+
if (!match) return null;
|
|
127
|
+
let parsed;
|
|
128
|
+
try {
|
|
129
|
+
parsed = JSON.parse(match[1]);
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
const version =
|
|
137
|
+
typeof parsed.mandrel_version === 'string' && parsed.mandrel_version.trim()
|
|
138
|
+
? parsed.mandrel_version.trim()
|
|
139
|
+
: null;
|
|
140
|
+
if (version === null) return null;
|
|
141
|
+
const authoredAt =
|
|
142
|
+
typeof parsed.authored_at === 'string' && parsed.authored_at.trim()
|
|
143
|
+
? parsed.authored_at.trim()
|
|
144
|
+
: null;
|
|
145
|
+
return { version, authoredAt };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Stamp a ticket body (Epic or Story markdown) with the framework version and
|
|
150
|
+
* authoring date — **once**. When the body already carries a `mandrel_version`
|
|
151
|
+
* the body is returned verbatim (immutability: never re-derive or bump an
|
|
152
|
+
* already-authored stamp). Otherwise the version keys are merged into (or
|
|
153
|
+
* create) the trailing `<!-- meta -->` block — appended **last** so the key
|
|
154
|
+
* order stays stable with the Story-body serializer — and the visible marker
|
|
155
|
+
* line is (re)emitted just above it.
|
|
156
|
+
*
|
|
157
|
+
* The `version` / `authoredAt` overrides let a caller (e.g. the Epic edit path)
|
|
158
|
+
* preserve a previously-authored stamp; both default to the running version and
|
|
159
|
+
* today's date when omitted.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} markdown
|
|
162
|
+
* @param {{ version?: string, authoredAt?: string }} [stamp]
|
|
163
|
+
* @returns {string}
|
|
164
|
+
*/
|
|
165
|
+
export function stampFrameworkVersion(markdown, stamp = {}) {
|
|
166
|
+
const body = typeof markdown === 'string' ? markdown : '';
|
|
167
|
+
|
|
168
|
+
// Immutability: a body that already carries a version is preserved verbatim.
|
|
169
|
+
if (extractFrameworkStamp(body) !== null) return body;
|
|
170
|
+
|
|
171
|
+
const version =
|
|
172
|
+
typeof stamp?.version === 'string' && stamp.version.trim()
|
|
173
|
+
? stamp.version.trim()
|
|
174
|
+
: resolveFrameworkVersion();
|
|
175
|
+
const authoredAt =
|
|
176
|
+
typeof stamp?.authoredAt === 'string' && stamp.authoredAt.trim()
|
|
177
|
+
? stamp.authoredAt.trim()
|
|
178
|
+
: formatAuthoredDate();
|
|
179
|
+
|
|
180
|
+
// Merge into any existing (version-less) meta block, appending the version
|
|
181
|
+
// keys last for stable key order.
|
|
182
|
+
const metaMatch = body.match(META_BLOCK_RE);
|
|
183
|
+
const meta = {};
|
|
184
|
+
if (metaMatch) {
|
|
185
|
+
try {
|
|
186
|
+
const parsed = JSON.parse(metaMatch[1]);
|
|
187
|
+
if (
|
|
188
|
+
parsed !== null &&
|
|
189
|
+
typeof parsed === 'object' &&
|
|
190
|
+
!Array.isArray(parsed)
|
|
191
|
+
) {
|
|
192
|
+
Object.assign(meta, parsed);
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
// Malformed meta comment — drop it and re-emit a clean block.
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
meta.mandrel_version = version;
|
|
199
|
+
meta.authored_at = authoredAt;
|
|
200
|
+
|
|
201
|
+
// Strip any existing meta block / marker so both re-append canonically.
|
|
202
|
+
const head = body
|
|
203
|
+
.replace(META_BLOCK_RE, '')
|
|
204
|
+
.replace(new RegExp(AUTHORED_MARKER_LINE_RE.source, 'm'), '')
|
|
205
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
206
|
+
.trimEnd();
|
|
207
|
+
|
|
208
|
+
const marker = authoredMarkerLine({ version, authoredAt });
|
|
209
|
+
return `${head}\n\n${marker}\n\n<!-- meta: ${JSON.stringify(meta)} -->`;
|
|
210
|
+
}
|
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
import crypto from 'node:crypto';
|
|
20
20
|
import fs from 'node:fs';
|
|
21
21
|
import path from 'node:path';
|
|
22
|
-
import { fileURLToPath } from 'node:url';
|
|
23
22
|
import { getCommands } from '../config/commands.js';
|
|
24
23
|
import {
|
|
25
24
|
getLimits,
|
|
@@ -28,6 +27,7 @@ import {
|
|
|
28
27
|
resolveConfig,
|
|
29
28
|
} from '../config-resolver.js';
|
|
30
29
|
import { sliceEpicBodyForDelivery } from '../epic-body-sections.js';
|
|
30
|
+
import { resolveFrameworkVersion } from '../framework-version.js';
|
|
31
31
|
import { Logger } from '../Logger.js';
|
|
32
32
|
import {
|
|
33
33
|
buildEnvelope,
|
|
@@ -133,31 +133,16 @@ export function formatSkillCapsulesSection(entries) {
|
|
|
133
133
|
// ---------------------------------------------------------------------------
|
|
134
134
|
|
|
135
135
|
/**
|
|
136
|
-
* Resolve the framework version
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* `<pkgRoot>/.agents/scripts/lib/orchestration/context-hydration-engine.js`,
|
|
142
|
-
* so the package manifest sits four directories up — the same layout in the
|
|
143
|
-
* dev repo and in the published tarball. Read that manifest's `version`.
|
|
144
|
-
*
|
|
145
|
-
* Falls back to `'unknown'` when the manifest is absent or unreadable so a
|
|
146
|
-
* missing package.json never crashes hydration.
|
|
136
|
+
* Resolve the framework version. Delegates to the shared
|
|
137
|
+
* {@link resolveFrameworkVersion} helper (single owner of the root
|
|
138
|
+
* `package.json` read) so the hydrator and the ticket-body stamp read the same
|
|
139
|
+
* source. Retained as a thin named wrapper so the mismatch-warning call sites
|
|
140
|
+
* below read against a stable local name.
|
|
147
141
|
*
|
|
148
142
|
* @returns {string}
|
|
149
143
|
*/
|
|
150
144
|
function getVersion() {
|
|
151
|
-
|
|
152
|
-
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
153
|
-
const pkgPath = path.resolve(moduleDir, '../../../..', 'package.json');
|
|
154
|
-
const parsed = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
155
|
-
return typeof parsed.version === 'string' && parsed.version.trim()
|
|
156
|
-
? parsed.version.trim()
|
|
157
|
-
: 'unknown';
|
|
158
|
-
} catch {
|
|
159
|
-
return 'unknown';
|
|
160
|
-
}
|
|
145
|
+
return resolveFrameworkVersion();
|
|
161
146
|
}
|
|
162
147
|
|
|
163
148
|
/**
|
|
@@ -29,8 +29,12 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { spawnSync } from 'node:child_process';
|
|
32
|
-
|
|
33
32
|
import { parseWorktreePorcelain } from '../worktree/inspector.js';
|
|
33
|
+
import {
|
|
34
|
+
executeFastForward,
|
|
35
|
+
planFastForward,
|
|
36
|
+
} from './git-cleanup/phases/fast-forward.js';
|
|
37
|
+
import { makeFfProbes } from './git-cleanup/phases/git-probes-ff.js';
|
|
34
38
|
|
|
35
39
|
const WT_SCRATCH_BRANCH = 'wt-branch';
|
|
36
40
|
|
|
@@ -148,6 +152,40 @@ export function findWorktreePathForBranch(branch, worktrees) {
|
|
|
148
152
|
return null;
|
|
149
153
|
}
|
|
150
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Classify a `git branch -D <branch>` result. Pure — no IO. Exported as the
|
|
157
|
+
* clean unit-test seam for the not-found rule (this module carries a
|
|
158
|
+
* `node:coverage ignore file` header, so the git-side glue is not directly
|
|
159
|
+
* covered).
|
|
160
|
+
*
|
|
161
|
+
* Story #4393 — on the `/deliver` post-merge reap path a branch is routinely
|
|
162
|
+
* *already gone* (a prior sweep, a re-run, or GitHub's `--delete-branch`
|
|
163
|
+
* already dropped it). `git branch -D` then exits non-zero with a "not found"
|
|
164
|
+
* stderr. That is an **already-reaped success**, not a reap failure: counting
|
|
165
|
+
* it as a failure forces `reapEpicBranches().ok` false, which makes
|
|
166
|
+
* BranchCleaner classify `failed`, which flips the merged Epic to
|
|
167
|
+
* `agent::blocked` and reopens it. Only a not-found stderr is absorbed as
|
|
168
|
+
* success; every other non-zero exit stays a genuine failure.
|
|
169
|
+
*
|
|
170
|
+
* @param {{ status: number, stderr?: string }} branchDel
|
|
171
|
+
* @returns {{ branchDeleted: boolean, alreadyAbsent: boolean, stderr?: string }}
|
|
172
|
+
*/
|
|
173
|
+
export function classifyBranchDeletion(branchDel) {
|
|
174
|
+
if (branchDel?.status === 0) {
|
|
175
|
+
return { branchDeleted: true, alreadyAbsent: false };
|
|
176
|
+
}
|
|
177
|
+
const stderr = (branchDel?.stderr ?? '').trim();
|
|
178
|
+
// git's own message for a missing ref: "error: branch 'foo' not found."
|
|
179
|
+
if (/\bnot found\b/i.test(stderr)) {
|
|
180
|
+
return { branchDeleted: true, alreadyAbsent: true };
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
branchDeleted: false,
|
|
184
|
+
alreadyAbsent: false,
|
|
185
|
+
...(stderr ? { stderr } : {}),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
151
189
|
/**
|
|
152
190
|
* Reap a single branch. Best-effort worktree remove → fallback to `--force`
|
|
153
191
|
* → fallback to filesystem rm → `git worktree prune` → `git branch -D`.
|
|
@@ -160,7 +198,7 @@ export function findWorktreePathForBranch(branch, worktrees) {
|
|
|
160
198
|
* rmSyncFn?: (path: string, opts: object) => void,
|
|
161
199
|
* logger?: { info?: Function, warn?: Function },
|
|
162
200
|
* }} opts
|
|
163
|
-
* @returns {{ branch: string, worktreeReaped: boolean, branchDeleted: boolean, method: string, stderr?: string }}
|
|
201
|
+
* @returns {{ branch: string, worktreeReaped: boolean, branchDeleted: boolean, alreadyAbsent: boolean, method: string, stderr?: string }}
|
|
164
202
|
*/
|
|
165
203
|
export function reapBranch(opts) {
|
|
166
204
|
const { branch, cwd, worktreePath, gitSpawn, rmSyncFn, logger } = opts;
|
|
@@ -197,16 +235,18 @@ export function reapBranch(opts) {
|
|
|
197
235
|
gitSpawn(cwd, 'worktree', 'prune');
|
|
198
236
|
}
|
|
199
237
|
|
|
200
|
-
// Drop the local branch.
|
|
238
|
+
// Drop the local branch. An already-absent branch (git → "not found") is
|
|
239
|
+
// already reaped — classifyBranchDeletion absorbs it as success so a benign
|
|
240
|
+
// missing ref never counts as a reap failure (Story #4393).
|
|
201
241
|
const branchDel = gitSpawn(cwd, 'branch', '-D', branch);
|
|
202
|
-
const branchDeleted
|
|
203
|
-
|
|
204
|
-
!branchDeleted && branchDel.stderr ? branchDel.stderr.trim() : undefined;
|
|
242
|
+
const { branchDeleted, alreadyAbsent, stderr } =
|
|
243
|
+
classifyBranchDeletion(branchDel);
|
|
205
244
|
|
|
206
245
|
return {
|
|
207
246
|
branch,
|
|
208
247
|
worktreeReaped,
|
|
209
248
|
branchDeleted,
|
|
249
|
+
alreadyAbsent,
|
|
210
250
|
method: method ?? 'unknown',
|
|
211
251
|
...(stderr ? { stderr } : {}),
|
|
212
252
|
};
|
|
@@ -328,6 +368,51 @@ export function deleteWtBranchIfPresent(opts) {
|
|
|
328
368
|
return { deleted: false, present: true, stderr };
|
|
329
369
|
}
|
|
330
370
|
|
|
371
|
+
/**
|
|
372
|
+
* Fast-forward the base branch to its remote after a confirmed merge, so a
|
|
373
|
+
* post-`/deliver` local checkout converges to `origin/<baseBranch>` without a
|
|
374
|
+
* manual `git pull`. Reuses `planFastForward` + `executeFastForward` from the
|
|
375
|
+
* git-cleanup phase library (the single source of the FF state machine),
|
|
376
|
+
* feeding them probes bound to the injected `gitSpawn`.
|
|
377
|
+
*
|
|
378
|
+
* @param {{
|
|
379
|
+
* cwd: string,
|
|
380
|
+
* baseBranch?: string,
|
|
381
|
+
* remoteName?: string,
|
|
382
|
+
* gitSpawn: (cwd: string, ...args: string[]) => { status: number, stdout: string, stderr: string },
|
|
383
|
+
* logger?: { info?: Function, warn?: Function },
|
|
384
|
+
* }} opts
|
|
385
|
+
* @returns {{ ok: boolean, applied: boolean, skipped: boolean, reason?: string, behind?: number, stderr?: string }}
|
|
386
|
+
*/
|
|
387
|
+
export function fastForwardBaseBranch(opts) {
|
|
388
|
+
const {
|
|
389
|
+
cwd,
|
|
390
|
+
baseBranch = 'main',
|
|
391
|
+
remoteName = 'origin',
|
|
392
|
+
gitSpawn,
|
|
393
|
+
logger,
|
|
394
|
+
} = opts;
|
|
395
|
+
const probe = makeFfProbes(gitSpawn);
|
|
396
|
+
const plan = planFastForward({
|
|
397
|
+
cwd,
|
|
398
|
+
baseBranch,
|
|
399
|
+
remoteName,
|
|
400
|
+
isCleanFn: probe.isClean,
|
|
401
|
+
currentBranchFn: probe.currentBranch,
|
|
402
|
+
fetchFn: probe.fetch,
|
|
403
|
+
canFastForwardFn: probe.canFastForward,
|
|
404
|
+
});
|
|
405
|
+
return executeFastForward({
|
|
406
|
+
cwd,
|
|
407
|
+
baseBranch,
|
|
408
|
+
remoteName,
|
|
409
|
+
plan,
|
|
410
|
+
checkoutFn: probe.checkout,
|
|
411
|
+
mergeFn: probe.merge,
|
|
412
|
+
...(logger ? { logger } : {}),
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
331
416
|
/**
|
|
332
417
|
* Reap every branch owned by the Epic. Best-effort — failures aggregate into
|
|
333
418
|
* the result rather than throwing.
|
|
@@ -350,6 +435,7 @@ export function deleteWtBranchIfPresent(opts) {
|
|
|
350
435
|
* switched: { switched: boolean, from: string|null, to: string|null, stderr?: string } | null,
|
|
351
436
|
* pruned: { pruned: string[], stderr?: string } | null,
|
|
352
437
|
* wtBranch: { deleted: boolean, present: boolean, reason?: string, stderr?: string } | null,
|
|
438
|
+
* fastForward: { ok: boolean, applied: boolean, skipped: boolean, reason?: string, behind?: number, stderr?: string } | null,
|
|
353
439
|
* epicBranchKept: boolean,
|
|
354
440
|
* ok: boolean,
|
|
355
441
|
* }}
|
|
@@ -375,6 +461,7 @@ export function reapEpicBranches(opts) {
|
|
|
375
461
|
switched: null,
|
|
376
462
|
pruned: null,
|
|
377
463
|
wtBranch: null,
|
|
464
|
+
fastForward: null,
|
|
378
465
|
epicBranchKept: false,
|
|
379
466
|
ok: true,
|
|
380
467
|
};
|
|
@@ -463,6 +550,26 @@ export function reapEpicBranches(opts) {
|
|
|
463
550
|
logger?.info?.(`[epic-cleanup] deleted stale ${WT_SCRATCH_BRANCH} ref`);
|
|
464
551
|
}
|
|
465
552
|
|
|
553
|
+
// After a confirmed merge (epic branch NOT kept), fast-forward the base
|
|
554
|
+
// branch so the local checkout converges to `origin/<baseBranch>` with no
|
|
555
|
+
// manual `git pull`. When the epic branch is kept (open PR), the merge is
|
|
556
|
+
// not confirmed, so the FF is skipped with an explicit reason rather than
|
|
557
|
+
// moving `main` under an in-flight PR.
|
|
558
|
+
const fastForward = epicHasOpenPr
|
|
559
|
+
? { ok: true, applied: false, skipped: true, reason: 'epic-branch-kept' }
|
|
560
|
+
: fastForwardBaseBranch({
|
|
561
|
+
cwd,
|
|
562
|
+
baseBranch,
|
|
563
|
+
remoteName: remote,
|
|
564
|
+
gitSpawn,
|
|
565
|
+
logger,
|
|
566
|
+
});
|
|
567
|
+
if (fastForward.applied) {
|
|
568
|
+
logger?.info?.(
|
|
569
|
+
`[epic-cleanup] fast-forwarded ${baseBranch} by ${fastForward.behind} commit(s) to ${remote}/${baseBranch}`,
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
466
573
|
const failures = reaped.filter((r) => !r.branchDeleted);
|
|
467
574
|
return {
|
|
468
575
|
epicId: state?.epicId ?? null,
|
|
@@ -471,7 +578,224 @@ export function reapEpicBranches(opts) {
|
|
|
471
578
|
switched,
|
|
472
579
|
pruned,
|
|
473
580
|
wtBranch,
|
|
581
|
+
fastForward,
|
|
474
582
|
epicBranchKept: epicHasOpenPr,
|
|
475
583
|
ok: failures.length === 0,
|
|
476
584
|
};
|
|
477
585
|
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Does a local branch head ref exist? Thin `git rev-parse --verify` wrapper
|
|
589
|
+
* exported for the resume-detect path (and its tests).
|
|
590
|
+
*
|
|
591
|
+
* @param {{ cwd: string, gitSpawn: Function, branch: string }} opts
|
|
592
|
+
* @returns {boolean}
|
|
593
|
+
*/
|
|
594
|
+
export function localRefExists({ cwd, gitSpawn, branch }) {
|
|
595
|
+
const res = gitSpawn(
|
|
596
|
+
cwd,
|
|
597
|
+
'rev-parse',
|
|
598
|
+
'--verify',
|
|
599
|
+
'--quiet',
|
|
600
|
+
`refs/heads/${branch}`,
|
|
601
|
+
);
|
|
602
|
+
return res.status === 0;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Probe whether the Epic branch's PR is MERGED and, if so, its URL (needed
|
|
607
|
+
* for the `epic.merge.armed` payload). Fails CLOSED to
|
|
608
|
+
* `{ merged: false, prUrl: null }` on any probe error — an indeterminate
|
|
609
|
+
* probe must never auto-arm a destructive reap.
|
|
610
|
+
*
|
|
611
|
+
* @param {{
|
|
612
|
+
* epicBranch: string,
|
|
613
|
+
* cwd: string,
|
|
614
|
+
* spawnFn?: typeof spawnSync,
|
|
615
|
+
* logger?: { warn?: Function },
|
|
616
|
+
* }} opts
|
|
617
|
+
* @returns {{ merged: boolean, prUrl: string|null }}
|
|
618
|
+
*/
|
|
619
|
+
export function epicPrMergeState(opts) {
|
|
620
|
+
const { epicBranch, cwd, spawnFn = spawnSync, logger } = opts;
|
|
621
|
+
if (typeof epicBranch !== 'string' || epicBranch.length === 0) {
|
|
622
|
+
return { merged: false, prUrl: null };
|
|
623
|
+
}
|
|
624
|
+
let result;
|
|
625
|
+
try {
|
|
626
|
+
result = spawnFn(
|
|
627
|
+
'gh',
|
|
628
|
+
[
|
|
629
|
+
'pr',
|
|
630
|
+
'list',
|
|
631
|
+
'--head',
|
|
632
|
+
epicBranch,
|
|
633
|
+
'--state',
|
|
634
|
+
'merged',
|
|
635
|
+
'--json',
|
|
636
|
+
'number,url,mergedAt',
|
|
637
|
+
'--limit',
|
|
638
|
+
'1',
|
|
639
|
+
],
|
|
640
|
+
{ cwd, encoding: 'utf-8', shell: false },
|
|
641
|
+
);
|
|
642
|
+
} catch (err) {
|
|
643
|
+
logger?.warn?.(
|
|
644
|
+
`[epic-cleanup] merged-PR probe threw for ${epicBranch} (treating as unmerged): ${err?.message ?? err}`,
|
|
645
|
+
);
|
|
646
|
+
return { merged: false, prUrl: null };
|
|
647
|
+
}
|
|
648
|
+
if (!result || result.status !== 0) {
|
|
649
|
+
logger?.warn?.(
|
|
650
|
+
`[epic-cleanup] merged-PR probe failed for ${epicBranch} (status=${result?.status}): ${(result?.stderr ?? '').trim()}`,
|
|
651
|
+
);
|
|
652
|
+
return { merged: false, prUrl: null };
|
|
653
|
+
}
|
|
654
|
+
let parsed;
|
|
655
|
+
try {
|
|
656
|
+
parsed = JSON.parse(String(result.stdout ?? '').trim() || '[]');
|
|
657
|
+
} catch {
|
|
658
|
+
return { merged: false, prUrl: null };
|
|
659
|
+
}
|
|
660
|
+
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
661
|
+
return { merged: false, prUrl: null };
|
|
662
|
+
}
|
|
663
|
+
const row = parsed[0];
|
|
664
|
+
const prUrl =
|
|
665
|
+
typeof row?.url === 'string' && row.url.length > 0 ? row.url : null;
|
|
666
|
+
// A merged-state row with no usable URL cannot arm (the schema requires a
|
|
667
|
+
// `prUrl`), so treat it as not-armable.
|
|
668
|
+
return { merged: prUrl !== null, prUrl };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Detect a merged-but-uncleaned Epic: the PR merged but one or more local
|
|
673
|
+
* `epic/<id>` / `story-<id>` refs still linger. This is the signal `/deliver`
|
|
674
|
+
* idempotent resume uses to auto-fire `epic.merge.armed` so Phase 9 reaps
|
|
675
|
+
* without a manual command. Pure given its injected ports.
|
|
676
|
+
*
|
|
677
|
+
* @param {{
|
|
678
|
+
* state: object|null,
|
|
679
|
+
* cwd: string,
|
|
680
|
+
* gitSpawn: Function,
|
|
681
|
+
* spawnFn?: Function,
|
|
682
|
+
* prMergeStateFn?: typeof epicPrMergeState,
|
|
683
|
+
* logger?: { warn?: Function, info?: Function },
|
|
684
|
+
* }} opts
|
|
685
|
+
* @returns {{
|
|
686
|
+
* epicId: number|null,
|
|
687
|
+
* epicBranch: string|null,
|
|
688
|
+
* presentRefs: string[],
|
|
689
|
+
* localRefsPresent: boolean,
|
|
690
|
+
* merged: boolean,
|
|
691
|
+
* prUrl: string|null,
|
|
692
|
+
* shouldArm: boolean,
|
|
693
|
+
* reason: string,
|
|
694
|
+
* }}
|
|
695
|
+
*/
|
|
696
|
+
export function detectMergedUncleanedEpic(opts) {
|
|
697
|
+
const {
|
|
698
|
+
state,
|
|
699
|
+
cwd,
|
|
700
|
+
gitSpawn,
|
|
701
|
+
spawnFn,
|
|
702
|
+
prMergeStateFn = epicPrMergeState,
|
|
703
|
+
logger,
|
|
704
|
+
} = opts;
|
|
705
|
+
const { epicBranch, storyBranches } = listEpicBranchesFromState(state);
|
|
706
|
+
if (!epicBranch) {
|
|
707
|
+
return {
|
|
708
|
+
epicId: null,
|
|
709
|
+
epicBranch: null,
|
|
710
|
+
presentRefs: [],
|
|
711
|
+
localRefsPresent: false,
|
|
712
|
+
merged: false,
|
|
713
|
+
prUrl: null,
|
|
714
|
+
shouldArm: false,
|
|
715
|
+
reason: 'no-state',
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
const presentRefs = [epicBranch, ...storyBranches].filter((branch) =>
|
|
719
|
+
localRefExists({ cwd, gitSpawn, branch }),
|
|
720
|
+
);
|
|
721
|
+
if (presentRefs.length === 0) {
|
|
722
|
+
// Already clean — nothing to arm. This is the idempotent no-op that
|
|
723
|
+
// makes re-running `/deliver` on an already-reaped Epic safe.
|
|
724
|
+
return {
|
|
725
|
+
epicId: state.epicId,
|
|
726
|
+
epicBranch,
|
|
727
|
+
presentRefs,
|
|
728
|
+
localRefsPresent: false,
|
|
729
|
+
merged: false,
|
|
730
|
+
prUrl: null,
|
|
731
|
+
shouldArm: false,
|
|
732
|
+
reason: 'no-local-refs',
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
const { merged, prUrl } = prMergeStateFn({
|
|
736
|
+
epicBranch,
|
|
737
|
+
cwd,
|
|
738
|
+
spawnFn,
|
|
739
|
+
logger,
|
|
740
|
+
});
|
|
741
|
+
const shouldArm = merged && prUrl !== null;
|
|
742
|
+
return {
|
|
743
|
+
epicId: state.epicId,
|
|
744
|
+
epicBranch,
|
|
745
|
+
presentRefs,
|
|
746
|
+
localRefsPresent: true,
|
|
747
|
+
merged,
|
|
748
|
+
prUrl,
|
|
749
|
+
shouldArm,
|
|
750
|
+
reason: shouldArm ? 'merged-uncleaned' : 'not-merged',
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* `/deliver` idempotent-resume auto-arm: detect a merged-but-uncleaned Epic
|
|
756
|
+
* and, when found, fire `epic.merge.armed` on the injected lifecycle `bus` so
|
|
757
|
+
* the Cleaner → BranchCleaner chain reaps Phase 9 without an operator command.
|
|
758
|
+
* A no-op (and never throws on a clean/unmerged Epic) so re-running resume is
|
|
759
|
+
* safe.
|
|
760
|
+
*
|
|
761
|
+
* @param {{
|
|
762
|
+
* state: object|null,
|
|
763
|
+
* cwd: string,
|
|
764
|
+
* gitSpawn: Function,
|
|
765
|
+
* spawnFn?: Function,
|
|
766
|
+
* bus: { emit: (event: string, payload: object) => Promise<unknown> },
|
|
767
|
+
* detectFn?: typeof detectMergedUncleanedEpic,
|
|
768
|
+
* logger?: { warn?: Function, info?: Function },
|
|
769
|
+
* }} opts
|
|
770
|
+
* @returns {Promise<{ armed: boolean, reason: string, prUrl: string|null, detection: object }>}
|
|
771
|
+
*/
|
|
772
|
+
export async function armCleanupIfMerged(opts) {
|
|
773
|
+
const { state, cwd, gitSpawn, spawnFn, bus, detectFn, logger } = opts;
|
|
774
|
+
if (!bus || typeof bus.emit !== 'function') {
|
|
775
|
+
throw new TypeError('armCleanupIfMerged requires a bus exposing emit()');
|
|
776
|
+
}
|
|
777
|
+
const detect = detectFn ?? detectMergedUncleanedEpic;
|
|
778
|
+
const detection = detect({ state, cwd, gitSpawn, spawnFn, logger });
|
|
779
|
+
if (!detection.shouldArm) {
|
|
780
|
+
return {
|
|
781
|
+
armed: false,
|
|
782
|
+
reason: detection.reason,
|
|
783
|
+
prUrl: detection.prUrl,
|
|
784
|
+
detection,
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
const payload = { prUrl: detection.prUrl };
|
|
788
|
+
if (Number.isInteger(detection.epicId) && detection.epicId > 0) {
|
|
789
|
+
payload.epicId = detection.epicId;
|
|
790
|
+
}
|
|
791
|
+
await bus.emit('epic.merge.armed', payload);
|
|
792
|
+
logger?.info?.(
|
|
793
|
+
`[epic-cleanup] resume auto-arm: fired epic.merge.armed for ${detection.epicBranch} (${detection.prUrl})`,
|
|
794
|
+
);
|
|
795
|
+
return {
|
|
796
|
+
armed: true,
|
|
797
|
+
reason: 'merged-uncleaned',
|
|
798
|
+
prUrl: detection.prUrl,
|
|
799
|
+
detection,
|
|
800
|
+
};
|
|
801
|
+
}
|