@bongos/core 1.19.119 → 1.19.121

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,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.119",
6
- "core_contract": "1.19.119",
7
- "source_commit": "1a3ff47e4556fdab934c24fb8e01bbb4bb01d095",
5
+ "core_version": "1.19.121",
6
+ "core_contract": "1.19.121",
7
+ "source_commit": "5bc8620889777dfaeebdc0bef12a6ac24c036e61",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-14T20:02:21.430Z",
9
+ "built_at": "2026-08-14T20:40:48.360Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 302,
12
+ "docs_redacted": 303,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1239,
14
+ "functional_verbatim": 1243,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1562,
20
- "tree_sha256": "df9b1ed828fdacdb04089002baf65d5176be91c467884f599031bbd8218cae30",
19
+ "file_count": 1567,
20
+ "tree_sha256": "c642320ac42a83f1863a48c74b443b0c761d83a85ad45114720ee0a9f60bc850",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -57,7 +57,7 @@
57
57
  {
58
58
  "path": ".claude/skills/builder-end/SKILL.md",
59
59
  "mode": "0000644",
60
- "sha256": "feeb86dee8244ac7205cc39c67adedfa30544ba9c4b594fd029e7dc4d1ac7383"
60
+ "sha256": "79afe09324f8b9ac27e0500972af701020d502f4a2502323b821ff5234216f70"
61
61
  },
