@bongos/core 1.21.151 → 1.21.153

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.
Files changed (55) hide show
  1. package/.bongos-core.json +73 -58
  2. package/docs/copy-inventory.md +323 -317
  3. package/docs/copy-registry.json +423 -369
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/modules-contract.md +30 -0
  6. package/docs/page-readings.json +234 -227
  7. package/modules/agents/module.json +1 -0
  8. package/modules/autonomy/module.json +1 -0
  9. package/modules/builder-settings/module.json +1 -0
  10. package/modules/copy-desk/module.json +1 -0
  11. package/modules/design-styles/module.json +1 -0
  12. package/modules/discord/module.json +1 -0
  13. package/modules/economy/module.json +1 -0
  14. package/modules/government/module.json +1 -0
  15. package/modules/grading/module.json +1 -0
  16. package/modules/hall-ui/module.json +1 -0
  17. package/modules/hall-ui/public/modules.html +13 -0
  18. package/modules/hall-ui/public/modules.js +101 -4
  19. package/modules/hall-ui/public/modules.states.json +2 -1
  20. package/modules/hall-ui/records/catalog.md +2 -0
  21. package/modules/ideas/module.json +1 -0
  22. package/modules/lifecycle/github-failure.js +50 -0
  23. package/modules/lifecycle/module.json +1 -0
  24. package/modules/lifecycle/routes/tasks.js +2 -2
  25. package/modules/memory/module.json +1 -0
  26. package/modules/npm-release/module.json +1 -0
  27. package/modules/onboarding/module.json +1 -0
  28. package/modules/page-notes/module.json +1 -0
  29. package/modules/pixel-art/module.json +1 -0
  30. package/modules/platform-identity/module.json +1 -0
  31. package/modules/provisioning/github-setup.js +51 -1
  32. package/modules/provisioning/module.json +1 -0
  33. package/modules/provisioning/routes/provisioning.js +7 -2
  34. package/modules/public-landing/module.json +1 -0
  35. package/modules/public-landing/public/projects.html +33 -0
  36. package/modules/render-deploy/module.json +1 -0
  37. package/modules/security/module.json +1 -0
  38. package/modules/sessions/module.json +1 -0
  39. package/modules/specialities/module.json +1 -0
  40. package/modules/status-ui/module.json +1 -0
  41. package/modules/ui-design/module.json +1 -0
  42. package/package-lock.json +2 -2
  43. package/package.json +1 -1
  44. package/release-notes.json +20 -0
  45. package/scripts/gds/module.js +4 -2
  46. package/src/bongos/module-listing.js +76 -2
  47. package/src/module-api.js +1 -1
  48. package/src/module-loader/catalog.js +15 -1
  49. package/src/module-loader/manifest-schema.js +40 -1
  50. package/tests/github_failure.mjs +79 -0
  51. package/tests/github_install_nudge.mjs +219 -0
  52. package/tests/hall_catalog_world.mjs +23 -3
  53. package/tests/module_catalog.mjs +34 -0
  54. package/tests/module_manifest.mjs +36 -0
  55. package/tests/module_store_listing.mjs +78 -1
@@ -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
@@ -4,6 +4,7 @@
4
4
  "description": "The deploy page for a project whose app deploys through Render: the service's Render deploys, each tied to its commit and the Bongos tasks it carried, what is live now, and deploy and roll back behind a two-step confirm (task 1004270). Off in every hall until its owner turns it on.",
5
5
  "version": "1.0.0",
6
6
  "coreVersion": "^1.19.0",
7
+ "category": { "parent": "hosting" },
7
8
  "default": false,
8
9
  "author": "Cloud Bongos",
9
10
  "origin": "Cloud Bongos core",
