@bongos/core 1.24.14 → 1.24.15

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 (161) hide show
  1. package/.bongos-core.json +296 -136
  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 +138 -123
  15. package/docs/copy-registry.json +296 -171
  16. package/docs/file-map.md +1 -0
  17. package/docs/module-api-changelog.md +2 -2
  18. package/docs/page-inventory.json +6 -1
  19. package/docs/page-readings.json +1040 -1021
  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/primer.html +1 -1
  67. package/modules/hall-ui/public/profile.html +1 -1
  68. package/modules/hall-ui/public/project-settings.html +1 -1
  69. package/modules/hall-ui/public/ranks.html +1 -1
  70. package/modules/hall-ui/public/roadmap.html +1 -1
  71. package/modules/hall-ui/public/roster.html +1 -1
  72. package/modules/hall-ui/public/sessions.html +1 -1
  73. package/modules/hall-ui/public/settings.html +1 -1
  74. package/modules/hall-ui/public/shell.js +22 -22
  75. package/modules/hall-ui/public/street.html +1 -1
  76. package/modules/hall-ui/public/studio.html +1 -1
  77. package/modules/hall-ui/public/style.css +28 -0
  78. package/modules/hall-ui/public/task.html +1 -1
  79. package/modules/hall-ui/public/thinking.html +1 -1
  80. package/modules/hall-ui/public/tweak-editor.html +1 -1
  81. package/modules/hall-ui/public/watch.html +1 -1
  82. package/modules/hall-ui/public/work.html +1 -1
  83. package/modules/ideas/blockers.js +13 -7
  84. package/modules/lifecycle/db-claim-reads.js +1 -1
  85. package/modules/lifecycle/db-ship.js +2 -2
  86. package/modules/lifecycle/db-tasks.js +4 -4
  87. package/modules/lifecycle/migrations/lifecycle_019_pay_type_gates.sql +50 -0
  88. package/modules/lifecycle/module.json +1 -0
  89. package/modules/lifecycle/pay-type-gates.js +180 -0
  90. package/modules/lifecycle/publish-reconciler.js +31 -1
  91. package/modules/lifecycle/rebase-gate.js +3 -2
  92. package/modules/lifecycle/routes/claims.js +3 -2
  93. package/modules/lifecycle/routes/pay-type-gates.js +59 -0
  94. package/modules/lifecycle/routes/project-settings.js +4 -0
  95. package/modules/lifecycle/routes/task-write-routes.js +43 -0
  96. package/modules/lifecycle/ship-card.js +4 -1
  97. package/modules/lifecycle/task-name-db.js +55 -0
  98. package/modules/lifecycle/task-name.js +140 -0
  99. package/modules/market-watch/db.js +10 -1
  100. package/modules/market-watch/migrations/market_watch_004_entry_corrections.sql +20 -0
  101. package/modules/market-watch/pages.js +20 -1
  102. package/modules/market-watch/proposals.js +57 -7
  103. package/modules/market-watch/routes/pages.js +2 -0
  104. package/modules/market-watch/routes/rules.js +4 -0
  105. package/modules/platform-identity/terms-acceptance.js +9 -9
  106. package/modules/public-landing/public/account-google.states.json +14 -0
  107. package/modules/public-landing/public/assets/cosmos.css +12 -0
  108. package/modules/public-landing/public/assets/hub-bar.js +44 -1
  109. package/modules/public-landing/public/privacy.html +5 -1
  110. package/modules/public-landing/public/projects-google.states.json +14 -0
  111. package/modules/public-landing/public/projects.html +39 -0
  112. package/modules/public-landing/public/terms.html +12 -2
  113. package/modules/ui-design/kit/fixtures/me-google.json +10 -0
  114. package/modules/ui-design/kit/fixtures/me-sign-in-methods-google.json +4 -0
  115. package/modules/ui-design/kit/serve.js +7 -1
  116. package/package-lock.json +2 -2
  117. package/package.json +1 -1
  118. package/release-notes.json +49 -1
  119. package/scripts/bongos/claim.js +4 -2
  120. package/scripts/bongos/land-watch.js +1 -0
  121. package/scripts/bongos/report-checkout.js +30 -0
  122. package/scripts/bongos/run-unit-tests.js +11 -0
  123. package/scripts/bongos/sessions-board.js +264 -0
  124. package/scripts/bongos/ship-finish.js +1 -0
  125. package/scripts/bongos/ship-flow.js +5 -2
  126. package/scripts/bongos/strand-lease.js +5 -3
  127. package/scripts/bongos/strand-watch.js +38 -7
  128. package/scripts/gds/report-checkout.js +7 -0
  129. package/scripts/gds/sessions-board.js +7 -0
  130. package/src/bongos/agent-standing-rules.js +13 -3
  131. package/src/bongos/routes/me.js +7 -1
  132. package/src/module-api.js +1 -1
  133. package/tests/agent_standing_rules.mjs +18 -1
  134. package/tests/ai_studio_billing.mjs +13 -0
  135. package/tests/ai_studio_gemini_models.mjs +81 -0
  136. package/tests/ai_studio_requests.mjs +24 -0
  137. package/tests/blocker_detail_hydration.mjs +5 -3
  138. package/tests/blocker_tasks_named_db.mjs +74 -0
  139. package/tests/claim_gate_ci_unblock.mjs +1 -1
  140. package/tests/github_chip.mjs +217 -0
  141. package/tests/hall_ai_studio.mjs +129 -5
  142. package/tests/host_topology_skips.mjs +1 -0
  143. package/tests/hub_account_page.mjs +3 -2
  144. package/tests/landing_legal_pages.mjs +3 -2
  145. package/tests/market_watch_data_db.mjs +6 -0
  146. package/tests/market_watch_pages.mjs +87 -2
  147. package/tests/market_watch_rules.mjs +101 -5
  148. package/tests/market_watch_rules_db.mjs +42 -0
  149. package/tests/nav_permission_atoms.mjs +1 -1
  150. package/tests/pay_terms.mjs +8 -0
  151. package/tests/pay_type_gates.mjs +234 -0
  152. package/tests/pay_type_gates_db.mjs +84 -0
  153. package/tests/project_account.mjs +125 -0
  154. package/tests/project_account_db.mjs +97 -0
  155. package/tests/self_healing_start.mjs +162 -0
  156. package/tests/sessions_board.mjs +153 -0
  157. package/tests/stop_refcheck.mjs +40 -0
  158. package/tests/task_name.mjs +237 -0
  159. package/tests/task_name_db.mjs +79 -0
  160. package/tests/terms_acceptance.mjs +25 -3
  161. package/tests/terms_grandfather_db.mjs +11 -4
