@bongos/core 1.19.1044 → 1.19.1046

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 (89) hide show
  1. package/.bongos-core.json +106 -121
  2. package/docs/adr/0337-the-hub-account-is-the-person-a-hall-account-is-their-seat-in-one-project.md +14 -2
  3. package/docs/adr/README.md +1 -1
  4. package/docs/copy-inventory.md +142 -158
  5. package/docs/copy-registry.json +173 -360
  6. package/docs/file-map.md +2 -3
  7. package/docs/module-api-changelog.md +4 -0
  8. package/docs/recipes/windows-builders.md +1 -1
  9. package/modules/hall-ui/public/atlas.html +1 -1
  10. package/modules/hall-ui/public/blockers.html +1 -1
  11. package/modules/hall-ui/public/board-room.html +1 -1
  12. package/modules/hall-ui/public/claim-action.js +250 -0
  13. package/modules/hall-ui/public/collab.css +1 -1
  14. package/modules/hall-ui/public/collab.html +1 -1
  15. package/modules/hall-ui/public/copy-desk.html +1 -1
  16. package/modules/hall-ui/public/deploy.html +1 -1
  17. package/modules/hall-ui/public/diagrams.html +1 -1
  18. package/modules/hall-ui/public/drachmae.html +1 -1
  19. package/modules/hall-ui/public/fleet.html +1 -1
  20. package/modules/hall-ui/public/gate.html +1 -1
  21. package/modules/hall-ui/public/goals-page.js +28 -44
  22. package/modules/hall-ui/public/goals-render.js +7 -28
  23. package/modules/hall-ui/public/goals.html +6 -3
  24. package/modules/hall-ui/public/government.html +1 -1
  25. package/modules/hall-ui/public/idea.html +1 -1
  26. package/modules/hall-ui/public/ideas.html +1 -1
  27. package/modules/hall-ui/public/index.html +1 -1
  28. package/modules/hall-ui/public/modules.html +1 -1
  29. package/modules/hall-ui/public/primer.html +1 -1
  30. package/modules/hall-ui/public/profile.html +1 -1
  31. package/modules/hall-ui/public/project-settings.html +1 -1
  32. package/modules/hall-ui/public/ranks.html +1 -1
  33. package/modules/hall-ui/public/roadmap.html +1 -1
  34. package/modules/hall-ui/public/{people.css → roster.css} +7 -11
  35. package/modules/hall-ui/public/roster.html +5 -5
  36. package/modules/hall-ui/public/roster.js +6 -7
  37. package/modules/hall-ui/public/roster.states.json +1 -1
  38. package/modules/hall-ui/public/sessions.html +1 -1
  39. package/modules/hall-ui/public/settings.html +2 -2
  40. package/modules/hall-ui/public/settings.js +2 -2
  41. package/modules/hall-ui/public/shell.js +3 -10
  42. package/modules/hall-ui/public/studio.html +1 -1
  43. package/modules/hall-ui/public/style.css +2 -9
  44. package/modules/hall-ui/public/task.html +1 -1
  45. package/modules/hall-ui/public/watch.html +1 -1
  46. package/modules/hall-ui/public/work.html +5 -2
  47. package/modules/hall-ui/public/work.js +35 -123
  48. package/modules/hall-ui/records/builders-roster.md +1 -1
  49. package/modules/hall-ui/records/roster-and-ledger.md +2 -2
  50. package/modules/platform-identity/account-visibility.js +3 -3
  51. package/modules/platform-identity/platform-identity.js +2 -2
  52. package/modules/platform-identity/routes/profile.js +1 -1
  53. package/modules/platform-identity/routes/sso.js +4 -3
  54. package/modules/platform-identity/scouting-authz.js +2 -2
  55. package/modules/platform-identity/tests/platform-identity.mjs +1 -1
  56. package/package-lock.json +2 -2
  57. package/package.json +1 -1
  58. package/release-notes.json +12 -0
  59. package/src/bongos/auth.js +9 -24
  60. package/src/bongos/serve-internal.js +13 -19
  61. package/src/module-api.js +1 -1
  62. package/tests/account_privacy_flags.mjs +1 -1
  63. package/tests/auth_resolve_failure.mjs +6 -6
  64. package/tests/claim_action.mjs +417 -0
  65. package/tests/core_upgrade_owner_door.mjs +1 -1
  66. package/tests/edit_hints.mjs +8 -8
  67. package/tests/effective_visibility_predicate.mjs +1 -1
  68. package/tests/goal_category_ui.mjs +3 -0
  69. package/tests/goal_criterion_confirm_ui.mjs +3 -0
  70. package/tests/goal_forest_page.mjs +3 -0
  71. package/tests/goal_invite_consent.mjs +3 -1
  72. package/tests/goal_mine_lens.mjs +3 -0
  73. package/tests/government_parity.mjs +1 -1
  74. package/tests/hall_builders_page_pass.mjs +2 -2
  75. package/tests/hall_mine_lens.mjs +3 -0
  76. package/tests/hall_nav.mjs +8 -8
  77. package/tests/hall_page_gate_map.mjs +14 -11
  78. package/tests/hall_roster_world.mjs +47 -78
  79. package/tests/module_contributions.mjs +0 -1
  80. package/tests/nav_permission_atoms.mjs +1 -1
  81. package/tests/{scouting_page_gate.mjs → page_gate_second_door.mjs} +34 -29
  82. package/tests/profile_rollup_consent.mjs +4 -4
  83. package/tests/profile_route.mjs +3 -26
  84. package/tests/scouting_owner_access_and_reach.mjs +4 -8
  85. package/modules/hall-ui/public/people.html +0 -79
  86. package/modules/hall-ui/public/people.js +0 -321
  87. package/modules/hall-ui/public/people.states.json +0 -89
  88. package/tests/people_directory_clip.mjs +0 -269
  89. package/tests/people_name_only.mjs +0 -263
package/docs/file-map.md CHANGED
@@ -134,7 +134,7 @@
134
134
  │ ├── sessions/ ← CORE-DOMAIN carve (BV1.R82, default-on): the session_records corpus + data plane (upload, cross-builder search/deep-dive), the BFG read-the-room scorer, session-pulse/stale-claim helpers. Registers no port — reached via HTTP POST to its own /sessions route; resolves the economy `reward` port for the cost-plus token reward. CLAUDE.md, module.json, routes/sessions.js, bfg.js, pulse.js.
135
135
  │ ├── autonomy/ ← CORE-DOMAIN carve (BV1.R83, default-on): the MAS autonomy surfaces — the $0 deterministic autonomy gate (System 3, inert until armed), the conductor/architect CLIs, and the in-process routine timers (incl. the weekly peer-vote-tally poller). Registers no port; resolves the lifecycle `tallyPeerVotes` doorway. CLAUDE.md, module.json, routes/autonomy.js, autonomy.js.
136
136
  │ ├── status-ui/ ← CORE-DOMAIN WEB-SURFACE carve (BV1.R84, default-on): the public status dashboard at status.<apex>, moved from public-status/. NO routes/db/port — declares contributes.webSurfaces:[{host:"status.",dir:"public"}], served by the loader web-surface seam (moduleWebSurfaces → serve-internal). CLAUDE.md, module.json, public/{index.html,status.js,style.css,cursors/,snapshots/}.
