@bongos/core 1.21.113 → 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 (260) hide show
  1. package/.bongos-core.json +603 -183
  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 +2 -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 +122 -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-verify.js +21 -2
  180. package/scripts/gds/government-walk.js +638 -4
  181. package/scripts/gds/pulse.js +9 -6
  182. package/scripts/gds/ship-escalation.js +22 -3
  183. package/scripts/gds/ship-finish.js +5 -4
  184. package/scripts/gds/ship-merge-gate.js +18 -6
  185. package/scripts/hall-preview/README.md +41 -1
  186. package/scripts/hall-preview/pref-writes.js +140 -0
  187. package/scripts/hall-preview/refresh-fixtures.js +101 -7
  188. package/scripts/hall-preview/scrub-identity.js +366 -7
  189. package/scripts/hall-preview/server.js +27 -1
  190. package/scripts/render-diagrams.sh +4 -2
  191. package/src/bongos/auth-idp.js +6 -1
  192. package/src/bongos/project-settings.js +41 -0
  193. package/src/bongos/route-rank-check.js +27 -0
  194. package/src/bongos/routes/me.js +68 -7
  195. package/src/bongos/serve-internal.js +5 -1
  196. package/src/module-api.js +7 -1
  197. package/src/platform-server.js +10 -0
  198. package/tests/application_lifecycle.mjs +47 -5
  199. package/tests/conductor_main_e2e.mjs +21 -0
  200. package/tests/connections_api.mjs +18 -0
  201. package/tests/craft_mode_nav.mjs +5 -3
  202. package/tests/craft_mode_tallies.mjs +52 -16
  203. package/tests/discord_approvals.mjs +11 -0
  204. package/tests/discord_channels.mjs +20 -0
  205. package/tests/fixtures/gov-seated.mjs +64 -0
  206. package/tests/fixtures/government-ask-cards.json +1284 -0
  207. package/tests/government_act_alone_buildings.mjs +1 -0
  208. package/tests/government_agent_asks.mjs +322 -0
  209. package/tests/government_asks.mjs +408 -0
  210. package/tests/government_build_rights.mjs +253 -0
  211. package/tests/government_buildouts.mjs +382 -0
  212. package/tests/government_counts.mjs +143 -0
  213. package/tests/government_decision_rules.mjs +5 -1
  214. package/tests/government_laws.mjs +488 -0
  215. package/tests/government_laws_session.mjs +321 -0
  216. package/tests/government_leads.mjs +375 -0
  217. package/tests/government_positions.mjs +127 -0
  218. package/tests/government_presets.mjs +215 -0
  219. package/tests/government_routes.mjs +8 -1
  220. package/tests/government_routing.mjs +118 -0
  221. package/tests/government_seed.mjs +5 -0
  222. package/tests/government_treasury.mjs +394 -0
  223. package/tests/governor_docket.mjs +26 -2
  224. package/tests/hall_buildouts.mjs +208 -0
  225. package/tests/hall_city.mjs +14 -6
  226. package/tests/hall_city_living.mjs +617 -0
  227. package/tests/hall_gov_counts.mjs +336 -0
  228. package/tests/hall_gov_living.mjs +636 -0
  229. package/tests/hall_gov_real_site.mjs +655 -0
  230. package/tests/hall_matter_card.mjs +6 -1
  231. package/tests/hall_my_agents.mjs +481 -0
  232. package/tests/hall_nav.mjs +107 -4
  233. package/tests/hall_page_gate_map.mjs +4 -0
  234. package/tests/hall_preview_governor_fixtures.mjs +30 -3
  235. package/tests/hall_preview_scrub.mjs +59 -1
  236. package/tests/hall_preview_security.mjs +169 -0
  237. package/tests/hall_reading_world.mjs +44 -0
  238. package/tests/hall_roster_world.mjs +6 -2
  239. package/tests/hall_street.mjs +18 -2
  240. package/tests/hall_street_amend.mjs +1 -1
  241. package/tests/hall_street_deck.mjs +609 -0
  242. package/tests/hall_street_depth.mjs +559 -0
  243. package/tests/hall_street_desk.mjs +1 -1
  244. package/tests/hall_street_inside.mjs +29 -5
  245. package/tests/hall_tweak_editor.mjs +4 -1
  246. package/tests/hub_account_page.mjs +94 -3
  247. package/tests/hub_connections_page.mjs +507 -0
  248. package/tests/hub_invite_badge.mjs +1 -1
  249. package/tests/idp_device_exchange.mjs +18 -0
  250. package/tests/landing_legal_pages.mjs +8 -5
  251. package/tests/landing_page.mjs +23 -4
  252. package/tests/nav_permission_atoms.mjs +4 -1
  253. package/tests/profile_connections_ui.mjs +88 -25
  254. package/tests/projects_hub.mjs +15 -0
  255. package/tests/render_check.mjs +2 -2
  256. package/tests/ship_broadcast_routing.mjs +57 -16
  257. package/tests/ship_broadcast_server.mjs +40 -7
  258. package/tests/ship_merge_gate.mjs +19 -0
  259. package/tests/ui_design_kit.mjs +2 -2
  260. package/tests/watch_applications_queue.mjs +23 -0
@@ -122,7 +122,9 @@ const discordIsLinked = (builderId) =>
122
122
  // `idea.triage` (task 1004231): the sky's panel draws "nod — make it claimable"
123
123
  // on a thought awaiting a nod only for a builder who may triage ideas; the nod
124
124
  // itself runs the existing POST /tasks/:id/promote, which stays the wall.
125
- const NAV_PERMISSION_ATOMS = Object.freeze(['board.vote.cast', 'core.pin.move', 'idea.triage']);
125
+ // `board.item.open` (task 1004714): My agents offers 'Make it a law' — a laws vote,
126
+ // POST /government/board/laws — only to a builder who may open a board item.
127
+ const NAV_PERMISSION_ATOMS = Object.freeze(['board.vote.cast', 'core.pin.move', 'idea.triage', 'board.item.open']);
126
128
 
