@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.
- package/.bongos-core.json +137 -67
- package/.claude/skills/goal-create/SKILL.md +5 -2
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +5 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +87 -0
- package/docs/adr/0386-the-funding-wall-is-a-per-project-rule-claim-time-hold-by-default.md +37 -0
- package/docs/adr/0387-two-ledgers-credits-measure-work-money-stays-with-the-processor.md +39 -0
- package/docs/adr/0388-a-module-sale-is-split-by-the-project-the-buyer-pays-the-platform-fee.md +42 -0
- package/docs/adr/0392-ai-generation-runs-on-a-processor-held-balance-and-the-builders-own-keys.md +33 -0
- package/docs/adr/README.md +5 -0
- package/docs/api/openapi.json +59 -4
- package/docs/api-reference.md +7 -1
- package/docs/file-map.md +3 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +1 -1
- package/modules/ai-studio/CLAUDE.md +15 -0
- package/modules/ai-studio/module.json +26 -0
- package/modules/ai-studio/routes/ai-studio.js +22 -0
- package/modules/copy-desk/chrome-pages.js +178 -0
- package/modules/copy-desk/page-data.js +34 -2
- package/modules/copy-desk/tests/copy_chrome_pages.mjs +127 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +1 -1
- package/modules/government/db.js +74 -0
- package/modules/government/routes/government.js +28 -18
- package/modules/grading/grader-subagent.js +30 -11
- package/modules/hall-ui/public/city.html +15 -15
- package/modules/lifecycle/db-analytics.js +10 -3
- package/modules/lifecycle/db-goals.js +30 -13
- package/modules/lifecycle/db-shared.js +9 -0
- package/modules/lifecycle/db-versions.js +21 -8
- package/modules/lifecycle/ship-assurance.js +4 -1
- package/modules/lifecycle/ship-card.js +8 -2
- package/modules/onboarding/howto-moments.js +8 -1
- package/modules/provisioning/starter-bundles.js +4 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +72 -0
- package/scripts/bongos/copy-apply.js +3 -1
- package/scripts/bongos/publish-manifest.js +1 -0
- package/scripts/bongos/strand-watch.js +106 -10
- package/scripts/bongos/tweak-renders.js +9 -2
- package/src/bongos/module-scope-map.js +4 -0
- package/src/module-api.js +1 -1
- package/src/platform-server.js +27 -1
- package/tests/batched_writes_parity.mjs +340 -0
- package/tests/conductor_main_e2e.mjs +4 -0
- package/tests/copy_desk_page_docx.mjs +3 -3
- package/tests/copy_desk_page_editor.mjs +6 -6
- package/tests/copy_desk_page_next.mjs +3 -2
- package/tests/copy_desk_page_reads.mjs +2 -1
- package/tests/copy_desk_page_submit.mjs +30 -2
- package/tests/fixtures/per-row-loops-legacy.cjs +245 -0
- package/tests/goal_archive_disposition.mjs +5 -5
- package/tests/government_routes.mjs +145 -22
- package/tests/grader.mjs +31 -0
- package/tests/hall_city.mjs +8 -0
- package/tests/hook_shell_tools.mjs +8 -0
- package/tests/hooks_drain.mjs +1 -1
- package/tests/host_topology_skips.mjs +2 -0
- package/tests/howto_moments.mjs +17 -0
- package/tests/module_loader.mjs +1 -1
- package/tests/module_skills_hook.mjs +69 -0
- package/tests/profile_role_stats.mjs +2 -1
- package/tests/public_landing_projects.mjs +37 -0
- package/tests/session_start_freshness.mjs +66 -1
- package/tests/shell_hooks.mjs +209 -0
- package/tests/ship_assurance.mjs +28 -0
- package/tests/ship_card.mjs +20 -5
- package/tests/strand_watch.mjs +48 -0
- 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
|
-
|
|
77
|
-
|
|
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
|
|
package/modules/government/db.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
}
|
|
457
|
-
|
|
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 =
|
|
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
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
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
|
|
197
|
-
const
|
|
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 (!
|
|
215
|
-
|
|
220
|
+
if (!evidence.has(cur)) evidence.set(cur, []);
|
|
221
|
+
evidence.get(cur).push(t);
|
|
216
222
|
}
|
|
217
|
-
return
|
|
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 =
|
|
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>
|