137
- │ ├── hall-ui/ ← CORE-DOMAIN WEB-SURFACE carve (BV1.R85, default-on): the authenticated builders' hall at builders.<apex>, moved from public-builders/ (~13k LOC, the largest). NO routes/db/port — declares contributes.webSurfaces:[{host:"builders.",dir:"public"}] for discovery, but UNLIKE status-ui keeps its DEDICATED serving block in serve-internal (the /builders shim + canonical redirects + citizen/Archon page gates + asset-stamping + hall-widget injection), partitioned out of the generic web-surface loop and served from BUILDERS_DIR. CLAUDE.md, module.json, records/ (one file per page family — what each family's redesign settled; CLAUDE.md points at them, and a new record is a new file there so concurrent hall tasks stop colliding on one insertion point, task 1003499), public/{index.html,builders.js,style.css,work,watch,gate,settings,ranks,sessions,primer,diagrams,atlas,...}.{html,js},cursors/,chime.wav. Four ARCHETYPE sheets sit beside the per-page ones: `record.css` (the task + idea records, task 1003305), `room.css` (the READING ROOMS — the primer + the diagrams index, task 1003306: the reading measure and rhythm, the table of contents, the progress hairline and the framed figure diagram-viewer.js upgrades on both pages), `oversight.css` (the OVERSIGHT family — government, watch, gate, task 1003469 (harbor was its fourth until task 1003890 removed the dev box): the row, the figure band inside a panel, the group head, the inline form, the grant toggle and the shared confirm modal; named oversight because the vocabulary wall bans "governance" on the government surface) and `panel.css` (the PANEL family — settings, profile, task 1003308 (pair was its third until task 1003890 removed the dev box): the row, the switch, the field with its label above, the chip control, the four voices and the sign-in gate; the hall's my-projects page was DELETED in the same task — "my projects" is the hub's user-level page, and the hall keeps no link or redirect; the family's fourth page `not-ready.html` was deleted by task 1003450, along with the emptied `requireNonXenosPage` gate that was the only thing redirecting to it). A page sheet then holds only what one page has and the others do not — `primer.css` the acknowledge gate, `diagrams.css` the legend strip, `gate.css` the hold reasons and file chips, `watch.css` the severities, the report reader and the grade trend, `settings.css` the rail layout, the group head, the token ladder and the voices grid, `profile.css` the identity band and the shelf, `people.css` the directory's identity cell (task 1003470 made the legacy door `/scouting` ship the same body and script as `/people`; task 1003440 then deleted the door — `scouting.html` and `scouting.states.json` are gone and the URL 301s to `/people`), `thinking.css` THE SKY's one sheet (task 1004231 / sky part 3 — `thinking.html` at `/thinking` is the prototype the owner adopted as the design of record, `docs/design/mocks/sky/`, built over `GET /sky` + `GET /sky/mine`; its script is the seven `sky-*.js` files on one shared `window.OTBSky`; it replaced THE ROOM of task 1004098, which survives as the list drawer, and the old `/sky` page, whose URL 301s here; `idea-objects.js` beside it is the shared five-object vocabulary).
137
+ │ ├── hall-ui/ ← CORE-DOMAIN WEB-SURFACE carve (BV1.R85, default-on): the authenticated builders' hall at builders.<apex>, moved from public-builders/ (~13k LOC, the largest). NO routes/db/port — declares contributes.webSurfaces:[{host:"builders.",dir:"public"}] for discovery, but UNLIKE status-ui keeps its DEDICATED serving block in serve-internal (the /builders shim + canonical redirects + citizen/Archon page gates + asset-stamping + hall-widget injection), partitioned out of the generic web-surface loop and served from BUILDERS_DIR. CLAUDE.md, module.json, records/ (one file per page family — what each family's redesign settled; CLAUDE.md points at them, and a new record is a new file there so concurrent hall tasks stop colliding on one insertion point, task 1003499), public/{index.html,builders.js,style.css,work,watch,gate,settings,ranks,sessions,primer,diagrams,atlas,...}.{html,js},cursors/,chime.wav. Four ARCHETYPE sheets sit beside the per-page ones: `record.css` (the task + idea records, task 1003305), `room.css` (the READING ROOMS — the primer + the diagrams index, task 1003306: the reading measure and rhythm, the table of contents, the progress hairline and the framed figure diagram-viewer.js upgrades on both pages), `oversight.css` (the OVERSIGHT family — government, watch, gate, task 1003469 (harbor was its fourth until task 1003890 removed the dev box): the row, the figure band inside a panel, the group head, the inline form, the grant toggle and the shared confirm modal; named oversight because the vocabulary wall bans "governance" on the government surface) and `panel.css` (the PANEL family — settings, profile, task 1003308 (pair was its third until task 1003890 removed the dev box): the row, the switch, the field with its label above, the chip control, the four voices and the sign-in gate; the hall's my-projects page was DELETED in the same task — "my projects" is the hub's user-level page, and the hall keeps no link or redirect; the family's fourth page `not-ready.html` was deleted by task 1003450, along with the emptied `requireNonXenosPage` gate that was the only thing redirecting to it). A page sheet then holds only what one page has and the others do not — `primer.css` the acknowledge gate, `diagrams.css` the legend strip, `gate.css` the hold reasons and file chips, `watch.css` the severities, the report reader and the grade trend, `settings.css` the rail layout, the group head, the token ladder and the voices grid, `profile.css` the identity band and the shelf, `roster.css` the Builders roster's identity cell (was `people.css`, shared with the cross-project `/people` directory until task 1003444 deleted that page — `/people` and its legacy door `/scouting` now 302 to `/roster`), `thinking.css` THE SKY's one sheet (task 1004231 / sky part 3 — `thinking.html` at `/thinking` is the prototype the owner adopted as the design of record, `docs/design/mocks/sky/`, built over `GET /sky` + `GET /sky/mine`; its script is the seven `sky-*.js` files on one shared `window.OTBSky`; it replaced THE ROOM of task 1004098, which survives as the list drawer, and the old `/sky` page, whose URL 301s here; `idea-objects.js` beside it is the shared five-object vocabulary).
138
138
  │ ├── lifecycle/ ← CORE-DOMAIN carve (BV1.R86, default-on): the build state machine — tasks · claims · versions · done-when · goals · dependencies · claim→ship→grade→credit · github-push + merge-lock + conflict-resolve + publish-reconciler. The LAST, most-coupled carve; registers the `lifecycle` kernel port (createTask, classifyTaskKind, tallyPeerVotes, work-tracking reads). CLAUDE.md, module.json, routes/{tasks,claims,versions,done-when,goals,dependencies,gate-approvals,analytics}.js, lifecycle.js, github-push.js, merge-lock.js, conflict-resolve.js, publish-reconciler.js, task-visuals.js + task-visual-db.js + routes/visuals.js (the optional ship-time visual, task 1003109 — the route file is NOT named task-*.js on purpose; see the module CLAUDE.md).
139
139
  │ ├── copy-desk/ ← CORE-DOMAIN module (task 1003113 / R02 of goal 1000074, default-on): the COPY half of the design contract — ADR 0081 gave design TOKENS a repo-owned source of truth, text had none. Owns the FLAG (anyone in any role marks a string or a whole surface as needing an artist's attention, with a reason required in BOTH the route and a CHECK constraint) and the QUEUE an artist reads it from. READS R01's committed registry (docs/copy-registry.json, generated by scripts/gds/copy-inventory.js) and never writes it. NO LIVE CMS is the hard non-goal: no column here could hold replacement text, and copy_no_cms.mjs pins that. Key is `copy-desk`, not `copy` — the branding contract already owns a `copy` block, so the bare word reds the "core carries no module code" fitness check. R03 (task 1003114, ADR 0233) added the PROPOSAL: an artist writes the replacement wording and it becomes a Bongos task carrying a fenced copy-proposal patch — no row, NO second migration (a proposals table is the forbidden column with extra steps), and scripts/gds/copy-apply.js lands it as a real diff under a claim. CLAUDE.md, module.json, registry.js, flags.js, proposals.js, routes/copy-desk.js, migrations/copy_desk_001_flags.sql, tests/{copy_flags,copy_queue,copy_no_cms,copy_proposals}.mjs. Its hall surface lives with hall-ui (public/copy-desk.{html,js,css}), per the one-key-per-web-surface precedent.
140
140
  │ ├── npm-release/ ← the deploy page for a project that publishes an npm package from GitHub (task 1002622, default-OFF — on only in the Bongos hall, which publishes @bongos/core). "Where the work is": every task finished in the last 14 days, placed in the stage it has actually reached (not merged · merged, no version yet · published, not running here · running here, not released · released · in no version), plus the version history — each version's tasks. The version↔task join reads the release-notes.json the package itself carries (scripts/gds/release-notes.js writes it at pack time), streamed out of the NEWEST version's npm tarball in-process (no child process, no sync call) when the running core's own copy stops short. module.json, work.js (the reading + the stage placement — the packument, tarball stream and tar reader it uses are the core's src/bongos/package-registry.js, reached through the doorway's readPackageRegistry since task 1004296), routes/work.js (GET /npm-release/work, gated core.pin.move like the page), public/work.{js,css} (the two sections /deploy lends it: #nr-work, #nr-versions). Task 1004301 added the same line on a task's own page: routes/task-where.js (GET /npm-release/task/:id, any signed-in builder — it shows nothing not already public) + public/task-widget.js, injected into task.html by the loader's uiSections seam (hallWidgetScripts file task-widget.js) to fill #task-contrib on otb:task-shown. Tests sit at the repo root — tests/npm_release_{work,page}.mjs — because a default-off module's own tests/ is skipped by the unit runner.
@@ -377,8 +377,7 @@ tests/
377
377
  ├── canonical_profile_url.mjs ← (task 1002804, goal 1000045 criterion C1, ADR 0214): the P08 proof — the canonical profile URL is resolved in ONE place, `handle` reaches `GET /builders/:id/profile` under the EXISTENCE predicate (so a hidden builder keeps their address), absent/provisional/private are BYTE-IDENTICAL so the numeric-id redirect cannot become an existence oracle, and the widened response still leaks no private column. Real core router over a WHERE-derived fake pool on a real socket; the real browser script in a vm above a stub DOM, run twice — as shipped (every surface dark, because `/u/<handle>` still 404s) and with the liveness constant flipped, so the three live behaviours are proved rather than deferred. Asserts its own DB-free lane membership. DB-free `unit` lane.
378
378
  ├── application_lifecycle.mjs ← (task 1002284, goal 1000045, ADR 0201 D1/D3.2/D4 + ADR 0208): the application lifecycle EXECUTED — apply, list-pending, approve, decline, RESCIND (`invited → dismissed`), idempotency on the state already held, and the two things that must never happen: a builder without `access_request.review` moving the queue, and a VOUCH buying admission. Real router, REAL `requirePermission` resolving against the real government catalog's rank seed, and the REAL `hasInvitedAccessRequest` from `src/bongos/db-kernel.js` pointed at the same in-memory `access_requests` table (a stubbed `./pool`), whose filter is built from each statement's own WHERE clause and which refuses a predicate it cannot read. The no-authority proof seeds a vouched row and asserts the approve path and the admission read are byte-identical to the unvouched twin, and that no statement on either path names `applicant_github_id`/`vouched_at`. DB-free `unit` lane.
379
379
  ├── application_echo.mjs ← (task 1002284, goal 1000045, ADR 0201 D1.3/D1.4): the hub's STATUSLESS echo of an application. Part A pins the domain functions — the read binds `APPLICATION_ECHO_WINDOW_DAYS` itself (one 30, not three), the write is an upsert on the pair, neither statement mentions a status, and a non-numeric principal is refused before any query (ADR 0171 D2). Part B runs the real `POST /my-projects/join` and `GET /my-projects` over an in-memory `platform_identity_application_echoes` table with the relay's answer scripted: only an accepted application leaves a trace — a standing membership, either door refusal, a members-only project and an unreachable project leave none — and best-effort is asserted in both directions (a failed echo write still reports the filed application; a failed echo read still lands the memberships listing). DB-free `unit` lane.
380
- ├── scouting_owner_access_and_reach.mjs ← (task 1002290, goal 1000045 C5, ADR 0210): the R14 proof — WHO may read the cross-project recruiting directory, and WHO appears in it. A project owner (an instance-asserted, hub-witnessed `membership_kind='owner'`) reads `GET /scouting` with no rank at all; a builder who is neither owner nor `project.curate` holder gets the unchanged 403; an un-witnessed owner claim is refused; a broken ownership read falls closed onto the permission gate rather than admitting or 500ing. And the recruiting opt-out finally bites: a public, unhidden, active account that opted out is absent from `/scouting` and the hall's `/people` (the same endpoint), returns when it opts back in, and the opt-out grants and denies no permission — an opted-out owner still reads the directory while being absent from their own copy of it. Since task 1002291 (R15, ADR 0334) it also proves the directory's NAME-ONLY tier: a hidden, reachable builder is listed by login, name and avatar alone, never ranked, and none of their record reaches the body. Fake pool filtered by each statement's own WHERE clause, real route, real `requirePermission` over the real government catalog, real HTTP. Mutation-checked five ways. DB-free `unit` lane. The page half is `people_name_only.mjs` (the real people.js + kit in a vm).
381
- ├── scouting_page_gate.mjs ← (task 1002290, goal 1000045 C5, ADR 0210 D3): the OTHER half of R14 — the `/builders/people` SHELL must admit exactly the two audiences `GET /scouting` admits. A project owner holding no permission reaches it; everyone the route refuses gets the byte-identical `?denied=project.curate` redirect; and each of six ways the `alsoAdmits` door can fail (module absent, function absent, throw, reject, non-boolean, prototype-only) fails CLOSED. Also pins that the other four page gates never consult the seam at all, and that exactly ONE gate declares a second door. Its own file because the route proof's ESM imports pull `auth.js` in before a session double could be installed — this one installs it first, the `government_page_gates.mjs` pattern. DB-free `unit` lane.
380
+ ├── scouting_owner_access_and_reach.mjs ← (task 1002290, goal 1000045 C5, ADR 0210): the R14 proof — WHO may read the cross-project recruiting directory, and WHO appears in it. A project owner (an instance-asserted, hub-witnessed `membership_kind='owner'`) reads `GET /scouting` with no rank at all; a builder who is neither owner nor `project.curate` holder gets the unchanged 403; an un-witnessed owner claim is refused; a broken ownership read falls closed onto the permission gate rather than admitting or 500ing. And the recruiting opt-out finally bites: a public, unhidden, active account that opted out is absent from `/scouting`, returns when it opts back in, and the opt-out grants and denies no permission — an opted-out owner still reads the directory while being absent from their own copy of it. Since task 1002291 (R15, ADR 0334) it also proves the directory's NAME-ONLY tier: a hidden, reachable builder is listed by login, name and avatar alone, never ranked, and none of their record reaches the body. Fake pool filtered by each statement's own WHERE clause, real route, real `requirePermission` over the real government catalog, real HTTP. Mutation-checked five ways. DB-free `unit` lane.
382
381
  ├── terms_acceptance.mjs ← (task 1002913, goal 1000045, ADR 0213): the platform T&C acceptance record. Proves the four properties that make it evidence rather than a claim — the /terms page carries the cross-project consent ADR 0141 §4 deferred to it and it is apex-only; acceptance is written ONLY by the hub's own /sso/authorize (driven over the real router with an attacker-controlled query string, so a forged timestamp/version/activate flag reaches nothing); it is written ONCE (the fake pool interprets the upsert's own column assignments, so COALESCE is executed, not string-matched, and an assignment shape it cannot read throws instead of reading as "unchanged"); and consent is two columns in the shared predicate, so an account the hub witnessed before the terms existed publishes nothing. Plus the projection rule — every read whose row a JS twin judges must select both consent columns, or the twin denies everyone silently. DB-free `unit` lane, asserts its own lane membership.
383
382
  ├── applicant_profile_boundary.mjs ← (task 1002972, goal 1000045 C4, privacy spec D7, ADR 0241): THE D7 BOUNDARY proof for the applicant view a reviewer sees. Whitelists read from the MODULES’ own exports — the private branch against `recruiter-sliver`’s `SLIVER_KEYS`/`SLIVER_KEYS_HIDDEN`, the public branch against the public-page payload’s key list — so a third shape (the “new disclosure class” D7 forbids) reds this file. Pins THE CONSENT SWAP in both directions on the same account: a private builder who never opted into recruiting gets an applicant view and gets `null` from `getRecruiterSliver`, so a refactor routing the port through the recruiter sliver cannot pass quietly. `hide_stats` beats the application in both branches; provisional and terms-never-accepted disclose nothing; planted sentinels asserted absent from the whole body with the avatar-embedded `github_id` pinned as the one honest exception. Plus the federated gate (ADR 0205): no echo → 404 and NO account row read (asserted on the statement log), the witness matched against the AUTHENTICATED client, an out-of-window echo stops working, and `Cache-Control: no-store`. Real router + real HTTP over in-memory tables filtered by each statement’s own WHERE clause. DB-free `unit` lane, asserted in the file itself.
384
383
  ├── recruiter_sliver_boundary.mjs ← (task 1002968, goal 1000045 C2/C5, privacy spec D3, ADR 0215): THE PRE-ACCEPT BOUNDARY proof for the D3 recruiter sliver. A WHITELIST over the module's own exported key list (a sixth key fails whether or not this file was told about it), planted improbable sentinels asserted absent from the WHOLE response body with two controls against vacuity, the ceiling assertion (the handle a recruiter is handed 404s at `GET /profiles/:handle`, with a 200 control) and the gate (both recruiting routes admit the same two audiences and refuse byte-identically). Real router + real HTTP over an in-memory table filtered by each statement's own WHERE clause. DB-free `unit` lane, asserted in the file itself.
@@ -2575,5 +2575,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2575
2575
  landed since 1.19.1042 with no explicit bump. run 36258640780. (task 1002620)
2576
2576
  1.19.1044 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2577
2577
  landed since 1.19.1043 with no explicit bump. run 36260641494. (task 1002620)
2578
+ 1.19.1045 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2579
+ landed since 1.19.1044 with no explicit bump. run 36263855490. (task 1002620)
2580
+ 1.19.1046 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2581
+ landed since 1.19.1045 with no explicit bump. run 36265788618. (task 1002620)
2578
2582
  ---------------------------------------------------------------------------
2579
2583
  ```
@@ -212,7 +212,7 @@ caught a real bug during task 1003548, so read its assertion before blaming the
212
212
 
213
213
  ## CRLF: a multi-line find silently misses, and a whole-file diff is usually line endings
214
214
 
215
- - Several hall files (`people.css`, `people.html`, `style.css`, the privacy spec) and every `.claude/skills/*/SKILL.md` are CRLF; `scripts/gds/*.js` and the tests are LF. An exact-string patch must detect the file's EOL before joining lines (`text.includes('\r\n') ? '\r\n' : '\n'`), or the find matches nothing and the edit is a silent no-op (session log 2026-08-24).
215
+ - Several hall files (`roster.css`, `roster.html`, `style.css`, the privacy spec) and every `.claude/skills/*/SKILL.md` are CRLF; `scripts/gds/*.js` and the tests are LF. An exact-string patch must detect the file's EOL before joining lines (`text.includes('\r\n') ? '\r\n' : '\n'`), or the find matches nothing and the edit is a silent no-op (session log 2026-08-24).
216
216
  - A `git diff --stat` that touches every line of a file you barely edited is an EOL flip — check with `git diff --stat --ignore-cr-at-eol`, and restore the original EOL before committing.
217
217
  - **`copy-inventory.js --check` no longer reds on line endings alone** (task 1003619). It used to: the generator emits LF and the check byte-compared that against a CRLF worktree file, so `/ship-check` showed one permanent red line on every Windows checkout while the committed content was already correct — and a builder who "fixed" it committed a whole-file CRLF diff. `docs/copy-inventory.md` is now pinned `text eol=lf` in `.gitattributes` (its `.json` sibling always was, via `*.json`), and the check compares EOL-blind so an already-CRLF worktree passes without a re-clone. If it reports STALE now, the content really is stale — regenerate, don't reach for `--ignore-cr-at-eol`. Since task 1003981 `*.md` IS pinned repo-wide (`*.md text eol=lf`), so a fresh clone gets LF markdown everywhere and a markdown reader no longer has to be CRLF-aware to be correct. An EXISTING Windows checkout keeps its CRLF copies until git rewrites them — the clean filter still normalises to LF on add, so `git status` stays quiet and nothing is broken; to convert in place, `git rm --cached -r . && git reset --hard`.
218
218
  - **A freshness check compares CONTENT, so route it through `sameContent()`** (`scripts/gds/gen-freshness.js`) rather than `===` on file bytes. A pin is the wrong lever on its own: regenerating never clears a byte-wise red, because the write lands LF and git converts it back on the next checkout. `tests/gen_freshness.mjs` enforces this for the four gated generators; `tests/api_client.mjs` was outside that guard and reported the client STALE forever on Windows until task 1003529 (`index.d.ts` and `README.md` are the two generated client files with no `eol=lf` pin).
@@ -91,7 +91,7 @@
91
91
 
92
92
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
93
93
 
94
- <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
94
+ <script src="/builders/shell.js?v=2026-09-26-nopeople"></script>
95
95
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
96
96
  <script src="/builders/palette.js" defer></script>
97
97
  <script src="/builders/atlas.js?v=2026-09-02-index" defer></script>
@@ -50,7 +50,7 @@
50
50
 
51
51
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
52
52
  chrome exists before the page script runs. -->
53
- <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
53
+ <script src="/builders/shell.js?v=2026-09-26-nopeople"></script>
54
54
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
55
55
  <script src="/builders/palette.js" defer></script>
56
56
  <!-- shared frontend toolkit (window.OTB — rank labels, number formats) + the
@@ -60,7 +60,7 @@
60
60
 
61
61
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
62
62
 
63
- <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
63
+ <script src="/builders/shell.js?v=2026-09-26-nopeople"></script>
64
64
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
65
65
  <script src="/builders/palette.js" defer></script>
66
66
  <script src="/builders/dom-utils.js?v=2026-06-16-board"></script>
@@ -0,0 +1,250 @@
1
+ // modules/hall-ui/public/claim-action.js — the hall's ONE claim/release action
2
+ // (task 1003458, from the board's own copy).
3
+ //
4
+ // WHY THIS EXISTS. /work owned the only working claim path in the hall, and the
5
+ // moment a second surface wanted a claim button it got one by copy-paste:
6
+ // goals-page.js's own comments still name the line ranges it lifted ("ported
7
+ // from work.js renderClaimable, work.js:345-394"). The copies then drifted, the
8
+ // way task-lib.js's header says they always do — by the time this was written
9
+ // the SAME refusal read "Someone else claimed that task first. The board will
10
+ // refresh." on /work and "… Reloading." on /goals, and the goals copy had
11
+ // quietly dropped the "Wait for it to ship." advice from TOUCHES_CONFLICT and
12
+ // "Pick another for now." from DEPS_NOT_SHIPPED. Neither copy had a single test.
13
+ //
14
+ // AND THE DRIFT HID A BIGGER HOLE. Both copies handled 8 of the server's refusal
15
+ // codes. modules/lifecycle/CLAUDE.md documents the claim gate's refusal chain in
16
+ // order, and REBASE_REQUIRED (#2), TASK_NOT_FOUND (#3), OPEN_BLOCKER (#5),
17
+ // GOAL_MEMBERSHIP_REQUIRED (#8) and MODULE_CONFLICT (#10) were in neither — so a
18
+ // builder who hit any of them, including the two most ordinary ones, got "The
19
+ // board could not complete that (409). Try again." tests/claim_action.mjs now
20
+ // reads db-claims.js and FAILS if the server grows a refusal this file has no
21
+ // sentence for, which is what makes "a new surface needs no new refusal
22
+ // handling" true rather than aspirational.
23
+ //
24
+ // Contract, mirrored from task-lib.js so load order only needs "before the page
25
+ // script": dependency-free (its own envelope readers, no window.OTB use), no
26
+ // eval-time DOM, everything hangs off one global, Node-require-safe so it can be
27
+ // unit-tested. The ACTION half takes its I/O injected — each board keeps its own
28
+ // postJSON (auth posture), toast and refresh — so this file never touches the
29
+ // network or the DOM itself.
30
+ //
31
+ // NOT here: task.js's friendlyActionError. That maps the LIFECYCLE refusals of
32
+ // POST /tasks/:id/ship and /confirm (task_not_confirmed, task_not_completed,
33
+ // verify_attestation_required) — a different route, a disjoint code set. Folding
34
+ // the two would make one switch that is wrong about half its cases.
35
+ (function () {
36
+ 'use strict';
37
+
38
+ // ADR 0116's envelope: { error: { code, message, details } }. Both readers fall
39
+ // back to the flat legacy shape, because a refusal that renders "[object
40
+ // Object]" is the defect task 1002462 was about and it must not come back.
41
+ function errCode(data) {
42
+ const e = data && data.error;
43
+ return (e && typeof e === 'object') ? e.code : e;
44
+ }
45
+ function errDetails(data) {
46
+ const e = data && data.error;
47
+ return (e && typeof e === 'object' && e.details) ? e.details : (data || {});
48
+ }
49
+ function errMessage(data) {
50
+ const e = data && data.error;
51
+ if (e && typeof e === 'object' && e.message) return e.message;
52
+ return (data && data.message) || '';
53
+ }
54
+
55
+ // Every code POST /claims, /claims/batch and /claims/:id/resolve can refuse
56
+ // with, in the order modules/lifecycle/CLAUDE.md documents the gate checking
57
+ // them. Keep this list in that order: it is the map a reader compares against.
58
+ function friendlyClaimError(status, data) {
59
+ const det = errDetails(data);
60
+ switch (errCode(data)) {
61
+ // 1. the builder, before anything about the task
62
+ case 'BUILDER_INACTIVE':
63
+ return 'Your account is inactive. Contact an Archon.';
64
+ // 2. ADR 0120 part 3 — one stale claim blocks every NEW claim, so the
65
+ // sentence has to name the way out or the builder just retries forever.
66
+ // The server sends its own hint; prefer it, since it names the task.
67
+ case 'REBASE_REQUIRED':
68
+ return errMessage(data)
69
+ || 'A task you hold needs rebasing before you can claim anything new. Run /strand-fix on it.';
70
+ // 3. the board is showing something the server no longer has
71
+ case 'TASK_NOT_FOUND':
72
+ return 'That task no longer exists. The board will refresh.';
73
+ case 'TASK_NOT_READY':
74
+ return 'That task is no longer ready to be claimed.';
75
+ // 5. task 1002650 — the feed filters these out, but a direct claim can
76
+ // still land on one, and "could not complete that" tells the builder
77
+ // nothing about a blocker they could go and read.
78
+ case 'OPEN_BLOCKER':
79
+ return 'That task is held by an open blocker. See /blockers for what it is waiting on.';
80
+ // 6 + 7 are two DIFFERENT axes (the permission queue vs the task's own
81
+ // requires_rank floor) and lifecycle/CLAUDE.md says never to collapse
82
+ // them — but from the builder's chair the remedy is identical, so they
83
+ // share one sentence rather than one case.
84
+ case 'rank_too_low_for_task':
85
+ case 'XENOS_RESTRICTED':
86
+ case 'INSUFFICIENT_RANK':
87
+ return 'Your rank is not yet high enough for that task.';
88
+ // 8. task 1003137. The refusal deliberately carries no title or scope for
89
+ // a private goal, so this must not invent one either.
90
+ case 'GOAL_MEMBERSHIP_REQUIRED':
91
+ return errMessage(data) || 'That task belongs to a goal you are not a member of.';
92
+ case 'DEPS_NOT_SHIPPED':
93
+ return 'That task waits on unshipped dependencies. Pick another for now.';
94
+ // 10. the module-level sibling of TOUCHES_CONFLICT, and a REFUSAL where
95
+ // TOUCHES_CONFLICT is only advisory.
96
+ case 'MODULE_CONFLICT':
97
+ return det.module_key
98
+ ? `That task shares module "${det.module_key}" with an active claim. Wait for it to ship.`
99
+ : 'That task shares a module with an active claim. Wait for it to ship.';
100
+ // 11. advisory since task 880 — it only refuses on the enforcing path.
101
+ case 'TOUCHES_CONFLICT': {
102
+ const titles = Array.isArray(det.conflicts) ? det.conflicts.map((c) => c.title).filter(Boolean) : [];
103
+ return titles.length
104
+ ? `That task overlaps an active claim: ${titles.join('; ')}. Wait for it to ship.`
105
+ : 'That task overlaps another active claim. Wait for it to ship.';
106
+ }
107
+ // 12. the 23505 backstop: anything unrecognised fails CLOSED onto this.
108
+ case 'ALREADY_CLAIMED':
109
+ return 'Someone else claimed that task first. The board will refresh.';
110
+ // route-layer gates, chained on POST /claims
111
+ case 'session_already_has_active_claim':
112
+ case 'session_already_has_bound_claim':
113
+ return 'This session already holds a claim. Ship or release it first.';
114
+ case 'newcomer_must_ship_first':
115
+ return det.what_to_do || 'Ship your current claim before opening another.';
116
+ // gateOneActiveClaimPerSession fails CLOSED: if it cannot read what this
117
+ // session holds it refuses rather than risk a second claim. Say that it is
118
+ // transient, or the builder reads it as a permission problem.
119
+ case 'session_claim_check_failed':
120
+ return 'Could not check what this session already holds. Try again in a moment.';
121
+ // release-side
122
+ case 'NOT_YOUR_CLAIM':
123
+ return 'That claim is not yours to release.';
124
+ case 'ALREADY_RESOLVED':
125
+ return 'That claim was already resolved. The board will refresh.';
126
+ default:
127
+ return `The board could not complete that (${status}). Try again.`;
128
+ }
129
+ }
130
+
131
+ // The action itself: the CALL, the refusal, and the refresh — the half the
132
+ // boards used to hand-roll four times each. `postJSON` must reject with
133
+ // { status, data } on a non-2xx (both boards' helpers already do) and may
134
+ // throw the sentinel 'redirecting to auth', which every caller swallows
135
+ // rather than toasting.
136
+ //
137
+ // What stays with the CALLER: its own button text, its re-entrancy guard, its
138
+ // confirm() wording, and its selection state. Those are page furniture and
139
+ // differ per surface; the network call and what a refusal MEANS do not. Each
140
+ // method answers { ok } (plus { redirected } on the auth sentinel) so a caller
141
+ // can re-enable its own button without re-reading the error.
142
+ //
143
+ // `refresh` is awaited after a success AND after a 409, because a 409 always
144
+ // means the board is showing something stale.
145
+ //
146
+ // `describeBatchReason` maps ONE item of a batch refusal (task-lib.js's
147
+ // friendlyBatchReason). Injected rather than imported to keep this file
148
+ // dependency-free; a caller that omits it gets the raw reason, never a crash.
149
+ function makeClaimAction({ postJSON, toast, refresh, API = '/api/bongos', describeBatchReason } = {}) {
150
+ const reason = describeBatchReason || ((f) => (f && f.reason) || 'not claimable');
151
+ const reload = async () => { if (refresh) await refresh(); };
152
+ const quiet = async () => { try { await reload(); } catch (_) { /* the toast already told them */ } };
153
+
154
+ // The batch endpoints are ALL-OR-NOTHING: a 409 batch_failed means nothing
155
+ // was claimed, so the toast has to name which tasks stopped it or the
156
+ // builder cannot tell what to deselect. task 1002462: this used to compare
157
+ // `err.data.error` (an object) to a string, so every batch refusal fell
158
+ // through to the generic line and the per-task reasons were dropped.
159
+ function batchRefusal(err) {
160
+ if (!(err && err.status === 409 && errCode(err.data) === 'batch_failed')) return null;
161
+ const failures = errDetails(err.data).failures;
162
+ const lines = Array.isArray(failures) ? failures.map((f) => `#${f.task_id} (${reason(f)})`) : [];
163
+ return lines.length
164
+ ? `The batch could not be claimed — ${lines.join('; ')}.`
165
+ : 'The batch could not be claimed. It was left untouched (all-or-nothing).';
166
+ }
167
+
168
+ async function run(fn, okMsg) {
169
+ try {
170
+ const out = await fn();
171
+ if (okMsg) toast(typeof okMsg === 'function' ? okMsg(out) : okMsg);
172
+ await reload();
173
+ return { ok: true, data: out };
174
+ } catch (err) {
175
+ if (err && err.message === 'redirecting to auth') return { ok: false, redirected: true };
176
+ const batch = batchRefusal(err);
177
+ toast(batch || friendlyClaimError(err && err.status, err && err.data));
178
+ if (batch || (err && err.status === 409)) await quiet();
179
+ return { ok: false, error: err };
180
+ }
181
+ }
182
+
183
+ // bind_session:false — a web claim has no working session, so leave it
184
+ // unbound (claims.js resolveCreatorSessionId) and the builder can ship it
185
+ // later from any of their own terminals.
186
+ const claimOne = (taskId) => postJSON(`${API}/claims`, { task_id: Number(taskId), bind_session: false });
187
+ // No DELETE /claims/:id exists — release is resolve(outcome=abandoned), the
188
+ // same path /builder-release uses, scoped server-side to the caller's own
189
+ // claim (NOT_YOUR_CLAIM).
190
+ const releaseOne = (claimId) => postJSON(`${API}/claims/${encodeURIComponent(claimId)}/resolve`, { outcome: 'abandoned' });
191
+
192
+ // THE DRY RUN. /claims/batch/validate asks the server what a bulk claim
193
+ // WOULD do, so a refusal can be shown before the click rather than after it.
194
+ // Best-effort by design: on any failure it answers "not parallel-safe, know
195
+ // nothing", because the caller uses this to decide whether to offer a
196
+ // parallel prompt, and copying a prompt for tasks nobody verified is worse
197
+ // than offering nothing. It therefore never throws and never toasts — an
198
+ // unavailable pre-check is not a refusal to report.
199
+ async function validateMany(ids) {
200
+ try {
201
+ const result = await postJSON(`${API}/claims/batch/validate`, { task_ids: ids });
202
+ return {
203
+ ok: true,
204
+ parallelSafe: !!result.parallel_safe,
205
+ byId: new Map((result.results || []).map((r) => [String(r.task_id), r])),
206
+ };
207
+ } catch (_) {
208
+ return { ok: false, parallelSafe: false, byId: new Map() };
209
+ }
210
+ }
211
+
212
+ return {
213
+ validateMany,
214
+ claim: (taskId) => run(() => claimOne(taskId), `You claimed #${taskId}. Open a terminal to start work.`),
215
+ release: (claimId, taskId) => run(() => releaseOne(claimId), `Released #${taskId}. It returns to the queue.`),
216
+
217
+ // One id takes the SINGLE endpoint, not a batch of one: the batch route is
218
+ // all-or-nothing and reports per-item reasons, which is the wrong shape
219
+ // (and the wrong sentence) for a selection of one.
220
+ claimMany: (ids) => run(
221
+ () => (ids.length === 1 ? claimOne(ids[0]) : postJSON(`${API}/claims/batch`, { task_ids: ids, bind_session: false })),
222
+ ids.length === 1
223
+ ? `You claimed #${ids[0]}. Open a terminal to start work.`
224
+ : `Claimed ${ids.length} tasks. Open a terminal to start work.`
225
+ ),
226
+
227
+ // entries: [claimId, taskId] pairs. The batch release is NOT all-or-nothing
228
+ // — it answers released[] + failures[] — so its sentence counts rather
229
+ // than lists.
230
+ releaseMany: (entries) => run(
231
+ () => (entries.length === 1
232
+ ? releaseOne(entries[0][0])
233
+ : postJSON(`${API}/claims/batch/release`, { claim_ids: entries.map(([cid]) => Number(cid)) })),
234
+ entries.length === 1
235
+ ? `Released #${entries[0][1]}. It returns to the queue.`
236
+ : (result) => {
237
+ const released = Array.isArray(result && result.released) ? result.released.length : 0;
238
+ const failed = Array.isArray(result && result.failures) ? result.failures.length : 0;
239
+ return failed
240
+ ? `Released ${released} of ${entries.length} — ${failed} could not be released.`
241
+ : `Released ${released} tasks. They return to the queue.`;
242
+ }
243
+ ),
244
+ };
245
+ }
246
+
247
+ const api = { errCode, errDetails, errMessage, friendlyClaimError, makeClaimAction };
248
+ if (typeof window !== 'undefined') window.OTBClaimAction = api;
249
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
250
+ })();
@@ -108,7 +108,7 @@
108
108
 
