@bongos/core 1.21.166 → 1.21.168

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 (83) hide show
  1. package/.bongos-core.json +153 -73
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +13 -0
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/api/openapi.json +306 -3
  8. package/docs/api-reference.md +7 -3
  9. package/docs/copy-inventory.md +21 -20
  10. package/docs/copy-registry.json +34 -25
  11. package/docs/module-api-changelog.md +4 -0
  12. package/docs/modules-contract.md +1 -0
  13. package/docs/page-inventory.json +3 -0
  14. package/docs/page-readings.json +445 -445
  15. package/modules/autonomy/governor-docket.js +10 -5
  16. package/modules/builder-settings/builder-needs.js +29 -3
  17. package/modules/copy-desk/guarded-lines.js +89 -0
  18. package/modules/copy-desk/guarded-lines.json +14 -0
  19. package/modules/copy-desk/module.json +2 -2
  20. package/modules/copy-desk/page-status.js +77 -7
  21. package/modules/copy-desk/pages.js +4 -0
  22. package/modules/copy-desk/pollers/stuck-page-digest.js +34 -0
  23. package/modules/copy-desk/routes/copy-desk.js +8 -0
  24. package/modules/copy-desk/stuck-tweaks.js +79 -0
  25. package/modules/copy-desk/tests/copy_no_cms.mjs +6 -1
  26. package/modules/copy-desk/tests/copy_page_status.mjs +2 -2
  27. package/modules/government/asks-db.js +38 -2
  28. package/modules/government/catalog.js +1 -1
  29. package/modules/government/docket.js +20 -2
  30. package/modules/government/migrations/government_032_ask_flags.sql +35 -0
  31. package/modules/government/routes/government.js +43 -1
  32. package/modules/hall-ui/public/approval-queue-lib.js +4 -0
  33. package/modules/hall-ui/public/approval-queue.js +1 -0
  34. package/modules/hall-ui/public/brand-apply.js +3 -0
  35. package/modules/hall-ui/public/city-decide.js +642 -0
  36. package/modules/hall-ui/public/city-draw.js +14 -3
  37. package/modules/hall-ui/public/city-switchboard.js +141 -0
  38. package/modules/hall-ui/public/city.css +176 -10
  39. package/modules/hall-ui/public/city.html +8 -3
  40. package/modules/hall-ui/public/city.js +185 -38
  41. package/modules/hall-ui/public/sky-data.js +4 -1
  42. package/modules/hall-ui/public/tweak-editor-lib.js +21 -2
  43. package/modules/hall-ui/public/tweak-editor.css +2 -0
  44. package/modules/hall-ui/public/tweak-editor.js +3 -1
  45. package/modules/lifecycle/db-claims.js +20 -5
  46. package/modules/lifecycle/rebase-gate.js +78 -0
  47. package/modules/lifecycle/routes/claims.js +10 -2
  48. package/package-lock.json +2 -2
  49. package/package.json +1 -1
  50. package/release-notes.json +28 -0
  51. package/scripts/gds/ask.js +68 -1
  52. package/scripts/gds/claim.js +6 -1
  53. package/scripts/gds/copy-apply.js +2 -0
  54. package/src/bongos/module-promotion.js +219 -0
  55. package/src/bongos/route-rank-check.js +7 -0
  56. package/src/bongos/routes/me.js +11 -0
  57. package/src/bongos/routes/modules.js +46 -0
  58. package/src/module-api.js +1 -1
  59. package/src/platform-server.js +5 -0
  60. package/tests/claim_gate_ci_unblock.mjs +4 -0
  61. package/tests/claim_gate_rebase.mjs +56 -3
  62. package/tests/copy_desk_page_editor.mjs +1 -1
  63. package/tests/copy_desk_page_submit.mjs +42 -0
  64. package/tests/copy_guarded_lines.mjs +54 -0
  65. package/tests/copy_stuck_page_tweaks.mjs +118 -0
  66. package/tests/government_ask_flags.mjs +80 -0
  67. package/tests/government_seed.mjs +1 -0
  68. package/tests/government_treasury.mjs +1 -0
  69. package/tests/governor_docket.mjs +1 -1
  70. package/tests/hall_approval_queue.mjs +15 -0
  71. package/tests/hall_city.mjs +5 -4
  72. package/tests/hall_city_living.mjs +118 -12
  73. package/tests/hall_city_rewording.mjs +51 -0
  74. package/tests/hall_city_switchboard.mjs +63 -0
  75. package/tests/hall_gov_real_site.mjs +2 -2
  76. package/tests/hall_sky.mjs +18 -0
  77. package/tests/hall_street_deck.mjs +6 -4
  78. package/tests/hall_street_depth.mjs +1 -1
  79. package/tests/hall_street_desk.mjs +5 -3
  80. package/tests/hall_tweak_editor.mjs +26 -0
  81. package/tests/module_promotion.mjs +290 -0
  82. package/tests/rebase_gate_set_aside.mjs +73 -0
  83. package/tests/reward_mode_surfacing.mjs +12 -0
@@ -10050,5 +10050,33 @@
10050
10050
  "id": "1004786",
10051
10051
  "text": "Settings no longer shows a broken image-generation key card on projects without the art tools. Before, builders were told their free art coverage had ended and to add a key, but the card could never load, so there was nowhere"
10052
10052
  }
