@bongos/core 1.21.152 → 1.21.154

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.
@@ -73,6 +73,22 @@
73
73
  <ul class="ov-rows" id="own-grid"></ul>
74
74
  <p class="ov-foot" id="submit-lede">A module built here can be submitted upstream for review, so it can join the shared catalog with your name and this project credited.</p>
75
75
  </div>
76
+
77
+ <!-- In the store — what GET /store/modules lists, each with what it is
78
+ strong and weak at (task 1003799, ADR 0343 D4), so two modules with
79
+ the same score can be told apart. Hidden until the store answers
80
+ with something to list. -->
81
+ <div class="ov-group" id="store-group" hidden>
82
+ <div class="ov-group__head">
83
+ <span class="ov-group__name">In the store</span>
84
+ <span class="ov-group__n" id="store-n"></span>
85
+ <!-- task 1003800: the buyer picks the part that matters to them, and
86
+ the server orders by it (sortByPart). Filled from sort_parts. -->
87
+ <label class="ov-group__note">Sort by <select id="store-sort"><option value="">Name</option></select></label>
88
+ </div>
89
+ <ul class="ov-rows" id="store-grid"></ul>
90
+ <p class="ov-foot" id="store-lede">A part is strong at 80 out of 100 or more, and weak below the listing floor. A part with no data yet is not counted against a module. Sort by a part to see the modules strongest at it first; a module with no score for that part goes last, and a new module is never ranked.</p>
91
+ </div>
76
92
  </section>
77
93
 
78
94
  <!-- One store version's how-to (task 1004366, ADR 0347): the HOWTO.md that
