mandrel 2.65.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 (82) hide show
  1. package/.agents/agents/acceptance-critic.md +7 -7
  2. package/.agents/agents/auditor.md +17 -18
  3. package/.agents/agents/plan-critic.md +5 -5
  4. package/.agents/agents/story-worker.md +5 -5
  5. package/.agents/docs/agentrc-reference.json +2 -1
  6. package/.agents/docs/configuration.md +2 -1
  7. package/.agents/docs/execution-reference.md +27 -5
  8. package/.agents/docs/workflows.md +4 -2
  9. package/.agents/instructions.md +12 -13
  10. package/.agents/rules/ci-remediation.md +3 -3
  11. package/.agents/rules/gherkin-standards.md +3 -2
  12. package/.agents/rules/git-conventions-reference.md +17 -8
  13. package/.agents/rules/git-conventions.md +10 -8
  14. package/.agents/rules/testing-standards.md +8 -7
  15. package/.agents/runtime-deps.json +1 -1
  16. package/.agents/schemas/agentrc.schema.json +6 -1
  17. package/.agents/scripts/boot-sweep.js +97 -9
  18. package/.agents/scripts/bootstrap.js +94 -89
  19. package/.agents/scripts/{git-cleanup.js → clean-git.js} +2 -2
  20. package/.agents/scripts/clean-temp.js +54 -0
  21. package/.agents/scripts/clean-worktrees.js +593 -0
  22. package/.agents/scripts/drain-pending-cleanup.js +5 -4
  23. package/.agents/scripts/lib/baselines/duplication-scanner.js +17 -7
  24. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +78 -78
  25. package/.agents/scripts/lib/clean-temp.js +440 -0
  26. package/.agents/scripts/lib/cli/standard-args.js +60 -76
  27. package/.agents/scripts/lib/cli-args.js +26 -0
  28. package/.agents/scripts/lib/config/gates/shared.js +3 -3
  29. package/.agents/scripts/lib/config-settings-schema-delivery.js +11 -2
  30. package/.agents/scripts/lib/feedback-loop/graduate-steps.js +205 -0
  31. package/.agents/scripts/lib/feedback-loop/graduator-core.js +47 -782
  32. package/.agents/scripts/lib/feedback-loop/graduator-gh.js +449 -0
  33. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  34. package/.agents/scripts/lib/observability/close-telemetry.js +330 -0
  35. package/.agents/scripts/lib/observability/runtime-friction.js +2 -0
  36. package/.agents/scripts/lib/observability/signal-validator.js +17 -5
  37. package/.agents/scripts/lib/observability/source-classifier.js +3 -1
  38. package/.agents/scripts/lib/orchestration/code-review.js +22 -0
  39. package/.agents/scripts/lib/orchestration/git-cleanup/phases/cli.js +1 -1
  40. package/.agents/scripts/lib/orchestration/plan-metrics.js +76 -63
  41. package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +149 -97
  42. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +23 -0
  43. package/.agents/scripts/lib/orchestration/run-epilogue.js +6 -0
  44. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +2 -0
  45. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +349 -263
  46. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +21 -7
  47. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +4 -0
  48. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +327 -314
  49. package/.agents/scripts/lib/orchestration/ticket-validator.js +19 -36
  50. package/.agents/scripts/lib/signals/detectors/common.js +63 -51
  51. package/.agents/scripts/lib/single-story-sweep.js +2 -2
  52. package/.agents/scripts/lib/temp-removal.js +110 -0
  53. package/.agents/scripts/lib/temp-retention.js +122 -73
  54. package/.agents/scripts/lib/transpile.js +28 -3
  55. package/.agents/scripts/lib/worktree/canonical-path.js +34 -0
  56. package/.agents/scripts/lib/worktree/lifecycle/reap.js +15 -4
  57. package/.agents/scripts/single-story-close.js +10 -2
  58. package/.agents/scripts/single-story-confirm-merge.js +267 -238
  59. package/.agents/scripts/single-story-init.js +120 -17
  60. package/.agents/skills/core/idea-refinement/SKILL.md +6 -6
  61. package/.agents/skills/stack/qa/qa-harness/SKILL.md +1 -2
  62. package/.agents/workflows/audit-architecture.md +5 -4
  63. package/.agents/workflows/audit-documentation.md +5 -5
  64. package/.agents/workflows/audit-performance.md +10 -10
  65. package/.agents/workflows/{git-cleanup.md → clean-git.md} +10 -10
  66. package/.agents/workflows/clean-temp.md +67 -0
  67. package/.agents/workflows/clean-worktrees.md +63 -0
  68. package/.agents/workflows/git-deliver.md +1 -1
  69. package/.agents/workflows/helpers/acceptance-self-eval.md +11 -10
  70. package/.agents/workflows/helpers/audit-lens-core.md +30 -57
  71. package/.agents/workflows/helpers/deliver-digest.md +2 -2
  72. package/.agents/workflows/helpers/deliver-reference.md +3 -1
  73. package/.agents/workflows/helpers/deliver-story-reference.md +2 -2
  74. package/.agents/workflows/helpers/deliver-story.md +6 -1
  75. package/.agents/workflows/helpers/parallel-tooling.md +16 -18
  76. package/.agents/workflows/mandrel-deliver.md +1 -1
  77. package/.agents/workflows/mandrel-plan.md +6 -5
  78. package/docs/CHANGELOG.md +39 -0
  79. package/lib/cli/guarded-sync.js +87 -0
  80. package/lib/cli/sync-agents.js +9 -92
  81. package/lib/cli/sync-commands.js +9 -101
  82. package/package.json +2 -2
