mandrel 2.66.0 → 2.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/.agents/agents/acceptance-critic.md +2 -2
  2. package/.agents/docs/agentrc-reference.json +2 -1
  3. package/.agents/docs/configuration.md +2 -1
  4. package/.agents/docs/workflows.md +4 -2
  5. package/.agents/instructions.md +2 -1
  6. package/.agents/rules/git-conventions-reference.md +5 -5
  7. package/.agents/rules/git-conventions.md +1 -1
  8. package/.agents/schemas/agentrc.schema.json +6 -1
  9. package/.agents/scripts/boot-sweep.js +97 -9
  10. package/.agents/scripts/{git-cleanup.js → clean-git.js} +2 -2
  11. package/.agents/scripts/clean-temp.js +54 -0
  12. package/.agents/scripts/clean-worktrees.js +593 -0
  13. package/.agents/scripts/drain-pending-cleanup.js +5 -4
  14. package/.agents/scripts/lib/clean-temp.js +440 -0
  15. package/.agents/scripts/lib/config-settings-schema-delivery.js +11 -2
  16. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  17. package/.agents/scripts/lib/observability/source-classifier.js +3 -1
  18. package/.agents/scripts/lib/orchestration/git-cleanup/phases/cli.js +1 -1
  19. package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +149 -97
  20. package/.agents/scripts/lib/single-story-sweep.js +2 -2
  21. package/.agents/scripts/lib/temp-removal.js +110 -0
  22. package/.agents/scripts/lib/temp-retention.js +122 -73
  23. package/.agents/scripts/lib/worktree/canonical-path.js +34 -0
  24. package/.agents/scripts/lib/worktree/lifecycle/reap.js +15 -4
  25. package/.agents/scripts/single-story-init.js +120 -17
  26. package/.agents/workflows/{git-cleanup.md → clean-git.md} +10 -10
  27. package/.agents/workflows/clean-temp.md +67 -0
  28. package/.agents/workflows/clean-worktrees.md +63 -0
  29. package/.agents/workflows/git-deliver.md +1 -1
  30. package/.agents/workflows/helpers/acceptance-self-eval.md +3 -2
  31. package/.agents/workflows/helpers/deliver-story-reference.md +2 -2
  32. package/docs/CHANGELOG.md +13 -0
  33. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Allowlisted auto-purge of spent temp artifacts: only a declared class's
