@bongos/core 1.19.728 → 1.19.730
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 +18 -18
- package/.claude/skills/worktree-clean/SKILL.md +5 -3
- package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +2 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/windows-builders.md +17 -1
- package/modules/government/protected-surfaces.json +6 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/stale-worktrees.js +68 -13
- package/scripts/gds/worktree.js +98 -2
- package/src/module-api.js +1 -1
- package/tests/government_protected_surfaces.mjs +204 -0
- package/tests/stale_worktrees.mjs +57 -1
- package/tests/worktree_helper.mjs +38 -1
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.730",
|
|
6
|
+
"core_contract": "1.19.730",
|
|
7
|
+
"source_commit": "3f93cfa0217ede14fe7949d98ed1e298cfc0bf17",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-13T15:
|
|
9
|
+
"built_at": "2026-09-13T15:24:59.913Z",
|
|
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": "
|
|
20
|
+
"tree_sha256": "262b2dd98348643b71dddab54d3637811bfcc13b336b0e48a2e2e516c16152c3",
|
|
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": "
|
|
275
|
+
"sha256": "4819fe448f4e35c3c7c3f4784718d51a7a4469f3c644d1b6c57b693b79da129e"
|
|
276
276
|
},
|
|
277
277
|
{
|
|
278
278
|
"path": ".devcontainer/Dockerfile",
|
|
@@ -1237,7 +1237,7 @@
|
|
|
1237
1237
|
{
|
|
1238
1238
|
"path": "docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md",
|
|
1239
1239
|
"mode": "0000644",
|
|
1240
|
-
"sha256": "
|
|
1240
|
+
"sha256": "0ad167bf535aab268bfedece8339cfcf315d85d264028d403ca2c889b6044fe2"
|
|
1241
1241
|
},
|
|
1242
1242
|
{
|
|
1243
1243
|
"path": "docs/adr/0152-landing-gate-served-by-the-core-app.md",
|
|
@@ -2482,7 +2482,7 @@
|
|
|
2482
2482
|
{
|
|
2483
2483
|
"path": "docs/module-api-changelog.md",
|
|
2484
2484
|
"mode": "0000644",
|
|
2485
|
-
"sha256": "
|
|
2485
|
+
"sha256": "73560b927b992fc1fdebf584b70d7ead444303708c64d76f38acb34f9cd01212"
|
|
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": "
|
|
2750
|
+
"sha256": "eef830271b0e77a161c61759970346b63730d3e6049b4944d24586abadb843de"
|
|
2751
2751
|
},
|
|
2752
2752
|
{
|
|
2753
2753
|
"path": "docs/recipes/work-on-a-task-from-your-dev-box.md",
|
|
@@ -4362,7 +4362,7 @@
|
|
|
4362
4362
|
{
|
|
4363
4363
|
"path": "modules/government/protected-surfaces.json",
|
|
4364
4364
|
"mode": "0000644",
|
|
4365
|
-
"sha256": "
|
|
4365
|
+
"sha256": "ec125c2bfba680c7645dda0b65dd6ece183dd57ceecdb5616ea2b8b34b776e56"
|
|
4366
4366
|
},
|
|
4367
4367
|
{
|
|
4368
4368
|
"path": "modules/government/ranks.js",
|
|
@@ -7512,12 +7512,12 @@
|
|
|
7512
7512
|
{
|
|
7513
7513
|
"path": "package-lock.json",
|
|
7514
7514
|
"mode": "0000644",
|
|
7515
|
-
"sha256": "
|
|
7515
|
+
"sha256": "9ae46f748e2463accb302101f1ee604ba32234081a73959da3d0d2e144e86390"
|
|
7516
7516
|
},
|
|
7517
7517
|
{
|
|
7518
7518
|
"path": "package.json",
|
|
7519
7519
|
"mode": "0000644",
|
|
7520
|
-
"sha256": "
|
|
7520
|
+
"sha256": "7210ef7cb81f359b560b4b1a0bacb86fd42ee0d48d51150e53af53e7b7fb4d7b"
|
|
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": "
|
|
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": "
|
|
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": "
|
|
9320
|
+
"sha256": "e16770f57d46b5ef94d4b98477943b7bff414b7a499081a338fd49709ccf6241"
|
|
9321
9321
|
},
|
|
9322
9322
|
{
|
|
9323
9323
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10807,7 +10807,7 @@
|
|
|
10807
10807
|
{
|
|
10808
10808
|
"path": "tests/government_protected_surfaces.mjs",
|
|
10809
10809
|
"mode": "0000644",
|
|
10810
|
-
"sha256": "
|
|
10810
|
+
"sha256": "25d8b1851872230a189dcaf04a82a769c1bc8b285540bce3eaed6159790edf4c"
|
|
10811
10811
|
},
|
|
10812
10812
|
{
|
|
10813
10813
|
"path": "tests/government_rank_sync.mjs",
|
|
@@ -12647,7 +12647,7 @@
|
|
|
12647
12647
|
{
|
|
12648
12648
|
"path": "tests/stale_worktrees.mjs",
|
|
12649
12649
|
"mode": "0000644",
|
|
12650
|
-
"sha256": "
|
|
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": "
|
|
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
|
|
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`.
|
|
@@ -79,6 +79,8 @@ This is the pillar that keeps configurability from becoming a foot-gun, and it h
|
|
|
79
79
|
|
|
80
80
|
**The protected-surface registry.** Today's rank→surface map — `PROTECTED_GLOBS` + the per-tier `EXPECTED_RANKS` — is baked into code (`src/bongos/permission-path-check.js`). Governance takes ownership of it **as data**: which paths are protected, and at what floor, becomes a governance-owned registry. The **out-of-band substrate stays the enforcer** — the pre-push hook, the grader pre-pass, `main-audit.js`, `gate-review.js`, and the goal-scope check *read the registry* instead of a hardcoded list, but enforcement never moves into the app. The Metic floor and the `system`-permission mapping *flow from* the registry. The registry (R101) is also the moment to **close the [ADR 0087](0087-compete-on-governance-not-tooling-cursor.md) §3 path-matching gap** the sweep confirmed: `permission-path-check.js`'s `normalize()` does backslash→slash + strip-`./` only — **no case-folding and no Unicode normalization** — so a case-varied or homoglyph path evades the protected-glob match (Linux-deployed, so latent today, but it is the Cursor-class `.cursorignore`/case-bypass CVE shape). The registry matcher should normalize (case-fold + Unicode NFC) and gate file **creation** as strictly as edits.
|
|
81
81
|
|
|
82
|
+
**Amendment, 2026-09-13 (task 1002891).** The registry now protects the files that *hold* the DigitalOcean and Cloudflare credentials, not only the files that decide who may spend them — and it does so from a **computed** set: `tests/government_protected_surfaces.mjs` recomputes every `process.env` credential read from the tracked tree and reds the build if one is unregistered. The task that asked for this named a single file as "the sole holder" of both tokens; by the time it was claimed that file read neither, and the readers had spread to five. A registry entry transcribed from prose would have recorded the gap as closed while leaving every live reader unprotected — the same drift the MIT1 execution-root tripwire exists to catch, which is why this one is built the same way.
|
|
83
|
+
|
|
82
84
|
The invariant this buys, stated as a testable promise (R112, R114): **the tab can re-map which surfaces are protected and at what floor (policy), but it cannot move enforcement into the request path (substrate).** An Archon can create a custom role and grant it every `system: false` permission in the catalog, assign it to a `xenos`, and that builder gains exactly those operational powers — **while a `system` permission the role "has" stays enforced by the substrate**: the deploy / protected-path / SSH attempt is still blocked, because [ADR 0043](0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md)'s layer-3 audit reads the real git history and the real registry, independent of anything the app grants. Config owns policy; the substrate owns enforcement; a forced grant cannot cross that line. (Note: `main` on this repo still has **no** GitHub branch protection — re-verified 2026-07-24, [ADR 0043](0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md) §"Branch-protection status" — so `main-audit.js` remains the *authoritative* substrate layer, not a redundant net. The registry does not change that; it only makes the protected list data instead of a constant.)
|
|
83
85
|
|
|
84
86
|
### 5. Capability in core, settings per project (C4)
|
|
@@ -1930,5 +1930,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1930
1930
|
landed since 1.19.726 with no explicit bump. run 34763937238. (task 1002620)
|
|
1931
1931
|
1.19.728 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1932
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)
|
|
1935
|
+
1.19.730 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1936
|
+
landed since 1.19.729 with no explicit bump. run 34765502139. (task 1002620)
|
|
1933
1937
|
---------------------------------------------------------------------------
|
|
1934
1938
|
```
|
|
@@ -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)
|
|
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
|
|
|
@@ -38,6 +38,12 @@
|
|
|
38
38
|
{ "glob": "modules/dev-box/boxes.js", "group": "box-access", "why": "boxScopeForRank / provision floor — decides who gets a full source clone" },
|
|
39
39
|
{ "glob": "modules/dev-box/box-access.js", "group": "box-access", "why": "decideSourceAccess (rank → starter|full source)" },
|
|
40
40
|
{ "glob": "modules/dev-box/box-credential.js", "group": "box-access", "why": "mints the box source credential" },
|
|
41
|
+
{ "glob": "scripts/gds/provision-config.js", "group": "box-access", "why": "task 1002891. CREDENTIAL READER: reads process.env.DO_API_TOKEN. The group already protects who DECIDES box access (boxes.js, box-access.js, box-credential.js) but protected nothing that HOLDS the infrastructure credentials those decisions spend — an asymmetry idea 1000692 raised and this entry closes. The set is computed, not listed: the tripwire in tests/government_protected_surfaces.mjs recomputes every process.env credential read from the tracked tree and reds if one is unregistered." },
|
|
42
|
+
{ "glob": "scripts/gds/provision-net.js", "group": "box-access", "why": "task 1002891. CREDENTIAL READER: reads process.env.CLOUDFLARE_API_TOKEN in checkDnsTokenScope. The filing task asserted this code lived in scripts/gds/provision.js; it had moved, and provision.js reads no credential at all. Registering the named file rather than the computed one would have recorded the gap as closed while closing nothing." },
|
|
43
|
+
{ "glob": "scripts/gds/box-infra.js", "group": "box-access", "why": "task 1002891. CREDENTIAL READER: reads process.env.CLOUDFLARE_API_TOKEN and process.env.CLOUDFLARE_ACCOUNT_ID into the CF config the box plane provisions against." },
|
|
44
|
+
{ "glob": "scripts/gds/cf-tunnel.js", "group": "box-access", "why": "task 1002891. CREDENTIAL READER: defaults its token parameter to process.env.CLOUDFLARE_API_TOKEN, so it reads the credential whenever a caller omits one. Not named by the recorded decision — the recomputation found it, which is the whole argument for computing the set instead of transcribing it." },
|
|
45
|
+
{ "glob": "scripts/gds/box.js", "group": "box-access", "why": "task 1002891. CREDENTIAL READER: reads process.env.DO_API_TOKEN — the DigitalOcean control plane for the box fleet. Also not named by the recorded decision. Registering it does NOT widen any module wall: the dev-box module key already lists this file and already derives protected:true from the modules/dev-box/ entries above, so the derived protected-module set is unchanged (verified before and after against src/bongos/module-scope-map.js)." },
|
|
46
|
+
{ "glob": "scripts/gds/do-api.js", "group": "box-access", "why": "task 1002891. The DigitalOcean API client the token is handed TO. Unlike the five above it reads no process.env value — it takes the token as a parameter and throws when a live call has none — so the drift tripwire does not demand it and its absence from that computed set is expected, not a bug. It is registered because the recorded decision named it and because it is the code that puts the credential on the wire." },
|
|
41
47
|
{ "glob": "modules/grading/grader.js", "group": "pipeline", "why": "BV1.R73: grading carved to modules/grading/ — the glob follows the file" },
|
|
42
48
|
{ "glob": "modules/grading/grader-workers/", "group": "pipeline", "why": "the adversarial panel (Narc/Hacker/Quality/Efficiency) — blunting these blunts the gate" },
|
|
43
49
|
{ "glob": "modules/grading/grader-rubric.json", "group": "pipeline", "why": "the grader's scoring rubric (ADR 0058)" },
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.730",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.730",
|
|
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.
|
|
3
|
+
"version": "1.19.730",
|
|
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: [
|
|
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
|
|
22
|
-
// `node scripts/gds/worktree.js remove <name
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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)
|
|
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 };
|
package/scripts/gds/worktree.js
CHANGED
|
@@ -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
|
-
|
|
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 = {
|
|
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.
|
|
74
|
+
const CORE_VERSION = '1.19.730'; // 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');
|
|
@@ -174,6 +174,58 @@ const ADDED_SINCE_R101 = [
|
|
|
174
174
|
+ 'otb-regen driver, so editing it changes how much of the tree that driver '
|
|
175
175
|
+ 'rewrites during a merge. executed-closure.js carries it as TRUSTED_NON_CODE.',
|
|
176
176
|
},
|
|
177
|
+
// task 1002891 (from idea 1000692, raised by the grader's hacker worker). Group
|
|
178
|
+
// 'box-access' protected who DECIDES box access but nothing that HOLDS the
|
|
179
|
+
// infrastructure credentials those decisions spend. The five readers below are the
|
|
180
|
+
// COMPUTED set — the credential-reader tripwire further down recomputes it from the
|
|
181
|
+
// tracked tree on every run — plus do-api.js, which the credential is handed to.
|
|
182
|
+
{
|
|
183
|
+
after: 'modules/dev-box/box-credential.js',
|
|
184
|
+
glob: 'scripts/gds/provision-config.js',
|
|
185
|
+
why: 'reads process.env.DO_API_TOKEN. The DigitalOcean credential for instance '
|
|
186
|
+
+ 'provisioning, previously unprotected at any rank while the files deciding WHO '
|
|
187
|
+
+ 'may provision were protected at Metic.',
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
after: 'scripts/gds/provision-config.js',
|
|
191
|
+
glob: 'scripts/gds/provision-net.js',
|
|
192
|
+
why: 'reads process.env.CLOUDFLARE_API_TOKEN in checkDnsTokenScope. The filing task '
|
|
193
|
+
+ 'placed this code in scripts/gds/provision.js, which by the time of the claim '
|
|
194
|
+
+ 'read no credential at all — registering the file the prose named would have '
|
|
195
|
+
+ 'recorded the gap as closed while closing nothing.',
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
after: 'scripts/gds/provision-net.js',
|
|
199
|
+
glob: 'scripts/gds/box-infra.js',
|
|
200
|
+
why: 'reads process.env.CLOUDFLARE_API_TOKEN and process.env.CLOUDFLARE_ACCOUNT_ID '
|
|
201
|
+
+ 'into the Cloudflare config the box plane provisions tunnels and DNS against.',
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
after: 'scripts/gds/box-infra.js',
|
|
205
|
+
glob: 'scripts/gds/cf-tunnel.js',
|
|
206
|
+
why: 'defaults its token parameter to process.env.CLOUDFLARE_API_TOKEN, so it reads '
|
|
207
|
+
+ 'the credential whenever a caller omits one. The recorded decision did not name '
|
|
208
|
+
+ 'this file; the recomputation found it, which is the case for computing the set '
|
|
209
|
+
+ 'rather than transcribing a list.',
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
after: 'scripts/gds/cf-tunnel.js',
|
|
213
|
+
glob: 'scripts/gds/box.js',
|
|
214
|
+
why: 'reads process.env.DO_API_TOKEN — the DigitalOcean control plane for the box '
|
|
215
|
+
+ 'fleet, and also not named by the recorded decision. Adding it widens no module '
|
|
216
|
+
+ 'wall: the dev-box key already lists this path and already derives protected '
|
|
217
|
+
+ 'from the modules/dev-box/ entries, so the derived protected-module set is '
|
|
218
|
+
+ 'unchanged (asserted below).',
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
after: 'scripts/gds/box.js',
|
|
222
|
+
glob: 'scripts/gds/do-api.js',
|
|
223
|
+
why: 'the DigitalOcean API client the token is handed TO. It reads no process.env '
|
|
224
|
+
+ 'value — the token arrives as a parameter and the constructor throws when a live '
|
|
225
|
+
+ 'call has none — so the tripwire below does not demand it and its absence from '
|
|
226
|
+
+ 'that computed set is expected. Registered because it is the code that puts the '
|
|
227
|
+
+ 'credential on the wire.',
|
|
228
|
+
},
|
|
177
229
|
];
|
|
178
230
|
|
|
179
231
|
// Surfaces RENAMED since R101 — still protected, under a new path. Distinct from
|
|
@@ -303,6 +355,158 @@ test('every execution root in the closure is a protected surface (MIT1 drift tri
|
|
|
303
355
|
}
|
|
304
356
|
});
|
|
305
357
|
|
|
358
|
+
// ---------- the credential tripwire: every credential READER is a protected surface ----------
|
|
359
|
+
//
|
|
360
|
+
// task 1002891, from idea 1000692 (the grader's hacker worker, on the 1002690 re-grade).
|
|
361
|
+
// Group 'box-access' protected the files that decide WHO may provision a box, but nothing
|
|
362
|
+
// that HOLDS the DigitalOcean / Cloudflare credentials those decisions spend.
|
|
363
|
+
//
|
|
364
|
+
// WHY THIS IS COMPUTED AND NOT LISTED. The task offered two options, both resting on a
|
|
365
|
+
// named file: "scripts/gds/provision.js is the sole holder of the DO and CF tokens". By
|
|
366
|
+
// the time it was claimed that sentence was false in both halves — provision.js reads
|
|
367
|
+
// NEITHER token (the code moved), and the readers are spread across five files. Taking the
|
|
368
|
+
// prose at its word would have registered a file holding no credential and recorded the
|
|
369
|
+
// gap as closed. That is the failure shape of task 1003366, where an audit named four
|
|
370
|
+
// execution roots and the code had eight, and the four missed the earliest vector.
|
|
371
|
+
//
|
|
372
|
+
// So the registry is pinned to what the tree actually does, and this test is the pin: add
|
|
373
|
+
// a process.env read of a credential anywhere in tracked JS and the build reds until the
|
|
374
|
+
// registry grows to match.
|
|
375
|
+
//
|
|
376
|
+
// FAIL CLOSED, DELIBERATELY. The scan is textual, so a `process.env.DO_API_TOKEN` written
|
|
377
|
+
// inside a comment counts as a read. That over-reports rather than under-reports, which is
|
|
378
|
+
// the right direction for a security tripwire: the fix is to register the file or reword
|
|
379
|
+
// the comment, and neither leaves a live credential unprotected.
|
|
380
|
+
const CREDENTIAL_ENV = ['DO_API_TOKEN', 'CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_ACCOUNT_ID'];
|
|
381
|
+
|
|
382
|
+
// Which CREDENTIAL_ENV names this source reads off process.env. Four spellings, because a
|
|
383
|
+
// tripwire that only knows the idiom in use today goes quiet the first time someone writes
|
|
384
|
+
// the same read differently: `process.env.NAME`, `process.env['NAME']`,
|
|
385
|
+
// `process['env']['NAME']`, and destructuring — `const { NAME } = process.env`, rename and
|
|
386
|
+
// multi-line included.
|
|
387
|
+
//
|
|
388
|
+
// KNOWN LIMIT, stated rather than papered over: a read through an intermediate binding
|
|
389
|
+
// (`const env = process.env; env.DO_API_TOKEN`) or a name assembled at runtime is not
|
|
390
|
+
// textually detectable and is not caught. Closing that needs an AST pass over the tree,
|
|
391
|
+
// which is a different piece of work; nothing in the tree reads a credential that way
|
|
392
|
+
// today, and the registry entries do not depend on the scan to stay correct — the scan is
|
|
393
|
+
// what catches DRIFT.
|
|
394
|
+
//
|
|
395
|
+
// Returns a sorted array; [] means "reads none", which is indistinguishable from "the
|
|
396
|
+
// scanner broke", so every caller also asserts the whole scan is non-vacuous. A scanner
|
|
397
|
+
// that quietly finds nothing looks exactly like a passing test.
|
|
398
|
+
function credentialReadsIn(source) {
|
|
399
|
+
const found = new Set();
|
|
400
|
+
for (const name of CREDENTIAL_ENV) {
|
|
401
|
+
const forms = [
|
|
402
|
+
new RegExp(`process\\.env\\.${name}\\b`),
|
|
403
|
+
new RegExp(`process\\.env\\[\\s*['"\`]${name}['"\`]\\s*\\]`),
|
|
404
|
+
new RegExp(`process\\s*\\[\\s*['"\`]env['"\`]\\s*\\]\\s*\\[\\s*['"\`]${name}['"\`]\\s*\\]`),
|
|
405
|
+
// Destructuring. [^}]* spans newlines inside a character class, so a multi-line
|
|
406
|
+
// destructure matches; the \\b keeps NAME_SUFFIX from counting as NAME.
|
|
407
|
+
new RegExp(`\\{[^}]*\\b${name}\\b[^}]*\\}\\s*=\\s*process\\.env\\b`),
|
|
408
|
+
];
|
|
409
|
+
if (forms.some((re) => re.test(source))) found.add(name);
|
|
410
|
+
}
|
|
411
|
+
return [...found].sort();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// tests/ is out of scope, and the exclusion is load-bearing rather than convenient: the
|
|
415
|
+
// unit tests directly below hold `process.env.DO_API_TOKEN` as fixture text, so without it
|
|
416
|
+
// this scanner reports ITSELF as an unregistered credential holder (it did, on the first
|
|
417
|
+
// run — which is also the evidence that it detects rather than quietly finding nothing).
|
|
418
|
+
// A test fixture is not a credential holder: tests/ is not the provisioning path, holds no
|
|
419
|
+
// live token, and is never what a droplet executes. Every path that DOES reach DigitalOcean
|
|
420
|
+
// or Cloudflare lives under scripts/ or modules/ and stays in scope.
|
|
421
|
+
const SCAN_EXCLUDED = /^tests\//;
|
|
422
|
+
|
|
423
|
+
// The tracked JS tree, scanned for credential reads. git ls-files (not a directory walk)
|
|
424
|
+
// so an untracked scratch file can never add or remove a protected surface.
|
|
425
|
+
function credentialReaders() {
|
|
426
|
+
const tracked = execFileSync('git', ['ls-files', '*.js', '*.mjs', '*.cjs'],
|
|
427
|
+
{ cwd: ROOT, encoding: 'utf8' })
|
|
428
|
+
.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
429
|
+
return tracked
|
|
430
|
+
.filter((rel) => !SCAN_EXCLUDED.test(rel))
|
|
431
|
+
.filter((rel) => credentialReadsIn(fs.readFileSync(path.join(ROOT, rel), 'utf8')).length > 0)
|
|
432
|
+
.sort();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
test('credentialReadsIn detects a read in every form it must', () => {
|
|
436
|
+
assert.deepEqual(credentialReadsIn('const t = process.env.DO_API_TOKEN || null;'),
|
|
437
|
+
['DO_API_TOKEN']);
|
|
438
|
+
assert.deepEqual(credentialReadsIn("process.env['CLOUDFLARE_API_TOKEN']"),
|
|
439
|
+
['CLOUDFLARE_API_TOKEN']);
|
|
440
|
+
assert.deepEqual(credentialReadsIn('process.env["CLOUDFLARE_ACCOUNT_ID"]'),
|
|
441
|
+
['CLOUDFLARE_ACCOUNT_ID']);
|
|
442
|
+
assert.deepEqual(credentialReadsIn('process.env[ `DO_API_TOKEN` ]'), ['DO_API_TOKEN']);
|
|
443
|
+
assert.deepEqual(credentialReadsIn("process['env']['DO_API_TOKEN']"), ['DO_API_TOKEN']);
|
|
444
|
+
assert.deepEqual(credentialReadsIn('const { DO_API_TOKEN } = process.env;'),
|
|
445
|
+
['DO_API_TOKEN']);
|
|
446
|
+
assert.deepEqual(credentialReadsIn('const { DO_API_TOKEN: tok } = process.env;'),
|
|
447
|
+
['DO_API_TOKEN'], 'a renamed destructure is still a read');
|
|
448
|
+
assert.deepEqual(
|
|
449
|
+
credentialReadsIn('const {\n PORT,\n CLOUDFLARE_API_TOKEN,\n} = process.env;'),
|
|
450
|
+
['CLOUDFLARE_API_TOKEN'], 'a multi-line destructure is still a read');
|
|
451
|
+
assert.deepEqual(
|
|
452
|
+
credentialReadsIn('process.env.DO_API_TOKEN; process.env.CLOUDFLARE_API_TOKEN;'),
|
|
453
|
+
['CLOUDFLARE_API_TOKEN', 'DO_API_TOKEN']);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test('credentialReadsIn does not fire on a near-miss', () => {
|
|
457
|
+
// The guard that matters: a scanner matching too loosely reds honest files until
|
|
458
|
+
// someone loosens it, and a loosened scanner protects nothing.
|
|
459
|
+
assert.deepEqual(credentialReadsIn(''), []);
|
|
460
|
+
assert.deepEqual(credentialReadsIn('export DO_API_TOKEN or run without --apply'), []);
|
|
461
|
+
assert.deepEqual(credentialReadsIn('opts.token || cfg.DO_API_TOKEN'), []);
|
|
462
|
+
assert.deepEqual(credentialReadsIn('process.env.DO_API_TOKEN_FILE'), [],
|
|
463
|
+
'a longer env name that merely starts with a credential name is a different variable');
|
|
464
|
+
assert.deepEqual(credentialReadsIn('process.env.SOME_OTHER_TOKEN'), []);
|
|
465
|
+
assert.deepEqual(credentialReadsIn('const { DO_API_TOKEN_FILE } = process.env;'), [],
|
|
466
|
+
'the destructure form must respect the word boundary too');
|
|
467
|
+
assert.deepEqual(credentialReadsIn('const { DO_API_TOKEN } = someOtherObject;'), [],
|
|
468
|
+
'destructuring a credential-shaped name off something else is not an env read');
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test('every credential reader in the tree is a protected surface (1002891 tripwire)', () => {
|
|
472
|
+
const readers = credentialReaders();
|
|
473
|
+
assert.ok(readers.length >= 5,
|
|
474
|
+
'fixture sanity: the scan found '
|
|
475
|
+
+ `${readers.length} credential reader(s), and the tree has had at least five since `
|
|
476
|
+
+ 'task 1002891. A scan that finds nothing passes every assertion below without '
|
|
477
|
+
+ 'checking anything — treat this as the scanner being broken, not as good news.');
|
|
478
|
+
for (const rel of readers) {
|
|
479
|
+
const surface = ppc.surfaceFor(rel);
|
|
480
|
+
assert.ok(surface,
|
|
481
|
+
`${rel} READS an infrastructure credential off process.env `
|
|
482
|
+
+ `(${credentialReadsIn(fs.readFileSync(path.join(ROOT, rel), 'utf8')).join(', ')}) `
|
|
483
|
+
+ 'but no protected surface covers it. Add it to '
|
|
484
|
+
+ 'modules/government/protected-surfaces.json (group "box-access") and declare it '
|
|
485
|
+
+ 'in ADDED_SINCE_R101 above.');
|
|
486
|
+
assert.ok(ppc.RANK_TIER[surface.floor] >= ppc.RANK_TIER.metic,
|
|
487
|
+
`${rel} holds an infrastructure credential but is floored at '${surface.floor}' — `
|
|
488
|
+
+ 'below Metic it stays writable by the ranks ADR 0043 keeps out of the trust core.');
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test('registering the credential readers widened no module wall', () => {
|
|
493
|
+
// The second-order effect task 1002891 was explicitly warned about, asserted rather
|
|
494
|
+
// than reasoned about. src/bongos/module-scope-map.js DERIVES a module's `protected`
|
|
495
|
+
// flag from overlap with this registry, and a protected module makes its goals
|
|
496
|
+
// Archon-mediated-join. The six surfaces added for 1002891 are file-level for exactly
|
|
497
|
+
// this reason: the modules/provisioning/ subtree (the task's option (b)) would have
|
|
498
|
+
// flipped `provisioning` and walled goals 1000032, 1000046 and 1000059.
|
|
499
|
+
const map = require(path.join(ROOT, 'src', 'bongos', 'module-scope-map.js'));
|
|
500
|
+
const protectedNow = map.protectedModules();
|
|
501
|
+
assert.ok(!protectedNow.includes('provisioning'),
|
|
502
|
+
'the provisioning module derived protected:true — a credential-reader entry reached '
|
|
503
|
+
+ 'modules/provisioning/ or scripts/gds/provision.js, which walls goals 1000032, '
|
|
504
|
+
+ '1000046 and 1000059 behind an Archon join. Keep the entries file-level.');
|
|
505
|
+
assert.ok(protectedNow.includes('dev-box'),
|
|
506
|
+
'dev-box was already protected via its modules/dev-box/ globs before 1002891; if it '
|
|
507
|
+
+ 'is not, the premise of the box.js entry has changed.');
|
|
508
|
+
});
|
|
509
|
+
|
|
306
510
|
// Metic is a FLOOR, not a fixed value (task 1003191). Pinning every surface to
|
|
307
511
|
// exactly 'metic' made the registry's own contract untestable and, worse,
|
|
308
512
|
// illegal: governance owns this data and raising one surface to 'archon' is a
|
|
@@ -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);
|