@bongos/core 1.24.78 → 1.24.80

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 (369) hide show
  1. package/.bongos-core.json +498 -353
  2. package/.claude/skills/ask-for-help/SKILL.md +1 -1
  3. package/.claude/skills/builder-sequence/SKILL.md +1 -1
  4. package/.claude/skills/design/SKILL.md +1 -1
  5. package/.claude/skills/goal-create/SKILL.md +1 -1
  6. package/.claude/skills/planning-session/SKILL.md +1 -1
  7. package/clients/bongos-client/README.md +1 -1
  8. package/clients/bongos-client/bongos-client.global.js +8 -0
  9. package/clients/bongos-client/index.cjs +8 -0
  10. package/clients/bongos-client/index.d.ts +11 -0
  11. package/clients/bongos-client/index.mjs +8 -0
  12. package/docs/adr/0274-one-kernel-three-role-packs.md +2 -2
  13. package/docs/adr/0399-the-visor-is-glass-over-the-scene-and-owns-no-camera.md +91 -0
  14. package/docs/adr/0400-a-chain-reserves-work-and-each-sub-quest-is-still-claimed-alone.md +109 -0
  15. package/docs/adr/0401-one-options-menu-opens-every-settings-screen-in-a-frame.md +53 -0
  16. package/docs/adr/README.md +3 -0
  17. package/docs/api/openapi.json +195 -3
  18. package/docs/api-reference.md +8 -4
  19. package/docs/architecture.md +7 -3
  20. package/docs/copy-inventory.md +390 -370
  21. package/docs/copy-registry.json +719 -518
  22. package/docs/design/characters/character-sprites-source.webp +0 -0
  23. package/docs/file-map.md +7 -8
  24. package/docs/module-api-changelog.md +4 -0
  25. package/docs/modules-contract.md +5 -0
  26. package/docs/onboarding/slash-commands.md +1 -1
  27. package/docs/packs/{engineer.md → artisan.md} +5 -5
  28. package/docs/packs/artist.md +50 -4
  29. package/docs/page-inventory.json +15 -4
  30. package/docs/page-readings.json +3182 -3336
  31. package/docs/{packs/ideator.md → recipes/ideation-playbook.md} +5 -48
  32. package/migrations/core_296_crafts_artisan_artist.sql +72 -0
  33. package/migrations/core_297_builder_character.sql +30 -0
  34. package/modules/copy-desk/proposals.js +1 -1
  35. package/modules/economy/credits-by-craft.js +29 -15
  36. package/modules/hall-ui/public/ai-studio.html +4 -4
  37. package/modules/hall-ui/public/atlas.html +5 -5
  38. package/modules/hall-ui/public/atlas.js +3 -1
  39. package/modules/hall-ui/public/blockers.html +5 -5
  40. package/modules/hall-ui/public/board-room.html +4 -4
  41. package/modules/hall-ui/public/characters/artisan.png +0 -0
  42. package/modules/hall-ui/public/characters/artist.png +0 -0
  43. package/modules/hall-ui/public/characters/patron.png +0 -0
  44. package/modules/hall-ui/public/city.css +2 -1
  45. package/modules/hall-ui/public/city.html +9 -6
  46. package/modules/hall-ui/public/city.js +7 -1
  47. package/modules/hall-ui/public/collab.html +4 -4
  48. package/modules/hall-ui/public/commands.html +4 -4
  49. package/modules/hall-ui/public/copy-desk.html +4 -4
  50. package/modules/hall-ui/public/craft-mode.js +258 -64
  51. package/modules/hall-ui/public/deploy.html +4 -4
  52. package/modules/hall-ui/public/diagram-viewer.js +2 -1
  53. package/modules/hall-ui/public/dom-utils.js +1 -1
  54. package/modules/hall-ui/public/goal-map.html +9 -6
  55. package/modules/hall-ui/public/goals.html +5 -5
  56. package/modules/hall-ui/public/government.html +4 -4
  57. package/modules/hall-ui/public/hall-render.js +3 -1
  58. package/modules/hall-ui/public/home-city-draw.js +3 -3
  59. package/modules/hall-ui/public/home-city.css +4 -0
  60. package/modules/hall-ui/public/home-city.js +17 -9
  61. package/modules/hall-ui/public/home-gallery.js +8 -1
  62. package/modules/hall-ui/public/home-idea-box.css +2 -2
  63. package/modules/hall-ui/public/home-idea-box.js +3 -3
  64. package/modules/hall-ui/public/idea.html +5 -5
  65. package/modules/hall-ui/public/ideas.html +5 -5
  66. package/modules/hall-ui/public/index.html +11 -7
  67. package/modules/hall-ui/public/market-watch-item.html +4 -4
  68. package/modules/hall-ui/public/market-watch-rules.html +4 -4
  69. package/modules/hall-ui/public/market-watch.html +6 -9
  70. package/modules/hall-ui/public/market-watch.js +22 -45
  71. package/modules/hall-ui/public/modules.html +4 -4
  72. package/modules/hall-ui/public/money.html +4 -4
  73. package/modules/hall-ui/public/my-agents.html +4 -4
  74. package/modules/hall-ui/public/options-menu.js +409 -0
  75. package/modules/hall-ui/public/paintbrush.js +3 -3
  76. package/modules/hall-ui/public/palette.js +11 -13
  77. package/modules/hall-ui/public/primer.html +5 -5
  78. package/modules/hall-ui/public/profile-crafts.js +1 -1
  79. package/modules/hall-ui/public/profile-roles.js +23 -19
  80. package/modules/hall-ui/public/profile.css +1 -1
  81. package/modules/hall-ui/public/profile.html +19 -18
  82. package/modules/hall-ui/public/profile.js +19 -18
  83. package/modules/hall-ui/public/profile.states.json +4 -4
  84. package/modules/hall-ui/public/project-settings.html +4 -4
  85. package/modules/hall-ui/public/roster.html +4 -4
  86. package/modules/hall-ui/public/roster.js +3 -3
  87. package/modules/hall-ui/public/settings.css +24 -4
  88. package/modules/hall-ui/public/settings.html +21 -17
  89. package/modules/hall-ui/public/settings.js +44 -44
  90. package/modules/hall-ui/public/shell-game.css +65 -0
  91. package/modules/hall-ui/public/shell-game.js +283 -3
  92. package/modules/hall-ui/public/shell.js +7 -26
  93. package/modules/hall-ui/public/sky-interaction.js +7 -0
  94. package/modules/hall-ui/public/speciality-tools.js +1 -2
  95. package/modules/hall-ui/public/street-inside.js +3 -4
  96. package/modules/hall-ui/public/street.html +4 -4
  97. package/modules/hall-ui/public/studio.html +5 -5
  98. package/modules/hall-ui/public/style.css +73 -47
  99. package/modules/hall-ui/public/task-detail.js +5 -4
  100. package/modules/hall-ui/public/task.html +5 -5
  101. package/modules/hall-ui/public/thinking.css +10 -0
  102. package/modules/hall-ui/public/thinking.html +9 -6
  103. package/modules/hall-ui/public/tweak-editor.html +5 -5
  104. package/modules/hall-ui/public/tweak-editor.js +5 -1
  105. package/modules/hall-ui/public/update-pill.js +91 -2
  106. package/modules/hall-ui/public/visor.css +80 -0
  107. package/modules/hall-ui/public/visor.js +316 -0
  108. package/modules/hall-ui/public/watch-repo-invite.js +159 -0
  109. package/modules/hall-ui/public/watch.html +6 -5
  110. package/modules/hall-ui/public/watch.js +17 -2
  111. package/modules/hall-ui/public/welcome-boot.js +15 -6
  112. package/modules/hall-ui/public/work.html +5 -5
  113. package/modules/hall-ui/records/builders-roster.md +2 -2
  114. package/modules/hall-ui/records/craft-mode.md +13 -11
  115. package/modules/hall-ui/records/game-mode.md +2 -2
  116. package/modules/hall-ui/records/governor-street.md +1 -1
  117. package/modules/hall-ui/records/home-city.md +3 -1
  118. package/modules/hall-ui/records/market-watch-settings.md +2 -0
  119. package/modules/hall-ui/records/options-menu.md +15 -0
  120. package/modules/hall-ui/records/profile-roles.md +11 -11
  121. package/modules/hall-ui/records/sky.md +3 -3
  122. package/modules/hall-ui/records/start-screen.md +21 -0
  123. package/modules/hall-ui/records/tweak-editor.md +1 -1
  124. package/modules/hall-ui/records/update-pill.md +12 -0
  125. package/modules/hall-ui/records/visor.md +23 -0
  126. package/modules/hall-ui/records/welcome-tower.md +5 -5
  127. package/modules/ideas/ideator-stats.js +3 -1
  128. package/modules/ideas/inbox.js +2 -2
  129. package/modules/lifecycle/activity.js +1 -1
  130. package/modules/lifecycle/cascade.js +4 -4
  131. package/modules/lifecycle/goal-spec-task.js +1 -1
  132. package/modules/lifecycle/help-requests.js +19 -15
  133. package/modules/lifecycle/kickoff-checklist.js +3 -3
  134. package/modules/lifecycle/kickoff-seed.js +1 -1
  135. package/modules/lifecycle/migrations/lifecycle_021_crafts_artisan_artist.sql +29 -0
  136. package/modules/lifecycle/mingle.js +4 -4
  137. package/modules/lifecycle/page-tweak-write.js +1 -1
  138. package/modules/lifecycle/routes/task-write-routes.js +3 -1
  139. package/modules/lifecycle/routes/tasks.js +3 -3
  140. package/modules/lifecycle/ship-card.js +2 -3
  141. package/modules/lifecycle/task-classifier.js +39 -29
  142. package/modules/lifecycle/tests/cascade.mjs +7 -7
  143. package/modules/onboarding/founding-mode.js +3 -0
  144. package/modules/onboarding/genesis-home.js +21 -1
  145. package/modules/onboarding/routes/onboarding.js +2 -0
  146. package/modules/page-notes/notes.js +3 -2
  147. package/modules/page-notes/split.js +1 -1
  148. package/modules/platform-identity/craft-rollup.js +35 -7
  149. package/modules/platform-identity/migrations/platform_identity_036_crafts_artisan_artist.sql +45 -0
  150. package/modules/platform-identity/platform-identity.js +2 -2
  151. package/modules/platform-identity/routes/community-leaderboard.js +2 -1
  152. package/modules/platform-identity/routes/community-search.js +2 -1
  153. package/modules/platform-identity/routes/sso.js +2 -1
  154. package/modules/platform-identity/tests/platform-identity.mjs +6 -6
  155. package/modules/public-landing/public/community.html +3 -5
  156. package/modules/public-landing/public/index.html +1 -1
  157. package/modules/public-landing/public/projects.html +1 -1
  158. package/modules/specialities/migrations/specialities_004_crafts_artisan_artist.sql +30 -0
  159. package/modules/specialities/routes/specialities.js +2 -2
  160. package/modules/status-ui/public/status.js +1 -1
  161. package/modules/ui-design/kit/fixtures/community-leaderboard.json +10 -10
  162. package/modules/ui-design/kit/fixtures/community-search.json +2 -2
  163. package/modules/ui-design/kit/fixtures/scouting-recruiter.json +1 -1
  164. package/package-lock.json +2 -2
  165. package/package.json +1 -1
  166. package/release-notes.json +76 -0
  167. package/scripts/bongos/autobongos-host.js +19 -4
  168. package/scripts/bongos/autobongos-loop.js +1 -1
  169. package/scripts/bongos/claude-materialize.js +20 -1
  170. package/scripts/bongos/discipline-modes.json +7 -12
  171. package/scripts/bongos/fitness.js +1 -1
  172. package/scripts/bongos/gen-hall-sitemap.js +7 -7
  173. package/scripts/bongos/grade-smoke.js +2 -2
  174. package/scripts/bongos/init.js +2 -2
  175. package/scripts/bongos/newcomer-floor.config.json +1 -2
  176. package/scripts/bongos/newcomer-floor.js +4 -3
  177. package/scripts/bongos/newcomer-floor.templates.json +10 -12
  178. package/scripts/bongos/newcomer-floor.test.js +47 -38
  179. package/scripts/bongos/role-pack-guard.js +5 -4
  180. package/scripts/bongos/seed-tasks.example.json +3 -3
  181. package/scripts/bongos/setup.js +20 -107
  182. package/scripts/bongos/start.js +2 -2
  183. package/scripts/bongos/task.js +5 -4
  184. package/scripts/bongos/triage.js +1 -1
  185. package/scripts/bongos/worktree.js +16 -4
  186. package/scripts/setup-builder.js +6 -46
  187. package/src/bongos/auth-admission.js +8 -8
  188. package/src/bongos/craft-mode.js +2 -0
  189. package/src/bongos/db.js +39 -1
  190. package/src/bongos/github-repos.js +78 -0
  191. package/src/bongos/module-scope-map.js +22 -0
  192. package/src/bongos/role-stats.js +27 -11
  193. package/src/bongos/routes/github.js +100 -2
  194. package/src/bongos/routes/me.js +33 -0
  195. package/src/bongos/serve-internal.js +26 -9
  196. package/src/crafts.js +66 -0
  197. package/src/module-api.js +6 -1
  198. package/src/module-loader/manifest-schema.js +16 -0
  199. package/src/modules.js +2 -2
  200. package/src/role-responsibilities.js +6 -5
  201. package/tests/acclaim_feed_evidence_db.mjs +1 -1
  202. package/tests/activity_rollup_as_of.mjs +1 -1
  203. package/tests/api_alias_caller_ratchet.mjs +3 -3
  204. package/tests/application_lifecycle.mjs +1 -1
  205. package/tests/artist_review.mjs +1 -1
  206. package/tests/artist_review_goal.mjs +1 -1
  207. package/tests/autobongos_host.mjs +19 -1
  208. package/tests/blocker_hall_live_proof.mjs +1 -1
  209. package/tests/blocker_tasks_named_db.mjs +2 -2
  210. package/tests/canonical_profile_url.mjs +1 -1
  211. package/tests/cap_newcomer_tasks.mjs +13 -13
  212. package/tests/character_creation.mjs +294 -0
  213. package/tests/claim_gate_open_blocker.mjs +2 -2
  214. package/tests/claim_gate_rebase.mjs +6 -6
  215. package/tests/claude_materialize.mjs +75 -1
  216. package/tests/clawback_confirmed_backlog.mjs +3 -3
  217. package/tests/collab_page.mjs +8 -9
  218. package/tests/collab_self_settling.mjs +2 -2
  219. package/tests/community_leaderboard_route.mjs +13 -5
  220. package/tests/community_proof_db.mjs +3 -0
  221. package/tests/community_search_route.mjs +7 -6
  222. package/tests/conductor.mjs +1 -1
  223. package/tests/conductor_main_e2e.mjs +8 -8
  224. package/tests/copy_desk_flags_db.mjs +1 -1
  225. package/tests/copy_desk_page_approve.mjs +2 -2
  226. package/tests/copy_desk_page_claim.mjs +2 -2
  227. package/tests/copy_desk_page_next.mjs +1 -1
  228. package/tests/copy_desk_page_release.mjs +1 -1
  229. package/tests/copy_desk_page_submit.mjs +2 -2
  230. package/tests/core_258_box_session_claims_db.mjs +1 -1
  231. package/tests/craft_mode.mjs +48 -34
  232. package/tests/craft_mode_home.mjs +20 -20
  233. package/tests/craft_mode_looks.mjs +16 -15
  234. package/tests/craft_mode_nav.mjs +31 -29
  235. package/tests/craft_review_mode.mjs +46 -41
  236. package/tests/crafts_vocabulary.mjs +70 -0
  237. package/tests/cross_discipline_priority_direction.mjs +5 -5
  238. package/tests/fitness.mjs +7 -11
  239. package/tests/funded_project_pays_proof_db.mjs +1 -1
  240. package/tests/genesis_home.mjs +21 -1
  241. package/tests/github_chip.mjs +3 -3
  242. package/tests/github_repo_invite.mjs +379 -0
  243. package/tests/goal_criterion_confirm_ui.mjs +1 -1
  244. package/tests/goal_invite_consent.mjs +1 -1
  245. package/tests/goal_map_page.mjs +2 -2
  246. package/tests/goal_sort_staleness.mjs +1 -1
  247. package/tests/goal_spec_task.mjs +1 -1
  248. package/tests/goal_spec_task_db.mjs +3 -3
  249. package/tests/grade_engine.mjs +2 -2
  250. package/tests/grader_no_artifact.mjs +1 -1
  251. package/tests/grader_prompt_fencing.mjs +1 -1
  252. package/tests/grader_prompt_window_bound.mjs +1 -1
  253. package/tests/grader_reader_lens.mjs +2 -2
  254. package/tests/grader_severity_gate.mjs +1 -1
  255. package/tests/grader_unbacked_score.mjs +1 -1
  256. package/tests/hall_approval_queue.mjs +1 -1
  257. package/tests/hall_board_world.mjs +2 -2
  258. package/tests/hall_builders_page_pass.mjs +1 -1
  259. package/tests/hall_city.mjs +1 -1
  260. package/tests/hall_fullscreen.mjs +27 -3
  261. package/tests/hall_kit_charts.mjs +1 -1
  262. package/tests/hall_landing_boot.mjs +1 -1
  263. package/tests/hall_my_agents.mjs +16 -16
  264. package/tests/hall_nav.mjs +22 -15
  265. package/tests/hall_options_menu.mjs +247 -0
  266. package/tests/hall_oversight_world.mjs +1 -1
  267. package/tests/hall_palette.mjs +8 -9
  268. package/tests/hall_profile_world.mjs +3 -3
  269. package/tests/hall_record_world.mjs +2 -2
  270. package/tests/hall_settings_world.mjs +2 -1
  271. package/tests/hall_sitemap.mjs +6 -5
  272. package/tests/hall_start_screen.mjs +244 -0
  273. package/tests/hall_street.mjs +2 -2
  274. package/tests/hall_street_inside.mjs +17 -9
  275. package/tests/hall_studio_world.mjs +3 -3
  276. package/tests/hall_visor.mjs +224 -0
  277. package/tests/help_requests.mjs +47 -15
  278. package/tests/helpers.mjs +4 -4
  279. package/tests/home_city.mjs +6 -4
  280. package/tests/home_city_widget.mjs +33 -14
  281. package/tests/home_gallery_widget.mjs +4 -4
  282. package/tests/home_idea_box.mjs +6 -6
  283. package/tests/hub_community_page.mjs +3 -3
  284. package/tests/hub_craft_rollup.mjs +75 -36
  285. package/tests/hub_recent_ships.mjs +3 -2
  286. package/tests/idea_cascade.mjs +2 -2
  287. package/tests/idea_credit_streams_db.mjs +1 -1
  288. package/tests/idea_detail_page.mjs +1 -1
  289. package/tests/idea_ideate_full.mjs +5 -2
  290. package/tests/idea_spark_hall.mjs +1 -1
  291. package/tests/init.mjs +3 -3
  292. package/tests/instance_manifest.mjs +2 -2
  293. package/tests/kickoff_checklist.mjs +1 -1
  294. package/tests/leaderboard_roles.mjs +34 -25
  295. package/tests/lifecycle_claim_ladder_refs.mjs +55 -0
  296. package/tests/lightbulb.mjs +14 -10
  297. package/tests/main_discipline.mjs +8 -8
  298. package/tests/markdown_line_endings.mjs +1 -1
  299. package/tests/market_watch_pages.mjs +52 -74
  300. package/tests/me_streak_leaderboard_queries.mjs +3 -3
  301. package/tests/migration_allocation.mjs +1 -1
  302. package/tests/mingle.mjs +36 -21
  303. package/tests/module-scope-map.mjs +22 -2
  304. package/tests/module_api.mjs +1 -0
  305. package/tests/module_contributions.mjs +13 -13
  306. package/tests/module_manifest.mjs +29 -0
  307. package/tests/module_skills_hook.mjs +2 -2
  308. package/tests/modules.mjs +14 -7
  309. package/tests/nav_permission_atoms.mjs +2 -1
  310. package/tests/needs_rebase.mjs +1 -1
  311. package/tests/newcomer_restock.mjs +13 -13
  312. package/tests/page_notes_panel.mjs +22 -22
  313. package/tests/page_tweak_no_block.mjs +1 -1
  314. package/tests/paintbrush.mjs +7 -7
  315. package/tests/privacy_matrix.mjs +1 -0
  316. package/tests/profile_activity.mjs +20 -23
  317. package/tests/profile_earnings.mjs +1 -1
  318. package/tests/profile_nudge_prefs.mjs +4 -4
  319. package/tests/profile_role_headline.mjs +73 -46
  320. package/tests/profile_role_stats.mjs +55 -45
  321. package/tests/profile_rollup_consent.mjs +19 -6
  322. package/tests/profile_route.mjs +12 -12
  323. package/tests/profile_ui_cross_project.mjs +4 -4
  324. package/tests/project_invite_suggestions.mjs +2 -2
  325. package/tests/promote_goal_id.mjs +1 -1
  326. package/tests/publish_manifest.mjs +1 -1
  327. package/tests/rank_floor_wiring.mjs +1 -1
  328. package/tests/regrade_actual_minutes.mjs +1 -1
  329. package/tests/removal_proposal.mjs +2 -2
  330. package/tests/reward_gate.mjs +4 -4
  331. package/tests/reward_mode_cost_plus_only.mjs +1 -1
  332. package/tests/role_responsibilities.mjs +18 -12
  333. package/tests/role_stats_db.mjs +17 -16
  334. package/tests/scouting_rollup_fanout.mjs +5 -5
  335. package/tests/seed_tasks.mjs +5 -2
  336. package/tests/session_earnings_mirror_db.mjs +1 -1
  337. package/tests/session_reward_on_land_db.mjs +1 -1
  338. package/tests/session_reward_reconcile_db.mjs +2 -2
  339. package/tests/sessions_kit_pager.mjs +1 -1
  340. package/tests/settings_art_key_absent.mjs +2 -2
  341. package/tests/settings_main_role.mjs +21 -20
  342. package/tests/setup_flow.mjs +3 -3
  343. package/tests/setup_smoke.mjs +20 -0
  344. package/tests/ship_card.mjs +6 -6
  345. package/tests/specialities.mjs +16 -4
  346. package/tests/speciality_db_shape.mjs +6 -6
  347. package/tests/speciality_injection.mjs +17 -17
  348. package/tests/speciality_offer_ui.mjs +5 -5
  349. package/tests/speciality_offered.mjs +6 -5
  350. package/tests/speciality_session_skills.mjs +7 -7
  351. package/tests/speciality_skills.mjs +11 -11
  352. package/tests/speciality_walkthrough_ui.mjs +2 -2
  353. package/tests/sso_checkin_identity_spoof.mjs +2 -1
  354. package/tests/task_classifier_vocab.mjs +10 -0
  355. package/tests/task_cli.mjs +11 -3
  356. package/tests/task_criteria_unlink_db.mjs +2 -2
  357. package/tests/task_discipline_required.mjs +38 -16
  358. package/tests/task_goal_required.mjs +4 -4
  359. package/tests/task_needs_db.mjs +1 -1
  360. package/tests/tweak_replaces_artist_review.mjs +1 -1
  361. package/tests/update_pill.mjs +153 -2
  362. package/tests/upkeep_home.mjs +1 -1
  363. package/tests/v3_c1_options_proof.mjs +145 -0
  364. package/tests/visual_review.mjs +3 -3
  365. package/tests/watch_applications_queue.mjs +8 -5
  366. package/tests/watch_sealed_floor.mjs +3 -1
  367. package/tests/welcome_boot.mjs +12 -9
  368. package/tests/welcome_boot_browser.mjs +13 -13
  369. package/tests/worktree_helper.mjs +16 -1
