@bongos/core 1.20.46 → 1.20.48

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 (68) hide show
  1. package/.bongos-core.json +103 -68
  2. package/docs/copy-inventory.md +230 -225
  3. package/docs/copy-registry.json +371 -286
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/page-inventory.json +7 -4
  6. package/docs/page-readings.json +248 -249
  7. package/modules/hall-ui/public/atlas.html +1 -1
  8. package/modules/hall-ui/public/blockers.html +1 -1
  9. package/modules/hall-ui/public/board-room.html +1 -1
  10. package/modules/hall-ui/public/bulk-toolbar.js +291 -0
  11. package/modules/hall-ui/public/collab.html +1 -1
  12. package/modules/hall-ui/public/commands.html +1 -1
  13. package/modules/hall-ui/public/copy-desk.html +1 -1
  14. package/modules/hall-ui/public/deploy.html +1 -1
  15. package/modules/hall-ui/public/diagrams.html +1 -1
  16. package/modules/hall-ui/public/drachmae.html +1 -1
  17. package/modules/hall-ui/public/fleet.html +1 -1
  18. package/modules/hall-ui/public/gate.html +1 -1
  19. package/modules/hall-ui/public/goals-page.js +29 -44
  20. package/modules/hall-ui/public/goals-render.js +33 -21
  21. package/modules/hall-ui/public/goals.html +13 -17
  22. package/modules/hall-ui/public/government.html +1 -1
  23. package/modules/hall-ui/public/hall-render.js +3 -0
  24. package/modules/hall-ui/public/idea.html +1 -1
  25. package/modules/hall-ui/public/ideas.html +1 -1
  26. package/modules/hall-ui/public/index.html +1 -1
  27. package/modules/hall-ui/public/modules.html +1 -1
  28. package/modules/hall-ui/public/primer.html +1 -1
  29. package/modules/hall-ui/public/profile.html +1 -1
  30. package/modules/hall-ui/public/project-settings.html +1 -1
  31. package/modules/hall-ui/public/ranks.html +1 -1
  32. package/modules/hall-ui/public/roadmap.html +1 -1
  33. package/modules/hall-ui/public/roster.html +1 -1
  34. package/modules/hall-ui/public/sessions.html +1 -1
  35. package/modules/hall-ui/public/settings.html +1 -1
  36. package/modules/hall-ui/public/shell.js +82 -1
  37. package/modules/hall-ui/public/studio.html +1 -1
  38. package/modules/hall-ui/public/style.css +110 -3
  39. package/modules/hall-ui/public/task.html +1 -1
  40. package/modules/hall-ui/public/thinking.html +1 -1
  41. package/modules/hall-ui/public/tweak-editor.html +1 -1
  42. package/modules/hall-ui/public/watch.html +1 -1
  43. package/modules/hall-ui/public/work-founding.states.json +39 -0
  44. package/modules/hall-ui/public/work.css +4 -66
  45. package/modules/hall-ui/public/work.html +3 -2
  46. package/modules/hall-ui/public/work.js +17 -236
  47. package/modules/hall-ui/records/founding-mode.md +31 -0
  48. package/modules/onboarding/founding-mode.js +185 -0
  49. package/modules/onboarding/port.js +7 -0
  50. package/modules/ui-design/kit/fixtures/me-founding.json +24 -0
  51. package/modules/ui-design/kit/serve.js +3 -1
  52. package/package-lock.json +2 -2
  53. package/package.json +1 -1
  54. package/release-notes.json +12 -0
  55. package/src/bongos/routes/me.js +18 -1
  56. package/src/module-api.js +1 -1
  57. package/tests/bulk_toolbar.mjs +202 -0
  58. package/tests/claim_action.mjs +13 -4
  59. package/tests/founding_mode.mjs +294 -0
  60. package/tests/goal_category_ui.mjs +1 -0
  61. package/tests/goal_criterion_confirm_ui.mjs +2 -0
  62. package/tests/goal_forest_page.mjs +1 -0
  63. package/tests/goal_invite_consent.mjs +1 -1
  64. package/tests/goal_mine_lens.mjs +1 -0
  65. package/tests/hall_board_world.mjs +1 -1
  66. package/tests/hall_mine_lens.mjs +1 -0
  67. package/tests/hall_tweak_editor.mjs +1 -1
  68. package/tests/nav_permission_atoms.mjs +1 -1
