@bongos/core 1.23.21 → 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 (61) hide show
  1. package/.bongos-core.json +117 -57
  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 +2 -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/government/db.js +74 -0
  23. package/modules/government/routes/government.js +28 -18
  24. package/modules/grading/grader-subagent.js +30 -11
  25. package/modules/hall-ui/public/city.html +15 -15
  26. package/modules/lifecycle/db-analytics.js +10 -3
  27. package/modules/lifecycle/db-goals.js +30 -13
  28. package/modules/lifecycle/db-shared.js +9 -0
  29. package/modules/lifecycle/db-versions.js +21 -8
  30. package/modules/lifecycle/ship-assurance.js +4 -1
  31. package/modules/lifecycle/ship-card.js +8 -2
  32. package/modules/onboarding/howto-moments.js +8 -1
  33. package/modules/provisioning/starter-bundles.js +4 -2
  34. package/package-lock.json +2 -2
  35. package/package.json +1 -1
  36. package/release-notes.json +66 -0
  37. package/scripts/bongos/publish-manifest.js +1 -0
  38. package/scripts/bongos/strand-watch.js +106 -10
  39. package/src/bongos/module-scope-map.js +4 -0
  40. package/src/module-api.js +1 -1
  41. package/src/platform-server.js +27 -1
  42. package/tests/batched_writes_parity.mjs +340 -0
  43. package/tests/conductor_main_e2e.mjs +4 -0
  44. package/tests/fixtures/per-row-loops-legacy.cjs +245 -0
  45. package/tests/goal_archive_disposition.mjs +5 -5
  46. package/tests/government_routes.mjs +145 -22
  47. package/tests/grader.mjs +31 -0
  48. package/tests/hall_city.mjs +8 -0
  49. package/tests/hook_shell_tools.mjs +8 -0
  50. package/tests/hooks_drain.mjs +1 -1
  51. package/tests/host_topology_skips.mjs +2 -0
  52. package/tests/howto_moments.mjs +17 -0
  53. package/tests/module_loader.mjs +1 -1
  54. package/tests/module_skills_hook.mjs +69 -0
  55. package/tests/public_landing_projects.mjs +37 -0
  56. package/tests/session_start_freshness.mjs +66 -1
  57. package/tests/shell_hooks.mjs +209 -0
  58. package/tests/ship_assurance.mjs +28 -0
  59. package/tests/ship_card.mjs +20 -5
  60. package/tests/strand_watch.mjs +48 -0
  61. package/tests/version_close_route.mjs +1 -1
@@ -31,7 +31,8 @@
31
31
  //
32
32
  // Usage:
33
33
  // node scripts/bongos/strand-watch.js # human-readable report
34
- // node scripts/bongos/strand-watch.js --line # ONE line, or nothing; always exit 0
34
+ // node scripts/bongos/strand-watch.js --line # ONE line, or nothing; always exit 0 (cached; see linePlan)
35
+ // node scripts/bongos/strand-watch.js --refresh-line # refresh that cache; prints nothing (spawned detached by --line)
35
36
  // node scripts/bongos/strand-watch.js --json # machine shape
36
37
  // node scripts/bongos/strand-watch.js --hours N # override the `completed` threshold
37
38
  //
@@ -273,6 +274,77 @@ function report(r) {
273
274
  console.log('');
274
275
  }
275
276
 
