@brunosps00/dev-workflow 2.1.0 → 2.2.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 (32) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +72 -0
  3. package/README.md +6 -3
  4. package/lib/constants.js +4 -0
  5. package/lib/init.js +26 -0
  6. package/package.json +1 -1
  7. package/scaffold/en/agent-instructions.md +2 -0
  8. package/scaffold/en/commands/dw-autopilot.md +2 -0
  9. package/scaffold/en/commands/dw-claude-run.md +1 -1
  10. package/scaffold/en/commands/dw-codex-run.md +1 -1
  11. package/scaffold/en/commands/dw-copilot-run.md +1 -1
  12. package/scaffold/en/commands/dw-harness-audit.md +2 -0
  13. package/scaffold/en/commands/dw-help.md +6 -2
  14. package/scaffold/en/commands/dw-pause.md +1 -0
  15. package/scaffold/en/commands/dw-report.md +169 -0
  16. package/scaffold/en/commands/dw-run.md +2 -1
  17. package/scaffold/en/commands/dw-worktree.md +100 -0
  18. package/scaffold/pt-br/agent-instructions.md +2 -0
  19. package/scaffold/pt-br/commands/dw-autopilot.md +2 -0
  20. package/scaffold/pt-br/commands/dw-claude-run.md +1 -1
  21. package/scaffold/pt-br/commands/dw-codex-run.md +1 -1
  22. package/scaffold/pt-br/commands/dw-copilot-run.md +1 -1
  23. package/scaffold/pt-br/commands/dw-harness-audit.md +2 -0
  24. package/scaffold/pt-br/commands/dw-help.md +6 -2
  25. package/scaffold/pt-br/commands/dw-pause.md +1 -0
  26. package/scaffold/pt-br/commands/dw-report.md +169 -0
  27. package/scaffold/pt-br/commands/dw-run.md +2 -1
  28. package/scaffold/pt-br/commands/dw-worktree.md +100 -0
  29. package/scaffold/scripts/hooks/git-guardrails.mjs +1 -0
  30. package/scaffold/scripts/lib/worktree-gc.mjs +460 -0
  31. package/scaffold/skill-registry.json +1 -1
  32. package/scaffold/skills/dw-cli-run/SKILL.md +10 -1
