@bongos/core 1.19.346 → 1.19.348

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.
@@ -0,0 +1,66 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Collab — {{worldName}}</title>
7
+ <meta name="description" content="Requests for help addressed to your craft or to you by name, and the asks you have open — with how long each has been waiting.">
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
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
12
+ <link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
13
+ <link rel="stylesheet" href="/builders/collab.css">
14
+ </head>
15
+ <body data-page="collab" data-page-title="Collab">
16
+
17
+ <div class="app">
18
+ <aside class="app-sidebar" id="app-sidebar" aria-label="Primary"></aside>
19
+ <div class="app-scrim" id="app-scrim" hidden></div>
20
+ <div class="app-body">
21
+ <header class="app-topbar" id="app-topbar"></header>
22
+ <div class="app-content">
23
+ <div class="page-head">
24
+ <p class="page-head__sub" id="cb-sub">Loading…</p>
25
+ </div>
26
+
27
+ <main>
28
+ <!-- ASKED OF YOU comes first: it is the half where someone is waiting on a
29
+ human. Your own asks are below, because they are a status check, not a
30
+ call to act. -->
31
+ <section class="scroll" aria-labelledby="cb-for-me-h">
32
+ <h2 class="scroll__h" id="cb-for-me-h">Asked of you</h2>
33
+ <p class="scroll__note" id="cb-for-me-note">
34
+ Requests addressed to your craft, or to you by name. Answering is optional
35
+ and declining costs nothing — nobody is assigned by being asked.
36
+ </p>
37
+ <ul class="cb-list" id="cb-for-me"></ul>
38
+ <p class="cb-empty" id="cb-for-me-empty" hidden>
39
+ Nothing is waiting on you. Requests reach you by your craft — set yours in
40
+ <a href="/builders/settings">Settings</a> if this stays empty.
41
+ </p>
42
+ </section>
43
+
44
+ <section class="scroll" aria-labelledby="cb-mine-h">
45
+ <h2 class="scroll__h" id="cb-mine-h">Your asks</h2>
46
+ <p class="scroll__note">
47
+ Only you can settle your own request — mark it answered when someone
48
+ helped, or withdraw it when you no longer need it.
49
+ </p>
50
+ <ul class="cb-list" id="cb-mine"></ul>
51
+ <p class="cb-empty" id="cb-mine-empty" hidden>You have no open asks.</p>
52
+ </section>
53
+ </main>
54
+ </div>
55
+ </div>
56
+ </div>
57
+
58
+ <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
59
+
60
+ <script src="/builders/shell.js?v=2026-07-02-shell1"></script>
61
+ <script src="/builders/palette.js" defer></script>
62
+ <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
63
+ <script src="/builders/collab-lib.js?v=2026-08-24-c02"></script>
64
+ <script src="/builders/collab.js?v=2026-08-24-c02"></script>
65
+ </body>
66
+ </html>
@@ -0,0 +1,139 @@
1
+ // modules/hall-ui/public/collab.js — the Collab page (/builders/collab).
2
+ // Task 1003172 / C02 of goal 1000075, criterion collab-page-matches-my-craft.
3
+ //
4
+ // The hall surface for C01's help requests. Two sections, in this order on
5
+ // purpose: what is ASKED OF YOU first, because someone is waiting on a human
6
+ // answer, then YOUR OWN ASKS, which are a status check rather than a call to act.
7
+ //
8
+ // THE PART THAT IS EASY TO SKIP, and the criterion names it explicitly: a request
9
+ // nobody takes must be VISIBLY AGING, not silently dead. Sorting the oldest to the
10
+ // top does not satisfy that — the oldest then looks exactly like the newest, only
11
+ // lower. So every row carries an age BAND with its own label and styling, and the
12
+ // rules for it live in collab-lib.js where a test can execute them.
13
+ //
14
+ // MATCHING IS THE SERVER'S JOB, not this file's. GET /help-requests/for-me derives
15
+ // the viewer's crafts from their own profile; sending a craft from here would be
16
+ // the privacy hole C01's grader found (a ?craft= param that let anyone read
17
+ // another craft's requests). This page never names a craft in a request.
18
+ (() => {
19
+ 'use strict';
20
+
21
+ // NO baseUrl OVERRIDE. The generated client's methods (api.helpRequests.*, api.me.*)
22
+ // emit API-RELATIVE paths like '/help-requests/for-me'. Passing baseUrl:'' means
23
+ // "these paths are already absolute" and sends them to the site root, where nothing
24
+ // is mounted — every call 404s. Letting it default resolves them against
25
+ // '/api/bongos/v1' (api-prefix.js VERSIONED_API_PREFIX), which is where the router
26
+ // actually mounts. Pages that DO pass baseUrl:'' must build full paths themselves
27
+ // via api.request('GET', `${API}/...`) — the two styles must never be mixed.
28
+ const api = window.BongosClient.createClient({ credentials: 'same-origin', throwOnError: false });
29
+ const { escapeHtml, $, toast } = window.OTB;
30
+ const L = window.OTBCollabLib;
31
+
32
+ let settling = false;
33
+
34
+ function ageChip(req, nowMs) {
35
+ const band = L.ageBand(req.created_at, nowMs);
36
+ return `<span class="cb-age cb-age--${band.id}" title="Asked ${escapeHtml(String(req.created_at || ''))}">${escapeHtml(band.label)}</span>`;
37
+ }
38
+
39
+ function contextChip(req) {
40
+ const ctx = L.contextOf(req);
41
+ if (!ctx) return '';
42
+ return `<a class="cb-ctx" href="${escapeHtml(ctx.href)}">${escapeHtml(ctx.kind)} #${escapeHtml(String(ctx.id))}</a>`;
43
+ }
44
+
45
+ // A row for the "asked of you" list. There is deliberately NO "claim this"
46
+ // button: answering is a conversation, and inventing a reply thread here would
47
+ // be reimplementing the Agora, which this goal explicitly forbids (C06 owns the
48
+ // chat mirror). What the page owes a helper is the request, its age, its
49
+ // context, and who to answer.
50
+ function forMeRow(req, viewerId, nowMs) {
51
+ const who = L.addressedTo(req, viewerId);
52
+ return `<li class="cb-item cb-item--${L.ageBand(req.created_at, nowMs).id}">
53
+ <p class="cb-item__stuck">${escapeHtml(req.what_is_stuck || '')}</p>
54
+ <p class="cb-item__meta">
55
+ <span class="cb-who">${escapeHtml(who)}</span>
56
+ ${contextChip(req)}
57
+ ${ageChip(req, nowMs)}
58
+ </p>
59
+ </li>`;
60
+ }
61
+
62
+ // Your own ask, with the two settle actions. Only the asker can settle — the
63
+ // server enforces it in the UPDATE's WHERE, this is just the affordance.
64
+ function mineRow(req, nowMs) {
65
+ return `<li class="cb-item cb-item--${L.ageBand(req.created_at, nowMs).id}" data-id="${escapeHtml(String(req.id))}">
66
+ <p class="cb-item__stuck">${escapeHtml(req.what_is_stuck || '')}</p>
67
+ <p class="cb-item__meta">
68
+ <span class="cb-who">${escapeHtml(L.addressedTo(req, null))}</span>
69
+ ${contextChip(req)}
70
+ ${ageChip(req, nowMs)}
71
+ </p>
72
+ <p class="cb-item__actions">
73
+ <button class="btn btn--sm" data-settle="answered" data-id="${escapeHtml(String(req.id))}">Someone helped</button>
74
+ <button class="btn btn--sm btn--quiet" data-settle="withdrawn" data-id="${escapeHtml(String(req.id))}">Withdraw</button>
75
+ </p>
76
+ </li>`;
77
+ }
78
+
79
+ function renderList(hostSel, emptySel, rows, html) {
80
+ const host = $(hostSel);
81
+ const empty = $(emptySel);
82
+ if (!host) return;
83
+ host.innerHTML = rows.map(html).join('');
84
+ if (empty) empty.hidden = rows.length > 0;
85
+ }
86
+
87
+ async function load() {
88
+ const nowMs = Date.now();
89
+ const [forMe, mine, me] = await Promise.all([
90
+ api.helpRequests.getHelpRequestsForMe({}),
91
+ api.helpRequests.getHelpRequestsMine({ query: { status: 'open' } }),
92
+ api.me.getMe ? api.me.getMe({}) : Promise.resolve({ ok: false }),
93
+ ]);
94
+
95
+ const viewerId = me && me.ok && me.data && me.data.builder ? me.data.builder.id : null;
96
+
97
+ const forMeRows = ((forMe.ok && forMe.data && forMe.data.help_requests) || []).slice().sort(L.byAgeOldestFirst);
98
+ const mineRows = ((mine.ok && mine.data && mine.data.help_requests) || []).slice().sort(L.byAgeOldestFirst);
99
+
100
+ renderList('#cb-for-me', '#cb-for-me-empty', forMeRows, (r) => forMeRow(r, viewerId, nowMs));
101
+ renderList('#cb-mine', '#cb-mine-empty', mineRows, (r) => mineRow(r, nowMs));
102
+
103
+ const sub = $('#cb-sub');
104
+ if (sub) {
105
+ const crafts = (forMe.ok && forMe.data && forMe.data.crafts) || [];
106
+ sub.textContent = crafts.length
107
+ ? `Matching your craft: ${crafts.join(', ')}.`
108
+ : 'You have not set a craft, so only requests addressed to you by name reach you.';
109
+ }
110
+ }
111
+
112
+ async function settle(id, status) {
113
+ if (settling) return;
114
+ settling = true;
115
+ try {
116
+ const r = await api.helpRequests.patchHelpRequestsId({ id, body: { status } });
117
+ if (!r.ok) {
118
+ toast(window.OTB.errMessage ? window.OTB.errMessage(r.data) : 'Could not update that request.');
119
+ return;
120
+ }
121
+ toast(status === 'answered' ? 'Marked answered.' : 'Withdrawn.');
122
+ await load();
123
+ } finally {
124
+ settling = false;
125
+ }
126
+ }
127
+
128
+ document.addEventListener('click', (ev) => {
129
+ const btn = ev.target && ev.target.closest ? ev.target.closest('[data-settle]') : null;
130
+ if (!btn) return;
131
+ ev.preventDefault();
132
+ settle(Number(btn.getAttribute('data-id')), btn.getAttribute('data-settle'));
133
+ });
134
+
135
+ load().catch(() => {
136
+ const sub = $('#cb-sub');
137
+ if (sub) sub.textContent = 'Could not load requests.';
138
+ });
139
+ })();
@@ -39,7 +39,15 @@
39
39
  'use strict';
