@bongos/core 1.19.1040 → 1.19.1042

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 (75) hide show
  1. package/.bongos-core.json +110 -80
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +6 -1
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +14 -0
  8. package/docs/adr/0339-a-projects-owner-upgrades-it-and-a-hosted-project-hands-off-to-the-hub-door.md +1 -0
  9. package/docs/adr/0340-every-hall-takes-its-update-from-settings-and-deploy-is-the-platform-halls-page.md +64 -0
  10. package/docs/adr/README.md +1 -0
  11. package/docs/api/openapi.json +64 -7
  12. package/docs/api-reference.md +7 -1
  13. package/docs/copy-inventory.md +41 -21
  14. package/docs/copy-registry.json +207 -27
  15. package/docs/file-map.md +1 -1
  16. package/docs/module-api-changelog.md +12 -0
  17. package/modules/grading/grader-subagent.js +7 -0
  18. package/modules/hall-ui/public/atlas.html +1 -1
  19. package/modules/hall-ui/public/blockers.html +1 -1
  20. package/modules/hall-ui/public/board-room.html +1 -1
  21. package/modules/hall-ui/public/collab.html +1 -1
  22. package/modules/hall-ui/public/copy-desk.html +1 -1
  23. package/modules/hall-ui/public/deploy.html +1 -1
  24. package/modules/hall-ui/public/diagrams.html +1 -1
  25. package/modules/hall-ui/public/drachmae.html +1 -1
  26. package/modules/hall-ui/public/fleet.html +1 -1
  27. package/modules/hall-ui/public/gate.html +1 -1
  28. package/modules/hall-ui/public/goals.html +1 -1
  29. package/modules/hall-ui/public/government.html +1 -1
  30. package/modules/hall-ui/public/idea.html +1 -1
  31. package/modules/hall-ui/public/ideas.html +1 -1
  32. package/modules/hall-ui/public/index.html +1 -1
  33. package/modules/hall-ui/public/modules.html +1 -1
  34. package/modules/hall-ui/public/palette.js +1 -0
  35. package/modules/hall-ui/public/people.html +1 -1
  36. package/modules/hall-ui/public/primer.html +1 -1
  37. package/modules/hall-ui/public/profile.html +1 -1
  38. package/modules/hall-ui/public/project-settings.html +1 -1
  39. package/modules/hall-ui/public/ranks.html +1 -1
  40. package/modules/hall-ui/public/roadmap.html +1 -1
  41. package/modules/hall-ui/public/roster.html +1 -1
  42. package/modules/hall-ui/public/sessions.html +1 -1
  43. package/modules/hall-ui/public/settings-software-update.js +194 -0
  44. package/modules/hall-ui/public/settings.css +39 -0
  45. package/modules/hall-ui/public/settings.html +24 -2
  46. package/modules/hall-ui/public/settings.js +5 -0
  47. package/modules/hall-ui/public/settings.states.json +2 -1
  48. package/modules/hall-ui/public/shell.js +25 -39
  49. package/modules/hall-ui/public/studio.html +1 -1
  50. package/modules/hall-ui/public/task.html +1 -1
  51. package/modules/hall-ui/public/watch.html +1 -1
  52. package/modules/hall-ui/public/work.html +1 -1
  53. package/modules/hall-ui/records/panel-family.md +9 -0
  54. package/modules/npm-release/public/work.js +8 -3
  55. package/modules/npm-release/work.js +8 -2
  56. package/package-lock.json +2 -2
  57. package/package.json +1 -1
  58. package/release-notes.json +16 -0
  59. package/scripts/gds/fitness.js +7 -0
  60. package/scripts/gds/main-audit.js +204 -7
  61. package/{modules/npm-release/registry.js → src/bongos/package-registry.js} +14 -7
  62. package/src/bongos/routes/core-update.js +65 -3
  63. package/src/bongos/serve-internal.js +17 -0
  64. package/src/bongos/software-update.js +127 -0
  65. package/src/module-api.js +10 -1
  66. package/tests/core_upgrade_owner_door.mjs +27 -18
  67. package/tests/grade_model_provider.mjs +1 -1
  68. package/tests/grader_reviewer_isolation.mjs +68 -0
  69. package/tests/helpers.mjs +92 -0
  70. package/tests/main_audit_attestation.mjs +238 -0
  71. package/tests/module_api.mjs +1 -0
  72. package/tests/nav_permission_atoms.mjs +1 -1
  73. package/tests/npm_release_page.mjs +11 -1
  74. package/tests/npm_release_work.mjs +9 -94
  75. package/tests/software_update.mjs +449 -0
@@ -76,7 +76,7 @@
76
76
  <div id="gate-modal-root"></div>
77
77
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
78
78
 
79
- <script src="/builders/shell.js?v=2026-09-26-own1"></script>
79
+ <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
80
80
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
81
81
  <script src="/builders/palette.js" defer></script>
