@bongos/core 1.19.795 → 1.19.797

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.795",
6
- "core_contract": "1.19.795",
7
- "source_commit": "59f35c05e7edb8428f4c532e5032e9333bd7a0b9",
5
+ "core_version": "1.19.797",
6
+ "core_contract": "1.19.797",
7
+ "source_commit": "8f5cae915f47995fbf47d3adaeeb356c02f4a9f7",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-17T00:54:38.883Z",
9
+ "built_at": "2026-09-17T01:13:47.184Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 486,
13
13
  "agent_docs_stubbed": 25,
14
- "functional_verbatim": 2182,
14
+ "functional_verbatim": 2183,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2693,
20
- "tree_sha256": "bb912c280be9ea67a9825f8ee7e6bbc68b2be165c826b3fb40185a0badd7cd9d",
19
+ "file_count": 2694,
20
+ "tree_sha256": "c15e5cbc3148cc9a221215b7607ed8cdc79df894902b2e7390bc9baa08d49bc6",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -207,7 +207,7 @@
207
207
  {
208
208
  "path": ".claude/skills/planning-session/SKILL.md",
209
209
  "mode": "0000644",
210
- "sha256": "ede2aa15718bff19d38ffa7f42d64bae7490a9025f7e85d43c43306da3f611b6"
210
+ "sha256": "9320b9a04d36d3169e5a01e4fb5dc595fe84ef292541307fd17974a283b4c509"
211
211
  },
