@bongos/core 1.19.863 → 1.19.865

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.
@@ -1,19 +1,35 @@
1
- // blockers.js — the Blockers page: the full open-blocker queue (task 1003455,
2
- // goal 1000089).
1
+ // blockers.js — the Blockers page: the full open-blocker queue, and now (task
2
+ // 1003456, goal 1000089) the write half — file, resolve, link.
3
3
  //
4
4
  // GET /blockers (modules/ideas/routes/blockers.js) is PUBLIC and had ZERO hall
5
- // consumers before this task — the only blocker a builder could ever see was the
6
- // #/blocker/N overlay task-detail.js already renders, reachable only by typing
7
- // the id into the address bar. Owner decision 2026-08-30: BOTH surfaces, from
8
- // ONE code path — this page is the full queue (a long list has nowhere else to
9
- // live), and goal-inbox.js's attention-inbox slice is the capped summary. Both
10
- // render from blockers-lib.js's blockerFacts(), so the two cannot drift apart
11
- // the way ~10 hand-rolled pagers did before the kit (hall-ui/CLAUDE.md).
5
+ // consumers before task 1003455 — the only blocker a builder could ever see was
6
+ // the #/blocker/N overlay task-detail.js already renders, reachable only by
7
+ // typing the id into the address bar. Owner decision 2026-08-30: BOTH surfaces,
8
+ // from ONE code path — this page is the full queue (a long list has nowhere
9
+ // else to live), and goal-inbox.js's attention-inbox slice is the capped
10
+ // summary. Both render from blockers-lib.js's blockerFacts(), so the two
11
+ // cannot drift apart the way ~10 hand-rolled pagers did before the kit.
12
+ //
13
+ // task 1003456 — the write half. POST /blockers, POST /blockers/:id/resolve
14
+ // and POST /blockers/:id/link already carry their own rank floors (any-builder
15
+ // for filing, blocker.review — metic+archon — for resolve/link; proven by the
16
+ // fitness route-rank-check gate, not re-proven here). AUTHORITY IS UNCHANGED:
17
+ // this file adds no floor of its own, only a COSMETIC mirror of the real one —
18
+ // the exact idea-triage-panel precedent in task-detail.js ("The panel only
19
+ // renders for metic/archon; the server enforces the same gate regardless").
20
+ // The one control open to EVERY signed-in viewer, including a non-reviewer, is
21
+ // filing — and the file form's own "blocking task #" field is deliberately
22
+ // NOT hidden from a non-reviewer, because a builder who is themselves stuck is
23
+ // exactly who most needs to say what it blocks, even though completing the
24
+ // LINK half needs a Metic+. That is the case "every API refusal renders as
25
+ // honest copy rather than a silent no-op" (the task's own done-when) exists
26
+ // for: filing still succeeds, and the honest message says a Metic+ has to
27
+ // finish the link — never a swallowed failure.
12
28
  //
13
- // Read-only: filing, resolving and linking a blocker to a task are task 1003456.
14
29
  // Deliberately UNGATED (tests/hall_page_gate_map.mjs) — its one data source is
15
30
  // public, the roster/drachmae precedent: a page must not be gated tighter than