@@ -204,6 +204,120 @@
204
204
  </li>`;
205
205
  }
206
206
 
207
+ // ---- the store listing (task 1003799, ADR 0343 D4) ----
208
+ //
209
+ // GET /store/modules entries carry their parts read as strengths, weaknesses and
210
+ // unmeasured parts (src/bongos/module-listing.js readParts). The row shows the
211
+ // short labels; the sentence behind each is one click away in the details, the
212
+ // splitCriterion idea: scannable in a list, thorough on demand.
213
+ let storeModules = [];
214
+ // The part the buyer sorts by (task 1003800): '' is name order. The server owns
215
+ // the order and the list of parts; this page only asks for one and shows it.
216
+ let storeSort = '';
217
+ let storeSortTitle = '';
218
+ let storeSortWired = false;
219
+
220
+ // The sorted-by part's own score, so the order explains itself on every row.
221
+ function sortedPartFact(m) {
222
+ if (!storeSort || m.is_new) return '';
223
+ const p = m.parts && m.parts[storeSort];
224
+ return p && p.score != null
225
+ ? `<span class="ov-fact">${escapeHtml(storeSortTitle)} <b>${escapeHtml(String(p.score))}</b></span>`
226
+ : `<span class="ov-fact">${escapeHtml(storeSortTitle)} not measured yet</span>`;
227
+ }
228
+
229
+ function partsList(items) {
230
+ return items.map((p) => `${escapeHtml(p.title)} <b>${escapeHtml(String(p.score))}</b>`).join(', ');
231
+ }
232
+
233
+ function storeFacts(m) {
234
+ const facts = [m.is_new
235
+ ? '<span class="fact-pill fact-pill--info" title="No real-world data yet, so it is listed but not ranked.">new</span>'
236
+ : `<span class="ov-fact">scores <b>${escapeHtml(String(m.overall))}</b></span>`];
237
+ const sorted = sortedPartFact(m);
238
+ if (sorted) facts.push(sorted);
239
+ const strong = Array.isArray(m.strengths) ? m.strengths : [];
240
+ const weak = Array.isArray(m.weaknesses) ? m.weaknesses : [];
241
+ if (strong.length) facts.push(`<span class="ov-fact">strong at ${partsList(strong)}</span>`);
242
+ // A weakness is the exception a buyer must not miss, so it is the warn pill.
243
+ if (weak.length) facts.push(`<span class="fact-pill fact-pill--warn">weak at ${partsList(weak)}</span>`);
244
+ return facts.join('');
245
+ }
246
+
247
+ function storeDetailLines(m) {
248
+ const lines = [...(m.strengths || []), ...(m.weaknesses || [])]
249
+ .map((p) => `<li><span class="ov-key ov-key--faint">${escapeHtml(p.title)}</span><span class="ov-lines__v">${escapeHtml(p.detail)}</span></li>`);
250
+ const missing = (m.unmeasured || []).map((p) => p.title);
251
+ if (missing.length) {
252
+ lines.push(`<li><span class="ov-key ov-key--faint">not measured yet</span><span class="ov-lines__v">${escapeHtml(missing.join(', '))}</span></li>`);
253
+ }
254
+ return lines.length ? `<ul class="ov-lines">${lines.join('')}</ul>` : '';
255
+ }
256
+
257
+ function storeRowHTML(m) {
258
+ const key = `store:${m.key}`;
259
+ const lines = storeDetailLines(m);
260
+ return `
261
+ <li class="ov-row mod-row" data-key="${escapeHtml(key)}">
262
+ <div class="ov-row__main">
263
+ <span class="ov-row__title">${escapeHtml(m.title || m.key)}<span class="ov-mono">${escapeHtml(m.key)}</span></span>
264
+ <div class="ov-row__facts">${storeFacts(m)}</div>
265
+ ${lines ? `<details class="mod-more"${openKeys.has(key) ? ' open' : ''}><summary aria-label="Strengths and weaknesses of ${escapeHtml(m.title || m.key)}"></summary>${lines}</details>` : ''}
266
+ </div>
267
+ <div class="ov-row__right mod-row__right"><span class="ov-mono">${escapeHtml(String(m.version || ''))}</span></div>
268
+ </li>`;
269
+ }
270
+
271
+ function matchesStoreQuery(m, q) {
272
+ if (!q) return true;
273
+ return [m.key, m.title, ...(m.strengths || []).map((p) => p.title), ...(m.weaknesses || []).map((p) => p.title)]
274
+ .some((f) => f && String(f).toLowerCase().includes(q));
275
+ }
276
+
277
+ function renderStore() {
278
+ const group = $('#store-group');
279
+ if (!group) return;
280
+ group.hidden = !storeModules.length;
281
+ if (!storeModules.length) return;
282
+ const shown = storeModules.filter((m) => matchesStoreQuery(m, query));
283
+ const list = $('#store-grid');
284
+ if (list) {
285
+ list.innerHTML = shown.length ? shown.map(storeRowHTML).join('')
286
+ : `<li>${kit.emptyStateHtml('No store module matches that search.')}</li>`;
287
+ }
288
+ const n = $('#store-n');
289
+ if (n) n.textContent = String(shown.length);
290
+ }
291
+
292
+ // Best-effort and last: the store is a second read, and the modules on disk ARE
293
+ // this page, so a store that cannot be read costs this group and nothing else.
294
+ // Filled and wired once, on the first store read; a re-read only re-renders.
295
+ function fillStoreSort(parts) {
296
+ const select = $('#store-sort');
297
+ if (storeSortWired || !select || !Array.isArray(parts)) return;
298
+ storeSortWired = true;
299
+ for (const p of parts) select.add(new Option(p.title, p.part));
300
+ select.addEventListener('change', () => {
301
+ storeSort = select.value;
302
+ storeSortTitle = storeSort ? select.options[select.selectedIndex].text : '';
303
+ loadStore();
304
+ });
305
+ }
306
+
307
+ async function loadStore() {
308
+ try {
309
+ const q = storeSort ? `?sort=${encodeURIComponent(storeSort)}` : '';
310
+ const res = await api.request('GET', `${API}/store/modules${q}`);
311
+ if (!res.ok) return;
312
+ const body = res.data || {};
313
+ fillStoreSort(body.sort_parts);
314
+ storeModules = [...(body.listed || []), ...(body.new || [])];
315
+ renderStore();
316
+ } catch (err) {
317
+ console.error('[modules] store listing failed to load', err);
318
+ }
319
+ }
320
+
207
321
  // `source` splits the page: 'core' = shipped by the core package (the
208
322
  // catalog you opt into), 'instance' = lives in this repo. Older payloads
209
323
  // predate the field, so fall back to the credit axis rather than emptying
@@ -248,6 +362,7 @@
248
362
  if (coreN) coreN.textContent = String(core.length);
249
363
  const ownN = $('#own-n');
250
364
  if (ownN) ownN.textContent = String(own.length);
365
+ renderStore();
251
366
 
252
367
  // The count line speaks only while a filter narrows the list — the figure
253
368
  // band under it already says how many there are.
@@ -366,6 +481,7 @@
366
481
  if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
367
482
  if (!res.ok) throw new Error(`modules → ${res.status}`);
368
483
  render(res.data);
484
+ loadStore();
369
485
  } catch (err) {
370
486
  console.error('[modules] failed to load', err);
371
487
  const list = $('#core-grid');
@@ -13,7 +13,8 @@
13
13
  "/api/(gds|bongos)/goals",
14
14
  "/api/(gds|bongos)/inbox/",
15
15
  "/api/(gds|bongos)/versions",
16
- "/api/(gds|bongos)/modules"
16
+ "/api/(gds|bongos)/modules",
17
+ "/api/(gds|bongos)/store/modules$"
17
18
  ],
18
19
  "ignoreConsole": [
19
20
  "/api/(gds|bongos)/",
@@ -10,3 +10,7 @@
10
10
  Also gone, and the one rule in that list: `.ab-caption` takes the lede's `--measure`, never a fresh `90ch`. **Gotcha:** `gen-atlas.js` run from the MAIN checkout walks `.claude/worktrees/*` and emits thousands of context nodes — run it from a worktree, or copy a worktree's `atlas.json` over, before previewing the atlas there.
11
11
 
12
12
  **A store version's how-to is a view of the Modules page, not a new page (task 1004366).** `/builders/modules?howto=<key>&version=<X.Y.Z>` hides the catalog and shows that version's `HOWTO.md` in the reading room's article card: the page adds `body.room` so `room.css`'s sizes apply, and the markdown goes through `md-reader.js` (author-written, so every byte is escaped; only absolute http(s), mailto and #anchor links become links). A query, not a `#/` route — the hash namespace is frozen to the idea and blocker overlays. "Save as PDF" is `window.print()` with an `@media print` block in `modules.css` that keeps only the document and prints in the system ink (`CanvasText`), because a literal colour is the token tier's. Harness fixture: `store__modules__howto.json`.
13
+
14
+ **The store's modules are a third group on the same panel, read as strengths and weaknesses (task 1003799, ADR 0343 D4).** `#store-group` ("In the store") lists what `GET /store/modules` lists, hidden until that read answers with something; it loads after the modules on disk and best-effort, so a store that cannot be read costs this group and nothing else. A row is the same `.ov-row`: the overall score as a plain fact (or the `new` info pill), `strong at Tests 96, How-to 88` as a plain fact, and `weak at …` as the warn pill, because a weakness is the exception a buyer must not miss. The sentence behind each part ("Tests scored 96 out of 100, from 40 test cases.") and the parts with no data yet sit in the row's `<details class="mod-more">`, the `splitCriterion` idea: short label in the list, the detail one click away. The reading is the server's (`readParts` in `src/bongos/module-listing.js`: strong at 80 or more, weak below the listing floor, unmeasured never weak); the page re-derives nothing. Harness fixture: `store__modules.json`.
15
+
16
+ **The buyer sorts the store by the part that matters to them (task 1003800, owner decision 2026-10-22).** A "Sort by" menu sits in the store group's head (`#store-sort`, `.ov-group__note`): Name, then the parts the server lists in `sort_parts`. Picking one re-reads `GET /store/modules?sort=<part>`, and the server orders the listed modules (`sortByPart` in `src/bongos/module-listing.js`: highest for that part first, a tie to the overall score then the key, a module with no score for the part last). The page never sorts the rows itself, and New modules keep name order because they are never ranked. While a part is picked, each listed row carries that part's score as a plain fact (`Reliability 91`, or `Reliability not measured yet`), so the order explains itself. The owner chose the buyer over a per-category "key part" because nothing records what a category is for, and a hidden weighting would break the score an author can check (ADR 0343 D3).
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/github-failure.js — say WHICH GitHub call failed, and what
4
+ // GitHub answered (task 1004766).
5
+ //
6
+ // WHY. GET /tasks/:id/publish-status answered every unexpected GitHub failure with
7
+ // a bare 502 `status_failed`, and logged only the error's code. On the toast
8
+ // instance (2026-10-20) the GitHub App was installed and the merge was on main,
9
+ // yet every probe 502'd: the builder saw "no explanation", and the operator's log
10
+ // said one word. The thrown errors already carry what is needed — the code says
11
+ // which step broke (minting the App token, listing PRs, reading a branch) and
12
+ // `status` / `detail` hold GitHub's own HTTP status and message — they were just
13
+ // dropped on the floor.
14
+ //
15
+ // SAFE TO SHOW. `detail` is GitHub's response body (an error message such as
16
+ // "Bad credentials" or "Resource not accessible by integration"), never a token:
17
+ // tokens travel in request headers, and no caller puts one in an error. It is
18
+ // trimmed and capped anyway. PURE.
19
+
20
+ const STEPS = Object.freeze({
21
+ APP_TOKEN_FAILED: "the server could not get a GitHub App token (GitHub refused the App's key or its installation)",
22
+ STATUS_FETCH_FAILED: 'GitHub refused a read of the repository',
23
+ PUSH_UNCONFIGURED: 'the server has no GitHub credential',
24
+ BAD_BRANCH: 'the branch name is not one the server will ask GitHub about',
25
+ });
26
+
27
+ const MAX_DETAIL = 300;
28
+
29
+ function describeGithubFailure(e) {
30
+ const code = (e && e.code) || null;
31
+ const status = e && Number.isInteger(e.status) ? e.status : null;
32
+ const detail = e && e.detail ? String(e.detail).replace(/\s+/g, ' ').trim().slice(0, MAX_DETAIL) : null;
33
+ const step = (code && STEPS[code]) || 'an unexpected error while asking GitHub';
34
+ const github = status ? ` GitHub answered HTTP ${status}${detail ? `: ${detail}` : ''}.` : '';
35
+ return {
36
+ message: `Could not read the land status: ${step}.${github}`,
37
+ details: { cause: code, github_status: status, github_detail: detail },
38
+ log: `${code || (e && e.message) || 'unknown'}${status ? ` (GitHub HTTP ${status}${detail ? `: ${detail}` : ''})` : ''}`,
39
+ };
40
+ }
41
+
42
+ // The publish-status route's answer, in one call so the (size-capped) route file
43
+ // stays one line: log the step + GitHub's reply, answer 502 with the same words.
44
+ function failGithubStatus(res, log, e) {
45
+ const why = describeGithubFailure(e);
46
+ if (log) log.error('[gds] GET /tasks/:id/publish-status', why.log);
47
+ return res.fail('status_failed', { status: 502, message: why.message, details: why.details });
48
+ }
49
+
50
+ module.exports = { describeGithubFailure, failGithubStatus };
@@ -55,6 +55,7 @@ const { makeAbandonHandler } = require('./abandon-handler');
55
55
  const githubPush = require('../github-push');
56
56
  const prAssign = require('../pr-assign');
57
57
  const shipAssurance = require('../ship-assurance');
58
+ const { failGithubStatus } = require('../github-failure');
58
59
  const RECONCILE_PROOF = Object.freeze({ kind: 'proof', reason: 'publish-status reconcile: land proven' });
59
60
  // task 1003764: publish-status with no ?branch= and no PR titled for the task — the shape
60
61
  // getPublishStatus gives a branch with no PR, plus how to ask about one. Never a 400.
@@ -1345,8 +1346,7 @@ module.exports = function buildTasksRouter() {
1345
1346
  } catch (e) {
1346
1347
  if (e.code === 'NO_REPO_INFO') return res.fail('no_repo_info', 500);
1347
1348
  if (e.code === 'APP_NOT_INSTALLED') return res.fail('push_unconfigured', { status: 503, message: appNotInstalledMessage(e) });
1348
- log.error('[gds] GET /tasks/:id/publish-status', e.code || e.message);
1349
- return res.fail('status_failed', 502);
1349
+ return failGithubStatus(res, log, e); // task 1004766: names the step + GitHub's answer
1350
1350
  }
1351
1351
  });
1352
1352
 
@@ -247,7 +247,57 @@ async function githubSetupRead(db, inst, row, status, { now = Date.now(), withSt
247
247
  };
248
248
  }
249
249
 
250
+ // The standing install nudge (task 1004763). A project made before the wizard's install
251
+ // step existed (task 1004649), or whose founder closed the GitHub tab, has an app nobody
252
+ // installed on its repo, and every ship then ends land-not-proven because the platform
253
+ // cannot read the repo. The checklist above only shows inside the creation wizard, so the
254
+ // project list and the project's own page carry this instead: the install link and the
255
+ // repo, from the latest attempt, until the runner has SEEN the install. null when there is
256
+ // no app of ours (nothing to install) or GitHub has confirmed it. PURE.
257
+ function installNudge(setup) {
258
+ if (!setup || !setup.checklist || setup.checklist.install === 'done' || !setup.install_url) return null;
259
+ return { install_url: setup.install_url, repo: setup.repo || null };
260
+ }
261
+
262
+ // The nudges for a whole project list in TWO reads, never one per project: each project's
263
+ // latest attempt, then which of those GitHub has been seen installed on since that attempt
264
+ // began. Only the install fact matters here: with an app of ours recorded, the checklist's
265
+ // install step is 'done' exactly when the runner saw it (githubChecklist), so the full
266
+ // read is not needed. Returns a Map of instance id → nudge or null. A switched-off project
267
+ // is silent; a failed read is no nudge for anyone, never a failed list.
268
+ async function installNudgesFor(db, insts) {
269
+ const out = new Map();
270
+ const live = (insts || []).filter((i) => i && i.id != null && i.status !== 'torn_down' && i.status !== 'tearing_down');
271
+ for (const i of insts || []) if (i && i.id != null) out.set(String(i.id), null);
272
+ if (!live.length) return out;
273
+ try {
274
+ const { rows: attempts } = await db.query(
275
+ `SELECT DISTINCT ON (instance_id) instance_id, app_slug, created_at
276
+ FROM provisioning_oauth_manifests
277
+ WHERE instance_id = ANY($1::bigint[])
278
+ ORDER BY instance_id, created_at DESC`,
279
+ [live.map((i) => i.id)]);
280
+ const withApp = attempts.filter((a) => a.app_slug);
281
+ if (!withApp.length) return out;
282
+ const { rows: seen } = await db.query(
283
+ `SELECT DISTINCT e.instance_id
284
+ FROM provisioning_events e
285
+ JOIN unnest($1::bigint[], $2::timestamptz[]) AS s(id, since)
286
+ ON e.instance_id = s.id AND e.created_at >= s.since
287
+ WHERE e.event = $3`,
288
+ [withApp.map((a) => a.instance_id), withApp.map((a) => new Date(a.created_at).toISOString()), SETUP_EVENTS.installed]);
289
+ const installed = new Set(seen.map((r) => String(r.instance_id)));
290
+ const byId = new Map(live.map((i) => [String(i.id), i]));
291
+ for (const a of withApp) {
292
+ const id = String(a.instance_id);
293
+ if (installed.has(id) || !byId.has(id)) continue;
294
+ out.set(id, { install_url: installUrlFor({ appSlug: a.app_slug }), repo: targetRepo(byId.get(id)) });
295
+ }
296
+ } catch (_) { for (const k of out.keys()) out.set(k, null); }
297
+ return out;
298
+ }
299
+
250
300
  module.exports = {
251
301
  SETUP_EVENTS, VERIFY_WINDOW_MS, installUrlFor, targetRepo, githubSetupFacts, githubChecklist, stillVerifying, githubSetupRead,
252
- validGithubLogin, pickUpFor, mintGuard, requeueConversion, recordSignInSeen,
302
+ validGithubLogin, pickUpFor, mintGuard, requeueConversion, recordSignInSeen, installNudge, installNudgesFor,
253
303
  };
@@ -621,12 +621,15 @@ module.exports = function provisioningRoutes() {
621
621
  router.get('/provisioning/instances', auth.requireBuilder, async (req, res) => {
622
622
  try {
623
623
  const rows = await provisioning.listInstancesForOwner(pool, req.builder.id);
624
+ // the standing 'Install the GitHub App' nudge (task 1004763): two reads for the whole list
625
+ const nudges = await githubSetup.installNudgesFor(pool, rows);
624
626
  const instances = [];
625
627
  for (const row of rows) {
626
628
  const open = await provisioning.getOpenIntent(pool, row.id);
627
629
  instances.push({
628
630
  ...ownerInstance(row),
629
631
  open_intent: open ? { action: open.action, state: open.state } : null,
632
+ github_install: nudges.get(String(row.id)) || null,
630
633
  });
631
634
  }
632
635
  res.json({ instances });
@@ -660,13 +663,15 @@ module.exports = function provisioningRoutes() {
660
663
  // The GitHub-App leg's truth rides the same read (task 1003235), so the done
661
664
  // panel recovers it from server state after a reload — not from sessionStorage.
662
665
  const manifest = await provisioning.getLatestOAuthManifestForInstance(pool, id);
666
+ const githubSetupNow = await githubSetup.githubSetupRead(pool, inst, manifest, manifest ? provisioning.publicGithubApp(manifest).status : null, { withState: false });
663
667
  res.json({
664
- instance: ownerInstance(inst),
668
+ // the standing install nudge rides the instance, as on the list read (task 1004763)
669
+ instance: { ...ownerInstance(inst), github_install: githubSetup.installNudge(githubSetupNow) },
665
670
  open_intent: open ? { action: open.action, state: open.state } : null,
666
671
  github_app: provisioning.publicGithubApp(manifest),
667
672
  // the four-step checklist from what the runner verified (task 1004649) — the
668
673
  // install link rides WITHOUT the state token this read never carries
669
- github_setup: await githubSetup.githubSetupRead(pool, inst, manifest, manifest ? provisioning.publicGithubApp(manifest).status : null, { withState: false }),
674
+ github_setup: githubSetupNow,
670
675
  });
671
676
  } catch (err) {
672
677
  log.error('[provisioning] GET /provisioning/instances/:id', err);
@@ -1140,6 +1140,7 @@ summary{min-height:24px;padding:3px 0;}
1140
1140
  stripFor/errMarkup/canRetry/canRevive the list row reads -->
1141
1141
  <div id="mStrip"></div>
1142
1142
  <div class="projMsg err" id="mErr" hidden></div>
1143
+ <div class="projMsg" id="mGh" hidden></div>
1143
1144
  <div class="projMsg" id="mMsg" role="status" aria-live="polite"></div>
1144
1145
  <div class="actions" id="mActs" hidden></div>
1145
1146
  </div>
@@ -8357,6 +8358,8 @@ summary{min-height:24px;padding:3px 0;}
8357
8358
  (renderCard) — a project the owner is being told about cannot sit under
8358
8359
  Live. The intent-vs-status contract is deliberately left alone. */