212
212
  {
213
213
  "path": ".claude/skills/priority-session/SKILL.md",
@@ -1997,12 +1997,12 @@
1997
1997
  {
1998
1998
  "path": "docs/copy-inventory.md",
1999
1999
  "mode": "0000644",
2000
- "sha256": "ec93785011f13f27444bd8ccfe9fe24da8cd125cd1b440bb974b78ebe1b28533"
2000
+ "sha256": "ab5057dfc9880545e8d2ac6ac44eb86af18f13a7efe1a071c9a3b9910a2ec5b6"
2001
2001
  },
2002
2002
  {
2003
2003
  "path": "docs/copy-registry.json",
2004
2004
  "mode": "0000644",
2005
- "sha256": "76dfd0c0caaedc010f832b44a3acef437f7352e86090cc46f7ce1edb15b6f192"
2005
+ "sha256": "8306b8b8748398081ddece28d92a5dd78b29facc39b55b394bb1b0877249264e"
2006
2006
  },
2007
2007
  {
2008
2008
  "path": "docs/design/apex-pass-2-direction.md",
@@ -2527,7 +2527,7 @@
2527
2527
  {
2528
2528
  "path": "docs/module-api-changelog.md",
2529
2529
  "mode": "0000644",
2530
- "sha256": "d9df5939232118367f1db9c0127c514a19f625aa46a8375e65dda21705e84671"
2530
+ "sha256": "e0e6a2d6f7b83b899430db2acfa43e9385fd35237cf0245cf54c415be7fe9424"
2531
2531
  },
2532
2532
  {
2533
2533
  "path": "docs/modules-contract.md",
@@ -4677,22 +4677,22 @@
4677
4677
  {
4678
4678
  "path": "modules/hall-ui/public/collab-lib.js",
4679
4679
  "mode": "0000644",
4680
- "sha256": "32099a90da4ff3deb49027102d4a42b7616d5f576340e74e0416b0dd1588a408"
4680
+ "sha256": "b5bb0d5f86879fe7fb154aaa819f2c5bceda50138f6c7cf9150bce2d0b51a832"
4681
4681
  },
4682
4682
  {
4683
4683
  "path": "modules/hall-ui/public/collab.css",
4684
4684
  "mode": "0000644",
4685
- "sha256": "964d51e697532fa88c6f1da5cc0e46f47d25ecef3a5cdf55526d721eb781257a"
4685
+ "sha256": "12c967c9bbed5fa4d1cb58b57f6f70eb90fee9626c7cad8e30e3a36b1f8c8db8"
4686
4686
  },
4687
4687
  {
4688
4688
  "path": "modules/hall-ui/public/collab.html",
4689
4689
  "mode": "0000644",
4690
- "sha256": "1710a2bf97ffa11daff277148299751fe1ca468e038bfa98c5d62d60b914cbf9"
4690
+ "sha256": "664bbcc84bd92ed3762f5c74f265ac86f320995f02d0093c12758db6e8af35ed"
4691
4691
  },
4692
4692
  {
4693
4693
  "path": "modules/hall-ui/public/collab.js",
4694
4694
  "mode": "0000644",
4695
- "sha256": "039a053ee61e4627b4eaa352c0f1d5c976e4ecbffede2dad9bde63d560e28d01"
4695
+ "sha256": "101a428f4b1bc76117eec665710265b201c32dbce76e2ddfd20e7e82b036faf7"
4696
4696
  },
4697
4697
  {
4698
4698
  "path": "modules/hall-ui/public/collab.states.json",
@@ -7702,12 +7702,12 @@
7702
7702
  {
7703
7703
  "path": "package-lock.json",
7704
7704
  "mode": "0000644",
7705
- "sha256": "abe39cacffdf6a8359f19b7e7a5a06a7055f99637edc556db2124968bdae5eb8"
7705
+ "sha256": "21954c7eccc561a9ad23abf43f4511040f8b67231aeb91fa88e47d324c3ff18d"
7706
7706
  },
7707
7707
  {
7708
7708
  "path": "package.json",
7709
7709
  "mode": "0000644",
7710
- "sha256": "6f9922b6e8c2eb446a10daa28bd6a34da2097b36098a70db45f16010c8529217"
7710
+ "sha256": "4a4cd623c012c80ec9a7d18065e78a47209ed2ded81d282e796e9bca6ce7ce6b"
7711
7711
  },
7712
7712
  {
7713
7713
  "path": "public-docs/index.html",
@@ -9587,7 +9587,7 @@
9587
9587
  {
9588
9588
  "path": "src/module-api.js",
9589
9589
  "mode": "0000644",
9590
- "sha256": "e21607486d5b15d09ea4e0f1ad1a3a626c9dbaf3713aa601dd113e5e3d7bd13e"
9590
+ "sha256": "12ad8d673d39e15d3ccf283f17927e66dbcf499f1b5243f15bd6cb46545bb1e2"
9591
9591
  },
9592
9592
  {
9593
9593
  "path": "src/module-loader/catalog.js",
@@ -10979,6 +10979,11 @@
10979
10979
  "mode": "0000644",
10980
10980
  "sha256": "3c420d28341a6e5aaeb5d4af978e72acefec3c9bd7d97f66fd11a72517118606"
10981
10981
  },
10982
+ {
10983
+ "path": "tests/goal_unknown_module_hint.mjs",
10984
+ "mode": "0000644",
10985
+ "sha256": "7e7863c75b1282150fb090da90da8628f68a97873c27527978d806fa9d2c4835"
10986
+ },
10982
10987
  {
10983
10988
  "path": "tests/goal_wall_proof.mjs",
10984
10989
  "mode": "0000644",
@@ -11287,7 +11292,7 @@
11287
11292
  {
11288
11293
  "path": "tests/hall_collab_world.mjs",
11289
11294
  "mode": "0000644",
11290
- "sha256": "847b3c8ea9bfb6c4b047f311338934cedd7e59e3936552e7265612cd0e5302d7"
11295
+ "sha256": "d005de58e0eabd019f616ee6c77123c5880b67034ce33dc1b01d687a2b284b10"
11291
11296
  },
11292
11297
  {
11293
11298
  "path": "tests/hall_error_envelope.mjs",
@@ -134,6 +134,9 @@ For the version being planned, **group the agreed criteria into goals**. For eac
134
134
  - **`scope_modules`** — the module **keys** (NOT file paths) whose files this goal's members may touch. **This is the wall, and it is set here and only here — never self-granted.** Keep it tight: the modules the goal's work actually needs. Widening later goes through an explicit request a trusted rank approves (`POST /goals/:id/scope`; Archon for a `protected` module) — BV1.R64.
135
135
  > **The valid key list is the source of truth in [`src/bongos/module-scope-map.js`](../../../src/bongos/module-scope-map.js) — read it, don't trust this list.** It exports `moduleKeys()` (all keys), `protectedModules()` / `isProtectedModule(key)` (which are PROTECTED), and `scopeForModule(key)` (a key's globs + `protected` flag). Each key maps to path-globs; `protected` is **derived** (the key's globs overlap the trust/ship-pipeline/migration/authz core), and a goal scoped to any protected key is **Archon-only** to create. The current roster (run `node -e "const m=require('./src/bongos/module-scope-map');console.log(m.moduleKeys().map(k=>k+(m.isProtectedModule(k)?' 🔒':'')).join('\n'))"` to print it live):
136
136
  > - **No roster is written down here on purpose.** The previous snapshot went stale within a release (it still omitted a key that had been added), which is exactly the failure a "read it live" rule prevents. Run the one-liner above; `protected` is derived from glob overlap, so it changes when the globs change.
137
+ > - **That file is the SOURCE. The RUNNING SERVER is the GATE.** `POST /goals` validates `scope_modules` against the roster the LIVE server loaded — not the one in your checkout — so a key added since the last deploy is valid here and refused on the wire. Merging to main deploys nothing (gate 3 of [the release pipeline](../../../docs/recipes/core-release-pipeline.md)), and this is not a rare race: the module roster grows precisely during the versions when planning sessions run. On 2026-08-16 goal 1000069 was created with `government` in scope and came back `unknown: ["government"]` — the key was on main; the site was a release behind. **The SessionStart deploy-state line already tells you whether you are exposed.** If it says the site is behind main, treat any recently-added key as suspect before you compose the goal, not after.
138
+ > - **The refusal is not a dead end — it hands you the answer.** `unknown_module` comes back with `valid: [...]`: the LIVE roster, from the server that just refused you. One rejected call tells you exactly what will be accepted, so read it and re-point rather than trying keys one at a time. (`tests/goal_unknown_module_hint.mjs` pins that the refusal keeps carrying it, because this instruction is only true while it does.)
139
+ > - **Prefer a deployed alias where one exists, and write the re-point down.** Goal 1000069 was walled to the deprecated `governance` alias, which ADR 0174 deliberately kept carrying BOTH sets of globs through the transition — so the wall was identical, not weaker — and the goal description recorded that `scope_modules` should be re-pointed once the newer release went live. A goal walled to an alias with no note is a goal nobody re-points.
137
140
  - **No covering key? → file a module-map prep task FIRST.** Every `scope_modules` entry must be a real key from `moduleKeys()`. If a goal's planned work touches file paths that **no existing key covers** (e.g. a brand-new module dir, or code still fused under `src/bongos/` that isn't yet broken out as its own key), the scope wall cannot place that work. Do **not** invent a key. Instead, **add a prep task to this version** — `kind='refactor'`, scoped to `kernel` (the module that owns `module-scope-map.js`) — to extend `MODULE_GLOBS` so a key covers those paths, and make the dependent goal's tasks depend on it. Adding/editing a key is itself a `kernel`-scoped (Archon-gated) change, so flag it for Archon sign-off in this session.
138
141
  - **Join policy** — a normal-scope goal is **open-join** (any builder); a goal whose scope includes a **`protected`** module is **Archon-mediated** (a non-Archon can't self-join; an Archon admits Metic+ members). Creating a protected-scope goal is itself **Archon-only** — so flag those for Archon sign-off in this session.
139
142
  - **Optional seed tasks / lead** — any obvious first tasks, and who leads.
@@ -4,13 +4,13 @@
4
4
  > Deterministic on a given tree — regenerate and commit rather than hand-editing.
5
5
  > Machine-readable twin: [`copy-registry.json`](copy-registry.json).
6
6
 
7
- **1793 strings** across 3 surfaces · 1549 distinct pieces of copy · 1793 certain, 0 heuristic · 169 shown in more than one place.
7
+ **1795 strings** across 3 surfaces · 1551 distinct pieces of copy · 1795 certain, 0 heuristic · 169 shown in more than one place.
8
8
 
9
9
  ## Surfaces
10
10
 
11
11
  | surface | lives at | files | strings |
12
12
  |---|---|---|---|
13
- | `builders-hall` | `modules/hall-ui/public` | 85 | 1303 |
13
+ | `builders-hall` | `modules/hall-ui/public` | 85 | 1305 |
14
14
  | `status-dashboard` | `modules/status-ui/public` | 4 | 100 |
15
15
  | `landing` | `modules/public-landing/public` | 5 | 390 |
16
16
 
@@ -78,7 +78,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
78
78
  | `996edffc1966` | 2x | Access requests | modules/hall-ui/public/watch.html:46, modules/hall-ui/public/watch.html:61 |
79
79
  | … | | _129 more in the JSON registry_ | |
80
80
 
81
- ## `builders-hall` — 1303 strings
81
+ ## `builders-hall` — 1305 strings
82
82
 
83
83
  | id | conf | text | where |
84
84
  |---|---|---|---|
@@ -134,7 +134,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
134
134
  | `a3185a43c230` | certain | A sound plays at each turn of your work — a gentle chime when Claude finishes, a flourish when a task ships or | `modules/hall-ui/public/settings.html:333` |
135
135
  | `83b1274f9088` | certain | A tally of recorded work sessions, and the latest five. The full record lives on the Sessions page. | `modules/hall-ui/public/watch.html:103` |
136
136
  | `daf73127afff` | certain | A word on why | `modules/hall-ui/public/hall-render.js:294` |
137
- | `1b326b0be1a4` | certain | A {…} — you are a {…}. Mingle pairs across crafts. | `modules/hall-ui/public/collab.js:278` |
137
+ | `1b326b0be1a4` | certain | A {…} — you are a {…}. Mingle pairs across crafts. | `modules/hall-ui/public/collab.js:365` |
138
138
  | `996edffc1966` | certain | Access requests | `modules/hall-ui/public/watch.html:46` |
139
139
  | `996edffc1966` | certain | Access requests | `modules/hall-ui/public/watch.html:61` |
140
140
  | `303f8d90266f` | certain | Achievements could not be loaded. | `modules/hall-ui/public/builders.js:1158` |
@@ -173,7 +173,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
173
173
  | `5fcfdf8975cf` | certain | As configuration: | `modules/hall-ui/public/government.js:829` |
174
174
  | `01a0f29ff406` | certain | As first filed | `modules/hall-ui/public/idea.js:252` |
175
175
  | `688cc6dd38ee` | certain | Asked of you | `modules/hall-ui/public/collab.html:41` |
176
- | `b2656d0b2402` | certain | Asked {…} | `modules/hall-ui/public/collab.js:49` |
176
+ | `b2656d0b2402` | certain | Asked {…} | `modules/hall-ui/public/collab.js:53` |
177
177
  | `b2656d0b2402` | certain | Asked {…} | `modules/hall-ui/public/watch.js:488` |
178
178
  | `8c8ec2597036` | certain | Assigned {…} to builder {…}. | `modules/hall-ui/public/government.js:463` |
179
179
  | `46714066f2da` | certain | Audit log | `modules/hall-ui/public/watch.html:148` |
@@ -272,6 +272,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
272
272
  | `12ee050d0180` | certain | Copy desk | `modules/hall-ui/public/copy-desk.html:27` |
273
273
  | `1214ea8e0d1b` | certain | Copy desk — {{worldName}} | `modules/hall-ui/public/copy-desk.html:6` |
274
274
  | `1214ea8e0d1b` | certain | Copy desk — {{worldName}} | `modules/hall-ui/public/copy-desk.html:6` |
275
+ | `30764a4f0e28` | certain | Copy for Session Start | `modules/hall-ui/public/collab.js:91` |
275
276
  | `7cabea700b57` | certain | Copy not supported here — select the value and copy by hand. | `modules/hall-ui/public/box-panel.js:415` |
276
277
  | `ebc8a6c16a22` | certain | Copy parallel prompt | `modules/hall-ui/public/work.html:146` |
277
278
  | `a873e7c29d80` | certain | Copy prompt | `modules/hall-ui/public/task.js:614` |
@@ -304,7 +305,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
304
305
  | `90691bceaf68` | certain | Could not load | `modules/hall-ui/public/task.js:707` |
305
306
  | `589a4cfc5442` | certain | Could not load idea {…}. | `modules/hall-ui/public/ideas.js:644` |
306
307
  | `192c893fc64e` | certain | Could not load modules. | `modules/hall-ui/public/modules.js:318` |
307
- | `8a4b590fa1a0` | certain | Could not load requests. | `modules/hall-ui/public/collab.js:367` |
308
+ | `8a4b590fa1a0` | certain | Could not load requests. | `modules/hall-ui/public/collab.js:458` |
308
309
  | `7775fec0fa85` | certain | Could not load sessions. | `modules/hall-ui/public/sessions.js:275` |
309
310
  | `7775fec0fa85` | certain | Could not load sessions. | `modules/hall-ui/public/sessions.js:313` |
310
311
  | `b2147cc90e36` | certain | Could not load the idea template. Reload, or file from the terminal with capture.js. | `modules/hall-ui/public/ideas.js:626` |
@@ -659,7 +660,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
659
660
  | `c72e0bdb834a` | certain | Not declared in module.json — this is who ships the module. | `modules/hall-ui/public/modules.js:62` |
660
661
  | `361b353ded5e` | certain | Not enough shipped work yet to chart. | `modules/hall-ui/public/hall-widgets.js:184` |
661
662
  | `90230572d017` | certain | Not found | `modules/hall-ui/public/task.js:695` |
662
- | `8226c979db4d` | certain | Not now | `modules/hall-ui/public/collab.js:278` |
663
+ | `8226c979db4d` | certain | Not now | `modules/hall-ui/public/collab.js:365` |
663
664
  | `5ffcdc1a0176` | certain | Not scored — this builder’s own profile waived every signal, or the session predates this measure. | `modules/hall-ui/public/sessions.js:67` |
664
665
  | `61250b49a90b` | certain | Not scored. | `modules/hall-ui/public/idea.js:84` |
665
666
  | `8d6afe6def47` | certain | Not signed in | `modules/hall-ui/public/settings.html:40` |
@@ -765,6 +766,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
765
766
  | `b03e17693dcd` | certain | Read it as a table | `modules/hall-ui/public/drachmae.js:148` |
766
767
  | `b03e17693dcd` | certain | Read it as a table | `modules/hall-ui/public/watch.js:1167` |
767
768
  | `b6fcfdd652a7` | certain | Read it here | `modules/hall-ui/public/work.html:45` |
769
+ | `053df132e22c` | certain | Read the full ask | `modules/hall-ui/public/collab.js:91` |
768
770
  | `c5f19a888a15` | certain | Read the handoff notes | `modules/hall-ui/public/task.js:438` |
769
771
  | `f9e998f4af3d` | certain | Read to the end and the button below unlocks. It is an honour system — nothing here checks your comprehension. | `modules/hall-ui/public/primer.html:100` |
770
772
  | `26e6c9879035` | certain | Reading the diagrams | `modules/hall-ui/public/diagrams.html:39` |
@@ -828,7 +830,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
828
830
  | `e8e4e49fe8f4` | certain | Saves when you press Save; shows everywhere at once. | `modules/hall-ui/public/settings.html:88` |
829
831
  | `42cbc20a6ec8` | certain | Saves when you press Save; your next | `modules/hall-ui/public/settings.html:136` |
830
832
  | `fc9d783ed0f5` | certain | Say why it is better. Whoever reviews this decides it from your reason, without asking you. | `modules/hall-ui/public/copy-desk.js:446` |
831
- | `12c1ec21a2c6` | certain | Say yes | `modules/hall-ui/public/collab.js:278` |
833
+ | `12c1ec21a2c6` | certain | Say yes | `modules/hall-ui/public/collab.js:365` |
832
834
  | `46711f7bc3df` | certain | Search every string the product shows a person, by what it says or the file it lives in. | `modules/hall-ui/public/copy-desk.html:65` |
833
835
  | `5d2ba8e72fa1` | certain | Search failed. | `modules/hall-ui/public/copy-desk.js:343` |
834
836
  | `373a7639bcb9` | certain | Sections in this primer | `modules/hall-ui/public/primer.html:52` |
@@ -863,7 +865,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
863
865
  | `ac44b08aec92` | certain | Ship it | `modules/hall-ui/public/task.js:600` |
864
866
  | `b94a6b5b3d8c` | certain | Shipped with the core | `modules/hall-ui/public/modules.html:56` |
865
867
  | `0c0a50ba1666` | certain | Shipped {…}{…}, {…}. | `modules/hall-ui/public/task.js:581` |
866
- | `6dde12eb0c78` | certain | Show all {…} | `modules/hall-ui/public/collab.js:211` |
868
+ | `6dde12eb0c78` | certain | Show all {…} | `modules/hall-ui/public/collab.js:245` |
867
869
  | `14d32a35c15f` | certain | Show my cross-project record | `modules/hall-ui/public/settings.js:516` |
868
870
  | `dc0f9fe50a65` | certain | Show the setup guide again | `modules/hall-ui/public/index.html:64` |
869
871
  | `2af4c0f2b4b9` | certain | Showing {…} of {…}. | `modules/hall-ui/public/ideas.js:547` |
@@ -885,7 +887,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
885
887
  | `87cee78ca134` | certain | Skip to content | `modules/hall-ui/public/shell.js:588` |
886
888
  | `688ca7b6e30f` | certain | Slop — reads as machine-written | `modules/hall-ui/public/copy-desk.html:90` |
887
889
  | `82df09a08476` | certain | So the Archon knows who you are | `modules/hall-ui/public/hall-render.js:294` |
888
- | `7da6a848cd26` | certain | Someone helped | `modules/hall-ui/public/collab.js:161` |
890
+ | `7da6a848cd26` | certain | Someone helped | `modules/hall-ui/public/collab.js:195` |
889
891
  | `867df58c9aaa` | certain | Something else | `modules/hall-ui/public/copy-desk.html:95` |
890
892
  | `c377d8ed744b` | certain | Something went wrong. Try again. | `modules/hall-ui/public/profile-connections.js:275` |
891
893
  | `e0767e80206c` | certain | Sound for {…} | `modules/hall-ui/public/settings.js:311` |
@@ -3,9 +3,9 @@
3
3
  "generator": "scripts/gds/copy-inventory.js",
4
4
  "goal": 1000074,
5
5
  "counts": {
6
- "strings": 1793,
7
- "unique_ids": 1549,
8
- "certain": 1793,
6
+ "strings": 1795,
7
+ "unique_ids": 1551,
8
+ "certain": 1795,
9
9
  "heuristic": 0,
10
10
  "duplicate_ids": 169
11
11
  },
@@ -13,7 +13,7 @@
13
13
  "builders-hall": {
14
14
  "at": "modules/hall-ui/public",
15
15
  "files": 85,
16
- "strings": 1303
16
+ "strings": 1305
17
17
  },
18
18
  "status-dashboard": {
19
19
  "at": "modules/status-ui/public",
@@ -549,7 +549,7 @@
549
549
  "text": "Asked {…}",
550
550
  "occurrences": 2,
551
551
  "at": [
552
- "modules/hall-ui/public/collab.js:49",
552
+ "modules/hall-ui/public/collab.js:53",
553
553
  "modules/hall-ui/public/watch.js:488"
554
554
  ]
555
555
  },
@@ -2278,7 +2278,7 @@
2278
2278
  "surface": "builders-hall",
2279
2279
  "text": "A {…} — you are a {…}. Mingle pairs across crafts.",
2280
2280
  "file": "modules/hall-ui/public/collab.js",
2281
- "line": 278,
2281
+ "line": 365,
2282
2282
  "origin": "js-markup",
2283
2283
  "confidence": "certain"
2284
2284
  },
@@ -2629,7 +2629,7 @@
2629
2629
  "surface": "builders-hall",
2630
2630
  "text": "Asked {…}",
2631
2631
  "file": "modules/hall-ui/public/collab.js",
2632
- "line": 49,
2632
+ "line": 53,
2633
2633
  "origin": "js-markup",
2634
2634
  "confidence": "certain"
2635
2635
  },
@@ -3515,6 +3515,15 @@
3515
3515
  "origin": "html-title",
3516
3516
  "confidence": "certain"
3517
3517
  },
3518
+ {
3519
+ "id": "30764a4f0e28",
3520
+ "surface": "builders-hall",
3521
+ "text": "Copy for Session Start",
3522
+ "file": "modules/hall-ui/public/collab.js",
3523
+ "line": 91,
3524
+ "origin": "js-markup",
3525
+ "confidence": "certain"
3526
+ },
3518
3527
  {
3519
3528
  "id": "7cabea700b57",
3520
3529
  "surface": "builders-hall",
@@ -3808,7 +3817,7 @@
3808
3817
  "surface": "builders-hall",
3809
3818
  "text": "Could not load requests.",
3810
3819
  "file": "modules/hall-ui/public/collab.js",
3811
- "line": 367,
3820
+ "line": 458,
3812
3821
  "origin": "js-text-assign",
3813
3822
  "confidence": "certain"
3814
3823
  },
@@ -7003,7 +7012,7 @@
7003
7012
  "surface": "builders-hall",
7004
7013
  "text": "Not now",
7005
7014
  "file": "modules/hall-ui/public/collab.js",
7006
- "line": 278,
7015
+ "line": 365,
7007
7016
  "origin": "js-markup",
7008
7017
  "confidence": "certain"
7009
7018
  },
@@ -7952,6 +7961,15 @@
7952
7961
  "origin": "html-text",
7953
7962
  "confidence": "certain"
7954
7963
  },
7964
+ {
7965
+ "id": "053df132e22c",
7966
+ "surface": "builders-hall",
7967
+ "text": "Read the full ask",
7968
+ "file": "modules/hall-ui/public/collab.js",
7969
+ "line": 91,
7970
+ "origin": "js-markup",
7971
+ "confidence": "certain"
7972
+ },
7955
7973
  {
7956
7974
  "id": "c5f19a888a15",
7957
7975
  "surface": "builders-hall",
@@ -8524,7 +8542,7 @@
8524
8542
  "surface": "builders-hall",
8525
8543
  "text": "Say yes",
8526
8544
  "file": "modules/hall-ui/public/collab.js",
8527
- "line": 278,
8545
+ "line": 365,
8528
8546
  "origin": "js-markup",
8529
8547
  "confidence": "certain"
8530
8548
  },
@@ -8839,7 +8857,7 @@
8839
8857
  "surface": "builders-hall",
8840
8858
  "text": "Show all {…}",
8841
8859
  "file": "modules/hall-ui/public/collab.js",
8842
- "line": 211,
8860
+ "line": 245,
8843
8861
  "origin": "js-text-assign",
8844
8862
  "confidence": "certain"
8845
8863
  },
@@ -9037,7 +9055,7 @@
9037
9055
  "surface": "builders-hall",
9038
9056
  "text": "Someone helped",
9039
9057
  "file": "modules/hall-ui/public/collab.js",
9040
- "line": 161,
9058
+ "line": 195,
9041
9059
  "origin": "js-markup",
9042
9060
  "confidence": "certain"
9043
9061
  },
