@bongos/core 1.18.3 → 1.18.18

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 (469) hide show
  1. package/.bongos-core.json +1575 -285
  2. package/.claude/skills/builder-box/SKILL.md +6 -0
  3. package/.claude/skills/builder-sequence/SKILL.md +97 -0
  4. package/.claude/skills/builder-ship/SKILL.md +8 -2
  5. package/.claude/skills/builder-start/SKILL.md +11 -0
  6. package/.claude/skills/builder-sync/SKILL.md +41 -0
  7. package/.claude/skills/fix-task/SKILL.md +64 -0
  8. package/.claude/skills/goal-create/SKILL.md +158 -0
  9. package/.claude/skills/goal-review/SKILL.md +3 -1
  10. package/.claude/skills/idea-triage/SKILL.md +14 -8
  11. package/.claude/skills/merge-mode/SKILL.md +2 -2
  12. package/.claude/skills/new-project/SKILL.md +1 -0
  13. package/.claude/skills/planning-session/SKILL.md +45 -18
  14. package/.env.local.example +4 -3
  15. package/README.md +4 -2
  16. package/bin/bongos.js +1 -0
  17. package/clients/bongos-client/README.md +2 -2
  18. package/clients/bongos-client/bongos-client.global.js +82 -18
  19. package/clients/bongos-client/index.cjs +82 -18
  20. package/clients/bongos-client/index.d.ts +131 -29
  21. package/clients/bongos-client/index.mjs +82 -18
  22. package/config/branding.neutral.json +6 -1
  23. package/config/governance.neutral.json +21 -0
  24. package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +24 -0
  25. package/docs/adr/0090-metic-task-authoring.md +1 -1
  26. package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +41 -0
  27. package/docs/adr/0144-federated-single-logout-backchannel.md +42 -0
  28. package/docs/adr/0145-devbox-app-branding-driven-module.md +44 -0
  29. package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +62 -0
  30. package/docs/adr/0146-configurable-reward-mode-cost-plus-only.md +53 -0
  31. package/docs/adr/0147-standalone-live-docs-regen.md +39 -0
  32. package/docs/adr/0148-task-scoped-box-source-access.md +48 -0
  33. package/docs/adr/0149-bongos-dev-local-launcher.md +57 -0
  34. package/docs/adr/0150-box-first-boot-bringup-vendored-instances.md +68 -0
  35. package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +229 -0
  36. package/docs/adr/0152-landing-gate-served-by-the-core-app.md +92 -0
  37. package/docs/adr/0152-metic-task-abandonment.md +67 -0
  38. package/docs/adr/0153-sequential-chain-runner.md +51 -0
  39. package/docs/adr/0154-goal-owner-criterion-authoring.md +86 -0
  40. package/docs/adr/README.md +10 -0
  41. package/docs/api/openapi.json +843 -17
  42. package/docs/api-reference.md +23 -10
  43. package/docs/architecture.md +4 -2
  44. package/docs/branding-contract.md +3 -1
  45. package/docs/canonical-permissions.md +21 -8
  46. package/docs/file-map.md +8 -0
  47. package/docs/onboarding/diagrams/01-task-lifecycle.mmd +2 -2
  48. package/docs/onboarding/diagrams/02-rank-ladder.mmd +3 -1
  49. package/docs/onboarding/diagrams/05-versioning.mmd +1 -3
  50. package/docs/onboarding/diagrams/assertions.json +4 -7
  51. package/docs/onboarding/drachmae-and-karma.md +3 -3
  52. package/docs/onboarding/primer.md +17 -16
  53. package/docs/onboarding/slash-commands.md +124 -0
  54. package/docs/recipes/builder-box-lifecycle.md +23 -0
  55. package/docs/recipes/devbox-app-release.md +14 -4
  56. package/docs/recipes/instance-first-run.md +16 -0
  57. package/docs/recipes/local-dev.md +81 -0
  58. package/docs/recipes/ops-gotchas.md +19 -1
  59. package/docs/recipes/overnight-builder.md +1 -1
  60. package/docs/recipes/parallel-goal-waves.md +174 -0
  61. package/docs/recipes/ship-and-regrade.md +2 -2
  62. package/docs/recipes/standalone-live-docs.md +48 -0
  63. package/docs/recipes/work-on-a-task-from-your-dev-box.md +108 -0
  64. package/migrations/003_pms.sql +6 -7
  65. package/migrations/006_pms_v2.sql +3 -31
  66. package/migrations/core_193_task_needs_rebase.sql +28 -0
  67. package/migrations/core_194_confirmed_backlog_clawback.sql +63 -0
  68. package/migrations/core_195_idea_triage_provenance.sql +57 -0
  69. package/migrations/core_196_session_reward_topup.sql +69 -0
  70. package/modules/autonomy/pollers/routine-timers.js +9 -8
  71. package/modules/builder-settings/wandering-prefs.js +61 -18
  72. package/modules/dev-box/app/README.md +73 -0
  73. package/modules/dev-box/app/assets/tray/mac-alertTemplate.png +0 -0
  74. package/modules/dev-box/app/assets/tray/mac-alertTemplate@2x.png +0 -0
  75. package/modules/dev-box/app/assets/tray/mac-busyTemplate.png +0 -0
  76. package/modules/dev-box/app/assets/tray/mac-busyTemplate@2x.png +0 -0
  77. package/modules/dev-box/app/assets/tray/mac-offTemplate.png +0 -0
  78. package/modules/dev-box/app/assets/tray/mac-offTemplate@2x.png +0 -0
  79. package/modules/dev-box/app/assets/tray/mac-onTemplate.png +0 -0
  80. package/modules/dev-box/app/assets/tray/mac-onTemplate@2x.png +0 -0
  81. package/modules/dev-box/app/assets/tray/win-alert.png +0 -0
  82. package/modules/dev-box/app/assets/tray/win-busy.png +0 -0
  83. package/modules/dev-box/app/assets/tray/win-off.png +0 -0
  84. package/modules/dev-box/app/assets/tray/win-on.png +0 -0
  85. package/modules/dev-box/app/build-resources/dmg-background.png +0 -0
  86. package/modules/dev-box/app/build-resources/dmg-background@2x.png +0 -0
  87. package/modules/dev-box/app/build-resources/icon.png +0 -0
  88. package/modules/dev-box/app/electron-builder.tmpl.yml +95 -0
  89. package/modules/dev-box/app/electron-builder.yml +95 -0
  90. package/modules/dev-box/app/package-lock.json +5291 -0
  91. package/modules/dev-box/app/package.json +30 -0
  92. package/modules/dev-box/app/renderer/app.js +319 -0
  93. package/modules/dev-box/app/renderer/index.html +45 -0
  94. package/modules/dev-box/app/renderer/styles.css +246 -0
  95. package/modules/dev-box/app/renderer/theme.generated.css +16 -0
  96. package/modules/dev-box/app/scripts/apply-dmg-backdrop.sh +82 -0
  97. package/modules/dev-box/app/scripts/dmgbuild-settings.py +39 -0
  98. package/modules/dev-box/app/scripts/gen-brand.js +138 -0
  99. package/modules/dev-box/app/scripts/gen-dmg-backdrop.py +323 -0
  100. package/modules/dev-box/app/scripts/gen-icons.js +286 -0
  101. package/modules/dev-box/app/scripts/vendor-client.js +23 -0
  102. package/modules/dev-box/app/src/box-controller.js +287 -0
  103. package/modules/dev-box/app/src/brand.generated.json +13 -0
  104. package/modules/dev-box/app/src/brand.js +16 -0
  105. package/modules/dev-box/app/src/config.js +19 -0
  106. package/modules/dev-box/app/src/gds-client.js +90 -0
  107. package/modules/dev-box/app/src/main.js +610 -0
  108. package/modules/dev-box/app/src/pairing.js +57 -0
  109. package/modules/dev-box/app/src/preload.js +29 -0
  110. package/modules/dev-box/app/src/session-store.js +58 -0
  111. package/modules/dev-box/app/src/ssh-setup.js +569 -0
  112. package/modules/dev-box/app/src/task-picker.js +70 -0
  113. package/modules/dev-box/app/src/tray.js +99 -0
  114. package/modules/dev-box/app/src/updater.js +55 -0
  115. package/modules/dev-box/app/src/vendor/bongos-client.cjs +698 -0
  116. package/modules/dev-box/app/test/unit.mjs +526 -0
  117. package/modules/dev-box/box-access.js +121 -4
  118. package/modules/dev-box/boxes.js +20 -0
  119. package/modules/dev-box/module.json +1 -1
  120. package/modules/dev-box/routes/box.js +87 -3
  121. package/modules/economy/credits.js +116 -25
  122. package/modules/economy/reward.js +8 -0
  123. package/modules/economy/streak.js +2 -1
  124. package/modules/governance/CLAUDE.md +15 -0
  125. package/modules/governance/catalog.js +419 -0
  126. package/modules/governance/config.js +174 -0
  127. package/modules/governance/db.js +125 -0
  128. package/modules/governance/migrations/governance_001_tables.sql +81 -0
  129. package/modules/governance/migrations/governance_002_seed_and_backfill.sql +103 -0
  130. package/modules/governance/migrations/governance_003_task_abandon_metic.sql +28 -0
  131. package/modules/governance/migrations/governance_004_criterion_create.sql +39 -0
  132. package/modules/governance/migrations/governance_005_resync_builder_rank_roles.sql +56 -0
  133. package/modules/governance/module.json +10 -0
  134. package/modules/governance/protected-surfaces.js +88 -0
  135. package/modules/governance/protected-surfaces.json +80 -0
  136. package/modules/governance/rank-roles.js +83 -0
  137. package/modules/governance/resolver.js +221 -0
  138. package/modules/governance/routes/governance.js +89 -0
  139. package/modules/governance/routes/role-sync.js +49 -0
  140. package/modules/governance/session-scopes.js +127 -0
  141. package/modules/governance/session-scopes.json +38 -0
  142. package/modules/hall-ui/public/atlas.css +117 -0
  143. package/modules/hall-ui/public/atlas.html +3 -114
  144. package/modules/hall-ui/public/brand-apply.js +34 -6
  145. package/modules/hall-ui/public/builders.js +187 -219
  146. package/modules/hall-ui/public/diagrams.html +2 -0
  147. package/modules/hall-ui/public/dom-utils.js +27 -0
  148. package/modules/hall-ui/public/drachmae.html +2 -0
  149. package/modules/hall-ui/public/drachmae.js +54 -1
  150. package/modules/hall-ui/public/gate.css +98 -0
  151. package/modules/hall-ui/public/gate.html +3 -95
  152. package/modules/hall-ui/public/goal-inbox.js +4 -1
  153. package/modules/hall-ui/public/goals-page.js +626 -114
  154. package/modules/hall-ui/public/goals.css +79 -1
  155. package/modules/hall-ui/public/goals.html +38 -11
  156. package/modules/hall-ui/public/hall-widgets.js +62 -52
  157. package/modules/hall-ui/public/harbor.css +123 -0
  158. package/modules/hall-ui/public/harbor.html +3 -120
  159. package/modules/hall-ui/public/index.html +11 -4
  160. package/modules/hall-ui/public/instances-panel.js +4 -1
  161. package/modules/hall-ui/public/modules.css +63 -0
  162. package/modules/hall-ui/public/modules.html +3 -60
  163. package/modules/hall-ui/public/new-project-wizard.js +7 -3
  164. package/modules/hall-ui/public/not-ready.html +2 -0
  165. package/modules/hall-ui/public/pair.css +73 -0
  166. package/modules/hall-ui/public/pair.html +3 -70
  167. package/modules/hall-ui/public/palette.js +302 -0
  168. package/modules/hall-ui/public/primer.css +13 -0
  169. package/modules/hall-ui/public/primer.html +2 -0
  170. package/modules/hall-ui/public/profile.html +2 -0
  171. package/modules/hall-ui/public/ranks.html +2 -0
  172. package/modules/hall-ui/public/roadmap.css +179 -0
  173. package/modules/hall-ui/public/roadmap.html +9 -172
  174. package/modules/hall-ui/public/roadmap.js +329 -316
  175. package/modules/hall-ui/public/sessions.css +16 -0
  176. package/modules/hall-ui/public/sessions.html +3 -13
  177. package/modules/hall-ui/public/settings.html +2 -0
  178. package/modules/hall-ui/public/settings.js +4 -4
  179. package/modules/hall-ui/public/shell.js +143 -18
  180. package/modules/hall-ui/public/style.css +231 -39
  181. package/modules/hall-ui/public/task-detail.js +347 -26
  182. package/modules/hall-ui/public/task.html +6 -1
  183. package/modules/hall-ui/public/watch.html +2 -0
  184. package/modules/hall-ui/public/work.html +8 -6
  185. package/modules/hall-ui/public/work.js +1 -1
  186. package/modules/ideas/inbox.js +61 -11
  187. package/modules/ideas/routes/inbox.js +23 -0
  188. package/modules/lifecycle/claim-eligibility.js +72 -0
  189. package/modules/lifecycle/db.js +519 -139
  190. package/modules/lifecycle/done-when.js +18 -3
  191. package/modules/lifecycle/github-push.js +19 -2
  192. package/modules/lifecycle/goal-advisory.js +114 -0
  193. package/modules/lifecycle/goal-authz.js +62 -0
  194. package/modules/lifecycle/lifecycle.js +9 -0
  195. package/modules/lifecycle/publish-reconciler.js +109 -6
  196. package/modules/lifecycle/routes/claims.js +68 -23
  197. package/modules/lifecycle/routes/done-when.js +110 -15
  198. package/modules/lifecycle/routes/goals.js +8 -38
  199. package/modules/lifecycle/routes/tasks.js +38 -6
  200. package/modules/lifecycle/ship-card.js +6 -0
  201. package/modules/onboarding/onboarding-state.js +1 -1
  202. package/modules/platform-identity/CLAUDE.md +15 -0
  203. package/modules/platform-identity/hub-device.js +147 -0
  204. package/modules/platform-identity/hub-keys.js +151 -0
  205. package/modules/platform-identity/migrations/platform_identity_001_tables.sql +114 -0
  206. package/modules/platform-identity/migrations/platform_identity_002_client_origin_index.sql +18 -0
  207. package/modules/platform-identity/migrations/platform_identity_003_project_memberships.sql +42 -0
  208. package/modules/platform-identity/migrations/platform_identity_004_projects.sql +44 -0
  209. package/modules/platform-identity/migrations/platform_identity_005_builder_activity.sql +42 -0
  210. package/modules/platform-identity/module.json +18 -0
  211. package/modules/platform-identity/platform-identity.js +531 -0
  212. package/modules/platform-identity/pollers/code-sweep.js +43 -0
  213. package/modules/platform-identity/routes/my-projects.js +140 -0
  214. package/modules/platform-identity/routes/projects.js +107 -0
  215. package/modules/platform-identity/routes/scouting.js +35 -0
  216. package/modules/platform-identity/routes/sso.js +402 -0
  217. package/modules/platform-identity/tests/platform-identity.mjs +388 -0
  218. package/modules/platform-identity/ui/.gitkeep +0 -0
  219. package/modules/security/routes/reports.js +10 -0
  220. package/modules/sessions/db.js +166 -21
  221. package/modules/sessions/module.json +1 -1
  222. package/modules/sessions/pollers/session-reward-reconcile.js +78 -0
  223. package/modules/sessions/routes/sessions.js +33 -0
  224. package/package-lock.json +2 -2
  225. package/package.json +1 -1
  226. package/public-landing/assets/anim/idle/frame_00.webp +0 -0
  227. package/public-landing/assets/anim/idle/frame_01.webp +0 -0
  228. package/public-landing/assets/anim/idle/frame_02.webp +0 -0
  229. package/public-landing/assets/anim/idle/frame_03.webp +0 -0
  230. package/public-landing/assets/anim/idle/frame_04.webp +0 -0
  231. package/public-landing/assets/anim/idle/frame_05.webp +0 -0
  232. package/public-landing/assets/anim/idle/frame_06.webp +0 -0
  233. package/public-landing/assets/anim/idle/frame_07.webp +0 -0
  234. package/public-landing/assets/anim/idle/frame_08.webp +0 -0
  235. package/public-landing/assets/anim/idle/frame_09.webp +0 -0
  236. package/public-landing/assets/anim/idle/frame_10.webp +0 -0
  237. package/public-landing/assets/anim/idle/frame_11.webp +0 -0
  238. package/public-landing/assets/anim/idle/frame_12.webp +0 -0
  239. package/public-landing/assets/anim/idle/frame_13.webp +0 -0
  240. package/public-landing/assets/anim/idle/frame_14.webp +0 -0
  241. package/public-landing/assets/anim/idle/frame_15.webp +0 -0
  242. package/public-landing/assets/anim/idle/frame_16.webp +0 -0
  243. package/public-landing/assets/anim/idle/frame_17.webp +0 -0
  244. package/public-landing/assets/anim/idle/frame_18.webp +0 -0
  245. package/public-landing/assets/anim/idle/frame_19.webp +0 -0
  246. package/public-landing/assets/anim/idle/frame_20.webp +0 -0
  247. package/public-landing/assets/anim/idle/frame_21.webp +0 -0
  248. package/public-landing/assets/anim/idle/frame_22.webp +0 -0
  249. package/public-landing/assets/anim/idle/frame_23.webp +0 -0
  250. package/public-landing/assets/anim/idle/frame_24.webp +0 -0
  251. package/public-landing/assets/anim/idle/frame_25.webp +0 -0
  252. package/public-landing/assets/anim/idle/frame_26.webp +0 -0
  253. package/public-landing/assets/anim/idle/frame_27.webp +0 -0
  254. package/public-landing/assets/anim/idle/frame_28.webp +0 -0
  255. package/public-landing/assets/anim/idle/frame_29.webp +0 -0
  256. package/public-landing/assets/anim/idle/frame_30.webp +0 -0
  257. package/public-landing/assets/anim/idle/frame_31.webp +0 -0
  258. package/public-landing/assets/anim/idle/frame_32.webp +0 -0
  259. package/public-landing/assets/anim/idle/frame_33.webp +0 -0
  260. package/public-landing/assets/anim/idle/frame_34.webp +0 -0
  261. package/public-landing/assets/anim/idle/frame_35.webp +0 -0
  262. package/public-landing/assets/anim/idle/frame_36.webp +0 -0
  263. package/public-landing/assets/anim/idle/frame_37.webp +0 -0
  264. package/public-landing/assets/anim/idle/frame_38.webp +0 -0
  265. package/public-landing/assets/anim/idle/frame_39.webp +0 -0
  266. package/public-landing/assets/anim/idle/frame_40.webp +0 -0
  267. package/public-landing/assets/anim/idle/frame_41.webp +0 -0
  268. package/public-landing/assets/anim/idle/frame_42.webp +0 -0
  269. package/public-landing/assets/anim/idle/frame_43.webp +0 -0
  270. package/public-landing/create.html +51 -58
  271. package/public-landing/gate/README.md +137 -0
  272. package/public-landing/gate/dispmaps/bar.png +0 -0
  273. package/public-landing/gate/dispmaps/flat.png +0 -0
  274. package/public-landing/gate/dispmaps/key.png +0 -0
  275. package/public-landing/gate/index.html +587 -0
  276. package/public-landing/gate/previews/cloud-bongos.jpg +0 -0
  277. package/public-landing/gate/previews/off-the-boats.jpg +0 -0
  278. package/public-landing/gate/space.css +799 -0
  279. package/public-landing/gate/space.js +1826 -0
  280. package/public-landing/gate/sprites/enter.png +0 -0
  281. package/public-landing/gate/sprites/search.png +0 -0
  282. package/public-landing/gate/sprites/terminal-blank.png +0 -0
  283. package/public-landing/gate/sprites/tile.png +0 -0
  284. package/public-landing/world/assets/clouds/cloud-island-large.png +0 -0
  285. package/public-landing/world/assets/clouds/cloud-puff.png +0 -0
  286. package/public-landing/world/assets/creatures/creature-whale-cargo.png +0 -0
  287. package/public-landing/world/assets/creatures/creature-whale-glide.png +0 -0
  288. package/public-landing/world/assets/plates/plate-clouds-near.png +0 -0
  289. package/public-landing/world/assets/plates/scene-establishing.png +0 -0
  290. package/scripts/gds/abandon.js +6 -4
  291. package/scripts/gds/api.js +10 -5
  292. package/scripts/gds/api.ps1 +26 -0
  293. package/scripts/gds/api.sh +6 -0
  294. package/scripts/gds/audit-ships.js +7 -1
  295. package/scripts/gds/backfill-sessions.js +78 -18
  296. package/scripts/gds/backup.js +23 -1
  297. package/scripts/gds/box-connect-lib.js +124 -31
  298. package/scripts/gds/box-sync.js +66 -0
  299. package/scripts/gds/box.js +225 -53
  300. package/scripts/gds/cf-tunnel.js +145 -6
  301. package/scripts/gds/claim.js +34 -3
  302. package/scripts/gds/cli-lib.js +161 -27
  303. package/scripts/gds/code.js +6 -2
  304. package/scripts/gds/deploy-divergence.js +209 -0
  305. package/scripts/gds/dev-lib.js +309 -0
  306. package/scripts/gds/dev.js +322 -0
  307. package/scripts/gds/doctor.js +90 -61
  308. package/scripts/gds/fetch-art-key.js +1 -1
  309. package/scripts/gds/fetch-sound-prefs.js +2 -2
  310. package/scripts/gds/fitness.js +228 -12
  311. package/scripts/gds/gds-db-backup.service +16 -1
  312. package/scripts/gds/gds-db-offsite.service +9 -1
  313. package/scripts/gds/gen-api-client.js +1 -1
  314. package/scripts/gds/gen-api-docs.js +36 -14
  315. package/scripts/gds/gen-diagrams.js +45 -28
  316. package/scripts/gds/gen-file-map.js +2 -1
  317. package/scripts/gds/gen-freshness.js +32 -0
  318. package/scripts/gds/gen-repo-map.js +3 -2
  319. package/scripts/gds/gen-session-index.js +14 -5
  320. package/scripts/gds/hooks/pre-push.js +2 -2
  321. package/scripts/gds/init.js +60 -3
  322. package/scripts/gds/install-git-hooks.js +1 -1
  323. package/scripts/gds/key-card.js +15 -6
  324. package/scripts/gds/key-status.js +1 -1
  325. package/scripts/gds/linkify-refs.js +35 -9
  326. package/scripts/gds/login.js +16 -1
  327. package/scripts/gds/paste-token.js +7 -1
  328. package/scripts/gds/preflight.js +126 -0
  329. package/scripts/gds/provision.js +123 -12
  330. package/scripts/gds/publish-manifest.js +7 -1
  331. package/scripts/gds/reauth.js +7 -3
  332. package/scripts/gds/regen-instance-docs.js +47 -0
  333. package/scripts/gds/run-unit-tests.js +4 -2
  334. package/scripts/gds/seed-bongos-v1-seo-tasks.js +104 -0
  335. package/scripts/gds/seed-bv1-profiles-tasks.js +126 -0
  336. package/scripts/gds/sequence.js +425 -0
  337. package/scripts/gds/session-cost.js +10 -1
  338. package/scripts/gds/setup.js +31 -5
  339. package/scripts/gds/shell.js +5 -3
  340. package/scripts/gds/ship.js +319 -29
  341. package/scripts/gds/smoke-gds.js +139 -8
  342. package/scripts/gds/start.js +171 -22
  343. package/scripts/gds/status.js +1 -1
  344. package/scripts/gds/triage.js +10 -9
  345. package/scripts/gds/upgrade.js +124 -2
  346. package/scripts/status-mirror-sync.js +114 -84
  347. package/src/bongos/api-errors.js +1 -0
  348. package/src/bongos/auth.js +447 -102
  349. package/src/bongos/db-kernel.js +27 -0
  350. package/src/bongos/db.js +39 -0
  351. package/src/bongos/module-scope-map.js +27 -1
  352. package/src/bongos/permission-path-check.js +136 -102
  353. package/src/bongos/repo-info.js +47 -11
  354. package/src/bongos/route-rank-check.js +429 -46
  355. package/src/bongos/routes/_helpers.js +12 -1
  356. package/src/bongos/routes/auth.js +219 -2
  357. package/src/bongos/routes/backup.js +58 -2
  358. package/src/bongos/routes/me.js +35 -1
  359. package/src/bongos/serve-internal.js +84 -15
  360. package/src/branding.js +32 -0
  361. package/src/instance-config.js +30 -0
  362. package/src/module-api.js +112 -1
  363. package/src/platform-server.js +86 -9
  364. package/tests/abandon_task.mjs +59 -3
  365. package/tests/api_cli_flags.mjs +53 -0
  366. package/tests/auth_page_gate.mjs +13 -0
  367. package/tests/backfill_sessions.mjs +127 -1
  368. package/tests/backup_cli.mjs +54 -0
  369. package/tests/bongos_box_connect.mjs +77 -0
  370. package/tests/box_access.mjs +130 -1
  371. package/tests/box_credential.mjs +125 -0
  372. package/tests/box_host_keys.mjs +24 -0
  373. package/tests/box_infra_resolve.mjs +149 -0
  374. package/tests/box_scope_session.mjs +6 -2
  375. package/tests/box_sync.mjs +55 -0
  376. package/tests/box_task_scope.mjs +99 -0
  377. package/tests/branding.mjs +12 -0
  378. package/tests/cf_ruleset_preflight.mjs +142 -0
  379. package/tests/cf_tunnel.mjs +37 -0
  380. package/tests/claim_gate_rebase.mjs +98 -0
  381. package/tests/clawback_confirmed_backlog.mjs +103 -0
  382. package/tests/cli_lib_formatters.mjs +20 -0
  383. package/tests/confirm_attribution.mjs +30 -46
  384. package/tests/credit_grant.mjs +198 -10
  385. package/tests/criterion_authoring_gate.mjs +272 -0
  386. package/tests/criterion_progress.mjs +33 -1
  387. package/tests/dev_lib.mjs +253 -0
  388. package/tests/devbox_artifact_gate.mjs +75 -0
  389. package/tests/devbox_dmg_backdrop.mjs +137 -0
  390. package/tests/devbox_icons.mjs +304 -0
  391. package/tests/devbox_installer_brand.mjs +232 -0
  392. package/tests/devbox_key_register.mjs +234 -0
  393. package/tests/docs_openapi_route.mjs +110 -0
  394. package/tests/doctor_hooks.mjs +141 -0
  395. package/tests/extract_json.mjs +140 -0
  396. package/tests/fitness.mjs +89 -0
  397. package/tests/gen_freshness.mjs +96 -0
  398. package/tests/goal_advisory.mjs +251 -0
  399. package/tests/goal_criterion_confirm_ui.mjs +269 -0
  400. package/tests/goal_routes.mjs +59 -4
  401. package/tests/goal_tier.mjs +12 -3
  402. package/tests/governance_catalog.mjs +129 -0
  403. package/tests/governance_claim_eligibility.mjs +262 -0
  404. package/tests/governance_config.mjs +187 -0
  405. package/tests/governance_ownership_scope.mjs +410 -0
  406. package/tests/governance_page_gates.mjs +324 -0
  407. package/tests/governance_parity.mjs +336 -0
  408. package/tests/governance_principals.mjs +232 -0
  409. package/tests/governance_protected_surfaces.mjs +208 -0
  410. package/tests/governance_require_permission.mjs +223 -0
  411. package/tests/governance_resolver.mjs +84 -0
  412. package/tests/governance_role_sync.mjs +231 -0
  413. package/tests/governance_seed.mjs +127 -0
  414. package/tests/governance_session_scope.mjs +434 -0
  415. package/tests/governance_vanilla_boot.mjs +203 -0
  416. package/tests/grade_attribution.mjs +7 -7
  417. package/tests/hall_audit.mjs +223 -0
  418. package/tests/hall_nav.mjs +191 -0
  419. package/tests/hall_palette.mjs +206 -0
  420. package/tests/hall_signout.mjs +124 -0
  421. package/tests/hook_user_agents.mjs +10 -0
  422. package/tests/idea_triage.mjs +162 -0
  423. package/tests/idp_device_exchange.mjs +93 -0
  424. package/tests/init.mjs +51 -0
  425. package/tests/instance_config.mjs +65 -0
  426. package/tests/instance_mismatch_guard.mjs +23 -6
  427. package/tests/instance_release_config.mjs +104 -0
  428. package/tests/linkify_refs.mjs +15 -2
  429. package/tests/module_contributions.mjs +55 -0
  430. package/tests/module_loader.mjs +10 -5
  431. package/tests/module_scope_active_claims.mjs +94 -0
  432. package/tests/needs_rebase.mjs +92 -0
  433. package/tests/one_active_claim_per_session.mjs +76 -0
  434. package/tests/override_attribution.mjs +10 -8
  435. package/tests/override_request_ownership.mjs +8 -4
  436. package/tests/parse_cookie_decode.mjs +42 -0
  437. package/tests/pay_on_land.mjs +129 -0
  438. package/tests/platform_boot.mjs +100 -1
  439. package/tests/post_login_landing.mjs +42 -0
  440. package/tests/preflight_checks.mjs +72 -0
  441. package/tests/provision.mjs +132 -7
  442. package/tests/publish_branch_route.mjs +53 -0
  443. package/tests/publish_reconciler.mjs +187 -2
  444. package/tests/repo_info_binding.mjs +248 -0
  445. package/tests/reward_gate.mjs +11 -2
  446. package/tests/reward_mode_cost_plus_only.mjs +116 -0
  447. package/tests/reward_mode_surfacing.mjs +168 -0
  448. package/tests/route_permission_check.mjs +301 -0
  449. package/tests/route_rank_check.mjs +254 -12
  450. package/tests/route_validation.mjs +30 -0
  451. package/tests/sequence.mjs +267 -0
  452. package/tests/session_digest.mjs +44 -0
  453. package/tests/session_index.mjs +18 -6
  454. package/tests/session_records.mjs +39 -1
  455. package/tests/session_reward_on_land.mjs +381 -0
  456. package/tests/session_reward_reconcile.mjs +229 -0
  457. package/tests/ship_api_client_pathspec.mjs +100 -0
  458. package/tests/ship_base_flag.mjs +93 -0
  459. package/tests/ship_ci_deploy.mjs +80 -3
  460. package/tests/ship_upstream_deletion.mjs +57 -0
  461. package/tests/smoke_target.mjs +233 -0
  462. package/tests/start_env_guard.mjs +87 -0
  463. package/tests/start_rebase_warning.mjs +103 -0
  464. package/tests/status_mirror_sync.mjs +122 -0
  465. package/tests/stop_refcheck.mjs +19 -1
  466. package/tests/task_goal_link.mjs +15 -6
  467. package/tests/upgrade.mjs +139 -0
  468. package/tests/web_admission_status.mjs +162 -0
  469. package/tests/write_validation_gaps.mjs +110 -0
