@bongos/core 1.21.167 → 1.21.169

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 +157 -72
  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 +308 -5
  8. package/docs/api-reference.md +7 -3
  9. package/docs/architecture.md +2 -0
  10. package/docs/copy-inventory.md +21 -20
  11. package/docs/copy-registry.json +34 -25
  12. package/docs/module-api-changelog.md +5 -1
  13. package/docs/modules-contract.md +2 -1
  14. package/docs/page-inventory.json +3 -0
  15. package/docs/page-readings.json +430 -430
  16. package/modules/autonomy/governor-docket.js +10 -5
  17. package/modules/builder-settings/builder-needs.js +29 -3
  18. package/modules/copy-desk/guarded-lines.js +89 -0
  19. package/modules/copy-desk/guarded-lines.json +14 -0
  20. package/modules/copy-desk/module.json +2 -2
  21. package/modules/copy-desk/page-status.js +77 -7
  22. package/modules/copy-desk/pages.js +4 -0
  23. package/modules/copy-desk/pollers/stuck-page-digest.js +34 -0
  24. package/modules/copy-desk/routes/copy-desk.js +8 -0
  25. package/modules/copy-desk/stuck-tweaks.js +79 -0
  26. package/modules/copy-desk/tests/copy_no_cms.mjs +6 -1
  27. package/modules/copy-desk/tests/copy_page_status.mjs +2 -2
  28. package/modules/government/asks-db.js +38 -2
  29. package/modules/government/catalog.js +1 -1
  30. package/modules/government/docket.js +20 -2
  31. package/modules/government/migrations/government_032_ask_flags.sql +35 -0
  32. package/modules/government/routes/government.js +43 -1
  33. package/modules/hall-ui/public/approval-queue-lib.js +4 -0
  34. package/modules/hall-ui/public/approval-queue.js +1 -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/tweak-editor-lib.js +21 -2
  42. package/modules/hall-ui/public/tweak-editor.css +2 -0
  43. package/modules/hall-ui/public/tweak-editor.js +3 -1
  44. package/modules/lifecycle/db-claims.js +20 -5
  45. package/modules/lifecycle/rebase-gate.js +78 -0
  46. package/modules/lifecycle/routes/claims.js +10 -2
  47. package/package-lock.json +2 -2
  48. package/package.json +1 -1
  49. package/release-notes.json +28 -0
  50. package/scripts/gds/ask.js +68 -1
  51. package/scripts/gds/claim.js +6 -1
  52. package/scripts/gds/copy-apply.js +2 -0
  53. package/src/bongos/module-promotion.js +219 -0
  54. package/src/bongos/module-store.js +47 -9
  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 +72 -10
  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_street_deck.mjs +6 -4
  77. package/tests/hall_street_depth.mjs +1 -1
  78. package/tests/hall_street_desk.mjs +5 -3
  79. package/tests/hall_tweak_editor.mjs +26 -0
  80. package/tests/module_channel_install.mjs +112 -0
  81. package/tests/module_promotion.mjs +290 -0
  82. package/tests/module_upgrade_updates.mjs +2 -2
  83. package/tests/rebase_gate_set_aside.mjs +73 -0
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.167'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.169'; // 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 });
@@ -318,6 +318,48 @@ try {
318
318
  assert.match(eng.description, /take it out/);
319
319
  assert.deepEqual([eng.status, eng.discipline], ['backlog', 'engineer']);
320
320
  });