82
82
  <script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
@@ -235,7 +235,7 @@
235
235
 
236
236
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
237
237
 
238
- <script src="/builders/shell.js?v=2026-09-26-own1"></script>
238
+ <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
239
239
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
240
240
  <script src="/builders/palette.js" defer></script>
241
241
  <script src="/builders/dom-utils.js?v=2026-06-14-domutils"></script>
@@ -134,7 +134,7 @@
134
134
 
135
135
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
136
136
 
137
- <script src="/builders/shell.js?v=2026-09-26-own1"></script>
137
+ <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
138
138
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
139
139
  <script src="/builders/palette.js" defer></script>
140
140
  <script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
@@ -52,7 +52,7 @@
52
52
 
53
53
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
54
54
 
55
- <script src="/builders/shell.js?v=2026-09-26-own1"></script>
55
+ <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
56
56
  <script src="/builders/palette.js" defer></script>
57
57
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib" defer></script>
58
58
  <!-- task-detail.js still owns the #/idea/N and #/blocker/N overlay, so a ref
@@ -225,7 +225,7 @@
225
225
 
226
226
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
227
227
 
228
- <script src="/builders/shell.js?v=2026-09-26-own1"></script>
228
+ <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
229
229
  <script src="/builders/palette.js" defer></script>
230
230
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
231
231
  <!-- task 1003069 (R08) — the quick-filing form's routed result links a task
@@ -102,7 +102,7 @@
102
102
  <script src="/builders/brand-apply.js?v=2026-06-27-r75" defer></script>
103
103
  <!-- The app shell (sidebar + top bar + view router). Dependency-free; loaded
104
104
  BEFORE builders.js so the chrome ids exist when events are wired. -->
105
- <script src="/builders/shell.js?v=2026-09-26-own1" defer></script>
105
+ <script src="/builders/shell.js?v=2026-09-26-swupdate" defer></script>
106
106
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
107
107
  <script src="/builders/palette.js" defer></script>
108
108
  <!-- task 2166: the shared component kit (window.OTBKit) — pager/ledger/rail.
@@ -80,7 +80,7 @@
80
80
 
81
81
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
82
82
 
83
- <script src="/builders/shell.js?v=2026-09-26-own1"></script>
83
+ <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
84
84
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
85
85
  <script src="/builders/palette.js" defer></script>
86
86
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
@@ -62,6 +62,7 @@
62
62
  { label: 'Extensions', href: '/settings#extensions' },
63
63
  { label: 'Preferences', href: '/settings#preferences' },
64
64
  { label: 'Sound', href: '/settings#sound' },
65
+ { label: 'Software update', href: '/settings#update' },
65
66
  ] },
66
67
  ];
67
68
 
@@ -66,7 +66,7 @@
66
66
 
67
67
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
68
68
  chrome exists before the page script runs. -->
69
- <script src="/builders/shell.js?v=2026-09-26-own1"></script>
69
+ <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
70
70
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
71
71
  <script src="/builders/palette.js" defer></script>
72
72
  <!-- shared frontend toolkit (window.OTB — rank labels, number formats) + the
@@ -115,7 +115,7 @@
115
115
 
116
116
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
117
117
 
118
- <script src="/builders/shell.js?v=2026-09-26-own1"></script>
118
+ <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
119
119
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
120
120
  <script src="/builders/palette.js" defer></script>
121
121
  <!-- the shared markdown reader (window.OTBMdReader) — primer.js renders through
@@ -187,7 +187,7 @@
187
187
 
188
188
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
189
189
  chrome exists before the page script runs. -->
190
- <script src="/builders/shell.js?v=2026-09-26-own1"></script>
190
+ <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
191
191
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
192
192
  <script src="/builders/palette.js" defer></script>
193
193
  <!-- shared frontend toolkit (window.OTB) — loaded before profile.js, which pulls from it. -->
@@ -58,7 +58,7 @@
58
58
 
59
59
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
60
60
 
61
- <script src="/builders/shell.js?v=2026-09-26-own1"></script>
61
+ <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
62
62
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
63
63
  <script src="/builders/palette.js" defer></script>
64
64
  <script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
@@ -68,7 +68,7 @@
68
68
 
69
69
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
70
70
  chrome exists before the page script runs. -->
71
- <script src="/builders/shell.js?v=2026-09-26-own1"></script>
71
+ <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
72
72
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
73
73
  <script src="/builders/palette.js" defer></script>
74
74
  <!-- shared frontend toolkit (window.OTB) — loaded before ranks.js, which pulls from it. -->
@@ -41,7 +41,7 @@
41
41
 
42
42
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
43
43
 
44
- <script src="/builders/shell.js?v=2026-09-26-own1"></script>
44
+ <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
45
45
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
46
46
  <script src="/builders/palette.js" defer></script>