@@ -0,0 +1,460 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dev-workflow worktree GC — deterministic lifecycle for delegation worktrees.
4
+ *
5
+ * The dw-*-run adapters create one git worktree per delegated task next to the
6
+ * main checkout (`../<project>-<slug>`). Left alone, merged worktrees pile up
7
+ * with their node_modules/build output (tens of GB). This script is the hard
8
+ * enforcement behind `/dw-worktree`: it lists every secondary worktree with a
9
+ * verdict, removes ONLY the ones proven safe (merged into an integration branch,
10
+ * clean, not in use, not locked), and never uses --force.
11
+ *
12
+ * node .dw/scripts/lib/worktree-gc.mjs list [--base <branch>] [--json] [--strict]
13
+ * node .dw/scripts/lib/worktree-gc.mjs clean [--base <branch>] [--apply] [--older-than <days>] [--keep-branches]
14
+ * node .dw/scripts/lib/worktree-gc.mjs create <slug> [--branch <name>] [--base <branch>] [--no-prep]
15
+ * node .dw/scripts/lib/worktree-gc.mjs merge <slug|path> [--base <branch>] [--keep-branches]
16
+ * node .dw/scripts/lib/worktree-gc.mjs prune
17
+ *
18
+ * Verdicts: REMOVABLE · KEEP:unmerged · KEEP:dirty · KEEP:in-use · KEEP:locked ·
19
+ * KEEP:recent (only with --older-than) · PRUNABLE (directory gone → `git worktree prune`).
20
+ *
21
+ * `clean` is a DRY-RUN unless --apply is passed. `merge` is the safe order from the
22
+ * main checkout: ff-only merge → worktree remove → branch delete → prune. Both refuse
23
+ * anything that is not proven safe and say why. Exit codes: 0 ok · 1 usage/abort ·
24
+ * 2 prep failed (worktree created but the delegate would run blind) · 3 `list --strict`
25
+ * found REMOVABLE leftovers.
26
+ */
27
+
28
+ import { spawnSync } from 'node:child_process';
29
+ import { existsSync, readdirSync, readFileSync, readlinkSync, statfsSync } from 'node:fs';
30
+ import { basename, dirname, join, resolve } from 'node:path';
31
+
32
+ const INTEGRATION_DEFAULTS = ['develop', 'main', 'master'];
33
+
34
+ function git(cwd, args, { allowFail = false } = {}) {
35
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
36
+ const out = (r.stdout || '').trim();
37
+ const err = (r.stderr || '').trim();
38
+ if (r.status !== 0 && !allowFail) {
39
+ throw new Error(`git ${args.join(' ')} failed in ${cwd}: ${err || out}`);
40
+ }
41
+ return { ok: r.status === 0, out, err };
42
+ }
43
+
44
+ function parseArgs(argv) {
45
+ const args = { _: [], flags: {} };
46
+ for (let i = 0; i < argv.length; i++) {
47
+ const a = argv[i];
48
+ if (a.startsWith('--')) {
49
+ const key = a.slice(2);
50
+ const next = argv[i + 1];
51
+ if (['base', 'branch', 'older-than'].includes(key)) {
52
+ if (next === undefined || next.startsWith('--')) throw new Error(`--${key} needs a value`);
53
+ args.flags[key] = next;
54
+ i++;
55
+ } else {
56
+ args.flags[key] = true;
57
+ }
58
+ } else {
59
+ args._.push(a);
60
+ }
61
+ }
62
+ return args;
63
+ }
64
+
65
+ function mainCheckout(cwd) {
66
+ const { out } = git(cwd, ['worktree', 'list', '--porcelain']);
67
+ const first = out.split('\n').find((l) => l.startsWith('worktree '));
68
+ if (!first) throw new Error('not inside a git repository');
69
+ return first.slice('worktree '.length);
70
+ }
71
+
72
+ function parseWorktrees(main) {
73
+ const { out } = git(main, ['worktree', 'list', '--porcelain']);
74
+ const entries = [];
75
+ let cur = null;
76
+ for (const line of out.split('\n')) {
77
+ if (line.startsWith('worktree ')) {
78
+ cur = { path: line.slice(9), head: null, branch: null, detached: false, bare: false, locked: null, prunable: null };
79
+ entries.push(cur);
80
+ } else if (!cur || !line.trim()) {
81
+ continue;
82
+ } else if (line.startsWith('HEAD ')) cur.head = line.slice(5);
83
+ else if (line.startsWith('branch ')) cur.branch = line.slice(7).replace(/^refs\/heads\//, '');
84
+ else if (line === 'detached') cur.detached = true;
85
+ else if (line === 'bare') cur.bare = true;
86
+ else if (line.startsWith('locked')) cur.locked = line.slice(6).trim() || 'locked';
87
+ else if (line.startsWith('prunable')) cur.prunable = line.slice(8).trim() || 'prunable';
88
+ }
89
+ return entries;
90
+ }
91
+
92
+ function readConfig(main) {
93
+ try {
94
+ const cfg = JSON.parse(readFileSync(join(main, '.dw', 'config.json'), 'utf8'));
95
+ return cfg && typeof cfg === 'object' ? cfg.worktree || {} : {};
96
+ } catch {
97
+ return {};
98
+ }
99
+ }
100
+
101
+ function branchExists(main, name) {
102
+ return git(main, ['show-ref', '--verify', '--quiet', `refs/heads/${name}`], { allowFail: true }).ok;
103
+ }
104
+
105
+ function integrationBranches(main, baseFlag) {
106
+ const cfg = readConfig(main);
107
+ const originHead = git(main, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], { allowFail: true });
108
+ const candidates = [
109
+ baseFlag,
110
+ process.env.DW_WORKTREE_BASE,
111
+ cfg.base,
112
+ originHead.ok ? originHead.out.replace(/^origin\//, '') : null,
113
+ ...INTEGRATION_DEFAULTS,
114
+ ].filter(Boolean);
115
+ const seen = new Set();
116
+ const result = [];
117
+ for (const c of candidates) {
118
+ if (seen.has(c)) continue;
119
+ seen.add(c);
120
+ if (branchExists(main, c)) result.push(c);
121
+ }
122
+ if (baseFlag && !result.includes(baseFlag)) throw new Error(`--base ${baseFlag}: no such local branch`);
123
+ if (result.length === 0) throw new Error('no integration branch found (tried --base, DW_WORKTREE_BASE, .dw/config.json worktree.base, origin/HEAD, develop, main, master)');
124
+ return result;
125
+ }
126
+
127
+ function processesIn(path) {
128
+ const found = [];
129
+ if (process.platform !== 'linux') return found;
130
+ let pids = [];
131
+ try {
132
+ pids = readdirSync('/proc').filter((d) => /^\d+$/.test(d));
133
+ } catch {
134
+ return found;
135
+ }
136
+ for (const pid of pids) {
137
+ if (Number(pid) === process.pid) continue;
138
+ try {
139
+ const cwd = readlinkSync(`/proc/${pid}/cwd`);
140
+ if (cwd === path || cwd.startsWith(path + '/')) {
141
+ let cmd = '';
142
+ try {
143
+ cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim().slice(0, 80);
144
+ } catch {
145
+ cmd = '?';
146
+ }
147
+ found.push({ pid: Number(pid), cmd });
148
+ }
149
+ } catch {
150
+ // process vanished or not ours — ignore
151
+ }
152
+ }
153
+ return found;
154
+ }
155
+
156
+ function sizeKb(path) {
157
+ const r = spawnSync('du', ['-sk', path], { encoding: 'utf8' });
158
+ if (r.status !== 0) return null;
159
+ const n = parseInt(r.stdout.split('\t')[0], 10);
160
+ return Number.isFinite(n) ? n : null;
161
+ }
162
+
163
+ function humanKb(kb) {
164
+ if (kb === null || kb === undefined) return '-';
165
+ if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(1)}G`;
166
+ if (kb >= 1024) return `${Math.round(kb / 1024)}M`;
167
+ return `${kb}K`;
168
+ }
169
+
170
+ function inspect(main, { base, olderThan, withSize = true } = {}) {
171
+ const integration = integrationBranches(main, base);
172
+ const primary = integration[0];
173
+ const entries = parseWorktrees(main).filter((e) => e.path !== main && !e.bare);
174
+ const now = Math.floor(Date.now() / 1000);
175
+ return {
176
+ main,
177
+ integration,
178
+ worktrees: entries.map((e) => {
179
+ const exists = existsSync(e.path);
180
+ const mergedInto = e.head
181
+ ? integration.filter((b) => git(main, ['merge-base', '--is-ancestor', e.head, b], { allowFail: true }).ok)
182
+ : [];
183
+ const ahead = e.head ? parseInt(git(main, ['rev-list', '--count', `${primary}..${e.head}`], { allowFail: true }).out || '0', 10) : 0;
184
+ const dirty = exists ? git(e.path, ['status', '--porcelain'], { allowFail: true }).out.split('\n').filter(Boolean).length : null;
185
+ const lastCommit = e.head ? parseInt(git(main, ['log', '-1', '--format=%ct', e.head], { allowFail: true }).out || '0', 10) : 0;
186
+ const ageDays = lastCommit ? Math.floor((now - lastCommit) / 86400) : null;
187
+ const procs = exists ? processesIn(e.path) : [];
188
+ const size = exists && withSize ? sizeKb(e.path) : null;
189
+
190
+ let verdict;
191
+ let reason;
192
+ if (!exists || e.prunable) {
193
+ verdict = 'PRUNABLE';
194
+ reason = e.prunable || 'directory missing';
195
+ } else if (e.locked) {
196
+ verdict = 'KEEP:locked';
197
+ reason = e.locked;
198
+ } else if (procs.length) {
199
+ verdict = 'KEEP:in-use';
200
+ reason = procs.map((p) => `${p.pid} ${p.cmd}`).join('; ');
201
+ } else if (dirty > 0) {
202
+ verdict = 'KEEP:dirty';
203
+ reason = `${dirty} uncommitted file(s)`;
204
+ } else if (mergedInto.length === 0) {
205
+ verdict = 'KEEP:unmerged';
206
+ reason = `${ahead} commit(s) ahead of ${primary}, merged into none of [${integration.join(', ')}]`;
207
+ } else if (olderThan !== undefined && ageDays !== null && ageDays < olderThan) {
208
+ verdict = 'KEEP:recent';
209
+ reason = `merged but last commit ${ageDays}d ago (< ${olderThan}d)`;
210
+ } else {
211
+ verdict = 'REMOVABLE';
212
+ reason = `merged into ${mergedInto.join(', ')}; clean; no process inside`;
213
+ }
214
+ return {
215
+ name: basename(e.path),
216
+ path: e.path,
217
+ branch: e.branch,
218
+ detached: e.detached,
219
+ head: e.head,
220
+ mergedInto,
221
+ ahead,
222
+ dirty,
223
+ ageDays,
224
+ sizeKb: size,
225
+ procs,
226
+ locked: e.locked,
227
+ verdict,
228
+ reason,
229
+ };
230
+ }),
231
+ };
232
+ }
233
+
234
+ function printTable(report) {
235
+ const rows = report.worktrees;
236
+ console.log(`main checkout: ${report.main}`);
237
+ console.log(`integration branches: ${report.integration.join(', ')} (primary: ${report.integration[0]})`);
238
+ if (!rows.length) {
239
+ console.log('no secondary worktrees.');
240
+ return;
241
+ }
242
+ const w = Math.max(...rows.map((r) => r.name.length), 8);
243
+ const b = Math.max(...rows.map((r) => (r.branch || '(detached)').length), 6);
244
+ console.log(`${'worktree'.padEnd(w)} ${'branch'.padEnd(b)} ${'size'.padStart(6)} age dirty verdict reason`);
245
+ for (const r of rows) {
246
+ console.log(
247
+ `${r.name.padEnd(w)} ${(r.branch || '(detached)').padEnd(b)} ${humanKb(r.sizeKb).padStart(6)} ${String(r.ageDays ?? '-').padStart(3)}d ${String(r.dirty ?? '-').padStart(5)} ${r.verdict.padEnd(15)} ${r.reason}`
248
+ );
249
+ }
250
+ const removable = rows.filter((r) => r.verdict === 'REMOVABLE');
251
+ const freed = removable.reduce((s, r) => s + (r.sizeKb || 0), 0);
252
+ console.log(`\n${rows.length} worktree(s): ${removable.length} REMOVABLE (~${humanKb(freed)} apparent), ${rows.filter((r) => r.verdict === 'PRUNABLE').length} PRUNABLE, ${rows.length - removable.length - rows.filter((r) => r.verdict === 'PRUNABLE').length} KEEP`);
253
+ }
254
+
255
+ function deleteBranch(main, r, keepBranches) {
256
+ if (!r.branch || keepBranches) return 'kept';
257
+ // A branch checked out in another worktree cannot be deleted.
258
+ const stillUsed = parseWorktrees(main).some((e) => e.branch === r.branch);
259
+ if (stillUsed) return 'kept (checked out elsewhere)';
260
+ const soft = git(main, ['branch', '-d', r.branch], { allowFail: true });
261
+ if (soft.ok) return 'deleted';
262
+ // `-d` only trusts HEAD/upstream. We already proved the branch head is an ancestor
263
+ // of an integration branch, so the commits are preserved — delete with that evidence.
264
+ if (r.mergedInto.length && r.head && git(main, ['merge-base', '--is-ancestor', r.head, r.mergedInto[0]], { allowFail: true }).ok) {
265
+ const hard = git(main, ['branch', '-D', r.branch], { allowFail: true });
266
+ return hard.ok ? `deleted (verified merged into ${r.mergedInto[0]})` : `kept (${hard.err})`;
267
+ }
268
+ return `kept (${soft.err})`;
269
+ }
270
+
271
+ function removeWorktree(main, r, keepBranches) {
272
+ // Never --force: a dirty or locked worktree is a KEEP verdict and never reaches here.
273
+ const rm = git(main, ['worktree', 'remove', r.path], { allowFail: true });
274
+ if (!rm.ok) return { removed: false, detail: rm.err };
275
+ const branch = deleteBranch(main, r, keepBranches);
276
+ return { removed: true, detail: `branch ${r.branch || '(detached)'}: ${branch}` };
277
+ }
278
+
279
+ function diskFree(path) {
280
+ try {
281
+ const s = statfsSync(path);
282
+ const free = (Number(s.bavail) * Number(s.bsize)) / 1024 / 1024 / 1024;
283
+ return `${free.toFixed(1)}G free on the filesystem of ${path}`;
284
+ } catch {
285
+ return null;
286
+ }
287
+ }
288
+
289
+ function cmdList(main, flags) {
290
+ const report = inspect(main, { base: flags.base });
291
+ if (flags.json) console.log(JSON.stringify(report, null, 2));
292
+ else printTable(report);
293
+ if (flags.strict && report.worktrees.some((r) => r.verdict === 'REMOVABLE' || r.verdict === 'PRUNABLE')) process.exit(3);
294
+ }
295
+
296
+ function cmdClean(main, flags) {
297
+ const olderThan = flags['older-than'] !== undefined ? parseInt(flags['older-than'], 10) : undefined;
298
+ if (olderThan !== undefined && !Number.isFinite(olderThan)) throw new Error('--older-than needs a number of days');
299
+ const report = inspect(main, { base: flags.base, olderThan });
300
+ printTable(report);
301
+ const targets = report.worktrees.filter((r) => r.verdict === 'REMOVABLE');
302
+ const prunable = report.worktrees.filter((r) => r.verdict === 'PRUNABLE');
303
+ if (!flags.apply) {
304
+ console.log(`\nDRY-RUN — nothing removed. Re-run with --apply to remove the ${targets.length} REMOVABLE worktree(s)${prunable.length ? ` and prune ${prunable.length} stale entry(ies)` : ''}.`);
305
+ return;
306
+ }
307
+ let freed = 0;
308
+ console.log('');
309
+ for (const r of targets) {
310
+ const res = removeWorktree(main, r, flags['keep-branches']);
311
+ if (res.removed) {
312
+ freed += r.sizeKb || 0;
313
+ console.log(`removed ${r.path} (${humanKb(r.sizeKb)}; ${res.detail})`);
314
+ } else {
315
+ console.log(`FAILED ${r.path} ${res.detail}`);
316
+ }
317
+ }
318
+ const pruned = git(main, ['worktree', 'prune', '-v'], { allowFail: true });
319
+ if (pruned.out) console.log(pruned.out);
320
+ const kept = report.worktrees.filter((r) => r.verdict.startsWith('KEEP'));
321
+ console.log(`\nremoved ${targets.length} worktree(s), ~${humanKb(freed)} apparent; kept ${kept.length}${kept.length ? ': ' + kept.map((r) => `${r.name} (${r.verdict})`).join(', ') : ''}`);
322
+ const free = diskFree(dirname(main));
323
+ if (free) console.log(free);
324
+ }
325
+
326
+ function detectPrep(worktreePath) {
327
+ const has = (f) => existsSync(join(worktreePath, f));
328
+ const cmds = [];
329
+ if (has('pnpm-lock.yaml')) cmds.push('pnpm install --frozen-lockfile --prefer-offline');
330
+ else if (has('package-lock.json')) cmds.push('npm ci --prefer-offline');
331
+ else if (has('yarn.lock')) cmds.push('yarn install --frozen-lockfile');
332
+ else if (has('bun.lockb') || has('bun.lock')) cmds.push('bun install --frozen-lockfile');
333
+ if (has('uv.lock')) cmds.push('uv sync --frozen');
334
+ if (cmds.length && has('package.json')) {
335
+ try {
336
+ const pkg = JSON.parse(readFileSync(join(worktreePath, 'package.json'), 'utf8'));
337
+ if (pkg.scripts && pkg.scripts['build:packages']) {
338
+ const runner = has('pnpm-lock.yaml') ? 'pnpm' : has('yarn.lock') ? 'yarn' : has('bun.lockb') || has('bun.lock') ? 'bun run' : 'npm run';
339
+ cmds.push(`${runner} build:packages`);
340
+ }
341
+ } catch {
342
+ // unreadable package.json — skip the build step
343
+ }
344
+ }
345
+ return cmds;
346
+ }
347
+
348
+ function cmdCreate(main, args) {
349
+ const slug = args._[1];
350
+ if (!slug || !/^[A-Za-z0-9._-]+$/.test(slug)) throw new Error('create needs a <slug> matching [A-Za-z0-9._-]+');
351
+ const integration = integrationBranches(main, args.flags.base);
352
+ const base = integration[0];
353
+ const branch = args.flags.branch || `feat/${slug}`;
354
+ const path = join(dirname(main), `${basename(main)}-${slug}`);
355
+ if (existsSync(path)) throw new Error(`${path} already exists — reuse it or pick another slug`);
356
+ if (branchExists(main, branch)) {
357
+ git(main, ['worktree', 'add', path, branch]);
358
+ } else {
359
+ git(main, ['worktree', 'add', '-b', branch, path, base]);
360
+ }
361
+ console.log(`created ${path} on ${branch} (from ${base})`);
362
+ const cfg = readConfig(main);
363
+ const prep = args.flags['no-prep'] ? [] : Array.isArray(cfg.prep) ? cfg.prep : detectPrep(path);
364
+ for (const cmd of prep) {
365
+ console.log(`prep: ${cmd}`);
366
+ const r = spawnSync(cmd, { cwd: path, shell: true, stdio: 'inherit' });
367
+ if (r.status !== 0) {
368
+ console.error(`PREP FAILED (${cmd}) — the worktree exists but a delegate would run blind. Fix it before dispatching.`);
369
+ process.exit(2);
370
+ }
371
+ }
372
+ if (!prep.length) console.log('prep: none (no lockfile detected; set .dw/config.json worktree.prep to override)');
373
+ console.log(`WORKTREE=${path}`);
374
+ }
375
+
376
+ function resolveTarget(main, ref) {
377
+ if (!ref) throw new Error('merge needs a <slug|path>');
378
+ const entries = parseWorktrees(main).filter((e) => e.path !== main);
379
+ const abs = resolve(ref);
380
+ return (
381
+ entries.find((e) => e.path === abs) ||
382
+ entries.find((e) => basename(e.path) === ref) ||
383
+ entries.find((e) => basename(e.path) === `${basename(main)}-${ref}`) ||
384
+ entries.find((e) => e.branch === ref)
385
+ );
386
+ }
387
+
388
+ function cmdMerge(main, args) {
389
+ const target = resolveTarget(main, args._[1]);
390
+ if (!target) throw new Error(`no worktree matches "${args._[1]}" (try the folder name, slug, path, or branch)`);
391
+ if (!target.branch) throw new Error(`${target.path} is detached — nothing to merge`);
392
+ const integration = integrationBranches(main, args.flags.base);
393
+ const base = integration[0];
394
+ const mainBranch = git(main, ['rev-parse', '--abbrev-ref', 'HEAD']).out;
395
+ if (mainBranch !== base) {
396
+ throw new Error(`main checkout is on ${mainBranch}, not ${base}. Switch it yourself (git -C ${main} checkout ${base}) or pass --base ${mainBranch}; this tool never changes the owner's active branch.`);
397
+ }
398
+ if (git(main, ['status', '--porcelain']).out) throw new Error(`main checkout ${main} has uncommitted changes — commit or stash before merging`);
399
+ const dirty = git(target.path, ['status', '--porcelain']).out;
400
+ if (dirty) throw new Error(`${target.path} has uncommitted changes:\n${dirty}\nCommit them in the worktree first (nothing is merged or removed).`);
401
+ const procs = processesIn(target.path);
402
+ if (procs.length) throw new Error(`${target.path} is in use by: ${procs.map((p) => `${p.pid} ${p.cmd}`).join('; ')} — stop them first`);
403
+
404
+ const merge = git(main, ['merge', '--ff-only', target.branch], { allowFail: true });
405
+ if (!merge.ok) {
406
+ throw new Error(`ff-only merge of ${target.branch} into ${base} refused: ${merge.err || merge.out}\nRebase inside the worktree (git -C ${target.path} rebase ${base}), re-run the gate, then merge again. Nothing was removed.`);
407
+ }
408
+ console.log(`merged ${target.branch} into ${base} (ff-only): ${git(main, ['rev-parse', '--short', 'HEAD']).out}`);
409
+ const row = { path: target.path, branch: target.branch, head: target.head, mergedInto: [base], sizeKb: sizeKb(target.path) };
410
+ const res = removeWorktree(main, row, args.flags['keep-branches']);
411
+ if (!res.removed) throw new Error(`merged, but could not remove ${target.path}: ${res.detail}`);
412
+ git(main, ['worktree', 'prune'], { allowFail: true });
413
+ console.log(`removed ${target.path} (${humanKb(row.sizeKb)}; ${res.detail}); worktrees pruned`);
414
+ const free = diskFree(dirname(main));
415
+ if (free) console.log(free);
416
+ }
417
+
418
+ function usage() {
419
+ console.log(`usage:
420
+ worktree-gc.mjs list [--base <branch>] [--json] [--strict]
421
+ worktree-gc.mjs clean [--base <branch>] [--apply] [--older-than <days>] [--keep-branches]
422
+ worktree-gc.mjs create <slug> [--branch <name>] [--base <branch>] [--no-prep]
423
+ worktree-gc.mjs merge <slug|path> [--base <branch>] [--keep-branches]
424
+ worktree-gc.mjs prune`);
425
+ }
426
+
427
+ function main() {
428
+ const args = parseArgs(process.argv.slice(2));
429
+ const cmd = args._[0];
430
+ if (!cmd || args.flags.help) {
431
+ usage();
432
+ process.exit(cmd ? 0 : 1);
433
+ }
434
+ const root = mainCheckout(process.cwd());
435
+ switch (cmd) {
436
+ case 'list':
437
+ return cmdList(root, args.flags);
438
+ case 'clean':
439
+ return cmdClean(root, args.flags);
440
+ case 'create':
441
+ return cmdCreate(root, args);
442
+ case 'merge':
443
+ return cmdMerge(root, args);
444
+ case 'prune': {
445
+ const r = git(root, ['worktree', 'prune', '-v'], { allowFail: true });
446
+ console.log(r.out || 'nothing to prune');
447
+ return;
448
+ }
449
+ default:
450
+ usage();
451
+ process.exit(1);
452
+ }
453
+ }
454
+
455
+ try {
456
+ main();
457
+ } catch (err) {
458
+ console.error(`worktree-gc: ${err.message}`);
459
+ process.exit(1);
460
+ }
@@ -35,7 +35,7 @@
35
35
  "trigger": "Fire an AI CLI (Codex/Claude/Copilot) in a dedicated git worktree to implement a prepared prompt; resumable per-task session; STOP for the gate",