@@ -86,6 +86,11 @@
86
86
  // view, GET /goals/:id/requests), or null while loading / on error / not a
87
87
  // manager. Feature-detected: an older server 404s and the panel just stays hidden.
88
88
  pendingRequests: null,
89
+ // task 1002439 (hall redesign) — goal_id → task-readiness rollup
90
+ // {done,active,ready,blocked,backlog,total,nextReady}. Fed by loadRollups()
91
+ // from GET /tasks?version=…&include=deps. Empty on an older server (the
92
+ // include=deps enrichment 404s gracefully → the list falls back to plain cards).
93
+ rollups: new Map(),
89
94
  };
90
95
 
91
96
  async function getJSON(path) {
@@ -147,6 +152,52 @@
147
152
  return m.display_name || (m.github_login ? `@${m.github_login}` : `builder #${m.builder_id}`);
148
153
  }
149
154
 
155
+ // ---- task-readiness rollup (hall redesign task 1002439) --------------------
156
+ // Dependencies are a TASK property; a goal is only "blocked" in the derived
157
+ // sense that all its actionable tasks wait on upstream tasks (the Tasks page,
158
+ // not this one, owns the actual task→task graph). So the Goals page rolls each
159
+ // goal's tasks into these buckets:
160
+ // done — settled (shipped/confirmed/completed)
161
+ // active — a live claim ("in progress")
162
+ // ready — claimable now (deps met + promoted)
163
+ // blocked — backlog with ≥1 unshipped dependency
164
+ // backlog — backlog, no unmet deps (just not started/promoted)
165
+ function readinessOf(t) {
166
+ const s = t.status;
167
+ if (DONE_STATUSES.has(s)) return 'done';
168
+ if (CUT_STATUSES.has(s)) return 'cut';
169
+ if (s === 'active') return 'active';
170
+ if (s === 'ready') return 'ready';
171
+ if (s === 'backlog' && (t.blocked_by || []).some((d) => !d.satisfied)) return 'blocked';
172
+ return 'backlog';
173
+ }
174
+ const EMPTY_ROLLUP = { done: 0, active: 0, ready: 0, blocked: 0, backlog: 0, cut: 0, total: 0, nextReady: null };
175
+ const rollupOf = (goalId) => state.rollups.get(String(goalId)) || EMPTY_ROLLUP;
176
+
177
+ // Fetch every task for the versions the goals span (one request each, with dep
178
+ // summaries) and fold into per-goal rollups. Failure-tolerant: an older server
179
+ // that doesn't honor include=deps just yields empty rollups and the list falls
180
+ // back to plain cards. Tasks come back priority-ordered, so the first `ready`
181
+ // one is the goal's best next pick.
182
+ async function loadRollups() {
183
+ const versions = [...new Set(state.goals.map((g) => g.version_id).filter(Boolean))];
184
+ const lists = await Promise.all(versions.map((v) =>
185
+ getJSON(`${API}/tasks?version=${encodeURIComponent(v)}&include=deps`)
186
+ .then((r) => r.tasks || []).catch(() => [])));
187
+ const roll = new Map();
188
+ for (const t of lists.flat()) {
189
+ if (t.goal_id == null) continue;
190
+ const key = String(t.goal_id);
191
+ let r = roll.get(key);
192
+ if (!r) { r = { done: 0, active: 0, ready: 0, blocked: 0, backlog: 0, cut: 0, total: 0, nextReady: null }; roll.set(key, r); }
193
+ const bucket = readinessOf(t);
194
+ r[bucket] += 1;
195
+ if (bucket !== 'cut') r.total += 1;
196
+ if (bucket === 'ready' && !r.nextReady) r.nextReady = t;
197
+ }
198
+ state.rollups = roll;
199
+ }
200
+
150
201
  /* ---------------- list view ---------------- */