@@ -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-30-commands"></script>
94
+ <script src="/builders/shell.js?v=2026-09-30-founding-commands"></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-30-commands"></script>
53
+ <script src="/builders/shell.js?v=2026-09-30-founding-commands"></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-30-commands"></script>
63
+ <script src="/builders/shell.js?v=2026-09-30-founding-commands"></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,291 @@
1
+ // modules/hall-ui/public/bulk-toolbar.js — the hall's ONE bulk-action toolbar
2
+ // (task 1003141, carved from idea 1000568).
3
+ //
4
+ // WHAT IT IS. The bar that floats over a board once one or more task cards are
5
+ // ticked: Bulk claim, Bulk release, Copy parallel prompt, Copy sequential prompt,
6
+ // Clear. It lived inside work.js (task 1759), and the Goals page carried a
7
+ // claim-only subset copied from it. Now both pages build it from here, so the
8
+ // toolbar a builder meets on /work and on /goals is the same toolbar.
9
+ //
10
+ // FLAGS ARE ELEMENTS. A page asks for a button by putting it in its markup: no
11
+ // release button in the HTML means no bulk release on that surface, and the
12
+ // parallel / sequential prompts the same. The factory never assumes one exists.
13
+ //
14
+ // WHAT STAYS WITH THE PAGE. The selection itself (a Set of ready task ids and a
15
+ // Map of claim id → task id, both mutated by the page's own checkboxes), how
16
+ // "Clear" repaints the page's cards, and the claim call itself — which is
17
+ // OTBClaimAction's, never this file's (tests/claim_action.mjs fails if a hall
18
+ // script POSTs to /claims). This file only decides what the bar shows and
19
+ // what each button does with the selection.
20
+ //
21
+ // Pure helpers (topoOrder, the two prompt builders) are exported for a test to
22
+ // run directly; the factory is browser-only. Dependency-free, like task-lib.js.
23
+ (() => {
24
+ 'use strict';
25
+
26
+ // Kahn's algorithm restricted to edges BETWEEN the selected ids (an
27
+ // out-of-selection dependency doesn't block sequencing the selection itself).
28
+ // Returns the topological order, or null if the restricted graph has a cycle
29
+ // (can't be sequenced).
30
+ function topoOrder(ids, edgesByTask) {
31
+ const remaining = new Set(ids);
32
+ const order = [];
33
+ let progressed = true;
34
+ while (remaining.size && progressed) {
35
+ progressed = false;
36
+ for (const id of Array.from(remaining)) {
37
+ const deps = (edgesByTask.get(id) || []).filter((d) => remaining.has(d));
38
+ if (deps.length === 0) {
39
+ order.push(id);
40
+ remaining.delete(id);
41
+ progressed = true;
42
+ }
43
+ }
44
+ }
45
+ return remaining.size ? null : order;
46
+ }
47
+
48
+ function hallBase(loc) {
49
+ return loc.hostname.startsWith('builders.') ? loc.origin : `${loc.origin}/builders`;
50
+ }
51
+
52
+ // The paste-into-your-agent prompt for several tasks at once, one session each.
53
+ function buildBulkParallelPrompt(tasks, loc = location) {
54
+ const base = hallBase(loc);
55
+ const lines = [
56
+ `I want to work on ${tasks.length} Bongos tasks IN PARALLEL — open ${tasks.length} separate Claude Code sessions, each in its own worktree, one per task below, so they can run at the same time without touching each other's files.`,
57
+ '',
58
+ ];
59
+ tasks.forEach((t, i) => {
60
+ lines.push(`Session ${i + 1} — task ${t.id} (${t.title}) — ${base}#/task/${t.id}`);
61
+ lines.push(`Claim it with /builder-claim ${t.id}, follow its acceptance criteria, stay within touches[], ship via /builder-ship when done.`);
62
+ lines.push('');
63
+ });
64
+ return lines.join('\n').trim();
65
+ }
66
+
67
+ // The same, for one session working the tasks in dependency order.
68
+ function buildBulkSequentialPrompt(tasks, loc = location) {
69
+ const base = hallBase(loc);
70
+ const lines = [
71
+ `I want to work on ${tasks.length} Bongos tasks IN ORDER, one after another in this same session — claim, build, and ship each before moving to the next.`,
72
+ '',
73
+ ];
74
+ tasks.forEach((t, i) => {
75
+ lines.push(`${i + 1}. Task ${t.id} (${t.title}) — ${base}#/task/${t.id}`);
76
+ });
77
+ lines.push('');
78
+ lines.push('For each: /builder-claim <id>, follow its acceptance criteria, stay within touches[], /builder-ship <id> before starting the next.');
79
+ return lines.join('\n');
80
+ }
81
+
82
+ // Build one page's toolbar.
83
+ // ids element ids: bar, count, claim, clear (required) and release,
84
+ // parallel, sequential (each optional — absent = feature off)
85
+ // selection { claim: Set<String task_id>, release: Map<String claim_id, task_id> }
86
+ // — the page's live object; release may be omitted
87
+ // findTask (numeric id) → the task row the prompts name, or undefined
88
+ // claimAction OTBClaimAction (claimMany, releaseMany, validateMany)
89
+ // getJSON (path, opts) → body, for the dependency lookups
90
+ // copyText (text) → Promise<boolean>
91
+ // onClear repaint the page's cards after the selection is emptied
92
+ function makeBulkToolbar({ ids, selection, findTask, claimAction, getJSON, API, copyText, toast, curLabel, describeReason, onClear }) {
93
+ const el = (key) => (ids[key] ? document.getElementById(ids[key]) : null);
94
+ const releaseSel = () => selection.release || new Map();
95
+
96
+ // Last server dry-run (POST /claims/batch/validate) result, keyed by the
97
+ // sorted selected-id set so an unrelated re-render doesn't refire it.
98
+ const validateCache = { key: '', parallelSafe: null, byId: new Map() };
99
+ let validateDebounce = null;
100
+ // Per-task dependency edges for the "can these be sequenced?" cycle check —
101
+ // fetched lazily via GET /tasks/:id since the claimable feed doesn't carry
102
+ // dependencies[] per row. Stable for a ready task within one page session, so
103
+ // caching by id is safe.
104
+ const depsCache = new Map(); // task_id -> [dependency task_id, ...]
105
+ const sequentialCache = { key: '', order: null };
106
+ let sequentialDebounce = null;
107
+
108
+ const selectionTotal = () => selection.claim.size + releaseSel().size;
109
+ const selectedClaimIds = () => [...selection.claim].map(Number);
110
+ const selectionKey = (list) => list.slice().sort((a, b) => a - b).join(',');
111
+
112
+ function scheduleValidate(key, list) {
113
+ if (validateDebounce) clearTimeout(validateDebounce);
114
+ validateDebounce = setTimeout(async () => {
115
+ // The dry run lives in claim-action.js with the rest of the claim path; it
116
+ // is best-effort there, so a failure lands as parallelSafe:false with an
117
+ // empty map rather than a throw — Parallel stays disabled with a generic
118
+ // title instead of copying a prompt for tasks nobody verified. The
119
+ // debounce, the cache key and the re-render stay here: page furniture.
120
+ const out = await claimAction.validateMany(list);
121
+ validateCache.key = key;
122
+ validateCache.parallelSafe = out.parallelSafe;
123
+ validateCache.byId = out.byId;
124
+ render();
125
+ }, 350);
126
+ }
127
+
128
+ async function fetchDeps(taskId) {
129
+ if (depsCache.has(taskId)) return depsCache.get(taskId);
130
+ let deps = [];
131
+ try {
132
+ const d = await getJSON(`${API}/tasks/${encodeURIComponent(taskId)}`, { softAuth: true });
133
+ if (d && d.task && Array.isArray(d.task.dependencies)) {
134
+ deps = d.task.dependencies.map((x) => Number(x.id)).filter((x) => Number.isFinite(x));
135
+ }
136
+ } catch (_) { /* treat as no known dependencies */ }
137
+ depsCache.set(taskId, deps);
138
+ return deps;
139
+ }
140
+
141
+ function scheduleSequentialCheck(key, list) {
142
+ if (sequentialDebounce) clearTimeout(sequentialDebounce);
143
+ sequentialDebounce = setTimeout(async () => {
144
+ const edgesByTask = new Map();
145
+ await Promise.all(list.map(async (id) => { edgesByTask.set(id, await fetchDeps(id)); }));
146
+ sequentialCache.key = key;
147
+ sequentialCache.order = topoOrder(list, edgesByTask);
148
+ render();
149
+ }, 350);
150
+ }
151
+
152
+ function render() {
153
+ const bar = el('bar');
154
+ if (!bar) return;
155
+ if (selectionTotal() === 0) { bar.hidden = true; return; }
156
+ bar.hidden = false;
157
+
158
+ const countEl = el('count');
159
+ if (countEl) {
160
+ const parts = [];
161
+ if (selection.claim.size) parts.push(`${selection.claim.size} to claim`);
162
+ if (releaseSel().size) parts.push(`${releaseSel().size} to release`);
163
+ countEl.textContent = parts.join(' · ');
164
+ }
165
+
166
+ const claimBtn = el('claim');
167
+ if (claimBtn) {
168
+ claimBtn.disabled = selection.claim.size === 0;
169
+ claimBtn.textContent = selection.claim.size > 1 ? `Bulk claim (${selection.claim.size})` : 'Bulk claim';
170
+ }
171
+ const releaseBtn = el('release');
172
+ if (releaseBtn) {
173
+ releaseBtn.disabled = releaseSel().size === 0;
174
+ releaseBtn.textContent = releaseSel().size > 1 ? `Bulk release (${releaseSel().size})` : 'Bulk release';
175
+ }
176
+
177
+ const parallelBtn = el('parallel');
178
+ if (parallelBtn) {
179
+ if (selection.claim.size < 2) {
180
+ parallelBtn.disabled = true;
181
+ parallelBtn.title = 'Select at least 2 waiting tasks to copy a parallel-session prompt.';
182
+ } else {
183
+ const list = selectedClaimIds();
184
+ const key = selectionKey(list);
185
+ if (validateCache.key === key && validateCache.parallelSafe != null) {
186
+ parallelBtn.disabled = !validateCache.parallelSafe;
187
+ if (!validateCache.parallelSafe) {
188
+ const bad = [...validateCache.byId.values()].filter((r) => !r.valid);
189
+ const detail = bad.map((r) => `#${r.task_id}: ${(r.reasons || []).map(describeReason).join(', ')}`).join(' · ');
190
+ parallelBtn.title = detail || 'Selected tasks conflict and cannot run in parallel.';
191
+ } else {
192
+ parallelBtn.title = '';
193
+ }
194
+ } else {
195
+ parallelBtn.disabled = true;
196
+ parallelBtn.title = 'Checking for conflicts…';
197
+ scheduleValidate(key, list);
198
+ }
199
+ }
200
+ }
201
+
202
+ const sequentialBtn = el('sequential');
203
+ if (sequentialBtn) {
204
+ if (selection.claim.size < 2) {
205
+ sequentialBtn.disabled = true;
206
+ sequentialBtn.title = 'Select at least 2 waiting tasks to copy a sequential prompt.';
207
+ } else {
208
+ const list = selectedClaimIds();
209
+ const key = selectionKey(list);
210
+ if (sequentialCache.key === key) {
211
+ sequentialBtn.disabled = sequentialCache.order == null;
212
+ sequentialBtn.title = sequentialCache.order == null
213
+ ? 'These tasks depend on each other in a cycle and cannot be ordered.'
214
+ : '';
215
+ } else {
216
+ sequentialBtn.disabled = true;
217
+ sequentialBtn.title = 'Checking dependency order…';
218
+ scheduleSequentialCheck(key, list);
219
+ }
220
+ }
221
+ }
222
+ }
223
+
224
+ function clearSelection() {
225
+ selection.claim.clear();
226
+ releaseSel().clear();
227
+ if (onClear) onClear();
228
+ render();
229
+ }
230
+
231
+ async function doBulkClaim() {
232
+ const list = selectedClaimIds();
233
+ if (!list.length) return;
234
+ const btn = el('claim');
235
+ if (btn) { btn.disabled = true; btn.textContent = 'Claiming…'; }
236
+ try {
237
+ const out = await claimAction.claimMany(list);
238
+ if (out.ok) selection.claim.clear();
239
+ } finally {
240
+ render();
241
+ }
242
+ }
243
+
244
+ async function doBulkRelease() {
245
+ const entries = [...releaseSel().entries()]; // [claimId, taskId]
246
+ if (!entries.length) return;
247
+ const label = entries.length === 1 ? `#${entries[0][1]}` : `${entries.length} tasks`;
248
+ const pronoun = entries.length === 1 ? 'It returns' : 'They return';
249
+ if (!window.confirm(`Release ${label}? ${pronoun} to the queue and no ${curLabel()} are earned.`)) return;
250
+ const btn = el('release');
251
+ if (btn) { btn.disabled = true; btn.textContent = 'Releasing…'; }
252
+ try {
253
+ const out = await claimAction.releaseMany(entries);
254
+ if (out.ok) releaseSel().clear();
255
+ } finally {
256
+ render();
257
+ }
258
+ }
259
+
260
+ async function copyParallel() {
261
+ const tasks = selectedClaimIds().map(findTask).filter(Boolean);
262
+ if (tasks.length < 2) return;
263
+ const ok = await copyText(buildBulkParallelPrompt(tasks));
264
+ toast(ok ? `Parallel-session prompt for ${tasks.length} tasks copied.` : 'Could not copy to the clipboard.');
265
+ }
266
+
267
+ async function copySequential() {
268
+ const key = selectionKey(selectedClaimIds());
269
+ if (sequentialCache.key !== key || sequentialCache.order == null) return;
270
+ const tasks = sequentialCache.order.map(findTask).filter(Boolean);
271
+ if (tasks.length < 2) return;
272
+ const ok = await copyText(buildBulkSequentialPrompt(tasks));
273
+ toast(ok ? `Sequential prompt for ${tasks.length} tasks copied.` : 'Could not copy to the clipboard.');
274
+ }
275
+
276
+ // Bind each button that exists in this page's markup.
277
+ function wire() {
278
+ el('claim')?.addEventListener('click', doBulkClaim);
279
+ el('release')?.addEventListener('click', doBulkRelease);
280
+ el('clear')?.addEventListener('click', clearSelection);
281
+ el('parallel')?.addEventListener('click', copyParallel);
282
+ el('sequential')?.addEventListener('click', copySequential);
283
+ }
284
+
285
+ return { render, wire, clearSelection, selectionTotal };
286
+ }
287
+
288
+ const api = { topoOrder, buildBulkParallelPrompt, buildBulkSequentialPrompt, makeBulkToolbar };
289
+ if (typeof window !== 'undefined') window.OTBBulkToolbar = api;
290
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
291
+ })();
@@ -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-30-commands"></script>
151
+ <script src="/builders/shell.js?v=2026-09-30-founding-commands"></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>
@@ -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-30-commands"></script>
57
+ <script src="/builders/shell.js?v=2026-09-30-founding-commands"></script>
58
58
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
59
59
  <script src="/builders/palette.js" defer></script>
