@bongos/core 1.21.112 → 1.21.114

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.
Files changed (265) hide show
  1. package/.bongos-core.json +616 -186
  2. package/.claude/skills/ask-for-help/SKILL.md +1 -1
  3. package/.claude/skills/bongos-feedback/SKILL.md +1 -1
  4. package/.claude/skills/builder-sequence/SKILL.md +1 -1
  5. package/.claude/skills/builder-ship/SKILL.md +1 -0
  6. package/GOVERNMENT.md +128 -11
  7. package/clients/bongos-client/README.md +1 -1
  8. package/clients/bongos-client/bongos-client.global.js +28 -0
  9. package/clients/bongos-client/index.cjs +28 -0
  10. package/clients/bongos-client/index.d.ts +45 -2
  11. package/clients/bongos-client/index.mjs +28 -0
  12. package/docs/adr/0372-an-ask-is-stored-per-docket-matter-and-the-server-refuses-slop.md +33 -0
  13. package/docs/adr/0373-an-ai-session-files-a-blocker-with-an-ask.md +31 -0
  14. package/docs/adr/0374-a-building-lead-is-a-held-position-appointed-by-a-people-matter.md +30 -0
  15. package/docs/adr/0375-who-may-build-without-a-vote-comes-from-the-form-of-government.md +38 -0
  16. package/docs/adr/0376-every-ask-is-routed-to-a-real-seat-on-the-server.md +30 -0
  17. package/docs/adr/0377-governance-is-a-held-position-read-on-me.md +24 -0
  18. package/docs/adr/0378-a-leads-build-outs-are-rows-built-at-once-or-by-a-passed-vote.md +33 -0
  19. package/docs/adr/0379-the-treasury-is-pots-per-goal-a-reserve-and-borrowing-over-real-spend.md +32 -0
  20. package/docs/adr/0380-a-strictness-preset-is-a-bundle-of-laws-switched-by-one-laws-vote.md +26 -0
  21. package/docs/adr/README.md +10 -1
  22. package/docs/api/openapi.json +989 -87
  23. package/docs/api-reference.md +18 -4
  24. package/docs/copy-inventory.md +343 -262
  25. package/docs/copy-registry.json +1223 -426
  26. package/docs/file-map.md +2 -1
  27. package/docs/module-api-changelog.md +4 -0
  28. package/docs/packs/engineer.md +2 -1
  29. package/docs/page-inventory.json +91 -6
  30. package/docs/page-readings.json +5017 -4458
  31. package/modules/builder-settings/law-locks.js +202 -0
  32. package/modules/builder-settings/routes/settings.js +66 -6
  33. package/modules/builder-settings/settings.js +7 -0
  34. package/modules/discord/ship-broadcast.js +22 -13
  35. package/modules/government/asks-db.js +54 -0
  36. package/modules/government/asks.js +269 -0
  37. package/modules/government/board-idea-reads.js +68 -0
  38. package/modules/government/board.js +21 -57
  39. package/modules/government/build-rights.js +148 -0
  40. package/modules/government/buildouts.js +160 -0
  41. package/modules/government/counts.js +81 -0
  42. package/modules/government/db.js +242 -0
  43. package/modules/government/decision-board.js +234 -8
  44. package/modules/government/decision-rules.js +2 -0
  45. package/modules/government/docket.js +114 -6
  46. package/modules/government/law-board.js +131 -0
  47. package/modules/government/laws.js +241 -0
  48. package/modules/government/leads.js +170 -0
  49. package/modules/government/migrations/government_027_laws.sql +143 -0
  50. package/modules/government/migrations/government_028_asks.sql +57 -0
  51. package/modules/government/migrations/government_029_leads.sql +135 -0
  52. package/modules/government/migrations/government_030_buildouts.sql +111 -0
  53. package/modules/government/migrations/government_031_treasury.sql +107 -0
  54. package/modules/government/module.json +2 -1
  55. package/modules/government/pollers/treasury-borrow.js +62 -0
  56. package/modules/government/positions.js +107 -0
  57. package/modules/government/presets.js +149 -0
  58. package/modules/government/routes/government.js +425 -2
  59. package/modules/government/routing.js +154 -0
  60. package/modules/government/treasury-db.js +166 -0
  61. package/modules/government/treasury.js +241 -0
  62. package/modules/hall-ui/public/atlas.html +2 -2
  63. package/modules/hall-ui/public/blockers.html +2 -2
  64. package/modules/hall-ui/public/board-room.html +2 -2
  65. package/modules/hall-ui/public/board-room.js +2 -0
  66. package/modules/hall-ui/public/city-draw.js +604 -11
  67. package/modules/hall-ui/public/city.css +157 -1
  68. package/modules/hall-ui/public/city.html +37 -7
  69. package/modules/hall-ui/public/city.js +441 -21
  70. package/modules/hall-ui/public/city.states.json +110 -5
  71. package/modules/hall-ui/public/collab.html +2 -2
  72. package/modules/hall-ui/public/commands.html +2 -2
  73. package/modules/hall-ui/public/copy-desk.html +2 -2
  74. package/modules/hall-ui/public/craft-mode.js +112 -29
  75. package/modules/hall-ui/public/deploy.html +2 -2
  76. package/modules/hall-ui/public/diagram-viewer.css +12 -0
  77. package/modules/hall-ui/public/diagram-viewer.js +4 -0
  78. package/modules/hall-ui/public/diagrams.html +2 -2
  79. package/modules/hall-ui/public/drachmae.html +2 -2
  80. package/modules/hall-ui/public/fleet.html +2 -2
  81. package/modules/hall-ui/public/gate.html +2 -2
  82. package/modules/hall-ui/public/goal-map.html +2 -2
  83. package/modules/hall-ui/public/goals.html +2 -2
  84. package/modules/hall-ui/public/gov-feeds.js +141 -0
  85. package/modules/hall-ui/public/gov-living.js +1002 -0
  86. package/modules/hall-ui/public/gov-positions.js +97 -0
  87. package/modules/hall-ui/public/government.html +2 -2
  88. package/modules/hall-ui/public/hall-render.js +3 -0
  89. package/modules/hall-ui/public/idea.html +2 -2
  90. package/modules/hall-ui/public/ideas.html +2 -2
  91. package/modules/hall-ui/public/index.html +3 -3
  92. package/modules/hall-ui/public/modules.html +2 -2
  93. package/modules/hall-ui/public/my-agents.css +334 -0
  94. package/modules/hall-ui/public/my-agents.html +72 -0
  95. package/modules/hall-ui/public/my-agents.js +603 -0
  96. package/modules/hall-ui/public/my-agents.states.json +37 -0
  97. package/modules/hall-ui/public/primer.html +2 -2
  98. package/modules/hall-ui/public/profile-connections.js +40 -28
  99. package/modules/hall-ui/public/profile.html +2 -2
  100. package/modules/hall-ui/public/project-settings.html +2 -2
  101. package/modules/hall-ui/public/ranks.html +2 -2
  102. package/modules/hall-ui/public/roadmap.html +2 -2
  103. package/modules/hall-ui/public/room.css +8 -0
  104. package/modules/hall-ui/public/roster.html +2 -2
  105. package/modules/hall-ui/public/sessions.html +2 -2
  106. package/modules/hall-ui/public/settings.html +2 -2
  107. package/modules/hall-ui/public/shell.js +116 -16
  108. package/modules/hall-ui/public/street-card.js +18 -1
  109. package/modules/hall-ui/public/street-deck.css +299 -0
  110. package/modules/hall-ui/public/street-deck.js +1145 -0
  111. package/modules/hall-ui/public/street-depth.js +712 -0
  112. package/modules/hall-ui/public/street-desk.js +5 -2
  113. package/modules/hall-ui/public/street-draw.js +109 -16
  114. package/modules/hall-ui/public/street-inside.js +137 -22
  115. package/modules/hall-ui/public/street-office.js +705 -0
  116. package/modules/hall-ui/public/street.css +161 -11
  117. package/modules/hall-ui/public/street.html +41 -11
  118. package/modules/hall-ui/public/street.js +78 -66
  119. package/modules/hall-ui/public/street.states.json +129 -21
  120. package/modules/hall-ui/public/studio.html +2 -2
  121. package/modules/hall-ui/public/style.css +5 -1
  122. package/modules/hall-ui/public/task.html +2 -2
  123. package/modules/hall-ui/public/thinking.html +2 -2
  124. package/modules/hall-ui/public/tweak-editor.html +2 -2
  125. package/modules/hall-ui/public/watch.html +2 -2
  126. package/modules/hall-ui/public/watch.js +14 -1
  127. package/modules/hall-ui/public/work.html +2 -2
  128. package/modules/hall-ui/records/living-city.md +57 -0
  129. package/modules/hall-ui/records/my-agents.md +21 -0
  130. package/modules/hall-ui/records/oversight-family.md +1 -0
  131. package/modules/hall-ui/records/profile-connections.md +10 -0
  132. package/modules/hall-ui/records/reading-rooms.md +2 -0
  133. package/modules/ideas/routes/blockers.js +92 -3
  134. package/modules/lifecycle/module.json +2 -1
  135. package/modules/lifecycle/moving.js +17 -1
  136. package/modules/lifecycle/routes/moving.js +6 -0
  137. package/modules/lifecycle/routes/project-settings.js +23 -0
  138. package/modules/onboarding/approval-broadcast.js +8 -0
  139. package/modules/onboarding/routes/access-requests.js +16 -6
  140. package/modules/platform-identity/connections.js +13 -2
  141. package/modules/platform-identity/routes/connections.js +7 -1
  142. package/modules/public-landing/public/account-private.states.json +2 -2
  143. package/modules/public-landing/public/account.html +65 -5
  144. package/modules/public-landing/public/account.states.json +3 -3
  145. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg.json +1 -1
  146. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp +0 -0
  147. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp.json +13 -0
  148. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg.json +1 -1
  149. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp +0 -0
  150. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp.json +13 -0
  151. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg.json +1 -1
  152. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp +0 -0
  153. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp.json +13 -0
  154. package/modules/public-landing/public/assets/world.css +4 -4
  155. package/modules/public-landing/public/connections-empty.states.json +14 -0
  156. package/modules/public-landing/public/connections-inactive.states.json +14 -0
  157. package/modules/public-landing/public/connections.html +450 -0
  158. package/modules/public-landing/public/connections.probes.json +414 -0
  159. package/modules/public-landing/public/connections.states.json +17 -0
  160. package/modules/public-landing/public/contact.html +2 -2
  161. package/modules/public-landing/public/guild.html +2 -2
  162. package/modules/public-landing/public/index.html +2 -2
  163. package/modules/public-landing/public/invitations.html +2 -2
  164. package/modules/public-landing/public/privacy.html +2 -2
  165. package/modules/public-landing/public/projects.html +2 -2
  166. package/modules/public-landing/public/projects.probes.json +6 -2
  167. package/modules/public-landing/public/terms.html +2 -2
  168. package/modules/ui-design/kit/fixtures/connections-empty.json +12 -0
  169. package/modules/ui-design/kit/fixtures/connections-inactive.json +7 -0
  170. package/modules/ui-design/kit/fixtures/connections.json +22 -0
  171. package/modules/ui-design/kit/serve.js +95 -0
  172. package/package-lock.json +2 -2
  173. package/package.json +1 -1
  174. package/release-notes.json +128 -0
  175. package/scripts/discord/README.md +8 -5
  176. package/scripts/discord/post.js +12 -1
  177. package/scripts/gds/artifact-audit.js +20 -3
  178. package/scripts/gds/ask.js +164 -0
  179. package/scripts/gds/autobongos-run.js +20 -5
  180. package/scripts/gds/autobongos-tweak-lane.js +262 -0
  181. package/scripts/gds/autobongos-verify.js +21 -2
  182. package/scripts/gds/claim.js +2 -0
  183. package/scripts/gds/government-walk.js +638 -4
  184. package/scripts/gds/pulse.js +9 -6
  185. package/scripts/gds/ship-escalation.js +22 -3
  186. package/scripts/gds/ship-finish.js +5 -4
  187. package/scripts/gds/ship-merge-gate.js +18 -6
  188. package/scripts/gds/tweak-halt.js +1 -0
  189. package/scripts/hall-preview/README.md +41 -1
  190. package/scripts/hall-preview/pref-writes.js +140 -0
  191. package/scripts/hall-preview/refresh-fixtures.js +101 -7
  192. package/scripts/hall-preview/scrub-identity.js +366 -7
  193. package/scripts/hall-preview/server.js +27 -1
  194. package/scripts/render-diagrams.sh +4 -2
  195. package/src/bongos/auth-idp.js +6 -1
  196. package/src/bongos/project-settings.js +41 -0
  197. package/src/bongos/route-rank-check.js +27 -0
  198. package/src/bongos/routes/me.js +68 -7
  199. package/src/bongos/serve-internal.js +5 -1
  200. package/src/module-api.js +7 -1
  201. package/src/platform-server.js +10 -0
  202. package/tests/application_lifecycle.mjs +47 -5
  203. package/tests/autobongos_tweak_lane.mjs +208 -0
  204. package/tests/conductor_main_e2e.mjs +21 -0
  205. package/tests/connections_api.mjs +18 -0
  206. package/tests/craft_mode_nav.mjs +5 -3
  207. package/tests/craft_mode_tallies.mjs +52 -16
  208. package/tests/discord_approvals.mjs +11 -0
  209. package/tests/discord_channels.mjs +20 -0
  210. package/tests/fixtures/gov-seated.mjs +64 -0
  211. package/tests/fixtures/government-ask-cards.json +1284 -0
  212. package/tests/government_act_alone_buildings.mjs +1 -0
  213. package/tests/government_agent_asks.mjs +322 -0
  214. package/tests/government_asks.mjs +408 -0
  215. package/tests/government_build_rights.mjs +253 -0
  216. package/tests/government_buildouts.mjs +382 -0
  217. package/tests/government_counts.mjs +143 -0
  218. package/tests/government_decision_rules.mjs +5 -1
  219. package/tests/government_laws.mjs +488 -0
  220. package/tests/government_laws_session.mjs +321 -0
  221. package/tests/government_leads.mjs +375 -0
  222. package/tests/government_positions.mjs +127 -0
  223. package/tests/government_presets.mjs +215 -0
  224. package/tests/government_routes.mjs +8 -1
  225. package/tests/government_routing.mjs +118 -0
  226. package/tests/government_seed.mjs +5 -0
  227. package/tests/government_treasury.mjs +394 -0
  228. package/tests/governor_docket.mjs +26 -2
  229. package/tests/hall_buildouts.mjs +208 -0
  230. package/tests/hall_city.mjs +14 -6
  231. package/tests/hall_city_living.mjs +617 -0
  232. package/tests/hall_gov_counts.mjs +336 -0
  233. package/tests/hall_gov_living.mjs +636 -0
  234. package/tests/hall_gov_real_site.mjs +655 -0
  235. package/tests/hall_matter_card.mjs +6 -1
  236. package/tests/hall_my_agents.mjs +481 -0
  237. package/tests/hall_nav.mjs +107 -4
  238. package/tests/hall_page_gate_map.mjs +4 -0
  239. package/tests/hall_preview_governor_fixtures.mjs +30 -3
  240. package/tests/hall_preview_scrub.mjs +59 -1
  241. package/tests/hall_preview_security.mjs +169 -0
  242. package/tests/hall_reading_world.mjs +44 -0
  243. package/tests/hall_roster_world.mjs +6 -2
  244. package/tests/hall_street.mjs +18 -2
  245. package/tests/hall_street_amend.mjs +1 -1
  246. package/tests/hall_street_deck.mjs +609 -0
  247. package/tests/hall_street_depth.mjs +559 -0
  248. package/tests/hall_street_desk.mjs +1 -1
  249. package/tests/hall_street_inside.mjs +29 -5
  250. package/tests/hall_tweak_editor.mjs +4 -1
  251. package/tests/hub_account_page.mjs +94 -3
  252. package/tests/hub_connections_page.mjs +507 -0
  253. package/tests/hub_invite_badge.mjs +1 -1
  254. package/tests/idp_device_exchange.mjs +18 -0
  255. package/tests/landing_legal_pages.mjs +8 -5
  256. package/tests/landing_page.mjs +23 -4
  257. package/tests/nav_permission_atoms.mjs +4 -1
  258. package/tests/profile_connections_ui.mjs +88 -25
  259. package/tests/projects_hub.mjs +15 -0
  260. package/tests/render_check.mjs +2 -2
  261. package/tests/ship_broadcast_routing.mjs +57 -16
  262. package/tests/ship_broadcast_server.mjs +40 -7
  263. package/tests/ship_merge_gate.mjs +19 -0
  264. package/tests/ui_design_kit.mjs +2 -2
  265. package/tests/watch_applications_queue.mjs +23 -0
