@bongos/core 1.23.20 → 1.23.22

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 (73) hide show
  1. package/.bongos-core.json +137 -67
  2. package/.claude/skills/goal-create/SKILL.md +5 -2
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +4 -0
  5. package/clients/bongos-client/index.cjs +4 -0
  6. package/clients/bongos-client/index.d.ts +5 -0
  7. package/clients/bongos-client/index.mjs +4 -0
  8. package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +87 -0
  9. package/docs/adr/0386-the-funding-wall-is-a-per-project-rule-claim-time-hold-by-default.md +37 -0
  10. package/docs/adr/0387-two-ledgers-credits-measure-work-money-stays-with-the-processor.md +39 -0
  11. package/docs/adr/0388-a-module-sale-is-split-by-the-project-the-buyer-pays-the-platform-fee.md +42 -0
  12. package/docs/adr/0392-ai-generation-runs-on-a-processor-held-balance-and-the-builders-own-keys.md +33 -0
  13. package/docs/adr/README.md +5 -0
  14. package/docs/api/openapi.json +59 -4
  15. package/docs/api-reference.md +7 -1
  16. package/docs/file-map.md +3 -0
  17. package/docs/module-api-changelog.md +4 -0
  18. package/docs/page-readings.json +1 -1
  19. package/modules/ai-studio/CLAUDE.md +15 -0
  20. package/modules/ai-studio/module.json +26 -0
  21. package/modules/ai-studio/routes/ai-studio.js +22 -0
  22. package/modules/copy-desk/chrome-pages.js +178 -0
  23. package/modules/copy-desk/page-data.js +34 -2
  24. package/modules/copy-desk/tests/copy_chrome_pages.mjs +127 -0
  25. package/modules/copy-desk/tests/copy_no_cms.mjs +1 -1
  26. package/modules/government/db.js +74 -0
  27. package/modules/government/routes/government.js +28 -18
  28. package/modules/grading/grader-subagent.js +30 -11
  29. package/modules/hall-ui/public/city.html +15 -15
  30. package/modules/lifecycle/db-analytics.js +10 -3
  31. package/modules/lifecycle/db-goals.js +30 -13
  32. package/modules/lifecycle/db-shared.js +9 -0
  33. package/modules/lifecycle/db-versions.js +21 -8
  34. package/modules/lifecycle/ship-assurance.js +4 -1
  35. package/modules/lifecycle/ship-card.js +8 -2
  36. package/modules/onboarding/howto-moments.js +8 -1
  37. package/modules/provisioning/starter-bundles.js +4 -2
  38. package/package-lock.json +2 -2
  39. package/package.json +1 -1
  40. package/release-notes.json +72 -0
  41. package/scripts/bongos/copy-apply.js +3 -1
  42. package/scripts/bongos/publish-manifest.js +1 -0
  43. package/scripts/bongos/strand-watch.js +106 -10
  44. package/scripts/bongos/tweak-renders.js +9 -2
  45. package/src/bongos/module-scope-map.js +4 -0
  46. package/src/module-api.js +1 -1
  47. package/src/platform-server.js +27 -1
  48. package/tests/batched_writes_parity.mjs +340 -0
  49. package/tests/conductor_main_e2e.mjs +4 -0
  50. package/tests/copy_desk_page_docx.mjs +3 -3
  51. package/tests/copy_desk_page_editor.mjs +6 -6
  52. package/tests/copy_desk_page_next.mjs +3 -2
  53. package/tests/copy_desk_page_reads.mjs +2 -1
  54. package/tests/copy_desk_page_submit.mjs +30 -2
  55. package/tests/fixtures/per-row-loops-legacy.cjs +245 -0
  56. package/tests/goal_archive_disposition.mjs +5 -5
  57. package/tests/government_routes.mjs +145 -22
  58. package/tests/grader.mjs +31 -0
  59. package/tests/hall_city.mjs +8 -0
  60. package/tests/hook_shell_tools.mjs +8 -0
  61. package/tests/hooks_drain.mjs +1 -1
  62. package/tests/host_topology_skips.mjs +2 -0
  63. package/tests/howto_moments.mjs +17 -0
  64. package/tests/module_loader.mjs +1 -1
  65. package/tests/module_skills_hook.mjs +69 -0
  66. package/tests/profile_role_stats.mjs +2 -1
  67. package/tests/public_landing_projects.mjs +37 -0
  68. package/tests/session_start_freshness.mjs +66 -1
  69. package/tests/shell_hooks.mjs +209 -0
  70. package/tests/ship_assurance.mjs +28 -0
  71. package/tests/ship_card.mjs +20 -5
  72. package/tests/strand_watch.mjs +48 -0
  73. package/tests/version_close_route.mjs +1 -1
