mandrel 1.85.0 → 1.86.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/rules/git-conventions.md +33 -0
- package/.agents/scripts/boot-sweep.js +183 -0
- package/.agents/scripts/epic-deliver-prepare.js +55 -0
- package/.agents/scripts/lib/orchestration/epic-cleanup.js +289 -1
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +83 -30
- 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 +181 -54
- package/.agents/scripts/single-story-init.js +7 -51
- package/.agents/workflows/git-deliver.md +36 -0
- package/.agents/workflows/helpers/deliver-epic-reference.md +19 -13
- package/.agents/workflows/helpers/deliver-epic.md +25 -0
- package/.agents/workflows/helpers/deliver-stories.md +16 -3
- package/.agents/workflows/plan.md +32 -4
- package/docs/CHANGELOG.md +12 -0
- package/package.json +1 -1
|
@@ -1,27 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* single-story-sweep.js —
|
|
2
|
+
* single-story-sweep.js — the scope-agnostic merged-branch sweep engine
|
|
3
|
+
* plus the `story-*` boot-sweep preset.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
5
|
+
* `sweepMergedBranches` is the single reap engine every boot cleanup
|
|
6
|
+
* path routes through. It sits directly over the `git-cleanup` phase
|
|
7
|
+
* library (`planCleanup` / `executeCleanup` / `executeFastForward` /
|
|
8
|
+
* `buildGlobFilter`) and the shared `evaluateProtection` guard set, so
|
|
9
|
+
* no reap path re-implements the `git branch -D` / `git merge --ff-only`
|
|
10
|
+
* primitives:
|
|
6
11
|
*
|
|
7
|
-
* - Scope: `
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
12
|
+
* - Scope: caller-supplied `include` / `exclude` globs (via
|
|
13
|
+
* `buildGlobFilter`). The story preset pins `story-*`.
|
|
14
|
+
* - Reap: merged local branches whose PR HEAD SHA equals the merged
|
|
15
|
+
* `headRefOid`, deleted local + origin with tracking-ref
|
|
16
|
+
* prune (`executeCleanup` in `--remote` mode).
|
|
11
17
|
* - Protection (Story #2011): each candidate is filtered through
|
|
12
18
|
* `evaluateProtection` before reaching `executeCleanup`. A
|
|
13
19
|
* candidate is protected (not reaped) when its branch HEAD
|
|
14
20
|
* differs from the PR's `headRefOid` (unpushed work), when
|
|
15
21
|
* its worktree has uncommitted edits, or when the parent
|
|
16
22
|
* Story ticket is not in a terminal state. Protected
|
|
17
|
-
* candidates surface
|
|
18
|
-
*
|
|
23
|
+
* candidates surface under `protected`.
|
|
24
|
+
* - Fast-forward (opt-in via `fastForward: true`): fast-forward the
|
|
25
|
+
* base branch through `executeFastForward` after the reap.
|
|
26
|
+
* Best-effort — a failed fast-forward never fails the sweep.
|
|
19
27
|
* - Concurrency (Story #2011): the sweep acquires a process-scoped
|
|
20
28
|
* lockfile around plan + execute. On lock contention the
|
|
21
|
-
* sweep is skipped (
|
|
29
|
+
* sweep is skipped (the host continues — same contract as a
|
|
22
30
|
* plan failure).
|
|
23
|
-
* -
|
|
24
|
-
*
|
|
31
|
+
* - Never touches the stash stack.
|
|
32
|
+
* - Errors are caught and surfaced in the envelope. Callers MUST NOT
|
|
33
|
+
* propagate sweep failures — the host proceeds either way.
|
|
34
|
+
*
|
|
35
|
+
* `sweepMergedStoryBranches` is a thin preset over the engine tuned for
|
|
36
|
+
* the boot path (`include: story-*`, `exclude: <current story branch>`,
|
|
37
|
+
* `fastForward: false`). Its exported name, signature, and result
|
|
38
|
+
* envelope are unchanged from the pre-engine implementation.
|
|
25
39
|
*
|
|
26
40
|
* Re-exports the same `planCleanup` / `executeCleanup` injection seams so
|
|
27
41
|
* tests can stub git/`gh` without touching the CLI.
|
|
@@ -30,7 +44,9 @@
|
|
|
30
44
|
import {
|
|
31
45
|
buildGlobFilter,
|
|
32
46
|
executeCleanup as defaultExecuteCleanup,
|
|
47
|
+
executeFastForward as defaultExecuteFastForward,
|
|
33
48
|
planCleanup as defaultPlanCleanup,
|
|
49
|
+
planFastForward as defaultPlanFastForward,
|
|
34
50
|
} from '../git-cleanup.js';
|
|
35
51
|
import { evaluateProtection as defaultEvaluateProtection } from './single-story-sweep/protection.js';
|
|
36
52
|
import { acquireSweepLock as defaultAcquireSweepLock } from './single-story-sweep/sweep-lock.js';
|
|
@@ -38,15 +54,20 @@ import { acquireSweepLock as defaultAcquireSweepLock } from './single-story-swee
|
|
|
38
54
|
const STORY_BRANCH_INCLUDE = 'story-*';
|
|
39
55
|
|
|
40
56
|
/**
|
|
41
|
-
*
|
|
57
|
+
* Scope-agnostic merged-branch sweep engine.
|
|
42
58
|
*
|
|
43
59
|
* @param {{
|
|
44
60
|
* cwd: string,
|
|
45
61
|
* baseBranch: string,
|
|
46
|
-
*
|
|
62
|
+
* include?: string[],
|
|
63
|
+
* exclude?: string[],
|
|
64
|
+
* fastForward?: boolean,
|
|
47
65
|
* logger?: { info?: (m: string) => void, warn?: (m: string) => void },
|
|
66
|
+
* logTag?: string,
|
|
48
67
|
* planCleanupFn?: typeof defaultPlanCleanup,
|
|
49
68
|
* executeCleanupFn?: typeof defaultExecuteCleanup,
|
|
69
|
+
* planFastForwardFn?: typeof defaultPlanFastForward,
|
|
70
|
+
* executeFastForwardFn?: typeof defaultExecuteFastForward,
|
|
50
71
|
* protectionFn?: typeof defaultEvaluateProtection,
|
|
51
72
|
* protectionCtx?: object,
|
|
52
73
|
* acquireLockFn?: typeof defaultAcquireSweepLock,
|
|
@@ -61,17 +82,23 @@ const STORY_BRANCH_INCLUDE = 'story-*';
|
|
|
61
82
|
* remoteDeleted: number,
|
|
62
83
|
* protected: Array<{ branch: string, reason: string, worktreePath?: string|null }>,
|
|
63
84
|
* failures: Array<{ branch: string|null, scope: string, stderr?: string }>,
|
|
85
|
+
* fastForward?: object,
|
|
64
86
|
* error?: string,
|
|
65
87
|
* reason?: string,
|
|
66
88
|
* }>}
|
|
67
89
|
*/
|
|
68
|
-
export async function
|
|
90
|
+
export async function sweepMergedBranches({
|
|
69
91
|
cwd,
|
|
70
92
|
baseBranch,
|
|
71
|
-
|
|
93
|
+
include = ['*'],
|
|
94
|
+
exclude = [],
|
|
95
|
+
fastForward = false,
|
|
72
96
|
logger = {},
|
|
97
|
+
logTag = '[sweep]',
|
|
73
98
|
planCleanupFn = defaultPlanCleanup,
|
|
74
99
|
executeCleanupFn = defaultExecuteCleanup,
|
|
100
|
+
planFastForwardFn = defaultPlanFastForward,
|
|
101
|
+
executeFastForwardFn = defaultExecuteFastForward,
|
|
75
102
|
protectionFn = defaultEvaluateProtection,
|
|
76
103
|
protectionCtx = null,
|
|
77
104
|
acquireLockFn = defaultAcquireSweepLock,
|
|
@@ -92,16 +119,13 @@ export async function sweepMergedStoryBranches({
|
|
|
92
119
|
|
|
93
120
|
// Optional lock acquisition. Skip silently when no lockPath is
|
|
94
121
|
// supplied (e.g. unit tests, callers that opt out). Contention is
|
|
95
|
-
// non-fatal — return a skipped result and let
|
|
122
|
+
// non-fatal — return a skipped result and let the host continue.
|
|
96
123
|
let releaseLock = () => {};
|
|
97
124
|
if (lockPath) {
|
|
98
|
-
const lockResult = acquireLockFn({
|
|
99
|
-
lockPath,
|
|
100
|
-
timeoutMs: lockTimeoutMs,
|
|
101
|
-
});
|
|
125
|
+
const lockResult = acquireLockFn({ lockPath, timeoutMs: lockTimeoutMs });
|
|
102
126
|
if (!lockResult.acquired) {
|
|
103
127
|
log.warn(
|
|
104
|
-
|
|
128
|
+
`${logTag} lock not acquired (${lockResult.reason}${
|
|
105
129
|
lockResult.detail ? `: ${lockResult.detail}` : ''
|
|
106
130
|
}); skipping sweep.`,
|
|
107
131
|
);
|
|
@@ -120,16 +144,28 @@ export async function sweepMergedStoryBranches({
|
|
|
120
144
|
}
|
|
121
145
|
|
|
122
146
|
try {
|
|
123
|
-
|
|
147
|
+
const reap = await runSweepUnderLock({
|
|
124
148
|
cwd,
|
|
125
149
|
baseBranch,
|
|
126
|
-
|
|
150
|
+
include,
|
|
151
|
+
exclude,
|
|
127
152
|
log,
|
|
153
|
+
logTag,
|
|
128
154
|
planCleanupFn,
|
|
129
155
|
executeCleanupFn,
|
|
130
156
|
protectionFn,
|
|
131
157
|
protectionCtx,
|
|
132
158
|
});
|
|
159
|
+
if (!fastForward) return reap;
|
|
160
|
+
const ff = runFastForwardStep({
|
|
161
|
+
cwd,
|
|
162
|
+
baseBranch,
|
|
163
|
+
log,
|
|
164
|
+
logTag,
|
|
165
|
+
planFastForwardFn,
|
|
166
|
+
executeFastForwardFn,
|
|
167
|
+
});
|
|
168
|
+
return { ...reap, fastForward: ff };
|
|
133
169
|
} finally {
|
|
134
170
|
try {
|
|
135
171
|
releaseLock();
|
|
@@ -139,41 +175,73 @@ export async function sweepMergedStoryBranches({
|
|
|
139
175
|
}
|
|
140
176
|
}
|
|
141
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Sweep merged `story-*` branches in `cwd`. Preset over
|
|
180
|
+
* {@link sweepMergedBranches} for the boot path: it pins the `story-*`
|
|
181
|
+
* include glob, excludes the current run's `currentStoryBranch`, and
|
|
182
|
+
* keeps `fastForward` off (the boot caller fast-forwards the base branch
|
|
183
|
+
* separately). Exported name, signature, and result-envelope shape are
|
|
184
|
+
* unchanged from the pre-engine implementation.
|
|
185
|
+
*
|
|
186
|
+
* @param {{
|
|
187
|
+
* cwd: string,
|
|
188
|
+
* baseBranch: string,
|
|
189
|
+
* currentStoryBranch: string,
|
|
190
|
+
* logger?: { info?: (m: string) => void, warn?: (m: string) => void },
|
|
191
|
+
* planCleanupFn?: typeof defaultPlanCleanup,
|
|
192
|
+
* executeCleanupFn?: typeof defaultExecuteCleanup,
|
|
193
|
+
* protectionFn?: typeof defaultEvaluateProtection,
|
|
194
|
+
* protectionCtx?: object,
|
|
195
|
+
* acquireLockFn?: typeof defaultAcquireSweepLock,
|
|
196
|
+
* lockPath?: string|null,
|
|
197
|
+
* lockTimeoutMs?: number,
|
|
198
|
+
* }} args
|
|
199
|
+
* @returns {Promise<object>} the {@link sweepMergedBranches} envelope.
|
|
200
|
+
*/
|
|
201
|
+
export function sweepMergedStoryBranches(args = {}) {
|
|
202
|
+
const { currentStoryBranch } = args;
|
|
203
|
+
const exclude =
|
|
204
|
+
typeof currentStoryBranch === 'string' && currentStoryBranch.length > 0
|
|
205
|
+
? [currentStoryBranch]
|
|
206
|
+
: [];
|
|
207
|
+
return sweepMergedBranches({
|
|
208
|
+
...args,
|
|
209
|
+
include: [STORY_BRANCH_INCLUDE],
|
|
210
|
+
exclude,
|
|
211
|
+
fastForward: false,
|
|
212
|
+
logTag: '[single-story-sweep]',
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
142
216
|
/**
|
|
143
217
|
* Inner: the plan + protect + execute pipeline. Kept separate so the
|
|
144
|
-
* outer
|
|
145
|
-
* acquire/release wrapper.
|
|
218
|
+
* outer engine can stay focused on the lock and fast-forward wrappers.
|
|
146
219
|
*/
|
|
147
220
|
async function runSweepUnderLock({
|
|
148
221
|
cwd,
|
|
149
222
|
baseBranch,
|
|
150
|
-
|
|
223
|
+
include,
|
|
224
|
+
exclude,
|
|
151
225
|
log,
|
|
226
|
+
logTag,
|
|
152
227
|
planCleanupFn,
|
|
153
228
|
executeCleanupFn,
|
|
154
229
|
protectionFn,
|
|
155
230
|
protectionCtx,
|
|
156
231
|
}) {
|
|
157
|
-
const exclude
|
|
158
|
-
typeof currentStoryBranch === 'string' && currentStoryBranch.length > 0
|
|
159
|
-
? [currentStoryBranch]
|
|
160
|
-
: [];
|
|
161
|
-
const filter = buildGlobFilter({
|
|
162
|
-
include: [STORY_BRANCH_INCLUDE],
|
|
163
|
-
exclude,
|
|
164
|
-
});
|
|
232
|
+
const filter = buildGlobFilter({ include, exclude });
|
|
165
233
|
|
|
166
234
|
let plan;
|
|
167
235
|
try {
|
|
168
236
|
plan = planCleanupFn({ cwd, baseBranch, filter });
|
|
169
237
|
} catch (err) {
|
|
170
238
|
const msg = err?.message ?? String(err);
|
|
171
|
-
log.warn(
|
|
239
|
+
log.warn(`${logTag} plan failed: ${msg}`);
|
|
172
240
|
return zeroResult({ error: `plan: ${msg}` });
|
|
173
241
|
}
|
|
174
242
|
|
|
175
243
|
if (plan.candidates.length === 0) {
|
|
176
|
-
log.info(
|
|
244
|
+
log.info(`${logTag} no merged branches to reap.`);
|
|
177
245
|
return {
|
|
178
246
|
ok: true,
|
|
179
247
|
skipped: false,
|
|
@@ -190,11 +258,12 @@ async function runSweepUnderLock({
|
|
|
190
258
|
protectionFn,
|
|
191
259
|
protectionCtx,
|
|
192
260
|
log,
|
|
261
|
+
logTag,
|
|
193
262
|
});
|
|
194
263
|
|
|
195
264
|
if (reapable.length === 0) {
|
|
196
265
|
log.info(
|
|
197
|
-
|
|
266
|
+
`${logTag} all ${plan.candidates.length} candidate(s) protected; no reap.`,
|
|
198
267
|
);
|
|
199
268
|
return {
|
|
200
269
|
ok: true,
|
|
@@ -207,20 +276,41 @@ async function runSweepUnderLock({
|
|
|
207
276
|
};
|
|
208
277
|
}
|
|
209
278
|
|
|
279
|
+
return executeReap({
|
|
280
|
+
reapable,
|
|
281
|
+
protectedList,
|
|
282
|
+
candidateCount: plan.candidates.length,
|
|
283
|
+
cwd,
|
|
284
|
+
executeCleanupFn,
|
|
285
|
+
log,
|
|
286
|
+
logTag,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Execute the reap plan for the reapable candidates and shape the result
|
|
292
|
+
* envelope. Split out of {@link runSweepUnderLock} so each function keeps
|
|
293
|
+
* a single responsibility.
|
|
294
|
+
*/
|
|
295
|
+
function executeReap({
|
|
296
|
+
reapable,
|
|
297
|
+
protectedList,
|
|
298
|
+
candidateCount,
|
|
299
|
+
cwd,
|
|
300
|
+
executeCleanupFn,
|
|
301
|
+
log,
|
|
302
|
+
logTag,
|
|
303
|
+
}) {
|
|
210
304
|
let result;
|
|
211
305
|
try {
|
|
212
|
-
result = executeCleanupFn({
|
|
213
|
-
candidates: reapable,
|
|
214
|
-
cwd,
|
|
215
|
-
remote: true,
|
|
216
|
-
});
|
|
306
|
+
result = executeCleanupFn({ candidates: reapable, cwd, remote: true });
|
|
217
307
|
} catch (err) {
|
|
218
308
|
const msg = err?.message ?? String(err);
|
|
219
|
-
log.warn(
|
|
309
|
+
log.warn(`${logTag} execute failed: ${msg}`);
|
|
220
310
|
return {
|
|
221
311
|
ok: false,
|
|
222
312
|
skipped: false,
|
|
223
|
-
candidates:
|
|
313
|
+
candidates: candidateCount,
|
|
224
314
|
localDeleted: 0,
|
|
225
315
|
remoteDeleted: 0,
|
|
226
316
|
protected: protectedList,
|
|
@@ -241,18 +331,18 @@ async function runSweepUnderLock({
|
|
|
241
331
|
const summary = `${localDeleted} local + ${remoteDeleted} remote${protectedSummary}`;
|
|
242
332
|
if (result.ok) {
|
|
243
333
|
log.info(
|
|
244
|
-
|
|
334
|
+
`${logTag} reaped ${summary}${reapedBranches ? ` [${reapedBranches}]` : ''}.`,
|
|
245
335
|
);
|
|
246
336
|
} else {
|
|
247
337
|
log.warn(
|
|
248
|
-
|
|
338
|
+
`${logTag} reaped ${summary} with ${result.failures.length} failure(s) — host continues.`,
|
|
249
339
|
);
|
|
250
340
|
}
|
|
251
341
|
|
|
252
342
|
return {
|
|
253
343
|
ok: result.ok,
|
|
254
344
|
skipped: false,
|
|
255
|
-
candidates:
|
|
345
|
+
candidates: candidateCount,
|
|
256
346
|
localDeleted,
|
|
257
347
|
remoteDeleted,
|
|
258
348
|
protected: protectedList,
|
|
@@ -260,6 +350,44 @@ async function runSweepUnderLock({
|
|
|
260
350
|
};
|
|
261
351
|
}
|
|
262
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Best-effort fast-forward of the base branch through the git-cleanup
|
|
355
|
+
* fast-forward phase. Never throws — a failed fast-forward is logged and
|
|
356
|
+
* returned as `{ ok: false, error }` but must never fail the sweep.
|
|
357
|
+
*/
|
|
358
|
+
function runFastForwardStep({
|
|
359
|
+
cwd,
|
|
360
|
+
baseBranch,
|
|
361
|
+
log,
|
|
362
|
+
logTag,
|
|
363
|
+
planFastForwardFn,
|
|
364
|
+
executeFastForwardFn,
|
|
365
|
+
}) {
|
|
366
|
+
try {
|
|
367
|
+
const plan = planFastForwardFn({ cwd, baseBranch });
|
|
368
|
+
const ff = executeFastForwardFn({
|
|
369
|
+
cwd,
|
|
370
|
+
baseBranch,
|
|
371
|
+
plan,
|
|
372
|
+
logger: {
|
|
373
|
+
info: (m) => log.info(m.replace(/^\[git-cleanup\]\s*/, `${logTag} `)),
|
|
374
|
+
warn: (m) => log.warn(m.replace(/^\[git-cleanup\]\s*/, `${logTag} `)),
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
return {
|
|
378
|
+
ok: ff.ok !== false,
|
|
379
|
+
applied: !!ff.applied,
|
|
380
|
+
skipped: !!ff.skipped,
|
|
381
|
+
behind: ff.behind ?? null,
|
|
382
|
+
reason: ff.reason ?? null,
|
|
383
|
+
};
|
|
384
|
+
} catch (err) {
|
|
385
|
+
const msg = err?.message ?? String(err);
|
|
386
|
+
log.warn(`${logTag} fast-forward failed: ${msg}`);
|
|
387
|
+
return { ok: false, applied: false, skipped: false, error: msg };
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
263
391
|
/**
|
|
264
392
|
* Iterate plan candidates and split them into `reapable` (safe to pass
|
|
265
393
|
* to executeCleanup) and `protectedList` (skipped, with a reason).
|
|
@@ -270,14 +398,15 @@ async function runSweepUnderLock({
|
|
|
270
398
|
*
|
|
271
399
|
* When no `protectionCtx` is supplied (legacy callers, unit tests),
|
|
272
400
|
* the protection check is bypassed entirely and every candidate is
|
|
273
|
-
* reapable. The CLI
|
|
274
|
-
*
|
|
401
|
+
* reapable. The boot-path CLI surfaces always supply a ctx, so this
|
|
402
|
+
* fallback never fires in production.
|
|
275
403
|
*/
|
|
276
404
|
async function partitionCandidates({
|
|
277
405
|
candidates,
|
|
278
406
|
protectionFn,
|
|
279
407
|
protectionCtx,
|
|
280
408
|
log,
|
|
409
|
+
logTag,
|
|
281
410
|
}) {
|
|
282
411
|
const reapable = [];
|
|
283
412
|
const protectedList = [];
|
|
@@ -291,7 +420,7 @@ async function partitionCandidates({
|
|
|
291
420
|
verdict = await protectionFn({ candidate, ctx: protectionCtx });
|
|
292
421
|
} catch (err) {
|
|
293
422
|
const reason = `protection-eval-error: ${err?.message ?? err}`;
|
|
294
|
-
log.warn(
|
|
423
|
+
log.warn(`${logTag} protected ${candidate.branch}: ${reason}`);
|
|
295
424
|
protectedList.push({
|
|
296
425
|
branch: candidate.branch,
|
|
297
426
|
reason,
|
|
@@ -300,9 +429,7 @@ async function partitionCandidates({
|
|
|
300
429
|
continue;
|
|
301
430
|
}
|
|
302
431
|
if (verdict?.protected) {
|
|
303
|
-
log.info(
|
|
304
|
-
`[single-story-sweep] protected ${candidate.branch}: ${verdict.reason}`,
|
|
305
|
-
);
|
|
432
|
+
log.info(`${logTag} protected ${candidate.branch}: ${verdict.reason}`);
|
|
306
433
|
protectedList.push({
|
|
307
434
|
branch: candidate.branch,
|
|
308
435
|
reason: verdict.reason ?? 'unknown',
|
|
@@ -35,7 +35,6 @@
|
|
|
35
35
|
* @see .agents/workflows/helpers/single-story-deliver.md
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
|
-
import { spawnSync as defaultSpawnSync } from 'node:child_process';
|
|
39
38
|
import { existsSync } from 'node:fs';
|
|
40
39
|
import path from 'node:path';
|
|
41
40
|
import { parseSprintArgs } from './lib/cli-args.js';
|
|
@@ -67,12 +66,18 @@ import {
|
|
|
67
66
|
upsertStructuredComment,
|
|
68
67
|
} from './lib/orchestration/ticketing.js';
|
|
69
68
|
import { createProvider } from './lib/provider-factory.js';
|
|
69
|
+
import { buildProtectionCtx } from './lib/single-story-sweep/protection-ctx.js';
|
|
70
70
|
// `sweepMergedStoryBranches` is imported dynamically below — its transitive
|
|
71
71
|
// graph reaches `picomatch` (via `git-cleanup.js`). Loading it statically
|
|
72
72
|
// would crash module resolution before `assertDepsInstalled()` can emit a
|
|
73
73
|
// friendly "run npm install" message.
|
|
74
74
|
import { WorktreeManager } from './lib/worktree-manager.js';
|
|
75
75
|
|
|
76
|
+
// `makeGhRunner` moved to the shared `single-story-sweep/protection-ctx.js`
|
|
77
|
+
// module (Story #4373) so the three boot callers build an identical
|
|
78
|
+
// protection ctx. Re-exported here to preserve its existing import path.
|
|
79
|
+
export { makeGhRunner } from './lib/single-story-sweep/protection-ctx.js';
|
|
80
|
+
|
|
76
81
|
/**
|
|
77
82
|
* Fail fast with a clear, actionable message when project deps are missing.
|
|
78
83
|
* Uses only Node builtins so it stays loadable when `node_modules/` is empty.
|
|
@@ -97,50 +102,6 @@ function assertDepsInstalled(projectRoot) {
|
|
|
97
102
|
|
|
98
103
|
const progress = Logger.createProgress('single-story-init', { stderr: true });
|
|
99
104
|
|
|
100
|
-
/**
|
|
101
|
-
* Build the synchronous `gh` runner the single-story sweep uses for its
|
|
102
|
-
* candidate-protection checks. Exported for testing.
|
|
103
|
-
*
|
|
104
|
-
* Story #2990: the sweep protection-ctx ghRunner stays on raw
|
|
105
|
-
* `spawnSync('gh', …)` (not the `lib/gh-exec.js` async facade) because
|
|
106
|
-
* `executeCleanup` invokes the protection checks inside a synchronous
|
|
107
|
-
* candidate-filter loop. The runner contract is the legacy
|
|
108
|
-
* `(args, opts) => stdout string` shape; converting it to async would
|
|
109
|
-
* ripple into the single-story-sweep planner, which is intentionally out
|
|
110
|
-
* of scope for the callers-only provider migration.
|
|
111
|
-
*
|
|
112
|
-
* Story #4073: the `spawnImpl` seam injects the `spawnSync` boundary so the
|
|
113
|
-
* runner's success/error handling can be unit-tested without a live `gh`
|
|
114
|
-
* binary. It defaults to `child_process.spawnSync` (mirroring the
|
|
115
|
-
* `spawnImpl` seam in `lib/gh-exec.js` and the `runner` seam in
|
|
116
|
-
* `lib/bootstrap/gh-preflight.js`), so the production CLI path is unchanged.
|
|
117
|
-
* The synchronous `spawnSync` shape is preserved deliberately — the
|
|
118
|
-
* candidate-filter loop in `executeCleanup` is synchronous, so converting
|
|
119
|
-
* this to the async `lib/gh-exec.js` facade would ripple into the
|
|
120
|
-
* single-story-sweep planner.
|
|
121
|
-
*
|
|
122
|
-
* @param {string} cwd Repo root used as the default spawn cwd.
|
|
123
|
-
* @param {typeof defaultSpawnSync} [spawnImpl] Injectable spawn boundary —
|
|
124
|
-
* defaults to `child_process.spawnSync`. Tests pass a fake to assert the
|
|
125
|
-
* error/exit-code handling without spawning a real child process.
|
|
126
|
-
* @returns {(args: string[], opts?: { cwd?: string }) => string}
|
|
127
|
-
*/
|
|
128
|
-
export function makeGhRunner(cwd, spawnImpl = defaultSpawnSync) {
|
|
129
|
-
return (args, opts) => {
|
|
130
|
-
const result = spawnImpl('gh', args, {
|
|
131
|
-
cwd: opts?.cwd ?? cwd,
|
|
132
|
-
encoding: 'utf-8',
|
|
133
|
-
shell: false,
|
|
134
|
-
});
|
|
135
|
-
if (result.status !== 0) {
|
|
136
|
-
throw new Error(
|
|
137
|
-
`gh ${args.join(' ')} exit ${result.status}: ${result.stderr ?? ''}`,
|
|
138
|
-
);
|
|
139
|
-
}
|
|
140
|
-
return result.stdout ?? '';
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
|
|
144
105
|
/**
|
|
145
106
|
* Validate that the fetched ticket is a standalone Story this script can
|
|
146
107
|
* deliver. Throws with the canonical operator-facing message otherwise.
|
|
@@ -222,12 +183,7 @@ export async function reapMergedStoryBranches({
|
|
|
222
183
|
info: (m) => progress('CLEANUP', m),
|
|
223
184
|
warn: (m) => progress('CLEANUP', `⚠️ ${m}`),
|
|
224
185
|
},
|
|
225
|
-
protectionCtx: {
|
|
226
|
-
repoRoot: cwd,
|
|
227
|
-
gitSpawn,
|
|
228
|
-
ghRunner: makeGhRunner(cwd),
|
|
229
|
-
getTicket: (id) => provider.getTicket(id),
|
|
230
|
-
},
|
|
186
|
+
protectionCtx: buildProtectionCtx({ cwd, provider }),
|
|
231
187
|
lockPath,
|
|
232
188
|
lockTimeoutMs,
|
|
233
189
|
});
|
|
@@ -70,6 +70,33 @@ it is about to run** before it acts.
|
|
|
70
70
|
|
|
71
71
|
---
|
|
72
72
|
|
|
73
|
+
## Boot sweep
|
|
74
|
+
|
|
75
|
+
Before detecting the git setup, run the **protected boot sweep** so
|
|
76
|
+
`/git-deliver` starts from a tidy local checkout — a feature branch this
|
|
77
|
+
command opened and pushed on a prior run, once its PR has merged, is reaped
|
|
78
|
+
here rather than left to accumulate:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
node .agents/scripts/boot-sweep.js \
|
|
82
|
+
--include 'feat/*' --include 'fix/*' --include 'chore/*' \
|
|
83
|
+
--include 'docs/*' --include 'refactor/*' \
|
|
84
|
+
--current "$(git rev-parse --abbrev-ref HEAD)"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
This is the **safe subset** of the `/git-cleanup` phases: it fast-forwards the
|
|
88
|
+
base branch (`main`), prunes stale remote-tracking refs, and reaps every local
|
|
89
|
+
branch whose PR is **merged** and whose HEAD matches the merged `headRefOid`.
|
|
90
|
+
It **never** touches the stash stack, and its `evaluateProtection` partition
|
|
91
|
+
skips (never reaps) any candidate with unpushed work, a dirty worktree, or a
|
|
92
|
+
still-open parent ticket; `--current` always excludes the branch you are on.
|
|
93
|
+
The sweep is **silent on a no-op** — nothing merged, `main` already current →
|
|
94
|
+
one summary line (`[boot-sweep] reaped 0 local + 0 remote; protected 0.`). Its
|
|
95
|
+
exit code is always `0`; a failed sweep is reported in that summary, never
|
|
96
|
+
allowed to fail the delivery run.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
73
100
|
## Step 0 — Detect Git Setup & Resolve Level
|
|
74
101
|
|
|
75
102
|
1. Resolve `[BASE_BRANCH]` from `--base` or `.agentrc.json` →
|
|
@@ -248,6 +275,15 @@ Do **not** poll CI. That is the `/deliver` Phase 7 job and is overkill for
|
|
|
248
275
|
ad-hoc changes. The operator (or GitHub's email notification) is the next
|
|
249
276
|
watcher.
|
|
250
277
|
|
|
278
|
+
> **Local ref left behind — reaped at the next boot.** At the **pr** level
|
|
279
|
+
> this command leaves a local feature branch behind after the PR merges; it
|
|
280
|
+
> does **not** reap it inline (the merge happens later, out of band). You do
|
|
281
|
+
> not need to run `/git-cleanup` by hand: the next time `/git-deliver` (or
|
|
282
|
+
> `/plan`) runs, its **Boot sweep** step reaps that merged ad-hoc branch and
|
|
283
|
+
> fast-forwards `main` automatically. The delivering flow owns tidying its own
|
|
284
|
+
> refs on the next boot — see
|
|
285
|
+
> [`.agents/rules/git-conventions.md` § Local checkout hygiene](../rules/git-conventions.md).
|
|
286
|
+
|
|
251
287
|
---
|
|
252
288
|
|
|
253
289
|
## Troubleshooting
|
|
@@ -509,20 +509,26 @@ node .agents/scripts/lifecycle-emit.js --epic <epicId> \
|
|
|
509
509
|
|
|
510
510
|
If Phase 8.5 fell back to the operator-merges-button path (`gh pr
|
|
511
511
|
merge --auto` was declined), the `epic.merge.armed` event never fires
|
|
512
|
-
inside this run and Phase 9 will not run automatically.
|
|
513
|
-
|
|
514
|
-
|
|
512
|
+
inside this run and Phase 9 will not run automatically. **Do not** hand-reap
|
|
513
|
+
the refs with a raw `git branch -D` sequence — drive the same
|
|
514
|
+
`BranchCleaner`-backed reap the auto-merge path uses by firing
|
|
515
|
+
`epic.merge.armed` after the operator merges the PR:
|
|
515
516
|
|
|
516
517
|
```bash
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
git branch -D epic/<epicId>
|
|
520
|
-
git branch -D story-<id1> story-<id2> ...
|
|
521
|
-
git remote prune origin
|
|
518
|
+
node .agents/scripts/lifecycle-emit.js --epic <epicId> \
|
|
519
|
+
--event epic.merge.armed --pr-url <prUrl>
|
|
522
520
|
```
|
|
523
521
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
522
|
+
That single emit reaps `epic/<epicId>` and every `story-<id>` ref from the
|
|
523
|
+
checkpoint, prunes stale tracking refs, and fast-forwards local `main` to
|
|
524
|
+
`origin/main` — the whole Phase 9 reap, not a partial hand-roll. A plain
|
|
525
|
+
`git-cleanup.js` sweep alone will **not** catch the `story-<id>` refs here,
|
|
526
|
+
because the epic PR squash-merge breaks the `git branch --merged main` signal
|
|
527
|
+
and the stories never had their own PRs; the lifecycle-emit surface above is
|
|
528
|
+
the correct driver.
|
|
529
|
+
|
|
530
|
+
Re-running `/deliver <epicId>` reaches the same outcome without the manual
|
|
531
|
+
emit: the idempotent-resume auto-arm
|
|
532
|
+
(`detectMergedUncleanedEpic` → `armCleanupIfMerged` in
|
|
533
|
+
[`epic-cleanup.js`](../../scripts/lib/orchestration/epic-cleanup.js)) detects
|
|
534
|
+
the merged-but-uncleaned Epic and fires `epic.merge.armed` for you.
|
|
@@ -734,6 +734,18 @@ On an arming decision the predicate emits `epic.merge.ready`; the downstream
|
|
|
734
734
|
`epic.merge.blocked` with the disqualifying reasons and exits without merging
|
|
735
735
|
— the operator merges manually.
|
|
736
736
|
|
|
737
|
+
**Blocked-path output (operator merges the button).** When arming is
|
|
738
|
+
declined, `epic.merge.armed` never fires inside this run, so Phase 9 does not
|
|
739
|
+
reap automatically. Surface the exact one-liner the operator runs **after**
|
|
740
|
+
they merge the PR by hand so local refs are reaped and `main` is
|
|
741
|
+
fast-forwarded (the idempotent-resume path below runs this automatically on
|
|
742
|
+
the next `/deliver <epicId>`):
|
|
743
|
+
|
|
744
|
+
```bash
|
|
745
|
+
node .agents/scripts/lifecycle-emit.js --epic <epicId> \
|
|
746
|
+
--event epic.merge.armed --pr-url <prUrl>
|
|
747
|
+
```
|
|
748
|
+
|
|
737
749
|
Close the phase wrapper by emitting `epic.automerge.end` (records the arm
|
|
738
750
|
outcome on the ledger; `merged: true` once GitHub completes the squash,
|
|
739
751
|
`merged: false` with a reason otherwise):
|
|
@@ -784,6 +796,19 @@ via `helpers/epic-deliver-story`'s own checkpointing). The PR from Phase 7 is
|
|
|
784
796
|
updated in place on subsequent runs. The authoritative live view is
|
|
785
797
|
the `epic-run-progress` structured comment.
|
|
786
798
|
|
|
799
|
+
**Resume auto-arm for a merged-but-uncleaned Epic.** When `/deliver` resumes
|
|
800
|
+
against an Epic whose PR already merged (operator merged the button in a prior
|
|
801
|
+
session) but whose local `epic/<id>` / `story-<id>` refs still linger, the
|
|
802
|
+
resume path detects the merged-but-uncleaned state
|
|
803
|
+
(`detectMergedUncleanedEpic` in
|
|
804
|
+
[`epic-cleanup.js`](../../scripts/lib/orchestration/epic-cleanup.js)) and fires
|
|
805
|
+
`epic.merge.armed` automatically so Phase 9 reaps — no manual command. The
|
|
806
|
+
detection is idempotent: an already-reaped Epic (no local refs) is a clean
|
|
807
|
+
no-op, and an unmerged Epic never arms. It resolves the merged PR's URL for the
|
|
808
|
+
required `epic.merge.armed` payload and fails closed (does **not** arm) on any
|
|
809
|
+
indeterminate `gh` probe. The one-liner under Phase 8.5 / Phase 9 is the manual
|
|
810
|
+
equivalent for the case where the operator does not re-run `/deliver`.
|
|
811
|
+
|
|
787
812
|
---
|
|
788
813
|
|
|
789
814
|
## Constraints
|
|
@@ -318,11 +318,24 @@ Print a final run summary listing every delivered Story in completion order:
|
|
|
318
318
|
All Stories delivered. PRs opened, auto-merge armed. CI will merge each
|
|
319
319
|
PR when checks pass; each child then confirms the merge and flips its
|
|
320
320
|
Story to `agent::done` (Story #3385 — until the merge confirms, a Story
|
|
321
|
-
rests at `agent::closing` with its issue OPEN).
|
|
322
|
-
`git-cleanup --fast-forward-main` after the last merge to bring local
|
|
323
|
-
main up to date.
|
|
321
|
+
rests at `agent::closing` with its issue OPEN).
|
|
324
322
|
```
|
|
325
323
|
|
|
324
|
+
Then **fast-forward `main` yourself** — do not instruct the operator to run it
|
|
325
|
+
after the last merge. The delivering flow owns bringing the local base branch
|
|
326
|
+
up to whatever has merged so far:
|
|
327
|
+
|
|
328
|
+
```bash
|
|
329
|
+
node .agents/scripts/git-cleanup.js --fast-forward-main --execute --yes
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
This runs only the fast-forward-main phase (`git fetch origin main` →
|
|
333
|
+
`git merge --ff-only`); it is idempotent and a no-op when `main` is already
|
|
334
|
+
current, so it is safe to run even while some PRs are still queued in
|
|
335
|
+
auto-merge. Report its one-line result in the summary. Merged Story branches
|
|
336
|
+
themselves are reaped by the boot sweep at the next `/plan` / `/deliver` boot —
|
|
337
|
+
see [`.agents/rules/git-conventions.md` § Local checkout hygiene](../../rules/git-conventions.md).
|
|
338
|
+
|
|
326
339
|
When some Stories are blocked or failed, list them explicitly with the
|
|
327
340
|
`blockerCommentId` or failure detail so the operator knows where to look.
|
|
328
341
|
|