127
129
  // navPermissionsFor(builderId) → { '<atom>': boolean } over NAV_PERMISSION_ATOMS,
128
130
  // or null when authority could not be established (government off, port missing,
@@ -187,7 +189,7 @@ module.exports = function buildMeRouter() {
187
189
  recentGrades, builderAvg, onboardingRow, shipCount, recentKarma, karma30d, discordLink, cliAuthed,
188
190
  behaviorPrefs, geminiKeyRow, needsRebaseTasks, foundingBuilder, helpRequestCount,
189
191
  taskRecommendationCount, boardVotesPending, socialMode, mingleIntroductionCount,
190
- navPermissions, foundingMode,
192
+ navPermissions, foundingMode, sessionLaws, govPositions,
191
193
  ] = await Promise.all([
192
194
  db.getBuilderById(req.builder.id),
193
195
  lc().getActiveClaimsForBuilder(req.builder.id),
@@ -318,7 +320,44 @@ module.exports = function buildMeRouter() {
318
320
  return await onboarding.foundingModeFor();
319
321
  } catch (_) { return null; }
320
322
  })(),
323
+ // task 1004707: the project's LAWS — what every AI session on the project
324
+ // reads, via the `government` port (core imports no module, ADR 0093).
325
+ // Read per request, uncached, so a law passed a moment ago is in the next
326
+ // session. null when the module is off or the read failed: the session then
327
+ // gets no laws block, and the settings slices resolve without locks.
328
+ (async () => {
329
+ try {
330
+ const government = seams.resolveOptional('government');
331
+ if (!government || typeof government.lawsForSession !== 'function') return null;
332
+ return await government.lawsForSession();
333
+ } catch (err) {
334
+ log.error({ err: err && err.message }, 'GET /me laws');
335
+ return null;
336
+ }
337
+ })(),
338
+ // task 1004713: the government POSITIONS this builder holds (owner, Lead,
339
+ // a building's sitting, the board), via the `government` port. Read per
340
+ // request, uncached. null when the module is off or a read failed.
341
+ (async () => {
342
+ try {
343
+ const government = seams.resolveOptional('government');
344
+ if (!government || typeof government.positionsFor !== 'function') return null;
345
+ return await government.positionsFor(req.builder.id);
346
+ } catch (err) {
347
+ log.error({ err: err && err.message }, 'GET /me positions');
348
+ return null;
349
+ }
350
+ })(),
321
351
  ]);
352
+ // task 1004707: a law's LOCKS bite on the three settings slices below
353
+ // (interaction, wandering, model allocation): their effective values and
354
+ // injected contracts are resolved with every lock applied, server-side —
355
+ // the builder-settings port's describeWithLaws is the SSOT. A builder's own
356
+ // stored choice is still reported as theirs.
357
+ const instanceModels = (() => { try { return branding().models || {}; } catch { return {}; } })();
358
+ const lawed = settings && typeof settings.describeWithLaws === 'function'
359
+ ? settings.describeWithLaws(behaviorPrefs, (sessionLaws && sessionLaws.laws) || [], { rank: builder && builder.rank, instanceModels })
360
+ : null;
322
361
  // V3.R84 (#302), criterion C3: the builder's month-to-date spend vs their
323
362
  // monthly budget. Surfaced here so the CLI (session-start ping) and the hall
324
363
  // profile banner read it without a follow-up call; the ship + art-pipeline
