@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
@@ -142,6 +142,24 @@ function stampAssets(html) {
142
142
  assetStamp(url) ? `${attr}="${url}?v=${assetStamp(url)}"` : m);
143
143
  }
144
144
 
145
+ // The cache lifetime a landing asset is served with (task 1004925). A URL whose
146
+ // ?v= is the file's CURRENT stamp can never name different bytes — a new file
147
+ // means a new stamp, so a new URL — so the browser may keep it for a year instead
148
+ // of re-fetching it after Cloudflare's four-hour default runs out. Only an exact
149
+ // match earns that, the hall's rule (serve-internal.js, task 1004855): a stale or
150
+ // made-up ?v=, or a bare URL, keeps whatever lifetime it had. Images and fonts
151
+ // that no page stamps (a css url(), a script-built src) get one day, up from four
152
+ // hours; everything else is left to the default. Returns null for "no header".
153
+ const MEDIA_ASSET = /\.(?:png|jpe?g|webp|avif|gif|svg|ico|woff2?|ttf|otf)$/i;
154
+ function landingAssetCache(urlPath, v) {
155
+ if (typeof urlPath !== 'string') return null;
156
+ if (typeof v === 'string' && /^[0-9a-f]{8}$/.test(v) && assetStamp(urlPath) === v) {
157
+ return 'public, max-age=31536000, immutable';
158
+ }
159
+ if (MEDIA_ASSET.test(urlPath)) return 'public, max-age=86400';
160
+ return null;
161
+ }
162
+
145
163
  const _landingPages = new Map(); // memoized per file; '' if absent (instance ships no landing)
