@bongos/core 1.24.79 → 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 (366) hide show
  1. package/.bongos-core.json +495 -350
  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 +2 -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 +70 -0
  167. package/scripts/bongos/autobongos-loop.js +1 -1
  168. package/scripts/bongos/discipline-modes.json +7 -12
  169. package/scripts/bongos/fitness.js +1 -1
  170. package/scripts/bongos/gen-hall-sitemap.js +7 -7
  171. package/scripts/bongos/grade-smoke.js +2 -2
  172. package/scripts/bongos/init.js +2 -2
  173. package/scripts/bongos/newcomer-floor.config.json +1 -2
  174. package/scripts/bongos/newcomer-floor.js +4 -3
  175. package/scripts/bongos/newcomer-floor.templates.json +10 -12
  176. package/scripts/bongos/newcomer-floor.test.js +47 -38
  177. package/scripts/bongos/role-pack-guard.js +5 -4
  178. package/scripts/bongos/seed-tasks.example.json +3 -3
  179. package/scripts/bongos/setup.js +20 -107
  180. package/scripts/bongos/start.js +2 -2
  181. package/scripts/bongos/task.js +5 -4
  182. package/scripts/bongos/triage.js +1 -1
  183. package/scripts/bongos/worktree.js +16 -4
  184. package/scripts/setup-builder.js +6 -46
  185. package/src/bongos/auth-admission.js +8 -8
  186. package/src/bongos/craft-mode.js +2 -0
  187. package/src/bongos/db.js +39 -1
  188. package/src/bongos/github-repos.js +78 -0
  189. package/src/bongos/module-scope-map.js +22 -0
  190. package/src/bongos/role-stats.js +27 -11
  191. package/src/bongos/routes/github.js +100 -2
  192. package/src/bongos/routes/me.js +33 -0
  193. package/src/bongos/serve-internal.js +26 -9
  194. package/src/crafts.js +66 -0
  195. package/src/module-api.js +6 -1
  196. package/src/module-loader/manifest-schema.js +16 -0
  197. package/src/modules.js +2 -2
  198. package/src/role-responsibilities.js +6 -5
  199. package/tests/acclaim_feed_evidence_db.mjs +1 -1
  200. package/tests/activity_rollup_as_of.mjs +1 -1
  201. package/tests/api_alias_caller_ratchet.mjs +3 -3
  202. package/tests/application_lifecycle.mjs +1 -1
  203. package/tests/artist_review.mjs +1 -1
  204. package/tests/artist_review_goal.mjs +1 -1
  205. package/tests/blocker_hall_live_proof.mjs +1 -1
  206. package/tests/blocker_tasks_named_db.mjs +2 -2
  207. package/tests/canonical_profile_url.mjs +1 -1
  208. package/tests/cap_newcomer_tasks.mjs +13 -13
  209. package/tests/character_creation.mjs +294 -0
  210. package/tests/claim_gate_open_blocker.mjs +2 -2
  211. package/tests/claim_gate_rebase.mjs +6 -6
  212. package/tests/claude_materialize.mjs +1 -1
  213. package/tests/clawback_confirmed_backlog.mjs +3 -3
  214. package/tests/collab_page.mjs +8 -9
  215. package/tests/collab_self_settling.mjs +2 -2
  216. package/tests/community_leaderboard_route.mjs +13 -5
  217. package/tests/community_proof_db.mjs +3 -0
  218. package/tests/community_search_route.mjs +7 -6
  219. package/tests/conductor.mjs +1 -1
  220. package/tests/conductor_main_e2e.mjs +8 -8
  221. package/tests/copy_desk_flags_db.mjs +1 -1
  222. package/tests/copy_desk_page_approve.mjs +2 -2
  223. package/tests/copy_desk_page_claim.mjs +2 -2
  224. package/tests/copy_desk_page_next.mjs +1 -1
  225. package/tests/copy_desk_page_release.mjs +1 -1
  226. package/tests/copy_desk_page_submit.mjs +2 -2
  227. package/tests/core_258_box_session_claims_db.mjs +1 -1
  228. package/tests/craft_mode.mjs +48 -34
  229. package/tests/craft_mode_home.mjs +20 -20
  230. package/tests/craft_mode_looks.mjs +16 -15
  231. package/tests/craft_mode_nav.mjs +31 -29
  232. package/tests/craft_review_mode.mjs +46 -41
  233. package/tests/crafts_vocabulary.mjs +70 -0
  234. package/tests/cross_discipline_priority_direction.mjs +5 -5
  235. package/tests/fitness.mjs +7 -11
  236. package/tests/funded_project_pays_proof_db.mjs +1 -1
  237. package/tests/genesis_home.mjs +21 -1
  238. package/tests/github_chip.mjs +3 -3
  239. package/tests/github_repo_invite.mjs +379 -0
  240. package/tests/goal_criterion_confirm_ui.mjs +1 -1
  241. package/tests/goal_invite_consent.mjs +1 -1
  242. package/tests/goal_map_page.mjs +2 -2
  243. package/tests/goal_sort_staleness.mjs +1 -1
  244. package/tests/goal_spec_task.mjs +1 -1
  245. package/tests/goal_spec_task_db.mjs +3 -3
  246. package/tests/grade_engine.mjs +2 -2
  247. package/tests/grader_no_artifact.mjs +1 -1
  248. package/tests/grader_prompt_fencing.mjs +1 -1
  249. package/tests/grader_prompt_window_bound.mjs +1 -1
  250. package/tests/grader_reader_lens.mjs +2 -2
  251. package/tests/grader_severity_gate.mjs +1 -1
  252. package/tests/grader_unbacked_score.mjs +1 -1
  253. package/tests/hall_approval_queue.mjs +1 -1
  254. package/tests/hall_board_world.mjs +2 -2
  255. package/tests/hall_builders_page_pass.mjs +1 -1
  256. package/tests/hall_city.mjs +1 -1
  257. package/tests/hall_fullscreen.mjs +27 -3
  258. package/tests/hall_kit_charts.mjs +1 -1
  259. package/tests/hall_landing_boot.mjs +1 -1
  260. package/tests/hall_my_agents.mjs +16 -16
  261. package/tests/hall_nav.mjs +22 -15
  262. package/tests/hall_options_menu.mjs +247 -0
  263. package/tests/hall_oversight_world.mjs +1 -1
  264. package/tests/hall_palette.mjs +8 -9
  265. package/tests/hall_profile_world.mjs +3 -3
  266. package/tests/hall_record_world.mjs +2 -2
  267. package/tests/hall_settings_world.mjs +2 -1
  268. package/tests/hall_sitemap.mjs +6 -5
  269. package/tests/hall_start_screen.mjs +244 -0
  270. package/tests/hall_street.mjs +2 -2
  271. package/tests/hall_street_inside.mjs +17 -9
  272. package/tests/hall_studio_world.mjs +3 -3
  273. package/tests/hall_visor.mjs +224 -0
  274. package/tests/help_requests.mjs +47 -15
  275. package/tests/helpers.mjs +4 -4
  276. package/tests/home_city.mjs +6 -4
  277. package/tests/home_city_widget.mjs +33 -14
  278. package/tests/home_gallery_widget.mjs +4 -4
  279. package/tests/home_idea_box.mjs +6 -6
  280. package/tests/hub_community_page.mjs +3 -3
  281. package/tests/hub_craft_rollup.mjs +75 -36
  282. package/tests/hub_recent_ships.mjs +3 -2
  283. package/tests/idea_cascade.mjs +2 -2
  284. package/tests/idea_credit_streams_db.mjs +1 -1
  285. package/tests/idea_detail_page.mjs +1 -1
  286. package/tests/idea_ideate_full.mjs +5 -2
  287. package/tests/idea_spark_hall.mjs +1 -1
  288. package/tests/init.mjs +3 -3
  289. package/tests/instance_manifest.mjs +2 -2
  290. package/tests/kickoff_checklist.mjs +1 -1
  291. package/tests/leaderboard_roles.mjs +34 -25
  292. package/tests/lifecycle_claim_ladder_refs.mjs +55 -0
  293. package/tests/lightbulb.mjs +14 -10
  294. package/tests/main_discipline.mjs +8 -8
  295. package/tests/markdown_line_endings.mjs +1 -1
  296. package/tests/market_watch_pages.mjs +52 -74
  297. package/tests/me_streak_leaderboard_queries.mjs +3 -3
  298. package/tests/migration_allocation.mjs +1 -1
  299. package/tests/mingle.mjs +36 -21
  300. package/tests/module-scope-map.mjs +22 -2
  301. package/tests/module_api.mjs +1 -0
  302. package/tests/module_contributions.mjs +13 -13
  303. package/tests/module_manifest.mjs +29 -0
  304. package/tests/module_skills_hook.mjs +2 -2
  305. package/tests/modules.mjs +14 -7
  306. package/tests/nav_permission_atoms.mjs +2 -1
  307. package/tests/needs_rebase.mjs +1 -1
  308. package/tests/newcomer_restock.mjs +13 -13
  309. package/tests/page_notes_panel.mjs +22 -22
  310. package/tests/page_tweak_no_block.mjs +1 -1
  311. package/tests/paintbrush.mjs +7 -7
  312. package/tests/privacy_matrix.mjs +1 -0
  313. package/tests/profile_activity.mjs +20 -23
  314. package/tests/profile_earnings.mjs +1 -1
  315. package/tests/profile_nudge_prefs.mjs +4 -4
  316. package/tests/profile_role_headline.mjs +73 -46
  317. package/tests/profile_role_stats.mjs +55 -45
  318. package/tests/profile_rollup_consent.mjs +19 -6
  319. package/tests/profile_route.mjs +12 -12
  320. package/tests/profile_ui_cross_project.mjs +4 -4
  321. package/tests/project_invite_suggestions.mjs +2 -2
  322. package/tests/promote_goal_id.mjs +1 -1
  323. package/tests/publish_manifest.mjs +1 -1
  324. package/tests/rank_floor_wiring.mjs +1 -1
  325. package/tests/regrade_actual_minutes.mjs +1 -1
  326. package/tests/removal_proposal.mjs +2 -2
  327. package/tests/reward_gate.mjs +4 -4
  328. package/tests/reward_mode_cost_plus_only.mjs +1 -1
  329. package/tests/role_responsibilities.mjs +18 -12
  330. package/tests/role_stats_db.mjs +17 -16
  331. package/tests/scouting_rollup_fanout.mjs +5 -5
  332. package/tests/seed_tasks.mjs +5 -2
  333. package/tests/session_earnings_mirror_db.mjs +1 -1
  334. package/tests/session_reward_on_land_db.mjs +1 -1
  335. package/tests/session_reward_reconcile_db.mjs +2 -2
  336. package/tests/sessions_kit_pager.mjs +1 -1
  337. package/tests/settings_art_key_absent.mjs +2 -2
  338. package/tests/settings_main_role.mjs +21 -20
  339. package/tests/setup_flow.mjs +3 -3
  340. package/tests/setup_smoke.mjs +20 -0
  341. package/tests/ship_card.mjs +6 -6
  342. package/tests/specialities.mjs +16 -4
  343. package/tests/speciality_db_shape.mjs +6 -6
  344. package/tests/speciality_injection.mjs +17 -17
  345. package/tests/speciality_offer_ui.mjs +5 -5
  346. package/tests/speciality_offered.mjs +6 -5
  347. package/tests/speciality_session_skills.mjs +7 -7
  348. package/tests/speciality_skills.mjs +11 -11
  349. package/tests/speciality_walkthrough_ui.mjs +2 -2
  350. package/tests/sso_checkin_identity_spoof.mjs +2 -1
  351. package/tests/task_classifier_vocab.mjs +10 -0
  352. package/tests/task_cli.mjs +11 -3
  353. package/tests/task_criteria_unlink_db.mjs +2 -2
  354. package/tests/task_discipline_required.mjs +38 -16
  355. package/tests/task_goal_required.mjs +4 -4
  356. package/tests/task_needs_db.mjs +1 -1
  357. package/tests/tweak_replaces_artist_review.mjs +1 -1
  358. package/tests/update_pill.mjs +153 -2
  359. package/tests/upkeep_home.mjs +1 -1
  360. package/tests/v3_c1_options_proof.mjs +145 -0
  361. package/tests/visual_review.mjs +3 -3
  362. package/tests/watch_applications_queue.mjs +8 -5
  363. package/tests/watch_sealed_floor.mjs +3 -1
  364. package/tests/welcome_boot.mjs +12 -9
  365. package/tests/welcome_boot_browser.mjs +13 -13
  366. package/tests/worktree_helper.mjs +16 -1