@@ -0,0 +1,178 @@
1
+ // modules/copy-desk/chrome-pages.js — the shared page chrome as its own pages
2
+ // (task 1004904, ADR 0341 D3's "shared" placement).
3
+ //
4
+ // The reader (scripts/bongos/page-reader.js) records every visible line of a page,
5
+ // and the skip link, top bar, navigation and footer are on every page of a
6
+ // surface, so each page's reading carried the same few dozen lines. An artist
7
+ // tweaking one page was shown, and could rewrite, words that change on all of
8
+ // them. This is the read-side split:
9
+ //
10
+ // * A real page's reading loses its chrome lines. They are not shown, not
11
+ // editable, not counted, not in its drift.
12
+ // * Each surface gains one page per chrome section: "<surface>:top-bar",
13
+ // "<surface>:navigation", "<surface>:footer", "<surface>:skip-link". Each
14
+ // lists every DISTINCT line of that section once, in the order it first
15
+ // appears, and goes through the same claim, draft, submit and approve flow
16
+ // as any page.
17
+ //
18
+ // PURE, like pages.js: it derives from the two committed artifacts and writes
19
+ // nothing. The generators and the committed files are untouched, so the
20
+ // reader's --stale check and the registry are exactly as they were, and a
21
+ // surface with no chrome gains no page.
22
+
23
+ 'use strict';
24
+
25
+ const crypto = require('node:crypto');
26
+
27
+ // section name the reader writes -> the page it becomes
28
+ const CHROME = Object.freeze([
29
+ Object.freeze({ section: 'top bar', slug: 'top-bar', title: 'Top bar' }),
30
+ Object.freeze({ section: 'navigation', slug: 'navigation', title: 'Navigation' }),
31
+ Object.freeze({ section: 'footer', slug: 'footer', title: 'Footer' }),
32
+ Object.freeze({ section: 'skip link', slug: 'skip-link', title: 'Skip link' }),
33
+ Object.freeze({ section: 'sidebar', slug: 'sidebar', title: 'Sidebar' }),
34
+ ]);
35
+
36
+ const CHROME_SECTIONS = new Set(CHROME.map((c) => c.section));
37
+
38
+ function isChromeLine(line) {
39
+ return !!line && line.placement === 'shared' && CHROME_SECTIONS.has(line.section);
40
+ }
41
+
42
+ function hash(parts) {
43
+ return crypto.createHash('sha256').update(parts.join('\n')).digest('hex').slice(0, 16);
44
+ }
45
+
46
+ function key(n) { return `L${String(n).padStart(4, '0')}`; }
47
+
48
+ function countsOf(lines) {
49
+ return {
50
+ lines: lines.length,
51
+ placed: lines.filter((l) => l.placement === 'placed').length,
52
+ shared: lines.filter((l) => l.placement === 'shared').length,
53
+ unplaced: lines.filter((l) => l.placement === 'unplaced').length,
54
+ };
55
+ }
56
+
57
+ // The chrome page ids a surface can have, so a caller can tell one from a page.
58
+ // A reading names its surface; one that does not is read from its id.
59
+ function surfaceOf(page) { return page.surface || String(page.id || '').split(':')[0]; }
60
+
61
+ function chromePageId(surface, slug) { return `${surface}:${slug}`; }
62
+ function isChromePageId(id) {
63
+ const m = /^[a-z0-9-]+:([a-z0-9-]+)$/.exec(String(id || ''));
64
+ return !!m && CHROME.some((c) => c.slug === m[1]);
65
+ }
66
+
67
+ // readings -> readings, every real page without its chrome and the chrome pages
68
+ // after them. Returns { readings, chrome } where chrome maps a surface to the
69
+ // pages it gained (the inventory is built from it).
70
+ function splitReadings(readings) {
71
+ const pages = Array.isArray(readings && readings.pages) ? readings.pages : [];
72
+ const real = [];
73
+ // surface -> slug -> { lines: Map text -> line, from: page, states: Set, sources: Set of pages }
74
+ const gathered = new Map();
75
+ const order = [];
76
+ for (const p of pages) {
77
+ const lines = Array.isArray(p && p.lines) ? p.lines : [];
78
+ const kept = [];
79
+ for (const l of lines) {
80
+ if (!isChromeLine(l)) { kept.push(l); continue; }
81
+ const spec = CHROME.find((c) => c.section === l.section);
82
+ const surface = surfaceOf(p);
83
+ if (!gathered.has(surface)) { gathered.set(surface, new Map()); order.push(surface); }
84
+ const bySlug = gathered.get(surface);
85
+ if (!bySlug.has(spec.slug)) bySlug.set(spec.slug, { spec, lines: new Map(), from: p, states: new Set(), sources: new Set() });
86
+ const g = bySlug.get(spec.slug);
87
+ const text = String(l.text);
88
+ const seen = g.lines.get(text);
89
+ if (!seen) g.lines.set(text, { ...l });
90
+ else if (!seen.file && l.file) { seen.file = l.file; seen.line = l.line; seen.string_id = l.string_id; }
91
+ if (!g.sources.has(p)) {
92
+ g.sources.add(p);
93
+ for (const s of p.states_read || []) g.states.add(s);
94
+ }
95
+ }
96
+ if (kept.length === lines.length) { real.push(p); continue; }
97
+ real.push({
98
+ ...p,
99
+ ...(p.counts ? { counts: countsOf(kept) } : {}),
100
+ lines: kept,
101
+ });
102
+ }
103
+ const chrome = new Map();
104
+ const made = [];
105
+ for (const surface of order) {
106
+ const bySlug = gathered.get(surface);
107
+ const list = [];
108
+ for (const spec of CHROME) {
109
+ const g = bySlug.get(spec.slug);
110
+ if (!g) continue;
111
+ const lines = [...g.lines.values()].map((l, i) => ({ ...l, key: key(i + 1) }));
112
+ const id = chromePageId(surface, spec.slug);
113
+ made.push({
114
+ id,
115
+ surface,
116
+ title: spec.title,
117
+ states_read: [...g.states],
118
+ files_hash: hash([...g.sources].map((s) => String(s.files_hash || ''))),
119
+ reading_hash: hash(lines.map((l) => `${l.section}\t${l.text}`)),
120
+ counts: countsOf(lines),
121
+ omitted: {},
122
+ notes: [`The ${spec.title.toLowerCase()} shown on every ${surface} page, each line once. Read from ${g.sources.size} pages.`],
123
+ lines,
124
+ });
125
+ list.push({ id, spec, from: g.from, sources: g.sources, lines });
126
+ }
127
+ chrome.set(surface, list);
128
+ }
129
+ const out = { ...readings, pages: [...real, ...made] };
130
+ if (readings.counts) {
131
+ // Totals are counted from the lines, not from each page's own counts: a
132
+ // reading need not carry them.
133
+ const all = countsOf(out.pages.flatMap((p) => p.lines || []));
134
+ out.counts = { ...readings.counts, pages: out.pages.length, ...all };
135
+ }
136
+ return { readings: out, chrome };
137
+ }
138
+
139
+ // inventory -> inventory, with the chrome pages of each surface after that
140
+ // surface's own pages. A chrome page opens on the first page it was read from
141
+ // (the live page shows it), and its files are the ones its lines come from, so
142
+ // the claim's touches and the "changed since" read name the shell, not a page.
143
+ function addChromePages(inventory, chrome) {
144
+ const pages = Array.isArray(inventory && inventory.pages) ? inventory.pages : [];
145
+ const byId = new Map(pages.map((p) => [p.id, p]));
146
+ const added = [];
147
+ for (const [surface, list] of chrome) {
148
+ for (const c of list) {
149
+ const src = byId.get(c.from.id) || {};
150
+ const files = [...new Set(c.lines.map((l) => l.file).filter(Boolean))];
151
+ added.push({
152
+ id: c.id,
153
+ surface,
154
+ module: src.module || null,
155
+ page: c.spec.slug,
156
+ id_from: 'shared-chrome',
157
+ title: c.spec.title,
158
+ document_title: `${c.spec.title} — shown on every ${surface} page`,
159
+ path: src.path || null,
160
+ url: src.url || null,
161
+ files,
162
+ states_files: [],
163
+ states: [],
164
+ });
165
+ }
166
+ }
167
+ const all = [...pages, ...added];
168
+ const out = { ...inventory, pages: all };
169
+ if (inventory.counts) out.counts = { ...inventory.counts, pages: all.length };
170
+ if (Array.isArray(inventory.surfaces)) {
171
+ const tally = new Map();
172
+ for (const p of all) tally.set(p.surface, (tally.get(p.surface) || 0) + 1);
173
+ out.surfaces = inventory.surfaces.map((s) => ({ ...s, pages: tally.get(s.id) || 0 }));
174
+ }
175
+ return out;
176
+ }
177
+
178
+ module.exports = { isChromeLine, isChromePageId, splitReadings, addChromePages };
@@ -24,6 +24,7 @@
24
24
 
