@bongos/core 1.19.827 → 1.19.829

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.
@@ -0,0 +1,110 @@
1
+ // blockers-lib.js — the ONE blocker-row renderer (task 1003455, goal 1000089).
2
+ //
3
+ // GET /blockers (modules/ideas/routes/blockers.js) had zero consumers — not the
4
+ // hall, not status-ui — and the only blocker a builder could ever see was
5
+ // GET /blockers/:id, reachable only by typing #/blocker/N into the address bar.
6
+ // Owner decision 2026-08-30: BOTH surfaces, from ONE code path — the open-blocker
7
+ // queue gets a real page (modules/hall-ui/public/blockers.js), and open blockers
8
+ // also become line items in the existing Needs-attention inbox
9
+ // (modules/hall-ui/public/goal-inbox.js), because hall-ui/CLAUDE.md's placement
10
+ // rule says an action awaiting a human is an inbox line, never a new Home card.
11
+ //
12
+ // This file is the shared middle: the goals-lib.js / goalRenderFacts precedent —
13
+ // ONE facts source that both callers render from their own DOM shape, so the two
14
+ // surfaces cannot drift apart the way ~10 hand-rolled pagers did before the kit.
15
+ // The two callers do not share a visual family (the inbox nests inside
16
+ // .ginbox__item, the page's rows are the oversight family's .ov-row), so — unlike
17
+ // goalRenderFacts, whose two callers share one family and one fixed class — the
18
+ // class names are the caller's own, passed in `opts`; only the FACTS (title, the
19
+ // filed date, the note, which tasks it blocks) are shared.
20
+ //
21
+ // Pure: no window.OTB, no DOM, no fetch — Node-require-safe like goals-lib.js /
22
+ // hall-kit.js, so a test can run the real arithmetic instead of agreeing with a
23
+ // copy of it.
24
+ (function (root, factory) {
25
+ var mod = factory();
26
+ if (typeof module !== 'undefined' && module.exports) module.exports = mod;
27
+ else root.OTBBlockersLib = mod;
28
+ }(typeof self !== 'undefined' ? self : this, function () {
29
+ 'use strict';
30
+
31
+ function esc(s) {
32
+ return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
33
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
34
+ });
35
+ }
36
+
37
+ function fmtDate(iso) {
38
+ if (!iso) return '';
39
+ var d = new Date(iso);
40
+ if (isNaN(d.getTime())) return '';
41
+ return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
42
+ }
43
+
44
+ // A blocker's body_md is the owner's own words, quoted the way goal-inbox.js's
45
+ // invitation/join rows already quote a note (the SAME convention, not a new
46
+ // one). It can run to a paragraph, and the row is a summary, so it is clipped —
47
+ // CSS line-clamp handles visual overflow on both surfaces, this just keeps one
48
+ // enormous blocker from making the row's own text node absurd.
49
+ function noteHtml(bodyMd, noteClass) {
50
+ var t = String(bodyMd || '').trim();
51
+ if (!t) return '';
52
+ var clipped = t.length > 240 ? t.slice(0, 239).replace(/\s+$/, '') + '…' : t;
53
+ return '<p class="' + esc(noteClass) + '">“' + esc(clipped) + '”</p>';
54
+ }
55
+
56
+ // `taskHref(id)` is the caller's own link builder (window.OTBTaskDetail.href on
57
+ // a live page) — kept OUT of this pure module the way hall-kit.js stays
58
+ // date-agnostic: the lib never learns what a task URL looks like.
59
+ function blockingLine(b, taskHref) {
60
+ var ids = Array.isArray(b.blocking_task_ids) ? b.blocking_task_ids : [];
61
+ if (!ids.length) return 'Blocking nothing yet';
62
+ var links = ids.map(function (id) {
63
+ var label = '#' + id;
64
+ return taskHref ? '<a href="' + esc(taskHref(id)) + '">' + esc(label) + '</a>' : esc(label);
65
+ }).join(', ');
66
+ var noun = ids.length === 1 ? 'task' : 'tasks';
67
+ return 'Blocking ' + ids.length + ' ' + noun + ': ' + links;
68
+ }
69
+
70
+ // blockerFacts(b, opts) — the shared render. `opts`:
71
+ // titleClass (default 'ov-row__title')
72
+ // noteClass (default 'ov-row__sub')
73
+ // whenClass (default 'ov-row__when')
74
+ // blockerHref(id) — the title's own link (both surfaces point at the SAME
75
+ // #/blocker/N overlay task-detail.js already renders —
76
+ // the "reachable only by typing the URL" bug this task
77
+ // fixes is exactly that overlay having no door INTO it).
78
+ // taskHref(id) — see blockingLine above.
79
+ // Returns { titleHtml, noteHtml, factsHtml } — fragments, not a wrapped row;
80
+ // each caller nests them in its OWN <li> (goal-inbox.js's .ginbox__item,
81
+ // blockers.js's .ov-row), the goalRenderFacts idiom.
82
+ function blockerFacts(b, opts) {
83
+ opts = opts || {};
84
+ var titleClass = opts.titleClass || 'ov-row__title';
85
+ var noteClass = opts.noteClass || 'ov-row__sub';
86
+ var whenClass = opts.whenClass || 'ov-row__when';
87
+ var blockerHref = opts.blockerHref || null;
88
+ var taskHref = opts.taskHref || null;
89
+
90
+ var titleText = esc(b.title || ('Blocker #' + b.id));
91
+ var titleHtml = blockerHref
92
+ ? '<a class="' + esc(titleClass) + '" href="' + esc(blockerHref(b.id)) + '">' + titleText + '</a>'
93
+ : '<span class="' + esc(titleClass) + '">' + titleText + '</span>';
94
+
95
+ // ONE fact line, `·`-joined — the watch.js convention (its box/report rows
96
+ // join multiple facts into a single .ov-row__when span the same way) rather
97
+ // than several sibling spans, which need a flex-gapped parent to space
98
+ // themselves and the attention-inbox context (.ginbox__sub) is not one.
99
+ var facts = 'Filed ' + esc(fmtDate(b.created_at)) + ' · ' + blockingLine(b, taskHref);
100
+ var factsHtml = '<span class="' + esc(whenClass) + '">' + facts + '</span>';
101
+
102
+ return {
103
+ titleHtml: titleHtml,
104
+ noteHtml: noteHtml(b.body_md, noteClass),
105
+ factsHtml: factsHtml,
106
+ };
107
+ }
108
+
109
+ return { esc: esc, fmtDate: fmtDate, blockingLine: blockingLine, blockerFacts: blockerFacts };
110
+ }));
@@ -0,0 +1,65 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Blockers — {{worldName}}</title>
7
+ <meta name="description" content="What is stuck on the owner right now: open blockers and the work they hold up.">
8
+ <link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
12
+ <!-- task 1003455: the row shape (title + facts) is the oversight family's,
13
+ the same archetype collab/copy-desk/watch/gate already ride. -->
14
+ <link rel="stylesheet" href="/builders/oversight.css">
15
+ </head>
16
+ <body data-page="blockers" data-page-title="Blockers">
17
+
18
+ <div class="app">
19
+ <aside class="app-sidebar" id="app-sidebar" aria-label="Primary"></aside>
20
+ <div class="app-scrim" id="app-scrim" hidden></div>
21
+ <div class="app-body">
22
+ <header class="app-topbar" id="app-topbar"></header>
23
+ <div class="app-content">
24
+ <div class="page-head page-head--board">
25
+ <h1 class="page-head__h">Blockers</h1>
26
+ <p class="page-head__sub" id="blockers-sub">Things stuck on the owner — a credential, a purchase, a product call only they can make. Resolving one un-sticks every task waiting on it automatically.</p>
27
+ </div>
28
+
29
+ <main id="hall-main">
30
+ <!-- task 1003455 (goal 1000089): the queue GET /blockers had no page for
31
+ — the only door in used to be typing #/blocker/N into the address
32
+ bar. This is the read half; filing/resolving/linking is task 1003456. -->
33
+ <section class="scroll" id="blockers-scroll" aria-labelledby="blockers-h">
34
+ <h2 id="blockers-h" class="scroll__h">Open blockers<span class="count-pill" id="blockers-count" hidden></span></h2>
35
+ <div id="blockers-body">
36
+ <p class="profile__placeholder">Loading…</p>
37
+ </div>
38
+ </section>
39
+ </main>
40
+
41
+ <footer class="app-foot" id="app-foot"></footer>
42
+ </div>
43
+ </div>
44
+ </div>
45
+
46
+ <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
47
+
48
+ <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
49
+ chrome exists before the page script runs. -->
50
+ <script src="/builders/shell.js?v=2026-07-02-shell1"></script>
51
+ <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
52
+ <script src="/builders/palette.js" defer></script>
53
+ <!-- shared frontend toolkit (window.OTB — rank labels, number formats) + the
54
+ branding pack's currency label, loaded BEFORE blockers.js. -->
55
+ <script src="/builders/dom-utils.js?v=2026-06-14-domutils"></script>
56
+ <script src="/builders/brand-apply.js"></script>
57
+ <script src="/builders/hall-kit.js"></script>
58
+ <!-- task-detail.js still owns the #/blocker/N deep-link overlay every row's
59
+ title opens into — this page needs its link-builder (OTBTaskDetail.href)
60
+ for the "blocking N tasks" facts line, but not the overlay's own render. -->
61
+ <script src="/builders/task-detail.js?v=2026-07-03-taskdetail"></script>
62
+ <script src="/builders/blockers-lib.js"></script>
63
+ <script src="/builders/blockers.js"></script>
64
+ </body>
65
+ </html>
@@ -0,0 +1,88 @@
1
+ // blockers.js — the Blockers page: the full open-blocker queue (task 1003455,
2
+ // goal 1000089).
3
+ //
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).
12
+ //
13
+ // Read-only: filing, resolving and linking a blocker to a task are task 1003456.
14
+ // Deliberately UNGATED (tests/hall_page_gate_map.mjs) — its one data source is
15
+ // public, the roster/drachmae precedent: a page must not be gated tighter than
16
+ // the API it fronts.
17
+ (function () {
18
+ 'use strict';
19
+ var kit = window.OTBKit;
20
+ var lib = window.OTBBlockersLib;
21
+ var API = '/api/bongos';
22
+ var body = document.getElementById('blockers-body');
23
+ var countPill = document.getElementById('blockers-count');
24
+ var sub = document.getElementById('blockers-sub');
25
+
26
+ var taskHref = function (id) {
27
+ return window.OTBTaskDetail ? window.OTBTaskDetail.href(id) : ('/task/' + encodeURIComponent(id));
28
+ };
29
+ var blockerHref = function (id) { return '#/blocker/' + encodeURIComponent(id); };
30
+
31
+ var all = [];
32
+
33
+ function rowHtml(b) {
34
+ var facts = lib.blockerFacts(b, {
35
+ titleClass: 'ov-row__title',
36
+ noteClass: 'ov-row__sub',
37
+ whenClass: 'ov-row__when',
38
+ blockerHref: blockerHref,
39
+ taskHref: taskHref,
40
+ });
41
+ return '<li class="ov-row" data-id="' + lib.esc(String(b.id)) + '">'
42
+ + '<div class="ov-row__main">'
43
+ + facts.titleHtml
44
+ + facts.noteHtml
45
+ + '<div class="ov-row__facts">' + facts.factsHtml + '</div>'
46
+ + '</div>'
47
+ + '</li>';
48
+ }
49
+
50
+ var pager = kit.makePager('blockers', 'hall-blockers-per-page', { defaultPerPage: 25 });
51
+ var listEl = null;
52
+
53
+ function rerender() {
54
+ var slice = kit.paginate(all, pager);
55
+ listEl.innerHTML = slice.length
56
+ ? slice.map(rowHtml).join('')
57
+ : kit.emptyStateHtml('Nothing is stuck on the owner right now.');
58
+ }
59
+
60
+ function mountBlockers(rows) {
61
+ all = Array.isArray(rows) ? rows : [];
62
+ if (countPill) { countPill.textContent = String(all.length); countPill.hidden = all.length === 0; }
63
+ if (sub) {
64
+ sub.textContent = all.length
65
+ ? (all.length === 1 ? 'One thing' : all.length + ' things') + ' stuck on the owner — a credential, a '
66
+ + 'purchase, a product call only they can make. Resolving one un-sticks every task waiting on it automatically.'
67
+ : 'Nothing is stuck on the owner right now.';
68
+ }
69
+ body.innerHTML = '<ul class="ov-rows" id="blockers-list"></ul>' + kit.pagerBarHtml(pager.prefix, { label: 'blockers' });
70
+ listEl = document.getElementById('blockers-list');
71
+ kit.wirePager(pager, rerender);
72
+ rerender();
73
+ }
74
+
75
+ function notice(msg) {
76
+ body.innerHTML = '';
77
+ body.className = 'note';
78
+ body.textContent = msg;
79
+ }
80
+
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.'); });
88
+ }());
@@ -384,6 +384,73 @@
384
384
  });