16
- // the API it fronts.
31
+ // the API it fronts. A signed-out visitor sees the queue and the File button;
32
+ // clicking it redirects to sign-in, same as every other hall write.
17
33
  (function () {
18
34
  'use strict';
19
35
  var kit = window.OTBKit;
@@ -22,6 +38,45 @@
22
38
  var body = document.getElementById('blockers-body');
23
39
  var countPill = document.getElementById('blockers-count');
24
40
  var sub = document.getElementById('blockers-sub');
41
+ var fileArea = document.getElementById('blockers-file-area');
42
+
43
+ // The generated typed client (task-detail.js's own pattern), result-mode so
44
+ // the status-inspecting control flow below (401 → sign-in, 403 → honest
45
+ // copy) reads 1:1.
46
+ var api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
47
+ var toast = function (m) { if (window.OTB && window.OTB.toast) window.OTB.toast(m, { duration: 3200 }); };
48
+
49
+ async function writeJSON(method, path, payload) {
50
+ var res = await api.request(method, path, { body: payload || {} });
51
+ if (res.status === 401) { window.location.assign(API + '/auth/web/start'); throw new Error('redirecting to auth'); }
52
+ var data = null;
53
+ try { data = res.data; } catch (_) { /* empty / non-JSON */ }
54
+ if (!res.ok) { var err = new Error(path + ' → ' + res.status); err.status = res.status; err.data = data || {}; throw err; }
55
+ return data;
56
+ }
57
+
58
+ // The R01/ADR-0116 envelope is `{ error: { code, message } }` — nested, the
59
+ // same trap task-detail.js's errMessage() guards against.
60
+ function errMessage(err) {
61
+ var d = err && err.data;
62
+ var e = d && d.error;
63
+ if (e && typeof e === 'object' && e.message) return e.message;
64
+ return (d && d.message) || '';
65
+ }
66
+ // Honest copy for a refusal — never a silent no-op (the done-when, verbatim).
67
+ // Generic: prefers the server's own message, falling back to the caller's.
68
+ // Filing has NO rank floor, so a 403 there is never "you need Metic+" — that
69
+ // reading belongs only to the two calls that actually gate on blocker.review.
70
+ function refusalMessage(err, fallback) {
71
+ return errMessage(err) || fallback;
72
+ }
73
+ // The reviewer-gated calls' version: a 403 on resolve/link IS the
74
+ // blocker.review floor, so it gets the specific message rather than
75
+ // whatever (or nothing) the server's envelope happened to say.
76
+ function reviewerRefusalMessage(err, fallback) {
77
+ if (err && err.status === 403) return 'You need Metic+ access to do that.';
78
+ return refusalMessage(err, fallback);
79
+ }
25
80
 
26
81
  var taskHref = function (id) {
27
82
  return window.OTBTaskDetail ? window.OTBTaskDetail.href(id) : ('/task/' + encodeURIComponent(id));
@@ -29,6 +84,17 @@
29
84
  var blockerHref = function (id) { return '#/blocker/' + encodeURIComponent(id); };
30
85
 
31
86
  var all = [];
87
+ // Which rank a reviewer action (Resolve / Link) needs — mirrors the server's
88
+ // blocker.review floor (metic+archon), cosmetically only; the server holds
89
+ // the actual gate regardless of what this variable says.
90
+ var viewer = { rank: null };
91
+ var isReviewer = function () { return viewer.rank === 'metic' || viewer.rank === 'archon'; };
92
+
93
+ // Which row (if any) has an inline action form open, and which form.
94
+ // { id, mode: 'resolve' | 'link' } | null — a row's OWN form, not a global
95
+ // one, so opening a second row's form closes whichever was open (one at a
96
+ // time keeps the list from growing a form per row on a long queue).
97
+ var openRow = null;
32
98
 
33
99
  function rowHtml(b) {
34
100
  var facts = lib.blockerFacts(b, {
@@ -38,12 +104,46 @@
38
104
  blockerHref: blockerHref,
39
105
  taskHref: taskHref,
40
106
  });
107
+ var right = '';
108
+ if (isReviewer()) {
109
+ var id = String(b.id);
110
+ if (openRow && String(openRow.id) === id && openRow.mode === 'resolve') {
111
+ right = '<div class="ov-row__right"><form class="td-form" data-resolve-form="' + lib.esc(id) + '">'
112
+ + '<label class="td-field td-field--wide"><span>Resolution note (optional)</span>'
113
+ + '<input type="text" name="note" maxlength="1000" placeholder="what got un-stuck"></label>'
114
+ + '<div class="td-actions">'
115
+ + '<button type="submit" class="td-btn td-btn--primary" data-submit>Resolve</button>'
116
+ + '<button type="button" class="td-btn" data-row-cancel>Cancel</button>'
117
+ + '</div><p class="td-action-note" data-err></p></form></div>';
118
+ } else if (openRow && String(openRow.id) === id && openRow.mode === 'link') {
119
+ right = '<div class="ov-row__right"><form class="td-form" data-link-form="' + lib.esc(id) + '">'
120
+ + '<label class="td-field td-field--wide"><span>Task #</span>'
121
+ + '<input type="number" name="taskId" min="1" required placeholder="e.g. 1002980"></label>'
122
+ + '<div class="td-actions">'
123
+ + '<button type="submit" class="td-btn td-btn--primary" data-submit>Link</button>'
124
+ + '<button type="button" class="td-btn" data-row-cancel>Cancel</button>'
125
+ + '</div><p class="td-action-note" data-err></p></form></div>';
126
+ } else {
127
+ right = '<div class="ov-row__right"><div class="ov-row__actions">'
128
+ + '<button type="button" class="btn-ghost" data-resolve-open="' + lib.esc(id) + '">Resolve</button>'
129
+ + '<button type="button" class="btn-ghost" data-link-open="' + lib.esc(id) + '">Link a task</button>'
130
+ + '</div></div>';
131
+ }
132
+ }
133
+ // Plain .ov-row, never --lead: this row has no LEAD column (a face, as
134
+ // collab's rows have) — it is .ov-row__main + .ov-row__right, exactly the
135
+ // shape .ov-row's own grid (minmax(0,1fr) auto) already handles correctly,
136
+ // both at full width and under the family's ≤640px collapse. --lead's
137
+ // three-column grid (auto minmax(0,1fr) auto) would misread the actions
138
+ // column as the row's middle content and pin it beside the text on a
139
+ // phone instead of dropping it beneath.
41
140
  return '<li class="ov-row" data-id="' + lib.esc(String(b.id)) + '">'
42
141
  + '<div class="ov-row__main">'
43
142
  + facts.titleHtml
44
143
  + facts.noteHtml
45
144
  + '<div class="ov-row__facts">' + facts.factsHtml + '</div>'
46
145
  + '</div>'
146
+ + right
47
147
  + '</li>';
48
148
  }
49
149
 
@@ -57,6 +157,10 @@
57
157
  : kit.emptyStateHtml('Nothing is stuck on the owner right now.');
58
158
  }
59
159
 
160
+ function reload() {
161
+ return fetchBlockers().catch(function () { toast('Couldn’t refresh the queue — try reloading the page.'); });
162
+ }
163
+
60
164
  function mountBlockers(rows) {
61
165
  all = Array.isArray(rows) ? rows : [];
62
166
  if (countPill) { countPill.textContent = String(all.length); countPill.hidden = all.length === 0; }
@@ -78,11 +182,172 @@
78
182
  body.textContent = msg;
79
183
  }
80
184
 
81
- fetch(API + '/blockers?limit=200', { headers: { Accept: 'application/json' } })
82
- .then(function (r) {
83
- if (!r.ok) throw new Error('http ' + r.status);
84
- return r.json();
85
- })
86
- .then(function (data) { mountBlockers(data && data.blockers); })
87
- .catch(function () { notice('Couldn’t load the blocker queue right now.'); });
185
+ function fetchBlockers() {
186
+ return fetch(API + '/blockers?limit=200', { headers: { Accept: 'application/json' } })
187
+ .then(function (r) {
188
+ if (!r.ok) throw new Error('http ' + r.status);
189
+ return r.json();
190
+ })
191
+ .then(function (data) { mountBlockers(data && data.blockers); });
192
+ }
193
+
194
+ // ---- filing (any signed-in builder) ----------------------------------------
195
+
196
+ function fileFormHtml() {
197
+ return '<div class="td-actions" id="blockers-file-toggle">'
198
+ + '<button type="button" class="btn-ghost" id="blockers-file-open">+ File a blocker</button>'
199
+ + '</div>'
200
+ + '<form class="td-form" id="blockers-file-form" hidden>'
201
+ + '<label class="td-field td-field--wide"><span>Title</span>'
202
+ + '<input type="text" id="bf-title" maxlength="200" required placeholder="what needs the owner’s decision"></label>'
203
+ + '<label class="td-field td-field--wide"><span>What’s stuck (optional)</span>'
204
+ + '<input type="text" id="bf-note" maxlength="2000" placeholder="a sentence of context"></label>'
205
+ + '<label class="td-field"><span>Blocking task # (optional)</span>'
206
+ + '<input type="number" id="bf-task-id" min="1" placeholder="e.g. 1002980"></label>'
207
+ + '<div class="td-actions">'
208
+ + '<button type="submit" class="td-btn td-btn--primary" id="bf-submit">File it</button>'
209
+ + '<button type="button" class="td-btn" id="bf-cancel">Cancel</button>'
210
+ + '</div><p class="td-action-note" id="bf-err"></p>'
211
+ // The one control open to a non-reviewer that the review floor still
212
+ // touches: filing succeeds regardless, but linking needs blocker.review,
213
+ // and a viewer below it is told so BEFORE they submit — not left to
214
+ // discover it from a refusal.
215
+ + (isReviewer() ? '' : '<p class="td-form-hint">Filing is open to anyone. Linking to a task needs Metic+ — if you name one and aren’t, the blocker still files and a Metic+ can link it.</p>')
216
+ + '</form>';
217
+ }
218
+
219
+ function wireFileForm() {
220
+ var openBtn = document.getElementById('blockers-file-open');
221
+ var form = document.getElementById('blockers-file-form');
222
+ var cancelBtn = document.getElementById('bf-cancel');
223
+ var errEl = document.getElementById('bf-err');
224
+ if (!openBtn || !form) return;
225
+ openBtn.addEventListener('click', function () {
226
+ openBtn.parentElement.hidden = true;
227
+ form.hidden = false;
228
+ document.getElementById('bf-title').focus();
229
+ });
230
+ cancelBtn.addEventListener('click', function () {
231
+ form.hidden = true;
232
+ openBtn.parentElement.hidden = false;
233
+ form.reset();
234
+ errEl.textContent = '';
235
+ });
236
+ form.addEventListener('submit', async function (ev) {
237
+ ev.preventDefault();
238
+ errEl.textContent = '';
239
+ var title = document.getElementById('bf-title').value.trim();
240
+ var note = document.getElementById('bf-note').value.trim();
241
+ var taskIdRaw = document.getElementById('bf-task-id').value.trim();
242
+ if (!title) { errEl.textContent = 'A title is required.'; return; }
243
+ var submitBtn = document.getElementById('bf-submit');
244
+ submitBtn.disabled = true;
245
+ try {
246
+ var res = await writeJSON('POST', API + '/blockers', { title: title, body_md: note });
247
+ var blocker = res && res.blocker;
248
+ if (res && res.skipped) {
249
+ // A duplicate (source, source_ref) skip returns no blocker id at
250
+ // all, so a typed task # was NEVER attempted — say so rather than
251
+ // silently dropping half the request the builder asked for.
252
+ toast(taskIdRaw
253
+ ? 'That blocker was already filed. It was NOT linked to task #' + taskIdRaw + ' — open the existing blocker to link it.'
254
+ : 'That blocker was already filed.');
255
+ } else if (blocker && taskIdRaw) {
256
+ var taskId = Number(taskIdRaw);
257
+ try {
258
+ await writeJSON('POST', API + '/blockers/' + encodeURIComponent(blocker.id) + '/link', { task_id: taskId });
259
+ toast('Blocker #' + blocker.id + ' filed and linked to task #' + taskId + '.');
260
+ } catch (linkErr) {
261
+ // Honest copy, not a silent no-op: the filing DID succeed, and the
262
+ // reader is told exactly what still needs a Metic+.
263
+ toast('Blocker #' + blocker.id + ' filed. ' + reviewerRefusalMessage(linkErr, 'Could not link it to task #' + taskId + ' — a Metic+ can do that from this page.'));
264
+ }
265
+ } else if (blocker) {
266
+ toast('Blocker #' + blocker.id + ' filed.');
267
+ } else {
268
+ toast('Filed.');
269
+ }
270
+ form.reset();
271
+ form.hidden = true;
272
+ document.getElementById('blockers-file-toggle').hidden = false;
273
+ await reload();
274
+ } catch (err) {
275
+ errEl.textContent = refusalMessage(err, 'Could not file that blocker. Try again.');
276
+ } finally {
277
+ submitBtn.disabled = false;
278
+ }
279
+ });
280
+ }
281
+
282
+ // ---- resolve / link (blocker.review — metic+archon; cosmetic mirror only) --
283
+
284
+ function closeRowForms() { openRow = null; rerender(); }
285
+
286
+ body.addEventListener('click', function (e) {
287
+ var resolveOpen = e.target.closest('[data-resolve-open]');
288
+ if (resolveOpen) { openRow = { id: resolveOpen.dataset.resolveOpen, mode: 'resolve' }; rerender(); return; }
289
+ var linkOpen = e.target.closest('[data-link-open]');
290
+ if (linkOpen) { openRow = { id: linkOpen.dataset.linkOpen, mode: 'link' }; rerender(); return; }
291
+ if (e.target.closest('[data-row-cancel]')) { closeRowForms(); }
292
+ });
293
+
294
+ body.addEventListener('submit', async function (e) {
295
+ var resolveForm = e.target.closest('[data-resolve-form]');
296
+ var linkForm = e.target.closest('[data-link-form]');
297
+ if (!resolveForm && !linkForm) return;
298
+ e.preventDefault();
299
+ var form = resolveForm || linkForm;
300
+ var id = (resolveForm || linkForm).getAttribute(resolveForm ? 'data-resolve-form' : 'data-link-form');
301
+ var errEl = form.querySelector('[data-err]');
302
+ var submitBtn = form.querySelector('[data-submit]');
303
+ submitBtn.disabled = true;
304
+ try {
305
+ if (resolveForm) {
306
+ var note = form.querySelector('[name="note"]').value.trim();
307
+ await writeJSON('POST', API + '/blockers/' + encodeURIComponent(id) + '/resolve', { resolution_note: note || null });
308
+ toast('Blocker #' + id + ' resolved.');
309
+ } else {
310
+ var taskId = Number(form.querySelector('[name="taskId"]').value);
311
+ await writeJSON('POST', API + '/blockers/' + encodeURIComponent(id) + '/link', { task_id: taskId });
312
+ toast('Blocker #' + id + ' linked to task #' + taskId + '.');
313
+ }
314
+ openRow = null;
315
+ await reload();
316
+ } catch (err) {
317
+ errEl.textContent = reviewerRefusalMessage(err, 'Could not do that. Try again.');
318
+ submitBtn.disabled = false;
319
+ }
320
+ });
321
+
322
+ // ---- boot -------------------------------------------------------------------
323
+
324
+ async function loadViewer() {
325
+ try {
326
+ var res = await api.request('GET', API + '/me');
327
+ if (res.ok && res.data && res.data.builder) {
328
+ viewer = { rank: String(res.data.builder.rank || '').toLowerCase() };
329
+ }
330
+ } catch (_) { /* signed out or offline — viewer stays { rank: null }, isReviewer() false */ }
331
+ }
332
+
333
+ loadViewer().then(function () {
334
+ if (fileArea) { fileArea.innerHTML = fileFormHtml(); wireFileForm(); }
335
+ // Boot race: GET /me and GET /blockers fire together, and rowHtml() reads
336
+ // isReviewer() at RENDER time. If the queue rendered first (the common
337
+ // case — /me is Metic+-gated work the server does more of), it rendered
338
+ // with isReviewer() still false and nothing re-draws it once the real
339
+ // rank lands — a Metic/Archon would see no Resolve/Link buttons until an
340
+ // unrelated re-render (a pager click, a file, an otb:blocker-resolved).
341
+ // Re-render now that the viewer is known, but only if the list actually
342
+ // exists yet (fetchBlockers may still be in flight — it will render
343
+ // correctly on its own the moment it resolves).
344
+ if (listEl) rerender();
345
+ });
346
+
347
+ fetchBlockers().catch(function () { notice('Couldn’t load the blocker queue right now.'); });
348
+
349
+ // A row's title opens the #/blocker/N overlay ON TOP of this page
350
+ // (task-detail.js), whose own Resolve panel fires this — refresh the queue
351
+ // in place rather than leaving a resolved row sitting there.
352
+ window.addEventListener('otb:blocker-resolved', reload);
88
353
  }());