151
202
 
152
203
  function populateVersionFilter() {
@@ -171,6 +222,13 @@
171
222
  // the server-side rank gate on POST /goals/reorder is the real enforcement.
172
223
  const isArchon = () => (state.me?.builder?.rank || '').toLowerCase() === 'archon';
173
224
 
225
+ // task 1002164 — confirming a criterion is Metic+ (POST /done-when/:id/satisfy,
226
+ // ADR 0086 §6). Client-side this only decides whether to SHOW the control; the
227
+ // server's requireRank('metic','archon') is the real gate.
228
+ const RANK_ORDER = { archon: 4, metic: 3, thetes: 2, xenos: 1 };
229
+ const canConfirmCriteria = () =>
230
+ (RANK_ORDER[(state.me?.builder?.rank || '').toLowerCase()] ?? 0) >= RANK_ORDER.metic;
231
+
174
232
  // task 1932 — the current filtered goal set, in display order. Shared by
175
233
  // renderList (what to page) and the drag-reorder dragend (which must submit
176
234
  // the FULL version order, not just the visible page).
@@ -189,67 +247,105 @@
189
247
  (!mineOnly || iAmMember(g)));
190
248
  }
191
249
 
250
+ // task 1002439 — a goal row's readiness rollup line (only the non-zero buckets).
251
+ const ROLL_DOTS = [
252
+ ['ready', 'ready', 'var(--ok)'],
253
+ ['active', 'in progress', 'var(--info)'],
254
+ ['blocked', 'blocked', 'var(--terracotta)'],
255
+ ['backlog', 'backlog', 'var(--ink-faint)'],
256
+ ];
257
+ function goalRollLine(r) {
258
+ const parts = ROLL_DOTS.filter(([k]) => r[k] > 0)
259
+ .map(([k, label, c]) => `<span class="gr-d"><i style="background:${c}"></i><b>${r[k]}</b> ${label}</span>`);
260
+ return parts.length ? `<div class="gr-roll">${parts.join('')}</div>` : '';
261
+ }
262
+
263
+ // task 1002439 — one goal, rendered as a rollup row: criteria ring · title +
264
+ // meta + readiness rollup · one next action (claim the next ready task, drill
265
+ // into what's blocking, or a review/achieved note).
266
+ function goalRowHtml(g) {
267
+ const d = state.details.get(String(g.id));
268
+ const crit = d ? (d.criteria || []) : [];
269
+ const sat = crit.filter((c) => c.satisfied).length;
270
+ const r = rollupOf(g.id);
271
+ const achieved = g.status === 'achieved';
272
+ const ringHtml = crit.length ? ring(sat, crit.length, achieved, 44)
273
+ : `<span class="gr-crit-none" aria-hidden="true">—</span>`;
274
+ const goalHref = `#/goal/${escapeHtml(String(g.id))}`;
275
+ let right;
276
+ if (achieved) {
277
+ right = `<span class="gr-met">✓ achieved</span>`;
278
+ } else if (crit.length && sat >= crit.length) {
279
+ right = `<span class="gr-met">Criteria met — awaiting review</span>`;
280
+ } else if (r.nextReady) {
281
+ right = `<div class="gr-next"><span class="gr-next__t">${escapeHtml(r.nextReady.title || 'Next task')}</span>`
282
+ + `<a class="gr-claim" href="#/task/${escapeHtml(String(r.nextReady.id))}">Claim next</a></div>`;
283
+ } else if (r.blocked > 0) {
284
+ right = `<a class="gr-drill" href="${goalHref}">See what's blocking <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a>`;
285
+ } else {
286
+ right = `<span class="gr-quiet">No tasks yet</span>`;
287
+ }
288
+ // NOTE: a rollup row nests real links (title → goal, Claim → task overlay,
289
+ // drill → goal), so the row itself is a <div>, not an <a> — nested anchors
290
+ // are invalid. Only the explicit links navigate.
291
+ return `<div class="gr-row" data-goal-id="${escapeHtml(String(g.id))}">
292
+ <div class="gr-ringcol">${ringHtml}</div>
293
+ <div class="gr-main">
294
+ <a class="gr-title" href="${goalHref}">${escapeHtml(g.title || 'Untitled goal')}</a>
295
+ <div class="gr-meta">#${escapeHtml(String(g.id))} · ${escapeHtml(g.version_id || '')}${(achieved || g.status === 'open') ? '' : ' · ' + escapeHtml(g.status)}</div>
296
+ ${achieved ? '' : goalRollLine(r)}
297
+ </div>
298
+ <div class="gr-right">${right}</div>
299
+ </div>`;
300
+ }
301
+
302
+ // task 1002439 — the Goals page, reorganised around momentum: a task-readiness
303
+ // summary, then goals grouped Active (has ready/in-progress work) · Blocked
304
+ // (only waiting-on-upstream tasks) · Achieved. "Blocked" here is a DERIVED
305
+ // rollup of task readiness — the actual task→task dependency graph lives on the
306
+ // Tasks page (owner call, this task). Filters (version/status/membership) still
307
+ // apply; pagination + Archon drag-reorder are retired for the grouped view.
192
308
  function renderList() {
193
309
  const grid = $('#goal-grid');
194
310
  if (!grid) return;
195
- const ver = $('#f-goal-version')?.value || '';
196
- // task 1932 — filter via the shared helper (drag-reorder reuses it).
197
311
  const rows = filteredGoals();
198
- const reorderable = !!ver && isArchon();
199
-
200
312
  const count = $('#goals-count');
201
313
  if (count) count.textContent = `${rows.length} goal${rows.length === 1 ? '' : 's'}`;
314
+ const pagerMount = $('#goals-pager-mount');
315
+ if (pagerMount) pagerMount.hidden = true;
202
316
 
203
317
  if (!rows.length) {
204
318
  grid.innerHTML = emptyStateHtml('No goals match these filters.');
205
- pagers.list.page = 1;
206
- const emptyPager = $('#goals-pager');
207
- if (emptyPager) emptyPager.hidden = true;
208
319
  return;
209
320
  }
210
321
 
211
- // task 1932 → kit (2171): paginate the grid through the shared pager (it
212
- // clamps the page — the filtered set may have shrunk under a re-render —
213
- // and paints the bar). Priority numbers reflect the GLOBAL position
214
- // (start + i + 1), not the page-local index, so drag-reorder priorities
215
- // stay meaningful across pages.
216
- const pageRows = paginate(rows, pagers.list);
217
- const start = (pagers.list.page - 1) * pagers.list.perPage;
218
- grid.innerHTML = pageRows.map((g, i) => {
219
- const d = state.details.get(String(g.id));
220
- const crit = d ? (d.criteria || []) : [];
221
- const sat = crit.filter((c) => c.satisfied).length;
222
- const members = d ? (d.members || []) : [];
223
- const leads = members.filter((m) => m.role === 'lead');
224
- const joined = members.some((m) => sameId(m.builder_id, selfId()));
225
- // task 1761: an open goal blocked by a not-yet-achieved prerequisite goal is
226
- // greyed + flagged; the tooltip names what it waits on.
227
- const unmetDeps = (d ? (d.dependencies || []) : []).filter((dep) => !dep.satisfied);
228
- const blocked = g.status === 'open' && unmetDeps.length > 0;
229
- const blockedPill = blocked
230
- ? `<span class="goal-badge goal-badge--blocked" title="Waiting on: ${escapeHtml(unmetDeps.map((u) => u.title || ('goal ' + u.id)).join(', '))}">🔒 blocked</span>`
231
- : '';
232
- const priority = ver ? `<span class="goal-card__priority" title="Priority within ${escapeHtml(ver)}">${start + i + 1}</span>` : '';
233
- const handle = reorderable ? `<span class="goal-card__handle" draggable="true" title="Drag to reprioritize" aria-hidden="true">⠿</span>` : '';
234
- return `<a class="goal-card${blocked ? ' goal-card--blocked' : ''}" href="#/goal/${escapeHtml(String(g.id))}" data-goal-id="${escapeHtml(String(g.id))}"${reorderable ? ' draggable="true"' : ''}>
235
- <div class="goal-card__head">
236
- ${handle}${priority}
237
- ${d ? ring(sat, crit.length, g.status === 'achieved', 34) : ''}
238
- <span class="goal-card__title">${escapeHtml(g.title || 'Untitled goal')}</span>
239
- </div>
240
- <div class="goal-card__meta">
241
- <span class="goal-card__id" title="Goal id — use this to reference the goal (e.g. 'goal ${escapeHtml(String(g.id))}'). Distinct from the priority position on the left.">#${escapeHtml(String(g.id))}</span>
242
- <span>${escapeHtml(g.version_id || '')}</span>
243
- ${statusBadge(g.status)}
244
- ${blockedPill}
245
- ${joined ? '<span class="role-pill">joined</span>' : ''}
246
- </div>
247
- <div class="goal-card__foot">
248
- <span><strong>${members.length}</strong> member${members.length === 1 ? '' : 's'}</span>
249
- ${leads.length ? `<span>lead: ${escapeHtml(leads.map(memberName).join(', '))}</span>` : ''}
250
- </div>
251
- </a>`;
252
- }).join('');
322
+ const isBlocked = (g) => {
323
+ const r = rollupOf(g.id);
324
+ return g.status === 'open' && r.ready === 0 && r.active === 0 && r.blocked > 0;
325
+ };
326
+ const achieved = rows.filter((g) => g.status === 'achieved');
327
+ const archived = rows.filter((g) => g.status === 'archived');
328
+ const openRows = rows.filter((g) => g.status !== 'achieved' && g.status !== 'archived');
329
+ const blocked = openRows.filter(isBlocked);
330
+ const active = openRows.filter((g) => !isBlocked(g));
331
+
332
+ let R = 0, A = 0, B = 0;
333
+ openRows.forEach((g) => { const r = rollupOf(g.id); R += r.ready; A += r.active; B += r.blocked; });
334
+ const summary = `<div class="gr-summary">
335
+ <span class="gr-sm"><span class="gr-sm__dot" style="background:var(--ok)"></span><b>${R}</b> ready to claim</span>
336
+ <span class="gr-sm"><span class="gr-sm__dot" style="background:var(--info)"></span><b>${A}</b> in progress</span>
337
+ <span class="gr-sm"><span class="gr-sm__dot" style="background:var(--terracotta)"></span><b>${B}</b> blocked</span>
338
+ </div>`;
339
+
340
+ const sec = (label, n) => `<div class="gr-sec"><b>${label}</b><span class="gr-sec__n">${n}</span></div>`;
341
+ const listOf = (arr) => `<div class="gr-list">${arr.map(goalRowHtml).join('')}</div>`;
342
+ const pill = (g) => `<a class="gr-pill" href="#/goal/${escapeHtml(String(g.id))}"><span class="gr-pill__ok">✓</span><b>${escapeHtml(g.title || 'Untitled')}</b><span class="gr-pill__id">#${escapeHtml(String(g.id))}</span></a>`;
343
+
344
+ grid.innerHTML = summary
345
+ + (active.length ? sec('Active', active.length) + listOf(active) : '')
346
+ + (blocked.length ? sec('Blocked', blocked.length) + listOf(blocked) : '')
347
+ + (achieved.length ? sec('Achieved', achieved.length) + `<div class="gr-pills">${achieved.map(pill).join('')}</div>` : '')
348
+ + (archived.length ? sec('Archived', archived.length) + `<div class="gr-pills gr-pills--muted">${archived.map(pill).join('')}</div>` : '');
253
349
  }