47
47
  <script src="/builders/dom-utils.js?v=2026-06-14-domutils"></script>
@@ -50,7 +50,7 @@
50
50
 
51
51
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
52
52
  chrome exists before the page script runs. -->
53
- <script src="/builders/shell.js?v=2026-09-26-own1"></script>
53
+ <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
54
54
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
55
55
  <script src="/builders/palette.js" defer></script>
56
56
  <!-- shared frontend toolkit (window.OTB — rank labels, number formats) + the
@@ -99,7 +99,7 @@
99
99
 
100
100
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
101
101
  chrome exists before the page script runs. -->
102
- <script src="/builders/shell.js?v=2026-09-26-own1"></script>
102
+ <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
103
103
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
104
104
  <script src="/builders/palette.js" defer></script>
105
105
  <!-- shared frontend toolkit (window.OTB) — loaded BEFORE sessions.js so it can pull from it. -->
@@ -0,0 +1,194 @@
1
+ // modules/hall-ui/public/settings-software-update.js — Settings → Software update
2
+ // (task 1004296).
3
+ //
4
+ // The owner, 2026-09-25: "each builders hall settings has a version description where they
5
+ // can pull the latest release. Just like iOS does." So this panel says three things and
6
+ // offers one: the version this project runs; either "up to date" or the newest release its
7
+ // update channel lets it take; what each version in between brings — and, for the people
8
+ // the upgrade door lets through, an Update button.
9
+ //
10
+ // THE BUTTON LEADS TO THE DOOR; IT IS NOT THE DOOR. Taking an update is the deploy door's
11
+ // two steps (see exactly what moves, then confirm that version) with its health check and
12
+ // auto-rollback (ADR 0293), and on a hosted project that door is on the hub (ADR 0339 D3).
13
+ // A second copy of that flow here would be a second place for its safety rules to drift, so
14
+ // the server names where this project's door is (`door`) and the button goes there.
15
+ //
16
+ // THE PAGE KNOWS NOTHING (deploy.js's rule, kept). Every version here is the server's reading
17
+ // with the time it was taken. "Up to date" is drawn only from an ok reading with nothing
18
+ // offered; a reading that failed says so, and release notes that could not be read are
19
+ // reported as unread, never drawn as a version with no changes.
20
+ //
21
+ // READ WHEN ITS GROUP OPENS, not with the page: an update's notes come out of the newer
22
+ // package's download (src/bongos/package-registry.js), and most visits to Settings are for
23
+ // something else. settings.js announces each group switch as a `settings:group` event.
24
+ //
25
+ // Its own file, like the sessions and interaction panels: settings.js is against the
26
+ // 1500-line ratchet, and this panel shares nothing with it but the toolkit globals.
27
+
28
+ (() => {
29
+ 'use strict';
30
+
31
+ const API = '/api/bongos';
32
+ const GROUP = 'update';
33
+ const { escapeHtml } = window.OTB;
34
+ const byId = (id) => document.getElementById(id);
35
+ const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
36
+
37
+ // Versions drawn open before "Show every version": enough to read the recent work
38
+ // without a project that is a hundred versions behind burying the button.
39
+ const VERSIONS_OPEN = 5;
40
+
41
+ let asked = false;
42
+
43
+ function ago(iso) {
44
+ const then = iso ? new Date(iso).getTime() : NaN;
45
+ if (!Number.isFinite(then)) return null;
46
+ const mins = Math.max(0, Math.round((Date.now() - then) / 60000));
47
+ if (mins < 1) return 'just now';
48
+ if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'} ago`;
49
+ const hours = Math.round(mins / 60);
50
+ if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
51
+ const days = Math.round(hours / 24);
52
+ return `${days} day${days === 1 ? '' : 's'} ago`;
53
+ }
54
+
55
+ const mono = (v) => `<span class="su-ver">${escapeHtml(String(v))}</span>`;
56
+ // Voice rule V4 (docs/voice.md): a dash is banned in QUOTED data — and a release-note line
57
+ // is a task's ship summary, handed over by the ledger — so it becomes a comma at render,
58
+ // the way goal-inbox.js and the public feed's cleanTitle() do. Never by editing the data.
59
+ const unDash = (raw) => String(raw == null ? '' : raw).replace(/\s*[—–]\s*/g, ', ').replace(/,\s*,/g, ',').trim();
60
+
61
+ function row(label, desc, ctl) {
62
+ return `<div class="prow">
63
+ <div class="prow__main"><span class="prow__label">${label}</span>${desc ? `<span class="prow__desc">${desc}</span>` : ''}</div>
64
+ ${ctl ? `<div class="prow__ctl">${ctl}</div>` : ''}
65
+ </div>`;
66
+ }
67
+
68
+ // Where the Update button goes, and what it says it will do. Only a plain same-origin path
69
+ // or an http(s) URL is ever put in an href; anything else draws no button.
70
+ function doorHtml(data, offered) {
71
+ const door = data.door || {};
72
+ if (!data.can_update) {
73
+ return '<p class="panel-hint su-door">Only the people allowed to upgrade this project see an Update button here.</p>';
74
+ }
75
+ if (door.kind === 'cli') {
76
+ return '<p class="panel-hint su-door">This project is upgraded on the server that runs it: <code>bongos upgrade</code> there takes this version, with the same health check and rollback.</p>';
77
+ }
78
+ const url = String(door.url || '');
79
+ const sameOrigin = /^\/[a-z0-9/_-]*$/i.test(url);
80
+ if (!sameOrigin && !/^https?:\/\/[^\s"'<>]+$/i.test(url)) return '';
81
+ let where = '';
82
+ if (!sameOrigin) {
83
+ try { where = ` on ${new URL(url).host}`; } catch (e) { return ''; }
84
+ }
85
+ return `<div class="row-end su-door">
86
+ <a class="pbtn pbtn--save" id="su-update" href="${escapeHtml(url)}">Update to ${escapeHtml(offered)}${sameOrigin ? '' : ' ↗'}</a>
87
+ <span class="panel-hint">Opens this project's upgrade${escapeHtml(where)}: you see what would move, confirm it, and it installs, checks itself, and rolls back if the project does not come back healthy.</span>
88
+ </div>`;
89
+ }
90
+
91
+ // One entry: its text, and — only in the hall whose ledger the ids belong to, which is
92
+ // the one that runs the door itself — a link to that task.
93
+ function entryHtml(e, linkTasks) {
94
+ const text = escapeHtml(unDash(e.text));
95
+ return linkTasks && e.id ? `<li><a href="/task/${escapeHtml(e.id)}">${text}</a></li>` : `<li>${text}</li>`;
96
+ }
97
+
98
+ function versionHtml(v, linkTasks, notesRead) {
99
+ const when = ago(v.published_at);
100
+ let body;
101
+ if (!notesRead) body = '<p class="prow__desc">Its notes could not be read.</p>';
102
+ else if (v.entries === null) body = '<p class="prow__desc">No notes were packed for this version.</p>';
103
+ else if (!v.entries.length) body = '<p class="prow__desc">No changes are listed for this version.</p>';
104
+ else body = `<ul class="su-entries">${v.entries.map((e) => entryHtml(e, linkTasks)).join('')}</ul>`;
105
+ return `<li class="su-version">
106
+ <span class="prow__label">${mono(v.version)}${when ? `<span class="prow__tag">published ${escapeHtml(when)}</span>` : ''}</span>
107
+ ${body}
108
+ </li>`;
109
+ }
110
+
111
+ function whatsNewHtml(w, linkTasks) {
112
+ if (!w) return '';
113
+ const n = w.count;
114
+ const head = `<h3 class="su-h" id="su-new-h">What's new in ${n === 1 ? 'this version' : `these ${escapeHtml(String(n))} versions`}</h3>`;
115
+ const warn = w.ok ? '' : `<p class="panel-status panel-status--danger" role="status">The release notes could not be read: ${escapeHtml(w.error || 'unknown error')}. The versions are still listed.</p>`;
116
+ const items = w.versions.map((v, i) => versionHtml(v, linkTasks, w.ok).replace('<li class="su-version">',
117
+ i < VERSIONS_OPEN ? '<li class="su-version">' : '<li class="su-version" hidden>'));
118
+ const hiddenCount = Math.max(0, w.versions.length - VERSIONS_OPEN);
119
+ const more = hiddenCount ? `<button type="button" class="pbtn pbtn--quiet" id="su-more">Show ${escapeHtml(String(hiddenCount))} more version${hiddenCount === 1 ? '' : 's'}</button>` : '';
120
+ const beyond = w.more ? `<p class="panel-hint">…and ${escapeHtml(String(w.more))} older version${w.more === 1 ? '' : 's'}, also in this update.</p>` : '';
121
+ return `${head}${warn}<ol class="su-versions" aria-labelledby="su-new-h">${items.join('')}</ol>${more ? `<div class="row-end">${more}</div>` : ''}${beyond}`;
122
+ }
123
+
124
+ function render(data) {
125
+ const body = byId('su-body');
126
+ const note = byId('su-note');
127
+ if (!body) return;
128
+ const u = (data && data.update) || {};
129
+ const checked = ago(u.checked_at);
130
+ const notes = [];
131
+ if (checked) notes.push(`Checked ${checked}.`);
132
+
133
+ if (u.reading !== 'ok') {
134
+ body.innerHTML = row('This project runs', u.running ? mono(u.running) : 'a version it could not name')
135
+ + `<p class="panel-status panel-status--danger" role="status">Could not check for a newer version: ${escapeHtml(u.error || 'no answer')}.</p>`;
136
+ if (note) note.textContent = notes.join(' ');
137
+ return;
138
+ }
139
+
140
+ let html = row('This project runs', mono(u.running));
141
+ if (!u.offered) {
142
+ html += row('Newest for this project', `${mono(u.running)} — this project is up to date.`, '<span class="su-pill su-pill--ok">Up to date</span>');
143
+ } else {
144
+ const when = ago(u.offered_published_at);
145
+ html += row('Newest for this project', `${mono(u.offered)}${when ? ` — published ${escapeHtml(when)}` : ''}`, '<span class="su-pill">Update ready</span>');
146
+ html += doorHtml(data, u.offered);
147
+ html += whatsNewHtml(u.whats_new, (data.door || {}).kind === 'local');
148
+ }
149
+ // A newer line this project's channel does not take is a fact worth one sentence — it
150
+ // explains why the newest number on the registry is not the one offered.
151
+ if (u.newest && u.newest !== (u.offered || u.running) && String(u.newest).split('.')[1] !== String(u.running).split('.')[1]) {
152
+ notes.push(`${u.newest} is also published; it starts a new line, and this project takes updates within ${String(u.running).split('.').slice(0, 2).join('.')} on its own.`);
153
+ }
154
+ body.innerHTML = html;
155
+ if (note) note.textContent = notes.join(' ');
156
+
157
+ const more = byId('su-more');
158
+ if (more) {
159
+ more.addEventListener('click', () => {
160
+ for (const li of body.querySelectorAll('.su-version[hidden]')) li.hidden = false;
161
+ more.parentNode.remove();
162
+ });
163
+ }
164
+ }
165
+
166
+ async function load() {
167
+ if (asked) return;
168
+ asked = true;
169
+ const body = byId('su-body');
170
+ if (body) body.innerHTML = '<p class="panel-hint">Checking for a newer version…</p>';
171
+ try {
172
+ const res = await api.request('GET', `${API}/software-update`);
173
+ if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
174
+ if (!res.ok || !res.data) throw new Error((res.data && res.data.error && res.data.error.message) || `HTTP ${res.status}`);
175
+ render(res.data);
176
+ } catch (err) {
177
+ // Its own failure, like every section here: this panel says it could not check, and
178
+ // the rest of Settings is untouched. Asking again is allowed.
179
+ asked = false;
180
+ if (body) body.innerHTML = `<p class="panel-status panel-status--danger" role="status">Could not check for updates: ${escapeHtml(err.message || 'unknown error')}.</p>`;
181
+ }
182
+ }
183
+
184
+ function boot() {
185
+ const section = byId('software-update-scroll');
186
+ if (!section) return;
187
+ section.hidden = false;
188
+ const groups = byId('settings-groups');
189
+ if (groups && groups.dataset && groups.dataset.active === GROUP) load();
190
+ document.addEventListener('settings:group', (e) => { if (e && e.detail && e.detail.group === GROUP) load(); });
191
+ }
192
+
193
+ boot();
194
+ })();
@@ -26,6 +26,7 @@
26
26
  .settings-groups[data-active="access"] > [data-group="access"],
27
27
  .settings-groups[data-active="preferences"] > [data-group="preferences"],
28
28
  .settings-groups[data-active="sound"] > [data-group="sound"],
29
+ .settings-groups[data-active="update"] > [data-group="update"],
29
30
  .settings-groups[data-active="extensions"] > [data-group="extensions"] { display: block; }
30
31
  .settings-groups > [data-group][hidden] { display: none !important; }
31
32
  .settings-groups .scroll { margin: 0 0 var(--sp-sm); }
@@ -212,3 +213,41 @@ select.voices__select { min-width: 140px; }
212
213
  font-family: inherit;
213
214
  opacity: 0.92;
214
215
  }
216
+
217
+ /* ---- Software update (task 1004296) -------------------------------------- */
218
+ /* A version is read digit by digit and compared against another, so it takes the mono
219
+ face. The state pill borrows project-settings' shape: a word in its own colour with a
220
+ hairline of the same, so "up to date" and "update ready" differ by more than hue. */
221
+ .su-ver { font-family: var(--font-mono); font-size: 13.5px; color: var(--ink); }
222
+ .su-pill {
223
+ display: inline-block;
224
+ padding: 1px 8px;
225
+ border: var(--line) solid currentColor;
226
+ border-radius: var(--radius-pill);
227
+ font-size: 11.5px;
228
+ font-weight: 600;
229
+ white-space: nowrap;
230
+ color: var(--accent-ink);
231
+ }
232
+ .su-pill--ok { color: var(--ok); }
233
+ .su-door { margin-top: var(--sp-sm); }
234
+ .su-door .panel-hint { flex: 1 1 18rem; min-width: 0; }
235
+ .su-h { margin: var(--sp-md) 0 0; font-size: 14.5px; font-weight: 600; color: var(--ink); }
236
+ .su-versions { list-style: none; margin: var(--sp-xs) 0 0; padding: 0; }
237
+ .su-version { padding: var(--sp-sm) 0; border-top: var(--line) solid var(--rule-soft); }
238
+ .su-version > .prow__label { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px; }
239
+ .su-entries { margin: var(--sp-hair) 0 0; padding-left: 1.15rem; max-width: var(--measure); font-size: 13.5px; color: var(--ink-soft); }
240
+ .su-entries li + li { margin-top: 2px; }
241
+ /* a task title is a link, and a link owes the 24px floor. Block, so a two-line note keeps
242
+ its bullet on its first line; the soft ink with an underline, because on the platform
243
+ every note is a link and a list of accent-coloured sentences reads as a warning. */
244
+ .su-entries a {
245
+ display: block;
246
+ min-height: 24px;
247
+ padding: 2px 0;
248
+ color: var(--ink-soft);
249
+ text-decoration: underline;
250
+ text-decoration-color: color-mix(in srgb, var(--ink-soft) 45%, transparent);
251
+ text-underline-offset: 3px;
252
+ }
253
+ .su-entries a:hover { color: var(--ink); text-decoration-color: currentColor; }
@@ -395,6 +395,26 @@
395
395
  <p class="section-note">Changes save at once. Your new choice plays the next time that event fires.</p>
396
396
  </section>
397
397
 
398
+ <!-- ============ group: update ============ -->
399
+
400
+ <!-- SOFTWARE UPDATE — this project's version, the newest release its update
401
+ channel allows, and what the versions between them carry (task 1004296).
402
+ Like a phone's Software Update screen: every builder reads it; the Update
403
+ button is drawn only for those the upgrade door lets through, and it LEADS
404
+ to that door (the two-step confirm and auto-rollback of ADR 0293) — the
405
+ act never happens on this page. Rendered by settings-software-update.js
406
+ from GET /api/bongos/software-update, read only when this group opens:
407
+ an update's notes come out of a package download. -->
408
+ <section class="scroll" id="software-update-scroll" aria-labelledby="software-update-h" data-group="update" hidden>
409
+ <h2 id="software-update-h" class="scroll__h">Software update</h2>
410
+ <p class="scroll__lede">
411
+ Which version of Cloud Bongos this project runs, whether a newer one is
412
+ ready for it, and what each newer version brings.
413
+ </p>
414
+ <div id="su-body" aria-live="polite"></div>
415
+ <p class="section-note" id="su-note"></p>
416
+ </section>
417
+
398
418
  <!-- ============ group: extensions ============ -->
399
419
 
400
420
  <!-- Module-contributed panels (BV1.R42, ADR 0083). settings.js appends a
@@ -416,7 +436,7 @@
416
436
 
417
437
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
418
438
  chrome exists before the page scripts run. -->
419
- <script src="/builders/shell.js?v=2026-09-26-own1"></script>
439
+ <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
420
440
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
421
441
  <script src="/builders/palette.js" defer></script>
422
442
  <!-- shared frontend toolkit (window.OTB) — loaded before consuming IIFEs. -->
@@ -426,7 +446,7 @@
426
446
  <!-- shared record overlay (#/idea|blocker/N) — every hall page carries it; this
427
447
  page was the one gap (task 2172). -->
428
448
  <script src="/builders/task-detail.js?v=2026-07-03-taskdetail"></script>
429
- <script src="/builders/settings.js?v=2026-09-02-panel"></script>
449
+ <script src="/builders/settings.js?v=2026-09-26-update"></script>
430
450
  <!-- task 1003341: the Active sessions panel. Its own file rather than more of
431
451
  settings.js, which sits close to the 1500-line size ratchet. -->
432
452
  <script src="/builders/settings-sessions.js?v=2026-08-29-sessions"></script>
@@ -439,5 +459,7 @@
439
459
  the panel grew from one switch to a rung picker with a derived default, and
440
460
  settings.js is against the 1500-line ratchet. -->
441
461
  <script src="/builders/settings-render.js?v=2026-09-16-render-levels"></script>
462
+ <!-- task 1004296: Settings → Software update. Own file on the same precedent. -->
463
+ <script src="/builders/settings-software-update.js?v=2026-09-26-update"></script>
442
464
  </body>
443
465
  </html>
@@ -1207,6 +1207,8 @@
1207
1207
  ...(window.OTB.moduleOn('dev-box') ? [{ id: 'devbox', label: 'Dev box' }] : []),
1208
1208
  { id: 'preferences', label: 'Preferences' },
1209
1209
  { id: 'sound', label: 'Sound' },
1210
+ // task 1004296 — every project's version and its update; settings-software-update.js.
1211
+ { id: 'update', label: 'Software update' },
1210
1212
  ];
1211
1213
  const RAIL_GROUPS = RAIL_ITEMS.map((i) => i.id).concat('extensions');
1212
1214
  let rail = null;
@@ -1230,6 +1232,7 @@
1230
1232
  'voices': 'voices-scroll',
1231
1233
  'sounds': 'sounds-scroll',
1232
1234
  'event-sounds': 'event-sounds-scroll',
1235
+ 'software-update': 'software-update-scroll',
1233
1236
  };