25
25
  const fs = require('node:fs');
26
26
  const path = require('node:path');
27
+ const chromePages = require('./chrome-pages');
27
28
 
28
29
  const ROOT = path.resolve(__dirname, '..', '..');
29
30
 
@@ -73,7 +74,38 @@ function load(which) {
73
74
  return value;
74
75
  }
75
76
 
76
- function loadInventory() { return load('inventory'); }
77
- function loadReadings() { return load('readings'); }
77
+ // The shared chrome (skip link, top bar, navigation, footer) is read out of
78
+ // every page and handed back as pages of its own (chrome-pages.js, task
79
+ // 1004904). Derived per raw load, so the committed files stay what the
80
+ // generators wrote; a WeakMap keeps one derivation per cached read.
81
+ const _derived = new WeakMap();
82
+ function derive() {
83
+ const rawReadings = load('readings');
84
+ if (!rawReadings.ok) return null;
85
+ const hit = _derived.get(rawReadings);
86
+ if (hit) return hit;
87
+ const split = chromePages.splitReadings(rawReadings.data);
88
+ const value = { readings: { ok: true, data: split.readings, at: rawReadings.at }, chrome: split.chrome };
89
+ _derived.set(rawReadings, value);
90
+ return value;
91
+ }
92
+
93
+ const _derivedInventory = new WeakMap();
94
+ function loadInventory() {
95
+ const raw = load('inventory');
96
+ if (!raw.ok) return raw;
97
+ const d = derive();
98
+ if (!d) return raw;
99
+ const key = d.readings;
100
+ const hit = _derivedInventory.get(raw);
101
+ if (hit && hit.key === key) return hit.value;
102
+ const value = { ok: true, data: chromePages.addChromePages(raw.data, d.chrome), at: raw.at };
103
+ _derivedInventory.set(raw, { key, value });
104
+ return value;
105
+ }
106
+ function loadReadings() {
107
+ const d = derive();
108
+ return d ? d.readings : load('readings');
109
+ }
78
110
 
79
111
  module.exports = { ARTIFACTS, loadInventory, loadReadings, _setRoot };