3
- * entries are candidates; the rest are reported, never touched. Never throws
3
+ * entries are candidates; the rest are reported, never touched by the
4
+ * auto-purge (only `/clean-temp`'s operator-confirmed path). Never throws
4
5
  * — a failed purge must not fail a land, boot, or persist.
5
6
  */
6
7
 
@@ -13,6 +14,14 @@ import {
13
14
  tempRootFrom,
14
15
  } from './config/temp-paths.js';
15
16
  import { Logger } from './Logger.js';
17
+ import {
18
+ KEEP_BASENAMES,
19
+ removeSparingKept,
20
+ safeReaddir,
21
+ sizeOf,
22
+ } from './temp-removal.js';
23
+
24
+ export { KEEP_BASENAMES };
16
25
 
17
26
  /**
18
27
  * Defaults for `delivery.tempRetention`; purge is on unless turned off.
@@ -26,6 +35,7 @@ export const TEMP_RETENTION_DEFAULTS = Object.freeze({
26
35
  validationEvidence: true,
27
36
  auditResults: true,
28
37
  planDirs: true,
38
+ scratch: true,
29
39
  }),
30
40
  });
31
41
 
@@ -33,12 +43,6 @@ export const PURGE_CLASS_NAMES = Object.freeze(
33
43
  Object.keys(TEMP_RETENTION_DEFAULTS.classes),
34
44
  );
35
45
 
36
- /**
37
- * Never deleted, re-checked at the deletion site: `signals.ndjson` is read
38
- * long after merge and its loss is silent and unrecoverable.
39
- */
40
- export const KEEP_BASENAMES = Object.freeze(['signals.ndjson']);
41
-
42
46
  /** Explicit allowlist: an untaught file in a Story dir is kept. */
43
47
  const STORY_EVIDENCE_BASENAMES = Object.freeze([
44
48
  'validation-evidence.json',
@@ -49,6 +53,9 @@ const STORY_EVIDENCE_BASENAMES = Object.freeze([
49
53
  /** Framework-owned, never purged (`*.lock` files are also skipped). */
50
54
  const RESERVED_TOP_LEVEL = Object.freeze(['qa', 'cache']);
51
55
 
56
+ /** Agent-authored scratch: `scratch/story-<id>/` or any other child. */
57
+ const SCRATCH_DIRNAME = 'scratch';
58
+
52
59
  const MS_PER_DAY = 24 * 60 * 60 * 1000;
53
60
 
54
61
  const STORY_DIR_PATTERN = /^story-(\d+)$/;
@@ -77,50 +84,6 @@ export function resolveTempRetention(config) {
77
84
  };
78
85
  }
79
86
 
80
- /**
81
- * `readdir` yielding `[]` for an absent or unreadable directory.
82
- *
83
- * @param {typeof fsPromises} fsp
84
- * @param {string} dir
85
- * @returns {Promise<import('node:fs').Dirent[]>}
86
- */
87
- async function safeReaddir(fsp, dir) {
88
- try {
89
- return await fsp.readdir(dir, { withFileTypes: true });
90
- } catch {
91
- return [];
92
- }
93
- }
94
-
95
- /**
96
- * Recursive byte total; a vanished child is skipped.
97
- *
98
- * @param {typeof fsPromises} fsp
99
- * @param {string} target
100
- * @returns {Promise<number>}
101
- */
102
- async function sizeOf(fsp, target) {
103
- let total = 0;
104
- const stack = [target];
105
- while (stack.length > 0) {
106
- const current = stack.pop();
107
- let stats;
108
- try {
109
- stats = await fsp.stat(current);
110
- } catch {
111
- continue;
112
- }
113
- if (!stats.isDirectory()) {
114
- total += stats.size;
115
- continue;
116
- }
117
- for (const child of await safeReaddir(fsp, current)) {
118
- stack.push(path.join(current, child.name));
119
- }
120
- }
121
- return total;
122
- }
123
-
124
87
  /**
125
88
  * One classified entry. `mtimeMs` is the entry's own mtime, not the newest
126
89
  * beneath it; widening it would change when an abandoned dir becomes eligible.
@@ -257,13 +220,44 @@ async function scanPlanDirs(tempRoot, fsp) {
257
220
  return entries;
258
221
  }
259
222
 
223
+ /**
224
+ * `<tempRoot>/scratch/*`: `story-<id>/` is Story-keyed, anything else is
225
+ * age-floored — the one place an agent's ad-hoc files are reapable.
226
+ */
227
+ async function scanScratch(tempRoot, fsp) {
228
+ const dir = path.join(tempRoot, SCRATCH_DIRNAME);
229
+ const entries = [];
230
+ for (const dirent of await safeReaddir(fsp, dir)) {
231
+ const match = dirent.isDirectory()
232
+ ? STORY_DIR_PATTERN.exec(dirent.name)
233
+ : null;
234
+ const entry = await makeEntry(
235
+ fsp,
236
+ path.join(dir, dirent.name),
237
+ 'scratch',
238
+ match ? Number(match[1]) : null,
239
+ );
240
+ if (entry) entries.push(entry);
241
+ }
242
+ return entries;
243
+ }
244
+
260
245
  const SCANNERS = Object.freeze({
261
246
  orchestrationLogs: scanOrchestrationLogs,
262
247
  validationEvidence: scanValidationEvidence,
263
248
  auditResults: scanAuditResults,
264
249
  planDirs: scanPlanDirs,
250
+ scratch: scanScratch,
265
251
  });
266
252
 
253
+ /** Fixed top-level dirs a class scanner walks. */
254
+ const CLASS_OWNED_DIRNAMES = Object.freeze([
255
+ ORCHESTRATION_DIRNAME,
256
+ 'standalone',
257
+ 'audits',
258
+ SCRATCH_DIRNAME,
259
+ ]);
260
+
267
261
  /**
268
262
  * Keep in lockstep with the scanners: an entry no class walks must surface
269
263
  * as unrecognized.
@@ -273,29 +267,49 @@ const SCANNERS = Object.freeze({
273
267
  */
274
268
  function isClassOwnedTopLevel(name) {
275
269
  return (
276
- name === ORCHESTRATION_DIRNAME ||
277
- name === 'standalone' ||
278
- name === 'audits' ||
270
+ CLASS_OWNED_DIRNAMES.includes(name) ||
279
271
  name.startsWith('plan-') ||
280
272
  RUN_DIR_PATTERN.test(name)
281
273
  );
282
274
  }
283
275
 
284
276
  /**
285
- * Unclaimed, non-reserved top-level entries: reported with sizes, never deleted.
277
+ * Top-level names no path may ever delete: the reserved trees, lock files,
278
+ * and the never-purged basenames.
279
+ *
280
+ * @param {string} name
281
+ * @returns {boolean}
282
+ */
283
+ export function isReservedTopLevel(name) {
284
+ return (
285
+ RESERVED_TOP_LEVEL.includes(name) ||
286
+ name.endsWith('.lock') ||
287
+ KEEP_BASENAMES.includes(name)
288
+ );
289
+ }
290
+
291
+ /**
292
+ * Unclaimed, non-reserved top-level entries: reported with sizes and the
293
+ * entry's own mtime. The auto-purge never deletes one; only an operator-
294
+ * confirmed `purgeUnrecognizedEntries` call does.
286
295
  *
287
296
  * @param {string} tempRoot
288
297
  * @param {typeof fsPromises} fsp
289
- * @returns {Promise<Array<{ path: string, bytes: number }>>}
298
+ * @returns {Promise<Array<{ path: string, bytes: number, mtimeMs: number }>>}
290
299
  */
291
300
  async function collectUnrecognized(tempRoot, fsp) {
292
301
  const found = [];
293
302
  for (const dirent of await safeReaddir(fsp, tempRoot)) {
294
303
  const { name } = dirent;
295
- if (isClassOwnedTopLevel(name)) continue;
296
- if (RESERVED_TOP_LEVEL.includes(name) || name.endsWith('.lock')) continue;
297
- const target = path.join(tempRoot, name);
298
- found.push({ path: target, bytes: await sizeOf(fsp, target) });
304
+ if (isClassOwnedTopLevel(name) || isReservedTopLevel(name)) continue;
305
+ const entry = await makeEntry(fsp, path.join(tempRoot, name), null, null);
306
+ if (entry) {
307
+ found.push({
308
+ path: entry.path,
309
+ bytes: entry.bytes,
310
+ mtimeMs: entry.mtimeMs,
311
+ });
312
+ }
299
313
  }
300
314
  return found;
301
315
  }
@@ -304,7 +318,7 @@ async function collectUnrecognized(tempRoot, fsp) {
304
318
  * Classify a temp tree without deleting anything.
305
319
  *
306
320
  * @param {{ config?: object, tempRoot?: string, fsp?: typeof fsPromises }} [args]
307
- * @returns {Promise<{ tempRoot: string, entries: object[], unrecognized: Array<{ path: string, bytes: number }> }>}
321
+ * @returns {Promise<{ tempRoot: string, entries: object[], unrecognized: Array<{ path: string, bytes: number, mtimeMs: number }> }>}
308
322
  */
309
323
  export async function collectTempEntries({
310
324
  config,
@@ -353,6 +367,7 @@ function isPurgeable(entry, ctx) {
353
367
  * @param {typeof fsPromises} [args.fsp]
354
368
  * @param {{ info: Function }} [args.logger]
355
369
  * @param {string} [args.label]
370
+ * @param {boolean} [args.dryRun] Report what would go; delete nothing.
356
371
  * @returns {Promise<object>} Result envelope; never throws.
357
372
  */
358
373
  async function purgeTempArtifacts({
@@ -366,6 +381,7 @@ async function purgeTempArtifacts({
366
381
  fsp = fsPromises,
367
382
  logger = Logger,
368
383
  label = 'temp-retention',
384
+ dryRun = false,
369
385
  } = {}) {
370
386
  const policy = resolveTempRetention(config);
371
387
  const base = {
@@ -402,22 +418,55 @@ async function purgeTempArtifacts({
402
418
  if (entry.keep) result.kept.push(entry.path);
403
419
  continue;
404
420
  }
405
- try {
406
- await fsp.rm(entry.path, { recursive: true, force: true });
407
- result.purged.push({ path: entry.path, bytes: entry.bytes });
408
- result.bytesReclaimed += entry.bytes;
409
- } catch (err) {
410
- result.errors.push(`${entry.path}: ${String(err?.message ?? err)}`);
411
- }
421
+ await purgeOne(fsp, entry, result, dryRun);
412
422
  }
413
423
 
414
- if (result.purged.length > 0) {
415
- logger?.info?.(
416
- `[${label}] purged ${result.purged.length} spent temp artifact(s), ` +
417
- `reclaimed ${formatBytes(result.bytesReclaimed)} under ${result.tempRoot}.`,
424
+ if (!dryRun) reportPurge(logger, label, result);
425
+ return result;
426
+ }
427
+
428
+ /**
429
+ * One summary line for a purge that deleted something.
430
+ *
431
+ * @param {{ info?: Function }|undefined} logger
432
+ * @param {string} label
433
+ * @param {object} result
434
+ */
435
+ function reportPurge(logger, label, result) {
436
+ if (result.purged.length === 0) return;
437
+ logger?.info?.(
438
+ `[${label}] purged ${result.purged.length} spent temp artifact(s), ` +
439
+ `reclaimed ${formatBytes(result.bytesReclaimed)} under ${result.tempRoot}.`,
440
+ );
441
+ }
442
+
443
+ /**
444
+ * Remove (or, on a dry run, only record) one purgeable entry into `result`.
445
+ *
446
+ * @param {typeof fsPromises} fsp
447
+ * @param {{ path: string, bytes: number }} entry
448
+ * @param {object} result Mutated in place.
449
+ * @param {boolean} dryRun
450
+ * @returns {Promise<void>}
451
+ */
452
+ async function purgeOne(fsp, entry, result, dryRun) {
453
+ if (dryRun) {
454
+ result.purged.push({ path: entry.path, bytes: entry.bytes });
455
+ result.bytesReclaimed += entry.bytes;
456
+ return;
457
+ }
458
+ try {
459
+ const { bytes, kept } = await removeSparingKept(
460
+ fsp,
461
+ entry.path,
462
+ entry.bytes,
418
463
  );
464
+ result.kept.push(...kept);
465
+ result.purged.push({ path: entry.path, bytes });
466
+ result.bytesReclaimed += bytes;
467
+ } catch (err) {
468
+ result.errors.push(`${entry.path}: ${String(err?.message ?? err)}`);
419
469
  }
420
- return result;
421
470
  }
422
471
 
423
472
  /**
@@ -0,0 +1,34 @@
1
+ /**
2
+ * worktree/canonical-path.js — the on-disk identity of a path, for every
3
+ * worktree identity and containment comparison.
4
+ */
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ /**
10
+ * `realpathSync.native` resolves symlinks (macOS `/var` ↔ `/private/var`) and
11
+ * expands Windows 8.3 short names (`RUNNER~1` ↔ `runneradmin`) — `git
12
+ * worktree list` reports the long form while `os.tmpdir()` / `process.cwd()`
13
+ * may carry the short one. A path that no longer exists canonicalises its
14
+ * nearest existing ancestor and re-appends the rest.
15
+ *
16
+ * @param {string} p
17
+ * @param {{ realpath?: (p: string) => string }} [deps]
18
+ * @returns {string}
19
+ */
20
+ export function canonicalPath(p, { realpath = fs.realpathSync.native } = {}) {
21
+ const resolved = path.resolve(p);
22
+ const tail = [];
23
+ let current = resolved;
24
+ for (;;) {
25
+ try {
26
+ return path.join(realpath(current), ...tail);
27
+ } catch {
28
+ const parent = path.dirname(current);
29
+ if (parent === current) return resolved;
30
+ tail.unshift(path.basename(current));
31
+ current = parent;
32
+ }
33
+ }
34
+ }
@@ -7,6 +7,7 @@
7
7
  import fs from 'node:fs';
8
8
  import { rm as fsPromisesRm } from 'node:fs/promises';
9
9
  import { fileURLToPath } from 'node:url';
10
+ import { canonicalPath } from '../canonical-path.js';
10
11
  import { isInsideWorktree, samePath } from '../inspector.js';
11
12
  import { sleepSync } from '../node-modules-strategy.js';
12
13
  import { checkMergeReachability } from './merge-reachability.js';
@@ -441,15 +442,25 @@ function runningCodePaths() {
441
442
  * The running-code path inside `wtPath`, or `null`. Reaping the tree the
442
443
  * running script was loaded from kills the process later, at its first lazy
443
444
  * read or dynamic `import()`; refusing only defers the tree to the next sweep.
445
+ * Shared by every reaper (close, the boot worktree sweep, `/clean-worktrees`).
446
+ * Both sides are canonicalised first: a short-name or symlinked spelling of
447
+ * the same tree must still be recognised.
444
448
  *
445
- * @param {object} ctx
449
+ * @param {{ platform?: string }} ctx
446
450
  * @param {string} wtPath
451
+ * @param {string[]} [extraPaths] Further paths to guard (e.g. `process.cwd()`).
447
452
  * @returns {string|null}
448
453
  */
449
- function findRunningCodeInside(ctx, wtPath) {
454
+ export function findRunningCodeInside(ctx, wtPath, extraPaths = []) {
455
+ const guarded = [...runningCodePaths(), ...extraPaths];
456
+ const target = canonicalPath(wtPath);
450
457
  return (
451
- runningCodePaths().find((p) => isInsideWorktree(p, wtPath, ctx.platform)) ??
452
- null
458
+ guarded.find(
459
+ (p) =>
460
+ typeof p === 'string' &&
461
+ p !== '' &&
462
+ isInsideWorktree(canonicalPath(p), target, ctx.platform),
463
+ ) ?? null
453
464
  );
454
465
  }
455
466
 
@@ -229,10 +229,60 @@ export function decideStoryBranchSeed({ localHas, remoteHas }) {
229
229
  }
230
230
 
231
231
  /**
232
- * Reap merged `story-*` branches (excluding the current one). Never blocks
233
- * init. Protected candidates (unpushed work, dirty worktree, open Story) are
234
- * skipped; the lockfile is shared with `boot-sweep.js` via
235
- * `resolveSweepLockPath` so concurrent reaps cannot race.
232
+ * Remove closed-Story `.worktrees/story-<id>` trees through the boot sweep's
233
+ * own seam (`runWorktreeSweep`: same lock, same invariants). The Story being
234
+ * initialized is always kept, whatever its ticket state. Never throws: a
235
+ * failure lands in the returned outcome, which rides the init envelope.
236
+ *
237
+ * @returns {Promise<object>} `{ ok, reaped, skipped, reason?, error? }`.
238
+ */
239
+ export async function reapClosedStoryWorktrees({
240
+ cwd,
241
+ storyBranch,
242
+ provider,
243
+ lockPath,
244
+ lockTimeoutMs,
245
+ worktreeSweepFn,
246
+ acquireLockFn,
247
+ }) {
248
+ const logger = {
249
+ info: (m) => progress('CLEANUP', m),
250
+ warn: (m) => progress('CLEANUP', `⚠️ ${m}`),
251
+ };
252
+ try {
253
+ const { runWorktreeSweep } = await import('./boot-sweep.js');
254
+ const outcome = await runWorktreeSweep({
255
+ root: cwd,
256
+ provider,
257
+ lockPath,
258
+ lockTimeoutMs,
259
+ ...(worktreeSweepFn ? { sweepFn: worktreeSweepFn } : {}),
260
+ ...(acquireLockFn ? { acquireLockFn } : {}),
261
+ logger,
262
+ logTag: '[worktree-sweep]',
263
+ keepPaths: [path.join(cwd, '.worktrees', storyBranch)],
264
+ });
265
+ if (outcome.reaped?.length > 0) {
266
+ progress(
267
+ 'CLEANUP',
268
+ `🧹 removed ${outcome.reaped.length} closed-Story worktree(s).`,
269
+ );
270
+ }
271
+ return outcome;
272
+ } catch (err) {
273
+ const msg = err?.message ?? String(err);
274
+ logger.warn(`worktree sweep threw (init continues): ${msg}`);
275
+ return { ok: false, error: msg, reaped: [], skipped: [] };
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Reap merged `story-*` branches (excluding the current one), then the
281
+ * closed-Story worktrees. Never blocks init. Protected candidates (unpushed
282
+ * work, dirty worktree, open Story) are skipped; the lockfile is shared with
283
+ * `boot-sweep.js` via `resolveSweepLockPath` so concurrent reaps cannot race.
284
+ *
285
+ * @returns {Promise<{ worktreeSweep: object }>}
236
286
  */
237
287
  export async function reapMergedStoryBranches({
238
288
  cwd,
@@ -241,6 +291,8 @@ export async function reapMergedStoryBranches({
241
291
  config,
242
292
  provider,
243
293
  injectedSweep,
294
+ worktreeSweepFn,
295
+ acquireLockFn,
244
296
  }) {
245
297
  const sweepFn =
246
298
  injectedSweep ??
@@ -248,7 +300,37 @@ export async function reapMergedStoryBranches({
248
300
  const tempRoot = config?.project?.paths?.tempRoot ?? 'temp';
249
301
  const lockPath = resolveSweepLockPath({ cwd, tempRoot });
250
302
  const lockTimeoutMs =
251
- config.delivery?.worktreeIsolation?.sweepLockMs ?? 60_000;
303
+ config?.delivery?.worktreeIsolation?.sweepLockMs ?? 60_000;
304
+ await reapMergedBranches({
305
+ cwd,
306
+ baseBranch,
307
+ storyBranch,
308
+ provider,
309
+ sweepFn,
310
+ lockPath,
311
+ lockTimeoutMs,
312
+ });
313
+ const worktreeSweep = await reapClosedStoryWorktrees({
314
+ cwd,
315
+ storyBranch,
316
+ provider,
317
+ lockPath,
318
+ lockTimeoutMs,
319
+ worktreeSweepFn,
320
+ acquireLockFn,
321
+ });
322
+ return { worktreeSweep };
323
+ }
324
+
325
+ async function reapMergedBranches({
326
+ cwd,
327
+ baseBranch,
328
+ storyBranch,
329
+ provider,
330
+ sweepFn,
331
+ lockPath,
332
+ lockTimeoutMs,
333
+ }) {
252
334
  try {
253
335
  const sweep = await sweepFn({
254
336
  cwd,
@@ -300,9 +382,12 @@ export async function reapMergedStoryBranches({
300
382
  * @param {object} opts.config
301
383
  * @param {object} opts.provider
302
384
  * @param {Function|undefined} opts.injectedSweep
385
+ * @param {Function} [opts.worktreeSweepFn] Test override for the
386
+ * closed-Story worktree sweep.
303
387
  * @param {Function} opts.progress
304
388
  * @param {import('./lib/git/cached-fetch.js').FetchCache} [opts.fetchCache]
305
389
  * Test override; production shares the module singleton.
390
+ * @returns {Promise<{ worktreeSweep: object }>}
306
391
  */
307
392
  export async function materializeBaseBranch({
308
393
  cwd,
@@ -311,6 +396,7 @@ export async function materializeBaseBranch({
311
396
  config,
312
397
  provider,
313
398
  injectedSweep,
399
+ worktreeSweepFn,
314
400
  progress,
315
401
  fetchCache,
316
402
  }) {
@@ -326,13 +412,14 @@ export async function materializeBaseBranch({
326
412
  );
327
413
  }
328
414
 
329
- await reapMergedStoryBranches({
415
+ const { worktreeSweep } = await reapMergedStoryBranches({
330
416
  cwd,
331
417
  baseBranch,
332
418
  storyBranch,
333
419
  config,
334
420
  provider,
335
421
  injectedSweep,
422
+ worktreeSweepFn,
336
423
  });
337
424
 
338
425
  if (!branchExistsLocally(baseBranch, cwd)) {
@@ -342,10 +429,19 @@ export async function materializeBaseBranch({
342
429
  `Failed to fetch base branch ${baseBranch}: ${r.stderr || '(no stderr)'}`,
343
430
  );
344
431
  }
345
- return;
432
+ return { worktreeSweep };
346
433
  }
347
434
 
348
- // `git fetch` leaves local base at the old tip until fast-forwarded.
435
+ fastForwardBase({ cwd, baseBranch, progress });
436
+ return { worktreeSweep };
437
+ }
438
+
439
+ /**
440
+ * `git fetch` leaves local base at the old tip until fast-forwarded.
441
+ *
442
+ * @param {{ cwd: string, baseBranch: string, progress: Function }} opts
443
+ */
444
+ function fastForwardBase({ cwd, baseBranch, progress }) {
349
445
  const ffPlan = planFastForward({ cwd, baseBranch });
350
446
  const ff = executeFastForward({
351
447
  cwd,
@@ -461,6 +557,7 @@ export async function runSingleStoryInit({
461
557
  injectedProvider,
462
558
  injectedConfig,
463
559
  injectedSweep,
560
+ injectedWorktreeSweep,
464
561
  injectedAcquireLease,
465
562
  steal = false,
466
563
  injectedVerifyRemote,
@@ -533,6 +630,7 @@ export async function runSingleStoryInit({
533
630
  let workCwd = cwd;
534
631
  let worktreeCreated = false;
535
632
  let installStatus = { status: 'skipped', reason: 'dry-run' };
633
+ let worktreeSweep = null;
536
634
 
537
635
  if (!dryRun) {
538
636
  const acquire = injectedAcquireLease ?? acquireStoryLease;
@@ -563,15 +661,17 @@ export async function runSingleStoryInit({
563
661
  await rollUpContainerEpic(provider, storyId, config);
564
662
 
565
663
  try {
566
- await injectedMaterialize({
567
- cwd,
568
- baseBranch,
569
- storyBranch,
570
- config,
571
- provider,
572
- injectedSweep,
573
- progress,
574
- });
664
+ ({ worktreeSweep } =
665
+ (await injectedMaterialize({
666
+ cwd,
667
+ baseBranch,
668
+ storyBranch,
669
+ config,
670
+ provider,
671
+ injectedSweep,
672
+ worktreeSweepFn: injectedWorktreeSweep,
673
+ progress,
674
+ })) ?? {});
575
675
  injectedSeedBranch({ cwd, storyBranch, baseBranch, progress });
576
676
  ({ workCwd, worktreeCreated, installStatus } =
577
677
  await injectedProvisionWorktree({
@@ -610,6 +710,9 @@ export async function runSingleStoryInit({
610
710
  installStatus,
611
711
  dependenciesInstalled,
612
712
  installFailed: installStatus.status === 'failed',
713
+ // Closed-Story worktree sweep outcome; a failure degrades here, never
714
+ // into an init failure. `null` under --dry-run.
715
+ worktreeSweep: worktreeSweep ?? null,
613
716
  dryRun,
614
717
  remoteVerified: remote.remoteVerified,
615
718
  remoteProbe: { remoteUrl: remote.remoteUrl, detail: remote.detail },
@@ -5,9 +5,9 @@ description: >-
5
5
  `git stash` entries — each step gated by operator confirmation.
6
6
  ---
7
7
 
8
- # /git-cleanup [--fast-forward-main] [--prune-remotes] [--branches] [--stashes] [--execute] [--remote] [--yes] [--include-content-merged] [--drop-stashes <ref>] [--exclude <pattern>] [--json]
8
+ # /clean-git [--fast-forward-main] [--prune-remotes] [--branches] [--stashes] [--execute] [--remote] [--yes] [--include-content-merged] [--drop-stashes <ref>] [--exclude <pattern>] [--json]
9
9
 
10
- `/git-cleanup` folds the four cleanup steps operators routinely run by hand
10
+ `/clean-git` folds the four cleanup steps operators routinely run by hand
11
11
  after a busy session into a single pipeline with per-step confirmation. It is a
12
12
  **recovery tool**, not a routine chore: the delivering flows already reap their
13
13
  own merged refs and fast-forward the base branch (see
@@ -22,13 +22,13 @@ Reach for it when the automated hygiene left an unusual state behind.
22
22
  > skill citation.
23
23
 
24
24
  The enumeration + reap logic lives in
25
- [`git-cleanup.js`](../scripts/git-cleanup.js) — it computes the candidate list,
25
+ [`clean-git.js`](../scripts/clean-git.js) — it computes the candidate list,
26
26
  the skip taxonomy, the detection signals, and the JSON envelope, and prints them
27
27
  itself. Without `--execute` the script is a **dry-run preview**; nothing is
28
28
  mutated. When no phase flag is passed, **all four phases run** sequentially; a
29
29
  phase flag narrows the run. A failure in one phase does not short-circuit the
30
30
  others — each runs and reports independently. The script documents its own
31
- flags: `node .agents/scripts/git-cleanup.js --help`.
31
+ flags: `node .agents/scripts/clean-git.js --help`.
32
32
 
33
33
  ## Phases
34
34
 
@@ -65,24 +65,24 @@ merged-PR branch in scope unless `--exclude`d.
65
65
 
66
66
  ```bash
67
67
  # Preview all four phases (no mutation).
68
- node .agents/scripts/git-cleanup.js
68
+ node .agents/scripts/clean-git.js
69
69
 
70
70
  # Run everything non-interactively, including origin refs. Branches detected
71
71
  # only by content-equivalence keep their origin ref — see the note below.
72
- node .agents/scripts/git-cleanup.js --execute --remote --yes
72
+ node .agents/scripts/clean-git.js --execute --remote --yes
73
73
 
74
74
  # Same, but also delete the origin refs of content-merged branches. Nobody is
75
75
  # watching, so opting in is the whole confirmation this delete ever gets.
76
- node .agents/scripts/git-cleanup.js --execute --remote --yes \
76
+ node .agents/scripts/clean-git.js --execute --remote --yes \
77
77
  --include-content-merged
78
78
 
79
79
  # Only fast-forward main.
80
- node .agents/scripts/git-cleanup.js --fast-forward-main --execute
80
+ node .agents/scripts/clean-git.js --fast-forward-main --execute
81
81
 
82
82
  # Only sweep merged branches + their origin refs.
83
- node .agents/scripts/git-cleanup.js --branches --execute --remote
83
+ node .agents/scripts/clean-git.js --branches --execute --remote
84
84
 
85
85
  # Drop specific stashes under --yes.
86
- node .agents/scripts/git-cleanup.js --stashes --execute --yes \
86
+ node .agents/scripts/clean-git.js --stashes --execute --yes \
87
87
  --drop-stashes 'stash@{0}' --drop-stashes 'stash@{2}'
88
88
  ```