1234
1237
 
1235
1238
  function activateGroup(group, opts) {
@@ -1238,6 +1241,8 @@
1238
1241
  if (!groups) return;
1239
1242
  groups.dataset.active = group;
1240
1243
  rail?.activate(group);
1244
+ // A panel whose read is expensive loads when its group opens, not with the page.
1245
+ if (typeof CustomEvent === 'function') document.dispatchEvent(new CustomEvent('settings:group', { detail: { group } }));
1241
1246
  if (!opts || opts.scroll !== false) window.scrollTo({ top: 0 });
1242
1247
  }
1243
1248
 
@@ -10,7 +10,8 @@
10
10
  "account": { "auth": true, "actions": [["wait", 900]], "expect": { "visible": ["#settings-h1", "#display-name-input", "#craft-chips", "#privacy-rows"] } },
11
11
  "access": { "auth": true, "url": "/settings#access", "actions": [["wait", 900]], "expect": { "visible": ["#cli-reissue-btn", "#sessions-list", "#artkey-card"] } },
12
12
  "preferences": { "auth": true, "url": "/settings#preferences", "actions": [["wait", 900]], "expect": { "visible": ["#wander-rows", "#render-rows"] } },
13
- "sound": { "auth": true, "url": "/settings#sound", "actions": [["wait", 900]], "expect": { "visible": ["#voices-table", "#sound-rows", "#event-sound-rows"] } }
13
+ "sound": { "auth": true, "url": "/settings#sound", "actions": [["wait", 900]], "expect": { "visible": ["#voices-table", "#sound-rows", "#event-sound-rows"] } },
14
+ "update": { "auth": true, "url": "/settings#software-update", "actions": [["wait", 1200]], "expect": { "visible": ["#software-update-h", "#su-body", "#su-update", ".su-versions"] } }
14
15
  },