385
385
  }
386
386
 
387
+ // ---- open blockers (task 1003455, goal 1000089) ---------------------------
388
+ //
389
+ // GET /blockers is public and had ZERO hall consumers — the only blocker a
390
+ // builder could ever see was the #/blocker/N overlay task-detail.js already
391
+ // renders, reachable only by typing the id into the address bar. This slice
392
+ // + the /blockers page (blockers.js) are ONE code path: both render from
393
+ // blockers-lib.js's blockerFacts(), loaded before this file (index.html).
394
+ const MAX_BLOCKER_ROWS = 5;
395
+
396
+ function blockerRowHtml(b) {
397
+ const lib = window.OTBBlockersLib;
398
+ const facts = lib.blockerFacts(b, {
399
+ titleClass: 'ginbox__goal',
400
+ noteClass: 'ginbox__note',
401
+ whenClass: 'ginbox__sub',
402
+ // The SAME overlay task-detail.js has rendered since task 1003066's
403
+ // predecessor — this slice is what gives it its first door in.
404
+ blockerHref: (id) => `#/blocker/${encodeURIComponent(id)}`,
405
+ taskHref: (id) => (window.OTBTaskDetail ? window.OTBTaskDetail.href(id) : `/task/${encodeURIComponent(id)}`),
406
+ });
407
+ return `<li class="ginbox__item">
408
+ <div class="ginbox__body">
409
+ ${facts.titleHtml}
410
+ ${facts.factsHtml}
411
+ ${facts.noteHtml}
412
+ </div>
413
+ </li>`;
414
+ }
415
+
416
+ function renderBlockers(data) {
417
+ const box = host && host.querySelector('#attention-blockers');
418
+ if (!box) return;
419
+ const rows = (data && Array.isArray(data.blockers)) ? data.blockers : [];
420
+ if (!rows.length) {
421
+ box.innerHTML = '';
422
+ window.OTBAttention?.refresh();
423
+ return;
424
+ }
425
+ const total = (data && data.page && Number.isFinite(data.page.total)) ? data.page.total : rows.length;
426
+ const more = total > rows.length
427
+ ? `<p class="ginbox__pending">…${total - rows.length} more — <a href="/blockers">open the queue</a>.</p>`
428
+ : '';
429
+ box.innerHTML = '<h3 class="ginbox__sec-h">Stuck on the owner '
430
+ + `<span class="count-pill">${total}</span></h3>`
431
+ + `<ul class="ginbox">${rows.map(blockerRowHtml).join('')}</ul>${more}`;
432
+ window.OTBAttention?.refresh();
433
+ }
434
+
435
+ // task 1003448's "Home bounded reads" rule: ask the server for exactly the
436
+ // rows the card will show (limit=MAX_BLOCKER_ROWS) and read `page.total` back,
437
+ // rather than fetching every open blocker and slicing it client-side — the
438
+ // Next-up / triage-card bug this file's own CLAUDE.md names as the shape to
439
+ // avoid.
440
+ function loadBlockers() {
441
+ return getJSON(`${API}/blockers?limit=${MAX_BLOCKER_ROWS}`)
442
+ .then((data) => renderBlockers(data))
443
+ .catch((err) => {
444
+ const box = host && host.querySelector('#attention-blockers');
445
+ if (box) box.innerHTML = '';
446
+ window.OTBAttention?.refresh();
447
+ if (err.code !== 'auth-needed' && err.code !== 'not-found') {
448
+ console.warn('[hall] blockers feed unavailable — the "Stuck on the owner" section is empty '
449
+ + 'because the request failed, NOT because nothing is open:', err);
450
+ }
451
+ });
452
+ }
453
+
387
454
  // task 2169: this widget IS the "Needs your attention" card — ONE capped