@@ -530,4 +530,10 @@
530
530
  id: 'attention-scroll', label: 'Needs your attention', order: 20, mount, core: true,
531
531
  labelledby: 'attention-h', view: 'home', zone: 'home-main', hiddenByDefault: true,
532
532
  });
533
+
534
+ // task 1003456: a blocker resolved through the #/blocker/N overlay (which
535
+ // this slice's own row titles open into) should not sit in the inbox until
536
+ // the next full load — the otb:idea-triaged precedent, applied to the
537
+ // sibling event task-detail.js's blockerPanel fires.
538
+ window.addEventListener('otb:blocker-resolved', loadBlockers);
533
539
  })();
@@ -153,9 +153,97 @@
153
153
  if (kind === 'idea' && r.triage_note) {
154
154
  nodes.push(h('p', 'deeplink-card__note', 'Triage note: ' + r.triage_note));
155
155
  }
156
+
157
+ // task 1003456 (goal 1000089): the write half of the blocker queue. The
158
+ // overlay is the SAME summary→detail relationship the idea triage panel
159
+ // above already has with the /idea page — a compact card, not the full
160
+ // queue — so it carries only Resolve (the one-tap "this is done" verb);
161
+ // Link a task lives on the fuller /blockers page, which has room for the
162
+ // task-id field and the queue to confirm against. Rendered only for
163
+ // metic/archon, the COSMETIC mirror of the real blocker.review floor —
164
+ // "AUTHORITY IS UNCHANGED... the hall must add none of its own" — the
165
+ // server enforces the same gate regardless, exactly the idea panel's rule.
166
+ if (kind === 'blocker' && r.status === 'open' && (rank === 'metic' || rank === 'archon')) {
167
+ nodes.push(blockerPanel(r));
168
+ }
169
+ // A resolved blocker says what closed it — otherwise the status word has
170
+ // no story behind it (the idea triage_note precedent, two blocks up).
171
+ if (kind === 'blocker' && r.status === 'resolved') {
172
+ nodes.push(h('p', 'deeplink-card__note', 'Resolved' + (r.resolution_note ? ': ' + r.resolution_note : '.')));
173
+ }
156
174
  return nodes;