@@ -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.15'; // 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.
@@ -0,0 +1,81 @@
1
+ // tests/ai_studio_gemini_models.mjs — a retired Gemini model never breaks the
2
+ // studio: when Google answers 404 for the model we name, the key's own model
3
+ // list is read, the newest model of the same kind is used, and that pick is
4
+ // remembered. Google's own reason reaches the message when it still fails.
5
+
6
+ import { test } from 'node:test';
7
+ import { strict as assert } from 'node:assert';
8
+ import { createRequire } from 'node:module';
9
+
10
+ const require = createRequire(import.meta.url);
11
+ const G = require('../modules/ai-studio/gemini.js');
12
+
13
+ const KEY = 'AIza-test-key-0000000000000000000000';
14
+ const gen = ['generateContent'];
15
+ const LIST = [
16
+ { name: 'models/gemini-2.5-flash', supportedGenerationMethods: gen },
17
+ { name: 'models/gemini-3.1-pro-preview', supportedGenerationMethods: gen },
18
+ { name: 'models/gemini-3-pro', supportedGenerationMethods: gen },
19
+ { name: 'models/gemini-3.1-flash-image-preview', supportedGenerationMethods: gen },
20
+ { name: 'models/gemini-3.1-flash-image', supportedGenerationMethods: gen },
21
+ { name: 'models/gemini-3.1-flash-lite-image', supportedGenerationMethods: gen },
22
+ { name: 'models/gemini-3.1-pro-tts', supportedGenerationMethods: gen },
23
+ { name: 'models/imagen-4.0-generate-001', supportedGenerationMethods: ['predict'] },
24
+ { name: 'models/text-embedding-004', supportedGenerationMethods: ['embedContent'] },
25
+ ];
26
+
27
+ test('pickModel takes the newest stable model of the right kind', () => {
28
+ assert.equal(G.pickModel('images', LIST), 'gemini-3.1-flash-image');
29
+ assert.equal(G.pickModel('reply', LIST), 'gemini-3-pro');
30
+ assert.equal(G.pickModel('reply', LIST.filter((m) => m.name !== 'models/gemini-3-pro')), 'gemini-2.5-flash', 'a stable model beats a preview, which Google can pull any day');
31
+ assert.equal(G.pickModel('reply', LIST.filter((m) => !/gemini-3-pro|2\.5-flash$/.test(m.name))), 'gemini-3.1-pro-preview');
32
+ assert.equal(G.pickModel('images', []), null);
33
+ });
34
+
35
+ test('a 404 on the named model falls over to the key\'s newest one, and remembers it', async () => {
36
+ const calls = [];
37
+ const PNG = Buffer.from('png').toString('base64');
38
+ const fetchImpl = async (url, init) => {
39
+ calls.push({ url, method: init.method, key: init.headers['x-goog-api-key'] });
40
+ if (init.method === 'GET') return { ok: true, status: 200, json: async () => ({ models: LIST }) };
41
+ if (url.includes('gemini-2.5-flash-image')) return { ok: false, status: 404, json: async () => ({ error: { message: 'models/gemini-2.5-flash-image is not found' } }) };
42
+ return { ok: true, status: 200, json: async () => ({ candidates: [{ content: { parts: [{ inlineData: { mimeType: 'image/png', data: PNG } }] } }] }) };
43
+ };
44
+ const out = await G.images({ fetchImpl, key: KEY, system: 's', brief: 'b', files: [] });
45
+ assert.equal(out.images.length, 1);
46
+ assert.ok(calls[2].url.includes('/gemini-3.1-flash-image:generateContent'));
47
+ assert.ok(calls.every((c) => c.key === KEY && !c.url.includes(KEY)), 'the key rides only the header');
48
+ calls.length = 0;
49
+ await G.images({ fetchImpl, key: KEY, system: 's', brief: 'b', files: [] });
50
+ assert.equal(calls.length, 1, 'the pick is remembered');
51
+ assert.ok(calls[0].url.includes('/gemini-3.1-flash-image:'));
52
+ assert.ok([...G._resolved.keys()].every((k) => !k.includes(KEY)), 'the memory holds a hash, never the key');
53
+ calls.length = 0;
54
+ await G.images({ fetchImpl, key: 'AIza-another-key-00000000000000000', system: 's', brief: 'b', files: [] });
55
+ assert.ok(calls[0].url.includes('/gemini-2.5-flash-image:'), 'another key does not inherit the first key\'s pick');
56
+ G._resolved.clear();
57
+ });
58
+
59
+ test('a model list that never answers is given up on, and the refusal still comes back', async () => {
60
+ const fetchImpl = (url, init) => (init.method === 'GET'
61
+ ? new Promise((_, reject) => init.signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))))
62
+ : Promise.resolve({ ok: false, status: 404, json: async () => ({ error: { message: 'not found' } }) }));
63
+ const real = globalThis.setTimeout;
64
+ globalThis.setTimeout = (fn, ms) => real(fn, ms > 1000 ? 5 : ms); // the 15 s limit, compressed
65
+ try {
66
+ await assert.rejects(G.images({ fetchImpl, key: KEY, system: 's', brief: 'b', files: [] }), /HTTP 404: not found\)/);
67
+ } finally {
68
+ globalThis.setTimeout = real;
69
+ }
70
+ });
71
+
72
+ test('a refusal folds Google\'s own reason into one sentence, with no key and no control characters', async () => {
73
+ const fetchImpl = async (url, init) => (init.method === 'GET'
74
+ ? { ok: false, status: 400, json: async () => ({}) }
75
+ : { ok: false, status: 400, json: async () => ({ error: { message: 'API key not valid. Please pass a valid API key.' } }) });
76
+ await assert.rejects(G.reply({ fetchImpl, key: KEY, system: 's', prompt: 'p', files: [] }),
77
+ { message: 'Google refused the request (HTTP 400: API key not valid. Please pass a valid API key). Check the key in Settings.' });
78
+ assert.equal(G.googleReason({ error: { message: 'bad AIzaSyD-abcdefghijklmnop\u0007\nkey' } }), ': bad [key] key');
79
+ assert.equal(G.googleReason({ error: { message: 'x'.repeat(500) } }).length, 162);
80
+ assert.equal(G.googleReason(null), '');
81
+ });
@@ -27,8 +27,10 @@ const OUT_PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1,
27
27
  const files = new Map();
