@bongos/core 1.19.727 → 1.19.729

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.727",
6
- "core_contract": "1.19.727",
7
- "source_commit": "8860409e2ae4203c97fdcf95d358324f1a5a6bc7",
5
+ "core_version": "1.19.729",
6
+ "core_contract": "1.19.729",
7
+ "source_commit": "399870dc41f0d71a77a79d1d48131070fbfe9828",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-13T14:53:19.653Z",
9
+ "built_at": "2026-09-13T15:18:33.790Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 477,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 2602,
20
- "tree_sha256": "90088f98c9e09359faf4ac7099d2444ec5c2434976bf00f808739710cfd4588f",
20
+ "tree_sha256": "787a5eba74a590d18c09c0e87a40655d7eb5dcdcd9865660bc209392db78f8ca",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -272,7 +272,7 @@
272
272
  {
273
273
  "path": ".claude/skills/worktree-clean/SKILL.md",
274
274
  "mode": "0000644",
275
- "sha256": "a51d79d257e7d502dc9becbd2f45e05af881b840ee287ce0e450ebb64a93dec4"
275
+ "sha256": "4819fe448f4e35c3c7c3f4784718d51a7a4469f3c644d1b6c57b693b79da129e"
276
276
  },
277
277
  {
278
278
  "path": ".devcontainer/Dockerfile",
@@ -2482,7 +2482,7 @@
2482
2482
  {
2483
2483
  "path": "docs/module-api-changelog.md",
2484
2484
  "mode": "0000644",
2485
- "sha256": "0fe647bae4864d656c91c5a89051ba630819dc6a792cea693bd7a0e4cecfa630"
2485
+ "sha256": "635d466437068d5f1e6875cb6307c836dc57eb10c2ac7a50c39aba18c423f386"
2486
2486
  },
2487
2487
  {
2488
2488
  "path": "docs/modules-contract.md",
@@ -2747,7 +2747,7 @@
2747
2747
  {
2748
2748
  "path": "docs/recipes/windows-builders.md",
2749
2749
  "mode": "0000644",
2750
- "sha256": "6760a34d404aa68c61896983c8aa621bd26244e5c98ed056045b3467359c4b53"
2750
+ "sha256": "eef830271b0e77a161c61759970346b63730d3e6049b4944d24586abadb843de"
2751
2751
  },
2752
2752
  {
2753
2753
  "path": "docs/recipes/work-on-a-task-from-your-dev-box.md",
@@ -7512,12 +7512,12 @@
7512
7512
  {
7513
7513
  "path": "package-lock.json",
7514
7514
  "mode": "0000644",
7515
- "sha256": "6cda7a3bc2dfd1ee7c15be6da4033a876e7222411c5e13057599d4568f557e01"
7515
+ "sha256": "51b06b26595e4850db318f091ea0cf01399add4ddff70489a7bd42c62e6dc85c"
7516
7516
  },
7517
7517
  {
7518
7518
  "path": "package.json",
7519
7519
  "mode": "0000644",
7520
- "sha256": "b7e89ddb86b30e922b78a438509453e72eab5e4d0293c6769f70fa0d3335dcda"
7520
+ "sha256": "46d1603b0da9d38a1afe85820cfce1f1f485e26f692a7b077bf8951793dc1ac6"
7521
7521
  },
7522
7522
  {
7523
7523
  "path": "public-docs/index.html",
@@ -8822,7 +8822,7 @@
8822
8822
  {
8823
8823
  "path": "scripts/gds/stale-worktrees.js",
8824
8824
  "mode": "0000644",
8825
- "sha256": "40c5ef66ef5efc3754da367e64604af765d2e595986fc7af1af10f793d1a57e0"
8825
+ "sha256": "b615f8cdf4b1e74d997fff9e8eaa7f5c77276eefaa02f1a0c0becd80f8697620"
8826
8826
  },
8827
8827
  {
8828
8828
  "path": "scripts/gds/start.js",
@@ -8907,7 +8907,7 @@
8907
8907
  {
8908
8908
  "path": "scripts/gds/worktree.js",
8909
8909
  "mode": "0000644",
8910
- "sha256": "af95d7ae434d587dae654e24640163c44ac293edb11d2c0deb979b855701ac85"
8910
+ "sha256": "69f167d079c1900568f740d8d4a1a2288f5ff47146634209f8a1a9350a3aa5e2"
8911
8911
  },
8912
8912
  {
8913
8913
  "path": "scripts/hall-preview/README.md",
@@ -9317,7 +9317,7 @@
9317
9317
  {
9318
9318
  "path": "src/module-api.js",
9319
9319
  "mode": "0000644",
9320
- "sha256": "ee4b22adb7ca8da6d689ac51fff3b65f6dfb4f6319e1685cd1e8d89b760f0903"
9320
+ "sha256": "c96971200e4ddee6375444c84cd0433158c026423638822b76ae941fa50f3956"
9321
9321
  },
9322
9322
  {
9323
9323
  "path": "src/module-loader/catalog.js",
@@ -9987,7 +9987,7 @@
9987
9987
  {
9988
9988
  "path": "tests/cli_package.mjs",
9989
9989
  "mode": "0000644",
9990
- "sha256": "b81bf677dc884eef7bd88128d024e3eed1699faa31a680b7d5d4cfdbcd11b09c"
9990
+ "sha256": "334c952eadfdd7d5ee467cfd50a9f178a23389e94288cdb6a431c69c6e8e327c"
9991
9991
  },
9992
9992
  {
9993
9993
  "path": "tests/cli_sessions.mjs",
@@ -12647,7 +12647,7 @@
12647
12647
  {
12648
12648
  "path": "tests/stale_worktrees.mjs",
12649
12649
  "mode": "0000644",
12650
- "sha256": "e003d6391d14212198c0e55494190e702adc0a27c2820362afe3d676c67a4963"
12650
+ "sha256": "97ca351bc03b3d56b99239739818c11fea384691ee597b54a0c4bef60be196f6"
12651
12651
  },
12652
12652
  {
12653
12653
  "path": "tests/start_env_guard.mjs",
@@ -13017,7 +13017,7 @@
13017
13017
  {
13018
13018
  "path": "tests/worktree_helper.mjs",
13019
13019
  "mode": "0000644",
13020
- "sha256": "9d511e7cf21cea05cc565e9e5022c7c86cd3cf0d67053125ec27aeae3348e30a"
13020
+ "sha256": "cac88e36d1e2ef2cbe2799275186f0787431b5b636fc52d1325b5dbbfcd56368"
13021
13021
  },
13022
13022
  {
13023
13023
  "path": "tests/world_tokens.mjs",
@@ -1,27 +1,29 @@
1
1
  ---
2
2
  name: worktree-clean
3
3
  description: >-
4
- Remove per-claim worktrees whose work already landed, via the junction-safe worktree.js remove — never raw git worktree remove, which follows the node_modules junction. Triggers: "/worktree-clean", "prune stale worktrees", or the SessionStart line "Stale worktrees: N".
4
+ Remove per-claim worktrees whose work landed, and sweep the husks a locked remove leaves, via junction-safe worktree.js — never raw git worktree remove, which follows the node_modules junction. Triggers: "/worktree-clean", "prune stale worktrees", "worktree husks".
5
5
  ---
6
6
 
7
7
  **Script skill (authoritative).** Two scripts do the work; this skill only sequences them and shows the list before anything is removed.
8
8
 
9
9
  ## What this skill does
10
10
 
11
- 1. `node scripts/gds/stale-worktrees.js` — the list. A worktree is **stale** when its branch is an ancestor of `origin/main` (the land happened) or the branch is gone (the ship deleted it). Detached worktrees and unmerged branches are **live** and never touched. Run `git fetch origin` first so "merged into main" reflects today's main, not the last fetch.
11
+ 1. `node scripts/gds/stale-worktrees.js` — the list. A worktree is **stale** when its branch is an ancestor of `origin/main` (the land happened) or the branch is gone (the ship deleted it). Detached worktrees and unmerged branches are **live** and never touched. Run `git fetch origin` first so "merged into main" reflects today's main, not the last fetch. The same call also names **husks** — directories under `.claude/worktrees/` that git does not list as worktrees at all (step 6).
12
12
  2. Show the stale list to the builder (names + why). The worktree holding the **current claim** is never stale by construction (its branch is unmerged), but say so if the builder asks.
13
13
  3. For each stale name: `node scripts/gds/worktree.js remove <name>`. That helper unlinks any node_modules junction BEFORE `git worktree remove`, which is the whole reason raw git is off-limits here (learning in `scripts/gds/CLAUDE.md` → "Gotchas / invariants"; the `git-guard.js` PreToolUse hook blocks the raw form).
14
14
  - A worktree with untracked leftovers (`.playwright-mcp/`, a stray screenshot) refuses without `--force`. Show the `git -C <path> status --porcelain` output; if it is only untracked junk, re-run with `--force`. If it shows **modified tracked files**, skip it and report — someone's uncommitted work is not this skill's to delete.
15
15
  4. `git worktree prune` — clears any registrations whose directory is already gone.
16
16
  5. Print the tally: removed / skipped (with why) / still live. Stale branches stay — a merged branch is harmless and `git branch -d` is the ship's business, not a cleanup chore.
17
+ 6. `node scripts/gds/worktree.js prune-husks` — the **husks**: directories still under `.claude/worktrees/` that git no longer lists. They are the residue of an OneDrive-locked `git worktree remove`, which deregisters the worktree but cannot delete the directory (task 1002594). Nothing else can see them: a deregistered husk is absent from `git worktree list`, which is all step 1's stale scan reads. The sweep deletes only an **empty** husk that this session is not standing in and that has not been touched in 24h — the harness parks live session directories in the same folder and a fresh one looks exactly like residue. It prints the reason for every skip; `--force` waives only the 24h rule.
17
18
 
18
19
  ## Safety
19
20
 
20
21
  - Read-only until step 3; the list is shown first.
21
22
  - Never `--force` over modified tracked files.
22
23
  - Never deletes branches, never touches the primary checkout, never calls `git worktree remove` directly.
24
+ - The husk sweep uses `rmdir`, never a recursive delete: a husk that gained files between the check and the call is refused by the filesystem, not emptied.
23
25
  - No claim is needed: worktrees are local machine state, not project state — nothing here is committed.
24
26
 
25
27
  ## Calls
26
28
 
27
- `node scripts/gds/stale-worktrees.js [--json]` · `node scripts/gds/worktree.js remove <name> [--force]` · `git worktree prune`.
29
+ `node scripts/gds/stale-worktrees.js [--json]` · `node scripts/gds/worktree.js remove <name> [--force]` · `node scripts/gds/worktree.js prune-husks [--force]` · `git worktree prune`.
@@ -1928,5 +1928,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1928
1928
  landed since 1.19.725 with no explicit bump. run 34763380132. (task 1002620)
1929
1929
  1.19.727 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1930
1930
  landed since 1.19.726 with no explicit bump. run 34763937238. (task 1002620)
1931
+ 1.19.728 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1932
+ landed since 1.19.727 with no explicit bump. run 34764630913. (task 1002620)
1933
+ 1.19.729 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1934
+ landed since 1.19.728 with no explicit bump. run 34765179905. (task 1002620)
1931
1935
  ---------------------------------------------------------------------------
1932
1936
  ```
@@ -79,7 +79,23 @@ checkout's `node_modules` (by walk-up inside the repo, by junction outside); a s
79
79
  `git worktree remove` on an in-repo worktree often errors `Permission denied` / `Device or
80
80
  resource busy` because OneDrive briefly locks the directory. The failed attempt usually still
81
81
  **deregisters** the worktree (`git worktree list` to confirm — the helper's `remove` does this
82
- check for you); the empty husk directory deletes on its own later. Don't fight it with retries.
82
+ check for you). Don't fight it with retries.
83
+
84
+ The empty husk directory does **not** delete on its own — this page and the helper both used to
85
+ say it did, and nothing ever swept one (task 1002594; 20 directories under `.claude/worktrees/`
86
+ for 13 registered worktrees when it was finally measured). Worse, a husk is invisible to
87
+ `stale-worktrees.js`, which reads `git worktree list` — precisely the list a deregistered
88
+ directory has left. Sweep them when the lock has cleared:
89
+
90
+ ```bash
91
+ node scripts/gds/worktree.js prune-husks
92
+ ```
93
+
94
+ It deletes only an **empty** husk that the current process is not standing in and that has not
95
+ been touched in 24 hours, and prints the reason for every skip. Those rules are not paranoia: the
96
+ Claude Code harness creates its own session directory under `.claude/worktrees/` — not a git
97
+ worktree at all, and live while that session runs — so a fresh one is indistinguishable from
98
+ residue. `--force` waives the 24-hour rule and nothing else.
83
99
 
84
100
  ## Local unit tests: baseline first, CI is truth
85
101
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.727",
3
+ "version": "1.19.729",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.727",
9
+ "version": "1.19.729",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.727",
3
+ "version": "1.19.729",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -13,20 +13,35 @@
13
13
  // deleted it). Both are pure git facts, so this runs in well under a second at
14
14
  // SessionStart. The primary checkout is never listed.
15
15
  //
16
+ // THE SECOND LEAK — HUSKS (task 1002594). Under OneDrive, `git worktree remove`
17
+ // often errors 'Permission denied' / 'Device or resource busy' but still
18
+ // DEREGISTERS the worktree, so `worktree.js remove` treats deregistration as
19
+ // success and tells the builder the locked directory "deletes on its own later".
20
+ // Nothing ever deleted it. That residue is invisible to the scan above BY
21
+ // CONSTRUCTION: a deregistered husk is, by definition, absent from
22
+ // `git worktree list`, which is the only thing classifyWorktrees reads. Measured
23
+ // on 2026-09-13 in this checkout: 20 directories under .claude/worktrees/, 13
24
+ // registered — 7 husks that no probe had ever named. So the husk scan reads the
25
+ // DISK and subtracts what git knows: the mirror image of the stale scan.
26
+ //
16
27
  // Usage:
17
28
  // node scripts/gds/stale-worktrees.js --line # ONE line, or nothing; always exit 0
18
- // node scripts/gds/stale-worktrees.js --json # { stale: [{name, path, branch, why}], live: [...] }
19
- // node scripts/gds/stale-worktrees.js # human report; exit 0 clean, 1 stale found
29
+ // node scripts/gds/stale-worktrees.js --json # { stale: [...], live: [...], husks: [...] }
30
+ // node scripts/gds/stale-worktrees.js # human report; exit 0 clean, 1 stale/husks found
20
31
  //
21
- // Removal is deliberately NOT here — `/worktree-clean` walks this list through
22
- // `node scripts/gds/worktree.js remove <name>`, the junction-safe path.
32
+ // Removal is deliberately NOT here — `/worktree-clean` walks these lists through
33
+ // `node scripts/gds/worktree.js remove <name>` and `worktree.js prune-husks`,
34
+ // the junction-safe paths.
23
35
 
24
36
  'use strict';
25
37
 
26
38
  const { execFileSync } = require('child_process');
39
+ const fs = require('fs');
27
40
  const path = require('path');
28
41
 
29
42
  const MAIN_REF = 'origin/main';
43
+ // Where worktree.js add puts a worktree by default; the only place husks collect.
44
+ const WORKTREES_SUBDIR = path.join('.claude', 'worktrees');
30
45
 
31
46
  function git(args, cwd) {
32
47
  return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
@@ -64,6 +79,20 @@ function classifyWorktrees(worktrees, { isMerged, branchExists }) {
64
79
  return { stale, live };
65
80
  }
66
81
 
82
+ // Directories sitting in .claude/worktrees/ that git does not list as a worktree —
83
+ // the deregister-then-never-delete residue. Pure (the directory listing and the
84
+ // registered paths are both injected) so the set arithmetic is testable without
85
+ // creating directories. Case- and separator-insensitive on both sides, because the
86
+ // porcelain and fs.readdirSync disagree about drive-letter case on Windows.
87
+ function classifyHusks({ dirNames, worktreePaths, worktreesRoot }) {
88
+ const norm = (p) => path.resolve(p).replace(/\\/g, '/').toLowerCase();
89
+ const registered = new Set(worktreePaths.map(norm));
90
+ return dirNames
91
+ .filter((name) => !registered.has(norm(path.join(worktreesRoot, name))))
92
+ .sort()
93
+ .map((name) => ({ name, path: path.join(worktreesRoot, name) }));
94
+ }
95
+
67
96
  // Three git calls total, whatever the worktree count: the list, every local branch,
68
97
  // every branch already merged into origin/main. Per-worktree subprocesses would be
69
98
  // N+1 over exactly the number this probe exists to shrink.
@@ -77,18 +106,40 @@ function scan(cwd) {
77
106
  let merged;
78
107
  try { merged = refNames(git(['branch', '--merged', MAIN_REF, '--format=%(refname:short)'], cwd)); }
79
108
  catch { merged = new Set(); } // no origin/main yet (fresh clone, no fetch) → nothing is provably stale
80
- return classifyWorktrees(worktrees, {
109
+ const r = classifyWorktrees(worktrees, {
81
110
  branchExists: (b) => existing.has(b),
82
111
  isMerged: (b) => merged.has(b),
83
112
  });
113
+ // The main checkout is always the first porcelain entry, so the husk directory is
114
+ // derivable without a second git call.
115
+ const mainRoot = worktrees.length ? worktrees[0].path : cwd;
116
+ const worktreesRoot = path.join(mainRoot, WORKTREES_SUBDIR);
117
+ let dirNames = [];
118
+ try {
119
+ dirNames = fs.readdirSync(worktreesRoot, { withFileTypes: true })
120
+ .filter((d) => d.isDirectory()).map((d) => d.name);
121
+ } catch { dirNames = []; } // no worktrees dir yet → no husks, not an error
122
+ r.husks = classifyHusks({ dirNames, worktreePaths: worktrees.map((w) => w.path), worktreesRoot });
123
+ return r;
124
+ }
125
+
126
+ function namesOf(list) {
127
+ const names = list.slice(0, 6).map((s) => s.name).join(', ');
128
+ return names + (list.length > 6 ? ` +${list.length - 6} more` : '');
84
129
  }
85
130
 
86
131
  function lineFor(r) {
87
- if (!r.stale.length) return '';
88
- const names = r.stale.slice(0, 6).map((s) => s.name).join(', ');
89
- const more = r.stale.length > 6 ? ` +${r.stale.length - 6} more` : '';
90
- return `Stale worktrees: ${r.stale.length} whose work already landed (${names}${more}). `
91
- + 'Run /worktree-clean — never `git worktree remove` (it follows the node_modules junction).';
132
+ const husks = r.husks || [];
133
+ const parts = [];
134
+ if (r.stale.length) {
135
+ parts.push(`Stale worktrees: ${r.stale.length} whose work already landed (${namesOf(r.stale)}). `
136
+ + 'Run /worktree-clean — never `git worktree remove` (it follows the node_modules junction).');
137
+ }
138
+ if (husks.length) {
139
+ parts.push(`Worktree husks: ${husks.length} deregistered dir(s) still on disk (${namesOf(husks)}). `
140
+ + 'Run node scripts/gds/worktree.js prune-husks.');
141
+ }
142
+ return parts.join(' ');
92
143
  }
93
144
 
94
145
  function main() {
@@ -102,12 +153,16 @@ function main() {
102
153
  }
103
154
  if (argv.includes('--json')) { console.log(JSON.stringify(r, null, 2)); return; }
104
155
  if (argv.includes('--line')) { const l = lineFor(r); if (l) console.log(l); return; }
105
- console.log(` Worktrees: ${r.live.length} live, ${r.stale.length} stale`);
156
+ const husks = r.husks || [];
157
+ console.log(` Worktrees: ${r.live.length} live, ${r.stale.length} stale, ${husks.length} husks`);
106
158
  for (const s of r.stale) console.log(` stale ${s.name} (${s.why})`);
159
+ for (const h of husks) console.log(` husk ${h.name} (on disk, not registered)`);
107
160
  for (const l of r.live) console.log(` live ${l.name} [${l.branch || 'detached'}]`);
108
- if (r.stale.length) { console.log(' Remove with: node scripts/gds/worktree.js remove <name> (or /worktree-clean)'); process.exit(1); }
161
+ if (r.stale.length) console.log(' Remove with: node scripts/gds/worktree.js remove <name> (or /worktree-clean)');
162
+ if (husks.length) console.log(' Sweep husks with: node scripts/gds/worktree.js prune-husks');
163
+ if (r.stale.length || husks.length) process.exit(1);
109
164
  }
110
165
 
111
166
  if (require.main === module) main();
112
167
 
113
- module.exports = { parseWorktreeList, classifyWorktrees, lineFor };
168
+ module.exports = { parseWorktreeList, classifyWorktrees, classifyHusks, lineFor };
@@ -22,9 +22,22 @@
22
22
  // which claim.js surfaces at claim time. Deliberately short here: two full
23
23
  // copies of the same prose would drift apart.
24
24
  //
25
+ // THE RESIDUE `remove` LEAVES — HUSKS (task 1002594). Under OneDrive, the
26
+ // `git worktree remove` below often errors 'Permission denied' / 'Device or
27
+ // resource busy' while still DEREGISTERING the worktree, so this tool has always
28
+ // accepted deregistration as success. What it then TOLD the builder was wrong:
29
+ // the locked directory does not "delete on its own later", and no probe could
30
+ // even see it, because stale-worktrees.js reads `git worktree list` and a
31
+ // deregistered husk is by definition absent from it. Measured 2026-09-13: 20
32
+ // directories under .claude/worktrees/, 13 registered. `prune-husks` is the
33
+ // deferred half of deregister-then-delete-later, and it is deliberately manual —
34
+ // the harness parks LIVE session directories in the same folder, so see
35
+ // huskDisposition for the three rules that keep a sweep from deleting one.
36
+ //
25
37
  // Usage:
26
38
  // node scripts/gds/worktree.js add <task-id|name> [--path <dir> | --outside] [--base <ref>]
27
39
  // node scripts/gds/worktree.js remove <name|path> [--force]
40
+ // node scripts/gds/worktree.js prune-husks [--force]
28
41
  // node scripts/gds/worktree.js list
29
42
 
30
43
  const fs = require('node:fs');
@@ -110,6 +123,31 @@ function removalPlan({ nodeModulesState }) {
110
123
  : ['git-worktree-remove'];
111
124
  }
112
125
 
126
+ // A husk younger than this may still be a LIVE session's directory rather than
127
+ // abandoned residue, so the default sweep leaves it for tomorrow.
128
+ const HUSK_QUIET_MS = 24 * 60 * 60 * 1000;
129
+
130
+ // Should this deregistered husk be deleted? Pure, so the safety rules can be proved
131
+ // without creating directories.
132
+ //
133
+ // WHY EACH RULE (task 1002594). The husks that collect under .claude/worktrees/ are
134
+ // not all the same animal: most are the OneDrive-lock residue cmdRemove below leaves
135
+ // behind, but the harness also creates a session directory there that is not a git
136
+ // worktree at all and is LIVE while its session runs. Deleting one of those pulls the
137
+ // floor out from under another agent's cwd. So: never a directory this process is
138
+ // standing in, never one holding files (residue is empty by construction — anything
139
+ // else is somebody's leftovers to inspect by hand), and by default never one touched
140
+ // today, because "recently created and empty" is exactly what a fresh session dir
141
+ // looks like. `force` waives only the age rule; in-use and non-empty are absolute.
142
+ function huskDisposition({ isCwdOrAncestor, isEmpty, ageMs, force }) {
143
+ if (isCwdOrAncestor) return { action: 'skip', why: 'in use — this session is inside it' };
144
+ if (!isEmpty) return { action: 'skip', why: 'not empty — inspect by hand' };
145
+ if (!force && ageMs < HUSK_QUIET_MS) {
146
+ return { action: 'skip', why: 'touched in the last 24h — may be a live session dir (--force sweeps it)' };
147
+ }
148
+ return { action: 'delete', why: 'empty, deregistered' };
149
+ }
150
+
113
151
  // -- CLI ----------------------------------------------------------------------
114
152
 
115
153
  function git(args, opts = {}) {
@@ -272,7 +310,11 @@ function cmdRemove(root, argv) {
272
310
  const still = listWorktrees(root).some((w) => path.resolve(w).toLowerCase() === path.resolve(wtPath).toLowerCase());
273
311
  if (!still) {
274
312
  console.log(`worktree deregistered: ${wtPath}`);
275
- console.log(' (the directory husk is briefly locked OneDrive — and deletes on its own later)');
313
+ // This used to claim the husk "deletes on its own later". It does not:
314
+ // nothing deleted it and nothing could see it either, since a deregistered
315
+ // directory is absent from `git worktree list` (task 1002594).
316
+ console.log(' (the directory husk is still locked — OneDrive — and stays on disk.');
317
+ console.log(' Sweep it once the lock clears: node scripts/gds/worktree.js prune-husks)');
276
318
  } else {
277
319
  console.error(`worktree remove failed — ${e.message}`);
278
320
  // The worktree is still live but its junction is already unlinked —
@@ -301,6 +343,56 @@ function cmdRemove(root, argv) {
301
343
  console.log(` (branch "${path.basename(wtPath)}" left in place — the land/ship pipeline owns branch cleanup)`);
302
344
  }
303
345
 
346
+ // The other half of the OneDrive story (task 1002594): cmdRemove above accepts a
347
+ // failed `git worktree remove` as long as the worktree got DEREGISTERED, and tells
348
+ // the builder the locked directory "deletes on its own later". Nothing did — and
349
+ // nothing could even SEE the residue, because stale-worktrees.js reads
350
+ // `git worktree list` and a deregistered husk is by definition absent from it. This
351
+ // is the sweep that makes that promise true.
352
+ function cmdPruneHusks(root, argv) {
353
+ const force = hasFlag('--force', argv);
354
+ const worktreesRoot = path.join(root, '.claude', 'worktrees');
355
+ const registered = new Set(listWorktrees(root).map((p) => path.resolve(p).replace(/\\/g, '/').toLowerCase()));
356
+ let entries;
357
+ try { entries = fs.readdirSync(worktreesRoot, { withFileTypes: true }); }
358
+ catch { console.log('no .claude/worktrees directory — nothing to sweep.'); return; }
359
+
360
+ const cwd = path.resolve(process.cwd());
361
+ const now = Date.now();
362
+ let deleted = 0;
363
+ let skipped = 0;
364
+ for (const ent of entries) {
365
+ if (!ent.isDirectory()) continue;
366
+ const huskPath = path.join(worktreesRoot, ent.name);
367
+ if (registered.has(path.resolve(huskPath).replace(/\\/g, '/').toLowerCase())) continue; // a real worktree: `remove` owns it
368
+ let isEmpty;
369
+ let ageMs;
370
+ try {
371
+ isEmpty = fs.readdirSync(huskPath).length === 0;
372
+ ageMs = now - fs.statSync(huskPath).mtimeMs;
373
+ } catch (e) {
374
+ console.log(` skip ${ent.name} (unreadable — ${e.message})`);
375
+ skipped += 1;
376
+ continue;
377
+ }
378
+ // path.relative is '' for the cwd itself and has no leading '..' for an ancestor.
379
+ const rel = path.relative(path.resolve(huskPath), cwd);
380
+ const isCwdOrAncestor = rel === '' || !rel.startsWith('..');
381
+ const { action, why } = huskDisposition({ isCwdOrAncestor, isEmpty, ageMs, force });
382
+ if (action === 'skip') { console.log(` skip ${ent.name} (${why})`); skipped += 1; continue; }
383
+ try {
384
+ fs.rmdirSync(huskPath); // rmdir, never rm -r: it refuses a directory that gained files since the check
385
+ console.log(` removed ${ent.name}`);
386
+ deleted += 1;
387
+ } catch (e) {
388
+ // Still locked (OneDrive) — say so plainly; the next sweep gets it.
389
+ console.log(` skip ${ent.name} (still locked — ${e.code || e.message}; it will sweep next run)`);
390
+ skipped += 1;
391
+ }
392
+ }
393
+ console.log(`husks: ${deleted} removed, ${skipped} skipped.`);
394
+ }
395
+
304
396
  function cmdList(root) {
305
397
  const all = listWorktrees(root);
306
398
  for (const w of all) {
@@ -322,13 +414,17 @@ function main() {
322
414
  }
323
415
  if (cmd === 'add') return cmdAdd(root, argv);
324
416
  if (cmd === 'remove') return cmdRemove(root, argv);
417
+ if (cmd === 'prune-husks') return cmdPruneHusks(root, argv);
325
418
  if (cmd === 'list') return cmdList(root);
326
419
  console.error('usage: node scripts/gds/worktree.js add <task-id|name> [--path <dir> | --outside] [--base <ref>]');
327
420
  console.error(' node scripts/gds/worktree.js remove <name|path> [--force]');
421
+ console.error(' node scripts/gds/worktree.js prune-husks [--force]');
328
422
  console.error(' node scripts/gds/worktree.js list');
329
423
  process.exit(2);
330
424
  }
331
425
 
332
426
  if (require.main === module) main();
333
427
 
334
- module.exports = { worktreeNameFor, needsJunction, removalPlan, nodeModulesState, shadowingNodeModules };
428
+ module.exports = {
429
+ worktreeNameFor, needsJunction, removalPlan, nodeModulesState, shadowingNodeModules, huskDisposition,
430
+ };
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.727'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.729'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -185,7 +185,13 @@ test('the packed tarball installs and every verb runs with no repo present', (t)
185
185
  // base or on missing arguments. Failing that way is fine; failing to find a file is not.
186
186
  const LOAD_ARGS = {
187
187
  login: ['login'],
188
- reauth: ['reauth'],
188
+ // `--no-open` is load-bearing, not cosmetic (task 1003909). HOME is a throwaway here, so
189
+ // there is no session and reauth takes its no-session path — which launches the builder's
190
+ // default browser at the dead API base (`rundll32 url.dll,FileProtocolHandler` on Windows).
191
+ // Without the flag, every run of this suite pops a connection-refused tab on the machine
192
+ // under test. The flag skips only the spawn: the script still loads, still prints its
193
+ // guidance, still exits 10, so every assertion below is unchanged.
194
+ reauth: ['reauth', '--no-open'],
189
195
  setup: ['setup'],
190
196
  box: ['box'],
191
197
  shell: ['shell'],
@@ -203,9 +209,42 @@ test('the packed tarball installs and every verb runs with no repo present', (t)
203
209
  'every advertised verb needs an entry here, or it goes unexercised',
204
210
  );
205
211
 
212
+ // No verb may launch a GUI on the machine running the suite (task 1003909). A script that can
213
+ // spawn a browser has two ways to be safe here, and it must pick one:
214
+ // * it offers an opt-out flag → the LOAD_ARGS entry above passes it, or
215
+ // * it stops short of the launch → STOPS_SHORT_OF_LAUNCH names a marker that must stay absent.
216
+ // Proving it, rather than assuming it, is the point: a verb that starts reaching its launcher
217
+ // fails here instead of on someone's desktop.
218
+ const LAUNCHES_BROWSER = /FileProtocolHandler|xdg-open/;
219
+ const OPT_OUT = '--no-open';
220
+ const STOPS_SHORT_OF_LAUNCH = {
221
+ // `bongos login` opens a browser from inside the device flow, several network steps past the
222
+ // instance-URL prompt — and with empty stdin that prompt is never answered, so the process
223
+ // ends right there. `▸ Connecting to` is the first thing printed once a URL is accepted, so
224
+ // its ABSENCE is the proof this run stopped well short of the launch.
225
+ login: /Connecting to/,
226
+ };
227
+ const mustStopShort = {};
228
+ for (const [verb, args] of Object.entries(LOAD_ARGS)) {
229
+ const src = fs.readFileSync(path.join(REPO_ROOT, 'scripts', 'gds', VERBS[verb].script), 'utf8');
230
+ if (!LAUNCHES_BROWSER.test(src)) continue;
231
+ if (src.includes(OPT_OUT)) {
232
+ assert.ok(args.includes(OPT_OUT),
233
+ `bongos ${verb} can spawn a browser and accepts ${OPT_OUT} — pass it in LOAD_ARGS, or this suite opens a window on the builder's machine`);
234
+ continue;
235
+ }
236
+ assert.ok(STOPS_SHORT_OF_LAUNCH[verb],
237
+ `bongos ${verb} can spawn a browser and has no ${OPT_OUT} — give it one, or add a STOPS_SHORT_OF_LAUNCH entry proving this run never reaches the launch`);
238
+ mustStopShort[verb] = STOPS_SHORT_OF_LAUNCH[verb];
239
+ }
240
+
206
241
  for (const [verb, args] of Object.entries(LOAD_ARGS)) {
207
242
  const res = runVerb(args);
208
243
  const all = `${res.stdout}\n${res.stderr}`;
244
+ if (mustStopShort[verb]) {
245
+ assert.doesNotMatch(all, mustStopShort[verb],
246
+ `bongos ${verb} got past the point where it can spawn a browser on this machine:\n${all}`);
247
+ }
209
248
  assert.ok(!/Cannot find module|MODULE_NOT_FOUND|ERR_MODULE_NOT_FOUND/.test(all),
210
249
  `bongos ${verb} hit a missing file — the manifest is incomplete:\n${all}`);
211
250
  assert.ok(!/ERR_REQUIRE_ESM|SyntaxError|ReferenceError/.test(all),
@@ -2,7 +2,7 @@
2
2
  // facts (task 1003365): no repo, no API, just the porcelain text + two predicates.
3
3
  import { test } from 'node:test';
4
4
  import assert from 'node:assert/strict';
5
- import { parseWorktreeList, classifyWorktrees, lineFor } from '../scripts/gds/stale-worktrees.js';
5
+ import { parseWorktreeList, classifyWorktrees, classifyHusks, lineFor } from '../scripts/gds/stale-worktrees.js';
6
6
 
7
7
  const PORCELAIN = [
8
8
  'worktree C:/repo',
@@ -64,3 +64,59 @@ test('--line caps the named list at six', () => {
64
64
  assert.match(line, /\+2 more/);
65
65
  assert.doesNotMatch(line, /task-7/);
66
66
  });
67
+
68
+ // ---- husks (task 1002594) ----
69
+ //
70
+ // The stale scan above reads `git worktree list`, so it is blind BY CONSTRUCTION to
71
+ // the residue of an OneDrive-locked remove: that directory was deregistered, which is
72
+ // precisely what makes it absent from the porcelain. The husk scan is the mirror —
73
+ // disk minus git.
74
+
75
+ const WT_ROOT = 'C:/repo/.claude/worktrees';
76
+
77
+ test('a directory on disk that git does not list is a husk', () => {
78
+ const husks = classifyHusks({
79
+ dirNames: ['task-1', 'task-2', 'leftover'],
80
+ worktreePaths: ['C:/repo', 'C:/repo/.claude/worktrees/task-1', 'C:/repo/.claude/worktrees/task-2'],
81
+ worktreesRoot: WT_ROOT,
82
+ });
83
+ assert.deepEqual(husks.map((h) => h.name), ['leftover']);
84
+ });
85
+
86
+ test('a registered worktree is never a husk, whatever the drive-letter case or separator', () => {
87
+ const husks = classifyHusks({
88
+ dirNames: ['task-1'],
89
+ worktreePaths: ['C:/repo', 'c:\\repo\\.claude\\worktrees\\task-1'],
90
+ worktreesRoot: WT_ROOT,
91
+ });
92
+ assert.deepEqual(husks, []);
93
+ });
94
+
95
+ test('every directory is a husk when git lists no worktrees but the main checkout', () => {
96
+ const husks = classifyHusks({
97
+ dirNames: ['b', 'a'],
98
+ worktreePaths: ['C:/repo'],
99
+ worktreesRoot: WT_ROOT,
100
+ });
101
+ assert.deepEqual(husks.map((h) => h.name), ['a', 'b']); // sorted, so the report is stable
102
+ });
103
+
104
+ test('--line names husks even when nothing is stale, and stays empty when neither exists', () => {
105
+ assert.equal(lineFor({ stale: [], live: [], husks: [] }), '');
106
+ assert.equal(lineFor({ stale: [], live: [] }), ''); // husks absent entirely (older caller)
107
+ const line = lineFor({ stale: [], live: [], husks: [{ name: 'task-9' }] });
108
+ assert.match(line, /Worktree husks: 1 /);
109
+ assert.match(line, /task-9/);
110
+ assert.match(line, /prune-husks/);
111
+ assert.doesNotMatch(line, /Stale worktrees/);
112
+ });
113
+
114
+ test('--line reports both leaks in one line when both exist', () => {
115
+ const line = lineFor({
116
+ stale: [{ name: 'task-1', why: 'merged into main' }],
117
+ live: [],
118
+ husks: [{ name: 'task-2' }],
119
+ });
120
+ assert.match(line, /Stale worktrees: 1 /);
121
+ assert.match(line, /Worktree husks: 1 /);
122
+ });
@@ -27,7 +27,9 @@ import os from 'node:os';
27
27
  import path from 'node:path';
28
28
 
29
29
  const require = createRequire(import.meta.url);
30
- const { worktreeNameFor, needsJunction, removalPlan, nodeModulesState, shadowingNodeModules } = require('../scripts/gds/worktree.js');
30
+ const { worktreeNameFor, needsJunction, removalPlan, nodeModulesState, shadowingNodeModules, huskDisposition } = require('../scripts/gds/worktree.js');
31
+
32
+ const HOUR = 60 * 60 * 1000;
31
33
 
32
34
  let passed = 0;
33
35
  let failed = 0;
@@ -189,6 +191,41 @@ test('the nearest stray wins, matching node\'s own resolution order', () => {
189
191
  'node stops at the first node_modules it meets walking up');
190
192
  });
191
193
 
194
+ // ---- 5. huskDisposition (task 1002594) ----
195
+ //
196
+ // A husk is a directory under .claude/worktrees/ that git does not list as a
197
+ // worktree — the residue of an OneDrive-locked `git worktree remove` that
198
+ // deregistered but could not delete. Three of the four inputs are SAFETY rules, and
199
+ // each one has a real directory behind it: the harness puts live session dirs in the
200
+ // same folder, and they look exactly like fresh residue.
201
+
202
+ test('an old, empty, unused husk is swept', () => {
203
+ eq(huskDisposition({ isCwdOrAncestor: false, isEmpty: true, ageMs: 48 * HOUR, force: false }).action, 'delete');
204
+ });
205
+
206
+ test('the directory this session is standing in is never swept — not even with --force', () => {
207
+ eq(huskDisposition({ isCwdOrAncestor: true, isEmpty: true, ageMs: 48 * HOUR, force: true }).action, 'skip');
208
+ });
209
+
210
+ test('a husk holding files is never swept — not even with --force', () => {
211
+ eq(huskDisposition({ isCwdOrAncestor: false, isEmpty: false, ageMs: 48 * HOUR, force: true }).action, 'skip');
212
+ });
213
+
214
+ test('a husk touched in the last 24h is held back — a live session dir looks just like this', () => {
215
+ const d = huskDisposition({ isCwdOrAncestor: false, isEmpty: true, ageMs: 2 * HOUR, force: false });
216
+ eq(d.action, 'skip');
217
+ eq(/24h/.test(d.why), true, 'the skip reason names the age rule');
218
+ });
219
+
220
+ test('--force waives the age rule, and only the age rule', () => {
221
+ eq(huskDisposition({ isCwdOrAncestor: false, isEmpty: true, ageMs: 2 * HOUR, force: true }).action, 'delete');
222
+ });
223
+
224
+ test('in-use outranks every other rule', () => {
225
+ eq(huskDisposition({ isCwdOrAncestor: true, isEmpty: false, ageMs: 0, force: true }).why,
226
+ 'in use — this session is inside it');
227
+ });
228
+
192
229
  console.log('');
193
230
  console.log(`worktree_helper: ${passed} passed, ${failed} failed`);
194
231
  process.exit(failed > 0 ? 1 : 0);