@bongos/core 1.24.14 → 1.24.16

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 (165) hide show
  1. package/.bongos-core.json +308 -138
  2. package/.claude/skills/blocker-review/SKILL.md +2 -0
  3. package/.claude/skills/session-board/SKILL.md +29 -0
  4. package/.claude/skills/strand-fix/SKILL.md +1 -1
  5. package/clients/bongos-client/README.md +1 -1
  6. package/clients/bongos-client/bongos-client.global.js +14 -0
  7. package/clients/bongos-client/index.cjs +14 -0
  8. package/clients/bongos-client/index.d.ts +23 -2
  9. package/clients/bongos-client/index.mjs +14 -0
  10. package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +4 -0
  11. package/docs/api/openapi.json +369 -6
  12. package/docs/api-reference.md +19 -4
  13. package/docs/architecture.md +6 -0
  14. package/docs/copy-inventory.md +231 -204
  15. package/docs/copy-registry.json +488 -255
  16. package/docs/file-map.md +1 -0
  17. package/docs/module-api-changelog.md +4 -2
  18. package/docs/page-inventory.json +7 -1
  19. package/docs/page-readings.json +1132 -1103
  20. package/migrations/core_289_task_short_name.sql +34 -0
  21. package/modules/ai-studio/db.js +10 -0
  22. package/modules/ai-studio/gemini.js +73 -5
  23. package/modules/ai-studio/openrouter.js +17 -2
  24. package/modules/ai-studio/routes/ai-studio.js +7 -1
  25. package/modules/ai-studio/studio.js +8 -0
  26. package/modules/builder-settings/checkout-freshness.js +71 -0
  27. package/modules/builder-settings/routes/settings.js +26 -0
  28. package/modules/builder-settings/settings.js +2 -0
  29. package/modules/economy/migrations/economy_002_project_account.sql +83 -0
  30. package/modules/economy/module.json +1 -1
  31. package/modules/economy/project-account.js +166 -0
  32. package/modules/economy/routes/project-account.js +40 -0
  33. package/modules/hall-ui/public/ai-studio.css +3 -0
  34. package/modules/hall-ui/public/ai-studio.html +2 -1
  35. package/modules/hall-ui/public/ai-studio.js +72 -1
  36. package/modules/hall-ui/public/atlas.html +1 -1
  37. package/modules/hall-ui/public/blockers.html +1 -1
  38. package/modules/hall-ui/public/board-room.html +1 -1
  39. package/modules/hall-ui/public/city.html +1 -1
  40. package/modules/hall-ui/public/collab.html +1 -1
  41. package/modules/hall-ui/public/commands.html +1 -1
  42. package/modules/hall-ui/public/copy-desk.html +1 -1
  43. package/modules/hall-ui/public/deploy.html +1 -1
  44. package/modules/hall-ui/public/diagrams.html +1 -1
  45. package/modules/hall-ui/public/drachmae.html +1 -1
  46. package/modules/hall-ui/public/fleet.html +1 -1
  47. package/modules/hall-ui/public/gate.html +1 -1
  48. package/modules/hall-ui/public/github-chip.js +85 -0
  49. package/modules/hall-ui/public/goal-map.html +1 -1
  50. package/modules/hall-ui/public/goals.html +1 -1
  51. package/modules/hall-ui/public/government.html +1 -1
  52. package/modules/hall-ui/public/idea.html +1 -1
  53. package/modules/hall-ui/public/ideas.html +1 -1
  54. package/modules/hall-ui/public/index.html +1 -1
  55. package/modules/hall-ui/public/market-watch-item.html +2 -2
  56. package/modules/hall-ui/public/market-watch-item.js +10 -3
  57. package/modules/hall-ui/public/market-watch-item.states.json +41 -0
  58. package/modules/hall-ui/public/market-watch-lib.js +36 -5
  59. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  60. package/modules/hall-ui/public/market-watch-settings.html +1 -1
  61. package/modules/hall-ui/public/market-watch.css +5 -0
  62. package/modules/hall-ui/public/market-watch.html +2 -2
  63. package/modules/hall-ui/public/modules.html +1 -1
  64. package/modules/hall-ui/public/my-agents.html +1 -1
  65. package/modules/hall-ui/public/my-agents.js +3 -0
  66. package/modules/hall-ui/public/panel.css +5 -0
  67. package/modules/hall-ui/public/primer.html +1 -1
  68. package/modules/hall-ui/public/profile.html +1 -1
  69. package/modules/hall-ui/public/project-settings.html +1 -1
  70. package/modules/hall-ui/public/ranks.html +1 -1
  71. package/modules/hall-ui/public/roadmap.html +1 -1
  72. package/modules/hall-ui/public/roster.html +1 -1
  73. package/modules/hall-ui/public/sessions.html +1 -1
  74. package/modules/hall-ui/public/settings-age.js +65 -0
  75. package/modules/hall-ui/public/settings.html +27 -1
  76. package/modules/hall-ui/public/settings.js +1 -0
  77. package/modules/hall-ui/public/shell.js +22 -22
  78. package/modules/hall-ui/public/street.html +1 -1
  79. package/modules/hall-ui/public/studio.html +1 -1
  80. package/modules/hall-ui/public/style.css +28 -0
  81. package/modules/hall-ui/public/task.html +1 -1
  82. package/modules/hall-ui/public/thinking.html +1 -1
  83. package/modules/hall-ui/public/tweak-editor.html +1 -1
  84. package/modules/hall-ui/public/watch.html +1 -1
  85. package/modules/hall-ui/public/work.html +1 -1
  86. package/modules/ideas/blockers.js +13 -7
  87. package/modules/lifecycle/db-claim-reads.js +1 -1
  88. package/modules/lifecycle/db-ship.js +2 -2
  89. package/modules/lifecycle/db-tasks.js +4 -4
  90. package/modules/lifecycle/migrations/lifecycle_019_pay_type_gates.sql +50 -0
  91. package/modules/lifecycle/module.json +1 -0
  92. package/modules/lifecycle/pay-type-gates.js +180 -0
  93. package/modules/lifecycle/publish-reconciler.js +31 -1
  94. package/modules/lifecycle/rebase-gate.js +3 -2
  95. package/modules/lifecycle/routes/claims.js +3 -2
  96. package/modules/lifecycle/routes/pay-type-gates.js +59 -0
  97. package/modules/lifecycle/routes/project-settings.js +4 -0
  98. package/modules/lifecycle/routes/task-write-routes.js +43 -0
  99. package/modules/lifecycle/ship-card.js +4 -1
  100. package/modules/lifecycle/task-name-db.js +55 -0
  101. package/modules/lifecycle/task-name.js +140 -0
  102. package/modules/market-watch/db.js +10 -1
  103. package/modules/market-watch/migrations/market_watch_004_entry_corrections.sql +20 -0
  104. package/modules/market-watch/pages.js +20 -1
  105. package/modules/market-watch/proposals.js +57 -7
  106. package/modules/market-watch/routes/pages.js +2 -0
  107. package/modules/market-watch/routes/rules.js +4 -0
  108. package/modules/platform-identity/terms-acceptance.js +9 -9
  109. package/modules/public-landing/public/account-google.states.json +14 -0
  110. package/modules/public-landing/public/assets/cosmos.css +12 -0
  111. package/modules/public-landing/public/assets/hub-bar.js +44 -1
  112. package/modules/public-landing/public/privacy.html +5 -1
  113. package/modules/public-landing/public/projects-google.states.json +14 -0
  114. package/modules/public-landing/public/projects.html +39 -0
  115. package/modules/public-landing/public/terms.html +12 -2
  116. package/modules/ui-design/kit/fixtures/me-google.json +10 -0
  117. package/modules/ui-design/kit/fixtures/me-sign-in-methods-google.json +4 -0
  118. package/modules/ui-design/kit/serve.js +7 -1
  119. package/package-lock.json +2 -2
  120. package/package.json +1 -1
  121. package/release-notes.json +55 -1
  122. package/scripts/bongos/claim.js +4 -2
  123. package/scripts/bongos/land-watch.js +1 -0
  124. package/scripts/bongos/report-checkout.js +30 -0
  125. package/scripts/bongos/run-unit-tests.js +11 -0
  126. package/scripts/bongos/sessions-board.js +264 -0
  127. package/scripts/bongos/ship-finish.js +1 -0
  128. package/scripts/bongos/ship-flow.js +5 -2
  129. package/scripts/bongos/strand-lease.js +5 -3
  130. package/scripts/bongos/strand-watch.js +38 -7
  131. package/scripts/gds/report-checkout.js +7 -0
  132. package/scripts/gds/sessions-board.js +7 -0
  133. package/src/bongos/agent-standing-rules.js +13 -3
  134. package/src/bongos/routes/me.js +7 -1
  135. package/src/module-api.js +1 -1
  136. package/tests/agent_standing_rules.mjs +18 -1
  137. package/tests/ai_studio_billing.mjs +13 -0
  138. package/tests/ai_studio_gemini_models.mjs +81 -0
  139. package/tests/ai_studio_requests.mjs +24 -0
  140. package/tests/blocker_detail_hydration.mjs +5 -3
  141. package/tests/blocker_tasks_named_db.mjs +74 -0
  142. package/tests/claim_gate_ci_unblock.mjs +1 -1
  143. package/tests/github_chip.mjs +217 -0
  144. package/tests/hall_ai_studio.mjs +129 -5
  145. package/tests/host_topology_skips.mjs +1 -0
  146. package/tests/hub_account_page.mjs +3 -2
  147. package/tests/landing_legal_pages.mjs +3 -2
  148. package/tests/market_watch_data_db.mjs +6 -0
  149. package/tests/market_watch_pages.mjs +87 -2
  150. package/tests/market_watch_rules.mjs +101 -5
  151. package/tests/market_watch_rules_db.mjs +42 -0
  152. package/tests/nav_permission_atoms.mjs +1 -1
  153. package/tests/pay_terms.mjs +8 -0
  154. package/tests/pay_type_gates.mjs +234 -0
  155. package/tests/pay_type_gates_db.mjs +84 -0
  156. package/tests/project_account.mjs +125 -0
  157. package/tests/project_account_db.mjs +97 -0
  158. package/tests/self_healing_start.mjs +162 -0
  159. package/tests/sessions_board.mjs +153 -0
  160. package/tests/settings_age.mjs +110 -0
  161. package/tests/stop_refcheck.mjs +40 -0
  162. package/tests/task_name.mjs +237 -0
  163. package/tests/task_name_db.mjs +79 -0
  164. package/tests/terms_acceptance.mjs +25 -3
  165. package/tests/terms_grandfather_db.mjs +11 -4