28
28
  const requests = new Map();
29
29
  let seq = 100;
30
+ let ageTier = 'adult';
30
31
  const meta = (f) => ({ id: f.id, request_id: f.request_id, role: f.role, name: f.name, content_type: f.content_type, size_bytes: f.bytes.length, created_at: f.created_at });
31
32
  Object.assign(db, {
33
+ latestAgeTier: async () => ageTier,
32
34
  appendLedger: async () => ({}),
33
35
  addUpload: async (builderId, f) => {
34
36
  const row = { id: String(++seq), builder_id: builderId, request_id: null, role: 'upload', name: f.name, content_type: f.content_type, bytes: f.bytes, created_at: 'now' };
@@ -177,6 +179,8 @@ test('THE WHOLE PATH: upload a sketch, send it, get a reply, an image and the ex
177
179
  assert.ok(providerCalls.every((c) => c.auth === `Bearer ${OUR_KEY}`));
178
180
  assert.match(providerCalls[0].body.messages[0].content, /art director's assistant/);
179
181
  assert.equal(providerCalls[0].body.messages[1].content[1].type, 'image_url');
182
+ // Every call carried the provider policy: no provider that keeps or trains on prompts.
183
+ assert.ok(providerCalls.every((c) => c.body.provider && c.body.provider.data_collection === 'deny' && c.body.provider.zdr === true));
180
184
 
181
185
  // Our key is in nothing a builder can see, and in no log line.
182
186
  for (const res of [up, sent, done]) assert.ok(!res.raw.includes(OUR_KEY));
@@ -317,3 +321,23 @@ test('an uploaded PDF downloads rather than opening in the page', async () => {
317
321
  const img = await call('POST', '/ai-studio/uploads', { name: 'i.png', content_type: 'image/png', data: PNG.toString('base64') });
318
322
  assert.match((await call('GET', `/ai-studio/files/${img.body.file.id}`)).headers['content-disposition'], /^inline;/);
319
323
  });
324
+
325
+ test('AI Studio is adults only: no recorded age, or under 18, is refused before anything runs', async () => {
326
+ for (const tier of [null, '16_17']) {
327
+ ageTier = tier;
328
+ const r = await call('POST', '/ai-studio/requests', { prompt: 'A tower', file_ids: [] });
329
+ assert.equal(r.status, 403);
330
+ assert.equal(r.body.error.code || r.body.error, 'adults_only');
331
+ }
332
+ ageTier = 'adult';
333
+ const s = await call('GET', '/ai-studio/status');
334
+ assert.equal(s.body.adult, true);
335
+ assert.match(s.body.terms, /18 and over/);
336
+ });
337
+
338
+ test('every OpenRouter call refuses providers that keep or train on prompts', () => {
339
+ const { providerPolicy } = require('../modules/ai-studio/openrouter.js');
340
+ assert.deepEqual(providerPolicy({}), { data_collection: 'deny', zdr: true });
341
+ assert.deepEqual(providerPolicy({ OPENROUTER_ZDR: 'off', OPENROUTER_ALLOWED_PROVIDERS: 'google-vertex, anthropic', OPENROUTER_DISTILLABLE_ONLY: 'on' }),
342
+ { data_collection: 'deny', only: ['google-vertex', 'anthropic'], enforce_distillable_text: true });
343
+ });
@@ -31,7 +31,7 @@ let queries = [];
31
31
 
32
32
  api.pool.query = async (sql, params = []) => {
33
33
  queries.push({ sql: String(sql), params });
34
- if (/FROM blockers b\s+WHERE b\.status = 'open'/.test(sql)) {
34
+ if (/FROM blockers b\s+LEFT JOIN LATERAL[\s\S]*?\) bl ON true\s+WHERE b\.status = 'open'/.test(sql)) {
35
35
  return {
36
36
  rows: [
37
37
  { id: '1000085', title: 'the open one', body_md: '', status: 'open',
@@ -40,7 +40,7 @@ api.pool.query = async (sql, params = []) => {
40
40
  ],
41
41
  };
42
42
  }
43
- if (/FROM blockers b WHERE b\.id = \$1/.test(sql)) {
43
+ if (/FROM blockers b LEFT JOIN LATERAL[\s\S]*?\) bl ON true WHERE b\.id = \$1/.test(sql)) {
44
44
  const id = String(params[0]);
45
45
  if (id === '1000085') {
46
46
  return {
@@ -87,7 +87,9 @@ async function test(name, fn) {
87
87
  }
88
88
 
89
89
  function blockingTaskIdsFragment(sql) {
90
- const match = sql.match(/COALESCE\(\s*\(SELECT array_agg\(tb\.task_id[\s\S]*?blocking_task_ids/);
90
+ // task 1004958: the ids now come from one LATERAL read (with the named tasks beside
91
+ // them); the identity that matters is still the projection AND the join it reads.
92
+ const match = sql.match(/COALESCE\(bl\.ids[\s\S]*?\) bl ON true/);
91
93
  assert.ok(match, `query does not carry a blocking_task_ids projection:\n${sql}`);
92
94
  return match[0].replace(/\s+/g, ' ').trim();
93
95
  }
@@ -0,0 +1,74 @@
1
+ // tests/blocker_tasks_named_db.mjs — task 1004958. Real DB.
2
+ // A blocker names the tasks it holds ("task N - <name>"), through ONE lateral read in
3
+ // modules/ideas/blockers.js that returns both blocking_task_ids (the hall's field) and
4
+ // blocking_tasks [{ id, short_name }]. tests/blocker_detail_hydration.mjs pins the SQL
5
+ // text against a fake pool; this runs the real listOpenBlockers / getBlocker against a
6
+ // fully migrated Postgres: ordering, a task with no name yet, a blocker holding nothing,
7
+ // and that the two reads agree.
8
+ // Self-skips without a reachable Postgres.
9
+ // Run: DATABASE_URL=... node tests/blocker_tasks_named_db.mjs
10
+ import { createRequire } from 'node:module';
11
+ import { strict as assert } from 'node:assert';
12
+ const require = createRequire(import.meta.url);
13
+ const { Pool } = require('pg');
14
+
15
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined, max: 1 });
16
+ try {
17
+ await pool.query('SELECT 1');
18
+ } catch (e) {
19
+ console.log(`blocker_tasks_named_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
20
+ await pool.end().catch(() => {});
21
+ process.exit(0);
22
+ }
23
+
24
+ const blockers = require('../modules/ideas/blockers.js');
25
+ const VER = 'ZZZ-NAMES';
26
+ const T1 = 990049581;
27
+ const T2 = 990049582;
28
+ const REF = 'task-1004958-names';
29
+ const q = (sql, params) => pool.query(sql, params).then((r) => r.rows);
30
+
31
+ let pass = 0;
32
+ const ok = (c, m) => { assert.ok(c, m); pass++; console.log(` ok - ${m}`); };
33
+
34
+ async function cleanup() {
35
+ await q(`DELETE FROM task_blockers WHERE task_id = ANY($1::bigint[]) OR blocker_id IN (SELECT id FROM blockers WHERE source = 'test' AND source_ref LIKE $2)`, [[T1, T2], `${REF}%`]).catch(() => {});
36
+ await q(`DELETE FROM blockers WHERE source = 'test' AND source_ref LIKE $1`, [`${REF}%`]).catch(() => {});
37
+ await q('DELETE FROM tasks WHERE id = ANY($1::bigint[])', [[T1, T2]]).catch(() => {});
38
+ await q('DELETE FROM versions WHERE id = $1', [VER]).catch(() => {});
39
+ }
40
+
41
+ try {
42
+ await cleanup();
43
+ await q(`INSERT INTO versions (id,name,status,track) VALUES ($1,'names','shipped','internal') ON CONFLICT (id) DO NOTHING`, [VER]);
44
+ // T2 first and with no name: the read must order by id and keep a null name.
45
+ await q(`INSERT INTO tasks (id,version_id,title,status,credits_reward,kind,discipline,short_name)
46
+ VALUES ($1,$3,'Payout approvals','backlog',10,'feature','engineer','payout approvals'),
47
+ ($2,$3,'#12','backlog',10,'feature','engineer',NULL)`, [T1, T2, VER]);
48
+ const holding = await blockers.createBlocker({ title: 'names fixture: holds two', bodyMd: '', source: 'test', sourceRef: `${REF}-a` });
49
+ const empty = await blockers.createBlocker({ title: 'names fixture: holds none', bodyMd: '', source: 'test', sourceRef: `${REF}-b` });
50
+ await blockers.linkBlockerToTask({ blockerId: holding.id, taskId: T2 }); // linked out of id order on purpose
51
+ await blockers.linkBlockerToTask({ blockerId: holding.id, taskId: T1 });
52
+
53
+ const one = await blockers.getBlocker(holding.id);
54
+ ok(JSON.stringify(one.blocking_task_ids.map(Number)) === JSON.stringify([T1, T2]), 'blocking_task_ids is unchanged in shape and ordered by id');
55
+ ok(Array.isArray(one.blocking_tasks) && one.blocking_tasks.length === 2, 'blocking_tasks carries one entry per held task');
56
+ ok(Number(one.blocking_tasks[0].id) === T1 && one.blocking_tasks[0].short_name === 'payout approvals', 'each entry names its task');
57
+ ok(Number(one.blocking_tasks[1].id) === T2 && one.blocking_tasks[1].short_name === null, 'a task with no name yet keeps null, never a guess');
58
+
59
+ const none = await blockers.getBlocker(empty.id);
60
+ ok(Array.isArray(none.blocking_task_ids) && none.blocking_task_ids.length === 0, 'a blocker holding nothing reads an empty id list');
61
+ ok(Array.isArray(none.blocking_tasks) && none.blocking_tasks.length === 0, 'and an empty task list, not null');
62
+
63
+ const list = await blockers.listOpenBlockers();
64
+ const fromList = list.find((b) => String(b.id) === String(holding.id));
65
+ ok(fromList && JSON.stringify(fromList.blocking_tasks) === JSON.stringify(one.blocking_tasks), 'the list and the detail read agree');
66
+ const paged = await blockers.listOpenBlockers({ limit: 1000, offset: 0 });
67
+ ok(paged.some((b) => String(b.id) === String(empty.id)), 'the paged list still returns a blocker that holds nothing (the lateral join drops no row)');
68
+ } finally {
69
+ await cleanup();
70
+ await pool.end().catch(() => {});
71
+ // blockers.js reads through the shared doorway pool; close it so the process exits.
72
+ await require('../src/module-api').pool.end().catch(() => {});
73
+ }
74
+ console.log(`\nblocker_tasks_named_db: ${pass} passed`);
@@ -66,7 +66,7 @@ function fakeClientFor(task) {
66
66
  seen.push(s);
67
67
  if (s === 'BEGIN' || s === 'ROLLBACK' || s === 'COMMIT') return { rows: [] };
68
68
  if (s.startsWith('SELECT status, rank FROM builders')) return { rows: [{ status: 'active', rank: 'archon' }] };
69
- if (s.startsWith('SELECT id, title, needs_rebase_reason, needs_rebase_at FROM tasks WHERE needs_rebase = true')) {
69
+ if (s.startsWith('SELECT id, title, short_name, needs_rebase_reason, needs_rebase_at FROM tasks WHERE needs_rebase = true')) {
70
70
  return { rows: [{ id: '900', title: 'stalled', needs_rebase_reason: 'strand:pr_not_landed', needs_rebase_at: new Date() }] };
71
71
  }
72
72
  if (s === 'SELECT kind, priority FROM tasks WHERE id = $1') {