@bongos/core 1.20.43 → 1.20.44

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.20.43",
6
- "core_contract": "1.20.43",
7
- "source_commit": "4d771a1a0917c29baeb888e981459cade4da163d",
5
+ "core_version": "1.20.44",
6
+ "core_contract": "1.20.44",
7
+ "source_commit": "a1a3b0419e36145a922de74f56ad59e7c6a862d4",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-10-01T01:43:09.317Z",
9
+ "built_at": "2026-10-01T02:04:05.728Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 566,
13
13
  "agent_docs_stubbed": 27,
14
- "functional_verbatim": 2690,
14
+ "functional_verbatim": 2691,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3284,
20
- "tree_sha256": "9619017af56fd7bd25e2825f3559277af091a1bf7d9e7b01d3fd23413efac6d9",
19
+ "file_count": 3285,
20
+ "tree_sha256": "b3a3d377b25ba92b3ee9a45c0a72813f65530c1dad3549a203339bebc6cf1b05",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2262,7 +2262,7 @@
2262
2262
  {
2263
2263
  "path": "docs/api/openapi.json",
2264
2264
  "mode": "0000644",
2265
- "sha256": "a1fe44960e8dad577eb2b85d5d2e60829f423458721f8bf6f94318cde07fcdef"
2265
+ "sha256": "4307e66fffa57ef78f14d21899770273433716303ad7b2d4ac7f4b838f41aea2"
2266
2266
  },