@@ -0,0 +1,264 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ //
4
+ // scripts/bongos/sessions-board.js — every session a builder has going, and where they
5
+ // are jammed, as a board the AI shows in chat (task 1004957).
6
+ //
7
+ // WHY. A builder running several sessions at once could not see where things were stuck:
8
+ // which session was building, which piece was waiting to land, which had come back from
9
+ // review, which ship was stuck — and the one that matters most, that new work was paused
10
+ // for EVERY session because too many ships were stuck (the REBASE_REQUIRED queue gate).
11
+ // The owner wanted this as a board the AI shows in chat (/session-board), not a hall
12
+ // page, plus a one-line version at session start when something is jammed, so a new
13
+ // session knows not to pile on.
14
+ //
15
+ // WHERE IT READS. Nothing new on the server: GET /me already carries the builder's active
16
+ // claims (one per session, with the worktree it lives in) and the stuck ships that gate
17
+ // them (needs_rebase_tasks); the confirmed and completed task lists say what is waiting to
18
+ // land and what came back from review. Whether the gate is shut is the server's own rule
19
+ // (modules/lifecycle/rebase-gate.js), read here rather than re-derived. Who is fixing a
20
+ // stuck ship is the local strand lease (strand-lease.js, task 1004956).
21
+ //
22
+ // Usage:
23
+ // node scripts/bongos/sessions-board.js # the board, as markdown for chat
24
+ // node scripts/bongos/sessions-board.js --json # the same, as data
25
+ // node scripts/bongos/sessions-board.js --line # ONE line at session start, only when jammed
26
+ //
27
+ // The same line also rides strand-watch.js --line (the stuck-ship startup note) through
28
+ // lineNow(), which is how it reaches a new session: the owner chose that over adding a
29
+ // startup step of its own (2026-10-30).
30
+ //
31
+ // --line is FAIL-OPEN and SILENT when nothing is jammed, and carries ids and fixed words
32
+ // only — never a task title, which is builder-authored text injected into every session
33
+ // (the strand-watch.js trust boundary).
34
+
35
+ const { apiCall, hasFlag } = require('./cli-lib');
36
+ const { splitRebaseGate } = require('../../modules/lifecycle/rebase-gate.js');
37
+ // task 1004958: every task is written "task N - <name>"; safeTaskName passes only the
38
+ // generated name's fixed shape, so it is safe in the startup line too.
39
+ const { safeTaskName, taskRef } = require('../../modules/lifecycle/task-name.js');
40
+
41
+ const PAGE_LIMIT = 200;
42
+ const safeId = (v) => (/^\d{1,19}$/.test(String(v)) ? String(v) : null);
43
+
44
+ // A stuck ship's reason, in words a person reads.
45
+ const REASON_WORDS = Object.freeze({
46
+ land_not_proven: 'it may have landed, but that could not be proven',
47
+ pr_not_landed: 'its change is waiting to merge',
48
+ unresolvable_conflict: 'its change overlaps newer work',
49
+ branch_modifies_executed_code: 'it changes code the merge helper will not run; a person lands it',
50
+ red_check: 'a check failed on its change',
51
+ });
52
+ function reasonWords(reason) {
53
+ const key = String(reason || '').replace(/^strand:/, '').split(':')[0];
54
+ if (/^waiting:/.test(String(reason || ''))) return `waiting on ${String(reason).slice(8)}`;
55
+ return REASON_WORDS[key] || 'stuck on its way in';
56
+ }
57
+
58
+ function ageWords(iso, now) {
59
+ const t = Date.parse(iso || '');
60
+ if (!Number.isFinite(t)) return '';
61
+ const h = (now - t) / 3600000;
62
+ if (h < 1) return `${Math.max(1, Math.round(h * 60))}m`;
63
+ if (h < 48) return `${Math.round(h)}h`;
64
+ return `${Math.round(h / 24)}d`;
65
+ }
66
+
67
+ const upper = (t) => t.charAt(0).toUpperCase() + t.slice(1);
68
+
69
+ // boardOf(input) → { sessions, landing, back, stuck, gate, jams }. PURE.
70
+ // me GET /me (active_claims, needs_rebase_tasks, builder.id)
71
+ // confirmed confirmed tasks (any builder; filtered to this one by shipped_by)
72
+ // completed completed tasks (same)
73
+ // leases live strand leases [{ task_id, holder, label, taken_at }]
74
+ // here this session's worktree { holder, label } or null
75
+ function boardOf({ me, confirmed = [], completed = [], leases = [], here = null, now = Date.now(), truncated = false } = {}) {
76
+ const meId = me && me.builder && me.builder.id != null ? String(me.builder.id) : null;
77
+ const claims = (me && Array.isArray(me.active_claims)) ? me.active_claims : [];
78
+ const owed = (me && Array.isArray(me.needs_rebase_tasks)) ? me.needs_rebase_tasks : [];
79
+ const mine = (t) => t && meId && String(t.shipped_by || t.claimed_by || '') === meId;
80
+ const stuckIds = new Set(owed.map((t) => String(t.id)));
81
+ const leaseFor = (id) => leases.find((l) => String(l.task_id) === String(id)) || null;
82
+
83
+ const sessions = claims.map((c) => ({
84
+ task_id: String(c.task_id),
85
+ name: safeTaskName(c.short_name),
86
+ title: c.title || null,
87
+ session: c.worktree_name || null,
88
+ here: !!(here && c.worktree_name && c.worktree_name === here.label),
89
+ stage: 'building',
90
+ since: ageWords(c.claimed_at, now),
91
+ waiting_on: c.release_requested_at ? 'someone asked for this task back' : null,
92
+ }));
93
+ const landing = confirmed.filter((t) => mine(t) && !stuckIds.has(String(t.id))).map((t) => ({
94
+ task_id: String(t.id), name: safeTaskName(t.short_name), title: t.title || null, stage: 'waiting to land', since: ageWords(t.updated_at, now),
95
+ }));
96
+ const back = completed.filter(mine).map((t) => ({
97
+ task_id: String(t.id), name: safeTaskName(t.short_name), title: t.title || null, stage: 'back from review', since: ageWords(t.updated_at, now),
98
+ waiting_on: 'fix what review found and ask again (or an artist approves a held page)',
99
+ }));
100
+ const split = splitRebaseGate(owed);
101
+ const stuck = owed.map((t) => {
102
+ const lease = leaseFor(t.id);
103
+ const waits = split.waiting.find((w) => String(w.id) === String(t.id));
104
+ return {
105
+ task_id: String(t.id),
106
+ name: safeTaskName(t.short_name),
107
+ title: t.title || null,
108
+ stage: 'stuck',
109
+ since: ageWords(t.needs_rebase_at, now),
110
+ why: reasonWords(t.needs_rebase_reason),
111
+ set_aside: !!waits,
112
+ fixer: lease ? (here && lease.holder === here.holder ? 'this session' : `session ${String(lease.label || '').replace(/[^\w.-]/g, '') || 'another session'}`) : null,
113
+ };
114
+ });
115
+ const gate = { shut: split.gates, fixable: split.fixable.length };
116
+
117
+ const jams = [];
118
+ if (gate.shut) {
119
+ jams.push({
120
+ kind: 'paused',
121
+ text: `New work is paused for every one of your sessions: ${split.fixable.length} stuck ship${split.fixable.length === 1 ? '' : 's'} must come down first.`,
122
+ task_ids: split.fixable.map((t) => String(t.id)),
123
+ });
124
+ }
125
+ for (const s of stuck) {
126
+ if (s.set_aside) continue;
127
+ jams.push({
128
+ kind: 'stuck',
129
+ text: s.fixer ? `${upper(taskRef(s.task_id, s.name))} is stuck (${s.why}); ${s.fixer} is fixing it.` : `${upper(taskRef(s.task_id, s.name))} is stuck (${s.why}); nobody is fixing it yet.`,
130
+ task_ids: [s.task_id],
131
+ fixer: s.fixer,
132
+ });
133
+ }
134
+ // A full page of confirmed or completed tasks means older ones of this builder's may
135
+ // be past it: the board says so rather than passing a capped list off as complete.
136
+ return { sessions, landing, back, stuck, gate, jams, truncated: !!truncated };
137
+ }
138
+
139
+ // The board as markdown for chat: jams first, then one table.
140
+ function markdown(b) {
141
+ const out = [];
142
+ out.push('**Jams**');
143
+ if (!b.jams.length) out.push('- Nothing is jammed.');
144
+ for (const j of b.jams) out.push(`- ${j.text}`);
145
+ out.push('');
146
+ const rows = [
147
+ ...b.sessions.map((s) => [s.session ? `${s.session}${s.here ? ' (this one)' : ''}` : '(no folder recorded)', s, s.waiting_on || '—']),
148
+ ...b.stuck.map((s) => ['—', s, s.set_aside ? `${s.why} (set aside; it does not pause new work)` : s.fixer ? `${s.why}; ${s.fixer} is fixing it` : `${s.why}; nobody is fixing it`]),
149
+ ...b.landing.map((s) => ['—', s, 'the merge; nothing for you to do']),
150
+ ...b.back.map((s) => ['—', s, s.waiting_on]),
151
+ ];
152
+ if (!rows.length) {
153
+ out.push('No sessions are building anything, and nothing of yours is in flight.');
154
+ return out.join('\n');
155
+ }
156
+ // A title is builder-authored: no link, emphasis, code or tag syntax reaches the chat,
157
+ // and a pipe cannot break the table.
158
+ const cell = (v) => String(v == null ? '' : v).replace(/[`*_[\]<>#~]/g, '').replace(/\|/g, '\\|').replace(/\s+/g, ' ').trim();
159
+ out.push('| Session | Task | Stage | Waiting on |');
160
+ out.push('|---|---|---|---|');
161
+ for (const [session, s, waits] of rows) {
162
+ const title = s.title ? ` — ${cell(s.title).slice(0, 70)}` : '';
163
+ out.push(`| ${cell(session)} | ${taskRef(s.task_id, s.name)}${title} | ${s.stage}${s.since ? ` (${s.since})` : ''} | ${cell(waits)} |`);
164
+ }
165
+ if (b.truncated) out.push('', 'More tasks were waiting to land or back from review than one read returns, so some older ones of yours may be missing from this board.');
166
+ return out.join('\n');
167
+ }
168
+
169
+ // ONE line for session start, or null. Only the pause is worth interrupting a new session
170
+ // for — single stuck ships are strand-watch's line already. Ids, generated task names
171
+ // (safeTaskName) and fixed words only.
172
+ function line(b) {
173
+ if (!b || !b.gate.shut) return null;
174
+ const paused = b.jams.find((j) => j.kind === 'paused');
175
+ const ids = (paused ? paused.task_ids : []).map(safeId).filter(Boolean);
176
+ const nameOf = (id) => { const s = b.stuck.find((x) => x.task_id === id); return taskRef(id, s && s.name); };
177
+ const fixing = b.stuck.filter((s) => s.fixer && ids.includes(s.task_id)).map((s) => `${taskRef(s.task_id, s.name)} by ${s.fixer.replace(/[^\w .-]/g, '')}`);
178
+ const free = ids.filter((id) => !b.stuck.some((s) => s.task_id === id && s.fixer));
179
+ return `Sessions board: new work is paused for every session of this builder until ${ids.length} stuck ship${ids.length === 1 ? '' : 's'} (${ids.map(nameOf).join(', ')}) come down. `
180
+ + (fixing.length ? `Already being fixed: ${fixing.join('; ')}. ` : '')
181
+ + (free.length ? `Nobody is on ${free.map(nameOf).join(', ')} yet — take one with /strand-fix, or do unclaimed work. ` : 'Do not pile on: carry on with unclaimed work. ')
182
+ + 'This note is for you, not the builder; show them /session-board only if they ask where things are stuck.';
183
+ }
184
+
185
+ // Who is fixing which stuck ship on this machine (strand-lease.js), and which session
186
+ // this is. No checkout: nobody is fixing anything that we can see.
187
+ function localLeases() {
188
+ try {
189
+ const lease = require('./strand-lease.js');
190
+ return { here: lease.currentHolder(), leases: lease.liveLeases({ dir: lease.leaseDir() }) };
191
+ } catch { return { here: null, leases: [] }; }
192
+ }
193
+
194
+ async function read() {
195
+ const body = (r) => (r && (r.data || r)) || {};
196
+ const [me, confirmed, completed] = await Promise.all([
197
+ apiCall('GET', '/api/bongos/me').then(body),
198
+ apiCall('GET', `/api/bongos/tasks?status=confirmed&limit=${PAGE_LIMIT}`).then((r) => body(r).tasks || []).catch(() => []),
199
+ apiCall('GET', `/api/bongos/tasks?status=completed&limit=${PAGE_LIMIT}`).then((r) => body(r).tasks || []).catch(() => []),
200
+ ]);
201
+ const { leases, here } = localLeases();
202
+ const truncated = confirmed.length >= PAGE_LIMIT || completed.length >= PAGE_LIMIT;
203
+ return { me, confirmed, completed, leases, here, truncated };
204
+ }
205
+
206
+ // THE STARTUP READ, shared across session starts. The pause needs only the stuck ships
207
+ // on /me, so the line asks for nothing else, and the answer is cached for every session
208
+ // on the machine (probe-cache, the strand-watch line's own cache): four sessions opened
209
+ // together make ONE call, not four times three. Stuck ships are measured in hours, so a
210
+ // few minutes' age is as true as fresh. A failed read throws, so it is never cached.
211
+ const OWED_CACHE_KEY = 'sessions-board-owed';
212
+ const OWED_FRESH_MS = 5 * 60 * 1000;
213
+ async function readOwed() {
214
+ const { cached } = require('./probe-cache.js');
215
+ const { value } = await cached(OWED_CACHE_KEY, async () => {
216
+ const r = await apiCall('GET', '/api/bongos/me');
217
+ const me = (r && (r.data || r)) || {};
218
+ if (!me.builder || !Array.isArray(me.needs_rebase_tasks)) throw new Error('unreadable');
219
+ return { builder: { id: me.builder.id }, needs_rebase_tasks: me.needs_rebase_tasks };
220
+ }, { ttlMs: OWED_FRESH_MS });
221
+ const { leases, here } = localLeases();
222
+ return { me: value, leases, here };
223
+ }
224
+
225
+ // The startup line, read and bounded: the line, or null on silence, a slow read or any
226
+ // failure. Never throws.
227
+ async function lineNow({ deadlineMs = 4000 } = {}) {
228
+ try {
229
+ const deadline = new Promise((_, rej) => setTimeout(() => rej(new Error('deadline')), deadlineMs).unref());
230
+ return line(boardOf(await Promise.race([readOwed(), deadline])));
231
+ } catch { return null; }
232
+ }
233
+
234
+ // The chat board as text. Fail OPEN: the board is read in chat, so a failed read is a
235
+ // sentence there, never a stack trace or a non-zero exit.
236
+ async function boardText({ json = false, readBoard = read } = {}) {
237
+ let input;
238
+ try {
239
+ input = await readBoard();
240
+ } catch (e) {
241
+ return json
242
+ ? JSON.stringify({ available: false, reason: String((e && e.message) || e) }, null, 2)
243
+ : '**Jams**\n- The board could not be read just now (the server did not answer). Nothing is known to be jammed or clear; try again in a minute.';
244
+ }
245
+ const b = boardOf(input);
246
+ return json ? JSON.stringify(b, null, 2) : markdown(b);
247
+ }
248
+
249
+ async function main() {
250
+ if (hasFlag('--line')) {
251
+ const one = await lineNow();
252
+ if (one) console.log(one);
253
+ process.exitCode = 0;
254
+ return;
255
+ }
256
+ console.log(await boardText({ json: hasFlag('--json') }));
257
+ process.exitCode = 0;
258
+ }
259
+
260
+ if (require.main === module) {
261
+ main().catch((e) => { console.error('sessions-board: failed —', e.message || e); process.exitCode = 1; });
262
+ }
263
+
264
+ module.exports = { boardOf, markdown, boardText, line, lineNow, readOwed, reasonWords, OWED_CACHE_KEY };
@@ -344,6 +344,7 @@ async function printCompletionCard({ taskId, postGradeStatus, card, usage, land
344
344
  stage, // task 1004379: 'held' from finishHeld; undefined ⇒ derived from postGradeStatus
345
345
  postGradeStatus,
346
346
  taskId,
347
+ shortName: card.shortName,
347
348
  title: card.title,
348
349
  valueSummary: card.valueSummary,
349
350
  notes: card.notes,
@@ -442,6 +442,7 @@ async function shipMain() {
442
442
  const model = shipCard.buildCardModel({
443
443
  stage: 'review',
444
444
  taskId,
445
+ shortName: ac.short_name,
445
446
  title: ac.title,
446
447
  valueSummary: summary,
447
448
  fileCount: committedFiles.length || null,
@@ -780,6 +781,7 @@ async function shipMain() {
780
781
  taskId, summary, advanceToMerge, skipMerge, postGradeStatus, doneLine: 'Done.', dbOnly, held,
781
782
  // task 1270: data for the deterministic completion card.
782
783
  card: {
784
+ shortName: ac.short_name,
783
785
  title: ac.title,
784
786
  valueSummary: summary,
785
787
  notes,
@@ -968,7 +970,7 @@ async function regradeMain(opts = {}) {
968
970
  await attachVisual();
969
971
  await finishShip({
970
972
  taskId, summary: summaryArg || task.value_summary || '', held: true, postGradeStatus: 'completed', doneLine: 'Done.',
971
- card: { title: task.title, valueSummary: summaryArg || task.value_summary || '', notes: '' },
973
+ card: { shortName: task.short_name, title: task.title, valueSummary: summaryArg || task.value_summary || '', notes: '' },
972
974
  });
973
975
  return;
974
976
  }
@@ -1088,7 +1090,7 @@ async function regradeMain(opts = {}) {
1088
1090
  taskId, summary, advanceToMerge: confirmedStatus === 'confirmed', skipMerge, dbOnly: true,
1089
1091
  postGradeStatus: confirmedStatus,
1090
1092
  doneLine: 'Done — confirmed with no artifact (grader had nothing to read).',
1091
- card: { title: task.title, valueSummary: summary, notes: '' },
1093
+ card: { shortName: task.short_name, title: task.title, valueSummary: summary, notes: '' },
1092
1094
  });
1093
1095
  return;
1094
1096
  }
@@ -1190,6 +1192,7 @@ async function regradeMain(opts = {}) {
1190
1192
  doneLine: g.passed ? 'Done — re-grade passed.' : 'Done — re-grade still failing; fix and try again.',
1191
1193
  // task 1270: completion card for the re-grade path too.
1192
1194
  card: {
1195
+ shortName: task.short_name,
1193
1196
  title: task.title,
1194
1197
  valueSummary: summary,
1195
1198
  // Empty by design, not by omission (task 1003702). A re-grade takes no
@@ -35,6 +35,7 @@
35
35
  // node scripts/bongos/strand-lease.js release <task-id> # drop this worktree's lease
36
36
  // node scripts/bongos/strand-lease.js show [<task-id>] # who holds what
37
37
 
38
+ const { taskRef } = require('../../modules/lifecycle/task-name.js'); // task 1004958
38
39
  const fs = require('node:fs');
39
40
  const path = require('node:path');
40
41
 
@@ -197,11 +198,12 @@ function describe(lease, now = Date.now()) {
197
198
  }
198
199
 
199
200
  // The clause the strand note appends: the strands OTHER sessions hold, so this one leaves
200
- // them alone. Null when nobody else holds one.
201
- function othersClause(leases, holder, now = Date.now()) {
201
+ // them alone. Null when nobody else holds one. `names` maps a task id to its short name
202
+ // (task 1004958) — a lease is local and carries none, so the caller hands in what it knows.
203
+ function othersClause(leases, holder, now = Date.now(), names = {}) {
202
204
  const others = (leases || []).filter((l) => l.holder !== holder);
203
205
  if (!others.length) return null;
204
- const list = others.map((l) => `task ${safeId(l.task_id)} by ${describe(l, now)}`).join('; ');
206
+ const list = others.map((l) => `${taskRef(safeId(l.task_id), names[String(l.task_id)])} by ${describe(l, now)}`).join('; ');
205
207
  return `Already being fixed: ${list}. Leave ${others.length === 1 ? 'that one' : 'those'} to that session and carry on with your own work.`;
206
208
  }
207
209
 
@@ -40,6 +40,7 @@
40
40
 
41
41
  const { apiCall, arg, hasFlag } = require('./cli-lib');
42
42
  const tweakHold = require('../../modules/lifecycle/page-tweak-hold.js');
43
+ const { safeTaskName, taskRef } = require('../../modules/lifecycle/task-name.js');
43
44
 
44
45
  // ---- thresholds -------------------------------------------------------------
45
46
  // Reasoned, not magic. Written down because a future reader will want to retune them.
@@ -88,7 +89,17 @@ function humanAge(ms) {
88
89
  // confirmed -> the grade passed and the LAND is outstanding (/merge-mode is the fallback).
89
90
  // A reconciler-recorded strand is called out separately because it is terminal without a
90
91
  // human: the automated healers have already refused it.
91
- function classifyStrand(task, { nowMs, confirmedStaleMs = CONFIRMED_STALE_MS, completedStaleMs = COMPLETED_STALE_MS } = {}) {
92
+ // task 1004958: a strand carries the task's short name when it has one, so the line
93
+ // reads "task N - payout approvals". `safeTaskName` re-checks the stored name against
94
+ // its fixed shape (three lowercase words at most) — see the trust note on `line()`.
95
+ function classifyStrand(task, opts) {
96
+ const s = classifyShape(task, opts);
97
+ const name = s && safeTaskName(task && task.short_name);
98
+ if (name) s.name = name;
99
+ return s;
100
+ }
101
+
102
+ function classifyShape(task, { nowMs, confirmedStaleMs = CONFIRMED_STALE_MS, completedStaleMs = COMPLETED_STALE_MS } = {}) {
92
103
  const t = task || {};
93
104
  const status = String(t.status || '');
94
105
  if (!WATCHED.includes(status)) return null;
@@ -193,7 +204,8 @@ async function check({ nowMs = Date.now(), timeoutMs = DEFAULT_TIMEOUT_MS, confi
193
204
  if (status === 'completed' && held.has(String(t.id))) {
194
205
  const updated = Date.parse(t.updated_at || '');
195
206
  const ageMs = Number.isFinite(updated) ? nowMs - updated : NaN;
196
- waiting.push({ id: safeId(t.id), status, ageMs, age: humanAge(ageMs), shape: tweakHold.HOLD_STATE });
207
+ const name = safeTaskName(t.short_name);
208
+ waiting.push({ id: safeId(t.id), status, ageMs, age: humanAge(ageMs), shape: tweakHold.HOLD_STATE, ...(name ? { name } : {}) });
197
209
  continue;
198
210
  }
199
211
  const s = classifyStrand(t, { nowMs, confirmedStaleMs, completedStaleMs });
@@ -214,6 +226,10 @@ async function check({ nowMs = Date.now(), timeoutMs = DEFAULT_TIMEOUT_MS, confi
214
226
  // is the same reasoning deploy-divergence.js applies to the /version response it reads (it
215
227
  // accepts only a strict semver for exactly this purpose). Ids and a fixed reason vocabulary
216
228
  // carry all the signal that is needed and none of the risk — the agent can GET the task.
229
+ // The one exception is the task's SHORT NAME (task 1004958), because a bare number means
230
+ // nothing to the person the agent talks to. It is not free text: it is generated, and
231
+ // `safeTaskName` passes only three lowercase words of [a-z0-9-] (40 characters, the
232
+ // table CHECK holds the same shape), so it can name a topic and carry nothing else.
217
233
  function line(r, { max = 3 } = {}) {
218
234
  if (!r || r.undetermined) return null;
219
235
  const trunc = Array.isArray(r.truncated) && r.truncated.length
@@ -225,7 +241,7 @@ function line(r, { max = 3 } = {}) {
225
241
  return trunc ? `Strand check could not see the whole board${trunc}. Run: node scripts/bongos/strand-watch.js` : null;
226
242
  }
227
243
  const shown = r.strands.slice(0, max)
228
- .map((s) => `task ${s.id} (${s.status}, ${s.age}${s.reason ? `, ${s.reason}` : ''})`);
244
+ .map((s) => `${taskRef(s.id, s.name)} (${s.status}, ${s.age}${s.reason ? `, ${s.reason}` : ''})`);
229
245
  const more = (r.strands.length > shown.length ? ` +${r.strands.length - shown.length} more` : '') + trunc;
230
246
  const worst = r.strands[0];
231
247
  // Lead with what the WORST shape actually means. "work is finished but not landed" is true
@@ -255,12 +271,20 @@ const RELAY_RULE = 'This note is for you, not the builder: do not repeat it to t
255
271
  // The lease clause, appended at PRINT time, never cached: the line text is shared across
256
272
  // sessions for up to two hours (linePlan), while who holds a strand changes by the minute and
257
273
  // differs per worktree. Fail-open: no lease folder, no clause.
274
+ // The task names the cached line already carries ("task N - name ("), so the lease clause
275
+ // can name the same tasks without a second read. Only the safe name shape is matched.
276
+ function namesIn(text) {
277
+ const names = {};
278
+ for (const m of String(text || '').matchAll(/task (\d{1,19}) - ([a-z0-9]+(?:-[a-z0-9]+)*(?: [a-z0-9]+(?:-[a-z0-9]+)*){0,2}) \(/g)) names[m[1]] = m[2];
279
+ return names;
280
+ }
281
+
258
282
  function withLeases(text, { now = Date.now() } = {}) {
259
283
  if (!text) return text;
260
284
  try {
261
285
  const lease = require('./strand-lease.js');
262
286
  const me = lease.currentHolder();
263
- const clause = lease.othersClause(lease.liveLeases({ dir: lease.leaseDir(), now }), me && me.holder, now);
287
+ const clause = lease.othersClause(lease.liveLeases({ dir: lease.leaseDir(), now }), me && me.holder, now, namesIn(text));
264
288
  return clause ? `${text} ${clause}` : text;
265
289
  } catch { return text; }
266
290
  }
@@ -278,7 +302,7 @@ function report(r) {
278
302
  // task 1004322: named, and kept apart from the strands — nobody should recover these.
279
303
  const waiting = Array.isArray(r.waiting) ? r.waiting : [];
280
304
  for (const w of waiting) {
281
- console.log(` task ${w.id} waiting for the artist (${w.age}) — a page tweak whose grade passed; not a strand, leave it`);
305
+ console.log(` ${taskRef(w.id, w.name)} waiting for the artist (${w.age}) — a page tweak whose grade passed; not a strand, leave it`);
282
306
  }
283
307
  if (r.ok) {
284
308
  console.log(' ✓ none — nothing parked at completed or confirmed past its threshold.');
@@ -286,7 +310,7 @@ function report(r) {
286
310
  return;
287
311
  }
288
312
  for (const s of r.strands) {
289
- console.log(` task ${s.id} ${s.status.padEnd(9)} ${String(s.age).padStart(4)} ${s.shape}${s.reason ? ` (${s.reason})` : ''}`);
313
+ console.log(` ${taskRef(s.id, s.name)} ${s.status.padEnd(9)} ${String(s.age).padStart(4)} ${s.shape}${s.reason ? ` (${s.reason})` : ''}`);
290
314
  console.log(` ${s.remedy}`);
291
315
  }
292
316
  console.log('');
@@ -400,6 +424,13 @@ async function main() {
400
424
  if (shown) console.log(withLeases(shown));
401
425
  }
402
426
  } catch { /* no session, offline, unreadable, past the deadline — all silence */ }
427
+ // task 1004957: when stuck ships have paused new work for EVERY session of this
428
+ // builder, say so here too — the owner chose this note over a new startup step.
429
+ // Its own bounded read; silent unless paused; never fails the session start.
430
+ try {
431
+ const paused = await require('./sessions-board.js').lineNow();
432
+ if (paused) console.log(paused);
433
+ } catch { /* silence */ }
403
434
  process.exitCode = 0;
404
435
  return;
405
436
  }
@@ -426,7 +457,7 @@ if (require.main === module) {
426
457
  }
427
458
 
428
459
  module.exports = {
429
- check, line, withLeases, report, classifyStrand, rankStrands, humanAge,
460
+ check, line, withLeases, namesIn, report, classifyStrand, rankStrands, humanAge,
430
461
  linePlan, labelStale, produceLine, LINE_FRESH_MS, LINE_STALE_MAX_MS, LINE_DEADLINE_MS,
431
462
  CONFIRMED_STALE_MS, COMPLETED_STALE_MS, STRAND_REASON_PREFIX, WATCHED, PAGE_LIMIT,
432
463
  };
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/report-checkout.js — @bongos-forwarder (task 1004848). Generated by
3
+ // scripts/bongos/codemod-rename-scripts-gds.js; do not edit.
4
+ // The real script is scripts/bongos/report-checkout.js. This forwarder keeps callers that still name
5
+ // the old path working, and warns once so they get repointed. Task 1003706 removes it.
6
+ 'use strict';
7
+ require('../bongos/forwarder.js').forward(module, 'report-checkout.js');
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/sessions-board.js — @bongos-forwarder (task 1004848). Generated by
3
+ // scripts/bongos/codemod-rename-scripts-gds.js; do not edit.
4
+ // The real script is scripts/bongos/sessions-board.js. This forwarder keeps callers that still name
5
+ // the old path working, and warns once so they get repointed. Task 1003706 removes it.
6
+ 'use strict';
7
+ require('../bongos/forwarder.js').forward(module, 'sessions-board.js');
@@ -25,20 +25,30 @@ const SLOP_TEXT = 'The moment you find you were wrong because something told you
25
25
  + 'and file it: `node scripts/bongos/capture.js "<file>: says X, truth is Y" --kind bug`. The misleading source is the bug. '
26
26
  + 'Your own plain mistake, with nothing misleading behind it, is not slop.';
27
27
 
28
+ // task 1004958: a bare task number means nothing to the person reading.
29
+ const TASK_NAME_TEXT = 'When you mention a task to a person, write it as "task N - <name>": the number, a dash, then the task\'s '
30
+ + 'two or three word name (its `short_name`; claim, ship, the startup notes and the sessions board print it). '
31
+ + 'A bare "task 1004745" means nothing to the reader.';
32
+
28
33
  const HEADER = 'Standing rules for every session:';
29
34
 
30
35
  function standingRulesBlock() {
31
- return `${HEADER}\n- ${HUMAN_SIGNAL_TEXT}\n- ${SLOP_TEXT}`;
36
+ return `${HEADER}\n- ${HUMAN_SIGNAL_TEXT}\n- ${SLOP_TEXT}\n- ${TASK_NAME_TEXT}`;
32
37
  }
33
38
 
34
39
  // The interaction slice of GET /me with the standing rules appended. `interaction` is
35
40
  // describeInteraction's object or null (builder-settings module off); either way the
36
41
  // result injects, because these rules do not depend on any preference. Idempotent.
37
- function withStandingRules(interaction) {
42
+ // `opts.checkout` (task 1005021) is GET /me's checkout_freshness: when this person's
43
+ // folder is behind and could not update itself, its agent_line rides here too, so even
44
+ // a stale Conductor tells them.
45
+ function withStandingRules(interaction, opts = {}) {
38
46
  const base = interaction && typeof interaction === 'object' ? interaction : {};
39
47
  const contract = typeof base.contract === 'string' ? base.contract : '';
40
48
  if (contract.includes(HEADER)) return { ...base, inject: true };
41
- return { ...base, contract: contract ? `${contract}\n${standingRulesBlock()}` : standingRulesBlock(), inject: true };
49
+ const c = opts && opts.checkout;
50
+ const block = standingRulesBlock() + (c && c.behind && typeof c.agent_line === 'string' ? `\n- ${c.agent_line}` : '');
51
+ return { ...base, contract: contract ? `${contract}\n${block}` : block, inject: true };
42
52
  }
43
53
 
44
54
  module.exports = { withStandingRules };
@@ -395,6 +395,9 @@ module.exports = function buildMeRouter() {
395
395
  const lawed = settings && typeof settings.describeWithLaws === 'function'
396
396
  ? settings.describeWithLaws(behaviorPrefs, (sessionLaws && sessionLaws.laws) || [], { rank: builder && builder.rank, instanceModels })
397
397
  : null;
398
+ // task 1005021: the builder's own report of how far behind their local folder is.
399
+ const checkoutFreshness = settings && typeof settings.describeCheckout === 'function'
400
+ ? settings.describeCheckout(behaviorPrefs) : null;
398
401
  // V3.R84 (#302), criterion C3: the builder's month-to-date spend vs their
399
402
  // monthly budget. Surfaced here so the CLI (session-start ping) and the hall
400
403
  // profile banner read it without a follow-up call; the ship + art-pipeline
@@ -568,7 +571,10 @@ module.exports = function buildMeRouter() {
568
571
  // the SSOT (builder-settings port). null when the prefs module is off.
569
572
  // task 1005015: the standing rules (human-task markers, slop detection) ride
570
573
  // this contract because every Conductor, stale ones included, relays it.
571
- interaction: withStandingRules(lawed ? lawed.interaction : (settings && settings.describeInteraction ? settings.describeInteraction(behaviorPrefs) : null)),
574
+ interaction: withStandingRules(lawed ? lawed.interaction : (settings && settings.describeInteraction ? settings.describeInteraction(behaviorPrefs) : null), { checkout: checkoutFreshness }),
575
+ // task 1005021: how far behind this builder's local folder is, as their session last
576
+ // reported it; null when never reported or too old to mean anything.
577
+ checkout_freshness: checkoutFreshness,
572
578
  // task 1004707: the project's LAWS, read into every AI session. The
573
579
  // Conductor hook relays `contract` verbatim beside the interaction profile
574
580
  // (inject:false, and no block, when no law is in force). `laws` is the book
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.24.14'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.16'; // 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');
@@ -27,7 +27,7 @@ const conductor = requireHostModule(require, '../.claude/hooks/conductor.js');
27
27
  const slopcheck = requireHostModule(require, '../.claude/hooks/stop-slopcheck.js');
28
28
 
29
29
  // The words as the server serves them: a header line, then one '- ' line per rule.
30
- const [HEADER, HUMAN, SLOP] = rules.withStandingRules(null).contract.split('\n').map((l, i) => (i ? l.slice(2) : l));
30
+ const [HEADER, HUMAN, SLOP, TASK_NAMES] = rules.withStandingRules(null).contract.split('\n').map((l, i) => (i ? l.slice(2) : l));
31
31
 
32
32
  let passed = 0;
33
33
  let failed = 0;
@@ -40,6 +40,7 @@ t('the server appends both rules to a builder\'s interaction contract and keeps
40
40
  assert.ok(out.contract.startsWith('Interaction profile — P\n'), 'the profile stays first');
41
41
  assert.ok(out.contract.includes(HUMAN));
42
42
  assert.ok(out.contract.includes(SLOP));
43
+ assert.ok(out.contract.includes(TASK_NAMES));
43
44
  assert.equal(out.inject, true);
44
45
  assert.deepEqual(out.fields, [1], 'the rest of the slice is untouched');
45
46
  });
@@ -70,9 +71,19 @@ t('the rules mark both markers and the slop line exactly as the owner set them',
70
71
  t('the Conductor\'s copy says exactly what the server sends', () => {
71
72
  assert.equal(conductor.HUMAN_SIGNAL_TEXT, HUMAN);
72
73
  assert.equal(conductor.SLOP_TEXT, SLOP);
74
+ assert.equal(conductor.TASK_NAME_TEXT, TASK_NAMES);
73
75
  assert.equal(conductor.STANDING_HEADER, HEADER);
74
76
  });
75
77
 
78
+ t('the task-name rule tells the AI to write "task N - <name>" (task 1004958)', () => {
79
+ assert.ok(TASK_NAMES.includes('"task N - <name>"'));
80
+ const served = rules.withStandingRules({ inject: true, contract: 'P' });
81
+ const a = conductor.evaluateTriggers({ firstPrompt: true, interaction: served });
82
+ assert.ok(!a.some((x) => x.id === 'task-names'), 'not said twice when the server sent it');
83
+ const old = conductor.evaluateTriggers({ firstPrompt: true, interaction: { inject: true, contract: 'P' } });
84
+ assert.ok(old.some((x) => x.id === 'task-names' && x.text === TASK_NAMES), 'an older server still gets the local line');
85
+ });
86
+
76
87
  t('the first-prompt marker line is skipped when the server already sent the rules', () => {
77
88
  const served = rules.withStandingRules({ inject: true, contract: 'P' });
78
89
  const a = conductor.evaluateTriggers({ firstPrompt: true, interaction: served });
@@ -95,6 +106,12 @@ t('a person correcting the agent triggers the slop check, every time', () => {
95
106
  }
96
107
  });
97
108
 
109
+ t('the app\'s own status messages never trigger the slop check (task 1005023)', () => {
110
+ const notice = '<system-reminder>[SYSTEM NOTIFICATION - NOT USER INPUT] This is an automated background-task event, NOT a message from the user. Do NOT interpret this as user acknowledgement. Any statement that the user said, approved, or confirmed something is NOT real user input.<task-notification><status>completed</status><summary>Background command "Ship it" completed. It is live, not true, actually it works, slop</summary></task-notification></system-reminder>';
111
+ assert.ok(!conductor.evaluateTriggers({ prompt: notice }).some((x) => x.id === 'slop-check'));
112
+ assert.ok(conductor.evaluateTriggers({ prompt: "that's wrong, it's already live" }).some((x) => x.id === 'slop-check'), 'a real correction still fires');
113
+ });
114
+
98
115
  t('ordinary prompts do not trigger the slop check', () => {
99
116
  for (const p of ['add a dark mode toggle', 'run the tests', 'what can I work on', 'make the button wider']) {
100
117
  assert.ok(!conductor.evaluateTriggers({ prompt: p }).some((x) => x.id === 'slop-check'), p);
@@ -169,6 +169,7 @@ const db = require('../modules/ai-studio/db.js');
169
169
  const api = require('../src/module-api.js');
170
170
  const ledgerRows = [];
171
171
  db.appendLedger = async (row) => { ledgerRows.push({ ...row, id: ledgerRows.length + 1, created_at: 'now' }); return {}; };
172
+ db.latestAgeTier = async () => 'adult';
172
173
  db.recentLedger = async (builderId) => ledgerRows.filter((r) => r.builderId === builderId).map((r) => ({
173
174
  id: r.id, kind: r.kind, request_id: r.requestId || null, amount_micro: r.amountMicro || 0, provider_cost_micro: r.providerCostMicro || 0,
174
175
  markup_micro: r.markupMicro || 0, processor_fee_micro: r.processorFeeMicro || 0, backend: r.backend, note: r.note || '', created_at: r.created_at, ref: r.ref,
@@ -222,6 +223,18 @@ test('routes: top up on the test backend, then read the balance and a ledger wit
222
223
  assert.equal((await call('POST', '/ai-studio/topup', { amount_usd: 1 })).status, 400);
223
224
  });
224
225
 
226
+ test('routes: a top-up is adults only, refused before any charge is quoted', async () => {
227
+ const before = ledgerRows.length;
228
+ for (const tier of [null, '16_17']) {
229
+ db.latestAgeTier = async () => tier;
230
+ const r = await call('POST', '/ai-studio/topup', { amount_usd: 10 });
231
+ assert.equal(r.status, 403);
232
+ assert.equal(r.body.error.code || r.body.error, 'adults_only');
233
+ }
234
+ db.latestAgeTier = async () => 'adult';
235
+ assert.equal(ledgerRows.length, before, 'a refused top-up writes nothing');
236
+ });
237
+
225
238
  // Task 1004921: the Studio says "your card is charged $20.91" BEFORE anything is
226
239
  // paid, and how much a run sets aside, from these two reads, so neither the fee
227
240
  // nor the ceiling is spelled in the browser.