@bongos/core 1.17.0

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 (1142) hide show
  1. package/.bongos-core.json +5727 -0
  2. package/.claude/skills/blocker-review/SKILL.md +91 -0
  3. package/.claude/skills/blocker-solve/SKILL.md +96 -0
  4. package/.claude/skills/builder-backup/SKILL.md +54 -0
  5. package/.claude/skills/builder-box/SKILL.md +45 -0
  6. package/.claude/skills/builder-claim/SKILL.md +76 -0
  7. package/.claude/skills/builder-connect/SKILL.md +59 -0
  8. package/.claude/skills/builder-cost/SKILL.md +47 -0
  9. package/.claude/skills/builder-end/SKILL.md +81 -0
  10. package/.claude/skills/builder-exit/SKILL.md +88 -0
  11. package/.claude/skills/builder-key/SKILL.md +40 -0
  12. package/.claude/skills/builder-reauth/SKILL.md +50 -0
  13. package/.claude/skills/builder-redteam/SKILL.md +109 -0
  14. package/.claude/skills/builder-release/SKILL.md +41 -0
  15. package/.claude/skills/builder-setup/SKILL.md +62 -0
  16. package/.claude/skills/builder-ship/SKILL.md +79 -0
  17. package/.claude/skills/builder-stage/SKILL.md +51 -0
  18. package/.claude/skills/builder-start/SKILL.md +46 -0
  19. package/.claude/skills/close-box/SKILL.md +43 -0
  20. package/.claude/skills/design-sync/SKILL.md +69 -0
  21. package/.claude/skills/dev/SKILL.md +49 -0
  22. package/.claude/skills/feedback/SKILL.md +32 -0
  23. package/.claude/skills/figma-design-sync/SKILL.md +74 -0
  24. package/.claude/skills/goal-review/SKILL.md +89 -0
  25. package/.claude/skills/idea-triage/SKILL.md +144 -0
  26. package/.claude/skills/ideate/SKILL.md +82 -0
  27. package/.claude/skills/merge-mode/SKILL.md +169 -0
  28. package/.claude/skills/new-project/SKILL.md +108 -0
  29. package/.claude/skills/otb-character-review/SKILL.md +71 -0
  30. package/.claude/skills/otb-design-review/SKILL.md +47 -0
  31. package/.claude/skills/otb-feedback-capture/SKILL.md +45 -0
  32. package/.claude/skills/otb-figma-sync/SKILL.md +54 -0
  33. package/.claude/skills/otb-tile-generate/SKILL.md +45 -0
  34. package/.claude/skills/paint/SKILL.md +70 -0
  35. package/.claude/skills/planning-session/SKILL.md +299 -0
  36. package/.claude/skills/priority-session/SKILL.md +134 -0
  37. package/.claude/skills/read-session-export/SKILL.md +58 -0
  38. package/.claude/skills/recall/SKILL.md +52 -0
  39. package/.claude/skills/session-handoff/SKILL.md +46 -0
  40. package/.claude/skills/status/SKILL.md +33 -0
  41. package/.devcontainer/Dockerfile +69 -0
  42. package/.devcontainer/README.md +73 -0
  43. package/.devcontainer/devcontainer-lock.json +19 -0
  44. package/.devcontainer/devcontainer.json +93 -0
  45. package/.devcontainer/healthcheck.sh +84 -0
  46. package/.devcontainer/init-firewall.sh +198 -0
  47. package/.devcontainer/post-create.sh +153 -0
  48. package/.dockerignore +36 -0
  49. package/.env.local.example +32 -0
  50. package/.gitattributes +83 -0
  51. package/.gitignore +91 -0
  52. package/CLAUDE.md +242 -0
  53. package/CODE_OF_CONDUCT.md +46 -0
  54. package/CONTRIBUTING.md +51 -0
  55. package/Dockerfile +46 -0
  56. package/GOVERNANCE.md +44 -0
  57. package/LICENSE +661 -0
  58. package/README.md +88 -0
  59. package/bin/bongos.js +222 -0
  60. package/clients/bongos-client/README.md +48 -0
  61. package/clients/bongos-client/bongos-client.global.js +617 -0
  62. package/clients/bongos-client/examples/hello-world.mjs +62 -0
  63. package/clients/bongos-client/index.cjs +619 -0
  64. package/clients/bongos-client/index.d.ts +793 -0
  65. package/clients/bongos-client/index.mjs +614 -0
  66. package/clients/bongos-client/package.json +32 -0
  67. package/config/branding.neutral.json +60 -0
  68. package/config/design-tokens.neutral.json +51 -0
  69. package/config/design-tokens.schema.json +96 -0
  70. package/config/modules.neutral.json +9 -0
  71. package/docker-compose.yml +52 -0
  72. package/docker-entrypoint.sh +30 -0
  73. package/docs/adr/0001-stack-choice-single-vps.md +45 -0
  74. package/docs/adr/0002-digitalocean-over-hetzner.md +27 -0
  75. package/docs/adr/0003-cloudflare-origin-cert-over-letsencrypt.md +35 -0
  76. package/docs/adr/0004-amazonprimea-name-and-trademark-acceptance.md +45 -0
  77. package/docs/adr/0005-invisible-other-players-in-v1.md +37 -0
  78. package/docs/adr/0006-singleton-worldroom-plus-queueroom.md +30 -0
  79. package/docs/adr/0007-persistent-identity-via-localstorage-uuid.md +31 -0
  80. package/docs/adr/0008-family-based-tile-generation.md +86 -0
  81. package/docs/adr/0008-google-chat-oauth-user-auth.md +37 -0
  82. package/docs/adr/0009-autotile-architecture.md +181 -0
  83. package/docs/adr/0010-pms-architecture.md +70 -0
  84. package/docs/adr/0011-terrain-id-rendering-model.md +174 -0
  85. package/docs/adr/0012-ground-object-cell-split.md +208 -0
  86. package/docs/adr/0013-paths-as-object-overlays-with-autotile-dispatch.md +158 -0
  87. package/docs/adr/0014-families-canonical-over-vocabulary.md +103 -0
  88. package/docs/adr/0015-task-dependencies-and-auto-promotion.md +125 -0
  89. package/docs/adr/0016-trust-boundary-server-enforced-permissions.md +132 -0
  90. package/docs/adr/0017-code-simplification-and-repo-cleanliness.md +74 -0
  91. package/docs/adr/0018-three-rank-model-goes-live.md +97 -0
  92. package/docs/adr/0019-red-team-bounty-payout-table.md +52 -0
  93. package/docs/adr/0020-security-model-public-stub.md +70 -0
  94. package/docs/adr/0021-per-builder-skill-model-preferences.md +99 -0
  95. package/docs/adr/0022-secrets-policy.md +124 -0
  96. package/docs/adr/0023-kind-multipliers-and-idea-bonus.md +118 -0
  97. package/docs/adr/0024-cloneable-repo-local-first-memory.md +277 -0
  98. package/docs/adr/0024-multi-agent-system-architecture.md +125 -0
  99. package/docs/adr/0025-offsite-backup-vendor-digitalocean-spaces.md +59 -0
  100. package/docs/adr/0025-structured-criterion-task-link.md +44 -0
  101. package/docs/adr/0026-bfg-memory-hygiene-and-cross-builder-learning.md +117 -0
  102. package/docs/adr/0027-bfg-session-inefficiency-evaluator.md +170 -0
  103. package/docs/adr/0028-honest-llm-spend-accounting.md +109 -0
  104. package/docs/adr/0029-status-page-outage-resilience.md +59 -0
  105. package/docs/adr/0030-builders-hall-live-updates.md +59 -0
  106. package/docs/adr/0031-cloud-dev-environments-for-builders.md +222 -0
  107. package/docs/adr/0032-discord-mirror-of-gds.md +50 -0
  108. package/docs/adr/0033-discord-bot-service-principal.md +82 -0
  109. package/docs/adr/0034-thetes-graduated-newcomer-rank.md +53 -0
  110. package/docs/adr/0035-builder-onboarding-three-paths.md +128 -0
  111. package/docs/adr/0036-builders-subdomain-and-ui-reorg.md +40 -0
  112. package/docs/adr/0037-discord-channel-management.md +51 -0
  113. package/docs/adr/0038-chromebook-ttyd-cloudflare-tunnel.md +111 -0
  114. package/docs/adr/0039-setup-first-onboarding-ux.md +49 -0
  115. package/docs/adr/0040-remote-control-default-browser-onramp.md +47 -0
  116. package/docs/adr/0041-temporary-grader-bypass-killswitch.md +40 -0
  117. package/docs/adr/0042-builder-self-deploy-ci-auto-merge.md +82 -0
  118. package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +74 -0
  119. package/docs/adr/0044-mediterranean-palette-replacement.md +73 -0
  120. package/docs/adr/0044-per-box-live-game-preview.md +57 -0
  121. package/docs/adr/0045-devbox-desktop-app.md +133 -0
  122. package/docs/adr/0046-sandbox-first-review-gate.md +72 -0
  123. package/docs/adr/0047-discord-bugs-intake-anti-abuse.md +36 -0
  124. package/docs/adr/0048-works-in-progress-ia-split.md +32 -0
  125. package/docs/adr/0049-split-parallel-safety-contract.md +46 -0
  126. package/docs/adr/0050-device-flow-admission-invite-gated-by-default.md +43 -0
  127. package/docs/adr/0051-full-session-transcript-corpus.md +57 -0
  128. package/docs/adr/0052-sandbox-for-everyone-game-only-preview.md +70 -0
  129. package/docs/adr/0053-scoped-dev-box-session.md +55 -0
  130. package/docs/adr/0054-session-token-reward-cost-plus.md +63 -0
  131. package/docs/adr/0055-server-mediated-branch-publish.md +47 -0
  132. package/docs/adr/0056-prod-deploy-script-mirror-and-healthcheck-retry.md +62 -0
  133. package/docs/adr/0057-container-cost-ledger.md +28 -0
  134. package/docs/adr/0057-discord-archon-approval-channels.md +64 -0
  135. package/docs/adr/0058-automatic-gate-surface-gatekeeper.md +64 -0
  136. package/docs/adr/0059-single-approval-remove-devbox-approval-gate.md +37 -0
  137. package/docs/adr/0060-gds-retrieval-layer.md +97 -0
  138. package/docs/adr/0061-context-layer-decomposition.md +61 -0
  139. package/docs/adr/0062-medusa-instance-model-decoupling.md +107 -0
  140. package/docs/adr/0063-generated-repo-map-symbol-skeleton.md +44 -0
  141. package/docs/adr/0064-rename-medusa-to-cloud-bongos.md +49 -0
  142. package/docs/adr/0065-agpl-nonprofit-ai-first-governance.md +69 -0
  143. package/docs/adr/0066-generated-file-map-sections.md +51 -0
  144. package/docs/adr/0067-session-start-fast-forward-pull.md +43 -0
  145. package/docs/adr/0068-shared-art-key-for-newcomers.md +38 -0
  146. package/docs/adr/0069-hall-live-channel-sse-decouple.md +35 -0
  147. package/docs/adr/0070-deterministic-session-card-delivery.md +44 -0
  148. package/docs/adr/0071-box-confirm-before-destroyed-and-drift-reconcile.md +59 -0
  149. package/docs/adr/0072-bongos-app-mac-signed-first-windows-deferred.md +35 -0
  150. package/docs/adr/0072-dev-box-code-staleness-visibility.md +39 -0
  151. package/docs/adr/0073-builder-needs-signal-and-byok-gemini-key.md +64 -0
  152. package/docs/adr/0073-secrets-scan-exclude-uri-detector.md +37 -0
  153. package/docs/adr/0074-memory-map-stays-light.md +32 -0
  154. package/docs/adr/0075-per-system-cost-attribution.md +37 -0
  155. package/docs/adr/0076-in-session-deterministic-compaction.md +58 -0
  156. package/docs/adr/0077-content-addressed-llm-cache.md +32 -0
  157. package/docs/adr/0078-deterministic-first-cron.md +48 -0
  158. package/docs/adr/0079-value-gated-autonomous-engagement.md +39 -0
  159. package/docs/adr/0080-llm-cache-api-transport-and-trust-model.md +46 -0
  160. package/docs/adr/0081-tool-agnostic-design-layer.md +62 -0
  161. package/docs/adr/0082-productized-key-storage-provisioning.md +32 -0
  162. package/docs/adr/0082-server-side-merge-conflict-auto-resolution.md +55 -0
  163. package/docs/adr/0083-modular-architecture-strangler-build-plan.md +143 -0
  164. package/docs/adr/0084-auto-derived-per-task-rank-floor.md +86 -0
  165. package/docs/adr/0085-remove-default-per-builder-spend-cap.md +39 -0
  166. package/docs/adr/0086-goal-scoped-work-hierarchy.md +123 -0
  167. package/docs/adr/0087-bongos-app-architecture-and-handoff-contract.md +49 -0
  168. package/docs/adr/0087-compete-on-governance-not-tooling-cursor.md +65 -0
  169. package/docs/adr/0088-consistent-character-generative-animation.md +169 -0
  170. package/docs/adr/0089-modular-multi-model-adversarial-grader.md +179 -0
  171. package/docs/adr/0090-metic-task-authoring.md +73 -0
  172. package/docs/adr/0091-bounding-the-kernel-and-db-carve.md +106 -0
  173. package/docs/adr/0092-hosting-module-and-on-prem-target.md +117 -0
  174. package/docs/adr/0093-tranche-2-core-carve-sequence.md +72 -0
  175. package/docs/adr/0094-repo-atlas-self-drawing-codebase-map.md +49 -0
  176. package/docs/adr/0095-borrowed-memory-and-retrieval-concepts.md +65 -0
  177. package/docs/adr/0095-cross-agent-context-management.md +58 -0
  178. package/docs/adr/0096-require-reward-before-workable.md +209 -0
  179. package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +46 -0
  180. package/docs/adr/0097-retroactive-reward-backfill.md +124 -0
  181. package/docs/adr/0098-public-mirror-publish-manifest.md +92 -0
  182. package/docs/adr/0099-delayed-redacted-mirror-export.md +55 -0
  183. package/docs/adr/0100-two-repo-instance-model.md +60 -0
  184. package/docs/adr/0101-goal-hierarchy-phase-5-judge-membership-reward.md +90 -0
  185. package/docs/adr/0102-finish-bounding-the-kernel.md +36 -0
  186. package/docs/adr/0103-core-first-extraction-cloud-bongos-trunk.md +70 -0
  187. package/docs/adr/0103-gdsv4-bongos-consolidation.md +28 -0
  188. package/docs/adr/0104-trust-gds-api-channel-in-auto-mode.md +42 -0
  189. package/docs/adr/0105-instance-seed-migrations-out-of-core.md +47 -0
  190. package/docs/adr/0106-goal-owner-manager-authority.md +84 -0
  191. package/docs/adr/0107-module-upstreaming-contribution-to-core-catalog.md +83 -0
  192. package/docs/adr/0108-instance-composition-configurable-root.md +87 -0
  193. package/docs/adr/0109-self-describing-openapi-and-hosted-docs.md +44 -0
  194. package/docs/adr/0110-untrack-whole-file-generated-nav-docs.md +49 -0
  195. package/docs/adr/0111-instance-hosting-provisioning-module.md +135 -0
  196. package/docs/adr/0112-private-goals-invitations-join-requests.md +72 -0
  197. package/docs/adr/0113-sandbox-studio-descope-superseded-by-0044-0052.md +41 -0
  198. package/docs/adr/0114-instance-docs-discovery-contract.md +32 -0
  199. package/docs/adr/0115-scheduled-routines-portability.md +31 -0
  200. package/docs/adr/0116-api-error-envelope-and-code-taxonomy.md +62 -0
  201. package/docs/adr/0117-api-versioning-and-deprecation-policy.md +50 -0
  202. package/docs/adr/0118-api-contract-source-of-truth-code-first.md +47 -0
  203. package/docs/adr/0119-api-pagination-contract.md +34 -0
  204. package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +108 -0
  205. package/docs/adr/0121-greenfield-vs-brownfield-onboarding-adopt-existing-repo.md +97 -0
  206. package/docs/adr/0122-onboarding-diagram-renders-deploy-rendered-off-git.md +43 -0
  207. package/docs/adr/0123-box-idle-sweep-autosave-before-destroy.md +36 -0
  208. package/docs/adr/0124-typed-api-response-bodies.md +37 -0
  209. package/docs/adr/0125-control-plane-scaffolds-standalone-instance-repo.md +36 -0
  210. package/docs/adr/0126-dedicated-cloudbongos-control-plane-droplet.md +30 -0
  211. package/docs/adr/0127-cross-box-fleet-observability.md +64 -0
  212. package/docs/adr/0128-provisioning-runner-privilege-and-db-auth-model.md +46 -0
  213. package/docs/adr/0129-cloudbongos-control-plane-pull-deploy.md +34 -0
  214. package/docs/adr/0130-provision-remote-exec-over-ssh.md +61 -0
  215. package/docs/adr/0131-rank-scoped-skill-visibility.md +47 -0
  216. package/docs/adr/0132-cohost-fleet-control-plane-migration.md +54 -0
  217. package/docs/adr/0133-github-app-manifest-onboarding-signin.md +42 -0
  218. package/docs/adr/0134-private-first-npm-distribution.md +38 -0
  219. package/docs/adr/README.md +208 -0
  220. package/docs/api/openapi.json +13720 -0
  221. package/docs/api-reference.md +437 -0
  222. package/docs/architecture.md +244 -0
  223. package/docs/branding-contract.md +66 -0
  224. package/docs/branding-fill-prompt.md +25 -0
  225. package/docs/canonical-permissions.md +241 -0
  226. package/docs/design/container-exterior-tiles.md +49 -0
  227. package/docs/design/container-exterior.md +81 -0
  228. package/docs/design/container-interior.md +77 -0
  229. package/docs/design/modular-architecture/00-research-report.md +107 -0
  230. package/docs/design/modular-architecture/01-architecture.md +125 -0
  231. package/docs/design/modular-architecture/02-module-map.md +116 -0
  232. package/docs/design/modular-architecture/03-builder-flows.md +104 -0
  233. package/docs/design/modular-architecture/04-module-lifecycle.md +62 -0
  234. package/docs/design/modular-architecture/README.md +27 -0
  235. package/docs/design/vanilla-hall-ui-redesign-scope.md +284 -0
  236. package/docs/design/world-map-scroll.md +41 -0
  237. package/docs/design-contract.md +320 -0
  238. package/docs/file-map.md +418 -0
  239. package/docs/handoff-template.md +92 -0
  240. package/docs/modules-contract.md +293 -0
  241. package/docs/onboarding/browser-terminal-guide.md +73 -0
  242. package/docs/onboarding/diagrams/01-task-lifecycle.mmd +28 -0
  243. package/docs/onboarding/diagrams/02-rank-ladder.mmd +54 -0
  244. package/docs/onboarding/diagrams/03-drachmae-karma.mmd +37 -0
  245. package/docs/onboarding/diagrams/04-architecture.mmd +51 -0
  246. package/docs/onboarding/diagrams/05-versioning.mmd +29 -0
  247. package/docs/onboarding/diagrams/README.md +94 -0
  248. package/docs/onboarding/diagrams/assertions.json +170 -0
  249. package/docs/onboarding/diagrams/puppeteer-config.json +1 -0
  250. package/docs/onboarding/diagrams/render-config.json +9 -0
  251. package/docs/onboarding/drachmae-and-karma.md +178 -0
  252. package/docs/onboarding/primer.md +202 -0
  253. package/docs/project-context.template.md +67 -0
  254. package/docs/recipes/README.md +35 -0
  255. package/docs/recipes/audit-replay.md +100 -0
  256. package/docs/recipes/bongos-cli-release.md +66 -0
  257. package/docs/recipes/builder-box-lifecycle.md +152 -0
  258. package/docs/recipes/claude-design-loop.md +143 -0
  259. package/docs/recipes/claude-gemini-bongos-doors.md +155 -0
  260. package/docs/recipes/cloudbongos-staging-gate.md +139 -0
  261. package/docs/recipes/cloudbongos-standup.md +111 -0
  262. package/docs/recipes/devbox-app-release.md +93 -0
  263. package/docs/recipes/devbox-app-windows-test.md +128 -0
  264. package/docs/recipes/failed-ship-recovery-proof.md +102 -0
  265. package/docs/recipes/figma-design-loop.md +129 -0
  266. package/docs/recipes/gds-db-backup.md +255 -0
  267. package/docs/recipes/instance-deploy.md +37 -0
  268. package/docs/recipes/managed-settings-remote-control.md +416 -0
  269. package/docs/recipes/memory-backup.md +71 -0
  270. package/docs/recipes/memory-sync-conflicts.md +98 -0
  271. package/docs/recipes/multi-builder-merge.md +78 -0
  272. package/docs/recipes/ops-gotchas.md +212 -0
  273. package/docs/recipes/overnight-builder.md +53 -0
  274. package/docs/recipes/packaging-the-core.md +49 -0
  275. package/docs/recipes/parallel-load-results-2026-05-30.md +63 -0
  276. package/docs/recipes/parallel-load-test.md +32 -0
  277. package/docs/recipes/pixel-art-pipeline.md +411 -0
  278. package/docs/recipes/private-npm-distribution.md +79 -0
  279. package/docs/recipes/search-index-ingestion.md +67 -0
  280. package/docs/recipes/self-host.md +60 -0
  281. package/docs/recipes/ship-and-regrade.md +72 -0
  282. package/docs/recipes/staging-data.md +50 -0
  283. package/docs/recipes/staging-environment.md +215 -0
  284. package/docs/recipes/upgrading-the-core.md +58 -0
  285. package/docs/research/cursor-competitive-analysis.md +255 -0
  286. package/docs/research/llm-model-agnostic-and-cheaper-grader.md +227 -0
  287. package/migrations/003_pms.sql +284 -0
  288. package/migrations/004_pms_cost_idempotency.sql +13 -0
  289. package/migrations/005_pms_smoke_filter.sql +44 -0
  290. package/migrations/006_pms_v2.sql +378 -0
  291. package/migrations/007_pms_v2_hierarchy_trigger.sql +59 -0
  292. package/migrations/008_pms_v2_migration_allocator.sql +27 -0
  293. package/migrations/009_pms_v2_blockers_table.sql +68 -0
  294. package/migrations/010_uptime.sql +29 -0
  295. package/migrations/011_backfill_v2_v3_done_when.sql +20 -0
  296. package/migrations/012_pms_v2_task_lifecycle_states.sql +82 -0
  297. package/migrations/013_claim_locking.sql +22 -0
  298. package/migrations/014_pms_task_discipline.sql +86 -0
  299. package/migrations/015_pms_estimation_calibration.sql +140 -0
  300. package/migrations/016_pms_task_grades.sql +60 -0
  301. package/migrations/017_version_progress_exclude_abandoned.sql +49 -0
  302. package/migrations/019_rename_pms_v3_to_gds_v3.sql +65 -0
  303. package/migrations/020_task_dependencies.sql +99 -0
  304. package/migrations/021_v3_renumber_and_deps.sql +224 -0
  305. package/migrations/022_v3_reconcile_dep_gated_status.sql +41 -0
  306. package/migrations/023_pms_rank.sql +30 -0
  307. package/migrations/025_builder_exit_status.sql +15 -0
  308. package/migrations/027_builders_preferred_disciplines.sql +46 -0
  309. package/migrations/028_tasks_parallel_safe.sql +71 -0
  310. package/migrations/029_rank_three_live.sql +32 -0
  311. package/migrations/030_vulnerability_reports.sql +61 -0
  312. package/migrations/031_reward_simplification.sql +57 -0
  313. package/migrations/032_repo_health_snapshots.sql +56 -0
  314. package/migrations/033_audit_log.sql +64 -0
  315. package/migrations/034_lifecycle_breakdown_leaf_only.sql +62 -0
  316. package/migrations/035_auth_token_hygiene.sql +66 -0
  317. package/migrations/037_builder_skill_model_prefs.sql +44 -0
  318. package/migrations/038_task_override_requests.sql +51 -0
  319. package/migrations/039_newcomer_friendly_column.sql +124 -0
  320. package/migrations/040_builder_karma.sql +58 -0
  321. package/migrations/041_kind_multipliers_and_idea_bonus.sql +42 -0
  322. package/migrations/042_criterion_proposal_workflow.sql +78 -0
  323. package/migrations/043_builder_onboarding_observability.sql +66 -0
  324. package/migrations/044_tasks_requires_rank.sql +97 -0
  325. package/migrations/045_builder_onboarding_state.sql +147 -0
  326. package/migrations/046_cost_log_builder_attribution.sql +42 -0
  327. package/migrations/054_builder_memory.sql +108 -0
  328. package/migrations/055_task_criteria.sql +90 -0
  329. package/migrations/058_peer_votes.sql +41 -0
  330. package/migrations/059_onboarding_pick_disciplines.sql +59 -0
  331. package/migrations/060_access_requests.sql +46 -0
  332. package/migrations/061_backfill_override_ship_attribution.sql +29 -0
  333. package/migrations/062_session_records.sql +34 -0
  334. package/migrations/063_session_records_tool_errors_idx.sql +11 -0
  335. package/migrations/064_grade_attempts.sql +18 -0
  336. package/migrations/065_bfg_principal.sql +58 -0
  337. package/migrations/066_live_notify_triggers.sql +69 -0
  338. package/migrations/067_builder_boxes.sql +117 -0
  339. package/migrations/067_discord_bot_foundation.sql +69 -0
  340. package/migrations/068_thetes_requires_rank.sql +27 -0
  341. package/migrations/069_builder_ssh_keys.sql +66 -0
  342. package/migrations/070_box_intents.sql +101 -0
  343. package/migrations/071_box_first_provision_approval.sql +46 -0
  344. package/migrations/072_onboarding_ship_three_remove_primer.sql +80 -0
  345. package/migrations/073_box_terminal_access.sql +46 -0
  346. package/migrations/075_builder_sound_prefs.sql +28 -0
  347. package/migrations/078_box_claude_active.sql +22 -0
  348. package/migrations/079_box_first_connected_at.sql +20 -0
  349. package/migrations/081_tasks_needs_migration.sql +34 -0
  350. package/migrations/084_tasks_security_sensitive.sql +24 -0
  351. package/migrations/085_security_engagement_docs.sql +34 -0
  352. package/migrations/086_rename_disciplines.sql +125 -0
  353. package/migrations/088_builders_anthropic_link.sql +28 -0
  354. package/migrations/089_builders_monthly_budget.sql +19 -0
  355. package/migrations/090_box_scoped_sessions.sql +38 -0
  356. package/migrations/091_session_token_reward.sql +44 -0
  357. package/migrations/093_tasks_sandbox_review.sql +22 -0
  358. package/migrations/102_remove_box_first_provision_approval.sql +58 -0
  359. package/migrations/103_search_chunks.sql +90 -0
  360. package/migrations/104_builder_box_blocked.sql +34 -0
  361. package/migrations/105_box_host_keys.sql +35 -0
  362. package/migrations/111_medusa_customer0_framing.sql +35 -0
  363. package/migrations/112_drop_track_check.sql +25 -0
  364. package/migrations/113_merge_lease.sql +16 -0
  365. package/migrations/114_cloudbongos_rename_live_strings.sql +32 -0
  366. package/migrations/118_currency_label_decoupling.sql +23 -0
  367. package/migrations/119_builder_behavior_prefs.sql +31 -0
  368. package/migrations/121_box_code_version.sql +37 -0
  369. package/migrations/122_builder_gemini_key.sql +30 -0
  370. package/migrations/123_cost_log_tiers.sql +33 -0
  371. package/migrations/124_cost_baseline.sql +45 -0
  372. package/migrations/125_llm_cache.sql +54 -0
  373. package/migrations/126_autonomy_runs.sql +33 -0
  374. package/migrations/143_cost_calibration.sql +63 -0
  375. package/migrations/145_backfill_task_requires_rank.sql +84 -0
  376. package/migrations/160_goal_tier.sql +88 -0
  377. package/migrations/163_polymorphic_dependencies.sql +187 -0
  378. package/migrations/164_event_sound_prefs.sql +20 -0
  379. package/migrations/165_task_module_key.sql +15 -0
  380. package/migrations/171_claims_published_head_sha.sql +24 -0
  381. package/migrations/174_close_orphaned_claims_on_terminal_tasks.sql +30 -0
  382. package/migrations/175_task_grades_diff_hash.sql +9 -0
  383. package/migrations/176_promotion_respects_open_blockers.sql +58 -0
  384. package/migrations/176_ui_discipline.sql +40 -0
  385. package/migrations/177_require_reward_before_workable.sql +76 -0
  386. package/migrations/178_reward_gate_auto_assign_revert_autopromote.sql +61 -0
  387. package/migrations/179_search_chunk_usefulness.sql +33 -0
  388. package/migrations/180_one_active_claim_per_session.sql +54 -0
  389. package/migrations/181_cost_log_skill_name.sql +33 -0
  390. package/migrations/184_claim_release_requested.sql +25 -0
  391. package/migrations/185_module_upstreaming_goal_wiring.sql +59 -0
  392. package/migrations/187_goal_sort_order.sql +30 -0
  393. package/migrations/187_tasks_kind_check_add_verify.sql +21 -0
  394. package/migrations/README.md +88 -0
  395. package/migrations/core_188_goal_visibility_and_membership_requests.sql +71 -0
  396. package/migrations/core_189_core_upgrades_ledger.sql +37 -0
  397. package/migrations/core_189_credit_log_task_id_index.sql +19 -0
  398. package/migrations/core_190_rebuild_claimable_task_views.sql +71 -0
  399. package/migrations/instance/018_pms_v3_planning_seed.sql +131 -0
  400. package/migrations/instance/080_gds_v4_planning_seed.sql +75 -0
  401. package/migrations/instance/110_medusa_planning_seed.sql +70 -0
  402. package/migrations/instance/120_bongos_v1_planning_seed.sql +69 -0
  403. package/migrations/instance/144_bongos_v1_module_system_criterion.sql +26 -0
  404. package/migrations/instance/159_bongos_v1_goal_hierarchy_criterion.sql +29 -0
  405. package/migrations/instance/172_v2_world_planning_seed.sql +163 -0
  406. package/migrations/instance/173_bongos_v1_c7_core_modularized.sql +33 -0
  407. package/migrations/instance/182_gdsv4_bongos_consolidation.sql +132 -0
  408. package/migrations/instance/183_gdsv4_carryover_themed_goals.sql +116 -0
  409. package/migrations/instance/186_bongos_v1_governance_planning_seed.sql +69 -0
  410. package/modules/autonomy/CLAUDE.md +49 -0
  411. package/modules/autonomy/db.js +50 -0
  412. package/modules/autonomy/module.json +13 -0
  413. package/modules/autonomy/pollers/routine-timers.js +146 -0
  414. package/modules/autonomy/routes/autonomy.js +37 -0
  415. package/modules/builder-settings/CLAUDE.md +58 -0
  416. package/modules/builder-settings/builder-needs.js +115 -0
  417. package/modules/builder-settings/db.js +168 -0
  418. package/modules/builder-settings/display-name.js +84 -0
  419. package/modules/builder-settings/event-sound-prefs.js +169 -0
  420. package/modules/builder-settings/interaction-prefs.js +201 -0
  421. package/modules/builder-settings/model-alloc-prefs.js +147 -0
  422. package/modules/builder-settings/module.json +10 -0
  423. package/modules/builder-settings/render-prefs.js +103 -0
  424. package/modules/builder-settings/routes/settings.js +417 -0
  425. package/modules/builder-settings/settings.js +52 -0
  426. package/modules/builder-settings/skill-prefs.js +196 -0
  427. package/modules/builder-settings/sound-prefs.js +178 -0
  428. package/modules/builder-settings/wandering-prefs.js +282 -0
  429. package/modules/dev-box/CLAUDE.md +38 -0
  430. package/modules/dev-box/box-access.js +275 -0
  431. package/modules/dev-box/box-credential.js +94 -0
  432. package/modules/dev-box/box-onboard.js +486 -0
  433. package/modules/dev-box/boxes.js +842 -0
  434. package/modules/dev-box/db.js +39 -0
  435. package/modules/dev-box/module.json +14 -0
  436. package/modules/dev-box/routes/box.js +977 -0
  437. package/modules/discord/CLAUDE.md +50 -0
  438. package/modules/discord/auth.js +54 -0
  439. package/modules/discord/bug-attachments.js +210 -0
  440. package/modules/discord/db.js +96 -0
  441. package/modules/discord/discord-approvals.js +145 -0
  442. package/modules/discord/discord-audit.js +29 -0
  443. package/modules/discord/discord-bot.js +545 -0
  444. package/modules/discord/discord-bugs.js +257 -0
  445. package/modules/discord/discord-channels.js +412 -0
  446. package/modules/discord/discord-inbound.js +147 -0
  447. package/modules/discord/discord-message-buffer.js +54 -0
  448. package/modules/discord/discord-reaction.js +186 -0
  449. package/modules/discord/discord-role-sync.js +318 -0
  450. package/modules/discord/module.json +14 -0
  451. package/modules/discord/pollers/discord-bot.js +19 -0
  452. package/modules/discord/routes/bug-attachments.js +44 -0
  453. package/modules/discord/routes/discord.js +102 -0
  454. package/modules/discord/ship-broadcast.js +78 -0
  455. package/modules/discord/webhook.js +74 -0
  456. package/modules/economy/CLAUDE.md +48 -0
  457. package/modules/economy/achievements.js +594 -0
  458. package/modules/economy/cost-classify.js +331 -0
  459. package/modules/economy/cost-summary-shape.js +90 -0
  460. package/modules/economy/cost.js +438 -0
  461. package/modules/economy/credits.js +430 -0
  462. package/modules/economy/migrations/economy_001_fix_gds_shipper_description.sql +91 -0
  463. package/modules/economy/module.json +10 -0
  464. package/modules/economy/reward.js +61 -0
  465. package/modules/economy/routes/cost.js +97 -0
  466. package/modules/economy/routes/leaderboard.js +26 -0
  467. package/modules/economy/routes/reward.js +42 -0
  468. package/modules/economy/streak.js +285 -0
  469. package/modules/grading/CLAUDE.md +42 -0
  470. package/modules/grading/db.js +39 -0
  471. package/modules/grading/grader-rubric.json +151 -0
  472. package/modules/grading/grader-workers/efficiency.js +213 -0
  473. package/modules/grading/grader-workers/extract-json.js +80 -0
  474. package/modules/grading/grader-workers/fence.js +100 -0
  475. package/modules/grading/grader-workers/hacker.js +239 -0
  476. package/modules/grading/grader-workers/index.js +183 -0
  477. package/modules/grading/grader-workers/narc.js +207 -0
  478. package/modules/grading/grader-workers/quality.js +198 -0
  479. package/modules/grading/grader-workers/rubric.js +20 -0
  480. package/modules/grading/grader-workers/worker-prompt.js +89 -0
  481. package/modules/grading/grader-workers/worker-verdict.js +50 -0
  482. package/modules/grading/grader.js +1790 -0
  483. package/modules/grading/module.json +12 -0
  484. package/modules/grading/routes/grade.js +51 -0
  485. package/modules/hall-ui/CLAUDE.md +61 -0
  486. package/modules/hall-ui/module.json +14 -0
  487. package/modules/hall-ui/public/atlas.html +180 -0
  488. package/modules/hall-ui/public/atlas.js +336 -0
  489. package/modules/hall-ui/public/box-panel.js +423 -0
  490. package/modules/hall-ui/public/brand-apply.js +117 -0
  491. package/modules/hall-ui/public/builders.js +3028 -0
  492. package/modules/hall-ui/public/chime.wav +0 -0
  493. package/modules/hall-ui/public/copy-on-click.js +174 -0
  494. package/modules/hall-ui/public/cursors/arrow.png +0 -0
  495. package/modules/hall-ui/public/cursors/hand-pointer.png +0 -0
  496. package/modules/hall-ui/public/cursors/magnifier-out.png +0 -0
  497. package/modules/hall-ui/public/cursors/magnifier.png +0 -0
  498. package/modules/hall-ui/public/diagram-viewer.css +125 -0
  499. package/modules/hall-ui/public/diagram-viewer.js +330 -0
  500. package/modules/hall-ui/public/diagrams.html +113 -0
  501. package/modules/hall-ui/public/dom-utils.js +212 -0
  502. package/modules/hall-ui/public/drachmae.html +68 -0
  503. package/modules/hall-ui/public/drachmae.js +136 -0
  504. package/modules/hall-ui/public/gate.html +143 -0
  505. package/modules/hall-ui/public/gate.js +228 -0
  506. package/modules/hall-ui/public/goals-page.js +1297 -0
  507. package/modules/hall-ui/public/goals.html +376 -0
  508. package/modules/hall-ui/public/goals.js +119 -0
  509. package/modules/hall-ui/public/hall-widgets.js +189 -0
  510. package/modules/hall-ui/public/harbor.html +167 -0
  511. package/modules/hall-ui/public/harbor.js +335 -0
  512. package/modules/hall-ui/public/index.html +116 -0
  513. package/modules/hall-ui/public/instances-panel.js +172 -0
  514. package/modules/hall-ui/public/md-reader.js +234 -0
  515. package/modules/hall-ui/public/modules.html +119 -0
  516. package/modules/hall-ui/public/modules.js +154 -0
  517. package/modules/hall-ui/public/new-project-wizard.js +186 -0
  518. package/modules/hall-ui/public/not-ready.html +48 -0
  519. package/modules/hall-ui/public/pair.html +119 -0
  520. package/modules/hall-ui/public/pair.js +132 -0
  521. package/modules/hall-ui/public/primer.css +404 -0
  522. package/modules/hall-ui/public/primer.html +85 -0
  523. package/modules/hall-ui/public/primer.js +641 -0
  524. package/modules/hall-ui/public/profile.html +85 -0
  525. package/modules/hall-ui/public/profile.js +236 -0
  526. package/modules/hall-ui/public/ranks.html +69 -0
  527. package/modules/hall-ui/public/ranks.js +206 -0
  528. package/modules/hall-ui/public/roadmap.html +209 -0
  529. package/modules/hall-ui/public/roadmap.js +375 -0
  530. package/modules/hall-ui/public/sessions.html +92 -0
  531. package/modules/hall-ui/public/sessions.js +222 -0
  532. package/modules/hall-ui/public/settings.html +640 -0
  533. package/modules/hall-ui/public/settings.js +1219 -0
  534. package/modules/hall-ui/public/shell.js +495 -0
  535. package/modules/hall-ui/public/style.css +1748 -0
  536. package/modules/hall-ui/public/task-detail.js +253 -0
  537. package/modules/hall-ui/public/task.html +83 -0
  538. package/modules/hall-ui/public/task.js +326 -0
  539. package/modules/hall-ui/public/watch.html +333 -0
  540. package/modules/hall-ui/public/watch.js +859 -0
  541. package/modules/hall-ui/public/work.html +469 -0
  542. package/modules/hall-ui/public/work.js +1292 -0
  543. package/modules/ideas/CLAUDE.md +37 -0
  544. package/modules/ideas/blockers.js +100 -0
  545. package/modules/ideas/inbox.js +459 -0
  546. package/modules/ideas/module.json +10 -0
  547. package/modules/ideas/routes/blockers.js +120 -0
  548. package/modules/ideas/routes/inbox.js +230 -0
  549. package/modules/lifecycle/CLAUDE.md +63 -0
  550. package/modules/lifecycle/conflict-resolve.js +436 -0
  551. package/modules/lifecycle/db.js +4970 -0
  552. package/modules/lifecycle/done-when.js +355 -0
  553. package/modules/lifecycle/github-push.js +969 -0
  554. package/modules/lifecycle/goal-conflicts.js +133 -0
  555. package/modules/lifecycle/hierarchy.js +104 -0
  556. package/modules/lifecycle/lifecycle.js +58 -0
  557. package/modules/lifecycle/merge-lock.js +261 -0
  558. package/modules/lifecycle/migrations/lifecycle_001_goal_first_leads.sql +28 -0
  559. package/modules/lifecycle/migrations/lifecycle_002_backfill_orphan_task_goals.sql +36 -0
  560. package/modules/lifecycle/module.json +13 -0
  561. package/modules/lifecycle/pollers/publish-reconciler.js +20 -0
  562. package/modules/lifecycle/publish-reconciler.js +418 -0
  563. package/modules/lifecycle/routes/analytics.js +63 -0
  564. package/modules/lifecycle/routes/claims.js +506 -0
  565. package/modules/lifecycle/routes/dependencies.js +82 -0
  566. package/modules/lifecycle/routes/done-when.js +136 -0
  567. package/modules/lifecycle/routes/gate-approvals.js +197 -0
  568. package/modules/lifecycle/routes/goals.js +1419 -0
  569. package/modules/lifecycle/routes/lifecycle.js +43 -0
  570. package/modules/lifecycle/routes/tasks.js +1703 -0
  571. package/modules/lifecycle/routes/versions.js +128 -0
  572. package/modules/lifecycle/ship-card.js +505 -0
  573. package/modules/lifecycle/ship-preflight.js +225 -0
  574. package/modules/lifecycle/task-classifier.js +512 -0
  575. package/modules/lifecycle/version-close.js +106 -0
  576. package/modules/memory/CLAUDE.md +40 -0
  577. package/modules/memory/learnings.js +275 -0
  578. package/modules/memory/memory.js +572 -0
  579. package/modules/memory/module.json +12 -0
  580. package/modules/memory/routes/learnings.js +90 -0
  581. package/modules/memory/routes/memory.js +916 -0
  582. package/modules/memory/routes/search.js +188 -0
  583. package/modules/memory/search-chunker.js +252 -0
  584. package/modules/memory/search.js +320 -0
  585. package/modules/onboarding/CLAUDE.md +55 -0
  586. package/modules/onboarding/approval-broadcast.js +59 -0
  587. package/modules/onboarding/db.js +54 -0
  588. package/modules/onboarding/module.json +10 -0
  589. package/modules/onboarding/newcomer-restock.js +212 -0
  590. package/modules/onboarding/onboarding-state.js +532 -0
  591. package/modules/onboarding/onboarding.js +508 -0
  592. package/modules/onboarding/port.js +63 -0
  593. package/modules/onboarding/routes/access-requests.js +208 -0
  594. package/modules/onboarding/routes/onboarding.js +54 -0
  595. package/modules/security/CLAUDE.md +44 -0
  596. package/modules/security/module.json +10 -0
  597. package/modules/security/routes/reports.js +251 -0
  598. package/modules/security/secret-scrub.js +201 -0
  599. package/modules/security/security.js +294 -0
  600. package/modules/sessions/CLAUDE.md +39 -0
  601. package/modules/sessions/bfg.js +357 -0
  602. package/modules/sessions/db.js +326 -0
  603. package/modules/sessions/module.json +10 -0
  604. package/modules/sessions/pulse.js +149 -0
  605. package/modules/sessions/routes/sessions.js +331 -0
  606. package/modules/status-ui/CLAUDE.md +47 -0
  607. package/modules/status-ui/module.json +14 -0
  608. package/modules/status-ui/public/README.md +29 -0
  609. package/modules/status-ui/public/cursors/arrow.png +0 -0
  610. package/modules/status-ui/public/cursors/hand-pointer.png +0 -0
  611. package/modules/status-ui/public/index.html +363 -0
  612. package/modules/status-ui/public/snapshots/.gitkeep +0 -0
  613. package/modules/status-ui/public/status.js +1375 -0
  614. package/modules/status-ui/public/style.css +1342 -0
  615. package/package-lock.json +975 -0
  616. package/package.json +28 -0
  617. package/public-docs/index.html +35 -0
  618. package/public-docs/redoc.standalone.js +1838 -0
  619. package/public-docs/redoc.standalone.js.LICENSE.txt +102 -0
  620. package/public-landing/buddha.png +0 -0
  621. package/public-landing/create.html +868 -0
  622. package/public-landing/index.html +168 -0
  623. package/scripts/bongos-mcp.js +28 -0
  624. package/scripts/cron/style_guide_audit.sh +134 -0
  625. package/scripts/discord/README.md +52 -0
  626. package/scripts/discord/lib.js +80 -0
  627. package/scripts/discord/post.js +51 -0
  628. package/scripts/gds/CLAUDE.md +144 -0
  629. package/scripts/gds/abandon.js +58 -0
  630. package/scripts/gds/adopt-detect.js +123 -0
  631. package/scripts/gds/adopt-import.js +111 -0
  632. package/scripts/gds/adopt-preflight.js +121 -0
  633. package/scripts/gds/api.js +78 -0
  634. package/scripts/gds/architect-audit.js +288 -0
  635. package/scripts/gds/art-stage.js +152 -0
  636. package/scripts/gds/audit-authorship.sh +243 -0
  637. package/scripts/gds/audit-deps.js +121 -0
  638. package/scripts/gds/audit-exports.js +128 -0
  639. package/scripts/gds/audit-replay.sh +161 -0
  640. package/scripts/gds/audit-ships.js +251 -0
  641. package/scripts/gds/autonomy-gate.js +135 -0
  642. package/scripts/gds/backfill-cost.js +165 -0
  643. package/scripts/gds/backfill-sessions.js +351 -0
  644. package/scripts/gds/backfill-task-rewards.js +408 -0
  645. package/scripts/gds/backup.js +94 -0
  646. package/scripts/gds/bfg-principal-token.js +65 -0
  647. package/scripts/gds/box-auth-check.js +160 -0
  648. package/scripts/gds/box-connect-lib.js +453 -0
  649. package/scripts/gds/box.js +1258 -0
  650. package/scripts/gds/build-bongos.js +94 -0
  651. package/scripts/gds/capture.js +123 -0
  652. package/scripts/gds/cf-tunnel.js +419 -0
  653. package/scripts/gds/ci-grade.js +82 -0
  654. package/scripts/gds/claim-recall.js +112 -0
  655. package/scripts/gds/claim.js +333 -0
  656. package/scripts/gds/classify-discipline.js +153 -0
  657. package/scripts/gds/claude-materialize.js +176 -0
  658. package/scripts/gds/cleanup-orphans.js +74 -0
  659. package/scripts/gds/cli-lib.js +518 -0
  660. package/scripts/gds/code.js +101 -0
  661. package/scripts/gds/codemod-rename-src-gds.js +145 -0
  662. package/scripts/gds/conductor-dispatch.js +426 -0
  663. package/scripts/gds/context-pack.js +228 -0
  664. package/scripts/gds/coreB-carve-proof.sh +716 -0
  665. package/scripts/gds/cost.js +47 -0
  666. package/scripts/gds/db-backup-nightly.sh +160 -0
  667. package/scripts/gds/db-backup-offsite.sh +152 -0
  668. package/scripts/gds/demote.js +40 -0
  669. package/scripts/gds/design-sync.js +62 -0
  670. package/scripts/gds/diagnose-achievements.js +118 -0
  671. package/scripts/gds/diagram-facts.js +319 -0
  672. package/scripts/gds/discipline-modes.json +25 -0
  673. package/scripts/gds/discord-channels.js +66 -0
  674. package/scripts/gds/do-api.js +176 -0
  675. package/scripts/gds/docs-entropy.js +403 -0
  676. package/scripts/gds/doctor.js +389 -0
  677. package/scripts/gds/feedback-latest.js +94 -0
  678. package/scripts/gds/fetch-art-key.js +106 -0
  679. package/scripts/gds/fetch-sound-prefs.js +88 -0
  680. package/scripts/gds/figma-design-sync.js +61 -0
  681. package/scripts/gds/fitness.js +1446 -0
  682. package/scripts/gds/gate-review.js +322 -0
  683. package/scripts/gds/gating.js +325 -0
  684. package/scripts/gds/gds-db-backup.service +24 -0
  685. package/scripts/gds/gds-db-backup.timer +20 -0
  686. package/scripts/gds/gds-db-offsite.service +29 -0
  687. package/scripts/gds/gds-db-offsite.timer +18 -0
  688. package/scripts/gds/gen-api-client.js +508 -0
  689. package/scripts/gds/gen-api-docs.js +1037 -0
  690. package/scripts/gds/gen-atlas.js +366 -0
  691. package/scripts/gds/gen-diagrams.js +608 -0
  692. package/scripts/gds/gen-file-map.js +196 -0
  693. package/scripts/gds/gen-repo-map.js +519 -0
  694. package/scripts/gds/gen-session-index.js +210 -0
  695. package/scripts/gds/git-merge-regen.js +147 -0
  696. package/scripts/gds/grade-smoke.js +205 -0
  697. package/scripts/gds/hooks/pre-push.js +169 -0
  698. package/scripts/gds/http-api-client.js +88 -0
  699. package/scripts/gds/init.js +903 -0
  700. package/scripts/gds/install-git-hooks.js +114 -0
  701. package/scripts/gds/key-card.js +213 -0
  702. package/scripts/gds/key-status.js +126 -0
  703. package/scripts/gds/land-watch.js +168 -0
  704. package/scripts/gds/learning-capture.js +145 -0
  705. package/scripts/gds/lib.sh +44 -0
  706. package/scripts/gds/linkify-refs.js +473 -0
  707. package/scripts/gds/local-preview-lib.js +159 -0
  708. package/scripts/gds/local-preview.js +95 -0
  709. package/scripts/gds/login.js +225 -0
  710. package/scripts/gds/main-audit.js +279 -0
  711. package/scripts/gds/me.js +88 -0
  712. package/scripts/gds/memory-map.js +174 -0
  713. package/scripts/gds/merge-lock.js +71 -0
  714. package/scripts/gds/migration-namespace.js +200 -0
  715. package/scripts/gds/mirror-lineage-check.js +319 -0
  716. package/scripts/gds/mirror-redact.js +305 -0
  717. package/scripts/gds/module.js +482 -0
  718. package/scripts/gds/newcomer-floor.config.json +13 -0
  719. package/scripts/gds/newcomer-floor.js +415 -0
  720. package/scripts/gds/newcomer-floor.templates.json +86 -0
  721. package/scripts/gds/newcomer-floor.test.js +235 -0
  722. package/scripts/gds/next-number.js +163 -0
  723. package/scripts/gds/oauth-secret.js +197 -0
  724. package/scripts/gds/onboard.js +217 -0
  725. package/scripts/gds/onboarding-analyze.js +226 -0
  726. package/scripts/gds/onboarding-config.js +178 -0
  727. package/scripts/gds/onboarding-ship.js +92 -0
  728. package/scripts/gds/package-core.js +684 -0
  729. package/scripts/gds/paste-token.js +131 -0
  730. package/scripts/gds/portability-smoke-art.sh +85 -0
  731. package/scripts/gds/portability-smoke-memory.sh +147 -0
  732. package/scripts/gds/provision.js +1324 -0
  733. package/scripts/gds/publish-manifest.js +307 -0
  734. package/scripts/gds/pulse.js +309 -0
  735. package/scripts/gds/push-main.js +45 -0
  736. package/scripts/gds/read-session-export.js +359 -0
  737. package/scripts/gds/reauth.js +149 -0
  738. package/scripts/gds/recall.js +135 -0
  739. package/scripts/gds/release.js +71 -0
  740. package/scripts/gds/render-ideas.js +173 -0
  741. package/scripts/gds/repo-metrics.js +363 -0
  742. package/scripts/gds/resolve-skill-model.js +57 -0
  743. package/scripts/gds/run-routine.js +197 -0
  744. package/scripts/gds/run-unit-tests.js +178 -0
  745. package/scripts/gds/sandbox-stage.js +653 -0
  746. package/scripts/gds/search-ingest-db.js +209 -0
  747. package/scripts/gds/search-ingest-md.js +225 -0
  748. package/scripts/gds/security-baseline-check.sh +180 -0
  749. package/scripts/gds/seed-adopt-tasks.js +75 -0
  750. package/scripts/gds/seed-api-contract-goal-tasks.js +141 -0
  751. package/scripts/gds/seed-bongos-app-tasks.js +294 -0
  752. package/scripts/gds/seed-bongos-coreB-tranche1-tasks.js +81 -0
  753. package/scripts/gds/seed-bongos-coreB-tranche2-tasks.js +100 -0
  754. package/scripts/gds/seed-bongos-goal-hierarchy-tasks.js +307 -0
  755. package/scripts/gds/seed-bongos-module-system-tasks.js +346 -0
  756. package/scripts/gds/seed-bongos-tasks.js +214 -0
  757. package/scripts/gds/seed-gds-v4-tasks.js +624 -0
  758. package/scripts/gds/seed-governance-tasks.js +340 -0
  759. package/scripts/gds/seed-medusa-tasks.js +440 -0
  760. package/scripts/gds/seed-module-upstreaming.js +219 -0
  761. package/scripts/gds/seed-productization-tasks.js +235 -0
  762. package/scripts/gds/seed-provisioning-tasks.js +83 -0
  763. package/scripts/gds/seed-staging.sh +104 -0
  764. package/scripts/gds/seed-v2-world-tasks.js +283 -0
  765. package/scripts/gds/seed-v3-tasks.js +1981 -0
  766. package/scripts/gds/session-cost.js +393 -0
  767. package/scripts/gds/session-digest.js +179 -0
  768. package/scripts/gds/session-eval.js +576 -0
  769. package/scripts/gds/session.js +404 -0
  770. package/scripts/gds/setup.js +504 -0
  771. package/scripts/gds/shell.js +100 -0
  772. package/scripts/gds/ship.js +3618 -0
  773. package/scripts/gds/smoke-batch-claim.sh +213 -0
  774. package/scripts/gds/smoke-batch-release.sh +296 -0
  775. package/scripts/gds/smoke-box.sh +150 -0
  776. package/scripts/gds/smoke-claim-consistency.sh +212 -0
  777. package/scripts/gds/smoke-dependencies.sh +84 -0
  778. package/scripts/gds/smoke-gds.js +125 -0
  779. package/scripts/gds/smoke-write.sh +168 -0
  780. package/scripts/gds/start.js +782 -0
  781. package/scripts/gds/status.js +365 -0
  782. package/scripts/gds/triage.js +366 -0
  783. package/scripts/gds/upgrade.js +421 -0
  784. package/scripts/gds/validate-design.js +254 -0
  785. package/scripts/gds/version-close.js +549 -0
  786. package/scripts/migrate.sh +183 -0
  787. package/scripts/migrate_cost_ledger.sh +178 -0
  788. package/scripts/public-mirror-export.js +291 -0
  789. package/scripts/render-diagrams.sh +88 -0
  790. package/scripts/security/dep-audit.js +276 -0
  791. package/scripts/setup-builder.js +274 -0
  792. package/scripts/setup-builder.sh +28 -0
  793. package/scripts/snapshots/backfill-history.mjs +292 -0
  794. package/scripts/snapshots/maybe-render-on-deploy.sh +66 -0
  795. package/scripts/snapshots/render-pre-art-eras.mjs +329 -0
  796. package/scripts/snapshots/render-world.mjs +322 -0
  797. package/scripts/status-mirror-sync.js +192 -0
  798. package/scripts/status-probe.mjs +185 -0
  799. package/server.js +212 -0
  800. package/src/asset-version.js +71 -0
  801. package/src/bongos/CLAUDE.md +64 -0
  802. package/src/bongos/api-errors.js +281 -0
  803. package/src/bongos/api-path-404.js +67 -0
  804. package/src/bongos/api-prefix.js +38 -0
  805. package/src/bongos/app-pair.js +172 -0
  806. package/src/bongos/auth.js +979 -0
  807. package/src/bongos/cascade-dispatch.js +123 -0
  808. package/src/bongos/db-kernel.js +252 -0
  809. package/src/bongos/db.js +911 -0
  810. package/src/bongos/goal-scope-check.js +120 -0
  811. package/src/bongos/hierarchy-config.js +189 -0
  812. package/src/bongos/live-channel.js +145 -0
  813. package/src/bongos/llm-cache.js +260 -0
  814. package/src/bongos/llm-pricing.js +220 -0
  815. package/src/bongos/metrics.js +286 -0
  816. package/src/bongos/middleware/audit.js +274 -0
  817. package/src/bongos/middleware/error-envelope.js +93 -0
  818. package/src/bongos/middleware/rate-limit.js +195 -0
  819. package/src/bongos/module-scope-map.js +271 -0
  820. package/src/bongos/optimizer.js +166 -0
  821. package/src/bongos/path-match.js +47 -0
  822. package/src/bongos/permission-path-check.js +248 -0
  823. package/src/bongos/pool.js +55 -0
  824. package/src/bongos/repo-info.js +91 -0
  825. package/src/bongos/route-rank-check.js +420 -0
  826. package/src/bongos/routes/CLAUDE.md +43 -0
  827. package/src/bongos/routes/_helpers.js +369 -0
  828. package/src/bongos/routes/audit-log.js +59 -0
  829. package/src/bongos/routes/auth.js +755 -0
  830. package/src/bongos/routes/backup.js +104 -0
  831. package/src/bongos/routes/builders.js +526 -0
  832. package/src/bongos/routes/healthz.js +16 -0
  833. package/src/bongos/routes/instance.js +129 -0
  834. package/src/bongos/routes/live.js +125 -0
  835. package/src/bongos/routes/llm-cache.js +119 -0
  836. package/src/bongos/routes/me.js +380 -0
  837. package/src/bongos/routes/modules.js +131 -0
  838. package/src/bongos/routes/optimizer.js +54 -0
  839. package/src/bongos/routes/public.js +476 -0
  840. package/src/bongos/routes/security.js +207 -0
  841. package/src/bongos/routes.js +166 -0
  842. package/src/bongos/secret-box.js +177 -0
  843. package/src/bongos/sensitive-surfaces.js +30 -0
  844. package/src/bongos/serve-internal.js +889 -0
  845. package/src/bongos/uptime-poller.js +90 -0
  846. package/src/bongos-downloads.js +70 -0
  847. package/src/branding.js +182 -0
  848. package/src/build-info.js +77 -0
  849. package/src/devbox-downloads.js +67 -0
  850. package/src/instance-config.js +200 -0
  851. package/src/module-api.js +366 -0
  852. package/src/module-loader/loader.js +268 -0
  853. package/src/module-loader/manifest-schema.js +140 -0
  854. package/src/module-loader/provenance.js +28 -0
  855. package/src/module-loader/semver.js +100 -0
  856. package/src/module-seams.js +291 -0
  857. package/src/modules.js +254 -0
  858. package/src/platform-server.js +135 -0
  859. package/src/ui/adapters/claude-design/index.js +333 -0
  860. package/src/ui/adapters/figma/index.js +328 -0
  861. package/tests/abandon_task.mjs +138 -0
  862. package/tests/achievements_reconcile.mjs +167 -0
  863. package/tests/adopt_detect.mjs +141 -0
  864. package/tests/adopt_import.mjs +132 -0
  865. package/tests/adopt_preflight.mjs +105 -0
  866. package/tests/anti_grid.mjs +89 -0
  867. package/tests/api_client.mjs +231 -0
  868. package/tests/api_docs.mjs +283 -0
  869. package/tests/api_error_envelope_buildcheck.mjs +56 -0
  870. package/tests/api_error_envelope_middleware.mjs +99 -0
  871. package/tests/api_error_registry.mjs +108 -0
  872. package/tests/api_pagination_contract.mjs +51 -0
  873. package/tests/api_path_404.mjs +96 -0
  874. package/tests/app_pair.mjs +168 -0
  875. package/tests/architect.mjs +140 -0
  876. package/tests/art_key.mjs +121 -0
  877. package/tests/art_stage.mjs +121 -0
  878. package/tests/asset_version.mjs +94 -0
  879. package/tests/atlas.mjs +69 -0
  880. package/tests/audit_deps.mjs +74 -0
  881. package/tests/audit_middleware.mjs +385 -0
  882. package/tests/auth_admission_gate.mjs +164 -0
  883. package/tests/auth_anthropic_identity.mjs +95 -0
  884. package/tests/auth_duplicate_cookie.mjs +56 -0
  885. package/tests/auth_error_brand.mjs +77 -0
  886. package/tests/auth_flow_slice1.mjs +223 -0
  887. package/tests/auth_inactive_builder.mjs +94 -0
  888. package/tests/auth_page_gate.mjs +92 -0
  889. package/tests/auth_session_ttl.mjs +155 -0
  890. package/tests/autonomous_fit_protected_paths.mjs +115 -0
  891. package/tests/autonomy_gate.mjs +85 -0
  892. package/tests/autotile_variants.mjs +256 -0
  893. package/tests/autotile_xverify.mjs +133 -0
  894. package/tests/backfill_sessions.mjs +101 -0
  895. package/tests/backfill_task_rewards.mjs +152 -0
  896. package/tests/backup_cli.mjs +104 -0
  897. package/tests/bfg.mjs +327 -0
  898. package/tests/bfg_write.mjs +423 -0
  899. package/tests/bongos_box_connect.mjs +89 -0
  900. package/tests/bongos_dispatch.mjs +74 -0
  901. package/tests/bongos_downloads.mjs +118 -0
  902. package/tests/bongos_login.mjs +34 -0
  903. package/tests/box_access.mjs +186 -0
  904. package/tests/box_auth_check.mjs +114 -0
  905. package/tests/box_code_staleness.mjs +116 -0
  906. package/tests/box_connect_e2e.mjs +196 -0
  907. package/tests/box_cost.mjs +70 -0
  908. package/tests/box_dns_repoint.mjs +151 -0
  909. package/tests/box_host_keys.mjs +102 -0
  910. package/tests/box_onboard.mjs +317 -0
  911. package/tests/box_scope_session.mjs +115 -0
  912. package/tests/box_ship_permissions.mjs +71 -0
  913. package/tests/box_terminal_ssrf.mjs +90 -0
  914. package/tests/boxes.mjs +1255 -0
  915. package/tests/branding.mjs +88 -0
  916. package/tests/bug_attachments.mjs +189 -0
  917. package/tests/build_info.mjs +88 -0
  918. package/tests/builder_needs.mjs +99 -0
  919. package/tests/cap_newcomer_tasks.mjs +114 -0
  920. package/tests/cascade-dispatch.mjs +95 -0
  921. package/tests/cf_tunnel.mjs +459 -0
  922. package/tests/claim_recall.mjs +150 -0
  923. package/tests/claims_bind_session.mjs +47 -0
  924. package/tests/claude_materialize.mjs +148 -0
  925. package/tests/cli_box_scope.mjs +52 -0
  926. package/tests/cli_lib_formatters.mjs +87 -0
  927. package/tests/cli_token_reissue.mjs +147 -0
  928. package/tests/client_bundle_route.mjs +47 -0
  929. package/tests/cloud_bongos_pack.mjs +85 -0
  930. package/tests/conductor.mjs +496 -0
  931. package/tests/confirm_attribution.mjs +156 -0
  932. package/tests/conflict_resolve.mjs +462 -0
  933. package/tests/context_pack.mjs +240 -0
  934. package/tests/cost.mjs +94 -0
  935. package/tests/cost_audit.mjs +122 -0
  936. package/tests/cost_budget_status.mjs +78 -0
  937. package/tests/cost_reconcile.mjs +102 -0
  938. package/tests/cost_summary_shape.mjs +94 -0
  939. package/tests/credit_grant.mjs +376 -0
  940. package/tests/criterion_progress.mjs +220 -0
  941. package/tests/criterion_proposal.mjs +109 -0
  942. package/tests/criterion_ref_parse.mjs +53 -0
  943. package/tests/currency_label.mjs +119 -0
  944. package/tests/day_cycle.mjs +197 -0
  945. package/tests/dependencies_migration.mjs +77 -0
  946. package/tests/dependencies_polymorphic.mjs +212 -0
  947. package/tests/deploy_instance.mjs +78 -0
  948. package/tests/devbox_downloads.mjs +122 -0
  949. package/tests/diff_derived_signals.mjs +115 -0
  950. package/tests/discord_approvals.mjs +174 -0
  951. package/tests/discord_autojoin.mjs +158 -0
  952. package/tests/discord_bot.mjs +94 -0
  953. package/tests/discord_bugs.mjs +297 -0
  954. package/tests/discord_channels.mjs +337 -0
  955. package/tests/discord_foundation.mjs +76 -0
  956. package/tests/discord_inbound.mjs +190 -0
  957. package/tests/discord_message_buffer.mjs +87 -0
  958. package/tests/discord_oauth.mjs +55 -0
  959. package/tests/discord_reaction.mjs +170 -0
  960. package/tests/discord_role_sync.mjs +151 -0
  961. package/tests/display_name.mjs +123 -0
  962. package/tests/dns_preflight.mjs +74 -0
  963. package/tests/event_sound_prefs.mjs +194 -0
  964. package/tests/eviction_policy.mjs +279 -0
  965. package/tests/figma_adapter.mjs +199 -0
  966. package/tests/file_map.mjs +91 -0
  967. package/tests/first_admin_bootstrap.mjs +40 -0
  968. package/tests/fitness.mjs +435 -0
  969. package/tests/fitness_two_root.mjs +104 -0
  970. package/tests/fixtures/session-506-digest.json +34 -0
  971. package/tests/gate_approvals.mjs +451 -0
  972. package/tests/gate_review.mjs +152 -0
  973. package/tests/git_merge_regen.mjs +151 -0
  974. package/tests/github_push_ancestry.mjs +119 -0
  975. package/tests/github_push_land_proof.mjs +127 -0
  976. package/tests/github_push_merge_driver.mjs +175 -0
  977. package/tests/goal_abuse_matrix.mjs +187 -0
  978. package/tests/goal_achievement.mjs +146 -0
  979. package/tests/goal_conflicts.mjs +305 -0
  980. package/tests/goal_dependencies_read.mjs +73 -0
  981. package/tests/goal_member_roles.mjs +420 -0
  982. package/tests/goal_membership_requests.mjs +332 -0
  983. package/tests/goal_routes.mjs +203 -0
  984. package/tests/goal_scope_check.mjs +167 -0
  985. package/tests/goal_tier.mjs +312 -0
  986. package/tests/goal_wall_proof.mjs +112 -0
  987. package/tests/grade_attempts.mjs +73 -0
  988. package/tests/grade_attribution.mjs +117 -0
  989. package/tests/grade_diff_hash.mjs +191 -0
  990. package/tests/grade_server_authoritative.mjs +305 -0
  991. package/tests/grade_unavailable_display.mjs +66 -0
  992. package/tests/grader.mjs +1960 -0
  993. package/tests/grader_bypass.mjs +57 -0
  994. package/tests/grader_panel.mjs +252 -0
  995. package/tests/grader_prompt_fencing.mjs +192 -0
  996. package/tests/helpers.mjs +52 -0
  997. package/tests/hierarchy-config.mjs +81 -0
  998. package/tests/init.mjs +463 -0
  999. package/tests/instance_config.mjs +76 -0
  1000. package/tests/instance_manifest.mjs +110 -0
  1001. package/tests/interaction_prefs.mjs +72 -0
  1002. package/tests/key-card.mjs +110 -0
  1003. package/tests/key-status.mjs +45 -0
  1004. package/tests/land_watch.mjs +115 -0
  1005. package/tests/lifecycle-card.mjs +145 -0
  1006. package/tests/lifecycle_breakdown_view.mjs +236 -0
  1007. package/tests/linkify_refs.mjs +264 -0
  1008. package/tests/live_channel.mjs +128 -0
  1009. package/tests/live_sse.mjs +122 -0
  1010. package/tests/llm-cache-route.mjs +110 -0
  1011. package/tests/llm-cache.mjs +140 -0
  1012. package/tests/llm-pricing.mjs +152 -0
  1013. package/tests/local_preview.mjs +136 -0
  1014. package/tests/main_audit.mjs +137 -0
  1015. package/tests/memory_isolation.mjs +431 -0
  1016. package/tests/memory_map.mjs +107 -0
  1017. package/tests/memory_pull.mjs +429 -0
  1018. package/tests/merge_lease.mjs +100 -0
  1019. package/tests/merge_lock.mjs +312 -0
  1020. package/tests/metrics.mjs +147 -0
  1021. package/tests/migration_allocation.mjs +146 -0
  1022. package/tests/migration_namespace.mjs +185 -0
  1023. package/tests/migrations_fresh_replay_guard.mjs +38 -0
  1024. package/tests/mirror_lineage_fulltree.mjs +45 -0
  1025. package/tests/mirror_lineage_proof.mjs +72 -0
  1026. package/tests/mirror_redact.mjs +193 -0
  1027. package/tests/model_alloc_prefs.mjs +63 -0
  1028. package/tests/module-scope-map.mjs +88 -0
  1029. package/tests/module_api.mjs +110 -0
  1030. package/tests/module_cli.mjs +292 -0
  1031. package/tests/module_contributions.mjs +148 -0
  1032. package/tests/module_loader.mjs +286 -0
  1033. package/tests/module_manifest.mjs +194 -0
  1034. package/tests/module_owned_tests.mjs +105 -0
  1035. package/tests/module_provenance.mjs +34 -0
  1036. package/tests/module_route_rank.mjs +147 -0
  1037. package/tests/module_seams.mjs +68 -0
  1038. package/tests/modules.mjs +156 -0
  1039. package/tests/modules_route.mjs +38 -0
  1040. package/tests/newcomer_floor.mjs +17 -0
  1041. package/tests/newcomer_restock.mjs +80 -0
  1042. package/tests/next_number.mjs +154 -0
  1043. package/tests/oauth_secret.mjs +60 -0
  1044. package/tests/onboard.mjs +70 -0
  1045. package/tests/onboarding_gate.mjs +267 -0
  1046. package/tests/onboarding_observability.mjs +344 -0
  1047. package/tests/onboarding_route_signals.mjs +175 -0
  1048. package/tests/onboarding_state.mjs +346 -0
  1049. package/tests/one_active_claim_per_session.mjs +111 -0
  1050. package/tests/orphaned_claim_ship.mjs +135 -0
  1051. package/tests/override_attribution.mjs +142 -0
  1052. package/tests/override_request_ownership.mjs +235 -0
  1053. package/tests/package_core.mjs +435 -0
  1054. package/tests/parallel-load.mjs +402 -0
  1055. package/tests/path_match.mjs +62 -0
  1056. package/tests/permission_path.mjs +151 -0
  1057. package/tests/platform_boot.mjs +167 -0
  1058. package/tests/pre_compact.mjs +60 -0
  1059. package/tests/preview_server.mjs +127 -0
  1060. package/tests/profile_route.mjs +197 -0
  1061. package/tests/provision.mjs +805 -0
  1062. package/tests/provisioning_github_app_routes.mjs +98 -0
  1063. package/tests/public_mirror_export.mjs +124 -0
  1064. package/tests/public_task_resolver.mjs +112 -0
  1065. package/tests/publish_branch_route.mjs +422 -0
  1066. package/tests/publish_manifest.mjs +184 -0
  1067. package/tests/publish_reconciler.mjs +728 -0
  1068. package/tests/rank_gate.mjs +463 -0
  1069. package/tests/rate_limit_public.mjs +217 -0
  1070. package/tests/read_session_export.mjs +89 -0
  1071. package/tests/recall_cli.mjs +82 -0
  1072. package/tests/recall_hints.mjs +119 -0
  1073. package/tests/recent_shipped_window.mjs +80 -0
  1074. package/tests/regrade_eligibility.mjs +202 -0
  1075. package/tests/render_prefs.mjs +122 -0
  1076. package/tests/repo_map.mjs +92 -0
  1077. package/tests/reward_gate.mjs +284 -0
  1078. package/tests/route_rank_check.mjs +243 -0
  1079. package/tests/route_validation.mjs +490 -0
  1080. package/tests/routine_timers.mjs +72 -0
  1081. package/tests/rubric_fixtures.mjs +140 -0
  1082. package/tests/run_routine.mjs +69 -0
  1083. package/tests/sandbox_stage.mjs +582 -0
  1084. package/tests/scrubber_corpus.mjs +257 -0
  1085. package/tests/search_chunker.mjs +209 -0
  1086. package/tests/search_ingest.mjs +96 -0
  1087. package/tests/search_ingest_db.mjs +88 -0
  1088. package/tests/search_isolation.mjs +204 -0
  1089. package/tests/search_route.mjs +95 -0
  1090. package/tests/secret_box.mjs +189 -0
  1091. package/tests/security_docs.mjs +186 -0
  1092. package/tests/security_route.mjs +108 -0
  1093. package/tests/served_paths_shipped.mjs +118 -0
  1094. package/tests/session-cards.mjs +142 -0
  1095. package/tests/session_506_regression.mjs +69 -0
  1096. package/tests/session_caps.mjs +238 -0
  1097. package/tests/session_cost.mjs +133 -0
  1098. package/tests/session_digest.mjs +73 -0
  1099. package/tests/session_eval.mjs +386 -0
  1100. package/tests/session_index.mjs +105 -0
  1101. package/tests/session_records.mjs +369 -0
  1102. package/tests/session_search.mjs +78 -0
  1103. package/tests/session_start_freshness.mjs +123 -0
  1104. package/tests/setup_smoke.mjs +154 -0
  1105. package/tests/ship_autolinkify.mjs +60 -0
  1106. package/tests/ship_automerge_injection.mjs +174 -0
  1107. package/tests/ship_broadcast_routing.mjs +125 -0
  1108. package/tests/ship_broadcast_server.mjs +175 -0
  1109. package/tests/ship_card.mjs +280 -0
  1110. package/tests/ship_ci_deploy.mjs +374 -0
  1111. package/tests/ship_preflight.mjs +368 -0
  1112. package/tests/ship_premerge.mjs +75 -0
  1113. package/tests/ship_resume.mjs +129 -0
  1114. package/tests/ship_upload_skip.mjs +89 -0
  1115. package/tests/ship_zero_commit.mjs +60 -0
  1116. package/tests/single_host_hall.mjs +98 -0
  1117. package/tests/skill_prefs.mjs +150 -0
  1118. package/tests/smoke_preflight.mjs +66 -0
  1119. package/tests/snapshot_picker.mjs +233 -0
  1120. package/tests/sound_prefs.mjs +198 -0
  1121. package/tests/sprite_facing.mjs +35 -0
  1122. package/tests/status-probe.mjs +89 -0
  1123. package/tests/status_backfill.mjs +94 -0
  1124. package/tests/stop_refcheck.mjs +127 -0
  1125. package/tests/streak.mjs +175 -0
  1126. package/tests/subdomain_auth.mjs +58 -0
  1127. package/tests/task_classifier_vocab.mjs +41 -0
  1128. package/tests/task_create_idempotency.mjs +96 -0
  1129. package/tests/task_goal_link.mjs +93 -0
  1130. package/tests/task_ownership_gate.mjs +125 -0
  1131. package/tests/terrain_picker.mjs +298 -0
  1132. package/tests/touches_overlap.mjs +75 -0
  1133. package/tests/triage_development.mjs +140 -0
  1134. package/tests/turn_in_place.mjs +280 -0
  1135. package/tests/upgrade.mjs +357 -0
  1136. package/tests/verify_task_guard.mjs +64 -0
  1137. package/tests/version_close.mjs +76 -0
  1138. package/tests/versions_route.mjs +93 -0
  1139. package/tests/walkability.mjs +188 -0
  1140. package/tests/wandering_prefs.mjs +176 -0
  1141. package/tests/worktree_disambiguation.mjs +105 -0
  1142. package/tests/worktree_guard.mjs +90 -0
