@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
@@ -0,0 +1,245 @@
1
+ 'use strict';
2
+
3
+ // tests/fixtures/per-row-loops-legacy.cjs — the FOUR one-query-per-row loops as
4
+ // they stood before task 1004929 batched them, kept verbatim (comments trimmed)
5
+ // so tests/batched_writes_parity.mjs and tests/government_routes.mjs can run the
6
+ // old and the new code over the same fake database and compare what each one
7
+ // returns and leaves behind. A frozen reference, not live code: nothing in src/
8
+ // or modules/ may require it.
9
+
10
+ const { nonTerminalSql, isTerminalStatus } = require('../../modules/lifecycle/task-status');
11
+ const { cancelPendingMembershipRequests } = require('../../modules/lifecycle/goal-close-sweep.js');
12
+
13
+ // modules/government/routes/government.js — POST /government/rank-sets/apply,
14
+ // the try-block body. `db` is the government db slice, `rankSet` the
15
+ // recommendedRankSet output. Returns { created, skipped } as the reply carried.
16
+ async function legacyApplyRankSet({ db, catalog, rankSet, builderId }) {
17
+ const set = rankSet;
18
+ const created = [];
19
+ const skipped = [];
20
+ for (const r of set.ranks) {
21
+ if (await db.getRankByKey(r.rank_key)) { skipped.push({ rank_key: r.rank_key, label: r.label }); continue; }
22
+ let rank;
23
+ try {
24
+ rank = await db.createRank({ rankKey: r.rank_key, label: r.label, description: r.description, createdBy: builderId });
25
+ } catch (err) {
26
+ if (err && err.code === '23505') { skipped.push({ rank_key: r.rank_key, label: r.label }); continue; }
27
+ throw err;
28
+ }
29
+ const granted = [];
30
+ for (const key of r.permissions) {
31
+ if (!catalog.byKey(key) || !catalog.isGrantableTo(key, rank)) continue;
32
+ if (await db.grantRankPermission({ rankKey: r.rank_key, permissionKey: key })) granted.push(key);
33
+ }
34
+ created.push({ rank_key: r.rank_key, label: r.label, permissions: granted });
35
+ }
36
+ return { created, skipped };
37
+ }
38
+
39
+ // modules/lifecycle/db-goals.js — archiveGoalWithDispositions, the plan loop.
40
+ // Runs on an open client (the caller has already locked the goal). Returns the
41
+ // applied list, or throws what the loop threw.
42
+ async function legacyArchiveLoop(client, { goalId, plan = [], reason }) {
43
+ const applied = [];
44
+ for (const step of plan) {
45
+ const { rows: tRows } = await client.query(
46
+ `SELECT id, title, status FROM tasks WHERE id = $1 AND goal_id = $2 FOR UPDATE`,
47
+ [step.taskId, goalId]
48
+ );
49
+ if (!tRows[0]) { applied.push({ task_id: String(step.taskId), verb: step.verb, result: 'skipped_not_in_goal' }); continue; }
50
+ if (isTerminalStatus(tRows[0].status)) {
51
+ applied.push({ task_id: String(step.taskId), verb: step.verb, result: 'skipped_already_terminal', status: tRows[0].status });
52
+ continue;
53
+ }
54
+
55
+ const { rows: claimed } = await client.query(
56
+ `SELECT id FROM claims WHERE task_id = $1 AND released_at IS NULL LIMIT 1`, [step.taskId]
57
+ );
58
+ if (claimed[0]) {
59
+ throw Object.assign(new Error('task has an active claim'), {
60
+ code: 'TASK_HAS_ACTIVE_CLAIM', taskId: String(step.taskId), taskTitle: tRows[0].title,
61
+ });
62
+ }
63
+
64
+ if (step.verb === 'abandon') {
65
+ await client.query(
66
+ `UPDATE tasks SET status = 'abandoned',
67
+ value_summary = COALESCE(NULLIF($2, ''), value_summary),
68
+ updated_at = now()
69
+ WHERE id = $1`,
70
+ [step.taskId, `Abandoned: ${reason}`]
71
+ );
72
+ applied.push({ task_id: String(step.taskId), verb: 'abandon', result: 'abandoned' });
73
+ } else {
74
+ const { rows: dest } = await client.query(
75
+ `SELECT id, status FROM goals WHERE id = $1 FOR SHARE`, [step.toGoalId]
76
+ );
77
+ if (!dest[0]) {
78
+ throw Object.assign(new Error('move target not found'), { code: 'MOVE_TARGET_NOT_FOUND', toGoalId: String(step.toGoalId) });
79
+ }
80
+ if (dest[0].status !== 'open') {
81
+ throw Object.assign(new Error('move target is not open'), {
82
+ code: 'MOVE_TARGET_NOT_OPEN', toGoalId: String(step.toGoalId), targetStatus: dest[0].status,
83
+ });
84
+ }
85
+ await client.query(`UPDATE tasks SET goal_id = $2, updated_at = now() WHERE id = $1`, [step.taskId, step.toGoalId]);
86
+ applied.push({ task_id: String(step.taskId), verb: 'move', result: 'moved', to_goal_id: String(step.toGoalId) });
87
+ }
88
+ }
89
+ return applied;
90
+ }
91
+
92
+ // modules/lifecycle/db-versions.js — closeVersion, the plan loop. Same contract.
93
+ async function legacyCloseLoop(client, { versionId, plan = [], reason, planningVersionId = null }) {
94
+ const applied = [];
95
+ for (const step of plan) {
96
+ const { rows: gRows } = await client.query(
97
+ `SELECT id, title, status FROM goals WHERE id = $1 AND version_id = $2 FOR UPDATE`,
98
+ [step.goalId, versionId]
99
+ );
100
+ if (!gRows[0]) { applied.push({ goal_id: String(step.goalId), verb: step.verb, result: 'skipped_not_on_version' }); continue; }
101
+ if (gRows[0].status !== 'open') {
102
+ applied.push({ goal_id: String(step.goalId), verb: step.verb, result: 'skipped_already_closed', status: gRows[0].status });
103
+ continue;
104
+ }
105
+
106
+ if (step.verb === 'abandon') {
107
+ const { rows: killed } = await client.query(
108
+ `UPDATE tasks SET status = 'abandoned',
109
+ value_summary = COALESCE(NULLIF($2, ''), value_summary),
110
+ updated_at = now()
111
+ WHERE goal_id = $1 AND ${nonTerminalSql('status')}
112
+ AND title NOT LIKE '\\_\\_smoke\\_\\_%' ESCAPE '\\'
113
+ RETURNING id`,
114
+ [step.goalId, `Abandoned: ${reason}`]
115
+ );
116
+ await client.query(`UPDATE goals SET status = 'archived', updated_at = now() WHERE id = $1`, [step.goalId]);
117
+ await cancelPendingMembershipRequests(client, step.goalId);
118
+ applied.push({ goal_id: String(step.goalId), verb: 'abandon', result: 'archived', tasks_abandoned: killed.length });
119
+ } else {
120
+ if (!planningVersionId) {
121
+ throw Object.assign(new Error('roll_forward with no planning version'), {
122
+ code: 'NO_PLANNING_VERSION', goalId: String(step.goalId),
123
+ });
124
+ }
125
+ const { rows: made } = await client.query(
126
+ `WITH next_order AS (
127
+ SELECT COALESCE(MAX(sort_order) + 1, 0) AS n FROM goals WHERE version_id = $2
128
+ )
129
+ INSERT INTO goals (version_id, title, subtitle, description, scope_modules,
130
+ status, visibility, accepting_requests, created_by, sort_order, category_id, working_area)
131
+ SELECT $2, g.title, g.subtitle, g.description, g.scope_modules,
132
+ 'open', g.visibility, g.accepting_requests, g.created_by, next_order.n, g.category_id, g.working_area
133
+ FROM goals g, next_order
134
+ WHERE g.id = $1
135
+ RETURNING id`,
136
+ [step.goalId, planningVersionId]
137
+ );
138
+ const successorId = made[0].id;
139
+
140
+ await client.query(
141
+ `INSERT INTO goal_members (goal_id, builder_id, membership_kind)
142
+ SELECT $2, builder_id, membership_kind FROM goal_members WHERE goal_id = $1
143
+ ON CONFLICT DO NOTHING`,
144
+ [step.goalId, successorId]
145
+ );
146
+
147
+ const { rows: moved } = await client.query(
148
+ `UPDATE tasks SET goal_id = $2, version_id = $3, updated_at = now()
149
+ WHERE goal_id = $1 AND ${nonTerminalSql('status')}
150
+ AND title NOT LIKE '\\_\\_smoke\\_\\_%' ESCAPE '\\'
151
+ RETURNING id`,
152
+ [step.goalId, successorId, planningVersionId]
153
+ );
154
+
155
+ await client.query(
156
+ `UPDATE goals SET succeeded_by_goal_id = $2, updated_at = now() WHERE id = $1`,
157
+ [step.goalId, successorId]
158
+ );
159
+ await client.query(`UPDATE goals SET status = 'archived', updated_at = now() WHERE id = $1`, [step.goalId]);
160
+ await cancelPendingMembershipRequests(client, step.goalId);
161
+
162
+ applied.push({
163
+ goal_id: String(step.goalId), verb: 'roll_forward', result: 'succeeded',
164
+ successor_goal_id: String(successorId), tasks_moved: moved.length,
165
+ });
166
+ }
167
+ }
168
+ return applied;
169
+ }
170
+
171
+ // modules/lifecycle/db-analytics.js — tallyPeerVotes, the whole function, with
172
+ // the transaction primitive passed in (it was api.withTx).
173
+ const PEER_VOTE_KARMA_LAG_DAYS = 7;
174
+ async function legacyTallyPeerVotes(withTx) {
175
+ return withTx(async (client) => {
176
+ const { rows: matured } = await client.query(
177
+ `SELECT pv.id, t.shipped_by AS builder_id, pv.vote
178
+ FROM peer_votes pv
179
+ JOIN tasks t ON t.id = pv.target_task_id
180
+ WHERE pv.tallied_at IS NULL
181
+ AND pv.recorded_at <= now() - ($1::int * interval '1 day')
182
+ AND t.shipped_by IS NOT NULL
183
+ FOR UPDATE OF pv`,
184
+ [PEER_VOTE_KARMA_LAG_DAYS]
185
+ );
186
+ const netByBuilder = new Map();
187
+ const voteIds = [];
188
+ for (const r of matured) {
189
+ voteIds.push(r.id);
190
+ netByBuilder.set(r.builder_id, (netByBuilder.get(r.builder_id) || 0) + r.vote);
191
+ }
192
+ const ideaVoteIds = [];
193
+ const { rows: [{ has: hasIdeaVotes }] } = await client.query(
194
+ `SELECT to_regclass('ideas_votes') IS NOT NULL AS has`
195
+ );
196
+ if (hasIdeaVotes) {
197
+ const { rows: maturedIdeas } = await client.query(
198
+ `SELECT iv.id, i.captured_by AS builder_id, iv.vote
199
+ FROM ideas_votes iv
200
+ JOIN idea_inbox i ON i.id = iv.target_idea_id
201
+ WHERE iv.tallied_at IS NULL
202
+ AND iv.recorded_at <= now() - ($1::int * interval '1 day')
203
+ AND i.captured_by IS NOT NULL
204
+ FOR UPDATE OF iv`,
205
+ [PEER_VOTE_KARMA_LAG_DAYS]
206
+ );
207
+ for (const r of maturedIdeas) {
208
+ ideaVoteIds.push(r.id);
209
+ netByBuilder.set(r.builder_id, (netByBuilder.get(r.builder_id) || 0) + r.vote);
210
+ }
211
+ }
212
+ const karmaRows = [];
213
+ for (const [builderId, net] of netByBuilder) {
214
+ if (net === 0) continue;
215
+ await client.query(
216
+ `INSERT INTO karma_log (builder_id, delta, reason, granted_by)
217
+ VALUES ($1, $2, 'peer_vote_weekly', NULL)`,
218
+ [builderId, net]
219
+ );
220
+ karmaRows.push({ builder_id: builderId, delta: net });
221
+ }
222
+ if (voteIds.length) {
223
+ await client.query(
224
+ `UPDATE peer_votes SET tallied_at = now() WHERE id = ANY($1::bigint[])`,
225
+ [voteIds]
226
+ );
227
+ }
228
+ if (ideaVoteIds.length) {
229
+ await client.query(
230
+ `UPDATE ideas_votes SET tallied_at = now() WHERE id = ANY($1::bigint[])`,
231
+ [ideaVoteIds]
232
+ );
233
+ }
234
+ return {
235
+ ok: true,
236
+ votes_tallied: voteIds.length + ideaVoteIds.length,
237
+ idea_votes_tallied: ideaVoteIds.length,
238
+ builders_credited: karmaRows.length,
239
+ karma: karmaRows,
240
+ lag_days: PEER_VOTE_KARMA_LAG_DAYS,
241
+ };
242
+ });
243
+ }
244
+
245
+ module.exports = { legacyApplyRankSet, legacyArchiveLoop, legacyCloseLoop, legacyTallyPeerVotes };
@@ -244,7 +244,7 @@ test('the whole disposition + archive runs inside ONE transaction', async () =>
244
244
  const pool = txPool((sql) => {
245
245
  const q = String(sql).replace(/\s+/g, ' ');
246
246
  if (/^SELECT .* FROM goals WHERE id = \$1 FOR UPDATE/.test(q.trim())) return { rows: [{ id: 7, status: 'open' }] };
247
- if (/FROM tasks WHERE id = \$1 AND goal_id = \$2 FOR UPDATE/.test(q)) return { rows: [{ id: 1, title: 't1', status: 'ready' }] };
247
+ if (/FROM tasks WHERE id = ANY\(\$1::bigint\[\]\) AND goal_id = \$2 ORDER BY id FOR UPDATE/.test(q)) return { rows: [{ id: 1, title: 't1', status: 'ready' }] };
248
248
  if (/FROM claims WHERE task_id/.test(q)) return { rows: [] };
249
249
  if (/count\(\*\)::int AS n FROM tasks/.test(q)) return { rows: [{ n: 0 }] };
250
250
  if (/UPDATE goals SET status = 'archived'/.test(q)) return { rows: [{ id: 7, status: 'archived' }] };
@@ -270,7 +270,7 @@ test('the goal row is locked BEFORE the plan is applied', async () => {
270
270
  const pool = txPool((sql) => {
271
271
  const q = String(sql).replace(/\s+/g, ' ');
272
272
  if (/FROM goals WHERE id = \$1 FOR UPDATE/.test(q)) return { rows: [{ id: 7, status: 'open' }] };
273
- if (/FROM tasks WHERE id = \$1 AND goal_id = \$2 FOR UPDATE/.test(q)) return { rows: [{ id: 1, title: 't', status: 'ready' }] };
273
+ if (/FROM tasks WHERE id = ANY\(\$1::bigint\[\]\) AND goal_id = \$2 ORDER BY id FOR UPDATE/.test(q)) return { rows: [{ id: 1, title: 't', status: 'ready' }] };
274
274
  if (/FROM claims/.test(q)) return { rows: [] };
275
275
  if (/count\(\*\)::int AS n/.test(q)) return { rows: [{ n: 0 }] };
276
276
  if (/UPDATE goals/.test(q)) return { rows: [{ id: 7, status: 'archived' }] };
@@ -287,8 +287,8 @@ test('an actively-claimed task stops the archive and rolls it back', async () =>
287
287
  const pool = txPool((sql) => {
288
288
  const q = String(sql).replace(/\s+/g, ' ');
289
289
  if (/FROM goals WHERE id = \$1 FOR UPDATE/.test(q)) return { rows: [{ id: 7, status: 'open' }] };
290
- if (/FROM tasks WHERE id = \$1 AND goal_id = \$2 FOR UPDATE/.test(q)) return { rows: [{ id: 1, title: 'busy', status: 'active' }] };
291
- if (/FROM claims WHERE task_id/.test(q)) return { rows: [{ id: 99 }] }; // someone is on it
290
+ if (/FROM tasks WHERE id = ANY\(\$1::bigint\[\]\) AND goal_id = \$2 ORDER BY id FOR UPDATE/.test(q)) return { rows: [{ id: 1, title: 'busy', status: 'active' }] };
291
+ if (/FROM claims WHERE task_id/.test(q)) return { rows: [{ task_id: 1 }] }; // someone is on it
292
292
  return { rows: [] };
293
293
  });
294
294
  await assert.rejects(
@@ -307,7 +307,7 @@ test('the invariant is re-checked against committed state, not against the plan'
307
307
  const pool = txPool((sql) => {
308
308
  const q = String(sql).replace(/\s+/g, ' ');
309
309
  if (/FROM goals WHERE id = \$1 FOR UPDATE/.test(q)) return { rows: [{ id: 7, status: 'open' }] };
310
- if (/FROM tasks WHERE id = \$1 AND goal_id = \$2 FOR UPDATE/.test(q)) return { rows: [{ id: 1, title: 't', status: 'ready' }] };
310
+ if (/FROM tasks WHERE id = ANY\(\$1::bigint\[\]\) AND goal_id = \$2 ORDER BY id FOR UPDATE/.test(q)) return { rows: [{ id: 1, title: 't', status: 'ready' }] };
311
311
  if (/FROM claims/.test(q)) return { rows: [] };
312
312
  if (/count\(\*\)::int AS n FROM tasks/.test(q)) return { rows: [{ n: 3 }] }; // three snuck in
313
313
  return { rows: [] };
@@ -149,6 +149,9 @@ const stub = {
149
149
  }),
150
150
  createRank: async (a) => ({ rank_key: a.rankKey, label: a.label, description: a.description ?? null, is_system: false, seeded_rank: null, created_by: a.createdBy, created_at: 't' }),
151
151
  grantRankPermission: async () => true,
152
+ // task 1004929: the rank-set apply's one-transaction write. The apply tests
153
+ // swap in the REAL function over a fake SQL store (withRealRankDb below).
154
+ applyRankSet: async () => new Map(),
152
155
  revokeRankPermission: async () => true,
153
156
  resetRankGrants: async ({ permissionKeys }) => permissionKeys.length,
154
157
  // task 1004481: `deleteAnswer` scripts what the locked delete found.
@@ -265,35 +268,155 @@ test('GET /government/assignments returns builder↔role rows', async () => {
265
268
 
266
269
  // --- recommended rank sets (task 1004400) -------------------------------------
267
270
 
271
+ // task 1004929: the apply is now ONE transaction written by db.applyRankSet. These
272
+ // tests run the REAL route into the REAL db functions, over a small fake of the
273
+ // two tables that understands exactly the statements the old per-row loop and
274
+ // the new batched write send. That is what lets the parity test below run the
275
+ // old loop (tests/fixtures/per-row-loops-legacy.cjs, verbatim) and the new route
276
+ // over the same starting tables and compare the replies AND what was written.
277
+ //
278
+ // `existing` are ranks already there; `race` are ranks another caller creates
279
+ // between this call's read and its insert (invisible to the read, a conflict on
280
+ // the insert) — the case the old loop caught as a 23505.
281
+ function rankStore({ existing = [], race = [], failOnGrant = false } = {}) {
282
+ let st = { ranks: existing.map((k) => ({ rank_key: k, label: 'Theirs', description: null, created_by: 7 })), grants: [] };
283
+ let snap = null;
284
+ const raced = new Set(race);
285
+ const queries = [];
286
+ const has = (k) => st.ranks.some((r) => r.rank_key === k);
287
+ const conflicts = (k) => {
288
+ if (raced.has(k)) { raced.delete(k); st.ranks.push({ rank_key: k, label: 'Raced', description: null, created_by: 8 }); }
289
+ return has(k);
290
+ };
291
+ const addRank = (k, label, description, createdBy) => {
292
+ const row = { rank_key: k, label, description: description ?? null, created_by: Number(createdBy) };
293
+ st.ranks.push(row);
294
+ return { ...row, is_system: false, seeded_rank: null, created_at: 't' };
295
+ };
296
+ const addGrant = (rk, pk) => {
297
+ if (failOnGrant) throw new Error('grant insert failed');
298
+ if (st.grants.some((g) => g.rank_key === rk && g.permission_key === pk)) return false;
299
+ st.grants.push({ rank_key: rk, permission_key: pk });
300
+ return true;
301
+ };
302
+ async function query(sql, p = []) {
303
+ const s = String(sql).replace(/\s+/g, ' ').trim();
304
+ queries.push(s);
305
+ if (s === 'BEGIN') { snap = JSON.parse(JSON.stringify(st)); return { rows: [] }; }
306
+ if (s === 'COMMIT') { snap = null; return { rows: [] }; }
307
+ if (s === 'ROLLBACK') { if (snap) st = snap; snap = null; return { rows: [] }; }
308
+ if (/^SELECT rank_key, label, description, is_system, seeded_rank, created_by, created_at FROM government_ranks WHERE rank_key = \$1$/.test(s)) {
309
+ return { rows: st.ranks.filter((r) => r.rank_key === p[0]).map((r) => ({ ...r, is_system: false, seeded_rank: null })) };
310
+ }
311
+ if (/^INSERT INTO government_ranks \(.*\) VALUES \(\$1, \$2, \$3, false, NULL, \$4\) RETURNING/.test(s)) {
312
+ if (conflicts(p[0])) throw Object.assign(new Error('duplicate key'), { code: '23505' });
313
+ return { rows: [addRank(p[0], p[1], p[2], p[3])] };
314
+ }
315
+ if (/^INSERT INTO government_rank_permissions \(rank_key, permission_key\) VALUES \(\$1, \$2\) ON CONFLICT/.test(s)) {
316
+ return { rows: [], rowCount: addGrant(p[0], p[1]) ? 1 : 0 };
317
+ }
318
+ if (/^SELECT rank_key FROM government_ranks WHERE rank_key = ANY\(\$1::text\[\]\)$/.test(s)) {
319
+ return { rows: st.ranks.filter((r) => p[0].includes(r.rank_key)).map((r) => ({ rank_key: r.rank_key })) };
320
+ }
321
+ if (/^INSERT INTO government_ranks .* FROM unnest\(\$1::text\[\], \$2::text\[\], \$3::text\[\]\) WITH ORDINALITY .* ORDER BY r\.ord ON CONFLICT DO NOTHING RETURNING rank_key$/.test(s)) {
322
+ const rows = [];
323
+ p[0].forEach((k, i) => { if (!conflicts(k)) { addRank(k, p[1][i], p[2][i], p[3]); rows.push({ rank_key: k }); } });
324
+ return { rows };
325
+ }
326
+ if (/^INSERT INTO government_rank_permissions .* FROM unnest\(\$1::text\[\], \$2::text\[\]\) WITH ORDINALITY .* ORDER BY g\.ord ON CONFLICT \(rank_key, permission_key\) DO NOTHING RETURNING rank_key, permission_key$/.test(s)) {
327
+ const rows = [];
328
+ p[0].forEach((rk, i) => { if (addGrant(rk, p[1][i])) rows.push({ rank_key: rk, permission_key: p[1][i] }); });
329
+ return { rows };
330
+ }
331
+ throw new Error(`rank store: unhandled statement: ${s.slice(0, 120)}`);
332
+ }
333
+ return { query, client: { query, release() {} }, queries, get state() { return JSON.parse(JSON.stringify(st)); } };
334
+ }
335
+
336
+ // Point the REAL government db functions at a rankStore for the length of `fn`.
337
+ const pgPool = require(path.join(ROOT, 'src', 'bongos', 'pool.js')).pool;
338
+ const RANK_DB_FNS = ['getRankByKey', 'createRank', 'grantRankPermission', 'applyRankSet'];
339
+ async function withRealRankDb(store, fn) {
340
+ const saved = Object.fromEntries(RANK_DB_FNS.map((k) => [k, stub[k]]));
341
+ const own = { query: Object.prototype.hasOwnProperty.call(pgPool, 'query'), connect: Object.prototype.hasOwnProperty.call(pgPool, 'connect') };
342
+ const realQuery = pgPool.query;
343
+ const realConnect = pgPool.connect;
344
+ for (const k of RANK_DB_FNS) stub[k] = REAL[k];
345
+ pgPool.query = store.query;
346
+ pgPool.connect = async () => store.client;
347
+ try { return await fn(); } finally {
348
+ Object.assign(stub, saved);
349
+ if (own.query) pgPool.query = realQuery; else delete pgPool.query;
350
+ if (own.connect) pgPool.connect = realConnect; else delete pgPool.connect;
351
+ }
352
+ }
353
+
268
354
  test('POST /government/rank-sets/apply creates the missing ranks with their grants and leaves existing ones alone', async () => {
269
- const realGet = stub.getRankByKey;
270
355
  // 'design-lead' already exists: it must be skipped, never re-granted.
271
- stub.getRankByKey = async (k) => (k === 'design-lead' ? { rank_key: k, label: 'Mine', is_system: false, seeded_rank: null } : null);
272
- try {
273
- const r = await call('POST', '/government/rank-sets/apply', { type: 'game', headcount: 20 });
274
- assert.equal(r.status, 200);
275
- assert.deepEqual(r.json.skipped, [{ rank_key: 'design-lead', label: 'Design lead' }], 'a kept rank is named by its label, not its key (task 1004478)');
276
- assert.deepEqual(r.json.created.map((c) => c.rank_key), ['playtester']);
277
- const creates = calls.filter(([k]) => k === 'createRank');
278
- assert.equal(creates.length, 1);
279
- assert.equal(creates[0][1].createdBy, 42, 'created_by is the session builder');
280
- const grants = calls.filter(([k]) => k === 'grantRankPermission').map(([, a]) => a);
281
- assert.ok(grants.length > 0 && grants.every((g) => g.rankKey === 'playtester'), 'an existing rank is never re-granted');
282
- assert.ok(grants.every((g) => catalog.isGrantableTo(g.permissionKey, { is_system: false, seeded_rank: null })));
283
- } finally { stub.getRankByKey = realGet; }
356
+ const store = rankStore({ existing: ['design-lead'] });
357
+ const r = await withRealRankDb(store, () => call('POST', '/government/rank-sets/apply', { type: 'game', headcount: 20 }));
358
+ assert.equal(r.status, 200);
359
+ assert.deepEqual(r.json.skipped, [{ rank_key: 'design-lead', label: 'Design lead' }], 'a kept rank is named by its label, not its key (task 1004478)');
360
+ assert.deepEqual(r.json.created.map((c) => c.rank_key), ['playtester']);
361
+ const { ranks, grants } = store.state;
362
+ assert.deepEqual(ranks.map((x) => [x.rank_key, x.label, x.created_by]), [['design-lead', 'Theirs', 7], ['playtester', 'Playtester', 42]],
363
+ 'the kept rank is untouched and created_by is the session builder');
364
+ assert.ok(grants.length > 0 && grants.every((g) => g.rank_key === 'playtester'), 'an existing rank is never re-granted');
365
+ assert.ok(grants.every((g) => catalog.isGrantableTo(g.permission_key, { is_system: false, seeded_rank: null })));
366
+ assert.deepEqual(r.json.created[0].permissions, grants.map((g) => g.permission_key), 'the reply names exactly what was granted');
367
+ assert.equal(store.queries.filter((q) => q === 'BEGIN').length, 1, 'one transaction');
368
+ assert.equal(store.queries.filter((q) => /^INSERT/.test(q)).length, 2, 'one rank insert and one grant insert, however many grants');
284
369
  });
285
370
 
286
371
  test('POST /government/rank-sets/apply needs a headcount and does nothing at founding size', async () => {
287
372
  const bad = await call('POST', '/government/rank-sets/apply', { type: 'game' });
288
373
  assert.equal(bad.status, 400);
289
- const realGet = stub.getRankByKey;
290
- stub.getRankByKey = async () => null;
291
- try {
292
- const solo = await call('POST', '/government/rank-sets/apply', { type: 'game', headcount: 1 });
293
- assert.equal(solo.status, 200);
294
- assert.deepEqual(solo.json.created, []);
295
- assert.equal(calls.filter(([k]) => k === 'createRank').length, 0);
296
- } finally { stub.getRankByKey = realGet; }
374
+ const store = rankStore();
375
+ const solo = await withRealRankDb(store, () => call('POST', '/government/rank-sets/apply', { type: 'game', headcount: 1 }));
376
+ assert.equal(solo.status, 200);
377
+ assert.deepEqual(solo.json.created, []);
378
+ assert.deepEqual(store.queries, [], 'an empty set touches nothing');
379
+ });
380
+
381
+ test('POST /government/rank-sets/apply: a failure part-way writes NOTHING (one transaction)', async () => {
382
+ const store = rankStore({ failOnGrant: true });
383
+ const r = await withRealRankDb(store, () => call('POST', '/government/rank-sets/apply', { type: 'business', headcount: 200 }));
384
+ assert.equal(r.status, 500);
385
+ assert.deepEqual(r.json.error.details, { created: [], skipped: [] }, 'nothing was written, so nothing is reported as added');
386
+ assert.deepEqual(store.state, { ranks: [], grants: [] }, 'the ranks inserted before the failure are rolled back');
387
+ assert.ok(store.queries.includes('ROLLBACK'));
388
+ });
389
+
390
+ test('POST /government/rank-sets/apply: the batched write gives the SAME reply and tables as the old per-row loop', async () => {
391
+ const legacy = require(path.join(ROOT, 'tests', 'fixtures', 'per-row-loops-legacy.cjs'));
392
+ const recommendations = require(path.join(ROOT, 'modules', 'government', 'recommendations.js'));
393
+ const realDb = { getRankByKey: REAL.getRankByKey, createRank: REAL.createRank, grantRankPermission: REAL.grantRankPermission };
394
+ let compared = 0;
395
+ for (const { key: type } of recommendations.PROJECT_TYPES) {
396
+ for (const headcount of [1, 5, 20, 80, 200]) {
397
+ const keys = recommendations.recommendedRankSet(type, headcount).ranks.map((x) => x.rank_key);
398
+ const scenarios = {
399
+ 'nothing there yet': {},
400
+ 'the first rank exists': { existing: keys.slice(0, 1) },
401
+ 'every rank exists': { existing: keys },
402
+ 'the last rank is created by someone else mid-call': { race: keys.slice(-1) },
403
+ };
404
+ for (const [name, opts] of Object.entries(scenarios)) {
405
+ const label = `${type} × ${headcount}: ${name}`;
406
+ const oldStore = rankStore(opts);
407
+ const old = await withRealRankDb(oldStore, () => legacy.legacyApplyRankSet({
408
+ db: realDb, catalog, rankSet: recommendations.recommendedRankSet(type, headcount), builderId: 42,
409
+ }));
410
+ const newStore = rankStore(opts);
411
+ const neu = await withRealRankDb(newStore, () => call('POST', '/government/rank-sets/apply', { type, headcount }));
412
+ assert.equal(neu.status, 200, label);
413
+ assert.deepEqual({ created: neu.json.created, skipped: neu.json.skipped }, old, `${label}: the same reply`);
414
+ assert.deepEqual(newStore.state, oldStore.state, `${label}: the same ranks and grants written, in the same order`);
415
+ compared += 1;
416
+ }
417
+ }
418
+ }
419
+ assert.ok(compared >= 20, `compared ${compared} cases`);
297
420
  });
298
421
 
299
422
  // --- create role -------------------------------------------------------------
package/tests/grader.mjs CHANGED
@@ -789,6 +789,37 @@ test('buildContextBundle reads changed files once, skips binaries/missing, respe
789
789
  assert.equal(bundle.total_chars, files['src/a.js'].length + files['docs/b.md'].length);
790
790
  });
791
791
 
792
+ // task 1004932 — the bundle rides in EVERY worker prompt, so its total is capped at
793
+ // 60k chars (was 120k), and it is filled most-changed first so the cap drops the
794
+ // lightly touched files, never the heart of the change.
795
+ const CAP_FILE = 15_000; // under the 16k per-file cap, so nothing truncates
796
+ function capFiles(names) { return Object.fromEntries(names.map((n) => [n, `// ${n}\n` + 'x'.repeat(CAP_FILE)])); }
797
+ function diffAdding(counts) {
798
+ return Object.entries(counts).map(([f, n]) =>
799
+ `diff --git a/${f} b/${f}\n--- a/${f}\n+++ b/${f}\n@@ -1 +1,${n} @@\n` +
800
+ Array.from({ length: n }, (_, i) => `+const line_${i}_of_${f.replace(/\W/g, '_')} = ${i};`).join('\n')).join('\n');
801
+ }
802
+
803
+ test('buildContextBundle caps the shared bundle at 60k chars (task 1004932)', () => {
804
+ const names = ['a.js', 'b.js', 'c.js', 'd.js', 'e.js'];
805
+ const files = capFiles(names);
806
+ const bundle = grader.buildContextBundle({ changedFiles: names, cwd: '/r', fsImpl: fakeFsFor(files) });
807
+ assert.ok(bundle.total_chars <= 60_000, `bundle is ${bundle.total_chars} chars — over the 60k cap`);
808
+ assert.ok(bundle.total_chars > 45_000, 'the cap is not lower than 60k: three ~15k files still fit');
809
+ assert.deepEqual(bundle.files.map((f) => f.file), ['a.js', 'b.js', 'c.js'], 'no diff: the caller order is kept');
810
+ assert.deepEqual(bundle.skipped, ['d.js', 'e.js'], 'what does not fit is named in skipped, never silent');
811
+ });
812
+
813
+ test('buildContextBundle fills most-changed first, so the cap drops the lightly touched files (task 1004932)', () => {
814
+ const names = ['docs/index.md', 'docs/map.md', 'tests/x.mjs', 'README.md', 'src/heart.js'];
815
+ const files = capFiles(names);
816
+ const diff = diffAdding({ 'docs/index.md': 1, 'docs/map.md': 1, 'tests/x.mjs': 20, 'README.md': 2, 'src/heart.js': 60 });
817
+ const bundle = grader.buildContextBundle({ changedFiles: names, cwd: '/r', diff, fsImpl: fakeFsFor(files) });
818
+ assert.deepEqual(bundle.files.map((f) => f.file), ['src/heart.js', 'tests/x.mjs', 'README.md'],
819
+ 'the file the diff is mostly about comes first, even when it is listed last');
820
+ assert.deepEqual(bundle.skipped, ['docs/index.md', 'docs/map.md'], 'ties keep their listed order, and one-line touches are what falls off');
821
+ });
822
+
792
823
  test('buildContextBundle truncates oversized files and marks them', () => {
793
824
  const big = 'x'.repeat(20_000);
794
825
  const bundle = grader.buildContextBundle({
@@ -289,6 +289,14 @@ test('the city sits in the hall chrome, behind the shell\'s full-screen switch',
289
289
  // gov-feeds.js, its one reader per page (shared with the shell's tally), load before it
290
290
  // task 1004702: the card answers its first ask in place, so the deck's card and sender (and the desk rows under them) load before the page
291
291
  assert.deepEqual(scripts, ['dom-utils', 'shell-trail', 'craft-mode', 'shell', 'palette', 'gov-living', 'gov-feeds', 'city-draw', 'hall-kit', 'street-draw', 'street-card', 'street-inside', 'street-desk', 'street-deck', 'city-decide', 'city-switchboard', 'city'], 'the shell first, as on every hall page');
292
+ // task 1004925: every script but dom-utils.js is deferred, so none holds up the
293
+ // parser; defer runs them in document order, which is why the order above still
294
+ // holds. dom-utils.js stays synchronous: the one-escaper guard
295
+ // (scripts/bongos/escape-helper-guard.js) requires window.OTB before anything runs.
296
+ const tags = [...html.matchAll(/<script\b[^>]*>/g)].map((m) => m[0]);
297
+ assert.equal(tags.length, scripts.length, 'no inline script runs ahead of the deferred ones');
298
+ assert.doesNotMatch(tags[0], /\b(defer|async)\b/, 'dom-utils.js loads synchronously, first');
299
+ for (const tag of tags.slice(1)) assert.match(tag, /\sdefer(?=[\s>])/, `${tag} is deferred`);
292
300
  const css = read('city.css').replace(/\/\*[\s\S]*?\*\//g, '');
293
301
  assert.match(css, /\.city-main \{ position: fixed; top: var\(--topbar-h, 56px\); left: var\(--sidebar-w-rail, 64px\);[^}]*contain: layout paint;/);
294
302
  assert.match(css, /html\[data-fullscreen="on"\] \.city-main \{ top: 0; left: 0; \}/);
@@ -57,13 +57,19 @@ function entries() {
57
57
  return out;
58
58
  }
59
59
 
60
+ // A hook may ALSO have an entry for the file-writing tools only (secret-gate does,
61
+ // since task 1004931 moved its shell half into the shell-hooks.js dispatcher). What
62
+ // must never exist is an entry naming SOME shell tools but not all — the bug above.
60
63
  test('every shell-reading hook is registered, and its matcher names every shell tool', () => {
61
64
  const all = entries();
62
65
  for (const hook of SHELL_HOOKS) {
63
66
  const found = all.filter((e) => e.command.includes(hook));
64
67
  assert.ok(found.length > 0, `${hook} is not registered in settings.json at all`);
68
+ let coversShell = false;
65
69
  for (const e of found) {
66
70
  const named = e.matcher.split('|').map((s) => s.trim());
71
+ const shellNamed = SHELL_TOOLS.filter((tool) => named.includes(tool));
72
+ if (shellNamed.length === 0) continue; // a file-writing-tools entry
67
73
  for (const tool of SHELL_TOOLS) {
68
74
  assert.ok(
69
75
  named.includes(tool),
@@ -71,7 +77,9 @@ test('every shell-reading hook is registered, and its matcher names every shell
71
77
  'that shell tool reaches the hook not at all, silently',
72
78
  );
73
79
  }
80
+ coversShell = true;
74
81
  }
82
+ assert.ok(coversShell, `${hook} has no entry whose matcher names the shell tools`);
75
83
  }
76
84
  });
77
85
 
@@ -20,7 +20,7 @@ import path from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
 
22
22
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
- const HOOKS = ['recall-hints', 'rank-change-greeting', 'conductor', 'pull-memory', 'edit-hints', 'stuck-detector'].map((n) => path.join(ROOT, '.claude', 'hooks', `${n}.js`));
23
+ const HOOKS = ['recall-hints', 'module-skills', 'rank-change-greeting', 'conductor', 'pull-memory', 'edit-hints', 'stuck-detector'].map((n) => path.join(ROOT, '.claude', 'hooks', `${n}.js`));
24
24
  // The SessionStart/SessionEnd scripts that did network work and then process.exit()ed — 59 recorded
25
25
  // UV_HANDLE_CLOSING aborts in three days (task 1003548). Same contract: exit 0 on their own, no abort.
26
26
  // session-digest requires `pg` at load (via db-kernel), so it can only be exercised where node_modules
@@ -40,6 +40,7 @@ const ALLOWED = {
40
40
  first_run_engine_check: '.claude/hooks/session-start.js (package-excluded)',
41
41
  hook_core_resolve: '.claude/hooks/_core-resolve.js (package-excluded)',
42
42
  'lifecycle-card': '.claude/hooks/lifecycle-card.js (package-excluded)',
43
+ module_skills_hook: '.claude/hooks/module-skills.js (package-excluded)',
43
44
  pre_compact: '.claude/hooks/pre-compact.js (package-excluded)',
44
45
  preview_server: 'src/preview-server.js (game module, not in the core)',
45
46
  recall_hints: '.claude/hooks/recall-hints.js (package-excluded)',
@@ -47,6 +48,7 @@ const ALLOWED = {
47
48
  run_routine_two_root: '.claude/hooks/_core-resolve.js (package-excluded)',
48
49
  'session-cards': '.claude/hooks/session-cards.js (package-excluded)',
49
50
  session_start_freshness: '.claude/hooks/session-start.js (package-excluded)',
51
+ shell_hooks: '.claude/hooks/shell-hooks.js (package-excluded)',
50
52
  stop_refcheck: '.claude/hooks/stop-refcheck.js (package-excluded; the task-1002234 precedent)',
51
53
  stuck_detector: '.claude/hooks/stuck-strikes.js (package-excluded)',
52
54
  worktree_guard: '.claude/hooks/worktree-guard.js (package-excluded)',
@@ -119,6 +119,23 @@ test('ordinary prompts are not guide requests', () => {
119
119
  }
120
120
  });
121
121
 
122
+ // Task 1004744: a pasted handoff quoting "skip the tutorial" made the Conductor tell
123
+ // the AI to run `npx bongos howto skip` unasked. Only a prompt that IS the request counts.
124
+ test('a long or multi-line prompt that merely quotes a request is not one', () => {
125
+ const handoff = [
126
+ 'Continue the onboarding chain from task 1004636.',
127
+ 'Context: the owner said "skip the tutorial" is a valid phrase the guide must accept.',
128
+ 'Next: wire the hall card.',
129
+ ].join('\n');
130
+ assert.equal(m.guideRequest(handoff), null, 'multi-line handoff');
131
+ const oneLong = 'Reviewing the copy for the how-to card: the button reads "skip the tutorial", which matches what the guide accepts, so leave it.';
132
+ assert.ok(oneLong.length > 100);
133
+ assert.equal(m.guideRequest(oneLong), null, 'one long line');
134
+ assert.equal(m.guideRequest(' skip the tutorial \n'), 'skip', 'surrounding whitespace is still the request');
135
+ const a = howtoAdvisory({ howto: { read: [] }, prompt: handoff, firstPrompt: false, activeClaims: [] });
136
+ assert.ok(!a || !String(a.id).startsWith('howto-request:'), 'the Conductor gives no howto command for the handoff');
137
+ });
138
+
122
139
  test('each request tells the AI the command that changes the stored state', () => {
123
140
  for (const kind of ['show', 'next', 'skip', 'reset']) assert.match(m.renderRequestForAi(kind), new RegExp(`npx bongos howto ${kind}`));
124
141
  });
@@ -278,7 +278,7 @@ test('the live tree discovers the extracted modules cleanly (BV1.R43/R48/R47/R49
278
278
  // That is precisely how the agents regression reached an open PR (task 1002485).
279
279
  loader._reset();
280
280
  const snap = loader.loaderSnapshot();
281
- assert.deepEqual(snap.loaded, ['agents', 'autonomy', 'builder-settings', 'copy-desk', 'design-styles', 'discord', 'economy', 'government', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'memory', 'npm-release', 'onboarding', 'page-notes', 'pixel-art', 'platform-identity', 'provisioning', 'public-landing', 'raise', 'render-deploy', 'security', 'sessions', 'specialities', 'status-ui', 'ui-design']);
281
+ assert.deepEqual(snap.loaded, ['agents', 'ai-studio', 'autonomy', 'builder-settings', 'copy-desk', 'design-styles', 'discord', 'economy', 'government', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'memory', 'npm-release', 'onboarding', 'page-notes', 'pixel-art', 'platform-identity', 'provisioning', 'public-landing', 'raise', 'render-deploy', 'security', 'sessions', 'specialities', 'status-ui', 'ui-design']);
282
282
  assert.deepEqual(snap.errors, []);
283
283
  assert.deepEqual(snap.skipped, []);
284
284
  loader._reset();