277
+ // ---- the SessionStart line, kept off the critical path (task 1004928) --------
278
+ //
279
+ // THE MEASUREMENT. The credit-burn audit timed this hook at 8.2 s — the slowest SessionStart
280
+ // producer, and so the floor for every session start. The shared cache it used (task 1003689)
281
+ // lived 60 s, which only helps sessions opened together; the next session an hour later paid
282
+ // the whole ledger read again, up to 8 s per call (and the held-tweak pass can add another).
283
+ //
284
+ // THE FIX. A strand is measured in hours (one hour for `confirmed`, twelve for `completed`), so
285
+ // an answer a few minutes old is as true as a fresh one. So: keep the answer LINE_FRESH_MS and
286
+ // print it at once; past that, up to LINE_STALE_MAX_MS, print it at once LABELLED WITH ITS AGE
287
+ // and refresh the cache in a detached child for the next session; with nothing usable, read the
288
+ // ledger but give up after LINE_DEADLINE_MS. An undetermined read is never cached — a stored
289
+ // silence would pass for a clean board for the whole fresh window.
290
+ const LINE_CACHE_KEY = 'strand-watch-line';
291
+ const LINE_FRESH_MS = 15 * 60 * 1000;
292
+ const LINE_STALE_MAX_MS = 2 * 60 * 60 * 1000;
293
+ const LINE_DEADLINE_MS = 4000;
294
+
295
+ // PURE — what to do with the cached record: 'fresh' (print it), 'stale' (print it with its age
296
+ // and refresh in the background) or 'miss' (read the ledger now, time-boxed). A record from the
297
+ // future means the clock moved, and is treated as a miss rather than trusted.
298
+ function linePlan(record, nowMs, { freshMs = LINE_FRESH_MS, staleMaxMs = LINE_STALE_MAX_MS } = {}) {
299
+ if (!record || typeof record.at !== 'number' || !Number.isFinite(nowMs)) return 'miss';
300
+ const age = nowMs - record.at;
301
+ if (age < 0) return 'miss';
302
+ if (age < freshMs) return 'fresh';
303
+ if (age < staleMaxMs) return 'stale';
304
+ return 'miss';
305
+ }
306
+
307
+ // PURE — the line as printed from a stale cache: the same words, plus how old they are, so a
308
+ // reader can weigh them. A clean (null) answer stays silent: there is nothing to label.
309
+ function labelStale(text, ageMs) {
310
+ if (!text) return null;
311
+ return `${text} (Checked ${humanAge(ageMs)} ago; refreshing for the next session.)`;
312
+ }
313
+
314
+ // The ledger read for the line, bounded as a WHOLE (check() bounds each call, not the sum).
315
+ // Throws on an undetermined read so probe-cache stores nothing.
316
+ async function produceLine(opts, deadlineMs) {
317
+ let timer;
318
+ const deadline = new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('deadline')), deadlineMs); });
319
+ try {
320
+ const r = await Promise.race([check({ ...opts, timeoutMs: Math.min(deadlineMs, DEFAULT_TIMEOUT_MS) }), deadline]);
321
+ if (!r || r.undetermined) throw new Error('undetermined');
322
+ return line(r);
323
+ } finally { clearTimeout(timer); }
324
+ }
325
+
326
+ function readLineRecord() {
327
+ try {
328
+ const { cachePath } = require('./probe-cache.js');
329
+ return JSON.parse(require('node:fs').readFileSync(cachePath(LINE_CACHE_KEY), 'utf8'));
330
+ } catch { return null; }
331
+ }
332
+
333
+ // Detached + unref'd + hidden, so the session start never waits on it (windowsHide: a detached
334
+ // child on Windows otherwise flashes a console window). --hours is passed through so the
335
+ // refreshed answer uses the same threshold.
336
+ function spawnRefresh() {
337
+ try {
338
+ const args = [__filename, '--refresh-line'];
339
+ const hours = arg('--hours');
340
+ if (hours) args.push('--hours', String(hours));
341
+ const child = require('node:child_process').spawn(process.execPath, args, {
342
+ detached: true, stdio: 'ignore', windowsHide: true, cwd: process.cwd(),
343
+ });
344
+ child.unref();
345
+ } catch { /* the next session reads the ledger itself */ }
346
+ }
347
+
276
348
  async function main() {
277
349
  const hours = Number(arg('--hours'));
278
350
  const opts = Number.isFinite(hours) && hours > 0 ? { completedStaleMs: hours * 3600000 } : {};
@@ -285,16 +357,39 @@ async function main() {
285
357
  // deploy-divergence's identical comment).
286
358
  if (hasFlag('--line')) {
287
359
  try {
288
- // SHARED ACROSS CONCURRENT SESSION STARTS (task 1003689). Four sessions opened
289
- // within three seconds — one worktree per claim, the normal pattern here — used
290
- // to make this same call four times and ALL of them timed out, which produces no
291
- // line at all. Silence from a truth-teller is indistinguishable from 'nothing to
292
- // report', so the brief was lost without a symptom. The first session to ask now
293
- // answers for the rest.
360
+ // SHARED ACROSS SESSION STARTS (task 1003689, widened by task 1004928). Four sessions
361
+ // opened within three seconds — one worktree per claim, the normal pattern here — used
362
+ // to make this same call four times and ALL of them timed out, which produces no line
363
+ // at all. The first session to ask answers for the rest; see linePlan for how long.
364
+ const rec = readLineRecord();
365
+ const plan = linePlan(rec, Date.now());
366
+ if (plan === 'fresh') {
367
+ if (rec.value) console.log(rec.value);
368
+ } else if (plan === 'stale') {
369
+ const one = labelStale(rec.value, Date.now() - rec.at);
370
+ if (one) console.log(one);
371
+ spawnRefresh();
372
+ } else {
373
+ const { cached } = require('./probe-cache.js');
374
+ const { value: one, from } = await cached(LINE_CACHE_KEY, () => produceLine(opts, LINE_DEADLINE_MS), { ttlMs: LINE_FRESH_MS });
375
+ // A sibling holds the lock and the cache handed back its last answer, however old:
376
+ // say how old, so a long-expired line is never passed off as current.
377
+ const shown = from === 'stale-while-locked' && rec && typeof rec.at === 'number'
378
+ ? labelStale(one, Date.now() - rec.at) : one;
379
+ if (shown) console.log(shown);
380
+ }
381
+ } catch { /* no session, offline, unreadable, past the deadline — all silence */ }
382
+ process.exitCode = 0;
383
+ return;
384
+ }
385
+
386
+ // --refresh-line: the detached child spawnRefresh starts. ttlMs 0 forces a fresh read; a
387
+ // sibling already refreshing holds the lock, and then this child simply stops. Prints nothing.
388
+ if (hasFlag('--refresh-line')) {
389
+ try {
294
390
  const { cached } = require('./probe-cache.js');
295
- const { value: one } = await cached('strand-watch-line', async () => line(await check(opts)));
296
- if (one) console.log(one);
297
- } catch { /* no session, offline, unreadable — all silence */ }
391
+ await cached(LINE_CACHE_KEY, () => produceLine(opts, DEFAULT_TIMEOUT_MS * 2), { ttlMs: 0 });
392
+ } catch { /* the next session reads the ledger itself */ }
298
393
  process.exitCode = 0;
299
394
  return;
300
395
  }
@@ -311,5 +406,6 @@ if (require.main === module) {
311
406
 
312
407
  module.exports = {
313
408
  check, line, report, classifyStrand, rankStrands, humanAge,
409
+ linePlan, labelStale, produceLine, LINE_FRESH_MS, LINE_STALE_MAX_MS, LINE_DEADLINE_MS,
314
410
  CONFIRMED_STALE_MS, COMPLETED_STALE_MS, STRAND_REASON_PREFIX, WATCHED, PAGE_LIMIT,
315
411
  };
@@ -182,6 +182,10 @@ const MODULE_GLOBS = {
182
182
  'page-notes': [
183
183
  'modules/page-notes/',
184
184
  ],
185
+ // AI generation for artists (task 1004916, ADR 0392): keys, prepaid balance, generate.
186
+ 'ai-studio': [
187
+ 'modules/ai-studio/',
188
+ ],
185
189
  // The /raise investor page (task 1004847, ADR 0384): hub-only.
186
190
  raise: [
187
191
  'modules/raise/',
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.23.21'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.23.22'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -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();