8359
8360
  if (inst.error_note && !inst.open_intent) return 'attention';
8361
+ /* an app nobody installed blocks every ship until the owner acts (task 1004763) */
8362
+ if (ghInstallRow(inst)) return 'attention';
8360
8363
  return STATE_GROUP[inst.status] || 'all';
8361
8364
  }
8362
8365
  function findText(inst) {
@@ -8828,6 +8831,21 @@ summary{min-height:24px;padding:3px 0;}
8828
8831
  '">Say what it is</a> and it goes on.';
8829
8832
  }
8830
8833
 
8834
+ /* the standing GitHub install nudge (task 1004763). A project whose app was made but never
8835
+ installed on its repo cannot have finished work added: the platform cannot read the
8836
+ repo, so every ship stops short. The wizard's checklist shows only while a project is
8837
+ being made, so the card and the project's own page say it, with GitHub's own install
8838
+ link, until the runner has seen the install. The click also asks for a fresh look
8839
+ (the a[data-gh-install] handler below). '' when there is nothing to install. */
8840
+ function ghInstallRow(inst) {
8841
+ var g = inst && inst.github_install;
8842
+ if (!g || !/^https:\/\/github\.com\/apps\//.test(String(g.install_url || ''))) return '';
8843
+ if (inst.status === 'torn_down' || inst.status === 'tearing_down') return '';
8844
+ return 'Finished work can’t be added to ' + (g.repo ? '<code>' + esc(g.repo) + '</code>' : 'its repo') +
8845
+ ' until the GitHub App is installed on it. <a class="btn small" href="' + esc(g.install_url) +
8846
+ '" target="_blank" rel="noopener" data-gh-install="' + esc(String(inst.id)) + '">Install the GitHub App ↗</a>';
8847
+ }
8848
+
8831
8849
  function renderCard(inst) {
8832
8850
  /* iid is for MARKUP interpolation only (esc() = HTML escape); the DOM
8833
8851
  lookup goes through liFor(), which CSS-escapes — the two escapes are
@@ -8851,6 +8869,8 @@ summary{min-height:24px;padding:3px 0;}
8851
8869
  if (addr) body += '<div class="addr">' + addr + '</div>';
8852
8870
  var pub = publishRow(inst);
8853
8871
  if (pub) body += '<div class="projMsg">' + pub + '</div>';
8872
+ var gh = ghInstallRow(inst);
8873
+ if (gh) body += '<div class="projMsg">' + gh + '</div>';
8854
8874
  if ((inst.status === 'error' || inst.error_note) && !inst.open_intent) {
8855
8875
  /* once a retry is queued the strip tells the story — the stale error
8856
8876
  paragraph would just contradict it (owner walk, 2026-08-12). Also
@@ -9198,6 +9218,9 @@ summary{min-height:24px;padding:3px 0;}
9198
9218
  var snag = (inst.status === 'error' || inst.error_note) && !inst.open_intent;
9199
9219
  $('mErr').innerHTML = snag ? errMarkup(inst) : '';
9200
9220
  $('mErr').hidden = !snag;
9221
+ var gh = ghInstallRow(inst);
9222
+ $('mGh').innerHTML = gh;
9223
+ $('mGh').hidden = !gh;
9201
9224
  var acts = [];
9202
9225
  if (inst.status === 'active' && addressReady(inst)) acts.push('<a class="btn small ghost" href="https://' + esc(inst.domain) + '" target="_blank" rel="noopener">Open site →</a>');
9203
9226
  if (canRetry(inst)) acts.push('<button type="button" class="btn small" data-m-retry>Try again</button>');
@@ -11231,6 +11254,16 @@ summary{min-height:24px;padding:3px 0;}
11231
11254
  return e.message || 'The restart could not be queued — try again in a moment.';
11232
11255
  }
11233
11256
 
11257
+ /* the install nudge's click (task 1004763), on the card and the project's own page alike:
11258
+ GitHub opens in its own tab as the link says, and the platform is asked to look again.
11259
+ GitHub's hand-back from this link carries no attempt, so without the ask nothing would
11260
+ re-check and the nudge would never clear. Best-effort: a refused ask changes nothing. */
11261
+ document.addEventListener('click', function (ev) {
11262
+ var a = ev.target.closest && ev.target.closest('a[data-gh-install]');
11263
+ if (!a) return;
11264
+ fetch(API + '/provisioning/instances/' + encodeURIComponent(a.getAttribute('data-gh-install')) + '/github-app/pick-up', { method: 'POST', credentials: 'same-origin' }).catch(function () {});
11265
+ });
11266
+
11234
11267
  /* the list's one delegated handler. A row holds no sender since task 1003355
11235
11268
  — only its two doors — so the Manage link is all there is to upgrade: it is
11236
11269
  a real anchor, a modified click keeps its browser meaning and a plain one
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.152",
3
+ "version": "1.21.154",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.152",
9
+ "version": "1.21.154",
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.21.152",
3
+ "version": "1.21.154",
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",
@@ -9926,5 +9926,25 @@
9926
9926
  "id": "1003784",
9927
9927
  "text": "Every module now says which of the eight categories it belongs to, and the module list in the terminal and the hall's Modules tab both show it and let you search by it."
9928
9928
  }
9929
+ ],
9930
+ "1.21.153": [
9931
+ {
9932
+ "id": "1004763",
9933
+ "text": "Project owners whose GitHub App was never installed now see an \"Install the GitHub App\" button on their project until GitHub confirms it."
9934
+ },
9935
+ {
9936
+ "id": "1003799",
9937
+ "text": "Two store modules with the same score no longer look identical: each now shows what it is strong at and weak at (tests, installs, reliability, testers, how-to), in the store listing and in the hall's Modules tab."
9938
+ },
9939
+ {
9940
+ "id": "1004766",
9941
+ "text": "When a project's server can't talk to GitHub, the land-status check now says which step failed and what GitHub answered, instead of a bare error."
9942
+ }
9943
+ ],
9944
+ "1.21.154": [
9945
+ {
9946
+ "id": "1003800",
9947
+ "text": "In the module store, a buyer can now sort by the score that matters to them, like reliability or tests, so the module strongest at that comes first."
9948
+ }
9929
9949
  ]
9930
9950
  }
@@ -35,6 +35,97 @@ function placeVersion(score, floor = QUALITY_FLOOR) {
35
35
  return { placement: 'listed', reason: 'at_or_above_floor' };
36
36
  }
37
37
 
38
+ // ---------------------------------------------------------------------------
39
+ // Strengths and weaknesses (task 1003799, ADR 0343 D4)
40
+ // ---------------------------------------------------------------------------
41
+ //
42
+ // The overall number sorts the listing; the parts explain it. Two modules that both
43
+ // score 74 can be good at different things, and a buyer who cares about reliability
44
+ // should be able to see which one is. So each listed entry carries its parts and a
45
+ // short reading of them:
46
+ //
47
+ // strength a part scored at or above STRONG_AT
48
+ // weakness a part scored below the floor — the same bar the listing uses
49
+ // unmeasured a part with no score yet (no installs, no testers, still running).
50
+ // Never a weakness: a missing part is never a zero (D3/D5).
51
+ //
52
+ // Security is not on the list: it is a pass/fail gate (D2), and every listed
53
+ // version has passed it. The thresholds are fixed numbers on purpose, so an author
54
+ // can check why a part reads as strong or weak, the same way the floor works.
55
+ const STRONG_AT = 80;
56
+
57
+ // The parts a buyer compares, in the order they are shown, with what their
58
+ // sample size counts. `docs` is shown but never averaged (ADR 0347).
59
+ const PARTS = [
60
+ { part: 'tests', title: 'Tests', counts: 'test cases' },
61
+ { part: 'install', title: 'Clean installs', counts: 'installs' },
62
+ { part: 'reliability', title: 'Reliability', counts: 'installs' },
63
+ { part: 'tester_feedback', title: 'Tester feedback', counts: 'testers' },
64
+ { part: 'docs', title: 'How-to', counts: null },
65
+ ];
66
+
67
+ // One reading of one version's current part results. `signals` is the newest
68
+ // module_assessment_signals row per part ({ part, outcome, score, sample_size });
69
+ // a part with no row is unmeasured. Pure.
70
+ function readParts(signals = [], { floor = QUALITY_FLOOR } = {}) {
71
+ const byPart = new Map(signals.map((s) => [s.part, s]));
72
+ const parts = {};
73
+ const strengths = [];
74
+ const weaknesses = [];
75
+ const unmeasured = [];
76
+ for (const { part, title, counts } of PARTS) {
77
+ const s = byPart.get(part);
78
+ const scored = s && s.outcome === 'scored' && Number.isFinite(Number(s.score));
79
+ parts[part] = s
80
+ ? { outcome: s.outcome, score: scored ? Number(s.score) : null, sample_size: s.sample_size == null ? null : Number(s.sample_size) }
81
+ : { outcome: 'no_data', score: null, sample_size: null };
82
+ if (!scored) { unmeasured.push({ part, title }); continue; }
83
+ const score = Number(s.score);
84
+ const n = parts[part].sample_size;
85
+ const from = counts && n ? `, from ${n} ${n === 1 ? counts.replace(/s$/, '') : counts}` : '';
86
+ const item = { part, title, score, detail: `${title} scored ${score} out of 100${from}.` };
87
+ if (score >= STRONG_AT) strengths.push(item);
88
+ else if (score < floor) weaknesses.push(item);
89
+ }
90
+ strengths.sort((a, b) => b.score - a.score);
91
+ weaknesses.sort((a, b) => a.score - b.score);
92
+ return { parts, strengths, weaknesses, unmeasured };
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Sort by the part a buyer cares about (task 1003800, owner decision 2026-10-22)
97
+ // ---------------------------------------------------------------------------
98
+ //
99
+ // A module strong at what a buyer needs should come first for THAT buyer. Nothing
100
+ // records what a category is "for", and a hidden per-category weighting would break
101
+ // the score an author can check (ADR 0343 D3), so the owner chose: the buyer picks
102
+ // the part. The rule, in a sentence an author can check:
103
+ //
104
+ // modules with a score for the part come first, highest first; a tie goes to the
105
+ // higher overall score, then the key. A module with no score for the part goes
106
+ // last, in key order: a missing part is never a zero, and never a first place.
107
+ //
108
+ // Only `listed` is sorted. A "New" version is never ranked (owner decision,
109
+ // 2026-10-01), so `new` keeps key order whatever the buyer picks. Today this runs
110
+ // over the whole store; once approved sub-categories exist (task 1004355) the same
111
+ // rule orders each one (ADR 0342 D2).
112
+ const SORT_PARTS = PARTS.map(({ part, title }) => ({ part, title }));
113
+
114
+ // `entries` carry `parts` as readParts builds them. Returns a new array. Pure.
115
+ function sortByPart(entries, part) {
116
+ const scoreOf = (e) => (e.parts && e.parts[part] ? e.parts[part].score : null);
117
+ return [...entries].sort((a, b) => {
118
+ const sa = scoreOf(a);
119
+ const sb = scoreOf(b);
120
+ if (sa == null || sb == null) {
121
+ if (sa != null) return -1;
122
+ if (sb != null) return 1;
123
+ return a.key.localeCompare(b.key);
124
+ }
125
+ return (sb - sa) || ((b.overall ?? -1) - (a.overall ?? -1)) || a.key.localeCompare(b.key);
126
+ });
127
+ }
128
+
38
129
  // The newest version row per module key, in semver order (a version that does not
39
130
  // parse is skipped, as latestVersions does in module-store.js).
40
131
  function newestPerModule(rows) {
@@ -49,11 +140,13 @@ function newestPerModule(rows) {
49
140
  return [...out.values()];
50
141
  }
51
142
 
52
- // The catalog: { floor, listed: [...], new: [...] }, each entry
53
- // { key, title, version, published_at, overall, is_new }. Excluded versions are left
143
+ // The catalog: { floor, sort, sort_parts, listed: [...], new: [...] }, each entry
144
+ // { key, title, version, published_at, overall, is_new, parts, strengths, weaknesses,
145
+ // unmeasured } (the last four from readParts). Excluded versions are left
54
146
  // out entirely: a module below the bar is not listed at all. Both lists are in key
55
- // order; ranking is a separate task (1003808) and does not happen here.
56
- async function listCatalog({ db, floor = QUALITY_FLOOR } = {}) {
147
+ // order unless `sort` names a part, which orders `listed` by sortByPart; ranking
148
+ // is a separate task (1003808) and does not happen here.
149
+ async function listCatalog({ db, floor = QUALITY_FLOOR, sort = null } = {}) {
57
150
  const pool = db || require('./pool').pool;
58
151
  const { rows: versions } = await pool.query(
59
152
  `SELECT m.module_key, m.title, v.id AS version_id, v.version, v.published_at
@@ -69,7 +162,22 @@ async function listCatalog({ db, floor = QUALITY_FLOOR } = {}) {
69
162
  ORDER BY version_id, computed_at DESC, id DESC`, [newest.map((r) => r.version_id)]);
70
163
  for (const s of rows) scores.set(String(s.version_id), s);
71
164
  }
72
- const out = { floor, listed: [], new: [] };
165
+ // Each version's current result per part: the newest row per (version, part),
166
+ // the read idx_module_assessment_signals_current exists for (core_265).
167
+ const signals = new Map();
168
+ if (newest.length) {
169
+ const { rows } = await pool.query(
170
+ `SELECT DISTINCT ON (version_id, part) version_id, part, outcome, score, sample_size
171
+ FROM module_assessment_signals
172
+ WHERE version_id = ANY($1)
173
+ ORDER BY version_id, part, measured_at DESC, id DESC`, [newest.map((r) => r.version_id)]);
174
+ for (const s of rows) {
175
+ const k = String(s.version_id);
176
+ if (!signals.has(k)) signals.set(k, []);
177
+ signals.get(k).push(s);
178
+ }
179
+ }
180
+ const out = { floor, sort: sort || null, sort_parts: SORT_PARTS, listed: [], new: [] };
73
181
  for (const r of newest.sort((a, b) => a.module_key.localeCompare(b.module_key))) {
74
182
  const score = scores.get(String(r.version_id)) || null;
75
183
  const { placement } = placeVersion(score, floor);
@@ -77,9 +185,11 @@ async function listCatalog({ db, floor = QUALITY_FLOOR } = {}) {
77
185
  out[placement].push({
78
186
  key: r.module_key, title: r.title, version: r.version, published_at: r.published_at,
79
187
  overall: placement === 'new' ? null : score.overall, is_new: placement === 'new',
188
+ ...readParts(signals.get(String(r.version_id)) || [], { floor }),
80
189
  });
81
190
  }
191
+ if (sort) out.listed = sortByPart(out.listed, sort);
82
192
  return out;
83
193
  }
84
194
 
85
- module.exports = { QUALITY_FLOOR, placeVersion, listCatalog };
195
+ module.exports = { QUALITY_FLOOR, STRONG_AT, SORT_PARTS, placeVersion, readParts, sortByPart, listCatalog };