@bongos/core 1.21.72 → 1.21.74

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 (68) hide show
  1. package/.bongos-core.json +87 -72
  2. package/bin/bongos.js +1 -0
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +4 -0
  5. package/clients/bongos-client/index.cjs +4 -0
  6. package/clients/bongos-client/index.d.ts +7 -0
  7. package/clients/bongos-client/index.mjs +4 -0
  8. package/docs/adr/0369-ship-mode-decides-who-starts-a-ship-not-how-it-lands.md +44 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +139 -3
  11. package/docs/api-reference.md +4 -2
  12. package/docs/copy-inventory.md +40 -36
  13. package/docs/copy-registry.json +97 -51
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-readings.json +329 -330
  16. package/modules/hall-ui/public/atlas.html +3 -3
  17. package/modules/hall-ui/public/blockers.html +3 -3
  18. package/modules/hall-ui/public/board-room.html +3 -3
  19. package/modules/hall-ui/public/city.html +3 -3
  20. package/modules/hall-ui/public/collab.html +3 -3
  21. package/modules/hall-ui/public/commands.html +3 -3
  22. package/modules/hall-ui/public/copy-desk.html +3 -3
  23. package/modules/hall-ui/public/craft-mode.js +69 -1
  24. package/modules/hall-ui/public/deploy.html +3 -3
  25. package/modules/hall-ui/public/diagrams.html +3 -3
  26. package/modules/hall-ui/public/drachmae.html +3 -3
  27. package/modules/hall-ui/public/fleet.html +3 -3
  28. package/modules/hall-ui/public/gate.html +3 -3
  29. package/modules/hall-ui/public/goal-map.html +3 -3
  30. package/modules/hall-ui/public/goals.html +3 -3
  31. package/modules/hall-ui/public/government.html +3 -3
  32. package/modules/hall-ui/public/idea.html +3 -3
  33. package/modules/hall-ui/public/ideas.html +3 -3
  34. package/modules/hall-ui/public/index.html +3 -3
  35. package/modules/hall-ui/public/modules.html +3 -3
  36. package/modules/hall-ui/public/primer.html +3 -3
  37. package/modules/hall-ui/public/profile.html +3 -3
  38. package/modules/hall-ui/public/project-settings.css +15 -0
  39. package/modules/hall-ui/public/project-settings.html +7 -7
  40. package/modules/hall-ui/public/project-settings.js +42 -0
  41. package/modules/hall-ui/public/ranks.html +3 -3
  42. package/modules/hall-ui/public/roadmap.html +3 -3
  43. package/modules/hall-ui/public/roster.html +3 -3
  44. package/modules/hall-ui/public/sessions.html +3 -3
  45. package/modules/hall-ui/public/settings.html +3 -3
  46. package/modules/hall-ui/public/shell.js +9 -0
  47. package/modules/hall-ui/public/street.html +3 -3
  48. package/modules/hall-ui/public/studio.html +3 -3
  49. package/modules/hall-ui/public/task.html +3 -3
  50. package/modules/hall-ui/public/thinking.html +3 -3
  51. package/modules/hall-ui/public/tweak-editor.html +3 -3
  52. package/modules/hall-ui/public/watch.html +3 -3
  53. package/modules/hall-ui/public/work.html +3 -3
  54. package/modules/lifecycle/routes/project-settings.js +65 -0
  55. package/package-lock.json +2 -2
  56. package/package.json +1 -1
  57. package/release-notes.json +12 -0
  58. package/scripts/gds/shipping-mode.js +71 -0
  59. package/src/bongos/project-settings.js +67 -0
  60. package/src/module-api.js +7 -1
  61. package/tests/craft_mode_nav.mjs +117 -1
  62. package/tests/hall_city.mjs +5 -3
  63. package/tests/hall_nav.mjs +3 -1
  64. package/tests/hall_street.mjs +2 -1
  65. package/tests/hall_tweak_editor.mjs +1 -1
  66. package/tests/nav_permission_atoms.mjs +1 -1
  67. package/tests/project_settings_page.mjs +8 -5
  68. package/tests/ship_mode.mjs +171 -0