15
16
  "reducedMotion": { "auth": true, "actions": [["wait", 900]] }
16
17
  }
@@ -347,8 +347,9 @@
347
347
  //
348
348
  // THE DOOR IS THE PROVISIONING MODULE'S, and a hosted project does not run it, so
349
349
  // there this link opened a page with nothing behind it (task 1004174). It is
350
- // dropped where the module is off, and re-pointed at the hub's own door for THIS
351
- // project once /core-update names one (pointDeployAtHub, ADR 0339).
350
+ // dropped where the module is off — and since task 1004296 it STAYS dropped: /deploy
351
+ // is the platform hall's page alone, and every other hall takes its update from
352
+ // Settings → Software update, whose Update button carries the hub hand-off.
352
353
  { id: 'deploy', label: 'Deploy', icon: 'admin', href: '/deploy', gate: 'archon', module: 'provisioning' },
353
354
  // task 1001949: what each hosted project costs. 'government' mirrors the metic floor
354
355
  // of `provisioning.fleet.manage`, the atom its page and its one read check (the
@@ -628,39 +629,22 @@
628
629
  coreUpdateAsked = true;
629
630
  api.request('GET', '/api/bongos/core-update').then(function (res) {
630
631
  if (!res || res.status !== 200 || !res.data) return;
631
- var handoff = hubHandoff(res.data.deploy_url);
632
- if (handoff) pointDeployAtHub(handoff);
633
- if (res.data.update) renderCoreUpdateBanner(res.data.update, handoff);
632
+ if (res.data.update) renderCoreUpdateBanner(res.data.update);
634
633
  }, function () { /* fail-soft: no banner is the correct answer to no answer */ });