@@ -0,0 +1,3028 @@
1
+ // public-builders/builders.js — the authenticated /builders single-page UI.
2
+ //
3
+ // Read-only across the wire EXCEPT the per-task copy-prompt button, which is
4
+ // purely client-side (writes to the clipboard, no server call).
5
+ //
6
+ // Auth model: cookie-based (gds_session, set by /api/bongos/auth/web/callback;
7
+ // the legacy pms_session cookie is still accepted on read during V3 rollout).
8
+ // On a 401 from /api/bongos/me we render an unauth landing card with a Sign-in
9
+ // button; on a 401 from any other endpoint we redirect straight into the web
10
+ // flow (the user was authenticated and now isn't — likely an expired session).
11
+
12
+ (() => {
13
+ 'use strict';
14
+
15
+ const API = '/api/bongos';
16
+ // Generated typed client (task 1996 / R09), served as window.BongosClient by
17
+ // /lib/bongos-client.js. baseUrl:'' passes the full same-origin paths this file
18
+ // builds through unchanged; result-mode ({ status, ok, data }) preserves control flow 1:1.
19
+ const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
20
+ const AUTH_NEEDED = 'auth-needed';
21
+
22
+ // moduleOn(name) — feature-module gate (R60, task 1199), shared via window.OTB
23
+ // (dom-utils.js). Reads the injected window.__MODULES__; defaults to TRUE when a
24
+ // module flag is missing so the full UI shows rather than silently hiding.
25
+ const { moduleOn } = window.OTB;
26
+
27
+ // ---- tiny helpers --------------------------------------------------------
28
+ // escapeHtml / $ / fmtNum / fmtDate / rankLabel / toast now come from the
29
+ // shared window.OTB toolkit (dom-utils.js, loaded first). Thin wrappers keep
30
+ // this file's exact variants (date-only fmtDate; '—' rankLabel empty; a
31
+ // 2200ms clearing toast). renderRankBadge + RANK_ORDER stay local below.
32
+ const { escapeHtml, $, fmtNum } = window.OTB;
33
+ const fmtDate = (iso) => window.OTB.fmtDate(iso);
34
+ const rankLabel = (rank) => window.OTB.rankLabel(rank);
35
+ const toast = (msg) => window.OTB.toast(msg, { duration: 2200, clear: true });
36
+
37
+ // Instance strings from the server-injected pack (window.__BRANDING__ is set
38
+ // synchronously in <head>, before this script) — never a hardcoded host brand
39
+ // (ADR 0062 §3). Neutral fallbacks so a missing pack degrades gracefully.
40
+ const brandWorld = () => (window.__BRANDING__ && window.__BRANDING__.identity && window.__BRANDING__.identity.worldName) || 'the project';
41
+ const curLabel = (caps) => {
42
+ const l = (window.__BRANDING__ && window.__BRANDING__.currency && window.__BRANDING__.currency.label) || 'credits';
43
+ return caps ? l.charAt(0).toUpperCase() + l.slice(1) : l;
44
+ };
45
+
46
+ async function getJSON(path, opts = {}) {
47
+ const res = await api.request('GET', path);
48
+ if (res.status === 401) {
49
+ if (opts.softAuth) {
50
+ const err = new Error(AUTH_NEEDED);
51
+ err.code = AUTH_NEEDED;
52
+ throw err;
53
+ }
54
+ window.location.assign(`${API}/auth/web/start`);
55
+ throw new Error('redirecting to auth');
56
+ }
57
+ if (!res.ok) throw new Error(`${path} → ${res.status}`);
58
+ return res.data;
59
+ }
60
+
61
+ async function patchJSON(path, body) {
62
+ const res = await api.request('PATCH', path, { body });
63
+ if (res.status === 401) {
64
+ window.location.assign(`${API}/auth/web/start`);
65
+ throw new Error('redirecting to auth');
66
+ }
67
+ if (!res.ok) throw new Error(`${path} → ${res.status}`);
68
+ return res.data;
69
+ }
70
+
71
+ async function postJSON(path, body) {
72
+ const res = await api.request('POST', path, { body });
73
+ if (res.status === 401) {
74
+ window.location.assign(`${API}/auth/web/start`);
75
+ throw new Error('redirecting to auth');
76
+ }
77
+ if (!res.ok) {
78
+ let detail = `${path} → ${res.status}`;
79
+ let code = null;
80
+ try {
81
+ const j = res.data;
82
+ // Prefer the human-facing message; fall back to the machine code.
83
+ detail = j.message || j.detail || j.error || detail;
84
+ code = j.error || null;
85
+ } catch (_) { /* keep default */ }
86
+ const err = new Error(detail);
87
+ err.status = res.status;
88
+ err.code = code;
89
+ throw err;
90
+ }
91
+ return res.data;
92
+ }
93
+
94
+ // ---- shared render helpers (C10) ----------------------------------------
95
+
96
+ // emptyRow — the placeholder/error <tr> every ledger table renders when it has
97
+ // nothing (or failed) to show. `message` is plain copy (escaped defensively
98
+ // even though current callers pass static literals).
99
+ function emptyRow(colspan, message) {
100
+ return `<tr><td colspan="${colspan}" class="ledger__empty">${escapeHtml(message)}</td></tr>`;
101
+ }
102
+
103
+ // signClasses — the up/down/flat BEM modifier for a signed number. `prefix` is
104
+ // the block, e.g. signClasses(delta, 'karma-feed__delta') → 'karma-feed__delta--up'.
105
+ // The +/- TEXT differs per feed (fmtNum vs raw int vs '±0'), so callers format
106
+ // the displayed value themselves.
107
+ function signClasses(n, prefix) {
108
+ const v = Number(n) || 0;
109
+ return `${prefix}--${v > 0 ? 'up' : v < 0 ? 'down' : 'flat'}`;
110
+ }
111
+
112
+ // renderPager — the works-wrought / roster twin pager. Clamps state.page into
113
+ // range (mutating it), hides the pager when there is ≤1 page, writes the
114
+ // "start–end of total" range label, and toggles prev/next. Returns { start } so
115
+ // the caller can slice. NOTE: the leaderboard pager deliberately does NOT use
116
+ // this — it never clamps and keeps its range/buttons always visible.
117
+ function renderPager(state, total, pageSize, { pagerEl, rangeEl, prevBtn, nextBtn }) {
118
+ const pageCount = Math.max(1, Math.ceil(total / pageSize));
119
+ if (state.page >= pageCount) state.page = pageCount - 1;
120
+ if (state.page < 0) state.page = 0;
121
+ const start = state.page * pageSize;
122
+ if (pagerEl) {
123
+ if (pageCount > 1) {
124
+ pagerEl.hidden = false;
125
+ if (rangeEl) {
126
+ const end = Math.min(start + pageSize, total);
127
+ rangeEl.textContent = `${start + 1}–${end} of ${total}`;
128
+ }
129
+ if (prevBtn) prevBtn.disabled = state.page === 0;
130
+ if (nextBtn) nextBtn.disabled = state.page >= pageCount - 1;
131
+ } else {
132
+ pagerEl.hidden = true;
133
+ }
134
+ }
135
+ return { start };
136
+ }
137
+
138
+ // ---- repo info (the clone URL the welcome walkthrough hands a newcomer) ---
139
+ //
140
+ // The /builder-* commands live INSIDE the repo (.claude/skills/, shelling out
141
+ // to scripts/gds/*.js). Setup now runs on a hosted dev box (#777), so the
142
+ // newcomer never clones a repo locally — the old repo-clone walkthrough (and
143
+ // the /public/repo-info fetch that fed it) was removed with the setup-first
144
+ // redesign. The /public/repo-info surface still exists for other callers.
145
+
146
+ // ---- rank badge (V3.R16 #240) -------------------------------------------
147
+ // Three live ranks per ADR 0018: archon > metic > xenos. Missing rank
148
+ // renders an em-dash plaque so the layout never breaks.
149
+
150
+ const RANK_ORDER = { archon: 4, metic: 3, thetes: 2, xenos: 1 };
151
+
152
+ function rankWeight(rank) {
153
+ return RANK_ORDER[rank] ?? -1;
154
+ }
155
+
156
+ // Coerce any rank that isn't one of the four live ranks (archon/metic/thetes/
157
+ // xenos — #692 / ADR 0034) to the entry rank. Reserved values still on the
158
+ // citizen/divine ladder would otherwise render as a dashed "unknown" plaque
159
+ // while /builders/ranks shows them as xenos. Applied to the
160
+ // builder-facing standing surfaces — the welcome panel, the profile Standing
161
+ // badge, and the public Roll — so they agree. NOT applied to the Archon-only
162
+ // roster below, which intentionally shows the true stored rank so an Archon
163
+ // can spot and fix a stray value. Mirrors the RANK_LADDER coercion in ranks.js.
164
+ function normalizeRank(rank) {
165
+ return RANK_ORDER[rank] ? rank : 'xenos';
166
+ }
167
+
168
+ // Short one-line summary of what each rank unlocks. Kept terse — the hall's
169
+ // job is the daily action surface, not the full permissions reference (that
170
+ // lives at /builders/ranks). Sourced from docs/canonical-permissions.md.
171
+ // Used by the rank-badge hover tooltip (opts.tip) and mirrored in ranks.js.
172
+ const RANK_UNLOCKS = {
173
+ xenos: [
174
+ 'Read every public page.',
175
+ 'Claim and ship tasks an Archon has flagged newcomer-friendly.',
176
+ `Earn ${curLabel()} for shipped work and karma for ratified ideas.`,
177
+ ],
178
+ thetes: [
179
+ 'Claim and ship any task in the general queue — no longer only newcomer-friendly work.',
180
+ `Keep earning ${curLabel()} and karma for every clean ship.`,
181
+ 'No triage, blockers, planning, or task-shaping yet — those levers open at Metic.',
182
+ ],
183
+ metic: [
184
+ 'Claim and ship any task in the ready queue.',
185
+ 'Triage the idea inbox; resolve blockers; run planning sessions.',
186
+ 'Propose new version criteria for Archon ratification.',
187
+ ],
188
+ archon: [
189
+ 'Create, promote, and edit tasks; reshape the dependency graph.',
190
+ 'Grant or revoke ranks; close versions; confirm ships past the grader.',
191
+ 'Adjudicate vulnerability reports and override requests.',
192
+ ],
193
+ };
194
+
195
+ // Monotonic counter for unique tooltip ids (aria-describedby). Re-renders
196
+ // replace innerHTML so duplicates would otherwise collide.
197
+ let rankTipSeq = 0;
198
+
199
+ function renderRankBadge(rank, opts = {}) {
200
+ const label = escapeHtml(rankLabel(rank));
201
+ const ariaLabel = rank ? `Rank: ${rankLabel(rank)}` : 'Rank not yet recorded';
202
+ const variant = RANK_ORDER[rank] ? `rank-badge--${rank}` : 'rank-badge--unknown';
203
+
204
+ // Plain plaque (default — used in tables, roster, leaderboard).
205
+ if (!opts.tip || !RANK_UNLOCKS[rank]) {
206
+ const title = opts.title ? ` title="${escapeHtml(opts.title)}"` : '';
207
+ return `<span class="rank-badge ${variant}" aria-label="${escapeHtml(ariaLabel)}"${title}>${label}</span>`;
208
+ }
209
+
210
+ // Tooltip variant — a hover/focus popover lists what this rank unlocks,
211
+ // and a transparent "stretched link" overlay makes the whole plaque
212
+ // navigate to the full ladder. We keep the badge a <span> (not an <a>) on
213
+ // purpose: the hall's global a:hover rule repaints links terracotta, which
214
+ // would otherwise bleed into the plaque AND the tip text. The overlay
215
+ // anchor carries the navigation + keyboard focus without touching colour.
216
+ // NB: this markup contains a <ul>, which is NOT valid inside a <p>. If a
217
+ // caller drops this tipped badge into a <p>, the HTML parser auto-closes
218
+ // the <p> at the <ul> and hoists the list out of .rank-tip — the unlocks
219
+ // then render as a visible block instead of a hover popover (#530). Render
220
+ // it inside a <div>/inline container, never a <p>.
221
+ const tipId = `rank-tip-${rank}-${rankTipSeq++}`;
222
+ const items = RANK_UNLOCKS[rank].map((u) => `<li>${escapeHtml(u)}</li>`).join('');
223
+ return `<span class="rank-badge-wrap">` +
224
+ `<span class="rank-badge ${variant} rank-badge--tipped" aria-hidden="true">${label}</span>` +
225
+ `<a class="rank-badge-stretch" href="/ranks" ` +
226
+ `aria-describedby="${tipId}" ` +
227
+ `aria-label="${escapeHtml(ariaLabel)} — see all standings"></a>` +
228
+ `<span class="rank-tip" id="${tipId}" role="tooltip">` +
229
+ `<span class="rank-tip__label">What ${label} unlocks</span>` +
230
+ `<ul class="rank-tip__list">${items}</ul>` +
231
+ `<span class="rank-tip__more">Walk the full ladder →</span>` +
232
+ `</span>` +
233
+ `</span>`;
234
+ }
235
+
236
+ // ---- profile -------------------------------------------------------------
237
+
238
+ // Cached karma payload from the most recent /me call. Lets non-karma
239
+ // re-renders (e.g. saving a discipline preference, which PATCHes the
240
+ // builder row but not karma) keep showing the same karma badge instead
241
+ // of flickering to zero. Updated only by loadAll().
242
+ let lastKarmaState = null;
243
+ // V3.R84 (#302), criterion C3: cache the most recent /me `cost` status so the
244
+ // Standing-card budget banner survives re-renders triggered by responses that
245
+ // don't carry cost (e.g. the disciplines PATCH).
246
+ let lastCostState = null;
247
+ // task 1013 / ADR 0073: cache the most recent /me `needs` list so the
248
+ // Standing-card "Action needed" banner survives re-renders that don't carry
249
+ // needs (same pattern as lastCostState).
250
+ let lastNeedsState = null;
251
+
252
+ // Cached /me payload from the most recent loadAll() call. Used by the
253
+ // welcome-chip reopen path (#408) so we can re-render the welcome panel
254
+ // from cached state without a fresh /me round-trip. Distinct from
255
+ // lastKarmaState: that one only stashes karma; this stashes the whole
256
+ // payload so renderWelcome's builder + onboarding args are available.
257
+ let lastMeState = null;
258
+
259
+ // Instance repo coords for the "get the code" clone block. The public instance
260
+ // manifest (GET /api/bongos/instance) is the channel for cross-instance repo coords
261
+ // — clientBranding deliberately omits `repo`, so window.__BRANDING__ can't carry
262
+ // it. Cached; the welcome re-renders when it lands so a fresh instance shows ITS
263
+ // OWN repo, never a hardcoded one (ADR 0062 §3, task 1705).
264
+ let instanceRepo = null;
265
+ async function loadInstanceRepo() {
266
+ try {
267
+ const inst = await getJSON(`${API}/instance`, { softAuth: true });
268
+ instanceRepo = inst && inst.repo && !inst.repo.error ? inst.repo : null;
269
+ if (instanceRepo && lastMeState) renderWelcome(lastMeState.builder, lastMeState.onboarding || null);
270
+ } catch (_) { /* best-effort — the clone block falls back to generic guidance */ }
271
+ }
272
+
273
+ // ---- hall section nav (#408) --------------------------------------------
274
+ //
275
+ // Single source of truth for what appears in the sticky strip at top of
276
+ // the page. Each entry binds a section's DOM id to a short Cinzel label
277
+ // and any rank/visibility gates. Order here is render order, not chip
278
+ // order — but rendering happens after all sections are in the DOM, so
279
+ // the two end up matching.
280
+ //
281
+ // Filter rules at render time:
282
+ // archonOnly: true → skip unless /me returns rank=archon.
283
+ // welcomeChip:true → ALWAYS shown pre-graduation (even when the
284
+ // panel is dismissed) so the builder can re-open
285
+ // it. Hidden post-graduation just like the panel.
286
+ // default → skip if the section's element is [hidden].
287
+ //
288
+ // Adding a new section: add a row here AND give the <section> a stable
289
+ // id. The nav inherits ranking from /me automatically.
290
+
291
+ // Hall section order (#726). The hall is a citizen's PERSONAL surface:
292
+ // Standing, Honours, Karma (with acclaim folded under it), Works, then
293
+ // Marks/Curve/Bounty/Roll. The Archon monitoring sections (Watch/Gate/Roster)
294
+ // moved to the dedicated /builders/watch page; the per-skill model picker
295
+ // (Voices) + craft preferences moved to /builders/settings. Both are linked
296
+ // from the header gated-nav, not the section strip.
297
+ //
298
+ // BV1.R75 (ADR 0062 §4): the SECTIONS array + the inline <section> markup were
299
+ // the two hardcoded copies of "what the hall shows". Both are gone. The single
300
+ // source of truth is now the hall-widget registry (window.OTB, dom-utils.js):
301
+ // hall-widgets.js registers the core widgets above (loads before this script),
302
+ // a module's client script registers its own (gated by loader.js + moduleOn).
303
+ // SECTIONS() derives the nav rows from the registry, preserving order + the
304
+ // per-widget gates (welcomeChip / archonOnly) carried on the registry entry.
305
+ const SECTIONS = () => window.OTB.hallWidgets().map((wgt) => ({
306
+ id: wgt.id,
307
+ label: wgt.label,
308
+ view: wgt.view || 'home',
309
+ welcomeChip: !!wgt.welcomeChip,
310
+ archonOnly: !!wgt.archonOnly,
311
+ }));
312
+
313
+ // task 1723: which container a widget mounts into. A widget may declare
314
+ // `zone` (a fine-grained Home slot) or `view` (home/standing/achievements/
315
+ // leaderboard); both default to the Home main zone so pre-redesign module
316
+ // widgets still land somewhere visible. Every container lives INSIDE <main>,
317
+ // so the `main .scroll` sweeps (newcomer gate, landing hide-all) keep working.
318
+ const VIEW_CONTAINERS = {
319
+ home: 'zone-home-main',
320
+ standing: 'zone-standing',
321
+ achievements: 'zone-achievements',
322
+ leaderboard: 'zone-leaderboard',
323
+ };
324
+ function widgetHost(wgt) {
325
+ const zone = wgt.zone ? document.getElementById(`zone-${wgt.zone}`) : null;
326
+ if (zone) return zone;
327
+ const viewId = VIEW_CONTAINERS[wgt.view || 'home'] || VIEW_CONTAINERS.home;
328
+ return document.getElementById(viewId) || document.querySelector('main');
329
+ }
330
+
331
+ // mountHallWidgets — stamp every registered widget's <section> into its view
332
+ // container (falling back to <main>), in registry order, then render its
333
+ // skeleton via mount(host). Idempotent: a second call (defensive) re-uses an
334
+ // existing <section>. Runs once at bootstrap BEFORE wireEvents() so the static
335
+ // element ids the data-render + event-wiring code expects (#welcome-minimize,
336
+ // #recent-ships, #lb-prev, …) are present. Each widget mount is
337
+ // failure-isolated so one bad widget can't blank the hall.
338
+ function mountHallWidgets() {
339
+ const main = document.querySelector('main');
340
+ if (!main || typeof window.OTB.hallWidgets !== 'function') return;
341
+ for (const wgt of window.OTB.hallWidgets()) {
342
+ try {
343
+ let section = document.getElementById(wgt.id);
344
+ if (!section) {
345
+ section = document.createElement('section');
346
+ section.id = wgt.id;
347
+ (widgetHost(wgt) || main).appendChild(section);
348
+ }
349
+ section.className = wgt.sectionClass || 'scroll';
350
+ if (wgt.labelledby) section.setAttribute('aria-labelledby', wgt.labelledby);
351
+ // hiddenByDefault widgets (welcome/onboarding/karma/acclaim/curve) start
352
+ // hidden exactly as the old inline markup did; their own render code
353
+ // un-hides them when there's something to show.
354
+ if (wgt.hiddenByDefault) section.hidden = true;
355
+ wgt.mount(section);
356
+ } catch (err) {
357
+ console.error(`[builders] hall widget "${wgt && wgt.id}" failed to mount`, err);
358
+ }
359
+ }
360
+ // brand-apply.js's currency-label pass ran at DOMContentLoaded, BEFORE these
361
+ // skeletons existed — re-run it over the mounted widgets so [data-currency-
362
+ // label]/[data-currency-tmpl] cells show the instance's unit, not the fallback.
363
+ window.OTBBranding?.applyCurrencyLabels?.(main);
364
+ }
365
+
366
+ // The IntersectionObserver instance survives across re-renders (it's
367
+ // disconnected and re-created when nav contents change). Stored on
368
+ // module scope so nav re-renders can dispose of the previous one.
369
+ let navObserver = null;
370
+
371
+ // task 1723: the old sticky chip strip is gone. renderNav now settles the
372
+ // three nav surfaces the shell owns or hosts:
373
+ // 1. the sidebar's gated entries, via window.OTBShell.applyAccess (cosmetic —
374
+ // the server enforces every page + endpoint regardless, ADR 0016);
375
+ // 2. the Home re-open affordance for a dismissed welcome banner (the old
376
+ // welcome chip's only job);
377
+ // 3. the Standing view's in-page section rail (#standing-rail), rebuilt from
378
+ // the registry's standing widgets that are currently shown.
379
+ // Keeps its (builder, onboarding) signature — all call sites pass them.
380
+ function renderNav(builder, onboarding) {
381
+ const isArchon = (builder?.rank || '').toLowerCase() === 'archon';
382
+ const graduated = !!(onboarding && onboarding.graduated);
383
+ // #833: for a newcomer the Work entry surfaces only once the "ship three"
384
+ // stage is current — before then the path reads one action at a time.
385
+ const stages = Array.isArray(onboarding?.stages) ? onboarding.stages : [];
386
+ const shipStepCurrent = stages.some((s) => s.id === 'ship_three' && s.is_current && !s.cleared);
387
+ window.OTBShell?.applyAccess({
388
+ authed: true,
389
+ rank: normalizeRank(builder?.rank),
390
+ graduated,
391
+ showWork: shipStepCurrent,
392
+ builder,
393
+ });
394
+
395
+ // Re-open affordance: pre-graduation, when the welcome banner is dismissed
396
+ // (hidden), a small link brings it back (reopenWelcomeFromNav).
397
+ const reopen = document.getElementById('welcome-reopen');
398
+ if (reopen) {
399
+ const welcomeEl = document.getElementById('welcome-scroll');
400
+ reopen.hidden = !(welcomeEl && welcomeEl.hidden && !graduated);
401
+ }
402
+
403
+ // Standing section rail: one link per visible standing panel.
404
+ const rail = document.getElementById('standing-rail');
405
+ if (rail) {
406
+ const items = SECTIONS().filter((s) => {
407
+ if (s.view !== 'standing') return false;
408
+ if (s.archonOnly && !isArchon) return false;
409
+ const el = document.getElementById(s.id);
410
+ return !!el && !el.hidden;
411
+ });
412
+ rail.innerHTML = items.map((s) =>
413
+ `<a class="rail-link" href="#${escapeHtml(s.id)}" data-section="${escapeHtml(s.id)}">${escapeHtml(s.label)}</a>`
414
+ ).join('');
415
+ rail.hidden = items.length < 2;
416
+ }
417
+
418
+ // Rebuild the observer to track whichever sections are currently in
419
+ // the rail. Doing this on each renderNav call covers both initial load
420
+ // and post-dismiss/re-open re-renders.
421
+ setupNavObserver();
422
+ }
423
+
424
+ // #748: newcomer gate. A Xenos sees a deliberately spare hall — their
425
+ // onboarding path and their Standing card — until they graduate to Thetes.
426
+ // Everything else (Honours, Karma, Acclaim, Works, Claim, Marks, Bounty,
427
+ // Roll) stays hidden so a fresh arrival isn't drowned before they've
428
+ // shipped their first works. The Settings link is the exception (task
429
+ // 1291): a newcomer needs it for first-time setup — CLI re-auth (Settings
430
+ // → CLI Access) and choosing their craft both live there. #766 also hides the
431
+ // citizen's primer + the "how the system works" maps — a newcomer's path is
432
+ // action-only. #770: the Work Board link IS shown to a Xenos — a newcomer
433
+ // needs to find the work they can claim, so the path "find work → claim →
434
+ // ship three" stays one click away; the rank ladder also stays so they can
435
+ // see what they're climbing toward. This is cosmetic UX only — every data
436
+ // endpoint stays server-rank-gated regardless (the Work Board's claimable
437
+ // feed only returns work accessible to the caller's rank), and a Xenos who
438
+ // types /primer or /diagrams directly is bounced to /builders/not-ready by
439
+ // the server (#770). Set early in loadAll so the async karma/acclaim feeds
440
+ // (which un-hide their own scroll on data) respect it; the section-hiding
441
+ // sweep then runs before renderNav so the chip strip matches.
442
+ const NEWCOMER_VISIBLE_SECTIONS = new Set([
443
+ 'welcome-scroll', 'onboarding-scroll', 'profile-scroll',
444
+ ]);
445
+ let newcomerGated = false;
446
+
447
+ function applyNewcomerGate() {
448
+ if (!newcomerGated) return;
449
+ document.querySelectorAll('main .scroll').forEach((el) => {
450
+ if (!NEWCOMER_VISIBLE_SECTIONS.has(el.id)) el.hidden = true;
451
+ });
452
+ // task 1723: the newcomer LINK gating (primer/diagrams/atlas hidden; Work
453
+ // only once the "ship three" stage is current, #833; Settings always
454
+ // visible, task 1291) moved to the shell sidebar — renderNav reports rank +
455
+ // showWork to OTBShell.applyAccess and shell.js resolves the 'member'/'work'
456
+ // gates. This function keeps only the widget-section sweep.
457
+ }
458
+
459
+ function setupNavObserver() {
460
+ if (navObserver) {
461
+ navObserver.disconnect();
462
+ navObserver = null;
463
+ }
464
+ const list = document.getElementById('standing-rail');
465
+ if (!list) return;
466
+
467
+ const chipsBySection = new Map();
468
+ list.querySelectorAll('a[data-section]').forEach((a) => {
469
+ chipsBySection.set(a.dataset.section, a);
470
+ });
471
+ if (chipsBySection.size === 0) return;
472
+
473
+ const sections = Array.from(chipsBySection.keys())
474
+ .map((id) => document.getElementById(id))
475
+ .filter((el) => el && !el.hidden);
476
+
477
+ if (sections.length === 0) return;
478
+
479
+ // rootMargin trims the active band to roughly the upper third of the
480
+ // viewport (top 40% ignored, bottom 55% ignored — a ~5% horizontal
481
+ // strip about a third of the way down the page). The active flip
482
+ // happens when a section headline crosses that band, not when the
483
+ // section first peeks in. Matches the rationale in the task spec.
484
+ navObserver = new IntersectionObserver((entries) => {
485
+ for (const entry of entries) {
486
+ if (entry.isIntersecting) {
487
+ const id = entry.target.id;
488
+ chipsBySection.forEach((chip, sectionId) => {
489
+ chip.classList.toggle('rail-link--active', sectionId === id);
490
+ });
491
+ // Auto-scroll the active chip into view on mobile so the user
492
+ // can see what section they're in without having to scroll the
493
+ // nav strip themselves.
494
+ const active = chipsBySection.get(id);
495
+ if (active && window.matchMedia('(max-width: 640px)').matches) {
496
+ active.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
497
+ }
498
+ break;
499
+ }
500
+ }
501
+ }, {
502
+ rootMargin: '-40% 0px -55% 0px',
503
+ threshold: 0,
504
+ });
505
+
506
+ sections.forEach((sec) => navObserver.observe(sec));
507
+ }
508
+
509
+ function reopenWelcomeFromNav(e) {
510
+ e.preventDefault();
511
+ const id = window.__buildersSelfId;
512
+ if (!id || !lastMeState) return;
513
+ try {
514
+ window.localStorage.removeItem(welcomeDismissalKey(id));
515
+ } catch (_) { /* private mode — re-open in-memory only */ }
516
+ // Re-render welcome from cached /me state and refresh the nav so the
517
+ // chip drops its "dismissed" styling.
518
+ renderWelcome(lastMeState.builder, lastMeState.onboarding || null);
519
+ renderNav(lastMeState.builder, lastMeState.onboarding || null);
520
+ // Now scroll to the (newly-unhidden) welcome panel.
521
+ const target = document.getElementById('welcome-scroll');
522
+ if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
523
+ }
524
+
525
+ function renderLanding() {
526
+ // task 1723: collapse the shell nav to the signed-out minimum (Home + a
527
+ // Sign-in button). Cosmetic — the server gates every page regardless.
528
+ window.OTBShell?.applyAccess({ authed: false });
529
+
530
+ const welcome = $('#welcome-line');
531
+ if (welcome) welcome.textContent = '';
532
+
533
+ const landing = $('#landing-root');
534
+ if (landing) {
535
+ landing.hidden = false;
536
+ landing.innerHTML = `
537
+ <div class="scroll landing">
538
+ <h2 class="scroll__h">Welcome</h2>
539
+ <p class="landing__lede">
540
+ This is the builders' hub for ${brandWorld()} — where builders claim
541
+ work, earn ${curLabel()}, and track their standing. Access is
542
+ invite-based: an Archon approves each new builder. To begin, request
543
+ access below.
544
+ </p>
545
+
546
+ <div class="landing__request" id="landing-request">
547
+ <h3 class="landing__request-h">New here — request access</h3>
548
+ <p class="landing__request-lede">
549
+ Leave your GitHub username and an Archon will let you in. No coding
550
+ needed to start — once you're approved, sign in with GitHub and a
551
+ step-by-step guide takes you the rest of the way.
552
+ </p>
553
+ <form class="landing__form" id="access-request-form" novalidate>
554
+ <label class="landing__field">
555
+ <span>GitHub username <span class="landing__req-mark" aria-hidden="true">*</span></span>
556
+ <input type="text" id="ar-login" name="github_login" autocomplete="username"
557
+ placeholder="e.g. octocat" maxlength="40" required>
558
+ </label>
559
+ <label class="landing__field">
560
+ <span>Your name <span class="landing__opt">(optional)</span></span>
561
+ <input type="text" id="ar-name" name="display_name" autocomplete="name"
562
+ placeholder="So the Archon knows who you are" maxlength="100">
563
+ </label>
564
+ <label class="landing__field">
565
+ <span>A word on why <span class="landing__opt">(optional)</span></span>
566
+ <input type="text" id="ar-note" name="note"
567
+ placeholder="How you got here, what you'd build" maxlength="500">
568
+ </label>
569
+ <p class="landing__form-hint">
570
+ No GitHub account yet? Make a free one at
571
+ <a href="https://github.com/signup" target="_blank" rel="noopener noreferrer">github.com</a>
572
+ first — your username is all we need.
573
+ </p>
574
+ <button type="submit" class="landing__btn landing__btn--request" id="ar-submit">Request access</button>
575
+ <p class="landing__request-status" id="ar-status" aria-live="polite"></p>
576
+ </form>
577
+ </div>
578
+
579
+ <p class="landing__signin-note">
580
+ Already a member? <a href="${API}/auth/web/start">Sign in with GitHub</a>.
581
+ We store only your GitHub login, avatar, and the work you ship.
582
+ </p>
583
+ </div>
584
+ `;
585
+ wireAccessRequestForm();
586
+ }
587
+
588
+ // Hide every widget section — nothing to show until the visitor signs in.
589
+ document.querySelectorAll('main .scroll').forEach((el) => {
590
+ if (!landing || !landing.contains(el)) el.hidden = true;
591
+ });
592
+ }
593
+
594
+ // Wires the public landing's access-request form. Submits to the one public
595
+ // GDS write (POST /access-requests). On success the form collapses into a
596
+ // confirmation so the stranger knows the Archon has been pinged.
597
+ function wireAccessRequestForm() {
598
+ const form = $('#access-request-form');
599
+ if (!form) return;
600
+ form.addEventListener('submit', async (e) => {
601
+ e.preventDefault();
602
+ const loginEl = $('#ar-login');
603
+ const nameEl = $('#ar-name');
604
+ const noteEl = $('#ar-note');
605
+ const submit = $('#ar-submit');
606
+ const status = $('#ar-status');
607
+ const login = (loginEl?.value || '').trim().replace(/^@/, '');
608
+ if (!login) {
609
+ if (status) { status.dataset.kind = 'err'; status.textContent = 'A GitHub username is needed.'; }
610
+ if (loginEl) loginEl.focus();
611
+ return;
612
+ }
613
+ if (submit) { submit.disabled = true; submit.textContent = 'Sending…'; }
614
+ if (status) { status.dataset.kind = ''; status.textContent = ''; }
615
+ try {
616
+ const data = await postJSON(`${API}/access-requests`, {
617
+ github_login: login,
618
+ display_name: (nameEl?.value || '').trim() || null,
619
+ note: (noteEl?.value || '').trim() || null,
620
+ });
621
+ const req = $('#landing-request');
622
+ if (req) {
623
+ req.innerHTML = `
624
+ <h3 class="landing__request-h">Your request is sent ✓</h3>
625
+ <p class="landing__request-lede">
626
+ ${data && data.already_pending
627
+ ? 'You were already in the queue — an Archon will approve you. Once approved, sign in with GitHub to get started.'
628
+ : 'An Archon has been notified and will approve you. Once approved, sign in with GitHub to get started.'}
629
+ </p>
630
+ <p class="landing__request-lede landing__request-lede--soft">
631
+ While you wait: install the Claude desktop app at
632
+ <a href="https://claude.com/download" target="_blank" rel="noopener noreferrer">claude.com/download</a>.
633
+ The full step-by-step guide appears here once you're in.
634
+ </p>`;
635
+ }
636
+ } catch (err) {
637
+ // Already known to the city — they don't need a request, they need to
638
+ // sign in. Both the "already a builder" and "already approved" cases
639
+ // collapse the form into a sign-in prompt rather than a red error.
640
+ if (err && (err.code === 'already_member' || err.code === 'already_approved')) {
641
+ const req = $('#landing-request');
642
+ if (req) {
643
+ req.innerHTML = `
644
+ <h3 class="landing__request-h">You already have access ✓</h3>
645
+ <p class="landing__request-lede">${escapeHtml(err.message || 'You already have access.')}</p>
646
+ <p class="landing__cta">
647
+ <a class="landing__btn" href="${API}/auth/web/start">Sign in with GitHub</a>
648
+ </p>`;
649
+ }
650
+ return;
651
+ }
652
+ if (submit) { submit.disabled = false; submit.textContent = 'Request access'; }
653
+ if (status) {
654
+ status.dataset.kind = 'err';
655
+ status.textContent = (err && err.status === 400)
656
+ ? 'That does not look like a GitHub username — check it and try again.'
657
+ : 'Could not send just now. Try again in a moment.';
658
+ }
659
+ }
660
+ });
661
+ }
662
+
663
+ // Reads the module-level lastKarmaState / lastCostState directly — every caller
664
+ // passed those same globals back in, so the params were redundant plumbing (C10).
665
+ function renderProfile(builder) {
666
+ const karma = lastKarmaState;
667
+ const cost = lastCostState;
668
+ const needs = lastNeedsState;
669
+ const avatar = builder.avatar_url
670
+ ? `<img class="profile__avatar" src="${escapeHtml(builder.avatar_url)}" alt="">`
671
+ : `<div class="profile__avatar" aria-hidden="true"></div>`;
672
+
673
+ const prefs = Array.isArray(builder.preferred_disciplines) ? builder.preferred_disciplines : [];
674
+
675
+ const rankBadge = builder.rank
676
+ ? renderRankBadge(normalizeRank(builder.rank), { tip: true })
677
+ : renderRankBadge(builder.rank, { title: 'No rank recorded yet' });
678
+
679
+ // #757: this builder's place on the Roll. Null until the leaderboard rows
680
+ // load (they're fetched async after the Standing card's first paint; the
681
+ // card re-renders once they arrive) — when null the stat simply omits.
682
+ const rollPlace = leaderboardPlace(builder.github_login);
683
+
684
+ // V3.R26 (#246): karma badge + 30-day delta. Drachmae and karma are two
685
+ // separate counters — drachmae = work shipped, karma = good citizenship
686
+ // (peer recognition + Archon ratifications). The tooltip on each badge
687
+ // spells the distinction out so a new builder can tell them apart.
688
+ const karmaTotal = Number(builder.karma) || 0;
689
+ const karma30d = karma ? Number(karma.delta_30d) || 0 : 0;
690
+ const karmaTrendText = karma30d > 0
691
+ ? `+${fmtNum(karma30d)} in 30d`
692
+ : (karma30d < 0 ? `${fmtNum(karma30d)} in 30d` : '±0 in 30d');
693
+ const karmaTrend = `<span class="karma-badge__trend ${signClasses(karma30d, 'karma-badge__trend')}">${karmaTrendText}</span>`;
694
+ const karmaTooltip = `Karma = peer recognition and approvals. ${curLabel(true)} = work shipped. The two counters move independently — see the karma feed for the audit trail.`;
695
+ const drachmaeTooltip = `${curLabel(true)} = total payout from shipped work. Karma is a separate counter for peer recognition and approvals.`;
696
+
697
+ // V3.R84 (#302), criterion C3: monthly-budget banner. `warn` (>=80%) is a
698
+ // heads-up; `over` (>=100%) means cost-incurring actions (ship, art pipeline)
699
+ // are blocked until an Archon raises the budget. Nothing renders under 80%.
700
+ const budgetBanner = (cost && (cost.level === 'warn' || cost.level === 'over'))
701
+ ? `<div class="profile__budget profile__budget--${cost.level}" role="status">
702
+ <strong>${cost.level === 'over' ? 'Monthly budget exceeded' : 'Approaching monthly budget'}</strong>
703
+ — $${escapeHtml(String(cost.spend_mtd_usd))} of $${escapeHtml(String(cost.budget_mtd_usd))} this month (${escapeHtml(String(cost.pct_used))}%).
704
+ ${cost.level === 'over'
705
+ ? 'Shipping and art generation are paused until an Archon raises your budget.'
706
+ : 'At 100% these actions pause until an Archon raises your budget.'}
707
+ </div>`
708
+ : '';
709
+
710
+ // task 1013 / ADR 0073: the consistent "Action needed" / "we cover it"
711
+ // banner. Renders the highest-priority `needs` item that isn't satisfied —
712
+ // amber + a Settings button when the system needs an input (e.g. the image
713
+ // key once free coverage ends), a calm green note while we cover it. Reads
714
+ // the SAME /me `needs` data the CLI session nudge does (builder-needs.js).
715
+ const need = (needs && Array.isArray(needs.items))
716
+ ? needs.items.find((n) => n && n.state !== 'satisfied')
717
+ : null;
718
+ const needsBanner = need
719
+ ? `<div class="profile__needs profile__needs--${need.state === 'action_needed' ? 'action' : 'covered'}" role="status">
720
+ <strong>${escapeHtml(need.title)}</strong> — ${escapeHtml(need.message)}
721
+ ${need.action && need.action.href
722
+ ? ` <a class="profile__needs-act" href="${escapeHtml(need.action.href)}">${escapeHtml(need.action.label || 'Set it up')} →</a>`
723
+ : ''}
724
+ </div>`
725
+ : '';
726
+
727
+ $('#profile-body').innerHTML = `
728
+ ${needsBanner}
729
+ ${budgetBanner}
730
+ ${avatar}
731
+ <div>
732
+ <div class="profile__name-row">
733
+ <p class="profile__name">${escapeHtml(builder.display_name || builder.github_login || 'Builder')}</p>
734
+ ${rankBadge}
735
+ </div>
736
+ <p class="profile__handle">@${escapeHtml(builder.github_login || 'unknown')}</p>
737
+ <div class="profile__stats">
738
+ <span>Member since ${fmtDate(builder.created_at)}</span>
739
+ <span title="${escapeHtml(karmaTooltip)}">${karmaTrend.replace(' in 30d', ' karma in 30d')}</span>
740
+ ${rollPlace ? `<span title="Your place on the leaderboard (top ${rollPlace.total}), ranked by ${curLabel()}.">#${rollPlace.place} on the leaderboard</span>` : ''}
741
+ </div>
742
+ <div class="profile__disciplines">
743
+ <span class="profile__disc-label">Preferred work:</span>
744
+ <span class="profile__disc-value">${prefs.length ? escapeHtml(prefs.join(', ')) : 'none chosen yet'}</span>
745
+ <a class="profile__disc-edit" href="/settings">Change in Settings →</a>
746
+ </div>
747
+ </div>
748
+ <div class="profile__credits">
749
+ <span class="profile__credits-num" title="${escapeHtml(drachmaeTooltip)}">${fmtNum(builder.total_credits)}</span>
750
+ <span class="profile__credits-label">${(window.OTBBranding?.currencyLabel?.() ?? 'credits')}</span>
751
+ <span class="profile__karma" title="${escapeHtml(karmaTooltip)}">
752
+ <span class="profile__karma-num">${fmtNum(karmaTotal)}</span>
753
+ <span class="profile__karma-label">karma</span>
754
+ </span>
755
+ </div>
756
+ `;
757
+ const welcomeLine = $('#welcome-line');
758
+ if (welcomeLine) {
759
+ welcomeLine.textContent = `Welcome back, ${builder.display_name || builder.github_login || 'builder'}.`;
760
+ }
761
+
762
+ // task 1723: the Home dashboard mirrors — quick-stat tiles + the standing
763
+ // summary card — are fed from the same /me payload. All writes are guarded:
764
+ // the widgets are registry-owned and a module could unregister them.
765
+ const setText = (id, text) => {
766
+ const el = document.getElementById(id);
767
+ if (el) el.textContent = text;
768
+ };
769
+ setText('stat-credits', fmtNum(builder.total_credits));
770
+ setText('stat-karma', fmtNum(karmaTotal));
771
+ setText('stat-karma-sub', karmaTrendText);
772
+ const statRank = document.getElementById('stat-rank');
773
+ if (statRank) statRank.innerHTML = builder.rank ? renderRankBadge(normalizeRank(builder.rank), { tip: false }) : '—';
774
+ setText('stat-rank-sub', rollPlace ? `#${rollPlace.place} on the leaderboard` : '');
775
+ const statsSection = document.getElementById('home-stats');
776
+ if (statsSection && !newcomerGated) statsSection.hidden = false;
777
+
778
+ const homeStanding = document.getElementById('home-standing-body');
779
+ if (homeStanding) {
780
+ homeStanding.innerHTML = `
781
+ ${needsBanner}
782
+ ${budgetBanner}
783
+ <div class="profile profile--compact">
784
+ ${avatar}
785
+ <div>
786
+ <div class="profile__name-row">
787
+ <p class="profile__name">${escapeHtml(builder.display_name || builder.github_login || 'Builder')}</p>
788
+ ${rankBadge}
789
+ </div>
790
+ <p class="profile__handle">@${escapeHtml(builder.github_login || 'unknown')}</p>
791
+ <div class="profile__stats">
792
+ <span>Member since ${fmtDate(builder.created_at)}</span>
793
+ <span>${prefs.length ? escapeHtml(prefs.join(', ')) : 'no preferred work yet'}</span>
794
+ </div>
795
+ </div>
796
+ </div>
797
+ `;
798
+ }
799
+ const homeStandingSection = document.getElementById('home-standing');
800
+ if (homeStandingSection && !newcomerGated) homeStandingSection.hidden = false;
801
+ }
802
+
803
+ // #731: renderCliTokenReissue / issueCliToken (V3.R91 #401) and
804
+ // renderDiscordLink / unlinkDiscord (F7, ADR 0033) moved to
805
+ // public-builders/settings.js — these controls now live on /builders/settings.
806
+
807
+ async function saveDisciplines() {
808
+ const selected = Array.from(document.querySelectorAll('.disc-pref'))
809
+ .filter((el) => el.checked)
810
+ .map((el) => el.value);
811
+ const btn = $('#disc-save');
812
+ if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; }
813
+ try {
814
+ const resp = await patchJSON(`${API}/me/disciplines`, { preferred_disciplines: selected });
815
+ renderProfile(resp.builder);
816
+ toast(selected.length ? `Preferred work: ${selected.join(', ')}` : 'Preference cleared.');
817
+ } catch (err) {
818
+ if (err && err.message === 'redirecting to auth') return;
819
+ console.error('[builders] save disciplines failed', err);
820
+ toast('Could not save your preference. Try again.');
821
+ if (btn) { btn.disabled = false; btn.textContent = 'Save'; }
822
+ }
823
+ }
824
+
825
+ // ---- first-visit welcome panel (V3.R77 #296) ----------------------------
826
+ // Greets a not-yet-graduated builder with their rank, what their rank
827
+ // unlocks, the next checklist step, and the two doors into the hall: the
828
+ // orientation maps and the /builder-start command. Dismissable. Reappears
829
+ // if the builder's onboarding stage regresses (rare — e.g. a stage row
830
+ // wipe in dev or an Archon-driven reset).
831
+ //
832
+ // Persistence: localStorage key `builders:welcome-dismissed:<builder_id>`
833
+ // stores the stage *index* (0-6) at the moment of dismissal. On every
834
+ // render we compute the builder's current stage index; if it's strictly
835
+ // less than the dismissed-at index, the panel reappears. Per-builder key
836
+ // so a shared browser doesn't carry one builder's dismissal to another.
837
+ //
838
+ // The panel only renders for the LOGGED-IN viewer (uses /me). It is not
839
+ // shown on profile pages of other builders — that's the read-only
840
+ // onboarding-list panel below, which is roster-visible.
841
+
842
+ // Canonical order of the onboarding stages, mirroring
843
+ // src/bongos/onboarding-state.js STAGES. 'graduated' is the terminal stage;
844
+ // when current === 'graduated' the panel hides entirely.
845
+ const ONBOARDING_STAGE_ORDER = [
846
+ 'auth_complete',
847
+ 'pick_disciplines',
848
+ 'first_start',
849
+ 'first_claim',
850
+ 'ship_three',
851
+ 'graduated',
852
+ ];
853
+
854
+ function stageIndex(stageId) {
855
+ const i = ONBOARDING_STAGE_ORDER.indexOf(stageId);
856
+ return i === -1 ? 0 : i;
857
+ }
858
+
859
+ function welcomeDismissalKey(builderId) {
860
+ return `builders:welcome-dismissed:${builderId}`;
861
+ }
862
+
863
+ function readDismissedStageIndex(builderId) {
864
+ try {
865
+ const raw = window.localStorage.getItem(welcomeDismissalKey(builderId));
866
+ if (raw === null) return null;
867
+ const n = Number(raw);
868
+ return Number.isInteger(n) ? n : null;
869
+ } catch (_) {
870
+ // localStorage can throw in private-browsing modes. Treat as "never
871
+ // dismissed" — the panel renders, which is the right fail-open
872
+ // behaviour for an onboarding affordance.
873
+ return null;
874
+ }
875
+ }
876
+
877
+ // #785: remember which setup config the builder picked (Desktop app vs Claude
878
+ // Online) so it survives a re-render / reload / tab switch — renderWelcome used
879
+ // to hardcode 'desktop' every time. Device-level (not per-builder): it's "how I
880
+ // connect from this machine". Fail-open to 'desktop' (the recommended default).
881
+ const SETUP_CONFIG_KEY = 'builders:setup-config';
882
+ function readSetupConfig() {
883
+ try {
884
+ const v = window.localStorage.getItem(SETUP_CONFIG_KEY);
885
+ return v === 'online' ? 'online' : 'desktop';
886
+ } catch (_) { return 'desktop'; }
887
+ }
888
+ function writeSetupConfig(v) {
889
+ try { window.localStorage.setItem(SETUP_CONFIG_KEY, v === 'online' ? 'online' : 'desktop'); } catch (_) { /* private mode — won't persist, fine */ }
890
+ }
891
+
892
+ function renderWelcome(builder, onboarding) {
893
+ const scroll = $('#welcome-scroll');
894
+ const body = $('#welcome-body');
895
+ if (!scroll || !body) return;
896
+
897
+ // No builder yet (unauth landing) → hide and exit.
898
+ if (!builder || !builder.id) { scroll.hidden = true; return; }
899
+
900
+ // Graduated → hide entirely. The checklist below will also hide itself.
901
+ if (onboarding && onboarding.graduated) { scroll.hidden = true; return; }
902
+
903
+ // Compute current stage index. When the /me payload didn't carry an
904
+ // onboarding object (legacy), default to the first stage so the panel
905
+ // still greets the builder rather than vanishing silently.
906
+ const currentStageId = (onboarding && onboarding.current_stage) || ONBOARDING_STAGE_ORDER[0];
907
+ const currentIdx = stageIndex(currentStageId);
908
+
909
+ // Dismissal check. Reappears on stage regression (currentIdx < dismissed).
910
+ const dismissedAt = readDismissedStageIndex(builder.id);
911
+ if (dismissedAt !== null && currentIdx >= dismissedAt) {
912
+ scroll.hidden = true;
913
+ return;
914
+ }
915
+
916
+ const rankBadge = renderRankBadge(normalizeRank(builder.rank), { tip: true });
917
+ const greeting = `Welcome, ${escapeHtml(builder.display_name || builder.github_login || 'builder')}.`;
918
+
919
+ // #777: the welcome panel now shows ONLY setup — a two-config selector that
920
+ // gets a zero-technical newcomer from "signed into the hall" to a working
921
+ // `claude` session on their dev box. Both paths reach the SAME hosted dev
922
+ // box (Xenos are box-only, ADR 0035): the Desktop app connects over an
923
+ // automated SSH link (/builder-connect — no keys to copy), and Claude Online
924
+ // is the in-browser ttyd terminal (ADR 0038 — no install, no SSH). Steps are
925
+ // short, scannable, and plain-spoken (theme stays in the headings). The
926
+ // "what to do next" arc lives in the Path checklist below — not here.
927
+ // #785: restore the builder's last-chosen config (persisted in localStorage)
928
+ // so a re-render / reload / tab switch doesn't snap back to Desktop.
929
+ const setupCfg = readSetupConfig();
930
+ // R60 (task 1199): the hosted dev-box onboarding below is the `dev-box`
931
+ // feature module's UI. A vanilla Cloud Bongos instance has dev-box OFF, so
932
+ // there is no box to host — show the bring-your-own-machine path instead.
933
+ // OTB (dev-box ON) renders the existing flow verbatim, so prod is unchanged.
934
+ const devBoxOn = moduleOn('dev-box');
935
+ // The #support pointer assumes the Discord module; drop it when discord is off.
936
+ const supportFoot = moduleOn('discord')
937
+ ? `Stuck on any step? <a href="${API}/auth/discord/start">Join our Discord</a> and ask in <strong>#support</strong>.`
938
+ : `Stuck on any step? See the project's README and docs.`;
939
+ const setupPanes = devBoxOn ? `
940
+ <p class="setup__intro">Your work runs on a dev box we host for you — there's almost nothing to install. Choose how to connect:</p>
941
+ <div class="setup__toggle" role="tablist" aria-label="Choose a setup">
942
+ <button type="button" class="setup__tab setup__tab--desktop" data-config="desktop" role="tab" aria-selected="${setupCfg === 'desktop'}">Claude Desktop app <span class="setup__tab-tag setup__tab-tag--rec">recommended</span></button>
943
+ <button type="button" class="setup__tab setup__tab--online" data-config="online" role="tab" aria-selected="${setupCfg === 'online'}">Claude Online <span class="setup__tab-tag">browser only</span></button>
944
+ </div>
945
+
946
+ <div class="setup__pane setup__pane--desktop">
947
+ <ol class="setup__steps">
948
+ <li><strong>Download the Dev Box app:</strong>
949
+ <a href="/downloads/devbox/mac-arm64">Mac</a> ·
950
+ <a href="/downloads/devbox/mac-x64">Mac (Intel)</a> ·
951
+ <a href="/downloads/devbox/win-x64">Windows</a> ·
952
+ <a href="/downloads/devbox/win-arm64">Windows (ARM)</a>.
953
+ <span class="setup__hint">First open: your computer warns about an unknown developer — that's
954
+ expected (we haven't bought the signing certificates yet; the app is ours). Mac: System Settings
955
+ → Privacy &amp; Security → “Open Anyway”. Windows: “More info” → “Run anyway”.</span></li>
956
+ <li><strong>Sign in from the app.</strong> It opens this hall in your browser — click one
957
+ <em>Approve</em> button and you're in. No terminal, nothing to paste.</li>
958
+ <li><strong>Flip the switch.</strong> Your dev box builds itself in a minute or two and the app
959
+ chimes when it's ready. It lives in the menu bar on Mac and the system tray on Windows, and it
960
+ wires up Claude Desktop for you along the way.</li>
961
+ <li><strong><a href="https://claude.com/download" target="_blank" rel="noopener noreferrer">Download Claude</a></strong>
962
+ for Mac or Windows, sign in with your <a href="https://claude.com" target="_blank" rel="noopener noreferrer">Claude</a>
963
+ account, then open <strong>Code → Environment → “${brandWorld()} Dev Box”</strong>. You're inside —
964
+ build from <code>/workspace</code>.</li>
965
+ </ol>
966
+ </div>
967
+
968
+ <div class="setup__pane setup__pane--online">
969
+ <ol class="setup__steps">
970
+ <li><strong>Create your <a href="https://claude.com" target="_blank" rel="noopener noreferrer">Claude</a> account.</strong></li>
971
+ <li><strong>Open your online environment.</strong> This is the environment you will build in — no SSH, nothing to install.
972
+ <div class="setup__box" data-box-panel></div>
973
+ </li>
974
+ <li><strong>Sign in when Claude prompts you.</strong> Claude starts automatically when you open the terminal.
975
+ <span class="setup__hint">To paste the sign-in code: <strong>Ctrl+Shift+V</strong> or right-click — plain Ctrl+V will not paste here. Claude takes over the terminal with its own interface once you're signed in. This guide stays open in its own tab. If Claude didn't start automatically, type <code>claude</code> and press Enter.</span></li>
976
+ <li><strong>Then drive it however you like:</strong>
977
+ <span class="setup__hint"><strong>Remote control (recommended)</strong> — type <code>/remote-control</code> for a link that runs this session in Claude's own web/app interface, the same UI you'd use on a computer. <strong>Or just stay in the online terminal</strong> and build right here in the browser — no remote control needed. Either way, reopening the online terminal later resumes your session right where you left it (closing the tab keeps it running).</span></li>
978
+ </ol>
979
+ </div>
980
+ ` : `
981
+ <p class="setup__intro">Build from your own machine — clone the repository, run the platform locally, and sign in from the terminal.</p>
982
+ <div class="setup__pane">
983
+ <ol class="setup__steps">
984
+ <li><strong>Clone the repository</strong> and install dependencies (<code>npm install</code>).</li>
985
+ <li><strong><a href="https://claude.com/download" target="_blank" rel="noopener noreferrer">Download Claude</a></strong> (or use the Claude Code CLI) and open the project.</li>
986
+ <li><strong>Run <code>/builder-setup</code></strong> to authenticate this machine, then <code>/builder-start</code> to claim your first task.</li>
987
+ </ol>
988
+ </div>
989
+ `;
990
+ // task 469: a "Set up your machine / get the code" door, available in BOTH
991
+ // the hosted dev-box flow and the bring-your-own-machine flow. The flows
992
+ // above get you a session running; this section answers the separate
993
+ // question a first-timer always has — "where does the code come from, and
994
+ // how do I get it onto my own laptop?". It's the hall mirror of the primer's
995
+ // "Step 0 — get the project onto your machine". Collapsed by default so it
996
+ // doesn't crowd the recommended path; the clone block is click-to-copy
997
+ // (copy-on-click.js picks up .welcome__paste). The commands mirror
998
+ // scripts/setup-builder.sh's own header verbatim so they can't drift.
999
+ // The clone commands come from the instance's own repo coords (loaded from
1000
+ // GET /api/bongos/instance), not a hardcoded repo, so a fresh instance's newcomer
1001
+ // clones ITS project (ADR 0062 §3, task 1705). Falls back to generic guidance
1002
+ // until the coords load (or if they're unavailable).
1003
+ const cloneBlock = instanceRepo
1004
+ ? `git clone ${escapeHtml(instanceRepo.url)}.git\ncd ${escapeHtml(instanceRepo.repo)}\n./scripts/setup-builder.sh`
1005
+ : `# clone your instance's repository, cd into it, then run:\n./scripts/setup-builder.sh`;
1006
+ const getCode = `
1007
+ <details class="setup__getcode">
1008
+ <summary class="setup__getcode-summary">Set up your machine / get the code</summary>
1009
+ <div class="setup__getcode-body">
1010
+ <p class="setup__hint">First install <a href="https://git-scm.com/downloads" target="_blank" rel="noopener noreferrer">Git</a>
1011
+ and <a href="https://claude.com/download" target="_blank" rel="noopener noreferrer">Claude Code</a> — that's all the tooling you need up front.</p>
1012
+ <p class="setup__getcode-lede">Then copy the project onto your machine and run the one-shot bootstrap (it checks your setup, installs dependencies, and signs you in):</p>
1013
+ <code class="welcome__paste">${cloneBlock}</code>
1014
+ <p class="setup__hint">On Windows, run <code>node scripts/setup-builder.js</code> instead of the last line. New to the steps? The
1015
+ <a href="/primer">primer</a> walks the whole path in plain language.</p>
1016
+ </div>
1017
+ </details>`;
1018
+ body.innerHTML = `
1019
+ <div class="welcome__lede">${greeting} Your standing is ${rankBadge}.</div>
1020
+ <div class="setup" data-config="${setupCfg}">
1021
+ ${setupPanes}
1022
+ ${getCode}
1023
+ <p class="setup__foot">${supportFoot}</p>
1024
+ </div>
1025
+ `;
1026
+ // Remember the stage we rendered for, so the dismiss handler can record it.
1027
+ scroll.dataset.stageIdx = String(currentIdx);
1028
+ scroll.hidden = false;
1029
+ // #783/#807: mount the shared dev-box panel into the online pane and paint
1030
+ // it from live server state. Browser-only onboarding hides SSH details
1031
+ // (showSsh:false) — a newcomer sees only the online terminal. The panel runs
1032
+ // its own poll while anything is in flight (survives tab flips / re-renders /
1033
+ // reloads / background approvals); mount re-binds to the freshly-rendered DOM.
1034
+ const boxMount = body.querySelector('[data-box-panel]');
1035
+ if (boxMount) onboardingBox().mount(boxMount);
1036
+ }
1037
+
1038
+ // #777/#783/#807: the dev-box management UI now lives in the shared OTBBoxPanel
1039
+ // module (public-builders/box-panel.js) so it can be reused verbatim on the
1040
+ // Settings page. Here it runs in browser-only mode (showSsh:false) — a newcomer
1041
+ // sees only the online terminal, never raw SSH details. We hold ONE instance;
1042
+ // it owns its own self-rescheduling poll + terminal-window handle. Lazily
1043
+ // created so it survives the very first renderWelcome regardless of which
1044
+ // <script> finished loading first; a no-op shim keeps callers safe if the
1045
+ // module somehow failed to load.
1046
+ let _onboardingBox = null;
1047
+ function onboardingBox() {
1048
+ if (!_onboardingBox && window.OTBBoxPanel) {
1049
+ _onboardingBox = window.OTBBoxPanel.create({ api: API, showSsh: false, toast });
1050
+ }
1051
+ return _onboardingBox || { mount() {}, refresh() {}, stop() {} };
1052
+ }
1053
+
1054
+ // ---- onboarding checklist (V3.R75 #294; reshaped #777) ------------------
1055
+ // Renders the "Path into the City" checklist for the CURRENT logged-in
1056
+ // builder. Hidden once graduated (current_stage === 'graduated') so it
1057
+ // doesn't clutter the hall for full citizens. The displayed list (#777) is a
1058
+ // short, action-only orientation path: Join Discord → Choose craft → Run
1059
+ // /builder-setup → Ship three (→ Thetes). Every step AUTO-detects server-side
1060
+ // (#777, per Lars: no manual ticks). Only two carry a control:
1061
+ // * pick_disciplines — the craft picker (PATCH /me/disciplines).
1062
+ // * join_discord — a Connect-Discord button (the existing OAuth flow,
1063
+ // auto-joins + assigns rank role); clears when linked.
1064
+ // cli_setup clears once the GDS CLI is authenticated (db.hasCliSession) and
1065
+ // ship_three on the 3rd ship — both passive. The terminal ship_three step
1066
+ // shows the live "N of 3" counter (its `detail`). `discord` is the /me discord
1067
+ // block ({ linked, configured }) — when Discord isn't configured server-side
1068
+ // the Connect button is suppressed.
1069
+
1070
+ function renderOnboarding(onboarding, discord) {
1071
+ const scroll = $('#onboarding-scroll');
1072
+ const list = $('#onboarding-list');
1073
+ if (!scroll || !list) return;
1074
+ if (!onboarding) {
1075
+ scroll.hidden = true;
1076
+ return;
1077
+ }
1078
+ // Hide the panel once the builder has graduated — no point lingering.
1079
+ if (onboarding.graduated) {
1080
+ scroll.hidden = true;
1081
+ return;
1082
+ }
1083
+ scroll.hidden = false;
1084
+
1085
+ const discordConfigured = !!(discord && discord.configured);
1086
+ const stages = Array.isArray(onboarding.stages) ? onboarding.stages : [];
1087
+ if (stages.length === 0) {
1088
+ list.innerHTML = `<li class="onboarding-item onboarding-item--placeholder">No onboarding steps recorded yet.</li>`;
1089
+ return;
1090
+ }
1091
+
1092
+ // task 470: a static, INFORMATIONAL lead-in step that sits AHEAD of the
1093
+ // data-driven steps — the genuine first action into the city is "obtain the
1094
+ // repo" (clone it, or open the hosted dev box where it's already cloned).
1095
+ // It mirrors onboarding-state.js PRE_AUTH_STEP. It is NOT one of the tracked
1096
+ // checklist steps (it carries no engine stage, never gates graduation, and
1097
+ // isn't counted in the "N of M steps cleared" status line) — it just makes
1098
+ // the checklist a newcomer sees acknowledge the very first real action,
1099
+ // which the auto-ticking steps below silently assumed already happened.
1100
+ const preAuthLead = `
1101
+ <li class="onboarding-item onboarding-item--informational" data-stage="obtain_repo">
1102
+ <span class="onboarding-item__mark" aria-hidden="true">›</span>
1103
+ <div class="onboarding-item__body">
1104
+ <p class="onboarding-item__label">First — obtain the repo</p>
1105
+ <p class="onboarding-item__hint">Clone the project to your machine, or open your hosted dev box (where it is already cloned). The build commands live inside the repo, so this comes first. The remaining steps below are tracked automatically.</p>
1106
+ </div>
1107
+ </li>`;
1108
+
1109
+ list.innerHTML = preAuthLead + stages.map((s) => {
1110
+ const status = s.cleared ? 'cleared' : (s.is_current ? 'current' : 'locked');
1111
+ const mark = s.cleared ? '✓' : (s.is_current ? '◆' : '○');
1112
+ const hint = s.hint ? `<p class="onboarding-item__hint">${escapeHtml(s.hint)}</p>` : '';
1113
+ // #766: the terminal 'ship_three' stage carries a live "N of 3" counter
1114
+ // in its `detail` field — render it as a small progress line so the
1115
+ // builder watches themselves climb toward Thetes with each ship.
1116
+ const detail = s.detail
1117
+ ? `<p class="onboarding-item__detail">${escapeHtml(s.detail)}</p>`
1118
+ : '';
1119
+ // Per-step affordance, shown ONLY while the step is the current, uncleared
1120
+ // one — every step now behaves uniformly (Lars, #833): locked/future steps
1121
+ // and cleared steps carry no control, so the path reads as one action at a
1122
+ // time. (The Discord step already did this; the craft picker and the
1123
+ // See-the-works link used to render on locked/cleared steps too.) Every
1124
+ // step auto-detects its own completion server-side (#777): join_discord
1125
+ // clears on the Discord link, cli_setup once the GDS CLI is authenticated,
1126
+ // ship_three at the third ship — no manual ticking.
1127
+ let action = '';
1128
+ if (s.is_current && !s.cleared) {
1129
+ if (s.id === 'pick_disciplines') {
1130
+ // V3 (2026-05-30): inline discipline picker → PATCH /me/disciplines.
1131
+ // Multi-select: each button is a toggle reflecting the saved prefs, so
1132
+ // a builder can pick engineer, artist, ideator, or any mix while the
1133
+ // step is current. (Settings can revise crafts later for graduated citizens.)
1134
+ const prefs = (lastMeState && lastMeState.builder && Array.isArray(lastMeState.builder.preferred_disciplines))
1135
+ ? lastMeState.builder.preferred_disciplines : [];
1136
+ const discBtn = (value, label) => {
1137
+ const on = prefs.includes(value);
1138
+ return `<button type="button" class="btn-ghost onboarding-item__disc${on ? ' onboarding-item__disc--on' : ''}" data-disc="${value}" aria-pressed="${on}">${label}</button>`;
1139
+ };
1140
+ // R59 (task 1198): 'artist' is offered only when the art-pipeline module
1141
+ // is enabled — engineer + ideator are methodology core. moduleOn defaults
1142
+ // to true if the flag is missing, so OTB (art on) is unchanged; a vanilla
1143
+ // instance hides the button the server would reject anyway.
1144
+ action = `
1145
+ <div class="onboarding-item__action onboarding-item__disc-pick">
1146
+ ${discBtn('engineer', 'Engineer')}
1147
+ ${OTB.moduleOn('art-pipeline') ? discBtn('artist', 'Artist') : ''}
1148
+ ${discBtn('ideator', 'Ideator')}
1149
+ </div>`;
1150
+ } else if (s.id === 'ship_three') {
1151
+ action = `
1152
+ <div class="onboarding-item__action">
1153
+ <a class="btn-ghost" href="/work">See the work board →</a>
1154
+ </div>`;
1155
+ } else if (s.id === 'join_discord' && discordConfigured) {
1156
+ // #777: the Connect button runs the existing OAuth flow (GET
1157
+ // /auth/discord/start → auto-join + rank role). Full-page nav so the
1158
+ // server can set the CSRF state cookie before redirecting to Discord.
1159
+ // The step then clears itself once the link lands (discord.linked) —
1160
+ // no manual mark. Same control as /builders/settings.
1161
+ action = `
1162
+ <div class="onboarding-item__action">
1163
+ <a class="btn-ghost onboarding-item__discord" href="${API}/auth/discord/start">Connect Discord &amp; join the server</a>
1164
+ </div>`;
1165
+ }
1166
+ }
1167
+ return `
1168
+ <li class="onboarding-item onboarding-item--${status}" data-stage="${escapeHtml(s.id)}">
1169
+ <span class="onboarding-item__mark" aria-hidden="true">${mark}</span>
1170
+ <div class="onboarding-item__body">
1171
+ <p class="onboarding-item__label">${escapeHtml(s.label)}</p>
1172
+ ${hint}
1173
+ ${detail}
1174
+ </div>
1175
+ ${action}
1176
+ </li>
1177
+ `;
1178
+ }).join('');
1179
+
1180
+ const cleared = onboarding.progress?.cleared ?? 0;
1181
+ const total = onboarding.progress?.total ?? stages.length;
1182
+ const status = $('#onboarding-status');
1183
+ if (status) {
1184
+ status.hidden = false;
1185
+ status.textContent = `${cleared} of ${total} steps cleared.`;
1186
+ }
1187
+ }
1188
+
1189
+
1190
+ // V3 (2026-05-30): clear the pick_disciplines onboarding stage from the path
1191
+ // checklist. Saves the chosen craft via the same PATCH /me/disciplines route
1192
+ // the profile uses, then re-renders the checklist (and the profile picker) so
1193
+ // both reflect the choice. The route echoes the updated onboarding state.
1194
+ async function pickDiscipline(value) {
1195
+ // Multi-select toggle: add the craft if not yet chosen, remove it if it is,
1196
+ // then save the full set (any mix of engineer, artist, ideator).
1197
+ const current = (lastMeState && lastMeState.builder && Array.isArray(lastMeState.builder.preferred_disciplines))
1198
+ ? lastMeState.builder.preferred_disciplines : [];
1199
+ const selected = current.includes(value)
1200
+ ? current.filter((d) => d !== value)
1201
+ : [...current, value];
1202
+ const buttons = document.querySelectorAll('.onboarding-item__disc');
1203
+ buttons.forEach((b) => { b.disabled = true; });
1204
+ try {
1205
+ const resp = await patchJSON(`${API}/me/disciplines`, { preferred_disciplines: selected });
1206
+ if (resp.builder && lastMeState) {
1207
+ // Keep the cached /me state and the profile picker in sync.
1208
+ lastMeState.builder = resp.builder;
1209
+ lastMeState.onboarding = resp.onboarding || lastMeState.onboarding;
1210
+ }
1211
+ if (resp.onboarding) renderOnboarding(resp.onboarding, lastMeState && lastMeState.discord);
1212
+ if (resp.builder) renderProfile(resp.builder);
1213
+ toast(selected.length ? `Craft: ${selected.join(', ')}.` : 'Craft cleared.');
1214
+ } catch (err) {
1215
+ if (err && err.message === 'redirecting to auth') return;
1216
+ console.error('[builders] pick discipline failed', err);
1217
+ toast('Could not save just now. Try again.');
1218
+ buttons.forEach((b) => { b.disabled = false; });
1219
+ }
1220
+ }
1221
+
1222
+ // ---- achievements --------------------------------------------------------
1223
+
1224
+ function renderAchievements(all, unlocked, lockedWithProgress) {
1225
+ const unlockedById = new Map(unlocked.map((u) => [u.achievement_id, u]));
1226
+ // Map achievement_id → progress_text for locked entries. Falsy/empty
1227
+ // progress just shows the credit-bonus line as before.
1228
+ const progressById = new Map(
1229
+ (lockedWithProgress || []).map((l) => [l.achievement_id, l.progress_text || '']),
1230
+ );
1231
+ const grid = $('#ach-grid');
1232
+ if (!all.length) {
1233
+ grid.innerHTML = `<div class="profile__placeholder">No achievements earned yet.</div>`;
1234
+ return;
1235
+ }
1236
+ grid.innerHTML = all.map((a) => {
1237
+ const got = unlockedById.get(a.id);
1238
+ const cls = got ? 'ach' : 'ach ach--locked';
1239
+ const bonus = a.credit_bonus ? `<p class="ach__bonus">+${fmtNum(a.credit_bonus)} ${(window.OTBBranding?.currencyLabel?.() ?? 'credits')}</p>` : '';
1240
+ const when = got ? `<p class="ach__bonus">Won ${fmtDate(got.unlocked_at)}</p>` : '';
1241
+ const progress = !got && progressById.has(a.id) && progressById.get(a.id)
1242
+ ? `<p class="ach__progress">${escapeHtml(progressById.get(a.id))}</p>`
1243
+ : '';
1244
+ return `
1245
+ <div class="${cls}" title="${escapeHtml(a.id)}">
1246
+ <div class="ach__icon" aria-hidden="true">${escapeHtml(a.icon || '✦')}</div>
1247
+ <div class="ach__body">
1248
+ <p class="ach__name">${escapeHtml(a.name)}</p>
1249
+ <p class="ach__desc">${escapeHtml(a.description || '')}</p>
1250
+ ${when || bonus}
1251
+ ${progress}
1252
+ </div>
1253
+ </div>
1254
+ `;
1255
+ }).join('');
1256
+ }
1257
+
1258
+ // ---- recent ships --------------------------------------------------------
1259
+
1260
+ // All shipped works for this builder, fetched once via ?limit=all then
1261
+ // filtered, sorted, and paged entirely client-side. Mirrors the status site's
1262
+ // Lately Inscribed pattern (modules/status-ui/public/status.js renderShippedPage), with
1263
+ // an additional version filter since a builder's history spans versions.
1264
+ const SHIPS_PAGE_SIZE = 10;
1265
+ const shipState = {
1266
+ all: [],
1267
+ page: 0,
1268
+ };
1269
+
1270
+ function filterShips(ships) {
1271
+ const win = $('#f-ships-window')?.value || 'all';
1272
+ const ver = $('#f-ships-version')?.value || '';
1273
+ let cutoff = null;
1274
+ if (win !== 'all') {
1275
+ const days = Number(win);
1276
+ if (Number.isFinite(days) && days > 0) {
1277
+ cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
1278
+ }
1279
+ }
1280
+ return ships.filter((s) => {
1281
+ if (ver && s.version_id !== ver) return false;
1282
+ if (cutoff !== null) {
1283
+ const t = s.shipped_at ? new Date(s.shipped_at).getTime() : 0;
1284
+ if (!t || t < cutoff) return false;
1285
+ }
1286
+ return true;
1287
+ });
1288
+ }
1289
+
1290
+ // task 1957 — a shipped row's payout is the ACTUAL ledger figure (credits_awarded),
1291
+ // not the creation-time estimate. taskCreditValue picks the right one by status.
1292
+ function shipCredit(s) {
1293
+ return Number(window.OTBBranding?.taskCreditValue?.(s) ?? s.credits_reward) || 0;
1294
+ }
1295
+
1296
+ function sortShips(ships, mode) {
1297
+ const copy = ships.slice();
1298
+ if (mode === 'oldest') {
1299
+ copy.sort((a, b) => new Date(a.shipped_at || 0) - new Date(b.shipped_at || 0));
1300
+ } else if (mode === 'drachmae-high') {
1301
+ copy.sort((a, b) => shipCredit(b) - shipCredit(a));
1302
+ } else if (mode === 'drachmae-low') {
1303
+ copy.sort((a, b) => shipCredit(a) - shipCredit(b));
1304
+ } else {
1305
+ // default: most recent first
1306
+ copy.sort((a, b) => new Date(b.shipped_at || 0) - new Date(a.shipped_at || 0));
1307
+ }
1308
+ return copy;
1309
+ }
1310
+
1311
+ function renderRecentShips() {
1312
+ const tbody = $('#recent-ships tbody');
1313
+ const countEl = $('#ships-count');
1314
+ const pagerEl = $('#ships-pager');
1315
+ const rangeEl = $('#ships-range');
1316
+ const prevBtn = $('#ships-prev');
1317
+ const nextBtn = $('#ships-next');
1318
+
1319
+ const filtered = filterShips(shipState.all);
1320
+ const mode = $('#f-ships-sort')?.value || 'recent';
1321
+ const sorted = sortShips(filtered, mode);
1322
+
1323
+ // task 1723: Home mirrors — the shipped-count stat tile and the compact
1324
+ // 5-row recent-work table are fed from the same feed. Guarded writes.
1325
+ const statShipped = document.getElementById('stat-shipped');
1326
+ if (statShipped) statShipped.textContent = fmtNum(shipState.all.length);
1327
+ const homeRecent = document.querySelector('#home-recent-table tbody');
1328
+ if (homeRecent) {
1329
+ const latest = sortShips(shipState.all, 'recent').slice(0, 5);
1330
+ homeRecent.innerHTML = latest.length
1331
+ ? latest.map((s) => `
1332
+ <tr>
1333
+ <td class="ledger__num">#${s.id}</td>
1334
+ <td>${escapeHtml(s.title)}</td>
1335
+ <td class="ledger__drachmae">${fmtNum(window.OTBBranding?.taskCreditValue?.(s) ?? s.credits_reward)}</td>
1336
+ <td class="ledger__date">${fmtDate(s.shipped_at)}</td>
1337
+ </tr>`).join('')
1338
+ : emptyRow(4, 'Nothing shipped yet. Claim a task on the Work board to get started.');
1339
+ }
1340
+ const homeRecentSection = document.getElementById('home-recent');
1341
+ if (homeRecentSection && !newcomerGated) homeRecentSection.hidden = false;
1342
+
1343
+ if (countEl) {
1344
+ const total = shipState.all.length;
1345
+ const shown = sorted.length;
1346
+ countEl.textContent = total
1347
+ ? (shown === total
1348
+ ? `${fmtNum(total)} work${total === 1 ? '' : 's'} shipped`
1349
+ : `${fmtNum(shown)} of ${fmtNum(total)} in view`)
1350
+ : '';
1351
+ }
1352
+
1353
+ if (!sorted.length) {
1354
+ tbody.innerHTML = shipState.all.length
1355
+ ? emptyRow(4, 'No work matches these filters. Widen the window or pick another version.')
1356
+ : emptyRow(4, 'Nothing shipped yet. Claim a task on the Work board to get started.');
1357
+ if (pagerEl) pagerEl.hidden = true;
1358
+ return;
1359
+ }
1360
+
1361
+ const { start } = renderPager(shipState, sorted.length, SHIPS_PAGE_SIZE, { pagerEl, rangeEl, prevBtn, nextBtn });
1362
+ const slice = sorted.slice(start, start + SHIPS_PAGE_SIZE);
1363
+
1364
+ tbody.innerHTML = slice.map((s) => `
1365
+ <tr>
1366
+ <td class="ledger__num">#${s.id}</td>
1367
+ <td>
1368
+ <div>${escapeHtml(s.title)}</div>
1369
+ ${s.value_summary ? `<div style="color:var(--ink-soft);font-size:0.88rem;font-style:italic;margin-top:2px">${escapeHtml(s.value_summary)}</div>` : ''}
1370
+ </td>
1371
+ <td class="ledger__drachmae">${fmtNum(window.OTBBranding?.taskCreditValue?.(s) ?? s.credits_reward)}</td>
1372
+ <td class="ledger__date">${fmtDate(s.shipped_at)}</td>
1373
+ </tr>
1374
+ `).join('');
1375
+ }
1376
+
1377
+ // Re-render when filters change. Reset to page 0 so the new filter starts at
1378
+ // the top — otherwise switching to a smaller window can leave you on page 9
1379
+ // of a 2-page result.
1380
+ function onShipsFilterChange() {
1381
+ shipState.page = 0;
1382
+ renderRecentShips();
1383
+ }
1384
+
1385
+ // Populate the ship-version filter from the versions API response. Called
1386
+ // from loadAll once both versionsResp and the ships response are ready.
1387
+ function populateShipVersionFilter(versions) {
1388
+ const sel = $('#f-ships-version');
1389
+ if (!sel) return;
1390
+ // Build from the versions we've shipped to, intersected with the catalog.
1391
+ const seen = new Set(shipState.all.map((s) => s.version_id).filter(Boolean));
1392
+ for (const v of versions) {
1393
+ if (!seen.has(v.id)) continue;
1394
+ const opt = document.createElement('option');
1395
+ opt.value = v.id;
1396
+ opt.textContent = v.id;
1397
+ if (v.name && v.name !== v.id) opt.title = v.name;
1398
+ sel.appendChild(opt);
1399
+ }
1400
+ }
1401
+
1402
+ // ---- recent grades (task 197) -------------------------------------------
1403
+
1404
+ function renderRecentGrades(grades) {
1405
+ const tbody = $('#recent-grades tbody');
1406
+ if (!tbody) return;
1407
+ if (!grades || !grades.length) {
1408
+ tbody.innerHTML = emptyRow(5, 'No work reviewed yet.');
1409
+ return;
1410
+ }
1411
+ tbody.innerHTML = grades.map((g) => {
1412
+ const score = Number(g.score).toFixed(1);
1413
+ const verdict = g.passed
1414
+ ? `<span style="color:var(--olive,#5b7a4d);font-weight:600">passed</span>`
1415
+ : `<span style="color:#a04040;font-weight:600">fell short</span>`;
1416
+ return `
1417
+ <tr>
1418
+ <td class="ledger__num">#${g.task_id}</td>
1419
+ <td>${escapeHtml(g.title || '')}</td>
1420
+ <td class="ledger__drachmae">${score} / 10</td>
1421
+ <td>${verdict}</td>
1422
+ <td class="ledger__date">${fmtDate(g.graded_at)}</td>
1423
+ </tr>
1424
+ `;
1425
+ }).join('');
1426
+ }
1427
+
1428
+ function renderRollingNote(rolling) {
1429
+ const note = $('#grades-rolling-note');
1430
+ if (!note) return;
1431
+ if (!rolling || !rolling.n || Number(rolling.n) === 0) {
1432
+ note.hidden = true;
1433
+ return;
1434
+ }
1435
+ const avg = Number(rolling.avg_score).toFixed(1);
1436
+ const passed = Number(rolling.n_passed || 0);
1437
+ const n = Number(rolling.n);
1438
+ note.textContent = `Last 14 days: ${avg} / 10 average across ${n} graded work${n === 1 ? '' : 's'} (${passed} passed).`;
1439
+ note.hidden = false;
1440
+ }
1441
+
1442
+ // ---- the dev curve (V3.R72 #291, C5) ------------------------------------
1443
+ //
1444
+ // Per-builder historical analytics: ship rate, cost-per-task, duration, and
1445
+ // grade pass-rate over time, plus a top-kinds fingerprint. Charts are inline
1446
+ // SVG sparklines — no chart library (the task spec's explicit constraint).
1447
+ // An Archon additionally gets a "You / All builders" scope toggle backed by
1448
+ // the ecosystem aggregate (individuals stripped), satisfying the
1449
+ // cross-builder view requirement without a per-builder roster on this page.
1450
+
1451
+ const analyticsState = { builderId: null, isArchon: false, scope: 'self', self: null, eco: null };
1452
+
1453
+ // Build a tiny SVG line sparkline from an array of numeric y-values. Returns
1454
+ // a self-contained <svg> string. A flat/short series degrades to a baseline.
1455
+ function sparkline(values, opts = {}) {
1456
+ const W = opts.width || 260;
1457
+ const H = opts.height || 46;
1458
+ const pad = 3;
1459
+ const color = opts.color || 'var(--accent-deep)';
1460
+ const nums = (values || []).map((v) => (Number.isFinite(Number(v)) ? Number(v) : 0));
1461
+ if (nums.length === 0) {
1462
+ return `<svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="no data"></svg>`;
1463
+ }
1464
+ const min = Math.min(...nums);
1465
+ const max = Math.max(...nums);
1466
+ const span = max - min || 1;
1467
+ const n = nums.length;
1468
+ const x = (i) => (n === 1 ? W / 2 : pad + (i * (W - 2 * pad)) / (n - 1));
1469
+ const y = (v) => H - pad - ((v - min) / span) * (H - 2 * pad);
1470
+ const pts = nums.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`);
1471
+ const line = pts.join(' ');
1472
+ const area = `${pad},${H - pad} ${line} ${(W - pad)},${H - pad}`;
1473
+ const last = nums[n - 1];
1474
+ return `<svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="trend, latest ${last}">
1475
+ <polyline class="spark__area" points="${area}" fill="${color}" />
1476
+ <polyline class="spark__line" points="${line}" fill="none" stroke="${color}" />
1477
+ ${n === 1 ? `<circle cx="${x(0).toFixed(1)}" cy="${y(last).toFixed(1)}" r="2.4" fill="${color}" />` : ''}
1478
+ </svg>`;
1479
+ }
1480
+
1481
+ function statCard(label, value, sub) {
1482
+ return `<div class="curve__stat">
1483
+ <div class="curve__stat-value">${escapeHtml(String(value))}</div>
1484
+ <div class="curve__stat-label">${escapeHtml(label)}</div>
1485
+ ${sub ? `<div class="curve__stat-sub">${escapeHtml(sub)}</div>` : ''}
1486
+ </div>`;
1487
+ }
1488
+
1489
+ function chartCard(title, values, color, footer) {
1490
+ const has = Array.isArray(values) && values.length > 0;
1491
+ return `<div class="curve__chart">
1492
+ <div class="curve__chart-title">${escapeHtml(title)}</div>
1493
+ ${has ? sparkline(values, { color }) : `<div class="curve__chart-empty">no data in window</div>`}
1494
+ ${footer ? `<div class="curve__chart-foot">${escapeHtml(footer)}</div>` : ''}
1495
+ </div>`;
1496
+ }
1497
+
1498
+ const pct = (v) => (v == null ? '—' : `${Math.round(Number(v) * 100)}%`);
1499
+ const usd = (v) => `$${(Number(v) || 0).toFixed(2)}`;
1500
+ const mins = (v) => (v == null ? '—' : `${Math.round(Number(v))}m`);
1501
+
1502
+ // Render the curve for whichever scope is active. `data` is either the self
1503
+ // payload (/analytics/builder/:id) or the ecosystem payload, normalized below.
1504
+ function renderCurveBody() {
1505
+ const headlineEl = $('#curve-headline');
1506
+ const gridEl = $('#curve-grid');
1507
+ const kindsEl = $('#curve-kinds');
1508
+ const emptyEl = $('#curve-empty');
1509
+ const noteEl = $('#curve-scope-note');
1510
+ if (!headlineEl || !gridEl || !kindsEl) return;
1511
+
1512
+ const isEco = analyticsState.scope === 'eco';
1513
+ const data = isEco ? analyticsState.eco : analyticsState.self;
1514
+
1515
+ // Scope toggle (Archon only).
1516
+ if (analyticsState.isArchon && noteEl) {
1517
+ noteEl.innerHTML =
1518
+ `<span class="curve__scope" role="group" aria-label="Analytics scope">` +
1519
+ `<button type="button" class="curve__scope-btn${!isEco ? ' is-active' : ''}" data-scope="self">You</button>` +
1520
+ `<button type="button" class="curve__scope-btn${isEco ? ' is-active' : ''}" data-scope="eco">All builders</button>` +
1521
+ `</span>`;
1522
+ }
1523
+
1524
+ if (!data) { gridEl.innerHTML = ''; headlineEl.innerHTML = ''; kindsEl.innerHTML = ''; return; }
1525
+
1526
+ const totalShipped = data.headline?.total_shipped || 0;
1527
+ if (totalShipped === 0) {
1528
+ if (emptyEl) emptyEl.style.display = '';
1529
+ headlineEl.innerHTML = ''; gridEl.innerHTML = ''; kindsEl.innerHTML = '';
1530
+ return;
1531
+ }
1532
+ if (emptyEl) emptyEl.style.display = 'none';
1533
+
1534
+ // Headline stat cards.
1535
+ const h = data.headline;
1536
+ const cards = [];
1537
+ cards.push(statCard(isEco ? 'Works shipped' : 'Works shipped', totalShipped, `last ${data.window_days}d`));
1538
+ if (isEco) {
1539
+ cards.push(statCard('Active builders', h.active_builders ?? 0));
1540
+ cards.push(statCard('Avg time / work', mins(h.avg_duration_min)));
1541
+ } else {
1542
+ cards.push(statCard('Cost / work', usd(h.cost_per_task)));
1543
+ cards.push(statCard('Avg time / work', mins(h.avg_duration_min)));
1544
+ cards.push(statCard('Confirmation rate', pct(h.confirmation_rate), `${h.resolved_claims || 0} claims`));
1545
+ cards.push(statCard('Grade pass rate', pct(h.grade_pass_rate), `${h.graded_n || 0} graded`));
1546
+ }
1547
+ headlineEl.innerHTML = cards.join('');
1548
+
1549
+ // Sparkline charts.
1550
+ const charts = [];
1551
+ charts.push(chartCard('Works shipped / day', (data.ship_rate || []).map((r) => r.n), 'var(--accent-deep)',
1552
+ `${(data.ship_rate || []).reduce((s, r) => s + Number(r.n), 0)} over ${data.window_days}d`));
1553
+ charts.push(chartCard('Time / work (min)', (data.duration_trend || []).map((r) => r.avg_actual), 'var(--sea)',
1554
+ 'actual minutes per shipped work'));
1555
+ if (!isEco) {
1556
+ charts.push(chartCard('Cost / work ($)', (data.cost_trend || []).map((r) => r.per_work), 'var(--terracotta)',
1557
+ `total ${usd(h.total_cost_usd)}`));
1558
+ charts.push(chartCard('Grade pass rate', (data.grade_trend || []).map((r) => (r.n ? r.n_passed / r.n : 0)), 'var(--accent)',
1559
+ 'share of work passing review'));
1560
+ }
1561
+ gridEl.innerHTML = charts.join('');
1562
+
1563
+ // Top kinds — horizontal bars.
1564
+ const kinds = (data.top_kinds || []).slice(0, 8);
1565
+ if (kinds.length) {
1566
+ const maxN = Math.max(...kinds.map((k) => Number(k.n)));
1567
+ kindsEl.innerHTML =
1568
+ `<div class="curve__chart-title">Kinds of work shipped</div>` +
1569
+ kinds.map((k) => {
1570
+ const w = maxN ? Math.round((Number(k.n) / maxN) * 100) : 0;
1571
+ return `<div class="curve__kind">
1572
+ <span class="curve__kind-label">${escapeHtml(k.kind)}</span>
1573
+ <span class="curve__kind-bar"><span class="curve__kind-fill" style="width:${w}%"></span></span>
1574
+ <span class="curve__kind-n">${k.n}</span>
1575
+ </div>`;
1576
+ }).join('');
1577
+ } else {
1578
+ kindsEl.innerHTML = '';
1579
+ }
1580
+ }
1581
+
1582
+ // Fire-and-forget loader: fetch the builder's own curve (and, for an Archon,
1583
+ // the ecosystem aggregate) and render. A failed/empty feed simply leaves the
1584
+ // section hidden rather than holding up the rest of the hall.
1585
+ async function loadAnalytics(builder) {
1586
+ const scroll = $('#analytics-scroll');
1587
+ if (!scroll || !builder?.id) return;
1588
+ analyticsState.builderId = builder.id;
1589
+ analyticsState.isArchon = (builder.rank || '').toLowerCase() === 'archon';
1590
+ analyticsState.scope = 'self';
1591
+ try {
1592
+ const self = await getJSON(`${API}/analytics/builder/${builder.id}?days=90`);
1593
+ analyticsState.self = self;
1594
+ // A gated newcomer with nothing shipped: keep the section hidden.
1595
+ if (!self || (self.headline?.total_shipped || 0) === 0) { scroll.hidden = true; return; }
1596
+ scroll.hidden = false;
1597
+ renderCurveBody();
1598
+ renderNav(builder, lastMeState?.onboarding || null); // re-show the Curve chip now that the section is visible
1599
+ if (analyticsState.isArchon) {
1600
+ getJSON(`${API}/public/analytics/ecosystem?days=90`)
1601
+ .then((eco) => { analyticsState.eco = eco; if (analyticsState.scope === 'eco') renderCurveBody(); })
1602
+ .catch(() => { /* ecosystem optional */ });
1603
+ }
1604
+ } catch (err) {
1605
+ console.error('[builders] analytics load failed', err);
1606
+ scroll.hidden = true;
1607
+ }
1608
+ }
1609
+
1610
+ // ---- karma feed (V3.R26 #246) -------------------------------------------
1611
+ //
1612
+ // The recent karma_log entries for the current builder. The /me payload
1613
+ // already includes them under `karma.recent`. The scroll stays hidden when
1614
+ // the builder has no history yet (zero karma is a common starting state,
1615
+ // so we don't want to push an empty table at every new citizen).
1616
+
1617
+ function renderKarmaFeed(entries) {
1618
+ const scroll = $('#karma-scroll');
1619
+ const tbody = $('#karma-feed tbody');
1620
+ if (!scroll || !tbody) return;
1621
+ if (!Array.isArray(entries) || entries.length === 0) {
1622
+ // Hide the scroll entirely if the builder has no karma history.
1623
+ // Keeps the page tidy for fresh citizens; reappears the moment a
1624
+ // karma_log row lands.
1625
+ scroll.hidden = true;
1626
+ return;
1627
+ }
1628
+ // #748: a gated newcomer never shows the karma feed, even if karma exists.
1629
+ scroll.hidden = newcomerGated;
1630
+ tbody.innerHTML = entries.map((k) => {
1631
+ const delta = Number(k.delta) || 0;
1632
+ const deltaCls = signClasses(delta, 'karma-feed__delta');
1633
+ const deltaText = delta > 0 ? `+${fmtNum(delta)}` : fmtNum(delta);
1634
+ const granted = k.granted_by
1635
+ ? `@${escapeHtml(k.granted_by_login || k.granted_by_display_name || `builder #${k.granted_by}`)}`
1636
+ : '<span style="opacity:0.6;">— all builders —</span>';
1637
+ return `
1638
+ <tr>
1639
+ <td class="ledger__date">${escapeHtml(fmtDate(k.recorded_at))}</td>
1640
+ <td class="karma-feed__delta ${deltaCls}">${deltaText}</td>
1641
+ <td>${escapeHtml(k.reason || '')}</td>
1642
+ <td>${granted}</td>
1643
+ </tr>
1644
+ `;
1645
+ }).join('');
1646
+ }
1647
+
1648
+ // ---- peer acclaim feed (V3.R25 #245) ------------------------------------
1649
+ //
1650
+ // Other builders' recently confirmed/shipped work, each with a +/- vote
1651
+ // widget. Votes fold into the shipping builder's karma on a weekly tally,
1652
+ // lagged 7 days (server-enforced). Metic+ may vote; the endpoint returns
1653
+ // can_vote so Xenos see the tallies read-only. The endpoint already excludes
1654
+ // the caller's own ships (anti-self-vote), so there's nothing to vote on that
1655
+ // isn't a peer's work.
1656
+
1657
+ let acclaimCanVote = false;
1658
+
1659
+ function renderAcclaimFeed(data) {
1660
+ const scroll = $('#acclaim-scroll');
1661
+ const tbody = $('#acclaim-feed tbody');
1662
+ if (!scroll || !tbody) return;
1663
+ const tasks = (data && Array.isArray(data.tasks)) ? data.tasks : [];
1664
+ acclaimCanVote = !!(data && data.can_vote);
1665
+ if (tasks.length === 0) {
1666
+ // Nothing to acclaim yet (e.g. the only confirmed work so far is your
1667
+ // own). Hide rather than show an empty ledger.
1668
+ scroll.hidden = true;
1669
+ return;
1670
+ }
1671
+ // #748: a gated newcomer never shows the acclaim feed.
1672
+ scroll.hidden = newcomerGated;
1673
+ tbody.innerHTML = tasks.map((t) => {
1674
+ const by = escapeHtml(t.display_name || (t.github_login ? `@${t.github_login}` : `builder #${t.shipped_by}`));
1675
+ const net = Number(t.net_votes) || 0;
1676
+ const netCls = signClasses(net, 'acclaim__net');
1677
+ const netText = net > 0 ? `+${net}` : `${net}`;
1678
+ const mine = t.my_vote == null ? 0 : Number(t.my_vote);
1679
+ // t.id is a bigserial integer, but coerce + escape it like every other
1680
+ // server-sourced value rather than trusting the type — it flows into both
1681
+ // innerHTML and querySelector attribute selectors below.
1682
+ const tid = escapeHtml(String(t.id));
1683
+ const controls = acclaimCanVote
1684
+ ? `<span class="acclaim__vote" role="group" aria-label="Vote on this work">
1685
+ <button type="button" class="acclaim__btn acclaim__btn--up${mine === 1 ? ' is-active' : ''}" data-vote-task="${tid}" data-vote="1" aria-pressed="${mine === 1}" title="Upvote (+1)">▲</button>
1686
+ <button type="button" class="acclaim__btn acclaim__btn--down${mine === -1 ? ' is-active' : ''}" data-vote-task="${tid}" data-vote="-1" aria-pressed="${mine === -1}" title="Downvote (−1)">▼</button>
1687
+ </span>`
1688
+ : '<span class="acclaim__readonly" title="Metic rank and above may vote">—</span>';
1689
+ const title = escapeHtml(t.value_summary || t.title || `task #${t.id}`);
1690
+ return `
1691
+ <tr data-acclaim-row="${tid}">
1692
+ <td>${title}</td>
1693
+ <td>${by}</td>
1694
+ <td class="acclaim__net ${netCls}" data-acclaim-net="${tid}">${netText}</td>
1695
+ <td>${controls}</td>
1696
+ </tr>
1697
+ `;
1698
+ }).join('');
1699
+ }
1700
+
1701
+ async function loadAcclaimFeed() {
1702
+ try {
1703
+ const data = await getJSON(`${API}/tasks/peer-votes/feed?limit=25`);
1704
+ renderAcclaimFeed(data);
1705
+ } catch (err) {
1706
+ // Non-fatal: a feed failure shouldn't take down the hall.
1707
+ console.warn('[hall] acclaim feed failed:', err.message);
1708
+ const scroll = $('#acclaim-scroll');
1709
+ if (scroll) scroll.hidden = true;
1710
+ }
1711
+ }
1712
+
1713
+ // Click delegation for the vote buttons. Toggling an already-active vote
1714
+ // clears it (sends 0). Re-fetches the row's net from the server response so
1715
+ // the displayed tally is always authoritative.
1716
+ async function onAcclaimClick(ev) {
1717
+ const btn = ev.target.closest('[data-vote-task]');
1718
+ if (!btn || !acclaimCanVote) return;
1719
+ // Coerce the id to an integer before it goes into a CSS attribute selector
1720
+ // or the URL — a non-numeric value would throw a querySelector SyntaxError.
1721
+ const taskIdNum = Number(btn.getAttribute('data-vote-task'));
1722
+ if (!Number.isInteger(taskIdNum)) return;
1723
+ const taskId = String(taskIdNum);
1724
+ const clicked = Number(btn.getAttribute('data-vote'));
1725
+ const wasActive = btn.classList.contains('is-active');
1726
+ const vote = wasActive ? 0 : clicked; // clicking your current vote clears it
1727
+ const row = $(`[data-acclaim-row="${taskId}"]`);
1728
+ const buttons = row ? row.querySelectorAll('.acclaim__btn') : [];
1729
+ buttons.forEach((b) => { b.disabled = true; });
1730
+ try {
1731
+ const res = await postJSON(`${API}/tasks/${encodeURIComponent(taskId)}/vote`, { vote });
1732
+ // Reflect the authoritative net + which button is active.
1733
+ const netEl = $(`[data-acclaim-net="${taskId}"]`);
1734
+ if (netEl) {
1735
+ const net = Number(res.net_votes) || 0;
1736
+ netEl.textContent = net > 0 ? `+${net}` : `${net}`;
1737
+ netEl.classList.remove('acclaim__net--up', 'acclaim__net--down', 'acclaim__net--flat');
1738
+ netEl.classList.add(signClasses(net, 'acclaim__net'));
1739
+ }
1740
+ buttons.forEach((b) => {
1741
+ const v = Number(b.getAttribute('data-vote'));
1742
+ const active = res.my_vote != null && Number(res.my_vote) === v;
1743
+ b.classList.toggle('is-active', active);
1744
+ b.setAttribute('aria-pressed', String(active));
1745
+ });
1746
+ } catch (err) {
1747
+ toast(err.status === 403 ? 'You can\'t vote on your own work.' : `Vote failed: ${err.message}`);
1748
+ } finally {
1749
+ buttons.forEach((b) => { b.disabled = false; });
1750
+ }
1751
+ }
1752
+
1753
+ // ---- task browser (removed #750) -----------------------------------------
1754
+ // The claimable-works list now lives solely on the /work Work Board (#748);
1755
+ // the hall's old Claim section + its renderTasks/copy-prompt machinery were
1756
+ // removed. work.js carries the equivalent rendering for that page.
1757
+
1758
+ // ---- red-team rewards (bounty table, V3.R65 #284) ------------------------
1759
+
1760
+ // Severity tiers ship low → critical; the server already returns them in that
1761
+ // order (src/bongos/security.js SEVERITIES), but we cap the row classes here so
1762
+ // the colouring is deterministic regardless of payload ordering.
1763
+ function renderBountyTable(rows) {
1764
+ const tbody = $('#bounty-table tbody');
1765
+ if (!tbody) return;
1766
+ if (!Array.isArray(rows) || !rows.length) {
1767
+ tbody.innerHTML = emptyRow(3, 'The reward schedule could not be loaded.');
1768
+ return;
1769
+ }
1770
+ tbody.innerHTML = rows.map((r) => {
1771
+ const sev = escapeHtml(r.severity || '');
1772
+ const overridden = r.overridden
1773
+ ? ` <span style="color:var(--ink-faint);font-size:0.82rem">(tuned)</span>`
1774
+ : '';
1775
+ return `
1776
+ <tr>
1777
+ <td style="text-transform:capitalize;font-weight:600">${sev}</td>
1778
+ <td class="ledger__drachmae">${fmtNum(r.drachmae)}${overridden}</td>
1779
+ <td style="color:var(--ink-soft)">${escapeHtml(r.blurb || '')}</td>
1780
+ </tr>
1781
+ `;
1782
+ }).join('');
1783
+ }
1784
+
1785
+ // ---- leaderboard ---------------------------------------------------------
1786
+
1787
+ // Locked at 8 for V2 (per limitations/pms-v2.md). Hardcoding here avoids an
1788
+ // extra API round-trip per page load — the catalog won't shift in V2.
1789
+ const ACH_EMOJI = {
1790
+ 'first-ship': '🚢',
1791
+ 'first-claim': '🪙',
1792
+ 'first-parallel-claim': '⚓',
1793
+ 'streak-3': '🔥',
1794
+ 'streak-7': '🌋',
1795
+ 'art-pipeline-shipper': '🎨',
1796
+ 'gds-shipper': '⚙️',
1797
+ 'breaking-the-100c-bar': '💯',
1798
+ };
1799
+
1800
+ function renderAchievementRow(ids) {
1801
+ if (!Array.isArray(ids) || ids.length === 0) return '';
1802
+ const inner = ids
1803
+ .map((id) => {
1804
+ const emoji = ACH_EMOJI[id];
1805
+ if (!emoji) return '';
1806
+ // Title attribute is the achievement id (kebab) — terse enough that hover
1807
+ // discloses the laurel without overstating the prose.
1808
+ return `<span class="lb-ach" title="${escapeHtml(id)}" aria-label="${escapeHtml(id)}">${emoji}</span>`;
1809
+ })
1810
+ .join('');
1811
+ return inner ? `<span class="lb-ach-row" aria-label="achievements">${inner}</span>` : '';
1812
+ }
1813
+
1814
+ const lbState = {
1815
+ rows: [],
1816
+ page: 0,
1817
+ pageSize: 25, // task 1750: top-100 Roll — 25/page keeps it to a few pages
1818
+
1819
+ sortBy: 'drachmae', // 'rank' | 'drachmae' | 'karma'
1820
+ sortDir: 'desc', // 'desc' | 'asc'
1821
+ };
1822
+
1823
+ // Stable sort. Server returns rows in drachmae-DESC order, which is also the
1824
+ // default view; for other modes (rank, karma, drachmae-ASC), re-sort here.
1825
+ // The original server order is preserved as a tie-breaker so equal-value rows
1826
+ // don't jitter between renders. Drachmae is the secondary tie-breaker when
1827
+ // sorting by karma (V3.R26 #246) so the karma toggle still reads sensibly
1828
+ // when many builders share the same karma value (e.g. zero).
1829
+ function sortedLeaderboardRows() {
1830
+ const indexed = lbState.rows.map((row, idx) => ({ row, idx }));
1831
+ const dir = lbState.sortDir === 'asc' ? 1 : -1;
1832
+ if (lbState.sortBy === 'rank') {
1833
+ return indexed
1834
+ .sort((a, b) => {
1835
+ const diff = (rankWeight(a.row.rank) - rankWeight(b.row.rank)) * dir;
1836
+ return diff !== 0 ? diff : a.idx - b.idx;
1837
+ })
1838
+ .map(({ row }) => row);
1839
+ }
1840
+ if (lbState.sortBy === 'karma') {
1841
+ return indexed
1842
+ .sort((a, b) => {
1843
+ const ak = Number(a.row.karma) || 0;
1844
+ const bk = Number(b.row.karma) || 0;
1845
+ const diff = (ak - bk) * dir;
1846
+ if (diff !== 0) return diff;
1847
+ // Tie-break on drachmae DESC, then stable on server order.
1848
+ const ac = Number(a.row.total_credits) || 0;
1849
+ const bc = Number(b.row.total_credits) || 0;
1850
+ if (ac !== bc) return bc - ac;
1851
+ return a.idx - b.idx;
1852
+ })
1853
+ .map(({ row }) => row);
1854
+ }
1855
+ if (lbState.sortBy === 'drachmae') {
1856
+ // Server already DESCs by drachmae, so the default mode is a no-op
1857
+ // copy. The ASC branch flips it.
1858
+ if (lbState.sortDir === 'desc') return lbState.rows.slice();
1859
+ return indexed
1860
+ .sort((a, b) => {
1861
+ const ac = Number(a.row.total_credits) || 0;
1862
+ const bc = Number(b.row.total_credits) || 0;
1863
+ const diff = (ac - bc) * dir;
1864
+ return diff !== 0 ? diff : a.idx - b.idx;
1865
+ })
1866
+ .map(({ row }) => row);
1867
+ }
1868
+ return lbState.rows.slice();
1869
+ }
1870
+
1871
+ // Map a sortable column id → the lbState.sortBy key. Adding a new sortable
1872
+ // column means adding an entry here + a header in index.html with id
1873
+ // `lb-sort-<key>`. The handler walks aria-sort across all three headers so
1874
+ // only one column shows its arrow at a time.
1875
+ const LB_SORT_HEADERS = {
1876
+ rank: 'lb-sort-rank',
1877
+ drachmae: 'lb-sort-drachmae',
1878
+ karma: 'lb-sort-karma',
1879
+ };
1880
+
1881
+ // #757: this builder's place on the Roll, ranked by drachmae (total_credits)
1882
+ // descending — the canonical leaderboard order, independent of whatever column
1883
+ // the Roll table is currently sorted by. Computed client-side from the rows
1884
+ // already fetched for the Roll, so the Standing card shows it with no extra
1885
+ // round-trip. Returns { place, total } or null (rows not loaded yet, or this
1886
+ // builder isn't on the roll). Matched by github_login (rows carry no id).
1887
+ function leaderboardPlace(login) {
1888
+ const rows = Array.isArray(lbState.rows) ? lbState.rows : [];
1889
+ if (!rows.length || !login) return null;
1890
+ const ranked = [...rows].sort(
1891
+ (a, b) => (Number(b.total_credits) || 0) - (Number(a.total_credits) || 0),
1892
+ );
1893
+ const idx = ranked.findIndex((r) => r.github_login === login);
1894
+ return idx === -1 ? null : { place: idx + 1, total: ranked.length };
1895
+ }
1896
+
1897
+ function renderLeaderboard() {
1898
+ const rows = sortedLeaderboardRows();
1899
+ const start = lbState.page * lbState.pageSize;
1900
+ const end = start + lbState.pageSize;
1901
+ const slice = rows.slice(start, end);
1902
+ const tbody = $('#leaderboard tbody');
1903
+
1904
+ if (!slice.length) {
1905
+ tbody.innerHTML = emptyRow(5, 'No more builders to show.');
1906
+ } else {
1907
+ tbody.innerHTML = slice.map((b, i) => {
1908
+ const place = start + i + 1;
1909
+ const name = b.display_name || b.github_login || 'unknown builder';
1910
+ const badges = renderAchievementRow(b.achievements);
1911
+ const rank = renderRankBadge(normalizeRank(b.rank));
1912
+ // task 1467: link the name to the builder's public profile page. Guarded
1913
+ // on b.id (the /leaderboard row carries it) so a row without an id degrades
1914
+ // to plain text rather than a broken /profile?id=undefined link.
1915
+ const nameCell = b.id != null
1916
+ ? `<a class="lb-name" href="/profile?id=${encodeURIComponent(b.id)}">${escapeHtml(name)}</a>`
1917
+ : escapeHtml(name);
1918
+ return `
1919
+ <tr>
1920
+ <td class="ledger__num">${place}</td>
1921
+ <td>${nameCell} <span style="color:var(--ink-faint);font-size:0.86rem">@${escapeHtml(b.github_login || '')}</span>${badges}</td>
1922
+ <td class="ledger__rank">${rank}</td>
1923
+ <td class="ledger__drachmae">${fmtNum(b.total_credits)}</td>
1924
+ <td class="ledger__drachmae">${fmtNum(b.karma)}</td>
1925
+ </tr>
1926
+ `;
1927
+ }).join('');
1928
+ }
1929
+
1930
+ // Reflect the active sort on every sortable header. Only one column
1931
+ // shows its arrow at a time; the others reset to "none" — matches the
1932
+ // ARIA-sort spec.
1933
+ for (const [key, headerId] of Object.entries(LB_SORT_HEADERS)) {
1934
+ const head = document.getElementById(headerId);
1935
+ if (!head) continue;
1936
+ const ariaSort = (lbState.sortBy === key && lbState.sortDir)
1937
+ ? (lbState.sortDir === 'asc' ? 'ascending' : 'descending')
1938
+ : 'none';
1939
+ head.setAttribute('aria-sort', ariaSort);
1940
+ }
1941
+
1942
+ $('#lb-range').textContent = rows.length
1943
+ ? `${start + 1}–${Math.min(end, rows.length)} of ${rows.length}`
1944
+ : '0';
1945
+ $('#lb-prev').disabled = lbState.page === 0;
1946
+ $('#lb-next').disabled = end >= rows.length;
1947
+ }
1948
+
1949
+ // Cycle: pick column → desc → asc → back to default (drachmae desc).
1950
+ // For a click on the active column we just flip direction; for a click on
1951
+ // a different column we activate it at 'desc' (the natural "best first"
1952
+ // direction for all three columns). Clicking the same column a third time
1953
+ // returns to the default drachmae-desc — discoverable, no dead state.
1954
+ function onLbSortClick(key) {
1955
+ if (lbState.sortBy === key) {
1956
+ if (lbState.sortDir === 'desc') {
1957
+ lbState.sortDir = 'asc';
1958
+ } else {
1959
+ // Third click returns to the default view.
1960
+ lbState.sortBy = 'drachmae';
1961
+ lbState.sortDir = 'desc';
1962
+ }
1963
+ } else {
1964
+ lbState.sortBy = key;
1965
+ lbState.sortDir = 'desc';
1966
+ }
1967
+ lbState.page = 0;
1968
+ renderLeaderboard();
1969
+ }
1970
+
1971
+ // ---- Archon security watch (V3.R66 / #285) -------------------------------
1972
+ //
1973
+ // Hidden by default; revealed only when /me returns rank='archon'. The
1974
+ // table renders open vulnerability reports + adjudication actions
1975
+ // (confirm / dispute / fix / link-fix). The audit log is collapsed
1976
+ // behind a <details> and loads on first open.
1977
+
1978
+ const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1 };
1979
+
1980
+ let securityState = {
1981
+ statusFilter: '',
1982
+ auditLoaded: false,
1983
+ };
1984
+
1985
+ function severityBadge(sev) {
1986
+ const cls = `sev-${sev}`;
1987
+ return `<span class="sev-badge ${cls}">${escapeHtml(sev || '?')}</span>`;
1988
+ }
1989
+
1990
+ function renderSecurityReports(payload) {
1991
+ const tbody = document.querySelector('#security-reports tbody');
1992
+ if (!tbody) return;
1993
+ const reports = Array.isArray(payload?.reports) ? payload.reports : [];
1994
+ const bounty = payload?.bounty_table || {};
1995
+ if (reports.length === 0) {
1996
+ tbody.innerHTML = emptyRow(7, 'No reports match this filter.');
1997
+ return;
1998
+ }
1999
+ // Sort: open first (by severity desc), then everything else newest first.
2000
+ const sorted = reports.slice().sort((a, b) => {
2001
+ const aOpen = a.status === 'open' || a.status === 'repro_attempted' ? 1 : 0;
2002
+ const bOpen = b.status === 'open' || b.status === 'repro_attempted' ? 1 : 0;
2003
+ if (aOpen !== bOpen) return bOpen - aOpen;
2004
+ const aS = SEVERITY_RANK[a.severity] || 0;
2005
+ const bS = SEVERITY_RANK[b.severity] || 0;
2006
+ if (aS !== bS) return bS - aS;
2007
+ return new Date(b.reported_at) - new Date(a.reported_at);
2008
+ });
2009
+ tbody.innerHTML = sorted.map((r) => {
2010
+ const reporter = r.reporter_builder_id ? `#${escapeHtml(String(r.reporter_builder_id))}` : '—';
2011
+ const drachmae = r.drachmae_awarded != null ? fmtNum(r.drachmae_awarded) : (bounty[r.severity] ? `(${fmtNum(bounty[r.severity])} if confirmed)` : '—');
2012
+ const actions = renderSecurityActions(r);
2013
+ return `
2014
+ <tr data-report-id="${r.id}">
2015
+ <td class="ledger__num">${escapeHtml(String(r.id))}</td>
2016
+ <td>${severityBadge(r.severity)}</td>
2017
+ <td>${escapeHtml(r.title || '')}<div style="font-size:0.78rem;opacity:0.7;">${escapeHtml(r.target || '')}</div></td>
2018
+ <td>${escapeHtml(r.status || '')}</td>
2019
+ <td>${reporter}</td>
2020
+ <td class="ledger__drachmae">${drachmae}</td>
2021
+ <td>${actions}</td>
2022
+ </tr>
2023
+ `;
2024
+ }).join('');
2025
+ }
2026
+
2027
+ function renderSecurityActions(report) {
2028
+ const id = report.id;
2029
+ const isTerminal = report.status === 'fixed' || report.status === 'wontfix';
2030
+ const buttons = [];
2031
+ if (!isTerminal && report.status !== 'confirmed') {
2032
+ buttons.push(`<button type="button" class="btn-ghost sec-act" data-action="confirm" data-id="${id}">Confirm</button>`);
2033
+ }
2034
+ if (!isTerminal && report.status !== 'disputed') {
2035
+ buttons.push(`<button type="button" class="btn-ghost sec-act" data-action="dispute" data-id="${id}">Dispute</button>`);
2036
+ }
2037
+ if (report.status !== 'fixed') {
2038
+ buttons.push(`<button type="button" class="btn-ghost sec-act" data-action="fix" data-id="${id}">Mark fixed</button>`);
2039
+ }
2040
+ buttons.push(`<button type="button" class="btn-ghost sec-act" data-action="link-fix" data-id="${id}" title="Attach a fix-task ID">Link fix…</button>`);
2041
+ return buttons.join(' ');
2042
+ }
2043
+
2044
+ async function loadSecurityReports() {
2045
+ const status = securityState.statusFilter;
2046
+ const url = status ? `${API}/security/reports?status=${encodeURIComponent(status)}` : `${API}/security/reports`;
2047
+ try {
2048
+ const payload = await getJSON(url);
2049
+ renderSecurityReports(payload);
2050
+ } catch (err) {
2051
+ if (err && err.message === 'redirecting to auth') return;
2052
+ const tbody = document.querySelector('#security-reports tbody');
2053
+ if (tbody) tbody.innerHTML = emptyRow(7, 'Could not load reports.');
2054
+ console.error('[builders] load security reports failed', err);
2055
+ }
2056
+ }
2057
+
2058
+ async function adjudicateReport(id, action) {
2059
+ const status = $('#security-status');
2060
+ if (status) { status.hidden = false; status.textContent = 'Working…'; }
2061
+ try {
2062
+ let body = {};
2063
+ let path = `${API}/security/reports/${encodeURIComponent(id)}/${action}`;
2064
+ if (action === 'link-fix') {
2065
+ const taskIdRaw = window.prompt(`Task ID to link as the fix for report #${id}?`, '');
2066
+ if (taskIdRaw == null) {
2067
+ if (status) status.textContent = 'Cancelled.';
2068
+ return;
2069
+ }
2070
+ const taskId = Number(taskIdRaw);
2071
+ if (!Number.isInteger(taskId) || taskId <= 0) {
2072
+ if (status) status.textContent = 'Bad task id — must be a positive integer.';
2073
+ return;
2074
+ }
2075
+ body = { task_id: taskId };
2076
+ }
2077
+ const resp = await api.request('POST', path, { body });
2078
+ if (resp.status === 401) { window.location.assign(`${API}/auth/web/start`); throw new Error('redirecting to auth'); }
2079
+ const data = resp.data || {};
2080
+ if (!resp.ok) {
2081
+ if (status) status.textContent = `Failed: ${data.error || resp.status}`;
2082
+ return;
2083
+ }
2084
+ if (status) status.textContent = `Report #${id} → ${data.report?.status || action}`;
2085
+ await loadSecurityReports();
2086
+ } catch (err) {
2087
+ if (err && err.message === 'redirecting to auth') return;
2088
+ console.error('[builders] adjudication failed', err);
2089
+ if (status) status.textContent = 'Adjudication failed. Try again.';
2090
+ }
2091
+ }
2092
+
2093
+ async function loadAuditLog() {
2094
+ const tbody = document.querySelector('#security-audit-log tbody');
2095
+ if (!tbody) return;
2096
+ tbody.innerHTML = emptyRow(5, 'Loading…');
2097
+ const method = $('#f-audit-method')?.value || '';
2098
+ const builderId = $('#f-audit-builder')?.value || '';
2099
+ const params = new URLSearchParams();
2100
+ params.set('limit', '50');
2101
+ if (method) params.set('method', method);
2102
+ if (builderId) params.set('builder_id', builderId);
2103
+ try {
2104
+ const payload = await getJSON(`${API}/audit-log?${params}`);
2105
+ const rows = Array.isArray(payload?.rows) ? payload.rows : [];
2106
+ if (rows.length === 0) {
2107
+ tbody.innerHTML = emptyRow(5, 'No matching audit entries.');
2108
+ return;
2109
+ }
2110
+ tbody.innerHTML = rows.map((r) => `
2111
+ <tr>
2112
+ <td>${escapeHtml(fmtDate(r.recorded_at))}</td>
2113
+ <td>#${escapeHtml(String(r.builder_id))}</td>
2114
+ <td>${escapeHtml(r.method)}</td>
2115
+ <td><code>${escapeHtml(r.route)}</code></td>
2116
+ <td>${escapeHtml(String(r.response_status))}</td>
2117
+ </tr>
2118
+ `).join('');
2119
+ } catch (err) {
2120
+ if (err && err.message === 'redirecting to auth') return;
2121
+ console.error('[builders] audit-log load failed', err);
2122
+ tbody.innerHTML = emptyRow(5, 'Could not load audit log.');
2123
+ }
2124
+ }
2125
+
2126
+ // task 1723: unhideArchonView is gone — the Archon nav group (Watch, Harbor,
2127
+ // Gate, Sessions) is the shell's, revealed by renderNav's applyAccess call
2128
+ // once /me confirms rank === 'archon' (shell.js gates Harbor on the dev-box
2129
+ // module too). The data still loads only on the Archon pages, each behind its
2130
+ // own archon-gated endpoint (#726).
2131
+
2132
+ // ---- Archon roster + management (V3.R14 / #238) --------------------------
2133
+ //
2134
+ // Archon-only roster of every builder, with promote / demote /
2135
+ // release-stale-claim controls. Hidden via #roster-scroll[hidden] for
2136
+ // non-Archons; unhideArchonView flips it on.
2137
+ //
2138
+ // Rank ladder is xenos < thetes < metic < archon (RANK_ORDER above). Promote
2139
+ // walks the ladder up ONE rung; demote walks it down one. The server enforces
2140
+ // the one-rung-promotion rule (#707), so the button must offer the true next
2141
+ // rung per row — a xenos promotes to thetes (not metic), a thetes to metic.
2142
+
2143
+ const ROSTER_LADDER = ['xenos', 'thetes', 'metic', 'archon'];
2144
+ // #408: paginate the Archon roster — it can grow past a screenful as the
2145
+ // community scales (200+ rows seen during stress test). 20 rows/page
2146
+ // matches the cardinality of "one comfortable scroll" inside the section
2147
+ // without forcing the user to scan further than they can hold in working
2148
+ // memory. Filter dropdowns still scope the dataset *before* pagination,
2149
+ // so a Show:onboarding filter still narrows to onboarding builders.
2150
+ const ROSTER_PAGE_SIZE = 20;
2151
+
2152
+ let rosterState = {
2153
+ rows: [],
2154
+ show: 'all',
2155
+ staleHours: 24,
2156
+ selfId: null,
2157
+ loading: false,
2158
+ page: 0,
2159
+ };
2160
+
2161
+ function nextRank(rank, direction /* 'up' | 'down' */) {
2162
+ const idx = ROSTER_LADDER.indexOf((rank || '').toLowerCase());
2163
+ if (idx < 0) return null;
2164
+ if (direction === 'up' && idx < ROSTER_LADDER.length - 1) return ROSTER_LADDER[idx + 1];
2165
+ if (direction === 'down' && idx > 0) return ROSTER_LADDER[idx - 1];
2166
+ return null;
2167
+ }
2168
+
2169
+ function fmtTtfsMinutes(min) {
2170
+ if (min === null || min === undefined) return '—';
2171
+ const n = Number(min);
2172
+ if (!Number.isFinite(n) || n < 0) return '—';
2173
+ if (n < 60) return `${n}m`;
2174
+ const hours = n / 60;
2175
+ if (hours < 24) return `${hours.toFixed(1)}h`;
2176
+ const days = hours / 24;
2177
+ if (days < 30) return `${days.toFixed(1)}d`;
2178
+ const months = days / 30;
2179
+ return `${months.toFixed(1)}mo`;
2180
+ }
2181
+
2182
+ function rosterFilterRows(rows) {
2183
+ switch (rosterState.show) {
2184
+ case 'onboarding':
2185
+ return rows.filter((r) => !r.first_ship_at && r.status !== 'inactive');
2186
+ case 'active':
2187
+ return rows.filter((r) => Number(r.active_claim_count) > 0);
2188
+ case 'stale':
2189
+ return rows.filter((r) => r.stale_claim);
2190
+ case 'inactive':
2191
+ return rows.filter((r) => r.status === 'inactive');
2192
+ case 'all':
2193
+ default:
2194
+ return rows;
2195
+ }
2196
+ }
2197
+
2198
+ function renderRoster() {
2199
+ const tbody = document.querySelector('#roster-table tbody');
2200
+ const countEl = $('#roster-count');
2201
+ const pagerEl = $('#roster-pager');
2202
+ const rangeEl = $('#roster-range');
2203
+ const prevBtn = $('#roster-prev');
2204
+ const nextBtn = $('#roster-next');
2205
+ if (!tbody) return;
2206
+ const rows = rosterFilterRows(rosterState.rows);
2207
+ if (countEl) {
2208
+ countEl.textContent = `${rows.length} / ${rosterState.rows.length}`;
2209
+ }
2210
+ if (rows.length === 0) {
2211
+ tbody.innerHTML = emptyRow(9, 'No builders match this filter.');
2212
+ if (pagerEl) pagerEl.hidden = true;
2213
+ return;
2214
+ }
2215
+ // #408: client-side pagination. renderPager clamps the page index in case a
2216
+ // filter/refresh shrank the dataset under our feet (e.g. on page 4, then
2217
+ // switch to a filter with only 30 matching rows → snap back to the last
2218
+ // legal page) and updates the range + prev/next controls.
2219
+ const { start } = renderPager(rosterState, rows.length, ROSTER_PAGE_SIZE, { pagerEl, rangeEl, prevBtn, nextBtn });
2220
+ const slice = rows.slice(start, start + ROSTER_PAGE_SIZE);
2221
+
2222
+ tbody.innerHTML = slice.map((b) => {
2223
+ const isSelf = String(b.id) === String(rosterState.selfId);
2224
+ const isInactive = b.status === 'inactive';
2225
+ const upRank = nextRank(b.rank, 'up');
2226
+ const downRank = nextRank(b.rank, 'down');
2227
+ const claimCount = Number(b.active_claim_count) || 0;
2228
+ const stale = !!b.stale_claim;
2229
+ const claimsCell = claimCount === 0
2230
+ ? '<span style="opacity:0.6;">none</span>'
2231
+ : `${claimCount}${stale ? ' <span class="roster__stale" title="At least one claim is stale (no pulse beyond threshold)">⚠ stale</span>' : ''}`;
2232
+ const actions = [];
2233
+ if (isSelf) {
2234
+ actions.push('<span style="opacity:0.55;font-size:0.8rem;">— self —</span>');
2235
+ } else if (isInactive) {
2236
+ actions.push('<span style="opacity:0.55;font-size:0.8rem;">inactive</span>');
2237
+ } else {
2238
+ if (upRank) {
2239
+ actions.push(`<button type="button" class="btn-ghost roster__promote" data-id="${escapeHtml(String(b.id))}" data-current="${escapeHtml(b.rank || '')}" data-next="${upRank}">Promote → ${upRank}</button>`);
2240
+ }
2241
+ if (downRank) {
2242
+ actions.push(`<button type="button" class="btn-ghost roster__demote" data-id="${escapeHtml(String(b.id))}" data-current="${escapeHtml(b.rank || '')}" data-next="${downRank}">Demote → ${downRank}</button>`);
2243
+ }
2244
+ if (stale) {
2245
+ actions.push(`<button type="button" class="btn-ghost roster__release-stale" data-id="${escapeHtml(String(b.id))}">Release stale claim</button>`);
2246
+ }
2247
+ }
2248
+ const inactiveTag = isInactive ? ' <span class="roster__inactive">inactive</span>' : '';
2249
+ const selfTag = isSelf ? ' <span class="roster__self">you</span>' : '';
2250
+ const handle = b.display_name || b.github_login || `builder #${b.id}`;
2251
+ return `
2252
+ <tr data-builder-id="${escapeHtml(String(b.id))}">
2253
+ <td>${escapeHtml(handle)}${selfTag}${inactiveTag}<div style="font-size:0.75rem;opacity:0.65;">#${escapeHtml(String(b.id))} · @${escapeHtml(b.github_login || '')}</div></td>
2254
+ <td>${renderRankBadge(b.rank)}</td>
2255
+ <td class="ledger__drachmae">${fmtNum(b.total_credits)}</td>
2256
+ <td>${fmtNum(b.karma)}</td>
2257
+ <td class="ledger__date">${escapeHtml(fmtDate(b.onboarded_at))}</td>
2258
+ <td class="ledger__date">${b.first_ship_at ? escapeHtml(fmtDate(b.first_ship_at)) : '<span style="opacity:0.6;">— not yet —</span>'}</td>
2259
+ <td>${fmtTtfsMinutes(b.time_to_first_ship_minutes)}</td>
2260
+ <td>${claimsCell}</td>
2261
+ <td>${actions.join(' ')}</td>
2262
+ </tr>
2263
+ `;
2264
+ }).join('');
2265
+ }
2266
+
2267
+ async function loadRoster() {
2268
+ const status = $('#roster-status');
2269
+ if (rosterState.loading) return;
2270
+ rosterState.loading = true;
2271
+ if (status) { status.hidden = false; status.textContent = 'Loading roster…'; }
2272
+ try {
2273
+ const payload = await getJSON(`${API}/builders/roster?stale_hours=${encodeURIComponent(rosterState.staleHours)}`);
2274
+ rosterState.rows = Array.isArray(payload?.builders) ? payload.builders : [];
2275
+ renderRoster();
2276
+ if (status) status.hidden = true;
2277
+ } catch (err) {
2278
+ if (err && err.message === 'redirecting to auth') return;
2279
+ console.error('[builders] load roster failed', err);
2280
+ const tbody = document.querySelector('#roster-table tbody');
2281
+ if (tbody) tbody.innerHTML = emptyRow(9, 'Could not load the roster.');
2282
+ if (status) { status.hidden = false; status.textContent = `Could not load roster: ${err.message}`; }
2283
+ } finally {
2284
+ rosterState.loading = false;
2285
+ }
2286
+ }
2287
+
2288
+ async function rosterChangeRank(builderId, nextRankName, currentRankName) {
2289
+ const status = $('#roster-status');
2290
+ const ok = window.confirm(
2291
+ `Change builder #${builderId} from ${currentRankName || '?'} to ${nextRankName}?`
2292
+ );
2293
+ if (!ok) return;
2294
+ if (status) { status.hidden = false; status.textContent = `Setting rank to ${nextRankName}…`; }
2295
+ try {
2296
+ const resp = await patchJSON(`${API}/builders/${encodeURIComponent(builderId)}/rank`, { rank: nextRankName });
2297
+ const newRank = resp?.builder?.rank || nextRankName;
2298
+ if (status) status.textContent = `Builder #${builderId} → ${newRank}.`;
2299
+ toast(`Builder #${builderId} is now ${newRank}.`);
2300
+ await loadRoster();
2301
+ } catch (err) {
2302
+ if (err && err.message === 'redirecting to auth') return;
2303
+ console.error('[builders] rank change failed', err);
2304
+ if (status) { status.hidden = false; status.textContent = `Rank change failed: ${err.message}`; }
2305
+ toast('Rank change failed.');
2306
+ }
2307
+ }
2308
+
2309
+ async function rosterReleaseStale(builderId) {
2310
+ const status = $('#roster-status');
2311
+ let claims;
2312
+ if (status) { status.hidden = false; status.textContent = 'Looking up stale claims…'; }
2313
+ try {
2314
+ const payload = await getJSON(`${API}/builders/${encodeURIComponent(builderId)}/active-claims?stale_hours=${encodeURIComponent(rosterState.staleHours)}`);
2315
+ claims = Array.isArray(payload?.claims) ? payload.claims : [];
2316
+ } catch (err) {
2317
+ if (err && err.message === 'redirecting to auth') return;
2318
+ if (status) status.textContent = `Could not fetch claims: ${err.message}`;
2319
+ return;
2320
+ }
2321
+ const stale = claims.filter((c) => c.stale_claim);
2322
+ if (stale.length === 0) {
2323
+ if (status) status.textContent = 'No stale claims left to release for that builder.';
2324
+ // The roster row was probably stale-from-cache — refresh.
2325
+ await loadRoster();
2326
+ return;
2327
+ }
2328
+ // Pick the oldest stale claim (the queue order matches claimed_at ASC
2329
+ // from the server). For multiple stale claims, surface the count so
2330
+ // the Archon knows another action may be needed.
2331
+ const target = stale[0];
2332
+ const reason = window.prompt(
2333
+ `Release stale claim #${target.claim_id} (task #${target.task_id} "${target.task_title || ''}") for builder #${builderId}?\n\nReason (required):`,
2334
+ 'pulse heartbeat went silent; freeing the task'
2335
+ );
2336
+ if (reason == null) {
2337
+ if (status) status.textContent = 'Cancelled.';
2338
+ return;
2339
+ }
2340
+ const trimmed = String(reason).trim();
2341
+ if (!trimmed) {
2342
+ if (status) status.textContent = 'A reason is required for an on-behalf release.';
2343
+ return;
2344
+ }
2345
+ if (status) status.textContent = `Releasing claim #${target.claim_id}…`;
2346
+ try {
2347
+ const resp = await api.request('POST', `${API}/claims/${encodeURIComponent(target.claim_id)}/release-on-behalf`, { body: { reason: trimmed } });
2348
+ if (resp.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
2349
+ const data = resp.data || {};
2350
+ if (!resp.ok) {
2351
+ if (status) status.textContent = `Release failed: ${data.error || resp.status}`;
2352
+ toast('Release failed.');
2353
+ return;
2354
+ }
2355
+ const remaining = stale.length - 1;
2356
+ if (status) status.textContent = `Released claim #${target.claim_id}.` + (remaining > 0 ? ` ${remaining} more stale claim(s) on this builder — click again to release the next.` : '');
2357
+ toast('Claim released.');
2358
+ await loadRoster();
2359
+ } catch (err) {
2360
+ if (status) status.textContent = `Release failed: ${err.message}`;
2361
+ toast('Release failed.');
2362
+ }
2363
+ }
2364
+
2365
+ // ---- override requests (V3.R74 / #293) -----------------------------------
2366
+ //
2367
+ // Archon-only. Lists open requests where a builder asked the Archon to
2368
+ // override a failed grader. Approve flips task to confirmed + pays the
2369
+ // requester; deny only stamps the decision row.
2370
+
2371
+ async function loadOverrideRequests() {
2372
+ const tbody = document.querySelector('#override-requests tbody');
2373
+ if (!tbody) return;
2374
+ try {
2375
+ const data = await getJSON(`${API}/override-requests?status=open`);
2376
+ const rows = Array.isArray(data?.requests) ? data.requests : [];
2377
+ if (rows.length === 0) {
2378
+ tbody.innerHTML = emptyRow(6, 'No open requests.');
2379
+ return;
2380
+ }
2381
+ tbody.innerHTML = rows.map((r) => `
2382
+ <tr data-request-id="${escapeHtml(String(r.id))}">
2383
+ <td>${escapeHtml(String(r.id))}</td>
2384
+ <td>#${escapeHtml(String(r.task_id))} — ${escapeHtml(r.task_title || '')}</td>
2385
+ <td>@${escapeHtml(r.requester_login || '')}</td>
2386
+ <td class="override__rationale">${escapeHtml(r.rationale || '')}</td>
2387
+ <td>${escapeHtml(String(r.credits_reward ?? '—'))}</td>
2388
+ <td>
2389
+ <button class="btn-ghost override__approve" type="button">Approve</button>
2390
+ <button class="btn-ghost override__deny" type="button">Deny</button>
2391
+ </td>
2392
+ </tr>
2393
+ `).join('');
2394
+ } catch (err) {
2395
+ if (err && err.message === 'redirecting to auth') return;
2396
+ console.error('[builders] override-requests failed', err);
2397
+ tbody.innerHTML = emptyRow(6, 'Could not load override requests.');
2398
+ }
2399
+ }
2400
+
2401
+ async function decideOverride(requestId, decision) {
2402
+ const status = $('#override-status');
2403
+ const verb = decision === 'approve' ? 'Approve' : 'Deny';
2404
+ const note = window.prompt(`${verb} request #${requestId} — optional note (sent to audit log):`, '');
2405
+ if (note === null) return; // user cancelled
2406
+ try {
2407
+ const res = await api.request('POST', `${API}/override-requests/${encodeURIComponent(requestId)}/decide`, { body: { decision, note: note || undefined } });
2408
+ if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
2409
+ const body = res.data || {};
2410
+ if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
2411
+ if (status) {
2412
+ status.hidden = false;
2413
+ status.textContent = decision === 'approve'
2414
+ ? `Approved — task flipped to confirmed, ${curLabel()} paid: ${body.confirmResult?.creditsAwarded ?? 0}.`
2415
+ : `Denied — the task stays at completed.`;
2416
+ }
2417
+ await loadOverrideRequests();
2418
+ } catch (err) {
2419
+ console.error('[builders] decide override failed', err);
2420
+ if (status) { status.hidden = false; status.textContent = `Could not decide: ${err.message}`; }
2421
+ }
2422
+ }
2423
+
2424
+ // ---- skill model preferences (V3.R85 / #304) -----------------------------
2425
+ //
2426
+ // The matrix lets each builder override the system default model for each
2427
+ // slash-command skill. System defaults come from the audit in
2428
+ // src/bongos/skill-prefs.js (mechanism vs judgment classification) and are
2429
+ // echoed back in the GET response so the table can label them.
2430
+
2431
+ const MODEL_LABELS = {
2432
+ opus: 'Opus',
2433
+ sonnet: 'Sonnet',
2434
+ haiku: 'Haiku',
2435
+ script: 'no LLM',
2436
+ };
2437
+
2438
+ function modelLabel(m) {
2439
+ return MODEL_LABELS[m] || m || '—';
2440
+ }
2441
+
2442
+ function renderSkillPrefs(data) {
2443
+ const tbody = $('#skill-prefs-table tbody');
2444
+ if (!tbody) return;
2445
+ const rows = Array.isArray(data?.effective) ? data.effective : [];
2446
+ const allowed = Array.isArray(data?.allowed_models) ? data.allowed_models : ['opus', 'sonnet', 'haiku', 'script'];
2447
+ if (rows.length === 0) {
2448
+ tbody.innerHTML = emptyRow(3, 'No skills registered.');
2449
+ return;
2450
+ }
2451
+ tbody.innerHTML = rows.map((row) => {
2452
+ const opts = ['<option value="">— default —</option>']
2453
+ .concat(allowed.map((m) => {
2454
+ const sel = row.override_model === m ? ' selected' : '';
2455
+ return `<option value="${m}"${sel}>${modelLabel(m)}</option>`;
2456
+ }))
2457
+ .join('');
2458
+ const defaultCell = `<span class="skill-prefs__default" title="System default for this skill">${modelLabel(row.default_model)}</span>`;
2459
+ return `
2460
+ <tr data-skill="${escapeHtml(row.skill)}">
2461
+ <td><code>${escapeHtml(row.skill)}</code></td>
2462
+ <td>${defaultCell}</td>
2463
+ <td>
2464
+ <select class="skill-prefs__select" data-skill="${escapeHtml(row.skill)}" aria-label="Model for ${escapeHtml(row.skill)}">
2465
+ ${opts}
2466
+ </select>
2467
+ </td>
2468
+ </tr>
2469
+ `;
2470
+ }).join('');
2471
+ }
2472
+
2473
+ async function saveSkillPref(skill, modelOrNull) {
2474
+ const status = $('#skill-prefs-status');
2475
+ if (status) { status.hidden = false; status.textContent = 'Saving…'; }
2476
+ try {
2477
+ const body = { [skill]: modelOrNull };
2478
+ const resp = await patchJSON(`${API}/me/skill-prefs`, body);
2479
+ renderSkillPrefs(resp);
2480
+ if (status) status.textContent = modelOrNull
2481
+ ? `Set ${skill} → ${modelLabel(modelOrNull)}.`
2482
+ : `Cleared ${skill} override (falls back to default).`;
2483
+ } catch (err) {
2484
+ if (err && err.message === 'redirecting to auth') return;
2485
+ console.error('[builders] save skill pref failed', err);
2486
+ if (status) status.textContent = 'Could not save. Try again.';
2487
+ }
2488
+ }
2489
+
2490
+ // ---- onboarding live-tick poll (task 1444) -------------------------------
2491
+ //
2492
+ // The onboarding checklist is rendered once from the page-load /me snapshot.
2493
+ // But three of its steps clear from LIVE server signals that flip WITHOUT any
2494
+ // local action or page reload while the hall sits open:
2495
+ // * box_connect — stamped by the box's heartbeat the first time a `claude`
2496
+ // process runs on the dev box (builder_boxes.first_connected_at);
2497
+ // * cli_setup — stamped when the GDS CLI is first authenticated on the box;
2498
+ // * join_discord — flips on the OAuth link (usually a full redirect, but can
2499
+ // also land from another tab).
2500
+ // Without a poll, a newcomer who connects their box AFTER opening the hall sees
2501
+ // the step stay unchecked until they manually reload — exactly the bug scootermack
2502
+ // hit. "The city watches" only worked on the next full reload. So while any
2503
+ // live-signal step is still uncleared, re-fetch /me on an interval and re-render
2504
+ // the checklist; stop once they all clear (or the builder graduates). The box
2505
+ // panel runs its OWN poll and is never re-mounted here (renderOnboarding touches
2506
+ // a different DOM subtree), so this doesn't disturb an in-flight setup.
2507
+ let onboardingPollTimer = null;
2508
+ const ONBOARDING_POLL_MS = 15000;
2509
+
2510
+ // True while at least one step that clears from a live signal (box/cli/discord)
2511
+ // is still uncleared and the builder hasn't graduated. The pick_disciplines and
2512
+ // ship_three steps flip via local action (PATCH re-renders) or shipping, so they
2513
+ // don't need a poll — scoping to the live-signal steps bounds polling to the
2514
+ // setup window rather than running forever for any non-graduated builder.
2515
+ function onboardingNeedsLivePoll(onboarding) {
2516
+ if (!onboarding || onboarding.graduated) return false;
2517
+ const stages = Array.isArray(onboarding.stages) ? onboarding.stages : [];
2518
+ return stages.some((s) =>
2519
+ (s.source === 'box' || s.source === 'cli' || s.source === 'discord') && !s.cleared);
2520
+ }
2521
+
2522
+ function stopOnboardingPoll() {
2523
+ if (onboardingPollTimer) { clearTimeout(onboardingPollTimer); onboardingPollTimer = null; }
2524
+ }
2525
+
2526
+ function scheduleOnboardingPoll(onboarding) {
2527
+ stopOnboardingPoll();
2528
+ if (!onboardingNeedsLivePoll(onboarding)) return;
2529
+ onboardingPollTimer = setTimeout(refreshOnboardingFromServer, ONBOARDING_POLL_MS);
2530
+ }
2531
+
2532
+ async function refreshOnboardingFromServer() {
2533
+ onboardingPollTimer = null;
2534
+ // Don't poll a backgrounded tab — wait for it to come back (the
2535
+ // visibilitychange listener re-arms from the cached state).
2536
+ if (document.hidden) { scheduleOnboardingPoll(lastMeState && lastMeState.onboarding); return; }
2537
+ let me;
2538
+ try {
2539
+ me = await getJSON(`${API}/me`, { softAuth: true });
2540
+ } catch (err) {
2541
+ // Session expired → stop (a redirect/landing will take over on next action).
2542
+ if (err && err.code === AUTH_NEEDED) { stopOnboardingPoll(); return; }
2543
+ // Transient (network blip / 5xx) → try again next interval so a single
2544
+ // failure doesn't permanently kill the auto-tick.
2545
+ scheduleOnboardingPoll(lastMeState && lastMeState.onboarding);
2546
+ return;
2547
+ }
2548
+ if (!me || !me.builder) return;
2549
+ const wasGraduated = !!(lastMeState && lastMeState.onboarding && lastMeState.onboarding.graduated);
2550
+ lastMeState = me;
2551
+ lastKarmaState = me.karma || lastKarmaState;
2552
+ lastCostState = me.cost || lastCostState;
2553
+ lastNeedsState = me.needs || lastNeedsState;
2554
+ renderOnboarding(me.onboarding || null, me.discord);
2555
+ // If they graduated while watching, hide the welcome panel + rebuild the nav
2556
+ // (drops the Welcome/Path chips) — the same transition loadAll would render.
2557
+ if (me.onboarding && me.onboarding.graduated && !wasGraduated) {
2558
+ renderWelcome(me.builder, me.onboarding);
2559
+ renderNav(me.builder, me.onboarding || null);
2560
+ }
2561
+ scheduleOnboardingPoll(me.onboarding || null);
2562
+ }
2563
+
2564
+ // ---- bootstrap -----------------------------------------------------------
2565
+
2566
+ async function loadAll() {
2567
+ // Profile first. If /me returns 401, render an unauth landing card with a
2568
+ // Sign-in button instead of bouncing straight to GitHub — gives the visitor
2569
+ // a beat of orientation about what they're walking into.
2570
+ let me;
2571
+ try {
2572
+ me = await getJSON(`${API}/me`, { softAuth: true });
2573
+ } catch (err) {
2574
+ if (err && err.code === AUTH_NEEDED) { renderLanding(); return; }
2575
+ throw err;
2576
+ }
2577
+ // Signed in: drop the landing card if a previous unauth render left it up.
2578
+ const landingRoot = $('#landing-root');
2579
+ if (landingRoot) { landingRoot.hidden = true; landingRoot.innerHTML = ''; }
2580
+
2581
+ lastKarmaState = me.karma || null;
2582
+ lastCostState = me.cost || null;
2583
+ lastNeedsState = me.needs || null; // task 1013 / ADR 0073: action-needed banner
2584
+ // #408: stash the whole /me payload so reopenWelcomeFromNav can re-render
2585
+ // the welcome panel without a fresh round-trip.
2586
+ lastMeState = me;
2587
+ // #748: decide the newcomer gate up front so the async karma/acclaim feeds
2588
+ // (which un-hide their own scroll on data arrival) honour it too.
2589
+ newcomerGated = normalizeRank(me.builder?.rank) === 'xenos';
2590
+ // V3.R77 (#296): first-visit welcome panel. Renders above the profile and
2591
+ // hides itself once dismissed or once the builder graduates. Computed
2592
+ // before profile so the dismiss button is wired by the time the user
2593
+ // sees the page.
2594
+ // Cache self id on window so the dismiss-click handler can find it
2595
+ // without re-fetching /me. Set BEFORE renderWelcome so the wired
2596
+ // dismiss listener (attached once at bootstrap) can use it immediately.
2597
+ window.__buildersSelfId = me.builder?.id ?? null;
2598
+ renderWelcome(me.builder, me.onboarding || null);
2599
+ renderProfile(me.builder);
2600
+ // #731: the CLI-token re-issue card (V3.R91 #401) and the Discord connect/
2601
+ // link card (F7, ADR 0033) moved to /builders/settings — the hall's Standing
2602
+ // card is identity-only now.
2603
+ // V3.R75 (#294): auto-completing onboarding checklist. The /me payload
2604
+ // carries the builder's own state directly so this is a render-only call.
2605
+ renderOnboarding(me.onboarding || null, me.discord);
2606
+ // task 1444: keep the checklist live — re-fetch /me on an interval while a
2607
+ // box/cli/discord step is still uncleared, so it ticks itself without a manual
2608
+ // reload the moment the dev box connects (or the CLI/Discord links).
2609
+ scheduleOnboardingPoll(me.onboarding || null);
2610
+ renderRecentGrades(me.grades?.recent || []);
2611
+ renderRollingNote(me.grades?.rolling || null);
2612
+ // V3.R72 (#291, C5): the dev curve. Loaded fire-and-forget — a slow or
2613
+ // empty analytics feed must not hold up the rest of the hall, and the
2614
+ // section un-hides itself (and re-renders the nav) only when there's data.
2615
+ loadAnalytics(me.builder);
2616
+ // V3.R26 (#246): karma feed — the recent karma_log entries powering the
2617
+ // "why did my karma move" answer. The /me payload bundles this so the
2618
+ // page doesn't need a follow-up call.
2619
+ renderKarmaFeed(me.karma?.recent || []);
2620
+ // V3.R25 (#245): peer acclaim feed — a separate endpoint (not bundled in
2621
+ // /me because it spans all builders' work), loaded fire-and-forget so a
2622
+ // slow/failed feed can't hold up the rest of the hall.
2623
+ loadAcclaimFeed();
2624
+ // Capture self id for the roster's "cannot change your own rank" guard.
2625
+ rosterState.selfId = me.builder?.id ?? null;
2626
+
2627
+ // #408: build the sticky nav now that every section has settled its
2628
+ // hidden state (welcome → karma → onboarding → security/roster).
2629
+ // The renderNav call also wires the IntersectionObserver for active-
2630
+ // section highlighting. Idempotent — safe to call again on dismiss/re-open.
2631
+ // #748: collapse the hall to the newcomer view (onboarding + Standing) for
2632
+ // a Xenos BEFORE building the nav, so the chip strip matches what's shown.
2633
+ applyNewcomerGate();
2634
+ renderNav(me.builder, me.onboarding || null);
2635
+
2636
+ // Then the rest, in parallel. Any individual section failure shouldn't take
2637
+ // down the page — log and show an inline placeholder.
2638
+ // #726: the per-skill model picker (Voices) moved to /builders/settings,
2639
+ // so the hall no longer fetches /me/skill-prefs here.
2640
+ const [achAll, ships, versionsResp, lb, bounty] = await Promise.allSettled([
2641
+ getJSON(`${API}/achievements`),
2642
+ getJSON(`${API}/me/recent-ships?limit=all`),
2643
+ getJSON(`${API}/versions`),
2644
+ getJSON(`${API}/leaderboard`),
2645
+ getJSON(`${API}/public/bounty-table`),
2646
+ ]);
2647
+
2648
+ if (achAll.status === 'fulfilled') {
2649
+ // Show top 10 by sort_order — already ordered server-side. (Set is locked at 8
2650
+ // for V2, so the slice is defensive.) Unlocked + locked-with-progress come
2651
+ // straight from the enriched /me payload.
2652
+ renderAchievements(
2653
+ (achAll.value.achievements || []).slice(0, 10),
2654
+ me.unlocked || [],
2655
+ me.locked_with_progress || [],
2656
+ );
2657
+ } else {
2658
+ $('#ach-grid').innerHTML = `<div class="profile__placeholder">Achievements could not be loaded.</div>`;
2659
+ }
2660
+
2661
+ if (ships.status === 'fulfilled') {
2662
+ shipState.all = ships.value.ships || [];
2663
+ renderRecentShips();
2664
+ } else {
2665
+ $('#recent-ships tbody').innerHTML = emptyRow(4, 'Your work history could not be loaded.');
2666
+ }
2667
+
2668
+ if (versionsResp.status === 'fulfilled') {
2669
+ const versions = versionsResp.value.versions || [];
2670
+ // Populate the Works Wrought version filter — limited to versions this
2671
+ // builder has actually shipped to, so the dropdown can't offer empty
2672
+ // options. (The old claimable-works version filter moved to /work, #750.)
2673
+ populateShipVersionFilter(versions);
2674
+ }
2675
+
2676
+ if (lb.status === 'fulfilled') {
2677
+ lbState.rows = lb.value.leaderboard || [];
2678
+ lbState.page = 0;
2679
+ renderLeaderboard();
2680
+ // #757: the Standing card painted before the Roll loaded, so re-render it
2681
+ // now that lbState.rows exists — this is when its "#N on the Roll" stat
2682
+ // can resolve.
2683
+ if (lastMeState && lastMeState.builder) renderProfile(lastMeState.builder);
2684
+ } else {
2685
+ $('#leaderboard tbody').innerHTML = emptyRow(5, 'The leaderboard could not be loaded.');
2686
+ }
2687
+
2688
+ if (bounty.status === 'fulfilled') {
2689
+ renderBountyTable(bounty.value.table || []);
2690
+ } else {
2691
+ $('#bounty-table tbody').innerHTML = emptyRow(3, 'The reward schedule could not be loaded.');
2692
+ }
2693
+
2694
+ }
2695
+
2696
+ function wireEvents() {
2697
+ // #profile-body is re-rendered after load, so delegate the save click.
2698
+ $('#profile-body').addEventListener('click', (e) => {
2699
+ if (e.target.closest('#disc-save')) saveDisciplines();
2700
+ });
2701
+
2702
+ // V3.R72 (#291): the dev-curve scope toggle (Archon only). Delegated on the
2703
+ // curve body since renderCurveBody replaces the toggle's innerHTML.
2704
+ $('#curve-body')?.addEventListener('click', (e) => {
2705
+ const btn = e.target.closest('.curve__scope-btn[data-scope]');
2706
+ if (!btn) return;
2707
+ const scope = btn.dataset.scope === 'eco' ? 'eco' : 'self';
2708
+ if (scope === analyticsState.scope) return;
2709
+ analyticsState.scope = scope;
2710
+ renderCurveBody();
2711
+ });
2712
+
2713
+ // task 1723: the Home "show the setup guide again" link re-opens a dismissed
2714
+ // welcome banner (the old welcome chip's job) — clears the dismissal flag,
2715
+ // re-renders the panel, refreshes the nav, then scrolls to it.
2716
+ document.getElementById('welcome-reopen')?.addEventListener('click', (e) => {
2717
+ reopenWelcomeFromNav(e);
2718
+ });
2719
+
2720
+ // V3.R75 (#294): the onboarding list re-renders on each /me load, so the
2721
+ // inline craft picker is wired by delegation to the list container. (The
2722
+ // primer-read ack button that also lived here was removed in #766.)
2723
+ $('#onboarding-list')?.addEventListener('click', (e) => {
2724
+ const disc = e.target.closest('.onboarding-item__disc[data-disc]');
2725
+ if (disc) { pickDiscipline(disc.dataset.disc); return; }
2726
+ // #777: the only other control is the Connect-Discord anchor, which is a
2727
+ // real link (full-page nav into the OAuth flow) — nothing to handle here.
2728
+ // Both new steps (join_discord, cli_setup) auto-detect server-side.
2729
+ });
2730
+
2731
+ // #777: setup-config toggle in the welcome panel. Flips which config pane
2732
+ // (Desktop app vs Claude Online) is shown — pure client-side, no round-trip.
2733
+ // The dev-box buttons + credential-copy inside the online pane are handled by
2734
+ // the OTBBoxPanel module's own scoped listener (#807), not here.
2735
+ $('#welcome-body')?.addEventListener('click', (e) => {
2736
+ const tab = e.target.closest('.setup__tab[data-config]');
2737
+ if (!tab) return;
2738
+ const setup = tab.closest('.setup');
2739
+ if (!setup) return;
2740
+ setup.dataset.config = tab.dataset.config;
2741
+ setup.querySelectorAll('.setup__tab').forEach((t) => {
2742
+ t.setAttribute('aria-selected', String(t === tab));
2743
+ });
2744
+ writeSetupConfig(tab.dataset.config); // #785: remember the choice
2745
+ });
2746
+
2747
+ // Welcome panel minimize toggle (replaces full dismiss).
2748
+ $('#welcome-minimize')?.addEventListener('click', () => {
2749
+ const scroll = $('#welcome-scroll');
2750
+ const btn = $('#welcome-minimize');
2751
+ if (!scroll) return;
2752
+ const minimized = scroll.classList.toggle('scroll--welcome--minimized');
2753
+ if (btn) btn.textContent = minimized ? '+' : '−';
2754
+ if (minimized) onboardingBox().stop(); else onboardingBox().refresh();
2755
+ });
2756
+
2757
+ // V3.R25 (#245): delegated click handler for the peer-acclaim vote buttons.
2758
+ $('#acclaim-feed')?.addEventListener('click', onAcclaimClick);
2759
+
2760
+ $('#f-ships-window')?.addEventListener('change', onShipsFilterChange);
2761
+ $('#f-ships-version')?.addEventListener('change', onShipsFilterChange);
2762
+ $('#f-ships-sort')?.addEventListener('change', renderRecentShips);
2763
+ $('#ships-prev')?.addEventListener('click', () => {
2764
+ if (shipState.page > 0) { shipState.page--; renderRecentShips(); }
2765
+ });
2766
+ $('#ships-next')?.addEventListener('click', () => {
2767
+ shipState.page++;
2768
+ renderRecentShips();
2769
+ });
2770
+
2771
+
2772
+ $('#lb-prev').addEventListener('click', () => {
2773
+ if (lbState.page > 0) { lbState.page--; renderLeaderboard(); }
2774
+ });
2775
+ $('#lb-next').addEventListener('click', () => {
2776
+ const max = Math.ceil(lbState.rows.length / lbState.pageSize) - 1;
2777
+ if (lbState.page < max) { lbState.page++; renderLeaderboard(); }
2778
+ });
2779
+
2780
+ // Sortable leaderboard headers — clickable + keyboard-operable. Each
2781
+ // sortable column registers under LB_SORT_HEADERS; the click handler
2782
+ // routes the column key into the shared cycle.
2783
+ for (const [key, headerId] of Object.entries(LB_SORT_HEADERS)) {
2784
+ const head = document.getElementById(headerId);
2785
+ if (!head) continue;
2786
+ head.setAttribute('tabindex', '0');
2787
+ head.setAttribute('role', 'button');
2788
+ head.addEventListener('click', () => onLbSortClick(key));
2789
+ head.addEventListener('keydown', (e) => {
2790
+ if (e.key === 'Enter' || e.key === ' ') {
2791
+ e.preventDefault();
2792
+ onLbSortClick(key);
2793
+ }
2794
+ });
2795
+ }
2796
+
2797
+ // Security watch (Archon-only) — status filter + delegated action clicks.
2798
+ // The section is hidden for non-Archons via unhideArchonView, so these
2799
+ // listeners are safe to attach unconditionally (they just won't fire).
2800
+ $('#f-security-status')?.addEventListener('change', (e) => {
2801
+ securityState.statusFilter = e.target.value || '';
2802
+ loadSecurityReports();
2803
+ });
2804
+ $('#security-reports')?.addEventListener('click', (e) => {
2805
+ const btn = e.target.closest('.sec-act');
2806
+ if (!btn) return;
2807
+ const id = btn.dataset.id;
2808
+ const action = btn.dataset.action;
2809
+ if (id && action) adjudicateReport(id, action);
2810
+ });
2811
+ // Override-requests approve/deny — delegated to the table body.
2812
+ $('#override-requests')?.addEventListener('click', (e) => {
2813
+ const tr = e.target.closest('tr[data-request-id]');
2814
+ if (!tr) return;
2815
+ const id = tr.dataset.requestId;
2816
+ if (e.target.classList.contains('override__approve')) decideOverride(id, 'approve');
2817
+ else if (e.target.classList.contains('override__deny')) decideOverride(id, 'deny');
2818
+ });
2819
+ // Audit log — lazy-load on first <details> open, refresh on filter change.
2820
+ $('#security-audit-details')?.addEventListener('toggle', (e) => {
2821
+ if (e.target.open && !securityState.auditLoaded) {
2822
+ securityState.auditLoaded = true;
2823
+ loadAuditLog();
2824
+ }
2825
+ });
2826
+ $('#f-audit-method')?.addEventListener('change', loadAuditLog);
2827
+ $('#f-audit-builder')?.addEventListener('change', loadAuditLog);
2828
+ $('#audit-refresh')?.addEventListener('click', loadAuditLog);
2829
+
2830
+ // Skill-prefs table — change handler delegated so post-render select
2831
+ // elements still fire. The empty value ("— default —") clears the override
2832
+ // via the null sentinel; everything else PATCHes the named model.
2833
+ const prefsTable = $('#skill-prefs-table');
2834
+ if (prefsTable) {
2835
+ prefsTable.addEventListener('change', (e) => {
2836
+ const sel = e.target.closest('.skill-prefs__select');
2837
+ if (!sel) return;
2838
+ const skill = sel.dataset.skill;
2839
+ if (!skill) return;
2840
+ const next = sel.value === '' ? null : sel.value;
2841
+ saveSkillPref(skill, next);
2842
+ });
2843
+ }
2844
+
2845
+ // Roster — Archon-only. Filter change, threshold change, refresh button.
2846
+ // Action buttons (promote / demote / release-stale) delegated to the
2847
+ // table body; the table is re-rendered on every action so listeners can
2848
+ // safely be one-shot at the container.
2849
+ $('#f-roster-show')?.addEventListener('change', (e) => {
2850
+ rosterState.show = e.target.value || 'all';
2851
+ // #408: changing the filter changes the dataset, so jump back to the
2852
+ // first page — otherwise a "show: onboarding (3 rows)" filter could
2853
+ // leave the user stranded on page 4 of "all".
2854
+ rosterState.page = 0;
2855
+ renderRoster();
2856
+ });
2857
+ $('#f-roster-stale')?.addEventListener('change', (e) => {
2858
+ const n = Number(e.target.value);
2859
+ if (Number.isFinite(n) && n > 0 && n <= 720) {
2860
+ rosterState.staleHours = n;
2861
+ rosterState.page = 0;
2862
+ loadRoster();
2863
+ } else {
2864
+ e.target.value = String(rosterState.staleHours);
2865
+ toast('Stale threshold must be 1–720 hours.');
2866
+ }
2867
+ });
2868
+ $('#roster-refresh')?.addEventListener('click', loadRoster);
2869
+ // #408: roster pager — prev/next buttons mirroring the leaderboard
2870
+ // pattern. Page index is clamped inside renderRoster, so we don't need
2871
+ // to guard the bounds here.
2872
+ $('#roster-prev')?.addEventListener('click', () => {
2873
+ if (rosterState.page > 0) { rosterState.page--; renderRoster(); }
2874
+ });
2875
+ $('#roster-next')?.addEventListener('click', () => {
2876
+ rosterState.page++;
2877
+ renderRoster();
2878
+ });
2879
+ $('#roster-table')?.addEventListener('click', (e) => {
2880
+ const btn = e.target.closest('button');
2881
+ if (!btn) return;
2882
+ const id = btn.dataset.id;
2883
+ if (!id) return;
2884
+ if (btn.classList.contains('roster__promote') || btn.classList.contains('roster__demote')) {
2885
+ rosterChangeRank(id, btn.dataset.next, btn.dataset.current);
2886
+ } else if (btn.classList.contains('roster__release-stale')) {
2887
+ rosterReleaseStale(id);
2888
+ }
2889
+ });
2890
+
2891
+ // Logout link: the /api/bongos/auth/logout endpoint is POST + requires the
2892
+ // session cookie. Convert the anchor click into a fetch then bounce home.
2893
+ $('#logout-link').addEventListener('click', async (e) => {
2894
+ e.preventDefault();
2895
+ try {
2896
+ await api.request('POST', `${API}/auth/logout`);
2897
+ } catch (_) { /* even if it fails we drop the cookie below */ }
2898
+ // Best-effort cookie clear on the client side too. Mirror the SERVER
2899
+ // logout (src/bongos/routes/auth.js): expire BOTH the current `gds_session`
2900
+ // and the legacy `pms_session`, each in BOTH the host-only form and the
2901
+ // apex-scoped `Domain=.amazonprimea.com` form (#726 — the session cookie
2902
+ // is set apex-scoped on production hosts so it crosses subdomains; an
2903
+ // expire only matches the cookie whose Domain attribute it names, so the
2904
+ // host-only Max-Age=0 alone leaves the apex cookie live). Clearing only
2905
+ // `pms_session` host-only — the prior behaviour — left a real
2906
+ // `gds_session` cookie behind on this offline fallback path.
2907
+ for (const name of ['gds_session', 'pms_session']) {
2908
+ document.cookie = `${name}=; Max-Age=0; path=/`;
2909
+ document.cookie = `${name}=; Max-Age=0; path=/; Domain=.${location.hostname.split('.').slice(-2).join('.')}`;
2910
+ }
2911
+ window.location.assign('/');
2912
+ });
2913
+ }
2914
+
2915
+ // ---- Live updates (#569 / ADR 0030 → R55 / ADR 0069) -------------------
2916
+ // The hall used to fetch every panel once at load and then freeze until a
2917
+ // manual refresh — so an onboarding step that ticked server-side (e.g. after
2918
+ // /builder-ship) never showed. We now open a Server-Sent Events stream
2919
+ // (GET /api/bongos/live) that emits a tiny `nudge` whenever GDS state changes via
2920
+ // ANY write path — API routes, background routines, even direct psql writes
2921
+ // (the trigger lives in the DB; see migration 066 + src/bongos/routes/live.js).
2922
+ // On a nudge we re-run loadAll(), debounced so a burst coalesces into one
2923
+ // refresh. SSE replaced the Colyseus 'builders_hall' room (R55) so the hall's
2924
+ // live refresh works on a game-less Cloud Bongos boot with no Colyseus in the
2925
+ // process; EventSource also auto-reconnects, so there's no manual backoff here.
2926
+ let liveSource = null;
2927
+ let liveHadOpen = false;
2928
+ let liveRefreshTimer = null;
2929
+ let liveReloading = false;
2930
+ let visibilityWired = false;
2931
+
2932
+ // Don't yank the DOM out from under an active interaction or clobber unsent
2933
+ // input: if the user is typing in / focused on a field (e.g. the disciplines
2934
+ // editor), defer the refresh and re-arm shortly.
2935
+ function userIsInteracting() {
2936
+ const el = document.activeElement;
2937
+ return !!el && /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName);
2938
+ }
2939
+
2940
+ function scheduleLiveRefresh(delay = 700) {
2941
+ if (liveRefreshTimer) return; // already coalescing a burst
2942
+ liveRefreshTimer = setTimeout(async () => {
2943
+ liveRefreshTimer = null;
2944
+ if (liveReloading || document.hidden || userIsInteracting()) {
2945
+ scheduleLiveRefresh(1500); // not now — try again soon, don't drop it
2946
+ return;
2947
+ }
2948
+ liveReloading = true;
2949
+ try {
2950
+ await loadAll();
2951
+ } catch (err) {
2952
+ console.error('[builders] live refresh failed', err);
2953
+ } finally {
2954
+ liveReloading = false;
2955
+ }
2956
+ }, delay);
2957
+ }
2958
+
2959
+ function connectLiveSource() {
2960
+ // EventSource missing (ancient browser) → the hall stays static; the
2961
+ // visibilitychange backstop below still refreshes on focus.
2962
+ if (liveSource || typeof EventSource === 'undefined') return;
2963
+ try {
2964
+ // Same-origin GET — the gds_session cookie rides automatically, so the
2965
+ // server's requireBuilder gate authenticates the viewer transparently.
2966
+ const es = new EventSource(`${API}/live`);
2967
+ liveSource = es;
2968
+ es.addEventListener('nudge', () => scheduleLiveRefresh());
2969
+ es.onopen = () => {
2970
+ // On a RECONNECT (not the first open) we may have missed a nudge while
2971
+ // the stream was down — refresh once to catch up. EventSource owns the
2972
+ // reconnect, so there is no manual backoff to manage here.
2973
+ if (liveHadOpen) scheduleLiveRefresh(500);
2974
+ liveHadOpen = true;
2975
+ };
2976
+ es.onerror = () => {
2977
+ // A transient drop leaves readyState === CONNECTING and EventSource
2978
+ // reconnects on its own. A terminal close (CLOSED — e.g. a 401) won't,
2979
+ // so drop the handle; visibilitychange can then re-open later.
2980
+ if (es.readyState === EventSource.CLOSED) {
2981
+ liveSource = null;
2982
+ }
2983
+ };
2984
+ } catch (err) {
2985
+ console.warn('[builders] live stream failed', err && err.message ? err.message : err);
2986
+ }
2987
+ }
2988
+
2989
+ function startLiveUpdates() {
2990
+ connectLiveSource();
2991
+ // Belt-and-braces: also refresh when the tab regains focus, covering any
2992
+ // nudge missed while the connection was briefly down or the laptop slept.
2993
+ if (!visibilityWired) {
2994
+ visibilityWired = true;
2995
+ document.addEventListener('visibilitychange', () => {
2996
+ if (!document.hidden) {
2997
+ if (!liveSource) connectLiveSource(); // resume a dropped connection
2998
+ scheduleLiveRefresh(200);
2999
+ }
3000
+ });
3001
+ }
3002
+ }
3003
+
3004
+ // The #NNN deep-link record overlay moved to task-detail.js (task 1753) —
3005
+
3006
+ document.addEventListener('DOMContentLoaded', () => {
3007
+ // BV1.R75: stamp the registry's <section>s into <main> BEFORE wiring events —
3008
+ // wireEvents() + the data-render code address the widgets by their static ids,
3009
+ // which only exist once the registry has mounted them.
3010
+ mountHallWidgets();
3011
+ wireEvents();
3012
+ // Fetch the instance's repo coords (public) so the welcome "get the code"
3013
+ // clone block shows this instance's own repository (ADR 0062 §3, task 1705).
3014
+ loadInstanceRepo();
3015
+ // #731: the Discord connect/link card moved to /builders/settings, and the
3016
+ // OAuth callback now lands there — so the ?discord=joined|linked toast is
3017
+ // handled by settings.js, not here.
3018
+ loadAll().then(() => {
3019
+ // Only open the live channel once we're authenticated — loadAll() returns
3020
+ // early (landing card) for an unauthenticated visitor, leaving selfId unset.
3021
+ if (window.__buildersSelfId) startLiveUpdates();
3022
+ }).catch((err) => {
3023
+ if (err && err.message === 'redirecting to auth') return;
3024
+ console.error('[builders] load failed', err);
3025
+ toast('The page could not be loaded. Try again.');
3026
+ });
3027
+ });
3028
+ })();