@@ -99,6 +99,8 @@
99
99
  studio: '<path d="M9 15.5V8.5"/><path d="M9 8.5C9 5.5 6.5 3.5 3.5 3.5c0 3 2.5 5 5.5 5Z"/><path d="M9 10C9 7.5 11.2 5.5 14 5.5c0 2.7-2.2 4.5-5 4.5Z"/>',
100
100
  settings: '<circle cx="9" cy="9" r="2.5"/><path d="M9 2.5v2m0 9v2M2.5 9h2m9 0h2M4.4 4.4l1.4 1.4m6.4 6.4 1.4 1.4m0-9.2-1.4 1.4M5.8 12.2l-1.4 1.4"/>',
101
101
  admin: '<path d="M9 2.5 15 5v4c0 3.5-2.4 6-6 7-3.6-1-6-3.5-6-7V5l6-2.5Z"/>',
102
+ // The governor city: a pediment over columns, the Governing mode's own glyph.
103
+ city: '<path d="M2.5 7 9 3l6.5 4M4 7.5v6.5M7 7.5v6.5M11 7.5v6.5M14 7.5v6.5M2.5 15.5h13"/>',
102
104
  signout: '<path d="M7 3.5H4a1 1 0 0 0-1 1v9a1 1 0 0 0 1 1h3M12 12l3-3-3-3M15 9H7"/>',
103
105
  };