@@ -0,0 +1,127 @@
1
+ // modules/copy-desk/tests/copy_chrome_pages.mjs — the shared page chrome as its
2
+ // own pages (task 1004904), pure.
3
+ //
4
+ // The skip link, top bar, navigation and footer are on every page of a surface.
5
+ // A real page's reading must not carry them, and each surface must gain one
6
+ // page per chrome section, each line once. Run over a small hand-made reading
7
+ // (the rules) and over the committed artifacts (the real data holds them).
8
+
9
+ import { strict as assert } from 'node:assert';
10
+ import { createRequire } from 'node:module';
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const chrome = require('../chrome-pages.js');
14
+ const pageData = require('../page-data.js');
15
+
16
+ let passed = 0;
17
+ let failed = 0;
18
+ function test(name, fn) {
19
+ try { fn(); passed++; console.log(` ok ${name}`); }
20
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
21
+ }
22
+
23
+ const L = (key, section, text, placement, row) => ({ key, section, text, placement, ...(row || {}) });
24
+ const READINGS = {
25
+ counts: { pages: 3, lines: 9, placed: 3, shared: 5, unplaced: 1 },
26
+ pages: [
27
+ { id: 'landing:index', surface: 'landing', reading_hash: 'h-index', files_hash: 'f1', states_read: ['in'], lines: [
28
+ L('L0001', 'skip link', 'Skip to content', 'shared', { file: 'shell.js', line: 9, string_id: 'a1' }),
29
+ L('L0002', 'top bar', 'Projects', 'shared', { file: 'nav.js', line: 3, string_id: 'a2' }),
30
+ L('L0003', 'hero', 'Build here', 'placed', { file: 'index.html', line: 4, string_id: 'a3' }),
31
+ ] },
32
+ { id: 'landing:account', surface: 'landing', reading_hash: 'h-acct', counts: { lines: 5, placed: 1, shared: 3, unplaced: 1 }, files_hash: 'f2', states_read: ['in', 'out'], lines: [
33
+ L('L0001', 'skip link', 'Skip to content', 'shared'),
34
+ L('L0002', 'top bar', 'Projects', 'shared'),
35
+ L('L0003', 'top bar', 'Sign in', 'shared'),
36
+ L('L0004', 'hero', 'Your account', 'placed'),
37
+ L('L0005', 'list', 'Something unplaced', 'unplaced'),
38
+ ] },
39
+ // No surface on the reading: it is read from the id.
40
+ { id: 'status:explore', reading_hash: 'h-exp', files_hash: 'f3', lines: [L('L0001', 'hero', 'Explore', 'placed')] },
41
+ ],
42
+ };
43
+ const INVENTORY = {
44
+ counts: { pages: 3, surfaces: 2 },
45
+ surfaces: [{ id: 'landing', pages: 2 }, { id: 'status', pages: 1 }],
46
+ pages: [
47
+ { id: 'landing:index', surface: 'landing', module: 'public-landing', page: 'index', title: 'Home', path: '/', url: 'https://{apex}/', files: ['index.html'] },
48
+ { id: 'landing:account', surface: 'landing', module: 'public-landing', page: 'account', title: 'Account', path: '/account', url: 'https://{apex}/account', files: ['account.html'] },
49
+ { id: 'status:explore', surface: 'status', module: 'status-ui', page: 'explore', title: 'Explore', path: '/explore', url: 'https://status.{apex}/explore', files: ['explore.html'] },
50
+ ],
51
+ };
52
+
53
+ const split = chrome.splitReadings(READINGS);
54
+ const byId = (rd, id) => rd.pages.find((p) => p.id === id);
55
+
56
+ test('a real page no longer carries its chrome, and keeps its own lines and keys', () => {
57
+ assert.deepEqual(byId(split.readings, 'landing:index').lines.map((l) => [l.key, l.section]), [['L0003', 'hero']]);
58
+ assert.deepEqual(byId(split.readings, 'landing:account').lines.map((l) => l.key), ['L0004', 'L0005']);
59
+ assert.equal(byId(split.readings, 'landing:index').reading_hash, 'h-index', 'a draft on the reading stays on it');
60
+ assert.deepEqual(byId(split.readings, 'landing:account').counts, { lines: 2, placed: 1, shared: 0, unplaced: 1 });
61
+ });
62
+
63
+ test('each chrome section becomes one page per surface, every distinct line once, in first-seen order', () => {
64
+ const top = byId(split.readings, 'landing:top-bar');
65
+ assert.deepEqual(top.lines.map((l) => [l.key, l.text]), [['L0001', 'Projects'], ['L0002', 'Sign in']]);
66
+ assert.deepEqual(byId(split.readings, 'landing:skip-link').lines.map((l) => l.text), ['Skip to content']);
67
+ assert.equal(byId(split.readings, 'landing:footer'), undefined, 'a surface with no footer gains no footer page');
68
+ assert.equal(byId(split.readings, 'status:top-bar'), undefined, 'a surface with no chrome gains no chrome page');
69
+ });
70
+
71
+ test('a chrome line keeps the registry row of its first occurrence that has one', () => {
72
+ const top = byId(split.readings, 'landing:top-bar').lines[0];
73
+ assert.deepEqual([top.file, top.line, top.string_id, top.placement], ['nav.js', 3, 'a2', 'shared']);
74
+ });
75
+
76
+ test('the reading totals follow the pages', () => {
77
+ assert.equal(split.readings.counts.pages, 5);
78
+ assert.equal(split.readings.counts.lines, 1 + 2 + 1 + 2 + 1);
79
+ assert.equal(split.readings.counts.shared, 3);
80
+ });
81
+
82
+ test('the inventory gains the same pages, after their surface, opening on a page that shows them', () => {
83
+ const inv = chrome.addChromePages(INVENTORY, split.chrome);
84
+ assert.deepEqual(inv.pages.map((p) => p.id), ['landing:index', 'landing:account', 'status:explore', 'landing:top-bar', 'landing:skip-link']);
85
+ const top = inv.pages.find((p) => p.id === 'landing:top-bar');
86
+ assert.deepEqual([top.surface, top.page, top.title, top.url], ['landing', 'top-bar', 'Top bar', 'https://{apex}/']);
87
+ assert.deepEqual(top.files, ['nav.js']);
88
+ assert.equal(inv.counts.pages, 5);
89
+ assert.deepEqual(inv.surfaces.map((s) => [s.id, s.pages]), [['landing', 4], ['status', 1]]);
90
+ });
91
+
92
+ test('a chrome page id is told from a page id', () => {
93
+ assert.equal(chrome.isChromePageId('landing:top-bar'), true);
94
+ assert.equal(chrome.isChromePageId('landing:index'), false);
95
+ });
96
+
97
+ test('only a shared line in a chrome section is chrome', () => {
98
+ assert.equal(chrome.isChromeLine(L('L1', 'top bar', 'x', 'placed')), false);
99
+ assert.equal(chrome.isChromeLine(L('L1', 'hero', 'x', 'shared')), false);
100
+ assert.equal(chrome.isChromeLine(L('L1', 'top bar', 'x', 'shared')), true);
101
+ });
102
+
103
+ test('the committed readings: no real page lists a skip link, top bar, navigation or footer line', () => {
104
+ const rd = pageData.loadReadings();
105
+ assert.equal(rd.ok, true);
106
+ for (const p of rd.data.pages) {
107
+ if (chrome.isChromePageId(p.id)) continue;
108
+ const bad = p.lines.filter(chrome.isChromeLine);
109
+ assert.equal(bad.length, 0, `${p.id} still lists ${bad.length} chrome lines`);
110
+ }
111
+ });
112
+
113
+ test('the committed data: every surface that had a top bar has a Top bar page, in both reads', () => {
114
+ const rd = pageData.loadReadings().data;
115
+ const inv = pageData.loadInventory().data;
116
+ for (const surface of ['builders', 'landing', 'status']) {
117
+ const id = `${surface}:top-bar`;
118
+ const r = rd.pages.find((p) => p.id === id);
119
+ assert.ok(r && r.lines.length > 0, `${id} in the readings`);
120
+ assert.ok(inv.pages.find((p) => p.id === id), `${id} in the inventory`);
121
+ assert.equal(new Set(r.lines.map((l) => l.text)).size, r.lines.length, `${id} lists each line once`);
122
+ }
123
+ assert.equal(rd.pages.length, inv.pages.length, 'the two reads name the same pages');
124
+ });
125
+
126
+ console.log(`\n${passed} passed, ${failed} failed`);
127
+ process.exit(failed ? 1 : 0);
@@ -70,7 +70,7 @@ const stripComments = (src) => src
70
70
  // Task 1004435 added routes/copy-desk-artist.js (one artist's approved pages, a
