@bongos/core 1.19.1043 → 1.19.1045

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.
@@ -2573,5 +2573,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2573
2573
  landed since 1.19.1041 with no explicit bump. run 36254852933. (task 1002620)
2574
2574
  1.19.1043 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2575
2575
  landed since 1.19.1042 with no explicit bump. run 36258640780. (task 1002620)
2576
+ 1.19.1044 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
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)
2576
2580
  ---------------------------------------------------------------------------
2577
2581
  ```
@@ -0,0 +1,84 @@
1
+ -- core_256_blocker_unlink_releases_task.sql — unlinking a blocker from a task
2
+ -- must release that task when it was the last thing holding it (task 1003518).
3
+ --
4
+ -- THE GAP. `POST /blockers/:id/link` parks a 'ready' task in 'blocked' so the
5
+ -- resolve trigger (009, tightened by core_223) has something to promote. Nothing
6
+ -- removed a link. Post-core_223 a task is released only when its LAST open
7
+ -- blocker resolves, so a wrong link — wrong task id, a duplicate filed by two
8
+ -- people, a blocker later understood to be irrelevant — was permanent. The only
9
+ -- escape was to resolve a blocker that was never real, putting a falsehood in
10
+ -- the blocker ledger to clear a bookkeeping artifact. core_223's own header
11
+ -- names this: "there is no unlink route to detach one first."
12
+ --
13
+ -- WHY A TRIGGER AND NOT THE ROUTE. tests/blocker_resolve_guard.mjs pins that
14
+ -- modules/ideas/blockers.js never writes `tasks.status = 'ready'` itself: "if it
15
+ -- ever started UPDATE-ing tasks.status, this guard would be bypassable and the
16
+ -- fix would need to live in two places." An unlink that promoted in JS would be
17
+ -- exactly that second place — a hand-copy of the predicate below, free to drift
18
+ -- from it. Putting the release here keeps ONE definition of "this task has
19
+ -- nothing open against it any more", and has a second payoff: the direct
20
+ -- `DELETE FROM task_blockers` that was the only workaround before this task now
21
+ -- does the right thing too, instead of silently leaving the task stranded.
22
+ --
23
+ -- NO `b.id <> …` EXCLUSION, AND THAT IS THE DIFFERENCE FROM core_223. That
24
+ -- migration runs in a BEFORE UPDATE trigger, where the blocker being resolved
25
+ -- still reads 'open' in the table and would count itself as an open blocker —
26
+ -- so without the exclusion nothing would ever promote. This is an AFTER DELETE
27
+ -- trigger: the link row is already gone when the body runs, so the NOT EXISTS
28
+ -- already sees exactly the blockers that remain. Adding the exclusion here would
29
+ -- be cargo-cult; omitting it is deliberate.
30
+ --
31
+ -- Deliberately NOT gating on dependencies, for core_223's reason: the
32
+ -- dep-promotion triggers (163) only ever flip 'backlog' -> 'ready', never
33
+ -- 'blocked' -> 'ready', so a task this left in 'blocked' for unshipped deps
34
+ -- would have no release path at all. The claim path re-checks DEPS_NOT_SHIPPED
35
+ -- as its second line of defence. Blocker-clearing and dep-clearing stay separate.
36
+ --
37
+ -- ONLY 'blocked' IS REWRITTEN. A task that was never parked (link arrived while
38
+ -- it was active/backlog/shipped — see LINK_BLOCKABLE_STATUSES) is left exactly
39
+ -- as it is, so an unlink can never launder a task past the metic+archon promote
40
+ -- gate.
41
+ --
42
+ -- Legacy markdown-anchor links (a task_blockers row with blocker_ref and no
43
+ -- blocker_id) are out of scope, the same scope core_223 and 176 draw: the JOIN
44
+ -- below only sees structured links, so a legacy row neither blocks nor releases.
45
+ --
46
+ -- CASCADE SAFETY. task_blockers.task_id and .blocker_id are both ON DELETE
47
+ -- CASCADE. Nothing in the repo hard-deletes a task (abandon is terminal) or a
48
+ -- blocker (resolve is), so in practice this fires only on a real unlink. If a
49
+ -- blocker ever were deleted outright, releasing the tasks it held is the correct
50
+ -- reading — the thing that was blocking them no longer exists.
51
+ --
52
+ -- Idempotent: CREATE OR REPLACE for the function, DROP IF EXISTS + CREATE for
53
+ -- the trigger (a trigger has no CREATE OR REPLACE before PG 14, and this must
54
+ -- apply cleanly on a re-run).
55
+
56
+ BEGIN;
57
+
58
+ CREATE OR REPLACE FUNCTION trg_release_task_on_blocker_unlink()
59
+ RETURNS TRIGGER AS $$
60
+ BEGIN
61
+ UPDATE tasks
62
+ SET status = 'ready', updated_at = now()
63
+ WHERE id = OLD.task_id
64
+ AND status = 'blocked'
65
+ -- … and nothing open is left against this task (core_223's predicate,
66
+ -- minus the self-exclusion an AFTER DELETE trigger does not need).
67
+ AND NOT EXISTS (
68
+ SELECT 1
69
+ FROM task_blockers tb
70
+ JOIN blockers b ON b.id = tb.blocker_id
71
+ WHERE tb.task_id = OLD.task_id
72
+ AND b.status = 'open'
73
+ );
74
+ RETURN OLD;
75
+ END;
76
+ $$ LANGUAGE plpgsql;
77
+
78
+ DROP TRIGGER IF EXISTS release_task_on_blocker_unlink ON task_blockers;
79
+ CREATE TRIGGER release_task_on_blocker_unlink
80
+ AFTER DELETE ON task_blockers
81
+ FOR EACH ROW
82
+ EXECUTE FUNCTION trg_release_task_on_blocker_unlink();
83
+
84
+ COMMIT;
@@ -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
+ })();
@@ -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>`
@@ -243,6 +243,9 @@
243
243
  <script src="/builders/hall-kit.js"></script>