@@ -2064,5 +2064,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2064
2064
  landed since 1.19.793 with no explicit bump. run 35167814500. (task 1002620)
2065
2065
  1.19.795 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2066
2066
  landed since 1.19.794 with no explicit bump. run 35168408728. (task 1002620)
2067
+ 1.19.796 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2068
+ landed since 1.19.795 with no explicit bump. run 35169093667. (task 1002620)
2069
+ 1.19.797 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2070
+ landed since 1.19.796 with no explicit bump. run 35169694391. (task 1002620)
2067
2071
  ---------------------------------------------------------------------------
2068
2072
  ```
@@ -120,6 +120,69 @@
120
120
  return { title: m[1], rest: m[2].trim() };
121
121
  }
122
122
 
123
+ // THE CARD FACE IS TWO SENTENCES (owner direction, task 1003960). The
124
+ // ask-for-help skill asks a session to write up to 4000 characters — what is
125
+ // stuck, why this person, the steps, how to verify, what has been tried — and
126
+ // printing all of it on the face buried every row under whichever ask happened
127
+ // to be longest. So the face carries the ask's first sentence as the title and
128
+ // its second as the sub line, and everything else waits behind a door.
129
+ //
130
+ // WHAT IS BEHIND THE DOOR IS THE WHOLE ASK, not the leftover. A reader opening
131
+ // "the full ask" wants the document, and the copy control beside it hands that
132
+ // document to a fresh session verbatim — so `more` is the original text,
133
+ // untrimmed of its own opening, rather than a remainder that would copy as a
134
+ // sentence fragment with no beginning.
135
+ //
136
+ // A LINE BUDGET AS WELL AS A SENTENCE COUNT. Sentence-splitting alone is not
137
+ // enough: an ask written as one 4000-character paragraph with no full stop has
138
+ // exactly one sentence, and counting sentences would put all of it on the face.
139
+ // Each of the two lines is therefore also capped, and a line that had to be cut
140
+ // sends the whole ask behind the door.
141
+ var LEAD_MAX = 200;
142
+
143
+ // Trailing punctuation a cut can land on, hoisted so the literal is compiled
144
+ // once rather than per row per line.
145
+ var TRAILING_PUNCT = /[\s.,;:!?\u2013\u2014-]+$/;
146
+
147
+ // Cut at a word boundary, never mid-word, and mark the cut. The ellipsis is
148
+ // the only place this page abbreviates, and it is reached only when the asker
149
+ // wrote no sentence short enough to be a line — the full words are one click
150
+ // away either way.
151
+ function clipWords(s, max) {
152
+ var t = String(s == null ? '' : s);
153
+ if (t.length <= max) return { text: t, clipped: false };
154
+ var cut = t.slice(0, max);
155
+ var sp = cut.lastIndexOf(' ');
156
+ if (sp > max * 0.6) cut = cut.slice(0, sp);
157
+ return { text: cut.replace(TRAILING_PUNCT, '') + '\u2026', clipped: true };
158
+ }
159
+
160
+ // summaryOf(text) -> { title, lead, more }. `more` is '' when the ask already
161
+ // FITS on the face, which is not the same as "has two sentences or fewer". A
162
+ // door onto nothing is a lie about there being more to read; so is no door
163
+ // over words the reader cannot see.
164
+ //
165
+ // THE CASE THAT LOOKS LIKE A BUG AND IS NOT (grader, task 1003960): two
166
+ // sentences whose SECOND runs past LEAD_MAX gets a door, even though the ask
167
+ // has only two sentences. That is correct and deliberate. The lead was cut, so
168
+ // part of that second sentence is not on the face; hiding the door there would
169
+ // abbreviate an ask with no way to read the rest, which is the one thing this
170
+ // whole change exists to prevent. The door tracks WHAT IS UNREAD, never the
171
+ // sentence count — which is why `hidden` is `l.clipped || second.rest` and not
172
+ // `second.rest` alone.
173
+ function summaryOf(text) {
174
+ var full = String(text == null ? '' : text).trim();
175
+ var first = titleOf(full);
176
+ var t = clipWords(first.title, TITLE_MAX);
177
+ // No sentence short enough to be a headline: the face carries a lead-in only
178
+ // and the ask goes behind the door whole.
179
+ if (t.clipped) return { title: t.text, lead: '', more: full };
180
+ var second = first.rest ? titleOf(first.rest) : { title: '', rest: '' };
181
+ var l = clipWords(second.title, LEAD_MAX);
182
+ var hidden = l.clipped || !!second.rest;
183
+ return { title: t.text, lead: l.text, more: hidden ? full : '' };
184
+ }
185
+
123
186
  // The "asked of you" list reads in runs: what is asked of you BY NAME first
124
187
  // (a personal ask is the one you owe an answer), then one run per craft.
125
188
  function groupOf(req) {
@@ -223,7 +286,7 @@
223
286
  BANDS: BANDS, ageBand: ageBand, byAgeOldestFirst: byAgeOldestFirst, byNewestFirst: byNewestFirst,
224
287
  askerOf: askerOf, contextOf: contextOf,
225
288
  directionLine: directionLine, boardLine: boardLine,
226
- titleOf: titleOf, groupOf: groupOf,
289
+ titleOf: titleOf, summaryOf: summaryOf, clipWords: clipWords, LEAD_MAX: LEAD_MAX, groupOf: groupOf,
227
290
  httpUrl: httpUrl, initialOf: initialOf, faceOf: faceOf, askers: askers, splitShown: splitShown,
228
291
  };
229
292
  })();
@@ -120,6 +120,62 @@
120
120
  .cb-band { grid-template-columns: 1fr; }
121
121
  }
122
122
 
123
+ /* ---- the door onto the whole ask -------------------------------------------
124
+ Owner direction, task 1003960: a card shows two sentences and no more. What
125
+ the asker actually wrote — up to four thousand characters of context, steps
126
+ and what they already ruled out — waits behind this pair of controls, which
127
+ sit together under the two visible lines.
128
+
129
+ TWO CONTROLS, TWO WEIGHTS. The door is a text action in the accent ink, like
130
+ every other door on this page; the copy control is a pill, because it does
131
+ something rather than going somewhere, and because a reader scanning the board
132
+ for "take this into a session" should find a button shape. It keeps its own
133
+ capitals — .btn-ghost lowercases its label, and this one is a named action the
134
+ owner wrote, so it does not borrow that class. */
135
+ .cb-ask { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 7px 0 0; }
136
+ .cb-ask__door {
137
+ font: inherit;
138
+ font-size: 12.5px;
139
+ color: var(--accent-ink);
140
+ background: none;
141
+ border: 0;
142
+ padding: 0;
143
+ cursor: pointer;
144
+ }
145
+ .cb-ask__door:hover { text-decoration: underline; }
146
+ .cb-ask__copy {
147
+ font: inherit;
148
+ font-size: 12px;
149
+ line-height: 1;
150
+ color: var(--ink-soft);
151
+ background: none;
152
+ border: 1px solid var(--rule);
153
+ border-radius: var(--radius-pill);
154
+ padding: 5px 11px;
155
+ cursor: pointer;
156
+ }
157
+ .cb-ask__copy:hover { border-color: var(--ink-faint); color: var(--ink); }
158
+
159
+ /* The ask itself, opened in place. pre-wrap because an ask is written as a
160
+ briefing with its own paragraphs and steps, and collapsing them here would
161
+ make the page's own rendering less readable than the text a reader copies out
162
+ of it. Capped to the measure so it never becomes the wall of prose this
163
+ change exists to end. */
164
+ .cb-ask__full {
165
+ margin: 9px 0 0;
166
+ padding: 11px 13px;
167
+ background: var(--glass);
168
+ border-radius: var(--radius-sm);
169
+ white-space: pre-wrap;
170
+ font-size: 13px;
171
+ line-height: 1.6;
172
+ color: var(--ink-soft);
173
+ /* --measure is 48ch, and ch scales with the ELEMENT's font-size — spending it
174
+ at 13px would cap a briefing at 380px, well under a readable line. So this
175
+ one block states its own measure, computed at its own size. */
176
+ max-width: 62ch;
177
+ }
178
+
123
179
  /* ---- empties and the foot -------------------------------------------------- */
124
180
  .cb-empty p { font-size: 14px; }
125
181
  .cb-foot { margin: var(--sp-sm) 0 0; font-size: 12.5px; color: var(--ink-faint); max-width: var(--measure); }
@@ -98,7 +98,7 @@
98
98
  <script src="/builders/shell.js?v=2026-07-02-shell1"></script>
99
99
  <script src="/builders/palette.js" defer></script>
100
100
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
101
- <script src="/builders/collab-lib.js?v=2026-09-16-blocks"></script>
102
- <script src="/builders/collab.js?v=2026-09-16-blocks"></script>
101
+ <script src="/builders/collab-lib.js?v=2026-09-16-twosentence"></script>
102
+ <script src="/builders/collab.js?v=2026-09-16-twosentence"></script>
103
103
  </body>
104
104
  </html>
@@ -13,11 +13,15 @@
13
13
  //
14
14
  // A row is the family's .ov-row--lead: the person's FACE first (collab-lib
15
15
  // faceOf: the asker on what was asked of you and on the board, the addressee on
16
- // your own asks), the ask's first sentence as the title and the rest as the sub
17
- // (titleOf), the direction as a fact with a door to their profile, the task or
18
- // goal as a chip, and the AGE as a state pill in the right column, so a glance
19
- // down a list reads the ages in words and in the verification colours. Every
20
- // rule that can be pure lives in collab-lib.js where a test can execute it.
16
+ // your own asks), then the ask's first TWO sentences and no more (summaryOf),
17
+ // then the direction as a fact with a door to their profile, the task or goal as
18
+ // a chip, and the AGE as a state pill in the right column, so a glance down a
19
+ // list reads the ages in words and in the verification colours. Every rule that
20
+ // can be pure lives in collab-lib.js where a test can execute it.
21
+ //
22
+ // Under those two sentences, when there is more ask than fits: a door onto the
23
+ // whole thing and a control that copies it verbatim for a fresh session (task
24
+ // 1003960).
21
25
  //
22
26
  // MATCHING IS THE SERVER'S JOB, not this file's. GET /help-requests/for-me derives
23
27
  // the viewer's crafts from their own profile; sending a craft from here would be
@@ -55,10 +59,40 @@
55
59
  return `<span class="ov-row__when">${escapeHtml(d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }))}</span>`;
56
60
  }
57
61
 
58
- function titleHtml(req) {
59
- const t = L.titleOf(req.what_is_stuck);
60
- return `<span class="ov-row__title">${escapeHtml(t.title)}</span>`
61
- + (t.rest ? `<p class="ov-row__sub">${escapeHtml(t.rest)}</p>` : '');
62
+ // A DOM id per row that is stable, unique and safe to put in an attribute. The
63
+ // same request appears in up to three blocks at once, so the block's key is
64
+ // part of the id — without it "all open asks" and "your asks" would hand the
65
+ // same id to two elements and every aria-controls on the page would resolve to
66
+ // whichever painted first.
67
+ function askId(key, id) {
68
+ return `cb-ask-${key}-${String(id).replace(/[^A-Za-z0-9_-]/g, '')}`;
69
+ }
70
+
71
+ // THE FACE IS TWO SENTENCES, AND THE ASK ITSELF IS BEHIND A DOOR (owner
72
+ // direction, task 1003960: "the text here does not need to be visible, all we
73
+ // need is two sentences"). An ask is written to be answerable by someone with
74
+ // none of your context — the skill asks for what is stuck, why this person, the
75
+ // steps, how to verify and what was already tried, up to 4000 characters — so
76
+ // the words are worth keeping and were only ever at the wrong altitude.
77
+ //
78
+ // THE COPY CONTROL IS THE POINT OF THE DOOR, not an extra. What is behind it is
79
+ // a handoff: the reader's next move is usually to open a fresh session and
80
+ // paste the whole ask in as its opening context, and a page that made them
81
+ // expand, select and drag over four thousand characters would be asking them to
82
+ // do by hand the one thing it knows they came to do. It sits beside the door in
83
+ // the CLOSED state on purpose — taking the ask away does not require reading it
84
+ // here first.
85
+ function askHtml(req, key) {
86
+ const s = L.summaryOf(req.what_is_stuck);
87
+ let html = `<span class="ov-row__title">${escapeHtml(s.title)}</span>`;
88
+ if (s.lead) html += `<p class="ov-row__sub">${escapeHtml(s.lead)}</p>`;
89
+ if (!s.more) return html;
90
+ const fid = askId(key, req.id);
91
+ return html + `<div class="cb-ask">
92
+ <button class="cb-ask__door" type="button" data-ask-toggle aria-expanded="false" aria-controls="${fid}">Read the full ask</button>
93
+ <button class="cb-ask__copy" type="button" data-ask-copy="${fid}">Copy for Session Start</button>
94
+ </div>
95
+ <p class="cb-ask__full" id="${fid}" hidden>${escapeHtml(s.more)}</p>`;
62
96
  }
63
97
 
64
98
  function contextChip(req) {
@@ -142,11 +176,11 @@
142
176
  // chat mirror). What the page owes a helper is the request, its age, its
143
177
  // context, and who to answer — and their face. A by-name ask is the personal
144
178
  // call on this reader, so its row is the LIT one (.cb-row--live).
145
- function forMeRow(req, nowMs, extra) {
179
+ function forMeRow(req, nowMs, extra, key) {
146
180
  const live = req.needs_builder_id != null ? ' cb-row--live' : '';
147
181
  return `<li class="ov-row ov-row--lead cb-row${live}" data-id="${escapeHtml(String(req.id))}"${extra ? ' data-extra hidden' : ''}>