104
106
  function icon(name) {
@@ -210,6 +212,13 @@
210
212
  // so the badge counts ballots this viewer can actually cast, never "items
211
213
  // open somewhere".
212
214
  { id: 'board-room', label: 'Board Room', icon: 'admin', href: '/board-room', gate: 'perm:board.vote.cast', count: 'board_votes' },
215
+ // The governor city (task 1004524): the owner put it on the sidebar on
216
+ // 2026-10-08, reversing "leave it off". It is the Governing mode's lead tab
217
+ // (craft-mode.js folds that mode's other tabs into a dropdown under it).
218
+ // Gated on the atom its page is (requireBoardVotePage), and it sits in this
219
+ // head-less group for the reason the Board Room does: the atom floors below
220
+ // any rank head. Cosmetic only (ADR 0016).
221
+ { id: 'city', label: 'City', icon: 'city', href: '/city', gate: 'perm:board.vote.cast' },
213
222
  // task 1004326 (BV2.TW15 of goal 1000095): the Ideas board (/ideas, task
214
223
  // 1002928) and the Copy desk (/copy-desk, task 1003113) are ARCHIVED — off
215
224
  // the nav on the owner's call of 2026-09-27, NOT deleted. Both pages and
@@ -203,9 +203,9 @@
203
203
  </div>
204
204
  </div>
205
205
 
206
- <script src="/builders/shell-trail.js?v=2026-10-04-lightbulb"></script>
207
- <script src="/builders/craft-mode.js?v=2026-10-04-lightbulb"></script>
208
- <script src="/builders/shell.js?v=2026-10-04-lightbulb"></script>
206
+ <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
207
+ <script src="/builders/craft-mode.js?v=2026-10-08-city-tab"></script>
208
+ <script src="/builders/shell.js?v=2026-10-08-city-tab"></script>
209
209
  <script src="/builders/palette.js" defer></script>
210
210
  <script src="/builders/city-draw.js?v=2026-10-04-pins1"></script>
211
211
  <script src="/builders/street-draw.js?v=2026-10-04-pins1"></script>
@@ -176,9 +176,9 @@
176
176
 
177
177
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
178
178
 
179
- <script src="/builders/shell-trail.js?v=2026-10-04-lightbulb"></script>
180
- <script src="/builders/craft-mode.js?v=2026-10-04-lightbulb"></script>
181
- <script src="/builders/shell.js?v=2026-10-04-lightbulb"></script>
179
+ <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
180
+ <script src="/builders/craft-mode.js?v=2026-10-08-city-tab"></script>
181
+ <script src="/builders/shell.js?v=2026-10-08-city-tab"></script>
182
182
  <script src="/builders/palette.js" defer></script>
183
183
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
184
184
  <!-- The kit, for its ONE renderer: the full-size ship-time visual figure
@@ -54,9 +54,9 @@
54
54
 
55
55
  <!-- The app shell (sidebar + top bar + footer). Dependency-free; runs at parse
56
56
  time so the chrome exists before the deferred page scripts execute. -->
57
- <script src="/builders/shell-trail.js?v=2026-10-04-lightbulb"></script>
58
- <script src="/builders/craft-mode.js?v=2026-10-04-lightbulb"></script>
59
- <script src="/builders/shell.js?v=2026-10-04-lightbulb"></script>
57
+ <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
58
+ <script src="/builders/craft-mode.js?v=2026-10-08-city-tab"></script>
59
+ <script src="/builders/shell.js?v=2026-10-08-city-tab"></script>
60
60
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
61
61
  <script src="/builders/palette.js" defer></script>
62
62
  <!-- shared frontend toolkit (window.OTB) — loaded FIRST so task.js can pull from it. -->
@@ -138,9 +138,9 @@
138
138
  </div>
139
139
  </div>
140
140
 
141
- <script src="/builders/shell-trail.js?v=2026-10-04-lightbulb"></script>
142
- <script src="/builders/craft-mode.js?v=2026-10-04-lightbulb"></script>
143
- <script src="/builders/shell.js?v=2026-10-04-lightbulb"></script>
141
+ <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
142
+ <script src="/builders/craft-mode.js?v=2026-10-08-city-tab"></script>
143
+ <script src="/builders/shell.js?v=2026-10-08-city-tab"></script>
144
144
  <script src="/builders/palette.js" defer></script>
145
145
 
146
146
  <script src="/builders/idea-objects.js?v=2026-09-27-split"></script>
@@ -142,9 +142,9 @@
142
142
 
143
143
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
144
144
 
145
- <script src="/builders/shell-trail.js?v=2026-10-04-lightbulb"></script>
146
- <script src="/builders/craft-mode.js?v=2026-10-04-lightbulb"></script>
147
- <script src="/builders/shell.js?v=2026-10-04-lightbulb"></script>
145
+ <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
146
+ <script src="/builders/craft-mode.js?v=2026-10-08-city-tab"></script>
147
+ <script src="/builders/shell.js?v=2026-10-08-city-tab"></script>
148
148
  <script src="/builders/palette.js" defer></script>
149
149
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
150
150
  <script src="/builders/brand-holes.js?v=2026-09-29-aqfix"></script>
@@ -165,9 +165,9 @@
165
165
 
166
166
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
167
167
  chrome exists before the page script runs. -->
168
- <script src="/builders/shell-trail.js?v=2026-10-04-lightbulb"></script>
169
- <script src="/builders/craft-mode.js?v=2026-10-04-lightbulb"></script>
170
- <script src="/builders/shell.js?v=2026-10-04-lightbulb"></script>
168
+ <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
169
+ <script src="/builders/craft-mode.js?v=2026-10-08-city-tab"></script>
170
+ <script src="/builders/shell.js?v=2026-10-08-city-tab"></script>
171
171
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
172
172
  <script src="/builders/palette.js" defer></script>
173
173
  <!-- shared frontend toolkit (window.OTB) — loaded before watch.js, which pulls from it. -->
@@ -171,9 +171,9 @@
171
171
 
172
172
  <!-- The app shell (sidebar + top bar + footer). Dependency-free; runs at parse
173
173
  time so the chrome exists before the deferred page scripts execute. -->
174
- <script src="/builders/shell-trail.js?v=2026-10-04-lightbulb"></script>
175
- <script src="/builders/craft-mode.js?v=2026-10-04-lightbulb"></script>
176
- <script src="/builders/shell.js?v=2026-10-04-lightbulb"></script>
174
+ <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
175
+ <script src="/builders/craft-mode.js?v=2026-10-08-city-tab"></script>
176
+ <script src="/builders/shell.js?v=2026-10-08-city-tab"></script>
177
177
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
178
178
  <script src="/builders/palette.js" defer></script>
179
179
  <!-- shared frontend toolkit (window.OTB) — loaded FIRST so work.js can pull from it. -->
@@ -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.72",
3
+ "version": "1.21.74",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.72",
9
+ "version": "1.21.74",
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.72",
3
+ "version": "1.21.74",
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",
@@ -9242,5 +9242,17 @@
9242
9242
  "id": "1004578",
9243
9243
  "text": "While a new project is being set up, founders now get a little cloud-hopping game: each cloud is a public project on the platform, in its own planet colour. When the project goes live, clouds and stars fill the screen and fade"
9244
9244
  }