146
164
  function landingPage(file) {
147
165
  if (!_landingPages.has(file)) {
@@ -364,7 +382,15 @@ function buildPlatformApp() {
364
382
  });
365
383
  // Assets fall through to the 404 tail when no module claims the apex root — the
366
384
  // handler is not mounted at all rather than pointed at a missing directory.
367
- const landingAssets = landingDir() && express.static(landingDir(), { index: false, fallthrough: true });
385
+ const landingAssets = landingDir() && express.static(landingDir(), {
386
+ index: false,
387
+ fallthrough: true,
388
+ setHeaders: (res) => {
389
+ const req = res.req || {};
390
+ const cache = landingAssetCache(req.path, req.query && req.query.v);
391
+ if (cache) res.setHeader('Cache-Control', cache);
392
+ },
393
+ });
368
394
  if (landingAssets) {
369
395
  app.use((req, res, next) => {
370
396
  if (!isApexHost(req)) return next();
@@ -0,0 +1,340 @@
1
+ // tests/batched_writes_parity.mjs — the batched goal archive, version close and
2
+ // weekly karma tally give the SAME results as the one-query-per-row loops they
3
+ // replaced (task 1004929).
4
+ //
5
+ // Each case runs the old loop (tests/fixtures/per-row-loops-legacy.cjs, a
6
+ // verbatim copy) and the new code over the SAME seeded fake database, then
7
+ // compares what each returned or threw AND what each left in the tables. The
8
+ // fake is a small interpreter for exactly the statements both versions send —
9
+ // old per-row reads and new batched reads answer from one store — and it hands
10
+ // bigint ids back as STRINGS, as node-pg does, so a plan that carries numbers is
11
+ // matched back to rows the way production matches them. BEGIN snapshots the
12
+ // store and ROLLBACK restores it, so a thrown run is compared on what survived.
13
+ //
14
+ // The rank-set apply parity lives in tests/government_routes.mjs, where the real
15
+ // router is already standing.
16
+ //
17
+ // Run: node --test tests/batched_writes_parity.mjs
18
+
19
+ import assert from 'node:assert/strict';
20
+ import { test } from 'node:test';
21
+ import { createRequire } from 'node:module';
22
+
23
+ process.env.NODE_ENV = 'test';
24
+ const require = createRequire(import.meta.url);
25
+ const { archiveGoalWithDispositions, closeVersion } = require('../modules/lifecycle/db.js');
26
+ const { tallyPeerVotes } = require('../modules/lifecycle/db-analytics.js');
27
+ const { idKey } = require('../modules/lifecycle/db-shared.js');
28
+ const pgPool = require('../src/bongos/pool').pool;
29
+ const legacy = require('./fixtures/per-row-loops-legacy.cjs');
30
+
31
+ const TERMINAL = new Set(['shipped', 'abandoned']);
32
+ const same = (a, b) => String(a) === String(b);
33
+ const inIds = (arr, v) => arr.some((x) => same(idKey(x), idKey(v)));
34
+ const clone = (v) => JSON.parse(JSON.stringify(v));
35
+
36
+ // ---- the fake database -------------------------------------------------------
37
+
38
+ function fakeDb(seed) {
39
+ let st = clone(seed);
40
+ let snap = null;
41
+ const queries = [];
42
+ const task = (id) => st.tasks.find((t) => same(t.id, idKey(id)));
43
+ const goal = (id) => st.goals.find((g) => same(g.id, idKey(id)));
44
+ const out = (row) => (row ? { ...row, id: String(row.id) } : row);
45
+ const openWork = (t) => !TERMINAL.has(t.status) && !t.title.startsWith('__smoke__');
46
+
47
+ async function query(sql, p = []) {
48
+ const s = String(sql).replace(/\s+/g, ' ').trim();
49
+ queries.push(s);
50
+ if (s === 'BEGIN') { snap = clone(st); return { rows: [] }; }
51
+ if (s === 'COMMIT') { snap = null; return { rows: [] }; }
52
+ if (s === 'ROLLBACK') { if (snap) st = snap; snap = null; return { rows: [] }; }
53
+
54
+ // ---- goal archive -------------------------------------------------------
55
+ if (/^SELECT .* FROM goals WHERE id = \$1 FOR UPDATE$/.test(s)) return { rows: [goal(p[0])].filter(Boolean).map(out) };
56
+ if (/FROM tasks WHERE id = \$1 AND goal_id = \$2 FOR UPDATE$/.test(s)) {
57
+ const t = task(p[0]);
58
+ return { rows: t && same(t.goal_id, p[1]) ? [out(t)] : [] };
59
+ }
60
+ if (/FROM tasks WHERE id = ANY\(\$1::bigint\[\]\) AND goal_id = \$2 ORDER BY id FOR UPDATE$/.test(s)) {
61
+ return { rows: st.tasks.filter((t) => inIds(p[0], t.id) && same(t.goal_id, p[1])).sort((a, b) => a.id - b.id).map(out) };
62
+ }
63
+ if (/^SELECT id FROM claims WHERE task_id = \$1 AND released_at IS NULL LIMIT 1$/.test(s)) {
64
+ return { rows: st.claims.filter((c) => same(c.task_id, idKey(p[0])) && !c.released_at).slice(0, 1).map((c) => ({ id: String(c.id) })) };
65
+ }
66
+ if (/^SELECT DISTINCT task_id FROM claims WHERE task_id = ANY\(\$1::bigint\[\]\) AND released_at IS NULL$/.test(s)) {
67
+ const ids = [...new Set(st.claims.filter((c) => inIds(p[0], c.task_id) && !c.released_at).map((c) => String(c.task_id)))];
68
+ return { rows: ids.map((task_id) => ({ task_id })) };
69
+ }
70
+ if (/^UPDATE tasks SET status = 'abandoned', .* WHERE id = \$1$/.test(s)) {
71
+ const t = task(p[0]); t.status = 'abandoned'; if (p[1]) t.value_summary = p[1];
72
+ return { rows: [], rowCount: 1 };
73
+ }
74
+ if (/^SELECT id, status FROM goals WHERE id = \$1 FOR SHARE$/.test(s)) return { rows: [goal(p[0])].filter(Boolean).map((g) => ({ id: String(g.id), status: g.status })) };
75
+ if (/^UPDATE tasks SET goal_id = \$2, updated_at = now\(\) WHERE id = \$1$/.test(s)) {
76
+ task(p[0]).goal_id = Number(p[1]);
77
+ return { rows: [], rowCount: 1 };
78
+ }
79
+ if (/count\(\*\)::int AS n FROM (tasks|goals)/.test(s)) return { rows: [{ n: 0 }] }; // the re-counts are not under test
80
+ if (/^UPDATE goal_membership_requests/.test(s)) return { rows: [], rowCount: 0 };
81
+
82
+ // ---- version close ------------------------------------------------------
83
+ if (/FROM versions WHERE id = \$1 FOR UPDATE$/.test(s)) return { rows: [{ id: p[0], status: 'building' }] };
84
+ if (/FROM goals WHERE id = \$1 AND version_id = \$2 FOR UPDATE$/.test(s)) {
85
+ const g = goal(p[0]);
86
+ return { rows: g && same(g.version_id, p[1]) ? [out(g)] : [] };
87
+ }
88
+ if (/FROM goals WHERE id = ANY\(\$1::bigint\[\]\) AND version_id = \$2 ORDER BY id FOR UPDATE$/.test(s)) {
89
+ return { rows: st.goals.filter((g) => inIds(p[0], g.id) && same(g.version_id, p[1])).sort((a, b) => a.id - b.id).map(out) };
90
+ }
91
+ if (/^UPDATE tasks SET status = 'abandoned', .* WHERE goal_id = \$1 AND .* RETURNING id$/.test(s)) {
92
+ const hit = st.tasks.filter((t) => same(t.goal_id, idKey(p[0])) && openWork(t));
93
+ for (const t of hit) { t.status = 'abandoned'; t.value_summary = p[1]; }
94
+ return { rows: hit.map((t) => ({ id: String(t.id) })) };
95
+ }
96
+ if (/^UPDATE goals SET status = 'archived', updated_at = now\(\) WHERE id = \$1/.test(s)) {
97
+ const g = goal(p[0]); g.status = 'archived';
98
+ return { rows: [out(g)], rowCount: 1 };
99
+ }
100
+ if (/^WITH next_order AS .* INSERT INTO goals/.test(s)) {
101
+ const src = goal(p[0]);
102
+ const id = st.nextGoalId++;
103
+ st.goals.push({ id, version_id: p[1], title: src.title, status: 'open', succeeded_by_goal_id: null });
104
+ return { rows: [{ id: String(id) }] };
105
+ }
106
+ if (/^INSERT INTO goal_members/.test(s)) return { rows: [], rowCount: 0 };
107
+ if (/^UPDATE tasks SET goal_id = \$2, version_id = \$3, .* RETURNING id$/.test(s)) {
108
+ const hit = st.tasks.filter((t) => same(t.goal_id, idKey(p[0])) && openWork(t));
109
+ for (const t of hit) { t.goal_id = Number(p[1]); t.version_id = p[2]; }
110
+ return { rows: hit.map((t) => ({ id: String(t.id) })) };
111
+ }
112
+ if (/^UPDATE goals SET succeeded_by_goal_id = \$2/.test(s)) { goal(p[0]).succeeded_by_goal_id = Number(p[1]); return { rows: [] }; }
113
+ if (/FROM tasks t LEFT JOIN goals g/.test(s)) return { rows: [] };
114
+ if (/^UPDATE versions SET status = 'shipped'/.test(s)) return { rows: [{ id: p[0], status: 'shipped' }] };
115
+
116
+ // ---- weekly karma tally -------------------------------------------------
117
+ if (/FROM peer_votes pv JOIN tasks t/.test(s)) {
118
+ return { rows: st.peerVotes.filter((v) => !v.tallied).map((v) => ({ id: String(v.id), builder_id: String(v.builder_id), vote: v.vote })) };
119
+ }
120
+ if (/to_regclass\('ideas_votes'\)/.test(s)) return { rows: [{ has: st.ideaVotes !== null }] };
121
+ if (/FROM ideas_votes iv JOIN idea_inbox i/.test(s)) {
122
+ return { rows: st.ideaVotes.filter((v) => !v.tallied).map((v) => ({ id: String(v.id), builder_id: String(v.builder_id), vote: v.vote })) };
123
+ }
124
+ if (/^INSERT INTO karma_log .* VALUES \(\$1, \$2, 'peer_vote_weekly', NULL\)$/.test(s)) {
125
+ st.karma.push({ builder_id: String(p[0]), delta: Number(p[1]), reason: 'peer_vote_weekly', granted_by: null });
126
+ return { rows: [], rowCount: 1 };
127
+ }
128
+ if (/^INSERT INTO karma_log .* FROM unnest\(\$1::bigint\[\], \$2::int\[\]\) WITH ORDINALITY .* ORDER BY k\.ord$/.test(s)) {
129
+ p[0].forEach((b, i) => st.karma.push({ builder_id: String(b), delta: Number(p[1][i]), reason: 'peer_vote_weekly', granted_by: null }));
130
+ return { rows: [], rowCount: p[0].length };
131
+ }
132
+ if (/^UPDATE (peer_votes|ideas_votes) SET tallied_at = now\(\) WHERE id = ANY/.test(s)) {
133
+ const list = s.startsWith('UPDATE peer_votes') ? st.peerVotes : st.ideaVotes;
134
+ for (const v of list) if (inIds(p[0], v.id)) v.tallied = true;
135
+ return { rows: [], rowCount: p[0].length };
136
+ }
137
+
138
+ throw new Error(`fake db: unhandled statement: ${s.slice(0, 120)}`);
139
+ }
140
+ const client = { query, release() {} };
141
+ return {
142
+ pool: { connect: async () => client, query },
143
+ client,
144
+ queries,
145
+ get state() { return clone(st); },
146
+ };
147
+ }
148
+
149
+ // Run a legacy loop the way its function ran it: inside one transaction, rolled
150
+ // back on a throw. Returns { result } or { error }.
151
+ async function runLegacyTx(db, fn) {
152
+ await db.client.query('BEGIN');
153
+ try {
154
+ const result = await fn(db.client);
155
+ await db.client.query('COMMIT');
156
+ return { result };
157
+ } catch (error) {
158
+ await db.client.query('ROLLBACK');
159
+ return { error };
160
+ }
161
+ }
162
+ async function runNew(fn) {
163
+ try { return { result: await fn() }; } catch (error) { return { error }; }
164
+ }
165
+ const errShape = (e) => (e ? { code: e.code, taskId: e.taskId, taskTitle: e.taskTitle, goalId: e.goalId, toGoalId: e.toGoalId, targetStatus: e.targetStatus } : null);
166
+
167
+ // ---- goal archive ------------------------------------------------------------
168
+
169
+ const ARCHIVE_SEED = {
170
+ goals: [
171
+ { id: 7, version_id: 'V1', title: 'the goal being archived', status: 'open' },
172
+ { id: 8, version_id: 'V1', title: 'a neighbour', status: 'open' },
173
+ { id: 9, version_id: 'V1', title: 'an open move target', status: 'open' },
174
+ { id: 10, version_id: 'V1', title: 'a closed move target', status: 'archived' },
175
+ ],
176
+ tasks: [
177
+ { id: 1, goal_id: 7, version_id: 'V1', status: 'ready', title: 'one', value_summary: null },
178
+ { id: 2, goal_id: 7, version_id: 'V1', status: 'shipped', title: 'two', value_summary: null },
179
+ { id: 3, goal_id: 8, version_id: 'V1', status: 'ready', title: 'three (elsewhere)', value_summary: null },
180
+ { id: 4, goal_id: 7, version_id: 'V1', status: 'active', title: 'four (claimed)', value_summary: null },
181
+ { id: 5, goal_id: 7, version_id: 'V1', status: 'backlog', title: 'five', value_summary: null },
182
+ { id: 6, goal_id: 7, version_id: 'V1', status: 'ready', title: 'six', value_summary: null },
183
+ ],
184
+ claims: [{ id: 99, task_id: 4, released_at: null }, { id: 98, task_id: 6, released_at: '2026-01-01' }],
185
+ nextGoalId: 100,
186
+ };
187
+
188
+ const ARCHIVE_PLANS = {
189
+ 'mixed verbs, skips and a move': [
190
+ { taskId: 1, verb: 'abandon' }, { taskId: 2, verb: 'abandon' }, { taskId: 3, verb: 'abandon' },
191
+ { taskId: 5, verb: 'move', toGoalId: 9 }, { taskId: 6, verb: 'abandon' },
192
+ ],
193
+ 'plan order is not id order': [{ taskId: 6, verb: 'abandon' }, { taskId: 1, verb: 'move', toGoalId: 9 }, { taskId: 5, verb: 'abandon' }],
194
+ 'a task named twice reads what the first step left': [
195
+ { taskId: 1, verb: 'abandon' }, { taskId: 1, verb: 'abandon' },
196
+ { taskId: 5, verb: 'move', toGoalId: 9 }, { taskId: 5, verb: 'abandon' },
197
+ ],
198
+ 'ids as strings, as a JSON body carries them': [{ taskId: '1', verb: 'abandon' }, { taskId: '5', verb: 'move', toGoalId: '9' }],
199
+ 'an actively claimed task stops it, after earlier steps': [{ taskId: 1, verb: 'abandon' }, { taskId: 4, verb: 'abandon' }, { taskId: 6, verb: 'abandon' }],
200
+ 'a closed move target stops it': [{ taskId: 1, verb: 'abandon' }, { taskId: 5, verb: 'move', toGoalId: 10 }],
201
+ 'a missing move target stops it': [{ taskId: 5, verb: 'move', toGoalId: 404 }],
202
+ 'a task that does not exist is skipped': [{ taskId: 777, verb: 'abandon' }, { taskId: 6, verb: 'abandon' }],
203
+ 'an empty plan': [],
204
+ };
205
+
206
+ for (const [name, plan] of Object.entries(ARCHIVE_PLANS)) {
207
+ test(`goal archive, old loop vs batched: ${name}`, async () => {
208
+ const args = { goalId: 7, plan, reason: 'cut in review' };
209
+ const oldDb = fakeDb(ARCHIVE_SEED);
210
+ const old = await runLegacyTx(oldDb, (c) => legacy.legacyArchiveLoop(c, args));
211
+ const neuDb = fakeDb(ARCHIVE_SEED);
212
+ const neu = await runNew(() => archiveGoalWithDispositions(args, { pool: neuDb.pool }));
213
+
214
+ assert.deepEqual(errShape(neu.error), errShape(old.error), 'the same refusal, naming the same task');
215
+ if (!old.error) assert.deepEqual(neu.result.applied, old.result, 'the same applied list, in plan order');
216
+ assert.deepEqual(neuDb.state.tasks, oldDb.state.tasks, 'the same tasks left behind');
217
+ });
218
+ }
219
+
220
+ test('goal archive reads every plan task in ONE locked statement and the claims in one more', async () => {
221
+ const db = fakeDb(ARCHIVE_SEED);
222
+ await archiveGoalWithDispositions({ goalId: 7, plan: ARCHIVE_PLANS['mixed verbs, skips and a move'], reason: 'r' }, { pool: db.pool });
223
+ assert.equal(db.queries.filter((q) => /FROM tasks WHERE id = ANY/.test(q)).length, 1);
224
+ assert.equal(db.queries.filter((q) => /FROM claims WHERE task_id/.test(q)).length, 1);
225
+ assert.equal(db.queries.filter((q) => /FROM tasks WHERE id = \$1/.test(q)).length, 0, 'no per-step read is left');
226
+ });
227
+
228
+ // ---- version close -----------------------------------------------------------
229
+
230
+ const CLOSE_SEED = {
231
+ goals: [
232
+ { id: 1, version_id: 'V1', title: 'g1', status: 'open', succeeded_by_goal_id: null },
233
+ { id: 2, version_id: 'V1', title: 'g2', status: 'open', succeeded_by_goal_id: null },
234
+ { id: 3, version_id: 'V1', title: 'g3 (already closed)', status: 'archived', succeeded_by_goal_id: null },
235
+ { id: 4, version_id: 'V9', title: 'g4 (another version)', status: 'open', succeeded_by_goal_id: null },
236
+ { id: 5, version_id: 'V1', title: 'g5', status: 'open', succeeded_by_goal_id: null },
237
+ ],
238
+ tasks: [
239
+ { id: 11, goal_id: 1, version_id: 'V1', status: 'ready', title: 'a', value_summary: null },
240
+ { id: 12, goal_id: 1, version_id: 'V1', status: 'shipped', title: 'b', value_summary: 'done' },
241
+ { id: 13, goal_id: 1, version_id: 'V1', status: 'ready', title: '__smoke__ fixture', value_summary: null },
242
+ { id: 21, goal_id: 2, version_id: 'V1', status: 'backlog', title: 'c', value_summary: null },
243
+ { id: 22, goal_id: 2, version_id: 'V1', status: 'active', title: 'd', value_summary: null },
244
+ { id: 51, goal_id: 5, version_id: 'V1', status: 'ready', title: 'e', value_summary: null },
245
+ ],
246
+ claims: [],
247
+ nextGoalId: 100,
248
+ };
249
+
250
+ const CLOSE_CASES = {
251
+ 'abandon and roll forward, with skips': {
252
+ plan: [{ goalId: 1, verb: 'abandon' }, { goalId: 3, verb: 'abandon' }, { goalId: 4, verb: 'roll_forward' }, { goalId: 2, verb: 'roll_forward' }],
253
+ planningVersionId: 'V2',
254
+ },
255
+ 'plan order is not id order': {
256
+ plan: [{ goalId: 5, verb: 'roll_forward' }, { goalId: 2, verb: 'abandon' }, { goalId: 1, verb: 'roll_forward' }],
257
+ planningVersionId: 'V2',
258
+ },
259
+ 'a goal named twice reads archived the second time': {
260
+ plan: [{ goalId: 1, verb: 'abandon' }, { goalId: 1, verb: 'roll_forward' }, { goalId: 2, verb: 'roll_forward' }, { goalId: 2, verb: 'abandon' }],
261
+ planningVersionId: 'V2',
262
+ },
263
+ 'ids as strings': { plan: [{ goalId: '2', verb: 'abandon' }, { goalId: '5', verb: 'roll_forward' }], planningVersionId: 'V2' },
264
+ 'a roll forward with no planning version throws after earlier steps': {
265
+ plan: [{ goalId: 1, verb: 'abandon' }, { goalId: 2, verb: 'roll_forward' }],
266
+ planningVersionId: null,
267
+ },
268
+ 'an empty plan': { plan: [], planningVersionId: 'V2' },
269
+ };
270
+
271
+ for (const [name, { plan, planningVersionId }] of Object.entries(CLOSE_CASES)) {
272
+ test(`version close, old loop vs batched: ${name}`, async () => {
273
+ const args = { versionId: 'V1', plan, reason: 'V1 is done', planningVersionId };
274
+ const oldDb = fakeDb(CLOSE_SEED);
275
+ const old = await runLegacyTx(oldDb, (c) => legacy.legacyCloseLoop(c, args));
276
+ const neuDb = fakeDb(CLOSE_SEED);
277
+ const neu = await runNew(() => closeVersion(args, { pool: neuDb.pool }));
278
+
279
+ assert.deepEqual(errShape(neu.error), errShape(old.error), 'the same refusal');
280
+ if (!old.error) assert.deepEqual(neu.result.applied, old.result, 'the same applied list, in plan order');
281
+ assert.deepEqual(neuDb.state.goals, oldDb.state.goals, 'the same goals, successors included');
282
+ assert.deepEqual(neuDb.state.tasks, oldDb.state.tasks, 'the same tasks');
283
+ });
284
+ }
285
+
286
+ test('version close locks every plan goal in ONE statement, in id order', async () => {
287
+ const db = fakeDb(CLOSE_SEED);
288
+ await closeVersion({ versionId: 'V1', plan: CLOSE_CASES['plan order is not id order'].plan, reason: 'r', planningVersionId: 'V2' }, { pool: db.pool });
289
+ const locks = db.queries.filter((q) => /FROM goals WHERE id = ANY\(\$1::bigint\[\]\) AND version_id = \$2 ORDER BY id FOR UPDATE/.test(q));
290
+ assert.equal(locks.length, 1);
291
+ assert.equal(db.queries.filter((q) => /FROM goals WHERE id = \$1 AND version_id/.test(q)).length, 0, 'no per-step lock is left');
292
+ });
293
+
294
+ // ---- weekly karma tally ------------------------------------------------------
295
+
296
+ const TALLY_SEED = {
297
+ goals: [], tasks: [], claims: [], nextGoalId: 1, karma: [],
298
+ peerVotes: [
299
+ { id: 1, builder_id: 30, vote: 1 }, { id: 2, builder_id: 10, vote: 1 }, { id: 3, builder_id: 30, vote: 1 },
300
+ { id: 4, builder_id: 20, vote: 1 }, { id: 5, builder_id: 20, vote: -1 }, // nets to zero: no row
301
+ { id: 6, builder_id: 40, vote: -1 },
302
+ ],
303
+ ideaVotes: [{ id: 7, builder_id: 10, vote: 1 }, { id: 8, builder_id: 50, vote: 1 }],
304
+ };
305
+
306
+ async function withFakePool(db, fn) {
307
+ const had = Object.prototype.hasOwnProperty.call(pgPool, 'connect');
308
+ const real = pgPool.connect;
309
+ pgPool.connect = db.pool.connect;
310
+ try { return await fn(); } finally { if (had) pgPool.connect = real; else delete pgPool.connect; }
311
+ }
312
+ const fakeWithTx = (db) => async (fn) => {
313
+ await db.client.query('BEGIN');
314
+ try { const r = await fn(db.client); await db.client.query('COMMIT'); return r; } catch (e) { await db.client.query('ROLLBACK'); throw e; }
315
+ };
316
+
317
+ for (const [name, seed] of Object.entries({
318
+ 'task and idea votes, a zero net, first-seen order': TALLY_SEED,
319
+ 'no ideas module': { ...TALLY_SEED, ideaVotes: null },
320
+ 'nothing matured': { ...TALLY_SEED, peerVotes: [], ideaVotes: [] },
321
+ })) {
322
+ test(`weekly karma tally, old loop vs batched: ${name}`, async () => {
323
+ const oldDb = fakeDb(seed);
324
+ const old = await legacy.legacyTallyPeerVotes(fakeWithTx(oldDb));
325
+ const neuDb = fakeDb(seed);
326
+ const neu = await withFakePool(neuDb, () => tallyPeerVotes());
327
+
328
+ assert.deepEqual(neu, old, 'the same reply');
329
+ assert.deepEqual(neuDb.state.karma, oldDb.state.karma, 'the same karma_log rows, in the same order');
330
+ assert.deepEqual(neuDb.state.peerVotes, oldDb.state.peerVotes, 'the same votes stamped');
331
+ assert.deepEqual(neuDb.state.ideaVotes, oldDb.state.ideaVotes);
332
+ });
333
+ }
334
+
335
+ test('the tally writes every credited builder in ONE insert', async () => {
336
+ const db = fakeDb(TALLY_SEED);
337
+ const r = await withFakePool(db, () => tallyPeerVotes());
338
+ assert.equal(r.builders_credited, 4);
339
+ assert.equal(db.queries.filter((q) => /^INSERT INTO karma_log/.test(q)).length, 1);
340
+ });
@@ -161,6 +161,10 @@ await test('asking for the next how-to step twice in one session reaches the AI
161
161
  const r = await runHook(mePayload(), { prompts: ["what's next in the tutorial", "what's next in the tutorial"] });
162
162
  assert.equal(r.outs.length, 2);
163
163
  for (const [i, out] of r.outs.entries()) assert.ok(out.includes('npx bongos howto next'), `ask ${i + 1} was swallowed; stdout: ${JSON.stringify(out.slice(0, 200))}`);
164
+ // task 1004742: the second ask prints the how-to answer ALONE; the rest of the
165
+ // block (here, the interaction profile) was already shown this session.
166
+ assert.ok(r.outs[0].includes(INTERACTION), 'the first prompt carries the whole block');
167
+ assert.ok(!r.outs[1].includes(INTERACTION), `the repeat re-printed the whole block; stdout: ${JSON.stringify(r.outs[1].slice(0, 300))}`);
164
168
  });
165
169
 
166
170
  summary();
@@ -75,12 +75,12 @@ const errDetail = (b) => (b && b.error && b.error.details) || {};
75
75
 
76
76
  const L = (key, section, text, placement, row) => ({ key, section, text, placement, ...(row || {}) });
77
77
  const STUDIO_LINES = [
78
- L('L0001', 'top bar', 'Studio', 'shared', { file: 'modules/hall-ui/public/shell.js', line: 40, string_id: 'sh0001' }),
79
- L('L0002', 'top bar', 'Ctrl K', 'shared'),
78
+ L('L0001', 'shell bar', 'Studio', 'shared', { file: 'modules/hall-ui/public/shell.js', line: 40, string_id: 'sh0001' }),
79
+ L('L0002', 'shell bar', 'Ctrl K', 'shared'),
80
80
  L('L0003', 'greeting', 'Welcome back', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 12, string_id: 'st0003' }),
81
81
  L('L0004', 'greeting', '{…} pages tweaked', 'placed', { file: 'modules/hall-ui/public/studio.js', line: 88, string_id: 'st0004' }),
82
82
  L('L0005', 'greeting', 'Your desk is quiet today', 'unplaced'),
83
- L('L0006', 'footer', 'Resume where you left off', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 90, string_id: 'st0006' }),
83
+ L('L0006', 'shell foot', 'Resume where you left off', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 90, string_id: 'st0006' }),
84
84
  ];