@@ -401,6 +440,14 @@ module.exports = function buildMeRouter() {
401
440
  // floor. null when authority could not be established, which the shell
402
441
  // reads as nothing-visible. Cosmetic only — ADR 0016, the server enforces.
403
442
  permissions: navPermissions,
443
+ // task 1004713 (owner ruling 2026-10-09: governance is a HELD POSITION, not a
444
+ // picked craft): { held: [{ kind, seat, building, title }], title,
445
+ // has_position, from } — the seats this builder holds, resolved server-side
446
+ // (modules/government/positions.js). The hall shows the governing surfaces
447
+ // and names the Governing mode by `title` only for a holder. null when it
448
+ // could not be read, which the hall reads as unknown (today's nav), never
449
+ // "holds none". Cosmetic only — every governing page keeps its own gate.
450
+ positions: govPositions,
404
451
  unlocked,
405
452
  locked_with_progress: lockedWithProgress,
406
453
  streak: {
@@ -469,13 +516,27 @@ module.exports = function buildMeRouter() {
469
516
  // level (clamped by rank) + its behavior contract. The Conductor + the
470
517
  // /settings UI read this; the builder-settings port's describeWandering is
471
518
  // the SSOT (BV1.R80). null when the prefs module is off.
472
- wandering: settings ? settings.describeWandering(behaviorPrefs, builder.rank) : null,
519
+ wandering: lawed ? lawed.wandering : (settings ? settings.describeWandering(behaviorPrefs, builder.rank) : null),
473
520
  // task 2010: the per-builder interaction profile (tone / technical level /
474
521
  // toolchain familiarity), resolved to an effective profile + an injectable
475
522
  // contract. The old §2 "The Prompter" owner-defaults, now per-builder. The
476
523
  // session-start injection + /settings UI read this; describeInteraction is
477
524
  // the SSOT (builder-settings port). null when the prefs module is off.
478
- interaction: settings && settings.describeInteraction ? settings.describeInteraction(behaviorPrefs) : null,
525
+ interaction: lawed ? lawed.interaction : (settings && settings.describeInteraction ? settings.describeInteraction(behaviorPrefs) : null),
526
+ // task 1004707: the project's LAWS, read into every AI session. The
527
+ // Conductor hook relays `contract` verbatim beside the interaction profile
528
+ // (inject:false, and no block, when no law is in force). `laws` is the book
529
+ // as the session reads it, numbered; `locks` is what each law's lock did to
530
+ // THIS builder's settings ({ setting, law_id, enforced, from, to } — a lock on
531
+ // a setting no table holds yet is enforced:false, its law's words still
532
+ // apply). The server writes all of it; nothing local can change it. null when
533
+ // the laws could not be read.
534
+ laws: sessionLaws ? {
535
+ inject: !!sessionLaws.inject,
536
+ contract: sessionLaws.contract || '',
537
+ laws: Array.isArray(sessionLaws.laws) ? sessionLaws.laws : [],
538
+ locks: lawed ? lawed.locks : [],
539
+ } : null,
479
540
  // task 1003855: the builder's ACTIVE specialities, one per craft at most
480
541
  // (the speciality_adoptions partial unique index enforces that). Each entry
481
542
  // carries its own injectable contract; the Conductor picks the one matching
@@ -491,9 +552,9 @@ module.exports = function buildMeRouter() {
491
552
  // task 2011: the resolved subagent model allocation (main + subagent tiers),
492
553
  // builder override → instance default (branding.models) → code default. The
493
554
  // agent consults `effective` when spawning subagents; §2 links here.
494
- model_allocation: settings && settings.describeModelAllocation
495
- ? settings.describeModelAllocation(behaviorPrefs, (() => { try { return branding().models || {}; } catch { return {}; } })())
496
- : null,
555
+ model_allocation: lawed ? lawed.model_allocation : (settings && settings.describeModelAllocation
556
+ ? settings.describeModelAllocation(behaviorPrefs, instanceModels)
557
+ : null),
497
558
  // task 1279: the per-builder "render widgets" knob (shares behavior_prefs).
498
559
  // { widgets_enabled } tells the start/claim/ship CLIs whether to emit the
499
560
  // rich HTML card or the cheap text fallback. No rank cap (cost/taste, not
@@ -1245,8 +1245,12 @@ function mountInternalSurfaces(app) {
1245
1245
  // task 1004473: `commands`, the Commands page — every skill and every bongos CLI
1246
1246
  // verb. Both reads behind it are requireBuilder, so it sits on this tier;
1247
1247
  // reading the list grants nothing (ADR 0310 §1).
1248
+ // task 1004685 (the living-city prototype): `my-agents` — how the viewer's own
1249
+ // Claude works for them. Every read behind it is the viewer's own and
1250
+ // requireBuilder (GET /me and its /me/* prefs, /specialities), so sign-in is the
1251
+ // whole gate; the governing reads it adds are gated by their own routes.
1248
1252
  const SIGNEDIN_BUILDERS_PAGE_RE =
1249
- /^\/builders\/(?:work|settings|atlas|modules|goals|goal-map|roadmap|copy-desk|studio|tweak-editor|ideas|thinking|collab|task|idea|primer(?:\.md)?|diagrams|commands)(?:\.html)?\/?$/;
1253
+ /^\/builders\/(?:work|settings|atlas|modules|goals|goal-map|roadmap|copy-desk|studio|tweak-editor|ideas|thinking|collab|task|idea|primer(?:\.md)?|diagrams|my-agents|commands)(?:\.html)?\/?$/;
1250
1254
  // The diagram image assets under /builders/diagrams/ ride the same tier as the
1251
1255
  // page that embeds them.
1252
1256
  const SIGNEDIN_BUILDERS_PREFIX_RE = /^\/builders\/diagrams\//;
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.113'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.114'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -473,6 +473,12 @@ module.exports = {
473
473
  get SHIP_MODES() { return require('./bongos/project-settings').SHIP_MODES; },
474
474
  get parseShipMode() { return require('./bongos/project-settings').parseShipMode; },
475
475
  get resolveShipMode() { return require('./bongos/project-settings').resolveShipMode; },
476
+ // The spend cap (task 1004712, ADR 0379): off by default (no limit, the owner's
477
+ // ruling). The treasury reads it; PATCH /project-settings/spend-cap writes it.
478
+ get parseSpendCap() { return require('./bongos/project-settings').parseSpendCap; },
479
+ get resolveSpendCap() { return require('./bongos/project-settings').resolveSpendCap; },
480
+ get setSpendCap() { return require('./bongos/project-settings').setSpendCap; },
481
+ get describeSpendCap() { return require('./bongos/project-settings').describeSpendCap; },
476
482
  get describeShipMode() { return require('./bongos/project-settings').describeShipMode; },
477
483
  // Tweak auto-pickup (task 1004677, ADR 0371 D3/D5): the switch and the monthly cap.
478
484
  get AUTOPICKUP_MODES() { return require('./bongos/project-settings').AUTOPICKUP_MODES; },
@@ -316,6 +316,16 @@ function buildPlatformApp() {
316
316
  if (!html) return next();
317
317
  res.type('html').send(html);
318
318
  });
319
+ // THE CONNECTIONS PAGE (task 1004278, ADR 0337 D5.4): the hub is the primary
320
+ // home for connections — your connections across every project, the requests
321
+ // in both directions, and "Builders you may know". Hub-only for the /account
322
+ // reason: every read and write it makes is a platform-identity route.
323
+ app.get('/connections', (req, res, next) => {
324
+ if (!isApexHost(req) || !isModuleEnabled('platform-identity')) return next();
325
+ const html = landingPage('connections.html');
326
+ if (!html) return next();
327
+ res.type('html').send(html);
328
+ });
319
329
  // THE GUILD PAGE (task 1002301, R25; ADR 0336 D4/D6): /g/<slug>, the namespace
320
330
  // D6 reserved. Hub-only for the /account reason — every read it makes is a
321
331
  // platform-identity route. ONE shell for every slug: the page reads the public
@@ -310,12 +310,12 @@ const pool = {
310
310
 
311
311
  // A vouched re-application taking over an unvouched pending row (ADR 0333 D4).
312
312
  // The SET list is matched exactly, like the resolve's below.
313
- if ((m = /^UPDATE access_requests SET display_name = \$2, note = \$3, applicant_github_id = \$4, applicant_handle = \$5, vouched_at = \$6 WHERE (.+?)$/.exec(sql))) {
313
+ if ((m = /^UPDATE access_requests SET display_name = \$2, note = \$3, applicant_github_id = \$4, applicant_handle = \$5, vouched_at = \$6 WHERE (.+?) RETURNING id, github_login, status, created_at$/.exec(sql))) {
314
314
  const hit = rows.filter(predicateFor(m[1], params));
315
315
  for (const r of hit) {
316
316
  [r.display_name, r.note, r.applicant_github_id, r.applicant_handle, r.vouched_at] = params.slice(1, 6);
317
317
  }
318
- return { rows: [], rowCount: hit.length };
318
+ return { rows: hit.map((r) => ({ id: r.id, github_login: r.github_login, status: r.status, created_at: r.created_at })), rowCount: hit.length };
319
319
  }
320
320
 
321
321
  // The resolve — one UPDATE, one WHERE, and that WHERE is the whole rule.
@@ -354,11 +354,13 @@ const poolPath = require.resolve('../src/bongos/pool.js');
354
354
  require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool } };
355
355
 
356
356
  // Discord broadcast is fire-and-forget on the public write; stubbed so the suite
357
- // is hermetic (it is best-effort in production and never affects the queue).
357
+ // is hermetic (it is best-effort in production and never affects the queue), and
358
+ // recorded so a test can see which #member-approvals lines would have gone out.
359
+ let broadcasts = [];
358
360
  const broadcastPath = require.resolve('../modules/onboarding/approval-broadcast.js');
359
361
  require.cache[broadcastPath] = {
360
362
  id: broadcastPath, filename: broadcastPath, loaded: true,
361
- exports: { postMemberApprovalRequest: async () => ({ posted: false, skipped: 'test' }) },
363
+ exports: { postMemberApprovalRequest: async (request) => { broadcasts.push(request); return { posted: false, skipped: 'test' }; } },
362
364
  };
363
365
 
364
366
  // The hub's side of the vouch, for real: a generated Ed25519 keypair, the real
@@ -452,7 +454,7 @@ function call(method, pathname, body, extraHeaders = {}) {
452
454
  req.end(payload);
453
455
  });
454
456
  }
455
- const reset = (rank = 'archon') => { seed(); actingRank = rank; actingId = 42; notices = []; hubViews = {}; hubAsked = []; };
457
+ const reset = (rank = 'archon') => { seed(); actingRank = rank; actingId = 42; notices = []; hubViews = {}; hubAsked = []; broadcasts = []; };
456
458
 
457
459
  // ===========================================================================
458
460
  // The interpreter is the instrument, so it is calibrated first
@@ -618,6 +620,19 @@ test('a vouched re-application takes over an UNVOUCHED pending row for its own l
618
620
  assert.equal(row(1).status, 'pending', 'and it decides nothing');
619
621
  });
620
622
 
623
+ // The first #member-approvals line still quotes the words the row held before the
624
+ // takeover, and a webhook cannot edit it; so the takeover posts the row's words now.
625
+ test('a vouched takeover re-posts the #member-approvals line with the words the row holds now', async () => {
626
+ reset();
627
+ await applyAs('ada', { [VOUCH_HEADER]: vouchFor('ada', { githubId: 8001, handle: 'ada-h' }) });
628
+ assert.equal(broadcasts.length, 1, 'one line, for the takeover — the duplicate INSERT posts nothing');
629
+ const [line] = broadcasts;
630
+ assert.equal(line.id, 1, 'the same request, so a 👍 on either line resolves the one row');
631
+ assert.equal(line.refiled, true);
632
+ assert.equal(line.display_name, 'New');
633
+ assert.match(line.note, /via platform My Projects/);
634
+ });
635
+
621
636
  test('a re-application never re-stamps an ALREADY vouched row, and an unvouched one changes nothing', async () => {
622
637
  reset();
623
638
  const bo = { ...row(2) };
@@ -626,6 +641,33 @@ test('a re-application never re-stamps an ALREADY vouched row, and an unvouched
626
641
  const ada = { ...row(1) };
627
642
  await applyAs('ada');
628
643
  assert.deepEqual(row(1), ada, 'an unvouched re-apply is the idempotent success it always was');
644
+ assert.deepEqual(broadcasts, [], 'and neither re-posts a line: nothing on the row changed');
645
+ });
646
+
647
+ // END TO END: a row written by the REAL vouch writer is the row the queue resolves
648
+ // from the hub. The profile tests below seed their vouched rows; this one proves the
649
+ // link between the two halves — the id the writer stores is the id the read asks for.
650
+ test('list-pending: the real vouch writer’s row resolves its profile and guild from the hub', async () => {
651
+ reset();
652
+ const profile = { view: 'public', account: { handle: 'newcomer-h', bio: 'hi' }, cross_project: null };
653
+ hubViews['7001'] = { profile, guild: { slug: 'lantern-works', name: 'Lantern Works' } };
654
+ await applyAs('Newcomer', { [VOUCH_HEADER]: vouchFor('newcomer') });
655
+ const r = await call('GET', '/api/gds/access-requests');
656
+ const got = r.json.requests.find((x) => x.github_login === 'Newcomer');
657
+ assert.ok(hubAsked.includes('7001'), 'the hub was asked about the id the vouch carried');
658
+ assert.deepEqual(got.profile, profile);
659
+ assert.deepEqual(got.guild, { slug: 'lantern-works', name: 'Lantern Works' });
660
+ });
661
+
662
+ test('list-pending: a vouched TAKEOVER resolves the vouched applicant, not whoever filed first', async () => {
663
+ reset();
664
+ hubViews['8001'] = { profile: { view: 'sliver', sliver: { disciplines: ['engineer'] } }, guild: null };
665
+ await applyAs('ada', { [VOUCH_HEADER]: vouchFor('ada', { githubId: 8001, handle: 'ada-h' }) });
666
+ const r = await call('GET', '/api/gds/access-requests');
667
+ const ada = r.json.requests.find((x) => x.github_login === 'ada');
668
+ assert.equal(ada.vouched, true);
669
+ assert.deepEqual(ada.profile, hubViews['8001'].profile);
670
+ assert.ok(hubAsked.includes('8001'));
629
671
  });
630
672
 
631
673
  test('list-pending: the queue carries the handle the vouch brought, beside the vouch mark', async () => {
@@ -128,4 +128,25 @@ await test('outside any claimed worktree no craft is guessed', async () => {
128
128
  assert.ok(r.out.includes(INTERACTION), 'and the rest of the hook still runs');
129
129
  });
130
130
 
131
+ // task 1004707: the project's LAWS, built by the SERVER's own helper (what the
132
+ // government port hands GET /me), run through the real hook.
133
+ const lawsLib = require('../modules/government/laws.js');
134
+ const LAW_BOOK = lawsLib.forSession([
135
+ { id: 11, text: 'Ask like a human', effect_on_ai: 'One short card with tap answers.', locks: [{ setting: 'verbosity', value: 'concise' }], repealed_at: null },
136
+ ]);
137
+
138
+ await test('a session on a project with laws is told the laws, first, in the server\'s own words', async () => {
139
+ const r = await runHook({ ...mePayload(), laws: { inject: LAW_BOOK.inject, contract: LAW_BOOK.contract, laws: LAW_BOOK.laws, locks: [] } });
140
+ assert.equal(r.code, 0);
141
+ assert.ok(r.out.includes('1. Ask like a human — One short card with tap answers.'), `the law reaches the session; stdout was: ${JSON.stringify(r.out.slice(0, 400))}`);
142
+ assert.ok(r.out.indexOf('Project laws') < r.out.indexOf(INTERACTION), 'ahead of the builder\'s own profile');
143
+ });
144
+
145
+ await test('a project with no laws sends no laws block', async () => {
146
+ const none = lawsLib.forSession([]);
147
+ const r = await runHook({ ...mePayload(), laws: { inject: none.inject, contract: none.contract, laws: [], locks: [] } });
148
+ assert.ok(!r.out.includes('Project laws'));
149
+ assert.ok(r.out.includes(INTERACTION), 'and the rest of the hook still runs');
150
+ });
151
+
131
152
  summary();
@@ -309,6 +309,24 @@ test('list: my connections, the count IS the array length, and paging is real',
309
309
  "…and applying the PUBLIC predicate here would erase your own connections from your own list");
310
310
  });
311
311
 
312
+ test('list: ?here=1 is the project cut — the same read, narrowed to this instance’s active builders', async () => {
313
+ expect(meActive(), result([]));
314
+ await call('GET', '/api/bongos/connections?here=1&limit=2&offset=4');
315
+ assert.ok(calls[1].text.includes(conn.HERE_SQL),
316
+ 'the hall tab’s cut (ADR 0337 D5.4): connections who also build on this project, joined on github_id');
317
+ assert.match(conn.HERE_SQL, /b\.status = 'active'/, 'a deactivated seat is not building here');
318
+ assert.deepEqual(calls[1].params, [ME.github_id, 2, 4],
319
+ 'a literal term with no new parameter — paging and the FANOUT_1 cap are the full list’s');
320
+ assert.match(calls[1].text, /\(pa\.profile_state = 'active' AND pa\.terms_accepted_at IS NOT NULL\)/,
321
+ 'still own scope on the consent floor — the cut narrows, it never swaps the predicate');
322
+
323
+ for (const q of ['', '?here=0', '?here=true', '?here=']) {
324
+ expect(meActive(), result([]));
325
+ await call('GET', `/api/bongos/connections${q}`);
326
+ assert.doesNotMatch(calls[1].text, /\bbuilders\b/, `"${q}" is the full list, unchanged`);
327
+ }
328
+ });
329
+
312
330
  test('list: the limit is clamped to FANOUT_1 however large a caller asks', async () => {
313
331
  expect(meActive(), result([]));
314
332
  await call('GET', '/api/bongos/connections?limit=999999');
@@ -128,8 +128,10 @@ const CRAFTS = ['building', 'artistry', 'ideating', 'governing'];
128
128
 
129
129
  test('THE ALWAYS-PRESENT LIST: one shared list, Builders first, then Collab and Profile, inside every mode', () => {
130
130
  const { craft } = boot();
131
- assert.deepEqual([...craft.ALWAYS_TABS], ['roster', 'collab', 'profile'],
132
- 'Builders (NAV id roster) heads the list (owner, 2026-10-01); Collab and Profile join it (owner, 2026-10-03)');
131
+ // task 1004685 (the living-city prototype): My agents joins as a PROPOSAL for the
132
+ // owner — every craft has agents, and round-3 board 6 pairs it with the city.
133
+ assert.deepEqual([...craft.ALWAYS_TABS], ['roster', 'collab', 'profile', 'my-agents'],
134
+ 'Builders (NAV id roster) heads the list (owner, 2026-10-01); Collab and Profile join it (owner, 2026-10-03); My agents (prototype, task 1004685)');
133
135
  for (const m of CRAFTS) {
134
136
  const tabs = craft.tabsFor(m);
135
137
  assert.ok(Array.isArray(tabs), `${m} has a named tab list`);
@@ -187,7 +189,7 @@ test('a mode never shows a tab the gate hid, and a later gate pass keeps the mod
187
189
  p.signIn('thetes');
188
190
  assert.ok(!p.showing().includes('watch'), 'a thetes in Governing does not get the metic-floored Watch tab');
189
191
  assert.ok(!p.headShowing('government'), 'nor its group head');
190
- assert.deepEqual(p.showing(), ['blockers', 'board-room', 'city', 'profile', 'collab', 'roster'], 'what a thetes board member in Governing sees');
192
+ assert.deepEqual(p.showing(), ['blockers', 'board-room', 'city', 'profile', 'my-agents', 'collab', 'roster'], 'what a thetes board member in Governing sees');
191
193
  p.signIn('archon');
192
194
  assert.ok(p.showing().includes('watch'), 'promoted, the gate lets it through and the mode keeps it');
193
195
  assert.ok(p.headShowing('government'), 'and its group head comes back with it, which only a mode pass after the gate can know');
@@ -1,11 +1,15 @@
1
1
  // tests/craft_mode_tallies.mjs — Governing mode's header tallies (task 1004563, goal
2
- // 1000095 WA6): "needs you N · moving M" beside the eyebrow.
2
+ // 1000095 WA6): "to do N · moving M" beside the eyebrow, on the city and the
3
+ // street only (task 1004701, owner ruling 2026-10-09).
3
4
  //
4
5
  // Runs the REAL craft-mode.js over a small DOM with a stubbed fetch, and the REAL
5
6
  // server count (modules/lifecycle/moving.js) over a fake pool, so the two halves are
6
7
  // pinned where they actually decide: which statuses "moving" counts (owner decision
7
8
  // 2026-10-08: active + confirmed), and that a figure the page could not read is left
8
- // out rather than drawn as a zero that says nothing waits on you.
9
+ // out rather than drawn as a zero that says nothing waits on you. Since the living
10
+ // city (task 1004685) both figures are the city's own (gov-living.js countsOf over
11
+ // gov-feeds.js), so the real gov-living.js and gov-feeds.js run beside it here;
12
+ // tests/hall_gov_counts.mjs holds the tally equal to every other surface.
9
13
  import assert from 'node:assert/strict';
10
14
  import { test } from 'node:test';
11
15
  import fs from 'node:fs';
@@ -16,6 +20,8 @@ import { fileURLToPath } from 'node:url';
16
20
 
17
21
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
18
22
  const CRAFT = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/craft-mode.js'), 'utf8');
23
+ const LIVING = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/gov-living.js'), 'utf8');
24
+ const FEEDS = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/gov-feeds.js'), 'utf8');
19
25
  const require = createRequire(import.meta.url);
20
26
  process.env.NODE_ENV = 'test';
21
27
  const { countMoving, movingHandler, MOVING_STATUSES } = require('../modules/lifecycle/moving.js');
@@ -44,7 +50,8 @@ function node(tag, id, cls) {
44
50
  function all(root) { return root.children.flatMap((c) => [c, ...all(c)]); }
45
51
 
46
52
  // answers: path → JSON body, or a number (an HTTP failure status), or 'throw'.
47
- function boot(mode, answers) {
53
+ // page: the body's data-page (the tally stands on the city and the street only)
54
+ function boot(mode, answers, page = 'city') {
48
55
  const sidebar = node('NAV', 'app-sidebar');
49
56
  sidebar.appendChild(node('DIV', '', 'app-sidebar__brand'));
50
57
  const topbar = node('DIV', 'app-topbar');
@@ -52,7 +59,7 @@ function boot(mode, answers) {
52
59
  const root = node('HTML');
53
60
  const asked = [];
54
61
  const document = {
55
- documentElement: root, body: node('BODY'), activeElement: null,
62
+ documentElement: root, body: (() => { const b = node('BODY'); if (page) b.setAttribute('data-page', page); return b; })(), activeElement: null,
56
63
  getElementById: (id) => [sidebar, topbar, ...all(sidebar), ...all(topbar)].find((n) => n.id === id) || null,
57
64
  querySelectorAll: () => [],
58
65
  createElement: (t) => node(t.toUpperCase()),
@@ -68,20 +75,34 @@ function boot(mode, answers) {
68
75
  if (typeof a === 'number') return Promise.resolve({ ok: false, status: a, json: () => Promise.resolve({}) });
69
76
  return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(a) });
70
77
  };
71
- const sandbox = { window, document, fetch, CustomEvent: class { constructor(t, i) { this.type = t; Object.assign(this, i); } } };
78
+ window.fetch = fetch;
79
+ const sandbox = { window, document, fetch, Date, CustomEvent: class { constructor(t, i) { this.type = t; Object.assign(this, i); } } };
72
80
  vm.createContext(sandbox);
81
+ // the living city's one counter and one reader, as the city and the street carry them
82
+ // (task 1004685): the tally prints their numbers, read through gov-feeds.js
83
+ vm.runInContext(LIVING, sandbox, { filename: 'gov-living.js' });
84
+ vm.runInContext(FEEDS, sandbox, { filename: 'gov-feeds.js' });
73
85
  vm.runInContext(CRAFT, sandbox, { filename: 'craft-mode.js' });
74
86
  const craft = window.OTBCraftMode;
75
87
  craft.mount();
76
88
  const box = () => all(topbar).find((n) => n.id === 'craft-tallies') || null;
77
89
  return { craft, topbar, asked, box };
78
90
  }
79
- const settle = () => new Promise((r) => setImmediate(r));
91
+ const settle = async () => { for (let i = 0; i < 20; i++) await new Promise((r) => setImmediate(r)); };
80
92
  const text = (html) => html.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
81
93
 
82
- const BOTH = { '/blockers?limit=1': { blockers: [{ id: 1 }], page: { limit: 1, offset: 0, total: 3 } }, '/moving': { moving: 12, active: 9, confirmed: 3 } };
94
+ // Since the living city (task 1004685) the tally prints the city's own counts:
95
+ // gov-living.js countsOf over gov-feeds.js COUNT_FEEDS, and since task 1004716 those
96
+ // stand on ONE server read, GET /government/counts. Here three matters on the
97
+ // viewer, no agent at work, twelve moving.
98
+ const COUNTS = { viewer: { id: '1' }, on_you: 3, on_others: 0, open: 3, agents_at_work: 0, moving: 12, routed: true, yours: ['b:1', 'b:2', 'b:3'], others: [] };
99
+ const BOTH = {
100
+ '/government/counts': COUNTS,
101
+ '/me': { builder: { id: '1', github_login: 'builder-1', rank: 'archon' } },
102
+ };
103
+ const COUNT_PATHS = ['/government/counts', '/me'];
83
104
 
84
- test('Governing reads both counts and draws "needs you 3 · moving 12" right after the eyebrow', async () => {
105
+ test('Governing reads the city\'s counts and draws "to do 3 · moving 12" right after the eyebrow', async () => {
85
106
  const { topbar, box, asked } = boot('governing', BOTH);
86
107
  await settle();
87
108
  const b = box();
@@ -89,24 +110,25 @@ test('Governing reads both counts and draws "needs you 3 · moving 12" right aft
89
110
  const eyebrow = all(topbar).find((n) => n.id === 'craft-eyebrow');
90
111
  assert.equal(eyebrow.nextSibling, b, 'they sit directly after the mode words');
91
112
  assert.equal(b.getAttribute('hidden'), null);
92
- assert.equal(text(b.innerHTML), 'needs you 3 · moving 12');
113
+ assert.equal(text(b.innerHTML), 'to do 3 · moving 12');
93
114
  assert.match(b.innerHTML, /craft-tally craft-tally--due/, 'something waits, so needs-you carries the warm dot');
94
- assert.deepEqual(asked.map((u) => u.replace('/api/bongos', '')).sort(), ['/blockers?limit=1', '/moving'], 'one page of one blocker for the total, one count — never a task list');
115
+ assert.deepEqual(asked.map((u) => u.replace('/api/bongos', '')).sort(), COUNT_PATHS,
116
+ 'one server count, once, beside the viewer the shell already reads — never a task list');
95
117
  });
96
118
 
97
- test('nothing waiting draws "needs you 0" without the dot', async () => {
98
- const { box } = boot('governing', { ...BOTH, '/blockers?limit=1': { blockers: [], page: { limit: 1, offset: 0, total: 0 } } });
119
+ test('nothing waiting draws "to do 0" without the dot', async () => {
120
+ const { box } = boot('governing', { ...BOTH, '/government/counts': { ...COUNTS, on_you: 0, open: 0, yours: [] } });
99
121
  await settle();
100
- assert.equal(text(box().innerHTML), 'needs you 0 · moving 12');
122
+ assert.equal(text(box().innerHTML), 'to do 0 · moving 12');
101
123
  assert.doesNotMatch(box().innerHTML, /craft-tally--due/);
102
124
  });
103
125
 
104
126
  test('a count that cannot be read is LEFT OUT, never drawn as zero', async () => {
105
- const noMoving = boot('governing', { ...BOTH, '/moving': 401 });
127
+ const noMoving = boot('governing', { ...BOTH, '/government/counts': { ...COUNTS, moving: null } });
106
128
  await settle();
107
- assert.equal(text(noMoving.box().innerHTML), 'needs you 3', 'a signed-out or failed /moving drops only its own figure');
129
+ assert.equal(text(noMoving.box().innerHTML), 'to do 3', 'a moving count the server could not read drops only its own figure');
108
130
 
109
- const offline = boot('governing', { '/blockers?limit=1': 'throw', '/moving': { moving: 'lots' } });
131
+ const offline = boot('governing', { '/government/counts': 'throw' });
110
132
  await settle();
111
133
  assert.equal(offline.box().getAttribute('hidden'), '', 'nothing readable: the slot stays hidden, no zeros');
112
134
  assert.equal(offline.box().innerHTML, '');
@@ -126,6 +148,20 @@ test('only Governing wears the tallies, and leaving it removes them', async () =
126
148
  assert.equal(box(), null, 'switching away takes them down');
127
149
  });
128
150
 
151
+ test('the top bar carries the tally on the city and the street only (owner ruling, task 1004701)', async () => {
152
+ for (const page of ['city', 'street']) {
153
+ const { box } = boot('governing', BOTH, page);
154
+ await settle();
155
+ assert.ok(box(), `${page} carries the tally`);
156
+ }
157
+ for (const page of ['my-agents', 'board', 'home', null]) {
158
+ const { box, asked } = boot('governing', BOTH, page);
159
+ await settle();
160
+ assert.equal(box(), null, `${page || 'a page with no name'} draws no tally`);
161
+ assert.deepEqual(asked, [], `${page || 'a page with no name'} asks the server for nothing`);
162
+ }
163
+ });
164
+
129
165
  test('the server counts active + confirmed only, and sums them', async () => {
130
166
  assert.deepEqual([...MOVING_STATUSES], ['active', 'confirmed']);
131
167
  let seen;
@@ -72,6 +72,17 @@ await t('memberApprovalLine round-trips through parseApprovalMarker', () => {
72
72
  assert.deepEqual(ap.parseApprovalMarker(line), { kind: 'member', id: 99 });
73
73
  });
74
74
 
75
+ // A vouched takeover (ADR 0333 D4) re-posts the request with the row's words now; it
76
+ // must still lead with the SAME marker, or a 👍 on it would resolve nothing.
77
+ await t('the refiled line carries the new words and round-trips to the same request', () => {
78
+ const line = bcast.memberApprovalLine({ id: 99, github_login: 'carol', display_name: 'Carol', note: 'the real me', refiled: true });
79
+ assert.deepEqual(ap.parseApprovalMarker(line), { kind: 'member', id: 99 });
80
+ assert.match(line, /Carol/);
81
+ assert.match(line, /the real me/);
82
+ assert.match(line, /replace the earlier line for #99/);
83
+ assert.doesNotMatch(bcast.memberApprovalLine({ id: 99, github_login: 'carol' }), /replace/, 'a first line says nothing about replacing');
84
+ });
85
+
75
86
  // ---- recordApprovalReaction (orchestration via fakes) ----------------------
76
87
  const GOOD_ID = '123456789012345678';
77
88
  const archon = { id: 1, rank: 'archon', github_login: 'example-owner' };
@@ -361,6 +361,26 @@ await t('the real config declares the board-room channel, visible to every build
361
361
  assert.ok(board.topic && board.topic.length > 0, 'board-room needs a topic');
362
362
  });
363
363
 
364
+ // task 1002536: the AI-news feed is the ZERO-CODE form the owner chose at the
365
+ // 2026-07-29 triage — an off-the-shelf RSS service posts into one channel, and
366
+ // nothing on our host polls anything. The channel is the whole repo surface, so
367
+ // pin it: builders-only by INHERITING the BUILDERS category (spelling `view`
368
+ // out would repeat the category and drift), and a topic that names #ideas,
369
+ // because pasting a link there is the article → idea path (the inbound handler
370
+ // reads only typed text, never a forwarded post, so Forward files no link).
371
+ await t('the real config declares ai-news, builders-only, pointing readers at #ideas', () => {
372
+ const cfg = ch.loadConfig();
373
+ const cat = cfg.categories.find((c) => (c.channels || []).some((x) => x.key === 'ai-news'));
374
+ assert.ok(cat, 'expected a channel keyed ai-news');
375
+ const news = cat.channels.find((x) => x.key === 'ai-news');
376
+ assert.equal(ch.slugify(news.name), 'ai-news', 'the name must slug to its key, or apply creates a duplicate');
377
+ assert.equal(cat.name, 'BUILDERS');
378
+ assert.equal(news.view, undefined, 'ai-news must inherit, not repeat, its category view');
379
+ assert.equal(cat.view, 'xenos', 'so every builder, and only builders, can read it');
380
+ assert.ok(news.topic && news.topic.length <= 1024, 'Discord refuses a topic over 1024 characters');
381
+ assert.match(news.topic, /#ideas/, 'the topic must say where an article becomes an idea');
382
+ });
383
+
364
384
  await t('the real config plans cleanly against an empty guild and never deletes', () => {
365
385
  const cfg = ch.loadConfig();
366
386
  const { ops } = ch.planChannelReconcile({ categories: [], channels: [] }, cfg, CTX_BOT);
@@ -0,0 +1,64 @@
1
+ // tests/fixtures/gov-seated.mjs — A SEATED GOVERNMENT, as test data for the
2
+ // hall's living-city tests (task 1004717). The hub has seated no Lead and built
3
+ // nothing, so the harness fixtures alone cannot show a Lead's floor, a build-out
4
+ // going up or one waiting on its vote. This builds them in the exact shapes the
5
+ // routes answer — GET /government/leads with five Leads appointed by People votes,
6
+ // Review's Testers room standing, a floor going up at Release and an annex
7
+ // waiting on its sitting at Treasury — and routes the docket to those seats by
8
+ // the server's own rule (modules/government/routing.js). The hall never reads it.
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { createRequire } from 'node:module';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const require = createRequire(import.meta.url);
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
17
+ const routing = require(path.join(ROOT, 'modules', 'government', 'routing.js'));
18
+ const fx = (name) => JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', name + '.json'), 'utf8'));
19
+
20
+ // five Leads (the fixture's viewer, 1000025, leads Planning)
21
+ export const SEATED = Object.freeze([
22
+ { building: 'planning', lead: '1000025', since: '2026-09-12', scope: 'Goals, priorities, the order of work.' },
23
+ { building: 'release', lead: '1000027', since: '2026-09-05', scope: 'Release floors and the path to going live.' },
24
+ { building: 'review', lead: '1000095', since: '2026-09-20', scope: 'Who checks finished work, and how.' },
25
+ { building: 'treasury', lead: '1000094', since: '2026-09-28', scope: 'Pots and the reserve.' },
26
+ { building: 'people', lead: '1000070', since: '2026-10-02', scope: 'Who joins, and the ranks they hold.' },
27
+ ]);
28
+
29
+ // GET /government/leads with them in office and the three build-outs on the record
30
+ export function leadsRead() {
31
+ const r = fx('government__leads');
32
+ const roster = fx('builders__roster').builders;
33
+ const at = (k) => r.buildings.find((b) => b.building === k);
34
+ for (const l of SEATED) {
35
+ const p = roster.find((x) => String(x.id) === l.lead) || { id: l.lead };
36
+ at(l.building).lead = { id: 'L' + l.building, building: l.building, builder: { id: String(p.id), login: p.github_login || null, name: p.display_name || null }, charter: l.scope, since: l.since, appointed_by: { item_id: '1' } };
37
+ at(l.building).answers = { seat: 'lead:' + l.building, builder_ids: [String(p.id)], fell_to_owner: false };
38
+ }
39
+ at('review').structure = { standing: [{ id: 2, capsule: 'committee', name: 'Testers', line: 'Who checks finished work.', state: 'built', progress: 1 }], proposed: [] };
40
+ at('release').structure = { standing: [{ id: 1, capsule: 'floor', name: 'Staging floor', line: '', state: 'building', progress: 0.55, allowed: { path: 'at_once', why: 'Its holder builds it out at once.' } }], proposed: [] };
41
+ at('treasury').structure = { standing: [], proposed: [{ capsule: 'annex', name: 'Investments arm', line: 'A wing for the reserve.', docket_id: 'board:77', item_id: '77' }] };
42
+ return r;
43
+ }
44
+
45
+ // the annex's sitting, open on the docket: nobody has voted yet
46
+ function sitting(seats) {
47
+ return {
48
+ id: 'board:77', source: 'board', building: 'treasury', rule: 'majority', state: 'moving', title: 'Add an annex to Treasury: Investments arm', short: 'an annex',
49
+ opened_at: '2026-10-08T10:00:00.000Z', deadline: null, tally: { for: 0, against: 0, open: seats }, ref: { type: 'board_item', id: '77' }, acts: [],
50
+ };
51
+ }
52
+
53
+ // GET /government/docket with the sitting, every matter routed to those seats
54
+ export function docketRouted() {
55
+ const d = fx('government__docket');
56
+ d.matters.push(sitting(d.buildings.find((b) => b.key === 'charter').holders.length));
57
+ const read = leadsRead();
58
+ const seats = { leads: read.buildings.filter((b) => b.lead).map((b) => ({ building: b.building, builder_id: b.lead.builder.id })), owner: read.owner, holders: {} };
59
+ d.matters.forEach((m) => {
60
+ const { route, ...bare } = m;
61
+ m.route = routing.routeMatter(bare, d.viewer.id, seats, Date.parse(d.generated_at));
62
+ });
63
+ return d;
64
+ }