@@ -0,0 +1,593 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * clean-worktrees.js — enumerate every git worktree of this project (plus
5
+ * unregistered directories under `.worktrees/`), classify each as a removal
6
+ * candidate or as kept-with-a-reason, and — only under `--execute` — remove
7
+ * the candidates through the worktree removal seam (flags: see HELP).
8
+ *
9
+ * Candidate classes: `closed-story`, `merged-branch`, `orphan-dir`,
10
+ * `detached`. Safety: dry-run by default; nothing outside the project root,
11
+ * no dirty tree, no HEAD unreachable from a remote-tracking ref, no tree a
12
+ * live process uses, and a `detached` tree only on a per-entry interactive
13
+ * yes (never under `--yes`).
14
+ */
15
+
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { createInterface } from 'node:readline/promises';
19
+ import { parseArgs } from 'node:util';
20
+
21
+ import { spawnCapture } from './lib/child-exec.js';
22
+ import { runAsCli } from './lib/cli-utils.js';
23
+ import { PROJECT_ROOT, resolveConfig } from './lib/config-resolver.js';
24
+ import * as defaultGit from './lib/git-utils.js';
25
+ import { AGENT_LABELS } from './lib/label-constants.js';
26
+ import { createProvider } from './lib/provider-factory.js';
27
+ import { makeGhRunner } from './lib/single-story-sweep/protection-ctx.js';
28
+ import { canonicalPath } from './lib/worktree/canonical-path.js';
29
+ import {
30
+ isInsideWorktree,
31
+ parseWorktreePorcelain,
32
+ samePath,
33
+ } from './lib/worktree/inspector.js';
34
+ import {
35
+ findRunningCodeInside,
36
+ removeWorktreeWithRecovery,
37
+ } from './lib/worktree/lifecycle/reap.js';
38
+
39
+ export const CLASSES = Object.freeze({
40
+ CLOSED_STORY: 'closed-story',
41
+ MERGED_BRANCH: 'merged-branch',
42
+ ORPHAN_DIR: 'orphan-dir',
43
+ DETACHED: 'detached',
44
+ });
45
+
46
+ const WORKTREE_DIR = '.worktrees';
47
+
48
+ /** Removal-seam diagnostics go to stderr: stdout carries the table / JSON. */
49
+ const STDERR_LOGGER = Object.freeze({
50
+ info: (m) => process.stderr.write(`${m}\n`),
51
+ warn: (m) => process.stderr.write(`${m}\n`),
52
+ error: (m) => process.stderr.write(`${m}\n`),
53
+ });
54
+
55
+ const writeStdout = (text) => process.stdout.write(`${text}\n`);
56
+
57
+ const HELP = `Usage: node .agents/scripts/clean-worktrees.js [options]
58
+
59
+ Lists every worktree of this project — each registered one plus any
60
+ unregistered directory under .worktrees/ — as a removal candidate or as
61
+ kept with a reason, and prints its size. Dry-run by default: nothing is
62
+ removed without --execute.
63
+
64
+ Candidate classes:
65
+ closed-story .worktrees/story-<id> whose Story is closed or agent::done
66
+ merged-branch a branch whose PR is MERGED and whose HEAD is the merged head
67
+ orphan-dir a directory under .worktrees/ git does not register
68
+ detached a registered worktree with a detached HEAD
69
+ (e.g. .claude/worktrees/*) — removed only on a per-entry
70
+ interactive yes, never under --yes
71
+
72
+ Always kept: the main checkout, anything outside the project root, a dirty
73
+ tree, a HEAD no remote-tracking ref contains, a tree the running process or
74
+ (macOS/Linux) any live process uses, an open Story, an unmerged branch.
75
+
76
+ Options:
77
+ --execute Remove candidates (asks per entry unless --yes).
78
+ --yes With --execute, remove non-detached candidates without asking.
79
+ --json Emit the result envelope (with bytesReclaimed) as JSON.
80
+ --cwd <dir> Checkout to inspect. Default: this project's root.
81
+ -h, --help Show this help.
82
+ `;
83
+
84
+ /** Containment on canonical paths (8.3 short names, symlinks, case). */
85
+ function isWithin(child, parent, platform) {
86
+ return isInsideWorktree(
87
+ canonicalPath(child),
88
+ canonicalPath(parent),
89
+ platform,
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Working directories of every visible process, or `null` where the platform
95
+ * does not expose them (Windows) — callers then skip the live-process check.
96
+ *
97
+ * @param {{ platform?: string, fsImpl?: typeof fs, spawn?: Function }} [deps]
98
+ * @returns {string[]|null}
99
+ */
100
+ export function listProcessCwds({
101
+ platform = process.platform,
102
+ fsImpl = fs,
103
+ spawn = spawnCapture,
104
+ } = {}) {
105
+ if (platform === 'linux') return linuxProcessCwds(fsImpl);
106
+ if (platform === 'darwin') return darwinProcessCwds(spawn);
107
+ return null;
108
+ }
109
+
110
+ function linuxProcessCwds(fsImpl) {
111
+ let pids;
112
+ try {
113
+ pids = fsImpl.readdirSync('/proc').filter((d) => /^\d+$/.test(d));
114
+ } catch {
115
+ return null;
116
+ }
117
+ const cwds = [];
118
+ for (const pid of pids) {
119
+ try {
120
+ cwds.push(fsImpl.readlinkSync(`/proc/${pid}/cwd`));
121
+ } catch {
122
+ // Another user's process, or it exited — not ours to see.
123
+ }
124
+ }
125
+ return cwds;
126
+ }
127
+
128
+ function darwinProcessCwds(spawn) {
129
+ const res = spawn('lsof', ['-w', '-a', '-d', 'cwd', '-Fn']);
130
+ if (!res || typeof res.stdout !== 'string' || res.stdout === '') return null;
131
+ return res.stdout
132
+ .split('\n')
133
+ .filter((line) => line.startsWith('n'))
134
+ .map((line) => line.slice(1));
135
+ }
136
+
137
+ /**
138
+ * Directory size in bytes; `du` where available, else a recursive walk that
139
+ * never follows symlinks. `null` when unreadable.
140
+ *
141
+ * @param {string} dir
142
+ * @param {{ platform?: string, spawn?: Function, fsImpl?: typeof fs }} [deps]
143
+ * @returns {number|null}
144
+ */
145
+ export function dirSizeBytes(
146
+ dir,
147
+ { platform = process.platform, spawn = spawnCapture, fsImpl = fs } = {},
148
+ ) {
149
+ if (platform !== 'win32') {
150
+ const res = spawn('du', ['-sk', dir]);
151
+ const kb = Number.parseInt(String(res?.stdout ?? ''), 10);
152
+ if (res?.status === 0 && Number.isFinite(kb)) return kb * 1024;
153
+ }
154
+ return walkSize(dir, fsImpl);
155
+ }
156
+
157
+ function walkSize(target, fsImpl) {
158
+ let stat;
159
+ try {
160
+ stat = fsImpl.lstatSync(target);
161
+ } catch {
162
+ return null;
163
+ }
164
+ if (!stat.isDirectory()) return stat.size;
165
+ let total = 0;
166
+ for (const name of fsImpl.readdirSync(target)) {
167
+ total += walkSize(path.join(target, name), fsImpl) ?? 0;
168
+ }
169
+ return total;
170
+ }
171
+
172
+ /**
173
+ * Registered worktrees (main first, as git lists it) plus directories under
174
+ * `<projectRoot>/.worktrees/` that git does not register.
175
+ *
176
+ * @param {{ cwd: string, git: object, platform: string, fsImpl?: typeof fs }} args
177
+ * @returns {{ projectRoot: string, registered: object[], orphans: string[] }}
178
+ */
179
+ export function enumerateWorktrees({ cwd, git, platform, fsImpl = fs }) {
180
+ const res = git.gitSpawn(cwd, 'worktree', 'list', '--porcelain');
181
+ if (res.status !== 0) {
182
+ throw new Error(
183
+ `git worktree list failed: ${res.stderr || res.stdout || 'unknown'}`,
184
+ );
185
+ }
186
+ const registered = parseWorktreePorcelain(res.stdout || '');
187
+ if (registered.length === 0) throw new Error('git listed no worktrees');
188
+ const projectRoot = canonicalPath(registered[0].path);
189
+ const known = registered.map((r) => canonicalPath(r.path));
190
+ const wtDir = path.join(projectRoot, WORKTREE_DIR);
191
+ let names = [];
192
+ try {
193
+ names = fsImpl
194
+ .readdirSync(wtDir, { withFileTypes: true })
195
+ .filter((d) => d.isDirectory())
196
+ .map((d) => d.name);
197
+ } catch {
198
+ // No .worktrees/ directory: nothing orphaned.
199
+ }
200
+ const orphans = names
201
+ .map((name) => path.join(wtDir, name))
202
+ .filter((p) => !known.some((k) => samePath(k, canonicalPath(p), platform)));
203
+ return { projectRoot, registered, orphans };
204
+ }
205
+
206
+ /** Kept-reason for a tree the running process or a live process uses. */
207
+ function liveUseReason(wtPath, env) {
208
+ if (findRunningCodeInside(env, wtPath, env.guardPaths)) {
209
+ return 'running-from-tree';
210
+ }
211
+ const cwds = env.processCwds ?? [];
212
+ if (cwds.some((c) => isWithin(c, wtPath, env.platform))) {
213
+ return 'live-process';
214
+ }
215
+ return null;
216
+ }
217
+
218
+ /** Kept-reason when the tree holds work that exists nowhere else. */
219
+ function uniqueWorkReason(rec, env) {
220
+ const status = env.git.gitSpawn(rec.path, 'status', '--porcelain');
221
+ if (status.status !== 0 || status.stdout.trim() !== '') return 'dirty-tree';
222
+ if (!rec.head) return 'unpushed-commits';
223
+ const refs = env.git.gitSpawn(
224
+ env.projectRoot,
225
+ 'for-each-ref',
226
+ '--contains',
227
+ rec.head,
228
+ '--count=1',
229
+ '--format=%(refname)',
230
+ 'refs/remotes/',
231
+ );
232
+ if (refs.status !== 0 || refs.stdout.trim() === '') {
233
+ return 'unpushed-commits';
234
+ }
235
+ return null;
236
+ }
237
+
238
+ function isStoryDone(ticket) {
239
+ if (!ticket) return false;
240
+ if (ticket.state === 'closed') return true;
241
+ return (ticket.labels ?? []).includes(AGENT_LABELS.DONE);
242
+ }
243
+
244
+ async function classifyStory(storyId, env) {
245
+ try {
246
+ const ticket = await env.getTicket(storyId);
247
+ return isStoryDone(ticket)
248
+ ? { class: CLASSES.CLOSED_STORY }
249
+ : { reason: 'story-open' };
250
+ } catch (err) {
251
+ return { reason: `provider-error: ${err?.message ?? err}` };
252
+ }
253
+ }
254
+
255
+ function classifyBranch(rec, env) {
256
+ let prs;
257
+ try {
258
+ prs = env.prLookup(rec.branch);
259
+ } catch (err) {
260
+ return { reason: `pr-lookup-failed: ${err?.message ?? err}` };
261
+ }
262
+ const merged = (prs ?? []).some((pr) => pr?.headRefOid === rec.head);
263
+ return merged
264
+ ? { class: CLASSES.MERGED_BRANCH }
265
+ : { reason: 'unmerged-branch' };
266
+ }
267
+
268
+ /** A Story id only for `<projectRoot>/.worktrees/story-<id>` on `story-<id>`. */
269
+ function storyIdOf(rec, env) {
270
+ const parent = path.dirname(path.resolve(rec.path));
271
+ const wtDir = path.join(env.projectRoot, WORKTREE_DIR);
272
+ if (!samePath(canonicalPath(parent), canonicalPath(wtDir), env.platform)) {
273
+ return null;
274
+ }
275
+ const fromDir = defaultGit.parseStoryBranch(path.basename(rec.path));
276
+ return fromDir !== null && fromDir === defaultGit.parseStoryBranch(rec.branch)
277
+ ? fromDir
278
+ : null;
279
+ }
280
+
281
+ /** First kept-reason that needs no network, or `null`. */
282
+ function localKeepReason(rec, env) {
283
+ if (rec.bare) return 'bare';
284
+ if (!isWithin(rec.path, env.projectRoot, env.platform)) {
285
+ return 'outside-project';
286
+ }
287
+ if (!fs.existsSync(rec.path)) return 'path-missing';
288
+ return liveUseReason(rec.path, env) ?? uniqueWorkReason(rec, env);
289
+ }
290
+
291
+ /**
292
+ * @param {object} rec a `parseWorktreePorcelain` record
293
+ * @param {object} env
294
+ * @returns {Promise<{ class?: string, reason?: string }>}
295
+ */
296
+ async function classifyRegistered(rec, env) {
297
+ const kept = localKeepReason(rec, env);
298
+ if (kept) return { reason: kept };
299
+ if (rec.detached) return { class: CLASSES.DETACHED };
300
+ const storyId = storyIdOf(rec, env);
301
+ if (storyId !== null) return classifyStory(storyId, env);
302
+ return classifyBranch(rec, env);
303
+ }
304
+
305
+ /**
306
+ * Classify every worktree: exactly one of `class` (a candidate) or `reason`
307
+ * (kept) per entry.
308
+ *
309
+ * @param {object} env resolved dependencies (see `buildEnv`)
310
+ * @returns {Promise<object[]>}
311
+ */
312
+ export async function classifyWorktrees(env) {
313
+ const entries = [];
314
+ const [main, ...rest] = env.registered;
315
+ entries.push(entryOf(main, { reason: 'main-checkout' }, false));
316
+ for (const rec of rest) {
317
+ entries.push(entryOf(rec, await classifyRegistered(rec, env), true, env));
318
+ }
319
+ for (const dir of env.orphans) {
320
+ const reason = liveUseReason(dir, env);
321
+ const verdict = reason ? { reason } : { class: CLASSES.ORPHAN_DIR };
322
+ entries.push(entryOf({ path: dir }, verdict, true, env));
323
+ }
324
+ return entries;
325
+ }
326
+
327
+ function entryOf(rec, verdict, sized, env) {
328
+ const exists = sized && fs.existsSync(rec.path);
329
+ return {
330
+ // One spelling per tree: git's `C:/…` and a short-name `RUNNER~1` form
331
+ // both become the canonical native path.
332
+ path: canonicalPath(rec.path),
333
+ branch: rec.branch ?? null,
334
+ head: rec.head ?? null,
335
+ class: verdict.class ?? null,
336
+ reason: verdict.reason ?? null,
337
+ sizeBytes: exists ? env.sizeOf(rec.path) : null,
338
+ action: verdict.class ? 'candidate' : 'kept',
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Whether to remove one candidate: `--yes` covers every class but
344
+ * `detached`, which only a per-entry interactive yes can remove.
345
+ *
346
+ * @returns {Promise<{ remove: boolean, reason?: string }>}
347
+ */
348
+ async function decide(entry, opts) {
349
+ const detached = entry.class === CLASSES.DETACHED;
350
+ if (opts.yes && !detached) return { remove: true };
351
+ if (detached && opts.yes) {
352
+ return { remove: false, reason: 'detached-never-under-yes' };
353
+ }
354
+ if (!opts.confirm) {
355
+ return { remove: false, reason: 'needs-confirmation' };
356
+ }
357
+ const ok = await opts.confirm(entry);
358
+ return ok ? { remove: true } : { remove: false, reason: 'declined' };
359
+ }
360
+
361
+ /**
362
+ * Remove the candidates `decide` approves; returns bytes reclaimed.
363
+ *
364
+ * @param {object[]} entries mutated in place (`action`, `reason`)
365
+ * @param {object} opts `{ yes, confirm, removeFn }`
366
+ * @returns {Promise<number>}
367
+ */
368
+ export async function executeRemovals(entries, opts) {
369
+ let bytes = 0;
370
+ for (const entry of entries.filter((e) => e.class)) {
371
+ const verdict = await decide(entry, opts);
372
+ if (!verdict.remove) {
373
+ entry.action = 'skipped';
374
+ entry.reason = verdict.reason;
375
+ continue;
376
+ }
377
+ const res = await opts.removeFn(entry.path);
378
+ entry.action = res.removed ? 'removed' : 'failed';
379
+ if (!res.removed) {
380
+ entry.reason = `remove-failed: ${res.reason ?? 'unknown'}`;
381
+ continue;
382
+ }
383
+ bytes += entry.sizeBytes ?? 0;
384
+ }
385
+ return bytes;
386
+ }
387
+
388
+ function makeRemoveFn({ projectRoot, git, platform, logger }) {
389
+ const ctx = {
390
+ repoRoot: projectRoot,
391
+ git,
392
+ platform,
393
+ logger,
394
+ worktreeRoot: path.join(projectRoot, WORKTREE_DIR),
395
+ listCache: { list: null, ts: 0 },
396
+ };
397
+ return (wtPath) => removeWorktreeWithRecovery(ctx, wtPath, {});
398
+ }
399
+
400
+ function makeTicketReader(projectRoot) {
401
+ let provider = null;
402
+ return (id) => {
403
+ provider ??= createProvider(resolveConfig({ cwd: projectRoot }));
404
+ return provider.getTicket(id);
405
+ };
406
+ }
407
+
408
+ function makePrLookup(projectRoot) {
409
+ const gh = makeGhRunner(projectRoot);
410
+ return (branch) =>
411
+ JSON.parse(
412
+ gh([
413
+ 'pr',
414
+ 'list',
415
+ '--head',
416
+ branch,
417
+ '--state',
418
+ 'merged',
419
+ '--json',
420
+ 'number,headRefOid',
421
+ '--limit',
422
+ '10',
423
+ ]) || '[]',
424
+ );
425
+ }
426
+
427
+ /**
428
+ * Resolve every dependency, defaulting to the real ones.
429
+ *
430
+ * @param {object} args
431
+ * @returns {object}
432
+ */
433
+ function buildEnv(args) {
434
+ const platform = args.platform ?? process.platform;
435
+ const git = args.git ?? defaultGit;
436
+ const cwd = path.resolve(args.cwd ?? PROJECT_ROOT);
437
+ const listing = enumerateWorktrees({ cwd, git, platform });
438
+ const { projectRoot } = listing;
439
+ return {
440
+ ...listing,
441
+ platform,
442
+ git,
443
+ guardPaths: [cwd, process.cwd(), ...(args.runningPaths ?? [])],
444
+ processCwds:
445
+ args.processCwds !== undefined
446
+ ? args.processCwds
447
+ : listProcessCwds({ platform }),
448
+ getTicket: args.getTicket ?? makeTicketReader(projectRoot),
449
+ prLookup: args.prLookup ?? makePrLookup(projectRoot),
450
+ sizeOf: args.sizeOf ?? ((p) => dirSizeBytes(p, { platform })),
451
+ removeFn:
452
+ args.removeFn ??
453
+ makeRemoveFn({
454
+ projectRoot,
455
+ git,
456
+ platform,
457
+ logger: args.logger ?? STDERR_LOGGER,
458
+ }),
459
+ };
460
+ }
461
+
462
+ /**
463
+ * Enumerate, classify and (under `execute`) remove. Seams for tests: every
464
+ * `buildEnv` dependency may be injected.
465
+ *
466
+ * @param {object} [args]
467
+ * @param {string} [args.cwd]
468
+ * @param {boolean} [args.execute]
469
+ * @param {boolean} [args.yes]
470
+ * @param {Function|null} [args.confirm] per-entry interactive prompt
471
+ * @returns {Promise<object>} the `clean-worktrees` envelope.
472
+ */
473
+ export async function runCleanWorktrees(args = {}) {
474
+ const env = buildEnv(args);
475
+ const entries = await classifyWorktrees(env);
476
+ const bytesReclaimed = args.execute
477
+ ? await executeRemovals(entries, {
478
+ yes: args.yes === true,
479
+ confirm: args.confirm ?? null,
480
+ removeFn: env.removeFn,
481
+ })
482
+ : 0;
483
+ return {
484
+ kind: 'clean-worktrees',
485
+ mode: args.execute ? 'execute' : 'dry-run',
486
+ projectRoot: env.projectRoot,
487
+ entries,
488
+ bytesReclaimed,
489
+ };
490
+ }
491
+
492
+ /**
493
+ * @param {number|null} bytes
494
+ * @returns {string}
495
+ */
496
+ export function formatBytes(bytes) {
497
+ if (bytes === null || bytes === undefined) return '-';
498
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
499
+ let value = bytes;
500
+ let unit = 0;
501
+ while (value >= 1024 && unit < units.length - 1) {
502
+ value /= 1024;
503
+ unit++;
504
+ }
505
+ return `${unit === 0 ? value : value.toFixed(1)}${units[unit]}`;
506
+ }
507
+
508
+ /**
509
+ * The human table: class, path, size, branch/HEAD, action and reason.
510
+ *
511
+ * @param {object} result the `runCleanWorktrees` envelope
512
+ * @returns {string}
513
+ */
514
+ export function renderTable(result) {
515
+ const rows = result.entries.map((e) => [
516
+ e.class ?? 'kept',
517
+ path.relative(result.projectRoot, e.path) || '.',
518
+ formatBytes(e.sizeBytes),
519
+ e.branch ?? (e.head ? `(detached ${e.head.slice(0, 7)})` : '-'),
520
+ e.reason ? `${e.action}: ${e.reason}` : e.action,
521
+ ]);
522
+ const header = ['CLASS', 'PATH', 'SIZE', 'BRANCH/HEAD', 'ACTION'];
523
+ const widths = header.map((h, i) =>
524
+ Math.max(h.length, ...rows.map((r) => r[i].length)),
525
+ );
526
+ const line = (cells) =>
527
+ cells
528
+ .map((c, i) => c.padEnd(widths[i]))
529
+ .join(' ')
530
+ .trimEnd();
531
+ const footer =
532
+ result.mode === 'dry-run'
533
+ ? 'Dry run — nothing removed. Re-run with --execute to remove candidates.'
534
+ : `Reclaimed ${formatBytes(result.bytesReclaimed)}.`;
535
+ return [line(header), ...rows.map(line), '', footer].join('\n');
536
+ }
537
+
538
+ async function promptConfirm(entry) {
539
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
540
+ try {
541
+ const answer = await rl.question(
542
+ `Remove ${entry.class} worktree ${entry.path}? [y/N] `,
543
+ );
544
+ return /^y(es)?$/i.test(answer.trim());
545
+ } finally {
546
+ rl.close();
547
+ }
548
+ }
549
+
550
+ /**
551
+ * CLI core: parse argv, run, render.
552
+ *
553
+ * @param {string[]} [argv]
554
+ * @param {{ runImpl?: typeof runCleanWorktrees, write?: (text: string) => void, isTTY?: boolean }} [deps]
555
+ * @returns {Promise<object|undefined>}
556
+ */
557
+ export async function runCleanWorktreesCli(
558
+ argv = process.argv.slice(2),
559
+ {
560
+ runImpl = runCleanWorktrees,
561
+ write = writeStdout,
562
+ isTTY = process.stdin.isTTY === true,
563
+ } = {},
564
+ ) {
565
+ const { values } = parseArgs({
566
+ args: argv,
567
+ options: {
568
+ execute: { type: 'boolean', default: false },
569
+ yes: { type: 'boolean', default: false },
570
+ json: { type: 'boolean', default: false },
571
+ cwd: { type: 'string' },
572
+ help: { type: 'boolean', short: 'h' },
573
+ },
574
+ strict: true,
575
+ });
576
+ if (values.help) {
577
+ write(HELP);
578
+ return undefined;
579
+ }
580
+ const result = await runImpl({
581
+ cwd: values.cwd,
582
+ execute: values.execute,
583
+ yes: values.yes,
584
+ confirm: isTTY && !values.json ? promptConfirm : null,
585
+ });
586
+ write(values.json ? JSON.stringify(result, null, 2) : renderTable(result));
587
+ return result;
588
+ }
589
+
590
+ runAsCli(import.meta.url, () => runCleanWorktreesCli(), {
591
+ source: 'clean-worktrees',
592
+ usage: HELP,
593
+ });
@@ -8,10 +8,11 @@
8
8
  * any still-stuck entries by enumerating the processes holding handles
9
9
  * inside the worktree path and terminating them.
10
10
  *
11
- * Invoked by `/mandrel-deliver` and `/mandrel-plan`
12
- * (via `drainPendingCleanupAtBoot` → `worktree-sweep.js`), and
13
- * `story-close` so the pending-cleanup ledger drains automatically
14
- * across the sprint lifecycle. Operators can also run it standalone:
11
+ * The same drain runs automatically at workflow boot: `runBootSweep`
12
+ * (`boot-sweep.js`) calls `sweepStaleStoryWorktrees`
13
+ * (`lib/orchestration/plan-runner/worktree-sweep.js`), which force-drains
14
+ * the ledger before reaping closed-Story worktrees. Operators can also run
15
+ * it standalone:
15
16
  *
16
17
  * node .agents/scripts/drain-pending-cleanup.js # full drain + escalate
17
18
  * node .agents/scripts/drain-pending-cleanup.js --no-escalate # passive drain only
@@ -5,24 +5,33 @@
5
5
  import { readFileSync } from 'node:fs';
6
6
  import { createRequire } from 'node:module';
7
7
  import path from 'node:path';
8
+ import { resolveDependencyVersion } from '../dependency-version.js';
8
9
 
9
10
  const DEFAULT_MIN_TOKENS = 50;
10
11
  const DEFAULT_FORMATS = Object.freeze(['javascript']);
11
12
 
12
13
  const require = createRequire(import.meta.url);
13
14
 
15
+ /** jscpd 5 is a Rust rewrite with no Node API; only major 4 exposes one. */
16
+ const SUPPORTED_JSCPD_MAJOR = '^4';
17
+
14
18
  /**
15
19
  * Lazy CJS load: jscpd's ESM entry has a broken transitive `colors/safe`
16
20
  * specifier under strict ESM resolution, and importers that never scan should
17
21
  * not pay the load.
18
22
  *
23
+ * No `detectClones` means an unsupported major, not a missing install.
24
+ *
25
+ * @param {NodeJS.Require} [requireFn] substitutes the module resolver
19
26
  * @returns {(opts: object) => Promise<Array<object>>}
20
27
  */
21
- export function resolveDetectClones() {
22
- const jscpd = require('jscpd');
23
- if (typeof jscpd.detectClones !== 'function') {
28
+ export function resolveDetectClones(requireFn = require) {
29
+ const jscpd = requireFn('jscpd');
30
+ if (typeof jscpd?.detectClones !== 'function') {
31
+ const version = resolveDependencyVersion('jscpd', requireFn) ?? 'unknown';
24
32
  throw new Error(
25
- "[Duplication] jscpd.detectClones is not available — run 'npm install'",
33
+ `[Duplication] jscpd ${version} exposes no detectClones Node API — ` +
34
+ `the duplication gate supports jscpd ${SUPPORTED_JSCPD_MAJOR}; install jscpd@${SUPPORTED_JSCPD_MAJOR}`,
26
35
  );
27
36
  }
28
37
  return jscpd.detectClones;
@@ -44,6 +53,9 @@ export function relativisePath(sourceId, cwd) {
44
53
  }
45
54
 
46
55
  /**
56
+ * A side with `end.line < start.line` counts nothing: its real span is
57
+ * unrecoverable, and widening it recorded hundreds of phantom lines.
58
+ *
47
59
  * @param {{ start?: { line?: number }, end?: { line?: number } }} dup
48
60
  * @returns {Array<number>} the 1-based line numbers the clone covers
49
61
  */
@@ -51,10 +63,8 @@ function cloneLineNumbers(dup) {
51
63
  const start = dup?.start?.line;
52
64
  const end = dup?.end?.line;
53
65
  if (!Number.isInteger(start) || !Number.isInteger(end)) return [];
54
- const lo = Math.min(start, end);
55
- const hi = Math.max(start, end);
56
66
  const lines = [];
57
- for (let n = lo; n <= hi; n += 1) lines.push(n);
67
+ for (let n = start; n <= end; n += 1) lines.push(n);
58
68
  return lines;
59
69
  }
60
70