@bongos/core 1.21.73 → 1.21.75

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.
@@ -362,6 +362,7 @@ Run `bongos upgrade` to confirm coreVersion compatibility, then `node scripts/gd
362
362
  - The publish denylist applies (ADR 0098), plus a refusal of credential-named files (`.env*`, `*.pem`, `*.key`, `id_rsa`, …) anywhere in the module. The store re-checks every hash and the denylist itself.
363
363
  - Gate: Metic+ (`module.submit`) for now. This is not `bongos module submit`, which proposes a module *into* core.
364
364
  - **A how-to is required** ([ADR 0347](adr/0347-every-store-module-ships-a-how-to.md)). `modules/<key>/HOWTO.md` is what a buyer reads — the store shows it as the module's page — so it is written for the person who installs the module, not for an AI working inside it (that is `CLAUDE.md`). It is plain Markdown: any AI or person can write it. Publish refuses the version unless it has these five level-2 headings, in any order and any case, each with some text under it: `## What it does`, `## Install and enable`, `## How to use it` (with one worked example), `## Configuration` ("None." is fine) and `## Limits and known issues` ("None known." is fine). HTML comments don't count as text, so the scaffold's prompts must be replaced. The refusal names each failing section. The check is one function, `checkHowto` in `scripts/gds/module-artifact.js`; the CLI runs it before uploading and the store runs it again on upload, so a hand-built upload can't skip it. `bongos module check` reports it as advice only — an upstream submit is not gated by it. Install and update don't re-check it, so a version published before the gate still installs. The file travels in the tarball, so its hash pins it to the version. An optional `howto.artifactUrl` in `module.json` (an `https://` link on `claude.ai`) is shown as an extra, never graded.
365
+ - **Getting listed** (task 1003807, [ADR 0343](adr/0343-a-module-score-is-a-security-gate-then-an-average-of-visible-parts.md)). Publishing a version does not by itself put it in the store's catalog, `GET /store/modules`. The catalog shows each module's newest version, judged by its current score. It must pass the Security gate first; a version that failed it, or has not been checked yet, is not listed. A new version with no install or reliability data yet is listed with a **New** label and is not ranked; the floor does not apply to it until it has a real score. Every other version is listed only if its overall score is **at least 50 out of 100**. Below that it is not listed at all. The floor is fixed in Bongos itself (`QUALITY_FLOOR` in `src/bongos/module-listing.js`) and returned in the catalog response; no author or instance setting moves it, and changing it is an owner-approved, reviewed task.
365
366
 
366
367
  ### 7. Install from the store
367
368
 
@@ -503,7 +503,7 @@
503
503
  "surface": "builders",
504
504
  "title": "Deploy",
505
505
  "states_read": ["work","out"],
506
- "files_hash": "9d04d1e917f1926c",
506
+ "files_hash": "535c116eea07d320",
507
507
  "reading_hash": "b9fc5f83cc76b257",
508
508
  "counts": {"lines":21,"placed":5,"shared":9,"unplaced":7},
509
509
  "omitted": {"data":1},
@@ -1035,7 +1035,7 @@
1035
1035
  "surface": "builders",
1036
1036
  "title": "Fleet costs",
1037
1037
  "states_read": ["ledger","out"],
1038
- "files_hash": "d817fbfbc40b315b",
1038
+ "files_hash": "153446369d287bc8",
1039
1039
  "reading_hash": "db04003c871f8317",
1040
1040
  "counts": {"lines":36,"placed":11,"shared":9,"unplaced":16},
1041
1041
  "omitted": {"data":11},
@@ -2586,8 +2586,8 @@
2586
2586
  "surface": "builders",
2587
2587
  "title": "Project settings",
2588
2588
  "states_read": ["default"],
2589
- "files_hash": "8ca315d376593099",
2590
- "reading_hash": "f6e04628d8e99880",
2589
+ "files_hash": "525a8712c618456d",
2590
+ "reading_hash": "8bbf4ff80ec8097c",
2591
2591
  "counts": {"lines":31,"placed":17,"shared":9,"unplaced":5},
2592
2592
  "omitted": {"data":1},
2593
2593
  "notes": [],
@@ -2601,13 +2601,13 @@
2601
2601
  {"key":"L0007","section":"top bar","text":"Ideating","placement":"shared"},
2602
2602
  {"key":"L0008","section":"top bar","text":"Governing","placement":"shared"},