@@ -4,6 +4,7 @@
4
4
  "description": "The red-team / vulnerability-report intake and the bounty payout system (rank-gated Metic+): the /builder-redteam skill files a report, the Archon dashboard adjudicates (confirm/dispute/fix/link-fix), and the env-aware bounty table sets the per-severity drachmae payout. A core-domain module carved out of the GDS core in BV1.R79 (ADR 0093 §1; 02-module-map §5 — security-the-FEATURE, NOT security-the-property, which stays kernel). default-on because every instance can take red-team reports. Registers the `security` kernel PORT the core /public/bounty-table resolves for the payout schedule. Also owns the scan-before-install surface (goal 1000055): the artifact-scan engine (quarantine/manifest/mirror) and the security_tool_scans record — POST Metic+, GET every rank.",
5
5
  "version": "1.0.0",
6
6
  "coreVersion": "^1.8.0",
7
+ "category": { "parent": "quality" },
7
8
  "default": true,
8
9
  "maintenance": { "status": "core-maintained" },
9
10
  "contributes": { "routes": ["reports", "scans"], "migrations": true },
@@ -4,6 +4,7 @@
4
4
  "description": "The session-records corpus + its data plane (POST /sessions upload, the Archon-gated cross-builder /sessions/search + /sessions/:id deep-dive + /sessions/upload-health, the server-side signal that a builder's session uploads have silently stopped), the BFG 'read-the-room' cross-builder scorer (ADR 0026 §6B), and the session-pulse / stale-claim heartbeat helpers. A core-domain module carved out of the GDS core in BV1.R82 (ADR 0093 §1); default-on because every instance records its builders' sessions for the BFG. Like the memory module, sessions registers no port — core never calls back into it: the ship-time digest write is an HTTP POST to the module's own /sessions route, and the only outward dependency is the economy `reward` kernel PORT it resolves (session token reward + sumCreditsForTasks) at upload time.",
5
5
  "version": "1.0.0",
6
6
  "coreVersion": "^1.10.0",
7
+ "category": { "parent": "knowledge" },
7
8
  "default": true,
8
9
  "maintenance": { "status": "core-maintained" },
9
10
  "contributes": { "routes": ["sessions"], "pollers": ["session-reward-reconcile", "session-upload-health"] },
@@ -4,6 +4,7 @@
4
4
  "description": "Role SUB-SPECIALITIES — a named bundle of knowledge, skills and an interaction contract that sits UNDER a craft (task 1003855; ADR 0296). A craft says how you work (engineer / ideator / artist / ui); a speciality says what you work ON and how a subject-matter expert wants to be engaged inside it. Deliberately a thin layer over three surfaces that already exist — discipline-modes.json for craft->playbook, behavior_prefs.interaction for the per-session contract (a speciality contract rides that SAME GET /me -> Conductor path rather than adding a second one), and the ADR 0083 manifest for declared skills; the only new machinery is the knowledge bundle. Training is two-fold per the owner's 2026-09-17 ruling: documents the builder supplied, plus learnings captured from sessions worked in the speciality (pointing at existing `learnings` rows, never a second corpus). Owned per-builder OR per-project, each carrying its owner's sharing decision (private / project / public) and an explicit sellable flag — the flag records intent only, because ADR 0107 defers the marketplace and there is nothing to list on. default-on: a speciality is how an instance's expertise enters the platform, so it ships like the hall and the status page.",
5
5
  "version": "1.0.0",
6
6
  "coreVersion": "^1.19.0",
7
+ "category": { "parent": "people" },
7
8
  "default": true,
8
9
  "maintenance": { "status": "core-maintained" },
9
10
  "migrations": true,
@@ -4,6 +4,7 @@
4
4
  "description": "The public build-status dashboard served at status.<apex> (status.example.com) — the live up/down banner, version-progress rollups, cost summary, recent ships, leaderboard, and the ecosystem fingerprint, all polling the read-only /api/bongos/public/* surface every 60s. A WEB-SURFACE core-domain module carved out of the GDS core in BV1.R84 (ADR 0093 §1; 02-module-map §6 — a web surface is first-class). default-on: a Cloud Bongos instance ships its status page like it ships its hall. Registers no port and contributes no routes — it declares a webSurface (host prefix + static dir) the loader hands serve-internal, which serves the module's public/ dir on the matching host exactly as it served public-status/ before. The public read endpoints it renders stay SHARED core in routes/public.js (the hall + the off-box mirror read them too).",
