@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
@@ -46,7 +46,9 @@ function fenceDocketMatters(project, mayManage) {
46
46
  return [{
47
47
  id: 'fence',
48
48
  rule: 'decide',
49
- state: mayManage ? 'needs' : 'settled',
49
+ // a standing state, not a to-do (owner, 2026-10-23): the switch lives on the
50
+ // Automation switchboard (automation.switch desk), so it never counts as on you
51
+ state: 'settled',
50
52
  title,
51
53
  short: 'the switch',
52
54
  where,
@@ -77,11 +79,14 @@ const PAUSE_REASONS = Object.freeze([
77
79
 
78
80
  // The Automation desks (task 1004559): rows the holder of autonomy.fence.manage
79
81
  // acts on in place. Pure; exported for tests.
80
- // The switch when it is on and has goals (otherwise the fence matter carries it).
82
+ // The switch, in either position: the Automation switchboard's master row.
81
83
  function switchDeskRows(project) {
82
- const goals = Array.isArray(project && project.goals) ? project.goals : [];
83
- if (!project || project.enabled === false || !goals.length) return [];
84
- return [{ id: 'switch', name: 'The switch', fact: `on · ${goals.length} ${goals.length === 1 ? 'goal' : 'goals'}`, facts: ['unattended building is on'], opened_at: project.updated_at || null, ref: { type: 'autonomy_fence', id: 'project' }, acts: [{ key: 'switch_off', options: OFF_REASONS }] }];
84
+ if (!project) return [];
85
+ const goals = Array.isArray(project.goals) ? project.goals : [];
86
+ if (project.enabled === false) {
87
+ return [{ id: 'switch', name: 'The switch', fact: 'off', facts: [project.paused_reason ? String(project.paused_reason).trim() : 'AI building: off'], opened_at: project.updated_at || null, ref: { type: 'autonomy_fence', id: 'project' }, acts: [{ key: 'switch_on' }] }];
88
+ }
89
+ return [{ id: 'switch', name: 'The switch', fact: `on · ${goals.length} ${goals.length === 1 ? 'goal' : 'goals'}`, facts: [goals.length ? 'AI building: on' : 'AI building: on, but no goal is allowed yet'], opened_at: project.updated_at || null, ref: { type: 'autonomy_fence', id: 'project' }, acts: [{ key: 'switch_off', options: OFF_REASONS }] }];
85
90
  }
86
91
  // One row per allowed goal (first in line, or not allowed any more), and one to
87
92
  // allow another of the open goals the screen already reads.
@@ -245,7 +245,32 @@ function mingleIntroductionsNeed(ctx) {
245
245
  };
246
246
  }
247
247
 
248
- const NEEDS = [artKeyNeed, helpRequestsNeed, taskRecommendationsNeed, boardVotesNeed, mingleIntroductionsNeed];
248
+ // task 1004777: a page tweak that has sat too long, one line PER PAGE. The
249
+ // copy-desk port (copy-desk.stuckPageTweaksFor) has already chosen who is told:
250
+ // a review left waiting goes to its artist, everything else to Metic+. Each item
251
+ // says where the page is and for how long, and links to the Studio.
252
+ // ctx.stuckPageTweaks — [{ page_id, title, state_word, days }]; absent = none.
253
+ function stuckPageTweakNeeds(ctx) {
254
+ const list = Array.isArray(ctx.stuckPageTweaks) ? ctx.stuckPageTweaks : [];
255
+ return list.map((s) => {
256
+ // A templated title ({{productName}}…) falls back to the page id. The same
257
+ // sniff sits in copy-desk page-status.js stuckDigestText, duplicated on
258
+ // purpose: this module cannot require copy-desk (ADR 0093).
259
+ const name = String(s.title || '').includes('{{') ? s.page_id : (s.title || s.page_id);
260
+ const days = Number.isFinite(Number(s.days)) && s.days != null ? Number(s.days) : null;
261
+ return {
262
+ id: `page_tweak_stuck:${s.page_id}`,
263
+ title: `Page tweak stuck: ${name}`,
264
+ what: 'A page tweak has sat in one place longer than it should.',
265
+ state: 'action_needed',
266
+ severity: 'info',
267
+ message: `${s.state_word}${days != null ? ` for ${days} day${days === 1 ? '' : 's'}` : ''}.`,
268
+ action: { label: 'Open the Studio', href: '/studio' },
269
+ };
270
+ });
271
+ }
272
+
273
+ const NEEDS = [artKeyNeed, helpRequestsNeed, taskRecommendationsNeed, boardVotesNeed, mingleIntroductionsNeed, stuckPageTweakNeeds];
249
274
 
250
275
  // Priority WITHIN a state bucket (task 1003738). computeNeeds used to sort by
251
276
  // state alone with a stable sort, so the NEEDS array order silently decided
@@ -275,6 +300,7 @@ const NEED_PRIORITY = {
275
300
  art_key: 2,
276
301
  task_recommendations: 3,
277
302
  mingle_introductions: 4,
303
+ // page_tweak_stuck:<page> ids carry no weight entry and sort last in their bucket.
278
304
  };
279
305
 
280
306
  // ---- founding grace (task 1002739 / direction §5.6, idea 1000701) -----------
@@ -333,7 +359,7 @@ function foundingWelcomeNeed(ctx) {
333
359
  // drop. Home's "From the system" inbox does exactly that.
334
360
  function computeNeeds(ctx = {}) {
335
361
  const grace = foundingGraceActive(ctx);
336
- let items = NEEDS.map((fn) => fn(ctx)).filter(Boolean);
362
+ let items = NEEDS.flatMap((fn) => fn(ctx)).filter(Boolean);
337
363
  if (grace) {
338
364
  items = items.map((n) => n.state !== 'action_needed' ? n : {
339
365
  ...n,
@@ -359,7 +385,7 @@ module.exports = {
359
385
  computeNeeds,
360
386
  // exported for tests / direct reuse
361
387
  artKeyNeed,
362
- helpRequestsNeed, taskRecommendationsNeed, boardVotesNeed, mingleIntroductionsNeed,
388
+ helpRequestsNeed, taskRecommendationsNeed, boardVotesNeed, mingleIntroductionsNeed, stuckPageTweakNeeds,
363
389
  foundingGraceActive,
364
390
  FOUNDING_GRACE_DAYS,
365
391
  SETTINGS_PATH,
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ // guarded-lines.js — page lines that must keep their meaning (task 1004776).
4
+ //
5
+ // WHY. Some page lines carry a promise the product must keep: the Account page's
6
+ // Private option must say the page is hidden from everyone, its owner included
7
+ // (tests/account_privacy_flags.mjs asserts it). An artist rewrote it to "you are a
8
+ // ninja." and only found out after approving, when CI went red. So the editor says
9
+ // so WHILE they write: the line carries its "must still say" sentence, and a
10
+ // submit whose new text drops what the note requires is WARNED — never refused,
11
+ // the artist may have a better wording — and the warning rides into the batch, so
12
+ // the applier and the Approval queue show it too.
13
+ //
14
+ // The list is guarded-lines.json beside this file. A guarded line is found on its
15
+ // page by its FILE plus every must_contain phrase (case-insensitive), not by a
16
+ // reading key or string id: both of those move when the line is rewritten, and a
17
+ // rewrite that keeps the promise must keep the guard. Pure; the JSON is read once.
18
+
19
+ const fs = require('node:fs');
20
+ const path = require('node:path');
21
+
22
+ const LIST_PATH = path.join(__dirname, 'guarded-lines.json');
23
+ let cached = null;
24
+ function loadList() {
25
+ if (!cached) {
26
+ try { cached = JSON.parse(fs.readFileSync(LIST_PATH, 'utf8')); } catch { cached = { lines: [] }; }
27
+ }
28
+ return cached;
29
+ }
30
+
31
+ const lower = (s) => String(s == null ? '' : s).toLowerCase();
32
+
33
+ function missingPhrases(entry, text) {
34
+ const t = lower(text);
35
+ return (entry.must_contain || []).filter((p) => !t.includes(lower(p)));
36
+ }
37
+
38
+ // The guard entries for one page.
39
+ function guardsFor(pageId, list = loadList()) {
40
+ return ((list && list.lines) || []).filter((e) => e && e.page_id === pageId);
41
+ }
42
+
43
+ // guardForLine(pageId, line) -> entry | null. `line` is a reading line
44
+ // ({ text, file }); a line with no file matches on its text alone.
45
+ function guardForLine(pageId, line, list = loadList()) {
46
+ if (!line) return null;
47
+ for (const e of guardsFor(pageId, list)) {
48
+ if (e.file && line.file && e.file !== line.file) continue;
49
+ if (!missingPhrases(e, line.text).length) return e;
50
+ }
51
+ return null;
52
+ }
53
+
54
+ // guardWarnings({ pageId, readingPage, lines }) -> [{ key, must_still_say, missing, guarded_by }]
55
+ // `lines` are the submitted lines ({ key, after, remove? }); each is looked up on
56
+ // the CURRENT reading by key, and warned when that reading line is guarded and the
57
+ // new text drops a required phrase (a removal drops all of them).
58
+ function guardWarnings({ pageId, readingPage, lines }, list = loadList()) {
59
+ const byKey = new Map(((readingPage && readingPage.lines) || []).map((l) => [l.key, l]));
60
+ const out = [];
61
+ for (const l of lines || []) {
62
+ const at = byKey.get(l.key);
63
+ const g = at ? guardForLine(pageId, at, list) : null;
64
+ if (!g) continue;
65
+ const missing = l.remove ? (g.must_contain || []).slice() : missingPhrases(g, l.after);
66
+ if (missing.length) out.push({ key: l.key, must_still_say: g.must_still_say, missing, guarded_by: g.guarded_by || null });
67
+ }
68
+ return out;
69
+ }
70
+
71
+ // checkList(readings) -> [{ page_id, problem }] — every entry must find exactly
72
+ // one line on its page in docs/page-readings.json (the honesty check).
73
+ function checkList(readings, list = loadList()) {
74
+ const pages = new Map(((readings && readings.pages) || []).map((p) => [p.id, p]));
75
+ const problems = [];
76
+ for (const e of (list && list.lines) || []) {
77
+ const page = pages.get(e.page_id);
78
+ if (!page) { problems.push({ page_id: e.page_id, problem: 'no such page in docs/page-readings.json' }); continue; }
79
+ if (!e.must_still_say || !Array.isArray(e.must_contain) || !e.must_contain.length) { problems.push({ page_id: e.page_id, problem: 'needs must_still_say and at least one must_contain phrase' }); continue; }
80
+ const hits = new Set(page.lines.filter((l) => guardForLine(e.page_id, l, { lines: [e] })).map((l) => l.text));
81
+ if (hits.size !== 1) problems.push({ page_id: e.page_id, problem: `matches ${hits.size} distinct lines (want exactly 1): ${e.must_contain.join(' + ')}` });
82
+ }
83
+ return problems;
84
+ }
85
+
86
+ // Test seam: a fixture list for the route-level tests; null goes back to the file.
87
+ function _setList(list) { cached = list; }
88
+
89
+ module.exports = { _setList, LIST_PATH, loadList, guardsFor, guardForLine, guardWarnings, checkList, missingPhrases };
@@ -0,0 +1,14 @@
1
+ {
2
+ "schema": 1,
3
+ "task": 1004776,
4
+ "how": "Page lines that carry a promise the product must keep. A line is found on its page by its file plus every must_contain phrase (case-insensitive), so a rewrite that keeps the promise keeps the guard. The tweak editor shows must_still_say under the line, and a submit whose new text drops a phrase is warned (never refused). guarded_by names the test that asserts the meaning. tests/copy_guarded_lines.mjs checks every entry still finds exactly one line in docs/page-readings.json.",
5
+ "lines": [
6
+ {
7
+ "page_id": "landing:account",
8
+ "file": "modules/public-landing/public/account.html",
9
+ "must_contain": ["Private hides the page from everyone", "including you"],
10
+ "must_still_say": "Private hides the page from everyone, including its owner.",
11
+ "guarded_by": "tests/account_privacy_flags.mjs"
12
+ }
13
+ ]
14
+ }
@@ -7,7 +7,7 @@
7
7
  "category": { "parent": "quality" },
8
8
  "default": true,
9
9
  "maintenance": { "status": "core-maintained" },
10
- "contributes": { "routes": ["copy-desk", "copy-desk-artist"], "migrations": true, "skills": ["tweak"] },
11
- "provides": ["copy-desk.artistStats"],
10
+ "contributes": { "routes": ["copy-desk", "copy-desk-artist"], "migrations": true, "skills": ["tweak"], "pollers": ["stuck-page-digest"] },
11
+ "provides": ["copy-desk.artistStats", "copy-desk.stuckPageTweaksFor"],
12
12
  "consumes": ["lifecycle"]
13
13
  }
@@ -33,6 +33,7 @@
33
33
  'use strict';
34
34
 
35
35
  const pages = require('./pages');
36
+ const guarded = require('./guarded-lines');
36
37
 
37
38
  const TERMINAL = new Set(['shipped', 'abandoned']);
38
39
 
@@ -277,7 +278,8 @@ function orderedSurfaces(inventory) {
277
278
  // (applied rounds only) stayed empty: the artist's finished work looked lost.
278
279
  // This is every page with an open round, where it is, since when (the round's
279
280
  // last update), and whether it has sat there too long. Waiting for review is
280
- // the artist's own move and a draft is theirs to finish, so neither is stuck;
281
+ // the artist's own move and a draft is theirs to finish; a draft is never stuck,
282
+ // and a review is stuck only after 3 days (task 1004777, so its artist is told);
281
283
  // a state the reads cannot name always needs a look.
282
284
  // ---------------------------------------------------------------------------
283
285
 
@@ -287,7 +289,8 @@ const IN_FLIGHT = Object.freeze({
287
289
  writing: { word: 'Being written', stuck_after: null },
288
290
  submitted: { word: 'Submitted, waiting to be applied', stuck_after: 3 * DAY_MS },
289
291
  applying: { word: 'Being applied', stuck_after: DAY_MS },
290
- waiting_for_artist: { word: 'Waiting for your review', stuck_after: null },
292
+ // task 1004777: a review left 3 days is stuck too, so its artist is told on Home.
293
+ waiting_for_artist: { word: 'Waiting for your review', stuck_after: 3 * DAY_MS },
291
294
  landing: { word: 'Approved, going live', stuck_after: DAY_MS },
292
295
  unknown: { word: 'Needs a look', stuck_after: 0 },
293
296
  });
@@ -313,12 +316,64 @@ function inFlightFor(list, byPage, nowMs) {
313
316
  since,
314
317
  stuck: kind.stuck_after != null && (kind.stuck_after === 0 || (age != null && age > kind.stuck_after)),
315
318
  holder: o.holder,
319
+ artist_id: r.artist_id != null ? String(r.artist_id) : null,
316
320
  });
317
321
  }
318
322
  const rank = (s) => { const i = IN_FLIGHT_ORDER.indexOf(s); return i === -1 ? 0 : i; };
319
323
  return out.sort((a, b) => (b.stuck - a.stuck) || rank(a.state) - rank(b.state) || String(a.since || '').localeCompare(String(b.since || '')));
320
324
  }
321
325
 
326
+ // ---------------------------------------------------------------------------
327
+ // Stuck page tweaks, told to someone (task 1004777). The Studio's in-flight list
328
+ // marks a stuck round, but only for whoever opens the Studio, and 11 rounds sat
329
+ // from late September unseen. The SAME list and stuck rule, derived from the
330
+ // ledger (no status table), now feeds two pushes: a Needs-attention line on Home
331
+ // for the person it waits on, and a once-a-day Discord digest.
332
+ //
333
+ // Who it waits on: a round waiting for review waits on ITS ARTIST; every other
334
+ // stuck state (submitted and not applied, being applied, approved but not live,
335
+ // a state the reads cannot name) waits on the builders who can apply and land
336
+ // work, Metic and up.
337
+ // ---------------------------------------------------------------------------
338
+
339
+ const APPLIER_RANKS = new Set(['metic', 'archon']);
340
+
341
+ function stuckPageTweaks({ inventory, tasks, now = Date.now() }) {
342
+ const { in_flight: inFlight } = composePages({ inventory, readings: null, tasks, now });
343
+ const nowMs = now instanceof Date ? now.getTime() : Number(now);
344
+ return inFlight.filter((f) => f.stuck).map((f) => {
345
+ const ms = f.since ? nowMs - Date.parse(f.since) : null;
346
+ return {
347
+ page_id: f.page_id,
348
+ title: f.title,
349
+ task_id: f.task_id,
350
+ state: f.state,
351
+ state_word: f.state_word,
352
+ since: f.since,
353
+ days: ms == null ? null : Math.max(0, Math.floor(ms / DAY_MS)),
354
+ waits_on: f.state === 'waiting_for_artist' ? 'artist' : 'appliers',
355
+ artist_id: f.artist_id,
356
+ };
357
+ });
358
+ }
359
+
360
+ // The stuck tweaks one builder should be told about on Home.
361
+ function stuckPageTweaksFor({ builderId, rank, stuck }) {
362
+ const applier = APPLIER_RANKS.has(String(rank || '').toLowerCase());
363
+ return (stuck || []).filter((s) => (s.waits_on === 'artist'
364
+ ? s.artist_id != null && String(s.artist_id) === String(builderId)
365
+ : applier));
366
+ }
367
+
368
+ // The Discord digest: page names and where each sits, never the words.
369
+ function stuckDigestText(stuck) {
370
+ if (!stuck || !stuck.length) return null;
371
+ const plain = (s) => String(s || '').replace(/\{\{[^}]*\}\}/g, '').replace(/[`*_~|<>@]/g, '').replace(/\s+/g, ' ').trim();
372
+ const name = (s) => (String(s.title || '').includes('{{') ? s.page_id : plain(s.title) || s.page_id);
373
+ const lines = stuck.map((s) => `- ${name(s)}: ${s.state_word.toLowerCase()}${s.days != null ? `, ${s.days} day${s.days === 1 ? '' : 's'}` : ''}`);
374
+ return [`Page tweaks stuck (${stuck.length}). Open the Studio to move them:`, ...lines].join('\n');
375
+ }
376
+
322
377
  // GET /copy-desk/pages — every page's status, count and drift, plus the
323
378
  // per-surface rollup (Artist Review Status) and the pages in flight.
324
379
  function composePages({ inventory, readings, tasks, now = Date.now() }) {
@@ -605,9 +660,12 @@ function composeNext({ inventory, readings, tasks, asks, builderId, alternatives
605
660
  // The margin note a line carries in the editor (ADR 0341 D4, TW10): shared
606
661
  // shell text changes on every page, and a line the applier cannot place goes
607
662
  // to an engineer at submit (pages.goesToEngineer, the submit's own rule).
608
- const EDITOR_NOTES = Object.freeze({ shared: 'changes this on every page', engineer: 'goes to an engineer' });
663
+ // task 1004776: a third kind, `guarded` — the line carries a promise the product
664
+ // must keep (guarded-lines.json); the line also carries `must_still_say`.
665
+ const EDITOR_NOTES = Object.freeze({ shared: 'changes this on every page', engineer: 'goes to an engineer', guarded: 'must still say' });
609
666
 
610
- function editorNote(line) {
667
+ function editorNote(line, pageId) {
668
+ if (pageId && guarded.guardForLine(pageId, line)) return 'guarded';
611
669
  if (pages.goesToEngineer(line)) return 'engineer';
612
670
  return line.placement === 'shared' ? 'shared' : null;
613
671
  }
@@ -622,9 +680,13 @@ function editorNote(line) {
622
680
  // page has not been read); `round` is the page's open round as the lifecycle
623
681
  // port lists it, or null.
624
682
  function composeEditor({ page, readingPage, round, builderId }) {
625
- const lines = ((readingPage && readingPage.lines) || []).map((l) => ({
626
- key: l.key, section: l.section, text: l.text, placement: l.placement, note: editorNote(l),
627
- }));
683
+ const lines = ((readingPage && readingPage.lines) || []).map((l) => {
684
+ const g = guarded.guardForLine(page.id, l);
685
+ return {
686
+ key: l.key, section: l.section, text: l.text, placement: l.placement, note: editorNote(l, page.id),
687
+ ...(g ? { must_still_say: g.must_still_say, must_contain: (g.must_contain || []).slice() } : {}),
688
+ };
689
+ });
628
690
  const readingHash = readingPage ? readingPage.reading_hash || null : null;
629
691
  let draft = null;
630
692
  let olderReading = false;
@@ -693,6 +755,9 @@ function approvalFor({ page, pageId, rounds, round, builderId, isArtist }) {
693
755
  if (applied.ok) for (const r of applied.applied.refused || []) if (r && r.key) refusedBy.set(String(r.key), String(r.code || 'refused'));
694
756
  const pinnedBy = new Map();
695
757
  if (applied.ok) for (const p of applied.applied.pinned_tests || []) if (p && p.key && Array.isArray(p.tests)) pinnedBy.set(String(p.key), p.tests.map(String));
758
+ // task 1004776: the submit's guarded-line warnings, by line.
759
+ const warnedBy = new Map();
760
+ if (batch.ok) for (const w of batch.batch.warnings || []) if (w && w.key) warnedBy.set(String(w.key), String(w.must_still_say || ''));
696
761
  const lines = batch.ok ? batch.batch.lines.map((l) => ({
697
762
  key: l.key,
698
763
  section: l.section == null ? null : l.section,
@@ -701,6 +766,7 @@ function approvalFor({ page, pageId, rounds, round, builderId, isArtist }) {
701
766
  placement: l.placement || null,
702
767
  refused: refusedBy.get(String(l.key)) || null,
703
768
  ...(pinnedBy.has(String(l.key)) ? { pinned_by: pinnedBy.get(String(l.key)) } : {}),
769
+ ...(warnedBy.has(String(l.key)) ? { must_still_say: warnedBy.get(String(l.key)) } : {}),
704
770
  })) : [];
705
771
  const shipped = (rounds || []).filter((r) => r.status === 'shipped');
706
772
  const now = countsFor(rounds);
@@ -823,4 +889,8 @@ module.exports = {
823
889
  lastRoundShippedAt,
824
890
  composeTally,
825
891
  composeArtistStats,
892
+ // task 1004777: stuck page tweaks, pushed to Home and Discord.
893
+ stuckPageTweaks,
894
+ stuckPageTweaksFor,
895
+ stuckDigestText,
826
896
  };
@@ -145,6 +145,10 @@ function composeBatchBlock(b) {
145
145
  file: l.file == null ? null : l.file, line: l.line == null ? null : l.line, string_id: l.string_id == null ? null : l.string_id,
146
146
  }));
147
147
  out.unplaced_tasks = Array.isArray(b.unplaced_tasks) ? b.unplaced_tasks : [];
148
+ // task 1004776: a guarded line whose new text drops what it must still say.
149
+ if (Array.isArray(b.warnings) && b.warnings.length) {
150
+ out.warnings = b.warnings.map((w) => ({ key: w.key, must_still_say: w.must_still_say, missing: (w.missing || []).map(String), guarded_by: w.guarded_by || null }));
151
+ }
148
152
  return fenceBlock(FENCES.batch, out);
149
153
  }
150
154
 
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ // modules/copy-desk/pollers/stuck-page-digest.js — the once-a-day Discord digest
4
+ // of stuck page tweaks (task 1004777). Loader-started (contributes.pollers); the
5
+ // rule and the post live in ../stuck-tweaks.js. Checks hourly; posts at most once
6
+ // per UTC day, after COPY_STUCK_DIGEST_HOUR_UTC (default 9), and only when a page
7
+ // is stuck. Best-effort: a failed read or post logs and waits for tomorrow.
8
+ // COPY_STUCK_DIGEST_DISABLED=1 turns it off.
9
+
10
+ const api = require('../../../src/module-api');
11
+ const stuck = require('../stuck-tweaks');
12
+
13
+ const log = api.logger('copy-desk');
14
+ const INTERVAL_MS = Number(process.env.COPY_STUCK_DIGEST_MS) || 60 * 60_000;
15
+ const state = { lastDay: null };
16
+ let timer = null;
17
+
18
+ function tick() {
19
+ stuck.runDigest({ state })
20
+ .then((r) => { if (r.posted) log.info(`[stuck-page-digest] posted ${r.text.split('\n').length - 1} stuck page tweak(s)`); })
21
+ .catch((e) => log.error(e, '[stuck-page-digest] digest failed'));
22
+ }
23
+
24
+ module.exports = {
25
+ start() {
26
+ if (process.env.COPY_STUCK_DIGEST_DISABLED === '1' || timer) return;
27
+ timer = setInterval(tick, INTERVAL_MS);
28
+ if (timer.unref) timer.unref();
29
+ },
30
+ stop() {
31
+ if (timer) clearInterval(timer);
32
+ timer = null;
33
+ },
34
+ };
@@ -60,6 +60,7 @@ const proposals = require('../proposals');
60
60
  const pageData = require('../page-data');
61
61
  const pageStatus = require('../page-status');
62
62
  const pages = require('../pages');
63
+ const guarded = require('../guarded-lines');
63
64
  const docx = require('../docx');
64
65
  const { waitingRoundOrFail, refuseDecision, nextWaiting } = require('../page-decisions');
65
66
  const { tallyCreditRows, readArtistStats } = require('../artist-stats');
@@ -231,6 +232,7 @@ function alreadySubmitted(round, builderId, pageId) {
231
232
  round: { task_id: String(round.id), state: 'submitted', status: 'ready' },
232
233
  batch: { line_count: b.batch.lines.length, submitted_at: b.batch.submitted_at, reading_hash: b.batch.reading_hash },
233
234
  unplaced_tasks: (b.batch.unplaced_tasks || []).map((id) => ({ task_id: String(id) })),
235
+ warnings: b.batch.warnings || [],
234
236
  next: { path: '/copy-desk/next' },
235
237
  };
236
238
  }
@@ -371,6 +373,8 @@ module.exports = function buildCopyDeskRouter() {
371
373
  if (!api.hasProvider('copy-desk.artistStats')) {
372
374
  api.registerProvider('copy-desk.artistStats', readArtistStats);
373
375
  }
376
+ // task 1004777: Home's Needs-attention reads stuck page tweaks through this port.
377
+ if (!api.hasProvider('copy-desk.stuckPageTweaksFor')) api.registerProvider('copy-desk.stuckPageTweaksFor', require('../stuck-tweaks').readStuckFor);
374
378
 
375
379
  // Page asks resolve when the page's tweak ships (ADR 0341 D6). Re-registered
376
380
  // on every router build (tests rebuild it), never stacked.
@@ -1299,6 +1303,9 @@ module.exports = function buildCopyDeskRouter() {
1299
1303
  if (d.draft.page_id !== pageId) return { ok: false, code: 'wrong_page', detail: { page_id: pageId, draft_page_id: d.draft.page_id } };
1300
1304
  const f = pages.freezeBatch({ readingPage, draft: d.draft, submittedAt });
1301
1305
  if (!f.ok) return f;
1306
+ // task 1004776: warn (never refuse) on a guarded line that drops its promise;
1307
+ // the warning rides in the batch so the applier and the Approval queue see it.
1308
+ f.batch.warnings = guarded.guardWarnings({ pageId, readingPage, lines: d.draft.lines });
1302
1309
  const probe = pages.replaceBlock(task.description, pages.FENCES.batch, '');
1303
1310
  if (!probe.ok) return probe;
1304
1311
  frozen = f;
@@ -1323,6 +1330,7 @@ module.exports = function buildCopyDeskRouter() {
1323
1330
  ok: true, outcome: 'submitted', page_id: pageId,
1324
1331
  round: { task_id: String(r.task.id), state: 'submitted', status: r.task.status },
1325
1332
  batch: { line_count: frozen.batch.lines.length, submitted_at: submittedAt, reading_hash: frozen.batch.reading_hash },
1333
+ warnings: frozen.batch.warnings || [],
1326
1334
  unplaced_tasks: r.unplaced_tasks.map((u) => ({ task_id: u.id, key: u.key, created: u.created })),
1327
1335
  // "Next up" (the editor's closing card) reads the recommendation.
1328
1336
  next: { path: '/copy-desk/next' },
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+
3
+ // stuck-tweaks.js — a page tweak that sits too long tells someone (task 1004777).
4
+ //
5
+ // The composer is page-status.js (stuckPageTweaks: the Studio's in-flight list
6
+ // and its stuck rule, derived from the ledger, no status table). This file is the
7
+ // I/O around it, kept out of routes/copy-desk.js, which sits at its line ratchet:
8
+ //
9
+ // • readStuckFor(builderId, rank) — the `copy-desk.stuckPageTweaksFor` port the
10
+ // kernel /me route reads for the Home Needs-attention inbox. Fails soft ([]).
11
+ // • the daily digest — at most one post per UTC day to the Discord ship channel
12
+ // (the `discord.post` port; no Discord, no post), page names only, never copy.
13
+
14
+ const api = require('../../src/module-api');
15
+ const pageData = require('./page-data');
16
+ const pageStatus = require('./page-status');
17
+
18
+ const DIGEST_CHANNEL = 'ship_feed';
19
+ const DIGEST_HOUR_UTC = Number(process.env.COPY_STUCK_DIGEST_HOUR_UTC) || 9;
20
+
21
+ async function readStuck({ now = Date.now(), resolve = (k) => (api.resolveOptional ? api.resolveOptional(k) : null) } = {}) {
22
+ const inv = pageData.loadInventory();
23
+ if (!inv.ok) return [];
24
+ const lifecycle = resolve('lifecycle');
25
+ if (!lifecycle || typeof lifecycle.listPageTweakTasks !== 'function') return [];
26
+ const tasks = await lifecycle.listPageTweakTasks();
27
+ return pageStatus.stuckPageTweaks({ inventory: inv.data, tasks, now });
28
+ }
29
+
30
+ // The stuck list is the same for every builder and only the audience filter is
31
+ // per builder, while GET /me (the hall's busiest boot read) asks on every page
32
+ // load. So the list is read at most once per CACHE_MS across ALL builders and
33
+ // requests (the modules/lifecycle/sky.js precedent): a stuck rule measured in
34
+ // days loses nothing to a minute of staleness, and one in-flight read is shared
35
+ // by concurrent callers rather than repeated.
36
+ const CACHE_MS = Number(process.env.COPY_STUCK_CACHE_MS) || 60_000;
37
+ let cache = { at: 0, p: null };
38
+ function readStuckCached({ nowMs = Date.now(), read = readStuck } = {}) {
39
+ if (!cache.p || nowMs - cache.at >= CACHE_MS) {
40
+ const p = read({ now: nowMs }).catch(() => []);
41
+ cache = { at: nowMs, p };
42
+ }
43
+ return cache.p;
44
+ }
45
+ function _resetCache() { cache = { at: 0, p: null }; }
46
+
47
+ async function readStuckFor(builderId, rank, opts = {}) {
48
+ try {
49
+ return pageStatus.stuckPageTweaksFor({ builderId, rank, stuck: await readStuckCached(opts) });
50
+ } catch (_) {
51
+ return [];
52
+ }
53
+ }
54
+
55
+ // The digest's once-a-day rule, pure: post when it is past the digest hour and
56
+ // nothing has been posted on this UTC day. `lastDay` is the day key last posted.
57
+ function digestDue({ nowMs, lastDay, hour = DIGEST_HOUR_UTC }) {
58
+ const d = new Date(nowMs);
59
+ const day = d.toISOString().slice(0, 10);
60
+ return { due: d.getUTCHours() >= hour && lastDay !== day, day };
61
+ }
62
+
63
+ // One digest attempt. Returns what happened, so the poller and the tests read
64
+ // the same outcome. A page that is not stuck produces nothing; no stuck page,
65
+ // no post.
66
+ async function runDigest({ nowMs = Date.now(), state, post, readStuckImpl = readStuck } = {}) {
67
+ const { due, day } = digestDue({ nowMs, lastDay: state.lastDay });
68
+ if (!due) return { posted: false, reason: 'not_due' };
69
+ const stuck = await readStuckImpl({ now: nowMs });
70
+ state.lastDay = day; // checked once a day, posted or not
71
+ const text = pageStatus.stuckDigestText(stuck);
72
+ if (!text) return { posted: false, reason: 'nothing_stuck' };
73
+ const port = post || (api.resolveOptional ? api.resolveOptional('discord.post', null) : null);
74
+ if (typeof port !== 'function') return { posted: false, reason: 'no_discord' };
75
+ const r = await port({ channel: DIGEST_CHANNEL, text });
76
+ return { posted: !!(r && r.posted), reason: r && r.reason, text };
77
+ }
78
+
79
+ module.exports = { DIGEST_CHANNEL, DIGEST_HOUR_UTC, CACHE_MS, readStuck, readStuckCached, readStuckFor, digestDue, runDigest, _resetCache };
@@ -300,7 +300,12 @@ await test('the module manifest declares no seam another module could write copy
300
300
  // profile's artist numbers. Anything more provided here would be a seam
301
301
  // another module could reach copy through, so the list is pinned exactly and
302
302
  // the provider is held to returning no wording below.
303
- assert.deepEqual(manifest.provides, ['copy-desk.artistStats'], 'a provided port here would be a copy write path for every other module');
303
+ // task 1004777 adds a second READ: copy-desk.stuckPageTweaksFor, the stuck
304
+ // page tweaks for Home's Needs-attention. It returns page ids, titles, state
305
+ // words and day counts, never a line of copy, and writes nothing.
306
+ assert.deepEqual(manifest.provides, ['copy-desk.artistStats', 'copy-desk.stuckPageTweaksFor'], 'a provided port here would be a copy write path for every other module');
307
+ const stuckSrc = stripComments(read('stuck-tweaks.js'));
308
+ assert.equal(/writeFile|\.query\(|UPDATE |INSERT /.test(stuckSrc), false, 'the stuck-tweaks port writes nothing');
304
309
  const stats = read('artist-stats.js');
305
310
  assert.equal(/\b(pool|query|writeFile|appendFile|INSERT|UPDATE|DELETE)\b/.test(stats.replace(/\/\/.*$/gm, '')), false,
306
311
  'the artist-stats port reads through other ports and writes nothing');
@@ -263,10 +263,10 @@ await test('in_flight: every open round, its plain state, how long, and stuck wh
263
263
  ['landing:c', 'landing', 'Approved, going live', true],
264
264
  ['status:h', 'applying', 'Being applied', true],
265
265
  ['landing:a', 'submitted', 'Submitted, waiting to be applied', true],
266
+ ['builders:d', 'waiting_for_artist', 'Waiting for your review', true],
266
267
  ['landing:b', 'submitted', 'Submitted, waiting to be applied', false],
267
- ['builders:d', 'waiting_for_artist', 'Waiting for your review', false],
268
268
  ['builders:e', 'writing', 'Being written', false],
269
- ], 'a shipped round is not in flight; waiting for review and writing are never stuck');
269
+ ], 'a shipped round is not in flight; writing is never stuck, and a review left 20 days is (task 1004777)');
270
270
  assert.equal(out.in_flight[1].since, ago(17));
271
271
  assert.equal(out.in_flight[1].task_id, '3');
272
272
  assert.equal(out.pages.find((p) => p.id === 'landing:a').status_label, 'Untweaked', 'the status still counts only what shipped');
@@ -18,7 +18,9 @@ const asks = require('./asks');
18
18
  const MAX_READ = 500;
19
19
 
20
20
  // Store (or replace) one matter's ask. `ask` is checkAsk's stored form.
21
- async function saveAsk({ matterId, ask, builderId }) {
21
+ // A rewrite closes the matter's reword flag (government_032); a pass to
22
+ // another seat keeps the words, so it passes resolveFlag: false.
23
+ async function saveAsk({ matterId, ask, builderId, resolveFlag = true }) {
22
24
  if (!asks.isMatterId(matterId)) throw Object.assign(new Error('bad matter id'), { code: 'BAD_MATTER_ID' });
23
25
  const { rows } = await pool.query(
24
26
  `INSERT INTO government_asks
@@ -33,6 +35,7 @@ async function saveAsk({ matterId, ask, builderId }) {
33
35
  [matterId, ask.question, JSON.stringify(ask.options), ask.recommend.key, ask.recommend.why,
34
36
  ask.waits_on, ask.if_nobody_answers, ask.detail || null, builderId == null ? null : builderId],
35
37
  );
38
+ if (resolveFlag) await pool.query('UPDATE government_ask_flags SET resolved_at = now() WHERE matter_id = $1 AND resolved_at IS NULL', [matterId]);
36
39
  return asks.fromRow(rows[0]);
37
40
  }
38
41
 
@@ -51,4 +54,37 @@ async function asksFor(matterIds) {
51
54
  return out;
52
55
  }
53
56
 
54
- module.exports = { saveAsk, asksFor, MAX_READ };
57
+ // ---------- the reword list (government_032, owner 2026-10-23) ----------
58
+ // A reader pressed "?" on a card: this matter's words did not make sense.
59
+ // Flagging again reopens a resolved flag and counts the time.
60
+ async function flagUnclear({ matterId, builderId, note }) {
61
+ if (!asks.isMatterId(matterId)) throw Object.assign(new Error('bad matter id'), { code: 'BAD_MATTER_ID' });
62
+ const text = typeof note === 'string' && note.trim() ? note.trim().slice(0, 500) : null;
63
+ const { rows } = await pool.query(
64
+ `INSERT INTO government_ask_flags (matter_id, flagged_by, note)
65
+ VALUES ($1, $2, $3)
66
+ ON CONFLICT (matter_id) DO UPDATE
67
+ SET flagged_by = EXCLUDED.flagged_by, flagged_at = now(), resolved_at = NULL,
68
+ note = COALESCE(EXCLUDED.note, government_ask_flags.note),
69
+ times = government_ask_flags.times + 1
70
+ RETURNING matter_id, flagged_at, note, times`,
71
+ [matterId, builderId == null ? null : builderId, text],
72
+ );
73
+ return rows[0];
74
+ }
75
+
76
+ // The OPEN flags of these matters → Map(matter_id → { flagged_at, note, times }).
77
+ async function unclearFor(matterIds) {
78
+ const ids = [...new Set((Array.isArray(matterIds) ? matterIds : []).filter(asks.isMatterId))].slice(0, MAX_READ);
79
+ const out = new Map();
80
+ if (!ids.length) return out;
81
+ const { rows } = await pool.query(
82
+ `SELECT matter_id, flagged_at, note, times FROM government_ask_flags
83
+ WHERE resolved_at IS NULL AND matter_id = ANY($1::text[])`,
84
+ [ids],
85
+ );
86
+ for (const r of rows) out.set(r.matter_id, { flagged_at: r.flagged_at, note: r.note, times: r.times });
87
+ return out;
88
+ }
89
+
90
+ module.exports = { saveAsk, asksFor, flagUnclear, unclearFor, MAX_READ };
@@ -230,7 +230,7 @@ const PERMISSIONS = [
230
230
  { key: 'module.assessment.rerun', system: false, floor: 'metic', guards: 'POST /store/modules/:key/versions/:version/reassess' },
231
231
  // Take a module out of the store (task 1003797, ADR 0338 D2). Stops new installs,
232
232
  // versions and downloads; never reaches an instance that already holds it.
233
- { key: 'module.delist', system: false, floor: 'metic', guards: 'POST /store/modules/:key/delist' },
233
+ { key: 'module.delist', system: false, floor: 'metic', guards: 'POST /store/modules/:key/delist, POST /store/modules/:key/versions/:version/release (a version the promotion gate held on tester — task 1003804)' },
234
234
  { key: 'security.report.file', system: false, floor: 'metic', guards: 'POST /security/reports, GET /security/adr' },
235
235
  { key: 'llm_cache.use', system: false, floor: 'metic', guards: 'POST /llm-cache/{lookup,store}' },
236
236
  { key: 'backup.manage', system: false, floor: 'metic', guards: 'GET /backup/status, POST /backup/trigger' },