10053
+ ],
10054
+ "1.21.167": [
10055
+ {
10056
+ "id": "1004784",
10057
+ "text": "The city's building cards now let you decide right there. Every waiting item shows its real answers inside the card, Tab flips through a building's items and the arrows flip buildings, and a number key picks then confirms. \"De"
10058
+ },
10059
+ {
10060
+ "id": "1004788",
10061
+ "text": "One stuck task no longer stops all your other work. If a finished task is waiting on something outside you, such as a safety guard on the server or a person who must land it, it is set aside: it no longer blocks new claims, it"
10062
+ },
10063
+ {
10064
+ "id": "1004776",
10065
+ "text": "The page tweak editor now tells the artist, while they write, when a line carries a promise the product must keep, such as the Account page saying Private hides the page from everyone including its owner. The line shows what i"
10066
+ },
10067
+ {
10068
+ "id": "1003804",
10069
+ "text": "New module versions now move from testers to everyone on their own: after 7 days, or after 2 days once 5 testers installed them cleanly. Any tester error holds a version until a reviewer releases it."
10070
+ },
10071
+ {
10072
+ "id": "1004782",
10073
+ "text": "Goals no longer count abandoned work in their credit totals. Will goal drops by 241 credits of dropped tasks; across the project about 20,000 credits of abandoned estimates stop inflating goal sizes in the sky map and goal pag"
10074
+ }
10075
+ ],
10076
+ "1.21.168": [
10077
+ {
10078
+ "id": "1004777",
10079
+ "text": "A page tweak that sits too long now tells someone. If a round waits too long for its artist's review, that artist gets a line in Needs attention on Home. If it waits to be applied or to go live, builders who can apply it get t"
10080
+ }
10053
10081
  ]
10054
10082
  }
@@ -135,11 +135,68 @@ async function check(file, deps = {}) {
135
135
  return { code: v.ok ? 0 : 1, lines: verdictLines(v) };
136
136
  }
137
137
 
138
+ // ---------- the reword list (owner, 2026-10-23) ----------
139
+ // A reader pressed "?" on a city card: the matter's words did not make sense.
140
+ // `unclear` prints what waits for a rewrite (the flagged matters, or with
141
+ // --legacy every open matter that waits on a person and has no stored card
142
+ // yet), each with the words it has now; `put` stores the rewritten card
143
+ // (PUT /government/asks/:matter), which closes the matter's flag.
144
+ async function client(deps) {
145
+ if (deps && deps.client) return deps.client;
146
+ const lib = require('./cli-lib');
147
+ if (!lib.readSessionToken()) throw new Error('no Bongos session — run /builder-setup first');
148
+ return lib.cliClient();
149
+ }
150
+ function matterLines(m) {
151
+ const out = [`${m.id} [${m.building}]${m.flag && m.flag.times > 1 ? ` flagged ${m.flag.times}x` : ''}`];
152
+ out.push(` title: ${String(m.title || '').trim()}`);
153
+ if (m.detail) out.push(` detail: ${String(m.detail).replace(/\s+/g, ' ').trim().slice(0, 600)}`);
154
+ if (m.ask) out.push(` card: ${m.ask.question} (${(m.ask.options || []).map((o) => o.label).join(' / ')})`);
155
+ if (m.flag && m.flag.note) out.push(` why unclear: ${m.flag.note}`);
156
+ if (Array.isArray(m.acts) && m.acts.length) out.push(` acts: ${m.acts.map((a) => a.key + (Array.isArray(a.options) && a.options.length ? '(' + a.options.map((o) => o.key).join('|') + ')' : '')).join(', ')}`);
157
+ return out;
158
+ }
159
+ async function unclear({ legacy = false, json = false } = {}, deps = {}) {
160
+ const api = await client(deps);
161
+ let list;
162
+ if (legacy) {
163
+ const r = await api.request('GET', '/government/docket');
164
+ if (!r || !r.ok) return { code: 1, lines: [`ask: the docket could not be read (${r && r.status}).`] };
165
+ list = ((r.data && r.data.matters) || []).filter((m) => m && m.state === 'needs' && !m.ask)
166
+ .map((m) => ({ id: m.id, building: m.building, source: m.source, title: m.title, detail: m.detail || null, ask: null, acts: m.acts || [], flag: m.unclear || null }));
167
+ } else {
168
+ const r = await api.request('GET', '/government/asks/unclear');
169
+ if (!r || !r.ok) return { code: 1, lines: [`ask: the reword list could not be read (${r && r.status}).`] };
170
+ list = (r.data && r.data.matters) || [];
171
+ }
172
+ if (json) return { code: 0, lines: [JSON.stringify(list, null, 2)] };
173
+ if (!list.length) return { code: 0, lines: [legacy ? 'ask: every open matter already carries a card.' : 'ask: nothing on the reword list.'] };
174
+ const lines = [`${list.length} ${legacy ? 'open matter(s) with no card yet' : 'matter(s) flagged as unclear'}:`, ''];
175
+ list.forEach((m) => { lines.push(...matterLines(m), ''); });
176
+ lines.push('Write each one a card (node scripts/gds/ask.js template), check it, then: node scripts/gds/ask.js put <matter-id> <file>');
177
+ return { code: 0, lines };
178
+ }
179
+ async function put(matterId, file, deps = {}) {
180
+ let body;
181
+ try { body = readJson(file); } catch (e) { return { code: 2, lines: [`ask: could not read the card as JSON (${e.message}).`] }; }
182
+ const local = asks.checkAsk(askOf(body));
183
+ if (!local.ok) return { code: 1, lines: verdictLines({ ok: false, ask: null, errors: local.errors, where: 'local' }) };
184
+ const api = await client(deps);
185
+ const r = await api.request('PUT', '/government/asks/' + encodeURIComponent(matterId), { body: { ask: askOf(body) } });
186
+ if (r && r.ok && r.data && r.data.ok) return { code: 0, lines: [`ask: saved the card on ${matterId}; its "?" flag is closed.`, '', ...verdictLines({ ok: true, ask: r.data.ask, where: 'server' })] };
187
+ const err = r && r.data && r.data.error;
188
+ return { code: 1, lines: [`ask: ${matterId} was not saved (${r && r.status}${err ? ' ' + err.code : ''}). ${err && err.message ? err.message : ''}`.trim()] };
189
+ }
190
+
138
191
  const USAGE = [
139
192
  'usage:',
140
193
  ' node scripts/gds/ask.js template print a blank card to fill in',
141
194
  ' node scripts/gds/ask.js check <file|-> check a card; on a pass, print it as a person reads it',
142
195
  ' --local check with the rules here, without the server',
196
+ ' node scripts/gds/ask.js unclear the reword list: matters a reader flagged with "?"',
197
+ ' --legacy instead: every open matter on a person with no card yet',
198
+ ' --json as JSON',
199
+ ' node scripts/gds/ask.js put <matter> <file> store a rewritten card on a matter (closes its flag)',
143
200
  ].join('\n');