321
+ // Task 1004776: a line that must keep its meaning. The real editor read marks
322
+ // it and says what it must still say; the real submit WARNS (never refuses)
323
+ // when the new text drops it, and the warning rides in the frozen batch to the
324
+ // Approval queue.
325
+ await test('a guarded line: the editor read marks it, the submit warns and still submits, the batch carries it', async () => {
326
+ const guarded = require('../modules/copy-desk/guarded-lines.js');
327
+ guarded._setList({ lines: [{
328
+ page_id: 'status:explore', file: 'modules/status-ui/public/explore.html',
329
+ must_contain: ['hidden from everyone'], must_still_say: 'A private project is hidden from everyone.', guarded_by: 'tests/x.mjs',
330
+ }] });
331
+ try {
332
+ const EXPLORE = '/copy-desk/pages/status:explore';
333
+ const LINES = [
334
+ L('L0001', 'list', 'Explore projects', 'placed', { file: 'modules/status-ui/public/explore.html', line: 3, string_id: 'ex0001' }),
335
+ L('L0002', 'list', 'A private project is hidden from everyone.', 'placed', { file: 'modules/status-ui/public/explore.html', line: 4, string_id: 'ex0002' }),
336
+ ];
337
+ const r0 = readingsWith({ hash: 'studio-h1', lines: STUDIO_LINES }, { hash: 'index-h2', lines: INDEX_LINES });
338
+ r0.pages.push({ id: 'status:explore', reading_hash: 'ex-h1', lines: LINES });
339
+ writeReadings(r0);
340
+ assert.equal((await call('POST', `${EXPLORE}/claim`, ALICE)).status, 201);
341
+
342
+ const read = await call('GET', `${EXPLORE}/draft`, ALICE);
343
+ assert.equal(read.status, 200, JSON.stringify(read.body));
344
+ const [plain, held] = read.body.reading.lines;
345
+ assert.equal(plain.note, null);
346
+ assert.equal(held.note, 'guarded', 'a third note kind beside shared and engineer');
347
+ assert.equal(held.must_still_say, 'A private project is hidden from everyone.');
348
+ assert.equal(read.body.notes.guarded, 'must still say');
349
+
350
+ const saved = await call('PUT', `${EXPLORE}/draft`, ALICE, { reading_hash: 'ex-h1', lines: [{ key: 'L0001', after: 'Explore' }, { key: 'L0002', after: 'you are a ninja.' }] });
351
+ assert.equal(saved.status, 200, JSON.stringify(saved.body));
352
+ const r = await call('POST', `${EXPLORE}/submit`, ALICE);
353
+ assert.equal(r.status, 200, 'warned, not refused');
354
+ assert.equal(r.body.outcome, 'submitted');
355
+ assert.deepEqual(r.body.warnings, [{ key: 'L0002', must_still_say: 'A private project is hidden from everyone.', missing: ['hidden from everyone'], guarded_by: 'tests/x.mjs' }]);
356
+ const round = db.tasks.find((t) => t.source_ref === 'page-tweak/status:explore/r1');
357
+ const batch = pages.parseBatchBlock(round.description).batch;
358
+ assert.deepEqual(batch.warnings.map((w) => w.key), ['L0002'], 'the warning rides in the frozen batch');
359
+ } finally {
360
+ guarded._setList(null);
361
+ }
362
+ });
321
363
  } finally {
322
364
  server.close();
323
365
  pageData._setRoot(null);
@@ -0,0 +1,54 @@
1
+ // tests/copy_guarded_lines.mjs — the guarded-lines list stays honest (task 1004776).
2
+ //
3
+ // modules/copy-desk/guarded-lines.json names page lines that must keep their
4
+ // meaning. Every entry must still find exactly ONE line on its page in the
5
+ // committed docs/page-readings.json; a rewrite that drops the line (or a phrase
6
+ // that now matches two lines) reds here, so the list cannot rot into guarding
7
+ // nothing. Plus the pure warning rule.
8
+ //
9
+ // Run: node tests/copy_guarded_lines.mjs
10
+
11
+ import { strict as assert } from 'node:assert';
12
+ import { createRequire } from 'node:module';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { makeRunner } from './helpers.mjs';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ const guarded = require('../modules/copy-desk/guarded-lines.js');
21
+ const { test, summary } = makeRunner();
22
+ const readings = JSON.parse(fs.readFileSync(path.join(ROOT, 'docs/page-readings.json'), 'utf8'));
23
+
24
+ await test('every guarded line still exists, exactly once, in the page readings', () => {
25
+ const list = guarded.loadList();
26
+ assert.ok(list.lines.length >= 1, 'seeded with the Account page Private line');
27
+ assert.deepEqual(guarded.checkList(readings), []);
28
+ });
29
+
30
+ await test('each entry names the test that guards its meaning, and that test exists', () => {
31
+ for (const e of guarded.loadList().lines) {
32
+ assert.ok(e.guarded_by && fs.existsSync(path.join(ROOT, e.guarded_by)), `${e.page_id}: guarded_by ${e.guarded_by}`);
33
+ }
34
+ });
35
+
36
+ await test('the honesty check reds a line that is gone, and one that matches twice', () => {
37
+ const page = { id: 'landing:x', lines: [{ key: 'L1', text: 'Hidden from everyone.', file: 'a.html' }, { key: 'L2', text: 'Also hidden from everyone!', file: 'a.html' }] };
38
+ const gone = { lines: [{ page_id: 'landing:x', file: 'a.html', must_contain: ['never shown'], must_still_say: 's' }] };
39
+ const twice = { lines: [{ page_id: 'landing:x', file: 'a.html', must_contain: ['hidden from everyone'], must_still_say: 's' }] };
40
+ assert.match(guarded.checkList({ pages: [page] }, gone)[0].problem, /matches 0/);
41
+ assert.match(guarded.checkList({ pages: [page] }, twice)[0].problem, /matches 2/);
42
+ assert.match(guarded.checkList({ pages: [] }, gone)[0].problem, /no such page/);
43
+ });
44
+
45
+ await test('a submit is warned when a guarded line drops a phrase or is removed, not when it keeps them', () => {
46
+ const list = { lines: [{ page_id: 'landing:account', file: 'a.html', must_contain: ['hides the page from everyone', 'including you'], must_still_say: 'Private hides it from everyone, you included.' }] };
47
+ const readingPage = { lines: [{ key: 'L0049', text: 'Private hides the page from everyone — including you.', file: 'a.html' }, { key: 'L0050', text: 'Other', file: 'a.html' }] };
48
+ const w = guarded.guardWarnings({ pageId: 'landing:account', readingPage, lines: [{ key: 'L0049', after: 'you are a ninja.' }, { key: 'L0050', after: 'x' }] }, list);
49
+ assert.deepEqual(w.map((x) => [x.key, x.missing]), [['L0049', ['hides the page from everyone', 'including you']]]);
50
+ assert.deepEqual(guarded.guardWarnings({ pageId: 'landing:account', readingPage, lines: [{ key: 'L0049', after: 'Ninja mode: Private HIDES THE PAGE FROM EVERYONE, including you.' }] }, list), []);
51
+ assert.equal(guarded.guardWarnings({ pageId: 'landing:account', readingPage, lines: [{ key: 'L0049', remove: true }] }, list).length, 1);
52
+ });
53
+
54
+ summary();
@@ -0,0 +1,118 @@
1
+ // tests/copy_stuck_page_tweaks.mjs — a page tweak that sits too long tells
2
+ // someone (task 1004777): a Needs-attention line on Home for the person it waits
3
+ // on, and a once-a-day Discord digest. Driven through the REAL composer
4
+ // (page-status.js stuckPageTweaks, the Studio's in-flight list and stuck rule),
5
+ // the REAL needs roster (builder-needs.computeNeeds) and the REAL digest
6
+ // (stuck-tweaks.runDigest). A page that is not stuck produces nothing.
7
+ //
8
+ // Run: node tests/copy_stuck_page_tweaks.mjs
9
+
10
+ import { strict as assert } from 'node:assert';
11
+ import { createRequire } from 'node:module';
12
+ import { makeRunner } from './helpers.mjs';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const pages = require('../modules/copy-desk/pages.js');
16
+ const status = require('../modules/copy-desk/page-status.js');
17
+ const stuckTweaks = require('../modules/copy-desk/stuck-tweaks.js');
18
+ const needs = require('../modules/builder-settings/builder-needs.js');
19
+ const { test, summary } = makeRunner();
20
+
21
+ const NOW = new Date('2026-10-23T12:00:00.000Z');
22
+ const ago = (d) => new Date(NOW.getTime() - d * 86400000).toISOString();
23
+ const pg = (id, title = id) => ({ id, surface: id.split(':')[0], title, path: '/', files: [] });
24
+ const PAGES = [pg('landing:index', '{{productName}}: {{tagline}}'), pg('landing:account', 'Account'), pg('builders:studio', 'Studio'), pg('landing:projects', 'Projects')];
25
+ const INVENTORY = { surfaces: [{ id: 'landing' }, { id: 'builders' }], pages: PAGES };
26
+ const batch = (id) => pages.composeBatchBlock({ page_id: id, reading_hash: 'h', submitted_at: ago(9), lines: [] });
27
+ const round = (n, id, extra) => ({ id: String(n), source: 'page-tweak', source_ref: pages.roundSourceRef(id, 1), description: pages.roundTaskBody(pg(id), 1), artist_id: '7', ...extra });
28
+ const ARTIST = '7';
29
+ const TASKS = [
30
+ // submitted 26 days ago and never applied: stuck, waits on the appliers
31
+ round(1, 'landing:index', { status: 'ready', description: `${pages.roundTaskBody(pg('landing:index'), 1)}\n\n${batch('landing:index')}`, updated_at: ago(26) }),
32
+ // held for its artist 5 days: stuck, waits on the artist
33
+ round(2, 'landing:account', { status: 'completed', grade_passed: true, updated_at: ago(5) }),
34
+ // submitted yesterday: NOT stuck
35
+ round(3, 'builders:studio', { status: 'ready', description: `${pages.roundTaskBody(pg('builders:studio'), 1)}\n\n${batch('builders:studio')}`, updated_at: ago(1) }),
36
+ // being written for a month: a draft is never stuck
37
+ round(4, 'landing:projects', { status: 'active', holder_id: '7', holder_worktree: false, updated_at: ago(30) }),
38
+ ];
39
+ const stuck = () => status.stuckPageTweaks({ inventory: INVENTORY, tasks: TASKS, now: NOW });
40
+
41
+ await test('the composer: only stuck pages, each with where it sits, how long, and who it waits on', () => {
42
+ assert.deepEqual(stuck().map((s) => [s.page_id, s.state, s.days, s.waits_on]), [
43
+ ['landing:index', 'submitted', 26, 'appliers'],
44
+ ['landing:account', 'waiting_for_artist', 5, 'artist'],
45
+ ]);
46
+ });
47
+
48
+ await test('Home: the artist sees their own review; a Metic+ builder sees what waits to be applied', () => {
49
+ const forArtist = status.stuckPageTweaksFor({ builderId: ARTIST, rank: 'thetes', stuck: stuck() });
50
+ assert.deepEqual(forArtist.map((s) => s.page_id), ['landing:account']);
51
+ const forMetic = status.stuckPageTweaksFor({ builderId: '99', rank: 'metic', stuck: stuck() });
52
+ assert.deepEqual(forMetic.map((s) => s.page_id), ['landing:index']);
53
+ assert.deepEqual(status.stuckPageTweaksFor({ builderId: '99', rank: 'thetes', stuck: stuck() }), [], 'nobody else is told');
54
+ });
55
+
56
+ await test('the real needs roster: one Needs-attention line per page, where and how long, linking to the Studio', () => {
57
+ const r = needs.computeNeeds({ rank: 'archon', ownKeySet: true, stuckPageTweaks: status.stuckPageTweaksFor({ builderId: ARTIST, rank: 'archon', stuck: stuck() }) });
58
+ const lines = r.items.filter((n) => n.id.startsWith('page_tweak_stuck:'));
59
+ assert.deepEqual(lines.map((n) => [n.id, n.title, n.message, n.state, n.action.href]), [
60
+ ['page_tweak_stuck:landing:index', 'Page tweak stuck: landing:index', 'Submitted, waiting to be applied for 26 days.', 'action_needed', '/studio'],
61
+ ['page_tweak_stuck:landing:account', 'Page tweak stuck: Account', 'Waiting for your review for 5 days.', 'action_needed', '/studio'],
62
+ ]);
63
+ });
64
+
65
+ await test('a page that is not stuck produces nothing: no line, no digest', async () => {
66
+ const fresh = TASKS.filter((t) => t.id === '3' || t.id === '4');
67
+ const none = status.stuckPageTweaks({ inventory: INVENTORY, tasks: fresh, now: NOW });
68
+ assert.deepEqual(none, []);
69
+ assert.equal(needs.computeNeeds({ rank: 'archon', ownKeySet: true, stuckPageTweaks: none }).items.filter((n) => n.id.startsWith('page_tweak_stuck:')).length, 0);
70
+ const posts = [];
71
+ const r = await stuckTweaks.runDigest({ nowMs: NOW.getTime(), state: { lastDay: null }, post: async (m) => { posts.push(m); return { posted: true }; }, readStuckImpl: async () => none });
72
+ assert.deepEqual([r.posted, r.reason, posts.length], [false, 'nothing_stuck', 0]);
73
+ });
74
+
75
+ await test('the real digest: page names only, never copy, to the ship channel, at most once a day', async () => {
76
+ const posts = [];
77
+ const post = async (m) => { posts.push(m); return { posted: true }; };
78
+ const readStuckImpl = async ({ now }) => status.stuckPageTweaks({ inventory: INVENTORY, tasks: TASKS, now });
79
+ const state = { lastDay: null };
80
+ const r = await stuckTweaks.runDigest({ nowMs: NOW.getTime(), state, post, readStuckImpl });
81
+ assert.equal(r.posted, true);
82
+ assert.equal(posts[0].channel, 'ship_feed');
83
+ assert.equal(posts[0].text, [
84
+ 'Page tweaks stuck (2). Open the Studio to move them:',
85
+ '- landing:index: submitted, waiting to be applied, 26 days',
86
+ '- Account: waiting for your review, 5 days',
87
+ ].join('\n'));
88
+ const again = await stuckTweaks.runDigest({ nowMs: NOW.getTime() + 3600_000, state, post, readStuckImpl });
89
+ assert.deepEqual([again.posted, again.reason, posts.length], [false, 'not_due', 1], 'once a day');
90
+ const early = await stuckTweaks.runDigest({ nowMs: Date.parse('2026-10-24T03:00:00Z'), state, post, readStuckImpl });
91
+ assert.equal(early.reason, 'not_due', 'not before the digest hour');
92
+ const next = await stuckTweaks.runDigest({ nowMs: Date.parse('2026-10-24T10:00:00Z'), state, post, readStuckImpl });
93
+ assert.equal(next.posted, true, 'the next day posts again');
94
+ });
95
+
96
+ await test('the Home read is shared: one ledger read per minute across every builder and request', async () => {
97
+ stuckTweaks._resetCache();
98
+ let reads = 0;
99
+ const read = async ({ now }) => { reads++; return status.stuckPageTweaks({ inventory: INVENTORY, tasks: TASKS, now }); };
100
+ const t0 = NOW.getTime();
101
+ const [a, b] = await Promise.all([
102
+ stuckTweaks.readStuckCached({ nowMs: t0, read }),
103
+ stuckTweaks.readStuckCached({ nowMs: t0 + 10, read }),
104
+ ]);
105
+ await stuckTweaks.readStuckCached({ nowMs: t0 + stuckTweaks.CACHE_MS - 1, read });
106
+ assert.equal(reads, 1, 'concurrent and repeat callers share one read');
107
+ assert.equal(a, b);
108
+ await stuckTweaks.readStuckCached({ nowMs: t0 + stuckTweaks.CACHE_MS, read });
109
+ assert.equal(reads, 2, 'and it refreshes after the window');
110
+ stuckTweaks._resetCache();
111
+ });
112
+
113
+ await test('no Discord on the instance: no post, no error', async () => {
114
+ const r = await stuckTweaks.runDigest({ nowMs: NOW.getTime(), state: { lastDay: null }, post: null, readStuckImpl: async () => stuck() });
115
+ assert.equal(r.posted, false);
116
+ });
117
+
118
+ summary();
@@ -0,0 +1,80 @@
1
+ // tests/government_ask_flags.mjs — the reword list (owner, 2026-10-23): the "?"
2
+ // on a city card flags a matter as unclear, an AI session reads the open flags
3
+ // and writes the matter a standard ask, and that write closes the flag.
4
+ import { strict as assert } from 'node:assert';
5
+ import { test } from 'node:test';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { createRequire } from 'node:module';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
+ const require = createRequire(import.meta.url);
13
+ const docket = require(path.join(ROOT, 'modules', 'government', 'docket.js'));
14
+ const rrc = require(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'));
15
+ const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8');
16
+
17
+ const VIEWER = { id: 42, rank: 'metic' };
18
+ const NOW = Date.parse('2026-10-23T12:00:00Z');
19
+ function contributions(rows) {
20
+ return {
21
+ sources: new Map([
22
+ ['blockers', { source: 'blockers', building: 'planning', gate: { anyBuilder: true }, read: async () => rows.blockers || [] }],
23
+ ['override_requests', { source: 'override_requests', building: 'release', gate: { permission: 'override_request.decide' }, read: async () => rows.overrides || [] }],
24
+ ]),
25
+ readings: new Map(), floors: new Map(), histories: new Map(), desks: new Map(),
26
+ };
27
+ }
28
+ const ROWS = {
29
+ blockers: [{ id: 7, title: 'Pick a host', state: 'needs', rule: 'decide' }, { id: 8, title: 'Clear already', state: 'needs', rule: 'decide' }],
30
+ overrides: [{ id: 5, title: 'Merge without review', state: 'needs', rule: 'decide' }],
31
+ };
32
+ const FLAG = { flagged_at: '2026-10-23T11:00:00.000Z', note: null, times: 1 };
33
+
34
+ test('the docket marks a flagged matter unclear, only among the matters the viewer sees', async () => {
35
+ const asked = [];
36
+ const readFlags = async (ids) => { asked.push(...ids); return new Map([['blockers:7', FLAG], ['override_requests:5', FLAG]]); };
37
+ const d = await docket.buildDocket({ viewer: VIEWER, permissions: ['board.vote.cast'], contributions: contributions(ROWS), now: NOW, readFlags });
38
+ const by = new Map(d.matters.map((m) => [m.id, m]));
39
+ assert.deepEqual(by.get('blockers:7').unclear, FLAG);
40
+ assert.equal(by.get('blockers:8').unclear, undefined, 'an unflagged matter carries nothing');
41
+ assert.ok(!asked.includes('override_requests:5'), 'a gated-out matter is never asked about');
42
+ const broken = await docket.buildDocket({ viewer: VIEWER, permissions: ['board.vote.cast'], contributions: contributions(ROWS), now: NOW, readFlags: async () => { throw new Error('db down'); } });
43
+ assert.ok(broken.matters.length > 0 && broken.matters.every((m) => m.unclear === undefined), 'an unreadable list marks nothing and the docket still stands');
44
+ });
45
+
46
+ test('both routes ride the docket\'s atom, and the flag route checks the matter is visible', () => {
47
+ const src = read('modules/government/routes/government.js');
48
+ for (const r of ["router.post('/government/asks/:matterId/unclear'", "router.get('/government/asks/unclear'"]) {
49
+ const line = src.split('\n').find((l) => l.includes(r));
50
+ assert.ok(line, r);
51
+ assert.match(line, /api\.requireBuilder, api\.requirePermission\('board\.vote\.cast'\)/, r + ': gated');
52
+ }
53
+ const body = src.split("router.post('/government/asks/:matterId/unclear'")[1].split('router.')[0];
54
+ assert.match(body, /docket\.findMatterFor\(req\.builder, matterId\)/, 'a flag is never put on a matter its flagger cannot see');
55
+ assert.match(body, /matter_not_found/);
56
+ const pins = rrc.GOVERNMENT_PINS || {};
57
+ const all = JSON.stringify(pins) + read('src/bongos/route-rank-check.js');
58
+ for (const k of ['POST /government/asks/:matterId/unclear', 'GET /government/asks/unclear']) assert.ok(all.includes(`'${k}': 'perm:board.vote.cast'`), k + ' pinned');
59
+ });
60
+
61
+ test('a rewrite closes the flag; passing the ask to another seat keeps it open', () => {
62
+ const db = read('modules/government/asks-db.js');
63
+ const save = db.split('async function saveAsk')[1].split('\n}\n')[0];
64
+ assert.match(save, /resolveFlag = true/);
65
+ assert.match(save, /if \(resolveFlag\) await pool\.query\('UPDATE government_ask_flags SET resolved_at = now\(\) WHERE matter_id = \$1 AND resolved_at IS NULL'/);
66
+ const routes = read('modules/government/routes/government.js');
67
+ const pass = routes.split("router.post('/government/asks/:matterId/pass'")[1].split('router.')[0];
68
+ assert.match(pass, /resolveFlag: false/, 'a pass keeps the words, so it keeps the flag');
69
+ const flag = db.split('async function flagUnclear')[1].split('\n}\n')[0];
70
+ assert.match(flag, /resolved_at = NULL/, 'flagging again reopens a closed flag');
71
+ assert.match(flag, /times = government_ask_flags\.times \+ 1/);
72
+ });
73
+
74
+ test('the table: one row per matter, the docket id grammar, a bounded note, nothing seeded', () => {
75
+ const sql = read('modules/government/migrations/government_032_ask_flags.sql');
76
+ assert.match(sql, /CREATE TABLE IF NOT EXISTS government_ask_flags/);
77
+ assert.match(sql, /matter_id\s+text\s+PRIMARY KEY/);
78
+ assert.match(sql, /char_length\(note\) <= 500/);
79
+ assert.doesNotMatch(sql, /\bINSERT INTO\b/);
80
+ });
@@ -51,6 +51,7 @@ const NON_GRANT_MIGRATIONS = new Set([
51
51
  'government_029_leads.sql', // the Lead of each building, seated by a passed People matter (task 1004709)
52
52
  'government_030_buildouts.sql', // a Lead's build-outs, on the record at once or by a passed vote (task 1004711)
53
53
  'government_031_treasury.sql', // the treasury: a pot per goal and the loans a dry pot asks the reserve for (task 1004712)
54
+ 'government_032_ask_flags.sql', // the reword list: matters a reader flagged as unclear (owner 2026-10-23)
54
55
  ]);
55
56
 
56
57
  // Permission keys RENAMED after the grant migrations that first granted them.
@@ -85,6 +85,7 @@ function project({ pots = [], borrows = [], cap = null, spend = {}, lastMonth =
85
85
  st.asks.set(params[0], row);
86
86
  return { rows: [row] };
87
87
  }
88
+ if (/^UPDATE government_ask_flags SET resolved_at/.test(s)) return { rows: [], rowCount: 0 };
88
89
  if (/FROM government_asks WHERE matter_id = ANY/.test(s)) return { rows: params[0].map((id) => st.asks.get(id)).filter(Boolean) };
89
90
  if (/FROM government_pot_borrows b JOIN goals g ON g\.id = b\.goal_id WHERE b\.state = 'open'/.test(s)) {
90
91
  return { rows: st.borrows.filter((b) => b.state === 'open').map((b) => ({ ...b, title: st.goals.find((g) => String(g.id) === String(b.goal_id)).title })) };
@@ -428,7 +428,7 @@ test('the fence: a matter only when it needs a governor, and only a fence manage
428
428
  assert.equal(off[0].where, 'automation · the switch', 'the kicker is building · floor only (task 1004568)');
429
429
  assert.equal(off[0].detail, 'payments touched', 'the reason rides in detail: the fact, and the more');
430
430
  assert.equal(autonomyDocket.fenceDocketMatters({ enabled: false, goals: [] }, true)[0].detail, undefined, 'no reason, no detail');
431
- assert.equal(off[0].state, 'needs');
431
+ assert.equal(off[0].state, 'settled', 'the switch is a standing state on the switchboard, never a to-do');
432
432
  assert.equal(autonomyDocket.fenceDocketMatters({ enabled: false, goals: [] }, false)[0].state, 'settled');
433
433
  assert.equal(autonomyDocket.fenceDocketMatters({ enabled: true, goals: [] }, true)[0].title, 'Unattended building is on, but no goal is allowed');
434
434
  });
@@ -396,4 +396,19 @@ await test('a line whose old words a test still checks says so, through the real
396
396
  assert.equal(lib.lineRow(line, 'after').pinned, 'A test still checks the old words: tests/landing_page.mjs. It needs updating when this ships.');
397
397
  });
398
398
 
399
+ // task 1004776: a submit warning on a guarded line is shown on that line.
400
+ await test('a guarded line the rewrite dropped says so in the queue, through the real composer', () => {
401
+ const description = [
402
+ pages.composeBatchBlock({ page_id: 'landing:index', reading_hash: 'h', submitted_at: '2026-09-19T00:00:00Z', lines: [
403
+ { key: 'L0003', section: 'hero', before: 'Private hides the page from everyone — including you.', after: 'you are a ninja.', placement: 'placed', file: 'x.html', line: 1, string_id: 'a' },
404
+ ], warnings: [{ key: 'L0003', must_still_say: 'Private hides the page from everyone, including its owner.', missing: ['including you'] }] }),
405
+ pages.composeAppliedBlock({ page_id: 'landing:index', applied_at: '2026-09-21T00:00:00Z', reading_hash_after: 'h2', lines_after: ['you are a ninja.'], refused: [] }),
406
+ ].join('\n\n');
407
+ const tasks = [{ id: 52, status: 'completed', grade_passed: true, description, source_ref: pages.roundSourceRef('landing:index', 1), holder_id: null, artist_id: ME, artist_login: 'me' }];
408
+ const q = pageStatus.composeApprovals({ inventory: INVENTORY, tasks, builderId: ME, crafts: ['artist'] });
409
+ const line = q.queue[0].lines[0];
410
+ assert.equal(line.must_still_say, 'Private hides the page from everyone, including its owner.');
411
+ assert.equal(lib.lineRow(line, 'after').guarded, 'This line no longer says what it must: Private hides the page from everyone, including its owner.');
412
+ });
413
+
399
414
  summary();
@@ -267,8 +267,9 @@ test('the page holds only the wordmark, the switch, the city, one button and one
267
267
  assert.equal((html.match(/<header\b/g) || []).length, 1);
268
268
  assert.equal((html.match(/<main\b/g) || []).length, 1);
269
269
  assert.equal((html.match(/<h1\b/g) || []).length, 1);
270
- assert.match(html, /<a class="city-start" id="cityStart" href="\/builders\/street"/);
271
- assert.equal((html.match(/<a\b/g) || []).length, 1, 'one door');
270
+ assert.match(html, /<a class="city-start" id="cityStart" href="#decide"/);
271
+ assert.equal((html.match(/<a\b/g) || []).length, 3, 'one door, and the two faint archived links to the street');
272
+ assert.ok(html.includes('<p class="city-archived">Archived: <a href="/builders/street">Street</a> &amp; <a href="/builders/street#building=charter">Building Level Views</a>.</p>'), 'the street is archived behind two faint links');
272
273
  assert.equal((html.match(/<button\b/g) || []).length, 1, 'one switch');
273
274
  assert.match(html, /class="city-mark">\{\{productName\}\}</);
274
275
  assert.equal(City.STREET_HREF, '/builders/street');
@@ -287,7 +288,7 @@ test('the city sits in the hall chrome, behind the shell\'s full-screen switch',
287
288
  // task 1004685 (the living city): gov-living.js, the model under the drawing, and
288
289
  // gov-feeds.js, its one reader per page (shared with the shell's tally), load before it
289
290
  // task 1004702: the card answers its first ask in place, so the deck's card and sender (and the desk rows under them) load before the page
290
- assert.deepEqual(scripts, ['shell-trail', 'craft-mode', 'shell', 'palette', 'gov-living', 'gov-feeds', 'city-draw', 'street-draw', 'street-card', 'street-inside', 'street-desk', 'street-deck', 'city'], 'the shell first, as on every hall page');
291
+ assert.deepEqual(scripts, ['shell-trail', 'craft-mode', 'shell', 'palette', 'gov-living', 'gov-feeds', 'city-draw', 'hall-kit', 'street-draw', 'street-card', 'street-inside', 'street-desk', 'street-deck', 'city-decide', 'city-switchboard', 'city'], 'the shell first, as on every hall page');
291
292
  const css = read('city.css').replace(/\/\*[\s\S]*?\*\//g, '');
292
293
  assert.match(css, /\.city-main \{ position: fixed; top: var\(--topbar-h, 56px\); left: var\(--sidebar-w-rail, 64px\);[^}]*contain: layout paint;/);
293
294
  assert.match(css, /html\[data-fullscreen="on"\] \.city-main \{ top: 0; left: 0; \}/);
@@ -398,7 +399,7 @@ test('the page reads the three feeds and draws the docket\'s pins and line', asy
398
399
  assert.ok(!moving.children[0].children.some((c) => c.className === 'city-pin__ring'), 'a moving pin only bobs');
399
400
  assert.equal(pins.find((s) => s.className.includes('settled')).children[0].children.length, 0, 'a settled pin is a plain dot');
400
401
  assert.equal(p.byId.get('cityLine').textContent, '2 matters wait on you · 1 moving');
401
- assert.equal(p.byId.get('cityStart').attrs.href, '/builders/street');
402
+ assert.equal(p.byId.get('cityStart').attrs.href, '#decide', 'the door opens the decide view in place; the street is archived');
402
403
  assert.ok(p.byId.get('cityBlds').children.length > 9, 'the civic quarter and the district are drawn');
403
404
  assert.ok(p.byId.get('cityParcels').children.some((s) => /offset/.test(Object.keys(s.style).join(' '))), 'parcels carry an offset-path');
404
405
  });