254
350
 
255
351
  // ---- drag-to-reorder (task 1760, Archon-only, one version at a time) ------
@@ -312,37 +408,273 @@
312
408
 
313
409
  /* ---------------- detail view ---------------- */
314
410
 
315
- // task 1828 — the row's inline action, mirroring the Work Board:
316
- // ready → Claim (POST /claims via doGoalClaim)
317
- // active + mine → Release (POST /claims/:id/resolve via doGoalRelease)
318
- // active + someone's → a muted "claimed · @who" (release is owner-only)
319
- // Signed-out or any other status → empty. The row still opens the overlay on click.
320
- function actionCell(t) {
321
- if (!state.me) return '<td class="ledger__action"></td>';
322
- const id = escapeHtml(String(t.id));
323
- if (t.status === 'ready') {
324
- return `<td class="ledger__action"><button class="goal-claim-btn" type="button" data-claim-task="${id}" aria-label="Claim task ${id}">Claim</button></td>`;
325
- }
326
- if (t.status === 'active') {
327
- const c = state.claimsByTask.get(String(t.id));
328
- if (c && sameId(c.builder_id, selfId())) {
329
- return `<td class="ledger__action"><button class="goal-release-btn" type="button" data-release-claim="${escapeHtml(String(c.claim_id))}" data-release-task="${id}" aria-label="Release task ${id}">Release</button></td>`;
411
+ // ===================================================================
412
+ // task 1002425 — Tasks-board parity for the goal's "To do" pane.
413
+ //
414
+ // The goal-detail To-do list now mirrors the Tasks board's Ready queue so a
415
+ // goal's tasks have the SAME FUNCTIONALITY as the board: rich claimable cards,
416
+ // a shared filter bar (search / sort / kind / discipline / newcomer / run-mode),
417
+ // multi-select with bulk claim, and a per-card copy-prompt — all opening the
418
+ // same shared task-detail overlay. The helpers below are PORTED from the board
419
+ // (work.js) so the two surfaces render + behave identically. (They're deliberate
420
+ // duplication for now; a follow-up idea to extract a shared task-card/board
421
+ // component — the drift-proof endgame — is filed at ship.)
422
+ //
423
+ // Scoped to one goal, so: no Version/Goal filter, no In-Progress/Backlog tab
424
+ // split. Every open (todo) task renders as a card whose action adapts to its
425
+ // status — ready → Claim (+ select + copy); mine-active → Release;
426
+ // someone else's active → a muted "claimed · @who"; backlog/blocked → open-only.
427
+ // ===================================================================
428
+
429
+ // Board-identical numeric helpers (work.js:214-240).
430
+ function creditsPerMinute(t) {
431
+ const c = Number(t.credits_reward); const m = Number(t.est_minutes);
432
+ if (!c || !m || m <= 0) return -Infinity;
433
+ return c / m;
434
+ }
435
+ function priorityRank(p) {
436
+ if (!p) return 99;
437
+ const m = /^P(\d+)/i.exec(String(p));
438
+ return m ? Number(m[1]) : 99;
439
+ }
440
+ // Two tasks can't run in parallel if their touches[] overlap (exact path or a
441
+ // directory-prefix either way); empty touches never conflict. Powers the
442
+ // filter bar's "Run mode" control (work.js:230-239).
443
+ function touchesConflict(aList, bList) {
444
+ const norm = (p) => String(p).replace(/\/+$/, '');
445
+ for (const a of (Array.isArray(aList) ? aList : [])) {
446
+ const na = norm(a);
447
+ for (const b of (Array.isArray(bList) ? bList : [])) {
448
+ const nb = norm(b);
449
+ if (na === nb || na.startsWith(nb + '/') || nb.startsWith(na + '/')) return true;
330
450
  }
331
- const who = c && (c.login || c.name) ? ` · @${escapeHtml(c.login || c.name)}` : '';
332
- return `<td class="ledger__action"><span class="ledger__claimed" title="Claimed by another builder">claimed${who}</span></td>`;
333
451
  }
334
- return '<td class="ledger__action"></td>';
452
+ return false;
335
453
  }
336
- function taskRowsTodo(tasks) {
337
- return tasks.map((t) => `
338
- <tr class="ledger__row--task" data-task-id="${escapeHtml(String(t.id))}" style="cursor:pointer" title="Open task #${escapeHtml(String(t.id))}">
339
- <td class="ledger__num">#${escapeHtml(String(t.id))}</td>
340
- <td>${escapeHtml(t.title || '')}</td>
341
- <td>${escapeHtml(t.kind || '—')}</td>
342
- <td><span class="task__tag">${escapeHtml(t.status || '')}</span></td>
343
- <td class="ledger__drachmae">${fmtNum(creditVal(t))}</td>
344
- ${actionCell(t)}
345
- </tr>`).join('');
454
+ // Per-task rank floor tag (work.js:247-254).
455
+ function rankTagHtml(requiresRank) {
456
+ const rr = (requiresRank || 'xenos').toLowerCase();
457
+ if (rr === 'xenos') {
458
+ return '<span class="task__tag task__tag--rank-open" title="No rank floor — any builder can claim this task">Any rank</span>';
459
+ }
460
+ const label = rr === 'archon' ? 'Archon' : rr.charAt(0).toUpperCase() + rr.slice(1) + '+';
461
+ return `<span class="task__tag task__tag--rank" title="Minimum rank required to claim this task">${escapeHtml(label)}</span>`;
462
+ }
463
+
464
+ // Free-text search over the goal's todo list (work.js:261-274, minus the
465
+ // goal-title match — a single goal makes that redundant).
466
+ function todoSearchQuery() {
467
+ const el = $('#f-goal-search');
468
+ return el ? el.value.trim().toLowerCase() : '';
469
+ }
470
+ function matchesTodoSearch(t, q) {
471
+ if (!q) return true;
472
+ const needle = q.replace(/^#/, '');
473
+ if (!needle) return true;
474
+ if (String(t.id).includes(needle)) return true;
475
+ if ((t.title || '').toLowerCase().includes(needle)) return true;
476
+ return false;
477
+ }
478
+
479
+ // Copy-prompt (work.js:454-503). deHashRefs stops a bare #NNN autolinking to
480
+ // GitHub in a pasted session; the prompt points the builder at the hall task URL.
481
+ function deHashRefs(text) {
482
+ return String(text == null ? '' : text).replace(/#(\d{1,5})\b/g, 'task $1');
483
+ }
484
+ function buildPromptForTask(t) {
485
+ const desc = deHashRefs((t.description || '').trim());
486
+ const hallBase = location.hostname.startsWith('builders.') ? location.origin : `${location.origin}/builders`;
487
+ const hallUrl = `${hallBase}#/task/${t.id}`;
488
+ return [
489
+ `I am working on GDS task ${t.id} (${t.title}) — ${hallUrl}`,
490
+ `Read the task description, follow its acceptance criteria, stay strictly within touches[], and ship via /builder-ship when done.`,
491
+ ``,
492
+ `Task description:`,
493
+ desc || '(no description)',
494
+ ].join('\n');
495
+ }
496
+ async function copyPromptForTask(taskId, btn) {
497
+ const t = state.work.todo.find((x) => String(x.id) === String(taskId));
498
+ if (!t) return;
499
+ const prompt = buildPromptForTask(t);
500
+ const icon = btn.querySelector('.task__copy-icon');
501
+ try {
502
+ await navigator.clipboard.writeText(prompt);
503
+ if (icon) icon.textContent = '✓';
504
+ setTimeout(() => { if (icon) icon.textContent = '📋'; }, 1600);
505
+ toast(`Prompt for #${taskId} copied to your clipboard.`);
506
+ } catch (err) {
507
+ try {
508
+ const ta = document.createElement('textarea');
509
+ ta.value = prompt; ta.style.position = 'fixed'; ta.style.opacity = '0';
510
+ document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
511
+ toast(`Prompt for #${taskId} copied.`);
512
+ } catch (_) { toast('Could not copy to the clipboard. Try again.'); }
513
+ }
514
+ }
515
+
516
+ // The claimable card (ported from work.js renderClaimable, work.js:345-394).
517
+ // Uses the SAME .task markup + classes as the board, so work.css (linked from
518
+ // goals.html) styles it identically. The lead cell + actions adapt to status.
519
+ function goalTaskCardHtml(t) {
520
+ const id = String(t.id);
521
+ const cpm = creditsPerMinute(t);
522
+ const cpmText = Number.isFinite(cpm) ? `${cpm.toFixed(2)} ${curLabel()}/min` : `— ${curLabel()}/min`;
523
+ const minutesText = t.est_minutes ? `${t.est_minutes}m` : '—';
524
+ const discipline = t.discipline || 'unclassified';
525
+ const disciplineTag = discipline !== 'unclassified' ? `<span class="task__tag">${escapeHtml(discipline)}</span>` : '';
526
+ const newcomerTag = t.newcomer_friendly ? '<span class="task__tag task__tag--newcomer">newcomer</span>' : '';
527
+ const rankTag = rankTagHtml(t.requires_rank);
528
+ const touches = Array.isArray(t.touches) ? t.touches : [];
529
+ const touchesChip = touches.length
530
+ ? `<span class="task__touches" title="${escapeHtml(touches.join('\n'))}">✎ ${touches.length} file${touches.length === 1 ? '' : 's'}</span>`
531
+ : '';
532
+ const claim = state.claimsByTask.get(id);
533
+ const isReady = t.status === 'ready' && !!state.me;
534
+ const mineActive = t.status === 'active' && claim && sameId(claim.builder_id, selfId());
535
+ const selected = goalSel.claim.has(id);
536
+ // Lead: a select checkbox on ready cards (drives bulk claim); otherwise a
537
+ // status tag so active/backlog cards read at a glance.
538
+ const lead = isReady
539
+ ? `<input type="checkbox" class="task__select" data-task-id="${escapeHtml(id)}" aria-label="Select task ${escapeHtml(id)} for bulk claim"${selected ? ' checked' : ''}>`
540
+ : `<span class="task__tag" title="Task status">${escapeHtml(t.status || '')}</span>`;
541
+ // Actions: ready → Claim + copy; mine-active → Release; other's active →
542
+ // "claimed · @who"; anything else → nothing (open via the ⤢ button / card).
543
+ let actions = '';
544
+ if (isReady) {
545
+ actions = `<button class="task__claim" type="button" data-claim-task="${escapeHtml(id)}" aria-label="Claim task ${escapeHtml(id)}">Claim</button>`
546
+ + `<button class="task__copy" type="button" data-copy-task="${escapeHtml(id)}" title="Copy the task prompt to your clipboard" aria-label="Copy prompt for task ${escapeHtml(id)}"><span class="task__copy-icon" aria-hidden="true">📋</span></button>`;
547
+ } else if (mineActive) {
548
+ actions = `<button class="wip__release" type="button" data-release-claim="${escapeHtml(String(claim.claim_id))}" data-release-task="${escapeHtml(id)}" aria-label="Release task ${escapeHtml(id)}">Release</button>`;
549
+ } else if (t.status === 'active' && claim) {
550
+ const who = (claim.login || claim.name) ? ` · @${escapeHtml(claim.login || claim.name)}` : '';
551
+ actions = `<span class="ledger__claimed" title="Claimed by another builder">claimed${who}</span>`;
552
+ }
553
+ return `
554
+ <div class="task${selected ? ' task--selected' : ''}${isReady ? '' : ' task--readonly'}" data-task-id="${escapeHtml(id)}">
555
+ <div class="task__id">
556
+ ${lead}
557
+ <span>#${escapeHtml(id)}</span>
558
+ <button class="task__open" type="button" data-task-id="${escapeHtml(id)}" title="Open task details" aria-label="Open details for task ${escapeHtml(id)}">⤢</button>
559
+ </div>
560
+ <div class="task__body">
561
+ <p class="task__title">${escapeHtml(t.title || '')}</p>
562
+ <div class="task__meta">
563
+ <span class="task__tag">${escapeHtml(t.version_id || '?')}</span>
564
+ <span class="task__tag">${escapeHtml(t.kind || 'unclassified')}</span>
565
+ ${disciplineTag}
566
+ ${newcomerTag}
567
+ ${rankTag}
568
+ ${t.priority ? `<span class="task__tag">${escapeHtml(t.priority)}</span>` : ''}
569
+ <span>${minutesText}</span>
570
+ <span>${cpmText}</span>
571
+ ${touchesChip}
572
+ </div>
573
+ </div>
574
+ <div class="task__credits">
575
+ <span class="task__credits-num">${fmtNum(window.OTBBranding?.taskCreditValue?.(t) ?? t.credits_reward)}</span>
576
+ <span class="task__credits-label" title="${window.OTBBranding?.rewardModeNote?.() ?? ''}">${window.OTBBranding?.taskCreditLabel?.(t) ?? (curLabel() + ' Est.')}</span>
577
+ </div>
578
+ <div class="task__actions">${actions}</div>
579
+ </div>`;
580
+ }
581
+
582
+ // ---- multi-select + bulk claim (work.js:583-828, claim-only subset) --------
583
+ // Selection holds String(task_id) of READY cards the viewer ticked. Bulk
584
+ // release + copy parallel/sequential prompts stay board-only for now (a goal is
585
+ // a narrow slice where the headline value is "grab several of this goal's ready
586
+ // tasks at once"); see the goals.html toolbar comment.
587
+ const goalSel = { claim: new Set() };
588
+ function goalSelTotal() { return goalSel.claim.size; }
589
+ function toggleGoalCardSelect(card, forceChecked) {
590
+ const cb = card.querySelector('.task__select');
591
+ if (!cb) return;
592
+ const next = forceChecked != null ? forceChecked : !cb.checked;
593
+ cb.checked = next;
594
+ const id = String(cb.dataset.taskId);
595
+ if (next) goalSel.claim.add(id); else goalSel.claim.delete(id);
596
+ card.classList.toggle('task--selected', next);
597
+ renderGoalBulkToolbar();
598
+ }
599
+ function clearGoalSelection() {
600
+ goalSel.claim.clear();
601
+ document.querySelectorAll('#goal-todo-list .task--selected').forEach((c) => c.classList.remove('task--selected'));
602
+ document.querySelectorAll('#goal-todo-list .task__select:checked').forEach((cb) => { cb.checked = false; });
603
+ renderGoalBulkToolbar();
604
+ }
605
+ // Drop any selected id that's fallen out from under the selection (claimed by
606
+ // someone else, shipped, or we navigated to another goal) so a stale tick never
607
+ // outlives its card. Called after every task reload (work.js:606-619).
608
+ function pruneGoalSelection() {
609
+ const readyIds = new Set(state.work.todo.filter((t) => t.status === 'ready').map((t) => String(t.id)));
610
+ for (const id of Array.from(goalSel.claim)) if (!readyIds.has(id)) goalSel.claim.delete(id);
611
+ }
612
+ function renderGoalBulkToolbar() {
613
+ const bar = $('#goal-bulk-toolbar');
614
+ if (!bar) return;
615
+ if (goalSelTotal() === 0) { bar.hidden = true; return; }
616
+ bar.hidden = false;
617
+ const countEl = $('#goal-bulk-count');
618
+ if (countEl) countEl.textContent = `${goalSel.claim.size} to claim`;
619
+ const claimBtn = $('#goal-bulk-claim-btn');
620
+ if (claimBtn) {
621
+ claimBtn.disabled = goalSel.claim.size === 0;
622
+ claimBtn.textContent = goalSel.claim.size > 1 ? `Bulk claim (${goalSel.claim.size})` : 'Bulk claim';
623
+ }
624
+ }
625
+ // A single batch-item failure reason → a readable fragment (work.js:545-562).
626
+ function friendlyBatchReason(item) {
627
+ switch (item && item.reason) {
628
+ case 'TASK_NOT_FOUND': return 'no longer exists';
629
+ case 'TASK_NOT_READY': return `no longer ready${item.currentStatus ? ` (${item.currentStatus})` : ''}`;
630
+ case 'ALREADY_CLAIMED': return 'already claimed';
631
+ case 'TOUCHES_CONFLICT': return `overlaps ${item.conflictTaskTitle || 'an active claim'}`;
632
+ case 'BATCH_TOUCHES_CONFLICT': return `overlaps task #${item.conflictsWithTaskId} in this selection`;
633
+ case 'DEPS_NOT_SHIPPED': return 'waits on unshipped dependencies';
634
+ case 'rank_too_low_for_task':
635
+ case 'XENOS_RESTRICTED':
636
+ case 'INSUFFICIENT_RANK': return 'rank too low';
637
+ case 'BUILDER_INACTIVE': return 'account inactive';
638
+ default: return (item && item.reason) || 'not claimable';
639
+ }
640
+ }
641
+ // Bulk claim the selected ready cards (work.js doBulkClaim, claims/batch is
642
+ // all-or-nothing: a 409 batch_failed means nothing was claimed). Reloads the
643
+ // goal's tasks either way so the cards reflect fresh truth.
644
+ async function doGoalBulkClaim() {
645
+ const ids = [...goalSel.claim].map(Number);
646
+ if (!ids.length) return;
647
+ const btn = $('#goal-bulk-claim-btn');
648
+ if (btn) { btn.disabled = true; btn.textContent = 'Claiming…'; }
649
+ try {
650
+ if (ids.length === 1) {
651
+ await postJSON(`${API}/claims`, { task_id: ids[0], bind_session: false });
652
+ toast(`You claimed #${ids[0]}. Open a terminal to start work.`);
653
+ } else {
654
+ await postJSON(`${API}/claims/batch`, { task_ids: ids, bind_session: false });
655
+ toast(`Claimed ${ids.length} tasks. Open a terminal to start work.`);
656
+ }
657
+ goalSel.claim.clear();
658
+ } catch (err) {
659
+ if (err && err.message === 'redirecting to auth') return;
660
+ // claims/batch is all-or-nothing: a 409 batch_failed means NOTHING was
661
+ // claimed. Read failures from the ADR 0116 envelope (error.details.failures)
662
+ // or the flat legacy shape, via the same normalizers friendlyClaimError uses.
663
+ const det = errDetails(err.data);
664
+ if (err.status === 409 && errCode(err.data) === 'batch_failed') {
665
+ const failures = Array.isArray(det.failures) ? det.failures
666
+ : (Array.isArray(err.data && err.data.failures) ? err.data.failures : []);
667
+ const lines = failures.map((f) => `#${f.task_id} (${friendlyBatchReason(f)})`);
668
+ toast(lines.length
669
+ ? `The batch could not be claimed — ${lines.join('; ')}.`
670
+ : 'The batch could not be claimed. It was left untouched (all-or-nothing).');
671
+ } else {
672
+ toast(friendlyClaimError(err.status, err.data));
673
+ }
674
+ } finally {
675
+ if (state.current != null) await loadGoalTasks(state.current);
676
+ else renderGoalBulkToolbar();
677
+ }
346
678
  }
347
679
  function taskRowsDone(tasks) {
348
680
  return tasks.map((t) => `
@@ -459,46 +791,101 @@
459
791
  }
460
792
 
461
793
  function renderWork() {
462
- const todoBody = document.querySelector('#goal-todo tbody');
794
+ renderTodoCards();
795
+ // ---- Completed → unchanged ledger table (the board's Completed tab is also
796
+ // a plain list, so the goal keeps its sortable table here). ----
463
797
  const doneBody = document.querySelector('#goal-done tbody');
464
- const todo = sortWorkRows(state.work.todo, state.workSort.todo);
465
- const done = sortWorkRows(state.work.done, state.workSort.done);
466
- // task 1956 → kit (2171): paginate each table through the shared pager.
467
- const todoSlice = paginate(todo, pagers.todo);
468
- const doneSlice = paginate(done, pagers.done);
469
- if (todoBody) {
470
- todoBody.innerHTML = todo.length
471
- ? taskRowsTodo(todoSlice)
472
- : `<tr><td colspan="6" class="ledger__empty">${emptyStateHtml(`Nothing queued under this goal. Members can author bounded tasks here${state.me ? '' : ' once signed in'}.`)}</td></tr>`;
473
- }
474
798
  if (doneBody) {
799
+ const done = sortWorkRows(state.work.done, state.workSort.done);
800
+ const doneSlice = paginate(done, pagers.done);
475
801
  doneBody.innerHTML = done.length
476
802
  ? taskRowsDone(doneSlice)
477
803
  : `<tr><td colspan="5" class="ledger__empty">${emptyStateHtml('Nothing completed yet.')}</td></tr>`;
478
804
  }
479
805
  workTabs?.setCount('todo', state.work.todo.length);
480
806
  workTabs?.setCount('done', state.work.done.length);
481
- reflectSortHeaders('todo');
482
807
  reflectSortHeaders('done');
808
+ renderGoalBulkToolbar();
809
+ }
810
+
811
+ // task 1002425 — the To-do list as Tasks-board cards: filter → run-mode →
812
+ // sort → paginate → render, mirroring work.js renderClaimable (276-395). The
813
+ // sort control lives on the filter bar (replacing the old column headers).
814
+ function renderTodoCards() {
815
+ const listEl = $('#goal-todo-list');
816
+ if (!listEl) return;
817
+ const sQuery = todoSearchQuery();
818
+ const kFilter = $('#f-goal-kind')?.value || '';
819
+ const dFilter = $('#f-goal-discipline')?.value || '';
820
+ const nFilter = $('#f-goal-newcomer')?.value || '';
821
+ const sortBy = $('#f-goal-sort')?.value || 'cpm';
822
+ let list = state.work.todo.filter((t) => {
823
+ if (!matchesTodoSearch(t, sQuery)) return false;
824
+ if (kFilter && (t.kind || 'unclassified') !== kFilter) return false;
825
+ if (dFilter && (t.discipline || 'unclassified') !== dFilter) return false;
826
+ if (nFilter === 'yes' && !t.newcomer_friendly) return false;
827
+ return true;
828
+ });
829
+ // Run-mode filter: parallel-safe (touches[] disjoint from the rest of the
830
+ // visible set) vs. file-conflicting. O(n²) over touches — fine at goal scale.
831
+ const fitFilter = $('#f-goal-fit')?.value || '';
832
+ if (fitFilter === 'parallel' || fitFilter === 'conflict') {
833
+ const base = list;
834
+ list = base.filter((t) => {
835
+ const conflicts = base.some((o) => o !== t && touchesConflict(t.touches, o.touches));
836
+ return fitFilter === 'parallel' ? !conflicts : conflicts;
837
+ });
838
+ }
839
+ if (sortBy === 'cpm') list = list.slice().sort((a, b) => creditsPerMinute(b) - creditsPerMinute(a));
840
+ else if (sortBy === 'credits') list = list.slice().sort((a, b) => creditVal(b) - creditVal(a));
841
+ else if (sortBy === 'minutes') list = list.slice().sort((a, b) => (Number(a.est_minutes) || Infinity) - (Number(b.est_minutes) || Infinity));
842
+ else if (sortBy === 'priority') list = list.slice().sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority));
843
+ else if (sortBy === 'id') list = list.slice().sort((a, b) => Number(a.id) - Number(b.id));
844
+ else if (sortBy === 'status') list = list.slice().sort((a, b) => String(a.status).localeCompare(String(b.status)));
845
+
846
+ const countEl = $('#goal-todo-count');
847
+ if (countEl) countEl.textContent = state.work.todo.length ? `${list.length} of ${state.work.todo.length} shown` : '';
848
+ if (!list.length) {
849
+ listEl.innerHTML = emptyStateHtml(state.work.todo.length
850
+ ? 'No tasks match these filters. Try widening them.'
851
+ : `Nothing queued under this goal. Members can author bounded tasks here${state.me ? '' : ' once signed in'}.`);
852
+ return;
853
+ }
854
+ const shown = paginate(list, pagers.todo);
855
+ listEl.innerHTML = shown.map(goalTaskCardHtml).join('');
483
856
  }
484
857
 
485
858
  // task 1828 — claim a ready task straight from a goal's Work list. Mirrors the
486
859
  // Work Board's doClaim (POST /claims, bind_session:false) + its typed-error
487
860
  // messages; on success the goal's tasks reload so the row updates in place.
488
861
  const pendingClaims = new Set();
862
+ // task 1002425 — the API emits the ADR 0116 envelope `{ error: { code, message,
863
+ // details } }` and the client passes the raw body through (throwOnError:false),
864
+ // so the typed code is at data.error.code, not data.error. These normalize BOTH
865
+ // that envelope and the flat legacy `{ error: 'CODE', ...fields }` shape — without
866
+ // them the switch below fell through to the generic default for every claim error.
867
+ function errCode(data) {
868
+ const e = data && data.error;
869
+ return (e && typeof e === 'object') ? e.code : e;
870
+ }
871
+ function errDetails(data) {
872
+ const e = data && data.error;
873
+ return (e && typeof e === 'object' && e.details) ? e.details : (data || {});
874
+ }
489
875
  function friendlyClaimError(status, data) {
490
- switch (data && data.error) {
876
+ const det = errDetails(data);
877
+ switch (errCode(data)) {
491
878
  case 'ALREADY_CLAIMED': return 'Someone else claimed that task first. Reloading.';
492
879
  case 'TASK_NOT_READY': return 'That task is no longer ready to be claimed.';
493
880
  case 'TOUCHES_CONFLICT': {
494
- const titles = Array.isArray(data.conflicts) ? data.conflicts.map((c) => c.title).filter(Boolean) : [];
881
+ const titles = Array.isArray(det.conflicts) ? det.conflicts.map((c) => c.title).filter(Boolean) : [];
495
882
  return titles.length ? `That task overlaps an active claim: ${titles.join('; ')}.` : 'That task overlaps another active claim.';
496
883
  }
497
884
  case 'DEPS_NOT_SHIPPED': return 'That task waits on unshipped dependencies.';
498
885
  case 'rank_too_low_for_task':
499
886
  case 'XENOS_RESTRICTED':
500
887
  case 'INSUFFICIENT_RANK': return 'Your rank is not yet high enough for that task.';
501
- case 'newcomer_must_ship_first': return data.what_to_do || 'Ship your current claim before opening another.';
888
+ case 'newcomer_must_ship_first': return det.what_to_do || 'Ship your current claim before opening another.';
502
889
  case 'NOT_YOUR_CLAIM': return 'That claim is not yours to release.';
503
890
  case 'ALREADY_RESOLVED': return 'That claim was already resolved. Reloading.';
504
891
  case 'BUILDER_INACTIVE': return 'Your account is inactive. Contact an Archon.';
@@ -620,9 +1007,20 @@
620
1007
 
621
1008
  /* ----- Completion (band column + #/goal/N/completion full view) ----- */
622
1009
 
1010
+ // task 1002164 — a criterion has three states, not two: confirmed (satisfied),
1011
+ // met-but-awaiting-confirmation (pending_review, derived server-side), and open.
1012
+ const isPending = (c) => !c.satisfied && !!c.pending_review;
1013
+
623
1014
  function completionSummaryHtml(g, crit, sat, ringSize) {
1015
+ const pending = crit.filter(isPending).length;
1016
+ // task 1002164 — an open goal used to punt confirmation to the /goal-review
1017
+ // CLI skill here; confirming now happens on this page, so the line reports
1018
+ // what is actually actionable instead of naming a command.
1019
+ const openLine = pending
1020
+ ? `${fmtNum(pending)} criteri${pending === 1 ? 'on is' : 'a are'} met and ready to confirm.`
1021
+ : 'Open — a criterion becomes confirmable once every task linked to it has shipped.';
624
1022
  const line = g.status === 'achieved' ? 'Goal achieved — every criterion confirmed.'
625
- : (g.status === 'archived' ? 'Archived.' : 'Open — satisfied criteria await confirmation in /goal-review.');
1023
+ : (g.status === 'archived' ? 'Archived.' : openLine);
626
1024
  return `<div style="display:flex;align-items:center;gap:16px">
627
1025
  ${ring(sat, crit.length, g.status === 'achieved', ringSize)}
628
1026
  <div>
@@ -635,7 +1033,7 @@
635
1033
  // is the point of clicking into the dedicated completion view. The capped band
636
1034
  // (withDetail=false) instead reveals detail via a per-criterion <details> expand
637
1035
  // (BV1.R79) so the full text is one click away, not hover-only.
638
- function critListHtml(list, withDetail) {
1036
+ function critListHtml(list, withDetail, canConfirm) {
639
1037
  if (!list.length) return emptyStateHtml('No done-when criteria yet.');
640
1038
  return `<ul class="crit-list${withDetail ? ' crit-list--full' : ''}">${list.map((c) => {
641
1039
  const parts = window.OTB.splitCriterion ? window.OTB.splitCriterion(c.criterion_md) : { label: c.criterion_md, detail: '' };
@@ -644,7 +1042,15 @@
644
1042
  : (parts.detail
645
1043
  ? `<details class="crit-d"><summary class="crit__text">${escapeHtml(parts.label || '')}</summary><div class="crit__detail">${escapeHtml(parts.detail)}</div></details>`
646
1044
  : `<span class="crit__text">${escapeHtml(parts.label || '')}</span>`);
647
- return `<li class="crit${c.satisfied ? ' crit--done' : ''}"><span class="crit__mark">${c.satisfied ? '✓' : ''}</span>${body}</li>`;
1045
+ const pending = isPending(c);
1046
+ // A Xenos sees the state but not the control — the server enforces either way.
1047
+ const action = !pending ? ''
1048
+ : (canConfirm
1049
+ ? `<button type="button" class="crit__confirm" data-confirm-crit="${escapeHtml(String(c.id))}"
1050
+ title="Every task linked to this criterion has shipped — confirm it as satisfied">Confirm</button>`
1051
+ : '<span class="crit__flag" title="Every task linked to this criterion has shipped — a Metic can confirm it">Ready</span>');
1052
+ const cls = c.satisfied ? ' crit--done' : (pending ? ' crit--pending' : '');
1053
+ return `<li class="crit${cls}"><span class="crit__mark">${c.satisfied ? '✓' : (pending ? '!' : '')}</span>${body}<span class="crit__act">${action}</span></li>`;
648
1054
  }).join('')}</ul>`;
649
1055
  }
650
1056
  function renderCompletion() {
@@ -653,16 +1059,48 @@
653
1059
  const g = d.goal;
654
1060
  const crit = d.criteria || [];
655
1061
  const sat = crit.filter((c) => c.satisfied).length;
1062
+ // task 1002164 — an archived/achieved goal has nothing left to confirm.
1063
+ const canConfirm = g.status === 'open' && canConfirmCriteria();
656
1064
  const band = $('#goal-completion-body');
657
- if (band) band.innerHTML = completionSummaryHtml(g, crit, sat, 56) + critListHtml(crit.slice(0, CRITERIA_CAP), false);
1065
+ if (band) band.innerHTML = completionSummaryHtml(g, crit, sat, 56) + critListHtml(crit.slice(0, CRITERIA_CAP), false, canConfirm);
658
1066
  renderViewAllFoot('#goal-completion-foot', crit.length, CRITERIA_CAP, 'completion', 'criteria');
659
1067
  setCount('#goal-completion-count', crit.length);
660
1068
  const full = $('#goal-completion-full');
661
- if (full) full.innerHTML = completionSummaryHtml(g, crit, sat, 64) + critListHtml(crit, true);
1069
+ if (full) full.innerHTML = completionSummaryHtml(g, crit, sat, 64) + critListHtml(crit, true, canConfirm);
662
1070
  const back = $('#goal-completion-back');
663
1071
  if (back && state.current != null) back.setAttribute('href', `#/goal/${state.current}`);
664
1072
  }
665
1073
 
1074
+ // task 1002164 — confirm a pending-review criterion straight from the hall
1075
+ // (POST /done-when/:id/satisfy, Metic+). This is the in-hall replacement for
1076
+ // the /goal-review LLM skill: confirming the goal's LAST open criterion flips
1077
+ // it open→achieved server-side, which the forced detail reload then reflects.
1078
+ const pendingConfirms = new Set();
1079
+ async function confirmCriterion(critId, btn) {
1080
+ if (pendingConfirms.has(critId)) return;
1081
+ pendingConfirms.add(critId);
1082
+ const label = btn ? btn.textContent : '';
1083
+ if (btn) { btn.disabled = true; btn.textContent = 'Confirming…'; }
1084
+ try {
1085
+ const res = await postJSON(`${API}/done-when/${encodeURIComponent(critId)}/satisfy`, {});
1086
+ toast(res && res.goal_achieved ? 'Criterion confirmed — that was the last one, so this goal is achieved.' : 'Criterion confirmed.');
1087
+ if (state.current != null) {
1088
+ await loadDetail(state.current, { force: true });
1089
+ renderDetail(state.current);
1090
+ }
1091
+ } catch (err) {
1092
+ if (err && err.message === 'redirecting to auth') return;
1093
+ const code = err && err.data && err.data.error;
1094
+ const reason = (code && (code.code || code)) === 'rank_forbidden'
1095
+ ? 'Confirming a criterion needs Metic rank or higher.'
1096
+ : `Could not confirm that criterion (${(err && err.status) || 'error'}).`;
1097
+ toast(reason);
1098
+ if (btn) { btn.disabled = false; btn.textContent = label; }
1099
+ } finally {
1100
+ pendingConfirms.delete(critId);
1101
+ }
1102
+ }
1103
+
666
1104
  /* ----- Members (band column + #/goal/N/members full view) ----- */
667
1105
 
668
1106
  function memberAvatar(m) {
@@ -891,6 +1329,7 @@
891
1329
  .sort((a, b) => new Date(b.shipped_at || 0) - new Date(a.shipped_at || 0)),
892
1330
  };
893
1331
  state.workLoaded = true; // task 1828 — member stats can now compute
1332
+ pruneGoalSelection(); // task 1002425 — drop ticks for cards that vanished (or a goal switch)
894
1333
  renderWork();
895
1334
  renderMembers(); // refresh per-member goal stats now that tasks landed
896
1335
  // Prefer the server-side detector when it exists (task 1736); any failure
@@ -918,9 +1357,10 @@
918
1357
  if (err && err.message === 'redirecting to auth') return;
919
1358
  console.error('[goals] tasks load failed', err);
920
1359
  if (!sameId(state.current, goalId)) return;
921
- const todoBody = document.querySelector('#goal-todo tbody');
1360
+ // task 1002425 — To do is now a card list (#goal-todo-list), not a table.
1361
+ const todoList = $('#goal-todo-list');
922
1362
  const doneBody = document.querySelector('#goal-done tbody');
923
- if (todoBody) todoBody.innerHTML = '<tr><td colspan="6" class="ledger__empty">Tasks could not be loaded.</td></tr>';
1363
+ if (todoList) todoList.innerHTML = emptyStateHtml('Tasks could not be loaded.');
924
1364
  if (doneBody) doneBody.innerHTML = '<tr><td colspan="5" class="ledger__empty">Tasks could not be loaded.</td></tr>';
925
1365
  state.conflictPairs = null;
926
1366
  state.conflictSource = null;
@@ -1212,13 +1652,18 @@
1212
1652
  if (el) el.hidden = true;
1213
1653
  });
1214
1654
  ['#goal-conflicts-card', '#goal-conflicts-view-card'].forEach((sel) => $(sel)?.classList.remove('goal-conflicts-card--live'));
1215
- reflectSortHeaders('todo');
1216
1655
  reflectSortHeaders('done');
1217
- // Kit shimmer placeholders while the goal's tasks load (was "…" cells).
1218
- const todoBody = document.querySelector('#goal-todo tbody');
1656
+ // task 1002425 — To do is a card list now; Completed stays a table. Show a
1657
+ // placeholder in the card list + skeleton rows in the Completed table while
1658
+ // the goal's tasks load, and reset the To-do count + hide the bulk toolbar.
1659
+ const todoList = $('#goal-todo-list');
1219
1660
  const doneBody = document.querySelector('#goal-done tbody');
1220
- if (todoBody) todoBody.innerHTML = skeletonRows(3, 6);
1661
+ if (todoList) todoList.innerHTML = '<div class="profile__placeholder">…</div>';
1221
1662
  if (doneBody) doneBody.innerHTML = skeletonRows(3, 5);
1663
+ const todoCount = $('#goal-todo-count');
1664
+ if (todoCount) todoCount.textContent = '';
1665
+ const bulkBar = $('#goal-bulk-toolbar');
1666
+ if (bulkBar) bulkBar.hidden = true;
1222
1667
  const conflicts = $('#goal-conflicts');
1223
1668
  if (conflicts) conflicts.innerHTML = '<span class="kit-skel"></span>';
1224
1669
  const conflictsFull = $('#goal-conflicts-full');
@@ -1331,7 +1776,12 @@
1331
1776
  populateVersionFilter();
1332
1777
  // Prefetch details (ring + members on the cards). Sequentially-parallel,
1333
1778
  // failure-tolerant: a card without detail still renders.
1334
- await Promise.allSettled(state.goals.map((g) => loadDetail(g.id)));
1779
+ // task 1002439 — also fold in per-goal task-readiness rollups (one request
1780
+ // per version, not per goal). Runs alongside the detail prefetch.
1781
+ await Promise.allSettled([
1782
+ ...state.goals.map((g) => loadDetail(g.id)),
1783
+ loadRollups(),
1784
+ ]);
1335
1785
  await route();
1336
1786
  } catch (err) {
1337
1787
  if (err && err.message === 'redirecting to auth') return;
@@ -1353,7 +1803,7 @@
1353
1803
  mount: $('#goals-filters'),
1354
1804
  primary: [
1355
1805
  { id: 'f-goal-version', label: 'Version', options: [{ value: '', label: '— any —' }] },
1356
- { id: 'f-goal-status', label: 'Status', value: 'open', options: [{ value: '', label: '— any —' }, 'open', 'achieved', 'archived'] },
1806
+ { id: 'f-goal-status', label: 'Status', options: [{ value: '', label: '— any —' }, 'open', 'achieved', 'archived'] },
1357
1807
  { id: 'f-goal-mine', label: 'Membership', options: [{ value: '', label: 'All goals' }, { value: 'mine', label: 'My goals' }] },
1358
1808
  ],
1359
1809
  onChange: onListFilterChange,
@@ -1388,6 +1838,41 @@
1388
1838
  { id: 'todo', label: 'To do', panel: 'goal-todo-pane' },
1389
1839
  { id: 'done', label: 'Completed', panel: 'goal-done-pane' },
1390
1840
  ],
1841
+ // task 1002425 — clear the card selection when leaving To do (matches the
1842
+ // board, work.js:1185) so the bulk toolbar can't linger over Completed.
1843
+ onChange: () => { if (goalSelTotal()) clearGoalSelection(); },
1844
+ });
1845
+
1846
+ // task 1002425 — the To-do filter bar, scoped to this goal: Search + Sort
1847
+ // primary, Kind / Discipline / Newcomer / Run-mode behind the "Filters"
1848
+ // disclosure (the same set the board offers, minus Version/Goal which are
1849
+ // redundant inside a goal). url:false is load-bearing — this page owns the
1850
+ // #/goal/N hash route, so filter state must NOT ride the URL.
1851
+ makeFilterBar({
1852
+ mount: $('#goal-work-filters'),
1853
+ search: { id: 'f-goal-search', label: 'Search', placeholder: 'task # or title…' },
1854
+ primary: [
1855
+ { id: 'f-goal-sort', label: 'Sort', options: [
1856
+ { value: 'cpm', label: `${curLabel()} / minute (high → low)` },
1857
+ { value: 'credits', label: `${curLabel()} (high → low)` },
1858
+ { value: 'minutes', label: 'minutes (low → high)' },
1859
+ { value: 'priority', label: 'priority (P0 first)' },
1860
+ { value: 'id', label: 'task number (low → high)' },
1861
+ { value: 'status', label: 'status' },
1862
+ ] },
1863
+ ],
1864
+ overflow: [
1865
+ { id: 'f-goal-kind', label: 'Kind', options: [{ value: '', label: '— any —' },
1866
+ 'feature', 'bug', 'infra', 'refactor', 'spike', 'learning-capture',
1867
+ 'cleanup', 'decision', 'blocker-resolution', 'unclassified'] },
1868
+ { id: 'f-goal-discipline', label: 'Discipline', options: [{ value: '', label: '— any —' }, 'engineer', 'artist', 'ideator', 'ui', 'unclassified'] },
1869
+ { id: 'f-goal-newcomer', label: 'Newcomer', options: [{ value: '', label: '— any —' }, { value: 'yes', label: 'newcomer-friendly only' }] },
1870
+ { id: 'f-goal-fit', label: 'Run mode', options: [{ value: '', label: '— any —' },
1871
+ { value: 'parallel', label: 'parallel-safe only' },
1872
+ { value: 'conflict', label: 'has file conflicts' }] },
1873
+ ],
1874
+ url: false,
1875
+ onChange: () => { pagers.todo.page = 1; renderTodoCards(); renderGoalBulkToolbar(); },
1391
1876
  });
1392
1877
  // task 1828 — click / keyboard sort on the Work-table column headers.
1393
1878
  Object.entries(WORK_SORT_HEADERS).forEach(([table, cols]) => {
@@ -1400,16 +1885,43 @@
1400
1885
  });
1401
1886
  });
1402
1887
  });
1403
- // task 1753/1828: a row opens the shared task-detail overlay; the inline Claim
1404
- // button (task 1828) claims without opening it.
1405
- ['#goal-todo', '#goal-done'].forEach((sel) => {
1888
+ // task 1002425 — the Completed table keeps its row-opens-overlay behaviour.
1889
+ $('#goal-done')?.addEventListener('click', (e) => {
1890
+ const row = e.target.closest('.ledger__row--task');
1891
+ if (row && row.dataset.taskId && window.OTBTaskDetail) window.OTBTaskDetail.open(row.dataset.taskId);
1892
+ });
1893
+ // task 1002425 — the To-do card list: multi-select + inline Claim/Release +
1894
+ // copy-prompt + open, mirroring the board's task-list wiring (work.js:1245-1272).
1895
+ $('#goal-todo-list')?.addEventListener('click', (e) => {
1896
+ const selectCb = e.target.closest('.task__select');
1897
+ if (selectCb) { const card = selectCb.closest('.task'); if (card) toggleGoalCardSelect(card, selectCb.checked); return; }
1898
+ const claimBtn = e.target.closest('[data-claim-task]');
1899
+ if (claimBtn) { e.stopPropagation(); doGoalClaim(claimBtn.dataset.claimTask, claimBtn); return; }
1900
+ const copyBtn = e.target.closest('[data-copy-task]');
1901
+ if (copyBtn) { copyPromptForTask(copyBtn.dataset.copyTask, copyBtn); return; }
1902
+ const releaseBtn = e.target.closest('[data-release-claim]');
1903
+ if (releaseBtn) { e.stopPropagation(); doGoalRelease(releaseBtn.dataset.releaseClaim, releaseBtn.dataset.releaseTask, releaseBtn); return; }
1904
+ const openBtn = e.target.closest('.task__open');
1905
+ if (openBtn && openBtn.dataset.taskId && window.OTBTaskDetail) { window.OTBTaskDetail.open(openBtn.dataset.taskId); return; }
1906
+ // Click elsewhere: a ready card toggles its selection; a read-only (active /
1907
+ // backlog) card opens the detail overlay (board affordance, work.js:1268-1271).
1908
+ const card = e.target.closest('.task');
1909
+ if (!card || !card.dataset.taskId) return;
1910
+ if (card.classList.contains('task--readonly')) {
1911
+ if (window.OTBTaskDetail) window.OTBTaskDetail.open(card.dataset.taskId);
1912
+ } else {
1913
+ toggleGoalCardSelect(card);
1914
+ }
1915
+ });
1916
+ // task 1002425 — the goal's bulk-action toolbar.
1917
+ $('#goal-bulk-claim-btn')?.addEventListener('click', doGoalBulkClaim);
1918
+ $('#goal-bulk-clear-btn')?.addEventListener('click', clearGoalSelection);
1919
+ // task 1002164 — Confirm on a pending-review criterion, in both the capped
1920
+ // band column and the dedicated #/goal/N/completion view.
1921
+ ['#goal-completion-body', '#goal-completion-full'].forEach((sel) => {
1406
1922
  $(sel)?.addEventListener('click', (e) => {
1407
- const claimBtn = e.target.closest('[data-claim-task]');
1408
- if (claimBtn) { e.stopPropagation(); doGoalClaim(claimBtn.dataset.claimTask, claimBtn); return; }
1409
- const releaseBtn = e.target.closest('[data-release-claim]');
1410
- if (releaseBtn) { e.stopPropagation(); doGoalRelease(releaseBtn.dataset.releaseClaim, releaseBtn.dataset.releaseTask, releaseBtn); return; }
1411
- const row = e.target.closest('.ledger__row--task');
1412
- if (row && row.dataset.taskId && window.OTBTaskDetail) window.OTBTaskDetail.open(row.dataset.taskId);
1923
+ const btn = e.target.closest('[data-confirm-crit]');
1924
+ if (btn) { e.preventDefault(); confirmCriterion(btn.dataset.confirmCrit, btn); }
1413
1925
  });
1414
1926
  });
1415
1927
  // task 1953 — Approve/Deny on the goal's own pending-requests panel.