@@ -23,31 +23,31 @@ function t(name, fn) {
23
23
 
24
24
  // A claimable feed row (only the fields capNewcomerTasks reads).
25
25
  const nc = (id, discipline) => ({ id, discipline, newcomer_friendly: true });
26
- const real = (id, discipline = 'engineer') => ({ id, discipline, newcomer_friendly: false });
26
+ const real = (id, discipline = 'artisan') => ({ id, discipline, newcomer_friendly: false });
27
27
  const ids = (rows) => rows.map((r) => r.id);
28
28
 
29
- // ---- the reported case: 3 artist + 2 ideator newcomer seats ----
30
- t('caps an artist+ideator pool of 5 down to 3, both lanes represented', () => {
29
+ // ---- the reported case: 3 artist + 2 artisan newcomer seats (was ideator before task 1005141 folded it into artist) ----
30
+ t('caps an artist+artisan pool of 5 down to 3, both lanes represented', () => {
31
31
  const feed = [
32
32
  nc(1259, 'artist'), nc(1260, 'artist'), nc(1261, 'artist'),
33
- nc(1176, 'ideator'), nc(1177, 'ideator'),
33
+ nc(1176, 'artisan'), nc(1177, 'artisan'),
34
34
  ];
35
35
  const out = capNewcomerTasks(feed);
36
36
  assert.equal(out.length, 3, 'exactly 3 newcomer rows kept');
37
- // Round-robin SELECTS artist[0]=1259, ideator[0]=1176, artist[1]=1260; the
37
+ // Round-robin SELECTS artist[0]=1259, artisan[0]=1176, artist[1]=1260; the
38
38
  // output preserves INPUT order (we filter the ordered array), so 1260 (which
39
39
  // came before 1176 in the feed) lands ahead of it.
40
40
  assert.deepEqual(ids(out), [1259, 1260, 1176]);
41
41
  // Both disciplines are represented before a duplicate fills the third slot.
42
42
  const disciplines = new Set(out.map((r) => r.discipline));
43
- assert.ok(disciplines.has('artist') && disciplines.has('ideator'));
43
+ assert.ok(disciplines.has('artist') && disciplines.has('artisan'));
44
44
  });
45
45
 
46
46
  // ---- non-newcomer (real) rows are never dropped ----
47
47
  t('real version tasks pass through untouched and unbounded', () => {
48
48
  const feed = [
49
49
  real(900), real(901), real(902), real(903),
50
- nc(1259, 'artist'), nc(1260, 'artist'), nc(1261, 'artist'), nc(1176, 'ideator'),
50
+ nc(1259, 'artist'), nc(1260, 'artist'), nc(1261, 'artist'), nc(1176, 'artisan'),
51
51
  ];
52
52
  const out = capNewcomerTasks(feed);
53
53
  const reals = out.filter((r) => r.newcomer_friendly !== true);
@@ -59,19 +59,19 @@ t('real version tasks pass through untouched and unbounded', () => {
59
59
  // ---- ordering is preserved (we filter the input array, not re-sort) ----
60
60
  t('preserves original feed ordering across mixed rows', () => {
61
61
  const feed = [
62
- real(900), nc(1259, 'artist'), real(901), nc(1176, 'ideator'),
63
- nc(1260, 'artist'), nc(1261, 'artist'), nc(1177, 'ideator'),
62
+ real(900), nc(1259, 'artist'), real(901), nc(1176, 'artisan'),
63
+ nc(1260, 'artist'), nc(1261, 'artist'), nc(1177, 'artisan'),
64
64
  ];
65
65
  const out = capNewcomerTasks(feed);
66
- // Kept newcomer ids (round-robin over [artist, ideator] groups by appearance):
67
- // artist[0]=1259, ideator[0]=1176, artist[1]=1260.
66
+ // Kept newcomer ids (round-robin over [artist, artisan] groups by appearance):
67
+ // artist[0]=1259, artisan[0]=1176, artist[1]=1260.
68
68
  // Output keeps input order, dropping only un-kept newcomer rows (1261, 1177).
69
69
  assert.deepEqual(ids(out), [900, 1259, 901, 1176, 1260]);
70
70
  });
71
71
 
72
72
  // ---- no-op when at or under the cap ----
73
73
  t('returns the list unchanged when newcomer rows <= cap', () => {
74
- const feed = [nc(1, 'artist'), nc(2, 'ideator'), real(3)];
74
+ const feed = [nc(1, 'artist'), nc(2, 'artisan'), real(3)];
75
75
  const out = capNewcomerTasks(feed);
76
76
  assert.equal(out.length, 3);
77
77
  assert.deepEqual(ids(out), [1, 2, 3]);
@@ -106,7 +106,7 @@ t('tolerates non-array input', () => {
106
106
 
107
107
  // ---- custom cap argument ----
108
108
  t('honors a custom cap', () => {
109
- const feed = [nc(1, 'artist'), nc(2, 'ideator'), nc(3, 'artist'), nc(4, 'ideator')];
109
+ const feed = [nc(1, 'artist'), nc(2, 'artisan'), nc(3, 'artist'), nc(4, 'artisan')];
110
110
  assert.equal(capNewcomerTasks(feed, 2).length, 2);
111
111
  assert.deepEqual(ids(capNewcomerTasks(feed, 2)), [1, 2]);
112
112
  });
@@ -0,0 +1,294 @@
1
+ // tests/character_creation.mjs — character creation and the three sprites (task 1005147,
2
+ // BONGOS-V3 G2, criterion you-are-a-character-in-first-person).
3
+ //
4
+ // A builder picks one of three characters when they join, and the pick lives on their
5
+ // ACCOUNT (core_297, PUT /me/character). These cases run the REAL craft-mode.js in a
6
+ // small DOM stub and drive it the way the shell does once /me answers: no character
7
+ // opens character creation; a pick is saved and worn; a held character is the only one
8
+ // offered and the top bar wears its sprite; changing it is signing out. The server half
9
+ // (migration, route, db write) is pinned beside them.
10
+ import assert from 'node:assert/strict';
11
+ import { test } from 'node:test';
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import vm from 'node:vm';
15
+ import { createRequire } from 'node:module';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { realOTB } from './helpers.mjs';
18
+
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ const HALL = path.join(ROOT, 'modules/hall-ui/public');
21
+ const CRAFT = fs.readFileSync(path.join(HALL, 'craft-mode.js'), 'utf8');
22
+ const require = createRequire(import.meta.url);
23
+
24
+ function el(tag) {
25
+ const attrs = {}, listeners = {};
26
+ const e = {
27
+ tagName: tag, id: '', className: '', dataset: {}, children: [], parentNode: null, _html: '', textContent: '',
28
+ classList: { _s: new Set(), add(c) { this._s.add(c); }, remove(c) { this._s.delete(c); }, toggle(c, on) { if (on) this._s.add(c); else this._s.delete(c); }, contains(c) { return this._s.has(c); } },
29
+ setAttribute(k, v) { attrs[k] = String(v); }, getAttribute(k) { return k in attrs ? attrs[k] : null; },
30
+ hasAttribute(k) { return k in attrs; }, removeAttribute(k) { delete attrs[k]; },
31
+ addEventListener(t, fn) { (listeners[t] ||= []).push(fn); },
32
+ fire(t, ev) { for (const fn of listeners[t] || []) fn(ev); },
33
+ appendChild(c) { c.parentNode = e; e.children.push(c); return c; },
34
+ removeChild(c) { e.children = e.children.filter((x) => x !== c); c.parentNode = null; },
35
+ contains() { return false; },
36
+ focus() {},
37
+ querySelector(sel) { return e._q(sel); },
38
+ querySelectorAll() { return []; },
39
+ set innerHTML(v) { e._html = String(v); e._err = null; }, get innerHTML() { return e._html; },
40
+ _q(sel) {
41
+ if (sel === '[data-char-error]' && /data-char-error/.test(e._html)) return (e._err ||= el('P'));
42
+ return null;
43
+ },
44
+ };
45
+ return e;
46
+ }
47
+
48
+ function boot({ store = {}, session = {}, put = () => ({ ok: true, status: 200, data: null }) } = {}) {
49
+ const root = el('HTML');
50
+ const body = el('BODY');
51
+ const requests = [];
52
+ const keyListeners = [];
53
+ const docObj = {
54
+ body, documentElement: root, activeElement: null, readyState: 'complete',
55
+ getElementById(id) {
56
+ if (id === 'craft-picker') return body.children.find((c) => c.id === 'craft-picker') || null;
57
+ return null;
58
+ },
59
+ querySelector: () => null, querySelectorAll: () => [],
60
+ createElement: (t) => el(t.toUpperCase()),
61
+ addEventListener(t, fn) { if (t === 'keydown') keyListeners.push(fn); },
62
+ removeEventListener(t, fn) { const i = keyListeners.indexOf(fn); if (i >= 0) keyListeners.splice(i, 1); },
63
+ dispatchEvent() { return true; },
64
+ };
65
+ const mk = (obj) => ({
66
+ getItem: (k) => (k in obj ? obj[k] : null),
67
+ setItem: (k, v) => { obj[k] = String(v); },
68
+ removeItem: (k) => { delete obj[k]; },
69
+ });
70
+ const win = {
71
+ OTB: realOTB(),
72
+ localStorage: mk(store), sessionStorage: mk(session),
73
+ location: { search: '', assign() {} },
74
+ matchMedia: () => ({ matches: false }),
75
+ BongosClient: {
76
+ createClient: () => ({
77
+ request(method, url, opts) {
78
+ requests.push({ method, url, body: opts && opts.body });
79
+ return Promise.resolve(put(method, url, opts));
80
+ },
81
+ }),
82
+ },
83
+ };
84
+ const sandbox = {
85
+ window: win, document: docObj, console, Promise,
86
+ CustomEvent: class { constructor(t, i) { this.type = t; Object.assign(this, i); } },
87
+ };
88
+ sandbox.globalThis = sandbox;
89
+ vm.createContext(sandbox);
90
+ vm.runInContext(CRAFT, sandbox, { filename: 'craft-mode.js' });
91
+ const picker = () => body.children.find((c) => c.id === 'craft-picker') || null;
92
+ const signedIn = { authed: true };
93
+ return { win, root, body, store, session, requests, keyListeners, picker, signedIn, C: win.OTBCraftMode };
94
+ }
95
+ const builder = { id: 7, github_login: 'ada', main_discipline: null };
96
+ const flush = () => new Promise((r) => setImmediate(r));
97
+
98
+ test('no character yet: character creation opens, three characters, nothing to dismiss', () => {
99
+ const t = boot();
100
+ assert.equal(t.C.characterGate(t.signedIn, { builder, character: null }), true, 'the gate holds the welcome and the picker back');
101
+ const p = t.picker();
102
+ assert.ok(p, 'the creation screen is up');
103
+ assert.match(p.innerHTML, /Choose your character/);
104
+ const doors = [...p.innerHTML.matchAll(/data-char-pick="([a-z]+)"/g)].map((m) => m[1]);
105
+ assert.deepEqual(doors, ['artisan', 'artist', 'patron'], 'Artisan, Artist, Patron — three, in that order');
106
+ for (const id of doors) assert.ok(p.innerHTML.includes(`/builders/characters/${id}.png`), `${id} stands on its sprite`);
107
+ assert.ok(!/data-craft-close/.test(p.innerHTML), 'no close button: the pick is the way through');
108
+ // Escape does not dismiss it.
109
+ let prevented = false;
110
+ for (const fn of t.keyListeners.slice()) fn({ key: 'Escape', preventDefault() { prevented = true; } });
111
+ assert.ok(prevented && t.picker(), 'Escape is swallowed and the screen stays');
112
+ });
113
+
114
+ test('an artist-led builder starts on the Artist door', () => {
115
+ const t = boot();
116
+ t.C.characterGate(t.signedIn, { builder: { ...builder, main_discipline: 'artist' }, character: null });
117
+ assert.match(t.picker().innerHTML, /craft-door--pre[^"]*" type="button" data-char-pick="artist"/);
118
+ });
119
+
120
+ test('picking saves the character to the account and the hall wears it', async () => {
121
+ const t = boot({ put: (m, u, o) => ({ ok: true, status: 200, data: { character: o.body.character } }) });
122
+ t.C.characterGate(t.signedIn, { builder, character: null });
123
+ const target = { closest: (sel) => (sel === 'button[data-char-pick]' ? { getAttribute: () => 'artist' } : null) };
124
+ t.picker().fire('click', { target });
125
+ await flush();
126
+ assert.deepEqual(JSON.parse(JSON.stringify(t.requests[0])), { method: 'PUT', url: '/api/bongos/me/character', body: { character: 'artist' } });
127
+ assert.equal(t.picker(), null, 'the screen closes');
128
+ assert.equal(t.root.dataset.craft, 'artist', 'the hall wears the character');
129
+ assert.equal(t.store['hall-character'], 'artist', 'remembered for the next first paint');
130
+ assert.equal(t.C.character(), 'artist');
131
+ });
132
+
133
+ test('a failed save keeps the screen up and says so', async () => {
134
+ const t = boot({ put: () => ({ ok: false, status: 500, data: null }) });
135
+ t.C.characterGate(t.signedIn, { builder, character: null });
136
+ const target = { closest: () => ({ getAttribute: () => 'artisan' }) };
137
+ t.picker().fire('click', { target });
138
+ await flush(); await flush();
139
+ assert.ok(t.picker(), 'still choosing');
140
+ const err = t.picker().querySelector('[data-char-error]');
141
+ assert.match(err.textContent, /could not be saved/);
142
+ assert.equal(err.hasAttribute('hidden'), false);
143
+ });
144
+
145
+ test('a held character is the only one offered, and the top bar wears its sprite', () => {
146
+ const t = boot();
147
+ assert.equal(t.C.characterGate(t.signedIn, { builder, character: 'artisan' }), false, 'nothing to create: the welcome may show');
148
+ assert.equal(t.picker(), null);
149
+ assert.equal(t.root.dataset.craft, 'artisan');
150
+ assert.equal(t.C.offered('artisan'), true);
151
+ assert.equal(t.C.offered('artist'), false, 'another character cannot be worn without signing out');
152
+ assert.equal(t.C.offered('patron'), false);
153
+ const sw = t.C.switchHtml();
154
+ assert.match(sw, /craft-switch--character/);
155
+ assert.match(sw, /data-craft-me/);
156
+ assert.match(sw, /\/builders\/characters\/artisan\.png/);
157
+ assert.ok(!/data-craft-opt=/.test(sw), 'the three-way switch is gone');
158
+ // A session's stored mode from before the character cannot override it.
159
+ t.session['hall-craft'] = 'patron';
160
+ assert.equal(t.C.current(), 'artisan');
161
+ });
162
+
163
+ test('the sprite is drawn on first paint from the last held character, before /me answers', () => {
164
+ const t = boot({ store: { 'hall-character': 'patron' } });
165
+ assert.match(t.C.switchHtml(), /\/builders\/characters\/patron\.png/);
166
+ });
167
+
168
+ test('an older core that says nothing about characters changes nothing', () => {
169
+ const t = boot();
170
+ assert.equal(t.C.characterGate(t.signedIn, { builder }), false);
171
+ assert.equal(t.picker(), null);
172
+ assert.equal(t.C.character(), undefined);
173
+ assert.equal(t.C.offered('artist'), true, 'the three crafts stay offered as before');
174
+ });
175
+
176
+ test('a visitor who is not signed in is never asked', () => {
177
+ const t = boot();
178
+ assert.equal(t.C.characterGate({ authed: false }, { character: null }), false);
179
+ assert.equal(t.picker(), null);
180
+ });
181
+
182
+ test('the character section never writes `hidden` onto the nav: a character only marks [data-craft-off]', () => {
183
+ const start = CRAFT.indexOf('// ---------- character creation (task 1005147');
184
+ const end = CRAFT.indexOf('// ---------- the shell\'s three calls');
185
+ const section = CRAFT.slice(start, end);
186
+ assert.ok(start > 0 && end > start);
187
+ assert.ok(!/nav-item|app-sidebar/.test(section), 'the character code does not touch the nav itself; applyNav does');
188
+ });
189
+
190
+ test('the owner\'s three sprites are on disk, one per character', () => {
191
+ for (const id of ['artisan', 'artist', 'patron']) {
192
+ const f = path.join(HALL, 'characters', id + '.png');
193
+ assert.ok(fs.existsSync(f), `${id}.png`);
194
+ const head = fs.readFileSync(f).subarray(0, 8);
195
+ assert.deepEqual([...head], [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], `${id}.png is a PNG`);
196
+ }
197
+ });
198
+
199
+ // ---------- the server half ----------
200
+ test('core_297 adds the account column, and the CHECK names exactly the three characters', () => {
201
+ const sql = fs.readFileSync(path.join(ROOT, 'migrations/core_297_builder_character.sql'), 'utf8');
202
+ assert.match(sql, /ADD COLUMN IF NOT EXISTS game_character text;/);
203
+ assert.match(sql, /game_character IN \('artisan', 'artist', 'patron'\)/);
204
+ assert.match(sql, /INSERT INTO schema_migrations \(version\) VALUES \('core_297_builder_character'\)/);
205
+ });
206
+
207
+ test('PUT /me/character is a self-service write behind requireBuilder, and /me reports the character fail-open', () => {
208
+ const src = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/me.js'), 'utf8');
209
+ assert.match(src, /router\.put\('\/me\/character', auth\.requireBuilder,/);
210
+ assert.match(src, /db\.setBuilderCharacter\(req\.builder\.id, raw\)/, 'only ever the caller\'s own row');
211
+ assert.match(src, /character: await characterP,/);
212
+ assert.match(src, /try \{ return await db\.getBuilderCharacter\(req\.builder\.id\); \} catch \(_\) \{ return null; \}/);
213
+ });
214
+
215
+ // The real route over HTTP: the real me.js router and error envelope, with only the
216
+ // sign-in (auth.requireBuilder) and the two db calls stood in for.
217
+ test('PUT /me/character over HTTP: sets, clears, and refuses with stable error codes', async () => {
218
+ const express = require('express');
219
+ const authPath = require.resolve('../src/bongos/auth.js');
220
+ const dbPath = require.resolve('../src/bongos/db.js');
221
+ const mePath = require.resolve('../src/bongos/routes/me.js');
222
+ const auth = require(authPath);
223
+ const db = require(dbPath);
224
+ const saved = { requireBuilder: auth.requireBuilder, setBuilderCharacter: db.setBuilderCharacter };
225
+ const writes = [];
226
+ auth.requireBuilder = (req, res, next) => {
227
+ if (req.get('x-test-signed-out')) return res.status(401).json({ error: { code: 'unauthorized' } });
228
+ req.builder = { id: 41, rank: 'xenos' };
229
+ next();
230
+ };
231
+ db.setBuilderCharacter = async (id, c) => {
232
+ writes.push([id, c]);
233
+ if (c !== null && !['artisan', 'artist', 'patron'].includes(c)) { const e = new Error('x'); e.code = 'invalid_character'; throw e; }
234
+ return c;
235
+ };
236
+ delete require.cache[mePath];
237
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
238
+ const app = express();
239
+ app.use(express.json());
240
+ app.use(attachFail);
241
+ app.use(require(mePath)());
242
+ const server = app.listen(0);
243
+ const base = `http://127.0.0.1:${server.address().port}`;
244
+ const put = (body, headers = {}) => fetch(base + '/me/character', {
245
+ method: 'PUT', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body),
246
+ }).then(async (r) => ({ status: r.status, body: await r.json() }));
247
+ try {
248
+ let r = await put({ character: 'artist' });
249
+ assert.equal(r.status, 200);
250
+ assert.equal(r.body.character, 'artist');
251
+ r = await put({ character: null });
252
+ assert.equal(r.status, 200, 'null is the deliberate clear (change character)');
253
+ assert.equal(r.body.character, null);
254
+ r = await put({});
255
+ assert.equal(r.status, 400);
256
+ assert.equal(r.body.error.code, 'character_required', 'a stable slug, not prose');
257
+ r = await put({ character: 'emperor' });
258
+ assert.equal(r.status, 422);
259
+ assert.equal(r.body.error.code, 'invalid_character');
260
+ r = await put({ character: 7 });
261
+ assert.equal(r.status, 400, 'a non-string is refused by the body schema');
262
+ r = await put({ character: 'artist', rank: 'archon' });
263
+ assert.equal(r.status, 400, 'strict: an unknown field (an attempt to write anything else) is refused');
264
+ r = await put({ character: 'artist' }, { 'x-test-signed-out': '1' });
265
+ assert.equal(r.status, 401, 'signed out never reaches the write');
266
+ assert.deepEqual(writes.map((w) => w[0]), [41, 41, 41], 'every write that ran was the caller\'s own row');
267
+ } finally {
268
+ server.close();
269
+ auth.requireBuilder = saved.requireBuilder;
270
+ db.setBuilderCharacter = saved.setBuilderCharacter;
271
+ delete require.cache[mePath];
272
+ }
273
+ });
274
+
275
+ test('setBuilderCharacter writes a character, clears with null, normalizes an old spelling, refuses anything else', async () => {
276
+ const poolPath = require.resolve('../src/bongos/pool.js');
277
+ const dbPath = require.resolve('../src/bongos/db.js');
278
+ const seen = [];
279
+ const real = require.cache[poolPath];
280
+ require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: { query: async (sql, params) => { seen.push(params); return { rows: [{ game_character: params[1] }] }; } } } };
281
+ delete require.cache[dbPath];
282
+ try {
283
+ const db = require(dbPath);
284
+ assert.equal(await db.setBuilderCharacter(7, 'artist'), 'artist');
285
+ assert.equal(await db.setBuilderCharacter(7, null), null);
286
+ assert.equal(await db.setBuilderCharacter(7, 'ruler'), 'patron', 'the sprite\'s old name lands as Patron');
287
+ await assert.rejects(db.setBuilderCharacter(7, 'governor-general'), { code: 'invalid_character' });
288
+ await assert.rejects(db.setBuilderCharacter(7, 'ui'), { code: 'invalid_character' }, 'a discipline is not a character');
289
+ assert.deepEqual(seen.map((p) => p[0]), [7, 7, 7], 'every write is the one builder\'s own row');
290
+ } finally {
291
+ if (real) require.cache[poolPath] = real; else delete require.cache[poolPath];
292
+ delete require.cache[dbPath];
293
+ }
294
+ });
@@ -53,8 +53,8 @@ try {
53
53
  // Archon so no rank/newcomer gate interferes; the gate under test is OPEN_BLOCKER.
54
54
  await q(`INSERT INTO builders (id,github_id,github_login,display_name,rank) VALUES ($1,'ob-w','ob-worker','OB Worker','archon')`, [WORKER]);
55
55
  // TRAP: ready (feed-hidden) with an open blocker linked. CONTROL: ready, clean.
56
- await q(`INSERT INTO tasks (id,version_id,title,status,credits_reward,kind,discipline) VALUES ($1,$2,'trap','ready',10,'feature','engineer')`, [TRAP, VER]);
57
- await q(`INSERT INTO tasks (id,version_id,title,status,credits_reward,kind,discipline) VALUES ($1,$2,'control','ready',10,'feature','engineer')`, [CONTROL, VER]);
56
+ await q(`INSERT INTO tasks (id,version_id,title,status,credits_reward,kind,discipline) VALUES ($1,$2,'trap','ready',10,'feature','artisan')`, [TRAP, VER]);
57
+ await q(`INSERT INTO tasks (id,version_id,title,status,credits_reward,kind,discipline) VALUES ($1,$2,'control','ready',10,'feature','artisan')`, [CONTROL, VER]);
58
58
  await q(`INSERT INTO blockers (id,title,status) VALUES ($1,'Owner must flip the switch','open')`, [BLOCKER]);
59
59
  // blocker_ref is NOT NULL and part of the key; linkBlockerToTask writes 'blocker:<id>'.
60
60
  await q(`INSERT INTO task_blockers (task_id,blocker_id,blocker_ref) VALUES ($1,$2,$3)`, [TRAP, BLOCKER, `blocker:${BLOCKER}`]);
@@ -60,11 +60,11 @@ try {
60
60
  // Archon so no rank/newcomer gate interferes; the gate under test is REBASE_REQUIRED.
61
61
  await q(`INSERT INTO builders (id,github_id,github_login,display_name,rank) VALUES ($1,'cg-w','cg-worker','CG Worker','archon')`, [WORKER]);
62
62
  // STALLED: a confirmed task owned by WORKER, flagged needs_rebase.
63
- await q(`INSERT INTO tasks (id,version_id,title,status,credits_reward,kind,shipped_by,discipline) VALUES ($1,$2,'stalled','confirmed',10,'feature',$3,'engineer')`, [STALLED, VER, WORKER]);
63
+ await q(`INSERT INTO tasks (id,version_id,title,status,credits_reward,kind,shipped_by,discipline) VALUES ($1,$2,'stalled','confirmed',10,'feature',$3,'artisan')`, [STALLED, VER, WORKER]);
64
64
  await q(`INSERT INTO claims (task_id,builder_id,claimed_at) VALUES ($1,$2, now())`, [STALLED, WORKER]);
65
65
  await db.flagTaskNeedsRebase(String(STALLED), 'strand:no_branch_no_tip');
66
66
  // FRESH: a ready, claimable task the worker will try to pick up.
67
- await q(`INSERT INTO tasks (id,version_id,title,status,credits_reward,kind,discipline) VALUES ($1,$2,'fresh','ready',10,'feature','engineer')`, [FRESH, VER]);
67
+ await q(`INSERT INTO tasks (id,version_id,title,status,credits_reward,kind,discipline) VALUES ($1,$2,'fresh','ready',10,'feature','artisan')`, [FRESH, VER]);
68
68
 
69
69
  // 1) With STALLED flagged, claiming FRESH is refused with REBASE_REQUIRED.
70
70
  let threw = null;
@@ -116,13 +116,13 @@ try {
116
116
  await q(`UPDATE claims SET released_at = now() WHERE task_id = $1 AND released_at IS NULL`, [FRESH]);
117
117
 
118
118
  const seedStrand = async (id, reason, ageHours) => {
119
- await q(`INSERT INTO tasks (id,version_id,title,status,credits_reward,kind,shipped_by,discipline) VALUES ($1,$2,'aged','confirmed',10,'feature',$3,'engineer')`, [id, VER, WORKER]);
119
+ await q(`INSERT INTO tasks (id,version_id,title,status,credits_reward,kind,shipped_by,discipline) VALUES ($1,$2,'aged','confirmed',10,'feature',$3,'artisan')`, [id, VER, WORKER]);
120
120
  await q(`INSERT INTO claims (task_id,builder_id,claimed_at) VALUES ($1,$2, now())`, [id, WORKER]);
121
121
  await db.flagTaskNeedsRebase(String(id), reason);
122
122
  await q(`UPDATE tasks SET needs_rebase_at = now() - ($2::bigint * interval '1 hour') WHERE id = $1`, [id, ageHours]);
123
123
  };
124
124
 
125
- await q(`INSERT INTO tasks (id,version_id,title,status,credits_reward,kind,discipline) VALUES ($1,$2,'fresh2','ready',10,'feature','engineer')`, [FRESH2, VER]);
125
+ await q(`INSERT INTO tasks (id,version_id,title,status,credits_reward,kind,discipline) VALUES ($1,$2,'fresh2','ready',10,'feature','artisan')`, [FRESH2, VER]);
126
126
 
127
127
  // 4) An unprovable strand AGED past the grace no longer refuses a claim.
128
128
  await seedStrand(AGED, 'strand:no_branch_no_tip', 48);
@@ -154,7 +154,7 @@ try {
154
154
  // 7) task 1004370 — the one task the gate lets through: a P1 kind=infra fix,
155
155
  // the shape a CI-unblock is filed in. With AGED_OTHER still gating, claiming
156
156
  // it succeeds; FRESH2 (a P1 feature) was refused in 6), so both halves count.
157
- await q(`INSERT INTO tasks (id,version_id,title,status,credits_reward,kind,priority,discipline) VALUES ($1,$2,'ci fix','ready',10,'infra',1,'engineer')`, [CIFIX, VER]);
157
+ await q(`INSERT INTO tasks (id,version_id,title,status,credits_reward,kind,priority,discipline) VALUES ($1,$2,'ci fix','ready',10,'infra',1,'artisan')`, [CIFIX, VER]);
158
158
  const ciClaim = await db.claimTask({ taskId: String(CIFIX), builderId: String(WORKER), worktreeName: 'cg-cifix' });
159
159
  ok(ciClaim && Number(ciClaim.task_id) === CIFIX,
160
160
  'a P1 infra task is claimable while a rebase is owed — the CI-unblock fix is never gated out');
@@ -171,7 +171,7 @@ try {
171
171
  await q(`UPDATE tasks SET status='shipped', needs_rebase=false WHERE id=$1`, [AGED_OTHER]);
172
172
  await q(`UPDATE claims SET released_at = now() WHERE task_id = ANY($1::bigint[]) AND released_at IS NULL`, [[CIFIX]]);
173
173
  await seedStrand(GUARDED, 'strand:branch_modifies_executed_code:.gitattributes', 1);
174
- await q(`INSERT INTO tasks (id,version_id,title,status,credits_reward,kind,discipline) VALUES ($1,$2,'fresh3','ready',10,'feature','engineer')`, [FRESH3, VER]);
174
+ await q(`INSERT INTO tasks (id,version_id,title,status,credits_reward,kind,discipline) VALUES ($1,$2,'fresh3','ready',10,'feature','artisan')`, [FRESH3, VER]);
175
175
  const claim3 = await db.claimTask({ taskId: String(FRESH3), builderId: String(WORKER), worktreeName: 'cg-fresh3' });
176
176
  ok(claim3 && Number(claim3.task_id) === FRESH3, 'a confirmed task parked on a server-side guard does not refuse a new claim');
177
177
  ok((await db.needsRebaseTasksForBuilder(String(WORKER))).some((t) => Number(t.id) === GUARDED),
@@ -764,7 +764,7 @@ t('the materialise-at-claim trigger is DATA: the ui mode opts in, every other di
764
764
  const modes = JSON.parse(readFileSync(join(ROOT, 'scripts', 'bongos', 'discipline-modes.json'), 'utf8')).modes;
765
765
  assert.equal(modes.ui.materializeModuleSkills, true, 'a ui claim brings the skills /design names');
766
766
  assert.equal(modes.ui.skill, 'design', 'and still routes to /design');
767
- for (const d of ['engineer', 'ideator', 'artist']) {
767
+ for (const d of ['artisan', 'artist']) {
768
768
  assert.ok(!modes[d].materializeModuleSkills, `${d} did not opt in, so its claim output is unchanged`);
769
769
  }
770
770
  });
@@ -60,9 +60,9 @@ try {
60
60
  // Two still-confirmed tasks (unlanded) + one legitimately shipped task.
61
61
  // discipline is required since core_247 (the column default 'unclassified' fails its CHECK).
62
62
  await q(`INSERT INTO tasks (id,version_id,title,status,credits_reward,kind,shipped_by,discipline) VALUES
63
- ($1,$4,'confirmed A','confirmed',20,'feature',$2,'engineer'),
64
- ($5,$4,'confirmed B','confirmed',15,'feature',$3,'engineer'),
65
- ($6,$4,'shipped legit','shipped',30,'feature',$2,'engineer')`, [T_A, A, B, VER, T_B, T_S]);
63
+ ($1,$4,'confirmed A','confirmed',20,'feature',$2,'artisan'),
64
+ ($5,$4,'confirmed B','confirmed',15,'feature',$3,'artisan'),
65
+ ($6,$4,'shipped legit','shipped',30,'feature',$2,'artisan')`, [T_A, A, B, VER, T_B, T_S]);
66
66
  // Seed the credits the OLD confirm-time rule would have paid (credit_log INSERT
67
67
  // fires the total_credits trigger, exactly like a real award).
68
68
  await q(`INSERT INTO credit_log (builder_id,task_id,delta,reason,description) VALUES
@@ -481,8 +481,8 @@ test('roleWord and roleLabel disagree exactly where they should', () => {
481
481
  // Executed, not grepped: the acronym exception has to survive both ways, or
482
482
  // "You asked uis" ships the next time a ui task files a help request.
483
483
  const { roleLabel, roleWord } = domUtils();
484
- assert.equal(roleLabel('engineer'), 'Engineer');
485
- assert.equal(roleWord('engineer'), 'engineer');
484
+ assert.equal(roleLabel('artisan'), 'Artisan');
485
+ assert.equal(roleWord('artisan'), 'artisan');
486
486
  assert.equal(roleLabel('ui'), 'UI');
487
487
  assert.equal(roleWord('ui'), 'UI', 'an acronym is not a common noun in any sentence');
488
488
  assert.equal(roleLabel('unclassified'), 'No role yet');
@@ -524,13 +524,13 @@ test('the mingle sub-line reads as English for every pair, and never shows the c
524
524
  const word = (d) => (OTB.roleIsUnset(d) ? '' : OTB.roleWord(d));
525
525
  const line = (a, b) => L.pairSentence(word(a), word(b));
526
526
 
527
- assert.equal(line('engineer', 'artist'), 'An engineer — you are an artist. Mingle pairs across roles.');
528
- assert.equal(line('artist', 'ideator'), 'An artist — you are an ideator. Mingle pairs across roles.');
527
+ assert.equal(line('artisan', 'artist'), 'An artisan — you are an artist. Mingle pairs across roles.');
528
+ assert.equal(line('artist', 'artisan'), 'An artist — you are an artisan. Mingle pairs across roles.');
529
529
 
530
530
  // 'ui' is the acronym exception roleWord() already makes, and it is why the article
531
531
  // cannot be a plain vowel test — "an UI" is how that shortcut fails.
532
- assert.equal(line('ui', 'engineer'), 'A UI — you are an engineer. Mingle pairs across roles.');
533
- assert.equal(line('engineer', 'ui'), 'An engineer — you are a UI. Mingle pairs across roles.');
532
+ assert.equal(line('ui', 'artisan'), 'A UI — you are an artisan. Mingle pairs across roles.');
533
+ assert.equal(line('artisan', 'ui'), 'An artisan — you are a UI. Mingle pairs across roles.');
534
534
  });
535
535
 
536
536
  test('a pair with an unset role drops the comparison rather than naming the default', () => {
@@ -541,7 +541,7 @@ test('a pair with an unset role drops the comparison rather than naming the defa
541
541
  // The defect this replaced rendered the database's own word — "A unclassified — you are
542
542
  // a engineer" — which is the example V2 names. Nothing a person did not choose may reach
543
543
  // the sentence, whichever side is missing.
544
- for (const pair of [[OTB.ROLE_UNSET, 'engineer'], ['engineer', OTB.ROLE_UNSET], [null, 'artist'], ['', '']]) {
544
+ for (const pair of [[OTB.ROLE_UNSET, 'artisan'], ['artisan', OTB.ROLE_UNSET], [null, 'artist'], ['', '']]) {
545
545
  const out = L.pairSentence(word(pair[0]), word(pair[1]));
546
546
  assert.equal(out, 'Mingle pairs across roles.', `pair ${JSON.stringify(pair)}`);
547
547
  assert.doesNotMatch(out, /unclassified/);
@@ -550,9 +550,8 @@ test('a pair with an unset role drops the comparison rather than naming the defa
550
550
 
551
551
  test('aOrAn is the article rule, stated once', () => {
552
552
  const L = lib();
553
- assert.equal(L.aOrAn('engineer'), 'an');
553
+ assert.equal(L.aOrAn('artisan'), 'an');
554
554
  assert.equal(L.aOrAn('artist'), 'an');
555
- assert.equal(L.aOrAn('ideator'), 'an');
556
555
  assert.equal(L.aOrAn('UI'), 'a', 'the acronym is spoken "you-eye", so it takes "a"');
557
556
  assert.equal(L.aOrAn('ui'), 'a');
558
557
  });
@@ -257,8 +257,8 @@ test('a craft the caller does not hold is dropped before it binds', async () =>
257
257
  // The C01 hole, one surface over: a craft value that did not come from the
258
258
  // caller's own profile must never widen a scope.
259
259
  const r = recorder();
260
- await help.listArchivedForBuilder({ builderId: 7, crafts: ['wizard', 'ideator'] }, { pool: r.pool });
261
- assert.deepEqual(r.calls[0].params[1], ['ideator']);
260
+ await help.listArchivedForBuilder({ builderId: 7, crafts: ['wizard', 'artist'] }, { pool: r.pool });
261
+ assert.deepEqual(r.calls[0].params[1], ['artist']);
262
262
  });
263
263
 
264
264
  test('the archive ROUTE reads crafts from the profile and takes nothing from the query', () => {
@@ -52,11 +52,11 @@ const account = (id, handle, extra = {}) => ({
52
52
 
53
53
  // Deliberately NOT in rank order — the module ranks, the fake does not.
54
54
  const BOARD = [
55
- { acct: account('205', 'third-builder'), credits: 50, tasks_shipped: 1, karma: 0, disciplines: ['artist', 'engineer'] },
55
+ { acct: account('205', 'third-builder'), credits: 50, tasks_shipped: 1, karma: 0, disciplines: ['artist', 'artisan'] },
56
56
  { acct: account('201', 'hidden-builder', { hide_stats: true }), credits: 250, tasks_shipped: 8, karma: 1, disciplines: ['artist'] },
57
- { acct: account('200', 'top-builder'), credits: 300, tasks_shipped: 9, karma: 4, disciplines: ['engineer'] },
58
- { acct: account('202', 'private-builder', { account_visibility: 'private' }), credits: 240, tasks_shipped: 7, karma: 1, disciplines: ['engineer'] },
59
- { acct: account('203', 'prov-builder', { profile_state: 'provisional', terms_accepted_at: null }), credits: 230, tasks_shipped: 7, karma: 1, disciplines: ['engineer'] },
57
+ { acct: account('200', 'top-builder'), credits: 300, tasks_shipped: 9, karma: 4, disciplines: ['artisan'] },
58
+ { acct: account('202', 'private-builder', { account_visibility: 'private' }), credits: 240, tasks_shipped: 7, karma: 1, disciplines: ['artisan'] },
59
+ { acct: account('203', 'prov-builder', { profile_state: 'provisional', terms_accepted_at: null }), credits: 230, tasks_shipped: 7, karma: 1, disciplines: ['artisan'] },
60
60
  // Opted out of RECRUITING only — spec D3: that opt-out does not touch a leaderboard.
61
61
  { acct: account('204', 'optout-builder', { recruiter_discoverable: false }), credits: 120, tasks_shipped: 3, karma: 0, disciplines: ['ui'] },
62
62
  // Ties top-builder on every number: the handle decides, so it ranks second.
@@ -99,6 +99,7 @@ const moduleApiPath = require.resolve('../src/module-api.js');
99
99
  require.cache[moduleApiPath] = {
100
100
  id: moduleApiPath, filename: moduleApiPath, loaded: true,
101
101
  exports: {
102
+ crafts: require('../src/crafts.js'), // the real vocabulary (task 1005141)
102
103
  pool: engine,
103
104
  corsPublicGet: () => (_req, res, next) => { res.set('Access-Control-Allow-Origin', '*'); next(); },
104
105
  logger: () => ({ error: () => {}, info: () => {}, warn: () => {} }),
@@ -168,7 +169,7 @@ await t('all-time binds no window start; a window binds exactly its start', asyn
168
169
  await t('ranked credits → ships → karma → handle, with a trimmed discipline filter and clamped paging', async () => {
169
170
  const all = await lb.listLeaderboard({ window: 'all', pool: engine });
170
171
  assert.deepEqual(all.results.map((r) => r.handle), ['top-builder', 'twin-builder', 'optout-builder', 'third-builder']);
171
- const eng = await lb.listLeaderboard({ window: 'all', discipline: ' engineer ', limit: 5000, offset: 1, pool: engine });
172
+ const eng = await lb.listLeaderboard({ window: 'all', discipline: ' artisan ', limit: 5000, offset: 1, pool: engine });
172
173
  assert.deepEqual(eng.results.map((r) => [r.place, r.handle]), [[2, 'third-builder']]);
173
174
  assert.equal(eng.limit, lb.MAX_LIMIT);
174
175
  });
@@ -280,6 +281,13 @@ await t('a discipline filter narrows the board and is echoed', async () => {
280
281
  'the hidden builder holds artist too, and still does not appear');
281
282
  });
282
283
 
284
+ await t('an old link\'s ideator reads as today\'s artist (task 1005141)', async () => {
285
+ const r = await get(port, `${PATH}?window=30d&discipline=ideator`);
286
+ assert.equal(r.status, 200);
287
+ assert.equal(r.json.discipline, 'artist');
288
+ assert.deepEqual(r.json.results.map((x) => x.handle), ['third-builder']);
289
+ });
290
+
283
291
  await t('place counts from the page offset, and page echoes the clamped values', async () => {
284
292
  const r = await get(port, `${PATH}?limit=1&offset=1`);
285
293
  assert.equal(r.status, 200);
@@ -137,6 +137,9 @@ require.cache[moduleApiPath] = {
137
137
  pool,
138
138
  corsPublicGet: () => (_req, _res, next) => next(),
139
139
  logger: () => ({ error: (o, m) => console.error(`route error: ${m}`, o), info: () => {}, warn: () => {} }),
140
+ // The routes fold an old craft name in ?discipline= through it (task 1005141); the real
141
+ // doorway's getter, so this stand-in cannot drift from it (task 1005193).
142
+ crafts: require('../src/crafts.js'),
140
143
  },
141
144
  };
142
145
  const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
@@ -102,7 +102,7 @@ const ACCOUNTS = [
102
102
  // active + public + NOT hidden — the plain control.
103
103
  { github_id: '100', handle: 'plain-vis', github_login: 'plainvis', display_name: 'Plain Visible',
104
104
  avatar_url: null, profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: false,
105
- disciplines: ['engineer'] },
105
+ disciplines: ['artisan'] },
106
106
  // active + public + HIDDEN — findable by name, not by discipline.
107
107
  { github_id: '101', handle: 'hidden-builder', github_login: 'hiddenbuilder', display_name: 'Hidden Builder',
108
108
  avatar_url: null, profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: true,
@@ -110,11 +110,11 @@ const ACCOUNTS = [
110
110
  // active + PRIVATE — absent entirely, findable or not.
111
111
  { github_id: '102', handle: 'private-builder', github_login: 'privatebuilder', display_name: 'Private Builder',
112
112
  avatar_url: null, profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'private', hide_stats: false,
113
- disciplines: ['engineer'] },
113
+ disciplines: ['artisan'] },
114
114
  // PROVISIONAL — absent entirely.
115
115
  { github_id: '103', handle: 'provisional-builder', github_login: 'provbuilder', display_name: 'Provisional Builder',
116
116
  avatar_url: null, profile_state: 'provisional', terms_accepted_at: null, account_visibility: 'public', hide_stats: false,
117
- disciplines: ['engineer'] },
117
+ disciplines: ['artisan'] },
118
118
  // active + public + NOT hidden, but 'octocat' sits mid-handle — a query for
119
119
  // it is NOT a prefix match, so this fixture exercises the FUZZY (`%`) leg
120
120
  // specifically, not the LIKE prefix leg.
@@ -185,6 +185,7 @@ const moduleApiPath = require.resolve('../src/module-api.js');
185
185
  require.cache[moduleApiPath] = {
186
186
  id: moduleApiPath, filename: moduleApiPath, loaded: true,
187
187
  exports: {
188
+ crafts: require('../src/crafts.js'), // the real vocabulary (task 1005141)
188
189
  pool: engine,
189
190
  corsPublicGet: () => (_req, _res, next) => next(),
190
191
  logger: () => ({ error: () => {}, info: () => {}, warn: () => {} }),
@@ -252,7 +253,7 @@ await t('the SAME hide_stats account drops out of a DISCIPLINE-filtered search',
252
253
  await t('a private account never appears — by name or by its real discipline', async () => {
253
254
  const byName = await get(port, '/api/bongos/community/search?q=private-builder');
254
255
  assert.ok(!byName.json.results.some((x) => x.github_login === 'privatebuilder'));
255
- const byDiscipline = await get(port, '/api/bongos/community/search?q=private&discipline=engineer');
256
+ const byDiscipline = await get(port, '/api/bongos/community/search?q=private&discipline=artisan');
256
257
  assert.ok(!byDiscipline.json.results.some((x) => x.github_login === 'privatebuilder'));
257
258
  });
258
259
 
@@ -271,8 +272,8 @@ await t('a case-differing, NON-PREFIX query hits the fuzzy leg (not just the LIK
271
272
  });
272
273
 
273
274
  await t('the plain visible account IS found under its real discipline, and ABSENT under another', async () => {
274
- const hit = await get(port, '/api/bongos/community/search?q=plain-vis&discipline=engineer');
275
- assert.ok(hit.json.results.some((x) => x.github_login === 'plainvis'), 'engineer is its real discipline');
275
+ const hit = await get(port, '/api/bongos/community/search?q=plain-vis&discipline=artisan');
276
+ assert.ok(hit.json.results.some((x) => x.github_login === 'plainvis'), 'artisan is its real discipline');
276
277
  const miss = await get(port, '/api/bongos/community/search?q=plain-vis&discipline=artist');
277
278
  assert.ok(!miss.json.results.some((x) => x.github_login === 'plainvis'), 'artist is not its discipline');
278
279
  });