5
5
  "version": "1.0.0",
6
6
  "coreVersion": "^1.11.0",
7
+ "category": { "parent": "pages" },
7
8
  "default": true,
8
9
  "maintenance": { "status": "core-maintained" },
9
10
  "contributes": {
@@ -4,6 +4,7 @@
4
4
  "description": "The platform capability that lets any builder on any instance create and design good-looking, functional UIs in line with that instance's world (scoped with the owner 2026-08-28; task 1003324, ADR 0197). It ships METHOD and never a world: the neutral design contract of ADR 0081 with its tool adapters (Claude Design, Figma), their sync CLIs and the validator; the /design playbook for ui-discipline work; the look-before-you-ship kit (kit/) and the style library (styles/). The thirteen design-style skills that lived in skills/ are the opt-in design-styles module since task 1004470; /design reaches them when that module is on. The instance's world — its branding pack, config/design-tokens.*, config/design-sources.json and its DESIGN.md — stays in config/ and at the repo root as the host boundary this module reads and never owns (ADR 0062 §7). Cloud Bongos's own front door is this module's first customer, not its owner. default-on because every instance has a face. No routes, no migrations, no port: declaration-only like character-anim, so disabling it withdraws only the skills it contributes and touches no served surface.",
5
5
  "version": "1.0.0",
6
6
  "coreVersion": "^1.19.0",
7
+ "category": { "parent": "pages" },
7
8
  "default": true,
8
9
  "maintenance": { "status": "core-maintained" },
9
10
  "contributes": { "skills": ["design", "design-sync", "figma-design-sync"], "disciplines": ["ui"] },
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.151",
3
+ "version": "1.21.153",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.151",
9
+ "version": "1.21.153",
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.151",
3
+ "version": "1.21.153",
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",
@@ -9920,5 +9920,25 @@
9920
9920
  "id": "1004739",
9921
9921
  "text": "The module test runner can now test against the private core: the server sends its own copy of the core into the private test repo, once per version."
9922
9922
  }
9923
+ ],
9924
+ "1.21.152": [
9925
+ {
9926
+ "id": "1003784",
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
+ }
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
+ }
9923
9943
  ]
9924
9944
  }
