@bongos/core 1.19.818 → 1.19.819

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.818",
6
- "core_contract": "1.19.818",
7
- "source_commit": "7f98428f5e6532100482d729716d92638bee2eb5",
5
+ "core_version": "1.19.819",
6
+ "core_contract": "1.19.819",
7
+ "source_commit": "b56f301dfa5e889c7f8e46624eb564de4bed3510",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-17T08:43:53.963Z",
9
+ "built_at": "2026-09-17T09:33:00.702Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 490,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2217,
14
+ "functional_verbatim": 2220,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2733,
20
- "tree_sha256": "35b8712820d4287686e4a01a9a497c386fd32f24a5109b691a39892d4aeaa964",
19
+ "file_count": 2736,
20
+ "tree_sha256": "235bf7bed8d6ad67aa73deddbbb1dc9a87fa6a697935f24b52bf2cdf5c299139",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -247,7 +247,7 @@
247
247
  {
248
248
  "path": ".claude/skills/strand-fix/SKILL.md",
249
249
  "mode": "0000644",
250
- "sha256": "42284146c4420b768afaba6edf89975aa2e0bf837d5465bbe86a1f1ad3e2cb75"
250
+ "sha256": "938a3a8ee5bfb25036f03519fc27de795ba88bd7ed8b33a7f6e31639023b4304"
251
251
  },