144
201
 
145
202
  async function main(argv = process.argv.slice(2)) {
@@ -153,6 +210,16 @@ async function main(argv = process.argv.slice(2)) {
153
210
  console.log(r.lines.join('\n'));
154
211
  return r.code;
155
212
  }
213
+ if (cmd === 'unclear') {
214
+ const r = await unclear({ legacy: argv.includes('--legacy'), json: argv.includes('--json') });
215
+ console.log(r.lines.join('\n'));
216
+ return r.code;
217
+ }
218
+ if (cmd === 'put' && argv[1] && argv[2]) {
219
+ const r = await put(argv[1], argv[2]);
220
+ console.log(r.lines.join('\n'));
221
+ return r.code;
222
+ }
156
223
  console.error(USAGE);
157
224
  return 2;
158
225
  }
@@ -161,4 +228,4 @@ if (require.main === module) {
161
228
  main().then((code) => { process.exitCode = code; }, (e) => { console.error(`ask: ${e && e.message}`); process.exitCode = 1; });
162
229
  }
163
230
 
164
- module.exports = { TEMPLATE, askOf, checkedAsk, postWithAsk, verdictLines, serverVerdict, check, main };
231
+ module.exports = { TEMPLATE, askOf, checkedAsk, postWithAsk, verdictLines, serverVerdict, check, unclear, put, main };
@@ -369,7 +369,10 @@ function claimFailureGuidance({ code, message, d }, r, taskId, ctx) {
369
369
  // posture) and this surface dropped it on the floor. Name the owed tasks, then
370
370
  // print the server's menu.
371
371
  if (code === 'REBASE_REQUIRED') {
372
- const owed = (d.rebaseTasks || d.rebase_tasks || []).slice(0, MAX_LISTED_TASKS);
372
+ // task 1004788: only the fixable ones gate; set-aside ones are listed apart.
373
+ const all = (d.rebaseTasks || d.rebase_tasks || []);
374
+ const owed = all.filter((t) => !t.waiting).slice(0, MAX_LISTED_TASKS);
375
+ const waiting = all.filter((t) => t.waiting).slice(0, MAX_LISTED_TASKS);
373
376
  lines.push(owed.length
374
377
  ? `Your queue is gated — ${owed.length} confirmed task(s) are waiting on your rebase:`
375
378
  : 'Your queue is gated — a confirmed task is waiting on your rebase.');
@@ -377,6 +380,8 @@ function claimFailureGuidance({ code, message, d }, r, taskId, ctx) {
377
380
  lines.push(` - task ${t.id}${t.title ? ` ${clip(t.title)}` : ''}`);
378
381
  if (t.reason) lines.push(` flagged: ${clip(t.reason)}`);
379
382
  }
383
+ if (waiting.length) lines.push(' Waiting, not on you (set aside; they land on their own):');
384
+ for (const t of waiting) lines.push(` - task ${t.id}${t.title ? ` ${clip(t.title)}` : ''} — ${clip(t.waits_on || t.reason || 'outside you')}`);
380
385
  lines.push(` ${clip(d.hint, 600) || 'Clear it with /strand-fix N, or rebase the branch and re-ship — or /builder-release it to give up on one.'}`);
381
386
  return { lines, exit: QUEUE_GATED_EXIT };
382
387
  }
@@ -598,6 +598,8 @@ async function batchMain({ taskId, batchFile, dryRun, pageId = null, now = () =>
598
598
  log(` - ${a.before}`);
599
599
  log(` + ${a.after}`);
600
600
  }
601
+ // task 1004776: the submit's guarded-line warnings, so whoever applies sees them too.
602
+ for (const w of batch.warnings || []) log(` ${w.key} WARNING: no longer says what it must: ${w.must_still_say}${w.guarded_by ? ` (guarded by ${w.guarded_by})` : ''}`);
601
603
  for (const x of result.refused) {
602
604
  log(` ${x.key} REFUSED (${x.code})${x.detail && x.detail.what_this_means ? `: ${x.detail.what_this_means}` : ''}`);
603
605
  log(` line: ${JSON.stringify(x.before)}`);
@@ -0,0 +1,219 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/module-promotion.js — the promotion gate: when a module version on
4
+ // the tester channel moves to general, and when it is held (task 1003804, ADR 0344
5
+ // D3; owner decisions 2026-10-23 recorded on the task).
6
+ //
7
+ // THE RULE, in the order it is applied:
8
+ // 1. A version only ever moves while its Security gate has passed and its own
9
+ // tests pass (ADR 0343). Tests pass = every test file passed (score 100), or
10
+ // the module declares no tests (`no_data` is not a fail). Anything else —
11
+ // failed, pending, not yet run — waits; it is never held for it.
12
+ // 2. HOLD: any tester reports a failed install, or any runtime error, on the
13
+ // version. A held version stays on tester until a Metic+ person releases it
14
+ // (`module.delist`, owner 2026-10-23) or a newer version replaces it.
15
+ // 3. EARLY: from day 2 on tester, 5 or more holders reported a clean install and
16
+ // nobody reported a problem.
17
+ // 4. SEVEN DAYS: on tester for 7 days. This applies whether or not any tester
18
+ // picked it up, so a module nobody volunteers for still gets updated — a
19
+ // version is only ever held on evidence, never for lack of testers.
20
+ //
21
+ // Every report on a tester-channel version comes from a tester, because only
22
+ // testers are handed one (task 1003805), so the reports are counted as they are.
23
+ //
24
+ // A move is a NEW store_module_version_channels row (core_276), never an update:
25
+ // set_by NULL when the sweep moves it, the person when a person releases it, and a
26
+ // short reason code. decidePromotion is the whole rule and is pure, so the tests
27
+ // drive it directly; sweepPromotions and releaseHeld are the two writers.
28
+
29
+ const DAY_MS = 24 * 60 * 60 * 1000;
30
+ const PROMOTE_AFTER_DAYS = 7;
31
+ const EARLY_AFTER_DAYS = 2;
32
+ const EARLY_CLEAN_INSTALLS = 5;
33
+ const SWEEP_LIMIT = 200;
34
+
35
+ const REASONS = Object.freeze({
36
+ AFTER_7_DAYS: 'promoted_after_7_days',
37
+ EARLY: 'promoted_early_clean_installs',
38
+ HELD: 'held_tester_errors',
39
+ RELEASED: 'released_by_person',
40
+ });
41
+
42
+ function poolOf(db) { return db || require('./pool').pool; }
43
+
44
+ // Did the ADR 0343 checks pass? signals: the current row per part.
45
+ function checksPass({ security, tests } = {}) {
46
+ if (!security || security.outcome !== 'passed') return false;
47
+ if (!tests) return false;
48
+ if (tests.outcome === 'no_data') return true;
49
+ return tests.outcome === 'scored' && Number(tests.score) === 100;
50
+ }
51
+
52
+ // The rule. evidence: { onTesterSince, held, security, tests, cleanInstalls,
53
+ // failedInstalls, runtimeErrors }. Returns { action: 'promote'|'hold'|'wait',
54
+ // reason, days_on_tester, ... } — the same object an author reads.
55
+ function decidePromotion(evidence, now = new Date()) {
56
+ const since = new Date(evidence.onTesterSince);
57
+ const days = Math.max(0, (now.getTime() - since.getTime()) / DAY_MS);
58
+ const base = {
59
+ days_on_tester: Math.floor(days),
60
+ clean_installs: evidence.cleanInstalls || 0,
61
+ failed_installs: evidence.failedInstalls || 0,
62
+ runtime_errors: evidence.runtimeErrors || 0,
63
+ checks_passed: checksPass(evidence),
64
+ general_on: new Date(since.getTime() + PROMOTE_AFTER_DAYS * DAY_MS).toISOString(),
65
+ };
66
+ if (evidence.held) return { action: 'wait', reason: 'held_until_released', ...base };
67
+ if (base.failed_installs > 0 || base.runtime_errors > 0) return { action: 'hold', reason: REASONS.HELD, ...base };
68
+ if (!base.checks_passed) return { action: 'wait', reason: 'checks_not_passed', ...base };
69
+ if (days >= PROMOTE_AFTER_DAYS) return { action: 'promote', reason: REASONS.AFTER_7_DAYS, ...base };
70
+ if (days >= EARLY_AFTER_DAYS && base.clean_installs >= EARLY_CLEAN_INSTALLS) {
71
+ return { action: 'promote', reason: REASONS.EARLY, ...base };
72
+ }
73
+ return { action: 'wait', reason: 'waiting_for_time_or_installs', ...base };
74
+ }
75
+
76
+ // The evidence for every version currently on tester (optionally one version),
77
+ // bounded. onTesterSince is when it reached tester (its first tester row since
78
+ // it was last off tester — in practice its publish), held is whether its current
79
+ // row is a hold.
80
+ async function readTesterEvidence({ db, versionId = null, limit = SWEEP_LIMIT } = {}) {
81
+ const pool = poolOf(db);
82
+ const { rows } = await pool.query(
83
+ // One probe per version into idx_store_module_version_channels_current (its
84
+ // newest row, LIMIT 1), never a DISTINCT ON over every version's whole move
85
+ // history: the work grows with versions, not with how often they moved.
86
+ `SELECT v.id AS version_id, v.module_key, v.version, cur.reason AS current_reason,
87
+ (SELECT min(t.set_at) FROM store_module_version_channels t
88
+ WHERE t.version_id = v.id AND t.channel = 'tester') AS on_tester_since,
89
+ (SELECT count(*) FROM module_install_reports r WHERE r.version_id = v.id AND r.ok)::int AS clean_installs,
90
+ (SELECT count(*) FROM module_install_reports r WHERE r.version_id = v.id AND NOT r.ok)::int AS failed_installs,
91
+ (SELECT coalesce(sum(e.error_count), 0) FROM module_runtime_error_counts e WHERE e.version_id = v.id)::int AS runtime_errors
92
+ FROM store_module_versions v
93
+ CROSS JOIN LATERAL (
94
+ SELECT c.channel, c.reason FROM store_module_version_channels c
95
+ WHERE c.version_id = v.id ORDER BY c.set_at DESC, c.id DESC LIMIT 1
96
+ ) cur
97
+ WHERE cur.channel = 'tester' AND ($1::bigint IS NULL OR v.id = $1)
98
+ ORDER BY v.id
99
+ LIMIT $2`, [versionId, limit]);
100
+ if (!rows.length) return [];
101
+ const { rows: signals } = await pool.query(
102
+ `SELECT DISTINCT ON (version_id, part) version_id, part, outcome, score
103
+ FROM module_assessment_signals
104
+ WHERE version_id = ANY($1) AND part IN ('security', 'tests')
105
+ ORDER BY version_id, part, measured_at DESC, id DESC`, [rows.map((r) => r.version_id)]);
106
+ const parts = new Map();
107
+ for (const s of signals) {
108
+ const k = String(s.version_id);
109
+ if (!parts.has(k)) parts.set(k, {});
110
+ parts.get(k)[s.part] = s;
111
+ }
112
+ return rows.map((r) => ({
113
+ versionId: r.version_id,
114
+ moduleKey: r.module_key,
115
+ version: r.version,
116
+ onTesterSince: r.on_tester_since,
117
+ currentReason: r.current_reason,
118
+ held: r.current_reason === REASONS.HELD,
119
+ cleanInstalls: r.clean_installs,
120
+ failedInstalls: r.failed_installs,
121
+ runtimeErrors: r.runtime_errors,
122
+ security: (parts.get(String(r.version_id)) || {}).security || null,
123
+ tests: (parts.get(String(r.version_id)) || {}).tests || null,
124
+ }));
125
+ }
126
+
127
+ // Insert the move only if the version's current row is still the one we read, so
128
+ // a sweep racing a release (or another sweep) writes at most one move.
129
+ async function insertMove(pool, { versionId, channel, reason, setBy = null, fromReason }) {
130
+ const { rowCount } = await pool.query(
131
+ `INSERT INTO store_module_version_channels (version_id, channel, reason, set_by)
132
+ SELECT $1, $2, $3, $4
133
+ WHERE (SELECT c.reason FROM store_module_version_channels c
134
+ WHERE c.version_id = $1 ORDER BY c.set_at DESC, c.id DESC LIMIT 1) = $5`,
135
+ [versionId, channel, reason, setBy, fromReason]);
136
+ return rowCount === 1;
137
+ }
138
+
139
+ // One pass: promote or hold every version on tester the rule says to. Returns
140
+ // { promoted: [...], held: [...] }. Per-version failures are isolated.
141
+ async function sweepPromotions({ db, now = new Date(), log = null } = {}) {
142
+ const pool = poolOf(db);
143
+ const out = { promoted: [], held: [] };
144
+ const evidence = await readTesterEvidence({ db: pool });
145
+ for (const e of evidence) {
146
+ const d = decidePromotion(e, now);
147
+ if (d.action === 'wait') continue;
148
+ try {
149
+ const channel = d.action === 'promote' ? 'general' : 'tester';
150
+ const moved = await insertMove(pool, { versionId: e.versionId, channel, reason: d.reason, fromReason: e.currentReason });
151
+ if (!moved) continue;
152
+ const entry = { version_id: String(e.versionId), module_key: e.moduleKey, version: e.version, reason: d.reason };
153
+ (d.action === 'promote' ? out.promoted : out.held).push(entry);
154
+ if (log) log.info(entry, `${e.moduleKey} ${e.version}: ${d.reason}`);
155
+ } catch (err) {
156
+ if (log) log.warn({ versionId: e.versionId, err: err.message }, 'promotion move failed; the next sweep retries');
157
+ }
158
+ }
159
+ return out;
160
+ }
161
+
162
+ // Where one version stands against the rule, for its author. null when the
163
+ // version has no channel row.
164
+ async function readStanding(versionId, { db, now = new Date() } = {}) {
165
+ const pool = poolOf(db);
166
+ const { rows } = await pool.query(
167
+ `SELECT channel, reason, set_by, set_at FROM store_module_version_channels
168
+ WHERE version_id = $1 ORDER BY set_at DESC, id DESC LIMIT 1`, [versionId]);
169
+ if (!rows.length) return null;
170
+ const cur = rows[0];
171
+ const standing = {
172
+ channel: cur.channel,
173
+ reason: cur.reason,
174
+ set_at: cur.set_at,
175
+ moved_by: cur.set_by == null ? 'system' : String(cur.set_by),
176
+ rule: RULE_TEXT,
177
+ };
178
+ if (cur.channel !== 'tester') return standing;
179
+ const [e] = await readTesterEvidence({ db: pool, versionId });
180
+ if (!e) return standing;
181
+ return { ...standing, next: decidePromotion(e, now) };
182
+ }
183
+
184
+ // A Metic+ person releases a HELD version to general. Only a held version: a
185
+ // version still waiting for its time is the rule's to move, not a person's.
186
+ async function releaseHeld(versionId, builderId, { db } = {}) {
187
+ const pool = poolOf(db);
188
+ const moved = await insertMove(pool, {
189
+ versionId, channel: 'general', reason: REASONS.RELEASED, setBy: builderId, fromReason: REASONS.HELD,
190
+ });
191
+ if (!moved) {
192
+ return { ok: false, code: 'not_held', status: 409, message: 'Only a version held on the tester channel can be released by a person. Others move on their own after seven days.' };
193
+ }
194
+ return { ok: true, channel: 'general', reason: REASONS.RELEASED };
195
+ }
196
+
197
+ const RULE_TEXT = `A new version goes to testers first. It reaches everyone after ${PROMOTE_AFTER_DAYS} days, `
198
+ + `or after ${EARLY_AFTER_DAYS} days once ${EARLY_CLEAN_INSTALLS} or more testers installed it cleanly, `
199
+ + 'as long as its security check and its own tests pass. Any failed tester install or runtime error '
200
+ + 'holds it on testers until a reviewer releases it.';
201
+
202
+ // The in-process sweep, started by src/platform-server.js after listen (never by
203
+ // a test). Hourly is plenty for a rule counted in days. MODULE_PROMOTION_DISABLED=1
204
+ // switches it off.
205
+ const SWEEP_MS = 60 * 60 * 1000;
206
+ let timer = null;
207
+
208
+ function start({ env = process.env, log = require('./logger').logger.child({ src: 'module-promotion' }) } = {}) {
209
+ if (env.MODULE_PROMOTION_DISABLED === '1' || timer) return false;
210
+ const run = () => sweepPromotions({ log }).catch((err) => log.warn({ err: err.message }, 'promotion sweep failed'));
211
+ setTimeout(run, 30_000).unref();
212
+ timer = setInterval(run, SWEEP_MS);
213
+ timer.unref();
214
+ return true;
215
+ }
216
+
217
+ module.exports = {
218
+ PROMOTE_AFTER_DAYS, EARLY_AFTER_DAYS, EARLY_CLEAN_INSTALLS, decidePromotion, sweepPromotions, readStanding, releaseHeld, start,
219
+ };
@@ -245,6 +245,9 @@ const EXPECTED_RANKS = {
245
245
  // Delisting a store module (task 1003797, ADR 0338 D2) stops every new install of
246
246
  // it for every instance, so it is pinned to its own atom.
247
247
  'POST /store/modules/:key/delist': 'perm:module.delist',
248
+ // Releasing a version the promotion gate held on tester (task 1003804) hands it to
249
+ // every project; the owner put it on the delist atom (2026-10-23), so it is pinned.
250
+ 'POST /store/modules/:key/versions/:version/release': 'perm:module.delist',
248
251
  };
249
252
 
250
253
  // The authz-core route pins (ADR 0174). Paths and the gating atom were renamed
@@ -320,6 +323,10 @@ const GOVERNMENT_PINS = {
320
323
  'POST /government/asks/check': 'perm:board.vote.cast',
321
324
  'PUT /government/asks/:matterId': 'perm:board.vote.cast',
322
325
  'POST /government/asks/:matterId/pass': 'perm:board.vote.cast',
326
+ // the reword list (owner 2026-10-23): a reader flags a card it cannot follow,
327
+ // a session reads the open flags; both under the docket's own atom
328
+ 'POST /government/asks/:matterId/unclear': 'perm:board.vote.cast',
329
+ 'GET /government/asks/unclear': 'perm:board.vote.cast',
323
330
  // task 1004564: a proposer withdraws their own open sitting before any vote —
324
331
  // the proposer's half of putting one up, so the same atom. The proposer-only
325
332
  // and no-votes walls are in withdrawItem (board-withdraw.js) and its write.
@@ -184,6 +184,15 @@ module.exports = function buildMeRouter() {
184
184
  // same GET /me -> Conductor path, deliberately not a second mechanism.
185
185
  // default-on, so present in prod; null when the module is off.
186
186
  const specialities = seams.resolveOptional('specialities');
187
+ // task 1004777: stuck page tweaks this builder should be told about, through
188
+ // the copy-desk PORT (core never imports the module, ADR 0093). Started here so
189
+ // it runs beside the fan-out below; [] when copy-desk is off or the read fails.
190
+ const stuckPageTweaksP = (async () => {
191
+ try {
192
+ const port = seams.resolveOptional('copy-desk.stuckPageTweaksFor');
193
+ return typeof port === 'function' ? await port(req.builder.id, req.builder.rank) : [];
194
+ } catch (_) { return []; }
195
+ })();
187
196
  const [
188
197
  builder, activeClaims, estVsActual, unlocked, lockedWithProgress, streakInfo,
189
198
  recentGrades, builderAvg, onboardingRow, shipCount, recentKarma, karma30d, discordLink, cliAuthed,
@@ -593,6 +602,8 @@ module.exports = function buildMeRouter() {
593
602
  openTaskRecommendations: taskRecommendationCount,
594
603
  // task 1003178 (C08): open mingle introductions waiting on this builder.
595
604
  openMingleIntroductions: mingleIntroductionCount,
605
+ // task 1004777: one Needs-attention line per stuck page tweak.
606
+ stuckPageTweaks: await stuckPageTweaksP,
596
607
  }) : [],
597
608
  };
598
609
  // Module→core contribution seam (BV1.R42, ADR 0083). Let each enabled module
@@ -112,6 +112,7 @@ const log = require('../logger').logger.child({ src: 'modules' });
112
112
  const { listOverrides, setOverride } = require('../module-overrides');
113
113
  const { listCatalog, SORT_PARTS } = require('../module-listing');
114
114
  const { readTesting, setTester, setGeneralOnly } = require('../module-testing');
115
+ const { readStanding, releaseHeld } = require('../module-promotion');
115
116
 
116
117
  // The sentence for a toggle that waits on a restart (task 1004463). 'routes' are the
117
118
  // module's pages, 'pollers' its background jobs. PURE.
@@ -578,6 +579,51 @@ module.exports = function buildModulesRouter() {
578
579
  res.json({ ok: true, module_key: key, status: r.module.status, delisted_at: r.module.delisted_at });
579
580
  }));
580
581
 
582
+ // GET /api/bongos/store/modules/:key/versions/:version/channel — where a version
583
+ // stands on the promotion gate (task 1003804, ADR 0344 D3): its current channel,
584
+ // who moved it there and why, the rule in plain words, and on tester the numbers
585
+ // the rule reads and what it is waiting for. Any signed-in builder may read it, so
586
+ // an author is never left guessing why a version has not reached everyone.
587
+ router.get('/store/modules/:key/versions/:version/channel', auth.requireBuilder,
588
+ asyncHandler('GET /store/modules/:key/versions/:version/channel', async (req, res) => {
589
+ const { key, version } = req.params;
590
+ if (!KEY_RE.test(key) || !parseVersion(version)) {
591
+ return res.fail('bad_request', { status: 400, message: 'A module key is lowercase kebab-case and a version is exact X.Y.Z.' });
592
+ }
593
+ const r = await resolveReadable(key, version);
594
+ if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
595
+ const standing = await readStanding(r.version.id);
596
+ if (!standing) return res.fail('version_has_no_channel', { status: 404, message: `${key} ${version} has no release channel recorded.` });
597
+ res.set('Cache-Control', 'no-store');
598
+ res.json({ module_key: key, version, ...standing });
599
+ }));
600
+
601
+ // POST /api/bongos/store/modules/:key/versions/:version/release — a Metic+ person
602
+ // releases a version the promotion gate HELD on tester (a tester reported a failed
603
+ // install or errors) to general (task 1003804). Gated on module.delist: the owner
604
+ // ruled (2026-10-23) the people who may take a module out of the store are the ones
605
+ // who may let a held version out, with no new permission. Only a held version: one
606
+ // still waiting for its time is the rule's to move. A reason is required; the audit
607
+ // middleware records it with who released it, and set_by on the new channel row.
608
+ router.post('/store/modules/:key/versions/:version/release', auth.requireBuilder, auth.requirePermission('module.delist'),
609
+ asyncHandler('POST /store/modules/:key/versions/:version/release', async (req, res) => {
610
+ if (validateOrRespond(req, res, { reason: { type: 'string', maxLength: 2000 } })) return;
611
+ const { key, version } = req.params;
612
+ if (!KEY_RE.test(key) || !parseVersion(version)) {
613
+ return res.fail('bad_request', { status: 400, message: 'A module key is lowercase kebab-case and a version is exact X.Y.Z.' });
614
+ }
615
+ const reason = String(req.body?.reason || '').trim();
616
+ if (!reason) {
617
+ return res.fail('reason_required', { status: 400, message: 'Say why this held version is safe for everyone, in reason.' });
618
+ }
619
+ const r = await resolveReadable(key, version);
620
+ if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
621
+ const out = await releaseHeld(r.version.id, req.builder.id);
622
+ if (!out.ok) return res.fail(out.code, { status: out.status, message: out.message });
623
+ log.info({ versionId: r.version.id, builderId: req.builder.id }, `${key} ${version} released from hold to general`);
624
+ res.json({ ok: true, module_key: key, version, channel: out.channel, reason: out.reason });
625
+ }));
626
+
581
627
  // GET /api/bongos/store/entitlements and /store/modules/:key/entitlement — the
582
628
  // read side of the entitlement record (task 1003813, ADR 0338 D1). Own-scoped
583
629
  // like /me/sessions: the holder is ALWAYS the caller (holder_kind 'builder',
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.166'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.168'; // 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');
@@ -420,6 +420,11 @@ if (require.main === module) {
420
420
  // starts the pollers never writes the tally or spawns the sender.
421
421
  try { startErrorTally(); startErrorReports(); }
422
422
  catch (e) { logger.warn({ err: e.message }, 'module error tally did not start; module error counts are not kept'); }
423
+ // The store's promotion gate (task 1003804): an hourly sweep moves tester-channel
424
+ // versions to general, or holds them, by ADR 0344 D3's rule. Here, not in
425
+ // startBackgroundServices(), so a test that starts the pollers writes no channel row.
426
+ try { const { start: startPromotionSweep } = require('./bongos/module-promotion'); startPromotionSweep(); }
427
+ catch (e) { logger.warn({ err: e.message }, 'module promotion sweep did not start; tester versions will not move to general'); }
423
428
  });
424
429
  })().catch((err) => {
425
430
  // THE AWAIT ABOVE MUST NOT SOFTEN A FATAL BOOT (task 1002857, caught by
@@ -13,6 +13,10 @@ import { strict as assert } from 'node:assert';
13
13
  import { createRequire } from 'node:module';
14
14
 
15
15
  const require = createRequire(import.meta.url);
16
+ // task 1004788: fixable strands gate only at REBASE_GATE_LIMIT (default 3). This
17
+ // suite's fake builder owes ONE, and what it pins is the P1-infra exemption from
18
+ // a gate that IS shut, so the limit is set to 1 to keep the gate shut.
19
+ process.env.REBASE_GATE_LIMIT = '1';
16
20
 
17
21
  let passed = 0;
18
22
  let failed = 0;
@@ -14,6 +14,9 @@ import { strict as assert } from 'node:assert';
14
14
  const require = createRequire(import.meta.url);
15
15
  const { Pool } = require('pg');
16
16
 
17
+ // Cases 1-7 predate task 1004788 and pin the gate's original single-strand shape,
18
+ // so they run with the limit at 1; cases 8-10 move it back to the default (3).
19
+ process.env.REBASE_GATE_LIMIT = '1';
17
20
  const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
18
21
  try {
19
22
  await pool.query('SELECT 1');
@@ -32,13 +35,16 @@ const VER = 'ZZZ-CG', WORKER = 90000201, STALLED = 90000201, FRESH = 90000202;
32
35
  const AGED = 90000203, FRESH2 = 90000204, AGED_OTHER = 90000205;
33
36
  // task 1004370 — the CI-unblock exemption.
34
37
  const CIFIX = 90000206;
38
+ // task 1004788 — the set-aside and the limit.
39
+ const GUARDED = 90000207, FRESH3 = 90000208, FIX1 = 90000209, FIX2 = 90000210, FIX3 = 90000211;
40
+ const ALL_IDS = [90000201, 90000202, 90000203, 90000204, 90000205, 90000206, 90000207, 90000208, 90000209, 90000210, 90000211];
35
41
  const q = (sql, params) => pool.query(sql, params).then((r) => r.rows);
36
42
 
37
43
  async function cleanup() {
38
44
  await q(`DELETE FROM builder_achievements WHERE builder_id=$1`, [WORKER]).catch(() => {});
39
- await q(`DELETE FROM credit_log WHERE builder_id=$1 OR task_id = ANY($2::bigint[])`, [WORKER, [STALLED, FRESH, AGED, FRESH2, AGED_OTHER, CIFIX]]).catch(() => {});
40
- await q(`DELETE FROM claims WHERE task_id = ANY($1::bigint[])`, [[STALLED, FRESH, AGED, FRESH2, AGED_OTHER, CIFIX]]).catch(() => {});
41
- await q(`DELETE FROM tasks WHERE id = ANY($1::bigint[])`, [[STALLED, FRESH, AGED, FRESH2, AGED_OTHER, CIFIX]]).catch(() => {});
45
+ await q(`DELETE FROM credit_log WHERE builder_id=$1 OR task_id = ANY($2::bigint[])`, [WORKER, ALL_IDS]).catch(() => {});
46
+ await q(`DELETE FROM claims WHERE task_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
47
+ await q(`DELETE FROM tasks WHERE id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
42
48
  await q(`DELETE FROM builders WHERE id=$1`, [WORKER]).catch(() => {});
43
49
  await q(`DELETE FROM versions WHERE id=$1`, [VER]).catch(() => {});
44
50
  }
@@ -151,6 +157,53 @@ try {
151
157
  ok((await db.needsRebaseTasksForBuilder(String(WORKER))).some((t) => Number(t.id) === AGED_OTHER),
152
158
  'and the owed rebase is still reported: the exemption lets one claim through, it clears nothing');
153
159
 
160
+ // ---------------------------------------------------------------------------
161
+ // task 1004788 — one stuck land must not block every claim. A strand parked on
162
+ // a SERVER-SIDE guard is set aside (never gates, still reported); fixable
163
+ // strands gate only once REBASE_GATE_LIMIT pile up; and the batch dry run and
164
+ // the batch claim ask the same question as claimTask.
165
+ // ---------------------------------------------------------------------------
166
+ // 8) Limit still 1, AGED_OTHER (fixable) cleared: a server-guard strand alone does not gate.
167
+ await q(`UPDATE tasks SET status='shipped', needs_rebase=false WHERE id=$1`, [AGED_OTHER]);
168
+ await q(`UPDATE claims SET released_at = now() WHERE task_id = ANY($1::bigint[]) AND released_at IS NULL`, [[CIFIX]]);
169
+ await seedStrand(GUARDED, 'strand:branch_modifies_executed_code:.gitattributes', 1);
170
+ await q(`INSERT INTO tasks (id,version_id,title,status,credits_reward,kind) VALUES ($1,$2,'fresh3','ready',10,'feature')`, [FRESH3, VER]);
171
+ const claim3 = await db.claimTask({ taskId: String(FRESH3), builderId: String(WORKER), worktreeName: 'cg-fresh3' });
172
+ ok(claim3 && Number(claim3.task_id) === FRESH3, 'a confirmed task parked on a server-side guard does not refuse a new claim');
173
+ ok((await db.needsRebaseTasksForBuilder(String(WORKER))).some((t) => Number(t.id) === GUARDED),
174
+ 'the set-aside task is still reported as owed, never dropped');
175
+ await q(`UPDATE claims SET released_at = now() WHERE task_id = $1 AND released_at IS NULL`, [FRESH3]);
176
+ await q(`UPDATE tasks SET status='ready' WHERE id=$1`, [FRESH3]);
177
+
178
+ // 9) Default limit (3): one and two fixable strands do not gate; the third does.
179
+ delete process.env.REBASE_GATE_LIMIT;
180
+ await seedStrand(FIX1, 'strand:unresolvable_conflict:docs/x.json', 1);
181
+ await seedStrand(FIX2, 'strand:pr_not_landed', 1);
182
+ const v2 = await db.validateClaimBatch({ taskIds: [FRESH3], builderId: String(WORKER) });
183
+ ok(!JSON.stringify(v2).includes('REBASE_REQUIRED'), 'two fixable strands (+ one set aside) do not gate the dry run');
184
+ const claim4 = await db.claimTask({ taskId: String(FRESH3), builderId: String(WORKER), worktreeName: 'cg-fresh3b' });
185
+ ok(claim4 && Number(claim4.task_id) === FRESH3, 'two fixable strands do not refuse a claim');
186
+ await q(`UPDATE claims SET released_at = now() WHERE task_id = $1 AND released_at IS NULL`, [FRESH3]);
187
+ await q(`UPDATE tasks SET status='ready' WHERE id=$1`, [FRESH3]);
188
+
189
+ await seedStrand(FIX3, 'strand:pr_not_landed', 1);
190
+ let threw3 = null;
191
+ try { await db.claimTask({ taskId: String(FRESH3), builderId: String(WORKER), worktreeName: 'cg-fresh3c' }); } catch (e) { threw3 = e; }
192
+ ok(threw3 && threw3.code === 'REBASE_REQUIRED', 'fixable strands at the limit still refuse');
193
+ const named = (threw3.rebaseTasks || []);
194
+ ok(named.filter((t) => !t.waiting).map((t) => Number(t.id)).sort().join() === [FIX1, FIX2, FIX3].sort().join(),
195
+ 'the refusal names exactly the fixable tasks as gating');
196
+ ok(named.some((t) => t.waiting && Number(t.id) === GUARDED && /server-side guard/.test(t.waits_on)),
197
+ 'and lists the set-aside one as waiting, with what it waits on');
198
+
199
+ // 10) The batch dry run and the batch claim agree with claimTask.
200
+ const v3 = await db.validateClaimBatch({ taskIds: [FRESH3], builderId: String(WORKER) });
201
+ ok(JSON.stringify(v3).includes('REBASE_REQUIRED'), 'the batch dry run refuses exactly when claimTask does');
202
+ let batchOut = null, batchErr = null;
203
+ try { batchOut = await db.claimTasksBatch({ taskIds: [FRESH3], builderId: String(WORKER), worktreePrefix: 'cg-b' }); } catch (e) { batchErr = e; }
204
+ ok(JSON.stringify(batchOut || batchErr).includes('REBASE_REQUIRED'), 'the batch claim refuses too');
205
+ ok((await q(`SELECT status FROM tasks WHERE id=$1`, [FRESH3]))[0].status === 'ready', 'and claims nothing');
206
+
154
207
  console.log(`\nclaim_gate_rebase (ADR 0120 part 3): ${pass} passed, 0 failed`);
155
208
  } catch (e) {
156
209
  console.error('claim_gate_rebase.mjs FAILED:', e.stack || e.message);
@@ -104,7 +104,7 @@ try {
104
104
  ['L0004', 'greeting', 'Your desk is quiet today', 'engineer'],
105
105
  ['L0005', 'footer', 'Resume where you left off', null],
106
106
  ]);
107
- assert.deepEqual(r.body.notes, { shared: 'changes this on every page', engineer: 'goes to an engineer' });
107
+ assert.deepEqual(r.body.notes, { shared: 'changes this on every page', engineer: 'goes to an engineer', guarded: 'must still say' });
108
108
  assert.equal(r.body.round, null);
109
109
  assert.equal(r.body.draft, null);
110
110
  assert.deepEqual(r.body.counter, { changed: 0, total: 5 });