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
@@ -37,6 +37,8 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
37
37
  'check-test-temp-hygiene.js',
38
38
  'check-windows-git-perf.js',
39
39
  'ceremony-derive.js',
40
+ 'clean-temp.js',
41
+ 'clean-worktrees.js',
40
42
  'cleanup-repo-test-temp.js',
41
43
  'coverage-capture.js',
42
44
  'deliver-light.js',
@@ -51,7 +53,7 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
51
53
  'generate-lens-checklists.js',
52
54
  'generate-skills-index.js',
53
55
  'generate-workflows-doc.js',
54
- 'git-cleanup.js',
56
+ 'clean-git.js',
55
57
  'lint-issue-body.js',
56
58
  'mandrel-update-preflight.js',
57
59
  'merge-baseline.js',
@@ -1,5 +1,5 @@
1
1
  /**
2
- * git-cleanup `main()`: argv, base-branch resolution, the four phases, and
2
+ * `clean-git.js` `main()`: argv, base-branch resolution, the four phases, and
3
3
  * the optional JSON envelope.
4
4
  *
5
5
  * @module lib/orchestration/git-cleanup/phases/cli
@@ -1,11 +1,17 @@
1
1
  /**
2
- * plan-runner/worktree-sweep.js — plan-boot sweep that force-removes
2
+ * plan-runner/worktree-sweep.js — boot sweep that removes
3
3
  * `.worktrees/story-<id>/` entries whose Story is closed or `agent::done`,
4
- * after draining the pending-cleanup manifest.
4
+ * after draining the pending-cleanup manifest. Called by `runBootSweep`
5
+ * (`boot-sweep.js`) under the shared merged-branch sweep lock.
5
6
  *
6
- * `--force` is deliberate: a done Story's branch is already merged, so any
7
- * residue (dirty artifacts, an interrupted rebase, a Windows lock) is noise;
8
- * the `WorktreeManager.reap` safety rails are for the active close path.
7
+ * Removal goes through `removeWorktreeWithRecovery` — the same seam close
8
+ * uses: Windows lock retries, an `fs.rm` fallback that discards residue a
9
+ * done Story's merged branch no longer needs, and the pending-cleanup
10
+ * hand-off when that exhausts. Branches are never deleted here; the
11
+ * merged-branch sweep owns them.
12
+ *
13
+ * Two trees are never touched: an open Story's, and the one the running
14
+ * process was loaded from or is working in (`findRunningCodeInside`).
9
15
  */
10
16
 
11
17
  import path from 'node:path';
@@ -13,8 +19,13 @@ import * as defaultGit from '../../git-utils.js';
13
19
  import { NOOP_LOGGER } from '../../Logger.js';
14
20
  import { AGENT_LABELS } from '../../label-constants.js';
15
21
  import { concurrentMap } from '../../util/concurrent-map.js';
16
- import { parseWorktreePorcelain } from '../../worktree/inspector.js';
22
+ import { canonicalPath } from '../../worktree/canonical-path.js';
23
+ import { parseWorktreePorcelain, samePath } from '../../worktree/inspector.js';
17
24
  import { forceDrainPendingCleanup } from '../../worktree/lifecycle/force-drain.js';
25
+ import {
26
+ findRunningCodeInside,
27
+ removeWorktreeWithRecovery,
28
+ } from '../../worktree/lifecycle/reap.js';
18
29
 
19
30
  const TICKET_READ_CONCURRENCY = 8;
20
31
 
@@ -27,33 +38,32 @@ function isStoryDone(ticket) {
27
38
  return labels.includes(DONE_LABEL);
28
39
  }
29
40
 
