@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 +36 -21
- package/.claude/skills/strand-fix/SKILL.md +11 -2
- package/.gitattributes +9 -0
- package/docs/module-api-changelog.md +2 -0
- package/docs/recipes/gds-db-backup.md +46 -0
- package/modules/discord/bug-attachments.js +36 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/bug-attachment-audit.js +143 -0
- package/scripts/gds/capture.js +19 -0
- package/scripts/gds/db-backup-nightly.sh +37 -1
- package/scripts/gds/skill-preflight.js +28 -4
- package/src/module-api.js +1 -1
- package/tests/bug_attachment_audit.mjs +122 -0
- package/tests/cli_surface.mjs +31 -2
- package/tests/module_api_lazy.mjs +32 -1
- package/tests/routine_capture_kind.mjs +147 -0
- package/tests/skill_preflight.mjs +45 -0
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
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-
|
|
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":
|
|
14
|
+
"functional_verbatim": 2220,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
7805
|
+
"sha256": "80eb7e1ecf3c324277a00d732da45ff1e094ac64e4ba176777395fc5c4cb33e4"
|
|
7806
7806
|
},
|
|
7807
7807
|
{
|
|
7808
7808
|
"path": "package.json",
|
|
7809
7809
|
"mode": "0000644",
|
|
7810
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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 };
|
package/scripts/gds/capture.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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.
|
|
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(``);
|
|
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 = ` and again `;
|
|
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: `` },
|
|
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: `` }];
|
|
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: `` },
|
|
89
|
+
{ id: '1003718', title: 're-file', description: `` },
|
|
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
|
+
});
|
package/tests/cli_surface.mjs
CHANGED
|
@@ -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: {
|
|
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 =
|
|
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);
|