@bongos/core 1.19.469 → 1.19.471

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,20 +1,23 @@
1
1
  // public-builders/modules.js — the hall "Modules" tab (/builders/modules).
2
2
  //
3
- // ADR 0107 §9 (task 1776) + §2/§3 the browsable catalog (task 1001772). Jobs:
3
+ // ADR 0107 §9 (task 1776) + §2/§3 the browsable catalog (task 1001772);
4
+ // redesigned onto the oversight family's row in task 1003472. Jobs:
4
5
  // 1. BROWSE the catalog — every module the core ships that this instance can
5
- // enable, each with its author + origin credit, filterable by a single
6
- // search box over name/author/origin/licence. Split from the instance's
7
- // OWN modules by `source` (which TREE it ships from — the core package or
8
- // this repo), which GET /api/bongos/modules reads structurally off the
9
- // loader's discovery roots. That is a different axis from `provenance`
10
- // (WHO built it, read off the manifest's origin) and both are shown: the
11
- // section says where it ships from, the chips say who to credit.
6
+ // enable, each with its author + origin credit, filterable by one search
7
+ // over name/author/origin/licence and a state lens. Split from the
8
+ // instance's OWN modules by `source` (which TREE it ships from — the core
9
+ // package or this repo), which GET /api/bongos/modules reads structurally
10
+ // off the loader's discovery roots. That is a different axis from
11
+ // `provenance` (WHO built it, read off the manifest's origin) and both are
12
+ // shown: the group says where it ships from, the facts say who to credit.
12
13
  // 2. Enable/disable — a toggle over config/modules.json (POST /modules/:key/enable).
13
14
  // Any signed-in builder sees the state; only metic+archon can flip it (the
14
15
  // server enforces this — a lower-rank click just gets a 403 + a friendly
15
- // toast, same as every other rank-gated write in this hall).
16
+ // toast, same as every other rank-gated write in this hall). A viewer below
17
+ // the floor sees the state as a pill rather than a dead control.
16
18
  // 3. "Submit for review" front door — files one of this project's own modules
17
19
  // into the upstreaming review queue (POST /modules/:key/submit, task 1770).
20
+ // The door sits on the module's own row.
18
21
  //
19
22
  // The rows come from the SAME builder as `bongos module list --catalog`
20
23
  // (src/module-loader/catalog.js), so the two surfaces cannot drift.
@@ -27,31 +30,41 @@
27
30
  // /lib/bongos-client.js. baseUrl:'' passes the full `${API}/...` URLs below
28
31
  // through unchanged; result-mode ({ status, ok, data }) preserves control flow 1:1.
29
32
  const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
30
- const { escapeHtml, $, toast } = window.OTB;
33
+ const { escapeHtml, $, toast, rankLabel } = window.OTB;
34
+ const kit = window.OTBKit;
31
35
 
32
36
  let viewerRank = 'xenos';
33
37
  let canToggle = false;
34
- // The last payload, held so the search box can re-filter without a refetch.
38
+ let submitLive = false;
39
+ // The last payload, held so the filter bar can re-render without a refetch.
35
40
  let allModules = [];
41
+ // The filter bar's current values (search + state lens).
42
+ let query = '';
43
+ let stateLens = '';
44
+ // Which rows a builder has opened, by key, so a re-render (a toggle, a
45
+ // filter) does not fold the description they were reading.
46
+ const openKeys = new Set();
36
47
 