148
182
  ${faceHtml(L.faceOf(req, 'in'))}
149
- <div class="ov-row__main">${titleHtml(req)}
183
+ <div class="ov-row__main">${askHtml(req, key)}
150
184
  <div class="ov-row__facts">${directionFact(req, 'in')}${contextChip(req)}</div>
151
185
  </div>
152
186
  <div class="ov-row__right">${ageChip(req, nowMs)}${whenLine(req.created_at)}</div>
@@ -156,11 +190,11 @@
156
190
  // Your own ask, led by who you asked, with the two settle actions. Only the
157
191
  // asker can settle — the server enforces it in the UPDATE's WHERE, this is
158
192
  // just the affordance.
159
- function mineRow(req, nowMs, extra) {
193
+ function mineRow(req, nowMs, extra, key) {
160
194
  const id = escapeHtml(String(req.id));
161
195
  return `<li class="ov-row ov-row--lead cb-row" data-id="${id}"${extra ? ' data-extra hidden' : ''}>
162
196
  ${faceHtml(L.faceOf(req, 'out'))}
163
- <div class="ov-row__main">${titleHtml(req)}
197
+ <div class="ov-row__main">${askHtml(req, key)}
164
198
  <div class="ov-row__facts">${directionFact(req, 'out')}${contextChip(req)}</div>
165
199
  </div>
166
200
  <div class="ov-row__right">${ageChip(req, nowMs)}
@@ -174,10 +208,10 @@
174
208
 
175
209
  // A row on the board of everyone's asks: the asker's face, the third-person
176
210
  // line, no actions — settling is the asker's, in their own block.
177
- function allRow(req, nowMs, extra) {
211
+ function allRow(req, nowMs, extra, key) {
178
212
  return `<li class="ov-row ov-row--lead cb-row" data-id="${escapeHtml(String(req.id))}"${extra ? ' data-extra hidden' : ''}>
179
213
  ${faceHtml(L.faceOf(req, 'in'))}
180
- <div class="ov-row__main">${titleHtml(req)}
214
+ <div class="ov-row__main">${askHtml(req, key)}
181
215
  <div class="ov-row__facts">${boardFact(req)}${contextChip(req)}</div>
182
216
  </div>
183
217
  <div class="ov-row__right">${ageChip(req, nowMs)}${whenLine(req.created_at)}</div>
@@ -195,8 +229,8 @@
195
229
  const { shown, hidden } = L.splitShown(list, SHOWN);
196
230
  const host = $(`#cb-${key}`);
197
231
  if (host) {
198
- host.innerHTML = shown.map((r) => rowFn(r, nowMs, false)).join('')
199
- + hidden.map((r) => rowFn(r, nowMs, true)).join('');
232
+ host.innerHTML = shown.map((r) => rowFn(r, nowMs, false, key)).join('')
233
+ + hidden.map((r) => rowFn(r, nowMs, true, key)).join('');
200
234
  }
201
235
  const n = $(`#cb-${key}-n`);
202
236
  if (n) n.textContent = list.length ? String(list.length) : '';
@@ -213,6 +247,59 @@
213
247
  }
214
248
  }