244
244
  <!-- shared TASK-DOMAIN helpers (window.OTBTaskLib) — priority/touches/prompt/refusal-copy, one copy for both boards (task 1002463). -->
245
245
  <script src="/builders/task-lib.js"></script>
246
+ <!-- task 1003458: the hall's ONE claim/release action + refusal copy (window.OTBClaimAction).
247
+ Before goals-render.js, which destructures errCode/errDetails/friendlyClaimError from it. -->
248
+ <script src="/builders/claim-action.js?v=2026-09-26-claimaction1"></script>
246
249
  <script src="/builders/task-detail.js?v=2026-07-03-taskdetail"></script>
247
250
  <script src="/builders/brand-apply.js?v=2026-06-27-r75"></script>
248
251
  <script src="/builders/goals-lib.js?v=2026-08-21-goalslib1"></script>
@@ -258,7 +261,7 @@
258
261
  <!-- task 1003206: the page RENDERERS, the second half of the carve goals-lib.js
259
262
  began — goals-lib took the pure helpers, this takes the view painters.
260
263
  Loaded BEFORE goals-page.js, which calls window.OTBGoalsRender(ctx). -->
261
- <script src="/builders/goals-render.js?v=2026-08-24-goalsrender1"></script>
262
- <script src="/builders/goals-page.js?v=2026-08-21-goals7"></script>
264
+ <script src="/builders/goals-render.js?v=2026-09-26-claimaction1"></script>
265
+ <script src="/builders/goals-page.js?v=2026-09-26-claimaction1"></script>
263
266
  </body>
264
267
  </html>
@@ -180,13 +180,16 @@
180
180
  <script src="/builders/hall-kit.js" defer></script>
181
181
  <!-- shared TASK-DOMAIN helpers (window.OTBTaskLib) — priority/touches/prompt/refusal-copy, one copy for both boards (task 1002463). -->
182
182
  <script src="/builders/task-lib.js" defer></script>
183
+ <!-- task 1003458: the hall's ONE claim/release action + refusal copy (window.OTBClaimAction).
184
+ Before work.js, which destructures friendlyClaimError from it. -->
185
+ <script src="/builders/claim-action.js?v=2026-09-26-claimaction1" defer></script>
183
186
  <!-- the hall's one definition of "mine" (window.OTBMineLens) — goals you are on, claims you hold, ships you made; shared with the goal board. -->
184
187
  <script src="/builders/mine-lens.js" defer></script>
185
188
  <!-- the Completed tab's project-wide momentum card (window.OTBWorkSummary), carved out of work.js. -->
186
189
  <script src="/builders/work-summary.js" defer></script>
187
190
  <script src="/builders/task-detail.js?v=2026-07-03-taskdetail" defer></script>
188
191
  <script src="/builders/brand-apply.js?v=2026-06-19-r56" defer></script>
189
- <script src="/builders/work.js?v=2026-07-03-workboard3" defer></script>
192
+ <script src="/builders/work.js?v=2026-09-26-claimaction1" defer></script>
190
193
  <script src="/builders/copy-on-click.js?v=2026-06-01-copy-1" defer></script>
191
194
  </body>
192
195
  </html>