85
85
  const INDEX_LINES = [
86
86
  L('L0001', 'hero', 'Build here', 'placed', { file: 'modules/public-landing/public/index.html', line: 3, string_id: 'ix0001' }),
@@ -64,11 +64,11 @@ const errCode = (b) => (b && b.error && b.error.code) || null;
64
64
 
65
65
  const L = (key, section, text, placement, row) => ({ key, section, text, placement, ...(row || {}) });
66
66
  const STUDIO_LINES = [
67
- L('L0001', 'top bar', 'Studio', 'shared', { file: 'modules/hall-ui/public/shell.js', line: 40, string_id: 'sh0001' }),
68
- L('L0002', 'top bar', 'Ctrl K', 'shared'),
67
+ L('L0001', 'shell bar', 'Studio', 'shared', { file: 'modules/hall-ui/public/shell.js', line: 40, string_id: 'sh0001' }),
68
+ L('L0002', 'shell bar', 'Ctrl K', 'shared'),
69
69
  L('L0003', 'greeting', 'Welcome back', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 12, string_id: 'st0003' }),
70
70
  L('L0004', 'greeting', 'Your desk is quiet today', 'unplaced'),
71
- L('L0005', 'footer', 'Resume where you left off', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 90, string_id: 'st0005' }),
71
+ L('L0005', 'shell foot', 'Resume where you left off', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 90, string_id: 'st0005' }),
72
72
  ];