2603
2603
  {"key":"L0009","section":"Project settings","text":"Project settings","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":15,"string_id":"e08628967f2f"},
2604
- {"key":"L0010","section":"Project settings","text":"Could not load","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":242,"string_id":"90691bceaf68"},
2604
+ {"key":"L0010","section":"Project settings","text":"Could not load","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":284,"string_id":"90691bceaf68"},
2605
2605
  {"key":"L0011","section":"The fundamentals","text":"The fundamentals","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":40,"string_id":"cb7c7879eeba"},
2606
- {"key":"L0012","section":"The fundamentals","text":"Five things every project settles. Two of them are knobs you can turn here; the other three are decided in committed configuration, and each says which file decides it.","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":41,"string_id":"c6c9d3129ab2"},
2606
+ {"key":"L0012","section":"The fundamentals","text":"Six things every project settles. Three of them are knobs you can turn here; the other three are decided in committed configuration, and each says which file decides it.","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":41,"string_id":"ce994c34804e"},
2607
2607
  {"key":"L0013","section":"The fundamentals","text":"{…} project{…}","placement":"placed","file":"modules/hall-ui/public/fleet.js","line":39,"string_id":"771bef3d31b5"},
2608
2608
  {"key":"L0014","section":"Danger zone","text":"Danger zone","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":57,"string_id":"13b22e48d9fb"},
2609
- {"key":"L0015","section":"Danger zone","text":"This project is hosted on {…}, and that is where it is taken offline or removed. Both links below open this project’s page there.","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":259,"string_id":"e97b2d549f10"},
2610
- {"key":"L0016","section":"Danger zone","text":"Manage this project on {…}","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":260,"string_id":"4e261fd15bd5"},
2609
+ {"key":"L0015","section":"Danger zone","text":"This project is hosted on {…}, and that is where it is taken offline or removed. Both links below open this project’s page there.","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":301,"string_id":"e97b2d549f10"},
2610
+ {"key":"L0016","section":"Danger zone","text":"Manage this project on {…}","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":302,"string_id":"4e261fd15bd5"},
2611
2611
  {"key":"L0017","section":"Danger zone","text":"Take it offline","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":60,"string_id":"da9da6e866bd"},
2612
2612
  {"key":"L0018","section":"Danger zone","text":"— stops the project and its nightly backups but keeps everything: its database, its backups and your repository on GitHub. You can bring it back.","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":60,"string_id":"61ebc5d684fd"},
2613
2613
  {"key":"L0019","section":"Danger zone","text":"Disconnect from Bongos entirely","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":61,"string_id":"d3efd513694b"},
@@ -219,3 +219,18 @@
219
219
  one sentence saying what it does. Plain list, the lede's measure. */
220
220
  .ps-danger__list { margin: 10px 0 0; padding-left: 18px; line-height: 1.6; max-width: var(--measure); }
221
221
  .ps-danger__list li + li { margin-top: 8px; }
222
+
223
+ /* Ship mode (task 1004628): two radio choices, each a full-width row whose whole
224
+ line is the hit target, so the 24px floor holds on a phone without a bigger dot. */
225
+ .ps-ship { margin: 10px 0 0; padding: 0; border: 0; }
226
+ .ps-ship__legend { padding: 0; font-size: 12px; color: var(--ink-soft); }
227
+ .ps-ship__choice {
228
+ display: flex;
229
+ align-items: flex-start;
230
+ gap: 8px;
231
+ min-height: 24px;
232
+ padding: 6px 0;
233
+ line-height: 1.5;
234
+ cursor: pointer;
235
+ }
236
+ .ps-ship__choice input { margin-top: 4px; flex: 0 0 auto; }
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Project settings — {{worldName}}</title>
7
- <meta name="description" content="The fundamentals of this project in one place — the stale timer, the category set, how credits are calculated, whether equity is real, and what share of upmarket revenue is paid on credits.">
7
+ <meta name="description" content="The fundamentals of this project in one place — how tasks ship, the stale timer, the category set, how credits are calculated, whether equity is real, and what share of upmarket revenue is paid on credits.">
8
8
  <link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
9
9
  <link rel="preconnect" href="https://fonts.googleapis.com">
10
10
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -39,11 +39,11 @@
39
39
  <section class="scroll" id="ps-body" aria-labelledby="ps-body-h" hidden>
40
40
  <h2 class="scroll__h" id="ps-body-h">The fundamentals</h2>
41
41
  <p class="scroll__lede">