157
175
  }
158
176
 
177
+ // ---- the blocker resolve panel (task 1003456) ------------------------------
178
+ //
179
+ // One verb, one form — the renderDiscard shape below, at blocker scale.
180
+ function blockerPanel(blocker) {
181
+ var box = h('div', 'td-triage');
182
+ var mode = 'idle';
183
+
184
+ function note(text) {
185
+ var p = box.querySelector('[data-err]');
186
+ if (p) p.textContent = text || '';
187
+ }
188
+ function busy(on, submitLabel) {
189
+ box.querySelectorAll('button, input').forEach(function (el) { el.disabled = on; });
190
+ var submit = box.querySelector('[data-submit]');
191
+ if (submit) submit.textContent = on ? '…' : submitLabel;
192
+ }
193
+ function go(next) { mode = next; render(); }
194
+
195
+ function render() {
196
+ if (mode === 'idle') return renderIdle();
197
+ return renderResolve();
198
+ }
199
+
200
+ function renderIdle() {
201
+ box.innerHTML = '<div class="td-actions"><button type="button" class="td-btn td-btn--primary" data-go="resolve">Resolve</button></div>';
202
+ }
203
+
204
+ function renderResolve() {
205
+ box.innerHTML =
206
+ '<form class="td-form" data-form="resolve">'
207
+ + '<label class="td-field td-field--wide"><span>Resolution note (optional)</span>'
208
+ + '<input type="text" name="note" maxlength="1000" placeholder="what got un-stuck"></label>'
209
+ + '<div class="td-actions">'
210
+ + '<button type="submit" class="td-btn td-btn--primary" data-submit>Resolve</button>'
211
+ + '<button type="button" class="td-btn" data-go="idle">Cancel</button>'
212
+ + '</div><p class="td-action-note" data-err></p></form>';
213
+ box.querySelector('[data-form="resolve"]').addEventListener('submit', function (ev) {
214
+ ev.preventDefault();
215
+ submitResolve(ev.target);
216
+ });
217
+ }
218
+
219
+ async function submitResolve(form) {
220
+ note('');
221
+ busy(true, 'Resolve');
222
+ try {
223
+ await postJSON(API + '/blockers/' + encodeURIComponent(blocker.id) + '/resolve', {
224
+ resolution_note: String(form.note.value || '').trim() || null,
225
+ });
226
+ toast('Blocker #' + blocker.id + ' resolved.');
227
+ try {
228
+ window.dispatchEvent(new CustomEvent('otb:blocker-resolved', { detail: { id: blocker.id } }));
229
+ } catch (_) { /* CustomEvent unsupported — the refresh is a nicety */ }
230
+ clearLink();
231
+ } catch (err) {
232
+ // Honest copy, never a silent no-op — the done-when, verbatim.
233
+ var msg = (err && err.status === 403) ? 'You need Metic+ access to resolve blockers.' : (errMessage(err) || 'Could not resolve that blocker. Try again.');
234
+ busy(false, 'Resolve');
235
+ note(msg);
236
+ }
237
+ }
238
+
239
+ box.addEventListener('click', function (e) {
240
+ var btn = e.target.closest('[data-go]');
241
+ if (btn) go(btn.dataset.go);
242
+ });
243
+ render();
244
+ return box;
245
+ }
246
+
159
247
  // ---- the triage panel (task 1002447) ---------------------------------------
160
248
  //
161
249
  // Promote → POST /tasks, then PATCH the idea promoted + linked to it.
@@ -633,10 +721,11 @@
633
721
  render({ loading: true, kind: kind, id: id });
634
722
  try {
635
723
  var ref = await fetchRef(kind, id);
636
- // task 759: probe the viewer's rank only for an idea (so the overlay can
637
- // offer Metic+ triage). Best-effort — a failure just hides the button.
724
+ // task 759: probe the viewer's rank for an idea OR a blocker (task
725
+ // 1003456 added the latter) so the overlay can offer Metic+ triage /
726
+ // resolve. Best-effort — a failure just hides the button.
638
727
  var viewer = null;
639
- if (kind === 'idea' && ref) {
728
+ if ((kind === 'idea' || kind === 'blocker') && ref) {
640
729
  try {
641
730
  var me = await getJSON(API + '/me', { softAuth: true });
642
731
  viewer = (me && me.builder) ? me.builder : null;
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.863",
3
+ "version": "1.19.865",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.863",
9
+ "version": "1.19.865",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.863",
3
+ "version": "1.19.865",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.863'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.865'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -27,6 +27,7 @@ const GOAL_INBOX_SRC = readFileSync(join(HALL, 'goal-inbox.js'), 'utf8');
27
27
  const BLOCKERS_JS_SRC = readFileSync(join(HALL, 'blockers.js'), 'utf8');
28
28
  const BLOCKERS_HTML_SRC = readFileSync(join(HALL, 'blockers.html'), 'utf8');
29
29
  const INDEX_HTML_SRC = readFileSync(join(HALL, 'index.html'), 'utf8');
30
+ const TASK_DETAIL_SRC = readFileSync(join(HALL, 'task-detail.js'), 'utf8');
30
31
 
31
32
  // ---- the attention-inbox slice (goal-inbox.js) -----------------------------
32
33
 
@@ -105,3 +106,94 @@ test('every title link opens the SAME #/blocker/N overlay task-detail.js already
105
106
  assert.match(GOAL_INBOX_SRC, /`#\/blocker\/\$\{encodeURIComponent\(id\)\}`/);
106
107
  assert.match(BLOCKERS_JS_SRC, /'#\/blocker\/' \+ encodeURIComponent\(id\)/);
107
108
  });
109
+
110
+ // ---- task 1003456: the write half — file, resolve, link -------------------
111
+ //
112
+ // POST /blockers, POST /blockers/:id/resolve and POST /blockers/:id/link
113
+ // already carry their own server-side rank floors (any-builder / blocker.review
114
+ // respectively), proven by the fitness route-rank-check gate — not re-proven
115
+ // here. What this pins is the CLIENT side of the done-when: filing/resolving/
116
+ // linking are actually wired to those routes, resolve/link mirror the real
117
+ // floor cosmetically (never invent a stricter or looser one), filing itself
118
+ // carries no such gate, and a refusal renders as honest copy rather than a
119
+ // silent no-op.
120
+
121
+ test('blockers.js files a blocker against the real POST /blockers route', () => {
122
+ assert.match(BLOCKERS_JS_SRC, /writeJSON\('POST', API \+ '\/blockers', \{ title: title, body_md: note \}\)/);
123
+ });
124
+
125
+ test('blockers.js resolves against the real POST /blockers/:id/resolve route', () => {
126
+ assert.match(BLOCKERS_JS_SRC, /API \+ '\/blockers\/' \+ encodeURIComponent\(id\) \+ '\/resolve'/);
127
+ });
128
+
129
+ test('blockers.js links against the real POST /blockers/:id/link route, from both the file form and a row', () => {
130
+ const matches = BLOCKERS_JS_SRC.match(/\/blockers\/'\s*\+\s*encodeURIComponent\([^)]+\)\s*\+\s*'\/link'/g) || [];
131
+ assert.ok(matches.length >= 2, `expected the file-form link attempt AND the per-row link form, found ${matches.length}`);
132
+ });
133
+
134
+ test('filing has no rank gate — any signed-in builder sees the File button, mirroring the any-builder route', () => {
135
+ assert.match(BLOCKERS_JS_SRC, /fileArea\.innerHTML = fileFormHtml\(\); wireFileForm\(\);/,
136
+ 'the file form is mounted unconditionally, not behind an isReviewer()/rank check');
137
+ });
138
+
139
+ test('resolve/link controls mirror the SAME metic-or-archon floor the idea triage panel already uses — no floor of its own', () => {
140
+ assert.match(BLOCKERS_JS_SRC, /isReviewer\s*=\s*function\s*\(\)\s*\{\s*return viewer\.rank === 'metic' \|\| viewer\.rank === 'archon';\s*\}/);
141
+ assert.match(TASK_DETAIL_SRC, /kind === 'blocker' && r\.status === 'open' && \(rank === 'metic' \|\| rank === 'archon'\)/);
142
+ });
143
+
144
+ test('a 403 on resolve/link/file-link renders HONEST copy, not a silent no-op (the done-when, verbatim)', () => {
145
+ assert.match(BLOCKERS_JS_SRC, /err && err\.status === 403.*You need Metic\+ access to do that\./);
146
+ // The file-then-link flow specifically: a filer without blocker.review still
147
+ // gets told the filing succeeded AND that linking needs a Metic+ — never a
148
+ // swallowed catch that leaves them guessing whether anything happened.
149
+ assert.match(BLOCKERS_JS_SRC, /catch \(linkErr\) \{[\s\S]*?reviewerRefusalMessage\(linkErr,/);
150
+ });
151
+
152
+ test('the reviewer-only Metic+ message is scoped to resolve/link, never applied to filing itself (which has no rank floor)', () => {
153
+ // Two distinct helpers: a bare 403 on POST /blockers (filing) is NOT a
154
+ // blocker.review refusal and must not be read as one.
155
+ assert.match(BLOCKERS_JS_SRC, /function refusalMessage\(err, fallback\) \{\s*return errMessage\(err\) \|\| fallback;\s*\}/);
156
+ assert.match(BLOCKERS_JS_SRC, /function reviewerRefusalMessage\(err, fallback\)/);
157
+ assert.match(BLOCKERS_JS_SRC, /errEl\.textContent = refusalMessage\(err, 'Could not file that blocker\. Try again\.'\);/,
158
+ 'the FILE form\'s own catch uses the generic helper, not the reviewer-specific one');
159
+ });
160
+
161
+ test('a deduped file with a task # typed says the link was NOT attempted, rather than a silent no-op', () => {
162
+ assert.match(BLOCKERS_JS_SRC, /taskIdRaw\s*\n\s*\?\s*'That blocker was already filed\. It was NOT linked/);
163
+ });
164
+
165
+ test('a 401 on any blockers.js write redirects to sign-in, same as every other hall write', () => {
166
+ assert.match(BLOCKERS_JS_SRC, /res\.status === 401.*window\.location\.assign\(API \+ '\/auth\/web\/start'\)/);
167
+ });
168
+
169
+ test('task-detail.js\'s blocker resolve panel posts to the real route and reports a refusal honestly', () => {
170
+ assert.match(TASK_DETAIL_SRC, /function blockerPanel\(blocker\)/);
171
+ assert.match(TASK_DETAIL_SRC, /postJSON\(API \+ '\/blockers\/' \+ encodeURIComponent\(blocker\.id\) \+ '\/resolve'/);
172
+ assert.match(TASK_DETAIL_SRC, /function submitResolve[\s\S]*?err && err\.status === 403[\s\S]*?You need Metic\+ access to resolve blockers\./);
173
+ });
174
+
175
+ test('resolving through the overlay tells the two OTHER blocker surfaces to refresh, so a resolved row does not sit there stale', () => {
176
+ assert.match(TASK_DETAIL_SRC, /new CustomEvent\('otb:blocker-resolved', \{ detail: \{ id: blocker\.id \} \}\)/);
177
+ assert.match(GOAL_INBOX_SRC, /window\.addEventListener\('otb:blocker-resolved', loadBlockers\)/);
178
+ assert.match(BLOCKERS_JS_SRC, /window\.addEventListener\('otb:blocker-resolved', reload\)/);
179
+ });
180
+
181
+ test('blockers.html carries the file-form mount, above the list', () => {
182
+ const fileAt = BLOCKERS_HTML_SRC.indexOf('id="blockers-file-area"');
183
+ const bodyAt = BLOCKERS_HTML_SRC.indexOf('id="blockers-body"');
184
+ assert.ok(fileAt > -1 && bodyAt > -1);
185
+ assert.ok(fileAt < bodyAt, 'filing sits above the queue it adds to');
186
+ });
187
+
188
+ test('a row never carries .ov-row--lead — this row has no lead/face column, and oversight.css\'s --lead grid would misplace the actions column on a phone', () => {
189
+ assert.doesNotMatch(BLOCKERS_JS_SRC, /ov-row--lead/);
190
+ });
191
+
192
+ test('viewer carries no unread field — dead state is not left lying around', () => {
193
+ assert.doesNotMatch(BLOCKERS_JS_SRC, /\bauthed\b/, 'viewer.authed was written and never read');
194
+ });
195
+
196
+ test('a resolved blocker on the overlay shows what closed it, the idea triage_note precedent', () => {
197
+ assert.match(TASK_DETAIL_SRC, /kind === 'blocker' && r\.status === 'resolved'/);
198
+ assert.match(TASK_DETAIL_SRC, /'Resolved' \+ \(r\.resolution_note \? ': ' \+ r\.resolution_note : '\.'\)/);
199
+ });