60
60
  <script src="/builders/dom-utils.js?v=2026-06-14-domutils"></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-30-commands"></script>
180
+ <script src="/builders/shell.js?v=2026-09-30-founding-commands"></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>
@@ -74,7 +74,7 @@
74
74
 
75
75
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
76
76
 
77
- <script src="/builders/shell.js?v=2026-09-30-commands"></script>
77
+ <script src="/builders/shell.js?v=2026-09-30-founding-commands"></script>
78
78
  <script src="/builders/palette.js" defer></script>
79
79
  <script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
80
80
  <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-30-commands"></script>
100
+ <script src="/builders/shell.js?v=2026-09-30-founding-commands"></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-30-commands"></script>
101
+ <script src="/builders/shell.js?v=2026-09-30-founding-commands"></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-30-commands"></script>
57
+ <script src="/builders/shell.js?v=2026-09-30-founding-commands"></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-30-commands"></script>
79
+ <script src="/builders/shell.js?v=2026-09-30-founding-commands"></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>
@@ -375,6 +375,20 @@
375
375
  // goal-title match — a single goal makes that redundant).
376
376
  // Copy-prompt (work.js:454-503). deHashRefs stops a bare #NNN autolinking to
377
377
  // GitHub in a pasted session; the prompt points the builder at the hall task URL.
