@bongos/core 1.19.968 → 1.19.969

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.968",
6
- "core_contract": "1.19.968",
7
- "source_commit": "8ccf04d976e2b856bc48cbfc6ad7d6563522bc51",
5
+ "core_version": "1.19.969",
6
+ "core_contract": "1.19.969",
7
+ "source_commit": "55409bed0b968ea075ebd993255b3e71131ad01e",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-22T23:25:06.335Z",
9
+ "built_at": "2026-09-22T23:40:21.961Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 515,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2365,
14
+ "functional_verbatim": 2369,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2906,
20
- "tree_sha256": "4ccb083b8cb616dd5c4c9c49f498b566996a1ead6ea244f46e5344d7a726a322",
19
+ "file_count": 2910,
20
+ "tree_sha256": "46d085a64c600456dab078e43c39f7b0bdff7a4d90ca202ebdfff0bb0cd98581",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -57,7 +57,7 @@
57
57
  {
58
58
  "path": ".claude/skills/builder-cost/SKILL.md",
59
59
  "mode": "0000644",
60
- "sha256": "d649e2e39359b11dbe156cfd35d6895d1283c0b56feba0ca96f6eff4f56f923a"
60
+ "sha256": "0f9b196e81b99a2313f6e1a0c0f091caaaf4b11b0d669076509ca60e3d10434a"
61
61
  },