@@ -568,15 +568,16 @@ test('the Projects band deep-links the hub\'s views; About and Docs are footer l
568
568
  // the profile band: the person, My projects, Account, Sign out as the honest anchor.
569
569
  // Account fills the slot that waited for a settings page (task 1004259) and is
570
570
  // apex-only, because /account is served only where platform-identity runs; so is
571
- // Invitations, in the same region, for /invitations (task 1004276).
571
+ // Invitations, in the same region, for /invitations (task 1004276), and Connections
572
+ // for /connections (task 1004278).
572
573
  assert.match(body, /<div class="menuBand" id="menuProfile" data-menu="profile" hidden>/);
573
574
  assert.match(body, /<p class="bandWho" id="bandWho"><\/p>/);
574
575
  assert.match(body, /<a class="bandT" href="\/projects\?view=mine">My projects<\/a>/);
575
- assert.match(body, /<!--apex-only:start--><li class="bandCell"><a class="bandT" href="\/invitations">Invitations<\/a><\/li><li class="bandCell"><a class="bandT" href="\/account">Account<\/a><\/li><!--apex-only:end-->/);
576
+ assert.match(body, /<!--apex-only:start--><li class="bandCell"><a class="bandT" href="\/invitations">Invitations<\/a><\/li><li class="bandCell"><a class="bandT" href="\/connections">Connections<\/a><\/li><li class="bandCell"><a class="bandT" href="\/account">Account<\/a><\/li><!--apex-only:end-->/);
576
577
  assert.doesNotMatch(body, /class="bandSlot"/, 'the slot is filled, not kept beside its item');
577
578
  assert.match(body, /<a class="bandT signout" href="\/api\/bongos\/auth\/logout">Sign out<\/a>/);
578
579
  // a crawler reads every destination without scripting on
579
- for (const copy of ['Sky', 'Table', 'Your projects', 'New project', 'About', 'Docs', 'My projects', 'Invitations', 'Account', 'Sign out']) {
580
+ for (const copy of ['Sky', 'Table', 'Your projects', 'New project', 'About', 'Docs', 'My projects', 'Invitations', 'Connections', 'Account', 'Sign out']) {
580
581
  assert.ok(body.includes('>' + copy + '<'), `"${copy}" is only reachable with scripting on`);
581
582
  }
582
583
  // the menu container is .menuBand: .band is this page's own content sections
@@ -614,7 +615,25 @@ test('the retired gate assets (world/assets/clouds|creatures|plates) are gone
614
615
  assert.doesNotMatch(HTML, /\bworld\/assets\//, 'the page links no path under the retired world/assets/ folder');
615
616
  assert.doesNotMatch(WORLD, /\bworld\/assets\//, 'world.css references no path under the retired folder');
616
617
  // the chrome landing's own plates (task 1003298) live at assets/world/ and are untouched by this cleanup
617
- assert.ok(fs.existsSync(path.join(LANDING, 'assets', 'world', 'r2-bongos-chrome-b.jpg')), 'the chrome plate at assets/world/ is untouched (task 1003649 converts it separately)');
618
+ assert.ok(fs.existsSync(path.join(LANDING, 'assets', 'world', 'r2-bongos-chrome-b.webp')), 'the chrome plate at assets/world/ is untouched by this cleanup');
619
+ });
620
+
621
+ // Dark mode's first paint fetched the black plates as JPEG at nearly twice the bytes
622
+ // light mode spent on the same sculpture; both halves of each pair are WebP now. The
623
+ // JPEG stays beside the plate as its source, and because a catalog art_url can name it.
624
+ test('the dark plates are WebP; the JPEG each is encoded from stays beside it (task 1003649)', () => {
625
+ const plates = { '--plate-set': 'r2-chrome-blobs-set', '--plate-bead': 'r2-chrome-blob-one', '--plate-bongos': 'r2-bongos-chrome-b' };
626
+ for (const [token, file] of Object.entries(plates)) {
627
+ assert.match(WORLD, new RegExp(`${token}: url\\(/assets/world/${file}\\.webp\\);`), `${token} names the WebP`);
628
+ const dir = path.join(LANDING, 'assets', 'world');
629
+ const head = fs.readFileSync(path.join(dir, `${file}.webp`)).subarray(0, 12);
630
+ assert.equal(head.toString('latin1', 0, 4) + head.toString('latin1', 8, 12), 'RIFFWEBP', `${file}.webp is a WebP file`);
631
+ const sidecar = JSON.parse(fs.readFileSync(path.join(dir, `${file}.webp.json`), 'utf8'));
632
+ assert.match(sidecar.encoded, /libwebp/, `${file}.webp.json records the encoding`);
633
+ assert.ok(sidecar.prompt, `${file}.webp.json carries the prompt`);
634
+ assert.ok(fs.existsSync(path.join(dir, `${file}.jpg`)) && fs.existsSync(path.join(dir, `${file}.jpg.json`)), `${file}.jpg stays beside its sidecar`);
635
+ }
636
+ assert.doesNotMatch(WORLD, /\.jpg\)/, 'world.css requests no JPEG plate');
618
637
  });
619
638
 
620
639
  // ── the CLI download (task 1004257) ─────────────────────────────────────────
@@ -87,7 +87,10 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
87
87
  // the file and left the query alone, so a returning browser kept the cached copy —
88
88
  // a fix that reaches nobody looks identical to a fix that does not work.
89
89
 
90
- const SHELL_SHA = 'd080c7a69a310077';
90
+ // task 1004685 (the living-city prototype): the held-seat nav and the My agents tab,
91
+ // then the reads it shares with the governor pages (window.__hallReads); task 1004713:
92
+ // the seats read from /me's server-resolved positions block.
93
+ const SHELL_SHA = 'd50a4cc5e6c954b5';
91
94
 
92
95
  test('shell.js has not changed without its cache-bust being bumped', () => {
93
96
  const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
@@ -36,6 +36,13 @@
36
36
  //
37
37
  // 5. `seen_at` AND `truncated` NEVER REACH THE SCREEN.
38
38
  //
39
+ // 6. YOUR OWN TAB IS THIS PROJECT'S CUT (ADR 0337 D5.4, task 1004279): only
40
+ // the connections who are also active builders here, read through the
41
+ // route's `?here=1`, modelled below against an in-memory `builders` table.
42
+ // The request queues and everyone else live on the hub's /connections, and
43
+ // the tab links there. It never reads the queues at all, because that read
44
+ // stamps `seen_at` on rows it would not render.
45
+ //
39
46
  // DB-free by construction — no Postgres, no network beyond loopback. The last
40
47
  // test proves this file runs in the lane that actually executes on a PR; three
41
48
  // tests once declared a lane they were in neither of and asserted nothing for
@@ -84,6 +91,8 @@ const PROV = 9006; // provisional → not addressable at all
84
91
  const SEC = 9007; // private AND connected to ME → addressable to ME only
85
92
  const NEW = 9008; // never asked → the Connect button's home
86
93
  const COOL = 9009; // declined ME 10 days ago → the swallowed re-ask
94
+ const FAR = 9010; // connected to ME, builds on another project only → off the own tab
95
+ const GONE = 9011; // connected to ME, deactivated here → off the own tab
87
96
  const OUTSIDER = 7777; // a second session, to prove "and to nobody else"
88
97
 
89
98
  // ===========================================================================
@@ -93,6 +102,9 @@ const OUTSIDER = 7777; // a second session, to prove "and to nobody else"
93
102
  let accounts = [];
94
103
  let edges = [];
95
104
  let nextEdgeId = 1;
105
+ // This instance's own builders (core's `builders` table: github_id is text
106
+ // there) — who "builds on this project" for the own tab's `?here=1` cut.
107
+ let builders = new Map();
96
108
 
97
109
  const acct = (githubId, handle, name, over = {}) => ({
98
110
  id: githubId, github_id: String(githubId), github_login: handle, display_name: name,
@@ -127,7 +139,11 @@ function seed() {
127
139
  acct(NEW, 'newbie', 'Newbie'),
128
140
  acct(COOL, 'cooled', 'Cooled'),
129
141
  acct(OUTSIDER, 'outsider', 'Outsider'),
142
+ acct(FAR, 'faraway', 'Far Away'),
143
+ acct(GONE, 'gone', 'Gone'),
130
144
  ];
145
+ builders = new Map([ME, ADA, BO, SEC, NEW].map((g) => [String(g), 'active']));
146
+ builders.set(String(GONE), 'deactivated');
131
147
  edges = [];
132
148
  nextEdgeId = 1;
133
149
  edge(ME, ADA, 'accepted');
@@ -280,12 +296,18 @@ const pool = {
280
296
  // ---- my accepted connections -----------------------------------------
281
297
  if (/^WITH edge AS \(/.test(sql)) {
282
298
  const floor = accountFloor(sql, { axis: 'active' });
299
+ // The project cut, read OUT OF the statement: the exact clause the domain
300
+ // exports, or no builders term at all. Any other spelling throws, so a
301
+ // drifted join can never quietly read as the full list.
302
+ const here = sql.includes(HERE_SQL);
303
+ if (!here && /\bbuilders\b/.test(sql)) throw new Error(`fake pool: a builders clause this interpreter cannot read — ${sql}`);
283
304
  const gid = params[0]; const limit = Number(params[1]); const offset = Number(params[2]);
284
305
  const mine = edges
285
306
  .filter((r) => r.status === 'accepted'
286
307
  && (String(r.requester_github_id) === String(gid) || String(r.addressee_github_id) === String(gid)))
287
308
  .map((r) => ({ id: r.id, gid: String(r.requester_github_id) === String(gid) ? r.addressee_github_id : r.requester_github_id, resolved_at: r.resolved_at }))
288
309
  .filter((e) => floor(findAccount(e.gid)))
310
+ .filter((e) => !here || builders.get(String(e.gid)) === 'active')
289
311
  .sort((a, b) => (b.resolved_at || 0) - (a.resolved_at || 0) || b.id - a.id)
290
312
  .slice(offset, offset + limit);
291
313
  return { rows: mine.map((e) => ({ id: String(e.id), connected_at: iso(e.resolved_at), ...counterparty(e.gid) })), rowCount: mine.length };
@@ -407,6 +429,7 @@ require.cache[moduleApiPath] = {
407
429
  },
408
430
  };
409
431
  const buildConnectionRoutes = require('../modules/platform-identity/routes/connections.js');
432
+ const { HERE_SQL } = require('../modules/platform-identity/connections.js');
410
433
  const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
411
434
 
412
435
  const app = express();
@@ -462,7 +485,9 @@ function makeEl() {
462
485
  // Boot the real profile-connections.js. `transport` defaults to the live
463
486
  // loopback client; a test may pass a canned one for a shape only a hostile or
464
487
  // future server could send.
465
- function boot({ transport } = {}) {
488
+ const HUB = 'https://hub.example';
489
+
490
+ function boot({ transport, hub = HUB } = {}) {
466
491
  const els = new Map();
467
492
  const $ = (sel) => { if (!els.has(sel)) els.set(sel, makeEl()); return els.get(sel); };
468
493
  $('#conn-scroll').hidden = true; // the markup ships it hidden
@@ -472,7 +497,7 @@ function boot({ transport } = {}) {
472
497
  console: { error() {}, warn() {}, log() {} },
473
498
  window: {
474
499
  BongosClient: { createClient: () => api },
475
- OTB: { $, escapeHtml, fmtDate: (d) => String(d).slice(0, 10), toast: (m) => toasts.push(m) },
500
+ OTB: { $, escapeHtml, fmtDate: (d) => String(d).slice(0, 10), toast: (m) => toasts.push(m), hubOrigin: () => hub },
476
501
  OTBKit: {
477
502
  makePager: (prefix, key) => ({ prefix, key, page: 1, perPage: 25 }),
478
503
  makeLedger: ({ mount, rowHtml, emptyMsg }) => ({
@@ -737,7 +762,7 @@ test('this page never asks a question about anyone else’s connections', async
737
762
  await page.render(profileOf('bo-was', 12), 99);
738
763
  assert.ok(asked.length > 0);
739
764
  for (const path of asked) {
740
- assert.match(path, /^\/api\/bongos\/connections(\/(requests|degree\/[^/]+))?$/,
765
+ assert.match(path, /^\/api\/bongos\/connections(\/(requests|degree\/[^/]+)|\?here=1)?$/,
741
766
  `the section may only read its own scope; ${path} names a subject the session did not`);
742
767
  assert.doesNotMatch(path, /\/builders\//, 'a per-builder connections read would be a public list, and there is deliberately no such route');
743
768
  }
@@ -845,9 +870,11 @@ test('the mutual count is the length of the names beside it, not a number the re
845
870
  test('seen_at never reaches the screen, on either side of a request', async () => {
846
871
  reset();
847
872
  const stamped = edge(ADA, ME, 'pending', { created_at: Date.now() - DAY, seen_at: Date.now() - 3600000 });
873
+ // Delete the accepted ADA–ME edge so the pending one is the pair's only row.
874
+ edges = edges.filter((r) => !(r.status === 'accepted' && r.requester_github_id === ME && r.addressee_github_id === ADA));
848
875
  const page = boot();
849
- await page.render(profileOf('octo', 7), 7); // own profile
850
- assert.match(page.html(), /Ada Lovelace/);
876
+ await page.render(profileOf('ada', 11), 99); // ADA's profile: her request is the decision there
877
+ assert.match(page.html(), /They have asked to connect\./);
851
878
  assert.doesNotMatch(page.html(), /seen/i);
852
879
  assert.doesNotMatch(page.html(), new RegExp(new Date(stamped.seen_at).toISOString().slice(0, 10) + 'T'));
853
880
  // and the API never even hands the requester one
@@ -857,45 +884,83 @@ test('seen_at never reaches the screen, on either side of a request', async () =
857
884
  assert.ok(!('seen_at' in asRequester.data.outgoing[0]), 'an outgoing row must carry no read receipt');
858
885
  });
859
886
 
887
+ test('your own tab never reads the request queues, so it never marks a request seen', async () => {
888
+ reset();
889
+ const ask = edge(NEW, ME, 'pending', { created_at: Date.now() - DAY });
890
+ const page = boot();
891
+ await page.render(profileOf('octo', 7), 7);
892
+ assert.equal(page.$('#conn-scroll').hidden, false);
893
+ assert.ok(!asked.includes('/api/bongos/connections/requests'),
894
+ 'GET /connections/requests stamps seen_at on what it returns — "delivered" — so a read this tab does not render would mark a request seen that nobody saw');
895
+ assert.equal(ask.seen_at, null, 'the incoming row is still unseen after the own tab rendered');
896
+ });
897
+
860
898
  // ===========================================================================
861
899
  // 7. Your own profile — the list, the queues, and the actions on them
862
900
  // ===========================================================================
863
901
 
864
- test('your own profile lists your connections, each 1st by definition, with both request queues', async () => {
902
+ test('your own tab lists only the connections who build on this project, and links to the hub for the rest', async () => {
865
903
  reset();
866
- edge(ADA, ME, 'pending', { created_at: Date.now() - DAY, note: 'worth comparing notes' });
867
- edge(ME, NEW, 'pending', { created_at: Date.now() - DAY });
904
+ edge(ME, FAR, 'accepted'); // a connection on another project only
905
+ edge(GONE, ME, 'accepted'); // a connection whose seat here is deactivated
906
+ edge(NEW, ME, 'pending', { created_at: Date.now() - DAY, note: 'worth comparing notes' });
907
+ edge(ME, CY, 'pending', { created_at: Date.now() - DAY });
868
908
  const page = boot();
869
909
  await page.render(profileOf('octo', 7), 7);
870
910
  assert.equal(page.$('#conn-scroll').hidden, false);
911
+ assert.ok(asked.includes('/api/bongos/connections?here=1'), 'the own tab reads the project cut, not the full list');
871
912
  const html = page.html();
872
- assert.match(html, /2 connections\./, 'the count is the length of the list the viewer was handed');
873
- assert.equal((html.match(/conn-degree--1/g) || []).length, 2, 'every accepted connection is 1st by definition');
913
+ assert.match(html, /2 of your connections build on this project\./, 'the count is the length of the list the viewer was handed');
914
+ assert.equal((html.match(/conn-degree--1/g) || []).length, 2, 'every listed connection is 1st by definition');
874
915
  assert.equal((html.match(/>1st</g) || []).length, 2, '…and it says so — the class alone is not the label');
875
- assert.match(html, /1 builder asking to connect/);
876
- assert.match(html, /worth comparing notes/);
877
- assert.match(html, /1 request you have sent/);
878
- assert.match(html, /data-act="withdraw"/);
879
- assert.match(html, /data-act="remove"/);
916
+ assert.match(html, /@ada/);
880
917
  // SEC is private, and it is in MY OWN list — own scope floors on consent only.
881
918
  assert.match(html, /@sec/);
919
+ assert.doesNotMatch(html, /@faraway/, 'a connection who does not build here is the hub page’s, not this tab’s');
920
+ assert.doesNotMatch(html, /@gone/, 'a deactivated seat is not building here');
921
+ assert.match(html, /data-act="remove"/);
922
+ // The queues moved to the hub: none drawn, none decidable here.
923
+ assert.doesNotMatch(html, /asking to connect|you have sent|worth comparing notes/);
924
+ assert.doesNotMatch(html, /data-act="(accept|decline|withdraw)"/);
925
+ assert.match(html, /<a class="pbtn" id="conn-hub-link" href="https:\/\/hub\.example\/connections">Open your Connections page<\/a>/);
882
926
  });
883
927
 
884
- test('accepting moves the asker into your connections; declining removes it and tells them nothing', async () => {
928
+ test('with no hub origin the own tab draws no link out, and still lists what it has', async () => {
885
929
  reset();
886
- const ask = edge(ADA, ME, 'pending', { created_at: Date.now() - DAY });
930
+ const page = boot({ hub: null });
931
+ await page.render(profileOf('octo', 7), 7);
932
+ assert.equal(page.$('#conn-scroll').hidden, false);
933
+ assert.doesNotMatch(page.html(), /conn-hub-link|Connections page/);
934
+ assert.match(page.html(), /2 of your connections build on this project\./);
935
+ });
936
+
937
+ test('no connections here yet says so in one line, and the empty ledger says how one arrives', async () => {
938
+ reset();
939
+ builders = new Map([[String(ME), 'active']]); // ADA and SEC build elsewhere
887
940
  const page = boot();
888
941
  await page.render(profileOf('octo', 7), 7);
942
+ assert.match(page.html(), /None of your connections build on this project yet\./);
943
+ assert.match(page.html(), /When someone you are connected with joins this project, they show up here\./);
944
+ assert.match(page.html(), /id="conn-hub-link"/, 'the way to the rest stays, which is when it matters most');
945
+ });
946
+
947
+ test('accepting and declining are decided on the asker’s profile; a decline tells them nothing', async () => {
948
+ reset();
949
+ edge(CY, ME, 'pending', { created_at: Date.now() - DAY });
950
+ const page = boot();
951
+ await page.render(profileOf('cy', 13), 99);
952
+ const ask = edges.find((r) => r.requester_github_id === CY && r.addressee_github_id === ME);
889
953
  await click(page, { act: 'accept', id: String(ask.id) });
890
954
  assert.ok(page.toasts.includes('Connected.'));
955
+ assert.match(page.html(), />1st</, 'the label re-reads as 1st once accepted');
891
956
 
892
957
  reset();
893
958
  const spurned = edge(NEW, ME, 'pending', { created_at: Date.now() - DAY });
894
959
  const page2 = boot();
895
- await page2.render(profileOf('octo', 7), 7);
960
+ await page2.render(profileOf('newbie', 18), 99);
896
961
  await click(page2, { act: 'decline', id: String(spurned.id) });
897
962
  assert.ok(page2.toasts.includes('Declined.'));
898
- assert.doesNotMatch(page2.html(), /Newbie/, 'the request leaves the inbox');
963
+ assert.doesNotMatch(page2.html(), /data-act="(accept|decline)"/, 'the decision leaves the page');
899
964
  // The requester's own page says nothing about it.
900
965
  session = { github_id: NEW, github_login: 'newbie' };
901
966
  const theirs = boot();
@@ -910,7 +975,7 @@ test('removing a connection takes it out of the list', async () => {
910
975
  const id = /data-act="remove" data-id="(\d+)"/.exec(page.html())[1];
911
976
  await click(page, { act: 'remove', id });
912
977
  assert.ok(page.toasts.includes('Connection removed.'));
913
- assert.match(page.html(), /1 connection\./);
978
+ assert.match(page.html(), /1 of your connections builds on this project\./);
914
979
  });
915
980
 
916
981
  // ===========================================================================
@@ -935,8 +1000,7 @@ function suggestTransport(suggestions, { onConnect } = {}) {
935
1000
  if (onConnect) onConnect(opts.body && opts.body.handle);
936
1001
  return { status: 201, ok: true, data: { status: 'pending' } };
937
1002
  }
938
- if (method === 'GET' && path === '/api/bongos/connections') return { status: 200, ok: true, data: { connections: [], count: 0 } };
939
- if (method === 'GET' && path === '/api/bongos/connections/requests') return { status: 200, ok: true, data: { incoming: [], outgoing: [] } };
1003
+ if (method === 'GET' && path === '/api/bongos/connections?here=1') return { status: 200, ok: true, data: { connections: [], count: 0 } };
940
1004
  if (method === 'GET' && path === '/api/bongos/connections/suggestions') {
941
1005
  calls.suggestions++;
942
1006
  return { status: 200, ok: true, data: { suggestions, count: suggestions.length } };
@@ -972,8 +1036,7 @@ test('a failed suggestions read leaves the rest of the own-profile section intac
972
1036
  const page = boot({
973
1037
  transport: {
974
1038
  request: async (_m, path) => {
975
- if (path === '/api/bongos/connections') return { status: 200, ok: true, data: { connections: [], count: 0 } };
976
- if (path === '/api/bongos/connections/requests') return { status: 200, ok: true, data: { incoming: [], outgoing: [] } };
1039
+ if (path === '/api/bongos/connections?here=1') return { status: 200, ok: true, data: { connections: [], count: 0 } };
977
1040
  return { status: 500, ok: false, data: null }; // suggestions read fails
978
1041
  },
979
1042
  },
@@ -981,7 +1044,7 @@ test('a failed suggestions read leaves the rest of the own-profile section intac
981
1044
  await page.render(profileOf('octo', 7), 7);
982
1045
  assert.equal(page.$('#conn-scroll').hidden, false, 'suggestions are additive, never load-bearing for the section itself');
983
1046
  assert.doesNotMatch(page.html(), /Builders you may know/);
984
- assert.match(page.html(), /0 connections\./);
1047
+ assert.match(page.html(), /None of your connections build on this project yet\./);
985
1048
  });
986
1049
 
987
1050
  test('clicking Connect on a suggestion sends the ask by handle and refreshes the section', async () => {
@@ -1944,6 +1944,21 @@ test('the kit proves the picker: a state renders it on the owner\'s feed-carried
1944
1944
  assert.ok(names.some((n) => n.startsWith('manage flows: its planet (task 1003347)')), 'the save probe');
1945
1945
  assert.ok(names.some((n) => n.includes('handed back to the platform')), 'the reset probe');
1946
1946
  assert.ok(names.some((n) => n.includes('its planet at 390')), 'the fold probe');
1947
+ // The reset must undo something (task 1003645): on a fresh page Demo's row carries
1948
+ // no pick and the head already shows the feed's, so a lone reset probe passed
1949
+ // whether or not the reset worked. It now continues the page a pick was saved
1950
+ // on, and that pick is not the template the head falls back to.
1951
+ const resetAt = probes.probes.findIndex((p) => p.name.includes('handed back to the platform'));
1952
+ const reset = probes.probes[resetAt];
1953
+ const picked = probes.probes[resetAt - 1];
1954
+ const headOf = (p) => (p.expect.attr || []).find((a) => a[0] === '#mOrb .worldOrb')?.[2];
1955
+ assert.equal(reset.continue, true, 'the reset runs on the page the pick was saved on');
1956
+ assert.ok(reset.steps.some((s) => s[0] === 'click' && s[1] === '[data-pick-reset]'), 'and presses the reset');
1957
+ assert.ok(picked.steps.some((s) => s[0] === 'click' && s[1] === '[data-pick-save]'), 'the probe before it saves a pick');
1958
+ const feed = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', 'featured.json'), 'utf8'));
1959
+ const demo = (feed.projects || feed).find((p) => p.origin === 'https://demo.cloudbongos.com');
1960
+ assert.equal(headOf(reset), demo.planet_template, 'the head falls back to the feed’s template');
1961
+ assert.ok(headOf(picked) && headOf(picked) !== headOf(reset), 'the saved pick differs from the fall-back, or the reset proves nothing');
1947
1962
  const saveProbe = probes.probes.find((p) => p.name.startsWith('manage flows: its planet (task 1003347)'));
1948
1963
  assert.ok(saveProbe.expect.text.some((t) => t[1] === 'Saved. The sky, your list and the card draw it this way from here; nothing on your project restarts for it.'), 'the route\'s no-restart receipt, verbatim');
1949
1964
  const stub = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'serve.js'), 'utf8');
@@ -25,8 +25,8 @@ test('a shared asset reaches every page in its dir whose HTML names it', () => {
25
25
  assert.deepEqual(names(['modules/hall-ui/public/sky-data.js']), ['goal-map.states.json', 'thinking.states.json']);
26
26
  assert.deepEqual(names(['modules/hall-ui/public/text-fit.js']), ['goals.states.json']);
27
27
  // the account page links the hub's sheet too since task 1004259, the guild page since task 1002301,
28
- // and the invitations page since task 1004276
29
- assert.deepEqual(names(['modules/public-landing/public/assets/cosmos.css']), ['account.states.json', 'guild.states.json', 'invitations.states.json', 'projects.states.json']);
28
+ // the invitations page since task 1004276, and the connections page since task 1004278
29
+ assert.deepEqual(names(['modules/public-landing/public/assets/cosmos.css']), ['account.states.json', 'connections.states.json', 'guild.states.json', 'invitations.states.json', 'projects.states.json']);
30
30
  });
31
31
 
32
32
  test('the page a builder changed comes before the pages a shared file reaches, so the render cap never drops it', () => {
@@ -8,12 +8,14 @@
8
8
  // PURE decision point — given a shipped task's security flag, it returns the
9
9
  // list of { channel, line } pairs the deploy step posts. This test proves:
10
10
  // 1. security_sensitive=true → ONLY the Archon-only #security-ship-feed.
11
- // 2. security_sensitive=false → the builder #ship-feed only. (#ship-news is
12
- // PAUSED — task 1749 / idea 458 — so a ship announces exactly once.)
11
+ // 2. security_sensitive=false → the builder #ship-feed, every ship, and the
12
+ // owner-facing #ship-news ONLY for a feature or a bug, as the plain
13
+ // summary with no id (task 1002846 / idea 1000458, the owner's pick: a
14
+ // kind filter). Any other kind, or none, stays off #ship-news.
13
15
  // 3. a falsy summary → [] (the long-standing no-summary, no-broadcast).
14
16
  // 4. the security branch can NEVER return ship_news / ship_feed, for ANY
15
- // taskId/summary — the load-bearing safety property (a leak here discloses
16
- // an unpatched exploit on a public channel).
17
+ // taskId/summary/kind — the load-bearing safety property (a leak here
18
+ // discloses an unpatched exploit on a public channel).
17
19
  //
18
20
  // Run: node tests/ship_broadcast_routing.mjs
19
21
 
@@ -63,24 +65,63 @@ test('security_sensitive=true NEVER posts to a public channel', () => {
63
65
  }
64
66
  });
65
67
 
66
- // ---- 2. non-security → #ship-feed only (#ship-news paused, task 1749) ----
68
+ // ---- 2. non-security → #ship-feed always; #ship-news for a feature or a bug ----
67
69
 
68
- test('security_sensitive=false → #ship-feed only (#id prefix); #ship-news paused', () => {
70
+ const ALL_KINDS = ['feature', 'bug', 'infra', 'refactor', 'spike', 'learning-capture', 'cleanup', 'decision', 'blocker-resolution', 'verify'];
71
+
72
+ test('the news kinds are exactly feature and bug, and every one is a real task kind', () => {
73
+ const { NEWS_KINDS } = require('../modules/discord/ship-broadcast.js');
74
+ const kinds = new Set(require('../modules/lifecycle/task-classifier.js').VALID_KINDS);
75
+ assert.deepEqual([...NEWS_KINDS].sort(), ['bug', 'feature']);
76
+ for (const k of NEWS_KINDS) assert.ok(kinds.has(k), `${k} is a kind a task can carry`);
77
+ for (const k of ALL_KINDS) assert.ok(kinds.has(k), `the test's kind list stays real: ${k}`);
78
+ });
79
+
80
+ test('a feature → the #ship-feed ledger line, then the plain #ship-news headline (no id)', () => {
69
81
  const targets = ship.chooseBroadcastTargets({
70
82
  securitySensitive: false,
71
83
  taskId: 669,
72
84
  summary: 'the keeper has a name now',
85
+ kind: 'feature',
73
86
  });
74
87
  assert.deepEqual(targets, [
75
88
  { channel: 'ship_feed', line: '#669 shipped — the keeper has a name now' },
89
+ { channel: 'ship_news', line: 'the keeper has a name now' },
76
90
  ]);
77
91
  });
78
92
 
79
- test('undefined securitySensitive falls through to the public path (#ship-feed only, #ship-news paused)', () => {
80
- const targets = ship.chooseBroadcastTargets({
81
- taskId: 42,
82
- summary: 'routine fix',
83
- });
93
+ test('a bug fix is news too', () => {
94
+ const targets = ship.chooseBroadcastTargets({ securitySensitive: false, taskId: 70, summary: 'the map loads again', kind: 'bug' });
95
+ assert.deepEqual(targets.map((t) => t.channel), ['ship_feed', 'ship_news']);
96
+ });
97
+
98
+ test('every other kind stays on #ship-feed alone', () => {
99
+ for (const kind of ALL_KINDS.filter((k) => k !== 'feature' && k !== 'bug')) {
100
+ const targets = ship.chooseBroadcastTargets({ securitySensitive: false, taskId: 9, summary: 'tidied the cache', kind });
101
+ assert.deepEqual(targets, [{ channel: 'ship_feed', line: '#9 shipped — tidied the cache' }], `kind=${kind}`);
102
+ }
103
+ });
104
+
105
+ test('a missing or unknown kind posts no headline (a quiet #ship-news is the safe failure)', () => {
106
+ for (const kind of [undefined, null, '', 'Feature', 'BUG', 'features', 1]) {
107
+ const targets = ship.chooseBroadcastTargets({ securitySensitive: false, taskId: 42, summary: 'routine fix', kind });
108
+ assert.deepEqual(targets.map((t) => t.channel), ['ship_feed'], `kind=${String(kind)}`);
109
+ }
110
+ });
111
+
112
+ test('undefined securitySensitive falls through to the public path (the kind rule still applies)', () => {
113
+ assert.deepEqual(ship.chooseBroadcastTargets({ taskId: 42, summary: 'routine fix' }).map((t) => t.channel), ['ship_feed']);
114
+ assert.deepEqual(ship.chooseBroadcastTargets({ taskId: 42, summary: 'new door', kind: 'feature' }).map((t) => t.channel), ['ship_feed', 'ship_news']);
115
+ });
116
+
117
+ test('the #ship-news headline never opens with #<id>, so applause can never map it to a task', () => {
118
+ const news = ship.chooseBroadcastTargets({ securitySensitive: false, taskId: 12, summary: 'search finds builders', kind: 'feature' })
119
+ .find((t) => t.channel === 'ship_news');
120
+ assert.doesNotMatch(news.line, /^#\d/);
121
+ });
122
+
123
+ test('a page approval stays a #ship-feed line, whatever its kind', () => {
124
+ const targets = ship.chooseBroadcastTargets({ securitySensitive: false, taskId: 31, summary: 'the landing reads better', kind: 'feature', pageTweak: true, pageArtist: 'Ana' });
84
125
  assert.deepEqual(targets.map((t) => t.channel), ['ship_feed']);
85
126
  });
86
127
 
@@ -95,11 +136,11 @@ test('falsy summary → [] (no broadcast), regardless of the security flag', ()
95
136
 
96
137
  // ---- 4. exhaustive: the security branch is leak-proof across many inputs ----
97
138
 
98
- test('exhaustive: security_sensitive=true never leaks to ship_news/ship_feed for any id/summary', () => {
139
+ test('exhaustive: security_sensitive=true never leaks to ship_news/ship_feed for any id/summary/kind', () => {
99
140
  const summaries = ['x', 'a long-ish value summary with spaces', '#641 cross-ref in summary', '🛡️ unicode'];
100
141
  for (const taskId of [1, 42, 990, 123456]) {
101
- for (const summary of summaries) {
102
- const targets = ship.chooseBroadcastTargets({ securitySensitive: true, taskId, summary });
142
+ for (const summary of summaries) for (const kind of [...ALL_KINDS, undefined]) for (const pageTweak of [false, true]) {
143
+ const targets = ship.chooseBroadcastTargets({ securitySensitive: true, taskId, summary, kind, pageTweak });
103
144
  assert.equal(targets.length, 1, 'security ship posts to exactly one channel');
104
145
  assert.equal(targets[0].channel, 'security_ship_feed');
105
146
  for (const { channel } of targets) {
@@ -112,7 +153,7 @@ test('exhaustive: security_sensitive=true never leaks to ship_news/ship_feed for
112
153
  // ---- purity: same input → deeply-equal output, no shared mutable state ----
113
154
 
114
155
  test('pure: repeated calls return independent, deeply-equal arrays', () => {
115
- const args = { securitySensitive: false, taskId: 5, summary: 's' };
156
+ const args = { securitySensitive: false, taskId: 5, summary: 's', kind: 'feature' };
116
157
  const a = ship.chooseBroadcastTargets(args);
117
158
  const b = ship.chooseBroadcastTargets(args);
118
159
  assert.deepEqual(a, b);
@@ -121,5 +162,5 @@ test('pure: repeated calls return independent, deeply-equal arrays', () => {
121
162
  assert.equal(ship.chooseBroadcastTargets(args)[0].channel, 'ship_feed');
122
163
  });
123
164
 
124
- console.log(`\nship broadcast routing (task 990) tests: ${passed} passed, ${failed} failed`);
165
+ console.log(`\nship broadcast routing (task 990, task 1002846) tests: ${passed} passed, ${failed} failed`);
125
166
  process.exit(failed > 0 ? 1 : 0);
@@ -56,10 +56,10 @@ const PUBLIC_CHANNELS = new Set(['ship_news', 'ship_feed']);
56
56
 
57
57
  // ---- 1. postShipBroadcast: routing parity with chooseBroadcastTargets ----
58
58
 
59
- await atest('non-security task → posts #ship-feed only (#ship-news paused, task 1749)', async () => {
59
+ await atest('non-security cleanup → posts #ship-feed only (not news, task 1002846)', async () => {
60
60
  const { fn, calls } = fakePoster();
61
61
  const res = await shipBroadcast.postShipBroadcast(
62
- { id: 669, value_summary: 'the keeper has a name now', security_sensitive: false },
62
+ { id: 669, value_summary: 'the keeper has a name now', security_sensitive: false, kind: 'cleanup' },
63
63
  { postWebhook: fn, log: quietLog }
64
64
  );
65
65
  assert.deepEqual(calls, [
@@ -69,10 +69,29 @@ await atest('non-security task → posts #ship-feed only (#ship-news paused, tas
69
69
  assert.equal(res.skipped, null);
70
70
  });
71
71
 
72
- await atest('security task → ONLY #security-ship-feed (never a public channel)', async () => {
72
+ await atest('non-security feature → #ship-feed, then the plain #ship-news headline, read off the row’s kind', async () => {
73
73
  const { fn, calls } = fakePoster();
74
74
  const res = await shipBroadcast.postShipBroadcast(
75
- { id: 990, value_summary: 'patched the credit-theft hole', security_sensitive: true },
75
+ { id: 670, value_summary: 'builders can find each other', security_sensitive: false, kind: 'feature' },
76
+ { postWebhook: fn, log: quietLog }
77
+ );
78
+ assert.deepEqual(calls, [
79
+ { channel: 'ship_feed', line: '#670 shipped — builders can find each other' },
80
+ { channel: 'ship_news', line: 'builders can find each other' },
81
+ ]);
82
+ assert.deepEqual(res.posted, ['ship_feed', 'ship_news']);
83
+ });
84
+
85
+ await atest('a row with no kind posts no headline', async () => {
86
+ const { fn, calls } = fakePoster();
87
+ await shipBroadcast.postShipBroadcast({ id: 671, value_summary: 'x', security_sensitive: false }, { postWebhook: fn, log: quietLog });
88
+ assert.deepEqual(calls.map((c) => c.channel), ['ship_feed']);
89
+ });
90
+
91
+ await atest('security task → ONLY #security-ship-feed (never a public channel), even a bug', async () => {
92
+ const { fn, calls } = fakePoster();
93
+ const res = await shipBroadcast.postShipBroadcast(
94
+ { id: 990, value_summary: 'patched the credit-theft hole', security_sensitive: true, kind: 'bug' },
76
95
  { postWebhook: fn, log: quietLog }
77
96
  );
78
97
  assert.equal(calls.length, 1);
@@ -112,9 +131,9 @@ await atest('missing task / id → skipped: no_task', async () => {
112
131
  });
113
132
 
114
133
  await atest('best-effort: a throwing webhook never propagates', async () => {
115
- // #ship-news is paused (task 1749), so a non-security ship has a single target
116
- // (#ship-feed). Prove the per-channel try/catch still swallows a failing post:
117
- // a throwing webhook must never propagate, and nothing is recorded posted.
134
+ // A non-news ship has a single target (#ship-feed). Prove the per-channel
135
+ // try/catch still swallows a failing post: a throwing webhook must never
136
+ // propagate, and nothing is recorded posted.
118
137
  const calls = [];
119
138
  const flaky = async (channel) => {
120
139
  calls.push(channel);
@@ -135,6 +154,20 @@ await atest('best-effort: a throwing webhook never propagates', async () => {
135
154
  assert.deepEqual(res.posted, [], 'a failed post is not recorded as posted');
136
155
  });
137
156
 
157
+ await atest('best-effort: a failing #ship-feed does not cost a feature its #ship-news headline', async () => {
158
+ const calls = [];
159
+ const flaky = async (channel) => {
160
+ calls.push(channel);
161
+ if (channel === 'ship_feed') throw new Error('webhook 500 / not configured');
162
+ };
163
+ const res = await shipBroadcast.postShipBroadcast(
164
+ { id: 43, value_summary: 'a new door', security_sensitive: false, kind: 'feature' },
165
+ { postWebhook: flaky, log: quietLog }
166
+ );
167
+ assert.deepEqual(calls, ['ship_feed', 'ship_news'], 'each target is attempted');
168
+ assert.deepEqual(res.posted, ['ship_news'], 'only the post that went through is recorded');
169
+ });
170
+
138
171
  // ---- 2. broadcastHandledByServer gate (ship.js) ----
139
172
 
140
173
  // Set the env BEFORE requiring ship.js is unnecessary — resolveDeployMode +
@@ -214,6 +214,25 @@ test('gateApplies: a vendored instance (no fitness.js on either side) stands the
214
214
  }
215
215
  });
216
216
 
217
+ // task 1004728: a vendored instance keeps an untracked `scripts` link into
218
+ // node_modules/@bongos/core (hidden by .git/info/exclude; grading needs it), so
219
+ // the core's fitness.js is ON DISK while the repo tracks none. The disk check read
220
+ // that as a gated repo and blocked every land on toast. An excluded plain file
221
+ // stands in for the link here: same on-disk shape, and portable to Windows.
222
+ test('gateApplies: an UNTRACKED fitness.js on disk (the vendored scripts link) does not gate the repo', () => {
223
+ const { dir, baseSha } = gitRepo(false);
224
+ try {
225
+ fs.mkdirSync(path.join(dir, 'scripts', 'gds'), { recursive: true });
226
+ fs.writeFileSync(path.join(dir, 'scripts', 'gds', 'fitness.js'), 'process.exit(1);\n');
227
+ fs.appendFileSync(path.join(dir, '.git', 'info', 'exclude'), '/scripts\n');
228
+ assert.equal(gate.gateApplies(dir, baseSha), false);
229
+ const verdict = gate.gateMergeResult({ repoRoot: dir, baseSha, log: () => {} });
230
+ assert.deepEqual(verdict, { block: false, reason: 'not-applicable' });
231
+ } finally {
232
+ fs.rmSync(dir, { recursive: true, force: true });
233
+ }
234
+ });
235
+
217
236
  test('gateApplies: a merge that DELETES fitness.js from a gated base is still measured (and blocked)', () => {
218
237
  const { dir, baseSha } = gitRepo(true);
219
238
  try {
@@ -43,14 +43,14 @@ test('the shipped states files validate and sit beside the page they name', () =
43
43
  }
44
44
  // the apex is dark only (task 1003336): its states files name the one mode, and
45
45
  // render.js reads it as the default so a plain `--page /` never renders light
46
- for (const rel of ['modules/public-landing/public/projects.states.json', 'modules/public-landing/public/index.states.json', 'modules/public-landing/public/account.states.json', 'modules/public-landing/public/guild.states.json', 'modules/public-landing/public/invitations.states.json']) {
46
+ for (const rel of ['modules/public-landing/public/projects.states.json', 'modules/public-landing/public/index.states.json', 'modules/public-landing/public/account.states.json', 'modules/public-landing/public/guild.states.json', 'modules/public-landing/public/invitations.states.json', 'modules/public-landing/public/connections.states.json', 'modules/public-landing/public/connections-empty.states.json', 'modules/public-landing/public/connections-inactive.states.json']) {
47
47
  assert.deepEqual(readJson(rel).modes, ['dark'], `${rel}: the apex renders dark only`);
48
48
  assert.equal(readJson(rel).modeQuery, false, `${rel}: no query walks a second mode`);
49
49
  }
50
50
  });
51
51
 
52
52
  test('the shipped probes files validate', () => {
53
- for (const rel of ['modules/public-landing/public/projects.probes.json', 'modules/public-landing/public/index.probes.json', 'modules/public-landing/public/account.probes.json', 'modules/public-landing/public/guild.probes.json', 'modules/public-landing/public/invitations.probes.json']) {
53
+ for (const rel of ['modules/public-landing/public/projects.probes.json', 'modules/public-landing/public/index.probes.json', 'modules/public-landing/public/account.probes.json', 'modules/public-landing/public/guild.probes.json', 'modules/public-landing/public/invitations.probes.json', 'modules/public-landing/public/connections.probes.json']) {
54
54
  const v = lib.validateProbesFile(readJson(rel));
55
55
  assert.deepEqual(v.errors, [], `${rel}: ${v.errors.join('; ')}`);
56
56
  }
@@ -470,6 +470,29 @@ await test('NO profile is not an error — the row renders exactly as it always
470
470
  assert.match(row.textContent, /octocat/);
471
471
  });
472
472
 
473
+ // The handle the vouch carried (ADR 0333 D5, task 1004673): when the hub is down the
474
+ // live profile is null, and the stored handle is what the queue can still call them.
475
+ await test('a vouched row with NO profile names the platform handle its vouch carried', async () => {
476
+ const h = await boot({ rows: { pending: withProfile(null, { applicant_handle: 'mona-h' }), invited: [], dismissed: [] } });
477
+ const facts = h.rows()[0].querySelector('.ov-row__facts');
478
+ assert.match(facts.textContent, /On the platform as @mona-h/);
479
+ assert.equal(facts.querySelectorAll('a').length, 0, 'plain text: the hub has no person page to point at');
480
+ });
481
+
482
+ await test('the handle stays off a row that drew its profile, and off an unvouched row', async () => {
483
+ const drew = await boot({ rows: { pending: withProfile(PUBLIC_PROFILE, { applicant_handle: 'mona-h' }), invited: [], dismissed: [] } });
484
+ assert.doesNotMatch(drew.rows()[0].textContent, /On the platform as/, 'the live record is shown instead');
485
+ const unvouched = await boot({ rows: { pending: withProfile(null, { vouched: false, applicant_handle: 'mona-h' }), invited: [], dismissed: [] } });
486
+ assert.doesNotMatch(unvouched.rows()[0].textContent, /On the platform as/, 'a name anyone could type has no vouched handle');
487
+ });
488
+
489
+ await test('the vouched handle is escaped', async () => {
490
+ const h = await boot({ rows: { pending: withProfile(null, { applicant_handle: '<b>x</b>' }), invited: [], dismissed: [] } });
491
+ const facts = h.rows()[0].querySelector('.ov-row__facts');
492
+ assert.equal(facts.querySelectorAll('b').length, 0);
493
+ assert.match(facts.textContent, /@<b>x<\/b>/);
494
+ });
495
+
473
496
  await test('an unrecognised view shape draws nothing rather than guessing', async () => {
474
497
  const h = await boot({ rows: { pending: withProfile({ view: 'everything', account: { bio: 'x' } }), invited: [], dismissed: [] } });
475
498
  assert.equal(h.rows()[0].querySelector('.ov-row__profile'), null,