378
+ // Clipboard write that says whether it worked (the bulk prompts report it).
379
+ async function copyText(text) {
380
+ try { await navigator.clipboard.writeText(text); return true; }
381
+ catch (_) {
382
+ try {
383
+ const ta = document.createElement('textarea');
384
+ ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
385
+ document.body.appendChild(ta); ta.select();
386
+ const ok = document.execCommand('copy');
387
+ ta.remove();
388
+ return ok;
389
+ } catch (_e) { return false; }
390
+ }
391
+ }
378
392
  async function copyPromptForTask(taskId, btn) {
379
393
  const t = state.work.todo.find((x) => String(x.id) === String(taskId));
380
394
  if (!t) return;
@@ -399,53 +413,25 @@
399
413
  // Uses the SAME .task markup + classes as the board, so work.css (linked from
400
414
  // goals.html) styles it identically. The lead cell + actions adapt to status.
401
415
 
402
- // ---- multi-select + bulk claim (work.js:583-828, claim-only subset) --------
403
- // Selection holds String(task_id) of READY cards the viewer ticked. Bulk
404
- // release + copy parallel/sequential prompts stay board-only for now (a goal is
405
- // a narrow slice where the headline value is "grab several of this goal's ready
406
- // tasks at once"); see the goals.html toolbar comment.
416
+ // ---- multi-select ------------------------------------------------------------
417
+ // Selection holds the READY cards the viewer ticked (to claim) and their own
418
+ // ACTIVE cards (to release). The toolbar over it — bulk claim / release, the
419
+ // parallel and sequential prompts — is the hall's shared one, bulk-toolbar.js,
420
+ // built in goals-render.js beside the selection.
407
421
  function toggleGoalCardSelect(card, forceChecked) {
408
- const cb = card.querySelector('.task__select');
422
+ const cb = card.querySelector('.task__select, .wip__select');
409
423
  if (!cb) return;
410
424
  const next = forceChecked != null ? forceChecked : !cb.checked;
411
425
  cb.checked = next;
412
426
  const id = String(cb.dataset.taskId);
413
- if (next) goalSel.claim.add(id); else goalSel.claim.delete(id);
427
+ const claimId = cb.dataset.claimId;
428
+ if (claimId) {
429
+ if (next) goalSel.release.set(claimId, id); else goalSel.release.delete(claimId);
430
+ } else if (next) goalSel.claim.add(id); else goalSel.claim.delete(id);
414
431
  card.classList.toggle('task--selected', next);
415
432
  renderGoalBulkToolbar();
416
433
  }
417
- function clearGoalSelection() {
418
- goalSel.claim.clear();
419
- document.querySelectorAll('#goal-todo-list .task--selected').forEach((c) => c.classList.remove('task--selected'));
420
- document.querySelectorAll('#goal-todo-list .task__select:checked').forEach((cb) => { cb.checked = false; });
421
- renderGoalBulkToolbar();
422
- }
423
- // Drop any selected id that's fallen out from under the selection (claimed by
424
- // someone else, shipped, or we navigated to another goal) so a stale tick never
425
- // outlives its card. Called after every task reload (work.js:606-619).
426
- // A single batch-item failure reason → a readable fragment (work.js:545-562).
427
- // Bulk claim the selected ready cards (work.js doBulkClaim, claims/batch is
428
- // all-or-nothing: a 409 batch_failed means nothing was claimed). Reloads the
429
- // goal's tasks either way so the cards reflect fresh truth.
430
- async function doGoalBulkClaim() {
431
- const ids = [...goalSel.claim].map(Number);
432
- if (!ids.length) return;
433
- const btn = $('#goal-bulk-claim-btn');
434
- if (btn) { btn.disabled = true; btn.textContent = 'Claiming…'; }
435
- try {
436
- const out = await claimAction.claimMany(ids);
437
- if (out.ok) goalSel.claim.clear();
438
- } finally {
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();
447
- }
448
- }
434
+ function clearGoalSelection() { bulk.clearSelection(); }
449
435
 
450
436
  // Client-side conflict estimate: exact/prefix overlap between the declared
451
437
  // touches[] of not-yet-done tasks. Deliberately conservative — the glob-aware
@@ -464,8 +450,8 @@
464
450
  // is passed straight through; workTabs is a GETTER because it is reassigned
465
451
  // after boot and a renderer must see the current controller, not the null it
466
452
  // held here at startup. This call sits AFTER every declaration it passes.
467
- const { GOAL_SORTS, WORK_SORT_HEADERS, errCode, errDetails, friendlyClaimError, goalSel, goalSelTotal, loadDetail, loadDirectory, loadGoalTasks, pagers, reflectSortHeaders, renderDetail, renderGoalBulkToolbar, renderList, renderTodoCards, renderWork } = window.OTBGoalsRender({
468
- API, curLabel, getJSON, patchJSON, postJSON, sameId, selfId, state,
453
+ const { GOAL_SORTS, WORK_SORT_HEADERS, bulk, errCode, errDetails, friendlyClaimError, goalSel, goalSelTotal, loadDetail, loadDirectory, loadGoalTasks, pagers, reflectSortHeaders, renderDetail, renderGoalBulkToolbar, renderList, renderTodoCards, renderWork } = window.OTBGoalsRender({
454
+ API, claimAction, copyText, curLabel, getJSON, patchJSON, postJSON, sameId, selfId, state,
469
455
  get workTabs() { return workTabs; },
470
456
  });
471
457
 
@@ -1073,7 +1059,7 @@
1073
1059
  // task 1002425 — the To-do card list: multi-select + inline Claim/Release +
1074
1060
  // copy-prompt + open, mirroring the board's task-list wiring (work.js:1245-1272).
1075
1061
  $('#goal-todo-list')?.addEventListener('click', (e) => {
1076
- const selectCb = e.target.closest('.task__select');
1062
+ const selectCb = e.target.closest('.task__select, .wip__select');
1077
1063
  if (selectCb) { const card = selectCb.closest('.task'); if (card) toggleGoalCardSelect(card, selectCb.checked); return; }
1078
1064
  const claimBtn = e.target.closest('[data-claim-task]');
1079
1065
  if (claimBtn) { e.stopPropagation(); doGoalClaim(claimBtn.dataset.claimTask, claimBtn); return; }
@@ -1095,8 +1081,7 @@
1095
1081
  }
1096
1082
  });
1097
1083
  // task 1002425 — the goal's bulk-action toolbar.
1098
- $('#goal-bulk-claim-btn')?.addEventListener('click', doGoalBulkClaim);
1099
- $('#goal-bulk-clear-btn')?.addEventListener('click', clearGoalSelection);
1084
+ bulk.wire();
1100
1085
  // task 1002164 — Confirm on a pending-review criterion, in both the capped
1101
1086
  // band column and the dedicated #/goal/N/completion view.
1102
1087
  ['#goal-completion-body', '#goal-completion-full'].forEach((sel) => {
@@ -18,7 +18,7 @@
18
18
  'use strict';
19
19
 
20
20
  window.OTBGoalsRender = function createGoalsRender(ctx) {
21
- const { API, curLabel, getJSON, patchJSON, postJSON, sameId, selfId, state } = ctx;
21
+ const { API, claimAction, copyText, curLabel, getJSON, patchJSON, postJSON, sameId, selfId, state } = ctx;
22
22
  const { escapeHtml, $, fmtNum, fmtDate, toast, rankLabel, categoryChip } = window.OTB;
23
23
  const {
24
24
  priorityRank, touchesConflict, rankTagHtml, deHashRefs,
@@ -346,12 +346,16 @@
346
346
  const claim = state.claimsByTask.get(id);
347
347
  const isReady = t.status === 'ready' && !!state.me;
348
348
  const mineActive = t.status === 'active' && claim && sameId(claim.builder_id, selfId());
349
- const selected = goalSel.claim.has(id);
350
- // Lead: a select checkbox on ready cards (drives bulk claim); otherwise a
351
- // status tag so active/backlog cards read at a glance.
349
+ const releaseKey = mineActive ? String(claim.claim_id) : null;
350
+ const selected = isReady ? goalSel.claim.has(id) : (mineActive && goalSel.release.has(releaseKey));
351
+ // Lead: a select checkbox on ready cards (drives bulk claim) and on the
352
+ // viewer's own active cards (drives bulk release, the board's `.wip__select`);
353
+ // otherwise a status tag so other cards read at a glance.
352
354
  const lead = isReady
353
355
  ? `<input type="checkbox" class="task__select" data-task-id="${escapeHtml(id)}" aria-label="Select task ${escapeHtml(id)} for bulk claim"${selected ? ' checked' : ''}>`
354
- : `<span class="task__tag" title="Task status">${escapeHtml(t.status || '')}</span>`;
356
+ : (mineActive
357
+ ? `<input type="checkbox" class="wip__select" data-claim-id="${escapeHtml(releaseKey)}" data-task-id="${escapeHtml(id)}" aria-label="Select task ${escapeHtml(id)} for bulk release"${selected ? ' checked' : ''}>`
358
+ : `<span class="task__tag" title="Task status">${escapeHtml(t.status || '')}</span>`);
355
359
  // Actions: ready → Claim + copy; mine-active → Release; other's active →
356
360
  // "claimed · @who"; anything else → nothing (open via the title / card).
357
361
  let actions = '';
@@ -392,25 +396,33 @@
392
396
  <div class="task__actions">${actions}</div>
393
397
  </div>`;
394
398
  }
395
- const goalSel = { claim: new Set() };
396
- function goalSelTotal() { return goalSel.claim.size; }
399
+ // The goal's selection: ready cards to claim, the viewer's own active cards to
400
+ // release (claim id -> task id). The toolbar over it is the hall's shared one.
401
+ const goalSel = { claim: new Set(), release: new Map() };
402
+ function goalSelTotal() { return goalSel.claim.size + goalSel.release.size; }
397
403
  function pruneGoalSelection() {
398
404
  const readyIds = new Set(state.work.todo.filter((t) => t.status === 'ready').map((t) => String(t.id)));
399
405
  for (const id of Array.from(goalSel.claim)) if (!readyIds.has(id)) goalSel.claim.delete(id);
406
+ const mineClaimIds = new Set(
407
+ state.work.todo
408
+ .filter((t) => t.status === 'active')
409
+ .map((t) => state.claimsByTask.get(String(t.id)))
410
+ .filter((c) => c && sameId(c.builder_id, selfId()))
411
+ .map((c) => String(c.claim_id)),
412
+ );
413
+ for (const claimId of Array.from(goalSel.release.keys())) if (!mineClaimIds.has(claimId)) goalSel.release.delete(claimId);
400
414
  }
401
- function renderGoalBulkToolbar() {
402
- const bar = $('#goal-bulk-toolbar');
403
- if (!bar) return;
404
- if (goalSelTotal() === 0) { bar.hidden = true; return; }
405
- bar.hidden = false;
406
- const countEl = $('#goal-bulk-count');
407
- if (countEl) countEl.textContent = `${goalSel.claim.size} to claim`;
408
- const claimBtn = $('#goal-bulk-claim-btn');
409
- if (claimBtn) {
410
- claimBtn.disabled = goalSel.claim.size === 0;
411
- claimBtn.textContent = goalSel.claim.size > 1 ? `Bulk claim (${goalSel.claim.size})` : 'Bulk claim';
412
- }
413
- }
415
+ const bulk = window.OTBBulkToolbar.makeBulkToolbar({
416
+ ids: { bar: 'goal-bulk-toolbar', count: 'goal-bulk-count', claim: 'goal-bulk-claim-btn', release: 'goal-bulk-release-btn', parallel: 'goal-bulk-parallel-btn', sequential: 'goal-bulk-sequential-btn', clear: 'goal-bulk-clear-btn' },
417
+ selection: goalSel,
418
+ findTask: (id) => state.work.todo.find((t) => Number(t.id) === id),
419
+ claimAction, getJSON, API, copyText, toast, curLabel, describeReason: friendlyBatchReason,
420
+ onClear: () => {
421
+ document.querySelectorAll('#goal-todo-list .task--selected').forEach((c) => c.classList.remove('task--selected'));
422
+ document.querySelectorAll('#goal-todo-list .task__select:checked, #goal-todo-list .wip__select:checked').forEach((cb) => { cb.checked = false; });
423
+ },
424
+ });
425
+ function renderGoalBulkToolbar() { bulk.render(); }
414
426
  function taskRowsDone(tasks) {
415
427
  return tasks.map((t) => `
416
428
  <tr class="ledger__row--task" data-task-id="${escapeHtml(String(t.id))}" style="cursor:pointer" title="Open task #${escapeHtml(String(t.id))}">
@@ -1464,6 +1476,6 @@
1464
1476
  return d;
1465
1477
  }
1466
1478
 
1467
- return { GOAL_SORTS, WORK_SORT_HEADERS, errCode, errDetails, friendlyClaimError, goalSel, goalSelTotal, loadDependencyForest, loadDetail, loadDirectory, loadGoalTasks, pagers, reflectSortHeaders, renderDependencyForest, renderDetail, renderGoalBulkToolbar, renderList, renderTodoCards, renderWork };
1479
+ return { GOAL_SORTS, WORK_SORT_HEADERS, errCode, errDetails, friendlyClaimError, bulk, goalSel, goalSelTotal, loadDependencyForest, loadDetail, loadDirectory, loadGoalTasks, pagers, reflectSortHeaders, renderDependencyForest, renderDetail, renderGoalBulkToolbar, renderList, renderTodoCards, renderWork };
1468
1480
  };
1469
1481
  })();