9245
+ ],
9246
+ "1.21.73": [
9247
+ {
9248
+ "id": "1004625",
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
+ }
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
+ }
9245
9257
  ]
9246
9258
  }
@@ -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 };
@@ -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
  };
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.72'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.74'; // 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,
@@ -187,7 +187,7 @@ test('a mode never shows a tab the gate hid, and a later gate pass keeps the mod
187
187
  p.signIn('thetes');
188
188
  assert.ok(!p.showing().includes('watch'), 'a thetes in Governing does not get the metic-floored Watch tab');
189
189
  assert.ok(!p.headShowing('government'), 'nor its group head');
190
- assert.deepEqual(p.showing(), ['blockers', 'board-room', 'profile', 'collab', 'roster'], 'what a thetes board member in Governing sees');
190
+ assert.deepEqual(p.showing(), ['blockers', 'board-room', 'city', 'profile', 'collab', 'roster'], 'what a thetes board member in Governing sees');
191
191
  p.signIn('archon');
192
192
  assert.ok(p.showing().includes('watch'), 'promoted, the gate lets it through and the mode keeps it');
193
193
  assert.ok(p.headShowing('government'), 'and its group head comes back with it, which only a mode pass after the gate can know');
@@ -202,3 +202,119 @@ test('shell.js applies the mode AFTER its gate loop, and style.css hides what th
202
202
  assert.match(read('style.css'), /#app-sidebar \[data-craft-off\] \{ display: none !important; \}/);
203
203
  assert.doesNotMatch(CRAFT, /\.hidden\s*=/, 'the mode never writes `hidden`, which the gate owns');
204
204
  });
205
+
206
+ // ---------- the Governing fold (owner, 2026-10-08) ----------
207
+ // Governing leads with the City and folds its other own tabs into one dropdown
208
+ // under it. The fold MOVES nodes, which the parsed sidebar above cannot model, so
209
+ // these cases run the real craft-mode.js against a small working tree: nodes with
210
+ // parents, insertBefore and comments, laid out as shell.js's NAV groups are.
211
+ function treeNode(tag, nodeType = 1) {
212
+ const attrs = {};
213
+ const n = {
214
+ tagName: tag, nodeType, id: '', className: '', dataset: {}, hidden: false, childNodes: [], parentNode: null, _html: '', type: '',
215
+ setAttribute(k, v) { attrs[k] = String(v); }, getAttribute(k) { return k in attrs ? attrs[k] : null; },
216
+ hasAttribute(k) { return k in attrs; }, removeAttribute(k) { delete attrs[k]; },
217
+ get classList() {
218
+ const self = this;
219
+ const list = () => self.className.split(/\s+/).filter(Boolean);
220
+ return {
221
+ contains: (c) => list().includes(c),
222
+ add: (c) => { if (!list().includes(c)) self.className = list().concat(c).join(' '); },
223
+ remove: (c) => { self.className = list().filter((x) => x !== c).join(' '); },
224
+ toggle(c, on) { if (on === undefined) on = !this.contains(c); if (on) this.add(c); else this.remove(c); return on; },
225
+ };
226
+ },
227
+ get nextSibling() { const p = this.parentNode; return p ? p.childNodes[p.childNodes.indexOf(this) + 1] || null : null; },
228
+ set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
229
+ appendChild(c) { return this.insertBefore(c, null); },
230
+ insertBefore(c, ref) {
231
+ if (c.parentNode) c.parentNode.removeChild(c);
232
+ const i = ref ? this.childNodes.indexOf(ref) : -1;
233
+ if (i === -1) this.childNodes.push(c); else this.childNodes.splice(i, 0, c);
234
+ c.parentNode = this;
235
+ return c;
236
+ },
237
+ removeChild(c) { this.childNodes.splice(this.childNodes.indexOf(c), 1); c.parentNode = null; return c; },
238
+ walk(fn) { for (const c of this.childNodes) { if (c.nodeType === 1) { fn(c); c.walk(fn); } } },
239
+ querySelectorAll(sel) { const out = []; this.walk((c) => { if (sel.startsWith('.') && c.classList.contains(sel.slice(1))) out.push(c); }); return out; },
240
+ querySelector(sel) { return this.querySelectorAll(sel)[0] || null; },
241
+ addEventListener() {}, focus() {},
242
+ };
243
+ return n;
244
+ }
245
+ function bootTree({ mode = null, activeId = null } = {}) {
246
+ const el = (tag, cls, id) => { const n = treeNode(tag); n.className = cls || ''; n.id = id || ''; return n; };
247
+ const sidebar = el('NAV', '', 'app-sidebar');
248
+ // shell.js's NAV, group by group: the unlabeled one holds its items directly,
249
+ // a labeled one a head and a .nav-items wrapper (renderSidebar's shape).
250
+ for (const block of SHELL.slice(SHELL.indexOf('var NAV = ['), SHELL.indexOf('// ---------- nav group collapse')).split('{ group: ').slice(1)) {
251
+ const label = /^'([^']*)'/.exec(block)[1];
252
+ const group = el('DIV', 'nav-group' + (label ? ' nav-group--collapsible' : ''));
253
+ let into = group;
254
+ if (label) {
255
+ const head = el('BUTTON', 'nav-group__head');
256
+ head.dataset.group = label.toLowerCase();
257
+ group.appendChild(head);
258
+ into = group.appendChild(el('DIV', 'nav-items', 'nav-items-' + label.toLowerCase()));
259
+ }
260
+ for (const m of block.matchAll(/\{ id: '([a-z-]+)', label:/g)) {
261
+ into.appendChild(el('A', 'nav-item' + (m[1] === activeId ? ' nav-item--active' : ''), 'nav-' + m[1]));
262
+ }
263
+ sidebar.appendChild(group);
264
+ }
265
+ const root = treeNode('HTML');
266
+ const byId = (id) => { if (sidebar.id === id) return sidebar; let hit = null; sidebar.walk((c) => { if (!hit && c.id === id) hit = c; }); return hit; };
267
+ const mk = (obj) => ({ getItem: (k) => (k in obj ? obj[k] : null), setItem: (k, v) => { obj[k] = String(v); }, removeItem: (k) => { delete obj[k]; } });
268
+ const documentObj = {
269
+ body: treeNode('BODY'), documentElement: root, activeElement: null,
270
+ getElementById: byId, querySelector: () => null, querySelectorAll: () => [],
271
+ createElement: (t) => treeNode(t.toUpperCase()), createComment: (t) => Object.assign(treeNode('#comment', 8), { data: t }),
272
+ addEventListener() {}, removeEventListener() {},
273
+ };
274
+ const windowObj = {
275
+ location: { search: '' }, matchMedia: () => ({ matches: false }),
276
+ localStorage: mk({}), sessionStorage: mk(mode ? { 'hall-craft': mode } : {}),
277
+ };
278
+ const sandbox = { window: windowObj, document: documentObj, console, CustomEvent: class { constructor(t, i) { this.type = t; Object.assign(this, i); } } };
279
+ sandbox.globalThis = sandbox;
280
+ vm.createContext(sandbox);
281
+ vm.runInContext(CRAFT, sandbox, { filename: 'craft-mode.js' });
282
+ const craft = windowObj.OTBCraftMode;
283
+ craft.mount();
284
+ const fold = () => byId('craft-fold');
285
+ const ids = (n) => n.querySelectorAll('.nav-item').map((a) => a.id.slice(4));
286
+ const order = () => ids(sidebar);
287
+ return { craft, sidebar, fold, ids, order, byId };
288
+ }
289
+
290
+ test('GOVERNING LEADS WITH THE CITY: its other tabs fold into one dropdown right under it', () => {
291
+ const before = bootTree().order();
292
+ const p = bootTree({ mode: 'governing' });
293
+ const fold = p.fold();
294
+ assert.ok(fold, 'the dropdown exists in Governing');
295
+ assert.equal(p.byId('nav-city').nextSibling, fold, 'directly under the City tab');
296
+ const own = p.craft.tabsFor('governing').filter((id) => !p.craft.ALWAYS_TABS.includes(id) && id !== 'city');
297
+ assert.deepEqual(JSON.parse(JSON.stringify(p.ids(fold))), JSON.parse(JSON.stringify(own)), 'every other Governing tab is in it, in the mode\'s order');
298
+ assert.ok(fold.classList.contains('nav-group--collapsed'), 'a dropdown starts closed');
299
+ const head = fold.querySelector('.nav-group__head');
300
+ assert.equal(head.getAttribute('aria-expanded'), 'false');
301
+ assert.equal(head.getAttribute('aria-controls'), 'nav-items-craft-fold', 'the head names the region it opens');
302
+ assert.match(head.innerHTML, /More governing/);
303
+ assert.ok(head.hasAttribute('data-craft-off') === false, 'its head shows while a tab inside does');
304
+ assert.ok(p.sidebar.querySelectorAll('.nav-group__head').find((h) => h.dataset.group === 'government').hasAttribute('data-craft-off'),
305
+ 'the emptied Government head hides, so no label sits over nothing');
306
+ p.craft.choose('building');
307
+ assert.equal(p.fold(), null, 'leaving Governing removes the dropdown');
308
+ assert.deepEqual(p.order(), before, 'and puts every tab back exactly where it was');
309
+ p.craft.choose('governing');
310
+ p.craft.choose('governing');
311
+ assert.equal(p.sidebar.querySelectorAll('.craft-fold').length, 1, 'choosing it again builds no second dropdown');
312
+ });
313
+
314
+ test('the dropdown opens on its own when the page you are on is inside it', () => {
315
+ const p = bootTree({ mode: 'governing', activeId: 'watch' });
316
+ assert.ok(!p.fold().classList.contains('nav-group--collapsed'), 'on Watch, the dropdown is open');
317
+ assert.equal(p.fold().querySelector('.nav-group__head').getAttribute('aria-expanded'), 'true');
318
+ assert.equal(bootTree({ mode: 'artistry' }).fold(), null, 'no other mode folds anything');
319
+ assert.equal(bootTree().fold(), null, 'nor does no mode');
320
+ });
@@ -271,8 +271,9 @@ test('the page holds only the wordmark, the switch, the city, one button and one
271
271
  });