42
- Five things every project settles. Two of them are knobs you can turn here;
42
+ Six things every project settles. Three of them are knobs you can turn here;
43
43
  the other three are decided in committed configuration, and each says which
44
44
  file decides it.
45
45
  </p>
46
- <!-- One mount, five rows, written by project-settings.js. The rows are NOT
46
+ <!-- One mount, six rows, written by project-settings.js. The rows are NOT
47
47
  authored in this file: each carries a value plus the provenance the
48
48
  server resolved it through, and markup that could drift from that is the
49
49
  thing this page exists to end. -->
@@ -77,6 +77,6 @@
77
77
  <script src="/builders/palette.js" defer></script>
78
78
  <script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
79
79
  <script src="/builders/hall-kit.js"></script>
80
- <script src="/builders/project-settings.js?v=2026-09-04-ps1"></script>
80
+ <script src="/builders/project-settings.js?v=2026-10-08-ship-mode"></script>
81
81
  </body>
82
82
  </html>
@@ -122,6 +122,30 @@
122
122
  ${sib}`;
123
123
  }
124
124
 
125
+ // Ship mode (task 1004628): two choices, each with the sentence the SERVER wrote,
126
+ // so the page never re-types what Auto or Manual means.
127
+ function shipBodyHtml(row) {
128
+ const v = row.value || {};
129
+ const choices = Array.isArray(row.choices) ? row.choices : [];
130
+ const invalid = row.invalid
131
+ ? `<p class="ps-invalid">A configured value could not be used: <code>${escapeHtml(String(row.invalid.raw))}</code>
132
+ — ${escapeHtml(String(row.invalid.reason))}. The mode above is the fallback that is actually in force.</p>`
133
+ : '';
134
+ return `
135
+ ${invalid}
136
+ <fieldset class="ps-ship">
137
+ <legend class="ps-ship__legend">How tasks ship</legend>
138
+ ${choices.map((c) => `
139
+ <label class="ps-ship__choice">
140
+ <input type="radio" name="ps-ship-mode" value="${escapeHtml(String(c.mode))}" ${c.mode === v.mode ? 'checked' : ''}>
141
+ <span><strong>${escapeHtml(String(c.label))}</strong> — ${escapeHtml(String(c.sentence))}</span>
142
+ </label>`).join('')}
143
+ </fieldset>
144
+ <div class="ps-controls">
145
+ <button type="button" class="btn" id="ps-ship-save">Save</button>
146
+ </div>`;
147
+ }
148
+
125
149
  function catsBodyHtml(row) {
126
150
  const v = row.value || {};
127
151
  const cats = Array.isArray(v.categories) ? v.categories : [];
@@ -150,6 +174,7 @@
150
174
  function render() {
151
175
  const mount = $('ps-rows');
152
176
  mount.innerHTML = `<ul class="ov-rows">${rows.map((row) => {
177
+ if (row.key === 'ship_mode' && row.editable) return rowHtml(row, shipBodyHtml(row));
153
178
  if (row.key === 'rot_timer' && row.editable) return rowHtml(row, rotBodyHtml(row));
154
179
  if (row.key === 'work_categories' && row.editable) return rowHtml(row, catsBodyHtml(row));
155
180
  return rowHtml(row, '');
@@ -166,6 +191,23 @@
166
191
  }
167
192
 
168
193
  function wire() {
194
+ const shipSave = $('ps-ship-save');
195
+ if (shipSave) {
196
+ shipSave.addEventListener('click', async () => {
197
+ const picked = document.querySelector('input[name="ps-ship-mode"]:checked');
198
+ if (!picked) { toast('Pick Auto or Manual.'); return; }
199
+ setBusy(true);
200
+ try {
201
+ const out = await sendJson('PATCH', '/project-settings/ship-mode', { mode: picked.value });
202
+ toast(out.mode === 'manual' ? 'Ship mode is now Manual.' : 'Ship mode is now Auto.');
203
+ await load();
204
+ } catch (err) {
205
+ toast(err.message || 'Could not save the ship mode.');
206
+ setBusy(false);
207
+ }
208
+ });
209
+ }
210
+
169
211
  const rotSave = $('ps-rot-save');
170
212
  if (rotSave) {
171
213
  rotSave.addEventListener('click', async () => {
@@ -34,6 +34,29 @@ const goalCategory = require('../goal-category');
34
34
 
35
35
  const log = api.logger('lifecycle');
36
36
 
37
+ // --- ship mode: who starts a ship (task 1004628, ADR 0369) -------------------
38
+ //
39
+ // One row shape, two readers: the settings page (inside GET /project-settings) and
40
+ // any builder's CLI or AI session (GET /project-settings/ship-mode), so the sentence
41
+ // a person reads and the one an agent obeys are the same words.
42
+ function shipModeRow(resolved) {
43
+ return {
44
+ key: 'ship_mode',
45
+ title: 'Ship mode',
46
+ editable: true,
47
+ state: 'live',
48
+ value: { mode: resolved.mode, chosen: resolved.chosen, modes: api.projectSettings.SHIP_MODES },
49
+ sentence: api.projectSettings.describeShipMode(resolved),
50
+ choices: [
51
+ { mode: 'auto', label: 'Auto (recommended)', sentence: 'When the AI finishes a task it ships and lands it. Nobody has to type a command.' },
52
+ { mode: 'manual', label: 'Manual', sentence: 'The AI stops before each ship and asks a person to approve it.' },
53
+ ],
54
+ source: resolved.source,
55
+ invalid: resolved.invalid,
56
+ reads: { file: 'project_settings[\'ship.mode\'] (migration core_231)', adr: 'ADR 0369' },
57
+ };
58
+ }
59
+
37
60
  // --- the three rows that are READ, never written -----------------------------
38
61
  //
39
62
  // Each names the committed artefact it resolves from, because "you cannot edit this"
@@ -214,12 +237,14 @@ module.exports = function buildProjectSettingsRouter() {
214
237
  // Inactive included: the set's SHAPE is what this page edits, and a deactivated
215
238
  // category the owner cannot see is one they cannot bring back.
216
239
  const categories = await goalCategory.listCategories({ includeInactive: true });
240
+ const shipMode = await api.projectSettings.resolveShipMode();
217
241
 
218
242
  // A settings read is a configuration comparison — a cached one is wrong the
219
243
  // moment somebody turns a knob (the rot.js precedent).
220
244
  res.set('Cache-Control', 'no-store');
221
245
  res.json({
222
246
  rows: [
247
+ shipModeRow(shipMode),
223
248
  {
224
249
  key: 'rot_timer',
225
250
  title: 'Stale timer',
@@ -316,7 +341,47 @@ module.exports = function buildProjectSettingsRouter() {
316
341
  }
317
342
  });
318
343
 
344
+ // GET /project-settings/ship-mode — the ship mode alone, for the CLI and the AI.
345
+ //
346
+ // rank: any signed-in builder. Unlike the full settings read this carries no
347
+ // reward internals or file paths an agent could not already read, and every
348
+ // builder's AI session must know whether to ship on its own or stop and ask —
349
+ // gating it Metic+ would leave a newcomer's session guessing.
350
+ router.get('/project-settings/ship-mode', api.requireBuilder, async (req, res) => {
351
+ try {
352
+ const resolved = await api.projectSettings.resolveShipMode();
353
+ res.set('Cache-Control', 'no-store');
354
+ const row = shipModeRow(resolved);
355
+ res.json({ mode: resolved.mode, chosen: resolved.chosen, source: resolved.source, sentence: row.sentence, choices: row.choices });
356
+ } catch (err) {
357
+ log.error(`GET /project-settings/ship-mode: ${err && err.message}`);
358
+ res.fail('project_settings_read_failed', { status: 500, message: 'internal error' });
359
+ }
360
+ });
361
+
362
+ // PATCH /project-settings/ship-mode — body { mode: 'auto' | 'manual' }.
363
+ //
364
+ // rank: project.settings.manage (metic+, government_010) — the same atom as the
365
+ // other writes on this page. Validated with the resolver's own parser, so the
366
+ // page, the CLI and the reader can never disagree about what a mode is.
367
+ router.patch('/project-settings/ship-mode', api.requireBuilder, api.requirePermission('project.settings.manage'), async (req, res) => {
368
+ if (api.validateOrRespond(req, res, { mode: { required: true, type: 'string' } })) return;
369
+ const parsed = api.projectSettings.parseShipMode(req.body.mode);
370
+ if (!parsed.mode) {
371
+ return res.fail('bad_ship_mode', { status: 400, message: `mode: ${parsed.reason}` });
372
+ }
373
+ try {
374
+ await api.projectSettings.set(api.projectSettings.KEYS.SHIP_MODE, parsed.mode, req.builder.id);
375
+ const resolved = await api.projectSettings.resolveShipMode();
376
+ res.json({ ok: true, mode: resolved.mode, chosen: resolved.chosen, source: resolved.source, sentence: api.projectSettings.describeShipMode(resolved) });
377
+ } catch (err) {
378
+ log.error(`PATCH /project-settings/ship-mode: ${err && err.message}`);
379
+ res.fail('project_settings_write_failed', { status: 500, message: 'internal error' });
380
+ }
381
+ });
382
+
319
383
  return router;
320
384
  };
321
385
 
322
386
  module.exports.settingsDeskRows = settingsDeskRows;
387
+ module.exports.shipModeRow = shipModeRow;
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.73",
3
+ "version": "1.21.75",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.73",
9
+ "version": "1.21.75",
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.73",
3
+ "version": "1.21.75",
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",
@@ -9248,5 +9248,17 @@
9248
9248
  "id": "1004625",
9249
9249
  "text": "The governor city now has its own tab, and in Governing mode it leads the sidebar with every other governing page tucked into one dropdown under it."
9250
9250
  }
9251
+ ],
9252
+ "1.21.74": [
9253
+ {
9254
+ "id": "1004628",
9255
+ "text": "Projects now have a Ship mode setting. Auto, the default, means the AI ships finished work on its own. Manual means it asks first. Owners can switch it on the project settings page."
9256
+ }
9257
+ ],
9258
+ "1.21.75": [
9259
+ {
9260
+ "id": "1003807",
9261
+ "text": "The module store now only shows modules that are safe and good enough: each must pass the security check, and once it has real-world data it needs a score of at least 50 out of 100. Brand-new modules show with a 'New' label un"
9262
+ }
9251
9263
  ]
9252
9264
  }
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/shipping-mode.js — read or set the project's ship mode (task 1004628, ADR 0369).
3
+ //
4
+ // WHY. Ship mode says WHO starts a ship: in `auto` the AI finishes a task and ships
5
+ // it with no commands from the person; in `manual` it stops and asks first. An AI
6
+ // session has to know which before it ships, and a person has to be able to change
7
+ // it without opening the settings page — so the mode is one command away.
8
+ //
9
+ // Usage:
10
+ // node scripts/gds/shipping-mode.js print the mode (any builder)
11
+ // node scripts/gds/shipping-mode.js auto|manual set it (needs project.settings.manage)
12
+ // node scripts/gds/shipping-mode.js --json print the mode as JSON
13
+ //
14
+ // Also `bongos ship-mode [auto|manual]`.
15
+
16
+ 'use strict';
17
+
18
+ const { apiCall, requireSession, errCode, cliExit } = require('./cli-lib');
19
+
20
+ const USAGE = 'usage: node scripts/gds/shipping-mode.js [auto|manual] [--json]';
21
+
22
+ // What a person sees. One sentence from the server, plus how to change it.
23
+ function describe(data) {
24
+ const lines = [`Ship mode: ${data.mode}${data.chosen ? '' : ' (default — not chosen yet)'}`];
25
+ if (data.sentence) lines.push(data.sentence);
26
+ lines.push(data.mode === 'manual'
27
+ ? 'Change it: node scripts/gds/shipping-mode.js auto'
28
+ : 'Change it: node scripts/gds/shipping-mode.js manual');
29
+ return lines.join('\n');
30
+ }
31
+
32
+ async function main(argv) {
33
+ const args = argv.filter((a) => a !== '--json');
34
+ const json = argv.includes('--json');
35
+ if (args.includes('--help') || args.includes('-h')) { console.log(USAGE); return 0; }
36
+ if (args.length > 1) { console.error(USAGE); return 2; }
37
+
38
+ await requireSession();
39
+
40
+ if (args.length === 0) {
41
+ const r = await apiCall('GET', '/api/bongos/project-settings/ship-mode');
42
+ if (!r.ok) {
43
+ console.error(`Could not read the ship mode (${r.status}${errCode(r.data) ? `: ${errCode(r.data)}` : ''}).`);
44
+ return 1;
45
+ }
46
+ console.log(json ? JSON.stringify(r.data) : describe(r.data));
47
+ return 0;
48
+ }
49
+
50
+ const mode = String(args[0]).trim().toLowerCase();
51
+ if (mode !== 'auto' && mode !== 'manual') { console.error(USAGE); return 2; }
52
+ const r = await apiCall('PATCH', '/api/bongos/project-settings/ship-mode', { mode });
53
+ if (!r.ok) {
54
+ if (r.status === 403) {
55
+ console.error('Only the project owner (or someone with project settings access) can change the ship mode.');
56
+ } else {
57
+ console.error(`Could not set the ship mode (${r.status}${errCode(r.data) ? `: ${errCode(r.data)}` : ''}).`);
58
+ }
59
+ return 1;
60
+ }
61
+ console.log(json ? JSON.stringify(r.data) : `Saved. ${describe(r.data)}`);
62
+ return 0;
63
+ }
64
+
65
+ if (require.main === module) {
66
+ main(process.argv.slice(2))
67
+ .then((code) => cliExit(code))
68
+ .catch((err) => { console.error('fatal:', err && err.message ? err.message : err); cliExit(1); });
69
+ }
70
+
71
+ module.exports = { describe, main };
@@ -0,0 +1,85 @@
1
+ // src/bongos/module-listing.js — which store modules the catalog lists, under the
2
+ // quality floor (task 1003807, criterion wa5-curated-listing, ADR 0343 D2/D5).
3
+ //
4
+ // One module appears once, as its NEWEST published version (semver order), judged by
5
+ // that version's CURRENT score: the newest module_assessment_scores row for it
6
+ // (computed_at DESC, id DESC), whether computed or a Metic+ override (core_265).
7
+ // placeVersion is the whole rule and is pure, so tests drive it directly:
8
+ //
9
+ // excluded the Security gate has not passed (false, or not run yet), or the
10
+ // version has no score row at all. The gate is a hard exclusion (D2).
11
+ // new passed the gate but has no real-world data yet (is_new, D5). Listed
12
+ // with a "New" label and never ranked: the floor does not apply until
13
+ // it has a real score (owner decision, 2026-10-01).
14
+ // listed passed the gate and its overall score is at least the floor.
15
+ // excluded otherwise: scored below the floor, or no overall to compare.
16
+ //
17
+ // A delisted module (ADR 0338 D2) is never in the catalog, whatever its score.
18
+
19
+ const { parseVersion, cmp } = require('../module-loader/semver');
20
+
21
+ // The quality floor, 0-100. It lives here and nowhere else: it is part of Bongos
22
+ // itself, not a live setting, so no author, builder or instance can move it.
23
+ // Changing it is an owner-approved, reviewed task (owner decision, 2026-10-01).
24
+ // Authors read the rule in docs/modules-contract.md ("Getting listed").
25
+ const QUALITY_FLOOR = 50;
26
+
27
+ // Where one version stands. `score` is its current score row, or null.
28
+ // Returns { placement: 'listed' | 'new' | 'excluded', reason }.
29
+ function placeVersion(score, floor = QUALITY_FLOOR) {
30
+ if (!score) return { placement: 'excluded', reason: 'not_assessed' };
31
+ if (score.security_passed !== true) return { placement: 'excluded', reason: 'security_not_passed' };
32
+ if (score.is_new) return { placement: 'new', reason: 'new' };
33
+ if (score.overall == null) return { placement: 'excluded', reason: 'no_score' };
34
+ if (score.overall < floor) return { placement: 'excluded', reason: 'below_floor' };
35
+ return { placement: 'listed', reason: 'at_or_above_floor' };
36
+ }
37
+
38
+ // The newest version row per module key, in semver order (a version that does not
39
+ // parse is skipped, as latestVersions does in module-store.js).
40
+ function newestPerModule(rows) {
41
+ const out = new Map();
42
+ for (const r of rows) {
43
+ const next = parseVersion(r.version);
44
+ if (!next) continue;
45
+ const have = out.get(r.module_key);
46
+ if (have && cmp(parseVersion(have.version), next) >= 0) continue;
47
+ out.set(r.module_key, r);
48
+ }
49
+ return [...out.values()];
50
+ }
51
+
52
+ // The catalog: { floor, listed: [...], new: [...] }, each entry
53
+ // { key, title, version, published_at, overall, is_new }. Excluded versions are left
54
+ // 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 } = {}) {
57
+ const pool = db || require('./pool').pool;
58
+ const { rows: versions } = await pool.query(
59
+ `SELECT m.module_key, m.title, v.id AS version_id, v.version, v.published_at
60
+ FROM store_modules m JOIN store_module_versions v ON v.module_key = m.module_key
61
+ WHERE m.status = 'listed'`);
62
+ const newest = newestPerModule(versions);
63
+ const scores = new Map();
64
+ if (newest.length) {
65
+ const { rows } = await pool.query(
66
+ `SELECT DISTINCT ON (version_id) version_id, overall, security_passed, is_new
67
+ FROM module_assessment_scores
68
+ WHERE version_id = ANY($1)
69
+ ORDER BY version_id, computed_at DESC, id DESC`, [newest.map((r) => r.version_id)]);
70
+ for (const s of rows) scores.set(String(s.version_id), s);
71
+ }
72
+ const out = { floor, listed: [], new: [] };
73
+ for (const r of newest.sort((a, b) => a.module_key.localeCompare(b.module_key))) {
74
+ const score = scores.get(String(r.version_id)) || null;
75
+ const { placement } = placeVersion(score, floor);
76
+ if (placement === 'excluded') continue;
77
+ out[placement].push({
78
+ key: r.module_key, title: r.title, version: r.version, published_at: r.published_at,
79
+ overall: placement === 'new' ? null : score.overall, is_new: placement === 'new',
80
+ });
81
+ }
82
+ return out;
83
+ }
84
+
85
+ module.exports = { QUALITY_FLOOR, placeVersion, listCatalog };
@@ -49,8 +49,20 @@ const { logger } = require('./logger');
49
49
  const SETTING_KEYS = {
50
50
  ROT_DAYS: 'attention.rot_days',
51
51
  FOCUS_VERSION: 'roadmap.focus_version',
52
+ SHIP_MODE: 'ship.mode',
52
53
  };
53
54
 
55
+ // SHIP MODE (task 1004628, ADR 0369) — WHO starts a ship, never HOW it lands.
56
+ // auto — the AI finishes a task, then ships and lands it with no commands from
57
+ // the person. The default, because a new owner who is handed terminal
58
+ // commands at every ship reads the project as broken (owner feedback,
59
+ // 2026-10-08).
60
+ // manual — the AI stops before shipping and the person approves each ship.
61
+ // It is NOT the deploy mode (config/deploy.json, ci|laptop): that one decides the
62
+ // mechanics of landing once a ship starts, is committed config, and is unchanged.
63
+ const SHIP_MODES = Object.freeze(['auto', 'manual']);
64
+ const DEFAULT_SHIP_MODE = 'auto';
65
+
54
66
  // The last-resort rot timer, in days. 30 because that is the shortest window in
55
67
  // which "nobody has touched this" is evidence of rot rather than evidence of a
56
68
  // normal week — a fortnight would fire on every ordinary holiday, and a quarter is
@@ -202,6 +214,56 @@ async function setFocusVersionId(versionId, builderId = null) {
202
214
  return String(versionId);
203
215
  }
204
216
 
217
+ // parseShipMode(raw) — one candidate as a ship mode, or why it is unusable. Pure,
218
+ // never throws, case-insensitive and trimmed so a hand-typed "Manual " still reads.
219
+ function parseShipMode(raw) {
220
+ if (raw == null || String(raw).trim() === '') return { reason: 'empty' };
221
+ const v = String(raw).trim().toLowerCase();
222
+ if (!SHIP_MODES.includes(v)) return { reason: `must be one of ${SHIP_MODES.join(' | ')}` };
223
+ return { mode: v };
224
+ }
225
+
226
+ // resolveShipMode() — the project's ship mode and where it came from.
227
+ // Returns { mode, source, chosen, invalid }:
228
+ // mode — always 'auto' or 'manual'
229
+ // source — 'project_settings' | 'default'
230
+ // chosen — true only when someone actually SET a usable value. "Never chosen" is
231
+ // a real answer the first-ship prompt (task 1004630) asks about, so it
232
+ // must not be confused with "chose the default".
233
+ // invalid — null, or { where, raw, reason } for a stored value that is unusable.
234
+ // A database that cannot be read answers the default rather than failing a ship —
235
+ // the same reporting posture as resolveRotDays.
236
+ async function resolveShipMode() {
237
+ let row = null;
238
+ try {
239
+ row = await getSetting(SETTING_KEYS.SHIP_MODE);
240
+ } catch (err) {
241
+ logger.error(`project_settings read failed; falling back to the default ship mode: ${err && err.message}`);
242
+ return { mode: DEFAULT_SHIP_MODE, source: 'default', chosen: false, invalid: null };
243
+ }
244
+ if (row && row.value != null && row.value !== '') {
245
+ const got = parseShipMode(row.value);
246
+ if (got.mode) return { mode: got.mode, source: 'project_settings', chosen: true, invalid: null };
247
+ return {
248
+ mode: DEFAULT_SHIP_MODE,
249
+ source: 'default',
250
+ chosen: false,
251
+ invalid: { where: `project_settings['${SETTING_KEYS.SHIP_MODE}']`, raw: String(row.value), reason: got.reason },
252
+ };
253
+ }
254
+ return { mode: DEFAULT_SHIP_MODE, source: 'default', chosen: false, invalid: null };
255
+ }
256
+
257
+ // describeShipMode(resolved) — one plain sentence for a person or an AI session.
258
+ function describeShipMode(resolved) {
259
+ const r = resolved || {};
260
+ const what = r.mode === 'manual'
261
+ ? 'Manual: the AI stops before each ship and waits for a person to approve it.'
262
+ : 'Auto: when the AI finishes a task it ships and lands it with no commands from a person.';
263
+ const how = r.chosen ? '' : ' (the default — nobody has chosen yet)';
264
+ return `${what}${how}`;
265
+ }
266
+
205
267
  // parseDays is exported for the tests only — the range check and the default are
206
268
  // asserted against the SAME constants the resolver reads, because a test that
207
269
  // re-typed 30 or 365 would be the second copy of the number this file exists to
@@ -218,4 +280,9 @@ module.exports = {
218
280
  describeRotTimer,
219
281
  getFocusVersionId,
220
282
  setFocusVersionId,
283
+ SHIP_MODES,
284
+ DEFAULT_SHIP_MODE,
285
+ parseShipMode,
286
+ resolveShipMode,
287
+ describeShipMode,
221
288
  };
@@ -97,6 +97,7 @@ const log = require('../logger').logger.child({ src: 'modules' });
97
97
  // property access on a CommonJS namespace, so reaching these through one made them read
98
98
  // as unused exports and tripped the dead-code ratchet that held this branch out of main.
99
99
  const { listOverrides, setOverride } = require('../module-overrides');
100
+ const { listCatalog } = require('../module-listing');
100
101
 
101
102
  // A packaged submission embeds every file's content (base64) — cap the total
102
103
  // so a runaway/misbuilt module can't bloat the review-queue table. Generous
@@ -692,6 +693,20 @@ module.exports = function buildModulesRouter() {
692
693
  res.json({ latest });
693
694
  }));
694
695
 
696
+ // The store catalog under the quality floor (task 1003807, ADR 0343 D2/D5): each
697
+ // listed module's newest version, if it passed the Security gate and scores at or
698
+ // above the floor, or under "new" if it has no real-world data yet. Below the bar
699
+ // is not listed at all. The floor is fixed in module-listing.js and returned here.
700
+ //
701
+ // rank: any-builder — a read of public store metadata, like /store/modules/latest:
702
+ // no entitlement taken, nothing changed.
703
+ router.get('/store/modules', auth.requireBuilder,
704
+ asyncHandler('GET /store/modules', async (_req, res) => {
705
+ const listing = await listCatalog();
706
+ res.set('Cache-Control', 'no-store');
707
+ res.json(listing);
708
+ }));
709
+
695
710
  router.post('/store/modules/:key/acquired', auth.requireBuilder,
696
711
  asyncHandler('POST /store/modules/:key/acquired', async (req, res) => {
697
712
  const { key } = req.params;
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.73'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.75'; // 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');
@@ -462,6 +462,12 @@ module.exports = {
462
462
  // page's own anti-drift rule forbids, caught one layer up on task 1003280's grade.
463
463
  get MIN_ROT_DAYS() { return require('./bongos/project-settings').MIN_ROT_DAYS; },
464
464
  get MAX_ROT_DAYS() { return require('./bongos/project-settings').MAX_ROT_DAYS; },
465
+ // Ship mode (task 1004628, ADR 0369): who starts a ship — the AI (auto) or a
466
+ // person (manual). The settings page writes it; the CLI and the AI read it.
467
+ get SHIP_MODES() { return require('./bongos/project-settings').SHIP_MODES; },
468
+ get parseShipMode() { return require('./bongos/project-settings').parseShipMode; },
469
+ get resolveShipMode() { return require('./bongos/project-settings').resolveShipMode; },
470
+ get describeShipMode() { return require('./bongos/project-settings').describeShipMode; },
465
471
  },
466
472
  readConfigFileSync: instanceConfig.readConfigFileSync,
467
473
  configHome: instanceConfig.configHome,