388
455
  // summary hosting every actionable item class: goal invitations +
389
456
  // join-requests (this file) and ideas awaiting triage (builders.js renders
@@ -417,6 +484,12 @@
417
484
  // triage. Its rows carry the shared .ginbox__item class, so refresh()
418
485
  // counts them with no change to the selector below.
419
486
  '<div id="attention-nod"></div>' +
487
+ // task 1003455 (goal 1000089): open blockers — things stuck on the owner
488
+ // (modules/ideas/blockers.js). AFTER nod, BEFORE rot: like every slice
489
+ // above it, a blocker is genuinely waiting on a human, but unlike them it
490
+ // is project-wide rather than addressed to this viewer, so it sits with
491
+ // rot's standing-question tail rather than the personal asks at the top.
492
+ '<div id="attention-blockers"></div>' +
420
493
  // task 1003279 (goal 1000072): work going quiet — a goal or task untouched
421
494
  // past the project's rot timer, with the owner's four verbs on it. LAST in
422
495
  // the card on purpose: every slice above is somebody waiting on YOU right
@@ -426,6 +499,7 @@
426
499
  host.addEventListener('click', onClick);
427
500
  load();
428
501
  loadNodQueue();
502
+ loadBlockers();
429
503
  loadRot();
430
504
  // task 1002802 (P06): the profile slice lives in its own file (builders.js is