272
272
 
273
273
  // task 1004544 (owner decision 2026-10-02): the city sits in the hall chrome
274
- // behind the shell's full-screen switch, the sky's mechanism (task 1004327),
275
- // and still has no sidebar link.
274
+ // behind the shell's full-screen switch, the sky's mechanism (task 1004327).
275
+ // The owner put the CITY on the sidebar on 2026-10-08 (the Governing mode's
276
+ // lead tab); the street still has no link of its own.
276
277
  test('the city sits in the hall chrome, behind the shell\'s full-screen switch', () => {
277
278
  const html = read('city.html').replace(/<!--[\s\S]*?-->/g, '');
278
279
  assert.match(html, /<body data-page="city"[^>]*\sdata-fullscreen-switch>/, 'the bare attribute: the page lays itself out for full screen');
@@ -285,7 +286,8 @@ test('the city sits in the hall chrome, behind the shell\'s full-screen switch',
285
286
  assert.match(css, /html\[data-fullscreen="on"\] \.city-main \{ top: 0; left: 0; \}/);
286
287
  assert.match(css, /html\[data-fullscreen="on"\] \.city-top \{ right: 184px; \}/, 'the strip steps clear of the corner exit');
287
288
  const shell = read('shell.js');
288
- assert.doesNotMatch(shell, /href:\s*'(\/builders)?\/(city|street)'/, 'no sidebar link (owner: leave it off the sidebar)');
289
+ assert.match(shell, /\{ id: 'city', label: 'City', icon: 'city', href: '\/city', gate: 'perm:board\.vote\.cast' \}/, 'the city is a tab, gated on its page\'s atom');
290
+ assert.doesNotMatch(shell, /href:\s*'(\/builders)?\/street'/, 'the street has no sidebar link: the way in is the city');
289
291
  });
290
292
 
291
293
  test('motion is CSS only and dies under reduced motion; no page :root; no innerHTML', () => {
@@ -267,7 +267,9 @@ test('sidebar groups read by audience, in order', () => {
267
267
  // purpose (task 1003479, and tests/goal_map_page.mjs pinned it); the owner
268
268
  // granted the sky that slot on 2026-09-24 (ADR 0227 §11), under Goals because
269
269
  // it is the goals seen as one picture.
270
- assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'goal-map', 'work', 'blockers', 'board-room', 'thinking', 'studio'],
270
+ // 2026-10-08: 'city' — the governor city — joins after the Board Room, on the
271
+ // same atom and for the same reason (owner call; it leads the Governing mode).
272
+ assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'goal-map', 'work', 'blockers', 'board-room', 'city', 'thinking', 'studio'],
271
273
  'the unlabeled group is the project itself (task 1002425 order)');
272
274
  assert.deepEqual(nav[1].items.map((i) => i.id), ['standing', 'profile'], 'You = your own numbers');
273
275
  // task 1003444: 'people' LEFT the group — the owner deleted the hall's
@@ -504,7 +504,8 @@ test('the street sits in the hall chrome, behind the shell\'s full-screen switch
504
504
  assert.match(css, /\.street-stage \{ position: fixed; top: var\(--topbar-h, 56px\); left: var\(--sidebar-w-rail, 64px\);[^}]*contain: layout paint;/);
505
505
  assert.match(css, /html\[data-fullscreen="on"\] \.street-stage \{ top: 0; left: 0; \}/);
506
506
  assert.match(css, /html\[data-fullscreen="on"\] \.street-top \{ padding-right: 184px; \}/, 'the strip steps clear of the corner exit');
507
- assert.doesNotMatch(read('shell.js'), /href:\s*'(\/builders)?\/(city|street)'/, 'no sidebar link (owner: leave it off the sidebar)');
507
+ // The city became a tab on 2026-10-08 (owner); the street is still reached through it.
508
+ assert.doesNotMatch(read('shell.js'), /href:\s*'(\/builders)?\/street'/, 'no sidebar link (owner: leave it off the sidebar)');
508
509
  });
509
510
 
510
511
  test('motion is CSS only and dies under reduced motion; no page :root; no innerHTML', () => {
@@ -635,7 +635,7 @@ try {
635
635
  assert.match(HTML, /<body data-page="tweak-editor"[^>]*data-fullscreen-switch/);
636
636
  assert.equal((HTML.match(/<h1\b/g) || []).length, 1);
637
637
  assert.ok(HTML.indexOf('tweak-editor-lib.js') < HTML.indexOf('tweak-editor.js?'), 'the lib loads first');
638
- assert.match(HTML, /shell\.js\?v=2026-10-04-lightbulb/);
638
+ assert.match(HTML, /shell\.js\?v=2026-10-08-city-tab/);
639
639
  assert.equal(/fonts\.googleapis\.com\/css/.test(HTML), false);
640
640
  for (const id of ['tw-bar', 'tw-counter', 'tw-open', 'tw-open-more', 'tw-divider', 'tw-openwrap', 'tw-open-menu', 'tw-open-tab', 'tw-open-split', 'tw-frame', 'tw-frame-view', 'tw-split-close', 'tw-download', 'tw-upload', 'tw-file', 'tw-submit', 'tw-paper', 'tw-doc', 'tw-notice', 'tw-done', 'tw-take', 'tw-pick', 'tw-picks', 'tw-refused', 'tw-refused-title', 'tw-refused-list', 'tw-refused-next', 'tw-refused-download', 'tw-refused-close']) {
641
641
  assert.ok(new RegExp(`id="${id}"`).test(HTML), `#${id} is in the markup`);