635
634
  }
636
635
 
637
- // task 1004174 (ADR 0339) — where a HOSTED project's owner takes the upgrade: the
638
- // hub's own deploy door scoped to this project, which the server names as
639
- // `deploy_url` when this project is federated with a hub. Only where this hall has no
640
- // door of its own: on the platform the local /deploy is the door, and a link away
641
- // from it would be a second road to the same button. An address that is not a plain
642
- // http(s) URL is ignored rather than trusted into an href.
643
- function hubHandoff(url) {
644
- if (moduleOn('provisioning')) return null;
645
- if (typeof url !== 'string' || !/^https?:\/\/[^\s"'<>]+$/i.test(url)) return null;
646
- return url;
647
- }
648
-
649
- // The rail's Deploy item is hidden on a hosted project (its module gate); this gives
650
- // it back, aimed at the hub. Its rank gate still applies, exactly as on the platform.
651
- function pointDeployAtHub(url) {
652
- var item = document.getElementById('nav-deploy');
653
- if (!item) return;
654
- item.setAttribute('href', url);
655
- if (item.dataset) delete item.dataset.module;
656
- item.hidden = !gateAllows(item.dataset && item.dataset.gate);
657
- }
636
+ // Where the banner's Upgrade goes (task 1004296). The platform's hall runs the deploy
637
+ // door itself, so there it is /deploy. Every other hall — a hosted project, a
638
+ // self-hosted instance — takes its update from Settings → Software update, which says
639
+ // what the update carries and, on a hosted project, hands the owner to the hub's door
640
+ // for this project (ADR 0339 D3). The banner no longer links off-site itself.
641
+ var SOFTWARE_UPDATE_HREF = '/settings#software-update';
658
642
 
659
643
  // SAY NOTHING WHEN THERE IS NOTHING TO SAY. `behind` is false both when the box
660
644
  // is current AND when the registry could not be read; `reading` separates them,
661
645
  // and neither renders. A banner is a claim about being out of date, and a wrong
662
646
  // one on a project that is actually current is worse than silence.
663
- function renderCoreUpdateBanner(update, handoff) {
647
+ function renderCoreUpdateBanner(update) {
664
648
  if (!update || update.reading !== 'ok' || !update.behind || !update.latest) return;
665
649
  // A dismissal is remembered PER VERSION, not forever: the next release is new
666
650
  // news and must re-announce itself, or the surface is dead after one click.
@@ -676,25 +660,27 @@
676
660
  el.className = 'core-update-notice';
677
661
  el.id = 'core-update-notice';
678
662
  el.setAttribute('role', 'status');
679
- // The action is shown only where it can actually be taken. The deploy door is
680
- // the provisioning module's, and a hosted project does NOT run that module
681
- // (probed: /api/bongos/provisioning/health is 404 on a tenant, 200 on the
682
- // platform) — so on those the banner informs and does not offer a button that
683
- // would 404. Task 1004046 is what gives those halls a real action.
663
+ // The action goes where the update can actually be taken. The deploy door is the
664
+ // provisioning module's, and a hosted project does NOT run that module (probed:
665
+ // /api/bongos/provisioning/health is 404 on a tenant, 200 on the platform) — so on
666
+ // those it goes to Settings → Software update (SOFTWARE_UPDATE_HREF above).
684
667
  // NOT A LINK TO THE PAGE YOU ARE ALREADY ON (task 1004145). This reads as the action
685
668
  // button — it is labelled with the verb and styled like one — but it is a plain
686
669
  // navigation to /deploy. On /deploy itself that is a no-op, and the owner who
687
670
  // pressed it there reasonably reported "I clicked update, but I didn't see anything
688
671
  // happen": nothing had, and nothing had been asked for. The control is on that page
689
672
  // already, so on it the banner informs and points, and does not pretend to act.
690
- var onDeploy = window.location && String(window.location.pathname || '').replace(/\/+$/, '') === '/deploy';
691
- // A hosted project has no door of its own; it hands its owner to the hub's door for
692
- // this project when the server named one (task 1004174), and otherwise only informs.
693
- var action = !moduleOn('provisioning')
694
- ? (handoff ? '<a class="core-update-notice__action" href="' + esc(handoff) + '">Upgrade</a>' : '')
695
- : (onDeploy
673
+ // The same rule on Settings → Software update: that panel IS the control there.
674
+ var here = window.location ? String(window.location.pathname || '').replace(/\/+$/, '') : '';
675
+ var onDeploy = here === '/deploy';
676
+ var onUpdatePanel = here === '/settings' && String(window.location.hash || '') === '#software-update';
677
+ var action = moduleOn('provisioning')
678
+ ? (onDeploy
696
679
  ? '<span class="core-update-notice__hint">Use the control below to take it.</span>'
697
- : '<a class="core-update-notice__action" href="/deploy">Upgrade</a>');
680
+ : '<a class="core-update-notice__action" href="/deploy">Upgrade</a>')
681
+ : (onUpdatePanel
682
+ ? '<span class="core-update-notice__hint">Its details are below.</span>'
683
+ : '<a class="core-update-notice__action" href="' + SOFTWARE_UPDATE_HREF + '">Upgrade</a>');
698
684
  el.innerHTML =
699
685
  '<span>Core <strong>' + esc(update.latest) + '</strong> is available — this project runs ' +
700
686
  esc(update.running || 'an unknown version') + '.</span>' +
@@ -66,7 +66,7 @@
66
66
 
67
67
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
68
68
 
69
- <script src="/builders/shell.js?v=2026-09-26-own1"></script>
69
+ <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
70
70
  <script src="/builders/palette.js" defer></script>
71
71
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
72
72
  <!-- The kit, for its ONE renderer: the full-size ship-time visual figure