431
505
  // over the size budget); this card owns its div, so it boots it here.
@@ -121,6 +121,10 @@
121
121
  <script src="/builders/hall-widgets.js?v=2026-06-27-r75" defer></script>
122
122
  <!-- BV1.R78 (task 1694): the Goals widget registers into the same registry. -->
123
123
  <script src="/builders/goals.js?v=2026-07-01-goals" defer></script>
124
+ <!-- task 1003455: the shared blocker-row renderer (window.OTBBlockersLib), read by
125
+ both goal-inbox.js's attention-inbox slice below and the /blockers page.
126
+ Loaded BEFORE goal-inbox.js, which calls it while rendering. -->
127
+ <script src="/builders/blockers-lib.js" defer></script>
124
128
  <!-- ADR 0112 (task 1954): the home-page goal-membership inbox — invitations to
125
129
  accept + join-requests to approve. Self-registers; hidden when empty. -->
126
130
  <script src="/builders/goal-inbox.js?v=2026-07-10-goal-inbox" defer></script>
@@ -165,6 +165,15 @@
165
165
  // active-nav detection (#nav-work), the 'work' gate case, and the server
166
166
  // page-gate all keep working — only the visible label and order change.
167
167
  { id: 'work', label: 'Tasks', icon: 'work', href: '/work', gate: 'signed' },