73
73
  const readingsWith = (studioHash, lines = STUDIO_LINES) => ({
74
74
  schema: 1,
@@ -96,13 +96,13 @@ try {
96
96
  assert.equal(r.body.page.surface_word, 'builders hall');
97
97
  assert.equal(r.body.reading.reading_hash, 'studio-h1');
98
98
  assert.deepEqual(r.body.reading.lines.map((l) => [l.key, l.section, l.text, l.note]), [
99
- ['L0001', 'top bar', 'Studio', 'shared'],
99
+ ['L0001', 'shell bar', 'Studio', 'shared'],
100
100
  // Shared shell text with no registry row cannot be applied mechanically,
101
101
  // so it goes to an engineer at submit, and the note says so.
102
- ['L0002', 'top bar', 'Ctrl K', 'engineer'],
102
+ ['L0002', 'shell bar', 'Ctrl K', 'engineer'],
103
103
  ['L0003', 'greeting', 'Welcome back', null],
104
104
  ['L0004', 'greeting', 'Your desk is quiet today', 'engineer'],
105
- ['L0005', 'footer', 'Resume where you left off', null],
105
+ ['L0005', 'shell foot', 'Resume where you left off', null],
106
106
  ]);
107
107
  assert.deepEqual(r.body.notes, { shared: 'changes this on every page', engineer: 'goes to an engineer', guarded: 'must still say' });
108
108
  assert.equal(r.body.round, null);
@@ -101,7 +101,8 @@ try {
101
101
 
102
102
  const { status, body } = await getNext(ALICE);
103
103
  assert.equal(status, 200, JSON.stringify(body));
104
- assert.deepEqual(order(body), ['landing:projects', 'status:explore', 'builders:studio', 'landing:index']);
104
+ assert.deepEqual(order(body), ['landing:projects', 'status:explore', 'builders:studio', 'landing:index', 'landing:skip-link']);
105
+ // landing:skip-link is the surface's shared chrome, a page of its own, untweaked
105
106
  assert.equal(body.recommended.why, '2 people asked for it');
106
107
  assert.equal(body.recommended.why_line, 'landing · 2 people asked for it · untweaked');
107
108
  assert.equal(body.alternatives[1].why, '1 line changed since your last pass', 'the round artist is the web claim, alice');
@@ -145,7 +146,7 @@ try {
145
146
  assert.equal(take.body.page_id, 'status:explore');
146
147
  const after = (await getNext(CAROL)).body;
147
148
  assert.deepEqual(after.skipped.map((s) => s.page_id).sort(), ['landing:projects', 'status:explore']);
148
- assert.deepEqual(order(after), ['builders:studio', 'landing:index']);
149
+ assert.deepEqual(order(after), ['builders:studio', 'landing:index', 'landing:skip-link', 'builders:top-bar']);
149
150
  });
150
151
 
151
152
  await test('a submitted round is in flight: skipped, and the claim agrees', async () => {
@@ -115,7 +115,8 @@ try {
115
115
  assert.equal(p('builders:studio').status_label, 'Text tweaked 1x');
116
116
  assert.equal(p('builders:studio').drift.changed_count, 1);
117
117
  assert.equal(p('builders:studio').open_round.state, 'writing');
118
- assert.deepEqual(body.surfaces.map((s) => s.line), ['1 of 2 tweaked', '1 of 1 tweaked', '0 of 1 tweaked']);
118
+ // Each surface's shared chrome is a page of its own now: landing gains a skip link page, builders a top bar page.
119
+ assert.deepEqual(body.surfaces.map((s) => s.line), ['1 of 3 tweaked', '1 of 2 tweaked', '0 of 1 tweaked']);
119
120
  assert.deepEqual(body.readings, { ok: true, at: 'docs/page-readings.json' });
120
121
  assert.deepEqual(body.inventory, { at: 'docs/page-inventory.json' });
121
122
  });
@@ -65,8 +65,8 @@ const errDetail = (b) => (b && b.error && b.error.details) || {};
65
65
  // and an unplaced line.
66
66
  const L = (key, section, text, placement, row) => ({ key, section, text, placement, ...(row || {}) });
67
67
  const STUDIO_LINES = [
68
- L('L0001', 'top bar', 'Studio', 'shared', { file: 'modules/hall-ui/public/shell.js', line: 40, string_id: 'sh0001' }),
69
- L('L0002', 'top bar', 'Ctrl K', 'shared'),
68
+ L('L0001', 'shell bar', 'Studio', 'shared', { file: 'modules/hall-ui/public/shell.js', line: 40, string_id: 'sh0001' }),
69
+ L('L0002', 'shell bar', 'Ctrl K', 'shared'),
70
70
  L('L0003', 'greeting', 'Welcome back', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 12, string_id: 'st0003' }),
71
71
  L('L0004', 'greeting', '{…} pages tweaked', 'placed', { file: 'modules/hall-ui/public/studio.js', line: 88, string_id: 'st0004' }),
72
72
  L('L0005', 'greeting', 'Your desk is quiet today', 'unplaced'),
@@ -360,6 +360,34 @@ try {
360
360
  guarded._setList(null);
361
361
  }
362
362
  });
363
+
364
+ // Task 1004904: a shared-chrome page is a page like any other, whole path.
365
+ await test('a chrome page: claimed, drafted and submitted through the real routes, its lines placed from the first occurrence', async () => {
366
+ const TOP = '/copy-desk/pages/builders:top-bar';
367
+ const chromeLine = (key, text, row) => L(key, 'top bar', text, 'shared', row);
368
+ const r0 = readingsWith(
369
+ { hash: 'studio-h1', lines: [chromeLine('L0001', 'Projects', { file: 'modules/hall-ui/public/shell.js', line: 41, string_id: 'sh0041' }), chromeLine('L0002', 'Sign out', { file: 'modules/hall-ui/public/shell.js', line: 42, string_id: 'sh0042' }), ...STUDIO_LINES.slice(2)] },
370
+ { hash: 'index-h2', lines: INDEX_LINES },
371
+ );
372
+ writeReadings(r0);
373
+ const studio = await call('GET', '/copy-desk/pages/builders:studio', ALICE);
374
+ assert.equal(studio.status, 200);
375
+ const page = await call('GET', TOP, ALICE);
376
+ assert.equal(page.status, 200, JSON.stringify(page.body));
377
+ assert.equal((await call('POST', `${TOP}/claim`, ALICE)).status, 201);
378
+ const read = await call('GET', `${TOP}/draft`, ALICE);
379
+ assert.equal(read.status, 200, JSON.stringify(read.body));
380
+ const hash = read.body.reading.reading_hash || read.body.reading_hash;
381
+ assert.ok(hash, 'the chrome page has a reading hash of its own');
382
+ assert.deepEqual(read.body.reading.lines.map((l) => l.text), ['Projects', 'Sign out']);
383
+ const saved = await call('PUT', `${TOP}/draft`, ALICE, { reading_hash: hash, lines: [{ key: 'L0001', after: 'Your projects' }] });
384
+ assert.equal(saved.status, 200, JSON.stringify(saved.body));
385
+ const sub = await call('POST', `${TOP}/submit`, ALICE);
386
+ assert.equal(sub.status, 200, JSON.stringify(sub.body));
387
+ const round = db.tasks.find((t) => t.source_ref === 'page-tweak/builders:top-bar/r1');
388
+ const batch = pages.parseBatchBlock(round.description).batch;
389
+ assert.deepEqual(batch.lines.map((l) => [l.key, l.file, l.line, l.before, l.after]), [['L0001', 'modules/hall-ui/public/shell.js', 41, 'Projects', 'Your projects']]);
390
+ });
363
391
  } finally {
364
392
  server.close();
365
393
  pageData._setRoot(null);