@bongos/core 1.24.79 → 1.24.81

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 (413) hide show
  1. package/.bongos-core.json +554 -394
  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/0402-helm-is-the-halls-look-and-the-hall-is-dark-only.md +142 -0
  17. package/docs/adr/README.md +4 -0
  18. package/docs/api/openapi.json +195 -3
  19. package/docs/api-reference.md +8 -4
  20. package/docs/architecture.md +7 -3
  21. package/docs/copy-inventory.md +393 -378
  22. package/docs/copy-registry.json +722 -578
  23. package/docs/design/characters/character-sprites-source.webp +0 -0
  24. package/docs/file-map.md +7 -8
  25. package/docs/module-api-changelog.md +4 -0
  26. package/docs/modules-contract.md +5 -0
  27. package/docs/onboarding/slash-commands.md +1 -1
  28. package/docs/packs/{engineer.md → artisan.md} +5 -5
  29. package/docs/packs/artist.md +50 -4
  30. package/docs/page-inventory.json +16 -7
  31. package/docs/page-readings.json +3184 -3338
  32. package/docs/{packs/ideator.md → recipes/ideation-playbook.md} +5 -48
  33. package/migrations/core_296_crafts_artisan_artist.sql +72 -0
  34. package/migrations/core_297_builder_character.sql +30 -0
  35. package/modules/copy-desk/proposals.js +1 -1
  36. package/modules/economy/credits-by-craft.js +29 -15
  37. package/modules/hall-ui/public/ai-studio.html +6 -4
  38. package/modules/hall-ui/public/ai-studio.states.json +1 -0
  39. package/modules/hall-ui/public/atlas.html +5 -5
  40. package/modules/hall-ui/public/atlas.js +3 -1
  41. package/modules/hall-ui/public/atlas.states.json +1 -0
  42. package/modules/hall-ui/public/blockers.html +5 -5
  43. package/modules/hall-ui/public/board-room.html +4 -4
  44. package/modules/hall-ui/public/board-room.states.json +1 -0
  45. package/modules/hall-ui/public/characters/artisan.png +0 -0
  46. package/modules/hall-ui/public/characters/artist.png +0 -0
  47. package/modules/hall-ui/public/characters/patron.png +0 -0
  48. package/modules/hall-ui/public/city.css +3 -4
  49. package/modules/hall-ui/public/city.html +9 -9
  50. package/modules/hall-ui/public/city.js +17 -56
  51. package/modules/hall-ui/public/city.states.json +7 -32
  52. package/modules/hall-ui/public/collab.html +4 -4
  53. package/modules/hall-ui/public/collab.states.json +1 -0
  54. package/modules/hall-ui/public/commands.html +4 -4
  55. package/modules/hall-ui/public/commands.states.json +1 -0
  56. package/modules/hall-ui/public/controls.css +63 -11
  57. package/modules/hall-ui/public/copy-desk.html +4 -4
  58. package/modules/hall-ui/public/copy-desk.states.json +1 -0
  59. package/modules/hall-ui/public/craft-mode.js +258 -64
  60. package/modules/hall-ui/public/deploy.html +4 -4
  61. package/modules/hall-ui/public/deploy.states.json +1 -0
  62. package/modules/hall-ui/public/diagram-viewer.js +2 -1
  63. package/modules/hall-ui/public/dom-utils.js +1 -1
  64. package/modules/hall-ui/public/goal-map.html +9 -6
  65. package/modules/hall-ui/public/goals.html +5 -5
  66. package/modules/hall-ui/public/goals.states.json +1 -0
  67. package/modules/hall-ui/public/government.html +4 -4
  68. package/modules/hall-ui/public/government.states.json +1 -0
  69. package/modules/hall-ui/public/hall-render.js +3 -1
  70. package/modules/hall-ui/public/home-city-draw.js +3 -3
  71. package/modules/hall-ui/public/home-city.css +4 -0
  72. package/modules/hall-ui/public/home-city.js +17 -9
  73. package/modules/hall-ui/public/home-gallery.js +8 -1
  74. package/modules/hall-ui/public/home-idea-box.css +2 -2
  75. package/modules/hall-ui/public/home-idea-box.js +3 -3
  76. package/modules/hall-ui/public/idea.html +5 -5
  77. package/modules/hall-ui/public/idea.states.json +1 -0
  78. package/modules/hall-ui/public/ideas.html +5 -5
  79. package/modules/hall-ui/public/ideas.states.json +1 -0
  80. package/modules/hall-ui/public/index-founding.states.json +1 -0
  81. package/modules/hall-ui/public/index-genesis-decide.states.json +1 -0
  82. package/modules/hall-ui/public/index-genesis-demo.states.json +1 -0
  83. package/modules/hall-ui/public/index-genesis.states.json +1 -0
  84. package/modules/hall-ui/public/index.html +11 -7
  85. package/modules/hall-ui/public/index.states.json +1 -0
  86. package/modules/hall-ui/public/market-watch-item.html +4 -4
  87. package/modules/hall-ui/public/market-watch-item.states.json +1 -0
  88. package/modules/hall-ui/public/market-watch-rules.html +4 -4
  89. package/modules/hall-ui/public/market-watch-rules.states.json +1 -0
  90. package/modules/hall-ui/public/market-watch.html +6 -9
  91. package/modules/hall-ui/public/market-watch.js +22 -45
  92. package/modules/hall-ui/public/market-watch.states.json +1 -0
  93. package/modules/hall-ui/public/modules.html +4 -4
  94. package/modules/hall-ui/public/modules.states.json +1 -0
  95. package/modules/hall-ui/public/money.html +4 -4
  96. package/modules/hall-ui/public/money.states.json +1 -0
  97. package/modules/hall-ui/public/my-agents.html +4 -4
  98. package/modules/hall-ui/public/my-agents.states.json +1 -0
  99. package/modules/hall-ui/public/options-menu.js +409 -0
  100. package/modules/hall-ui/public/paintbrush.js +3 -3
  101. package/modules/hall-ui/public/palette.js +11 -13
  102. package/modules/hall-ui/public/primer.html +5 -5
  103. package/modules/hall-ui/public/primer.states.json +1 -0
  104. package/modules/hall-ui/public/profile-crafts.js +1 -1
  105. package/modules/hall-ui/public/profile-roles.js +23 -19
  106. package/modules/hall-ui/public/profile.css +1 -1
  107. package/modules/hall-ui/public/profile.html +19 -18
  108. package/modules/hall-ui/public/profile.js +19 -18
  109. package/modules/hall-ui/public/profile.states.json +5 -4
  110. package/modules/hall-ui/public/project-settings.html +4 -4
  111. package/modules/hall-ui/public/roster.html +4 -4
  112. package/modules/hall-ui/public/roster.js +3 -3
  113. package/modules/hall-ui/public/roster.states.json +1 -0
  114. package/modules/hall-ui/public/settings.css +24 -4
  115. package/modules/hall-ui/public/settings.html +21 -17
  116. package/modules/hall-ui/public/settings.js +44 -44
  117. package/modules/hall-ui/public/settings.states.json +1 -0
  118. package/modules/hall-ui/public/shell-game.css +110 -0
  119. package/modules/hall-ui/public/shell-game.js +316 -3
  120. package/modules/hall-ui/public/shell.js +15 -66
  121. package/modules/hall-ui/public/sky-interaction.js +7 -0
  122. package/modules/hall-ui/public/speciality-tools.js +1 -2
  123. package/modules/hall-ui/public/street-card.states.json +1 -0
  124. package/modules/hall-ui/public/street-inside.js +3 -4
  125. package/modules/hall-ui/public/street.css +1 -5
  126. package/modules/hall-ui/public/street.html +6 -10
  127. package/modules/hall-ui/public/street.js +8 -59
  128. package/modules/hall-ui/public/street.states.json +7 -32
  129. package/modules/hall-ui/public/studio.html +5 -5
  130. package/modules/hall-ui/public/studio.states.json +1 -0
  131. package/modules/hall-ui/public/style.css +197 -112
  132. package/modules/hall-ui/public/task-detail.js +5 -4
  133. package/modules/hall-ui/public/task.html +5 -5
  134. package/modules/hall-ui/public/task.states.json +1 -0
  135. package/modules/hall-ui/public/thinking.css +10 -0
  136. package/modules/hall-ui/public/thinking.html +9 -6
  137. package/modules/hall-ui/public/thinking.states.json +1 -0
  138. package/modules/hall-ui/public/tweak-editor.html +5 -5
  139. package/modules/hall-ui/public/tweak-editor.js +5 -1
  140. package/modules/hall-ui/public/tweak-editor.states.json +1 -0
  141. package/modules/hall-ui/public/update-pill.js +91 -2
  142. package/modules/hall-ui/public/visor.css +80 -0
  143. package/modules/hall-ui/public/visor.js +316 -0
  144. package/modules/hall-ui/public/watch-repo-invite.js +159 -0
  145. package/modules/hall-ui/public/watch.html +6 -5
  146. package/modules/hall-ui/public/watch.js +17 -2
  147. package/modules/hall-ui/public/watch.states.json +1 -0
  148. package/modules/hall-ui/public/welcome-boot.js +15 -6
  149. package/modules/hall-ui/public/work-founding.states.json +1 -0
  150. package/modules/hall-ui/public/work.html +5 -5
  151. package/modules/hall-ui/public/work.states.json +1 -0
  152. package/modules/hall-ui/records/builders-roster.md +2 -2
  153. package/modules/hall-ui/records/craft-mode.md +13 -11
  154. package/modules/hall-ui/records/game-mode.md +2 -2
  155. package/modules/hall-ui/records/governor-street.md +1 -1
  156. package/modules/hall-ui/records/home-city.md +3 -1
  157. package/modules/hall-ui/records/market-watch-settings.md +2 -0
  158. package/modules/hall-ui/records/options-menu.md +15 -0
  159. package/modules/hall-ui/records/profile-roles.md +11 -11
  160. package/modules/hall-ui/records/sky.md +3 -3
  161. package/modules/hall-ui/records/start-screen.md +25 -0
  162. package/modules/hall-ui/records/tweak-editor.md +1 -1
  163. package/modules/hall-ui/records/update-pill.md +12 -0
  164. package/modules/hall-ui/records/visor.md +23 -0
  165. package/modules/hall-ui/records/welcome-tower.md +5 -5
  166. package/modules/hall-ui/specimens/components.states.json +2 -1
  167. package/modules/ideas/ideator-stats.js +3 -1
  168. package/modules/ideas/inbox.js +2 -2
  169. package/modules/lifecycle/activity.js +1 -1
  170. package/modules/lifecycle/cascade.js +4 -4
  171. package/modules/lifecycle/goal-spec-task.js +1 -1
  172. package/modules/lifecycle/help-requests.js +19 -15
  173. package/modules/lifecycle/kickoff-checklist.js +3 -3
  174. package/modules/lifecycle/kickoff-seed.js +1 -1
  175. package/modules/lifecycle/migrations/lifecycle_021_crafts_artisan_artist.sql +29 -0
  176. package/modules/lifecycle/mingle.js +4 -4
  177. package/modules/lifecycle/page-tweak-write.js +1 -1
  178. package/modules/lifecycle/routes/task-write-routes.js +3 -1
  179. package/modules/lifecycle/routes/tasks.js +3 -3
  180. package/modules/lifecycle/ship-card.js +2 -3
  181. package/modules/lifecycle/task-classifier.js +39 -29
  182. package/modules/lifecycle/tests/cascade.mjs +7 -7
  183. package/modules/onboarding/founding-mode.js +3 -0
  184. package/modules/onboarding/genesis-home.js +21 -1
  185. package/modules/onboarding/routes/onboarding.js +2 -0
  186. package/modules/page-notes/notes.js +3 -2
  187. package/modules/page-notes/split.js +1 -1
  188. package/modules/platform-identity/craft-rollup.js +35 -7
  189. package/modules/platform-identity/migrations/platform_identity_036_crafts_artisan_artist.sql +45 -0
  190. package/modules/platform-identity/platform-identity.js +2 -2
  191. package/modules/platform-identity/routes/community-leaderboard.js +2 -1
  192. package/modules/platform-identity/routes/community-search.js +2 -1
  193. package/modules/platform-identity/routes/sso.js +2 -1
  194. package/modules/platform-identity/tests/platform-identity.mjs +6 -6
  195. package/modules/public-landing/public/community.html +3 -5
  196. package/modules/public-landing/public/index.html +1 -1
  197. package/modules/public-landing/public/projects.html +1 -1
  198. package/modules/specialities/migrations/specialities_004_crafts_artisan_artist.sql +30 -0
  199. package/modules/specialities/routes/specialities.js +2 -2
  200. package/modules/status-ui/public/status.js +1 -1
  201. package/modules/ui-design/kit/fixtures/community-leaderboard.json +10 -10
  202. package/modules/ui-design/kit/fixtures/community-search.json +2 -2
  203. package/modules/ui-design/kit/fixtures/scouting-recruiter.json +1 -1
  204. package/modules/ui-design/public/brand.html +2 -2
  205. package/package-lock.json +2 -2
  206. package/package.json +1 -1
  207. package/release-notes.json +76 -0
  208. package/scripts/bongos/autobongos-loop.js +1 -1
  209. package/scripts/bongos/discipline-modes.json +7 -12
  210. package/scripts/bongos/fitness.js +1 -1
  211. package/scripts/bongos/gen-hall-sitemap.js +7 -7
  212. package/scripts/bongos/grade-smoke.js +2 -2
  213. package/scripts/bongos/init.js +2 -2
  214. package/scripts/bongos/newcomer-floor.config.json +1 -2
  215. package/scripts/bongos/newcomer-floor.js +4 -3
  216. package/scripts/bongos/newcomer-floor.templates.json +10 -12
  217. package/scripts/bongos/newcomer-floor.test.js +47 -38
  218. package/scripts/bongos/role-pack-guard.js +5 -4
  219. package/scripts/bongos/seed-tasks.example.json +3 -3
  220. package/scripts/bongos/setup.js +20 -107
  221. package/scripts/bongos/start.js +2 -2
  222. package/scripts/bongos/task.js +5 -4
  223. package/scripts/bongos/triage.js +1 -1
  224. package/scripts/bongos/worktree.js +16 -4
  225. package/scripts/hall-preview/README.md +2 -3
  226. package/scripts/setup-builder.js +6 -46
  227. package/src/bongos/auth-admission.js +8 -8
  228. package/src/bongos/craft-mode.js +2 -0
  229. package/src/bongos/db.js +39 -1
  230. package/src/bongos/github-repos.js +78 -0
  231. package/src/bongos/module-scope-map.js +22 -0
  232. package/src/bongos/role-stats.js +27 -11
  233. package/src/bongos/routes/github.js +100 -2
  234. package/src/bongos/routes/me.js +33 -0
  235. package/src/bongos/serve-internal.js +33 -11
  236. package/src/crafts.js +66 -0
  237. package/src/hall-helm.js +79 -0
  238. package/src/module-api.js +6 -1
  239. package/src/module-loader/manifest-schema.js +16 -0
  240. package/src/modules.js +2 -2
  241. package/src/role-responsibilities.js +6 -5
  242. package/tests/acclaim_feed_evidence_db.mjs +1 -1
  243. package/tests/activity_rollup_as_of.mjs +1 -1
  244. package/tests/api_alias_caller_ratchet.mjs +3 -3
  245. package/tests/application_lifecycle.mjs +1 -1
  246. package/tests/artist_review.mjs +1 -1
  247. package/tests/artist_review_goal.mjs +1 -1
  248. package/tests/blocker_hall_live_proof.mjs +1 -1
  249. package/tests/blocker_tasks_named_db.mjs +2 -2
  250. package/tests/canonical_profile_url.mjs +1 -1
  251. package/tests/cap_newcomer_tasks.mjs +13 -13
  252. package/tests/character_creation.mjs +294 -0
  253. package/tests/claim_gate_open_blocker.mjs +2 -2
  254. package/tests/claim_gate_rebase.mjs +6 -6
  255. package/tests/claude_materialize.mjs +1 -1
  256. package/tests/clawback_confirmed_backlog.mjs +3 -3
  257. package/tests/collab_page.mjs +8 -9
  258. package/tests/collab_self_settling.mjs +2 -2
  259. package/tests/community_leaderboard_route.mjs +13 -5
  260. package/tests/community_proof_db.mjs +3 -0
  261. package/tests/community_search_route.mjs +7 -6
  262. package/tests/conductor.mjs +1 -1
  263. package/tests/conductor_main_e2e.mjs +8 -8
  264. package/tests/copy_desk_flags_db.mjs +1 -1
  265. package/tests/copy_desk_page_approve.mjs +2 -2
  266. package/tests/copy_desk_page_claim.mjs +2 -2
  267. package/tests/copy_desk_page_next.mjs +1 -1
  268. package/tests/copy_desk_page_release.mjs +1 -1
  269. package/tests/copy_desk_page_submit.mjs +2 -2
  270. package/tests/core_258_box_session_claims_db.mjs +1 -1
  271. package/tests/craft_mode.mjs +48 -34
  272. package/tests/craft_mode_home.mjs +20 -20
  273. package/tests/craft_mode_looks.mjs +16 -15
  274. package/tests/craft_mode_nav.mjs +31 -29
  275. package/tests/craft_review_mode.mjs +46 -41
  276. package/tests/crafts_vocabulary.mjs +70 -0
  277. package/tests/cross_discipline_priority_direction.mjs +5 -5
  278. package/tests/design_reference.mjs +1 -1
  279. package/tests/fitness.mjs +7 -11
  280. package/tests/funded_project_pays_proof_db.mjs +1 -1
  281. package/tests/genesis_home.mjs +21 -1
  282. package/tests/github_chip.mjs +3 -3
  283. package/tests/github_repo_invite.mjs +379 -0
  284. package/tests/goal_criterion_confirm_ui.mjs +1 -1
  285. package/tests/goal_invite_consent.mjs +1 -1
  286. package/tests/goal_map_page.mjs +2 -2
  287. package/tests/goal_sort_staleness.mjs +1 -1
  288. package/tests/goal_spec_task.mjs +1 -1
  289. package/tests/goal_spec_task_db.mjs +3 -3
  290. package/tests/grade_engine.mjs +2 -2
  291. package/tests/grader_no_artifact.mjs +1 -1
  292. package/tests/grader_prompt_fencing.mjs +1 -1
  293. package/tests/grader_prompt_window_bound.mjs +1 -1
  294. package/tests/grader_reader_lens.mjs +2 -2
  295. package/tests/grader_severity_gate.mjs +1 -1
  296. package/tests/grader_unbacked_score.mjs +1 -1
  297. package/tests/hall_approval_queue.mjs +1 -1
  298. package/tests/hall_board_world.mjs +2 -2
  299. package/tests/hall_builders_page_pass.mjs +3 -2
  300. package/tests/hall_city.mjs +16 -30
  301. package/tests/hall_controls.mjs +52 -4
  302. package/tests/hall_fullscreen.mjs +27 -3
  303. package/tests/hall_helm.mjs +187 -0
  304. package/tests/hall_kit_charts.mjs +1 -1
  305. package/tests/hall_landing_boot.mjs +1 -1
  306. package/tests/hall_my_agents.mjs +16 -16
  307. package/tests/hall_nav.mjs +22 -15
  308. package/tests/hall_options_menu.mjs +247 -0
  309. package/tests/hall_oversight_world.mjs +1 -1
  310. package/tests/hall_palette.mjs +8 -9
  311. package/tests/hall_profile_world.mjs +3 -3
  312. package/tests/hall_record_world.mjs +6 -4
  313. package/tests/hall_settings_world.mjs +2 -1
  314. package/tests/hall_sitemap.mjs +6 -5
  315. package/tests/hall_start_screen.mjs +307 -0
  316. package/tests/hall_street.mjs +15 -28
  317. package/tests/hall_street_inside.mjs +17 -9
  318. package/tests/hall_studio_world.mjs +3 -3
  319. package/tests/hall_topbar_320.mjs +4 -2
  320. package/tests/hall_visor.mjs +224 -0
  321. package/tests/help_requests.mjs +47 -15
  322. package/tests/helpers.mjs +4 -4
  323. package/tests/home_city.mjs +6 -4
  324. package/tests/home_city_widget.mjs +33 -14
  325. package/tests/home_gallery_widget.mjs +4 -4
  326. package/tests/home_idea_box.mjs +6 -6
  327. package/tests/hub_community_page.mjs +3 -3
  328. package/tests/hub_craft_rollup.mjs +75 -36
  329. package/tests/hub_recent_ships.mjs +3 -2
  330. package/tests/idea_cascade.mjs +2 -2
  331. package/tests/idea_credit_streams_db.mjs +1 -1
  332. package/tests/idea_detail_page.mjs +1 -1
  333. package/tests/idea_ideate_full.mjs +5 -2
  334. package/tests/idea_spark_hall.mjs +1 -1
  335. package/tests/init.mjs +3 -3
  336. package/tests/instance_manifest.mjs +2 -2
  337. package/tests/kickoff_checklist.mjs +1 -1
  338. package/tests/leaderboard_roles.mjs +34 -25
  339. package/tests/lifecycle_claim_ladder_refs.mjs +55 -0
  340. package/tests/lightbulb.mjs +14 -10
  341. package/tests/main_discipline.mjs +8 -8
  342. package/tests/markdown_line_endings.mjs +1 -1
  343. package/tests/market_watch_pages.mjs +52 -74
  344. package/tests/me_streak_leaderboard_queries.mjs +3 -3
  345. package/tests/migration_allocation.mjs +1 -1
  346. package/tests/mingle.mjs +36 -21
  347. package/tests/module-scope-map.mjs +22 -2
  348. package/tests/module_api.mjs +1 -0
  349. package/tests/module_contributions.mjs +13 -13
  350. package/tests/module_manifest.mjs +29 -0
  351. package/tests/module_skills_hook.mjs +2 -2
  352. package/tests/modules.mjs +14 -7
  353. package/tests/nav_permission_atoms.mjs +2 -1
  354. package/tests/needs_rebase.mjs +1 -1
  355. package/tests/newcomer_restock.mjs +13 -13
  356. package/tests/page_notes_panel.mjs +22 -22
  357. package/tests/page_tweak_no_block.mjs +1 -1
  358. package/tests/paintbrush.mjs +7 -7
  359. package/tests/privacy_matrix.mjs +1 -0
  360. package/tests/profile_activity.mjs +20 -23
  361. package/tests/profile_earnings.mjs +1 -1
  362. package/tests/profile_nudge_prefs.mjs +4 -4
  363. package/tests/profile_role_headline.mjs +73 -46
  364. package/tests/profile_role_stats.mjs +55 -45
  365. package/tests/profile_rollup_consent.mjs +19 -6
  366. package/tests/profile_route.mjs +12 -12
  367. package/tests/profile_ui_cross_project.mjs +4 -4
  368. package/tests/project_invite_suggestions.mjs +2 -2
  369. package/tests/promote_goal_id.mjs +1 -1
  370. package/tests/publish_manifest.mjs +1 -1
  371. package/tests/rank_floor_wiring.mjs +1 -1
  372. package/tests/regrade_actual_minutes.mjs +1 -1
  373. package/tests/removal_proposal.mjs +2 -2
  374. package/tests/reward_gate.mjs +4 -4
  375. package/tests/reward_mode_cost_plus_only.mjs +1 -1
  376. package/tests/role_responsibilities.mjs +18 -12
  377. package/tests/role_stats_db.mjs +17 -16
  378. package/tests/scouting_rollup_fanout.mjs +5 -5
  379. package/tests/seed_tasks.mjs +5 -2
  380. package/tests/session_earnings_mirror_db.mjs +1 -1
  381. package/tests/session_reward_on_land_db.mjs +1 -1
  382. package/tests/session_reward_reconcile_db.mjs +2 -2
  383. package/tests/sessions_kit_pager.mjs +1 -1
  384. package/tests/settings_art_key_absent.mjs +2 -2
  385. package/tests/settings_main_role.mjs +21 -20
  386. package/tests/setup_flow.mjs +3 -3
  387. package/tests/setup_smoke.mjs +20 -0
  388. package/tests/ship_card.mjs +6 -6
  389. package/tests/specialities.mjs +16 -4
  390. package/tests/speciality_db_shape.mjs +6 -6
  391. package/tests/speciality_injection.mjs +17 -17
  392. package/tests/speciality_offer_ui.mjs +5 -5
  393. package/tests/speciality_offered.mjs +6 -5
  394. package/tests/speciality_session_skills.mjs +7 -7
  395. package/tests/speciality_skills.mjs +11 -11
  396. package/tests/speciality_walkthrough_ui.mjs +2 -2
  397. package/tests/sso_checkin_identity_spoof.mjs +2 -1
  398. package/tests/task_classifier_vocab.mjs +10 -0
  399. package/tests/task_cli.mjs +11 -3
  400. package/tests/task_criteria_unlink_db.mjs +2 -2
  401. package/tests/task_discipline_required.mjs +38 -16
  402. package/tests/task_goal_required.mjs +4 -4
  403. package/tests/task_needs_db.mjs +1 -1
  404. package/tests/tweak_replaces_artist_review.mjs +1 -1
  405. package/tests/update_pill.mjs +153 -2
  406. package/tests/upkeep_home.mjs +1 -1
  407. package/tests/v3_c1_options_proof.mjs +145 -0
  408. package/tests/visual_review.mjs +3 -3
  409. package/tests/watch_applications_queue.mjs +8 -5
  410. package/tests/watch_sealed_floor.mjs +3 -1
  411. package/tests/welcome_boot.mjs +12 -9
  412. package/tests/welcome_boot_browser.mjs +13 -13
  413. package/tests/worktree_helper.mjs +16 -1
