@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
@@ -0,0 +1,269 @@
1
+ 'use strict';
2
+
3
+ // modules/government/asks.js — THE ASK: the one shape every request from an AI
4
+ // agent to a person takes (task 1004703, goal 1000111 "government is how a
5
+ // project steers its AI").
6
+ //
7
+ // WHY. The owner's biggest worry: agents "slop" people with run-on text when they
8
+ // need a decision, and a wall of words suppresses the decision. So a blocker, a
9
+ // "please confirm", a review or a sign-off carries an ASK, stored in this shape:
10
+ //
11
+ // { question: 'Turn unattended building back on?', // ≤ 12 words, ends '?'
12
+ // options: [ // 2 or 3 tap answers, each a short label + a one-line result
13
+ // { key: 'on', label: 'Turn it on', result: 'Agents build overnight again.', act?: 'go ahead' },
14
+ // { key: 'off', label: 'Keep it off', result: 'Nothing builds while you sleep.' } ],
15
+ // recommend: { key: 'on', why: 'The evaluation passed.' }, // one of the keys, one line
16
+ // waits_on: 'owner', // a seat: owner | lead:<b> | committee:<b>.<k> | board:<b>
17
+ // if_nobody_answers: 'It stays off.', // one line
18
+ // detail?: '…the long text…' } // rides along; never the ask itself
19
+ //
20
+ // The SERVER REFUSES an ask that breaks the shape and names what to fix
21
+ // (checkAsk → errors[]; refusalLine → one plain paragraph). The rules are the
22
+ // prototype's (modules/hall-ui/public/gov-living.js askFormat, ASK_RULES): the
23
+ // hall's copy and this one are held to the same verdicts, rule by rule, by
24
+ // tests/government_asks.mjs. The server adds what only a STORED ask needs: a
25
+ // seat it can be routed by (task 1004705 routes it), answer keys and acts in a
26
+ // fixed grammar, and caps on every string.
27
+ //
28
+ // WHERE AN ASK LIVES. One row per docket matter, keyed by the matter's docket id
29
+ // (`blockers:12`, `board:34`, `override_requests:5` — docket.js normalizeMatter),
30
+ // in government_asks (government_028). The docket attaches each matter's ask as
31
+ // `ask` (docket.js buildDocket), so every surface reading the docket — the
32
+ // street, the deck, the city — reads the same stored words. Written by POST
33
+ // /blockers (ideas, through the `government` port) and PUT
34
+ // /government/asks/:matter; checked without storing by POST /government/asks/check.
35
+ //
36
+ // PURE: no db, no requires. asks-db.js holds the queries.
37
+
38
+ // The prototype's rules, number for number (gov-living.js ASK_RULES).
39
+ const ASK_RULES = Object.freeze({ question_words: 12, options_min: 2, options_max: 3, label_words: 4, line_words: 14, why_words: 20 });
40
+ // Caps on characters, so a stored ask can never be a wall in a short coat.
41
+ const CAPS = Object.freeze({ question: 160, label: 40, line: 160, why: 200, detail: 4000 });
42
+ // No markup or links in a line a person reads at a glance (gov-living.js MARKUP).
43
+ const MARKUP = /\*\*|`|^#|\[[^\]]*\]\(|https?:\/\/|<\/?[a-z]/im;
44
+ // Control characters other than tab and line breaks (line breaks have their own rule).
45
+ // eslint-disable-next-line no-control-regex
46
+ const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;
47
+ const BUILDINGS = Object.freeze(['charter', 'rules', 'disputes', 'people', 'planning', 'automation', 'review', 'release', 'treasury']);
48
+ // The seats an ask can wait on (gov-living.js seatResolver's grammar). An
49
+ // unseated building falls to the owner when it is routed (owner ruling).
50
+ const SEAT = new RegExp(`^(owner|(lead|board):(${BUILDINGS.join('|')})|committee:(${BUILDINGS.join('|')})\\.[a-z][a-z0-9_]{0,31})$`);
51
+ // An answer's key: what a tap sends. Its act, optional: the matter's own act it
52
+ // takes ('go ahead', 'record', 'force:tested_by_hand'); the deck maps it onto
53
+ // the desk row's real route and refuses one the row does not offer.
54
+ const KEY = /^[a-z][a-z0-9_-]{0,23}$/;
55
+ const ACT = /^[a-z][a-z_ ]{0,31}(:[a-z_]{1,31})?$/;
56
+ // A docket matter's id, as docket.js normalizeMatter writes it: `<source>:<id>`.
57
+ const MATTER_ID = /^[a-z][a-z_]{1,39}:[A-Za-z0-9._-]{1,64}$/;
58
+ const MAX_OPTIONS_READ = 8;
59
+
60
+ const str = (v) => (typeof v === 'string' ? v : '');
61
+ const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null);
62
+ function words(text) { return str(text).trim().split(/\s+/).filter(Boolean); }
63
+
64
+ // Sentences end at . ! ? followed by a space and a capital (or a digit, a quote,
65
+ // a bracket), or by the end; a version (1.20.82) or a decimal does not end one.
66
+ // The hall's own rule, character for character (gov-living.js sentences).
67
+ function sentences(text) {
68
+ const t = str(text).replace(/\s+/g, ' ').trim();
69
+ const out = [];
70
+ let start = 0;
71
+ for (let i = 0; i < t.length; i++) {
72
+ const ch = t[i];
73
+ if (ch !== '.' && ch !== '!' && ch !== '?') continue;
74
+ const next = t[i + 1];
75
+ if (next !== undefined && next !== ' ') continue;
76
+ const after = t[i + 2];
77
+ if (next === ' ' && after !== undefined && !/[A-Z0-9"'(*[]/.test(after)) continue;
78
+ out.push(t.slice(start, i + 1).trim());
79
+ start = i + 2;
80
+ }
81
+ if (start < t.length) out.push(t.slice(start).trim());
82
+ return out.filter((s) => /[A-Za-z0-9]/.test(s));
83
+ }
84
+
85
+ // checkAsk(raw) → { ok, errors: [{ field, rule, message }], question_words, ask }
86
+ // `ask` is the stored form (trimmed strings, only the known fields) when ok,
87
+ // else null. Every rule that breaks is named, so the agent fixes them all at once.
88
+ function checkAsk(raw) {
89
+ const errors = [];
90
+ const err = (field, rule, message) => errors.push({ field, rule, message });
91
+ const c = obj(raw);
92
+ if (!c) {
93
+ err('ask', 'missing', 'Send the ask as a card: a question, two or three answers, a pick, a seat and what happens if nobody answers.');
94
+ return { ok: false, errors, question_words: 0, ask: null };
95
+ }
96
+
97
+ // ---- the question: the prototype's rules ----
98
+ const q = str(c.question).trim();
99
+ const qWords = words(q).length;
100
+ if (!q) err('question', 'missing', 'Ask one question.');
101
+ else {
102
+ if (qWords > ASK_RULES.question_words) err('question', 'too_long', `The question is ${qWords} words; ${ASK_RULES.question_words} at most.`);
103
+ if (!/\?$/.test(q)) err('question', 'not_a_question', 'End the question with a question mark.');
104
+ if (sentences(q).length > 1 || /\n/.test(q)) err('question', 'more_than_one', 'Ask exactly one question.');
105
+ if (MARKUP.test(q)) err('question', 'markup', 'No markup or links in the question.');
106
+ if (qWords <= ASK_RULES.question_words && (q.length > CAPS.question || CONTROL.test(q))) err('question', 'too_long', `The question is ${q.length} characters; ${CAPS.question} at most.`);
107
+ }
108
+
109
+ // ---- the answers ----
110
+ const opts = Array.isArray(c.options) ? c.options : [];
111
+ if (opts.length < ASK_RULES.options_min || opts.length > ASK_RULES.options_max) {
112
+ err('options', 'count', `Offer ${ASK_RULES.options_min} or ${ASK_RULES.options_max} tap answers, not ${opts.length}.`);
113
+ }
114
+ const keys = new Set();
115
+ const outOpts = [];
116
+ opts.slice(0, MAX_OPTIONS_READ).forEach((o, i) => {
117
+ const f = `options[${i}]`;
118
+ if (!obj(o)) { err(f, 'missing', 'An answer needs a key, a label and a result.'); return; }
119
+ const k = str(o.key).trim();
120
+ if (!k) err(f, 'no_key', 'An answer needs a key.');
121
+ else if (keys.has(k)) err(f, 'duplicate', `Two answers share the key "${k}".`);
122
+ else if (!KEY.test(k)) err(f, 'bad_key', `The key "${k.slice(0, 30)}" is not one short lower-case word.`);
123
+ keys.add(k);
124
+ const label = str(o.label).trim();
125
+ const lw = words(label).length;
126
+ if (!lw) err(f, 'no_label', 'An answer needs a label to tap.');
127
+ else if (lw > ASK_RULES.label_words) err(f, 'label_too_long', `"${label}" is ${lw} words; a tap label is ${ASK_RULES.label_words} at most.`);
128
+ else if (label.length > CAPS.label || CONTROL.test(label) || /\n/.test(label)) err(f, 'label_too_long', `A tap label is ${CAPS.label} characters at most, on one line.`);
129
+ lineRule(f + '.result', o.result, 'Say in one line what this answer does.');
130
+ let act = null;
131
+ if (o.act != null && o.act !== '') {
132
+ act = str(o.act).trim();
133
+ if (!ACT.test(act)) err(f, 'bad_act', 'An answer\'s act names one act of the matter, like "go ahead" or "record".');
134
+ }
135
+ outOpts.push({ key: k, label, result: str(o.result).trim(), ...(act ? { act } : {}) });
136
+ });
137
+
138
+ // ---- the pick, and why ----
139
+ const rec = obj(c.recommend);
140
+ if (!rec) err('recommend', 'missing', 'Recommend an answer and say why.');
141
+ else {
142
+ if (!keys.has(str(rec.key))) err('recommend', 'unknown_key', 'Recommend one of the answers offered.');
143
+ const why = str(rec.why);
144
+ const ww = words(why).length;
145
+ if (!ww) err('recommend', 'no_why', 'Say why in one line.');
146
+ else if (ww > ASK_RULES.why_words || /\n/.test(why)) err('recommend', 'why_too_long', `The why is ${ww} words; ${ASK_RULES.why_words} at most, one line.`);
147
+ else if (why.trim().length > CAPS.why || CONTROL.test(why)) err('recommend', 'why_too_long', `The why is ${CAPS.why} characters at most.`);
148
+ }
149
+
150
+ // ---- who it waits on: a seat the direction desk can route ----
151
+ const seat = str(c.waits_on).trim();
152
+ if (!seat) err('waits_on', 'missing', 'Say who this waits on.');
153
+ else if (!SEAT.test(seat)) err('waits_on', 'unknown_seat', `"${seat.slice(0, 40)}" is not a seat: say owner, lead:<building>, committee:<building>.<name> or board:<building>.`);
154
+
155
+ // ---- what happens if nobody answers ----
156
+ lineRule('if_nobody_answers', c.if_nobody_answers, 'Say what happens if nobody answers.');
157
+
158
+ // ---- the long text: it may ride along, never as the ask ----
159
+ const detail = c.detail == null ? '' : (typeof c.detail === 'string' ? c.detail.trim() : null);
160
+ if (detail === null) err('detail', 'not_text', 'The detail is plain text.');
161
+ else if (detail.length > CAPS.detail) err('detail', 'too_long', `The detail is ${detail.length} characters; ${CAPS.detail} at most.`);
162
+
163
+ const ok = errors.length === 0;
164
+ return {
165
+ ok,
166
+ errors,
167
+ question_words: qWords,
168
+ ask: ok ? {
169
+ question: q,
170
+ options: outOpts,
171
+ recommend: { key: str(rec.key), why: str(rec.why).trim() },
172
+ waits_on: seat,
173
+ if_nobody_answers: str(c.if_nobody_answers).trim(),
174
+ detail: detail || null,
175
+ } : null,
176
+ };
177
+
178
+ function lineRule(field, text, missingMessage) {
179
+ const t = str(text).trim();
180
+ const n = words(t).length;
181
+ if (!n) err(field, 'missing', missingMessage);
182
+ else if (/\n/.test(t) || n > ASK_RULES.line_words) err(field, 'not_one_line', `${n} words; one line of ${ASK_RULES.line_words} at most.`);
183
+ else if (MARKUP.test(t)) err(field, 'markup', 'No markup or links.');
184
+ else if (t.length > CAPS.line || CONTROL.test(t)) err(field, 'not_one_line', `One line of ${CAPS.line} characters at most.`);
185
+ }
186
+ }
187
+
188
+ // The refusal, in plain words: what was refused and every fix, in one paragraph.
189
+ function refusalLine(errors) {
190
+ const list = Array.isArray(errors) ? errors : [];
191
+ if (!list.length) return '';
192
+ const fixes = list.slice(0, 8).map((e) => String(e.message || '').trim()).filter(Boolean);
193
+ const more = list.length > 8 ? ` And ${list.length - 8} more.` : '';
194
+ return `This ask was refused: a person must be able to answer it at a glance. ${fixes.join(' ')}${more}`;
195
+ }
196
+
197
+ // task 1004704 — AGENTS WRITE ASKS. When an AI session files a blocker by hand
198
+ // without a card, the route answers this (400 `ask_required`): what to send, in
199
+ // plain words, and the helper that checks a card before filing.
200
+ const ASK_REQUIRED_MESSAGE = 'An AI session files a blocker with an ask: a question of at most '
201
+ + `${ASK_RULES.question_words} words, ${ASK_RULES.options_min} or ${ASK_RULES.options_max} tap answers each with a one-line result, `
202
+ + 'a pick and why, who it waits on, and what happens if nobody answers. Put the long text in detail. '
203
+ + 'Check the card first with: node scripts/gds/ask.js check <file>';
204
+
205
+ // How a seat reads to a person.
206
+ function seatWords(seat) {
207
+ const s = str(seat);
208
+ if (s === 'owner') return 'the owner';
209
+ const m = /^(lead|board|committee):([a-z]+)(?:\.([a-z0-9_]+))?$/.exec(s);
210
+ if (!m) return s;
211
+ const building = m[2].charAt(0).toUpperCase() + m[2].slice(1);
212
+ if (m[1] === 'lead') return `the ${building} lead`;
213
+ if (m[1] === 'board') return `the ${building} board`;
214
+ return `the ${building} ${String(m[3] || '').replace(/_/g, ' ')} committee`;
215
+ }
216
+
217
+ // cardText(ask) → the card as a person reads it, in plain text: the question,
218
+ // the answers (the pick marked), why, who it waits on, what happens if nobody
219
+ // answers. What an agent prints in chat for anything that is not a stored
220
+ // matter — a "please confirm" at the end of a run, a help request, a review
221
+ // request — so every ask reads the same wherever it lands. `detail` is left out:
222
+ // it rides along, it is never the ask.
223
+ function cardText(ask) {
224
+ const a = obj(ask);
225
+ if (!a) return '';
226
+ const rec = obj(a.recommend) || {};
227
+ const lines = [str(a.question).trim()];
228
+ (Array.isArray(a.options) ? a.options : []).forEach((o, i) => {
229
+ const opt = obj(o) || {};
230
+ const pick = str(opt.key) && str(opt.key) === str(rec.key) ? ' (recommended)' : '';
231
+ lines.push(` ${i + 1}. ${str(opt.label).trim()}${pick}: ${str(opt.result).trim()}`);
232
+ });
233
+ if (str(rec.why).trim()) lines.push(`Why: ${str(rec.why).trim()}`);
234
+ lines.push(`Waits on ${seatWords(a.waits_on)}. If nobody answers: ${str(a.if_nobody_answers).trim()}`);
235
+ return lines.join('\n');
236
+ }
237
+
238
+ // A stored row → the ask as every reader gets it (docket matters, blockers).
239
+ function fromRow(row) {
240
+ if (!row) return null;
241
+ return {
242
+ question: row.question,
243
+ options: Array.isArray(row.options) ? row.options : [],
244
+ recommend: { key: row.recommend_key, why: row.recommend_why },
245
+ waits_on: row.waits_on,
246
+ if_nobody_answers: row.if_nobody_answers,
247
+ detail: row.detail || null,
248
+ written_at: row.written_at instanceof Date ? row.written_at.toISOString() : (row.written_at || null),
249
+ };
250
+ }
251
+
252
+ function isMatterId(id) { return typeof id === 'string' && MATTER_ID.test(id); }
253
+
254
+ module.exports = {
255
+ ASK_RULES,
256
+ CAPS,
257
+ BUILDINGS,
258
+ SEAT,
259
+ MATTER_ID,
260
+ ASK_REQUIRED_MESSAGE,
261
+ checkAsk,
262
+ refusalLine,
263
+ cardText,
264
+ seatWords,
265
+ fromRow,
266
+ isMatterId,
267
+ sentences,
268
+ words,
269
+ };
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ // modules/government/board-idea-reads.js — the board's two reads about a Full
4
+ // Idea: the feedback a returned idea carries back to its author (R13), and
5
+ // whether the board CARRIED it (BV2.R17, task 1004096). Moved out of board.js
6
+ // unchanged (task 1004706) to keep that file under the 1,500-line ratchet;
7
+ // board.js re-exports all three by name, so every caller and the government
8
+ // port read them where they always did. Reads only — db is looked up by property
9
+ // at call time, so a test's monkeypatched db reaches them.
10
+
11
+ const db = require('./db');
12
+
13
+ // ── R13's read: the feedback a returned idea carries back to its author ─────
14
+ //
15
+ // One reasoned objection is what returns an idea (§3), so the feedback IS that
16
+ // vote's reason + flagged section, straight off the most recent returned item.
17
+ // Null when this idea has never been returned by the board — the caller (the
18
+ // ideas resubmit path) falls back to the sub-bar weakest-section flag, which is
19
+ // derivable from the stored field_values at any time (R09). Shape mirrors the
20
+ // vote payload so the return flow renders ONE thing for both rejection sources.
21
+ async function latestRejectionForIdea(ideaId) {
22
+ const id = Number(ideaId);
23
+ if (!Number.isFinite(id) || id <= 0) return null;
24
+ const row = await db.getLatestReturnedItemWithObjection('full_idea', id);
25
+ if (!row) return null;
26
+ return Object.freeze({
27
+ source: 'board',
28
+ item_id: row.item_id,
29
+ section_key: row.section_key || null,
30
+ reason_md: row.reason_md || null,
31
+ closed_at: row.closed_at,
32
+ });
33
+ }
34
+
35
+ // ── BV2.R17's read: did the board CARRY this idea? (task 1004096) ───────────
36
+ //
37
+ // The ratification half of the ideator surface turns a Full Idea into a goal,
38
+ // and spec decision 9 is that the ideator RECOMMENDS while the board DECIDES.
39
+ // The ideas module holds the author's recommendation on its own grade row, so
40
+ // without this read "the board decides" would be a sentence in a comment: the
41
+ // only fact stopping a ratification from being one person's opinion is that a
42
+ // sitting actually closed `passed`.
43
+ //
44
+ // Null means "no passing decision on record", which the caller must treat as a
45
+ // refusal and never as an absence it may work around. A returned item, an item
46
+ // still open, and an idea that never reached the board all read the same here,
47
+ // on purpose — none of them is a mandate, and distinguishing them would invite a
48
+ // caller to accept one of them.
49
+ //
50
+ // Frozen, like latestRejectionForIdea, so a caller cannot decorate the record of
51
+ // a decision and pass it on as though the board had said more than it did.
52
+ async function boardPassForIdea(ideaId) {
53
+ const id = Number(ideaId);
54
+ if (!Number.isFinite(id) || id <= 0) return null;
55
+ const row = await db.getLatestPassedItem('full_idea', id);
56
+ if (!row) return null;
57
+ return Object.freeze({
58
+ item_id: row.item_id,
59
+ outcome: 'passed',
60
+ opened_at: row.opened_at,
61
+ closed_at: row.closed_at,
62
+ });
63
+ }
64
+
65
+ // boardPassForIdea for a list, in one read (task 1004560).
66
+ const boardPassedIdeaIds = async (ids) => new Set(await db.listPassedSubjectIds('full_idea', ids));
67
+
68
+ module.exports = { latestRejectionForIdea, boardPassForIdea, boardPassedIdeaIds };
@@ -63,6 +63,7 @@ const charter = require('./charter');
63
63
  const recommendations = require('./recommendations');
64
64
  const decisionRules = require('./decision-rules');
65
65
  const decisionBoard = require('./decision-board');
66
+ const lawBoard = require('./law-board');
66
67
 
67
68
  // ADR 0175 §5: the completeness bar a Full Idea must clear to reach the board.
68
69
  // Held equal to economy's IDEA_FULL_PASS_BAR by test — see the header.
@@ -631,11 +632,14 @@ async function closeItem(item, { cause = 'vote' } = {}) {
631
632
  : null;
632
633
  const rulesAmendment = !!amendment && amendment.kind === 'decision_rules';
633
634
  let appliedDecisionRules = null;
635
+ // task 1004706: a laws amendment is written the same way — inside the close.
636
+ const lawsAmendment = !!amendment && amendment.kind === 'laws';
637
+ let appliedLaws = null;
634
638
 
635
639
  const closed = await db.closeBoardItem({
636
640
  itemId: item.id,
637
641
  outcome,
638
- afterClose: (payMoney || rulesAmendment)
642
+ afterClose: (payMoney || rulesAmendment || lawsAmendment)
639
643
  ? async (client, closedRow) => {
640
644
  if (payMoney) {
641
645
  credit = await reward.awardIdeaGovernancePassCredit(client, {
@@ -645,6 +649,7 @@ async function closeItem(item, { cause = 'vote' } = {}) {
645
649
  });
646
650
  }
647
651
  if (rulesAmendment) appliedDecisionRules = await decisionBoard.applyPassedDecisionRules(client, closedRow, amendment);
652
+ if (lawsAmendment) appliedLaws = await lawBoard.applyPassedLaws(client, closedRow, amendment);
648
653
  }
649
654
  : null,
650
655
  });
@@ -664,7 +669,8 @@ async function closeItem(item, { cause = 'vote' } = {}) {
664
669
  // failure, never silently absorbed. The apply is re-drivable from the passed
665
670
  // item + amendment rows. A returned amendment applies nothing.
666
671
  let appliedConstitution = null;
667
- if (outcome === 'passed' && closed.subject_type === 'constitutional_amendment' && !rulesAmendment) {
672
+ // A rules or laws amendment changes its own table, never the board block.
673
+ if (outcome === 'passed' && closed.subject_type === 'constitutional_amendment' && !rulesAmendment && !lawsAmendment) {
668
674
  try {
669
675
  if (amendment) {
670
676
  appliedConstitution = governmentConfig.applyBoardAmendment(amendment.proposed);
@@ -708,6 +714,7 @@ async function closeItem(item, { cause = 'vote' } = {}) {
708
714
  ...(credit ? { credit } : {}),
709
715
  ...(appliedConstitution ? { applied_constitution: appliedConstitution } : {}),
710
716
  ...(appliedDecisionRules ? { applied_decision_rules: appliedDecisionRules } : {}),
717
+ ...(appliedLaws ? { applied_laws: appliedLaws } : {}),
711
718
  ...(acted ? { acted: acted.result } : {}),
712
719
  };
713
720
  }
@@ -907,6 +914,10 @@ const withOpenNudge = (fn) => async (args) => {
907
914
  };
908
915
  const proposeDecisionRules = withOpenNudge((a) => decisionBoard.proposeDecisionRules(a));
909
916
  const openMatter = withOpenNudge((a) => decisionBoard.openMatter(a));
917
+ // task 1004711: a Lead's build-out, built at once or filed as a vote (decision-board.js buildOut)
918
+ const buildOut = withOpenNudge((a) => decisionBoard.buildOut(a));
919
+ // task 1004706: the project's laws — an amendment of kind 'laws' (law-board.js).
920
+ const proposeLaws = withOpenNudge((a) => lawBoard.proposeLaws(a));
910
921
 
911
922
  // ── the board's third subject: closing a genesis stage (task 1004429) ───────
912
923
  //
@@ -1293,7 +1304,8 @@ async function constitutionView({ historyLimit = 50, type = null, headcount: ask
1293
1304
  // there is a missed warning rather than a wrong one.
1294
1305
  // Only board-block amendments say what the board block should be (a rules
1295
1306
  // amendment changes the table, task 1004529).
1296
- divergence: constitutionDivergence(live, history.filter((h) => h.kind !== 'decision_rules')),
1307
+ // Nor do laws amendments (task 1004706): they change the law book.
1308
+ divergence: constitutionDivergence(live, history.filter((h) => h.kind !== 'decision_rules' && h.kind !== 'laws')),
1297
1309
  // Who decides what at each building, as today or set by a passed amendment.
1298
1310
  decision_rules: decisionBoard.constitutionBlock(rulesTable),
1299
1311
  // The genesis charter + legislation stages' payload (task 1004427): every
@@ -1318,60 +1330,8 @@ async function constitutionView({ historyLimit = 50, type = null, headcount: ask
1318
1330
  };
1319
1331
  }
1320
1332
 
1321
- // ── R13's read: the feedback a returned idea carries back to its author ─────
1322
- //
1323
- // One reasoned objection is what returns an idea (§3), so the feedback IS that
1324
- // vote's reason + flagged section, straight off the most recent returned item.
1325
- // Null when this idea has never been returned by the board — the caller (the
1326
- // ideas resubmit path) falls back to the sub-bar weakest-section flag, which is
1327
- // derivable from the stored field_values at any time (R09). Shape mirrors the
1328
- // vote payload so the return flow renders ONE thing for both rejection sources.
1329
- async function latestRejectionForIdea(ideaId) {
1330
- const id = Number(ideaId);
1331
- if (!Number.isFinite(id) || id <= 0) return null;
1332
- const row = await db.getLatestReturnedItemWithObjection('full_idea', id);
1333
- if (!row) return null;
1334
- return Object.freeze({
1335
- source: 'board',
1336
- item_id: row.item_id,
1337
- section_key: row.section_key || null,
1338
- reason_md: row.reason_md || null,
1339
- closed_at: row.closed_at,
1340
- });
1341
- }
1342
-
1343
- // ── BV2.R17's read: did the board CARRY this idea? (task 1004096) ───────────
1344
- //
1345
- // The ratification half of the ideator surface turns a Full Idea into a goal,
1346
- // and spec decision 9 is that the ideator RECOMMENDS while the board DECIDES.
1347
- // The ideas module holds the author's recommendation on its own grade row, so
1348
- // without this read "the board decides" would be a sentence in a comment: the
1349
- // only fact stopping a ratification from being one person's opinion is that a
1350
- // sitting actually closed `passed`.
1351
- //
1352
- // Null means "no passing decision on record", which the caller must treat as a
1353
- // refusal and never as an absence it may work around. A returned item, an item
1354
- // still open, and an idea that never reached the board all read the same here,
1355
- // on purpose — none of them is a mandate, and distinguishing them would invite a
1356
- // caller to accept one of them.
1357
- //
1358
- // Frozen, like latestRejectionForIdea, so a caller cannot decorate the record of
1359
- // a decision and pass it on as though the board had said more than it did.
1360
- async function boardPassForIdea(ideaId) {
1361
- const id = Number(ideaId);
1362
- if (!Number.isFinite(id) || id <= 0) return null;
1363
- const row = await db.getLatestPassedItem('full_idea', id);
1364
- if (!row) return null;
1365
- return Object.freeze({
1366
- item_id: row.item_id,
1367
- outcome: 'passed',
1368
- opened_at: row.opened_at,
1369
- closed_at: row.closed_at,
1370
- });
1371
- }
1372
-
1373
- // boardPassForIdea for a list, in one read (task 1004560).
1374
- const boardPassedIdeaIds = async (ids) => new Set(await db.listPassedSubjectIds('full_idea', ids));
1333
+ // ── R13's and BV2.R17's reads about a Full Idea: board-idea-reads.js ───────
1334
+ const { latestRejectionForIdea, boardPassForIdea, boardPassedIdeaIds } = require('./board-idea-reads');
1375
1335
 
1376
1336
  // ── the summons: what is waiting on THIS member's vote (task 1003271) ───────
1377
1337
  //
@@ -1456,6 +1416,10 @@ async function pendingVotesFor(builderId) {
1456
1416
  module.exports = {
1457
1417
  proposeDecisionRules,
1458
1418
  openMatter,
1419
+ buildOut,
1420
+ proposeLaws,
1421
+ lawsView: (...a) => lawBoard.lawsView(...a),
1422
+ leadsView: (...a) => decisionBoard.leadsView(...a),
1459
1423
  decisionRulesView: (...a) => decisionBoard.decisionRulesView(...a),
1460
1424
  decisionRulesWriteAllowed: decisionBoard.decisionRulesWriteAllowed,
1461
1425
  applyPassedDecisionRules: decisionBoard.applyPassedDecisionRules,
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ // modules/government/build-rights.js — WHO MAY BUILD WITHOUT A VOTE: whether the
4
+ // holder of a building (its Lead, or the owner when it has none) may change the
5
+ // building's structure at once, or must file it as a vote (task 1004710, goal
6
+ // 1000111; ADR 0375).
7
+ //
8
+ // THE OWNER'S RULING (2026-10-09). It comes from the FORM OF GOVERNMENT, and in
9
+ // future will often be shaped by goals. It is NOT a per-Lead flag: a Lead's
10
+ // charter is one line of words (leads.js), and the prototype's
11
+ // `can_build_without_vote` is not stored anywhere.
12
+ //
13
+ // WHAT IS BUILT. A build-out is one of four capsules (the hall's office storey):
14
+ // a committee room, a new floor, an annex, or growing into a district. This file
15
+ // answers, per building and per capsule, { without_vote, why, ground }. Carrying a
16
+ // build-out out is task 1004711; this is the rule it will ask.
17
+ //
18
+ // THE RULE, in the order it is applied (first match decides):
19
+ // 1. A DISTRICT always goes to a vote: it is a new part of the city, not a
20
+ // change inside one building. ground 'city'
21
+ // 2. A VACANT building (its row says no one holds it) builds nothing. ground 'building'
22
+ // 3. A GOAL that LIMITS the building: a vote. ground 'goal'
23
+ // 4. A building handed to a SITTING of its own (decision-rules.js): under a
24
+ // rule that lets a seated member act alone ('decide', 'first') the holder
25
+ // builds at once if that sitting seats them; under any other rule
26
+ // (consent, majority, unanimous, sign-off) its sitting votes. ground 'building'
27
+ // 5. A GOAL that GRANTS the building: at once. ground 'goal'
28
+ // 6. Otherwise the FORM OF GOVERNMENT answers (FORM_ANSWERS): ground 'form'
29
+ // Monarchy / BDFL at once — the founder decides alone, and whoever
30
+ // holds a building acts for them in it.
31
+ // Timocratic Republic at once when the holder has earned a vote (sits
32
+ // on the board); otherwise a vote.
33
+ // Direct Democracy a vote — every builder decides changes together.
34
+ // Foundation a vote — the council of trustees decides.
35
+ // Custom a vote — settings that match no named form grant
36
+ // nothing beyond what they say.
37
+ // A form that is not available yet (Presidential, Cooperative, Federation)
38
+ // can never be in force (charter.js refuses it), so it has no answer here.
39
+ //
40
+ // THE GOALS HOOK. `goals` is a list of terms a goal sets on a building:
41
+ // { goal_id, building: <key> | '*', capsules?: [<capsule>...], allow: bool, why? }
42
+ // A limit (allow false) beats the building's own sitting and the form; a grant
43
+ // (allow true) beats the form only — never a district, a vacant building, or a
44
+ // sitting the project voted in. No goal stores terms yet, so the server passes
45
+ // an empty list today; the hook is here so a goal can grant or limit later
46
+ // without a second rule.
47
+ //
48
+ // FAIL CLOSED. Anything this file cannot read — an unknown form, a missing
49
+ // decision row, a holder nobody can name — answers "a vote", never "at once".
50
+ //
51
+ // PURE: no db. The caller resolves, per request and uncached (ADR 0016), the
52
+ // constitution in force, each building's decision row, and who sits on the
53
+ // board and on each sitting, and hands them in. Requires only charter.js (to
54
+ // name the form from the settings) and decision-rules.js (its rules).
55
+
56
+ const { readCharter } = require('./charter');
57
+ const { AREA_KEYS, DECISION_RULES } = require('./decision-rules');
58
+
59
+ // The four build-outs, smallest first; held equal to the hall's CAPSULES
60
+ // (modules/hall-ui/public/street-office.js) by tests/government_build_rights.mjs.
61
+ const CAPSULES = Object.freeze(['committee', 'floor', 'annex', 'district']);
62
+
63
+ // The documented answer of each form (the table above, as data). `holder`:
64
+ // 'any' builds at once, 'board' builds at once only when the holder sits on the
65
+ // board, 'none' always goes to a vote.
66
+ const FORM_ANSWERS = Object.freeze({
67
+ monarchy: Object.freeze({ holder: 'any', yes: 'The founder decides alone, so whoever holds this building builds it out at once.' }),
68
+ 'timocratic-republic': Object.freeze({
69
+ holder: 'board',
70
+ yes: 'Its holder has earned a vote, so they build it out at once.',
71
+ no: 'Its holder has not earned a vote yet, so the board votes on it.',
72
+ }),
73
+ 'direct-democracy': Object.freeze({ holder: 'none', no: 'Every builder decides changes together, so it goes to a vote.' }),
74
+ foundation: Object.freeze({ holder: 'none', no: 'The council of trustees decides changes, so it goes to a vote.' }),
75
+ custom: Object.freeze({ holder: 'none', no: 'These settings match no named form, so a change goes to a vote.' }),
76
+ });
77
+
78
+ const ACTS_ALONE = Object.freeze(DECISION_RULES.filter((r) => r.actsAlone).map((r) => r.key));
79
+
80
+ const answer = (withoutVote, why, by) => Object.freeze({ without_vote: !!withoutVote, why, ground: by });
81
+
82
+ // A goal's term matches this building and capsule.
83
+ function termMatches(t, building, capsule) {
84
+ if (!t || typeof t !== 'object') return false;
85
+ if (t.building !== '*' && t.building !== building) return false;
86
+ if (Array.isArray(t.capsules) && t.capsules.length && !t.capsules.includes(capsule)) return false;
87
+ return typeof t.allow === 'boolean';
88
+ }
89
+ const goalWhy = (t, fallback) => (typeof t.why === 'string' && t.why.trim() ? t.why.trim() : fallback);
90
+
91
+ // The answer for one capsule at one building.
92
+ // form — readCharter(board).form ('monarchy', …, 'custom')
93
+ // decision — the building's resolved row (decision-rules.resolveRow), or null
94
+ // holder — { seat, on_board: bool, in_sitting: bool }: who holds it, and
95
+ // whether the board / the building's own sitting seats them
96
+ // goals — the goals hook (see above); [] today
97
+ function buildRight({ building, capsule, form, decision, holder, goals = [] } = {}) {
98
+ if (!AREA_KEYS.includes(building) || !CAPSULES.includes(capsule)) return answer(false, 'This cannot be built.', 'city');
99
+ if (capsule === 'district') return answer(false, 'A district is a new part of the city, so the board votes on it.', 'city');
100
+ if (!decision) return answer(false, 'How this building decides could not be read, so it goes to a vote.', 'building');
101
+ if (decision.rule === 'vacant') return answer(false, 'No one holds this building yet, so nothing is built here.', 'building');
102
+
103
+ const terms = Array.isArray(goals) ? goals.filter((t) => termMatches(t, building, capsule)) : [];
104
+ const limit = terms.find((t) => t.allow === false);
105
+ if (limit) return answer(false, goalWhy(limit, 'A goal limits building here, so it goes to a vote.'), 'goal');
106
+
107
+ const h = holder || {};
108
+ if (decision.sitting) {
109
+ const by = `${decision.name} is decided by ${decision.who}`;
110
+ if (ACTS_ALONE.includes(decision.rule)) {
111
+ return h.in_sitting
112
+ ? answer(true, `${by}, and its holder is one of them, so they build it out at once.`, 'building')
113
+ : answer(false, `${by}, and its holder is not one of them, so it goes to them.`, 'building');
114
+ }
115
+ return answer(false, `${by} (${decision.rule_words}), so it goes to them as a vote.`, 'building');
116
+ }
117
+
118
+ const grant = terms.find((t) => t.allow === true);
119
+ if (grant) return answer(true, goalWhy(grant, 'A goal lets its holder build here at once.'), 'goal');
120
+
121
+ const f = FORM_ANSWERS[form] || FORM_ANSWERS.custom;
122
+ if (f.holder === 'any') return answer(true, f.yes, 'form');
123
+ if (f.holder === 'board' && h.on_board) return answer(true, f.yes, 'form');
124
+ return answer(false, f.no, 'form');
125
+ }
126
+
127
+ // Every capsule at one building: { form, form_name, holder, capsules: { committee: {…}, … } }.
128
+ // `board` is the constitution's board block in force.
129
+ function buildRightsFor({ building, board, decision, holder, goals = [] } = {}) {
130
+ const read = readCharter(board);
131
+ const capsules = {};
132
+ for (const capsule of CAPSULES) {
133
+ capsules[capsule] = buildRight({ building, capsule, form: read.form, decision, holder, goals });
134
+ }
135
+ return Object.freeze({
136
+ form: read.form,
137
+ form_name: read.name,
138
+ holder: holder && holder.seat ? holder.seat : null,
139
+ capsules: Object.freeze(capsules),
140
+ });
141
+ }
142
+
143
+ module.exports = {
144
+ CAPSULES,
145
+ FORM_ANSWERS,
146
+ buildRight,
147
+ buildRightsFor,
148
+ };