40
40
 
41
41
  const API = '/api/gds';
42
- const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
42
+ // NO baseUrl OVERRIDE — this page calls the GENERATED namespaced methods
43
+ // (api.inbox.*, api.me.*), whose path templates are API-RELATIVE ('/inbox/template').
44
+ // baseUrl:'' means "these paths are already absolute" and passes them through
45
+ // unchanged, which sent every request to the SITE ROOT and 404'd — this page was
46
+ // dead in production from the day it shipped. Letting it default resolves them
47
+ // against '/api/bongos/v1' (api-prefix.js VERSIONED_API_PREFIX), where the router
48
+ // actually mounts. A page that DOES pass baseUrl:'' must build full paths itself
49
+ // via api.request('GET', `${API}/...`) as work.js/copy-desk.js do — never mix the two.
50
+ const api = window.BongosClient.createClient({ credentials: 'same-origin', throwOnError: false });
43
51
  const { escapeHtml, $, fmtDate } = window.OTB;
44
52
 
45
53
  const ideaId = () => {
@@ -44,7 +44,15 @@
44
44
  (() => {
45
45
  'use strict';
46
46
 
47
- const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
47
+ // NO baseUrl OVERRIDE — this page calls the GENERATED namespaced methods
48
+ // (api.inbox.*, api.me.*), whose path templates are API-RELATIVE ('/inbox/template').
49
+ // baseUrl:'' means "these paths are already absolute" and passes them through
50
+ // unchanged, which sent every request to the SITE ROOT and 404'd — this page was
51
+ // dead in production from the day it shipped. Letting it default resolves them
52
+ // against '/api/bongos/v1' (api-prefix.js VERSIONED_API_PREFIX), where the router
53
+ // actually mounts. A page that DOES pass baseUrl:'' must build full paths itself
54
+ // via api.request('GET', `${API}/...`) as work.js/copy-desk.js do — never mix the two.
55
+ const api = window.BongosClient.createClient({ credentials: 'same-origin', throwOnError: false });
48
56
  const { escapeHtml, $, toast } = window.OTB;
49
57
 
50
58
  // One key per THING BEING WRITTEN. A new idea and a spark being developed are
@@ -417,6 +425,14 @@
417
425
  }
418
426
 
419
427
  async function main() {
428
+ // THE QUEUE IS LOADED FIRST, AND UNCONDITIONALLY. The composer and the spark
429
+ // queue are independent surfaces that happen to share a page: the queue needs
430
+ // no template. Loading it after the template check meant one failed fetch took
431
+ // BOTH down — the reported symptom was "Could not load the idea template" AND a
432
+ // queue stuck on "Loading…" forever, which read as two bugs and was one.
433
+ // A surface that does not depend on the failure must not be hostage to it.
434
+ loadQueue();
435
+
420
436
  const r = await api.inbox.getInboxTemplate({});
421
437
  if (!r.ok || !r.data || !r.data.template) {
422
438
  $('#fi-sub').textContent = 'Could not load the idea template. Reload, or file from the terminal with capture.js.';
@@ -434,10 +450,6 @@
434
450
  $('#fi-form').addEventListener('submit', file);
435
451
  $('#fi-clear').addEventListener('click', discard);
436
452
 
437
- // The queue loads on every visit, develop mode or not: having just picked one
438
- // up, seeing what else is waiting is the natural next thing.
439
- loadQueue();
440
-
441
453
  if (DEVELOP_ID) {
442
454
  const d = await api.inbox.getInboxId({ id: DEVELOP_ID });
443
455
  if (!d.ok || !d.data || !d.data.idea) {
@@ -153,6 +153,10 @@
153
153
  { id: 'profile', label: 'Profile', icon: 'profile', href: '/profile', gate: 'signed' },
154
154
  ] },
155
155
  { group: 'Community', gate: 'member', items: [
156
+ // task 1003172 (C02, goal 1000075): help requests addressed to you. In
157
+ // Community because the group is picked by WHOSE data it is, and this is
158
+ // other builders reaching you — not your own numbers (that is You).
159
+ { id: 'collab', label: 'Collab', icon: 'leaderboard', href: '/collab', gate: 'member' },
156
160
  { id: 'leaderboard', label: 'Leaderboard', icon: 'leaderboard', href: '/#leaderboard', view: 'leaderboard', gate: 'member' },
157
161
  // task 1002577: the cross-project builder directory (/scouting graduated
158
162
  // into the hall). Gated to MATCH its data — /api/gds/scouting checks
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.346",
3
+ "version": "1.19.348",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.346",
9
+ "version": "1.19.348",
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.19.346",
3
+ "version": "1.19.348",
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",
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/client-baseurl-guard.js — a web page must not send its API calls to
3
+ // the site root (task 1003263).
4
+ //
5
+ // THE BUG THIS EXISTS TO STOP. The generated client (clients/bongos-client) exposes
6
+ // two call styles, and mixing them is silent and fatal:
7
+ //
8
+ // NAMESPACED api.inbox.getInboxTemplate({}) → path template '/inbox/template',
9
+ // API-RELATIVE, needs the API base
10
+ // ESCAPE HATCH api.request('GET', `${API}/me`) → caller supplies the FULL path
11
+ //
12
+ // createClient documents `baseUrl: ''` as "paths are already absolute" and passes
13
+ // them through unchanged. So a page that sets baseUrl:'' AND calls a namespaced
14
+ // method requests '/inbox/template' — the SITE ROOT, where nothing is mounted — and
15
+ // 404s on every call. The router mounts at '/api/bongos/v1' (api-prefix.js
16
+ // VERSIONED_API_PREFIX), which is exactly the client's own default.
17
+ //
18
+ // WHY A FITNESS CHECK AND NOT A TEST. Three pages shipped this defect — ideas.js and
19
+ // idea.js were dead in production from the day they shipped, and collab.js was caught
20
+ // in review one commit before it would have been. Every page test stubs the client
21
+ // method directly, so URL construction is never exercised; tests/idea_hall_composer.mjs
22
+ // even asserted with a regex against the source text, which passes happily on a broken
23
+ // URL. 54 green tests over pages that 404 on every call. The defect is invisible to the
24
+ // test style this repo uses for hall pages, so it needs a guard at a different altitude.
25
+ //
26
+ // STATIC AND CONSERVATIVE. It flags only the unambiguous combination — an explicit
27
+ // empty baseUrl in a createClient call, in a file that also makes a namespaced call.
28
+ // A page using the escape hatch with baseUrl:'' is CORRECT and stays silent (work.js,
29
+ // copy-desk.js, goal-inbox.js and most of the hall do exactly that).
30
+
31
+ 'use strict';
32
+
33
+ const fs = require('node:fs');
34
+ const path = require('node:path');
35
+ const { execFileSync } = require('node:child_process');
36
+
37
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
38
+ const NAME = 'a web page sends its API calls to the API, not the site root (task 1003263)';
39
+
40
+ // createClient({ ... baseUrl: '' ... }) — an EXPLICIT empty base. Omitting baseUrl is
41
+ // the correct fix and must not match; only the literal empty string is the defect.
42
+ const EMPTY_BASEURL = /createClient\(\s*\{[^}]*baseUrl\s*:\s*(''|""|`\s*`)/;
43
+
44
+ // api.<namespace>.<method>( — the generated namespaced style. `api.request(` is the
45
+ // escape hatch and is deliberately NOT matched: with it, baseUrl:'' is correct.
46
+ const NAMESPACED_CALL = /\bapi\s*\.\s*(?!request\b)[A-Za-z_$][\w$]*\s*\.\s*[A-Za-z_$][\w$]*\s*\(/;
47
+
48
+ // Every tracked browser-served script. Server code never calls createClient this way.
49
+ function trackedPageScripts() {
50
+ try {
51
+ const out = execFileSync('git', ['ls-files', 'modules/*/public/*.js', 'public/*.js'],
52
+ { cwd: REPO_ROOT, encoding: 'utf8' });
53
+ return out.split('\n').map((s) => s.trim()).filter(Boolean);
54
+ } catch {
55
+ return [];
56
+ }
57
+ }
58
+
59
+ function scanPages(files, read) {
60
+ const violations = [];
61
+ let scanned = 0;
62
+ let usingClient = 0;
63
+ for (const rel of files) {
64
+ const src = read(rel);
65
+ if (src == null) continue;
66
+ scanned += 1;
67
+ if (!/createClient\(/.test(src)) continue;
68
+ usingClient += 1;
69
+ if (!EMPTY_BASEURL.test(src)) continue;
70
+ if (!NAMESPACED_CALL.test(src)) continue; // escape-hatch page — baseUrl:'' is right
71
+ const m = NAMESPACED_CALL.exec(src);
72
+ violations.push(
73
+ `${rel} sets baseUrl:'' AND calls the generated client (${m[0].replace(/\s+/g, '')}…). `
74
+ + "Those paths are API-relative, so baseUrl:'' sends them to the site root and every "
75
+ + 'call 404s. Drop the baseUrl override, or switch every call to '
76
+ + "api.request('METHOD', `${API}/…`) with a full path.",
77
+ );
78
+ }
79
+ return { violations, scanned, usingClient };
80
+ }
81
+
82
+ function checkClientBaseUrl({ files, read } = {}) {
83
+ const list = files || trackedPageScripts();
84
+ if (!list.length) {
85
+ return {
86
+ name: NAME, ok: false, hardFail: true,
87
+ violations: ['scan defect — enumerated 0 tracked page scripts; a broken enumeration, not a clean result.'],
88
+ warnings: [], note: 'static scan of browser-served scripts for a root-bound API client.',
89
+ };
90
+ }
91
+ const reader = read || ((rel) => {
92
+ try { return fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8'); } catch { return null; }
93
+ });
94
+ const { violations, scanned, usingClient } = scanPages(list, reader);
95
+ return {
96
+ name: NAME,
97
+ ok: violations.length === 0,
98
+ hardFail: violations.length > 0,
99
+ violations,
100
+ warnings: [],
101
+ note: `${scanned} browser-served script(s) scanned, ${usingClient} building a client: none may combine `
102
+ + "baseUrl:'' with a generated namespaced call (api.<ns>.<method>()) — that pairing 404s every request. "
103
+ + "baseUrl:'' with api.request() and a full path is the correct escape-hatch style and is not flagged.",
104
+ };
105
+ }
106
+
107
+ function main() {
108
+ const r = checkClientBaseUrl();
109
+ for (const v of r.violations) console.error(`✗ ${v}`);
110
+ console.log(`${r.hardFail ? 'FAIL' : 'PASS'} ${r.note}`);
111
+ process.exit(r.hardFail ? 1 : 0);
112
+ }
113
+
114
+ if (require.main === module) main();
115
+
116
+ module.exports = { checkClientBaseUrl, scanPages, trackedPageScripts, EMPTY_BASEURL, NAMESPACED_CALL };
@@ -160,6 +160,28 @@ async function fetchLive(base, timeoutMs = DEFAULT_TIMEOUT_MS) {
160
160
  }
161
161
  }
162
162
 
163
+ // Is the automation that should have PREVENTED this divergence itself alive? (task 1003158)
164
+ //
165
+ // This script detects the SYMPTOM — live is behind main — and only ever at a moment a human is
166
+ // already looking. What went unwatched is the auto-upgrade subscription itself: when it stopped,
167
+ // nothing said so, and cloudbongos.com sat 36 releases behind for days. Reading the roster here means
168
+ // a dead upgrader is reported in the same breath as the staleness it was supposed to prevent.
169
+ //
170
+ // Speaks ONLY when a subscription manifest is actually present. An ordinary core checkout has none by
171
+ // design (ADR 0136 — it ships empty), and alarming about that every session is exactly the noise that
172
+ // gets a real warning scrolled past. Present-but-broken, or present-and-subscribing-nothing, is the
173
+ // signal worth having: someone meant to enroll instances here and none are being upgraded.
174
+ function subscriptionHealth(dir = REPO_ROOT) {
175
+ try {
176
+ const uc = require('./update-channel');
177
+ const roster = uc.loadSubscriptions({ instanceDir: dir });
178
+ if (!roster.present) return null;
179
+ return uc.describeRoster(roster, { controlPlane: true });
180
+ } catch {
181
+ return null; // never let the health probe break the divergence check it rides along with
182
+ }
183
+ }
184
+
163
185
  async function check({ base, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
164
186
  const target = base || arg('--url') || API_BASE;
165
187
  const local = coreVersionAt(null);
@@ -173,7 +195,9 @@ async function check({ base, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
173
195
  const unreleased = unreleasedSince(mainRef);
174
196
  const live = await fetchLive(target, timeoutMs);
175
197
 
176
- if (live.error) return { ok: false, undetermined: true, reason: `could not read ${live.url}: ${live.error}`, target, local, onMain, unreleased };
198
+ const subscriptions = subscriptionHealth();
199
+
200
+ if (live.error) return { ok: false, undetermined: true, reason: `could not read ${live.url}: ${live.error}`, target, local, onMain, unreleased, subscriptions };
177
201
 
178
202
  const behind = cmpSemver(live.coreVersion, onMain) < 0;
179
203
  const ahead = cmpSemver(live.coreVersion, onMain) > 0;
@@ -181,7 +205,7 @@ async function check({ base, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
181
205
 
182
206
  return {
183
207
  ok: !behind && !hasUnreleased,
184
- target, local, onMain, mainRef, unreleased, live, behind, ahead, hasUnreleased,
208
+ target, local, onMain, mainRef, unreleased, live, behind, ahead, hasUnreleased, subscriptions,
185
209
  };
186
210
  }
187
211
 
@@ -232,6 +256,13 @@ function report(r, { quiet = false } = {}) {
232
256
  if (r.ahead) {
233
257
  say(` note: live (${r.live.coreVersion}) is AHEAD of ${r.mainRef} (${r.onMain}) — this checkout is stale; fetch.`);
234
258
  }
259
+ // The watcher's watcher: if this checkout carries a subscription roster and that roster is not
260
+ // healthy, the unattended upgrade is not going to close the gap above on its own.
261
+ if (r.subscriptions && r.subscriptions.level !== 'ok') {
262
+ say('');
263
+ say(` ${r.subscriptions.level === 'alarm' ? '⚠ AUTO-UPGRADE' : 'note: auto-upgrade'} — ${r.subscriptions.message}`);
264
+ if (r.behind) say(' So the gap above will NOT close by itself.');
265
+ }
235
266
  say('');
236
267
  say(' Merging to main deploys nothing by design. "shipped" means "merged" —');
237
268
  say(' it does not mean users can see it.');
@@ -317,4 +348,4 @@ if (require.main === module) {
317
348
  });
318
349
  }
319
350
 
320
- module.exports = { check, report, line, coreVersionAt, unreleasedSince, cmpSemver };
351
+ module.exports = { check, report, line, coreVersionAt, unreleasedSince, cmpSemver, subscriptionHealth };
@@ -1307,6 +1307,7 @@ const CHECKS = [
1307
1307
  require('./control-manifest.js').checkControlsExist, // Check 21 — task 1003131: goal-1000076 controls assert their own existence (the ADR 0022 assurance-of-assurance)
1308
1308
  require('./routine-schedule.js').checkRoutineCadencesScheduled, // Check 25 — task 1003209: a routine that declares a cadence must have a scheduler firing it AT that period (ADR 0188)
1309
1309
  require('./baseline-staleness.js').checkBaselineFloors, // Check 26 — task 1003210: a shrink-only baseline that never shrinks is unmanaged debt; a floor exempting one must say why
1310
+ require('./client-baseurl-guard.js').checkClientBaseUrl, // Check 27 — task 1003263: a page must not combine baseUrl:'' with a generated namespaced call (that pairing 404s every request; three pages shipped it, two of them dead in prod)
1310
1311
  ];
1311
1312
 
1312
1313
  function runAll() {