168
+ // task 1003455 (goal 1000089): the open-blocker queue — what's stuck on the
169
+ // owner. Lives with the project's own state (Roadmap/Goals/Tasks), not You
170
+ // or Community, because its subject is the project, not a person. Its one
171
+ // data source (GET /blockers) is deliberately public, like Roadmap's — but
172
+ // the NAV FLAG stays 'signed' on the roster/drachmae precedent: an ungated
173
+ // page can still gate cosmetically wider in the sidebar than the API it
174
+ // fronts, showing the link to signed-in builders while a stranger who
175
+ // already has the URL can still open it.
176
+ { id: 'blockers', label: 'Blockers', icon: 'admin', href: '/blockers', gate: 'signed' },
168
177
  // task 1003744 (goal 1000111): the Board Room lives HERE, in the project's
169
178
  // own group, and gates on the atom its page gates on.
170
179
  //
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.827",
3
+ "version": "1.19.829",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.827",
9
+ "version": "1.19.829",
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.827",
3
+ "version": "1.19.829",
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",
@@ -191,6 +191,24 @@ const KNOWN_FAILING = new Set([
191
191
  'served_paths_shipped',
192
192
  ]);
193
193
 
194
+ // Task 1003845: on Windows + Node 24 a suite that hard-exits after serving HTTP can
195
+ // print a clean verdict and THEN abort natively (the cliExit class in
196
+ // scripts/gds/CLAUDE.md), exiting 127 or 0xC0000409 depending on timing. Counted as
197
+ // FAILED, these swap in and out of a Windows baseline, so a new failure can hide
198
+ // behind a vanished one. Recognised by signature plus the suite's own verdict —
199
+ // never by a list of names, which docs/recipes/windows-builders.md shows goes stale
200
+ // in hours. Only the `N passed, M failed` summary counts as a verdict: node:test
201
+ // suites set exitCode rather than hard-exiting, so they do not hit this.
202
+ const LIBUV_TEARDOWN_ABORT = /Assertion failed: !\(handle->flags & UV_HANDLE_CLOSING\)/;
203
+
204
+ function isCleanTeardownAbort(stdout, stderr) {
205
+ if (!LIBUV_TEARDOWN_ABORT.test(stderr)) return false;
206
+ // Line-leading only: a passing case titled "... is FAIL-CLOSED" is not a failure.
207
+ if (/^\s*FAIL\b/m.test(stdout)) return false;
208
+ const lastLine = stdout.trim().split('\n').pop();
209
+ return /\b[1-9]\d* passed, 0 failed\s*$/.test(lastLine);
210
+ }
211
+
194
212
  // Discover modules/<key>/tests/*.mjs. Pure-ish (reads disk; `isEnabled` is