71
71
  // read) and artist-stats.js's readArtistPages under it.
72
72
  const ROUTE_FILES = fs.readdirSync(path.join(MODULE_DIR, 'routes')).filter((f) => f.endsWith('.js')).sort().map((f) => `routes/${f}`);
73
- const MODULE_FILES = ['registry.js', 'flags.js', 'proposals.js', 'pages.js', 'page-status.js', 'page-data.js', 'docx.js', 'page-decisions.js', 'artist-stats.js', 'auto-close.js', ...ROUTE_FILES];
73
+ const MODULE_FILES = ['registry.js', 'flags.js', 'proposals.js', 'pages.js', 'page-status.js', 'page-data.js', 'chrome-pages.js', 'docx.js', 'page-decisions.js', 'artist-stats.js', 'auto-close.js', ...ROUTE_FILES];
74
74
 
75
75
  console.log('copy-desk: NO LIVE CMS');
76
76
 
@@ -169,6 +169,79 @@ async function grantRankPermission({ rankKey, permissionKey }) {
169
169
  return rowCount > 0;
170
170
  }
171
171
 
172
+ // Add a recommended rank set in ONE transaction and three statements (task
173
+ // 1004929) — the batched form of "createRank, then grantRankPermission per key",
174
+ // which used to cost a read, an insert and one insert per grant for every rank,
175
+ // each its own round trip and none of them atomic. A rank that already exists is
176
+ // LEFT ALONE: it is neither inserted nor granted, and a rank a concurrent call
177
+ // created between the read and the insert is caught by ON CONFLICT DO NOTHING —
178
+ // the batched twin of the old per-row 23505 catch. Grants go only to the ranks
179
+ // THIS call created. Still a dumb write: `permissionKeys` arrive screened against
180
+ // the catalog by the route (routes/government.js), which owns that decision.
181
+ //
182
+ // `ranks`: [{ rankKey, label, description, permissionKeys }], in set order.
183
+ // Returns a Map of rank_key → the permission keys actually granted, in input
184
+ // order, for each rank this call created; a key absent from the Map was skipped.
185
+ async function applyRankSet({ ranks, createdBy }) {
186
+ const firstByKey = new Map();
187
+ for (const r of ranks) if (!firstByKey.has(r.rankKey)) firstByKey.set(r.rankKey, r);
188
+ const wanted = [...firstByKey.values()];
189
+ const created = new Map();
190
+ if (wanted.length === 0) return created;
191
+ const client = await pool.connect();
192
+ try {
193
+ await client.query('BEGIN');
194
+ const { rows: existing } = await client.query(
195
+ `SELECT rank_key FROM government_ranks WHERE rank_key = ANY($1::text[])`,
196
+ [wanted.map((r) => r.rankKey)],
197
+ );
198
+ const have = new Set(existing.map((row) => row.rank_key));
199
+ const fresh = wanted.filter((r) => !have.has(r.rankKey));
200
+ if (fresh.length > 0) {
201
+ // WITH ORDINALITY + ORDER BY keeps the ids in set order, as the loop did.
202
+ const { rows: inserted } = await client.query(
203
+ `INSERT INTO government_ranks (rank_key, label, description, is_system, seeded_rank, created_by)
204
+ SELECT r.rank_key, r.label, r.description, false, NULL, $4::bigint
205
+ FROM unnest($1::text[], $2::text[], $3::text[]) WITH ORDINALITY AS r(rank_key, label, description, ord)
206
+ ORDER BY r.ord
207
+ ON CONFLICT DO NOTHING
208
+ RETURNING rank_key`,
209
+ [fresh.map((r) => r.rankKey), fresh.map((r) => r.label), fresh.map((r) => r.description ?? null), createdBy],
210
+ );
211
+ const made = new Set(inserted.map((row) => row.rank_key));
212
+ const grantRanks = [];
213
+ const grantKeys = [];
214
+ for (const r of fresh) {
215
+ if (!made.has(r.rankKey)) continue;
216
+ created.set(r.rankKey, []);
217
+ for (const key of new Set(r.permissionKeys)) { grantRanks.push(r.rankKey); grantKeys.push(key); }
218
+ }
219
+ if (grantKeys.length > 0) {
220
+ const { rows: granted } = await client.query(
221
+ `INSERT INTO government_rank_permissions (rank_key, permission_key)
222
+ SELECT g.rank_key, g.permission_key
223
+ FROM unnest($1::text[], $2::text[]) WITH ORDINALITY AS g(rank_key, permission_key, ord)
224
+ ORDER BY g.ord
225
+ ON CONFLICT (rank_key, permission_key) DO NOTHING
226
+ RETURNING rank_key, permission_key`,
227
+ [grantRanks, grantKeys],
228
+ );
229
+ const landed = new Set(granted.map((row) => `${row.rank_key}\u0000${row.permission_key}`));
230
+ grantRanks.forEach((rk, i) => {
231
+ if (landed.has(`${rk}\u0000${grantKeys[i]}`)) created.get(rk).push(grantKeys[i]);
232
+ });
233
+ }
234
+ }
235
+ await client.query('COMMIT');
236
+ } catch (err) {
237
+ await client.query('ROLLBACK').catch(() => {});
238
+ throw err;
239
+ } finally {
240
+ client.release();
241
+ }
242
+ return created;
243
+ }
244
+
172
245
  async function revokeRankPermission({ rankKey, permissionKey }) {
173
246
  const { rowCount } = await pool.query(
174
247
  `DELETE FROM government_rank_permissions
@@ -1165,6 +1238,7 @@ module.exports = {
1165
1238
  getRankByKey,
1166
1239
  createRank,
1167
1240
  grantRankPermission,
1241
+ applyRankSet,
1168
1242
  revokeRankPermission,
1169
1243
  resetRankGrants,
1170
1244
  deleteCustomRank,
@@ -434,40 +434,50 @@ module.exports = function buildGovernmentRouter() {
434
434
 
435
435
  // rank: perm government.manage — add a RECOMMENDED RANK SET (task 1004400):
436
436
  // the custom ranks recommendations.js names for a project type and size, each
437
- // with its grants. It is the create form and the grant toggle run in a loop,
438
- // under the same checks: a rank that already exists is LEFT ALONE (never
437
+ // with its grants. It does what the create form and the grant toggle do, for
438
+ // every rank in the set, under the same checks: a rank that already exists is LEFT ALONE (never
439
439
  // re-granted, never relabelled — an owner may have edited it), and every grant
440
440
  // is re-checked with catalog.isGrantableTo, so a set can never write what the
441
441
  // toggle would refuse. Re-applying is safe; the reply says what was added.
442
+ //
443
+ // task 1004929: the whole set is written by db.applyRankSet in ONE transaction
444
+ // and three statements, instead of a read + insert + one insert per grant for
445
+ // every rank. A rank this route creates is always a CUSTOM rank (is_system
446
+ // false, no seeded_rank — what createRank returns), so each grant is screened
447
+ // against that shape before the write, exactly as the toggle would screen it.
448
+ // A failure now writes nothing, so a 500 reports empty created/skipped lists.
442
449
  router.post('/government/rank-sets/apply', api.requireBuilder, api.requirePermission('government.manage'), async (req, res) => {
443
450
  if (validateOrRespond(req, res, {
444
451
  type: { type: 'string', maxLength: 32 },
445
452
  headcount: { required: true, type: 'integer', min: 0, max: 100000 },
446
453
  })) return;
447
454
  const set = recommendations.recommendedRankSet(req.body.type, req.body.headcount);
448
- const created = [];
449
- const skipped = [];
455
+ const customRank = { is_system: false, seeded_rank: null };
450
456
  try {
457
+ const granted = await db.applyRankSet({
458
+ createdBy: req.builder.id,
459
+ ranks: set.ranks.map((r) => ({
460
+ rankKey: r.rank_key,
461
+ label: r.label,
462
+ description: r.description,
463
+ permissionKeys: r.permissions.filter((key) => catalog.byKey(key) && catalog.isGrantableTo(key, customRank)),
464
+ })),
465
+ });
466
+ const created = [];
467
+ const skipped = [];
451
468
  for (const r of set.ranks) {
452
- if (await db.getRankByKey(r.rank_key)) { skipped.push({ rank_key: r.rank_key, label: r.label }); continue; }
453
- let rank;
454
- try {
455
- rank = await db.createRank({ rankKey: r.rank_key, label: r.label, description: r.description, createdBy: req.builder.id });
456
- } catch (err) {
457
- if (err && err.code === '23505') { skipped.push({ rank_key: r.rank_key, label: r.label }); continue; }
458
- throw err;
459
- }
460
- const granted = [];
461
- for (const key of r.permissions) {
462
- if (!catalog.byKey(key) || !catalog.isGrantableTo(key, rank)) continue;
463
- if (await db.grantRankPermission({ rankKey: r.rank_key, permissionKey: key })) granted.push(key);
469
+ // delete-on-read: a key named twice in a set is created once, then skipped.
470
+ if (granted.has(r.rank_key)) {
471
+ created.push({ rank_key: r.rank_key, label: r.label, permissions: granted.get(r.rank_key) });
472
+ granted.delete(r.rank_key);
473
+ } else {
474
+ skipped.push({ rank_key: r.rank_key, label: r.label });
464
475
  }
465
- created.push({ rank_key: r.rank_key, label: r.label, permissions: granted });
466
476
  }
467
477
  res.json({ ok: true, type: set.type, size: set.size, created, skipped });
468
478
  } catch (err) {
469
479
  log.error('[gds] POST /government/rank-sets/apply', err);
470
- res.fail('rank_set_apply_failed', { status: 500, message: 'internal error', details: { created, skipped } });
480
+ res.fail('rank_set_apply_failed', { status: 500, message: 'internal error', details: { created: [], skipped: [] } });
471
481
  }
472
482
  });
473
483
 
@@ -183,18 +183,23 @@ function runSubagent({ prompt, opts = {} }) {
183
183
  // agentic Read turns re-opening the same changed files. Read them ONCE here
184
184
  // and hand every worker the post-change contents inline (fenced, untrusted),
185
185
  // so a worker's Read tool is only needed for files beyond the bundle.
186
+ //
187
+ // task 1004932: the bundle rides in EVERY worker prompt, so its total is paid
188
+ // once per worker; 120k chars made it the largest fixed input of a grade.
186
189
  const CONTEXT_BUNDLE_MAX_FILES = 24;
187
- const CONTEXT_BUNDLE_MAX_TOTAL_CHARS = 120_000;
190
+ const CONTEXT_BUNDLE_MAX_TOTAL_CHARS = 60_000;
188
191
  const CONTEXT_BUNDLE_MAX_FILE_CHARS = 16_000;
189
192
  // Binary-ish extensions never worth inlining (the diff already names them).
190
193
  const CONTEXT_BUNDLE_SKIP_RE = /\.(?:png|jpe?g|gif|webp|ico|svgz|woff2?|ttf|eot|zip|gz|tgz|tar|pdf|mp[34]|wav|ogg|bin|exe|dll|node)$/i;
191
194
 
192
- // Added lines per file, from the unified diff. Only lines long enough to be a
193
- // meaningful fingerprint (a bare `}` or `});` matches anywhere) — used to prove
194
- // a truncated bundle prefix actually SHOWS the change (task 1002905).
195
+ // One pass over a unified diff, per file: how many lines it adds (a relevance
196
+ // weight), and the added lines long enough to be a meaningful fingerprint (a bare
197
+ // `}` or `});` matches anywhere) — used to prove a truncated bundle prefix
198
+ // actually SHOWS the change (task 1002905).
195
199
  const BUNDLE_EVIDENCE_MIN_CHARS = 12;
196
- function diffAddedLinesByFile(diff) {
197
- const byFile = new Map();
200
+ function scanDiff(diff) {
201
+ const evidence = new Map();
202
+ const added = new Map();
198
203
  let cur = null;
199
204
  let prevWasOldHeader = false;
200
205
  for (const line of String(diff || '').split('\n')) {
@@ -209,12 +214,26 @@ function diffAddedLinesByFile(diff) {
209
214
  prevWasOldHeader = line.startsWith('--- ');
210
215
  if (prevWasOldHeader || line.startsWith('diff --git')) continue;
211
216
  if (!cur || !line.startsWith('+')) continue;
217
+ added.set(cur, (added.get(cur) || 0) + 1);
212
218
  const t = line.slice(1).trim();
213
219
  if (t.length < BUNDLE_EVIDENCE_MIN_CHARS) continue;
214
- if (!byFile.has(cur)) byFile.set(cur, []);
215
- byFile.get(cur).push(t);
220
+ if (!evidence.has(cur)) evidence.set(cur, []);
221
+ evidence.get(cur).push(t);
216
222
  }
217
- return byFile;
223
+ return { evidence, added };
224
+ }
225
+ function diffAddedLinesByFile(diff) { return scanDiff(diff).evidence; }
226
+
227
+ // The order the bundle is filled in (task 1004932): the files the diff adds the
228
+ // most lines to first, so when the total cap bites it is the lightly touched
229
+ // files (a one-line doc pointer, a generated index) that fall to skipped[] for
230
+ // the worker to Read, not the heart of the change. Stable: ties, and every file
231
+ // when there is no diff, keep the caller's order.
232
+ function orderByChange(changedFiles, added) {
233
+ return changedFiles
234
+ .map((file, i) => ({ file, i, n: added.get(file) || 0 }))
235
+ .sort((a, b) => (b.n - a.n) || (a.i - b.i))
236
+ .map((x) => x.file);
218
237
  }
219
238
 
220
239
  // Pure-ish (fs reads only, injectable): returns { files: [{file, content,
@@ -241,8 +260,8 @@ function buildContextBundle({ changedFiles, cwd, diff, fsImpl = fs } = {}) {
241
260
  // exactly the pre-change state). A prefix that contains none of the change is
242
261
  // worse than no bundle at all, so refuse it: skipped[] already tells the worker
243
262
  // to Read the file.
244
- const addedByFile = diffAddedLinesByFile(diff);
245
- for (const rel of (Array.isArray(changedFiles) ? changedFiles : [])) {
263
+ const { evidence: addedByFile, added } = scanDiff(diff);
264
+ for (const rel of orderByChange(Array.isArray(changedFiles) ? changedFiles : [], added)) {
246
265
  if (files.length >= CONTEXT_BUNDLE_MAX_FILES || total >= CONTEXT_BUNDLE_MAX_TOTAL_CHARS) {
247
266
  skipped.push(rel);
248
267
  continue;
@@ -106,24 +106,24 @@
106
106
  </div>
107
107
 
108
108
  <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
109
- <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
110
- <script src="/builders/craft-mode.js?v=2026-10-09-living3"></script>
111
- <script src="/builders/shell.js?v=2026-10-25-escape"></script>
109
+ <script src="/builders/shell-trail.js?v=2026-10-08-city-tab" defer></script>
110
+ <script src="/builders/craft-mode.js?v=2026-10-09-living3" defer></script>
111
+ <script src="/builders/shell.js?v=2026-10-25-escape" defer></script>
112
112
  <script src="/builders/palette.js" defer></script>
113
- <script src="/builders/gov-living.js?v=2026-10-09-positions1"></script>
114
- <script src="/builders/gov-feeds.js?v=2026-10-09-living3"></script>
115
- <script src="/builders/city-draw.js?v=2026-10-17-tidy"></script>
113
+ <script src="/builders/gov-living.js?v=2026-10-09-positions1" defer></script>
114
+ <script src="/builders/gov-feeds.js?v=2026-10-09-living3" defer></script>
115
+ <script src="/builders/city-draw.js?v=2026-10-17-tidy" defer></script>
116
116
  <!-- a building's card answers its first ask in place through the deck's own
117
117
  route (task 1004702): the desk rows (street-desk.js over the street's kit)
118
118
  and the deck's card and sender (street-deck.js) -->
119
- <script src="/builders/hall-kit.js?v=2026-10-23-decide12"></script>
120
- <script src="/builders/street-draw.js?v=2026-10-09-living3"></script>
121
- <script src="/builders/street-card.js?v=2026-10-04-record1"></script>
122
- <script src="/builders/street-inside.js?v=2026-10-09-living3"></script>
123
- <script src="/builders/street-desk.js?v=2026-10-04-record1"></script>
124
- <script src="/builders/street-deck.js?v=2026-10-13-glass1"></script>
125
- <script src="/builders/city-decide.js?v=2026-10-23-decide12"></script>
126
- <script src="/builders/city-switchboard.js?v=2026-10-23-board5"></script>
127
- <script src="/builders/city.js?v=2026-10-23-decide12"></script>
119
+ <script src="/builders/hall-kit.js?v=2026-10-23-decide12" defer></script>
120
+ <script src="/builders/street-draw.js?v=2026-10-09-living3" defer></script>
121
+ <script src="/builders/street-card.js?v=2026-10-04-record1" defer></script>
122
+ <script src="/builders/street-inside.js?v=2026-10-09-living3" defer></script>
123
+ <script src="/builders/street-desk.js?v=2026-10-04-record1" defer></script>
124
+ <script src="/builders/street-deck.js?v=2026-10-13-glass1" defer></script>
125
+ <script src="/builders/city-decide.js?v=2026-10-23-decide12" defer></script>
126
+ <script src="/builders/city-switchboard.js?v=2026-10-23-board5" defer></script>
127
+ <script src="/builders/city.js?v=2026-10-23-decide12" defer></script>
128
128
  </body>
129
129
  </html>