@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.
- package/.bongos-core.json +50 -20
- package/docs/copy-inventory.md +20 -14
- package/docs/copy-registry.json +83 -19
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/windows-builders.md +20 -28
- package/modules/hall-ui/public/blockers-lib.js +110 -0
- package/modules/hall-ui/public/blockers.html +65 -0
- package/modules/hall-ui/public/blockers.js +88 -0
- package/modules/hall-ui/public/goal-inbox.js +74 -0
- package/modules/hall-ui/public/index.html +4 -0
- package/modules/hall-ui/public/shell.js +9 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/run-unit-tests.js +29 -2
- package/src/module-api.js +1 -1
- package/tests/blockers_hall.mjs +107 -0
- package/tests/blockers_lib.mjs +102 -0
- package/tests/hall_nav.mjs +4 -1
- package/tests/hall_page_gate_map.mjs +8 -0
- package/tests/unit_runner_teardown_abort.mjs +67 -0
|
@@ -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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
});
|