37
- function provenanceChip(m) {
48
+ const floor = () => rankLabel('metic');
49
+
50
+ function provenancePill(m) {
38
51
  return m.provenance === 'core'
39
- ? '<span class="mod-chip mod-chip--core">core-loaded</span>'
40
- : '<span class="mod-chip mod-chip--own">built here</span>';
52
+ ? '<span class="fact-pill fact-pill--info">core-loaded</span>'
53
+ : '<span class="fact-pill">built here</span>';
41
54
  }
42
55
 
43
56
  // Author + origin credit (ADR 0107 §2/§3 — credit now, money later). Reads
44
57
  // `credit`, not the raw manifest fields, so a core module whose manifest
45
- // declares no author still shows who ships it rather than a blank card; an
58
+ // declares no author still shows who ships it rather than a blank row; an
46
59
  // INFERRED credit is marked so it never reads as something the author typed.
47
- function attributionChips(m) {
60
+ function attributionFacts(m) {
48
61
  const credit = m.credit || { author: m.author, origin: m.origin, inferred: false };
49
62
  const mark = credit.inferred ? '<abbr class="mod-inferred" title="Not declared in module.json — this is who ships the module.">*</abbr>' : '';
50
- const chips = [];
51
- if (credit.author) chips.push(`<span class="mod-chip">by ${escapeHtml(credit.author)}${mark}</span>`);
52
- if (credit.origin && credit.origin !== credit.author) chips.push(`<span class="mod-chip">from ${escapeHtml(credit.origin)}${mark}</span>`);
53
- if (m.license) chips.push(`<span class="mod-chip">${escapeHtml(m.license)}</span>`);
54
- return chips.join('');
63
+ const facts = [];
64
+ if (credit.author) facts.push(`<span class="ov-fact">by <b>${escapeHtml(credit.author)}</b>${mark}</span>`);
65
+ if (credit.origin && credit.origin !== credit.author) facts.push(`<span class="ov-fact">from <b>${escapeHtml(credit.origin)}</b>${mark}</span>`);
66
+ if (m.license) facts.push(`<span class="fact-chip">${escapeHtml(m.license)}</span>`);
67
+ return facts.join('');
55
68
  }
56
69
 
57
70
  // Free-text browse over the fields a builder would search by. Mirrors
@@ -63,41 +76,77 @@
63
76
  return [m.key, m.title, m.description, credit.author, credit.origin, m.license]
64
77
  .some((f) => f && String(f).toLowerCase().includes(q));
65
78
  }
79
+ function matchesLens(m) {
80
+ if (stateLens === 'enabled') return !!m.enabled;
81
+ if (stateLens === 'disabled') return !m.enabled;
82
+ return true;
83
+ }
66
84
 
67
85
  // Upkeep posture (ADR 0107 §7 / ADR 0166) — who keeps this module true as the
68
- // core advances. A module with no declared maintenance is shown as such rather
69
- // than silently: an unowned catalog entry is the thing the policy exists to
70
- // catch, so it must be visible here, not only in `bongos upgrade`'s output.
71
- function maintenanceChip(m) {
86
+ // core advances. Colour marks the EXCEPTION: an unowned entry is the thing the
87
+ // policy exists to catch, so it is the warn pill; a maintained one is a plain
88
+ // fact, since seventeen "core-maintained" pills would be the loudest thing on
89
+ // the page and none of them would be news.
90
+ function upkeepFact(m) {
72
91
  const mt = m.maintenance;
73
- if (!mt || !mt.status) return '<span class="mod-chip" title="No maintenance declared — nobody is on the hook for this module as the core advances.">upkeep: undeclared</span>';
74
- const detail = [
75
- mt.status === 'maintained' && m.maintainer ? `Maintainer: ${m.maintainer}` : '',
76
- mt.successor ? `Superseded by "${mt.successor}"` : '',
77
- mt.removeAfter ? `Removable after core ${mt.removeAfter}` : '',
78
- mt.note || '',
79
- ].filter(Boolean).join(' — ');
80
- return `<span class="mod-chip" title="${escapeHtml(detail || mt.status)}">upkeep: ${escapeHtml(mt.status)}</span>`;
92
+ if (!mt || !mt.status) return '<span class="fact-pill fact-pill--warn" title="No maintenance declared — nobody is on the hook for this module as the core advances.">upkeep undeclared</span>';
93
+ const status = String(mt.status);
94
+ if (status === 'deprecated' || status === 'superseded' || mt.successor || mt.removeAfter) {
95
+ return `<span class="fact-pill fact-pill--warn">${escapeHtml(status)}</span>`;
96
+ }
97
+ return `<span class="ov-fact">upkeep <b>${escapeHtml(status)}</b></span>`;
81
98
  }
82
99
 
83
- function cardHTML(m) {
100
+ // The lines the row's details disclosure opens onto: the facts the payload
101
+ // carries that the card never rendered — what the module contributes, the
102
+ // core it needs, who maintains it and what it hands off to.
103
+ function detailLines(m) {
104
+ const lines = [];
105
+ const line = (k, v) => { if (v) lines.push(`<li><span class="ov-key ov-key--faint">${escapeHtml(k)}</span><span class="ov-lines__v">${v}</span></li>`); };
106
+ line('key', `<span class="ov-mono">${escapeHtml(m.key)}</span>`);
107
+ if (m.coreVersion) line('needs core', `<span class="ov-mono">${escapeHtml(String(m.coreVersion))}</span>`);
108
+ const c = m.contributes || {};
109
+ for (const kind of Object.keys(c)) {
110
+ const items = Array.isArray(c[kind]) ? c[kind] : [];
111
+ if (items.length) line(kind, items.map((x) => `<span class="fact-chip">${escapeHtml(String(x))}</span>`).join(''));
112
+ }
113
+ const mt = m.maintenance || {};
114
+ if (m.maintainer) line('maintainer', escapeHtml(String(m.maintainer)));
115
+ if (mt.successor) line('superseded by', escapeHtml(String(mt.successor)));
116
+ if (mt.removeAfter) line('removable after core', `<span class="ov-mono">${escapeHtml(String(mt.removeAfter))}</span>`);
117
+ if (mt.note) line('note', escapeHtml(String(mt.note)));
118
+ if (m.default != null) line('ships', m.default ? 'default on' : 'default off');
119
+ return lines.length ? `<ul class="ov-lines">${lines.join('')}</ul>` : '';
120
+ }
121
+
122
+ // The right column: ONE control. The family's grant toggle when the viewer
123
+ // can flip it (enabled in ink, disabled faint, the accent on hover); the
124
+ // state as a pill when they cannot. A module built here also carries its
125
+ // submit door, since that door is about this module and no other.
126
+ function rightHtml(m) {
127
+ const on = !!m.enabled;
128
+ const control = canToggle
129
+ ? `<button type="button" class="ov-toggle mod-toggle" aria-pressed="${on ? 'true' : 'false'}" data-key="${escapeHtml(m.key)}">${on ? 'enabled' : 'disabled'}</button>`
130
+ : `<span class="fact-pill ${on ? 'fact-pill--ok' : ''}" title="${escapeHtml(floor())} and above can enable or disable a module">${on ? 'enabled' : 'disabled'}</span>`;
131
+ const submit = sourceOf(m) === 'instance'
132
+ ? `<button type="button" class="btn-ghost btn--sm mod-submit" data-submit-key="${escapeHtml(m.key)}"${submitLive ? '' : ' disabled title="Submit pipeline not live yet — tracked by task 1770"'}>submit for review</button>`
133
+ : '';
134
+ return control + submit;
135
+ }
136
+
137
+ function rowHTML(m) {
138
+ const desc = String(m.description || '').trim();
139
+ const lines = detailLines(m);
84
140
  return `
85
- <div class="mod-card" data-key="${escapeHtml(m.key)}">
86
- <div class="mod-card__head">
87
- <div>
88
- <div class="mod-card__title">${escapeHtml(m.title || m.key)}</div>
89
- <div class="mod-card__key">${escapeHtml(m.key)}</div>
90
- </div>
91
- </div>
92
- <p class="mod-card__desc">${escapeHtml(m.description || '')}</p>
93
- <div class="mod-chips">${provenanceChip(m)}${attributionChips(m)}${maintenanceChip(m)}</div>
94
- <div class="mod-card__actions">
95
- <button type="button" class="mod-toggle" data-on="${m.enabled ? '1' : '0'}"
96
- ${canToggle ? '' : 'disabled title="Metic+ can enable or disable a module"'}>
97
- ${m.enabled ? 'Enabled' : 'Disabled'}
98
- </button>
141
+ <li class="ov-row mod-row" data-key="${escapeHtml(m.key)}">
142
+ <div class="ov-row__main">
143
+ <span class="ov-row__title">${escapeHtml(m.title || m.key)}<span class="ov-mono">${escapeHtml(m.key)}</span></span>
144
+ ${desc ? `<p class="ov-row__sub mod-row__desc">${escapeHtml(desc)}</p>` : ''}
145
+ <div class="ov-row__facts">${provenancePill(m)}${attributionFacts(m)}${upkeepFact(m)}</div>
146
+ ${lines ? `<details class="mod-more"${openKeys.has(m.key) ? ' open' : ''}><summary aria-label="Details for ${escapeHtml(m.title || m.key)}"></summary>${lines}</details>` : ''}
99
147
  </div>
100
- </div>`;
148
+ <div class="ov-row__right mod-row__right">${rightHtml(m)}</div>
149
+ </li>`;
101
150
  }
102
151
 
103
152
  // `source` splits the page: 'core' = shipped by the core package (the
@@ -109,81 +158,126 @@
109
158
  return m.provenance === 'core' ? 'core' : 'instance';
110
159
  }
111
160
 
112
- function renderGrids() {
113
- const input = $('#mod-search');
114
- const q = input ? input.value.trim().toLowerCase() : '';
115
- const matching = allModules.filter((m) => matchesQuery(m, q));
161
+ // The figure band: the four numbers the page head used to carry as prose.
162
+ function figsHTML() {
163
+ const core = allModules.filter((m) => sourceOf(m) === 'core');
164
+ const own = allModules.filter((m) => sourceOf(m) === 'instance');
165
+ const enabled = allModules.filter((m) => m.enabled).length;
166
+ const undeclared = allModules.filter((m) => !m.maintenance || !m.maintenance.status).length;
167
+ const fig = (l, v, s) => `<div class="ov-fig"><span class="ov-fig__l">${l}</span><span class="ov-fig__v">${v}</span>${s ? `<span class="ov-fig__s">${s}</span>` : ''}</div>`;
168
+ // Short labels on purpose: the band is two columns at 390px and a label
169
+ // that wraps a phone's column is cut with an ellipsis.
170
+ return fig('modules', allModules.length, `${core.length} from the core`)
171
+ + fig('enabled', enabled, `of ${allModules.length}`)
172
+ + fig('built here', own.length, own.length ? 'can go upstream' : 'none on disk')
173
+ + fig('no upkeep', undeclared, undeclared ? 'nobody on the hook' : 'every module owned');
174
+ }
175
+
176
+ function renderRows() {
177
+ const matching = allModules.filter((m) => matchesQuery(m, query) && matchesLens(m));
116
178
  const core = matching.filter((m) => sourceOf(m) === 'core');
117
179
  const own = matching.filter((m) => sourceOf(m) === 'instance');
180
+ const narrowed = !!(query || stateLens);
118
181
 
119
- const coreGrid = $('#core-grid');
120
- if (coreGrid) {
121
- coreGrid.innerHTML = core.length ? core.map(cardHTML).join('')
122
- : `<div class="mod-empty">${q ? 'No catalog module matches that search.' : 'The core ships no modules this instance can see.'}</div>`;
182
+ const coreList = $('#core-grid');
183
+ if (coreList) {
184
+ coreList.innerHTML = core.length ? core.map(rowHTML).join('')
185
+ : `<li>${kit.emptyStateHtml(narrowed ? 'No catalog module matches that search.' : 'The core ships no modules this instance can see.')}</li>`;
123
186
  }
124
- const ownGrid = $('#own-grid');
125
- if (ownGrid) {
126
- ownGrid.innerHTML = own.length ? own.map(cardHTML).join('')
127
- : `<div class="mod-empty">${q ? 'None of this project\'s own modules match that search.' : 'None on disk — everything here ships with the core.'}</div>`;
187
+ const ownList = $('#own-grid');
188
+ if (ownList) {
189
+ ownList.innerHTML = own.length ? own.map(rowHTML).join('')
190
+ : `<li>${kit.emptyStateHtml(narrowed ? 'None of this project\'s own modules match that search.' : 'None on disk — everything here ships with the core.')}</li>`;
128
191
  }
192
+ const coreN = $('#core-n');
193
+ if (coreN) coreN.textContent = String(core.length);
194
+ const ownN = $('#own-n');
195
+ if (ownN) ownN.textContent = String(own.length);
129
196
 
130
- const count = $('#mod-browse-count');
197
+ // The count line speaks only while a filter narrows the list — the figure
198
+ // band under it already says how many there are.
199
+ const count = $('#mod-count');
131
200
  if (count) {
132
- count.textContent = q
133
- ? `${matching.length} of ${allModules.length} module(s) match “${input.value.trim()}”.`
201
+ const n = allModules.length;
202
+ count.textContent = narrowed
203
+ ? `${matching.length} of ${n} ${n === 1 ? 'module' : 'modules'} match${query ? ` “${query}”` : ''}${stateLens ? ` · ${stateLens}` : ''}.`
134
204
  : '';
135
205
  }
206
+ }
136
207
 
137
- document.querySelectorAll('.mod-toggle').forEach((btn) => btn.addEventListener('click', onToggle));
208
+ // One listener on the panel outlives every re-render: the toggle, the submit
209
+ // door and the details disclosure all bubble to it.
210
+ let wired = false;
211
+ function wire() {
212
+ if (wired) return;
213
+ wired = true;
214
+ const panel = $('#core-scroll');
215
+ if (!panel) return;
216
+ panel.addEventListener('click', (ev) => {
217
+ const toggle = ev.target.closest('.mod-toggle');
218
+ if (toggle) { onToggle(toggle); return; }
219
+ const submit = ev.target.closest('.mod-submit');
220
+ if (submit) onSubmit(submit);
221
+ });
222
+ panel.addEventListener('toggle', (ev) => {
223
+ const d = ev.target;
224
+ if (!(d instanceof HTMLDetailsElement) || !d.classList.contains('mod-more')) return;
225
+ const row = d.closest('.mod-row');
226
+ const key = row && row.dataset.key;
227
+ if (!key) return;
228
+ if (d.open) openKeys.add(key); else openKeys.delete(key);
229
+ }, true);
138
230
  }
139
231
 
140
232
  function render(data) {
141
233
  allModules = Array.isArray(data.modules) ? data.modules : [];
142
- const core = allModules.filter((m) => sourceOf(m) === 'core');
143
- const own = allModules.filter((m) => sourceOf(m) === 'instance');
144
- const enabled = core.filter((m) => m.enabled).length;
234
+ submitLive = !!data.submit_pipeline_live;
235
+
236
+ const figs = $('#mod-figs');
237
+ if (figs) figs.innerHTML = figsHTML();
145
238
 
146
- const sub = $('#modules-sub');
147
- if (sub) {
148
- sub.textContent = `${allModules.length} module(s) — ${core.length} in the core catalog (${enabled} enabled here), `
149
- + `${own.length} built by this project.`;
239
+ const mount = $('#mod-filters');
240
+ if (mount && !mount.childElementCount) {
241
+ kit.makeFilterBar({
242
+ mount,
243
+ search: { id: 'mod-search', label: 'Search', placeholder: 'name, author, origin or licence' },
244
+ primary: [
245
+ { id: 'mod-state', label: 'State', options: [
246
+ { value: '', label: 'all' }, { value: 'enabled', label: 'enabled' }, { value: 'disabled', label: 'disabled' }] },
247
+ ],
248
+ onChange: (vals) => {
249
+ query = String(vals['mod-search'] || '').trim().toLowerCase();
250
+ stateLens = String(vals['mod-state'] || '');
251
+ renderRows();
252
+ },
253
+ });
150
254
  }
151
255
 
152
- renderGrids();
153
-
154
- // Only this project's OWN modules can be submitted upstream: a module that
155
- // already ships with the core is, by definition, already in the catalog.
156
- const submitStatus = $('#submit-status');
157
- if (submitStatus) {
158
- submitStatus.innerHTML = own.length
159
- ? own.map((m) => `
160
- <span style="display:inline-flex;align-items:center;gap:8px;margin:4px 12px 4px 0;">
161
- <button type="button" class="mod-submit" data-submit-key="${escapeHtml(m.key)}"
162
- ${data.submit_pipeline_live ? '' : 'disabled title="Submit pipeline not live yet — tracked by task 1770"'}>
163
- Submit "${escapeHtml(m.key)}" for review
164
- </button>
165
- </span>`).join('')
166
- : '<span class="mod-note">Nothing to submit — every module here already ships with the core.</span>';
167
- document.querySelectorAll('[data-submit-key]').forEach((btn) => btn.addEventListener('click', onSubmit));
256
+ const lede = $('#submit-lede');
257
+ if (lede && !allModules.some((m) => sourceOf(m) === 'instance')) {
258
+ lede.textContent = 'Nothing to submit — every module here ships with the core.';
168
259
  }
169
260
 
170
- const search = $('#mod-search');
171
- if (search) search.addEventListener('input', renderGrids);
261
+ wire();
262
+ renderRows();
172
263
  }
173
264
 
174
- async function onToggle(ev) {
175
- const btn = ev.currentTarget;
176
- const card = btn.closest('.mod-card');
177
- const key = card && card.dataset.key;
265
+ async function onToggle(btn) {
266
+ const key = btn.dataset.key;
178
267
  if (!key) return;
179
- const next = btn.dataset.on !== '1';
268
+ const next = btn.getAttribute('aria-pressed') !== 'true';
180
269
  btn.disabled = true;
181
270
  try {
182
271
  const res = await api.request('POST', `${API}/modules/${encodeURIComponent(key)}/enable`, { body: { enabled: next } });
183
272
  const body = res.data || {};
184
273
  if (!res.ok) throw new Error(body.message || `enable → ${res.status}`);
185
- btn.dataset.on = next ? '1' : '0';
186
- btn.textContent = next ? 'Enabled' : 'Disabled';
274
+ const m = allModules.find((x) => x.key === key);
275
+ if (m) m.enabled = next;
276
+ btn.setAttribute('aria-pressed', next ? 'true' : 'false');
277
+ btn.textContent = next ? 'enabled' : 'disabled';
278
+ const figs = $('#mod-figs');
279
+ if (figs) figs.innerHTML = figsHTML();
280
+ renderRows();
187
281
  toast(body.note || (next ? `${key} enabled.` : `${key} disabled.`), { duration: 3200 });
188
282
  } catch (err) {
189
283
  console.error('[modules] toggle failed', err);
@@ -193,8 +287,7 @@
193
287
  }
194
288
  }
195
289
 
196
- async function onSubmit(ev) {
197
- const btn = ev.currentTarget;
290
+ async function onSubmit(btn) {
198
291
  const key = btn.dataset.submitKey;
199
292
  try {
200
293
  const res = await api.request('POST', `${API}/modules/${encodeURIComponent(key)}/submit`);
@@ -217,12 +310,11 @@
217
310
  const res = await api.request('GET', `${API}/modules`);
218
311
  if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
219
312
  if (!res.ok) throw new Error(`modules → ${res.status}`);
220
- const data = res.data;
221
- render(data);
313
+ render(res.data);
222
314
  } catch (err) {
223
315
  console.error('[modules] failed to load', err);
224
- const sub = $('#modules-sub');
225
- if (sub) sub.textContent = 'Could not load modules. Return to the hall and try again.';
316
+ const list = $('#core-grid');
317
+ if (list) list.innerHTML = `<li>${kit.emptyStateHtml('Could not load modules. Return to the hall and try again.')}</li>`;
226
318
  toast('Could not load modules.', { duration: 3200 });
227
319
  }
228
320
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "_": "Modules — the core + instance module grids under one search. The empty state matters more than the populated one: a filter that matches nothing is the state a browse page spends real time in. Written with the re-skin (task 1003307) so this page is MEASURED at three widths in both modes rather than looked at once.",
2
+ "_": "Modules — the catalog as ONE panel of oversight rows (task 1003472): the kit filter bar, the figure band, two groups under heads. The empty state matters more than the populated one: a filter that matches nothing is the state a browse page spends real time in. Written with the re-skin (task 1003307) so this page is MEASURED at three widths in both modes rather than looked at once; the redesign added the `lens` and `details` states. Populated look: the hall-preview harness (scripts/hall-preview/fixtures/modules.json carries eighteen modules, one built by the project).",
3
3
  "page": "/builders/modules",
4
4
  "surface": "hall-ui",
5
5
  "stub": {
@@ -46,6 +46,10 @@
46
46
  "search-empty": {
47
47
  "auth": true,
48
48
  "actions": [
49
+ [
50
+ "wait",
51
+ 600
52
+ ],
49
53
  [
50
54
  "fill",
51
55
  "#mod-search",
@@ -57,6 +61,49 @@
57
61
  ]
58
62
  ]
59
63
  },
64
+ "lens": {
65
+ "auth": true,
66
+ "actions": [
67
+ [
68
+ "wait",
69
+ 600
70
+ ],
71
+ [
72
+ "focus",
73
+ "#mod-state"
74
+ ],
75
+ [
76
+ "press",
77
+ "ArrowDown"
78
+ ],
79
+ [
80
+ "wait",
81
+ 300
82
+ ]
83
+ ],
84
+ "expect": {
85
+ "visible": [
86
+ "#mod-state"
87
+ ]
88
+ }
89
+ },
90
+ "details": {
91
+ "auth": true,
92
+ "actions": [
93
+ [
94
+ "wait",
95
+ 600
96
+ ],
97
+ [
98
+ "clickIfVisible",
99
+ "#core-grid .mod-row:first-child .mod-more > summary"
100
+ ],
101
+ [
102
+ "wait",
103
+ 300
104
+ ]
105
+ ]
106
+ },
60
107
  "out": {
61
108
  "auth": false
62
109
  }
@@ -3057,18 +3057,18 @@ html.palette-open { overflow: hidden; }
3057
3057
  The state colours are the hall's fixed verification vocabulary, never the
3058
3058
  world's warm accent states (ADR 0196 §2): a status must not re-tint when an
3059
3059
  instance re-brands. */
3060
- /* The five names below are the SAME pill, found by the task 1003307 audit:
3061
- government, harbor, gate, watch and modules each invented one because the
3062
- shell had none to take. They are aliased rather than renamed because renaming
3063
- would mean editing five JS row builders, and a class name is not what was
3064
- wrong — the duplicated, hand-rolled label voice was. Their own state
3065
- modifiers stay in their own sheets. */
3060
+ /* The names below are the SAME pill, found by the task 1003307 audit:
3061
+ government, harbor, gate and watch each invented one because the shell had
3062
+ none to take (modules had one too; its rows take .fact-pill by name since
3063
+ task 1003472). They are aliased rather than renamed because renaming would
3064
+ mean editing the JS row builders, and a class name is not what was wrong —
3065
+ the duplicated, hand-rolled label voice was. Their own state modifiers stay
3066
+ in their own sheets. */
3066
3067
  .fact-pill,
3067
3068
  .gov-pill,
3068
3069
  .harbor-pill,
3069
3070
  .gate-pill,
3070
- .badge,
3071
- .mod-chip {
3071
+ .badge {
3072
3072
  display: inline-flex;
3073
3073
  align-items: center;
3074
3074
  min-height: 24px;
@@ -117,9 +117,13 @@
117
117
  const msOf = (iso) => { const t = iso ? Date.parse(iso) : NaN; return Number.isNaN(t) ? 0 : t; };
118
118
  const taskActivityMs = (t) => Math.max(msOf(t.updated_at), msOf(t.shipped_at));
119
119
 
120
- // The staleness buckets both boards offer. A CLIENT-side vocabulary, not the
121
- // project's configured stale timer (task 1003279 wires that into the sweep);
122
- // these are lenses for a person browsing, and the sweep is the enforcement.
120
+ // The staleness buckets both boards offer. A CLIENT-side vocabulary over TASKS,
121
+ // deliberately NOT the project's configured stale-claim timer — that one lives in
122
+ // src/stale-timer.js, counts hours rather than days, is about a CLAIM going silent,
123
+ // and is what the sweeper enforces and the Archon roster reports (task 1003476).
124
+ // These are lenses for a person browsing a board; that is the machine's rule. Two
125
+ // different questions, so two vocabularies — but only one of them may be a number
126
+ // typed twice, which is the bug 1003476 closed.
123
127
  const STALE_CHOICES = [
124
128
  { value: '', label: '— any age —' },
125
129
  { value: '14', label: 'untouched 14+ days' },
@@ -562,21 +562,29 @@ async function getClaimPublishedSha(taskId) {
562
562
  // time_to_first_ship_minutes (NULL until they've shipped)
563
563
  // - claims: active_claim_count, stale_claim
564
564
  //
565
- // "stale_claim" mirrors the stale-claim-release sweeper definition
566
- // (.claude/scheduled-tasks/stale-claim-release/SKILL.md): a still-open
567
- // claim where either the last session_pulses row is older than the
568
- // stale threshold, or there's no pulse row at all AND claimed_at is
569
- // older than the threshold. Default threshold is 24h to match this
570
- // task's spec — the sweeper itself uses GDS_STALE_CLAIM_HOURS (default
571
- // 6h) and is in charge of *acting*. The roster only *surfaces*.
565
+ // "stale_claim" mirrors the stale-claim-release sweeper's definition: a
566
+ // still-open claim where either the last session_pulses row is older than
567
+ // the stale threshold, or there's no pulse row at all AND claimed_at is
568
+ // older than the threshold.
569
+ //
570
+ // THE THRESHOLD IS THE PROJECT'S, NOT THIS ROUTE'S (task 1003476, goal
571
+ // 1000072). It used to default to a hardcoded 24h "to match this task's
572
+ // spec" while the sweeper acted on GDS_STALE_CLAIM_HOURS (6h) — so for
573
+ // eighteen hours after a claim had been auto-released, tagged
574
+ // [auto-released:stale] and handed back to the queue, this roster still
575
+ // showed it healthy. The roster surfaces and the sweeper acts, but they
576
+ // must answer the same question the same way, so both now read
577
+ // src/stale-timer.js. An explicit `staleHours` still overrides, because
578
+ // "what would 48h show me?" is a lens a person points at the roster and
579
+ // not a reconfiguration of the project.
572
580
  //
573
581
  // Returns: array of builder roster rows, ordered by:
574
582
  // 1. inactive builders sink to the bottom (status='inactive' last)
575
583
  // 2. still-onboarding (first_ship_at IS NULL) above shipped builders
576
584
  // 3. then by created_at DESC inside each group (newest first)
577
585
 
578
- async function getBuildersRoster({ staleHours = 24 } = {}) {
579
- const hours = Number(staleHours);
586
+ async function getBuildersRoster({ staleHours } = {}) {
587
+ const hours = Number(staleHours ?? api.resolveStaleClaimHours().hours);
580
588
  if (!Number.isFinite(hours) || hours <= 0) {
581
589
  throw new Error('getBuildersRoster: staleHours must be a positive number');
582
590
  }
@@ -629,9 +637,12 @@ async function getBuildersRoster({ staleHours = 24 } = {}) {
629
637
  // signals, so the Archon can pick the right one to release. Used by
630
638
  // the management panel's per-row "release stale claim" action.
631
639
 
632
- async function getActiveClaimsForBuilderWithStale(builderId, { staleHours = 24 } = {}) {
640
+ // The threshold defaults to the PROJECT's stale-claim timer, not to a number of its
641
+ // own (task 1003476) — a claim the roster called stale must still be stale one click
642
+ // later, in the view an Archon opens to release it.
643
+ async function getActiveClaimsForBuilderWithStale(builderId, { staleHours } = {}) {
633
644
  const builder = Number(builderId);
634
- const hours = Number(staleHours);
645
+ const hours = Number(staleHours ?? api.resolveStaleClaimHours().hours);
635
646
  if (!Number.isFinite(builder)) {
636
647
  throw new Error('getActiveClaimsForBuilderWithStale: builderId must be numeric');
637
648
  }
@@ -39,7 +39,17 @@
39
39
  // getStaleClaimsForBuilder().
40
40
  // -------------------------------------------------------------------------
41
41
 
42
- const { pool } = require('../../src/module-api');
42
+ const api = require('../../src/module-api');
43
+
44
+ const { pool } = api;
45
+
46
+ // The project's stale-claim timer, in minutes (task 1003476, goal 1000072). These
47
+ // helpers used to default to a hardcoded 30 minutes, which was neither the sweeper's
48
+ // 6h nor the roster's 24h — a third answer to one question, reachable by any caller
49
+ // that omitted the argument. There is one answer now, and it is the project's.
50
+ // Resolved per call, never at require time: an env change must reach a long-running
51
+ // process on its next sweep, not on its next restart.
52
+ const projectThresholdMin = () => api.staleClaimMinutes(api.resolveStaleClaimHours().hours);
43
53
 
44
54
  // Record a heartbeat for a claim. Idempotent UPSERT — the PK is
45
55
  // claim_id so repeated pulses just bump last_pulse_at.
@@ -77,8 +87,8 @@ async function recordPulse(claimId, builderId) {
77
87
  // This function is READ-ONLY by design (see open question #1 above);
78
88
  // the write-side marker is deferred to a follow-up task that owns
79
89
  // the migration.
80
- async function sweepStaleClaims({ thresholdMin = 30 } = {}) {
81
- const threshold = Number(thresholdMin);
90
+ async function sweepStaleClaims({ thresholdMin } = {}) {
91
+ const threshold = Number(thresholdMin ?? projectThresholdMin());
82
92
  if (!Number.isFinite(threshold) || threshold <= 0) {
83
93
  throw new Error('sweepStaleClaims: thresholdMin must be a positive number');
84
94
  }
@@ -110,9 +120,9 @@ async function sweepStaleClaims({ thresholdMin = 30 } = {}) {
110
120
  // Today, "stale" is computed the same way as sweepStaleClaims() —
111
121
  // once a marker column exists (follow-up a), this can pivot to read
112
122
  // claims.pulse_lost_at IS NOT NULL instead.
113
- async function getStaleClaimsForBuilder(builderId, { thresholdMin = 30 } = {}) {
123
+ async function getStaleClaimsForBuilder(builderId, { thresholdMin } = {}) {
114
124
  const builder = Number(builderId);
115
- const threshold = Number(thresholdMin);
125
+ const threshold = Number(thresholdMin ?? projectThresholdMin());
116
126
  if (!Number.isFinite(builder)) {
117
127
  throw new Error('getStaleClaimsForBuilder: builderId must be numeric');
118
128
  }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.469",
3
+ "version": "1.19.471",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.469",
9
+ "version": "1.19.471",
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.469",
3
+ "version": "1.19.471",
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",