62
62
  {
63
63
  "path": ".claude/skills/builder-end/SKILL.md",
@@ -2697,7 +2697,7 @@
2697
2697
  {
2698
2698
  "path": "docs/module-api-changelog.md",
2699
2699
  "mode": "0000644",
2700
- "sha256": "aa4b0d91bfda4df6b776aa7ea600750b3f24be1e0ffee255ceb170184df90636"
2700
+ "sha256": "8874af5deda6102925f3caea6c829bc6c637ec707eb7b6cf8ef67fa79b0919a7"
2701
2701
  },
2702
2702
  {
2703
2703
  "path": "docs/modules-contract.md",
@@ -2892,7 +2892,7 @@
2892
2892
  {
2893
2893
  "path": "docs/recipes/parallel-goal-waves.md",
2894
2894
  "mode": "0000644",
2895
- "sha256": "0ce5b2395e8e4a78a58af68e45cc25d1ca10a5ddc6cc349e2d2b300a85a83a68"
2895
+ "sha256": "4879badb2ab0678098d3f5cfa1002a6b4b50e8a691a5d4afc17b533803fbe262"
2896
2896
  },
2897
2897
  {
2898
2898
  "path": "docs/recipes/private-npm-distribution.md",
@@ -4244,15 +4244,20 @@
4244
4244
  "mode": "0000644",
4245
4245
  "sha256": "fefd4908bd22aa3b747a7c8b4146e5a7e53731fe92612db105ea3512ae34300c"
4246
4246
  },
4247
+ {
4248
+ "path": "modules/economy/cost-report.js",
4249
+ "mode": "0000644",
4250
+ "sha256": "484448390d675c3884cbee5d15405e1e7167bbc111f8461e06890750b7f68be4"
4251
+ },
4247
4252
  {
4248
4253
  "path": "modules/economy/cost-summary-shape.js",
4249
4254
  "mode": "0000644",
4250
- "sha256": "4aedb7a2d412be5ead4e6e59bd993d650078299bd4625a4de043bdd0201bc28b"
4255
+ "sha256": "449251df02a484bc865c3844d0a85beffe5e3be920db0f09833f962df3e8b3d4"
4251
4256
  },
4252
4257
  {
4253
4258
  "path": "modules/economy/cost.js",
4254
4259
  "mode": "0000644",
4255
- "sha256": "6cc28fe57bb50b6c3f4b7d4485838427fa5f12b5a79848f0358fdaa2874f6c1e"
4260
+ "sha256": "ce3ce114f5315c78a2feb810bce09cde84941765629f0e5a10a44aced2c34210"
4256
4261
  },
4257
4262
  {
4258
4263
  "path": "modules/economy/credits.js",
@@ -8102,12 +8107,12 @@
8102
8107
  {
8103
8108
  "path": "package-lock.json",
8104
8109
  "mode": "0000644",
8105
- "sha256": "a4f60be63bc56da8ba3332e604d1fbb0b4b3288f9b3cf14a52d05227e040a3f4"
8110
+ "sha256": "ede312c963ab08f5b2fec23b90c3c5fc829149893c08e83196be37b2a3ef11b5"
8106
8111
  },
8107
8112
  {
8108
8113
  "path": "package.json",
8109
8114
  "mode": "0000644",
8110
- "sha256": "4d9fb5822b9ba10648ba9d9e0c2bd7c5659215e65bb8395455913b7b14c43998"
8115
+ "sha256": "40a32e40062ce4becee8999e5b2cdb5dd54d5a0ad65885341afcf3651e002d55"
8111
8116
  },
8112
8117
  {
8113
8118
  "path": "public-docs/index.html",
@@ -8509,6 +8514,11 @@
8509
8514
  "mode": "0000644",
8510
8515
  "sha256": "55ac5440a1c5481766198d6a89cef601fde816024b592b1e77f535c465a2df47"
8511
8516
  },
8517
+ {
8518
+ "path": "scripts/gds/cost-report.js",
8519
+ "mode": "0000644",
8520
+ "sha256": "988d9e5dec9ed4bd35a3aea25fc841a832a2a251ee6eefa7fb0d86f7ee5b7b2e"
8521
+ },
8512
8522
  {
8513
8523
  "path": "scripts/gds/cost.js",
8514
8524
  "mode": "0000644",
@@ -8607,7 +8617,7 @@
8607
8617
  {
8608
8618
  "path": "scripts/gds/doctor.js",
8609
8619
  "mode": "0000644",
8610
- "sha256": "f690a26bac623e67d4d4a8d889c5f69b05a8afb98eda13309e73b45c9f5fbbaa"
8620
+ "sha256": "be0f4da3a9885bdc555f720d853923b226ae993a6a965a934c728032a0575988"
8611
8621
  },
8612
8622
  {
8613
8623
  "path": "scripts/gds/enroll-control-plane.js",
@@ -8832,7 +8842,7 @@
8832
8842
  {
8833
8843
  "path": "scripts/gds/install-git-hooks.js",
8834
8844
  "mode": "0000644",
8835
- "sha256": "668d97543e863394527bad2a629037bfc81eea2f8244f53eb5e79fd43f7485c0"
8845
+ "sha256": "6234ca799f99e5a16622043b0f43d42624efe5fb6862d74e4b8c2b7603cf8bef"
8836
8846
  },
8837
8847
  {
8838
8848
  "path": "scripts/gds/instance-apex.js",
@@ -9237,7 +9247,7 @@
9237
9247
  {
9238
9248
  "path": "scripts/gds/search-ingest-md.js",
9239
9249
  "mode": "0000644",
9240
- "sha256": "802d16a48f6a47bf41b1768fff3da0ae1b18a4d73147b3559a781f18ea47c75c"
9250
+ "sha256": "7638bd1d2160ef1aa5b386dd555084cb33e1af02bcd9e2f55967cb32a8e0bba5"
9241
9251
  },
9242
9252
  {
9243
9253
  "path": "scripts/gds/security-baseline-check.sh",
@@ -10112,7 +10122,7 @@
10112
10122
  {
10113
10123
  "path": "src/module-api.js",
10114
10124
  "mode": "0000644",
10115
- "sha256": "4f5478f0a18dbb167df7e86861ae757cceaf084a43436461af1fca4855256505"
10125
+ "sha256": "37d85c40aec959cbe0091ef9f1c2d14831fcc8271dcaf91a8d42c5d879a80878"
10116
10126
  },
10117
10127
  {
10118
10128
  "path": "src/module-loader/catalog.js",
@@ -11049,10 +11059,15 @@
11049
11059
  "mode": "0000644",
11050
11060
  "sha256": "8356250d8fc753afb2960d867f81586531053afd15f4e66da1a1b7b62b6b5db7"
11051
11061
  },
11062
+ {
11063
+ "path": "tests/cost_report.mjs",
11064
+ "mode": "0000644",
11065
+ "sha256": "cb69ea9b53e37f8590bb1cd2e594df1a63fa07f452ad7b0bf7a607507f8e2c82"
11066
+ },
11052
11067
  {
11053
11068
  "path": "tests/cost_summary_shape.mjs",
11054
11069
  "mode": "0000644",
11055
- "sha256": "397619dac27a319b1c4c2ef2ca5b92b02815429825e983dbba68be27fb4701d5"
11070
+ "sha256": "50f195ef2060df92016b58f541a28e7492dd95ad4da8bd0781bdc07007193338"
11056
11071
  },
11057
11072
  {
11058
11073
  "path": "tests/credit_grant.mjs",
@@ -12624,6 +12639,11 @@
12624
12639
  "mode": "0000644",
12625
12640
  "sha256": "d05b6e354ac08cf5512a62b76b213df44ab1ef8639a1ba635319e9b5e8511a5c"
12626
12641
  },
12642
+ {
12643
+ "path": "tests/merge_driver_register.mjs",
12644
+ "mode": "0000644",
12645
+ "sha256": "c75880064b52b6edb2689232eb1e6401567618f46b16581140685ccceee2d41c"
12646
+ },
12627
12647
  {
12628
12648
  "path": "tests/merge_lease.mjs",
12629
12649
  "mode": "0000644",
@@ -13652,7 +13672,7 @@
13652
13672
  {
13653
13673
  "path": "tests/search_ingest.mjs",
13654
13674
  "mode": "0000644",
13655
- "sha256": "8d685a46ab9f4cf9aaa71b9ee78774d95c16daa27c15a229b8628a97688b9a55"
13675
+ "sha256": "dd92de1e35d82fc4ea82c2f5cb99b506153e5386e4668846cba87e0f8d135d97"
13656
13676
  },
13657
13677
  {
13658
13678
  "path": "tests/search_ingest_db.mjs",
@@ -42,7 +42,11 @@ The CLI hits `POST /api/bongos/cost`, which inserts a `cost_log` row.
42
42
  - **Don't double-count.** Before logging an art-pipeline cost manually, verify it isn't already in the DB from the one-time cost-ledger backfill (those rows use `source: 'art-cost-ledger'`).
43
43
  - **Don't log without a description for `other` category.**
44
44
 
45
+ ## Reading the ledger back
46
+
47
+ To see what has been spent rather than add to it, run `node scripts/gds/cost-report.js` — total spend plus totals by month, category and source. The endpoint it reads is public, so no rank is needed, but the session still supplies the instance URL. `--source X`, `--category Y` and `--days N` scope the breakdowns (the header total stays project-wide); `--json` prints the raw payload. The newest month is labelled partial — it is spend to date, not a month's worth.
48
+
45
49
  ## Files this skill touches
46
50
 
47
51
  - Reads: `~/.config/otb/gds-session.json`
48
- - Calls: `POST /api/bongos/cost`
52
+ - Calls: `POST /api/bongos/cost` (logging), `GET /public/cost-summary` (the read-out, via `scripts/gds/cost-report.js`)
@@ -2410,5 +2410,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2410
2410
  landed since 1.19.966 with no explicit bump. run 35795807836. (task 1002620)
2411
2411
  1.19.968 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2412
2412
  landed since 1.19.967 with no explicit bump. run 35797263467. (task 1002620)
2413
+ 1.19.969 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2414
+ landed since 1.19.968 with no explicit bump. run 35798488315. (task 1002620)
2413
2415
  ---------------------------------------------------------------------------
2414
2416
  ```
@@ -105,6 +105,11 @@ One file will become the contention point. Here it was the module's nested `CLAU
105
105
  **Generated files: always resolve by regenerating, never by picking a side.**
106
106
 
107
107
  ```bash
108
+ # --theirs only CLEARS THE CONFLICT MARKERS so git will accept the path as resolved
109
+ # (git add refuses a still-conflicted path). It is not a choice of content: the
110
+ # generator on the next line overwrites the file from source, so whichever side you
111
+ # took is discarded. --ours works identically here. This is the one place the "never
112
+ # pick a side" rule above does not apply, because nothing is being picked.
108
113
  git checkout --theirs <file>
109
114
  node scripts/gds/gen-repo-map.js # or gen-api-docs.js, gen-diagrams.js
110
115
  git add <file> && git commit --no-edit
@@ -112,6 +117,8 @@ git add <file> && git commit --no-edit
112
117
 
113
118
  Picking a side leaves the artefact inconsistent with source, and the freshness fitness check fails on the *next* task instead of yours. The generated symbol blocks and the OpenAPI spec both bit us this way.
114
119
 
120
+ **If the generator is not the last thing you run, you have picked a side.** That is the test to apply, not the flag you typed.
121
+
115
122
  > If `ship.js` says *"auto-merge: bailed; task stays at confirmed"*, don't reach for `/merge-mode` first. Merge `origin/main` into the **task's own branch**, resolve there, re-run `fitness.js`, then re-run `ship.js <id>` — it resumes from `confirmed`. `/merge-mode` is an operator procedure that mutates the main worktree and assumes a bash/SSH environment.
116
123
 
117
124
  ---
@@ -0,0 +1,66 @@
1
+ // modules/economy/cost-report.js — the spend read-out's text rendering (task
2
+ // 1001621: "what did this cost, by month, category and source", the figures
3
+ // hosting ROI is sized against). Pure (no DB, no I/O) so the CLI
4
+ // (scripts/gds/cost-report.js) stays a thin fetch-and-print shell and the
5
+ // wording is unit-testable DB-free (tests/cost_report.mjs). Reads a
6
+ // db.costSummary() payload / the GET /public/cost-summary response as-is.
7
+
8
+ function usd(n) {
9
+ return `$${(Number(n) || 0).toFixed(2)}`;
10
+ }
11
+
12
+ // A dimension the server did not send is NOT the same as a dimension with no
13
+ // rows: the CLI reads a LIVE instance, which may predate the key. Say which,
14
+ // so an old server never reads as "nothing was spent".
15
+ function section(label, rows, render) {
16
+ if (!Array.isArray(rows)) return [` ${label}: unavailable (this server predates the rollup)`];
17
+ if (!rows.length) return [` ${label}: no spend recorded`];
18
+ return [` ${label}:`, ...rows.map((r) => ` - ${render(r)}`)];
19
+ }
20
+
21
+ // The newest month is spend-to-date, not a month's worth, so an unlabelled row
22
+ // reads as a collapse in spend at the top of every month — in the one figure a
23
+ // hosting bill is sized against. Compared in UTC: a boundary mismatch with the
24
+ // server's timezone only drops the label for a few hours, which under-claims
25
+ // rather than calling a partial month complete.
26
+ function monthLabel(row, now) {
27
+ const current = now.toISOString().slice(0, 7);
28
+ const partial = row.month === current ? ' (partial — month to date)' : '';
29
+ return `${row.month}: ${usd(row.total_usd)}${partial}`;
30
+ }
31
+
32
+ function filterLabel(filters) {
33
+ const f = filters || {};
34
+ const parts = [];
35
+ if (f.source) parts.push(`source=${f.source}`);
36
+ if (f.category) parts.push(`category=${f.category}`);
37
+ if (f.days) parts.push(`last ${f.days} days`);
38
+ return parts.join(' · ');
39
+ }
40
+
41
+ /**
42
+ * Render a spend rollup — total, then totals by month, category and source —
43
+ * from a cost-summary payload, for the `cost-report` CLI.
44
+ * @param {object} summary - db.costSummary() / GET /public/cost-summary.
45
+ * @param {object} [opts] - { now } — the clock, injectable so the partial-month
46
+ * label is testable.
47
+ * @returns {string} the printable report.
48
+ */
49
+ function formatCostReport(summary, { now = new Date() } = {}) {
50
+ const s = summary || {};
51
+ const lines = ['Spend rollup — cost_log'];
52
+ // The header total stays project-wide whatever the filter: costSummary scopes
53
+ // only the breakdowns, so labelling it is what keeps the two figures honest.
54
+ lines.push(` total spend: ${usd(s.total_usd)} (project-wide, all time)`);
55
+ const label = filterLabel(s.applied_filters);
56
+ if (label) {
57
+ lines.push(` filter: ${label}`);
58
+ lines.push(` under filter: ${usd(s.filtered_total_usd)}`);
59
+ }
60
+ lines.push(...section('by month', s.by_month, (r) => monthLabel(r, now)));
61
+ lines.push(...section('by category', s.by_category, (r) => `${r.category}: ${usd(r.total_usd)}`));
62
+ lines.push(...section('by source', s.by_source, (r) => `${r.source}: ${usd(r.total_usd)}`));
63
+ return lines.join('\n');
64
+ }
65
+
66
+ module.exports = { formatCostReport };
@@ -87,4 +87,16 @@ function shapeCostCalibration(rows) {
87
87
  };
88
88
  }
89
89
 
90
- module.exports = { shapeBySource, shapeByTier, shapeSavings, shapeCostCalibration };
90
+ // by_month: cost_log rows grouped by calendar month (task 1001621), the axis the
91
+ // daily series cannot answer — "what does a month of this cost" is the figure
92
+ // hosting ROI is sized against. The month label is formatted in SQL (to_char),
93
+ // not here, so a driver Date + a UTC-shifted server can never move a row into
94
+ // the neighbouring month. Rows arrive pre-sorted oldest-first by SQL.
95
+ function shapeByMonth(rows) {
96
+ return (rows || []).map((r) => ({
97
+ month: String(r.month),
98
+ total_usd: round2(r.total_usd),
99
+ }));
100
+ }
101
+
102
+ module.exports = { shapeBySource, shapeByTier, shapeSavings, shapeCostCalibration, shapeByMonth };
@@ -327,6 +327,7 @@ async function costSummary(filters = {}) {
327
327
  { rows: filteredTotalRows },
328
328
  { rows: sourceOptRows },
329
329
  { rows: categoryOptRows },
330
+ { rows: byMonthRows },
330
331
  ] = await Promise.all([
331
332
  pool.query(`SELECT COALESCE(SUM(amount_usd), 0) AS total_usd FROM cost_log`),
332
333
  pool.query(
@@ -423,6 +424,16 @@ async function costSummary(filters = {}) {
423
424
  pool.query(
424
425
  `SELECT DISTINCT category FROM cost_log WHERE category IS NOT NULL ORDER BY category`
425
426
  ),
427
+ // task 1001621: by_month — the axis `daily` cannot answer. A month of spend
428
+ // is what a hosting bill is compared against, and the day series is
429
+ // deliberately project-wide, so it cannot be re-bucketed per slice. This one
430
+ // honors the same filters as the other breakdowns. to_char formats the label
431
+ // in SQL so no driver Date/timezone can shift a row into the next month.
432
+ pool.query(
433
+ `SELECT to_char(recorded_at, 'YYYY-MM') AS month, COALESCE(SUM(amount_usd), 0) AS total_usd
434
+ FROM cost_log ${whereCl} GROUP BY month ORDER BY month`,
435
+ params
436
+ ),
426
437
  ]);
427
438
 
428
439
  // savings — the counterfactual ledger (cost_baseline, migration 124). Guarded:
@@ -474,6 +485,9 @@ async function costSummary(filters = {}) {
474
485
  daily_credits: valueHistory.map((r) => ({ day: r.day, credits: Number(r.credits) })),
475
486
  // System 1 / task 1345 — additive dimensions (existing keys keep shape).
476
487
  by_source: costSummaryShape.shapeBySource(bySourceRows),
488
+ // task 1001621 — spend per calendar month, under the same filters as the
489
+ // other breakdowns. Additive; existing keys keep their shape.
490
+ by_month: costSummaryShape.shapeByMonth(byMonthRows),
477
491
  by_tier: costSummaryShape.shapeByTier(byTierRows[0]),
478
492
  savings: costSummaryShape.shapeSavings(savingsRows),
479
493
  // task 572 / spend-audit F6 — cost-per-task & cost-per-credit medians.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.968",
3
+ "version": "1.19.969",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.968",
9
+ "version": "1.19.969",
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.968",
3
+ "version": "1.19.969",
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,63 @@
1
+ #!/usr/bin/env node
2
+ // Read the spend rollup: total spend, and totals by month, category and source.
3
+ //
4
+ // The read half of cost.js, which only LOGS spend — task 1001621 (ADR 0092
5
+ // finding): nothing rolled it up where a person could size hosting against real
6
+ // numbers. The endpoint it reads is public, so no RANK is needed — but the base
7
+ // URL still comes from your CLI session (or <PREFIX>_API_BASE), which is what
8
+ // says WHICH instance to ask.
9
+ //
10
+ // Usage:
11
+ // node scripts/gds/cost-report.js [--source X] [--category Y] [--days N] [--json]
12
+ //
13
+ // The filters scope the BREAKDOWNS only — the header total stays project-wide,
14
+ // which is how the server's treasury filter already behaves.
15
+
16
+ const { cliClient, arg, readSessionToken } = require('./cli-lib');
17
+ // A CLI may require a module directly (the backfill-cost.js / cost-classify
18
+ // precedent, BV1.R77 / ADR 0093 §1-2). The rendering is pure and lives there so
19
+ // it is testable without a server (tests/cost_report.mjs).
20
+ const { formatCostReport } = require('../../modules/economy/cost-report');
21
+
22
+ // arg() hands back whatever follows the flag, so `--source --json` would filter
23
+ // on the literal '--json' and silently report $0 — a wrong answer, not an error.
24
+ function value(name, args) {
25
+ const v = arg(name, args);
26
+ if (v == null) return null;
27
+ if (String(v).startsWith('--')) {
28
+ console.error(`${name} needs a value (got '${v}')`);
29
+ process.exit(2);
30
+ }
31
+ return v;
32
+ }
33
+
34
+ (async () => {
35
+ const args = process.argv.slice(2);
36
+ const query = {};
37
+ const source = value('--source', args);
38
+ const category = value('--category', args);
39
+ const days = value('--days', args);
40
+ if (source) query.source = source;
41
+ if (category) query.category = category;
42
+ if (days) query.days = days;
43
+
44
+ const api = await cliClient();
45
+ const r = await api.public.getPublicCostSummary({ query });
46
+ if (!r.ok) {
47
+ console.error(`cost report failed (HTTP ${r.status}):`, r.data);
48
+ process.exit(1);
49
+ }
50
+ console.log(args.includes('--json') ? JSON.stringify(r.data, null, 2) : formatCostReport(r.data));
51
+ })().catch((err) => {
52
+ // A session-less or offline run lands here, and a raw fetch stack says nothing
53
+ // about WHICH instance was tried. readSessionToken() returns null when not
54
+ // signed in, which is itself the answer in that case.
55
+ const session = readSessionToken();
56
+ const msg = (err && err.message) || String(err);
57
+ console.error(`cost-report failed (instance: ${session ? session.apiBase : 'no session — falling back to this checkout\'s default'}).`);
58
+ console.error(` cause: ${msg}`);
59
+ if (/fetch failed|ECONNREFUSED|ENOTFOUND|EAI_AGAIN/.test(msg)) {
60
+ console.error(' The instance could not be reached. Sign in with `node scripts/gds/setup.js`, or name one with <PREFIX>_API_BASE.');
61
+ }
62
+ process.exit(1);
63
+ });
@@ -28,6 +28,7 @@ const path = require('node:path');
28
28
  const os = require('node:os');
29
29
  const { execSync, spawnSync } = require('node:child_process');
30
30
  const { cliClient, SESSION_PATH, loadSession } = require('./cli-lib');
31
+ const { classifyMergeDriverState, MERGE_DRIVER_NAME } = require('./install-git-hooks.js');
31
32
  const { preflightChecks } = require('./preflight');
32
33
 
33
34
  // ANSI helpers (only if stdout is a TTY; otherwise emit plain text)
@@ -253,6 +254,20 @@ function classifyPreCommitState({ preCommitPath, present, executable }) {
253
254
 
254
255
  function checkGitPlumbing() {
255
256
  section('Git plumbing');
257
+
258
+ // The merge-driver section is reported FIRST and unconditionally — it is checked
259
+ // even when core.hooksPath is unset, because the two are independent and the
260
+ // half-written-section wedge (task 1003846) is silent until a merge dies with
261
+ // `fatal: custom merge driver ... lacks command line`. A builder who hits that has
262
+ // no reason to connect it to a setup step that ran days ago, so doctor naming it
263
+ // is the whole point. Early-returning before this line would hide it on exactly
264
+ // the clones most likely to be mis-set-up.
265
+ const md = classifyMergeDriverState({
266
+ name: gitOut(`git config --get merge.${MERGE_DRIVER_NAME}.name`),
267
+ driver: gitOut(`git config --get merge.${MERGE_DRIVER_NAME}.driver`),
268
+ });
269
+ ({ ok, warn, fail }[md.status])('git', md.message);
270
+
256
271
  const verdict = classifyHooksState({
257
272
  hooksPath: gitOut('git config --get core.hooksPath'),
258
273
  worktreeRoot: gitOut('git rev-parse --show-toplevel'),
@@ -438,7 +453,7 @@ function checkRunnerDrift() {
438
453
  // --------------------------------------------------------------------------
439
454
  // Guarded so requiring this file for its pure helpers (tests/doctor_hooks.mjs)
440
455
  // does not run the checks, which shell out to git and hit the network.
441
- module.exports = { classifyHooksState, classifyPreCommitState, runnerDriftRows };
456
+ module.exports = { classifyHooksState, classifyMergeDriverState, classifyPreCommitState, runnerDriftRows };
442
457
 
443
458
  if (require.main === module) (async () => {
444
459
  console.log(`${C.bold}GDS Doctor${C.reset} — local-machine health check`);
@@ -30,21 +30,88 @@ function git(args) {
30
30
  // the generated files (docs/repo-map.md + the nested CLAUDE.md blocks).
31
31
  const MERGE_DRIVER_NAME = 'otb-regen';
32
32
 
33
- function registerMergeDriver(root) {
33
+ // ORDER MATTERS, and the wrong order is worse than not registering at all (task 1003846).
34
+ //
35
+ // These are two git-config writes, not one atomic operation. If the first succeeds and
36
+ // the second throws, the half-written section SURVIVES — and git does not treat a named
37
+ // driver with no command as "fall back to a text merge". It treats it as fatal:
38
+ //
39
+ // fatal: custom merge driver otb-regen lacks command line.
40
+ //
41
+ // exit 128, no unmerged paths, nothing that looks like a merge conflict. Every merge
42
+ // touching any `merge=otb-regen` path then dies — which is now the root CLAUDE.md, the
43
+ // file/session-log maps, two nested CLAUDE.md files, the copy-registry pair and the nine
44
+ // generated API artifacts. So the failure mode of the old order was "no merge in this
45
+ // clone can complete", where not registering at all merely yields an ordinary conflict.
46
+ //
47
+ // Both halves of the fix are verified, not assumed (see tests/merge_driver_register.mjs):
48
+ // - `.driver` alone IS a valid section — git runs it and the merge succeeds.
49
+ // - `.name` alone is the fatal state above.
50
+ // So writing `.driver` FIRST makes the intermediate state safe, and removing the whole
51
+ // section on failure makes even a mid-write abort leave no trace.
52
+ function registerMergeDriver(root, { run = execFileSync } = {}) {
34
53
  // execFileSync (arg array, no shell) so the literal %O/%A/%B/%P placeholders and
35
54
  // the spaces in the command survive verbatim on every platform (a shell would
36
55
  // mangle %VARS on Windows cmd).
37
56
  try {
38
- execFileSync('git', ['config', `merge.${MERGE_DRIVER_NAME}.name`,
39
- `${require('./cli-lib').productName()} generated-file regenerator (task 1243)`], { cwd: root, stdio: 'ignore' });
40
- execFileSync('git', ['config', `merge.${MERGE_DRIVER_NAME}.driver`,
57
+ // .driver FIRST — a section carrying only .driver is valid and works.
58
+ run('git', ['config', `merge.${MERGE_DRIVER_NAME}.driver`,
41
59
  'node scripts/gds/git-merge-regen.js %O %A %B %P'], { cwd: root, stdio: 'ignore' });
60
+ // .name SECOND — cosmetic (it is what `git config --list` shows); if this throws,
61
+ // the section left behind is the safe driver-only one.
62
+ run('git', ['config', `merge.${MERGE_DRIVER_NAME}.name`,
63
+ `${require('./cli-lib').productName()} generated-file regenerator (task 1243)`], { cwd: root, stdio: 'ignore' });
42
64
  return { ok: true };
43
65
  } catch (e) {
66
+ // Belt and braces: leave NO trace rather than a possibly-partial section. A missing
67
+ // section is a normal textual conflict; a partial one can be the exit-128 wedge.
68
+ // This removal is itself best-effort — if it throws (e.g. no section to remove, which
69
+ // git reports as an error) the original failure is still what we report.
70
+ try {
71
+ run('git', ['config', '--remove-section', `merge.${MERGE_DRIVER_NAME}`], { cwd: root, stdio: 'ignore' });
72
+ } catch (_) { /* nothing to clean up, or we cannot — report the original failure */ }
44
73
  return { ok: false, error: e.message };
45
74
  }
46
75
  }
47
76
 
77
+ // The doctor-facing verdict for an already-registered driver, pure so it is testable
78
+ // without a git repo (the classifyHooksState precedent in doctor.js).
79
+ //
80
+ // The wedge state is INVISIBLE until the next merge dies, which is exactly why doctor
81
+ // should say so: a builder who hits `fatal: custom merge driver ... lacks command line`
82
+ // has no reason to connect it to a setup step that ran days earlier.
83
+ function classifyMergeDriverState({ name, driver }) {
84
+ const hasName = Boolean(name && String(name).trim());
85
+ const hasDriver = Boolean(driver && String(driver).trim());
86
+ const installer = 'node scripts/gds/install-git-hooks.js';
87
+
88
+ if (hasDriver) {
89
+ return {
90
+ status: 'ok',
91
+ code: 'registered',
92
+ message: `merge driver '${MERGE_DRIVER_NAME}' registered — generated-file conflicts auto-regenerate on merge.`,
93
+ };
94
+ }
95
+ if (hasName) {
96
+ return {
97
+ status: 'fail',
98
+ code: 'name-without-driver',
99
+ message:
100
+ `merge.${MERGE_DRIVER_NAME}.name is set but .driver is MISSING. Git treats this as fatal, not as a `
101
+ + `fallback: every merge touching a \`merge=${MERGE_DRIVER_NAME}\` path will die with `
102
+ + `"fatal: custom merge driver ${MERGE_DRIVER_NAME} lacks command line" (exit 128, no conflict shown). `
103
+ + `Repair with: ${installer} — or clear it with: git config --remove-section merge.${MERGE_DRIVER_NAME}`,
104
+ };
105
+ }
106
+ return {
107
+ status: 'warn',
108
+ code: 'unregistered',
109
+ message:
110
+ `merge driver '${MERGE_DRIVER_NAME}' is not registered, so generated-file merges will raise ordinary `
111
+ + `text conflicts instead of auto-regenerating (ship.js still self-heals). Register with: ${installer}`,
112
+ };
113
+ }
114
+
48
115
  // Resolve the configured hooks dir (absolute) for reporting / verification.
49
116
  function configuredHooksDir() {
50
117
  try {
@@ -114,4 +181,4 @@ if (require.main === module) {
114
181
  }
115
182
  }
116
183
 
117
- module.exports = { activate, configuredHooksDir, registerMergeDriver, MERGE_DRIVER_NAME };
184
+ module.exports = { activate, classifyMergeDriverState, configuredHooksDir, registerMergeDriver, MERGE_DRIVER_NAME };
@@ -48,7 +48,7 @@ const SENSITIVE_DOC_GLOBS = [
48
48
  'docs/canonical-permissions.md', // the rank-ladder / authority reference
49
49
  'docs/security/', // threat models + red-team reports
50
50
  'docs/audits/', // security + architecture audit findings
51
- 'docs/governance/', // the gate ENUMERATION + choke-point map — task 1002598
51
+ 'docs/governance/', // the gate ENUMERATION + choke-point map
52
52
  'docs/adr/0016-trust-boundary-server-enforced-permissions.md',
53
53
  'docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md',
54
54
  ];
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.968'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.969'; // 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,83 @@
1
+ // tests/cost_report.mjs — the spend read-out's text rendering (task 1001621).
2
+ // DB-free. Run: node tests/cost_report.mjs
3
+
4
+ import { strict as assert } from 'node:assert';
5
+ import { createRequire } from 'node:module';
6
+ const require = createRequire(import.meta.url);
7
+ const { formatCostReport } = require('../modules/economy/cost-report.js');
8
+
9
+ let passed = 0, failed = 0;
10
+ function test(name, fn) {
11
+ try { fn(); console.log(` ok ${name}`); passed++; }
12
+ catch (err) { console.error(` FAIL ${name}`); console.error(' ', err.message); failed++; }
13
+ }
14
+
15
+ const FULL = {
16
+ total_usd: 123.45,
17
+ filtered_total_usd: 123.45,
18
+ applied_filters: { source: null, category: null, days: null },
19
+ by_month: [
20
+ { month: '2026-06', total_usd: 100 },
21
+ { month: '2026-07', total_usd: 23.45 },
22
+ ],
23
+ by_category: [{ category: 'api', total_usd: 120 }, { category: 'infra', total_usd: 3.45 }],
24
+ by_source: [{ source: 'session:interactive', total_usd: 123.45 }],
25
+ };
26
+
27
+ test('renders the three dimensions the task asks for: month, category, source', () => {
28
+ const out = formatCostReport(FULL);
29
+ assert.match(out, /total spend:\s+\$123\.45/);
30
+ assert.match(out, /2026-06: \$100\.00/);
31
+ assert.match(out, /2026-07: \$23\.45/);
32
+ assert.match(out, /api: \$120\.00/);
33
+ assert.match(out, /session:interactive: \$123\.45/);
34
+ });
35
+
36
+ test('no filter applied → no filter line and no filtered subtotal', () => {
37
+ const out = formatCostReport(FULL);
38
+ assert.doesNotMatch(out, /filter:/);
39
+ assert.doesNotMatch(out, /under filter:/);
40
+ });
41
+
42
+ test('a filter applied → the filter is named and the subtotal is separate from the total', () => {
43
+ const out = formatCostReport({
44
+ ...FULL,
45
+ filtered_total_usd: 23.45,
46
+ applied_filters: { source: 'grader', category: 'api', days: 30 },
47
+ });
48
+ assert.match(out, /filter:.*source=grader/);
49
+ assert.match(out, /filter:.*category=api/);
50
+ assert.match(out, /filter:.*last 30 days/);
51
+ assert.match(out, /under filter:\s+\$23\.45/);
52
+ // the header total stays project-wide — costSummary never rescopes it
53
+ assert.match(out, /total spend:\s+\$123\.45/);
54
+ });
55
+
56
+ test('a server predating by_month says so rather than reading as zero spend', () => {
57
+ const { by_month, ...older } = FULL;
58
+ const out = formatCostReport(older);
59
+ assert.match(out, /by month:\s+unavailable/);
60
+ assert.doesNotMatch(out, /2026-06/);
61
+ });
62
+
63
+ test('the current month is labelled partial — it is spend to date, not a month', () => {
64
+ const out = formatCostReport(FULL, { now: new Date('2026-07-15T00:00:00Z') });
65
+ assert.match(out, /2026-07: \$23\.45\s+\(partial — month to date\)/);
66
+ // a month that has closed carries no such caveat
67
+ assert.match(out, /2026-06: \$100\.00\n/);
68
+ });
69
+
70
+ test('no row is labelled partial once the newest month has closed', () => {
71
+ const out = formatCostReport(FULL, { now: new Date('2026-09-22T00:00:00Z') });
72
+ assert.doesNotMatch(out, /partial/);
73
+ });
74
+
75
+ test('an empty by_month is reported as no spend, not as unavailable', () => {
76
+ const out = formatCostReport({ ...FULL, by_month: [] });
77
+ assert.match(out, /by month:/);
78
+ assert.doesNotMatch(out, /unavailable/);
79
+ assert.match(out, /no spend recorded/);
80
+ });
81
+
82
+ console.log(`\ncost_report: ${passed} passed, ${failed} failed`);
83
+ process.exit(failed ? 1 : 0);
@@ -4,7 +4,7 @@
4
4
  import { strict as assert } from 'node:assert';
5
5
  import { createRequire } from 'node:module';
6
6
  const require = createRequire(import.meta.url);
7
- const { shapeBySource, shapeByTier, shapeSavings, shapeCostCalibration } = require('../modules/economy/cost-summary-shape.js');
7
+ const { shapeBySource, shapeByTier, shapeSavings, shapeCostCalibration, shapeByMonth } = require('../modules/economy/cost-summary-shape.js');
8
8
 
9
9
  let passed = 0, failed = 0;
10
10
  function test(name, fn) {
@@ -90,5 +90,21 @@ test('shapeCostCalibration empty/undefined → empty by_kind, null overall', ()
90
90
  assert.deepEqual(shapeCostCalibration(undefined), { by_kind: [], overall: null });
91
91
  });
92
92
 
93
+ test('shapeByMonth coerces numbers and preserves the SQL month ordering (task 1001621)', () => {
94
+ const out = shapeByMonth([
95
+ { month: '2026-06', total_usd: '100.005' },
96
+ { month: '2026-07', total_usd: 23.45 },
97
+ ]);
98
+ assert.deepEqual(out, [
99
+ { month: '2026-06', total_usd: 100.01 }, // round2
100
+ { month: '2026-07', total_usd: 23.45 },
101
+ ]);
102
+ });
103
+
104
+ test('shapeByMonth handles empty/undefined input', () => {
105
+ assert.deepEqual(shapeByMonth([]), []);
106
+ assert.deepEqual(shapeByMonth(undefined), []);
107
+ });
108
+
93
109
  console.log(`\ncost_summary_shape: ${passed} passed, ${failed} failed`);
94
110
  process.exit(failed ? 1 : 0);
@@ -0,0 +1,150 @@
1
+ // tests/merge_driver_register.mjs
2
+ //
3
+ // Task 1003846 — registerMergeDriver wrote two git-config keys in sequence inside one
4
+ // try, `.name` first. If the first succeeded and the second threw, the half-written
5
+ // section SURVIVED, and git does not treat a named driver with no command as "fall back
6
+ // to a text merge" — it treats it as fatal:
7
+ //
8
+ // fatal: custom merge driver otb-regen lacks command line.
9
+ //
10
+ // exit 128, no unmerged paths. Every merge touching any `merge=otb-regen` path then dies
11
+ // — the root CLAUDE.md, the file/session-log maps, two nested CLAUDE.md files, the
12
+ // copy-registry pair and the nine generated API artifacts. So the partial-write state was
13
+ // strictly WORSE than not registering at all, which merely yields an ordinary conflict.
14
+ //
15
+ // This file pins BOTH halves of the fix, and pins them against real git rather than a
16
+ // mock, because the whole finding rests on how git itself reads a partial section — a
17
+ // mock would only assert what we already believe. The first two tests would have passed
18
+ // on the buggy code too if they were written against a stub; they are written against a
19
+ // throwaway repo on disk precisely so they cannot.
20
+ //
21
+ // Run: node --test tests/merge_driver_register.mjs
22
+
23
+ import { strict as assert } from 'node:assert';
24
+ import { test } from 'node:test';
25
+ import { execFileSync } from 'node:child_process';
26
+ import { createRequire } from 'node:module';
27
+ import fs from 'node:fs';
28
+ import os from 'node:os';
29
+ import path from 'node:path';
30
+ import { fileURLToPath } from 'node:url';
31
+
32
+ const require = createRequire(import.meta.url);
33
+ // fileURLToPath, never URL.pathname — on Windows the latter yields '/C:/…', which is not
34
+ // a usable path. Enforced by fitness.js (task 1002889).
35
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
36
+ const { classifyMergeDriverState, registerMergeDriver, MERGE_DRIVER_NAME } =
37
+ require(path.join(REPO_ROOT, 'scripts/gds/install-git-hooks.js'));
38
+
39
+ // A throwaway repo with one file attributed to the driver and two divergent branches,
40
+ // so `git merge` actually has to consult the driver config.
41
+ function scratchRepo() {
42
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mergedriver-'));
43
+ const git = (args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
44
+ git(['init', '-q', '.']);
45
+ git(['config', 'user.email', 'test@example.com']);
46
+ git(['config', 'user.name', 'test']);
47
+ fs.writeFileSync(path.join(dir, 'f.md'), 'base\n');
48
+ fs.writeFileSync(path.join(dir, '.gitattributes'), `f.md merge=${MERGE_DRIVER_NAME}\n`);
49
+ git(['add', '-A']);
50
+ git(['commit', '-qm', 'base']);
51
+ const base = git(['branch', '--show-current']).trim();
52
+ git(['checkout', '-qb', 'other']);
53
+ fs.writeFileSync(path.join(dir, 'f.md'), 'theirs\n');
54
+ git(['commit', '-qam', 'other']);
55
+ git(['checkout', '-q', base]);
56
+ fs.writeFileSync(path.join(dir, 'f.md'), 'ours\n');
57
+ git(['commit', '-qam', 'mine']);
58
+ return { dir, git };
59
+ }
60
+
61
+ // Returns null on a clean merge, or git's stderr on failure.
62
+ function tryMerge(dir) {
63
+ try {
64
+ execFileSync('git', ['merge', 'other'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
65
+ return null;
66
+ } catch (e) {
67
+ return String(e.stderr || e.message);
68
+ }
69
+ }
70
+
71
+ test('THE BUG: a section with .name and no .driver makes every merge fatal', () => {
72
+ const { dir, git } = scratchRepo();
73
+ git(['config', `merge.${MERGE_DRIVER_NAME}.name`, 'half-written']);
74
+ const err = tryMerge(dir);
75
+ assert.ok(err, 'merge must FAIL in the half-written state — if this passes, the premise is gone');
76
+ assert.match(err, /lacks command line/,
77
+ 'git must reject a named driver with no command (this is why .name cannot be written first)');
78
+ fs.rmSync(dir, { recursive: true, force: true });
79
+ });
80
+
81
+ test('THE FIX RESTS ON THIS: a section with only .driver is valid and runs', () => {
82
+ const { dir, git } = scratchRepo();
83
+ // `cp %B %A` stands in for git-merge-regen.js: it resolves by taking theirs, which is
84
+ // observable in the file afterwards, so this asserts the driver RAN — not merely that
85
+ // the merge exited 0, which a fallback text merge would also do.
86
+ git(['config', `merge.${MERGE_DRIVER_NAME}.driver`, 'cp %B %A']);
87
+ assert.equal(tryMerge(dir), null, 'a driver-only section must merge cleanly');
88
+ assert.equal(fs.readFileSync(path.join(dir, 'f.md'), 'utf8'), 'theirs\n',
89
+ 'the custom driver must have run — otherwise this proves nothing about ordering');
90
+ fs.rmSync(dir, { recursive: true, force: true });
91
+ });
92
+
93
+ test('registerMergeDriver writes .driver BEFORE .name', () => {
94
+ const order = [];
95
+ const run = (_bin, args) => { order.push(args[1]); };
96
+ const res = registerMergeDriver('/nowhere', { run });
97
+ assert.equal(res.ok, true);
98
+ assert.deepEqual(order, [`merge.${MERGE_DRIVER_NAME}.driver`, `merge.${MERGE_DRIVER_NAME}.name`],
99
+ 'order is the fix: the intermediate state after write #1 must be the SAFE one');
100
+ });
101
+
102
+ test('a failure mid-registration removes the whole section, leaving no trace', () => {
103
+ const calls = [];
104
+ const run = (_bin, args) => {
105
+ calls.push(args);
106
+ // Fail on the SECOND write (.name) — the exact partial-write this task is about.
107
+ if (args[1] === `merge.${MERGE_DRIVER_NAME}.name`) throw new Error('simulated config failure');
108
+ };
109
+ const res = registerMergeDriver('/nowhere', { run });
110
+ assert.equal(res.ok, false, 'a failed registration must report failure');
111
+ assert.match(res.error, /simulated config failure/, 'and must report the ORIGINAL error, not the cleanup');
112
+ assert.deepEqual(calls[calls.length - 1], ['config', '--remove-section', `merge.${MERGE_DRIVER_NAME}`],
113
+ 'the last action must be removing the section, so nothing partial survives');
114
+ });
115
+
116
+ test('cleanup failing does not mask the original error', () => {
117
+ const run = (_bin, args) => {
118
+ if (args[1] === `merge.${MERGE_DRIVER_NAME}.name`) throw new Error('original failure');
119
+ if (args[1] === '--remove-section') throw new Error('cleanup also failed');
120
+ };
121
+ const res = registerMergeDriver('/nowhere', { run });
122
+ assert.equal(res.ok, false);
123
+ assert.match(res.error, /original failure/, 'the reported error must be the one that matters');
124
+ });
125
+
126
+ test('classifyMergeDriverState: name without driver is the FAIL doctor must surface', () => {
127
+ const v = classifyMergeDriverState({ name: 'something', driver: '' });
128
+ assert.equal(v.status, 'fail');
129
+ assert.equal(v.code, 'name-without-driver');
130
+ assert.match(v.message, /lacks command line/, 'the message must quote the fatal a builder will actually see');
131
+ assert.match(v.message, /--remove-section/, 'and must give a way out');
132
+ });
133
+
134
+ test('classifyMergeDriverState: driver present is ok, with or without a name', () => {
135
+ assert.equal(classifyMergeDriverState({ name: 'n', driver: 'd' }).status, 'ok');
136
+ assert.equal(classifyMergeDriverState({ name: '', driver: 'd' }).status, 'ok',
137
+ 'driver-only is a WORKING section (proved against real git above), so it must not be reported as a fault');
138
+ });
139
+
140
+ test('classifyMergeDriverState: neither set is a warn, not a fail', () => {
141
+ const v = classifyMergeDriverState({ name: '', driver: '' });
142
+ assert.equal(v.status, 'warn');
143
+ assert.equal(v.code, 'unregistered');
144
+ assert.match(v.message, /ship\.js still self-heals/, 'absent is not a hole in the walls — say so');
145
+ });
146
+
147
+ test('whitespace-only config values count as absent', () => {
148
+ assert.equal(classifyMergeDriverState({ name: ' ', driver: ' ' }).code, 'unregistered');
149
+ assert.equal(classifyMergeDriverState({ name: 'n', driver: ' ' }).code, 'name-without-driver');
150
+ });
@@ -54,10 +54,46 @@ test('CLAUDE.md is xenos (write-protected but READ-PUBLIC — the carve-out)', (
54
54
  assert.equal(minRankForDoc('CLAUDE.md'), 'xenos');
55
55
  });
56
56
 
57
- test('SENSITIVE_DOC_GLOBS is the curated source of truth (subtrees end with /)', () => {
58
- assert.ok(SENSITIVE_DOC_GLOBS.includes('docs/security/'));
59
- assert.ok(SENSITIVE_DOC_GLOBS.includes('docs/audits/'));
60
- assert.ok(SENSITIVE_DOC_GLOBS.includes('docs/canonical-permissions.md'));
57
+ // EXHAUSTIVE, not a spot-check (task 1002882). The old version asserted three of the
58
+ // six entries with includes(), which meant the list could GAIN or LOSE an entry — a
59
+ // whole read-gated subtree appearing or vanishing — with nothing red. docs/governance/
60
+ // was added under task 1002598 and this test never noticed.
61
+ //
62
+ // A deepEqual pin makes every change to the read floor a deliberate, reviewed edit:
63
+ // you cannot widen or narrow what a sub-Metic search can reach without also editing
64
+ // this line and saying why. The functional assertions above already cover BEHAVIOUR
65
+ // (minRankForDoc returns 'metic'); this one states the list's INTENT, which is the
66
+ // half that was under-described.
67
+ //
68
+ // Keep it in sync with scripts/gds/publish-manifest.js:185, where SENSITIVE_DOC_EXCLUDE
69
+ // is derived from this same array by filtering to docs/security + docs/audits — a
70
+ // silent addition here changes the PUBLISH boundary too.
71
+ test('SENSITIVE_DOC_GLOBS is the curated source of truth (exhaustive pin)', () => {
72
+ assert.deepEqual(SENSITIVE_DOC_GLOBS, [
73
+ 'docs/canonical-permissions.md',
74
+ 'docs/security/',
75
+ 'docs/audits/',
76
+ 'docs/governance/',
77
+ 'docs/adr/0016-trust-boundary-server-enforced-permissions.md',
78
+ 'docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md',
79
+ ], 'adding or removing a read-gated doc must be a deliberate edit here, not a silent one');
80
+
81
+ // A trailing '/' means "subtree" to the glob dialect this shares with
82
+ // permission-path-check; a missing one would gate only an exact file of that name.
83
+ for (const g of ['docs/security/', 'docs/audits/', 'docs/governance/']) {
84
+ assert.ok(SENSITIVE_DOC_GLOBS.includes(g), `${g} must keep its trailing slash to gate the subtree`);
85
+ }
86
+ });
87
+
88
+ // The publish boundary reads this same array, so a change here silently moves what the
89
+ // public AGPL mirror ships. Pin the derivation, not just the source (task 1002882).
90
+ test('SENSITIVE_DOC_EXCLUDE derives docs/security + docs/audits from the same array', () => {
91
+ const derived = SENSITIVE_DOC_GLOBS.filter(
92
+ (g) => g.startsWith('docs/security') || g.startsWith('docs/audits')
93
+ );
94
+ assert.deepEqual(derived, ['docs/security/', 'docs/audits/'],
95
+ 'publish-manifest.js:185 applies exactly this filter — if the array gains another '
96
+ + 'docs/security* or docs/audits* entry, the publish denylist changes with it');
61
97
  });
62
98
 
63
99
  test('collectMarkdown finds the repo corpus but excludes node_modules/.git/worktrees/public', () => {