30
- function storyIdFromPath(wtPath) {
31
- const parts = wtPath.replace(/\\/g, '/').split('/');
32
- const last = parts[parts.length - 1] ?? '';
33
- return defaultGit.parseStoryBranch(last);
41
+ /**
42
+ * The Story id of a `<worktreeRoot>/story-<id>` entry, else `null` — a
43
+ * `story-<id>` directory anywhere else (e.g. `.claude/worktrees/`) is not
44
+ * this sweep's to judge.
45
+ *
46
+ * @param {string} wtPath
47
+ * @param {string} worktreeRoot
48
+ * @param {string} platform
49
+ * @returns {number|null}
50
+ */
51
+ function storyIdFromPath(wtPath, worktreeRoot, platform) {
52
+ const parent = path.dirname(canonicalPath(wtPath));
53
+ if (!samePath(parent, canonicalPath(worktreeRoot), platform)) return null;
54
+ return defaultGit.parseStoryBranch(path.basename(path.resolve(wtPath)));
34
55
  }
35
56
 
36
57
  /**
37
- * Never touches a worktree whose Story is still open.
58
+ * Validate and normalise the options bag.
38
59
  *
39
60
  * @param {object} opts
40
- * @param {object} opts.provider Only `getTicket(id)` is required.
41
- * @param {string} opts.repoRoot Absolute path to the main checkout.
42
- * @param {object} [opts.git]
43
- * @param {object} [opts.logger]
44
- * @returns {Promise<{
45
- * reaped: Array<{ storyId: number, path: string }>,
46
- * skipped: Array<{ storyId: number|null, path: string, reason: string }>,
47
- * }>}
61
+ * @returns {object}
48
62
  */
49
- export async function sweepStaleStoryWorktrees(opts = {}) {
63
+ function resolveSweepOptions(opts) {
50
64
  const ctx = opts.ctx;
51
65
  const provider = opts.provider ?? ctx?.provider;
52
66
  const repoRoot = opts.repoRoot ?? ctx?.cwd;
53
- const git = opts.git ?? defaultGit;
54
- const logger = opts.logger ?? ctx?.logger ?? NOOP_LOGGER;
55
- const fsRm = opts.fsRm;
56
- const worktreeRoot = opts.worktreeRoot;
57
67
  if (!provider || typeof provider.getTicket !== 'function') {
58
68
  throw new Error(
59
69
  'sweepStaleStoryWorktrees: provider with getTicket(id) is required',
@@ -62,107 +72,149 @@ export async function sweepStaleStoryWorktrees(opts = {}) {
62
72
  if (!repoRoot || typeof repoRoot !== 'string') {
63
73
  throw new Error('sweepStaleStoryWorktrees: repoRoot is required');
64
74
  }
75
+ const platform = opts.platform ?? process.platform;
76
+ return {
77
+ provider,
78
+ repoRoot,
79
+ platform,
80
+ git: opts.git ?? defaultGit,
81
+ logger: opts.logger ?? ctx?.logger ?? NOOP_LOGGER,
82
+ fsRm: opts.fsRm,
83
+ sleepFn: opts.sleepFn,
84
+ worktreeRoot: path.resolve(
85
+ opts.worktreeRoot ?? path.join(repoRoot, '.worktrees'),
86
+ ),
87
+ guardPaths: [process.cwd(), ...(opts.runningPaths ?? [])],
88
+ };
89
+ }
65
90
 
66
- const resolvedWorktreeRoot =
67
- worktreeRoot ?? path.join(repoRoot, '.worktrees');
91
+ /**
92
+ * Read every Story worktree's ticket; each mapper captures its own error so
93
+ * one provider hiccup cannot abort the sweep via concurrentMap's
94
+ * first-rejection-wins policy.
95
+ */
96
+ function readStoryTickets(entries, o) {
97
+ return concurrentMap(
98
+ entries,
99
+ async (entry) => {
100
+ const wtPath = entry.path;
101
+ const storyId = wtPath
102
+ ? storyIdFromPath(wtPath, o.worktreeRoot, o.platform)
103
+ : null;
104
+ if (storyId === null) return { kind: 'non-story' };
105
+ try {
106
+ const ticket = await o.provider.getTicket(storyId);
107
+ return { kind: 'ok', wtPath, storyId, ticket };
108
+ } catch (err) {
109
+ return { kind: 'provider-error', wtPath, storyId, error: err };
110
+ }
111
+ },
112
+ { concurrency: TICKET_READ_CONCURRENCY },
113
+ );
114
+ }
115
+
116
+ /**
117
+ * Why a read entry must be kept, or `null` when it may be removed.
118
+ *
119
+ * @returns {string|null}
120
+ */
121
+ function keepReason(r, o) {
122
+ if (r.kind === 'provider-error') {
123
+ o.logger.warn(
124
+ `worktree-sweep: provider.getTicket(#${r.storyId}) failed: ${r.error.message}`,
125
+ );
126
+ return `provider-error: ${r.error.message}`;
127
+ }
128
+ if (!isStoryDone(r.ticket)) return 'story-open';
129
+ const selfPath = findRunningCodeInside(o, r.wtPath, o.guardPaths);
130
+ if (selfPath) {
131
+ o.logger.warn(
132
+ `worktree-sweep: skipped storyId=${r.storyId} path=${r.wtPath} — the running process uses it (${selfPath})`,
133
+ );
134
+ return 'running-from-target-tree';
135
+ }
136
+ return null;
137
+ }
138
+
139
+ async function removeOne(r, o) {
140
+ const ctx = {
141
+ repoRoot: o.repoRoot,
142
+ git: o.git,
143
+ logger: o.logger,
144
+ platform: o.platform,
145
+ worktreeRoot: o.worktreeRoot,
146
+ listCache: { list: null, ts: 0 },
147
+ fsRm: o.fsRm,
148
+ };
149
+ const res = await removeWorktreeWithRecovery(ctx, r.wtPath, {
150
+ storyId: r.storyId,
151
+ ...(o.sleepFn ? { sleepFn: o.sleepFn, retryDelay: 0 } : {}),
152
+ });
153
+ return res.removed
154
+ ? null
155
+ : `remove-failed: ${res.reason ?? 'worktree-remove-failed'}`;
156
+ }
157
+
158
+ /**
159
+ * Never touches a worktree whose Story is still open, nor the one the
160
+ * running process uses.
161
+ *
162
+ * @param {object} opts
163
+ * @param {object} opts.provider Only `getTicket(id)` is required.
164
+ * @param {string} opts.repoRoot Absolute path to the main checkout.
165
+ * @param {object} [opts.git]
166
+ * @param {object} [opts.logger]
167
+ * @param {string[]} [opts.runningPaths] Extra paths the running process owns.
168
+ * @returns {Promise<{
169
+ * reaped: Array<{ storyId: number, path: string }>,
170
+ * skipped: Array<{ storyId: number|null, path: string, reason: string }>,
171
+ * }>}
172
+ */
173
+ export async function sweepStaleStoryWorktrees(opts = {}) {
174
+ const o = resolveSweepOptions(opts);
68
175
 
69
176
  // Drain the pending-cleanup manifest first so entries whose Windows locks
70
177
  // have released self-heal instead of accumulating.
71
178
  const drainResult = await forceDrainPendingCleanup({
72
- repoRoot,
73
- worktreeRoot: resolvedWorktreeRoot,
74
- git,
75
- fsRm,
76
- logger,
179
+ repoRoot: o.repoRoot,
180
+ worktreeRoot: o.worktreeRoot,
181
+ git: o.git,
182
+ fsRm: o.fsRm,
183
+ logger: o.logger,
77
184
  });
78
185
 
79
186
  const reaped = [];
80
187
  const skipped = [];
81
188
 
82
- const listRes = git.gitSpawn(repoRoot, 'worktree', 'list', '--porcelain');
189
+ const listRes = o.git.gitSpawn(o.repoRoot, 'worktree', 'list', '--porcelain');
83
190
  if (listRes.status !== 0) {
84
- logger.warn(
191
+ o.logger.warn(
85
192
  `worktree-sweep: git worktree list failed: ${listRes.stderr || listRes.stdout || 'unknown'}`,
86
193
  );
87
194
  return { reaped, skipped };
88
195
  }
89
196
 
90
- const entries = parseWorktreePorcelain(listRes.stdout || '');
91
-
92
- // Each mapper captures its own error so one provider hiccup cannot abort
93
- // the sweep via concurrentMap's first-rejection-wins policy.
94
- const reads = await concurrentMap(
95
- entries,
96
- async (entry) => {
97
- const wtPath = entry.path;
98
- if (!wtPath) return { kind: 'no-path' };
99
- const storyId = storyIdFromPath(wtPath);
100
- if (storyId === null) return { kind: 'non-story' };
101
- try {
102
- const ticket = await provider.getTicket(storyId);
103
- return { kind: 'ok', wtPath, storyId, ticket };
104
- } catch (err) {
105
- return { kind: 'provider-error', wtPath, storyId, error: err };
106
- }
107
- },
108
- { concurrency: TICKET_READ_CONCURRENCY },
197
+ const reads = await readStoryTickets(
198
+ parseWorktreePorcelain(listRes.stdout || ''),
199
+ o,
109
200
  );
110
201
 
111
202
  // Removes stay sequential: they mutate .git/worktrees/, and racing git's
112
203
  // locking on Windows can leave a partial admin dir the next remove trips on.
113
204
  for (const r of reads) {
114
- if (r.kind === 'no-path' || r.kind === 'non-story') continue;
115
- if (r.kind === 'provider-error') {
116
- skipped.push({
117
- storyId: r.storyId,
118
- path: r.wtPath,
119
- reason: `provider-error: ${r.error.message}`,
120
- });
121
- logger.warn(
122
- `worktree-sweep: provider.getTicket(#${r.storyId}) failed: ${r.error.message}`,
123
- );
124
- continue;
125
- }
126
-
127
- if (!isStoryDone(r.ticket)) {
128
- skipped.push({
129
- storyId: r.storyId,
130
- path: r.wtPath,
131
- reason: 'story-open',
132
- });
133
- continue;
134
- }
135
-
136
- const res = git.gitSpawn(
137
- repoRoot,
138
- 'worktree',
139
- 'remove',
140
- '--force',
141
- r.wtPath,
142
- );
143
- if (res.status !== 0) {
144
- const reason = (
145
- res.stderr ||
146
- res.stdout ||
147
- 'worktree-remove-failed'
148
- ).trim();
149
- skipped.push({
150
- storyId: r.storyId,
151
- path: r.wtPath,
152
- reason: `remove-failed: ${reason}`,
153
- });
154
- logger.warn(
155
- `worktree-sweep: failed to reap storyId=${r.storyId} path=${r.wtPath}: ${reason}`,
156
- );
205
+ if (r.kind === 'non-story') continue;
206
+ const reason = keepReason(r, o) ?? (await removeOne(r, o));
207
+ if (reason) {
208
+ skipped.push({ storyId: r.storyId, path: r.wtPath, reason });
157
209
  continue;
158
210
  }
159
211
  reaped.push({ storyId: r.storyId, path: r.wtPath });
160
- logger.info(
212
+ o.logger.info(
161
213
  `worktree-sweep: reaped stale worktree storyId=${r.storyId} path=${r.wtPath}`,
162
214
  );
163
215
  }
164
216
 
165
- git.gitSpawn(repoRoot, 'worktree', 'prune');
217
+ o.git.gitSpawn(o.repoRoot, 'worktree', 'prune');
166
218
 
167
219
  return {
168
220
  reaped,
@@ -11,7 +11,7 @@ import {
11
11
  executeFastForward as defaultExecuteFastForward,
12
12
  planCleanup as defaultPlanCleanup,
13
13
  planFastForward as defaultPlanFastForward,
14
- } from '../git-cleanup.js';
14
+ } from '../clean-git.js';
15
15
  import { evaluateProtection as defaultEvaluateProtection } from './single-story-sweep/protection.js';
16
16
  import { acquireSweepLock as defaultAcquireSweepLock } from './single-story-sweep/sweep-lock.js';
17
17
 
@@ -175,7 +175,7 @@ export function sweepMergedStoryBranches(args = {}) {
175
175
 
176
176
  /**
177
177
  * Content-merged candidates are report-only: content equivalence is weaker
178
- * than a merged PR or ancestry, so only an operator-confirmed `/git-cleanup`
178
+ * than a merged PR or ancestry, so only an operator-confirmed `/clean-git`
179
179
  * may reap them.
180
180
  */
181
181
  function partitionContentMerged(candidates) {
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Filesystem primitives for the temp-retention engine: sizing a tree and
3
+ * deleting one while sparing the never-purged basenames at any depth.
4
+ */
5
+
6
+ import path from 'node:path';
7
+
8
+ /**
9
+ * Never deleted, re-checked at the deletion site: `signals.ndjson` is read
10
+ * long after merge and its loss is silent and unrecoverable.
11
+ */
12
+ export const KEEP_BASENAMES = Object.freeze(['signals.ndjson']);
13
+
14
+ /**
15
+ * `readdir` yielding `[]` for an absent or unreadable directory.
16
+ *
17
+ * @param {typeof import('node:fs/promises')} fsp
18
+ * @param {string} dir
19
+ * @returns {Promise<import('node:fs').Dirent[]>}
20
+ */
21
+ export async function safeReaddir(fsp, dir) {
22
+ try {
23
+ return await fsp.readdir(dir, { withFileTypes: true });
24
+ } catch {
25
+ return [];
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Recursive byte total; a vanished child is skipped.
31
+ *
32
+ * @param {typeof import('node:fs/promises')} fsp
33
+ * @param {string} target
34
+ * @returns {Promise<number>}
35
+ */
36
+ export async function sizeOf(fsp, target) {
37
+ let total = 0;
38
+ const stack = [target];
39
+ while (stack.length > 0) {
40
+ const current = stack.pop();
41
+ let stats;
42
+ try {
43
+ stats = await fsp.stat(current);
44
+ } catch {
45
+ continue;
46
+ }
47
+ if (!stats.isDirectory()) {
48
+ total += stats.size;
49
+ continue;
50
+ }
51
+ for (const child of await safeReaddir(fsp, current)) {
52
+ stack.push(path.join(current, child.name));
53
+ }
54
+ }
55
+ return total;
56
+ }
57
+
58
+ /**
59
+ * Paths of every never-purged basename at any depth under `target`.
60
+ *
61
+ * @param {typeof import('node:fs/promises')} fsp
62
+ * @param {string} target
63
+ * @returns {Promise<string[]>}
64
+ */
65
+ async function findKeptDescendants(fsp, target) {
66
+ const kept = [];
67
+ const stack = [target];
68
+ while (stack.length > 0) {
69
+ const current = stack.pop();
70
+ for (const child of await safeReaddir(fsp, current)) {
71
+ const childPath = path.join(current, child.name);
72
+ if (child.isDirectory()) stack.push(childPath);
73
+ else if (KEEP_BASENAMES.includes(child.name)) kept.push(childPath);
74
+ }
75
+ }
76
+ return kept;
77
+ }
78
+
79
+ /**
80
+ * Delete `target` while sparing every never-purged basename beneath it: a
81
+ * tree holding none goes in one `rm`; otherwise its children are removed
82
+ * one by one and the kept files (with their parent dirs) stay.
83
+ *
84
+ * @param {typeof import('node:fs/promises')} fsp
85
+ * @param {string} target
86
+ * @param {number} [knownBytes] Pre-computed size, spared a second walk.
87
+ * @returns {Promise<{ bytes: number, kept: string[] }>}
88
+ */
89
+ export async function removeSparingKept(fsp, target, knownBytes) {
90
+ const stats = await fsp.stat(target);
91
+ if (!stats.isDirectory()) {
92
+ if (KEEP_BASENAMES.includes(path.basename(target))) {
93
+ return { bytes: 0, kept: [target] };
94
+ }
95
+ await fsp.rm(target, { force: true });
96
+ return { bytes: stats.size, kept: [] };
97
+ }
98
+ const kept = await findKeptDescendants(fsp, target);
99
+ if (kept.length === 0) {
100
+ const bytes = knownBytes ?? (await sizeOf(fsp, target));
101
+ await fsp.rm(target, { recursive: true, force: true });
102
+ return { bytes, kept };
103
+ }
104
+ let bytes = 0;
105
+ for (const child of await safeReaddir(fsp, target)) {
106
+ bytes += (await removeSparingKept(fsp, path.join(target, child.name)))
107
+ .bytes;
108
+ }
109
+ return { bytes, kept };
110
+ }