@@ -452,14 +452,16 @@ function formatCatalog(rows, { catalogOnly = false, query = '', core = null, del
452
452
  return out.join('\n');
453
453
  }
454
454
 
455
- out.push(` ${pad('', 5)}${pad('KEY', 20)}${pad('VERSION', 9)}${pad('SOURCE', 10)}${pad('AUTHOR', 20)}${pad('ORIGIN', 26)}${pad('LICENSE', 12)}UPKEEP`);
455
+ out.push(` ${pad('', 5)}${pad('KEY', 20)}${pad('VERSION', 9)}${pad('SOURCE', 10)}${pad('CATEGORY', 28)}${pad('AUTHOR', 20)}${pad('ORIGIN', 26)}${pad('LICENSE', 12)}UPKEEP`);
456
456
  for (const r of shown) {
457
457
  const mark = r.enabled ? '[x]' : '[ ]';
458
458
  // An inferred credit is marked so it never reads as something the author
459
459
  // typed — the catalog's whole job is honest attribution (ADR 0107 §2).
460
460
  const suffix = r.credit.inferred ? '*' : '';
461
461
  const upkeep = ((r.maintenance && r.maintenance.status) || 'undeclared') + (delisted.has(r.key) ? ' · DELISTED' : '');
462
- out.push(` ${pad(mark, 5)}${pad(clip(r.key, 19), 20)}${pad(clip(r.version || '—', 8), 9)}${pad(r.source, 10)}${pad(clip((r.credit.author || '—') + suffix, 19), 20)}${pad(clip((r.credit.origin || '—') + suffix, 25), 26)}${pad(clip(r.license || '—', 11), 12)}${upkeep}`);
462
+ // The parent key, plus the sub-category once one is approved (ADR 0342).
463
+ const cat = r.category ? r.category.parent + (r.category.sub ? `/${r.category.sub}` : '') : '—';
464
+ out.push(` ${pad(mark, 5)}${pad(clip(r.key, 19), 20)}${pad(clip(r.version || '—', 8), 9)}${pad(r.source, 10)}${pad(clip(cat, 27), 28)}${pad(clip((r.credit.author || '—') + suffix, 19), 20)}${pad(clip((r.credit.origin || '—') + suffix, 25), 26)}${pad(clip(r.license || '—', 11), 12)}${upkeep}`);
463
465
  }
464
466
  out.push('');
465
467
  out.push(' [x] enabled here · [ ] available, not enabled');
@@ -35,6 +35,63 @@ 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
+
38
95
  // The newest version row per module key, in semver order (a version that does not
39
96
  // parse is skipped, as latestVersions does in module-store.js).
40
97
  function newestPerModule(rows) {
@@ -50,7 +107,8 @@ function newestPerModule(rows) {
50
107
  }
51
108
 
52
109
  // The catalog: { floor, listed: [...], new: [...] }, each entry
53
- // { key, title, version, published_at, overall, is_new }. Excluded versions are left
110
+ // { key, title, version, published_at, overall, is_new, parts, strengths, weaknesses,
111
+ // unmeasured } (the last four from readParts). Excluded versions are left
54
112
  // out entirely: a module below the bar is not listed at all. Both lists are in key
55
113
  // order; ranking is a separate task (1003808) and does not happen here.
56
114
  async function listCatalog({ db, floor = QUALITY_FLOOR } = {}) {
@@ -69,6 +127,21 @@ async function listCatalog({ db, floor = QUALITY_FLOOR } = {}) {
69
127
  ORDER BY version_id, computed_at DESC, id DESC`, [newest.map((r) => r.version_id)]);
70
128
  for (const s of rows) scores.set(String(s.version_id), s);
71
129
  }
130
+ // Each version's current result per part: the newest row per (version, part),
131
+ // the read idx_module_assessment_signals_current exists for (core_265).
132
+ const signals = new Map();
133
+ if (newest.length) {
134
+ const { rows } = await pool.query(
135
+ `SELECT DISTINCT ON (version_id, part) version_id, part, outcome, score, sample_size
136
+ FROM module_assessment_signals
137
+ WHERE version_id = ANY($1)
138
+ ORDER BY version_id, part, measured_at DESC, id DESC`, [newest.map((r) => r.version_id)]);
139
+ for (const s of rows) {
140
+ const k = String(s.version_id);
141
+ if (!signals.has(k)) signals.set(k, []);
142
+ signals.get(k).push(s);
143
+ }
144
+ }
72
145
  const out = { floor, listed: [], new: [] };
73
146
  for (const r of newest.sort((a, b) => a.module_key.localeCompare(b.module_key))) {
74
147
  const score = scores.get(String(r.version_id)) || null;
@@ -77,9 +150,10 @@ async function listCatalog({ db, floor = QUALITY_FLOOR } = {}) {
77
150
  out[placement].push({
78
151
  key: r.module_key, title: r.title, version: r.version, published_at: r.published_at,
79
152
  overall: placement === 'new' ? null : score.overall, is_new: placement === 'new',
153
+ ...readParts(signals.get(String(r.version_id)) || [], { floor }),
80
154
  });
81
155
  }
82
156
  return out;
83
157
  }
84
158
 
85
- module.exports = { QUALITY_FLOOR, placeVersion, listCatalog };
159
+ module.exports = { QUALITY_FLOOR, STRONG_AT, placeVersion, readParts, listCatalog };
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.151'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.153'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -30,6 +30,7 @@
30
30
  const fs = require('node:fs');
31
31
  const path = require('node:path');
32
32
  const { deriveProvenance } = require('./provenance');
33
+ const { CATEGORY_PARENTS } = require('./manifest-schema');
33
34
 
34
35
  // The core's own published + source package names. A checkout whose package.json
35
36
  // says one of these IS the core, so its modules/ IS the catalog (see
@@ -99,6 +100,17 @@ function creditFor(manifest = {}, { source = 'instance', coreProjectName = CORE_
99
100
  };
100
101
  }
101
102
 
103
+ // Where a module is browsed (ADR 0342): its parent key, that parent's label, and its
104
+ // sub-category key — or null when the manifest declares none. The label is resolved
105
+ // HERE so the hall and the CLI never keep their own copy of the parent list. A parent
106
+ // outside the closed list cannot reach this (the validator refuses it), so an unknown
107
+ // one is null rather than shown under a label nobody chose.
108
+ function categoryFor(manifest = {}) {
109
+ const c = manifest.category;
110
+ if (!c || typeof c !== 'object' || !Object.hasOwn(CATEGORY_PARENTS, c.parent)) return null;
111
+ return { parent: c.parent, parentTitle: CATEGORY_PARENTS[c.parent], sub: c.sub || null };
112
+ }
113
+
102
114
  // One catalog row per discovered module. `loaded` is the loader's
103
115
  // discoverModules().loaded ([{ key, dir, manifest }]); `enabled` is the resolved
104
116
  // enablement map (src/modules.js modules()).
@@ -138,6 +150,7 @@ function buildCatalog({
138
150
  license: manifest.license || null,
139
151
  maintainer: manifest.maintainer || null,
140
152
  maintenance: manifest.maintenance || null,
153
+ category: categoryFor(manifest),
141
154
  };
142
155
  })
143
156
  .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
@@ -160,7 +173,8 @@ function instanceRows(rows = []) {
160
173
  function searchCatalog(rows = [], query = '') {
161
174
  const q = String(query || '').trim().toLowerCase();
162
175
  if (!q) return rows;
163
- return rows.filter((r) => [r.key, r.title, r.description, r.credit.author, r.credit.origin, r.license]
176
+ return rows.filter((r) => [r.key, r.title, r.description, r.credit.author, r.credit.origin, r.license,
177
+ r.category && r.category.parentTitle, r.category && r.category.sub]
164
178
  .some((f) => f && String(f).toLowerCase().includes(q)));
165
179
  }
166
180
 
@@ -53,6 +53,10 @@
53
53
  // // actually declares in `contributes`, and every entry must be
54
54
  // // one it actually lists there — a typo'd exemption is an error,
55
55
  // // never a silent pass.
56
+ // "category": { // OPTIONAL. Where the module is browsed and ranked (ADR 0342).
57
+ // "parent": "money", // REQUIRED in-block: one of the eight CATEGORY_PARENTS keys.
58
+ // "sub": "stripe-payments" // OPTIONAL. An approved sub-category key — the comparable set
59
+ // }, // the store's cap of ten counts within; absent while pending.
56
60
  // "howto": { // OPTIONAL. Extras beside the module's HOWTO.md (ADR 0347 D3).
57
61
  // "artifactUrl": "https://claude.ai/…" // A Claude page with the same how-to, shown as an extra,
58
62
  // }, // never graded and never required — the file is the how-to.
@@ -106,6 +110,24 @@ const SPEND_KEYS = ['requiresPayer'];
106
110
  // The `howto` block (ADR 0347 D3) — closed for the same reason; not exported either.
107
111
  const HOWTO_KEYS = ['artifactUrl'];
108
112
 
113
+ // The eight fixed parent categories (ADR 0342 D1), key → the label a catalog shows.
114
+ // A parent is for browsing only: the cap of ten counts within a SUB-category, never a
115
+ // parent (D2). The list is closed so a typo'd "monye" fails loud rather than filing
116
+ // the module under a ninth parent nobody browses; changing it needs a new ADR.
117
+ // `sub` is shape-checked only — which sub-categories are approved is ledger state
118
+ // (task 1004355), not something a pure validator can know.
119
+ const CATEGORY_PARENTS = Object.freeze({
120
+ work: 'Work & planning',
121
+ people: 'Team & people',
122
+ quality: 'Quality & review',
123
+ money: 'Money',
124
+ communication: 'Communication',
125
+ pages: 'Pages & design',
126
+ hosting: 'Hosting & deploy',
127
+ knowledge: 'Knowledge',
128
+ });
129
+ const CATEGORY_KEYS = ['parent', 'sub'];
130
+
109
131
  // An https link on claude.ai (or a subdomain). Shape only: the store never fetches it.
110
132
  function isClaudeArtifactUrl(v) {
111
133
  if (!isStr(v)) return false;
@@ -320,6 +342,23 @@ function validateManifest(obj) {
320
342
  }
321
343
  }
322
344
 
345
+ // --- optional category (ADR 0342) ---
346
+ if ('category' in obj) {
347
+ const c = obj.category;
348
+ const parents = Object.keys(CATEGORY_PARENTS);
349
+ if (!isObj(c)) E('category: must be an object (e.g. { "parent": "money", "sub": "stripe-payments" })');
350
+ else {
351
+ for (const k of Object.keys(c)) {
352
+ if (!CATEGORY_KEYS.includes(k)) E(`category.${k}: unknown key (allowed: ${CATEGORY_KEYS.join(', ')}) — a module has exactly one sub-category (ADR 0342 D4)`);
353
+ }
354
+ if (!isStr(c.parent)) E(`category.parent: required — one of ${parents.join(', ')}`);
355
+ else if (!parents.includes(c.parent)) E(`category.parent: "${c.parent}" is not one of ${parents.join(', ')} (ADR 0342 D1)`);
356
+ if ('sub' in c && (!isStr(c.sub) || !KEY_RE.test(c.sub))) {
357
+ E('category.sub: must be a sub-category key (lowercase kebab-case) when present — leave it out while a new one is pending approval');
358
+ }
359
+ }
360
+ }
361
+
323
362
  // --- optional how-to extras (ADR 0347 D3) ---
324
363
  if ('howto' in obj) {
325
364
  const h = obj.howto;
@@ -370,5 +409,5 @@ function assertValidManifest(obj, label = 'module.json') {
370
409
 
371
410
  module.exports = {
372
411
  validateManifest, assertValidManifest, declaredEntries, entryLabel, KEY_RE, ALLOWED_CONTRIBUTES, ARRAY_OF_STRING_KEYS, ATTRIBUTION_KEYS,
373
- MAINTENANCE_STATUSES, MAINTENANCE_KEYS, SUNSET_STATUSES,
412
+ MAINTENANCE_STATUSES, MAINTENANCE_KEYS, SUNSET_STATUSES, CATEGORY_PARENTS,
374
413
  };
@@ -0,0 +1,79 @@
1
+ // tests/github_failure.mjs — a failed GitHub call says WHICH step broke and what
2
+ // GitHub answered (task 1004766).
3
+ //
4
+ // On the toast instance every GET /tasks/:id/publish-status answered a bare 502
5
+ // `status_failed` with no message, and the server log carried one word. The thrown
6
+ // errors already held the step (code) and GitHub's reply (status, detail).
7
+ //
8
+ // Run: node tests/github_failure.mjs
9
+
10
+ import { strict as assert } from 'node:assert';
11
+ import { createRequire } from 'node:module';
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import url from 'node:url';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const ROOT = path.dirname(path.dirname(url.fileURLToPath(import.meta.url)));
18
+ const { describeGithubFailure } = require(path.join(ROOT, 'modules/lifecycle/github-failure.js'));
19
+
20
+ let passed = 0;
21
+ let failed = 0;
22
+ function t(name, fn) {
23
+ try { fn(); passed++; console.log(` ok ${name}`); }
24
+ catch (e) { failed++; console.log(` FAIL ${name}`); console.log(` ${e.message}`); }
25
+ }
26
+ const thrown = (code, extra = {}) => Object.assign(new Error(code), { code }, extra);
27
+
28
+ t('an App token refusal names the step and GitHub\'s answer', () => {
29
+ const r = describeGithubFailure(thrown('APP_TOKEN_FAILED', { status: 401, detail: '{"message":"A JSON web token could not be decoded"}' }));
30
+ assert.match(r.message, /GitHub App token/);
31
+ assert.match(r.message, /HTTP 401: \{"message":"A JSON web token could not be decoded"\}/);
32
+ assert.equal(r.details.cause, 'APP_TOKEN_FAILED');
33
+ assert.equal(r.details.github_status, 401);
34
+ assert.match(r.log, /APP_TOKEN_FAILED \(GitHub HTTP 401/);
35
+ });
36
+
37
+ t('a refused repository read is told apart from a token failure', () => {
38
+ const r = describeGithubFailure(thrown('STATUS_FETCH_FAILED', { status: 403 }));
39
+ assert.match(r.message, /refused a read of the repository/);
40
+ assert.match(r.message, /HTTP 403\./);
41
+ assert.equal(r.details.github_detail, null);
42
+ });
43
+
44
+ t('an unknown error still says something, and never invents a GitHub status', () => {
45
+ const r = describeGithubFailure(new Error('fetch failed'));
46
+ assert.match(r.message, /unexpected error/);
47
+ assert.doesNotMatch(r.message, /HTTP/);
48
+ assert.equal(r.details.cause, null);
49
+ assert.equal(r.log, 'fetch failed');
50
+ });
51
+
52
+ t('GitHub\'s detail is capped', () => {
53
+ const r = describeGithubFailure(thrown('APP_TOKEN_FAILED', { status: 500, detail: 'x'.repeat(5000) }));
54
+ assert.ok(r.details.github_detail.length <= 300);
55
+ });
56
+
57
+ t('publish-status sends the description, not a bare 502', () => {
58
+ const src = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/routes/tasks.js'), 'utf8');
59
+ const i = src.indexOf("router.get('/tasks/:id/publish-status'");
60
+ const handler = src.slice(i, src.indexOf('router.', i + 20));
61
+ assert.match(handler, /failGithubStatus\(res, log, e\)/);
62
+ assert.doesNotMatch(handler, /res\.fail\('status_failed', 502\)/);
63
+ });
64
+
65
+ t('the route answer carries the message and logs the step', () => {
66
+ const { failGithubStatus } = require(path.join(ROOT, 'modules/lifecycle/github-failure.js'));
67
+ let sent = null;
68
+ const logged = [];
69
+ const res = { fail: (code, opts) => { sent = { code, ...opts }; return 'sent'; } };
70
+ const out = failGithubStatus(res, { error: (...a) => logged.push(a.join(' ')) }, thrown('APP_TOKEN_FAILED', { status: 401 }));
71
+ assert.equal(out, 'sent');
72
+ assert.equal(sent.code, 'status_failed');
73
+ assert.equal(sent.status, 502);
74
+ assert.match(sent.message, /GitHub App token.*HTTP 401/);
75
+ assert.match(logged[0], /APP_TOKEN_FAILED \(GitHub HTTP 401\)/);
76
+ });
77
+
78
+ console.log(`\n${passed} passed, ${failed} failed`);
79
+ process.exit(failed ? 1 : 0);