@@ -1,4 +1,4 @@
1
- // tests/lightbulb.mjs — the Ideating lightbulb (task 1004498): an ideator's
1
+ // tests/lightbulb.mjs — the lightbulb (task 1004498; Ideating's, the Artist's since task 1005141): an ideator's
2
2
  // text-only notes about the page they are on, split into attributed ideas.
3
3
  //
4
4
  // Owner decisions, 2026-10-04: an AI splits the notes (one idea if it cannot);
@@ -50,12 +50,13 @@ test('a submit names its page and says something; it is never longer than the ca
50
50
  assert.equal(N.normalizeIdeatorSubmit({ page_id: 'builders:studio', body_md: 'x'.repeat(N.MAX_IDEATOR_BODY + 1) }).error, 'body_too_long');
51
51
  });
52
52
 
53
- test('the split speaks of an ideator\'s notes, and fences them as data like the palette does', () => {
54
- const p = N.buildSplitPrompt({ pageTitle: 'Studio', pageId: 'builders:studio', bodyMd: 'Hi\n~~~~IDEATOR-NOTES-guess~~~~', voice: 'ideator', nonce: 'n1' });
55
- assert.match(p, /an ideator's notes, written while looking at one page/);
53
+ test('the split speaks of an artist\'s notes (the ideator folded into the artist, task 1005141), and fences them as data like the palette does', () => {
54
+ const p = N.buildSplitPrompt({ pageTitle: 'Studio', pageId: 'builders:studio', bodyMd: 'Hi\n~~~~IDEATOR-NOTES-guess~~~~', voice: 'notes', nonce: 'n1' });
55
+ assert.match(p, /an artist's notes, written while looking at one page/);
56
56
  assert.equal(p.split('~~~~IDEATOR-NOTES-n1~~~~').length, 3);
57
57
  assert.ok(!p.includes('IDEATOR-NOTES-guess'));
58
- assert.doesNotMatch(p, /artist/);
58
+ assert.doesNotMatch(p, /ideator/, 'no ideator left in the words the model reads');
59
+ assert.doesNotMatch(p, /feedback about one page/, 'the notes voice, not the palette\'s');
59
60
  assert.match(N.buildSplitPrompt({ pageTitle: 'S', pageId: 'builders:s', bodyMd: 'x', nonce: 'n' }), /^You turn an artist's feedback about one page of a web app/, 'the palette\'s words are unchanged');
60
61
  });
61
62
 
@@ -108,7 +109,7 @@ api.resolveOptional = (name) => {
108
109
  const modelSaying = (reply, wait = 0) => ({
109
110
  runSubagent: async ({ prompt, opts }) => {
110
111
  assert.equal(opts.tools, '', 'the split runs with zero tools');
111
- assert.match(prompt, /ideator/);
112
+ assert.match(prompt, /an artist's notes/);
112
113
  if (wait) await new Promise((r) => setTimeout(r, wait));
113
114
  return { stdout: reply };
114
115
  },
@@ -142,14 +143,14 @@ function call(method, p, body) {
142
143
  }
143
144
  const NOTES = { page_id: 'builders:studio', page_path: '/studio', page_title: 'Studio', body_md: 'Sort the drafts by page.\n\nAlso show who wrote each one.' };
144
145
 
145
- test('THE WHOLE PATH: an ideator\'s notes come back as ideas under their name, each linking to the page, and nothing is stored', async () => {
146
+ test('THE WHOLE PATH: an artist\'s notes come back as ideas under their name, each linking to the page, and nothing is stored', async () => {
146
147
  viewer = { id: 9, rank: 'thetes', display_name: 'Ana', github_login: 'ana' };
147
148
  captured = []; captureFails = false; captureOn = true;
148
149
  grade = modelSaying('{"ideas":[{"title":"Sort drafts by page","body":"Group them."},{"title":"Show each draft\'s writer","body":"Name them."}]}');
149
150
  const r = await call('POST', '/page-notes/ideas', NOTES);
150
151
  assert.equal(r.status, 201);
151
152
  assert.deepEqual(r.body, { ideas: [{ id: '701', title: 'Sort drafts by page' }, { id: '702', title: 'Show each draft\'s writer' }], split_by: 'ai' });
152
- assert.deepEqual(captured.map((c) => [c.capturedBy, c.filedVia]), [[9, 'hall'], [9, 'hall']], 'attributed to the ideator, filed through the hall');
153
+ assert.deepEqual(captured.map((c) => [c.capturedBy, c.filedVia]), [[9, 'hall'], [9, 'hall']], 'attributed to the artist, filed through the hall');
153
154
  for (const c of captured) assert.ok(c.bodyMd.endsWith("From Ana's ideas on Studio. Page: [Studio](/studio)"), 'each idea links back to the page');
154
155
  });
155
156
 
@@ -372,11 +373,14 @@ test('storage that throws keeps nothing, and the panel still files', async () =>
372
373
  assert.match(p.q('#lb-body').innerHTML, /Filed as 2 ideas/);
373
374
  });
374
375
 
375
- test('craft-mode.js gives the tool its OWN icon, so it is never read as the Ideating pill beside it (owner, 2026-10-04)', () => {
376
+ test('craft-mode.js gives the tool its OWN icon, and makes it the artist\'s since Ideating folded in (owner, 2026-10-04; task 1005141)', () => {
376
377
  const craft = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/craft-mode.js'), 'utf8');
377
378
  assert.match(craft, /\{ id: 'lightbulb', icon: 'note', short: 'Ideas', label: 'Write ideas about this page', script: 'lightbulb\.js', api: 'OTBLightbulb' \}/);
378
379
  assert.match(craft, /^ {4}note: '<path /m, 'the note glyph is drawn');
379
- assert.doesNotMatch(/ideating: \[([\s\S]*?)\]/.exec(/var TOOLS = \{([\s\S]*?)\n {2}\};/.exec(craft)[1])[1], /icon: 'ideating'/);
380
+ const tools = /var TOOLS = \{([\s\S]*?)\n {2}\};/.exec(craft)[1];
381
+ assert.doesNotMatch(tools, /ideating:/, 'no Ideating tool list: the mode is gone');
382
+ assert.match(/artist: \[([\s\S]*?)\n {4}\]/.exec(tools)[1], /id: 'lightbulb'/, 'the lightbulb is one of the artist\'s tools');
383
+ assert.doesNotMatch(tools, /icon: 'ideating'/, 'never the old Ideating pill glyph');
380
384
  });
381
385
 
382
386
  test('the only route it calls is the lightbulb\'s own, and it rides the palette\'s styles', () => {
@@ -38,33 +38,33 @@ const offered = enabledDisciplines();
38
38
  const last = () => calls[calls.length - 1];
39
39
 
40
40
  await t('the write goes through the one exported statement', async () => {
41
- await db.updateBuilderDisciplines(7, ['engineer']);
41
+ await db.updateBuilderDisciplines(7, ['artisan']);
42
42
  assert.equal(last().sql, db.UPDATE_BUILDER_DISCIPLINES_SQL);
43
43
  assert.match(db.UPDATE_BUILDER_DISCIPLINES_SQL, /main_discipline = CASE WHEN \$3::boolean THEN \$4::text/);
44
44
  assert.match(db.UPDATE_BUILDER_DISCIPLINES_SQL, /WHEN main_discipline = ANY\(\$2::text\[\]\) THEN main_discipline\s+ELSE NULL END/);
45
45
  });
46
46
 
47
47
  await t('not mentioned: the flag is false, so the stored main role is left to the statement to keep or clear', async () => {
48
- await db.updateBuilderDisciplines(7, ['engineer', 'ideator']);
48
+ await db.updateBuilderDisciplines(7, ['artisan', 'artist']);
49
49
  assert.deepEqual(last().params.slice(2), [false, null]);
50
50
  });
51
51
 
52
52
  await t('named: the flag is true and the role travels', async () => {
53
- await db.updateBuilderDisciplines(7, ['ideator', 'engineer'], { mainDiscipline: 'ideator' });
54
- assert.deepEqual(last().params.slice(2), [true, 'ideator']);
55
- assert.deepEqual(last().params[1], offered.filter((d) => ['ideator', 'engineer'].includes(d)),
53
+ await db.updateBuilderDisciplines(7, ['artist', 'artisan'], { mainDiscipline: 'artist' });
54
+ assert.deepEqual(last().params.slice(2), [true, 'artist']);
55
+ assert.deepEqual(last().params[1], offered.filter((d) => ['artist', 'artisan'].includes(d)),
56
56
  'the set is still stored in canonical order: the main role is what carries the choice');
57
57
  });
58
58
 
59
59
  await t('null clears it on purpose', async () => {
60
- await db.updateBuilderDisciplines(7, ['engineer'], { mainDiscipline: null });
60
+ await db.updateBuilderDisciplines(7, ['artisan'], { mainDiscipline: null });
61
61
  assert.deepEqual(last().params.slice(2), [true, null]);
62
62
  });
63
63
 
64
64
  await t('a main role that is not one of the chosen crafts is refused, and nothing is written', async () => {
65
65
  const before = calls.length;
66
- await assert.rejects(() => db.updateBuilderDisciplines(7, ['engineer'], { mainDiscipline: 'ideator' }),
67
- (err) => err.code === 'invalid_main_discipline' && err.bad[0] === 'ideator');
66
+ await assert.rejects(() => db.updateBuilderDisciplines(7, ['artisan'], { mainDiscipline: 'artist' }),
67
+ (err) => err.code === 'invalid_main_discipline' && err.bad[0] === 'artist');
68
68
  assert.equal(calls.length, before);
69
69
  });
70
70
 
@@ -41,7 +41,7 @@ test('every tracked .md resolves to eol=lf, at any depth', () => {
41
41
  'README.md',
42
42
  'CLAUDE.md',
43
43
  'docs/architecture.md',
44
- 'docs/packs/engineer.md',
44
+ 'docs/packs/artisan.md',
45
45
  'docs/adr/README.md',
46
46
  '.claude/skills/merge-mode/SKILL.md',
47
47
  'modules/hall-ui/CLAUDE.md',
@@ -442,28 +442,29 @@ test('MARKET WATCH OFF: both pages show the quiet off line, not an error, and as
442
442
  }
443
443
  });
444
444
 
445
- // ---- the owner's Settings tab (task 1005045) ---------------------------------------
445
+ // ---- the owner's settings, an options menu entry (task 1005144; before it task 1005045) ----
446
446
  //
447
- // Owner decision 2026-11-03 (idea 1001310): one sidebar item. The settings that had their
448
- // own page at /market-watch-settings are a Settings tab on this page, shown to the owner
449
- // (archon, cosmetic — the four settings routes keep their own wall, ADR 0016) and to
450
- // nobody else. These boot the real market-watch.js with the kit and the settings script
451
- // stood in, so what is asserted is which tabs the page builds for which viewer.
452
-
453
- function bootDesk({ rank, homeStatus = 200, modules = { enabled: { 'market-watch': true } }, meStatus = 200, shared } = {}) {
447
+ // BONGOS-V3 R04 (ADR 0401): every setting is reached from the ONE options menu. The
448
+ // settings that had their own page (task 1004938) and then an owner-only Settings tab here
449
+ // (task 1005045) are the menu's "Market Watch settings" entry: the menu opens this page in
450
+ // its frame (?embed=1, which shell-game.js reads), and the page then shows the settings
451
+ // panel ALONE. A plain visit is the watch, with no tab strip and no settings read, and an
452
+ // old #settings link goes on to the menu. These boot the real market-watch.js with the
453
+ // settings script stood in. The four settings routes stay the wall (ADR 0016).
454
+
455
+ function bootDesk({ rank = 'archon', homeStatus = 200, modules = { enabled: { 'market-watch': true } }, embed = false, hash = '' } = {}) {
454
456
  const els = {};
455
457
  const el = (id) => (els[id] = els[id] || { id, hidden: true, innerHTML: '', textContent: '', style: {}, value: '', dataset: {}, title: '', addEventListener() {}, querySelectorAll: () => [], setAttribute() {} });
456
458
  const listeners = {};
457
459
  const fetches = [];
458
- const tabsMade = [];
459
460
  const settingsLoads = [];
461
+ const replaced = [];
460
462
  const window = {
461
463
  __MODULES__: modules,
462
464
  OTBMarketWatch: MW,
463
- OTBKit: { makeTabs: (opts) => { tabsMade.push(opts); return { activate() {}, active: () => opts.initial || opts.tabs[0].id }; } },
464
465
  OTBMarketWatchSettings: { load: () => { settingsLoads.push(1); } },
465
- __hallReads: shared === undefined ? undefined : { '/me': shared },
466
- location: { pathname: '/market-watch', search: '', hash: '', assign() {} },
466
+ OTBShellGame: { embedded: () => embed, menuPath: () => '/settings' },
467
+ location: { pathname: '/market-watch', search: embed ? '?embed=1' : '', hash, assign() {}, replace(u) { replaced.push(u); } },
467
468
  localStorage: { getItem: () => null, setItem() {} },
468
469
  };
469
470
  const document = {
@@ -477,61 +478,61 @@ function bootDesk({ rank, homeStatus = 200, modules = { enabled: { 'market-watch
477
478
  window, document, URLSearchParams, console,
478
479
  fetch: async (url) => {
479
480
  fetches.push(url);
480
- if (/\/me$/.test(url)) return answer(meStatus, { builder: { id: '1', rank } });
481
+ if (/\/me$/.test(url)) return answer(200, { builder: { id: '1', rank } });
481
482
  if (/market-watch\/home$/.test(url)) return answer(homeStatus, homeStatus === 200 ? home() : { error: { code: 'not_found' } });
482
483
  return answer(404, {});
483
484
  },
484
485
  });
485
486
  vm.runInContext(readFileSync(new URL('../modules/hall-ui/public/market-watch.js', import.meta.url), 'utf8'), ctx);
486
- return { els: new Proxy(els, { get: (_, id) => el(id) }), fetches, tabsMade, settingsLoads, ready: () => listeners.DOMContentLoaded() };
487
+ return { els: new Proxy(els, { get: (_, id) => el(id) }), fetches, settingsLoads, replaced, ready: () => listeners.DOMContentLoaded() };
487
488
  }
488
489
 
489
- test('SETTINGS TAB: the owner gets a Watch tab and a Settings tab, linked by #settings, and the settings load', async () => {
490
- const p = bootDesk({ rank: 'archon' });
490
+ test('SETTINGS IN THE MENU: inside the options menu frame the page is the settings panel alone', async () => {
491
+ const p = bootDesk({ embed: true });
491
492
  await p.ready();
492
- assert.equal(p.els['mw-desk'].hidden, false, 'the desk still draws');
493
- assert.equal(p.tabsMade.length, 1, 'one tab strip');
494
- const t = JSON.parse(JSON.stringify(p.tabsMade[0]));
495
- assert.deepEqual(t.tabs.map((x) => [x.id, x.label, x.panel]), [['watch', 'Watch', 'mw-panel-watch'], ['settings', 'Settings', 'mw-panel-settings']]);
496
- assert.equal(t.hash, true, '#settings is the address the old URL redirects to');
497
- assert.equal(t.initial, 'watch', 'the owner lands on the watch, like everyone else');
498
- assert.equal(p.els['mw-tabs'].hidden, false, 'the strip shows');
493
+ assert.equal(p.els['mw-panel-settings'].hidden, false, 'the settings panel shows');
494
+ assert.equal(p.els['mw-panel-watch'].hidden, true, 'the watch does not');
499
495
  assert.equal(p.settingsLoads.length, 1, 'the settings read is made once');
496
+ assert.deepEqual(p.fetches, [], 'no watch read and no /me: the settings routes answer who may see them');
497
+ // even when the watch would fail to read, the switch and key stay reachable
498
+ const failing = bootDesk({ embed: true, homeStatus: 500 });
499
+ await failing.ready();
500
+ assert.equal(failing.settingsLoads.length, 1);
500
501
  });
501
502
 
502
- test('SETTINGS TAB: a non-owner sees no tab strip and no settings, and the settings are never read', async () => {
503
- for (const rank of ['metic', 'thetes', 'xenos', null]) {
503
+ test('SETTINGS IN THE MENU: a plain visit is the watch, with no tab strip and no settings read, for every rank', async () => {
504
+ for (const rank of ['archon', 'metic', 'thetes', 'xenos', null]) {
504
505
  const p = bootDesk({ rank });
505
506
  await p.ready();
506
507
  assert.equal(p.els['mw-desk'].hidden, false, `${rank}: the desk draws`);
507
- assert.equal(p.tabsMade.length, 0, `${rank}: no tab strip`);
508
- assert.equal(p.els['mw-tabs'].hidden, true, `${rank}: the strip stays hidden`);
509
508
  assert.equal(p.els['mw-panel-settings'].hidden, true, `${rank}: the settings panel stays hidden`);
510
- assert.equal(p.settingsLoads.length, 0, `${rank}: the owner-only read is not made`);
509
+ assert.equal(p.settingsLoads.length, 0, `${rank}: the settings are not read`);
510
+ assert.ok(!p.fetches.some((u) => /\/me$/.test(u)), `${rank}: and nobody is asked who they are`);
511
511
  }
512
- // An unreadable /me is not an owner.
513
- const blind = bootDesk({ rank: 'archon', meStatus: 500 });
514
- await blind.ready();
515
- assert.equal(blind.tabsMade.length, 0, 'an unreadable identity builds no settings tab');
516
512
  });
517
513
 
518
- test('SETTINGS TAB: where Market Watch is off there is nothing to set, so no tab (and still nothing asked)', async () => {
519
- const off = bootDesk({ rank: 'archon', modules: { enabled: { 'market-watch': false } } });
514
+ test('SETTINGS IN THE MENU: an old #settings link opens the menu entry instead, and reads nothing', async () => {
515
+ const p = bootDesk({ hash: '#settings' });
516
+ await p.ready();
517
+ assert.deepEqual(p.replaced, ['/settings?open=market-watch'], 'a replace, so Back does not bounce through the watch');
518
+ assert.deepEqual(p.fetches, []);
519
+ assert.equal(p.settingsLoads.length, 0);
520
+ });
521
+
522
+ test('SETTINGS IN THE MENU: where Market Watch is off there is nothing to set (and nothing asked)', async () => {
523
+ const off = bootDesk({ embed: true, modules: { enabled: { 'market-watch': false } } });
520
524
  await off.ready();
521
- assert.equal(off.tabsMade.length, 0);
522
- assert.equal(off.fetches.length, 0, 'an off module is not asked, and neither is /me');
523
- const unmounted = bootDesk({ rank: 'archon', modules: undefined, homeStatus: 404 });
524
- await unmounted.ready();
525
- assert.equal(unmounted.els['mw-off'].hidden, false);
526
- assert.equal(unmounted.tabsMade.length, 0, 'unmounted routes have no settings either');
525
+ assert.equal(off.els['mw-off'].hidden, false, 'the off state shows in the frame');
526
+ assert.equal(off.settingsLoads.length, 0);
527
+ assert.equal(off.fetches.length, 0);
527
528
  });
528
529
 
529
- test('SETTINGS TAB: the page carries every setting the old page did, in a hidden owner panel, and the old page is gone', () => {
530
+ test('SETTINGS PANEL: the page carries every setting the old page did, in a hidden panel, and the old page is gone', () => {
530
531
  const pub = (f) => new URL(`../modules/hall-ui/public/${f}`, import.meta.url);
531
532
  const html = readFileSync(pub('market-watch.html'), 'utf8');
532
533
  const panel = html.match(/<section[^>]*id="mw-panel-settings"[^>]*>[\s\S]*?<\/section>\s*<!-- \/mw-panel-settings -->/);
533
534
  assert.ok(panel, 'one settings panel, closed by its marker comment');
534
- assert.match(panel[0].split('>')[0], /\bhidden\b/, 'the panel starts hidden: nobody sees it until the owner check passes');
535
+ assert.match(panel[0].split('>')[0], /\bhidden\b/, 'the panel starts hidden: only the options menu frame shows it');
535
536
  for (const id of ['mws-power', 'mws-cap', 'mws-cad', 'mws-weeks', 'mws-key-in', 'mws-key-save', 'mws-key-clear', 'mws-sealed', 'mws-off', 'mws-error']) {
536
537
  assert.match(panel[0], new RegExp(`id="${id}"`), `${id} is on the tab`);
537
538
  }
@@ -539,12 +540,12 @@ test('SETTINGS TAB: the page carries every setting the old page did, in a hidden
539
540
  // While GET /market-watch/settings is in flight every section below is [hidden], so the
540
541
  // panel opens on the self-hiding page loader (records/loader.md) rather than a blank tab.
541
542
  const inner = panel[0].slice(panel[0].indexOf('>') + 1).replace(/^\s*(<!--[\s\S]*?-->\s*)*/, '');
542
- assert.match(inner, /^<p class="hall-loading hall-loading--page hall-loading--wait" role="status">Loading…<\/p>/, 'the settings tab opens on the page loader');
543
+ assert.match(inner, /^<p class="hall-loading hall-loading--page hall-loading--wait" role="status">Loading…<\/p>/, 'the settings panel opens on the page loader');
543
544
  const after = inner.slice(inner.indexOf('</p>') + 4);
544
545
  const kids = [...after.matchAll(/^ {10}<(section|div|p)\b([^>]*)>/gm)];
545
546
  assert.ok(kids.length >= 4, `the loader's siblings were read (${kids.length})`);
546
547
  for (const [, tag, attrs] of kids) assert.match(attrs, /\bhidden\b/, `a <${tag}> after the loader starts hidden, or the loader would never show`);
547
- assert.match(html, /<div[^>]*id="mw-tabs"[^>]*hidden/, 'the tab strip starts hidden');
548
+ assert.doesNotMatch(html, /id="mw-tabs"/, 'no tab strip: the settings are an options menu entry (task 1005144)');
548
549
  assert.match(html, /<div[^>]*id="mw-panel-watch"/, 'the watch is its own panel');
549
550
  assert.match(html, /market-watch-settings\.css/);
550
551
  assert.match(html, /<script src="\/builders\/market-watch-settings\.js"><\/script>\s*<script src="\/builders\/market-watch\.js"><\/script>/,
@@ -558,48 +559,25 @@ test('SETTINGS TAB: the page carries every setting the old page did, in a hidden
558
559
  assert.match(js, /window\.OTBMarketWatchSettings = \{ load \}/);
559
560
  });
560
561
 
561
- test('SETTINGS TAB: the old settings URL redirects to the tab, and no gate dispatches to it', () => {
562
+ test('SETTINGS IN THE MENU: the old settings URL opens the menu entry, even framed, and no gate dispatches to it', () => {
562
563
  const serve = readFileSync(new URL('../src/bongos/serve-internal.js', import.meta.url), 'utf8');
563
- const decl = 'const RETIRED_MW_SETTINGS_RE = ';
564
+ const decl = 'const RETIRED_OPTIONS_RE = ';
564
565
  const i = serve.indexOf(decl);
565
566
  assert.ok(i > 0, 'the old URL keeps a matcher, so a bookmark still lands');
566
- const block = serve.slice(i, i + 700);
567
- assert.ok(block.includes('res.redirect(302,'), 'a temporary redirect, never cached past a re-point');
568
- assert.ok(block.includes('/market-watch${qs}#settings'), 'landing on the Settings tab, query string kept');
569
567
  const rx = eval(serve.slice(i + decl.length, serve.indexOf(';', i))); // eslint-disable-line no-eval
570
568
  for (const p of ['/builders/market-watch-settings', '/builders/market-watch-settings/', '/builders/market-watch-settings.html']) {
571
569
  assert.ok(rx.test(p), `${p} reaches the redirect`);
572
570
  }
573
- for (const p of ['/builders/market-watch', '/builders/market-watch-rules', '/api/bongos/market-watch/settings', '/builders/market-watch-settingsx']) {
571
+ for (const p of ['/builders/market-watch', '/builders/market-watch-item', '/api/bongos/market-watch/settings', '/builders/market-watch-settingsx']) {
574
572
  assert.ok(!rx.test(p), `${p} is not redirected`);
575
573
  }
574
+ const block = serve.slice(i, i + 900);
575
+ assert.ok(block.includes("embed === '1' && m[1] !== 'market-watch-settings'"), 'the retired page has no page left, so even a framed request is sent to the menu');
576
+ assert.ok(block.includes('res.redirect(302,'), 'a temporary redirect, never cached past a re-point');
577
+ assert.match(serve, /'market-watch-settings': 'market-watch'/, 'it opens the Market Watch settings entry');
576
578
  const mwRe = serve.match(/const MARKET_WATCH_PAGE_RE = (\/[^\n]*?\/);/);
577
579
  assert.ok(mwRe, 'the Market Watch page gate is still a literal');
578
580
  const gate = eval(mwRe[1]); // eslint-disable-line no-eval
579
581
  assert.ok(!gate.test('/builders/market-watch-settings'), 'the page gate no longer names the retired page');
580
582
  for (const p of ['/builders/market-watch', '/builders/market-watch-item', '/builders/market-watch-rules']) assert.ok(gate.test(p), p);
581
583
  });
582
-
583
- test('SETTINGS TAB: the page adopts the shell\'s one /me read instead of asking again', async () => {
584
- const owner = bootDesk({ rank: 'archon', shared: Promise.resolve({ builder: { id: '1', rank: 'archon' } }) });
585
- await owner.ready();
586
- assert.equal(owner.tabsMade.length, 1, 'the shared answer is enough to know the owner');
587
- assert.ok(!owner.fetches.some((u) => /\/me$/.test(u)), 'no second /me request');
588
- for (const answer of [{ builder: { id: '2', rank: 'metic' } }, false, null]) {
589
- const p = bootDesk({ rank: 'archon', shared: Promise.resolve(answer) });
590
- await p.ready();
591
- assert.equal(p.tabsMade.length, 0, `${JSON.stringify(answer)}: no tab`);
592
- assert.ok(!p.fetches.some((u) => /\/me$/.test(u)), 'and no fallback request either');
593
- }
594
- });
595
-
596
- test('SETTINGS TAB: a watch that fails to read keeps the owner\'s tab, so the switch and key stay reachable', async () => {
597
- const p = bootDesk({ rank: 'archon', homeStatus: 500 });
598
- await p.ready();
599
- assert.equal(p.els['mw-fail'].hidden, false, 'the failure line shows on the Watch tab');
600
- assert.equal(p.tabsMade.length, 1, 'the settings are their own read, so the owner still gets them');
601
- assert.equal(p.settingsLoads.length, 1);
602
- const member = bootDesk({ rank: 'metic', homeStatus: 500 });
603
- await member.ready();
604
- assert.equal(member.tabsMade.length, 0, 'and a non-owner still gets none');
605
- });
@@ -64,7 +64,7 @@ const TASKS = {
64
64
  104: { kind: 'refactor', est_minutes: 90 },
65
65
  105: { kind: 'bug', est_minutes: 20 },
66
66
  };
67
- const claimsFor = (ids) => ids.map((id) => ({ task_id: String(id), discipline: 'engineer' }));
67
+ const claimsFor = (ids) => ids.map((id) => ({ task_id: String(id), discipline: 'artisan' }));
68
68
 
69
69
  await test('/me sends the same number of streak queries for 1 claim as for 5', async () => {
70
70
  const counts = [];
@@ -117,7 +117,7 @@ await test('each streak_preview is exactly what wouldExtendStreak returns, in ev
117
117
  await test('the claim keeps its own fields and gains craft_mode beside the preview', async () => {
118
118
  const db = fakeDb({ tasks: TASKS });
119
119
  const cur = await streak.computeStreak(1, db);
120
- const [c] = await claimsWithStreakPreview(rewardOn(db), 1, [{ task_id: '101', discipline: 'engineer', id: 7 }], cur, null);
120
+ const [c] = await claimsWithStreakPreview(rewardOn(db), 1, [{ task_id: '101', discipline: 'artisan', id: 7 }], cur, null);
121
121
  assert.equal(c.id, 7);
122
122
  assert.equal(c.task_id, '101');
123
123
  assert.ok('craft_mode' in c);
@@ -149,7 +149,7 @@ await test('two leaderboard reads within the window hit the database once', asyn
149
149
  await test('a caller editing its rows does not edit the cached answer', async () => {
150
150
  achievements.clearLeaderboardCache();
151
151
  const a = await achievements.getLeaderboardWithAchievements(100, 5);
152
- a[0].roles = ['engineer'];
152
+ a[0].roles = ['artisan'];
153
153
  const b = await achievements.getLeaderboardWithAchievements(100, 5);
154
154
  assert.equal(b[0].roles, undefined);
155
155
  });
@@ -266,7 +266,7 @@ function shouldReserveMigrationNumber(task) {
266
266
  const db = require('../modules/lifecycle/db.js');
267
267
  const setters = [
268
268
  [() => db.updateTaskKind(1, 'bug'), 'kind'],
269
- [() => db.updateTaskDiscipline(1, 'engineer'), 'discipline'],
269
+ [() => db.updateTaskDiscipline(1, 'artisan'), 'discipline'],
270
270
  [() => db.updateTaskNewcomerFriendly(1, true), 'newcomer_friendly'],
271
271
  [() => db.updateTaskParallelSafe(1, true), 'parallel_safe'],
272
272
  [() => db.updateTaskAutomationTag(1, 'live-only'), 'automation_tag'],
package/tests/mingle.mjs CHANGED
@@ -76,13 +76,28 @@ const off = () => async () => null;
76
76
  // ── 1. A PAIR IS ONLY EVER CROSS-CRAFT ──────────────────────────────────────
77
77
 
78
78
  test('THE RULE: crafts must be DISJOINT, not merely different', () => {
79
- assert.equal(mingle.isCrossCraft(['builder'], ['artist']), true);
80
- assert.equal(mingle.isCrossCraft(['builder'], ['builder']), false);
79
+ assert.equal(mingle.isCrossCraft(['artisan'], ['artist']), true);
80
+ assert.equal(mingle.isCrossCraft(['artisan'], ['artisan']), false);
81
81
  // The case a "different primary craft" rule would get wrong: someone who is
82
- // both shares a craft with a pure engineer, so pairing them puts two of the
82
+ // both shares a craft with a pure artisan, so pairing them puts two of the
83
83
  // same in a room and calls it cross-craft.
84
- assert.equal(mingle.isCrossCraft(['artist', 'builder'], ['builder']), false);
85
- assert.equal(mingle.isCrossCraft(['artist', 'builder'], ['ideator']), true);
84
+ assert.equal(mingle.isCrossCraft(['artist', 'artisan'], ['artisan']), false);
85
+ assert.equal(mingle.isCrossCraft(['artist', 'artisan'], ['artist']), false);
86
+ assert.equal(mingle.isCrossCraft(['artist', 'artisan'], ['ui']), true);
87
+ });
88
+
89
+ test('since task 1005141 an artist and an (old) ideator are the SAME craft, so never a pair', () => {
90
+ // The fold: a builder still carrying 'ideator' in preferred_disciplines maps to
91
+ // the artist craft through help-requests' map, so artist × ideator is no longer
92
+ // cross-craft.
93
+ const help = require(path.join(ROOT, 'modules', 'lifecycle', 'help-requests.js'));
94
+ const ideator = [help.craftForDiscipline('ideator')];
95
+ const artist = [help.craftForDiscipline('artist')];
96
+ assert.deepEqual(ideator, ['artist']);
97
+ assert.equal(mingle.isCrossCraft(artist, ideator), false);
98
+ const engineer = [help.craftForDiscipline('engineer')];
99
+ assert.deepEqual(engineer, ['artisan'], 'a stored engineer discipline is the artisan craft');
100
+ assert.equal(mingle.isCrossCraft(engineer, artist), true);
86
101
  });
87
102
 
88
103
  test('a builder with NO craft is never pairable', () => {
@@ -99,11 +114,11 @@ test('a builder with NO craft is never pairable', () => {
99
114
 
100
115
  test('EVERY pair the planner emits is cross-craft', () => {
101
116
  const eligible = [
102
- { builder_id: '10', crafts: ['builder'] },
103
- { builder_id: '20', crafts: ['builder'] },
117
+ { builder_id: '10', crafts: ['artisan'] },
118
+ { builder_id: '20', crafts: ['artisan'] },
104
119
  { builder_id: '30', crafts: ['artist'] },
105
- { builder_id: '40', crafts: ['ideator'] },
106
- { builder_id: '50', crafts: ['artist', 'builder'] },
120
+ { builder_id: '40', crafts: ['artist'] },
121
+ { builder_id: '50', crafts: ['artist', 'artisan'] },
107
122
  ];
108
123
  const byId = new Map(eligible.map((b) => [b.builder_id, b.crafts]));
109
124
  const pairs = mingle.planPairs(eligible);
@@ -117,9 +132,9 @@ test('EVERY pair the planner emits is cross-craft', () => {
117
132
 
118
133
  test('nobody is put in two pairs in one round', () => {
119
134
  const pairs = mingle.planPairs([
120
- { builder_id: '1', crafts: ['builder'] },
135
+ { builder_id: '1', crafts: ['artisan'] },
121
136
  { builder_id: '2', crafts: ['artist'] },
122
- { builder_id: '3', crafts: ['ideator'] },
137
+ { builder_id: '3', crafts: ['artist'] },
123
138
  { builder_id: '4', crafts: ['ui'] },
124
139
  ]);
125
140
  const seen = new Set();
@@ -154,7 +169,7 @@ test('the matching rule is RECORDED, and the route serves it verbatim', () => {
154
169
 
155
170
  test('SWITCH 1 OFF: the project disabled means no pairing, and no write at all', async () => {
156
171
  const pool = recordingPool({ eligible: [
157
- { builder_id: '1', crafts: ['builder'] },
172
+ { builder_id: '1', crafts: ['artisan'] },
158
173
  { builder_id: '2', crafts: ['artist'] },
159
174
  ] });
160
175
  const out = await mingle.runMingleRound({}, { pool, getSetting: off() });
@@ -174,7 +189,7 @@ test('SWITCH 2 OFF: eligibility is opted-in only, enforced in SQL', async () =>
174
189
 
175
190
  test('BOTH ON is the only state that pairs', async () => {
176
191
  const pool = recordingPool({ eligible: [
177
- { builder_id: '1', crafts: ['builder'] },
192
+ { builder_id: '1', crafts: ['artisan'] },
178
193
  { builder_id: '2', crafts: ['artist'] },
179
194
  ] });
180
195
  const out = await mingle.runMingleRound({}, { pool, getSetting: on() });
@@ -185,9 +200,9 @@ test('BOTH ON is the only state that pairs', async () => {
185
200
 
186
201
  test('a round is ONE insert, so a partial round cannot be written', () => {
187
202
  const pool = recordingPool({ eligible: [
188
- { builder_id: '1', crafts: ['builder'] },
203
+ { builder_id: '1', crafts: ['artisan'] },
189
204
  { builder_id: '2', crafts: ['artist'] },
190
- { builder_id: '3', crafts: ['ideator'] },
205
+ { builder_id: '3', crafts: ['artist'] },
191
206
  { builder_id: '4', crafts: ['ui'] },
192
207
  ] });
193
208
  return mingle.runMingleRound({}, { pool, getSetting: on() }).then((out) => {
@@ -202,8 +217,8 @@ test('an empty plan writes nothing at all', async () => {
202
217
  // Two builders of the SAME craft are not a pair, so there is nothing to insert
203
218
  // — and no empty INSERT should be issued to discover that.
204
219
  const pool = recordingPool({ eligible: [
205
- { builder_id: '1', crafts: ['builder'] },
206
- { builder_id: '2', crafts: ['builder'] },
220
+ { builder_id: '1', crafts: ['artisan'] },
221
+ { builder_id: '2', crafts: ['artisan'] },
207
222
  ] });
208
223
  const out = await mingle.runMingleRound({}, { pool, getSetting: on() });
209
224
  assert.deepEqual(out.paired, []);
@@ -243,7 +258,7 @@ test('the two switches are reported SEPARATELY, so a builder can see which is of
243
258
  const PENALTY_SURFACES = /credit|karma|rank|streak|achievement|reward|score|penalt|cooldown|strike/i;
244
259
 
245
260
  test('THE NO-PENALTY PROPERTY: a decline writes ONE row in ONE table', async () => {
246
- const pool = recordingPool({ pairRow: { id: 5, builder_lo: '1', builder_hi: '2', craft_lo: 'builder', craft_hi: 'artist', status: 'declined', decided_by: '1', decided_at: 'now' } });
261
+ const pool = recordingPool({ pairRow: { id: 5, builder_lo: '1', builder_hi: '2', craft_lo: 'artisan', craft_hi: 'artist', status: 'declined', decided_by: '1', decided_at: 'now' } });
247
262
  const out = await mingle.decidePair(5, 1, 'declined', { pool });
248
263
  assert.equal(out.pair.status, 'declined');
249
264
 
@@ -274,7 +289,7 @@ test('a declined pair may be introduced again — a decline is not a permanent s
274
289
  // is free.
275
290
  assert.match(MIGRATION, /CREATE UNIQUE INDEX[\s\S]{0,200}lifecycle_mingle_pairs_open_uniq[\s\S]{0,120}WHERE status = 'proposed'/);
276
291
  const eligible = [
277
- { builder_id: '1', crafts: ['builder'] },
292
+ { builder_id: '1', crafts: ['artisan'] },
278
293
  { builder_id: '2', crafts: ['artist'] },
279
294
  ];
280
295
  // With the pair already OPEN, it is skipped...
@@ -388,8 +403,8 @@ test('the route is registered in the module manifest', () => {
388
403
  });
389
404
 
390
405
  test('the craft vocabulary is CONSUMED from help-requests, never redefined', () => {
391
- // Two spellings of the same craft ('builder' the craft is 'engineer' the
392
- // discipline) would silently match nobody.
406
+ // Two spellings of the same craft (before task 1005141, 'builder' the craft was
407
+ // 'engineer' the discipline) would silently match nobody.
393
408
  assert.match(SRC, /require\('\.\/help-requests'\)/);
394
409
  assert.ok(!/CRAFT_TO_DISCIPLINE\s*=|DISCIPLINE_TO_CRAFT\s*=/.test(SRC),
395
410
  'mingle must not declare its own craft map');
@@ -407,9 +407,9 @@ test('methodology joined the roster (task 1002989) — the instruction surfaces
407
407
  const covers = (touch) => gsc.scopeSubset(['methodology'], [touch]).ok;
408
408
  for (const touch of [
409
409
  'CLAUDE.md', // the kernel half of the split (1002990)
410
- 'docs/packs/engineer.md', // the three role packs the split authored (1002990)
410
+ 'docs/packs/artisan.md', // the three role packs the split authored (1002990)
411
+ 'docs/packs/artist.md',
411
412
  'docs/packs/artist.md',
412
- 'docs/packs/ideator.md',
413
413
  '.claude/skills/status/SKILL.md', // the skills floor no module claims
414
414
  '.claude/rules/content-charter.md', // pre-carved for task 1003687
415
415
  'docs/adr/0264-the-ten-working-areas.md', // the ADR library
@@ -767,3 +767,23 @@ test('every file under src/bongos/routes/ is protected by the blanket prefix AND
767
767
  );
768
768
  }
769
769
  });
770
+
771
+ // Task 1004793: the module store's code (src/bongos/module-*.js + its scripts/bongos/
772
+ // module-*.js CLIs) was owned by no key, so no goal could author a change to it. Every
773
+ // such file must resolve to a key, and the store files to the 'module-store' key. The
774
+ // directory is read live, so a NEW store file added without an owner fails here.
775
+ test('every module-* store file resolves to a key, the store files to module-store', () => {
776
+ const all = Object.values(scope.MODULE_GLOBS);
777
+ const files = [
778
+ ...fs.readdirSync(path.join(ROOT, 'src', 'bongos')).filter((f) => /^module-.*\.js$/.test(f)).map((f) => 'src/bongos/' + f),
779
+ ...fs.readdirSync(path.join(ROOT, 'scripts', 'bongos')).filter((f) => /^module-.*\.js$/.test(f)).map((f) => 'scripts/bongos/' + f),
780
+ ];
781
+ assert.ok(files.length >= 20, 'expected the store files to still exist, saw ' + files.length);
782
+ const store = scope.MODULE_GLOBS['module-store'];
783
+ for (const f of files) {
784
+ assert.ok(all.some((g) => matchOne(f, g)), f + ' is owned by no key in MODULE_GLOBS');
785
+ if (f === 'src/bongos/module-scope-map.js') assert.ok(matchOne(f, scope.MODULE_GLOBS.kernel), f + ' stays kernel');
786
+ else assert.ok(matchOne(f, store), f + ' should be owned by module-store');
787
+ }
788
+ assert.equal(gsc.scopeSubset(['module-store'], ['src/bongos/module-store.js']).ok, true, 'a module-store goal may touch module-store.js');
789
+ });
@@ -65,6 +65,7 @@ const PUBLISHED_SURFACE = [
65
65
  'buildInfo', 'validateOrRespond', 'LIMITS', 'parseId', 'asyncHandler', 'corsPublicGet', // parseId added 1.8.0 (BV1.R78); asyncHandler + corsPublicGet added 1.12.0 (BV1.R86; lifecycle routes)
66
66
  'parsePagination', 'pageMeta', 'PAGINATION', // R14 (#2001, 1.17.0): shared pagination contract for module list routes (ADR 0119)
67
67
  'responsibilityFor', 'ROLE_RESPONSIBILITIES', // added by task 1003732 (ADR 0275; criterion wa6-written-responsibilities): the ONE source for the sentence each craft is answerable for. It rides the doorway because two modules need the same text — grading judges role-shaped work against it, the hall shows it to the person being judged — and modules never import each other. Declared here deliberately: this list is what makes adding to the doorway a decision rather than a side effect
68
+ 'crafts', // added by task 1005141 (BONGOS-V3 C1): the craft vocabulary (src/crafts.js) — artisan · artist · patron and normalizeCraft() for the old spellings (engineer/ideator/builder). On the doorway because every module input boundary that takes a craft (help requests, task discipline, specialities) must read an old word as today's the same way, and modules never import each other
68
69
  'readPackageRegistry', // added by task 1004296 (ADR 0340 D5): the npm registry reader moved into the core, because Settings → Software update needs it in every hall; the module that publishes a package reads the same answer and cache through here rather than keeping a second copy
69
70
  'logger',
70
71
  ];
@@ -169,26 +169,26 @@ test('craft picker options: OTB.disciplines() mirrors the server list, and falls
169
169
  const OTB = global.window.OTB;
170
170
 
171
171
  // the server's list wins, in the server's order
172
- global.window.__MODULES__ = { enabled: {}, disciplines: ['engineer', 'ideator', 'ui'] };
173
- assert.deepEqual(OTB.disciplines(), ['engineer', 'ideator', 'ui']);
172
+ global.window.__MODULES__ = { enabled: {}, disciplines: ['artisan', 'artist', 'ui'] };
173
+ assert.deepEqual(OTB.disciplines(), ['artisan', 'artist', 'ui']);
174
174
  // a module-contributed craft is offered without any per-craft moduleOn() branch
175
- global.window.__MODULES__ = { enabled: { 'art-pipeline': true }, disciplines: ['engineer', 'ideator', 'artist', 'ui'] };
175
+ global.window.__MODULES__ = { enabled: { 'art-pipeline': true }, disciplines: ['artisan', 'artist', 'artist', 'ui'] };
176
176
  assert.ok(OTB.disciplines().includes('ui') && OTB.disciplines().includes('artist'));
177
177
  // withdrawn with its module
178
- global.window.__MODULES__ = { enabled: {}, disciplines: ['engineer', 'ideator'] };
179
- assert.deepEqual(OTB.disciplines(), ['engineer', 'ideator']);
178
+ global.window.__MODULES__ = { enabled: {}, disciplines: ['artisan', 'artist'] };
179
+ assert.deepEqual(OTB.disciplines(), ['artisan', 'artist']);
180
180
 
181
181
  // an older server that sends no list degrades to the core two, never to an
182
182
  // empty picker (which would strand a builder with no way to pick a craft)
183
183
  for (const m of [undefined, {}, { enabled: {} }, { disciplines: [] }, { disciplines: 'nope' }]) {
184
184
  global.window.__MODULES__ = m;
185
- assert.deepEqual(OTB.disciplines(), ['engineer', 'ideator'], `fallback for ${JSON.stringify(m)}`);
185
+ assert.deepEqual(OTB.disciplines(), ['artisan', 'artist'], `fallback for ${JSON.stringify(m)}`);
186
186
  }
187
187
 
188
188
  // returns a COPY — a picker mutating its options cannot corrupt the global
189
- global.window.__MODULES__ = { disciplines: ['engineer', 'ideator', 'ui'] };
189
+ global.window.__MODULES__ = { disciplines: ['artisan', 'artist', 'ui'] };
190
190
  OTB.disciplines().push('bogus');
191
- assert.deepEqual(OTB.disciplines(), ['engineer', 'ideator', 'ui']);
191
+ assert.deepEqual(OTB.disciplines(), ['artisan', 'artist', 'ui']);
192
192
  });
193
193
 
194
194
  test('craft picker options: roleLabel names an acronym correctly', () => {
@@ -199,9 +199,9 @@ test('craft picker options: roleLabel names an acronym correctly', () => {
199
199
  require(domUtils);
200
200
  const OTB = global.window.OTB;
201
201
  assert.equal(OTB.roleLabel('ui'), 'UI', 'not "Ui"');
202
- assert.equal(OTB.roleLabel('engineer'), 'Engineer');
202
+ assert.equal(OTB.roleLabel('artisan'), 'Artisan');
203
+ assert.equal(OTB.roleLabel('artist'), 'Artist');
203
204
  assert.equal(OTB.roleLabel('artist'), 'Artist');
204
- assert.equal(OTB.roleLabel('ideator'), 'Ideator');
205
205
  // a craft a module adds later needs no new entry here
206
206
  assert.equal(OTB.roleLabel('archivist'), 'Archivist');
207
207
  for (const bad of ['', null, undefined]) assert.equal(OTB.roleLabel(bad), '');
@@ -253,7 +253,7 @@ test('an unset role is never shown to a person as the database default', () => {
253
253
  assert.equal(OTB.ROLE_UNSET, 'unclassified', 'the stored value is unchanged');
254
254
  assert.equal(OTB.roleLabel('unclassified'), 'No role yet');
255
255
  assert.ok(OTB.roleIsUnset('unclassified') && OTB.roleIsUnset(null) && OTB.roleIsUnset(''));
256
- assert.ok(!OTB.roleIsUnset('engineer'));
256
+ assert.ok(!OTB.roleIsUnset('artisan'));
257
257
 
258
258
  const readSrc = (p) => readFileSync(path.join(ROOT, p), 'utf8');
259
259
  // The role-bearing render sites must not interpolate the raw default into markup.
@@ -271,8 +271,8 @@ test('craft picker options: neither picker hardcodes the craft list any more', (
271
271
  for (const p of ['modules/hall-ui/public/settings.js', 'modules/hall-ui/public/setup-gate.js']) {
272
272
  const src = read(p);
273
273
  assert.ok(/OTB\.disciplines\(\)/.test(src), `${p} builds its options from OTB.disciplines()`);
274
- assert.ok(!/\['engineer',\s*'artist',\s*'ideator'\]/.test(src), `${p} no longer hardcodes the craft triple`);
275
- assert.ok(!/discBtn\('engineer'/.test(src), `${p} no longer names a craft literally`);
274
+ assert.ok(!/\['artisan',\s*'artist',\s*'artist'\]/.test(src), `${p} no longer hardcodes the craft triple`);
275
+ assert.ok(!/discBtn\('artisan'/.test(src), `${p} no longer names a craft literally`);
276
276
  }
277
277
  });
278
278