2267
2267
  {
2268
2268
  "path": "docs/architecture.md",
@@ -2797,12 +2797,12 @@
2797
2797
  {
2798
2798
  "path": "docs/module-api-changelog.md",
2799
2799
  "mode": "0000644",
2800
- "sha256": "1d0dd6bacd73b8a4eceb9d7fbf6fd89fdfa0d8e6b1576605ef50a438f69a69be"
2800
+ "sha256": "4af4f158a9e9c33bd72de0881331c5d7646fa237a36ecb81f6d88214ae4e0f0f"
2801
2801
  },
2802
2802
  {
2803
2803
  "path": "docs/modules-contract.md",
2804
2804
  "mode": "0000644",
2805
- "sha256": "c82d7e11d94d7303da1af813147522b347dc1292d633ba3787d555211dbe9802"
2805
+ "sha256": "15148b0d5a175c6eaf24fcc54709500e2c036ef966011c8505ecc8988a904b97"
2806
2806
  },
2807
2807
  {
2808
2808
  "path": "docs/onboarding/diagrams/01-task-lifecycle.mmd",
@@ -2887,7 +2887,7 @@
2887
2887
  {
2888
2888
  "path": "docs/page-readings.json",
2889
2889
  "mode": "0000644",
2890
- "sha256": "430b53669889f1df1cfb99359ab3ae819dd8c7d37d716271dab516c39d11eb6d"
2890
+ "sha256": "c68874a27eee3390e2cd64c01b3898f6f71f0fbaf851b51fcffcd1aff952299f"
2891
2891
  },
2892
2892
  {
2893
2893
  "path": "docs/project-context.template.md",
@@ -5202,7 +5202,7 @@
5202
5202
  {
5203
5203
  "path": "modules/hall-ui/public/collab.css",
5204
5204
  "mode": "0000644",
5205
- "sha256": "d39ea908838f1eae4e4f29b49f667d9e08cdd961c7441cb1fbc9090ac7bd4457"
5205
+ "sha256": "4ee8e77a58b688c93d9d0588ec2a5e47ce7d751ae84101a8547f45f07a34b71f"
5206
5206
  },
5207
5207
  {
5208
5208
  "path": "modules/hall-ui/public/collab.html",
@@ -5557,7 +5557,7 @@
5557
5557
  {
5558
5558
  "path": "modules/hall-ui/public/oversight.css",
5559
5559
  "mode": "0000644",
5560
- "sha256": "f2a6313335a12520f83c77d03e571900465ae0e46136839ba9f92164043ec69a"
5560
+ "sha256": "582ab7d5151e8125537f7a2d969485a446ed7b69b4c4dba1e6b43168afaf1a0e"
5561
5561
  },
5562
5562
  {
5563
5563
  "path": "modules/hall-ui/public/palette.js",
@@ -9052,12 +9052,12 @@
9052
9052
  {
9053
9053
  "path": "package-lock.json",
9054
9054
  "mode": "0000644",
9055
- "sha256": "61fb420fbe1a74df0fdc837237a1625c3c9e361f4473b66ea06b0a088eb52945"
9055
+ "sha256": "69714e37e8d92416589c29c25fbf93105b5e74c1fb8a7b0c4e61135cdab04e36"
9056
9056
  },
9057
9057
  {
9058
9058
  "path": "package.json",
9059
9059
  "mode": "0000644",
9060
- "sha256": "7dc8116ba9b3b0c97b8c1ad59f3f0679d21580a21b79175074d3be21d279023d"
9060
+ "sha256": "83a632e6a1841013c7720d8202c493b98cdc191c8e51c0d5dfd3adca29a958fb"
9061
9061
  },
9062
9062
  {
9063
9063
  "path": "public-docs/index.html",
@@ -9077,7 +9077,7 @@
9077
9077
  {
9078
9078
  "path": "release-notes.json",
9079
9079
  "mode": "0000644",
9080
- "sha256": "8ec55f2c1db4d0116b89d222385131b63500893aa9f860e7eb563de7173ce02a"
9080
+ "sha256": "8d4fd2a1e48f98f5110d0358262ea82c8622848376dc56f35581b54e2cbb3a97"
9081
9081
  },
9082
9082
  {
9083
9083
  "path": "scripts/bongos-mcp.js",
@@ -9922,7 +9922,7 @@
9922
9922
  {
9923
9923
  "path": "scripts/gds/module-artifact.js",
9924
9924
  "mode": "0000644",
9925
- "sha256": "a16e9cee3135bcd14ae99f58dfbd17657610c2fc5b5c126ed052ccca49297428"
9925
+ "sha256": "f2d0770ca07105a0555190783cd37ff838babcec337a93a2ebd2a448efc37062"
9926
9926
  },
9927
9927
  {
9928
9928
  "path": "scripts/gds/module-assess-security.js",
@@ -9937,7 +9937,7 @@
9937
9937
  {
9938
9938
  "path": "scripts/gds/module.js",
9939
9939
  "mode": "0000644",
9940
- "sha256": "2bb8d87422e2b24db1ea9fa0c51556b09a7bea99da5af7ad3798c3b386809f4b"
9940
+ "sha256": "8563b7fa47b7c3f00e6c070c496a924875ea49ca41b681c1209c5819d2551f97"
9941
9941
  },
9942
9942
  {
9943
9943
  "path": "scripts/gds/move-escalation.js",
@@ -11167,7 +11167,7 @@
11167
11167
  {
11168
11168
  "path": "src/bongos/routes/modules.js",
11169
11169
  "mode": "0000644",
11170
- "sha256": "bd4e80ff8712d0797a62bddb3577f52f4cf161ac2273028e8a8def0455118e63"
11170
+ "sha256": "3acd1e40da2502d9c69f7260548585f31e35a102c6dcc5cd58a71965a6b8a42c"
11171
11171
  },
11172
11172
  {
11173
11173
  "path": "src/bongos/routes/my-sessions.js",
@@ -11237,7 +11237,7 @@
11237
11237
  {
11238
11238
  "path": "src/module-api.js",
11239
11239
  "mode": "0000644",
11240
- "sha256": "b0af63fbad35e7a1006c0e8326238add2ab9a6d9d0eaad89d08a0fe7286c7443"
11240
+ "sha256": "38d5ae5c4d42fa6941f1e3ea4365b7dca33258e71cd723bfe9dafe1b74a844a2"
11241
11241
  },
11242
11242
  {
11243
11243
  "path": "src/module-loader/catalog.js",
@@ -11252,7 +11252,7 @@
11252
11252
  {
11253
11253
  "path": "src/module-loader/manifest-schema.js",
11254
11254
  "mode": "0000644",
11255
- "sha256": "2af9174e0a2793c04c2abe7e90cf8ec4d995e5f386f866b3b557671dc7e602e1"
11255
+ "sha256": "e1e05eb67795d78e014dd08363cd82df06c15117c1df2b0758209d093f9343ce"
11256
11256
  },
11257
11257
  {
11258
11258
  "path": "src/module-loader/provenance.js",
@@ -13384,6 +13384,11 @@
13384
13384
  "mode": "0000644",
13385
13385
  "sha256": "0156e00234c90316c63c65a48c779edd05a56262947241cbd06de7a32ea8a881"
13386
13386
  },
13387
+ {
13388
+ "path": "tests/hall_lead_row_home.mjs",
13389
+ "mode": "0000644",
13390
+ "sha256": "3c12b6045361c5d6830f5bc16ca70b0443a4e16b660b9018eb659d12c988b41b"
13391
+ },
13387
13392
  {
13388
13393
  "path": "tests/hall_mine_lens.mjs",
13389
13394
  "mode": "0000644",
@@ -14272,7 +14277,7 @@
14272
14277
  {
14273
14278
  "path": "tests/module_manifest.mjs",
14274
14279
  "mode": "0000644",
14275
- "sha256": "fcc12c33fc39c4cbd887afd18bc62480da30f4f3ec7dd70eafe3b6eff72b1f08"
14280
+ "sha256": "19903e912a86ac216c7e7bf06585d7b7faf6b5689d246955402fc972ce3845f1"
14276
14281
  },
14277
14282
  {
14278
14283
  "path": "tests/module_owned_tests.mjs",
@@ -14297,12 +14302,12 @@
14297
14302
  {
14298
14303
  "path": "tests/module_store_publish.mjs",
14299
14304
  "mode": "0000644",
14300
- "sha256": "87fac3ad4c8bbbe2e1d08b978235db93e747f58726edbd043a21f32f6044ff34"
14305
+ "sha256": "6392bc2e1554cdef18145a0598f4d7fc22305c0744e864c0f2526848609fd13a"
14301
14306
  },
14302
14307
  {
14303
14308
  "path": "tests/module_store_publish_route.mjs",
14304
14309
  "mode": "0000644",
14305
- "sha256": "4c96e94c15ef2d2f6fa1192d0cb1918ec0ae8b5d3300753962bfe6c1abdd3260"
14310
+ "sha256": "59d14575351c6b6529c6aff8e661ce915b8159988ad13585eea752c4bde4d9b6"
14306
14311
  },
14307
14312
  {
14308
14313
  "path": "tests/module_store_registry_migration.mjs",
@@ -19282,7 +19282,7 @@
19282
19282
  "store"
19283
19283
  ],
19284
19284
  "summary": "POST /store/modules/:key/versions",
19285
- "description": "POST /api/bongos/store/modules/:key/versions — publish one module version to the store (task 1004271, ADR 0338 D1). Body: the gzip tarball `bongos module publish` builds (scripts/gds/module-artifact.js), sent as application/gzip. The route trusts nothing but the bytes: it recomputes every hash, re-runs the publish denylist and validates module.json itself (verifyModuleArtifact), then keeps the tarball on the control plane's disk and INSERTs the version row in one transaction (module-store.js). A key's first publish makes the caller its author; after that only the author may publish, a delisted key takes nothing new, and a version is published once and never changed. rank: metic+archon — it puts code in a shared store other instances install from. Gate: requirePermission('module.submit') — the atom that already guards filing a module into a shared queue; a dedicated `module.publish` atom is a follow-up once who-may-sell is decided.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `module.submit` (all required).",
19285
+ "description": "POST /api/bongos/store/modules/:key/versions — publish one module version to the store (task 1004271, ADR 0338 D1). Body: the gzip tarball `bongos module publish` builds (scripts/gds/module-artifact.js), sent as application/gzip. The route trusts nothing but the bytes: it recomputes every hash, re-runs the publish denylist, validates module.json and checks that HOWTO.md has its required sections (ADR 0347 D4) itself (verifyModuleArtifact), then keeps the tarball on the control plane's disk and INSERTs the version row in one transaction (module-store.js). A key's first publish makes the caller its author; after that only the author may publish, a delisted key takes nothing new, and a version is published once and never changed. rank: metic+archon — it puts code in a shared store other instances install from. Gate: requirePermission('module.submit') — the atom that already guards filing a module into a shared queue; a dedicated `module.publish` atom is a follow-up once who-may-sell is decided.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `module.submit` (all required).",
19286
19286
  "x-rank": "metic+archon",
19287
19287
  "x-source": "src/bongos/routes/modules.js",
19288
19288
  "x-permissions": [
@@ -2743,5 +2743,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2743
2743
  landed since 1.20.41 with no explicit bump. run 36801585300. (task 1002620)
2744
2744
  1.20.43 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2745
2745
  landed since 1.20.42 with no explicit bump. run 36802367948. (task 1002620)
2746
+ 1.20.44 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2747
+ landed since 1.20.43 with no explicit bump. run 36804013644. (task 1002620)
2746
2748
  ---------------------------------------------------------------------------
2747
2749
  ```
@@ -113,6 +113,7 @@ Every module must have a `module.json` at its root (`modules/<key>/module.json`)
113
113
  "consumes": [], // OPTIONAL. Seam ports this module resolves (required caps).
114
114
  "prerequisites": { "modules": [] }, // OPTIONAL. Other modules that must be enabled first.
115
115
  "spend": { "requiresPayer": true }, // OPTIONAL. This module spends MONEY for whoever calls it.
116
+ "howto": { "artifactUrl": "https://claude.ai/…" }, // OPTIONAL. A Claude page beside HOWTO.md — never instead of it (ADR 0347 D3).
116
117
  "dependencies": { "discord.js": "^14.26.4" } // OPTIONAL. External npm deps this module needs at runtime.
117
118
  }
118
119
  ```
@@ -321,6 +322,7 @@ This creates `modules/<key>/` with:
321
322
  - `migrations/` — empty directory.
322
323
  - `ui/` — empty directory.
323
324
  - `CLAUDE.md` — per-module reference explaining the boundary, the seam pattern, and what to keep out of the module.
325
+ - `HOWTO.md` — the how-to for the person who installs the module: the five required sections as headings, each with its prompt in an HTML comment (see §6).
324
326
 
325
327
  ### 2. Write the module
326
328
 
@@ -359,6 +361,7 @@ Run `bongos upgrade` to confirm coreVersion compatibility, then `node scripts/gd
359
361
  - Your first publish of a key makes you its author; only the author can publish later versions, and a delisted key takes none.
360
362
  - The publish denylist applies (ADR 0098), plus a refusal of credential-named files (`.env*`, `*.pem`, `*.key`, `id_rsa`, …) anywhere in the module. The store re-checks every hash and the denylist itself.
361
363
  - Gate: Metic+ (`module.submit`) for now. This is not `bongos module submit`, which proposes a module *into* core.
364
+ - **A how-to is required** ([ADR 0347](adr/0347-every-store-module-ships-a-how-to.md)). `modules/<key>/HOWTO.md` is what a buyer reads — the store shows it as the module's page — so it is written for the person who installs the module, not for an AI working inside it (that is `CLAUDE.md`). It is plain Markdown: any AI or person can write it. Publish refuses the version unless it has these five level-2 headings, in any order and any case, each with some text under it: `## What it does`, `## Install and enable`, `## How to use it` (with one worked example), `## Configuration` ("None." is fine) and `## Limits and known issues` ("None known." is fine). HTML comments don't count as text, so the scaffold's prompts must be replaced. The refusal names each failing section. The check is one function, `checkHowto` in `scripts/gds/module-artifact.js`; the CLI runs it before uploading and the store runs it again on upload, so a hand-built upload can't skip it. `bongos module check` reports it as advice only — an upstream submit is not gated by it. Install and update don't re-check it, so a version published before the gate still installs. The file travels in the tarball, so its hash pins it to the version. An optional `howto.artifactUrl` in `module.json` (an `https://` link on `claude.ai`) is shown as an extra, never graded.
362
365
 
363
366
  ### 7. Install from the store
364
367
 
@@ -12,7 +12,7 @@
12
12
  "surface": "builders",
13
13
  "title": "Repo Atlas",
14
14
  "states_read": ["map","truth","detail","index","out"],
15
- "files_hash": "dba137b09374d2e6",
15
+ "files_hash": "6ad36383395e3de3",
16
16
  "reading_hash": "411161b0a6167a1e",
17
17
  "counts": {"lines":13,"placed":8,"shared":5,"unplaced":0},
18
18
  "omitted": {"data":1},
@@ -38,7 +38,7 @@
38
38
  "surface": "builders",
39
39
  "title": "Blockers",
40
40
  "states_read": ["default"],
41
- "files_hash": "afabc8607665ad52",
41
+ "files_hash": "632c93f4db32b846",
42
42
  "reading_hash": "c8c742437a2c76c2",
43
43
  "counts": {"lines":16,"placed":6,"shared":5,"unplaced":5},
44
44
  "omitted": {"data":4},
@@ -67,7 +67,7 @@
67
67
  "surface": "builders",
68
68
  "title": "Board Room",
69
69
  "states_read": ["room","out","sitting-meta","objection-form"],
70
- "files_hash": "c1978638f2ef9558",
70
+ "files_hash": "2d1d695b800e13bc",
71
71
  "reading_hash": "202a0cbb6bc54aac",
72
72
  "counts": {"lines":56,"placed":26,"shared":5,"unplaced":25},
73
73
  "omitted": {"data":9},
@@ -136,7 +136,7 @@
136
136
  "surface": "builders",
137
137
  "title": "Collab",
138
138
  "states_read": ["board","out"],
139
- "files_hash": "793b7af1446ab69e",
139
+ "files_hash": "2acb05affd061ce7",
140
140
  "reading_hash": "cedb8171693a308e",
141
141
  "counts": {"lines":68,"placed":23,"shared":4,"unplaced":41},
142
142
  "omitted": {"data":12},
@@ -145,7 +145,7 @@
145
145
  {"key":"L0001","section":"skip link","text":"Skip to content","placement":"shared","file":"modules/hall-ui/public/shell.js","line":767,"string_id":"87cee78ca134"},
146
146
  {"key":"L0002","section":"top bar","text":"Collab","placement":"shared"},
147
147
  {"key":"L0003","section":"top bar","text":"Jump to…","placement":"shared","file":"modules/hall-ui/public/shell.js","line":487,"string_id":"eba610d25523"},
148
- {"key":"L0004","section":"top bar","text":"⌘K","placement":"shared"},
148
+ {"key":"L0004","section":"top bar","text":"Ctrl K","placement":"shared"},
149
149
  {"key":"L0005","section":"Collab","text":"Collab","placement":"unplaced"},
150
150
  {"key":"L0006","section":"Collab","text":"{…} and {…}","placement":"placed","file":"modules/hall-ui/public/profile-nudge.js","line":77,"string_id":"56b00d773232"},
151
151
  {"key":"L0007","section":"Collab","text":"Waiting on you","placement":"unplaced"},
@@ -217,7 +217,7 @@
217
217
  "surface": "builders",
218
218
  "title": "Copy desk",
219
219
  "states_read": ["queue","out","search","composer","closer","proposer"],
220
- "files_hash": "d96a711bf72a38ae",
220
+ "files_hash": "8631e59378897edf",
221
221
  "reading_hash": "8d8433d27c7405a0",
222
222
  "counts": {"lines":106,"placed":71,"shared":5,"unplaced":30},
223
223
  "omitted": {"data":117},
@@ -260,7 +260,7 @@
260
260
  {"key":"L0035","section":"Flagged","text":"said, when flagged:","placement":"placed","file":"modules/hall-ui/public/copy-desk.js","line":124,"string_id":"2a42549b34ae"},
261
261
  {"key":"L0036","section":"Flagged","text":"{…} of {…}","placement":"placed","file":"modules/hall-ui/public/sky-panel.js","line":120,"string_id":"73c1372b43d9"},
262
262
  {"key":"L0037","section":"Flagged","text":"target {…}","placement":"unplaced"},
263
- {"key":"L0038","section":"Flagged","text":"Restricted.","placement":"placed","file":"modules/hall-ui/public/board-room.js","line":521,"string_id":"0887725744f2"},
263
+ {"key":"L0038","section":"Flagged","text":"Restricted.","placement":"placed","file":"modules/hall-ui/public/board-room.js","line":526,"string_id":"0887725744f2"},
264
264
  {"key":"L0039","section":"Flagged","text":"off voice","placement":"unplaced"},
265
265
  {"key":"L0040","section":"Flagged","text":"moved","placement":"unplaced"},
266
266
  {"key":"L0041","section":"Flagged","text":"whole {…}","placement":"unplaced"},
@@ -278,7 +278,7 @@
278
278
  {"key":"L0053","section":"Said more than once","text":"Available from the {…} rank up.","placement":"placed","file":"modules/hall-ui/public/gate.js","line":237,"string_id":"1de02052abf2"},
279
279
  {"key":"L0054","section":"Said more than once","text":"Could not save. Try again.","placement":"placed","file":"modules/hall-ui/public/settings-interaction.js","line":96,"string_id":"8c234d5c8bd4"},
280
280
  {"key":"L0055","section":"Said more than once","text":"already flagged","placement":"unplaced","occurrences":2},
281
- {"key":"L0056","section":"Said more than once","text":"Restricted.","placement":"placed","file":"modules/hall-ui/public/board-room.js","line":521,"string_id":"0887725744f2"},
281
+ {"key":"L0056","section":"Said more than once","text":"Restricted.","placement":"placed","file":"modules/hall-ui/public/board-room.js","line":526,"string_id":"0887725744f2"},
282
282
  {"key":"L0057","section":"Said more than once","text":"looked at","placement":"unplaced"},
283
283
  {"key":"L0058","section":"Find something to flag","text":"Find something to flag","placement":"placed","file":"modules/hall-ui/public/copy-desk.html","line":64,"string_id":"1def0e7c716e"},
284
284
  {"key":"L0059","section":"Find something to flag","text":"Search every string the product shows a person, by what it says or the file it lives in.","placement":"placed","file":"modules/hall-ui/public/copy-desk.html","line":65,"string_id":"46711f7bc3df"},
@@ -336,7 +336,7 @@
336
336
  "surface": "builders",
337
337
  "title": "Deploy",
338
338
  "states_read": ["work","out"],
339
- "files_hash": "786235bca48a981a",
339
+ "files_hash": "d06badab7642ea93",
340
340
  "reading_hash": "eb5acd9bd5fc73e9",
341
341
  "counts": {"lines":7,"placed":0,"shared":5,"unplaced":2},
342
342
  "omitted": {"data":1},
@@ -838,7 +838,7 @@
838
838
  "surface": "builders",
839
839
  "title": "Fleet costs",
840
840
  "states_read": ["ledger","out"],
841
- "files_hash": "23ae0ec03adc9b80",
841
+ "files_hash": "5056fa71f75d5c6d",
842
842
  "reading_hash": "9493385a64bb0e9f",
843
843
  "counts": {"lines":22,"placed":6,"shared":5,"unplaced":11},
844
844
  "omitted": {"data":11},
@@ -873,7 +873,7 @@
873
873
  "surface": "builders",
874
874
  "title": "Gate",
875
875
  "states_read": ["queue","out","approve"],
876
- "files_hash": "14ac6944f79fb3cb",
876
+ "files_hash": "ec25d2c025db0abd",
877
877
  "reading_hash": "08d3e5d3657e5afb",
878
878
  "counts": {"lines":41,"placed":17,"shared":5,"unplaced":19},
879
879
  "omitted": {"data":34},
@@ -884,7 +884,7 @@
884
884
  {"key":"L0003","section":"top bar","text":"Jump to…","placement":"shared","file":"modules/hall-ui/public/shell.js","line":487,"string_id":"eba610d25523"},
885
885
  {"key":"L0004","section":"top bar","text":"Ctrl K","placement":"shared"},
886
886
  {"key":"L0005","section":"Gate","text":"Gate","placement":"unplaced"},
887
- {"key":"L0006","section":"Gate","text":"Changes touching protected build surfaces, held for your approval.","placement":"placed","file":"modules/hall-ui/public/gate.js","line":431,"string_id":"4b714191b423"},
887
+ {"key":"L0006","section":"Gate","text":"Changes touching protected build surfaces, held for your approval.","placement":"placed","file":"modules/hall-ui/public/gate.js","line":432,"string_id":"4b714191b423"},
888
888
  {"key":"L0007","section":"Gate","text":"Awaiting your approval","placement":"unplaced"},
889
889
  {"key":"L0008","section":"Gate","text":"held at the gate check","placement":"unplaced"},
890
890
  {"key":"L0009","section":"Gate","text":"Hard-floor holds","placement":"unplaced"},
@@ -997,7 +997,7 @@
997
997
  "surface": "builders",
998
998
  "title": "Goals",
999
999
  "states_read": ["list","filters","cards","detail","out"],
1000
- "files_hash": "c0cc109370ca958c",
1000
+ "files_hash": "968230876b9152ba",
1001
1001
  "reading_hash": "cf7f8688bcae4dd5",
1002
1002
  "counts": {"lines":146,"placed":36,"shared":5,"unplaced":105},
1003
1003
  "omitted": {"data":250},
@@ -1008,14 +1008,14 @@
1008
1008
  {"key":"L0003","section":"top bar","text":"Jump to…","placement":"shared","file":"modules/hall-ui/public/shell.js","line":487,"string_id":"eba610d25523"},
1009
1009
  {"key":"L0004","section":"top bar","text":"Ctrl K","placement":"shared"},
1010
1010
  {"key":"L0005","section":"Goals","text":"Goals","placement":"unplaced"},
1011
- {"key":"L0006","section":"Goals","text":"Goal {…}","placement":"placed","file":"modules/hall-ui/public/gate.js","line":280,"string_id":"ec88b946ad16","state":"detail"},
1011
+ {"key":"L0006","section":"Goals","text":"Goal {…}","placement":"placed","file":"modules/hall-ui/public/gate.js","line":288,"string_id":"ec88b946ad16","state":"detail"},
1012
1012
  {"key":"L0007","section":"Goals","text":"{…} and {…}","placement":"placed","file":"modules/hall-ui/public/profile-nudge.js","line":77,"string_id":"56b00d773232","occurrences":8},
1013
1013
  {"key":"L0008","section":"Goals","text":"See which goals wait on each other →","placement":"placed","file":"modules/hall-ui/public/goals.html","line":47,"string_id":"292761c89fae"},
1014
1014
  {"key":"L0009","section":"Goals","text":"← All goals","placement":"placed","file":"modules/hall-ui/public/goals.html","line":83,"string_id":"d4b84b7fbdeb","state":"detail"},
1015
1015
  {"key":"L0010","section":"#1000081 {…}","text":"Claude's notes on this goal","placement":"placed","file":"modules/hall-ui/public/goals.html","line":94,"string_id":"5b3b75ee0596","state":"detail"},
1016
1016
  {"key":"L0011","section":"Completion3","text":"Completion","placement":"unplaced","state":"detail"},
1017
1017
  {"key":"L0012","section":"Completion3","text":"{…} of {…} criteria satisfied","placement":"placed","file":"modules/hall-ui/public/goals-lib.js","line":32,"string_id":"cc5106af4b35","state":"detail"},
1018
- {"key":"L0013","section":"Completion3","text":"Goal {…}","placement":"placed","file":"modules/hall-ui/public/gate.js","line":280,"string_id":"ec88b946ad16","state":"detail"},
1018
+ {"key":"L0013","section":"Completion3","text":"Goal {…}","placement":"placed","file":"modules/hall-ui/public/gate.js","line":288,"string_id":"ec88b946ad16","state":"detail"},
1019
1019
  {"key":"L0014","section":"Completion3","text":"{…} — harness fixture text","placement":"unplaced","state":"detail","occurrences":3},
1020
1020
  {"key":"L0015","section":"Members0","text":"Members","placement":"unplaced","state":"detail"},
1021
1021
  {"key":"L0016","section":"Members0","text":"No {…} assigned yet — an Archon can appoint one.","placement":"unplaced","state":"detail"},
@@ -1156,7 +1156,7 @@
1156
1156
  "surface": "builders",
1157
1157
  "title": "Government",
1158
1158
  "states_read": ["constitution","out","permissions","board","constitution-tab"],
1159
- "files_hash": "5181c9903bdea83f",
1159
+ "files_hash": "c1c4058d40d669d2",
1160
1160
  "reading_hash": "f7f7466cabdf5569",
1161
1161
  "counts": {"lines":71,"placed":24,"shared":5,"unplaced":42},
1162
1162
  "omitted": {"data":10},
@@ -1185,7 +1185,7 @@
1185
1185
  {"key":"L0021","section":"Constitution","text":"Every {…} builder at","placement":"unplaced"},
1186
1186
  {"key":"L0022","section":"Constitution","text":"or above","placement":"placed","file":"modules/hall-ui/public/government.js","line":564,"string_id":"c38db02adb61"},
1187
1187
  {"key":"L0023","section":"Constitution","text":"sits on the board — that is","placement":"unplaced"},
1188
- {"key":"L0024","section":"Constitution","text":"and","placement":"placed","file":"modules/hall-ui/public/deploy.js","line":375,"string_id":"084748fd85b4"},
1188
+ {"key":"L0024","section":"Constitution","text":"and","placement":"placed","file":"modules/hall-ui/public/deploy.js","line":541,"string_id":"084748fd85b4"},
1189
1189
  {"key":"L0025","section":"Constitution","text":". Membership follows the rank, live.","placement":"unplaced"},
1190
1190
  {"key":"L0026","section":"Constitution","text":"What counts as passing","placement":"unplaced"},
1191
1191
  {"key":"L0027","section":"Constitution","text":"Items pass by","placement":"unplaced"},
@@ -1584,7 +1584,7 @@
1584
1584
  "surface": "builders",
1585
1585
  "title": "Modules",
1586
1586
  "states_read": ["browse","search-empty","lens","details","out"],
1587
- "files_hash": "38f79e90abc42a25",
1587
+ "files_hash": "e02acc87a87fbb67",
1588
1588
  "reading_hash": "165201cda2af2264",
1589
1589
  "counts": {"lines":42,"placed":14,"shared":5,"unplaced":23},
1590
1590
  "omitted": {"data":77},
@@ -2222,7 +2222,7 @@
2222
2222
  "surface": "builders",
2223
2223
  "title": "Project settings",
2224
2224
  "states_read": ["default"],
2225
- "files_hash": "f3007d41f4f59d9f",
2225
+ "files_hash": "01c19996a012a0d3",
2226
2226
  "reading_hash": "940a00917a01cd2e",
2227
2227
  "counts": {"lines":10,"placed":5,"shared":5,"unplaced":0},
2228
2228
  "omitted": {"data":1},
@@ -2388,7 +2388,7 @@
2388
2388
  "surface": "builders",
2389
2389
  "title": "Sessions",
2390
2390
  "states_read": ["ledger","out"],
2391
- "files_hash": "794fdcbab2e44337",
2391
+ "files_hash": "ad46927c48018cef",
2392
2392
  "reading_hash": "b8315ec3484a1431",
2393
2393
  "counts": {"lines":54,"placed":15,"shared":5,"unplaced":34},
2394
2394
  "omitted": {"data":16},
@@ -2980,7 +2980,7 @@
2980
2980
  "surface": "builders",
2981
2981
  "title": "Watch",
2982
2982
  "states_read": ["board","out","operations","security"],
2983
- "files_hash": "4b89e8960b706953",
2983
+ "files_hash": "3acbd4398dd35520",
2984
2984
  "reading_hash": "de49e347cca76e41",
2985
2985
  "counts": {"lines":156,"placed":44,"shared":5,"unplaced":107},
2986
2986
  "omitted": {"data":123},
@@ -4,7 +4,7 @@
4
4
  feel collaborative" — asked of you left, your asks right, every open ask
5
5
  below, each condensed to the newest three with a show-all). The panel is the
6
6
  shell's .scroll, the two-up is the shell's .view-zone--band, the row is
7
- oversight.css's .ov-row--lead (a face, then the words, then the state), the
7
+ the .ov-row--lead below (a face, then the words, then the state), the
8
8
  age is a .fact-pill, the context a .fact-chip, the expander the shell's
9
9
  .btn-ghost. This sheet holds only what Collab has and no other page does: the
10
10
  colour of each age band, the faces, the block head, the lit personal call, and
@@ -168,6 +168,13 @@
168
168
  entirely (task 1004041): the age pill says how long in words and carries the
169
169
  exact timestamp on its title, so a date beside it was the same fact twice —
170
170
  which is what the "All open asks" rows were doing at full width. */
171
+ /* The --lead row: a face, then the words, then the state. Only Collab renders it,
172
+ so the rule lives here (it was in oversight.css, unused by any oversight page). */
173
+ .ov-row--lead { grid-template-columns: auto minmax(0, 1fr) auto; }
174
+ @media (max-width: 640px) {
175
+ .ov-row--lead { grid-template-columns: auto minmax(0, 1fr); }
176
+ .ov-row--lead > .ov-row__right { grid-column: 2; }
177
+ }
171
178
  .cb-band .ov-row--lead { grid-template-columns: auto minmax(0, 1fr); row-gap: 8px; }
172
179
  .cb-band .ov-row--lead > .ov-row__right {
173
180
  grid-column: 2;
@@ -94,7 +94,6 @@
94
94
  border-top: var(--line) solid var(--rule-soft);
95
95
  }
96
96
  .ov-rows > .ov-row:first-child, .ov-group__head + .ov-rows > .ov-row:first-child, .ov-group__head + .ov-row { border-top: 0; }
97
- .ov-row--lead { grid-template-columns: auto minmax(0, 1fr) auto; }
98
97
  /* A dense row: a permission, a voter, a machine principal — sixty of them in
99
98
  one panel is the ordinary case, so the air tightens. */
100
99
  .ov-row--dense { padding: 10px 0; }
@@ -131,8 +130,6 @@ a.ov-row__title:hover { color: var(--accent-ink); text-decoration: underline; te
131
130
  .ov-row__when { font-size: 12.5px; color: var(--ink-faint); font-variant-numeric: tabular-nums; white-space: nowrap; }
132
131
  @media (max-width: 640px) {
133
132
  .ov-row { grid-template-columns: minmax(0, 1fr); }
134
- .ov-row--lead { grid-template-columns: auto minmax(0, 1fr); }
135
- .ov-row--lead > .ov-row__right { grid-column: 2; }
136
133
  .ov-row__right { align-items: flex-start; text-align: left; }
137
134
  .ov-row__actions { justify-content: flex-start; }
138
135
  }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.43",
3
+ "version": "1.20.44",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.43",
9
+ "version": "1.20.44",
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.20.43",
3
+ "version": "1.20.44",
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",
@@ -8367,5 +8367,15 @@
8367
8367
  "id": "1004471",
8368
8368
  "text": "Claude's list of project commands is now short enough to read in full every session: the rarely used admin commands still work when you type them but no longer take up room, and one new /owner-review walks the backlog, bugs, i"
8369
8369
  }
8370
+ ],
8371
+ "1.20.44": [
8372
+ {
8373
+ "id": "1003637",
8374
+ "text": "The collab page's row layout rule now lives in the collab stylesheet instead of the shared oversight one."
8375
+ },
8376
+ {
8377
+ "id": "1004365",
8378
+ "text": "Every store module now comes with a how-to page, and the store refuses to publish one without it."
8379
+ }
8370
8380
  ]
8371
8381
  }
@@ -22,6 +22,13 @@
22
22
  // secrets), plus credential-named files anywhere in the module (SECRET_FILE_RE). Anything the denylist catches refuses the whole publish rather than being
23
23
  // silently dropped: a module that ships without a file it expected is a broken module.
24
24
  //
25
+ // THE HOW-TO GATE (ADR 0347 D4, task 1004365). A store version must carry HOWTO.md
26
+ // with the five required sections, each with some text. checkHowto is the ONE checker:
27
+ // `bongos module publish` runs it before uploading and the store's upload route runs
28
+ // it again through verifyModuleArtifact({ requireHowto: true }), so a hand-built upload
29
+ // can't skip it. It is opt-in there because install and update verify with the same
30
+ // function, and a version published before the gate existed must still install.
31
+ //
25
32
  // Both halves live here so the CLI (packModule) and the store route
26
33
  // (verifyModuleArtifact) cannot disagree about the format. Pure apart from reading the
27
34
  // module directory in packModule; never touches the network or the database.
@@ -47,6 +54,42 @@ const NEVER_PACKED = new Set(['.upstream-signoff.json', INNER_MANIFEST_PATH]);
47
54
  const MAX_TARBALL_BYTES = 5 * 1024 * 1024;
48
55
  const READ_LIMITS = { maxUnpackedBytes: 16 * 1024 * 1024, maxFiles: 2000 };
49
56
 
57
+ const HOWTO_FILE = 'HOWTO.md';
58
+ // ADR 0347 D2 — level-2 headings, matched case-insensitively, in any order.
59
+ const HOWTO_SECTIONS = ['What it does', 'Install and enable', 'How to use it', 'Configuration', 'Limits and known issues'];
60
+
61
+ // Judge HOWTO.md's text (null = the file is absent). Reads headings only, never the
62
+ // writing: a section counts as filled when it holds any text once HTML comments (the
63
+ // scaffold's prompts) are removed. A heading inside a fenced code block doesn't count.
64
+ // Returns { ok, problems: [a plain sentence naming the file or the section] }.
65
+ function checkHowto(text) {
66
+ if (text == null) return { ok: false, problems: [`${HOWTO_FILE} is missing — every store module ships one (ADR 0347)`] };
67
+ const bodies = new Map();
68
+ let current = null;
69
+ let fence = null;
70
+ for (const line of String(text).replace(/<!--[\s\S]*?-->/g, '').split(/\r?\n/)) {
71
+ const f = line.match(/^\s*(```|~~~)/);
72
+ if (f) fence = fence === f[1] ? null : (fence || f[1]);
73
+ const h = !fence && !f && line.match(/^(#{1,6})\s+(.*?)\s*#*\s*$/);
74
+ if (h) {
75
+ // A level-1/2 heading starts a new section; a deeper one is structure, not text.
76
+ if (h[1].length <= 2) {
77
+ current = h[1].length === 2 ? h[2].toLowerCase() : null;
78
+ if (current !== null && !bodies.has(current)) bodies.set(current, '');
79
+ }
80
+ continue;
81
+ }
82
+ if (current !== null) bodies.set(current, bodies.get(current) + line.trim());
83
+ }
84
+ const problems = [];
85
+ for (const name of HOWTO_SECTIONS) {
86
+ const body = bodies.get(name.toLowerCase());
87
+ if (body === undefined) problems.push(`${HOWTO_FILE}: section "${name}" is missing`);
88
+ else if (!body) problems.push(`${HOWTO_FILE}: section "${name}" is empty`);
89
+ }
90
+ return { ok: problems.length === 0, problems };
91
+ }
92
+
50
93
  function tarballName(key, version) { return `${ARTIFACT_NAME}-${key}-${version}.tgz`; }
51
94
 
52
95
  // Every file under dir, as module-relative forward-slash paths, sorted. Skips
@@ -144,7 +187,9 @@ function packModule(key, { modulesDir, now = () => new Date(), modeOf } = {}) {
144
187
  // `includeFiles: true` (install, task 1003785) also returns `files`: the module's own
145
188
  // files — [{ path, mode, buf }], the inner manifest left out — from the SAME parse the
146
189
  // hashes were checked against, so a caller that places them never re-reads the bytes.
147
- async function verifyModuleArtifact(tgz, { key, maxBytes = MAX_TARBALL_BYTES, includeFiles = false } = {}) {
190
+ // `requireHowto: true` (the store's upload route, ADR 0347 D4) also refuses a version
191
+ // whose HOWTO.md fails checkHowto.
192
+ async function verifyModuleArtifact(tgz, { key, maxBytes = MAX_TARBALL_BYTES, includeFiles = false, requireHowto = false } = {}) {
148
193
  const fail = (code, message) => ({ ok: false, code, message });
149
194
  if (!Buffer.isBuffer(tgz) || tgz.length === 0) return fail('empty_artifact', 'the upload is empty');
150
195
  if (tgz.length > maxBytes) return fail('artifact_too_large', `the tarball is over ${maxBytes} bytes`);
@@ -204,6 +249,11 @@ async function verifyModuleArtifact(tgz, { key, maxBytes = MAX_TARBALL_BYTES, in
204
249
  if (manifest.module_version !== moduleJson.version || manifest.core_version !== moduleJson.coreVersion) {
205
250
  return fail('bad_manifest', 'the manifest version does not match module.json');
206
251
  }
252
+ if (requireHowto) {
253
+ const howto = byName.get(HOWTO_FILE);
254
+ const h = checkHowto(howto ? howto.buf.toString('utf8') : null);
255
+ if (!h.ok) return fail('howto_incomplete', h.problems.join('; '));
256
+ }
207
257
 
208
258
  const out = {
209
259
  ok: true, manifest, moduleJson,
@@ -216,4 +266,4 @@ async function verifyModuleArtifact(tgz, { key, maxBytes = MAX_TARBALL_BYTES, in
216
266
  return out;
217
267
  }
218
268
 
219
- module.exports = { MAX_TARBALL_BYTES, packModule, verifyModuleArtifact, deniedFiles };
269
+ module.exports = { MAX_TARBALL_BYTES, HOWTO_FILE, HOWTO_SECTIONS, packModule, verifyModuleArtifact, deniedFiles, checkHowto };
@@ -8,8 +8,8 @@
8
8
  //
9
9
  // bongos module new <key>
10
10
  // Scaffolds modules/<key>/ — a VALID, loader-discoverable module.json (M03),
11
- // the dir skeleton (routes/, migrations/, ui/), a sample route factory, and a
12
- // nested CLAUDE.md — with NO edit to any core file. The loader
11
+ // the dir skeleton (routes/, migrations/, ui/), a sample route factory, a
12
+ // HOWTO.md template for the store page (ADR 0347), and a nested CLAUDE.md — with NO edit to any core file. The loader
13
13
  // (src/module-loader/loader.js) discovers it; enabling it (config/modules.json
14
14
  // or env) mounts its routes behind the kernel's auth — the strangler endgame
15
15
  // where adding a feature is "drop a directory," not "edit routes.js + modules.js".
@@ -94,6 +94,12 @@ function readJson(p) {
94
94
  catch (e) { if (e.code === 'ENOENT') return null; throw e; }
95
95
  }
96
96
 
97
+ // A text file's contents, or null when it does not exist.
98
+ function readText(p) {
99
+ try { return fs.readFileSync(p, 'utf8'); }
100
+ catch (e) { if (e.code === 'ENOENT') return null; throw e; }
101
+ }
102
+
97
103
  function envPrefix() {
98
104
  const { FALLBACK_ENV_PREFIX } = require('../../src/instance-config');
99
105
  try { return require('../../src/branding').branding().envPrefix || FALLBACK_ENV_PREFIX; }
@@ -213,9 +219,39 @@ Put \`NNN_*.sql\` under \`migrations/\` and set \`contributes.migrations: true\`
213
219
  \`require('../../../src/module-api')\` — the doorway — is the ONLY core import
214
220
  allowed. No deep core imports, no sibling-module imports; cooperate through
215
221
  kernel seams (\`api.registerProvider\` / \`api.resolve\` / \`api.on\` / \`api.emit\`).
222
+
223
+ ## The how-to (\`HOWTO.md\`)
224
+ This CLAUDE.md is for AI sessions working *inside* the module. \`HOWTO.md\` is for the
225
+ person who installs it — the store shows it as the module's page (ADR 0347). It is
226
+ plain Markdown, so any AI or person can write it. \`bongos module publish\` refuses
227
+ the module until each of its five sections has some text; replace the prompts in
228
+ \`<!-- -->\` with real answers. An optional \`howto.artifactUrl\` in module.json can
229
+ link a Claude page as an extra — never instead of the file.
216
230
  `;
217
231
  }
218
232
 
233
+ // The HOWTO.md a fresh module starts from (ADR 0347 D2): every required section as a
234
+ // heading, with its prompt in an HTML comment. checkHowto strips comments, so an
235
+ // untouched scaffold fails the publish gate until the author writes each section.
236
+ function howtoTemplate(key, manifest) {
237
+ const { HOWTO_SECTIONS } = require('./module-artifact');
238
+ const prompts = {
239
+ 'What it does': 'A few sentences: what this module adds, and who it is for.',
240
+ 'Install and enable': `How to get it (\`bongos module install ${key}\`) and switch it on (hall Modules tab).`,
241
+ 'How to use it': 'Its commands, routes and hall surfaces, with one worked example.',
242
+ 'Configuration': 'Env vars and settings, with their defaults. "None." is a fine answer.',
243
+ 'Limits and known issues': 'What it does not do, and anything known to go wrong. "None known." is a fine answer.',
244
+ };
245
+ const sections = HOWTO_SECTIONS.map((name) => `## ${name}\n\n<!-- ${prompts[name]} -->\n`).join('\n');
246
+ return `# ${manifest.title} — how to use it
247
+
248
+ <!-- Written for the person who installs this module, or for any AI helping them.
249
+ The store shows this file as the module's page. Every section below must have
250
+ some text before \`bongos module publish\` will accept the module (ADR 0347). -->
251
+
252
+ ${sections}`;
253
+ }
254
+
219
255
  // Has the core advanced past a deprecated module's declared removal version?
220
256
  // `removeAfter: "1.12.0"` means "may be deleted once core is ABOVE 1.12.0".
221
257
  function isPastRemoveAfter(maintenance, core) {
@@ -283,6 +319,7 @@ function scaffoldModule({ key, modulesDir = MODULES_DIR, core = coreVersion(), f
283
319
  'module.json': JSON.stringify(manifest, null, 2) + '\n',
284
320
  [`routes/${key}.js`]: routeTemplate(key),
285
321
  'CLAUDE.md': claudeMdTemplate(key, manifest),
322
+ 'HOWTO.md': howtoTemplate(key, manifest),
286
323
  // git does not track empty dirs — keep the rest of the skeleton with placeholders.
287
324
  'migrations/.gitkeep': '',
288
325
  'ui/.gitkeep': '',
@@ -621,6 +658,7 @@ function cmdNew(args, { log = console.log, errlog = console.error } = {}) {
621
658
  log(` • credit it — fill in author/origin/maintainer in module.json (license defaults to AGPL-3.0)`);
622
659
  log(` • enable it — add "${key}": true to config/modules.json (or set <PREFIX>_MODULE_${ENVKEY}=1)`);
623
660
  log(` • build it — fill routes/${key}.js; add migrations/ + ui/ (see modules/${key}/CLAUDE.md)`);
661
+ log(` • explain it — fill in every section of modules/${key}/HOWTO.md (publish refuses it until you do)`);
624
662
  log(' • verify — bongos upgrade (checks coreVersion compatibility)');
625
663
  return 0;
626
664
  }
@@ -672,7 +710,7 @@ function cmdUpgrade(_args, { log = console.log, errlog = console.error, gather =
672
710
  return 0;
673
711
  }
674
712
 
675
- function cmdCheck(args, { log = console.log, errlog = console.error, check = checkModulePublishability, sign = recordSignOff } = {}) {
713
+ function cmdCheck(args, { log = console.log, errlog = console.error, check = checkModulePublishability, sign = recordSignOff, howtoCheck } = {}) {
676
714
  const key = args.find((a) => !a.startsWith('-'));
677
715
  if (!key) {
678
716
  errlog('usage: bongos module check <key> [--sign-off "Name <email>"]');
@@ -692,6 +730,13 @@ function cmdCheck(args, { log = console.log, errlog = console.error, check = che
692
730
  for (const f of result.findings) errlog(` ✗ ${f.kind}: ${f.detail}`);
693
731
  }
694
732
 
733
+ // The store's how-to gate (ADR 0347 D4), reported here so an author learns early.
734
+ // Advisory only: it gates `bongos module publish`, never an upstream submit.
735
+ const { checkHowto, HOWTO_FILE } = require('./module-artifact');
736
+ const howto = (howtoCheck || ((k) => checkHowto(readText(path.join(MODULES_DIR, k, HOWTO_FILE)))))(key);
737
+ if (howto.ok) log(` ✓ ${HOWTO_FILE} has every required section — ready for \`bongos module publish\`.`);
738
+ else for (const p of howto.problems) log(` ! ${p} — needed before \`bongos module publish\` (not for an upstream submit)`);
739
+
695
740
  if (!result.ok) {
696
741
  errlog(`\nREFUSING: "${key}" is not clear to submit upstream. Fix the finding(s) above and re-run.`);
697
742
  return 1;
@@ -814,6 +859,14 @@ async function cmdPublish(args, {
814
859
  catch (e) { errlog(`REFUSING: ${e.message}`); return 1; }
815
860
  const { tgz, sidecar } = packed;
816
861
  log(`bongos module publish — "${key}" ${sidecar.module_version} (needs core ${sidecar.core_version})`);
862
+ // The how-to gate (ADR 0347 D4) — the same checker the store re-runs on upload.
863
+ const howto = artifact.checkHowto(readText(path.join(modulesDir, key, artifact.HOWTO_FILE)));
864
+ if (!howto.ok) {
865
+ for (const p of howto.problems) errlog(` ✗ ${p}`);
866
+ errlog(`REFUSING: "${key}" needs a complete ${artifact.HOWTO_FILE} before it can be published — fill in the section(s) above.`);
867
+ return 1;
868
+ }
869
+ log(` ✓ ${artifact.HOWTO_FILE} has all ${artifact.HOWTO_SECTIONS.length} required sections`);
817
870
  log(` ✓ packed ${sidecar.file_count} file(s), ${tgz.length} bytes — tree ${sidecar.tree_sha256.slice(0, 12)}…`);
818
871
  if (tgz.length > artifact.MAX_TARBALL_BYTES) {
819
872
  errlog(`REFUSING: the tarball is ${tgz.length} bytes, over the store's ${artifact.MAX_TARBALL_BYTES}-byte cap.`);
@@ -1130,7 +1183,7 @@ async function cmdUpdate(args, {
1130
1183
  function printHelp(out = console.log) {
1131
1184
  out('bongos module — scaffold + manage Cloud Bongos modules\n');
1132
1185
  out('Usage:');
1133
- out(' bongos module new <key> Scaffold modules/<key>/ (manifest + skeleton + sample route + CLAUDE.md)');
1186
+ out(' bongos module new <key> Scaffold modules/<key>/ (manifest + skeleton + sample route + HOWTO.md + CLAUDE.md)');
1134
1187
  out(' bongos module list [--catalog] [--search <text>] [--json]');
1135
1188
  out(' Browse the module catalog — what you can enable, with author + origin credit');
1136
1189
  out(' bongos upgrade Pre-check enabled modules against the core version (fail-closed)');
@@ -308,9 +308,10 @@ module.exports = function buildModulesRouter() {
308
308
  // the store (task 1004271, ADR 0338 D1). Body: the gzip tarball
309
309
  // `bongos module publish` builds (scripts/gds/module-artifact.js), sent as
310
310
  // application/gzip. The route trusts nothing but the bytes: it recomputes every
311
- // hash, re-runs the publish denylist and validates module.json itself
312
- // (verifyModuleArtifact), then keeps the tarball on the control plane's disk and
313
- // INSERTs the version row in one transaction (module-store.js). A key's first
311
+ // hash, re-runs the publish denylist, validates module.json and checks that
312
+ // HOWTO.md has its required sections (ADR 0347 D4) itself (verifyModuleArtifact),
313
+ // then keeps the tarball on the control plane's disk and INSERTs the version row
314
+ // in one transaction (module-store.js). A key's first
314
315
  // publish makes the caller its author; after that only the author may publish,
315
316
  // a delisted key takes nothing new, and a version is published once and never
316
317
  // changed.
@@ -333,7 +334,7 @@ module.exports = function buildModulesRouter() {
333
334
  return res.fail('empty_artifact', { status: 400, message: 'Send the tarball as the body, Content-Type: application/gzip.' });
334
335
  }
335
336
 
336
- const v = await moduleArtifact.verifyModuleArtifact(tgz, { key });
337
+ const v = await moduleArtifact.verifyModuleArtifact(tgz, { key, requireHowto: true });
337
338
  if (!v.ok) {
338
339
  return res.fail(v.code, { status: v.code === 'artifact_too_large' ? 413 : 422, message: v.message });
339
340
  }
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.43'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.44'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -53,6 +53,9 @@
53
53
  // // actually declares in `contributes`, and every entry must be
54
54
  // // one it actually lists there — a typo'd exemption is an error,
55
55
  // // never a silent pass.
56
+ // "howto": { // OPTIONAL. Extras beside the module's HOWTO.md (ADR 0347 D3).
57
+ // "artifactUrl": "https://claude.ai/…" // A Claude page with the same how-to, shown as an extra,
58
+ // }, // never graded and never required — the file is the how-to.
56
59
  // "provides": ["reward"], // OPTIONAL. seam PORTS this module registers a provider for.
57
60
  // "consumes": ["grade"], // OPTIONAL. seam ports this module resolves (required capabilities).
58
61
  // "prerequisites": { "modules": ["economy"] }, // OPTIONAL. other modules that must be enabled.
@@ -100,6 +103,17 @@ const ATTRIBUTION_KEYS = ['author', 'origin', 'license', 'maintainer'];
100
103
  // Deliberately NOT exported: the validator and its own error message are the only
101
104
  // readers, and an export nothing imports is dead weight the knip ratchet counts.
102
105
  const SPEND_KEYS = ['requiresPayer'];
106
+ // The `howto` block (ADR 0347 D3) — closed for the same reason; not exported either.
107
+ const HOWTO_KEYS = ['artifactUrl'];
108
+
109
+ // An https link on claude.ai (or a subdomain). Shape only: the store never fetches it.
110
+ function isClaudeArtifactUrl(v) {
111
+ if (!isStr(v)) return false;
112
+ let u;
113
+ try { u = new URL(v); } catch { return false; }
114
+ const host = u.hostname.toLowerCase();
115
+ return u.protocol === 'https:' && !u.username && !u.password && (host === 'claude.ai' || host.endsWith('.claude.ai'));
116
+ }
103
117
 
104
118
  const MAINTENANCE_STATUSES = ['core-maintained', 'maintained', 'deprecated', 'orphaned'];
105
119
  const MAINTENANCE_KEYS = ['status', 'since', 'removeAfter', 'successor', 'note'];
@@ -306,6 +320,20 @@ function validateManifest(obj) {
306
320
  }
307
321
  }
308
322
 
323
+ // --- optional how-to extras (ADR 0347 D3) ---
324
+ if ('howto' in obj) {
325
+ const h = obj.howto;
326
+ if (!isObj(h)) E('howto: must be an object (e.g. { "artifactUrl": "https://claude.ai/…" })');
327
+ else {
328
+ for (const k of Object.keys(h)) {
329
+ if (!HOWTO_KEYS.includes(k)) E(`howto.${k}: unknown key (allowed: ${HOWTO_KEYS.join(', ')})`);
330
+ }
331
+ if ('artifactUrl' in h && !isClaudeArtifactUrl(h.artifactUrl)) {
332
+ E('howto.artifactUrl: must be an https:// link on claude.ai when present');
333
+ }
334
+ }
335
+ }
336
+
309
337
  // --- optional seam declarations ---
310
338
  if ('provides' in obj && !isStrArray(obj.provides)) E('provides: must be an array of non-empty strings (port names)');
311
339
  if ('consumes' in obj && !isStrArray(obj.consumes)) E('consumes: must be an array of non-empty strings (port names)');
@@ -0,0 +1,21 @@
1
+ // .ov-row--lead is rendered only by the Collab page, so its grid rule lives in
2
+ // collab.css, not oversight.css (task 1003637).
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { readFileSync } from 'node:fs';
6
+
7
+ const read = (f) => readFileSync(new URL(`../modules/hall-ui/public/${f}`, import.meta.url), 'utf8');
8
+
9
+ test('oversight.css no longer defines .ov-row--lead', () => {
10
+ assert.doesNotMatch(read('oversight.css'), /\.ov-row--lead/);
11
+ });
12
+
13
+ test('collab.css owns the base rule and its 640px companion', () => {
14
+ const css = read('collab.css');
15
+ assert.match(css, /^\.ov-row--lead \{ grid-template-columns: auto minmax\(0, 1fr\) auto; \}/m);
16
+ assert.match(css, /@media \(max-width: 640px\) \{\s*\.ov-row--lead \{[^}]*\}\s*\.ov-row--lead > \.ov-row__right \{ grid-column: 2; \}/);
17
+ });
18
+
19
+ test('collab.js still renders the class, so the rule is not dead', () => {
20
+ assert.match(read('collab.js'), /ov-row--lead/);
21
+ });
@@ -370,3 +370,25 @@ test('every bundled module with a declarativeSeams block validates against its o
370
370
  }
371
371
  assert.deepEqual(bad, [], bad.join('\n'));
372
372
  });
373
+
374
+ // --- howto.artifactUrl (ADR 0347 D3, task 1004365) ---
375
+
376
+ test('howto.artifactUrl: optional, and an https claude.ai link is accepted', () => {
377
+ assert.equal(validateManifest(baseManifest()).valid, true);
378
+ for (const url of ['https://claude.ai/artifact/abc123', 'https://claude.ai/code/artifact/0b1c']) {
379
+ const r = validateManifest({ ...baseManifest(), howto: { artifactUrl: url } });
380
+ assert.equal(r.valid, true, `${url}: ${r.errors.join('; ')}`);
381
+ }
382
+ });
383
+
384
+ test('howto.artifactUrl: anything but an https claude.ai link is refused, as is an unknown key', () => {
385
+ for (const url of ['http://claude.ai/artifact/x', 'https://evil.example/claude.ai', 'https://claude.ai.evil.example/x',
386
+ 'https://user:pw@claude.ai/x', 'javascript:alert(1)', '', 42]) {
387
+ const r = validateManifest({ ...baseManifest(), howto: { artifactUrl: url } });
388
+ assert.equal(r.valid, false, String(url));
389
+ assert.ok(r.errors.some((e) => e.startsWith('howto.artifactUrl')), r.errors.join('; '));
390
+ }
391
+ assert.ok(validateManifest({ ...baseManifest(), howto: { artifactURL: 'https://claude.ai/x' } }).errors
392
+ .some((e) => e.startsWith('howto.artifactURL: unknown key')));
393
+ assert.equal(validateManifest({ ...baseManifest(), howto: 'https://claude.ai/x' }).valid, false);
394
+ });
@@ -27,12 +27,21 @@ function moduleJson(over = {}) {
27
27
  };
28
28
  }
29
29
 
30
- // A temp modules/ root holding one module. files: { relPath: content }.
30
+ // A how-to with all five required sections (ADR 0347 D2).
31
+ const HOWTO = [
32
+ '# Weather', '', '## What it does', 'Shows the weather.', '', '## Install and enable',
33
+ '`bongos module install weather`, then switch it on.', '', '## How to use it', 'Open /weather.', '',
34
+ '## Configuration', 'None.', '', '## Limits and known issues', 'None known.', '',
35
+ ].join('\n');
36
+
37
+ // A temp modules/ root holding one module. files: { relPath: content }; a null
38
+ // content leaves that file out.
31
39
  function fixture(files = {}, mj = moduleJson()) {
32
40
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-publish-'));
33
41
  const dir = path.join(root, mj.key);
34
- const all = { 'module.json': JSON.stringify(mj, null, 2), 'routes/weather.js': 'module.exports = () => null;\n', ...files };
42
+ const all = { 'module.json': JSON.stringify(mj, null, 2), 'routes/weather.js': 'module.exports = () => null;\n', 'HOWTO.md': HOWTO, ...files };
35
43
  for (const [rel, body] of Object.entries(all)) {
44
+ if (body === null) continue;
36
45
  fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
37
46
  fs.writeFileSync(path.join(dir, rel), body);
38
47
  }
@@ -54,12 +63,12 @@ function retar(tgz, edit) {
54
63
  test('pack: the tarball is the core release format — package/ root, inner manifest, a tree pin', async () => {
55
64
  const { tgz, manifest, sidecar } = pack(fixture());
56
65
  const names = format.readTar(tgz).map((e) => e.name).sort();
57
- assert.deepEqual(names, ['package/.bongos-module.json', 'package/module.json', 'package/routes/weather.js']);
66
+ assert.deepEqual(names, ['package/.bongos-module.json', 'package/HOWTO.md', 'package/module.json', 'package/routes/weather.js']);
58
67
  assert.equal(manifest.artifact, 'bongos-module');
59
68
  assert.equal(manifest.module_key, 'weather');
60
69
  assert.equal(manifest.module_version, '1.2.0');
61
70
  assert.equal(manifest.core_version, '^1.0.0');
62
- assert.equal(manifest.file_count, 2);
71
+ assert.equal(manifest.file_count, 3);
63
72
  assert.equal(manifest.tree_sha256, format.canonicalTreeHash(manifest.files));
64
73
  assert.equal(sidecar.tarball.sha256, format.sha256hex(tgz));
65
74
  assert.equal(sidecar.tarball.name, 'bongos-module-weather-1.2.0.tgz');
@@ -302,6 +311,81 @@ test('cli: uploads the packed bytes, and a store refusal is a failure exit with
302
311
  assert.ok(out.lines.some((l) => /HTTP 409/.test(l) && /already published/.test(l)));
303
312
  });
304
313
 
314
+ // ---- the how-to gate (ADR 0347, task 1004365) -----------------------------------
315
+
316
+ test('howto: a complete how-to passes, headings in any order and any case', () => {
317
+ assert.deepEqual(artifact.checkHowto(HOWTO), { ok: true, problems: [] });
318
+ const reordered = HOWTO.replace('## Configuration\nNone.\n', '').replace('# Weather', '# Weather\n## CONFIGURATION\nNone.');
319
+ assert.equal(artifact.checkHowto(reordered).ok, true);
320
+ });
321
+
322
+ test('howto: a missing file, a missing section and an empty section are each refused by name', () => {
323
+ assert.deepEqual(artifact.checkHowto(null).problems, ['HOWTO.md is missing — every store module ships one (ADR 0347)']);
324
+ assert.deepEqual(artifact.checkHowto(HOWTO.replace('## Configuration\nNone.\n', '')).problems,
325
+ ['HOWTO.md: section "Configuration" is missing']);
326
+ assert.deepEqual(artifact.checkHowto(HOWTO.replace('Open /weather.', '')).problems,
327
+ ['HOWTO.md: section "How to use it" is empty']);
328
+ });
329
+
330
+ test('howto: a prompt in a comment, a sub-heading alone, or a heading inside a code fence does not count', () => {
331
+ assert.deepEqual(artifact.checkHowto(HOWTO.replace('None known.', '<!-- say what goes wrong -->')).problems,
332
+ ['HOWTO.md: section "Limits and known issues" is empty']);
333
+ assert.deepEqual(artifact.checkHowto(HOWTO.replace('None known.', '### Later')).problems,
334
+ ['HOWTO.md: section "Limits and known issues" is empty']);
335
+ const fenced = HOWTO.replace('## Configuration\nNone.\n', '').replace('Open /weather.', 'Open /weather.\n```\n## Configuration\n```');
336
+ assert.deepEqual(artifact.checkHowto(fenced).problems, ['HOWTO.md: section "Configuration" is missing']);
337
+ });
338
+
339
+ test('howto: the scaffold\'s template has every section but fails the gate until it is written', () => {
340
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-howto-'));
341
+ moduleCli.scaffoldModule({ key: 'weather', modulesDir: root, core: '1.0.0' });
342
+ const text = fs.readFileSync(path.join(root, 'weather', 'HOWTO.md'), 'utf8');
343
+ const res = artifact.checkHowto(text);
344
+ assert.deepEqual(res.problems, artifact.HOWTO_SECTIONS.map((s) => `HOWTO.md: section "${s}" is empty`));
345
+ });
346
+
347
+ test('howto: the file is packed into the tarball, so its hash pins it to the version', async () => {
348
+ const v = await artifact.verifyModuleArtifact(pack(fixture()).tgz, { key: 'weather', requireHowto: true });
349
+ assert.equal(v.ok, true, v.message);
350
+ assert.ok(v.manifest.files.some((f) => f.path === 'HOWTO.md'));
351
+ });
352
+
353
+ test('howto: verify refuses an incomplete how-to only when asked, so older versions still install', async () => {
354
+ const tgz = pack(fixture({ 'HOWTO.md': null })).tgz;
355
+ assert.equal((await artifact.verifyModuleArtifact(tgz, { key: 'weather' })).ok, true, 'install/update verify is unchanged');
356
+ const v = await artifact.verifyModuleArtifact(tgz, { key: 'weather', requireHowto: true });
357
+ assert.equal(v.code, 'howto_incomplete');
358
+ assert.match(v.message, /HOWTO\.md is missing/);
359
+ });
360
+
361
+ test('cli: publish refuses a missing or incomplete how-to by name, before anything is uploaded', async () => {
362
+ for (const [files, re] of [
363
+ [{ 'HOWTO.md': null }, /HOWTO\.md is missing/],
364
+ [{ 'HOWTO.md': HOWTO.replace('## Configuration\nNone.\n', '') }, /section "Configuration" is missing/],
365
+ [{ 'HOWTO.md': HOWTO.replace('Shows the weather.', '') }, /section "What it does" is empty/],
366
+ ]) {
367
+ const out = sink();
368
+ let uploaded = false;
369
+ const code = await moduleCli.cmdPublish(['weather'], {
370
+ log: out.log, errlog: out.log, modulesDir: fixture(files), upload: async () => { uploaded = true; },
371
+ });
372
+ assert.equal(code, 1);
373
+ assert.equal(uploaded, false);
374
+ assert.ok(out.lines.some((l) => re.test(l)), out.lines.join('\n'));
375
+ }
376
+ });
377
+
378
+ test('cli: module check reports the how-to as advice and never fails an upstream check on it', () => {
379
+ const out = sink();
380
+ const code = moduleCli.cmdCheck(['weather'], {
381
+ log: out.log, errlog: out.log,
382
+ check: () => ({ ok: true, findings: [] }),
383
+ howtoCheck: () => artifact.checkHowto(null),
384
+ });
385
+ assert.equal(code, 0);
386
+ assert.ok(out.lines.some((l) => /HOWTO\.md is missing/.test(l) && /bongos module publish/.test(l)));
387
+ });
388
+
305
389
  test('cli: no key is a usage error', async () => {
306
390
  assert.equal(await moduleCli.cmdPublish([], { log: () => {}, errlog: () => {} }), 2);
307
391
  });
@@ -66,13 +66,17 @@ async function invoke(handle, req) {
66
66
  return res;
67
67
  }
68
68
 
69
- function packed(version = '1.0.0') {
69
+ // A how-to with all five required sections (ADR 0347 D2).
70
+ const HOWTO = artifact.HOWTO_SECTIONS.map((h) => `## ${h}\nSome text.\n`).join('\n');
71
+
72
+ function packed(version = '1.0.0', { howto = HOWTO } = {}) {
70
73
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-src-'));
71
74
  const dir = path.join(root, 'weather');
72
75
  fs.mkdirSync(dir);
73
76
  fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({
74
77
  key: 'weather', title: 'Weather', description: 'd', version, coreVersion: '^1.0.0', contributes: {},
75
78
  }));
79
+ if (howto !== null) fs.writeFileSync(path.join(dir, 'HOWTO.md'), howto);
76
80
  return artifact.packModule('weather', { modulesDir: root, modeOf: () => '644' }).tgz;
77
81
  }
78
82
 
@@ -119,3 +123,18 @@ test('a tarball for another key, garbage, or no body is refused before the regis
119
123
  assert.equal((await invoke(route.handle, { params: { key: 'Bad_Key' }, body: packed(), builder: { id: 42 } })).body.error, 'bad_module_key');
120
124
  assert.equal(state.versions.length, before);
121
125
  });
126
+
127
+ test('an upload that skipped the CLI\'s how-to check is refused by the store, and nothing is kept', async () => {
128
+ const before = state.versions.length;
129
+ const missing = await invoke(route.handle, { params: { key: 'weather' }, body: packed('3.0.0', { howto: null }), builder: { id: 42 } });
130
+ assert.equal(missing.statusCode, 422);
131
+ assert.equal(missing.body.error, 'howto_incomplete');
132
+ assert.match(missing.body.message, /HOWTO\.md is missing/);
133
+ const empty = await invoke(route.handle, {
134
+ params: { key: 'weather' }, body: packed('3.0.0', { howto: HOWTO.replace('## Configuration\nSome text.', '## Configuration\n') }), builder: { id: 42 },
135
+ });
136
+ assert.equal(empty.body.error, 'howto_incomplete');
137
+ assert.match(empty.body.message, /section "Configuration" is empty/);
138
+ assert.equal(state.versions.length, before);
139
+ assert.ok(!fs.existsSync(path.join(STORE, 'weather', 'weather-3.0.0.tgz')));
140
+ });