195
213
  // injected so this is unit-testable with zero real modules/ or DB config —
196
214
  // mirrors the injected-dependency pattern the module loader itself uses).
@@ -266,6 +284,7 @@ function main() {
266
284
  const total = files.length + moduleTests.length;
267
285
 
268
286
  const failures = [];
287
+ const teardownAborts = [];
269
288
  const runOne = (displayName, filePath) => {
270
289
  try {
271
290
  execFileSync(process.execPath, [filePath], {
@@ -283,8 +302,13 @@ function main() {
283
302
  console.log(` ok ${displayName}`);
284
303
  } catch (err) {
285
304
  const why = err.killed ? 'TIMEOUT' : `exit ${err.status}`;
286
- console.log(` FAIL ${displayName} (${why})`);
287
305
  const stdoutText = (err.stdout ? err.stdout.toString() : '').trim();
306
+ if (!err.killed && isCleanTeardownAbort(stdoutText, err.stderr ? err.stderr.toString() : '')) {
307
+ console.log(` ok ${displayName} (clean verdict, then libuv teardown abort, ${why})`);
308
+ teardownAborts.push(displayName);
309
+ return;
310
+ }
311
+ console.log(` FAIL ${displayName} (${why})`);
288
312
  // The actual failures first: the file's own FAIL lines, then its stdout
289
313
  // tail (the last failure's assertion detail + summary), both bounded.
290
314
  const failLines = stdoutText ? stdoutText.split('\n').filter((l) => /\bFAIL\b/.test(l)).slice(0, 5) : [];
@@ -323,6 +347,9 @@ function main() {
323
347
 
324
348
  const label = integrationMode ? 'run-unit-tests --integration' : 'run-unit-tests';
325
349
  console.log('');
350
+ if (teardownAborts.length) {
351
+ console.log(`${label}: ${teardownAborts.length} passed but aborted in libuv teardown (not failures; task 1003845) — ${teardownAborts.join(', ')}`);
352
+ }
326
353
  if (failures.length === 0) {
327
354
  console.log(`${label}: all ${total} passed.`);
328
355
  process.exit(0);
@@ -333,4 +360,4 @@ function main() {
333
360
 
334
361
  if (require.main === module) main();
335
362
 
336
- module.exports = { discoverModuleTests };
363
+ module.exports = { discoverModuleTests, isCleanTeardownAbort };
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.827'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.829'; // 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,107 @@
1
+ // tests/blockers_hall.mjs — task 1003455 (goal 1000089): "Blockers become
2
+ // visible: the queue page and the attention slice, from one code path."
3
+ //
4
+ // GET /blockers had zero hall consumers before this task. Done-when: the queue
5
+ // renders on a real page from GET /blockers, the attention inbox shows open
6
+ // blockers as line items, BOTH read the same renderer (blockers-lib.js, pinned
7
+ // separately in tests/blockers_lib.mjs), and the page's list is paged through
8
+ // OTBKit rather than hand-rolled. This file pins the WIRING — that both
9
+ // consumers actually call the shared lib and the real API, and that the page
10
+ // uses the kit's pager rather than reimplementing slicing.
11
+ //
12
+ // Source-regex style, matching tests/rot_inbox.mjs's checks on the same file —
13
+ // pure text assertions over the real client source, no DOM/vm harness needed for
14
+ // what this pins (presence + call shape, not runtime behaviour).
15
+ //
16
+ // Run: node tests/blockers_hall.mjs
17
+
18
+ import { strict as assert } from 'node:assert';
19
+ import { test } from 'node:test';
20
+ import { readFileSync } from 'node:fs';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { dirname, join } from 'node:path';
23
+
24
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
25
+ const HALL = join(ROOT, 'modules/hall-ui/public');
26
+ const GOAL_INBOX_SRC = readFileSync(join(HALL, 'goal-inbox.js'), 'utf8');
27
+ const BLOCKERS_JS_SRC = readFileSync(join(HALL, 'blockers.js'), 'utf8');
28
+ const BLOCKERS_HTML_SRC = readFileSync(join(HALL, 'blockers.html'), 'utf8');
29
+ const INDEX_HTML_SRC = readFileSync(join(HALL, 'index.html'), 'utf8');
30
+
31
+ // ---- the attention-inbox slice (goal-inbox.js) -----------------------------
32
+
33
+ test('goal-inbox.js carries a blockers slice div in the SAME needs-attention card', () => {
34
+ assert.match(GOAL_INBOX_SRC, /<div id="attention-blockers"><\/div>/,
35
+ 'a second Home card would violate the placement rule (hall-ui/CLAUDE.md): an action awaiting a human is an inbox line, never a new card');
36
+ });
37
+
38
+ test('goal-inbox.js reads the real GET /blockers route, not a stand-in', () => {
39
+ assert.match(GOAL_INBOX_SRC, /getJSON\(`\$\{API\}\/blockers/);
40
+ });
41
+
42
+ test('goal-inbox.js asks the server for a BOUNDED read and reports the total, not fetch-all-then-slice', () => {
43
+ // The "Home bounded reads" rule this module's own CLAUDE.md names: a capped
44
+ // Home card must ask for exactly what it shows and get a total back, never
45
+ // download the whole feed and .slice() it client-side.
46
+ assert.match(GOAL_INBOX_SRC, /\/blockers\?limit=\$\{MAX_BLOCKER_ROWS\}/);
47
+ assert.match(GOAL_INBOX_SRC, /data\.page\.total/);
48
+ });
49
+
50
+ test('goal-inbox.js renders blocker rows through the SHARED lib, not a re-implementation', () => {
51
+ assert.match(GOAL_INBOX_SRC, /window\.OTBBlockersLib/);
52
+ assert.match(GOAL_INBOX_SRC, /lib\.blockerFacts\(/);
53
+ });
54
+
55
+ test('the blocker row carries .ginbox__item, so the card\'s count-pill and visibility include it', () => {
56
+ assert.match(GOAL_INBOX_SRC, /function blockerRowHtml[\s\S]*?class="ginbox__item"/);
57
+ });
58
+
59
+ test('the mount() boot sequence actually calls loadBlockers()', () => {
60
+ assert.match(GOAL_INBOX_SRC, /loadNodQueue\(\);\s*\n\s*loadBlockers\(\);\s*\n\s*loadRot\(\);/);
61
+ });
62
+
63
+ // ---- the full queue page (blockers.html + blockers.js) ---------------------
64
+
65
+ test('blockers.js reads the real GET /blockers route, not a stand-in', () => {
66
+ assert.match(BLOCKERS_JS_SRC, /API \+ '\/blockers/);
67
+ });
68
+
69
+ test('blockers.js renders rows through the SAME shared lib the attention slice uses', () => {
70
+ assert.match(BLOCKERS_JS_SRC, /window\.OTBBlockersLib/);
71
+ assert.match(BLOCKERS_JS_SRC, /lib\.blockerFacts\(/);
72
+ });
73
+
74
+ test('blockers.js pages through OTBKit — makePager/paginate/wirePager/pagerBarHtml — not by hand', () => {
75
+ assert.match(BLOCKERS_JS_SRC, /kit\.makePager\(/);
76
+ assert.match(BLOCKERS_JS_SRC, /kit\.paginate\(/);
77
+ assert.match(BLOCKERS_JS_SRC, /kit\.wirePager\(/);
78
+ assert.match(BLOCKERS_JS_SRC, /kit\.pagerBarHtml\(/);
79
+ // The negative half: no hand-rolled slice math (start/end arithmetic) sitting
80
+ // beside the kit call, which is what "paged through OTBKit rather than
81
+ // hand-rolled" would otherwise silently tolerate.
82
+ assert.doesNotMatch(BLOCKERS_JS_SRC, /\.slice\(\s*\(?\s*pager\.page/);
83
+ });
84
+
85
+ test('blockers.html loads blockers-lib.js BEFORE blockers.js', () => {
86
+ const libAt = BLOCKERS_HTML_SRC.indexOf('src="/builders/blockers-lib.js"');
87
+ const pageAt = BLOCKERS_HTML_SRC.indexOf('src="/builders/blockers.js"');
88
+ assert.ok(libAt > -1 && pageAt > -1, 'both scripts must be present');
89
+ assert.ok(libAt < pageAt, 'blockers.js calls window.OTBBlockersLib at load time');
90
+ });
91
+
92
+ test('index.html loads blockers-lib.js BEFORE goal-inbox.js', () => {
93
+ // The literal <script src> attribute, not a bare filename mention — a
94
+ // surrounding comment is free to name either file in either order (and does:
95
+ // profile-nudge.js's own comment mentions "goal-inbox.js" ahead of the real
96
+ // goal-inbox.js <script> tag), so only the src strings say what actually loads
97
+ // first.
98
+ const libAt = INDEX_HTML_SRC.indexOf('src="/builders/blockers-lib.js"');
99
+ const inboxAt = INDEX_HTML_SRC.indexOf('src="/builders/goal-inbox.js');
100
+ assert.ok(libAt > -1 && inboxAt > -1, 'both scripts must be present');
101
+ assert.ok(libAt < inboxAt);
102
+ });
103
+
104
+ test('every title link opens the SAME #/blocker/N overlay task-detail.js already renders', () => {
105
+ assert.match(GOAL_INBOX_SRC, /`#\/blocker\/\$\{encodeURIComponent\(id\)\}`/);
106
+ assert.match(BLOCKERS_JS_SRC, /'#\/blocker\/' \+ encodeURIComponent\(id\)/);
107
+ });