36
36
  "expected_output": "Audit-logged run with a 0-10 dual evaluation, a resumable session-id sidecar, and a STOP-for-gate Structured Return.",
37
37
  "load_policy": "lazy-references",
38
- "context_limit": 19000,
38
+ "context_limit": 20500,
39
39
  "bundled": true
40
40
  },
41
41
  {
@@ -54,7 +54,8 @@ worktree, `AUTO_READONLY`, freely parallel, score the report without a re-run ga
54
54
  ## Pre-flight (fail early > run in the wrong place)
55
55
  1. `<cli> --version` answers (CLI + credentials present). Missing → `BLOCKED`.
56
56
  2. **Declare the mode** (WRITE or READ-ONLY) and pick the matching `AUTO`/`AUTO_READONLY` slot.
57
- 3. WRITE only: `git worktree list` — confirm the target worktree and that it is **not** the main checkout.
57
+ 3. WRITE only: `git worktree list` — confirm the target worktree and that it is **not** the main checkout. Missing →
58
+ create it with `/dw-worktree create <slug>` (naming convention + install/build prep), never a bare `git worktree add`.
58
59
  4. A **prompt/spec** exists (what the CLI receives). Common conventions: `.dw/spec/<slug>/codex-prompt.md`
59
60
  (dev-workflow), `PROMPT.md`, `TASK.md`, or a path the owner names. **Read it** — it is the scope/fence/gate.
60
61
 
@@ -77,6 +78,11 @@ The main thread **orchestrates**; execution goes to one of these:
77
78
  > **Nesting:** the agent running the CLI **must not spawn further subagents** (nesting limit; Workflow nests only
78
79
  > 1 level). The gate is **another phase/agent** of the workflow — never a sub-spawn from inside the runner agent.
79
80
 
81
+ ## Progress loop (auto-arm)
82
+ WRITE runs invoke `/dw-report` before dispatch (idempotent while `.dw/reports/.active.json` is live; skipped when
83
+ `DW_REPORT_AUTO=off`; `armed_by: dw-cli-run`). Ticks read the stream for measurable progress (`grep -c
84
+ '"type":"item.completed"'`, last `command_execution`); the gate verdict is `finished` → final report + auto-disarm.
85
+
80
86
  ## Protocol
81
87
  1. **Resolve target.** Worktree + prompt path (from the user or inferred). Derive a `<slug>` (the spec slug or
82
88
  worktree tag) — it keys the durable audit log and the session sidecar.
@@ -181,6 +187,9 @@ The stream ends with the adapter's `USAGE` block (token counts). Extract it from
181
187
  - **STOP — not merged.** The CLI implemented in the worktree; **run the gate** (tests/lint/build + review; if the
182
188
  project uses dev-workflow: `/dw-review` + `/dw-qa` + `/dw-secure-audit`) **before** any merge.
183
189
  - **Merge = the owner's explicit decision.** Never automatic here.
190
+ - **End of life = the same turn as the merge (hard).** When the owner merges: `/dw-worktree merge <slug>` (ff-only from the
191
+ main checkout → remove → branch delete → prune) or, if merged by other means, `/dw-worktree clean --apply` in that
192
+ same turn. Never `git worktree remove --force`; KEEP verdicts (dirty/unmerged/in-use) are reported, not forced.
184
193
  - Flag **uncommitted** work or edits **outside the fence**.
185
194
  - **Stop/abort a running CLI:** use **`TaskStop <task_id>`** (if `run_in_background`/Workflow) OR
186
195
  **`git worktree remove --force <worktree>`** (pulls the rug — the CLI has nowhere to write and dies). Fallback: