@bongos/core 1.19.864 → 1.19.866

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.
@@ -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;
@@ -29,6 +29,10 @@ const api = require('../../../src/module-api');
29
29
  // depRefusalRow: the ONE shape a dependency refusal carries, shared with the claim
30
30
  // path so promote and claim describe a blocked task identically (task 1003553).
31
31
  const { depRefusalRow } = require('../dead-deps.js');
32
+ // gradeQualityWhere: the ONE definition of a task_grades row that is an actual
33
+ // quality verdict rather than an outage or an errored worker (task 1002652).
34
+ // The acclaim feed reads it so a grader outage never reaches a voter as 0.0.
35
+ const { gradeQualityWhere } = require('../db-shared');
32
36
  const auth = api;
33
37
  const db = require('../db');
34
38
  const hierarchy = require('../hierarchy');
@@ -771,9 +775,28 @@ module.exports = function buildTasksRouter() {
771
775
  t.shipped_at, t.shipped_by,
772
776
  b.github_login, b.display_name,
773
777
  COALESCE(pv.net, 0)::int AS net_votes,
774
- mine.vote AS my_vote
778
+ mine.vote AS my_vote,
779
+ -- task 1002835: a karma vote is uninformed without the two facts
780
+ -- the ship already recorded. BOTH stay NULL when absent — an
781
+ -- ungraded ship and a zero-scoring one are different claims, and
782
+ -- so are an unlogged cost and a free one, so neither is
783
+ -- COALESCEd to a number the client would print as a measurement.
784
+ --
785
+ -- BOTH JOINS ARE INDEXED LOOKUPS, not scans: task_grades.task_id
786
+ -- is declared UNIQUE (migrations/016_pms_task_grades.sql:14, which
787
+ -- gives it its own index) and cost_log.task_id carries
788
+ -- idx_cost_log_task (migrations/003_pms.sql:152). Neither needs a
789
+ -- migration here, and neither degrades as cost_log grows.
790
+ CASE WHEN ${gradeQualityWhere('g')} THEN g.score END AS grade_score,
791
+ CASE WHEN ${gradeQualityWhere('g')} THEN g.passed END AS grade_passed,
792
+ cost.total_usd AS cost_usd
775
793
  FROM tasks t
776
794
  LEFT JOIN builders b ON b.id = t.shipped_by
795
+ LEFT JOIN task_grades g ON g.task_id = t.id
796
+ LEFT JOIN LATERAL (
797
+ SELECT SUM(amount_usd) AS total_usd
798
+ FROM cost_log WHERE task_id = t.id
799
+ ) cost ON true
777
800
  LEFT JOIN LATERAL (
778
801
  SELECT SUM(vote) AS net
779
802
  FROM peer_votes WHERE target_task_id = t.id
@@ -788,7 +811,15 @@ module.exports = function buildTasksRouter() {
788
811
  LIMIT $2`,
789
812
  [req.builder.id, limit]
790
813
  );
791
- res.json({ tasks: rows, can_vote: ['metic', 'archon'].includes(req.builder.rank) });
814
+ const canVote = ['metic', 'archon'].includes(req.builder.rank);
815
+ // The cost is INTERNAL SPEND, and it is on this feed for one reason: to
816
+ // inform a vote. So it reaches only the ranks that can cast one. The grade
817
+ // is a quality verdict rather than spend and stays readable by any builder,
818
+ // as it already is on the grades table. Note the field is OMITTED, not
819
+ // nulled: a null here means "nothing was logged", and a reader who is not
820
+ // shown a figure must not be told a figure is missing.
821
+ const tasks = canVote ? rows : rows.map(({ cost_usd, ...rest }) => rest);
822
+ res.json({ tasks, can_vote: canVote });
792
823
  } catch (err) {
793
824
  // Log the detail server-side; don't return err.message — a raw Postgres
794
825
  // error can leak schema internals (table/column/constraint names).
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.864",
3
+ "version": "1.19.866",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.864",
9
+ "version": "1.19.866",
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.864",
3
+ "version": "1.19.866",
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",
@@ -71,6 +71,11 @@ const SUPPORT_MODULES = new Set(['helpers']);
71
71
 
72
72
  // Need a live DB / server / network — run in the integration-smoke gate instead.
73
73
  const INTEGRATION = new Set([
74
+ // task 1004004: the acclaim feed proves a claim about SQL semantics — that a
75
+ // grader outage nulls rather than scoring 0.0, and that a summed cost over no
76
+ // rows is NULL rather than a free ship. Only a transaction engine settles
77
+ // those, so this runs here and the DB-free sibling covers the renderer.
78
+ 'acclaim_feed_evidence_db',
74
79
  'asset_version', 'auth_flow_slice1', 'backfill_sessions', 'bfg', 'bfg_write',
75
80
  'box_connect_e2e', 'box_onboard', 'boxes', 'cf_tunnel', 'cli_token_reissue',
76
81
  // consumer_layout_boot builds the core .tgz, `npm install`s it, and boots a live
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.864'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.866'; // 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');
@@ -0,0 +1,186 @@
1
+ // tests/acclaim_feed_evidence_db.mjs — the peer-acclaim feed's grade + cost
2
+ // evidence, against a REAL Postgres (task 1004004, from task 1002835).
3
+ //
4
+ // WHY THIS EXISTS AND ITS DB-FREE SIBLING IS NOT ENOUGH.
5
+ // tests/hall_builders_page_pass.mjs runs the RENDERER over every shape the row
6
+ // can take, and asserts the route's SQL by reading its source. That proves the
7
+ // client is honest about a NULL — it cannot prove the query ever PRODUCES one.
8
+ // The whole argument of part C is a claim about SQL semantics that only a
9
+ // transaction engine can settle:
10
+ //
11
+ // - a grader OUTAGE row carries score 0 / passed=false in task_grades, and
12
+ // gradeQualityWhere must turn that into NULL rather than let a voter read
13
+ // it as a 0.0 verdict;
14
+ // - SUM(amount_usd) over ZERO cost_log rows is NULL, not 0, which is the
15
+ // difference between "no cost logged" and "this ship was free";
16
+ // - a ship with several cost rows must sum them, not pick one;
17
+ // - the two LATERALs and the grades join must not multiply the row out.
18
+ //
19
+ // Runs the route's own query text — sliced out of the shipped source, never
20
+ // retyped — so a future edit to the route is tested rather than a copy of it.
21
+ // Self-skips without a reachable Postgres; in the INTEGRATION set, so the
22
+ // DB-free unit gate never runs it and .github/workflows/integration.yml does.
23
+ //
24
+ // Run: DATABASE_URL=... node tests/acclaim_feed_evidence_db.mjs
25
+ import { createRequire } from 'node:module';
26
+ import { strict as assert } from 'node:assert';
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+
31
+ const require = createRequire(import.meta.url);
32
+ const { Pool } = require('pg');
33
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
34
+
35
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
36
+ try {
37
+ await pool.query('SELECT 1');
38
+ } catch (e) {
39
+ console.log(`acclaim_feed_evidence_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
40
+ await pool.end().catch(() => {});
41
+ process.exit(0);
42
+ }
43
+
44
+ // The query under test, taken from the shipped route. gradeQualityWhere is
45
+ // interpolated by the route at call time, so interpolate it the same way.
46
+ const { gradeQualityWhere } = require('../modules/lifecycle/db-shared.js');
47
+ const routeSrc = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/routes/tasks.js'), 'utf8');
48
+ const start = routeSrc.indexOf('`SELECT t.id, t.title, t.value_summary, t.version_id, t.status,');
49
+ assert.ok(start > 0, 'the peer-votes feed query is still where this test slices it from');
50
+ const end = routeSrc.indexOf('LIMIT $2`', start);
51
+ assert.ok(end > start, 'the feed query still ends at its LIMIT');
52
+ const SQL = routeSrc
53
+ .slice(start + 1, end + 'LIMIT $2'.length)
54
+ .replace(/\$\{gradeQualityWhere\('g'\)\}/g, gradeQualityWhere('g'));
55
+ assert.ok(!SQL.includes('${'), 'every interpolation in the sliced query was resolved');
56
+
57
+ const VOTER = 90000801;
58
+ const SHIPPER = 90000802;
59
+ const VER = 'ZZZ-ACC';
60
+ // graded+costed, outage, ungraded, costed-twice, zero-cost
61
+ const T = { GOOD: 90000811, OUTAGE: 90000812, UNGRADED: 90000813, TWICE: 90000814, FREE: 90000815 };
62
+ const IDS = Object.values(T);
63
+
64
+ const q = (sql, params) => pool.query(sql, params).then((r) => r.rows);
65
+
66
+ async function cleanup() {
67
+ await q('DELETE FROM peer_votes WHERE target_task_id = ANY($1)', [IDS]);
68
+ await q('DELETE FROM cost_log WHERE task_id = ANY($1)', [IDS]);
69
+ await q('DELETE FROM task_grades WHERE task_id = ANY($1)', [IDS]);
70
+ await q('DELETE FROM tasks WHERE id = ANY($1)', [IDS]);
71
+ await q('DELETE FROM builders WHERE id = ANY($1)', [[VOTER, SHIPPER]]);
72
+ await q('DELETE FROM versions WHERE id = $1', [VER]);
73
+ }
74
+
75
+ let failures = 0;
76
+ const check = (name, fn) => {
77
+ try { fn(); console.log(` PASS ${name}`); } catch (e) {
78
+ failures += 1; console.log(` FAIL ${name}\n ${e.message}`);
79
+ }
80
+ };
81
+
82
+ try {
83
+ await cleanup();
84
+
85
+ await q("INSERT INTO versions (id, name, status) VALUES ($1, 'acclaim evidence fixture', 'planning')", [VER]);
86
+ for (const [id, login] of [[VOTER, 'zz-acc-voter'], [SHIPPER, 'zz-acc-shipper']]) {
87
+ await q("INSERT INTO builders (id, github_login, display_name, rank) VALUES ($1, $2, $2, 'metic')", [id, login]);
88
+ }
89
+ for (const [id, title] of [
90
+ [T.GOOD, '__acc__ graded and costed'],
91
+ [T.OUTAGE, '__acc__ grader outage'],
92
+ [T.UNGRADED, '__acc__ never graded'],
93
+ [T.TWICE, '__acc__ two cost rows'],
94
+ [T.FREE, '__acc__ a real zero'],
95
+ ]) {
96
+ await q(
97
+ `INSERT INTO tasks (id, version_id, title, status, shipped_by, shipped_at, discipline, kind)
98
+ VALUES ($1, $2, $3, 'shipped', $4, now(), 'engineer', 'feature')`,
99
+ [id, VER, title, SHIPPER],
100
+ );
101
+ }
102
+
103
+ const grade = (taskId, score, passed, rubric, signals) => q(
104
+ `INSERT INTO task_grades (task_id, score, passed, threshold, grader_kind, grader_version, rubric_scores, signals)
105
+ VALUES ($1, $2, $3, 7.0, 'subagent', 'test', $4::jsonb, $5::jsonb)`,
106
+ [taskId, score, passed, JSON.stringify(rubric), JSON.stringify(signals)],
107
+ );
108
+ const FULL = { functional_fidelity: 8, code_quality: 8 };
109
+ await grade(T.GOOD, 8.5, true, FULL, {});
110
+ // The shape that matters: an outage parks score 0 / passed=false with
111
+ // json-null rubric axes. A voter must never read that as a 0.0 verdict.
112
+ await grade(T.OUTAGE, 0, false, { functional_fidelity: null, code_quality: null }, { panel_outcome: 'unavailable' });
113
+ await grade(T.TWICE, 6.0, false, FULL, {});
114
+ await grade(T.FREE, 0, false, FULL, {});
115
+
116
+ const cost = (taskId, amount) => q(
117
+ "INSERT INTO cost_log (task_id, amount_usd, category, source) VALUES ($1, $2, 'api', 'test')",
118
+ [taskId, amount],
119
+ );
120
+ await cost(T.GOOD, '12.3456');
121
+ await cost(T.TWICE, '1.50');
122
+ await cost(T.TWICE, '2.25');
123
+ await cost(T.FREE, '0');
124
+ // T.OUTAGE and T.UNGRADED get no cost row at all.
125
+
126
+ const rows = await q(SQL, [VOTER, 100]);
127
+ const byId = new Map(rows.map((r) => [String(r.id), r]));
128
+ const got = (id) => {
129
+ const row = byId.get(String(id));
130
+ assert.ok(row, `task ${id} is on the feed`);
131
+ return row;
132
+ };
133
+
134
+ check('every fixture ship reaches the feed exactly once (the joins do not multiply rows)', () => {
135
+ for (const id of IDS) got(id);
136
+ for (const id of IDS) {
137
+ const n = rows.filter((r) => String(r.id) === String(id)).length;
138
+ assert.equal(n, 1, `task ${id} appears once, not ${n}`);
139
+ }
140
+ });
141
+
142
+ check('a graded, costed ship carries both figures', () => {
143
+ const r = got(T.GOOD);
144
+ assert.equal(Number(r.grade_score), 8.5);
145
+ assert.equal(r.grade_passed, true);
146
+ assert.equal(Number(r.cost_usd), 12.3456);
147
+ });
148
+
149
+ check('a GRADER OUTAGE arrives as NULL, never as its placeholder 0', () => {
150
+ const r = got(T.OUTAGE);
151
+ assert.equal(r.grade_score, null, 'the outage score is nulled by gradeQualityWhere');
152
+ assert.equal(r.grade_passed, null, 'and so is its passed flag — no verdict was reached');
153
+ });
154
+
155
+ check('a ship that was never graded has no grade row and so no score', () => {
156
+ assert.equal(got(T.UNGRADED).grade_score, null);
157
+ });
158
+
159
+ check('an unlogged cost is NULL, not 0 — the sum is never COALESCEd', () => {
160
+ assert.equal(got(T.UNGRADED).cost_usd, null, 'no cost_log row means no figure, not a free ship');
161
+ assert.equal(got(T.OUTAGE).cost_usd, null);
162
+ });
163
+
164
+ check('several cost rows sum rather than one winning', () => {
165
+ assert.equal(Number(got(T.TWICE).cost_usd), 3.75);
166
+ });
167
+
168
+ check('a genuine zero survives, because it IS a measurement', () => {
169
+ const r = got(T.FREE);
170
+ assert.equal(Number(r.cost_usd), 0, 'a logged 0 is a logged 0, distinct from NULL');
171
+ assert.notEqual(r.cost_usd, null);
172
+ assert.equal(Number(r.grade_score), 0, 'a real 0.0 verdict is not an outage and is not nulled');
173
+ assert.equal(r.grade_passed, false);
174
+ });
175
+
176
+ check('the caller never sees their own ships, so nobody votes on themselves', () => {
177
+ assert.ok(!rows.some((r) => String(r.shipped_by) === String(VOTER)));
178
+ });
179
+ } finally {
180
+ await cleanup().catch(() => {});
181
+ await pool.end().catch(() => {});
182
+ }
183
+
184
+ const passed = 8 - failures;
185
+ console.log(`\nacclaim_feed_evidence_db: ${passed} passed, ${failures} failed`);
186
+ process.exit(failures ? 1 : 0);
@@ -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
+ });