62
62
  {
63
63
  "path": ".claude/skills/builder-exit/SKILL.md",
@@ -1467,7 +1467,7 @@
1467
1467
  {
1468
1468
  "path": "docs/file-map.md",
1469
1469
  "mode": "0000644",
1470
- "sha256": "e4df7e072247c388228632abf5cb2ed0fdae535d09ea06c4f87e9bcae056769e"
1470
+ "sha256": "f76f816dbbab7668c7dbd1ff7406dee095e043f23262a2c6afd900ff9c59b337"
1471
1471
  },
1472
1472
  {
1473
1473
  "path": "docs/handoff-template.md",
@@ -1577,7 +1577,7 @@
1577
1577
  {
1578
1578
  "path": "docs/recipes/core-release-pipeline.md",
1579
1579
  "mode": "0000644",
1580
- "sha256": "80d1e530d5c15bad53c3bcead244dfbb824236d1db0d281a41abd09f009f300d"
1580
+ "sha256": "512d67da7fba233e31f318b3fa8aacb377fecaf4f1810696f8357507620424db"
1581
1581
  },
1582
1582
  {
1583
1583
  "path": "docs/recipes/devbox-app-release.md",
@@ -1642,7 +1642,7 @@
1642
1642
  {
1643
1643
  "path": "docs/recipes/ops-gotchas.md",
1644
1644
  "mode": "0000644",
1645
- "sha256": "1e35b15c1f366aae52aedbd12e4a193343f8fd13b2fc6bdec13f927b7a22e5f4"
1645
+ "sha256": "6374ff40b5b054d57761d25bf7b21059c8b2d8d35354120cc221431edd3260a2"
1646
1646
  },
1647
1647
  {
1648
1648
  "path": "docs/recipes/overnight-builder.md",
@@ -1699,6 +1699,11 @@
1699
1699
  "mode": "0000644",
1700
1700
  "sha256": "732f3b01b7d2e6cc238dd5f48d5ed774f6c2c27ecc737710047758bf85f2d9e5"
1701
1701
  },
1702
+ {
1703
+ "path": "docs/recipes/windows-builders.md",
1704
+ "mode": "0000644",
1705
+ "sha256": "2e24454839aa02d1f3205dd92a6856254e59ca04f88573a1f42641ddb5924e32"
1706
+ },
1702
1707
  {
1703
1708
  "path": "docs/recipes/work-on-a-task-from-your-dev-box.md",
1704
1709
  "mode": "0000644",
@@ -4162,12 +4167,12 @@
4162
4167
  {
4163
4168
  "path": "package-lock.json",
4164
4169
  "mode": "0000644",
4165
- "sha256": "2a008b4ca09e8abb2613478eb696fb66b63b4dd075cee7bbd6d7fb60a9866513"
4170
+ "sha256": "f4b51e6942ce9cd2f52b077f67e671feb4ac93e03d08a2fc487981205fc362b9"
4166
4171
  },
4167
4172
  {
4168
4173
  "path": "package.json",
4169
4174
  "mode": "0000644",
4170
- "sha256": "ce8caad093e4acd61c3de150836996162c8265ed1ceb09a27130bbe58ea10aaf"
4175
+ "sha256": "1d7889d13a0d5ab2930528a5b8efdde5e9b28cb1467f5f0b29dec2e60ce722f0"
4171
4176
  },
4172
4177
  {
4173
4178
  "path": "public-docs/index.html",
@@ -4507,7 +4512,7 @@
4507
4512
  {
4508
4513
  "path": "scripts/gds/api.js",
4509
4514
  "mode": "0000644",
4510
- "sha256": "8f7332da44883801fb4674747c9e9a60e485353b36df6bebe1db8fc1ebd3558f"
4515
+ "sha256": "96d6b69e9440dac3ed81c490e460a832d385714de0b379650af80817f89ded0f"
4511
4516
  },
4512
4517
  {
4513
4518
  "path": "scripts/gds/api.ps1",
@@ -4657,7 +4662,7 @@
4657
4662
  {
4658
4663
  "path": "scripts/gds/cli-lib.js",
4659
4664
  "mode": "0000644",
4660
- "sha256": "e86444334085b00ff4256fcb6bf15e5f443dc75bdcbf8ca6eb87f0b41fcd3230"
4665
+ "sha256": "83d27d14600c4c8ec8912c7ad8ce771e0e34903ccc9b567dcc27cb95aeb3c37d"
4661
4666
  },
4662
4667
  {
4663
4668
  "path": "scripts/gds/code.js",
@@ -5307,7 +5312,7 @@
5307
5312
  {
5308
5313
  "path": "scripts/gds/ship.js",
5309
5314
  "mode": "0000644",
5310
- "sha256": "be7d4d35f8f19e4687e9a8444ca36c222895805a7ec35952f92a27ef567ed2b5"
5315
+ "sha256": "7ae14021dbae94ffc5a70cf05081e645ca2dcf9685ccf135658538cb44e79dde"
5311
5316
  },
5312
5317
  {
5313
5318
  "path": "scripts/gds/smoke-batch-claim.sh",
@@ -5384,6 +5389,11 @@
5384
5389
  "mode": "0000644",
5385
5390
  "sha256": "32e0c1f2a955d4cabeae00d3749b283abd70f05f34b5fcbd84be3f35e2e655c8"
5386
5391
  },
5392
+ {
5393
+ "path": "scripts/gds/worktree.js",
5394
+ "mode": "0000644",
5395
+ "sha256": "74fc8349287498ede068156f904aaef3f7dbeafce2b40942dd5a8a2a0208564a"
5396
+ },
5387
5397
  {
5388
5398
  "path": "scripts/hall-preview/README.md",
5389
5399
  "mode": "0000644",
@@ -5742,7 +5752,7 @@
5742
5752
  {
5743
5753
  "path": "src/module-api.js",
5744
5754
  "mode": "0000644",
5745
- "sha256": "a990b6b2c309018a149e0190b2fcd74a0d6e2b3754cfdd3eabdf505038a19d87"
5755
+ "sha256": "180b8c272ae0895dc57e67f1a0558c603bb811e6bda5d0dee4d1547239851728"
5746
5756
  },
5747
5757
  {
5748
5758
  "path": "src/module-loader/catalog.js",
@@ -5854,6 +5864,11 @@
5854
5864
  "mode": "0000644",
5855
5865
  "sha256": "3a54640e1707b27916b9f0168e1ca12141f34148be975d7cad74f02aac166977"
5856
5866
  },
5867
+ {
5868
+ "path": "tests/api_msys_path_repair.mjs",
5869
+ "mode": "0000644",
5870
+ "sha256": "ebb0a0183ca30ebe5207c4703e85e49c32e958778d76373f597553cf259feb39"
5871
+ },
5857
5872
  {
5858
5873
  "path": "tests/api_pagination_contract.mjs",
5859
5874
  "mode": "0000644",
@@ -7609,6 +7624,11 @@
7609
7624
  "mode": "0000644",
7610
7625
  "sha256": "e236bc3d6882bc6ddf5d389371a92a96aec62769f14c37bbca31810bcbea0d61"
7611
7626
  },
7627
+ {
7628
+ "path": "tests/ship_post_land_verify.mjs",
7629
+ "mode": "0000644",
7630
+ "sha256": "6df2fb6fde081c9d98baab346a794946a51e4e51e70d154d41d9e94939180d52"
7631
+ },
7612
7632
  {
7613
7633
  "path": "tests/ship_preflight.mjs",
7614
7634
  "mode": "0000644",
@@ -7824,6 +7844,11 @@
7824
7844
  "mode": "0000644",
7825
7845
  "sha256": "aa1aab27bdfc5761586514fa07dacfad125fb1d5dcc7f01c060cbe3556985454"
7826
7846
  },
7847
+ {
7848
+ "path": "tests/worktree_helper.mjs",
7849
+ "mode": "0000644",
7850
+ "sha256": "367e4c9a63bc8752b94d0fc7af171d34390034adc2a75c8ec15f00b335c077ca"
7851
+ },
7827
7852
  {
7828
7853
  "path": "tests/write_validation_gaps.mjs",
7829
7854
  "mode": "0000644",
@@ -13,7 +13,7 @@ Composes operations that already exist, in a safe order, with the destructive on
13
13
 
14
14
  1. **Resolve the claim** — `/builder-ship` when there's committed, shippable work; `/builder-release` when there isn't. (It calls the sibling skills; it does not re-implement shipping.)
15
15
  2. **Clean-git verify** — refuse to go further if any *tracked* file is uncommitted. This is the same guard `scripts/gds/ship.js` enforces (`trackedDirty` in [`src/bongos/ship-preflight.js`](../../../src/bongos/ship-preflight.js)): untracked scratch is ignored, but a modified/staged/deleted tracked file means real work isn't committed — stop before anything is lost.
16
- 3. **Worktree teardown** — from the main checkout, `git worktree remove` this session's linked worktree, `prune`, and delete the now-merged `claude/<name>` branch.
16
+ 3. **Worktree teardown** — from the main checkout, remove this session's linked worktree via `node scripts/gds/worktree.js remove` (junction-aware), `prune`, and delete the now-merged `claude/<name>` branch.
17
17
  4. **Box close-out (dev box only)** — if running on a dev box (`/etc/otb/box.env` exists), offer to `close-box` so the box stops billing. Optional and gated on a yes; declining just lets the box idle-park. On a laptop this step is skipped — there's no box to close.
18
18
 
19
19
  It never pushes to `main`, never `ssh`-es, never runs a deploy — landing is `/builder-ship`'s job (server-mediated). It only ever touches the caller's **own** claim, **own** worktree, and **own** box.
@@ -46,7 +46,7 @@ Proceed only when **all** hold: we're in a linked worktree, the claim is resolve
46
46
  You can't remove the worktree you're standing in, so run the removal **from the main checkout** — resolve its path the way `ship.js` does (`findMainWorktree`: parse `git worktree list --porcelain` for the block whose `branch` is `main`). From there:
47
47
 
48
48
  ```bash
49
- git worktree remove <this-worktree-path> # refuses if dirty — that's the safety net; do not --force
49
+ node scripts/gds/worktree.js remove <this-worktree-path> # junction-aware wrapper: unlinks a node_modules junction FIRST, then runs `git worktree remove` (which still refuses a dirty tree; do not --force)
50
50
  git worktree prune
51
51
  git branch -d claude/<worktree-name> # -d (not -D): git refuses an unmerged branch, by design
52
52
  ```
@@ -69,7 +69,7 @@ Skip this entirely if step 0 found no `/etc/otb/box.env` (a laptop — there's n
69
69
  ## Constraints
70
70
 
71
71
  - **Own-scoped.** Only this session's claim, this session's worktree, and the caller's own box — never another builder's claim, never another worktree, never `git worktree remove` a path you didn't create, never another builder's box.
72
- - **Never destroy uncommitted work.** The clean-git verify gates teardown; `git worktree remove` (no `--force`) and `git branch -d` (not `-D`) are the load-bearing safety nets — keep them as written.
72
+ - **Never destroy uncommitted work.** The clean-git verify gates teardown; the dirty-tree refusal in `git worktree remove` and `git branch -d` (not `-D`) are the load-bearing safety nets — keep them as written. But note: that refusal does NOT protect a Windows worktree carrying a `node_modules` junction — node_modules is gitignored, so the clean check passes and `git worktree remove` (even without `--force`) follows the junction and deletes the SHARED node_modules (task 1002809). That is why teardown runs through `node scripts/gds/worktree.js remove`, which unlinks the junction first.
73
73
  - **Box close is opt-in and destructive.** On a box, never `close-box` without an explicit yes — it deprovisions the box and any unshipped `/workspace` work is gone. Declining is always fine; the box idle-parks on its own.
74
74
  - **Never push / ssh / deploy.** Landing goes through `/builder-ship` (server-mediated); this skill stays local to the builder's machine (its one possible API write is the opt-in own-scoped `close-box`).
75
75
  - **The destructive steps (teardown, box-close) never run unattended without a clear go.** Ship vs. release can be inferred from state (commits ⇒ ship, none ⇒ release) and proceed; removing the worktree ends the session and closing the box destroys it, so wait for an explicit yes before steps 3 and 4.
package/docs/file-map.md CHANGED
@@ -56,6 +56,7 @@
56
56
  │ ├── recipes/ ← long-form operational guides (formerly learnings/) — Postgres, Caddy, the pixel-art pipeline, etc.
57
57
  │ │ ├── README.md
58
58
  │ │ ├── ops-gotchas.md ← Postgres, Caddy, SSH, Mac toolchain recipes
59
+ │ │ ├── windows-builders.md ← (task 1002809) the Windows + Git Bash builder loop: worktree junction trap, api.js MSYS auto-repair, --body-file pattern, local test baseline
59
60
  │ │ ├── pixel-art-pipeline.md
60
61
  │ │ ├── overnight-builder.md ← (#358) operator runbook for the autonomous overnight session-runner
61
62
  │ │ ├── builder-box-lifecycle.md ← (#598/ADR 0031) operator runbook: provision/park/wake/deprovision dev boxes, the two sweeps, config env, going-live steps
@@ -170,6 +171,7 @@
170
171
  │ ├── smoke-box.sh ← (#598) hermetic box-lifecycle smoke (no DB/DO/network): runs tests/boxes.mjs + tests/cf_tunnel.mjs (#771 named tunnel) + route-rank + module-load
171
172
  │ ├── recall.js ← `/recall` CLI: two-call progressive-disclosure search (summary → `--expand <id>`) over the GDS retrieval layer (ADR 0060); `node scripts/gds/recall.js <query>`
172
173
  │ ├── capture.js ← file an idea_inbox row from the CLI (the ideas.capture kernel port); backs the "capture a tangent" flow across skills
174
+ │ ├── worktree.js ← (task 1002809) junction-safe worktree add/remove/list — outside-repo worktrees get a node_modules junction via node (no shell quoting), removed BEFORE `git worktree remove`
173
175
  │ ├── learning-capture.js ← file a `learnings` row (DB-backed short-form rule capture, replacing hand-edited recipe files for a single gotcha)
174
176
  │ ├── docs-entropy.js ← ([#…] docs-entropy) markdown-corpus linter: broken internal links + cross-file duplicate-prose detector across every tracked `.md`; `--verbose` / `--file-ideas`
175
177
  │ ├── gen-diagrams.js ← generates the 5 onboarding diagrams (`.mmd`/`.svg`/`.png` + `assertions.json`) from templates in this file — never hand-edit a `.mmd`/assertions.json; runs on deploy
@@ -130,6 +130,59 @@ Also: an instance can have **more than one plane to update** (e.g. a web instanc
130
130
 
131
131
  ---
132
132
 
133
+ ## The pin-bump runbook — cloudbongos.com, one paste per step (task 1002767)
134
+
135
+ The generic mechanics are [upgrading-the-core](upgrading-the-core.md); this is the **operational sequence for the live mothership** — registry pin, pull-deploy repo at `/home/lars/cloudbongos-instance`, `cloudbongos.service` serving `127.0.0.1:3002`. Every trap below cost a real round trip on the 2026-08-13 (`1.19.91 → 1.19.94`) run; the goal is that the next bump is one paste, not fifteen.
136
+
137
+ **Cadence: bump once, at session end.** One upgrade covers every release the session landed (publish-on-merge cuts one per merge — you don't chase each). Don't bump per-ship.
138
+
139
+ ### Step 1 — pick the version (your machine, main checkout)
140
+
141
+ ```bash
142
+ git fetch origin --tags
143
+ git tag --list 'core-v*' --contains <your-merge-sha> | sort -V | head -1
144
+ ```
145
+
146
+ **Failure this prevents — pinning a release that predates your work.** The version your own ship report printed is usually the release cut for the *previous* merge (ADR 0161 publishes *after* each land). The version to pin is the newest tag that **contains** your merge. If no tag contains it, the publish lane hasn't finished (or is disarmed): a green `publish` *check* is not evidence of a publish — read the Publish **step log** for `+ @bongos/core@<version>`. The package is private, so an anonymous `npm view` proves nothing either.
147
+
148
+ ### Step 2 — the bump (the owner runs this line, via `!` in the session)
149
+
150
+ The permission classifier blocks the agent from running a mutating `sudo -u lars` over ssh, so this paste is the **owner's**. The `!` prefix also keeps ssh honest: a bare `ssh host` is non-interactive here — it connects, prints the MOTD, and exits — so the command must ride as an argument.
151
+
152
+ ```
153
+ ! ssh -i ~/.ssh/otb_builder jaxri@REDACTED_IP "sudo -n -u lars bash -c 'cd /home/lars/cloudbongos-instance && export NPM_TOKEN=\$(grep -oP \"(?<=_authToken=).*\" /home/lars/.npmrc | head -1) && [ -n \"\$NPM_TOKEN\" ] || { echo EMPTY NPM_TOKEN — aborting; exit 12; }; ./node_modules/.bin/bongos upgrade --registry --to <VER> --service cloudbongos.service --health-url http://127.0.0.1:3002/healthz --commit-pin'"
154
+ ```
155
+
156
+ What each piece is for — each guards a failure that actually happened:
157
+
158
+ - **Token extraction runs as lars** (the whole command is inside `sudo -u lars bash -c`). Reading `/home/lars/.npmrc` as `jaxri` is `Permission denied`, which yields an **empty** `NPM_TOKEN`, which sends npm to the **public** registry, which 404s on the private `@bongos/core` — and the upgrade rolls back cleanly, having taught you nothing. The `[ -n "$NPM_TOKEN" ] || exit` guard aborts instead of half-running.
159
+ - **`--to <VER>` is required** — `bongos upgrade` refuses to guess a target.
160
+ - **`--service` + `--health-url` buy the automatic rollback.** Point the health URL at `http://127.0.0.1:3002/healthz`, **not the public URL** — a pre-launch `basic_auth` on the apex makes a healthy boot look failed and triggers a needless rollback.
161
+ - **`--commit-pin` commits AND pushes the pin.** The instance is a pull-deploy repo: the next sync runs `git reset --hard origin/main`, which reverts an uncommitted pin, and `npm ci` then reinstalls the old core. Nothing errors. This is exactly how live sat 13 releases behind (task 1002712). The same applies to any tracked instance-config edit (`config/branding.json`): commit + push or the next deploy tick undoes it. A commit that fails to *push* saves nothing — deploy.sh resets to the remote.
162
+ - **The `core_upgrades` ledger row needs `PGDATABASE`.** The migrate step resolves the instance DB itself, but the ledger write reads only the env — under `sudo -u lars bash -c` there is none, so the run logs `ledger skipped (no DATABASE_URL or PGDATABASE)`. The bump still succeeds. If you want the row: read the DB name once (`! ssh -i ~/.ssh/otb_builder jaxri@REDACTED_IP "sudo -n -u lars grep -A1 '\"db\"' /home/lars/cloudbongos-instance/config/branding.json"`) and splice `export PGDATABASE=<name> &&` into the paste before the `bongos upgrade` call.
163
+
164
+ ### Step 3 — verify on the box, not from your browser
165
+
166
+ ```
167
+ ! ssh -i ~/.ssh/otb_builder jaxri@REDACTED_IP "curl -s http://127.0.0.1:3002/version"
168
+ ```
169
+
170
+ `coreVersion` must equal the target. This is the proof; the deploy tool's own output is not.
171
+
172
+ **Failure this prevents — trusting a stale or gated public view.** The apex may sit behind pre-launch `basic_auth`, and HTML rides the CDN. (The classic *stylesheet* staleness — new HTML styled by a 4-hour-old `cosmos.css` — was fixed by content-hashed asset URLs in task 1002766, so a broken-*looking* page is no longer expected; if you must check publicly, use a cold profile or `fetch(url, {cache:'reload'})`.)
173
+
174
+ ### Step 4 — prove the pin is durable
175
+
176
+ The `--commit-pin` output already reports commit + push. To prove durability the hard way, force a deploy tick and re-read the version — a reverted pin shows up here and nowhere else:
177
+
178
+ ```
179
+ ! ssh -i ~/.ssh/otb_builder jaxri@REDACTED_IP "sudo -n systemctl start cloudbongos-deploy.service; sleep 5; curl -s http://127.0.0.1:3002/version"
180
+ ```
181
+
182
+ If `coreVersion` regressed: the pin didn't reach `origin/main` — re-run the bump and watch for the `PIN NOT COMMITTED` warning text, or commit+push `package.json` + `package-lock.json` in the instance repo by hand.
183
+
184
+ ---
185
+
133
186
  ## Detecting divergence
134
187
 
135
188
  [`scripts/gds/deploy-divergence.js`](../../scripts/gds/deploy-divergence.js) is the comparison that was missing during the ten-day incident. It runs **warn-only at the end of every ship** and can be run by hand any time:
@@ -149,6 +149,8 @@ Organized by topic, not chronology. New entries go in the matching section; crea
149
149
 
150
150
  ## Windows / local dev (PowerShell)
151
151
 
152
+ > Git Bash builder-loop gotchas (worktree junctions, api.js path mangling, local test baseline) live in their own recipe: [windows-builders.md](windows-builders.md).
153
+
152
154
  ### PowerShell 5.1 turns a clean `node scripts/gds/*.js` exit into a false failure
153
155
 
154
156
  - **Symptom:** Running `node scripts/gds/api.js GET /api/gds/me 2>&1` (or the same on `claim.js`/`ship.js`) inside a PowerShell 5.1 session reports failure — `$?` is `$false` and the stderr line surfaces as a `NativeCommandError` — even though node exited 0 and the JSON on stdout is exactly right.
@@ -198,6 +200,12 @@ Organized by topic, not chronology. New entries go in the matching section; crea
198
200
  - **Related:** publish-on-merge (ADR 0161) pushes a `release: core X.Y.Z` carrier commit after every land, so on an instance that DOES deploy, the deploy run belongs to the carrier's sha, not the merge commit's. A strict sha match never hit — hence the same permanent `pending`. The probe now also accepts a successful `main` deploy that started after the merge.
199
201
  - **The wait is bounded.** A probe stuck at `deploy=pending` for a reason that never resolves looks identical to a slow deploy on any single sweep, so after `<redacted>` (default 30 min) a task whose land is *provable on main* ships anyway, and the sweep logs `deploy still unconfirmed after Nmin`. This lifts only the deploy gate — the per-task land proof is untouched, so it can never become a branch-reuse false-ship. Raise the env var on an instance whose real deploy legitimately takes longer than 30 minutes.
200
202
 
203
+ ### Moving the live cloudbongos.com pin — use the runbook, not memory
204
+
205
+ - **What:** the end-of-session pin bump (registry-pinned mothership) is a scripted sequence now: [the pin-bump runbook](core-release-pipeline.md#<redacted>). One paste per step; each step names the failure it prevents.
206
+ - **The three that bite hardest** if you freelance it: an NPM token extracted as the wrong user comes back **empty**, npm silently hits the public registry, 404s on the private package, and rolls back cleanly (you learn nothing); a pin left **uncommitted or unpushed** is reverted by the pull-deploy's `git reset --hard` on the next tick — this is how live sat 13 releases behind; and a `--health-url` pointed at the **public apex** instead of `127.0.0.1:3002` reads pre-launch `basic_auth` as a failed boot and triggers a needless rollback.
207
+ - **Cadence:** once per session, at close — one bump covers all the session's releases. The version to pin is the newest `core-v*` tag that **contains** your merge, not the one your ship report printed.
208
+
201
209
  ---
202
210
 
203
211
  ## Status page + monitoring
@@ -0,0 +1,103 @@
1
+ ---
2
+ track: internal
3
+ scope: builder tooling — the Windows + Git Bash builder loop (worktrees, api.js, ship.js, local tests)
4
+ ---
5
+
6
+ # Building on Windows (Git Bash) — the gotchas, and what the tooling now does for you
7
+
8
+ Everything here was paid for at least once on a real Windows box (task 1002548's learnings, the
9
+ HANDOFF-apex-ui session, task 1002809). Most of it is now **fixed in the tooling** — this recipe
10
+ records what changed, what you still have to know, and the one destructive trap to respect.
11
+ PowerShell-specific gotchas (stderr false-failures, BOM) live in
12
+ [ops-gotchas.md → Windows / local dev](ops-gotchas.md#windows--local-dev-powershell).
13
+
14
+ ## api.js: MSYS path mangling is auto-repaired (no more `MSYS_NO_PATHCONV=1`)
15
+
16
+ - **Was:** Git Bash rewrites a leading-slash argument before node sees it — `node scripts/gds/api.js GET /tasks/123`
17
+ arrived as `GET C:/Program Files/Git/tasks/123` and hit the usage error, unless every call was
18
+ prefixed `MSYS_NO_PATHCONV=1`.
19
+ - **Now (task 1002809):** `api.js` detects the mangled prefix via `EXEPATH` and strips it itself
20
+ (`repairMsysMangledPath` in `cli-lib.js`). Call it plain; the old env prefix is harmless if you
21
+ still type it out of habit.
22
+ - **Still true:** judge success by the JSON + status line, not folklore about exit codes — and if
23
+ you ever see the libuv `UV_HANDLE_CLOSING` assertion after the JSON printed, that teardown class
24
+ of bug is tracked in [scripts/gds/CLAUDE.md](../../scripts/gds/CLAUDE.md) (task 1002734's
25
+ `cliExit` rule).
26
+
27
+ ## api.js: inline `--body` loses its quotes — use `--body-file`
28
+
29
+ Shells on Windows strip/re-split inline JSON quoting. The durable pattern for any POST/PATCH body:
30
+
31
+ ```bash
32
+ # write the JSON with a real editor/tool, then:
33
+ node scripts/gds/api.js POST /tasks --body-file C:/tmp/body.json
34
+ # or pipe it (no temp file):
35
+ node scripts/gds/api.js POST /claims --body-file - <<'EOF'
36
+ {"task_id": 123, "worktree_name": "task-123"}
37
+ EOF
38
+ ```
39
+
40
+ `api.js` now points you here when an inline `--body` fails to parse (task 1002809).
41
+
42
+ ## Worktrees: prefer inside-repo; outside-repo needs the junction helper
43
+
44
+ Every claim gets its own real worktree (owner directive — see the `/builder-claim` skill). Two
45
+ placements work on Windows:
46
+
47
+ - **Default — `.claude/worktrees/<name>` (inside the repo).** No `node_modules` setup at all:
48
+ node's resolution walks up to the main checkout's copy. This is what the harness `EnterWorktree`
49
+ primitive creates.
50
+ - **Outside the repo (e.g. `C:/tmp/task-N`)** — useful because OneDrive briefly locks in-repo
51
+ dirs. An outside worktree can't walk up to `node_modules`, so it needs a **junction** — and this
52
+ is where the destructive trap lives. Use the helper, never hand-rolled `mklink`:
53
+
54
+ ```bash
55
+ node scripts/gds/worktree.js add <task-id> --outside # worktree + junction, no shell quoting
56
+ node scripts/gds/worktree.js remove task-<id> # unlinks the junction FIRST
57
+ node scripts/gds/worktree.js list # shows which worktrees carry junctions
58
+ ```
59
+
60
+ ### ⚠ The trap (why the helper exists — task 1002809)
61
+
62
+ - `cmd //c mklink /J` from Git Bash **mangles quoted targets containing spaces** (the target grows
63
+ a leading `\`), producing a junction that resolves to nothing. The helper uses node's
64
+ `fs.symlinkSync(target, link, 'junction')` — no shell, no quoting, no elevation.
65
+ - `git worktree remove` (and `--force`) **follows a junction into its target** — with a hand-made
66
+ junction in place it deletes the **shared `node_modules` of the main checkout**. The helper
67
+ always unlinks the junction (link only) before calling git. If you ever bypass the helper:
68
+ remove the junction yourself first (`fs.rmdirSync` / `rmdir` on the link), then
69
+ `git worktree remove`.
70
+
71
+ ## `Cannot find module 'pg'` (or any dep) inside a worktree
72
+
73
+ Run `npm install` in the **main checkout**, never in the worktree. Worktrees share the main
74
+ checkout's `node_modules` (by walk-up inside the repo, by junction outside); a second
75
+ `node_modules` inside a worktree only creates drift.
76
+
77
+ ## OneDrive locks `git worktree remove` — the first attempt usually still worked
78
+
79
+ `git worktree remove` on an in-repo worktree often errors `Permission denied` / `Device or
80
+ resource busy` because OneDrive briefly locks the directory. The failed attempt usually still
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.
83
+
84
+ ## Local unit tests: baseline first, CI is truth
85
+
86
+ The local `tests/*.mjs` suite has a **large stable failure baseline on Windows boxes** (mostly
87
+ DB-backed tests with no local Postgres — ~184 failures at last count, and the count drifts).
88
+ Before blaming your change: run the suite once *before* touching anything (or on a clean
89
+ checkout), and compare against that. The CI `unit` check on the PR is the authoritative verdict.
90
+
91
+ ## Never pipe `ship.js` through `head`/`tail`
92
+
93
+ When the pager closes the pipe mid-run, node dies on EPIPE and the land chain aborts silently —
94
+ the task strands at `confirmed` (hit on task 1001860). Run it unpiped or `> file`; a stranded
95
+ confirmed task re-lands by re-running `node scripts/gds/ship.js <id>` (resume is idempotent).
96
+
97
+ ## ship.js: a failed post-land ship call may be a success — now auto-verified
98
+
99
+ The final `POST /tasks/<id>/ship` after a successful merge can be refused (`task_not_confirmed`)
100
+ because the server already landed the task first (reconcile-on-read or the ~5-min reconciler).
101
+ `ship.js` now normalizes both API error shapes and **verifies the live task status with a GET
102
+ before reporting failure** (task 1002809) — a verified `shipped` prints as the success it is. If
103
+ you see a ship-call failure from older tooling, check `GET /tasks/<id>` before reacting.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.119",
3
+ "version": "1.19.121",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.119",
9
+ "version": "1.19.121",
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.119",
3
+ "version": "1.19.121",
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",
@@ -18,12 +18,15 @@
18
18
  // on a 2xx, 1 on any non-2xx or transport error, 2 on bad invocation.
19
19
 
20
20
  const fs = require('node:fs');
21
- const { cliClient, arg, stripBom, resolveQuiet, isBoxScope, printBoxScopeGuidanceAndExit, cliExit } = require('./cli-lib');
21
+ const { cliClient, arg, stripBom, resolveQuiet, isBoxScope, printBoxScopeGuidanceAndExit, cliExit, repairMsysMangledPath } = require('./cli-lib');
22
22
 
23
23
 
24
24
  (async () => {
25
25
  const method = (process.argv[2] || '').toUpperCase();
26
- const urlPath = process.argv[3];
26
+ // task 1002809: Git Bash converts the leading-slash path arg into a Windows
27
+ // path under the Git install root before node sees it; undo that so builders
28
+ // don't need the MSYS_NO_PATHCONV=1 prefix.
29
+ const urlPath = repairMsysMangledPath(process.argv[3]);
27
30
  if (!method || !urlPath || !urlPath.startsWith('/')) {
28
31
  console.error("usage: node scripts/gds/api.js <METHOD> <path> [--body '<json>' | --body-file <file|->] [--quiet]");
29
32
  process.exit(2);
@@ -46,6 +49,13 @@ const { cliClient, arg, stripBom, resolveQuiet, isBoxScope, printBoxScopeGuidanc
46
49
  if (raw != null && raw.trim()) body = JSON.parse(raw);
47
50
  } catch (e) {
48
51
  console.error(`api: could not read/parse JSON body: ${e.message}`);
52
+ // task 1002809: inline --body is the usual culprit — shells (especially on
53
+ // Windows) strip or re-split the quotes before node sees them. Teach the
54
+ // durable pattern instead of leaving a bare parse error.
55
+ if (bodyFile == null && bodyInline != null) {
56
+ console.error(' Inline --body often loses its quoting through the shell. Durable pattern:');
57
+ console.error(' write the JSON to a file and pass --body-file <path>, or pipe it via --body-file -');
58
+ }
49
59
  process.exit(2);
50
60
  }
51
61
 
@@ -771,6 +771,34 @@ function parseSessionRequest(text) {
771
771
  return { timeBudgetMin, mode, parallel };
772
772
  }
773
773
 
774
+ // task 1002809: Git Bash (MSYS) rewrites a leading-slash argument into a Windows
775
+ // path under the Git install root BEFORE node sees it — `/tasks/123` arrives as
776
+ // `C:/Program Files/Git/tasks/123` — unless the builder remembers
777
+ // MSYS_NO_PATHCONV=1. Undo it here so no one needs the env prefix. EXEPATH (set
778
+ // by Git Bash) points INSIDE the install (`…\Git\bin`), while the mangled prefix
779
+ // is the install root (`…\Git`), so we strip the LONGEST ancestor of EXEPATH
780
+ // that prefixes the arg — never the bare drive root (that would "repair"
781
+ // arbitrary Windows paths). Case-insensitive, both slash styles. Pure + exported
782
+ // for tests/api_msys_path_repair.mjs; no-op off win32 or without EXEPATH.
783
+ function repairMsysMangledPath(raw, { exepath = process.env.EXEPATH, platform = process.platform } = {}) {
784
+ if (!raw || raw.startsWith('/')) return raw;
785
+ if (platform !== 'win32' || !exepath) return raw;
786
+ const rawFwd = String(raw).replace(/\\/g, '/');
787
+ const rawLower = rawFwd.toLowerCase();
788
+ let ancestor = String(exepath).replace(/\\/g, '/').replace(/\/+$/, '');
789
+ let best = null;
790
+ while (ancestor.includes('/')) {
791
+ // Stop before the bare drive root ("C:" after the last slice).
792
+ if (/^[a-z]:$/i.test(ancestor)) break;
793
+ if (rawLower.startsWith(ancestor.toLowerCase() + '/') && (best === null || ancestor.length > best.length)) {
794
+ best = ancestor;
795
+ }
796
+ ancestor = ancestor.slice(0, ancestor.lastIndexOf('/'));
797
+ }
798
+ if (best === null) return raw;
799
+ return rawFwd.slice(best.length);
800
+ }
801
+
774
802
  module.exports = {
775
803
  API_BASE,
776
804
  hallBase,
@@ -784,6 +812,7 @@ module.exports = {
784
812
  arg,
785
813
  hasFlag,
786
814
  stripBom,
815
+ repairMsysMangledPath,
787
816
  resolveQuiet,
788
817
  readSessionToken,
789
818
  apiCall,
@@ -3122,6 +3122,40 @@ function playShipBell() {
3122
3122
  playSound(shipWav);
3123
3123
  }
3124
3124
 
3125
+ // task 1002809: normalize an API error body across BOTH wire shapes — the live
3126
+ // ADR 0116 (R01) envelope `{ error: { code, message } }` and the legacy flat
3127
+ // `{ error: 'code' }`. Same normalization as claim.js:formatClaimFailure and
3128
+ // cli-lib:isBoxScope; pure + exported for tests/ship_post_land_verify.mjs.
3129
+ function normalizeApiError(data) {
3130
+ const errRaw = data ? data.error : undefined;
3131
+ const isEnvelope = errRaw !== null && typeof errRaw === 'object';
3132
+ return {
3133
+ code: isEnvelope ? errRaw.code : errRaw,
3134
+ message: isEnvelope ? errRaw.message : undefined,
3135
+ };
3136
+ }
3137
+
3138
+ // task 1002809: classify a FAILED post-land POST /tasks/<id>/ship. The merge
3139
+ // already succeeded, so the server may simply have landed the task first
3140
+ // (reconcile-on-read — task 1049 — or the ~5-min reconciler racing this call).
3141
+ // The old check compared `data.error === 'task_not_confirmed'` — dead against
3142
+ // the envelope shape, so the already-landed SUCCESS rendered as a failure with
3143
+ // an "[object Object]" reason (the HANDOFF-apex-ui "400 after a successful
3144
+ // land" gotcha). liveStatus is the task's status from a verify GET (null when
3145
+ // that GET itself failed): 'shipped' is authoritative success whatever the
3146
+ // error said; task_not_confirmed with no live status keeps the task-1049
3147
+ // benefit of the doubt; any KNOWN non-shipped status stays a loud failure.
3148
+ // Pure + exported for tests/ship_post_land_verify.mjs.
3149
+ function postLandShipOutcome({ code, liveStatus }) {
3150
+ if (liveStatus === 'shipped') {
3151
+ return { alreadyShipped: true, why: 'the server landed it first — the failed ship call lost that race, nothing to fix' };
3152
+ }
3153
+ if (liveStatus == null && code === 'task_not_confirmed') {
3154
+ return { alreadyShipped: true, why: 'the server reconciled it from the merged PR' };
3155
+ }
3156
+ return { alreadyShipped: false };
3157
+ }
3158
+
3125
3159
  // Step 3 of the lifecycle: confirmed → shipped. Merge the branch into main,
3126
3160
  // deploy, flip the task to shipped, and fire the Discord broadcast (only when
3127
3161
  // the change actually deploys — #94 / ADR 0032; best-effort). Side-effecting;
@@ -3146,17 +3180,26 @@ async function mergeAndDeploy({ taskId, summary, advanceToMerge, skipMerge, dbOn
3146
3180
 
3147
3181
  const ship = await (await cliClient()).tasks.postTasksIdShip({ id: taskId, body: {} });
3148
3182
  if (!ship.ok) {
3149
- // task 1049: the server's publish-status reconcile-on-read may have already
3150
- // flipped this task to shipped (a concurrent status read saw merge+deploy).
3151
- // That's a SUCCESS — don't print it as a failure.
3152
- if (ship.status === 409 && ship.data && ship.data.error === 'task_not_confirmed') {
3153
- console.log(`Task #${taskId} is now: shipped (the server reconciled it from the merged PR).`);
3183
+ // task 1049 / 1002809: the server may have already flipped this task to
3184
+ // shipped (reconcile-on-read, or the reconciler racing this call). Verify
3185
+ // the LIVE status before treating the refusal as a failure — the old
3186
+ // flat-shape string check never matched the R01 envelope, so this success
3187
+ // path was dead and every reconciled land reported as a failed ship.
3188
+ const { code, message } = normalizeApiError(ship.data);
3189
+ let liveStatus = null;
3190
+ try {
3191
+ const t = await (await cliClient()).tasks.getTasksId({ id: taskId });
3192
+ if (t.ok && t.data && t.data.task) liveStatus = t.data.task.status;
3193
+ } catch (_) { /* verify is best-effort; fall back to the code-based call */ }
3194
+ const outcome = postLandShipOutcome({ code, liveStatus });
3195
+ if (outcome.alreadyShipped) {
3196
+ console.log(`Task #${taskId} is now: shipped (${outcome.why}).`);
3154
3197
  } else {
3155
- console.error(` /tasks/${taskId}/ship failed:`, ship.data);
3198
+ const e = message || code || `HTTP ${ship.status}`;
3199
+ console.error(` /tasks/${taskId}/ship failed: ${e}${liveStatus ? ` (task is at '${liveStatus}')` : ''}`);
3156
3200
  // The merge itself succeeded — only the status flip failed, so a re-run
3157
3201
  // resumes cleanly (task 941). Report it rather than letting the card claim
3158
3202
  // the land is in progress.
3159
- const e = (ship.data && (ship.data.message || ship.data.error)) || `HTTP ${ship.status}`;
3160
3203
  return landBailed(`merged, but the ship call failed — ${e}`, `node scripts/gds/ship.js ${taskId}`);
3161
3204
  }
3162
3205
  } else {
@@ -4418,6 +4461,8 @@ module.exports = {
4418
4461
  // task 1049 (B): pure CI-land outcome classifier — a timed-out-but-armed PR is
4419
4462
  // 'pending' (success), not failure. Exported so the semantics are unit-tested.
4420
4463
  classifyLandOutcome,
4464
+ normalizeApiError,
4465
+ postLandShipOutcome,
4421
4466
  // task 1718: pure poll-path decision — enable-failure polls the SERVER (not a
4422
4467
  // dead-end strand at confirmed), enable-success watches GitHub. Exported so the
4423
4468
  // regression test locks in that a worktree enable-failure still waits for the land.
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/worktree.js — junction-safe worktree lifecycle (task 1002809).
3
+ //
4
+ // The trap this kills: a worktree OUTSIDE the repo root (C:/tmp/wt-N — used to
5
+ // dodge OneDrive file locks) can't resolve node_modules by walking up, so
6
+ // Windows builders hand-made junctions with `cmd //c mklink` from Git Bash —
7
+ // which mangles quoted targets containing spaces (dead junction) — and then
8
+ // `git worktree remove` FOLLOWED the junction and deleted the SHARED
9
+ // node_modules of the main checkout. This helper creates junctions with
10
+ // fs.symlinkSync(..., 'junction') (no shell, no quoting) and always removes the
11
+ // junction BEFORE git worktree remove. Worktrees INSIDE the repo
12
+ // (.claude/worktrees/<name>, the project default) need no junction at all.
13
+ //
14
+ // Usage:
15
+ // node scripts/gds/worktree.js add <task-id|name> [--path <dir> | --outside] [--base <ref>]
16
+ // node scripts/gds/worktree.js remove <name|path> [--force]
17
+ // node scripts/gds/worktree.js list
18
+
19
+ const fs = require('node:fs');
20
+ const path = require('node:path');
21
+ const { execFileSync } = require('node:child_process');
22
+ const { arg, hasFlag } = require('./cli-lib');
23
+
24
+ // -- pure helpers (exported for tests/worktree_helper.mjs) --------------------
25
+
26
+ // A numeric task id becomes the conventional task-<id>; an explicit name passes
27
+ // through; anything with path separators or dot-traversal is rejected (null).
28
+ function worktreeNameFor(raw) {
29
+ const s = String(raw == null ? '' : raw).trim();
30
+ if (!s || s === '.' || s === '..') return null;
31
+ if (/^\d+$/.test(s)) return `task-${s}`;
32
+ if (!/^[A-Za-z0-9._-]+$/.test(s)) return null;
33
+ return s;
34
+ }
35
+
36
+ // Is the worktree OUTSIDE the repo root? Inside the repo, node's resolution
37
+ // walks up to the main checkout's node_modules; outside it can't. Pure string
38
+ // compare (case-insensitive, both slash styles); callers pass absolute paths.
39
+ function isOutsideRepo({ wtPath, repoRoot }) {
40
+ const norm = (p) => String(p).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
41
+ const wt = norm(wtPath);
42
+ const root = norm(repoRoot);
43
+ return !(wt === root || wt.startsWith(root + '/'));
44
+ }
45
+
46
+ // Only a win32 worktree outside the repo root gets a junction (the OneDrive
47
+ // dodge); posix symlink semantics differ and aren't the trap this fixes.
48
+ function needsJunction({ wtPath, repoRoot, platform }) {
49
+ if (platform !== 'win32') return false;
50
+ return isOutsideRepo({ wtPath, repoRoot });
51
+ }
52
+
53
+ // Ordered removal steps. The junction MUST go before git worktree remove (git
54
+ // follows it into the shared node_modules); a REAL node_modules dir is left for
55
+ // git to refuse — this tool never deletes real directories.
56
+ function removalPlan({ nodeModulesState }) {
57
+ return nodeModulesState === 'junction'
58
+ ? ['unlink-junction', 'git-worktree-remove']
59
+ : ['git-worktree-remove'];
60
+ }
61
+
62
+ // -- CLI ----------------------------------------------------------------------
63
+
64
+ function git(args, opts = {}) {
65
+ return execFileSync('git', args, { encoding: 'utf8', ...opts }).trim();
66
+ }
67
+
68
+ // Main checkout root from any worktree — same derivation as merge-lock.js.
69
+ function mainWorktreeRoot() {
70
+ const common = git(['rev-parse', '--git-common-dir']);
71
+ const abs = path.isAbsolute(common) ? common : path.resolve(process.cwd(), common);
72
+ return path.dirname(abs);
73
+ }
74
+
75
+ function listWorktrees(root) {
76
+ const out = git(['worktree', 'list', '--porcelain'], { cwd: root });
77
+ return out.split('\n').filter((l) => l.startsWith('worktree ')).map((l) => l.slice('worktree '.length));
78
+ }
79
+
80
+ function nodeModulesState(wtPath) {
81
+ const p = path.join(wtPath, 'node_modules');
82
+ let st;
83
+ try { st = fs.lstatSync(p); } catch (_) { return 'absent'; }
84
+ return st.isSymbolicLink() ? 'junction' : 'real-dir';
85
+ }
86
+
87
+ function resolveWorktreePath(root, nameOrPath) {
88
+ const all = listWorktrees(root);
89
+ const norm = (p) => path.resolve(p).replace(/\\/g, '/').toLowerCase();
90
+ const byPath = all.find((w) => norm(w) === norm(nameOrPath));
91
+ if (byPath) return byPath;
92
+ // Case-insensitive, matching the filesystem (and the byPath compare above).
93
+ const byName = all.filter((w) => path.basename(w).toLowerCase() === String(nameOrPath).toLowerCase());
94
+ if (byName.length === 1) return byName[0];
95
+ if (byName.length > 1) {
96
+ console.error(`worktree: "${nameOrPath}" matches ${byName.length} worktrees — pass the full path:`);
97
+ for (const w of byName) console.error(` ${w}`);
98
+ process.exit(2);
99
+ }
100
+ return null;
101
+ }
102
+
103
+ function cmdAdd(root, argv) {
104
+ const name = worktreeNameFor(argv[1]);
105
+ if (!name) {
106
+ console.error('worktree add: pass a task id (→ task-<id>) or a simple name (letters/digits/._-).');
107
+ process.exit(2);
108
+ }
109
+ const explicitPath = arg('--path', argv);
110
+ const outside = hasFlag('--outside', argv);
111
+ // --outside exists to dodge OneDrive file locks — a Windows problem. On posix
112
+ // an outside-repo worktree would just lose node_modules resolution (no
113
+ // junction is created there), so refuse rather than hand back a silently
114
+ // broken tree; the in-repo default works everywhere.
115
+ if (outside && process.platform !== 'win32') {
116
+ console.error('worktree add: --outside is a Windows OneDrive dodge; use the default in-repo location here');
117
+ console.error(' (an outside-repo worktree on this platform cannot resolve the shared node_modules).');
118
+ process.exit(2);
119
+ }
120
+ const wtPath = path.resolve(explicitPath
121
+ || (outside ? path.join('C:/tmp', name)
122
+ : path.join(root, '.claude', 'worktrees', name)));
123
+ // Default base origin/main so the branch starts current; best-effort fetch
124
+ // first (offline still works off the local ref).
125
+ const base = arg('--base', argv) || 'origin/main';
126
+ try { execFileSync('git', ['fetch', 'origin', 'main'], { cwd: root, stdio: 'ignore', timeout: 15000 }); } catch (_) { /* offline is fine */ }
127
+
128
+ console.log(`worktree add: ${wtPath} (branch ${name}, base ${base})`);
129
+ try {
130
+ execFileSync('git', ['worktree', 'add', wtPath, '-b', name, base], { cwd: root, stdio: 'inherit' });
131
+ } catch (e) {
132
+ console.error(`worktree add failed — ${e.message}`);
133
+ console.error(' (a branch of that name may already exist: remove it or pick another name)');
134
+ process.exit(1);
135
+ }
136
+
137
+ if (needsJunction({ wtPath, repoRoot: root, platform: process.platform })) {
138
+ const target = path.join(root, 'node_modules');
139
+ if (!fs.existsSync(target)) {
140
+ console.error(`⚠ ${target} does not exist — run npm install in the MAIN checkout first`);
141
+ console.error(' (worktrees never get their own node_modules; they share the main one).');
142
+ } else {
143
+ // fs.symlinkSync with type 'junction' needs no shell and no elevation —
144
+ // this is the fix for the mangled `cmd //c mklink` quoting trap.
145
+ fs.symlinkSync(target, path.join(wtPath, 'node_modules'), 'junction');
146
+ console.log(` node_modules junction → ${target}`);
147
+ console.log(' (remove this worktree with `node scripts/gds/worktree.js remove` — it unlinks the junction first;');
148
+ console.log(' a bare `git worktree remove` would follow the junction into the SHARED node_modules.)');
149
+ }
150
+ } else if (isOutsideRepo({ wtPath, repoRoot: root })) {
151
+ // Explicit --path outside the repo on posix: no junction is created here,
152
+ // so say plainly that node_modules won't resolve rather than fail later.
153
+ console.log(' ⚠ this worktree is outside the repo — node_modules will NOT resolve from here on this platform.');
154
+ }
155
+ console.log('');
156
+ console.log('Next:');
157
+ console.log(` cd "${wtPath}"`);
158
+ console.log(' node scripts/gds/claim.js <task-id> (worktree auto-detected)');
159
+ }
160
+
161
+ function cmdRemove(root, argv) {
162
+ const target = argv[1];
163
+ if (!target) {
164
+ console.error('usage: node scripts/gds/worktree.js remove <name|path> [--force]');
165
+ process.exit(2);
166
+ }
167
+ const wtPath = resolveWorktreePath(root, target);
168
+ if (!wtPath) {
169
+ console.error(`worktree remove: "${target}" is not a registered worktree (see: git worktree list).`);
170
+ process.exit(1);
171
+ }
172
+ if (path.resolve(wtPath).toLowerCase() === path.resolve(root).toLowerCase()) {
173
+ console.error('worktree remove: refusing to remove the MAIN checkout.');
174
+ process.exit(2);
175
+ }
176
+ const state = nodeModulesState(wtPath);
177
+ for (const step of removalPlan({ nodeModulesState: state })) {
178
+ if (step === 'unlink-junction') {
179
+ // rmdir on a junction removes the LINK only, never the target's contents.
180
+ fs.rmdirSync(path.join(wtPath, 'node_modules'));
181
+ console.log(' removed node_modules junction (link only — the shared node_modules is untouched)');
182
+ } else {
183
+ const args = ['worktree', 'remove', ...(hasFlag('--force', argv) ? ['--force'] : []), wtPath];
184
+ try {
185
+ execFileSync('git', args, { cwd: root, stdio: 'inherit' });
186
+ console.log(`worktree removed: ${wtPath}`);
187
+ } catch (e) {
188
+ // OneDrive briefly locks the dir; the failed attempt usually still
189
+ // DEREGISTERS the worktree — verify instead of fighting it with retries.
190
+ const still = listWorktrees(root).some((w) => path.resolve(w).toLowerCase() === path.resolve(wtPath).toLowerCase());
191
+ if (!still) {
192
+ console.log(`worktree deregistered: ${wtPath}`);
193
+ console.log(' (the directory husk is briefly locked — OneDrive — and deletes on its own later)');
194
+ } else {
195
+ console.error(`worktree remove failed — ${e.message}`);
196
+ // The worktree is still live but its junction is already unlinked —
197
+ // restore it so the tree stays usable (node_modules keeps resolving)
198
+ // instead of stranding the builder back at hand-rolled mklink.
199
+ if (state === 'junction') {
200
+ try {
201
+ fs.symlinkSync(path.join(root, 'node_modules'), path.join(wtPath, 'node_modules'), 'junction');
202
+ console.error(' node_modules junction restored — the worktree remains fully usable.');
203
+ } catch (restoreErr) {
204
+ console.error(` could not restore the node_modules junction (${restoreErr.message}) —`);
205
+ console.error(' re-create it: node -e "require(\'fs\').symlinkSync(\'<main>/node_modules\', \'<wt>/node_modules\', \'junction\')"');
206
+ }
207
+ }
208
+ if (state === 'real-dir') {
209
+ console.error(' node_modules here is a REAL directory (not a junction) — if it and the other');
210
+ console.error(' untracked files are disposable, re-run with --force.');
211
+ } else {
212
+ console.error(' re-run with --force if the leftover untracked files are disposable.');
213
+ }
214
+ process.exit(1);
215
+ }
216
+ }
217
+ }
218
+ }
219
+ console.log(` (branch "${path.basename(wtPath)}" left in place — the land/ship pipeline owns branch cleanup)`);
220
+ }
221
+
222
+ function cmdList(root) {
223
+ const all = listWorktrees(root);
224
+ for (const w of all) {
225
+ const isMain = path.resolve(w).toLowerCase() === path.resolve(root).toLowerCase();
226
+ const tag = isMain ? 'main checkout' : `node_modules: ${nodeModulesState(w)}`;
227
+ console.log(`${w} [${tag}]`);
228
+ }
229
+ }
230
+
231
+ function main() {
232
+ const argv = process.argv.slice(2);
233
+ const cmd = argv[0];
234
+ let root;
235
+ try {
236
+ root = mainWorktreeRoot();
237
+ } catch (e) {
238
+ console.error('worktree: not a git repo (cannot resolve the main checkout):', e.message);
239
+ process.exit(2);
240
+ }
241
+ if (cmd === 'add') return cmdAdd(root, argv);
242
+ if (cmd === 'remove') return cmdRemove(root, argv);
243
+ if (cmd === 'list') return cmdList(root);
244
+ console.error('usage: node scripts/gds/worktree.js add <task-id|name> [--path <dir> | --outside] [--base <ref>]');
245
+ console.error(' node scripts/gds/worktree.js remove <name|path> [--force]');
246
+ console.error(' node scripts/gds/worktree.js list');
247
+ process.exit(2);
248
+ }
249
+
250
+ if (require.main === module) main();
251
+
252
+ module.exports = { worktreeNameFor, needsJunction, removalPlan, nodeModulesState };
package/src/module-api.js CHANGED
@@ -665,8 +665,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
665
665
  // landed since 1.19.117 with no explicit bump. run 31833658900. (task 1002620)
666
666
  // 1.19.119 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
667
667
  // landed since 1.19.118 with no explicit bump. run 31835957309. (task 1002620)
668
+ // 1.19.120 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
669
+ // landed since 1.19.119 with no explicit bump. run 31836959112. (task 1002620)
670
+ // 1.19.121 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
671
+ // landed since 1.19.120 with no explicit bump. run 31838955115. (task 1002620)
668
672
  // ---------------------------------------------------------------------------
669
- const CORE_VERSION = '1.19.119'; // CI auto-patch carrier (ADR 0161); see changelog above
673
+ const CORE_VERSION = '1.19.121'; // CI auto-patch carrier (ADR 0161); see changelog above
670
674
 
671
675
  // A namespaced logger so a module's log lines are attributable + consistent.
672
676
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,104 @@
1
+ // tests/api_msys_path_repair.mjs
2
+ //
3
+ // Unit tests for repairMsysMangledPath (task 1002809). Git Bash (MSYS) rewrites
4
+ // a leading-slash argument into a Windows path under the Git install root before
5
+ // node ever sees it — `/tasks/123` arrives as `C:/Program Files/Git/tasks/123` —
6
+ // unless the builder remembers MSYS_NO_PATHCONV=1. api.js undoes that mangling
7
+ // itself so no builder needs the env prefix.
8
+ //
9
+ // Contract proved here:
10
+ // 1. A path that already starts with '/' is returned untouched (the repair is
11
+ // a no-op everywhere the shell behaved).
12
+ // 2. A mangled path is repaired by stripping the LONGEST ancestor of EXEPATH
13
+ // that prefixes it — EXEPATH is `<git-root>\bin` while the msys root that
14
+ // prefixes mangled args is `<git-root>`, so ancestor-walking is required.
15
+ // 3. The drive root alone is never treated as the msys root (stripping `C:/`
16
+ // would "repair" any Windows path into nonsense).
17
+ // 4. Non-win32, missing EXEPATH, and unrelated Windows paths pass through.
18
+ //
19
+ // Run: node tests/api_msys_path_repair.mjs
20
+
21
+ import { createRequire } from 'node:module';
22
+ import fs from 'node:fs';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ const require = createRequire(import.meta.url);
26
+ const { repairMsysMangledPath } = require('../scripts/gds/cli-lib.js');
27
+
28
+ let passed = 0;
29
+ let failed = 0;
30
+ function test(name, fn) {
31
+ try {
32
+ fn();
33
+ passed++;
34
+ console.log(` ok ${name}`);
35
+ } catch (err) {
36
+ failed++;
37
+ console.log(` FAIL ${name}`);
38
+ console.log(` ${err.message}`);
39
+ }
40
+ }
41
+ function eq(actual, expected, msg) {
42
+ if (actual !== expected) throw new Error(`${msg || 'mismatch'}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
43
+ }
44
+
45
+ const GIT_BASH = { exepath: 'C:\\Program Files\\Git\\bin', platform: 'win32' };
46
+
47
+ test('already-correct path is untouched', () => {
48
+ eq(repairMsysMangledPath('/api/gds/inbox', GIT_BASH), '/api/gds/inbox');
49
+ eq(repairMsysMangledPath('/tasks/1002809', GIT_BASH), '/tasks/1002809');
50
+ });
51
+
52
+ test('mangled path is repaired via EXEPATH ancestor (forward slashes)', () => {
53
+ eq(repairMsysMangledPath('C:/Program Files/Git/tasks/1002809', GIT_BASH), '/tasks/1002809');
54
+ eq(repairMsysMangledPath('C:/Program Files/Git/api/gds/inbox', GIT_BASH), '/api/gds/inbox');
55
+ });
56
+
57
+ test('mangled path with backslashes is repaired and normalized', () => {
58
+ eq(repairMsysMangledPath('C:\\Program Files\\Git\\claims', GIT_BASH), '/claims');
59
+ });
60
+
61
+ test('prefix match is case-insensitive (Windows paths are)', () => {
62
+ eq(repairMsysMangledPath('c:/program files/git/tasks/5', GIT_BASH), '/tasks/5');
63
+ });
64
+
65
+ test('longest matching ancestor wins (a legit /Git/... path survives)', () => {
66
+ // Original arg /Git/tasks/9 mangles to <root>/Git/tasks/9 — stripping the
67
+ // git root must give back /Git/tasks/9, not /tasks/9.
68
+ eq(repairMsysMangledPath('C:/Program Files/Git/Git/tasks/9', GIT_BASH), '/Git/tasks/9');
69
+ });
70
+
71
+ test('drive root alone is never stripped', () => {
72
+ // Root-level install: EXEPATH C:\Git\bin → msys root C:/Git is strippable...
73
+ eq(repairMsysMangledPath('C:/Git/tasks/7', { exepath: 'C:\\Git\\bin', platform: 'win32' }), '/tasks/7');
74
+ // ...but C:/ by itself is not a valid msys root — an unrelated Windows path
75
+ // must pass through unchanged, not get "repaired" into /tmp/x.json.
76
+ eq(repairMsysMangledPath('C:/tmp/x.json', GIT_BASH), 'C:/tmp/x.json');
77
+ });
78
+
79
+ test('non-win32 platform passes through', () => {
80
+ eq(repairMsysMangledPath('C:/Program Files/Git/tasks/1', { exepath: 'C:\\Program Files\\Git\\bin', platform: 'linux' }), 'C:/Program Files/Git/tasks/1');
81
+ });
82
+
83
+ test('missing EXEPATH passes through (empty = not a Git Bash shell)', () => {
84
+ eq(repairMsysMangledPath('C:/Program Files/Git/tasks/1', { exepath: '', platform: 'win32' }), 'C:/Program Files/Git/tasks/1');
85
+ });
86
+
87
+ test('empty/undefined input passes through', () => {
88
+ eq(repairMsysMangledPath('', GIT_BASH), '');
89
+ eq(repairMsysMangledPath(undefined, GIT_BASH), undefined);
90
+ });
91
+
92
+ // Static wiring check (the cli_exit_no_abort.mjs pattern): the pure helper being
93
+ // correct is worthless if api.js stops applying it to its positional path arg —
94
+ // that exact mutation survived every behavioural test here.
95
+ test('api.js applies the repair to process.argv[3] (static)', () => {
96
+ const src = fs.readFileSync(fileURLToPath(new URL('../scripts/gds/api.js', import.meta.url)), 'utf8');
97
+ if (!/repairMsysMangledPath\(\s*process\.argv\[3\]\s*\)/.test(src)) {
98
+ throw new Error('api.js no longer wraps process.argv[3] in repairMsysMangledPath(...)');
99
+ }
100
+ });
101
+
102
+ console.log('');
103
+ console.log(`api_msys_path_repair: ${passed} passed, ${failed} failed`);
104
+ process.exit(failed > 0 ? 1 : 0);
@@ -0,0 +1,114 @@
1
+ // tests/ship_post_land_verify.mjs
2
+ //
3
+ // Unit tests for the post-land ship-call outcome (task 1002809). After a
4
+ // successful merge, ship.js POSTs /tasks/<id>/ship; the server may have landed
5
+ // the task first (reconcile-on-read, or the ~5-min reconciler racing this call),
6
+ // in which case it refuses with task_not_confirmed. The old code compared
7
+ // `ship.data.error === 'task_not_confirmed'` — the legacy FLAT shape — but the
8
+ // live server emits the ADR 0116 (R01) envelope `{ error: { code, … } }`, so the
9
+ // string test never matched and an already-landed SUCCESS rendered as
10
+ // "merged, but the ship call failed — [object Object]" (the HANDOFF-apex-ui
11
+ // '400 task_not_confirmed AFTER a successful land' gotcha). Same bug class as
12
+ // claim.js task 1476 / cli-lib isBoxScope task 1002162.
13
+ //
14
+ // What we prove here (pure helpers only — the live GET verify is exercised by
15
+ // every real ship):
16
+ // 1. normalizeApiError reads BOTH body shapes (envelope + flat) and survives
17
+ // missing/odd bodies.
18
+ // 2. postLandShipOutcome: a live status of 'shipped' is SUCCESS whatever the
19
+ // error code or HTTP status was (the GET verify is authoritative).
20
+ // 3. task_not_confirmed with the verify GET unavailable keeps the task-1049
21
+ // benefit-of-the-doubt success (the server reconciled it).
22
+ // 4. A known live status that is NOT shipped stays a loud failure — never
23
+ // paper over a genuinely stranded task.
24
+ //
25
+ // Run: node tests/ship_post_land_verify.mjs
26
+
27
+ import { createRequire } from 'node:module';
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const ship = require('../scripts/gds/ship.js');
31
+ const { normalizeApiError, postLandShipOutcome } = ship;
32
+
33
+ let passed = 0;
34
+ let failed = 0;
35
+ function test(name, fn) {
36
+ try {
37
+ fn();
38
+ passed++;
39
+ console.log(` ok ${name}`);
40
+ } catch (err) {
41
+ failed++;
42
+ console.log(` FAIL ${name}`);
43
+ console.log(` ${err.message}`);
44
+ }
45
+ }
46
+ function eq(actual, expected, msg) {
47
+ if (actual !== expected) throw new Error(`${msg || 'mismatch'}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
48
+ }
49
+
50
+ // ---- 1. normalizeApiError: both wire shapes ----
51
+
52
+ test('normalizeApiError reads the R01 envelope', () => {
53
+ const { code, message } = normalizeApiError({ error: { code: 'task_not_confirmed', message: 'not at confirmed' } });
54
+ eq(code, 'task_not_confirmed');
55
+ eq(message, 'not at confirmed');
56
+ });
57
+
58
+ test('normalizeApiError reads the legacy flat shape', () => {
59
+ const { code, message } = normalizeApiError({ error: 'task_not_confirmed' });
60
+ eq(code, 'task_not_confirmed');
61
+ eq(message, undefined);
62
+ });
63
+
64
+ test('normalizeApiError survives missing/odd bodies', () => {
65
+ eq(normalizeApiError(null).code, undefined);
66
+ eq(normalizeApiError({}).code, undefined);
67
+ eq(normalizeApiError({ error: { message: 'no code' } }).message, 'no code');
68
+ });
69
+
70
+ test('normalizeApiError survives { error: null } (typeof null is object)', () => {
71
+ eq(normalizeApiError({ error: null }).code, null);
72
+ eq(normalizeApiError({ error: null }).message, undefined);
73
+ });
74
+
75
+ // ---- 2. live status 'shipped' is success regardless of code/status ----
76
+
77
+ test('verified shipped ⇒ success even on an unexpected code', () => {
78
+ const out = postLandShipOutcome({ code: 'internal_error', liveStatus: 'shipped' });
79
+ eq(out.alreadyShipped, true);
80
+ if (!out.why || typeof out.why !== 'string') throw new Error('expected a human why-line');
81
+ });
82
+
83
+ test('verified shipped ⇒ success on task_not_confirmed (409 or 400 alike)', () => {
84
+ eq(postLandShipOutcome({ code: 'task_not_confirmed', liveStatus: 'shipped' }).alreadyShipped, true);
85
+ });
86
+
87
+ // ---- 3. verify GET unavailable: keep the task-1049 reconcile success ----
88
+
89
+ test('task_not_confirmed with no live status keeps the reconciled success', () => {
90
+ const out = postLandShipOutcome({ code: 'task_not_confirmed', liveStatus: null });
91
+ eq(out.alreadyShipped, true);
92
+ // The why feeds `shipped (${why})` directly — an absent why prints
93
+ // "shipped (undefined)", the same garbage-output class this task fixes.
94
+ if (!out.why || typeof out.why !== 'string') throw new Error('expected a human why-line');
95
+ });
96
+
97
+ test('an unknown code with no live status is a failure (no blind success)', () => {
98
+ eq(postLandShipOutcome({ code: 'internal_error', liveStatus: null }).alreadyShipped, false);
99
+ });
100
+
101
+ // ---- 4. a known non-shipped status stays a loud failure ----
102
+
103
+ test('task_not_confirmed but live status confirmed ⇒ failure, not success', () => {
104
+ const out = postLandShipOutcome({ code: 'task_not_confirmed', liveStatus: 'confirmed' });
105
+ eq(out.alreadyShipped, false);
106
+ });
107
+
108
+ test('task_not_confirmed but live status completed ⇒ failure', () => {
109
+ eq(postLandShipOutcome({ code: 'task_not_confirmed', liveStatus: 'completed' }).alreadyShipped, false);
110
+ });
111
+
112
+ console.log('');
113
+ console.log(`ship_post_land_verify: ${passed} passed, ${failed} failed`);
114
+ process.exit(failed > 0 ? 1 : 0);
@@ -0,0 +1,145 @@
1
+ // tests/worktree_helper.mjs
2
+ //
3
+ // Unit tests for the pure decision helpers behind scripts/gds/worktree.js
4
+ // (task 1002809) — the junction-safe worktree lifecycle for Windows builders.
5
+ //
6
+ // The trap this tooling kills: a worktree OUTSIDE the repo root (C:/tmp/wt-N,
7
+ // used to dodge OneDrive locks) needs a node_modules junction back to the main
8
+ // checkout; `cmd //c mklink` from Git Bash mangles quoted targets with spaces
9
+ // (dead junction), and `git worktree remove` FOLLOWS the junction and deletes
10
+ // the SHARED node_modules. The helper creates junctions with
11
+ // fs.symlinkSync(..., 'junction') (no shell involved) and always removes the
12
+ // junction BEFORE git worktree remove.
13
+ //
14
+ // What we prove here:
15
+ // 1. worktreeNameFor: a numeric task id becomes task-<id>; an explicit name
16
+ // passes through; junk is rejected.
17
+ // 2. needsJunction: only win32 AND a path outside the repo root.
18
+ // 3. removalPlan: a junction is unlinked BEFORE git worktree remove; a real
19
+ // node_modules dir is never touched by us (git refuses; we surface that);
20
+ // absent node_modules goes straight to git worktree remove.
21
+ //
22
+ // Run: node tests/worktree_helper.mjs
23
+
24
+ import { createRequire } from 'node:module';
25
+ import fs from 'node:fs';
26
+ import os from 'node:os';
27
+ import path from 'node:path';
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const { worktreeNameFor, needsJunction, removalPlan, nodeModulesState } = require('../scripts/gds/worktree.js');
31
+
32
+ let passed = 0;
33
+ let failed = 0;
34
+ function test(name, fn) {
35
+ try {
36
+ fn();
37
+ passed++;
38
+ console.log(` ok ${name}`);
39
+ } catch (err) {
40
+ failed++;
41
+ console.log(` FAIL ${name}`);
42
+ console.log(` ${err.message}`);
43
+ }
44
+ }
45
+ function eq(actual, expected, msg) {
46
+ if (actual !== expected) throw new Error(`${msg || 'mismatch'}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
47
+ }
48
+ function eqArr(actual, expected, msg) {
49
+ eq(JSON.stringify(actual), JSON.stringify(expected), msg);
50
+ }
51
+
52
+ // ---- 1. worktreeNameFor ----
53
+
54
+ test('numeric task id becomes task-<id>', () => {
55
+ eq(worktreeNameFor('1002809'), 'task-1002809');
56
+ });
57
+
58
+ test('an explicit task-N name passes through', () => {
59
+ eq(worktreeNameFor('task-77'), 'task-77');
60
+ });
61
+
62
+ test('a plain name passes through', () => {
63
+ eq(worktreeNameFor('spike-tiles'), 'spike-tiles');
64
+ });
65
+
66
+ test('junk (path separators, empty) is rejected with null', () => {
67
+ eq(worktreeNameFor('a/b'), null);
68
+ eq(worktreeNameFor('..'), null);
69
+ eq(worktreeNameFor(''), null);
70
+ });
71
+
72
+ // ---- 2. needsJunction ----
73
+
74
+ const REPO = 'C:\\Users\\rocke\\OneDrive\\Desktop\\Cloud Bongos\\cloud-bongos';
75
+
76
+ test('outside-repo worktree on win32 needs a junction', () => {
77
+ eq(needsJunction({ wtPath: 'C:\\tmp\\task-9', repoRoot: REPO, platform: 'win32' }), true);
78
+ });
79
+
80
+ test('inside-repo worktree resolves node_modules by walking up — no junction', () => {
81
+ eq(needsJunction({ wtPath: REPO + '\\.claude\\worktrees\\task-9', repoRoot: REPO, platform: 'win32' }), false);
82
+ });
83
+
84
+ test('non-win32 never gets a junction (symlink semantics differ; not the trap)', () => {
85
+ eq(needsJunction({ wtPath: '/tmp/task-9', repoRoot: '/home/u/repo', platform: 'linux' }), false);
86
+ });
87
+
88
+ test('a sibling dir that merely shares the repo prefix is still outside', () => {
89
+ eq(needsJunction({ wtPath: REPO + '-other\\task-9', repoRoot: REPO, platform: 'win32' }), true);
90
+ });
91
+
92
+ test('path compare is case-insensitive (Windows filesystems are)', () => {
93
+ eq(needsJunction({ wtPath: REPO.toUpperCase() + '\\.CLAUDE\\WORKTREES\\TASK-9', repoRoot: REPO, platform: 'win32' }), false);
94
+ eq(needsJunction({ wtPath: 'c:\\TMP\\task-9', repoRoot: REPO, platform: 'win32' }), true);
95
+ });
96
+
97
+ // ---- 2b. nodeModulesState — the classifier that picks the removal order ----
98
+ // Behavioural, against a real scratch dir: a dir-symlink stands in for the
99
+ // junction (node's lstat reports both as symbolic links, on every platform).
100
+
101
+ test('nodeModulesState: absent / real-dir / junction classified from disk', () => {
102
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), 'wt-helper-test-'));
103
+ try {
104
+ eq(nodeModulesState(path.join(base, 'wt-absent')), 'absent');
105
+
106
+ const wtReal = path.join(base, 'wt-real');
107
+ fs.mkdirSync(path.join(wtReal, 'node_modules'), { recursive: true });
108
+ eq(nodeModulesState(wtReal), 'real-dir');
109
+
110
+ const target = path.join(base, 'shared-nm');
111
+ fs.mkdirSync(target);
112
+ fs.writeFileSync(path.join(target, 'canary.txt'), 'x');
113
+ const wtJct = path.join(base, 'wt-junction');
114
+ fs.mkdirSync(wtJct);
115
+ const link = path.join(wtJct, 'node_modules');
116
+ fs.symlinkSync(target, link, 'junction');
117
+ eq(nodeModulesState(wtJct), 'junction');
118
+
119
+ // Removing the link (the helper's unlink step) must not touch the target.
120
+ // win32 junctions need rmdir; a posix dir-symlink needs unlink — cover both
121
+ // so this file passes on Linux CI and the real Windows box alike.
122
+ try { fs.rmdirSync(link); } catch (_) { fs.unlinkSync(link); }
123
+ eq(fs.existsSync(path.join(target, 'canary.txt')), true, 'target survives link removal');
124
+ } finally {
125
+ fs.rmSync(base, { recursive: true, force: true });
126
+ }
127
+ });
128
+
129
+ // ---- 3. removalPlan — junction-first ordering is the whole point ----
130
+
131
+ test('junction node_modules: unlink FIRST, then git worktree remove', () => {
132
+ eqArr(removalPlan({ nodeModulesState: 'junction' }), ['unlink-junction', 'git-worktree-remove']);
133
+ });
134
+
135
+ test('absent node_modules: straight to git worktree remove', () => {
136
+ eqArr(removalPlan({ nodeModulesState: 'absent' }), ['git-worktree-remove']);
137
+ });
138
+
139
+ test('a REAL node_modules dir is left for git to refuse — we never rm it', () => {
140
+ eqArr(removalPlan({ nodeModulesState: 'real-dir' }), ['git-worktree-remove']);
141
+ });
142
+
143
+ console.log('');
144
+ console.log(`worktree_helper: ${passed} passed, ${failed} failed`);
145
+ process.exit(failed > 0 ? 1 : 0);