252
252
  {
253
253
  "path": ".claude/skills/worktree-clean/SKILL.md",
@@ -302,7 +302,7 @@
302
302
  {
303
303
  "path": ".gitattributes",
304
304
  "mode": "0000644",
305
- "sha256": "38bf33dd14a7bff6545f961c4f30172ac850f6a0578900a3c10295c4fc0f27e9"
305
+ "sha256": "2d993829478b5f8931e5a8248818b70279055d1af643e51f07de6161eab593aa"
306
306
  },
307
307
  {
308
308
  "path": "CLAUDE.md",
@@ -2547,7 +2547,7 @@
2547
2547
  {
2548
2548
  "path": "docs/module-api-changelog.md",
2549
2549
  "mode": "0000644",
2550
- "sha256": "0b561d6587c66805edf357a97265aa313ab4cf1610e23c63efbb30fc0f1fdf78"
2550
+ "sha256": "671ac9949acbb7120d9a6b3acf43082dba90d2c5119afb61b7cd215fbbec2fdc"
2551
2551
  },
2552
2552
  {
2553
2553
  "path": "docs/modules-contract.md",
@@ -2692,7 +2692,7 @@
2692
2692
  {
2693
2693
  "path": "docs/recipes/gds-db-backup.md",
2694
2694
  "mode": "0000644",
2695
- "sha256": "0f2dfdeca8d49a07a0da043eaa9f2b16669228c358cb56a7786c6c59e5e29808"
2695
+ "sha256": "b3d6a3e3a400bf593b0f2d671f35cb1a60279f495c8763d311f8cdb68a6d603a"
2696
2696
  },
2697
2697
  {
2698
2698
  "path": "docs/recipes/instance-deploy.md",
@@ -4142,7 +4142,7 @@
4142
4142
  {
4143
4143
  "path": "modules/discord/bug-attachments.js",
4144
4144
  "mode": "0000644",
4145
- "sha256": "c9ee5215e1856173fd4855e31abc656147171c4a8fea2daf066563a758bd2bf5"
4145
+ "sha256": "b773430f5abe8a742e35dc59335805c531821d0701a50f2090ed599f3c66dafe"
4146
4146
  },
4147
4147
  {
4148
4148
  "path": "modules/discord/db.js",
@@ -7802,12 +7802,12 @@
7802
7802
  {
7803
7803
  "path": "package-lock.json",
7804
7804
  "mode": "0000644",
7805
- "sha256": "c314948249345b2ef9e9c43e7dc32287f3901882cbc0c4d51a475d568ed0fb76"
7805
+ "sha256": "80eb7e1ecf3c324277a00d732da45ff1e094ac64e4ba176777395fc5c4cb33e4"
7806
7806
  },
7807
7807
  {
7808
7808
  "path": "package.json",
7809
7809
  "mode": "0000644",
7810
- "sha256": "8be1abd0cb53633278698edda8b8b78edf57224e89eb3b08806ede49014dab6d"
7810
+ "sha256": "30a0133ce3cc21770d6915e5d7bd65d7c8a3a8e0494d0eb0e3230586f66f0876"
7811
7811
  },
7812
7812
  {
7813
7813
  "path": "public-docs/index.html",
@@ -8044,6 +8044,11 @@
8044
8044
  "mode": "0000644",
8045
8045
  "sha256": "bf2f9c0ccbdaaa615bcd4020402dac32466a5a3ace6a1fb5cb543baa905a2ecd"
8046
8046
  },
8047
+ {
8048
+ "path": "scripts/gds/bug-attachment-audit.js",
8049
+ "mode": "0000644",
8050
+ "sha256": "959e349e988146d7be1eb872cf80eb0d25d3d4daf95bdcf1b077a0d81c0c598f"
8051
+ },
8047
8052
  {
8048
8053
  "path": "scripts/gds/bug-triage.js",
8049
8054
  "mode": "0000644",
@@ -8067,7 +8072,7 @@
8067
8072
  {
8068
8073
  "path": "scripts/gds/capture.js",
8069
8074
  "mode": "0000644",
8070
- "sha256": "7320809dacf61454d114d5c55c244e7eac3314f36302eab879950355540573f6"
8075
+ "sha256": "9edb2127b0712ec052c7c9946246665773e552c95f1e276c1991854fe9feb12f"
8071
8076
  },
8072
8077
  {
8073
8078
  "path": "scripts/gds/category-advisory-guard.js",
@@ -8177,7 +8182,7 @@
8177
8182
  {
8178
8183
  "path": "scripts/gds/db-backup-nightly.sh",
8179
8184
  "mode": "0000644",
8180
- "sha256": "93f0b743b90a0cfdc91806ecb10da20456b98c0fac0f2b8eb3d690bccf79e532"
8185
+ "sha256": "5e8b7ebfe0b82a426e229a256daf231cb597d8e1df2b908c528a0ed0a67066a5"
8181
8186
  },
8182
8187
  {
8183
8188
  "path": "scripts/gds/db-backup-offsite.sh",
@@ -9142,7 +9147,7 @@
9142
9147
  {
9143
9148
  "path": "scripts/gds/skill-preflight.js",
9144
9149
  "mode": "0000644",
9145
- "sha256": "2c8297314642a459adb31d4253540fac4a8b3b32a4b79dddd9f0e6cdb87b51fc"
9150
+ "sha256": "913e8b89d3023375462a0308480ddebddbc8536a0312961297a12382f5b40e70"
9146
9151
  },
9147
9152
  {
9148
9153
  "path": "scripts/gds/smoke-batch-claim.sh",
@@ -9697,7 +9702,7 @@
9697
9702
  {
9698
9703
  "path": "src/module-api.js",
9699
9704
  "mode": "0000644",
9700
- "sha256": "d26e3a559beac4c6431a5cf5476c7a0976798ec8b9174814c06f7d64fd1f6f91"
9705
+ "sha256": "37b1b65e9b2e4136ec74c3d22f13da1a58740de5f1a6e4ddd29dd46cc544eb1f"
9701
9706
  },
9702
9707
  {
9703
9708
  "path": "src/module-loader/catalog.js",
@@ -10279,6 +10284,11 @@
10279
10284
  "mode": "0000644",
10280
10285
  "sha256": "a8fe5a479c38703217de2cb9357ac3a77b28d928feba125e97929bb31c669496"
10281
10286
  },
10287
+ {
10288
+ "path": "tests/bug_attachment_audit.mjs",
10289
+ "mode": "0000644",
10290
+ "sha256": "995bc60d7c4d51c18c90dfe5e3e220ec884c793267f9d0403c299e7c2ecb96fa"
10291
+ },
10282
10292
  {
10283
10293
  "path": "tests/bug_attachments.mjs",
10284
10294
  "mode": "0000644",
@@ -10432,7 +10442,7 @@
10432
10442
  {
10433
10443
  "path": "tests/cli_surface.mjs",
10434
10444
  "mode": "0000644",
10435
- "sha256": "5a8cc9320d66980280965ee34fbb5fc4bbcc30c8ffa860324a99ce0ce4e4d61f"
10445
+ "sha256": "1475bff8f7e51244f451401f92ac647ddf859eacecf277a8b3f9990a23b84059"
10436
10446
  },
10437
10447
  {
10438
10448
  "path": "tests/cli_token_reissue.mjs",
@@ -12042,7 +12052,7 @@
12042
12052
  {
12043
12053
  "path": "tests/module_api_lazy.mjs",
12044
12054
  "mode": "0000644",
12045
- "sha256": "40c396fda3a618bf9fa2d2ae3d8d30b50e62c4210199d70b5e29cb82a046d037"
12055
+ "sha256": "9183d78e4d195428afbee8c71746e12db2455f8529ac1223d690a7b8f7ded538"
12046
12056
  },
12047
12057
  {
12048
12058
  "path": "tests/module_asset_subdomain.mjs",
@@ -12794,6 +12804,11 @@
12794
12804
  "mode": "0000644",
12795
12805
  "sha256": "b56a6945713e738542d612f6d43dd9bd00ce1a370b3d7b3e130f4e59a056ee15"
12796
12806
  },
12807
+ {
12808
+ "path": "tests/routine_capture_kind.mjs",
12809
+ "mode": "0000644",
12810
+ "sha256": "0d45c19886939b8a068df438d2e03e749efb3a84258764ed0a345ce96128827d"
12811
+ },
12797
12812
  {
12798
12813
  "path": "tests/routine_schedule.mjs",
12799
12814
  "mode": "0000644",
@@ -13192,7 +13207,7 @@
13192
13207
  {
13193
13208
  "path": "tests/skill_preflight.mjs",
13194
13209
  "mode": "0000644",
13195
- "sha256": "df2c86d02d560b242a341c905b9ee5fbb25c6479c506736807947749e0a033fa"
13210
+ "sha256": "fe5f29a252d709d417e07f41e9ff2537980e5c42245821fbc966b6b2ba9d9e1e"
13196
13211
  },
13197
13212
  {
13198
13213
  "path": "tests/skill_prefs.mjs",
@@ -32,10 +32,19 @@ node scripts/gds/skill-preflight.js strand-fix # exits 1 naming what is MISSIN
32
32
  git log origin/main --oneline --grep="N" | head
33
33
  gh pr list --search "N" --state all --json number,state,mergeable,url
34
34
  ```
35
- 2. **Pick the ONE matching case.**
35
+ 2. **Pick the ONE matching case.** (Nothing here moves **main** by hand: landing is server-mediated (ADR 0031 §6 / ADR 0055), so the step that lands is always `ship.js`. `git fetch` always works.)
36
36
  - **A. A main commit carries `(task N` and the post-merge `unit` + `main-audit` on main are green** (`gh run list --branch main --limit 4`): the land happened; only the proof failed. The proof is the claim's recorded tip (`claims.published_head_sha`, task 1547) being an ancestor of main — and a `no_branch_no_tip` claim has none (the work rode in on another branch, e.g. a cloud-agent batch, or a `--here` claim). **Record the tip first**: the owner (Archon) sets that column on the task's latest claim to the full 40-char SHA of the main commit — a direct DB write on the box as the DB owner, so it runs under this task's own ledger with the SQL in the ship note (`UPDATE claims SET published_head_sha = '<sha>' WHERE id = (SELECT id FROM claims WHERE task_id = N ORDER BY claimed_at DESC, id DESC LIMIT 1)`). Then the reconciler proves it within ≤5 min, or flip it yourself: `node scripts/gds/api.js POST /api/bongos/tasks/N/ship --body '{}'`. Never `no_artifact: true` here — the artifact exists, and the ledger would record a falsehood. Credits land on the flip (ADR 0120). (Before task 1003434 the manual POST refused even with a tip when the claim had no branch; the reconciler path always worked.)
37
37
  - **B. A local branch exists with commits ahead of `origin/main` and no PR**: the ship never pushed. From that worktree: `git push -u origin <branch>` then `gh pr create --base main --head <branch> --fill`. Green `unit` → server auto-merge → the reconciler flips it within ~5 min. If it merges but does not flip, do **A**.
38
- - **C. A PR exists but is `CONFLICTING`**: the branch owner brings it up to date — `git merge origin/main`, resolve, push — not this skill, and never `--force`. **Merge, never rebase** (task 1002788): the branch is already published and the server's publish is append-only, so rewriting its history makes every later push non-fast-forward and the task un-landable while the claim gate keeps the owner's queue shut. Report the PR URL and stop.
38
+ - **C. A PR exists but is `CONFLICTING`**: only the **branch owner** can resolve this, and **never with `--force`**. **Merge, never rebase** (task 1002788): the branch is already published and the server's publish is append-only, so rewriting its history makes every later push non-fast-forward and the task un-landable while the claim gate keeps the owner's queue shut.
39
+ - **If you ARE the branch owner** (the usual case — it is your own strand), from that worktree:
40
+ ```bash
41
+ git fetch -q origin
42
+ git merge origin/main # resolve the conflicts, commit
43
+ node scripts/gds/ship.js <task-id>
44
+ ```
45
+ **`ship.js` is the landing step, not `git push`.** Landing is **server-mediated** (ADR 0031 §6 / ADR 0055): `/builder-ship` uploads a bundle and the SERVER moves main. A push from your machine does not land the task even where it succeeds, and on the documented default machine a push to main has no credential at all. So "resolve and push" is not an exit — it leaves the task at `confirmed`, unpaid, with nothing named to do next. That is what stranded task 1003529.
46
+ **The resume does NOT re-grade.** `ship.js` sees `confirmed` + "you last held the claim", prints `resuming the merge + deploy (no re-claim needed)`, and lands it. The standing caution about re-running `ship.js` on graded work is about `--regrade`, which pays for a second panel; a bare resume from `confirmed` does not. Verified end to end on task 1003529 → PR #745 merged as `e827063f`.
47
+ - **If it is SOMEONE ELSE'S strand**: report the PR URL and stop. That exit is still correct — only the owner can resolve a real content conflict, and this skill does not resolve conflicts for them.
39
48
  - **D. Nothing anywhere** — no branch, no PR, no commit: a codeless confirm. Ask what the task delivered. Work done outside this repo (on the box's instance checkout, a DB-only change captured in the ship note) is legitimately artifact-free → **A** with the reason in the ship body: `--body '{"note":"codeless: <where the work lives>"}'`. If the work was never done, release it instead so it returns to `ready`: `node scripts/gds/release.js N --reason "stranded codeless confirm; nothing delivered"`.
40
49
  3. **Verify**: `node scripts/gds/api.js GET /api/bongos/tasks/N` → `"status": "shipped"` (or `ready` after a release). Re-run `strand-watch.js --line` — the task must be gone from it. If the claim gate was the symptom, `claim.js` now succeeds.
41
50
 
package/.gitattributes CHANGED
@@ -28,6 +28,15 @@
28
28
  *.py text eol=lf
29
29
  *.json text eol=lf
30
30
 
31
+ # SQL: pinned for the same reason the .md above is, not for tidiness (task 1003945).
32
+ # Unpinned, *.sql fell through `* text=auto`, so a Windows clone with the common
33
+ # core.autocrlf=true got CRLF in the worktree for all 232 of them while the repo
34
+ # content stayed LF. Anything that reads a migration AS TEXT — a column-scanning
35
+ # helper, a doc generator, a checksum — then trips over a \r that CI on Linux never
36
+ # sees. That is how task 1003759's bug sat in the local failure baseline for weeks,
37
+ # read as environmental noise.
38
+ *.sql text eol=lf
39
+
31
40
  # Future Windows-only wrappers (none today) must be CRLF — cmd.exe/PowerShell
32
41
  # expect it. Declared up front so a later setup-builder.cmd lands correct.
33
42
  *.bat text eol=crlf
@@ -2110,5 +2110,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2110
2110
  landed since 1.19.816 with no explicit bump. run 35200401779. (task 1002620)
2111
2111
  1.19.818 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2112
2112
  landed since 1.19.817 with no explicit bump. run 35201213516. (task 1002620)
2113
+ 1.19.819 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2114
+ landed since 1.19.818 with no explicit bump. run 35205615659. (task 1002620)
2113
2115
  ---------------------------------------------------------------------------
2114
2116
  ```
@@ -242,6 +242,52 @@ curl -s https://example.com/api/gds/healthz
242
242
  curl -s https://example.com/api/gds/public/progress | jq '.progress[0]'
243
243
  ```
244
244
 
245
+ ## Bug attachments — the state that is not in the database (task 1003918)
246
+
247
+ The nightly dump is not the whole of this instance. Re-hosted `#bugs` screenshots
248
+ live on disk under `var/bug-attachments`, outside Postgres and outside git, and they
249
+ are the one artefact here that **cannot be regenerated**: the Discord CDN URL each was
250
+ copied from is signed and expires in about a day, so our copy *is* the image.
251
+
252
+ Nothing backed them up until this task. All five this instance had ever stored were
253
+ lost in the 2026-07-05 droplet move (ADR 0126) — `pg_dump` carried the database and
254
+ nothing carried `var/` — and the loss surfaced two months later, when somebody opened
255
+ an old bug task and saw a broken image.
256
+
257
+ Two things changed, and one thing is still on you:
258
+
259
+ 1. **The nightly job archives them.** `db-backup-nightly.sh` now writes
260
+ `<db>-attachments-<TS>.tar.gz` beside the dump, same timestamp and same retention,
261
+ so a restore drill that picks a date gets the database and the images that go with
262
+ it. Best-effort: a missing or empty attachments dir is the ordinary case on a fresh
263
+ instance and never fails a night of database backup.
264
+
265
+ 2. **The default store follows the instance, not the package.** It resolves against the
266
+ instance root, so an instance running a vendored core no longer keeps these files
267
+ inside `node_modules/@bongos/core/var`, where every core reinstall would wipe them.
268
+
269
+ 3. **On a box where the instance root is itself impermanent, set `BUG_ATTACHMENTS_DIR`**
270
+ to a persistent path (e.g. `/srv/<instance>/bug-attachments`) in the service
271
+ environment, and make sure that path is on the volume your backups actually cover.
272
+ The override wins over everything above.
273
+
274
+ ### Checking
275
+
276
+ ```bash
277
+ node scripts/gds/bug-attachment-audit.js
278
+ ```
279
+
280
+ Reads every bug task's description, extracts the attachment links it promises a reader,
281
+ and asks the disk whether it can keep them. Exits 1 when any are missing and names the
282
+ task each broken image sits in. `--json` for a machine, `--line` for a one-line probe
283
+ that never blocks a session.
284
+
285
+ A missing file cannot be re-fetched. Restore it from an attachments archive:
286
+
287
+ ```bash
288
+ tar -xzf /var/backups/gds/<db>-attachments-<TS>.tar.gz -C "$BUG_ATTACHMENTS_DIR"
289
+ ```
290
+
245
291
  ## Limits — what this recipe does NOT cover
246
292
 
247
293
  1. **Whole-droplet loss window is ~1 week, not zero.** The weekly offsite copy ([#415](https://example.com/builders#/task/415), above) closes the catastrophic-loss gap, but it runs weekly — so in the worst case (droplet dies right before the weekly run) you lose up to ~7 days of writes that made it into the local daily dumps but not yet offsite. Acceptable for the Bongos DB's shape; tighten the offsite cadence to daily if that ever stops being true.
@@ -50,8 +50,43 @@ const NAME_RE = /^bug-[a-z0-9]{1,40}-[0-9a-f]{16}\.(png|jpg|gif|webp)$/;
50
50
  // Where re-hosted images live. Outside the git tree (a `var/` dir) so a deploy's
51
51
  // `git reset --hard` / pull never wipes them; gitignored. Env override for tests
52
52
  // / non-default layouts.
53
+ //
54
+ // RESOLVED AGAINST THE INSTANCE ROOT, not this file (task 1003918). The default
55
+ // used to be __dirname/../../var, which is the repo root in a single checkout and
56
+ // `node_modules/@bongos/core/var` on an instance running a vendored core — where
57
+ // every core reinstall wipes it. That is the task 1002237 shape, and these files
58
+ // are the one thing here that cannot be regenerated: a Discord CDN URL expires in
59
+ // about a day, so our copy IS the image. All five attachments this instance ever
60
+ // stored are already gone (the 2026-07-05 droplet move carried the database and
61
+ // not var/), which is what this default is meant to stop happening again.
62
+ //
63
+ // BUG_ATTACHMENTS_DIR still wins, and on a box where the instance root is itself
64
+ // impermanent that override is the answer — the standup recipe says so.
65
+ // The instance root is resolved ONCE. resolveInstanceRoot() reads an optional
66
+ // bongos.config.json on every call, and this runs per served attachment — a sync
67
+ // file read per image request, where the old code did a path.join. The root cannot
68
+ // move under a running process, so caching it costs nothing and keeps the serve
69
+ // path as cheap as it was. The env override is still read every call, so a test
70
+ // (or an operator) can still change it without restarting.
71
+ let instanceRootCache;
72
+ function attachmentsRoot() {
73
+ if (instanceRootCache === undefined) {
74
+ try {
75
+ // Through the published doorway (ADR 0083), like every other core read here.
76
+ instanceRootCache = require('../../src/module-api').resolveInstanceRoot();
77
+ } catch (e) {
78
+ // Never let a storage-path lookup take down the bot: fall back to the historical
79
+ // location, which is correct in a single checkout and is where any existing
80
+ // files already are.
81
+ instanceRootCache = path.join(__dirname, '..', '..');
82
+ }
83
+ }
84
+ return instanceRootCache;
85
+ }
86
+
53
87
  function attachmentsDir() {
54
- return process.env.BUG_ATTACHMENTS_DIR || path.join(__dirname, '..', '..', 'var', 'bug-attachments');
88
+ if (process.env.BUG_ATTACHMENTS_DIR) return process.env.BUG_ATTACHMENTS_DIR;
89
+ return path.join(attachmentsRoot(), 'var', 'bug-attachments');
55
90
  }
56
91
 
57
92
  // PURE — is this a well-formed, bot-generated attachment name?
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.818",
3
+ "version": "1.19.819",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.818",
9
+ "version": "1.19.819",
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.818",
3
+ "version": "1.19.819",
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",
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/bug-attachment-audit.js — do the images our task descriptions
3
+ // promise actually exist? (task 1003918)
4
+ //
5
+ // WHY THIS EXISTS. A re-hosted #bugs screenshot is the one artefact in this
6
+ // system that cannot be regenerated: the Discord CDN URL it was copied from is
7
+ // signed and expires in about a day, so our copy IS the image. They live on disk
8
+ // under var/bug-attachments — outside Postgres, outside git — and nothing
9
+ // noticed when all five this instance had ever stored disappeared in the
10
+ // 2026-07-05 droplet move. The loss surfaced on 2026-09-14, two months later,
11
+ // because somebody happened to open an old bug task and saw a broken image.
12
+ //
13
+ // So: read the descriptions, extract every attachment link they promise, and ask
14
+ // the disk whether it can keep that promise. A count of broken promises is the
15
+ // thing that should have been non-zero and visible in July.
16
+ //
17
+ // Usage:
18
+ // node scripts/gds/bug-attachment-audit.js # human report; exit 1 if any are missing
19
+ // node scripts/gds/bug-attachment-audit.js --json # { described, present, missing: [...] }
20
+ // node scripts/gds/bug-attachment-audit.js --line # one line, or nothing; always exit 0
21
+ //
22
+ // Read-only. It never writes, never deletes, and never touches Discord.
23
+
24
+ 'use strict';
25
+
26
+ const fs = require('node:fs');
27
+ const path = require('node:path');
28
+
29
+ // The link shape the bot embeds in a description. Deliberately matched against
30
+ // the SERVE ROUTE's path rather than any filename found on disk: what matters is
31
+ // what a reader will click, and a file nothing links to is not a broken promise.
32
+ const LINK_RE = /\/api\/(?:gds|bongos)\/bug-attachments\/(bug-[a-z0-9]{1,40}-[0-9a-f]{16}\.(?:png|jpg|gif|webp))/gi;
33
+
34
+ // PURE — every attachment name a body of text promises, deduped, in first-seen
35
+ // order. Exported so the extraction is testable without a database or a disk.
36
+ function describedAttachments(text) {
37
+ const out = [];
38
+ const seen = new Set();
39
+ for (const m of String(text || '').matchAll(LINK_RE)) {
40
+ const name = m[1];
41
+ if (!seen.has(name)) { seen.add(name); out.push(name); }
42
+ }
43
+ return out;
44
+ }
45
+
46
+ // PURE — split the promised names into kept and broken, given an existence test.
47
+ // `rows` are { id, title, description }; the result names the TASK for each
48
+ // missing file, because "three files are gone" is not actionable and "the image
49
+ // in task N is gone" is.
50
+ function auditRows(rows, exists) {
51
+ const described = [];
52
+ const missing = [];
53
+ for (const row of rows || []) {
54
+ for (const name of describedAttachments(row && row.description)) {
55
+ described.push({ name, taskId: row.id });
56
+ if (!exists(name)) missing.push({ name, taskId: row.id, title: row.title || '' });
57
+ }
58
+ }
59
+ return { described, missing, present: described.length - missing.length };
60
+ }
61
+
62
+ // EVERY status, explicitly, and paged. Two ways to get a comfortable zero here,
63
+ // both of which I hit while building this:
64
+ //
65
+ // - GET /tasks omits abandoned rows unless you ask for them. The five files this
66
+ // audit exists for are described in tasks that are now abandoned or shipped, so
67
+ // the obvious query returned '0 linked, 0 missing' — indistinguishable from
68
+ // 'nothing is broken'.
69
+ // - the route clamps `limit`, so one page is not the table.
70
+ //
71
+ // A probe whose failure mode is a clean bill of health is worse than no probe, so
72
+ // the status list is spelled out rather than defaulted.
73
+ // 'claimed' is not one — claimTask sets status='active' (db-claims.js). Listing it
74
+ // only bought a no-op query, and a comment promising EVERY status should be right.
75
+ const TASK_STATUSES = ['backlog', 'ready', 'active', 'completed', 'confirmed', 'shipped', 'abandoned'];
76
+
77
+ async function fetchRows() {
78
+ const { cliClient } = require('./cli-lib');
79
+ const { TASK_LIST_MAX_LIMIT, buildTaskPageQuery } = require('../../modules/lifecycle/task-list-limits.js');
80
+ const api = await cliClient();
81
+ const byId = new Map();
82
+ for (const status of TASK_STATUSES) {
83
+ let offset = 0;
84
+ for (let page = 0; page < 100; page++) {
85
+ const q = buildTaskPageQuery({ kind: 'bug', status, limit: TASK_LIST_MAX_LIMIT, offset });
86
+ const r = await api.tasks.getTasks({ query: q });
87
+ if (!r || !r.ok) throw new Error(`could not list ${status} tasks (${r && r.status})`);
88
+ const rows = (r.data && r.data.tasks) || [];
89
+ for (const t of rows) byId.set(String(t.id), t);
90
+ // A short page is the end. Compare against the limit the route HONOURED.
91
+ if (rows.length < q.limit) break;
92
+ offset += q.limit;
93
+ }
94
+ }
95
+ return [...byId.values()];
96
+ }
97
+
98
+ async function main() {
99
+ const argv = process.argv.slice(2);
100
+ const dir = require('../../modules/discord/bug-attachments.js').attachmentsDir();
101
+ let rows;
102
+ try {
103
+ rows = await fetchRows();
104
+ } catch (e) {
105
+ if (argv.includes('--line')) process.exit(0); // a probe never blocks a session
106
+ console.error(`bug-attachment-audit: undetermined — ${e.message || e}`);
107
+ process.exit(2);
108
+ }
109
+ // Through the serve route's OWN resolver, not a bare path.join. The name pattern
110
+ // already excludes slashes and dots, and a test pins that '../../etc/passwd' is
111
+ // never extracted — but the route that actually opens these files defends itself
112
+ // with a resolved-path prefix check, and a probe that reads the same store should
113
+ // not hold itself to a weaker rule than the thing it audits.
114
+ const { resolveSafe } = require('../../modules/discord/bug-attachments.js');
115
+ const r = auditRows(rows, (name) => {
116
+ const p = resolveSafe(name);
117
+ return Boolean(p) && fs.existsSync(p);
118
+ });
119
+
120
+ if (argv.includes('--json')) {
121
+ console.log(JSON.stringify({ dir, described: r.described.length, present: r.present, missing: r.missing }, null, 2));
122
+ return;
123
+ }
124
+ if (argv.includes('--line')) {
125
+ if (r.missing.length) {
126
+ console.log(`Bug attachments: ${r.missing.length} of ${r.described.length} linked screenshot(s) are MISSING from ${dir} `
127
+ + '— every task describing one renders a broken image. These cannot be re-fetched (the Discord URL has expired).');
128
+ }
129
+ return;
130
+ }
131
+ console.log(` Bug attachments: ${r.described.length} linked, ${r.present} present, ${r.missing.length} missing`);
132
+ console.log(` Store: ${dir}`);
133
+ for (const m of r.missing) console.log(` missing ${m.name} (task ${m.taskId})`);
134
+ if (r.missing.length) {
135
+ console.log(' These cannot be re-fetched — the Discord CDN URL they were copied from has expired.');
136
+ console.log(' Restore from a nightly attachments archive: /var/backups/gds/<db>-attachments-*.tar.gz');
137
+ process.exit(1);
138
+ }
139
+ }
140
+
141
+ if (require.main === module) main();
142
+
143
+ module.exports = { describedAttachments, auditRows, LINK_RE };
@@ -282,6 +282,25 @@ async function main() {
282
282
  const noPrompt = hasFlag('--no-prompt', args);
283
283
  const goalArg = arg('--goal', args);
284
284
 
285
+ // A BAD --kind used to reach the server and come back a 400 (task 1003694).
286
+ // That is not merely late, it is invisible where it matters: a SCHEDULED routine
287
+ // filing its findings gets the 400 into a log nobody reads, the audit work still
288
+ // runs, and only the step that turns a finding into a durable row fails — so no
289
+ // queue accumulates to make the absence noticeable. dep-audit-nightly filed with
290
+ // `--kind security`, which is not a kind, and had therefore NEVER filed anything.
291
+ //
292
+ // Refusing here makes the same typo surface at authoring time, on the machine of
293
+ // whoever wrote the command, with the valid list in the message.
294
+ if (kindFromArg !== null) {
295
+ const { VALID_KINDS } = require('../../modules/ideas/inbox.js');
296
+ if (!VALID_KINDS.includes(kindFromArg)) {
297
+ console.error(`--kind "${kindFromArg}" is not a valid kind. The server would refuse this with a 400.`);
298
+ console.error(`Valid kinds: ${VALID_KINDS.join(', ')}`);
299
+ console.error('Adding a new kind is a schema change (the idea_inbox CHECK constraint) and needs its own migration + ADR.');
300
+ process.exit(2);
301
+ }
302
+ }
303
+
285
304
  if (parseGoalArg(goalArg).kind === 'invalid') {
286
305
  console.error(`--goal must be a positive goal id, or the literal "none" to file homeless (got "${goalArg}").`);
287
306
  process.exit(2);
@@ -170,6 +170,42 @@ if [ "$BYTES" -gt "$WARN_BYTES" ]; then
170
170
  echo "[$(date -u +%FT%TZ)] gds-db-backup: WARN latest dump is ${BYTES} bytes (> ${WARN_BYTES} threshold) — review what grew; is it a regenerable table excludable like search_chunks?" >&2
171
171
  fi
172
172
 
173
+ # ---- bug attachments (task 1003918) -----------------------------------------
174
+
175
+ # The DB dump above is not the whole state. Re-hosted #bugs screenshots live on
176
+ # disk under var/bug-attachments, outside Postgres and outside git, and they are
177
+ # the one artefact here that CANNOT be regenerated: the Discord CDN URL they were
178
+ # copied from is signed and expires in about a day, so our copy IS the image.
179
+ #
180
+ # Nothing backed them up. All five this instance ever stored are gone — the
181
+ # 2026-07-05 droplet move carried the database and not var/ — and every task
182
+ # description embedding one now renders a broken image in the hall.
183
+ #
184
+ # Tarred beside the dump, same timestamp, same retention, so a restore drill that
185
+ # picks a date gets the database and the images that go with it. Best-effort by
186
+ # design: a missing or empty attachments dir is the ordinary case on a fresh
187
+ # instance and must never fail a night of database backup.
188
+ ATTACH_DIR="${BUG_ATTACHMENTS_DIR:-$REPO_ROOT/var/bug-attachments}"
189
+ ATTACH_OUT="${BACKUP_DIR}/${DB_NAME}-attachments-${TS}.tar.gz"
190
+ ATTACH_GLOB="${DB_NAME}-attachments-${D}${D}${D}${D}-${D}${D}-${D}${D}T${D}${D}${D}${D}${D}${D}Z.tar.gz"
191
+
192
+ if [ -d "$ATTACH_DIR" ]; then
193
+ ATTACH_COUNT=$(find "$ATTACH_DIR" -maxdepth 1 -type f | wc -l)
194
+ if [ "$ATTACH_COUNT" -gt 0 ]; then
195
+ if tar -czf "$ATTACH_OUT.partial" -C "$ATTACH_DIR" . 2>/dev/null; then
196
+ mv "$ATTACH_OUT.partial" "$ATTACH_OUT"
197
+ echo "[$(date -u +%FT%TZ)] gds-db-backup: wrote ${ATTACH_OUT} (${ATTACH_COUNT} attachment(s))"
198
+ else
199
+ rm -f "$ATTACH_OUT.partial"
200
+ echo "[$(date -u +%FT%TZ)] gds-db-backup: WARN could not archive ${ATTACH_DIR} — database dump is unaffected" >&2
201
+ fi
202
+ else
203
+ echo "[$(date -u +%FT%TZ)] gds-db-backup: no bug attachments to archive"
204
+ fi
205
+ else
206
+ echo "[$(date -u +%FT%TZ)] gds-db-backup: no bug-attachment dir at ${ATTACH_DIR} — nothing to archive"
207
+ fi
208
+
173
209
  # ---- retention prune --------------------------------------------------------
174
210
 
175
211
  # Delete dumps older than RETENTION_DAYS. -mtime +N means strictly older
@@ -177,7 +213,7 @@ fi
177
213
  # Done AFTER the new dump lands so a streak of failed runs can't erase
178
214
  # everything: we never delete on a day we didn't successfully dump.
179
215
  PRUNED=$(find "$BACKUP_DIR" -maxdepth 1 -type f \
180
- -name "$DUMP_GLOB" \
216
+ \( -name "$DUMP_GLOB" -o -name "$ATTACH_GLOB" \) \
181
217
  -mtime +"${RETENTION_DAYS}" \
182
218
  -print -delete | wc -l)
183
219
 
@@ -120,13 +120,36 @@ const CAPABILITIES = {
120
120
  },
121
121
  };
122
122
 
123
+ // EVERY read of a SKILL.md goes through here (task 1003754).
124
+ //
125
+ // These files are CRLF in a Windows checkout — .gitattributes pins *.js, *.json,
126
+ // *.sh and *.sql, but Markdown falls through `* text=auto`. Every regex below is
127
+ // anchored on \n, starting with the frontmatter fence /^---\n/, so on Windows the
128
+ // frontmatter simply did not match and parseRequires returned `declared: false`
129
+ // for EVERY skill.
130
+ //
131
+ // That is worse than one red test. Fitness check G03 compares what a skill's fenced
132
+ // commands imply against what it declares — and with nothing parsed as declared, the
133
+ // check reported '53 skill(s) scanned; 0 declare a machine contract' and had nothing
134
+ // to compare. The guard against a skill's ops commands outrunning its declaration
135
+ // was inert on any Windows box.
136
+ //
137
+ // Normalising once at the boundary is deliberately not 'make each regex CRLF-aware':
138
+ // there are six of them and the next one added would forget.
139
+ function readSkillMd(p) {
140
+ return String(fs.readFileSync(p, 'utf8')).replace(/\r\n/g, '\n');
141
+ }
142
+
123
143
  // ---- the declaration ------------------------------------------------------
124
144
  // Read `requires:` out of a skill's YAML frontmatter. Accepts the inline form
125
145
  // (`requires: [gh, main-checkout]`) and the dash-list form. Returns
126
146
  // { requires, declared, error }: `declared` distinguishes "declared nothing"
127
147
  // from "declared an empty list", which the fitness check needs to tell apart.
128
148
  function parseRequires(md) {
129
- const m = /^---\n([\s\S]*?)\n---/.exec(String(md || ''));
149
+ // Normalised here too, not only in readSkillMd: this is exported and a caller may
150
+ // hand it raw file text (the fitness check's injected `read` does exactly that).
151
+ const src = String(md || '').replace(/\r\n/g, '\n');
152
+ const m = /^---\n([\s\S]*?)\n---/.exec(src);
130
153
  if (!m) return { requires: [], declared: false, error: null };
131
154
  const fm = m[1];
132
155
  const inline = /^requires:[ \t]*\[(.*?)\][ \t]*$/m.exec(fm);
@@ -176,7 +199,8 @@ function fencedCode(md) {
176
199
  }
177
200
 
178
201
  function inferRequired(md) {
179
- const code = fencedCode(md);
202
+ // Same reason as parseRequires: the SIGNALS below are ^-anchored per line.
203
+ const code = fencedCode(String(md || '').replace(/\r\n/g, '\n'));
180
204
  const out = new Set();
181
205
  for (const [rx, cap] of SIGNALS) if (rx.test(code)) out.add(cap);
182
206
  return [...out].sort();
@@ -224,7 +248,7 @@ function preflightSkill(name, caps = CAPABILITIES) {
224
248
  return { ok: false, requires: [], missing: [{ name: 'skill', why: `"${name}" is not a skill name — it must be a single directory name under .claude/skills/.` }], unknown: [], checked: [] };
225
249
  }
226
250
  let md;
227
- try { md = fs.readFileSync(p, 'utf8'); } catch {
251
+ try { md = readSkillMd(p); } catch {
228
252
  return { ok: false, missing: [{ name: 'skill', why: `no skill named "${name}" at .claude/skills/${name}/SKILL.md` }], unknown: [], checked: [] };
229
253
  }
230
254
  const { requires, error } = parseRequires(md);
@@ -263,7 +287,7 @@ function checkOpsSkillsDeclareMachine({ dir = SKILLS_DIR, read = null, names: in
263
287
  };
264
288
  }
265
289
  const readOne = read || ((n) => {
266
- try { return fs.readFileSync(path.join(dir, n, 'SKILL.md'), 'utf8'); } catch { return null; }
290
+ try { return readSkillMd(path.join(dir, n, 'SKILL.md')); } catch { return null; }
267
291
  });
268
292
 
269
293
  let declaring = 0;
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.818'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.819'; // 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');
@@ -0,0 +1,122 @@
1
+ // tests/bug_attachment_audit.mjs — the described-but-missing probe (task 1003918).
2
+ //
3
+ // WHY THIS FILE EXISTS. Five re-hosted #bugs screenshots — every one this instance
4
+ // ever stored — disappeared in the 2026-07-05 droplet move, and nobody noticed for
5
+ // two months. They cannot be re-fetched: the Discord CDN URL they were copied from
6
+ // is signed and expires in about a day, so our copy IS the image. The probe is what
7
+ // should have said so in July.
8
+ //
9
+ // A probe like this fails by finding nothing, and a clean bill of health looks
10
+ // identical to working — which is exactly what happened while building it: the
11
+ // first cut queried GET /tasks without naming a status, the route omits abandoned
12
+ // rows by default, and every task describing these files is now abandoned or
13
+ // shipped. It printed "0 linked, 0 missing". So the cases below lean on the ways
14
+ // the extraction can go quietly empty.
15
+ //
16
+ // Run: node tests/bug_attachment_audit.mjs
17
+
18
+ import { test } from 'node:test';
19
+ import assert from 'node:assert/strict';
20
+ import { createRequire } from 'node:module';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const { describedAttachments, auditRows } = require('../scripts/gds/bug-attachment-audit.js');
24
+ const { attachmentsDir } = require('../modules/discord/bug-attachments.js');
25
+ const path = require('node:path');
26
+
27
+ // The real link shape, taken verbatim from task 1003718's description.
28
+ const REAL = '/api/gds/bug-attachments/bug-1523688670492627115-8bb16de1f5b4c20e.png';
29
+
30
+ test('extracts the attachment name from a real embedded link', () => {
31
+ const got = describedAttachments(`![screenshot](https://cloudbongos.com${REAL})`);
32
+ assert.deepEqual(got, ['bug-1523688670492627115-8bb16de1f5b4c20e.png']);
33
+ });
34
+
35
+ test('the 19-digit Discord message id is not too long for the name pattern', () => {
36
+ // The id in every real filename here is 19 characters. A pattern sized for
37
+ // something shorter would match nothing and report a clean board.
38
+ assert.equal(describedAttachments(REAL).length, 1);
39
+ });
40
+
41
+ test('both /gds/ and /bongos/ spellings of the route are recognised', () => {
42
+ const a = describedAttachments('/api/gds/bug-attachments/bug-1-0123456789abcdef.png');
43
+ const b = describedAttachments('/api/bongos/bug-attachments/bug-1-0123456789abcdef.png');
44
+ assert.deepEqual(a, b, 'the route was renamed; a description can carry either');
45
+ assert.equal(a.length, 1);
46
+ });
47
+
48
+ test('the same file linked twice in one description is counted once', () => {
49
+ const text = `![a](${REAL}) and again ![a](${REAL})`;
50
+ assert.equal(describedAttachments(text).length, 1);
51
+ });
52
+
53
+ test('a description with no attachment yields nothing — and that is not an error', () => {
54
+ assert.deepEqual(describedAttachments('a bug about the hall sidebar'), []);
55
+ assert.deepEqual(describedAttachments(null), []);
56
+ assert.deepEqual(describedAttachments(undefined), []);
57
+ });
58
+
59
+ test('a malformed name is NOT extracted — the pattern is the traversal defence too', () => {
60
+ assert.deepEqual(describedAttachments('/api/gds/bug-attachments/../../etc/passwd'), []);
61
+ assert.deepEqual(describedAttachments('/api/gds/bug-attachments/bug-1-short.png'), []);
62
+ assert.deepEqual(describedAttachments('/api/gds/bug-attachments/bug-1-0123456789abcdef.exe'), []);
63
+ });
64
+
65
+ test('auditRows names the TASK for each missing file, not just the file', () => {
66
+ const rows = [
67
+ { id: '1003718', title: 'a re-filed bug', description: `![x](${REAL})` },
68
+ { id: '1003719', title: 'no images here', description: 'plain prose' },
69
+ ];
70
+ const r = auditRows(rows, () => false);
71
+ assert.equal(r.described.length, 1);
72
+ assert.equal(r.missing.length, 1);
73
+ assert.equal(r.missing[0].taskId, '1003718', '"a file is gone" is not actionable; "the image in task N is gone" is');
74
+ assert.equal(r.present, 0);
75
+ });
76
+
77
+ test('a present file is not reported missing', () => {
78
+ const rows = [{ id: '1', title: 't', description: `![x](${REAL})` }];
79
+ const r = auditRows(rows, () => true);
80
+ assert.deepEqual(r.missing, []);
81
+ assert.equal(r.present, 1);
82
+ });
83
+
84
+ test('the same file described by two tasks is reported for BOTH', () => {
85
+ // task 1002076 was re-filed as 1003718 and both descriptions carry the links;
86
+ // each renders a broken image, so each is worth naming.
87
+ const rows = [
88
+ { id: '1002076', title: 'original', description: `![x](${REAL})` },
89
+ { id: '1003718', title: 're-file', description: `![x](${REAL})` },
90
+ ];
91
+ const r = auditRows(rows, () => false);
92
+ assert.equal(r.missing.length, 2);
93
+ });
94
+
95
+ test('the store resolves against the INSTANCE root, not the core package (task 1003918)', () => {
96
+ // The old default was __dirname/../../var, which on a vendored core is
97
+ // node_modules/@bongos/core/var — wiped by every reinstall, and these files
98
+ // cannot be regenerated.
99
+ const prev = process.env.BUG_ATTACHMENTS_DIR;
100
+ delete process.env.BUG_ATTACHMENTS_DIR;
101
+ try {
102
+ const dir = attachmentsDir();
103
+ assert.ok(!dir.includes('node_modules'), `the store must not live inside an installed package: ${dir}`);
104
+ assert.equal(path.basename(dir), 'bug-attachments');
105
+ assert.equal(path.basename(path.dirname(dir)), 'var');
106
+ } finally {
107
+ if (prev === undefined) delete process.env.BUG_ATTACHMENTS_DIR;
108
+ else process.env.BUG_ATTACHMENTS_DIR = prev;
109
+ }
110
+ });
111
+
112
+ test('BUG_ATTACHMENTS_DIR still wins outright', () => {
113
+ const prev = process.env.BUG_ATTACHMENTS_DIR;
114
+ process.env.BUG_ATTACHMENTS_DIR = '/srv/persistent/bug-attachments';
115
+ try {
116
+ assert.equal(attachmentsDir(), '/srv/persistent/bug-attachments',
117
+ 'on a box whose instance root is impermanent, the override is the answer');
118
+ } finally {
119
+ if (prev === undefined) delete process.env.BUG_ATTACHMENTS_DIR;
120
+ else process.env.BUG_ATTACHMENTS_DIR = prev;
121
+ }
122
+ });
@@ -169,18 +169,47 @@ function runGuidance(fn, env) {
169
169
  const code =
170
170
  `const l=require(${JSON.stringify(path.join(REPO_ROOT, 'scripts', 'gds', 'cli-lib.js'))});` +
171
171
  `Promise.resolve(l[${JSON.stringify(fn)}]()).catch(()=>{});`;
172
+ // HOME ALONE IS NOT ISOLATION ON WINDOWS (task 1003941). cli-lib resolves the
173
+ // session through os.homedir(), which on win32 reads USERPROFILE and ignores
174
+ // HOME entirely — so the child found the DEVELOPER'S REAL session, requireSession
175
+ // succeeded, and the guidance these tests exist to read was never printed. That
176
+ // failed on any Windows box whose builder happened to be signed in, and went
177
+ // green again once their CLI session expired, which made it read as a flake.
178
+ //
179
+ // HOMEDRIVE/HOMEPATH are os.homedir()'s second win32 source, so all four go.
180
+ const HOMELESS = path.join(REPO_ROOT, 'no-such-home-for-tests');
172
181
  const r = spawnSync(process.execPath, ['-e', code], {
173
182
  encoding: 'utf8',
174
- env: { ...process.env, ...env, HOME: path.join(REPO_ROOT, 'no-such-home-for-tests') },
183
+ env: {
184
+ ...process.env,
185
+ ...env,
186
+ HOME: HOMELESS,
187
+ USERPROFILE: HOMELESS,
188
+ HOMEDRIVE: '',
189
+ HOMEPATH: HOMELESS,
190
+ },
175
191
  });
176
192
  return `${r.stdout || ''}${r.stderr || ''}`;
177
193
  }
178
194
 
195
+ // The isolation above either worked or it did not, and the difference is visible:
196
+ // a child with no session PRINTS guidance, and a child that found one prints
197
+ // nothing. Every assertion below that looks for something ABSENT is satisfied for
198
+ // free by that silence, so the silence is checked for first (task 1003941).
199
+ function guidanceOutput(fn, env) {
200
+ const out = runGuidance(fn, env);
201
+ assert.ok(out.trim().length > 0,
202
+ `${fn} printed NOTHING. That means the child found a real CLI session instead of `
203
+ + 'the blanked home, so this test is reading an unisolated environment and every '
204
+ + '"must not contain" assertion here is vacuous (task 1003941).');
205
+ return out;
206
+ }
207
+
179
208
  const GUIDANCE = ['printUnauthGuidanceAndExit', 'printBoxScopeGuidanceAndExit', 'requireSession'];
180
209
 
181
210
  test('no guidance path speaks slash commands to a terminal reader', () => {
182
211
  for (const fn of GUIDANCE) {
183
- const out = runGuidance(fn, { BONGOS_SURFACE: 'terminal' });
212
+ const out = guidanceOutput(fn, { BONGOS_SURFACE: 'terminal' });
184
213
  const leaks = [...out.matchAll(/\/builder-[a-z-]+/g)].map((m) => m[0]);
185
214
  assert.deepEqual(leaks, [], `${fn} leaked to a terminal reader: ${leaks.join(', ')}\n${out}`);
186
215
  assert.ok(!/node scripts\/gds\//.test(out), `${fn} told a terminal reader to run a repo script:\n${out}`);
@@ -35,8 +35,13 @@ function loadedAfter(spec, read = null) {
35
35
  const ROOT = ${JSON.stringify(ROOT)};
36
36
  const m = require(path.join(ROOT, ${JSON.stringify(spec)}));
37
37
  ${read ? `void m[${JSON.stringify(read)}];` : ''}
38
+ // POSIX-normalised before filtering (task 1003941). path.relative returns
39
+ // backslashes on win32, so 'src/bongos/' matched NOTHING and every list came
40
+ // back empty — which made the two laziness assertions below pass VACUOUSLY on
41
+ // Windows: they assert that a filtered-empty list is empty. An eager require
42
+ // reintroduced at the doorway would have shipped green.
38
43
  const files = Object.keys(require.cache)
39
- .map((f) => path.relative(ROOT, f))
44
+ .map((f) => path.relative(ROOT, f).split(path.sep).join('/'))
40
45
  .filter((f) => f.startsWith('src/bongos/'));
41
46
  process.stdout.write(JSON.stringify(files));
42
47
  `;
@@ -50,6 +55,32 @@ function loadedAfter(spec, read = null) {
50
55
  return JSON.parse(out);
51
56
  }
52
57
 
58
+ // CALIBRATION — can the instrument see anything at all? (task 1003941)
59
+ //
60
+ // Every other assertion in this file has the shape "nothing heavy is in this
61
+ // list", which an EMPTY list satisfies for free. So an empty list is not evidence
62
+ // of laziness; it is evidence of nothing, and the two tests that guard the ADR
63
+ // 0083 doorway invariant cannot tell the difference.
64
+ //
65
+ // That is not hypothetical. loadedAfter filtered on a 'src/bongos/' prefix after
66
+ // path.relative, which returns BACKSLASHES on win32 — so on Windows the filter
67
+ // matched nothing, every list was empty, and both doorway tests passed vacuously.
68
+ // An eager require reintroduced at the doorway would have shipped green.
69
+ //
70
+ // The fix normalises separators, but a normalisation can break again silently, and
71
+ // asserting the doorway's own list is non-empty would be wrong — loading NOTHING
72
+ // from src/bongos/ is precisely what the doorway is supposed to do. So the probe
73
+ // is calibrated against a module that certainly does load one. If this test fails,
74
+ // every emptiness assertion below is meaningless and should be read as untested.
75
+ test('CALIBRATION: the probe can see a src/bongos/ require at all', () => {
76
+ const files = loadedAfter('src/bongos/pool.js');
77
+ assert.ok(files.includes('src/bongos/pool.js'),
78
+ 'requiring src/bongos/pool.js must show up in the probe\'s own list. It does not, so '
79
+ + 'the list is empty for reasons unrelated to laziness — check the path normalisation '
80
+ + 'in loadedAfter (task 1003941). Until this passes, every "loads nothing" assertion '
81
+ + `in this file is vacuous. Saw: ${JSON.stringify(files.slice(0, 5))}`);
82
+ });
83
+
53
84
  test('importing the doorway loads no server: no pool, no auth, no routes', () => {
54
85
  const files = loadedAfter('src/module-api.js');
55
86
  const heavy = files.filter((f) => HEAVY.test(f));
@@ -0,0 +1,147 @@
1
+ // tests/routine_capture_kind.mjs — a routine that files its findings must name a
2
+ // kind the server accepts (task 1003694).
3
+ //
4
+ // THE FAILURE THIS PINS. .claude/scheduled-tasks/dep-audit-nightly/SKILL.md filed
5
+ // with `--kind security`. That is not one of the kinds — captureIdea throws BAD_KIND
6
+ // and the route 400s — so the capture step of that nightly routine had NEVER filed
7
+ // anything.
8
+ //
9
+ // It is quiet in the worst way. The routine still runs, still does the audit, and
10
+ // only the step that turns a finding into a durable row fails. There is no
11
+ // accumulating queue whose emptiness would look wrong, so nothing ever looked wrong.
12
+ // A scheduled routine cannot notice its own silence, which is exactly why this is a
13
+ // test and not a runtime check.
14
+ //
15
+ // Run: node tests/routine_capture_kind.mjs
16
+
17
+ import { test } from 'node:test';
18
+ import assert from 'node:assert/strict';
19
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { createRequire } from 'node:module';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const { VALID_KINDS } = require('../modules/ideas/inbox.js');
26
+
27
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
28
+
29
+ // Every SKILL.md that could carry a capture command: the scheduled routines and the
30
+ // skills. A routine is the dangerous case (nobody reads its output), but a skill with
31
+ // a bad kind wastes a builder's time the same way.
32
+ function skillFiles() {
33
+ const out = [];
34
+ const roots = [
35
+ path.join(REPO_ROOT, '.claude', 'scheduled-tasks'),
36
+ path.join(REPO_ROOT, '.claude', 'skills'),
37
+ ];
38
+ for (const root of roots) {
39
+ if (!existsSync(root)) continue;
40
+ for (const d of readdirSync(root, { withFileTypes: true })) {
41
+ if (!d.isDirectory()) continue;
42
+ const p = path.join(root, d.name, 'SKILL.md');
43
+ if (existsSync(p)) out.push(p);
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ // `--kind foo` — a LITERAL kind, on a CAPTURE command only.
50
+ //
51
+ // Both narrowings are load-bearing, and I got each wrong first:
52
+ //
53
+ // - CAPTURE ONLY. `--kind` is not unique to capture.js. recall.js takes its own
54
+ // `--kind doc|db`, and a matcher that reads every `--kind` in the tree reports
55
+ // three recall invocations as broken. A guard whose first run is mostly noise
56
+ // is a guard people learn to suppress.
57
+ // - LITERAL ONLY. A placeholder (`--kind <bug|cleanup>`, `--kind cleanup|refactor`)
58
+ // documents a CHOICE rather than passing a value; overnight-code-review and
59
+ // repo-simplify-sweep both use that form deliberately.
60
+ const CAPTURE_LINE_RE = /capture\.js/;
61
+ const KIND_RE = /--kind\s+([a-z][a-z0-9-]*)(?=[\s`'"]|$)/g;
62
+
63
+ // A capture command can wrap across lines with a trailing backslash, so the unit of
64
+ // inspection is the command, not the line: a run of lines joined while the previous
65
+ // one ends in `\`.
66
+ function captureCommands(text) {
67
+ const lines = String(text || '').split('\n');
68
+ const out = [];
69
+ let buf = null;
70
+ for (const line of lines) {
71
+ const continues = /\\\s*$/.test(line);
72
+ if (buf !== null) {
73
+ buf += ' ' + line;
74
+ if (!continues) { out.push(buf); buf = null; }
75
+ } else if (CAPTURE_LINE_RE.test(line)) {
76
+ if (continues) buf = line;
77
+ else out.push(line);
78
+ }
79
+ }
80
+ if (buf !== null) out.push(buf);
81
+ return out;
82
+ }
83
+
84
+ function literalKinds(text) {
85
+ const out = [];
86
+ for (const cmd of captureCommands(text)) {
87
+ for (const m of cmd.matchAll(KIND_RE)) {
88
+ const after = cmd.slice(m.index + m[0].length, m.index + m[0].length + 1);
89
+ if (after === '|') continue; // `--kind cleanup|refactor` — a menu, not a value
90
+ out.push(m[1]);
91
+ }
92
+ }
93
+ return out;
94
+ }
95
+
96
+ test('every literal --kind in a routine or skill is a kind the server accepts', () => {
97
+ const files = skillFiles();
98
+ // The scan must not pass by finding nothing: these files exist, and at least one of
99
+ // them documents a capture command.
100
+ assert.ok(files.length > 0, 'found no SKILL.md files at all — the scan is broken, not clean');
101
+
102
+ const bad = [];
103
+ let seen = 0;
104
+ for (const f of files) {
105
+ for (const kind of literalKinds(readFileSync(f, 'utf8'))) {
106
+ seen += 1;
107
+ if (!VALID_KINDS.includes(kind)) bad.push(`${path.relative(REPO_ROOT, f)}: --kind ${kind}`);
108
+ }
109
+ }
110
+ assert.ok(seen > 0, 'no literal --kind found anywhere — the pattern has drifted, and this test would pass an empty repo');
111
+ assert.deepEqual(bad, [],
112
+ `these file a kind the server rejects with a 400 — for a SCHEDULED routine that means its findings are silently never filed:\n ${bad.join('\n ')}\n`
113
+ + `Valid kinds: ${VALID_KINDS.join(', ')}`);
114
+ });
115
+
116
+ test('the matcher reads a literal kind on a capture command', () => {
117
+ assert.deepEqual(literalKinds('node scripts/gds/capture.js "t" --kind infra --body-file x'), ['infra']);
118
+ assert.deepEqual(literalKinds('node scripts/gds/capture.js "t" --kind security'), ['security'],
119
+ 'the real bug is still caught');
120
+ });
121
+
122
+ test('a DIFFERENT tool\'s --kind is not this guard\'s business', () => {
123
+ // recall.js takes --kind doc|db. Reading every --kind in the tree reported three
124
+ // recall invocations as broken on the first run of this guard.
125
+ assert.deepEqual(literalKinds('node scripts/gds/recall.js nightly backup --kind doc'), []);
126
+ assert.deepEqual(literalKinds('add `--kind doc` for ADRs/recipes'), []);
127
+ });
128
+
129
+ test('a documented menu is not a value', () => {
130
+ assert.deepEqual(literalKinds('node scripts/gds/capture.js --kind <bug|cleanup|refactor>'), []);
131
+ assert.deepEqual(literalKinds('node scripts/gds/capture.js --kind cleanup|refactor \\'), []);
132
+ });
133
+
134
+ test('a capture command wrapped over lines is still read', () => {
135
+ const text = ['node scripts/gds/capture.js "t" \\', ' --kind security \\', ' --body-file /tmp/x'].join('\n');
136
+ assert.deepEqual(literalKinds(text), ['security'],
137
+ 'dep-audit-nightly writes exactly this shape — a line-at-a-time matcher misses it');
138
+ });
139
+
140
+ test('dep-audit-nightly specifically now files with an accepted kind (task 1003694)', () => {
141
+ const p = path.join(REPO_ROOT, '.claude', 'scheduled-tasks', 'dep-audit-nightly', 'SKILL.md');
142
+ const src = readFileSync(p, 'utf8');
143
+ assert.ok(!/--kind\s+security\b/.test(src), 'security is not a kind and never was');
144
+ const kinds = literalKinds(src);
145
+ assert.ok(kinds.length > 0, 'the routine must still file something');
146
+ for (const k of kinds) assert.ok(VALID_KINDS.includes(k), `${k} is not a valid kind`);
147
+ });
@@ -140,6 +140,51 @@ test('the live tree passes the declare-check', () => {
140
140
  assert.equal(r.hardFail, false, `unexpected:\n ${r.violations.join('\n ')}`);
141
141
  });
142
142
 
143
+ // ---- CRLF (task 1003754) --------------------------------------------------
144
+ //
145
+ // Every regex in skill-preflight is anchored on \n, starting with the frontmatter
146
+ // fence /^---\n/. SKILL.md files are CRLF in a Windows checkout — .gitattributes
147
+ // pins *.js, *.json, *.sh and *.sql, but Markdown falls through `* text=auto` — so
148
+ // the frontmatter never matched and parseRequires reported `declared: false` for
149
+ // EVERY skill.
150
+ //
151
+ // The visible symptom was one red test below. The real damage was quieter: fitness
152
+ // check G03 compares a skill's fenced commands against its declaration, and with
153
+ // nothing parsed as declared it had nothing to compare — '0 declare a machine
154
+ // contract'. The guard against a skill's ops commands outrunning its declaration was
155
+ // INERT on Windows, which is a false green, not a false red.
156
+
157
+ test('parseRequires reads a CRLF frontmatter identically to an LF one (task 1003754)', () => {
158
+ const lf = '---\nname: x\nrequires: [gh, main-checkout]\n---\nbody\n';
159
+ const crlf = lf.replace(/\n/g, '\r\n');
160
+ assert.deepEqual(sp.parseRequires(crlf), sp.parseRequires(lf),
161
+ 'a checkout\'s line endings must not change what a skill is understood to require');
162
+ assert.deepEqual(sp.parseRequires(crlf).requires, ['gh', 'main-checkout']);
163
+ assert.equal(sp.parseRequires(crlf).declared, true);
164
+ });
165
+
166
+ test('the dash-list form survives CRLF too', () => {
167
+ const crlf = '---\r\nname: x\r\nrequires:\r\n - gh\r\n - droplet-ssh\r\n---\r\nbody\r\n';
168
+ assert.deepEqual(sp.parseRequires(crlf).requires, ['gh', 'droplet-ssh']);
169
+ });
170
+
171
+ test('inferRequired reads CRLF fenced commands (task 1003754)', () => {
172
+ // The half that would silently stop CATCHING an undeclared ops command.
173
+ const crlf = '---\r\nname: x\r\n---\r\n```bash\r\ngit push origin main\r\n```\r\n';
174
+ assert.ok(sp.inferRequired(crlf).includes('push-credential'),
175
+ 'a main-push in a CRLF skill must still imply a push credential, or G03 cannot catch it');
176
+ });
177
+
178
+ test('the live corpus is NOT empty — G03 has something to compare (task 1003754)', () => {
179
+ // The check\'s own note read "0 declare a machine contract" on Windows. A guard
180
+ // with an empty corpus passes whatever the skills do.
181
+ const r = sp.checkOpsSkillsDeclareMachine();
182
+ const n = Number(/(\d+) declare a machine contract/.exec(r.note || '')?.[1] ?? 0);
183
+ assert.ok(n > 0,
184
+ `no skill parsed as declaring a machine contract, so check G03 has nothing to compare and `
185
+ + `cannot fail. Note was: ${r.note}`);
186
+ });
187
+
143
188
  test('merge-mode really declares the machine it needs', () => {
144
189
  // The skill at the centre of the incident.
145
190
  const r = sp.preflightSkill('merge-mode', caps);