@@ -93,7 +93,8 @@ function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = fal
93
93
  addEventListener() {}, dispatchEvent() {},
94
94
  get cookie() { return ''; }, set cookie(_v) {},
95
95
  };
96
- const location = { hostname: 'h', origin: 'https://h', href: 'https://h/' + page + search, pathname: path || '/' + page, hash, search, assign() {}, replace() {} };
96
+ const assigned = []; // task 1005144: where the page sent the browser (game mode's Escape opens the menu)
97
+ const location = { hostname: 'h', origin: 'https://h', href: 'https://h/' + page + search, pathname: path || '/' + page, hash, search, assign(u) { assigned.push(u); }, replace() {} };
97
98
  // `session` is the TAB's sessionStorage: pass one object to several boots and
98
99
  // they are page loads in one tab (the trail, task 1004476).
99
100
  const sessionStorage = {
@@ -133,6 +134,7 @@ function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = fal
133
134
  const root = documentObj.documentElement;
134
135
  return {
135
136
  store,
137
+ assigned,
136
138
  game: () => windowObj.OTBShellGame,
137
139
  gameStamp: () => root.dataset.game === 'on',
138
140
  // The "how to leave" line: showing = the shell created it and it is not hidden.
@@ -518,8 +520,8 @@ test('game mode: every hall page carries the switch — none opts out, and each
518
520
  test('game mode: leaving brings the bars back for THIS page only, and never rewrites a page\'s own memory', () => {
519
521
  const store = { 'hall-game': 'on', 'hall-fullscreen:thinking': 'on' };
520
522
  const goals = boot({ page: 'goals', optIn: false, store });
521
- goals.press('Escape');
522
- assert.equal(goals.on(), false, 'Escape still leaves');
523
+ goals.button().click();
524
+ assert.equal(goals.on(), false, 'the corner button still leaves (Escape opens the options menu, below)');
523
525
  assert.equal(store['hall-fullscreen:goals'], undefined, 'nothing remembered for the page');
524
526
  assert.equal(boot({ page: 'goals', optIn: false, store }).on(), true, 'the next load opens without bars again');
525
527
  const sky = boot({ page: 'thinking', store });
@@ -528,6 +530,28 @@ test('game mode: leaving brings the bars back for THIS page only, and never rewr
528
530
  assert.equal(store['hall-fullscreen:thinking'], 'on', 'the sky\'s own choice survives game mode');
529
531
  });
530
532
 
533
+ // task 1005144 (BONGOS-V3 R04, ADR 0399 §2.4 row 6): in game mode the last Escape is the
534
+ // options menu's. It opens the menu, remembering where it came from, and the bars stay away.
535
+ test('game mode: the last Escape opens the options menu, and the bars stay hidden', () => {
536
+ const store = { 'hall-game': 'on' };
537
+ const goals = boot({ page: 'goals', optIn: false, store, hash: '#/goal/7' });
538
+ goals.press('Escape');
539
+ assert.equal(goals.on(), true, 'the shell did not take the press');
540
+ assert.deepEqual(goals.assigned, ['/settings?from=' + encodeURIComponent('/goals#/goal/7')], 'the menu, and the way back');
541
+ const apex = boot({ page: 'goals', optIn: false, store, path: '/builders/goals' });
542
+ apex.press('Escape');
543
+ assert.deepEqual(apex.assigned, ['/builders/settings?from=' + encodeURIComponent('/builders/goals')], 'on the apex too');
544
+ const used = boot({ page: 'goals', optIn: false, store });
545
+ used.press('Escape', { pageUses: true });
546
+ used.press('Escape', { target: { tagName: 'INPUT' } });
547
+ assert.deepEqual(used.assigned, [], 'a press a page used, or one in a field, never opens it');
548
+ const plain = boot({ page: 'goals', optIn: false, store: {} });
549
+ plain.button().click();
550
+ plain.press('Escape');
551
+ assert.deepEqual(plain.assigned, [], 'outside game mode Escape is the shell\'s, as before');
552
+ assert.equal(plain.on(), false, 'and it leaves full screen');
553
+ });
554
+
531
555
  test('game mode: the Escape hand-off is unchanged — a press the page used, or one in a field, does not leave', () => {
532
556
  const p = boot({ page: 'goals', optIn: false, store: { 'hall-game': 'on' } });
533
557
  p.press('Escape', { pageUses: true });
@@ -379,7 +379,7 @@ test('the migrated pages run for real against the kit and emit its classes', ()
379
379
  assert.equal(typeof s.window.OTBHallRender, 'function', 'the renderer factory survived the migration');
380
380
  assert.equal(typeof s.window.OTBKit.barSeriesHtml, 'function');
381
381
  assert.match(
382
- s.window.OTBKit.barSeriesHtml([{ label: 'engineer', value: 3 }]),
382
+ s.window.OTBKit.barSeriesHtml([{ label: 'artisan', value: 3 }]),
383
383
  /class="kit-bars"/,
384
384
  );
385
385
  });
@@ -72,7 +72,7 @@ async function boot({ meStatus, settle = false }) {
72
72
  // the same reason categoryChip is: an undefined helper throws mid-render.
73
73
  roleAxisLabel: () => 'Role',
74
74
  roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
75
- roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
75
+ roleOptions: () => [{ value: '', label: '— any —' }, { value: 'artisan', label: 'Artisan' }, { value: 'artist', label: 'Artist' }],
76
76
  roleIsUnset: (d) => !d || d === 'unclassified',
77
77
  ROLE_UNSET: 'unclassified',
78
78
  moduleOn: () => true,
@@ -388,48 +388,48 @@ function bootCraft({ session = {} } = {}) {
388
388
  }
389
389
  const seatOf = (has, title) => ({ known: true, has_position: has, title: has ? title : null });
390
390
 
391
- test('CRAFT: with no seat, Governing is not offered anywhere', () => {
391
+ test('CRAFT: with no seat, Patron (Governing until task 1005141) is not offered anywhere', () => {
392
392
  const b = bootCraft();
393
393
  b.craft.setPositions(seatOf(false));
394
- assert.match(b.craft.switchHtml(), /data-craft-opt="governing"[^>]* hidden>/, 'the switch hides it');
395
- b.craft.choose('governing');
394
+ assert.match(b.craft.switchHtml(), /data-craft-opt="patron"[^>]* hidden>/, 'the switch hides it');
395
+ b.craft.choose('patron');
396
396
  assert.equal(b.root.dataset.craft, undefined, 'and choosing it does nothing');
397
397
  b.craft.open();
398
398
  const picker = b.body.children[0];
399
399
  assert.ok(picker, 'the picker opened');
400
- assert.doesNotMatch(picker.innerHTML, /data-craft-pick="governing"/, 'no Governing door');
401
- assert.match(picker.innerHTML, /data-doors="3"/, 'three doors, laid out as three');
400
+ assert.doesNotMatch(picker.innerHTML, /data-craft-pick="patron"/, 'no Patron door');
401
+ assert.match(picker.innerHTML, /data-doors="2"/, 'two doors, laid out as two');
402
402
  });
403
403
 
404
- test('CRAFT: a stored Governing reads as no mode for a builder known to hold no seat', () => {
404
+ test('CRAFT: a stored Patron — even under its pre-rename id, Governing — reads as no mode for a builder known to hold no seat', () => {
405
405
  const b = bootCraft({ session: { 'hall-craft': 'governing' } });
406
- assert.equal(b.craft.current(), 'governing', 'unknown seats: today\'s behaviour');
406
+ assert.equal(b.craft.current(), 'patron', 'unknown seats: today\'s behaviour, the old id read as Patron (task 1005141)');
407
407
  b.craft.setPositions(seatOf(false));
408
408
  assert.equal(b.craft.current(), null);
409
409
  assert.equal(b.session['hall-craft'], undefined, 'and the stored choice is dropped');
410
410
  });
411
411
 
412
- test('CRAFT: a seat names the Governing mode', () => {
412
+ test('CRAFT: a seat names the Patron mode', () => {
413
413
  const b = bootCraft();
414
414
  b.craft.setPositions(seatOf(true, 'Treasury Lead'));
415
415
  assert.equal(b.craft.seatName(), 'Treasury Lead');
416
- assert.match(b.craft.switchHtml(), /data-craft-opt="governing"[^>]*>[\s\S]*?Treasury Lead<\/span>/);
417
- b.craft.choose('governing');
418
- assert.equal(b.root.dataset.craft, 'governing');
416
+ assert.match(b.craft.switchHtml(), /data-craft-opt="patron"[^>]*>[\s\S]*?Treasury Lead<\/span>/);
417
+ b.craft.choose('patron');
418
+ assert.equal(b.root.dataset.craft, 'patron');
419
419
  b.craft.open();
420
420
  assert.match(b.body.children[0].innerHTML, /craft-door__name">Treasury Lead</);
421
- assert.match(b.body.children[0].innerHTML, /data-doors="4"/);
421
+ assert.match(b.body.children[0].innerHTML, /data-doors="3"/);
422
422
  });
423
423
 
424
- test('CRAFT: unknown seats are today\'s picker — four doors and a free Governing', () => {
424
+ test('CRAFT: unknown seats are today\'s picker — three doors and a free Patron', () => {
425
425
  const b = bootCraft();
426
426
  b.craft.setPositions(null);
427
427
  assert.equal(b.craft.seatName(), null);
428
428
  assert.doesNotMatch(b.craft.switchHtml(), / hidden>/);
429
- assert.match(b.craft.switchHtml(), />Governing<\/span>/);
429
+ assert.match(b.craft.switchHtml(), />Patron<\/span>/);
430
430
  b.craft.setPositions({ known: false, has_position: false });
431
- b.craft.choose('governing');
432
- assert.equal(b.root.dataset.craft, 'governing', 'an unknown answer never takes the mode away');
431
+ b.craft.choose('patron');
432
+ assert.equal(b.root.dataset.craft, 'patron', 'an unknown answer never takes the mode away');
433
433
  });
434
434
 
435
435
  // ---------------------------------------------------------------------------
@@ -301,7 +301,8 @@ test('sidebar groups read by audience, in order', () => {
301
301
  // how your own Claude works for you, the personal half of "the city steers
302
302
  // everyone, My agents steers yours". Its subject is the viewer's own agents.
303
303
  // task 1005050: 'standing' (Accomplishments) left — your numbers are the one Profile page.
304
- assert.deepEqual(nav[3].items.map((i) => i.id), ['profile', 'my-agents'], 'You = your own numbers and your own agents');
304
+ // task 1005144 (ADR 0401): 'my-agents' LEFT for the one options menu (the profile chip).
305
+ assert.deepEqual(nav[3].items.map((i) => i.id), ['profile'], 'You = your own numbers');
305
306
  // task 1003444: 'people' LEFT the group — the owner deleted the hall's
306
307
  // cross-project directory (ADR 0337 D5); its home is the hub, and /people
307
308
  // redirects to /roster. Builders is the hall's directory now.
@@ -324,7 +325,7 @@ test('sidebar groups read by audience, in order', () => {
324
325
  // Economy and Diagrams explained what the Primer explains; they are its chapters
325
326
  // now, so Docs is the Primer and the four references that are not a document.
326
327
  assert.deepEqual(nav[6].items.map((i) => i.id),
327
- ['primer', 'commands', 'atlas', 'modules', 'apidocs'], 'Docs = reference material');
328
+ ['primer', 'commands', 'atlas', 'apidocs'], 'Docs = reference material (Modules is an options menu entry since task 1005144)');
328
329
  // 'government' (the Permissions tab, BV1.R113) is the group's one archon-gated item.
329
330
  // 'project-settings' joins the Government group at the metic floor (task 1003280,
330
331
  // goal 1000072) — it sits before 'government', which is the one archon-only item.
@@ -342,7 +343,12 @@ test('sidebar groups read by audience, in order', () => {
342
343
  // idea 1001310). The settings are an owner-only Settings tab on /market-watch now.
343
344
  // 'gate' (Autobongos) and 'sessions' LEFT the group in task 1005049 (owner decision
344
345
  // 2026-11-03, idea 1001315): they are tabs on Watch, and /gate + /sessions redirect there.
345
- assert.deepEqual(nav[5].items.map((i) => i.id), ['watch', 'project-settings', 'money', 'deploy', 'government']);
346
+ // task 1005144 (ADR 0401): 'project-settings' and 'government' (Permissions) LEFT for the
347
+ // one options menu; their URLs open it (serve-internal.js RETIRED_TO_OPTIONS).
348
+ assert.deepEqual(nav[5].items.map((i) => i.id), ['watch', 'money', 'deploy']);
349
+ for (const gone of ['/my-agents', '/project-settings', '/government', '/modules']) {
350
+ assert.ok(!nav.flatMap((g) => g.items).some((i) => i.href === gone), `no item points at ${gone}, an options menu entry now`);
351
+ }
346
352
  assert.ok(!nav.flatMap((g) => g.items).some((i) => i.href === '/gate' || i.href === '/sessions'), 'no item points at a page that became a Watch tab');
347
353
  // task 1005045: and no label is left twice anywhere in the sidebar, so the next pair
348
354
  // fails here instead of in front of the owner.
@@ -792,7 +798,7 @@ test('no glyph path data reads as an IP address to the release leak scan', () =>
792
798
  // block (task 1004713). These drive the real shell and the real gov-positions.js.
793
799
 
794
800
  const tick = () => new Promise((resolve) => setImmediate(resolve));
795
- const GOVERNING = ['city', 'board-room', 'watch', 'project-settings', 'money', 'deploy', 'government'];
801
+ const GOVERNING = ['city', 'board-room', 'watch', 'money', 'deploy'];
796
802
  const lead = (building, title) => ({ kind: 'lead', seat: 'lead:' + building, building, title });
797
803
  const block = (held) => ({ held, title: held.length ? held[0].title : null, has_position: held.length > 0 });
798
804
 
@@ -821,7 +827,7 @@ test('POSITION: a Lead keeps every governing surface, and the seat names it', as
821
827
  const b = await seated({ builder: { id: '424242', rank: 'thetes' }, positions: block([lead('planning', 'Planning Lead')]) });
822
828
  assert.equal(b.shell.positions().title, 'Planning Lead');
823
829
  // a thetes Lead: the seat opens the governing tabs their rank and permissions allow
824
- for (const id of ['board-room', 'city', 'my-agents']) assert.equal(b.navHidden(id), false, `${id} shows`);
830
+ for (const id of ['board-room', 'city', 'profile']) assert.equal(b.navHidden(id), false, `${id} shows`);
825
831
  });
826
832
 
827
833
  test('POSITION: the fixture viewer holds what the fixture /me says', async () => {
@@ -833,10 +839,10 @@ test('POSITION: the fixture viewer holds what the fixture /me says', async () =>
833
839
 
834
840
  test('POSITION: a builder with no seat sees no governing surface, and only those go', async () => {
835
841
  // an archon the server says holds no seat: rank, craft and permissions do not make one
836
- const b = await seated({ builder: { id: '424242', rank: 'archon', preferred_disciplines: ['engineer'] }, positions: block([]) });
842
+ const b = await seated({ builder: { id: '424242', rank: 'archon', preferred_disciplines: ['artisan'] }, positions: block([]) });
837
843
  assert.equal(b.shell.positions().has_position, false);
838
- for (const id of ['board-room', 'city', 'watch', 'project-settings', 'government']) assert.equal(b.navHidden(id), true, `${id} hides`);
839
- for (const id of ['home', 'work', 'goals', 'my-agents', 'profile', 'primer']) assert.equal(b.navHidden(id), false, `${id} still shows`);
844
+ for (const id of ['board-room', 'city', 'watch', 'money']) assert.equal(b.navHidden(id), true, `${id} hides`);
845
+ for (const id of ['home', 'work', 'goals', 'profile', 'primer']) assert.equal(b.navHidden(id), false, `${id} still shows`);
840
846
  });
841
847
 
842
848
  test('POSITION: unknown is today\'s nav — a /me with no positions block hides nothing', async () => {
@@ -859,18 +865,19 @@ test('POSITION: ?as=nobody views the hall with no seat, and can only hide', asyn
859
865
  const b = await seated({ builder: me.builder, positions: me.positions, search: '?as=nobody' });
860
866
  assert.equal(b.shell.positions().as, 'nobody');
861
867
  for (const id of GOVERNING) assert.equal(b.navHidden(id), true, `${id} hides for the stand-in`);
862
- assert.equal(b.navHidden('my-agents'), false);
868
+ assert.equal(b.navHidden('profile'), false);
863
869
  // a xenos stand-in stays a xenos: the pretend rank never rises above the real one
864
870
  const x = await seated({ builder: { id: '9', rank: 'xenos' }, positions: block([lead('planning', 'Planning Lead')]), search: '?as=nobody' });
865
871
  assert.equal(x.navHidden('goal-map'), true, 'a member page stays hidden from a real xenos');
866
872
  });
867
873
 
868
- test('My agents draws its own glyph and lands on a real page', () => {
869
- const item = parseNav(boot().sidebarHtml).flatMap((g) => g.items).find((i) => i.id === 'my-agents');
870
- assert.ok(item, 'the item renders');
871
- assert.equal(item.href, '/my-agents');
872
- assert.equal(item.gate, 'signed');
873
- assert.ok(hrefResolves(item), 'my-agents.html exists');
874
+ // task 1005144 (ADR 0401): My agents, Project settings, Permissions and Modules are entries
875
+ // in the one options menu, reached by the profile chip, which now says so.
876
+ test('the settings pages left the sidebar for the options menu, and the profile chip names it', () => {
877
+ const html = boot().sidebarHtml;
878
+ const ids = parseNav(html).flatMap((g) => g.items).map((i) => i.id);
879
+ for (const gone of ['my-agents', 'project-settings', 'government', 'modules']) assert.ok(!ids.includes(gone), `${gone} is not a nav item`);
880
+ assert.ok(html.includes('<a class="shell-user" id="shell-user" href="/settings" title="Options" aria-label="Options"'), 'the chip opens the menu and says so');
874
881
  });
875
882
 
876
883
  // task 1005050 (owner decision 2026-11-03, idea 1001315): Accomplishments
@@ -0,0 +1,247 @@
1
+ // tests/hall_options_menu.mjs — the ONE options menu (task 1005144, BONGOS-V3 R04,
2
+ // goal 1000141, criterion game-opens-not-navigates; ADR 0401).
3
+ //
4
+ // Every setting and configuration is reached from one vertical, grouped menu with a
5
+ // search box at the top: the Settings page. The screens that were pages of their own
6
+ // (My agents, Project settings, Modules, Permissions, Market Watch's settings and rules)
7
+ // open INSIDE it, in a frame asking for the old address with ?embed=1, and a plain visit
8
+ // to an old address is sent to the menu with that screen open. These run the real
9
+ // options-menu.js (its pure half), the real shell-game.js in a DOM stub, and read the
10
+ // server's redirect map, so a menu entry, its old URL and its frame cannot drift apart.
11
+ import { test } from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import vm from 'node:vm';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { createRequire } from 'node:module';
18
+
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
21
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
22
+ const Menu = createRequire(import.meta.url)(path.join(HALL, 'options-menu.js'));
23
+ const SERVE = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'serve-internal.js'), 'utf8');
24
+
25
+ const ids = (menu) => menu.flatMap((g) => g.items.map((i) => i.id));
26
+ const signed = (rank) => ({ authed: true, rank });
27
+
28
+ test('the menu holds every settings surface: Settings\' own groups and the six screens that were pages', () => {
29
+ const all = Menu.entries();
30
+ const embeds = all.filter((e) => e.embed).map((e) => e.embed).sort();
31
+ assert.deepEqual(embeds, ['/government', '/market-watch', '/market-watch-rules', '/modules', '/my-agents', '/project-settings']);
32
+ for (const e of all.filter((x) => x.embed)) {
33
+ assert.ok(fs.existsSync(path.join(HALL, e.embed.slice(1) + '.html')), `${e.embed}.html exists for the frame`);
34
+ }
35
+ // every native entry is a real group on the Settings page, and every group has an entry
36
+ const html = read('settings.html');
37
+ const groups = new Set([...html.matchAll(/data-group="([a-z-]+)"/g)].map((m) => m[1]));
38
+ const natives = all.filter((e) => e.native).map((e) => e.id);
39
+ for (const id of natives) assert.ok(groups.has(id), `#${id} is a Settings group`);
40
+ for (const g of groups) assert.ok(natives.includes(g), `the ${g} group is reachable from the menu`);
41
+ // ids are unique, so one address opens one thing
42
+ assert.equal(new Set(all.map((e) => e.id)).size, all.length);
43
+ });
44
+
45
+ test('the proof\'s three settings are all in the one menu: a sound, a CLI token and a module toggle', () => {
46
+ const menu = Menu.visibleMenu(signed('archon'));
47
+ const hit = (q) => Menu.search(q, menu)[0];
48
+ assert.equal(hit('sound').id, 'sound');
49
+ assert.deepEqual([hit('cli token').id, hit('cli token').anchor], ['access', 'cli']);
50
+ assert.equal(hit('module toggle').id, 'modules');
51
+ });
52
+
53
+ test('who sees what: the same rank keys the sidebar used, and module and contribution gates', () => {
54
+ const xenos = ids(Menu.visibleMenu(signed('xenos')));
55
+ assert.ok(xenos.includes('account') && xenos.includes('my-agents') && xenos.includes('access') && xenos.includes('market-watch-rules'));
56
+ for (const id of ['modules', 'project-settings', 'permissions', 'market-watch']) assert.ok(!xenos.includes(id), `a xenos does not see ${id}`);
57
+ const thetes = ids(Menu.visibleMenu(signed('thetes')));
58
+ assert.ok(thetes.includes('modules') && !thetes.includes('project-settings'), 'Modules from member, as its sidebar item was');
59
+ const metic = ids(Menu.visibleMenu(signed('metic')));
60
+ assert.ok(metic.includes('project-settings') && metic.includes('permissions') && !metic.includes('market-watch'));
61
+ const archon = ids(Menu.visibleMenu(signed('archon')));
62
+ assert.ok(archon.includes('market-watch'), 'Market Watch settings: the owner, as its tab was');
63
+ assert.deepEqual(ids(Menu.visibleMenu({ authed: false, rank: 'archon' })), [], 'signed out: nothing');
64
+ const off = ids(Menu.visibleMenu(signed('archon'), { moduleOn: (k) => k !== 'market-watch' }));
65
+ assert.ok(!off.includes('market-watch') && !off.includes('market-watch-rules'), 'a module that is off has no entries');
66
+ assert.ok(!archon.includes('extensions'), 'Extensions only when a module contributed a panel');
67
+ assert.ok(ids(Menu.visibleMenu(signed('archon'), { contributed: true })).includes('extensions'));
68
+ // a group with nothing in it is not drawn
69
+ for (const g of Menu.visibleMenu(signed('xenos'))) assert.ok(g.items.length > 0, `${g.head} has entries`);
70
+ });
71
+
72
+ test('the search: every word must match, label hits first, and a sub-place says where it is', () => {
73
+ const menu = Menu.visibleMenu(signed('archon'));
74
+ assert.deepEqual(Menu.search('', menu), []);
75
+ assert.deepEqual(Menu.search('zzzz', menu), []);
76
+ const talk = Menu.search('talks', menu)[0];
77
+ assert.deepEqual([talk.id, talk.anchor, talk.where], ['my-agents', 'talk', 'Your agents']);
78
+ assert.equal(Menu.search('PERMISSIONS', menu)[0].id, 'permissions', 'case does not matter');
79
+ // Enter opens the first line, so the first line must be the place meant
80
+ assert.equal(Menu.search('token', menu)[0].label, 'CLI access', 'the exact place above the group that holds it');
81
+ assert.equal(Menu.search('access', menu)[0].label, 'Access and keys', 'but a label hit on the group still leads');
82
+ assert.equal(Menu.search('danger zone', menu)[0].id, 'project-settings', 'a word only the entry\'s words carry still finds it');
83
+ // a viewer never finds what they cannot open
84
+ assert.ok(!Menu.search('permissions', Menu.visibleMenu(signed('xenos'))).some((h) => h.id === 'permissions'));
85
+ });
86
+
87
+ test('what an address opens: ?open=<entry> with its #anchor, a bare #<entry>, and nothing else', () => {
88
+ assert.deepEqual(Menu.target('?open=my-agents', '#talk'), { id: 'my-agents', anchor: 'talk' });
89
+ assert.deepEqual(Menu.target('?open=market-watch', ''), { id: 'market-watch', anchor: 'settings' }, 'an entry\'s own anchor');
90
+ assert.deepEqual(Menu.target('', '#sound'), { id: 'sound', anchor: null });
91
+ assert.equal(Menu.target('', '#cli'), null, 'a panel anchor is the page\'s own to land');
92
+ assert.equal(Menu.target('?open=nope', ''), null);
93
+ assert.equal(Menu.frameSrc(Menu.entry('my-agents'), 'talk', ''), '/my-agents?embed=1#talk');
94
+ assert.equal(Menu.frameSrc(Menu.entry('permissions'), null, '/builders'), '/builders/government?embed=1');
95
+ assert.equal(Menu.frameSrc(Menu.entry('sound'), null, ''), null, 'a native group has no frame');
96
+ });
97
+
98
+ test('closing goes back only to a same-site path', () => {
99
+ assert.equal(Menu.closeTo('?from=' + encodeURIComponent('/goals#/goal/7')), '/goals#/goal/7');
100
+ for (const bad of ['//evil.example/x', 'https://evil.example', '/\\evil', '']) {
101
+ assert.equal(Menu.closeTo('?from=' + encodeURIComponent(bad)), null, `${bad || 'nothing'} is not a way back`);
102
+ }
103
+ });
104
+
105
+ test('the server sends every old address to its entry, lets the frame through, and the two maps agree', () => {
106
+ const m = SERVE.match(/const RETIRED_TO_OPTIONS = Object\.freeze\((\{[\s\S]*?\})\);/);
107
+ assert.ok(m, 'the server map is a literal');
108
+ const server = vm.runInNewContext('(' + m[1] + ')');
109
+ const client = Object.fromEntries(Object.entries(Menu.RETIRED).map(([p, id]) => [p.slice(1), id]));
110
+ assert.deepEqual({ ...server }, client, 'serve-internal.js RETIRED_TO_OPTIONS and options-menu.js RETIRED are one map');
111
+ for (const id of Object.values(client)) assert.ok(Menu.entry(id), `${id} is a menu entry`);
112
+ const reDecl = 'const RETIRED_OPTIONS_RE = ';
113
+ const i = SERVE.indexOf(reDecl);
114
+ const rx = vm.runInNewContext(SERVE.slice(i + reDecl.length, SERVE.indexOf(';', i)));
115
+ for (const p of Object.keys(server)) {
116
+ for (const v of [`/builders/${p}`, `/builders/${p}/`, `/builders/${p}.html`]) assert.ok(rx.test(v), `${v} is redirected`);
117
+ }
118
+ for (const p of ['/builders/settings', '/builders/market-watch', '/builders/modulesx', '/modules/x/public/a.js']) assert.ok(!rx.test(p), `${p} is not`);
119
+ const block = SERVE.slice(i, SERVE.indexOf('});', i));
120
+ assert.match(block, /if \(embed === '1' && m\[1\] !== 'market-watch-settings'\) return next\(\);/, 'the frame passes through to the page and its own gate');
121
+ assert.match(block, /\/settings\?open=\$\{RETIRED_TO_OPTIONS\[m\[1\]\]\}/);
122
+ // it runs BEFORE the page gates, which still guard the framed request
123
+ assert.ok(i < SERVE.indexOf('PROJECT_SETTINGS_PAGE_RE.test(norm)'), 'ahead of the page-gate dispatch');
124
+ assert.match(SERVE, /GOVERNMENT_PAGE_RE\.test\(norm\)\) \{\s*return gdsAuth\.requireGovernmentPage/, 'the Permissions frame keeps its page gate');
125
+ });
126
+
127
+ test('the Settings page IS the menu: one search over the groups, loaded before settings.js', () => {
128
+ const html = read('settings.html');
129
+ assert.match(html, /<nav class="opt-rail" id="settings-rail" aria-label="Options"><\/nav>/);
130
+ assert.ok(html.indexOf('options-menu.js') > 0 && html.indexOf('options-menu.js') < html.indexOf('/builders/settings.js'));
131
+ assert.match(html, /<title>Options — /);
132
+ const js = read('settings.js');
133
+ assert.match(js, /menu\.mount\(\{/, 'settings.js hands the menu the viewer');
134
+ assert.doesNotMatch(js, /makeSectionRail/, 'the old rail is gone, not drawn beside the menu');
135
+ const css = read('settings.css');
136
+ assert.match(css, /\.settings-groups > \.opt-embed\.opt-embed--on \{ display: block; \}/, 'one framed screen shows at a time');
137
+ });
138
+
139
+ // ---- shell-game.js: the embedded page and game mode's Escape --------------------
140
+
141
+ function bootGame({ search = '', framed = true, game = false, path: p = '/my-agents' } = {}) {
142
+ const docListeners = {};
143
+ const winListeners = {};
144
+ const timers = [];
145
+ const assigned = [];
146
+ const root = { dataset: {} };
147
+ const bodyAttrs = {};
148
+ const parent = { OTBOptions: { escapes: 0, escape() { this.escapes += 1; } } };
149
+ const win = {
150
+ location: { pathname: p, search, hash: '', origin: 'https://h', href: 'https://h' + p + search, assign(u) { assigned.push(u); } },
151
+ localStorage: { getItem: (k) => (k === 'hall-game' && game ? 'on' : null), setItem() {} },
152
+ sessionStorage: { getItem: () => null, setItem() {} },
153
+ addEventListener(t, fn) { (winListeners[t] ||= []).push(fn); },
154
+ setTimeout(fn) { timers.push(fn); },
155
+ };
156
+ win.parent = framed ? parent : win;
157
+ const document = {
158
+ documentElement: root,
159
+ body: { setAttribute(k, v) { bodyAttrs[k] = v; } },
160
+ addEventListener(t, fn) { (docListeners[t] ||= []).push(fn); },
161
+ };
162
+ const ctx = vm.createContext({ window: win, document, URLSearchParams, URL });
163
+ vm.runInContext(read('shell-game.js'), ctx);
164
+ return {
165
+ root, bodyAttrs, parent, assigned, game: win.OTBShellGame,
166
+ press(key, target = { tagName: 'DIV' }) {
167
+ const ev = { key, target, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
168
+ for (const fn of winListeners.keydown || []) fn(ev);
169
+ while (timers.length) timers.shift()();
170
+ return ev;
171
+ },
172
+ click(href, target = null) {
173
+ const attrs = { href };
174
+ if (target) attrs.target = target;
175
+ const a = { getAttribute: (k) => (k in attrs ? attrs[k] : null), setAttribute: (k, v) => { attrs[k] = v; } };
176
+ for (const fn of docListeners.click || []) fn({ target: { closest: () => a } });
177
+ return attrs.target || null;
178
+ },
179
+ };
180
+ }
181
+
182
+ test('EMBEDDED: only a framed ?embed=1 page drops its chrome, and the switch is off', () => {
183
+ const framed = bootGame({ search: '?embed=1' });
184
+ assert.equal(framed.root.dataset.embed, 'on');
185
+ assert.equal(framed.bodyAttrs['data-fullscreen-switch'], 'off', 'no corner button, no Escape-to-leave');
186
+ assert.equal(framed.game.embedded(), true);
187
+ const alone = bootGame({ search: '?embed=1', framed: false });
188
+ assert.equal(alone.root.dataset.embed, undefined, 'the same URL opened on its own keeps its chrome');
189
+ assert.equal(bootGame({ search: '' }).root.dataset.embed, undefined);
190
+ const css = read('style.css');
191
+ assert.match(css, /html\[data-embed="on"\] \.app-sidebar,\s*html\[data-embed="on"\] \.app-scrim,\s*html\[data-embed="on"\] \.app-foot,\s*html\[data-embed="on"\] \.app-topbar \{ display: none; \}/);
192
+ });
193
+
194
+ test('EMBEDDED: an Escape nobody used goes to the menu; a link to another page leaves the frame', () => {
195
+ const p = bootGame({ search: '?embed=1' });
196
+ p.press('Escape');
197
+ assert.equal(p.parent.OTBOptions.escapes, 1, 'the menu got it');
198
+ p.press('Escape', { tagName: 'INPUT' });
199
+ assert.equal(p.parent.OTBOptions.escapes, 1, 'a field keeps its own Escape');
200
+ assert.equal(p.click('/task/42'), '_top', 'another page opens in the whole window');
201
+ assert.equal(p.click('#talk'), null, 'a place in the same page stays in the frame');
202
+ assert.equal(p.click('/goals', '_blank'), '_blank', 'a link that names its own target keeps it');
203
+ });
204
+
205
+ test('GAME MODE: on the menu itself the last Escape closes it; outside game mode it is not the menu\'s', () => {
206
+ // the menu page registers window.OTBOptions; shell-game hands it the press
207
+ const menu = bootGame({ game: true, framed: false, path: '/settings' });
208
+ menu.parent.OTBOptions.escapes = 0;
209
+ const ev = menu.press('Escape');
210
+ assert.equal(ev.defaultPrevented, true, 'marked used, so the shell does not also bring the bars back');
211
+ assert.deepEqual(menu.assigned, ['/settings?from=%2Fsettings'], 'with no menu script loaded it opens the menu page');
212
+ const off = bootGame({ game: false, framed: false, path: '/goals' });
213
+ off.press('Escape');
214
+ assert.deepEqual(off.assigned, [], 'outside game mode the press stays the shell\'s');
215
+ });
216
+
217
+ test('options-menu.js gives the menu its Escape: close in game mode, nothing outside it', () => {
218
+ const src = read('options-menu.js');
219
+ assert.match(src, /function escape\(\) \{\s*var game = window\.OTBShellGame;\s*if \(game && game\.on && game\.on\(\)\) close\(\);/);
220
+ assert.match(src, /root\.OTBOptions = \{/, 'the menu registers itself where shell-game.js looks');
221
+ });
222
+
223
+ // In game mode an Escape nobody used opens the options menu, so a pop-up that closes on
224
+ // Escape must say it used the press (ADR 0399 §2.4 row 5). These seven closed something and
225
+ // let the press fall through: before task 1005144 that also brought the bars back (the city
226
+ // card was task 1005188); after it, it would also have left the page for the menu.
227
+ test('every pop-up that closes on Escape spends the press, so the menu never opens from under it', () => {
228
+ const spent = {
229
+ 'atlas.js': /ev\.key === 'Escape' && popup\.classList\.contains\('show'\)\) \{ ev\.preventDefault\(\); closePopup\(\);/,
230
+ 'city.js': /if \(e\.key === 'Escape'\) \{ if \(e\.preventDefault\) e\.preventDefault\(\); closeCard\(\);/,
231
+ 'home-gallery.js': /if \(isFs\(\) \|\| reading\.length\) e\.preventDefault\(\);/,
232
+ 'task-detail.js': /e\.key === 'Escape' && document\.getElementById\('deeplink-card'\)\) \{ e\.preventDefault\(\); clearLink\(\);/,
233
+ 'tweak-editor.js': /\$\('tw-openwrap'\)\.classList\.contains\('is-open'\)\) ev\.preventDefault\(\);/,
234
+ 'update-pill.js': /state\.card && !state\.card\.hidden\) \{ e\.preventDefault\(\); closeCard\(\);/,
235
+ 'diagram-viewer.js': /&& fullscreen\) \{ e\.preventDefault\(\); toggleFullscreen\(fullscreen\);/,
236
+ };
237
+ for (const [f, re] of Object.entries(spent)) assert.match(read(f), re, `${f} spends the Escape it used`);
238
+ // and a framed screen never asks for a craft: the menu page around it does
239
+ assert.match(read('craft-mode.js'), /if \(window\.OTBShellGame && window\.OTBShellGame\.embedded && window\.OTBShellGame\.embedded\(\)\) return;/);
240
+ });
241
+
242
+ test('the start screen\'s Options is the menu, and its first tip says what Escape does now', () => {
243
+ const src = read('shell-game.js');
244
+ assert.match(src, /\{ id: 'options', label: 'Options', href: '\/settings' \}/, 'Options opens the one options menu');
245
+ assert.match(src, /'Escape opens the options menu, where every setting lives\.'/);
246
+ assert.doesNotMatch(src, /Escape brings the navigation back/, 'no tip promises what Escape no longer does');
247
+ });
@@ -206,7 +206,7 @@ async function bootWatchLedger(overrides = {}) {
206
206
  };
207
207
  sandbox.globalThis = sandbox;
208
208
  vm.createContext(sandbox);
209
- for (const f of ['dom-utils.js', 'hall-kit.js', 'watch.js']) {
209
+ for (const f of ['dom-utils.js', 'hall-kit.js', 'watch-repo-invite.js', 'watch.js']) {
210
210
  vm.runInContext(read(f), sandbox, { filename: f });
211
211
  }
212
212
  // load() is async: let every section's promise settle before reading the DOM.
@@ -156,11 +156,10 @@ test('Government section anchors exist in government.js makeTabs', () => {
156
156
  const ids = [...read('government.js').matchAll(/\{ id: '([a-z-]+)', label: '[^']*', panel:/g)].map((m) => m[1]);
157
157
  assert.ok(ids.length === 2, `sanity: found the two government tabs (${ids})`);
158
158
  assert.ok(!ids.includes('board-room'), 'the Board Room is a page, not a tab (ADR 0266)');
159
- const declared = declaredHrefs((x) => x.startsWith('/government#'));
160
- assert.ok(declared.length === 2, 'palette declares the two Government sections');
161
- for (const h of declared) {
162
- assert.ok(ids.includes(h.split('#')[1]), `${h} must be a real government.js tab id`);
163
- }
159
+ // task 1005144: Permissions and the Constitution are an options menu entry, rank-gated
160
+ // in the menu, so the palette declares no /government row — one offered to every
161
+ // signed-in reader would land a sub-Metic one on a menu without that entry.
162
+ assert.equal(declaredHrefs((x) => x.startsWith('/government')).length, 0, 'no palette row points at the old page');
164
163
  });
165
164
 
166
165
  test('Settings panel anchors exist in settings.js RAIL_GROUPS', () => {
@@ -182,8 +181,8 @@ test('My agents anchors are ones my-agents.js lands, and the moved Settings pane
182
181
  const block = (src.match(/const ANCHORS = \{([\s\S]*?)\n \};/) || [, ''])[1];
183
182
  const keys = [...block.matchAll(/^\s*([a-z-]+): \{/gm)].map((m) => m[1]);
184
183
  assert.ok(keys.length >= 5, `sanity: my-agents.js ANCHORS (${keys})`);
185
- const declared = declaredHrefs((x) => x.startsWith('/my-agents#'));
186
- assert.ok(declared.length >= 5, 'the palette offers the agent settings where they live now');
184
+ const declared = declaredHrefs((x) => x.startsWith('/settings?open=my-agents#'));
185
+ assert.ok(declared.length >= 5, 'the palette offers the agent settings where they live now (the options menu, task 1005144)');
187
186
  for (const h of declared) assert.ok(keys.includes(h.split('#')[1]), `${h} must be a my-agents.js anchor`);
188
187
  assert.equal(declaredHrefs((x) => /\/settings#(interaction|wandering|rendering|voices|specialities)/.test(x)).length, 0,
189
188
  'no palette row points at a Settings panel that left');
@@ -207,10 +206,10 @@ test('pages come from the sidebar, and a gated-off nav item is not a destination
207
206
  test('section groups appear only when their owning nav item is visible', () => {
208
207
  const all = bootPalette({ navItems: NAV_FIXTURE }).compute('');
209
208
  assert.ok(all.some((r) => r.group === 'Profile'), 'Profile sections offered when nav-profile shows');
210
- assert.ok(all.some((r) => r.group === 'Settings'), 'Settings panels offered when the profile chip shows');
209
+ assert.ok(all.some((r) => r.group === 'Options'), 'Options (the one menu, task 1005144) offered when the profile chip shows');
211
210
 
212
211
  const gatedOut = bootPalette({ navItems: NAV_FIXTURE, visibleIds: [] }).compute('');
213
- assert.ok(!gatedOut.some((r) => ['Profile', 'Tasks', 'Settings'].includes(r.group)),
212
+ assert.ok(!gatedOut.some((r) => ['Profile', 'Tasks', 'Options'].includes(r.group)),
214
213
  'no sub-destinations when their owners are hidden');
215
214
  });
216
215
 
@@ -164,7 +164,7 @@ function bootCharts() {
164
164
  // the same reason categoryChip is: an undefined helper throws mid-render.
165
165
  roleAxisLabel: () => 'Role',
166
166
  roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
167
- roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
167
+ roleOptions: () => [{ value: '', label: '— any —' }, { value: 'artisan', label: 'Artisan' }, { value: 'artist', label: 'Artist' }],
168
168
  roleIsUnset: (d) => !d || d === 'unclassified',
169
169
  ROLE_UNSET: 'unclassified',
170
170
  escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
@@ -308,7 +308,7 @@ function bootRender({ rows, status = 200, analytics = null, earnings = [] }) {
308
308
  // the same reason categoryChip is: an undefined helper throws mid-render.
309
309
  roleAxisLabel: () => 'Role',
310
310
  roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
311
- roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
311
+ roleOptions: () => [{ value: '', label: '— any —' }, { value: 'artisan', label: 'Artisan' }, { value: 'artist', label: 'Artist' }],
312
312
  roleIsUnset: (d) => !d || d === 'unclassified',
313
313
  ROLE_UNSET: 'unclassified',
314
314
  escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
@@ -328,7 +328,7 @@ function bootRender({ rows, status = 200, analytics = null, earnings = [] }) {
328
328
  return { C: sandbox.window.OTBProfileCharts, api, requests, mount, controls };
329
329
  }
330
330
 
331
- const ship = (d, extra = {}) => ({ shipped_at: `${d}T10:00:00Z`, discipline: 'engineer', credits_awarded: 10, credits_reward: 12, ...extra });
331
+ const ship = (d, extra = {}) => ({ shipped_at: `${d}T10:00:00Z`, discipline: 'artisan', credits_awarded: 10, credits_reward: 12, ...extra });
332
332
  // an earnings row: what the ledger paid on one day, for one kind of credit
333
333
  const pay = (d, credits, kind = 'session') => ({ day: d, kind, credits });
334
334
 
@@ -395,7 +395,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
395
395
  // the same reason categoryChip is: an undefined helper throws mid-render.
396
396
  roleAxisLabel: () => 'Role',
397
397
  roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
398
- roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
398
+ roleOptions: () => [{ value: '', label: '— any —' }, { value: 'artisan', label: 'Artisan' }, { value: 'artist', label: 'Artist' }],
399
399
  roleIsUnset: (d) => !d || d === 'unclassified',
400
400
  ROLE_UNSET: 'unclassified',
401
401
  // task 1004038 — the kind axis asks the same question. Stubbed for the
@@ -446,7 +446,7 @@ const settle = () => new Promise((r) => setTimeout(r, 0));
446
446
 
447
447
  const BASE_TASK = {
448
448
  id: '1003305', title: 'A record in the world', status: 'shipped', version_id: 'BONGOS-V1',
449
- kind: 'feature', discipline: 'engineer', priority: 1, requires_rank: 'metic',
449
+ kind: 'feature', discipline: 'artisan', priority: 1, requires_rank: 'metic',
450
450
  credits_reward: 60, est_minutes: 240, actual_minutes: 37,
451
451
  created_at: '2026-08-28T00:00:00Z', description: 'the description', touches: ['a.js', 'b.js'],
452
452
  dependencies: [], dependents: [],
@@ -222,7 +222,8 @@ test('every old link to those sections lands on My agents', () => {
222
222
  // settings.js asks before it draws anything, and on every later hash
223
223
  const html = read('settings.html');
224
224
  assert.ok(html.indexOf('settings-moved.js') > 0 && html.indexOf('settings-moved.js') < html.indexOf('/builders/settings.js'), 'loaded before settings.js');
225
- assert.match(settingsJs, /location\.replace\(/, 'a replace, so Back does not bounce through Settings');
225
+ // task 1005144: My agents is an options menu entry now, so a moved link opens it in place.
226
+ assert.match(settingsJs, /OTBOptions\.activate\('my-agents', anchor\)/, 'opened in the menu, no page load');
226
227
  assert.doesNotMatch(settingsJs, /'(interaction|wandering|rendering|voices)': '[a-z-]+-scroll'/, 'no PANEL_ANCHORS entry for a section that left');
227
228
  });
228
229