215
249
 
250
+ // The per-row door. Independent of the block's "Show all N", which flips
251
+ // [data-extra] on the <li>s: this flips one <p> inside one row, so opening a
252
+ // row inside a condensed block and then expanding the block cannot leave either
253
+ // control describing a state the page is not in.
254
+ function toggleAsk(btn) {
255
+ const full = document.getElementById(btn.getAttribute('aria-controls'));
256
+ if (!full) return;
257
+ const open = btn.getAttribute('aria-expanded') === 'true';
258
+ full.hidden = open;
259
+ btn.setAttribute('aria-expanded', open ? 'false' : 'true');
260
+ btn.textContent = open ? 'Read the full ask' : 'Hide the full ask';
261
+ }
262
+
263
+ // Clipboard, with the hall's own fallback (copy-on-click.js's rule): the async
264
+ // API is absent outside a secure context, and a copy control that silently did
265
+ // nothing on a plain-http preview would be worse than no control at all — so
266
+ // the caller is always told which way it went.
267
+ function writeClipboard(text) {
268
+ if (navigator.clipboard && navigator.clipboard.writeText && window.isSecureContext) {
269
+ return navigator.clipboard.writeText(text).then(() => true, () => legacyCopy(text));
270
+ }
271
+ return Promise.resolve(legacyCopy(text));
272
+ }
273
+
274
+ function legacyCopy(text) {
275
+ try {
276
+ const ta = document.createElement('textarea');
277
+ ta.value = text;
278
+ ta.setAttribute('readonly', '');
279
+ ta.style.position = 'fixed';
280
+ ta.style.top = '-1000px';
281
+ ta.style.opacity = '0';
282
+ document.body.appendChild(ta);
283
+ ta.select();
284
+ const ok = document.execCommand('copy');
285
+ document.body.removeChild(ta);
286
+ return ok;
287
+ } catch (_) { return false; }
288
+ }
289
+
290
+ // textContent, not innerText: the hidden <p> holds the ask verbatim including
291
+ // its line breaks, and innerText on a hidden element is not guaranteed to
292
+ // return them. What lands on the clipboard is exactly what the asker wrote.
293
+ function copyAsk(btn) {
294
+ const full = document.getElementById(btn.getAttribute('data-ask-copy'));
295
+ if (!full) return;
296
+ const text = full.textContent || '';
297
+ if (!text) return;
298
+ writeClipboard(text).then((ok) => {
299
+ toast(ok ? 'Copied — paste it in to start a session on this.' : 'Could not copy — open the full ask and copy it by hand.');
300
+ });
301
+ }
302
+
216
303
  function toggleMore(btn) {
217
304
  const key = btn.id.replace(/^cb-|-more$/g, '');
218
305
  const host = $(`#cb-${key}`);
@@ -350,6 +437,10 @@
350
437
  const closest = (sel) => (ev.target && ev.target.closest ? ev.target.closest(sel) : null);
351
438
  const more = closest('.cb-more');
352
439
  if (more) { ev.preventDefault(); toggleMore(more); return; }
440
+ const door = closest('[data-ask-toggle]');
441
+ if (door) { ev.preventDefault(); toggleAsk(door); return; }
442
+ const copyBtn = closest('[data-ask-copy]');
443
+ if (copyBtn) { ev.preventDefault(); copyAsk(copyBtn); return; }
353
444
  const mingleBtn = closest('[data-mingle]');
354
445
  if (mingleBtn) {
355
446
  ev.preventDefault();
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.795",
3
+ "version": "1.19.797",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.795",
9
+ "version": "1.19.797",
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.795",
3
+ "version": "1.19.797",
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",
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.795'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.797'; // 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,103 @@
1
+ // tests/goal_unknown_module_hint.mjs
2
+ //
3
+ // The `unknown_module` refusal must keep handing back the LIVE module roster
4
+ // (task 1003106, goal 1000095, from idea 1000760).
5
+ //
6
+ // WHY THIS EXISTS. `POST /goals` validates `scope_modules` against the roster
7
+ // the RUNNING server loaded, while a planning session reads the roster from its
8
+ // own checkout. Those are different lists whenever the deployed core pin sits
9
+ // behind `main` — which is the normal state, by design, because merging to main
10
+ // deploys nothing (gate 3 of the release pipeline). On 2026-08-16 a session
11
+ // created a goal with `government` in scope and was refused: the key was on
12
+ // main, the site was a release behind.
13
+ //
14
+ // The fix is guidance in `/planning-session` Phase 2.5, and the part of that
15
+ // guidance with teeth is "the refusal is not a dead end — read `valid` off it
16
+ // and re-point". **That instruction is only true while the refusal carries the
17
+ // list.** If someone trims the payload to a bare code, the skill keeps telling
18
+ // agents to read a field that is no longer there, and the failure is silent:
19
+ // the planning session simply guesses again. So the two are pinned together
20
+ // here — the route's payload, the roster it is built from, and the skill text
21
+ // that depends on both.
22
+ //
23
+ // Deliberately NOT a re-test of the schema layer: `tests/goal_routes.mjs`
24
+ // covers the request-validation branches and says in its own words that the
25
+ // unknown-key check is in-handler, which is exactly the gap this fills.
26
+ //
27
+ // Pure: no DB, no network.
28
+ //
29
+ // Run: node tests/goal_unknown_module_hint.mjs
30
+
31
+ import { strict as assert } from 'node:assert';
32
+ import fs from 'node:fs';
33
+ import path from 'node:path';
34
+ import { fileURLToPath } from 'node:url';
35
+ import { createRequire } from 'node:module';
36
+
37
+ const require = createRequire(import.meta.url);
38
+ const scopeMap = require('../src/bongos/module-scope-map.js');
39
+
40
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
41
+ const GOALS_ROUTE = path.join(ROOT, 'modules', 'lifecycle', 'routes', 'goals.js');
42
+ const SKILL = path.join(ROOT, '.claude', 'skills', 'planning-session', 'SKILL.md');
43
+
44
+ let passed = 0;
45
+ let failed = 0;
46
+ function test(name, fn) {
47
+ try {
48
+ fn();
49
+ passed++;
50
+ console.log(` ok ${name}`);
51
+ } catch (err) {
52
+ failed++;
53
+ console.log(` FAIL ${name}`);
54
+ console.log(` ${err.message}`);
55
+ }
56
+ }
57
+
58
+ // ---- the roster the refusal would hand back is real ------------------------
59
+
60
+ test('moduleKeys() returns a real, non-empty roster', () => {
61
+ const keys = scopeMap.moduleKeys();
62
+ assert.ok(Array.isArray(keys), 'moduleKeys() returns an array');
63
+ assert.ok(keys.length >= 10, `expected a substantial roster, got ${keys.length}`);
64
+ assert.equal(new Set(keys).size, keys.length, 'keys are unique');
65
+ for (const k of keys) {
66
+ assert.ok(typeof k === 'string' && k.trim(), `key ${JSON.stringify(k)} is a non-empty string`);
67
+ assert.ok(scopeMap.isModuleKey(k), `isModuleKey agrees about ${k}`);
68
+ }
69
+ // A roster that answered "yes" to everything would make the refusal
70
+ // unreachable and this whole file vacuous.
71
+ assert.equal(scopeMap.isModuleKey('definitely-not-a-module'), false);
72
+ });
73
+
74
+ // ---- the refusal carries it ------------------------------------------------
75
+
76
+ test('every unknown_module refusal hands back valid: moduleKeys()', () => {
77
+ const src = fs.readFileSync(GOALS_ROUTE, 'utf8');
78
+ const sites = [...src.matchAll(/res\.fail\(\s*'unknown_module'[\s\S]{0,200}?\)/g)].map((m) => m[0]);
79
+ // The count IS the vacuity guard: a regex that silently matched nothing would
80
+ // make the loop below pass without asserting anything at all.
81
+ assert.equal(sites.length, 2, `expected 2 unknown_module refusals in goals.js, found ${sites.length}`);
82
+ for (const site of sites) {
83
+ assert.match(
84
+ site, /valid:\s*moduleKeys\(\)/,
85
+ `an unknown_module refusal without valid: moduleKeys() — /planning-session Phase 2.5 tells agents to read that field:\n${site}`
86
+ );
87
+ }
88
+ });
89
+
90
+ // ---- and the skill still tells agents to read it ---------------------------
91
+
92
+ test('Phase 2.5 tells the agent the server is the gate and the refusal carries the answer', () => {
93
+ const skill = fs.readFileSync(SKILL, 'utf8');
94
+ assert.ok(skill.includes('### Phase 2.5'), 'Phase 2.5 still exists');
95
+ assert.match(skill, /RUNNING SERVER is the GATE/,
96
+ 'Phase 2.5 must still say the live server, not the checkout, decides a key');
97
+ assert.match(skill, /`unknown_module` comes back with `valid/,
98
+ 'Phase 2.5 must still point at the field this test pins');
99
+ });
100
+
101
+ console.log('');
102
+ console.log(`unknown_module carries the live roster (task 1003106): ${passed} passed, ${failed} failed`);
103
+ if (failed > 0) process.exit(1);
@@ -123,10 +123,121 @@ test('the first line is decided: the ask\'s first sentence is the title, the res
123
123
  assert.equal(L.titleOf('docs/x.md is stale and y.js too').title, 'docs/x.md is stale and y.js too', 'a dot inside a path is not a sentence break');
124
124
  const t = L.titleOf(' padded. and trailing ');
125
125
  assert.equal(`${t.title} ${t.rest}`, 'padded. and trailing', 'nothing is clipped');
126
+ // titleOf is still the sentence splitter; what the page DOES with the tail
127
+ // changed in task 1003960 and is asserted in the next test.
128
+ assert.equal(typeof L.summaryOf, 'function', 'the page face is built on summaryOf now');
129
+ });
130
+
131
+ test('a card face carries two sentences at most, and the whole ask waits behind a door', () => {
132
+ const L = lib();
133
+
134
+ // The common case: two short sentences fit, so there is nothing behind a door
135
+ // and the page must not offer one. A door onto nothing is a lie about there
136
+ // being more to read.
137
+ const fits = L.summaryOf('The pin will not move past 782. Can you look before I ship?');
138
+ assert.deepEqual(fits, {
139
+ title: 'The pin will not move past 782.',
140
+ lead: 'Can you look before I ship?',
141
+ more: '',
142
+ });
143
+ assert.equal(L.summaryOf('Can you look at the pin?').more, '', 'one sentence needs no door');
144
+
145
+ // The case the owner reported: a real ask, written the way the ask-for-help
146
+ // skill asks for it, is five parts long. Two reach the face; the rest does not.
147
+ const long = [
148
+ 'The deploy pin is stuck at 1.19.782.',
149
+ 'I need a Metic because the release gate is above my rank.',
150
+ 'Run the release pipeline, then check /version reports 789.',
151
+ 'Verify by loading the hall and reading the footer.',
152
+ 'I already ruled out a stale CDN cache and a bad merge.',
153
+ ].join(' ');
154
+ const s = L.summaryOf(long);
155
+ assert.equal(s.title, 'The deploy pin is stuck at 1.19.782.');
156
+ assert.equal(s.lead, 'I need a Metic because the release gate is above my rank.');
157
+ assert.ok(s.more, 'the rest goes behind the door');
158
+ assert.equal(s.more, long, 'and what is behind the door is the WHOLE ask, so a copy is not a fragment');
159
+ assert.ok(!s.title.includes('Verify by loading'), 'nothing past the second sentence is on the face');
160
+
161
+ // A sentence count alone is not enough: one 4000-character paragraph with no
162
+ // full stop is ONE sentence, and counting sentences would put all of it on the
163
+ // face. The line budget catches it, and the ask still goes behind the door whole.
164
+ const blob = 'x '.repeat(900).trim();
165
+ const b = L.summaryOf(blob);
166
+ assert.ok(b.title.length < 160, 'a blob is cut to a lead-in');
167
+ assert.ok(b.title.endsWith('\u2026'), 'and the cut is marked');
168
+ assert.equal(b.lead, '');
169
+ assert.equal(b.more, blob, 'the words are not lost, only moved');
170
+
171
+ // The cut never lands mid-word.
172
+ const words = 'antidisestablishmentarianism '.repeat(40).trim();
173
+ const w = L.summaryOf(words);
174
+ assert.ok(/(^|\s)antidisestablishmentarianism\u2026$/.test(w.title) || w.title.endsWith('antidisestablishmentarianism\u2026'),
175
+ 'clipWords cuts at a space, never inside a word');
176
+
177
+ assert.deepEqual(L.summaryOf(''), { title: '', lead: '', more: '' }, 'an empty ask renders nothing, not a door');
178
+ });
179
+
180
+ test('the door tracks what is UNREAD, not the sentence count', () => {
181
+ const L = lib();
182
+
183
+ // Raised by the grader on task 1003960 as a violation of "one or two sentences
184
+ // shows no expander". It is neither a violation nor a bug, and the rule it
185
+ // appeals to was the imprecise one: an ask earns a door when part of it is not
186
+ // on the face, and sentence two can be longer than the face on its own.
187
+ const twoButLong = 'The pin will not move. ' + 'I have tried '.repeat(30).trim() + '.';
188
+ const r = L.summaryOf(twoButLong);
189
+ assert.equal(r.title, 'The pin will not move.');
190
+ assert.ok(r.lead.endsWith('\u2026'), 'the second sentence did not fit, so it was cut');
191
+ assert.ok(r.lead.length < twoButLong.length, 'and part of it is therefore unread');
192
+ assert.equal(r.more, twoButLong,
193
+ 'so it gets a door — hiding one here would abbreviate an ask with no way to read the rest');
194
+
195
+ // The mirror case, so the pair states the rule between them: two sentences
196
+ // that BOTH fit are fully on the face, so there is nothing to open.
197
+ assert.equal(L.summaryOf('The pin will not move. Can you look?').more, '',
198
+ 'nothing unread means no door');
199
+ });
200
+
201
+ test('the door and the copy control are on the row, and the copy control is reachable while it is shut', () => {
126
202
  const js = read('collab.js');
127
- assert.match(js, /L\.titleOf\(req\.what_is_stuck\)/);
128
- assert.match(js, /class="ov-row__title">\$\{escapeHtml\(t\.title\)\}/);
129
- assert.match(js, /class="ov-row__sub">\$\{escapeHtml\(t\.rest\)\}/);
203
+ assert.match(js, /L\.summaryOf\(req\.what_is_stuck\)/, 'the face is built from summaryOf');
204
+ assert.match(js, /class="ov-row__title">\$\{escapeHtml\(s\.title\)\}/);
205
+ assert.match(js, /class="ov-row__sub">\$\{escapeHtml\(s\.lead\)\}/);
206
+ assert.match(js, /if \(!s\.more\) return html;/, 'no door when the ask already fits');
207
+
208
+ // The door announces its state and names what it controls, so it works from
209
+ // the keyboard and reads correctly to a screen reader.
210
+ assert.match(js, /data-ask-toggle aria-expanded="false" aria-controls="\$\{fid\}"/);
211
+ assert.match(js, /aria-expanded', open \? 'false' : 'true'/, 'and the state flips when it opens');
212
+ assert.match(js, /Read the full ask/);
213
+ assert.match(js, /Hide the full ask/);
214
+
215
+ // The copy control sits in the SAME block as the door, which is rendered
216
+ // whether or not the ask is open — the owner asked for it "in the closed
217
+ // version next to the link", and a control only reachable after expanding
218
+ // would make a reader open four thousand characters to take them away.
219
+ const bar = js.slice(js.indexOf('<div class="cb-ask">'), js.indexOf('</div>', js.indexOf('<div class="cb-ask">')));
220
+ assert.match(bar, /data-ask-toggle/, 'the door is in the bar');
221
+ assert.match(bar, /Copy for Session Start/, 'and so is the copy control, in the owner\'s words');
222
+ assert.ok(!/hidden/.test(bar), 'the bar itself is never hidden');
223
+ assert.match(js, /<p class="cb-ask__full" id="\$\{fid\}" hidden>/, 'only the ask is hidden');
224
+
225
+ // What lands on the clipboard is the ask verbatim, line breaks and all.
226
+ assert.match(js, /full\.textContent/, 'copied from textContent, so the line breaks survive');
227
+ assert.ok(!/full\.innerText/.test(js), 'innerText on a hidden element is not guaranteed to keep them');
228
+ assert.match(js, /document\.execCommand\('copy'\)/, 'with the hall\'s fallback for an insecure context');
229
+
230
+ // One request can appear in three blocks at once, so the id is block-scoped.
231
+ assert.match(js, /function askId\(key, id\)/);
232
+ assert.match(js, /cb-ask-\$\{key\}-/);
233
+ assert.match(js, /rowFn\(r, nowMs, false, key\)/, 'and paintBlock hands the key down');
234
+
235
+ const css = read('collab.css');
236
+ for (const cls of ['.cb-ask ', '.cb-ask__door', '.cb-ask__copy', '.cb-ask__full']) {
237
+ assert.ok(css.includes(cls), `collab.css dresses ${cls.trim()}`);
238
+ }
239
+ assert.match(css, /\.cb-ask__full[\s\S]*?white-space: pre-wrap/, 'the opened ask keeps its own paragraphs');
240
+ assert.ok(!/\.cb-ask__copy[^}]*text-transform/.test(css), 'the copy control keeps the capitals the owner wrote');
130
241
  });
131
242
 
132
243
  test('"asked of you" reads in runs — by name first, then each craft', () => {