109
109
  /* ---- the face -----------------------------------------------------------------
110
110
  A row leads with WHO: a 36px disc, the picture when the server sent one, the
111
- initial on the glass when it did not (people.css's .dir-avatar, at the row's
111
+ initial on the glass when it did not (roster.css's .dir-avatar, at the row's
112
112
  size). A craft ask wears the craft's letter in the label face, so it never
113
113
  passes for a person. Decorative — the name beside it is the door. */
114
114
  .cb-face {
@@ -148,7 +148,7 @@
148
148
 
149
149
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
150
150
 
151
- <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
151
+ <script src="/builders/shell.js?v=2026-09-26-nopeople"></script>
152
152
  <script src="/builders/palette.js" defer></script>
153
153
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
154
154
  <script src="/builders/collab-lib.js?v=2026-09-24-archive"></script>
@@ -177,7 +177,7 @@
177
177
 
178
178
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
179
179
 
180
- <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
180
+ <script src="/builders/shell.js?v=2026-09-26-nopeople"></script>
181
181
  <script src="/builders/palette.js" defer></script>
182
182
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
183
183
  <script src="/builders/hall-kit.js"></script>
@@ -69,7 +69,7 @@
69
69
 
70
70
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
71
71
 
72
- <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
72
+ <script src="/builders/shell.js?v=2026-09-26-nopeople"></script>
73
73
  <script src="/builders/palette.js" defer></script>
74
74
  <script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
75
75
  <script src="/builders/hall-kit.js"></script>
@@ -97,7 +97,7 @@
97
97
 
98
98
  <!-- The app shell (sidebar + top bar + footer). Dependency-free; runs at parse
99
99
  time so the chrome exists before the deferred page scripts execute. -->
100
- <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
100
+ <script src="/builders/shell.js?v=2026-09-26-nopeople"></script>
101
101
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
102
102
  <script src="/builders/palette.js" defer></script>
103
103
  <script src="/builders/diagram-viewer.js?v=2026-06-14-diagviewer-1" defer></script>
@@ -98,7 +98,7 @@
98
98
 
99
99
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
100
100
 
101
- <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
101
+ <script src="/builders/shell.js?v=2026-09-26-nopeople"></script>
102
102
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
103
103
  <script src="/builders/palette.js" defer></script>
104
104
  <script src="/builders/md-reader.js?v=2026-09-02-mdreader-2"></script>
@@ -54,7 +54,7 @@
54
54
 
55
55
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
56
56
 
57
- <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
57
+ <script src="/builders/shell.js?v=2026-09-26-nopeople"></script>
58
58
  <script src="/builders/palette.js" defer></script>
59
59
  <script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
60
60
  <script src="/builders/hall-kit.js"></script>
@@ -76,7 +76,7 @@
76
76
  <div id="gate-modal-root"></div>
77
77
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
78
78
 
79
- <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
79
+ <script src="/builders/shell.js?v=2026-09-26-nopeople"></script>
80
80
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
81
81
  <script src="/builders/palette.js" defer></script>
82
82
  <script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
@@ -23,6 +23,8 @@
23
23
  (function () {
24
24
  'use strict';
25
25
  const { escapeHtml, $, fmtNum, fmtDate, toast, rankLabel } = window.OTB;
26
+ // task 1003458 — the hall's one claim/release action.
27
+ const { makeClaimAction } = window.OTBClaimAction;
26
28
  // Shared task-domain helpers (task 1002463). These lived in two copies here and
27
29
  // in the other board and had already drifted — goals-page.js had silently lost
28
30
  // friendlyBatchReason's MODULE_CONFLICT case. Destructured so every call site
@@ -110,6 +112,18 @@
110
112
  if (!res.ok) { const e = new Error(`${path} → ${res.status}`); e.status = res.status; e.data = data; throw e; }
111
113
  return data;
112
114
  }
115
+
116
+ // The hall's ONE claim/release action (task 1003458). This page's three
117
+ // claim/release functions used to be hand-copied from work.js — their own
118
+ // comments still say so — and the copies drifted. They now keep only what is
119
+ // genuinely this page (button text, the pending guard, the confirm wording,
120
+ // the goal selection) and hand the call, the refusal and the refresh here.
121
+ // Refreshing the open goal is this surface's equivalent of the board's
122
+ // refreshLive; with no goal open there is no task list to reload.
123
+ const claimAction = makeClaimAction({
124
+ postJSON, toast, API, describeBatchReason: friendlyBatchReason,
125
+ refresh: async () => { if (state.current != null) await loadGoalTasks(state.current); },
126
+ });
113
127
  // task 1953 — the visibility toggle is the only PATCH this page makes.
114
128
  async function patchJSON(path, body) {
115
129
  const res = await api.request('PATCH', path, { body: body || {} });
@@ -419,35 +433,17 @@
419
433
  const btn = $('#goal-bulk-claim-btn');
420
434
  if (btn) { btn.disabled = true; btn.textContent = 'Claiming…'; }
421
435
  try {
422
- if (ids.length === 1) {
423
- await postJSON(`${API}/claims`, { task_id: ids[0], bind_session: false });
424
- toast(`You claimed #${ids[0]}. Open a terminal to start work.`);
425
- } else {
426
- await postJSON(`${API}/claims/batch`, { task_ids: ids, bind_session: false });
427
- toast(`Claimed ${ids.length} tasks. Open a terminal to start work.`);
428
- }
429
- goalSel.claim.clear();
430
- } catch (err) {
431
- if (err && err.message === 'redirecting to auth') return;
432
- // claims/batch is all-or-nothing: a 409 batch_failed means NOTHING was
433
- // claimed. Read failures from the ADR 0116 envelope (error.details.failures)
434
- // or the flat legacy shape, via the same normalizers friendlyClaimError uses.
435
- const det = errDetails(err.data);
436
- if (err.status === 409 && errCode(err.data) === 'batch_failed') {
437
- // One read covers both envelopes: errDetails returns error.details on the ADR
438
- // 0116 shape and the raw body on the flat legacy one, so a second
439
- // `err.data.failures` fallback here is unreachable (task 1003657).
440
- const failures = Array.isArray(det.failures) ? det.failures : [];
441
- const lines = failures.map((f) => `#${f.task_id} (${friendlyBatchReason(f)})`);
442
- toast(lines.length
443
- ? `The batch could not be claimed — ${lines.join('; ')}.`
444
- : 'The batch could not be claimed. It was left untouched (all-or-nothing).');
445
- } else {
446
- toast(friendlyClaimError(err.status, err.data));
447
- }
436
+ const out = await claimAction.claimMany(ids);
437
+ if (out.ok) goalSel.claim.clear();
448
438
  } finally {
449
- if (state.current != null) await loadGoalTasks(state.current);
450
- else renderGoalBulkToolbar();
439
+ // The shared action already reloaded the open goal's task list, on the
440
+ // success path AND the 409 one — re-fetching here would make every bulk
441
+ // claim on this page fetch and re-render twice. work.js's doBulkClaim is
442
+ // the right shape: redraw the toolbar, nothing else. It has to happen on
443
+ // every path, because the button was disabled above and the paths where
444
+ // no reload runs (the auth redirect, a refresh that failed quietly) have
445
+ // nothing else to re-enable it.
446
+ renderGoalBulkToolbar();
451
447
  }
452
448
  }
453
449
 
@@ -517,15 +513,8 @@
517
513
  pendingClaims.add(key);
518
514
  if (btn) { btn.disabled = true; btn.textContent = 'Claiming…'; }
519
515
  try {
520
- // bind_session:false — a web claim has no working session (matches the
521
- // Work Board), so the builder can ship it later from any terminal.
522
- await postJSON(`${API}/claims`, { task_id: Number(taskId), bind_session: false });
523
- toast(`You claimed #${taskId}. Open a terminal to start work.`);
524
- if (state.current != null) await loadGoalTasks(state.current);
525
- } catch (err) {
526
- if (err && err.message === 'redirecting to auth') return;
527
- toast(friendlyClaimError(err.status, err.data));
528
- if (btn) { btn.disabled = false; btn.textContent = 'Claim'; }
516
+ const out = await claimAction.claim(taskId);
517
+ if (!out.ok && !out.redirected && btn) { btn.disabled = false; btn.textContent = 'Claim'; }
529
518
  } finally {
530
519
  pendingClaims.delete(key);
531
520
  }
@@ -542,13 +531,8 @@
542
531
  pendingClaims.add(key);
543
532
  if (btn) { btn.disabled = true; btn.textContent = 'Releasing…'; }
544
533
  try {
545
- await postJSON(`${API}/claims/${encodeURIComponent(claimId)}/resolve`, { outcome: 'abandoned' });
546
- toast(`Released #${taskId}. It returns to the queue.`);
547
- if (state.current != null) await loadGoalTasks(state.current);
548
- } catch (err) {
549
- if (err && err.message === 'redirecting to auth') return;
550
- toast(friendlyClaimError(err.status, err.data));
551
- if (btn) { btn.disabled = false; btn.textContent = 'Release'; }
534
+ const out = await claimAction.release(claimId, taskId);
535
+ if (!out.ok && !out.redirected && btn) { btn.disabled = false; btn.textContent = 'Release'; }
552
536
  } finally {
553
537
  pendingClaims.delete(key);
554
538
  }
@@ -557,34 +557,13 @@
557
557
  const shown = paginate(list, pagers.todo);
558
558
  listEl.innerHTML = shown.map(goalTaskCardHtml).join('');
559
559
  }
560
- function errCode(data) {
561
- const e = data && data.error;
562
- return (e && typeof e === 'object') ? e.code : e;
563
- }
564
- function errDetails(data) {
565
- const e = data && data.error;
566
- return (e && typeof e === 'object' && e.details) ? e.details : (data || {});
567
- }
568
- function friendlyClaimError(status, data) {
569
- const det = errDetails(data);
570
- switch (errCode(data)) {
571
- case 'ALREADY_CLAIMED': return 'Someone else claimed that task first. Reloading.';
572
- case 'TASK_NOT_READY': return 'That task is no longer ready to be claimed.';
573
- case 'TOUCHES_CONFLICT': {
574
- const titles = Array.isArray(det.conflicts) ? det.conflicts.map((c) => c.title).filter(Boolean) : [];
575
- return titles.length ? `That task overlaps an active claim: ${titles.join('; ')}.` : 'That task overlaps another active claim.';
576
- }
577
- case 'DEPS_NOT_SHIPPED': return 'That task waits on unshipped dependencies.';
578
- case 'rank_too_low_for_task':
579
- case 'XENOS_RESTRICTED':
580
- case 'INSUFFICIENT_RANK': return 'Your rank is not yet high enough for that task.';
581
- case 'newcomer_must_ship_first': return det.what_to_do || 'Ship your current claim before opening another.';
582
- case 'NOT_YOUR_CLAIM': return 'That claim is not yours to release.';
583
- case 'ALREADY_RESOLVED': return 'That claim was already resolved. Reloading.';
584
- case 'BUILDER_INACTIVE': return 'Your account is inactive. Contact an Archon.';
585
- default: return `The board could not do that (${status}). Try again.`;
586
- }
587
- }
560
+ // The hall's ONE claim/release refusal copy + envelope readers (task 1003458).
561
+ // This file used to carry its own errCode/errDetails and a friendlyClaimError
562
+ // ported from work.js — and the two had drifted: the same refusal said
563
+ // "Reloading." here and "The board will refresh." there, and this copy had
564
+ // lost the "Wait for it to ship." / "Pick another for now." advice entirely.
565
+ // One definition now, in claim-action.js, complete against the server's chain.
566
+ const { errCode, errDetails, friendlyClaimError } = window.OTBClaimAction;
588
567
  function conflictHtml(c) {
589
568
  const sev = c.severity
590
569
  ? `<span class="conflict__sev conflict__sev--${escapeHtml(c.severity)}">${escapeHtml(c.severity)}</span>`