@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,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') {
@@ -0,0 +1,217 @@
1
+ // tests/github_chip.mjs — the Link GitHub chip in the top bar (task 1004996, goal
2
+ // 1000095 WA6; owner ask 2026-10-29).
3
+ //
4
+ // Someone with no GitHub linked sees one very small chip with the engineer's icon
5
+ // in the top bar; a press starts the GitHub link (ADR 0396). Anyone with GitHub
6
+ // linked sees nothing, and nothing ever pops up. Three copies draw it — the hall's
7
+ // github-chip.js, the hub's assets/hub-bar.js and projects.html's own bar — so
8
+ // this file RUNS each one under a small DOM stub and holds their words, link and
9
+ // glyph equal, then checks the shell loads the hall's copy and both sheets keep
10
+ // the 24px floor.
11
+ //
12
+ // Run: node --test tests/github_chip.mjs
13
+
14
+ import test from 'node:test';
15
+ import assert from 'node:assert/strict';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import vm from 'node:vm';
19
+ import { createRequire } from 'node:module';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
+ const require = createRequire(import.meta.url);
24
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
25
+ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
26
+ const read = (p) => fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n');
27
+ const CHIP_SRC = read(path.join(HALL, 'github-chip.js'));
28
+ const BAR = read(path.join(LANDING, 'assets', 'hub-bar.js'));
29
+ const HUB = read(path.join(LANDING, 'projects.html'));
30
+ const SHELL = read(path.join(HALL, 'shell.js'));
31
+ const chip = require(path.join(HALL, 'github-chip.js'));
32
+ // the engineer's glyph IS the Building craft's (craft-mode.js ICONS.building)
33
+ const BUILDING = /building: '<path d="([^"]+)"\/>'/.exec(read(path.join(HALL, 'craft-mode.js')))[1];
34
+
35
+ // ── a DOM just big enough for the three copies ──────────────────────────────
36
+ class El {
37
+ constructor(tag, id) { this.tagName = tag.toUpperCase(); this.id = id || ''; this.children = []; this.parentNode = null; this.attrs = {}; this.textContent = ''; this.hidden = false; this.className = ''; }
38
+ get firstChild() { return this.children[0] || null; }
39
+ setAttribute(k, v) { this.attrs[k] = String(v); }
40
+ getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
41
+ appendChild(c) { c.parentNode = this; this.children.push(c); return c; }
42
+ insertBefore(c, ref) { c.parentNode = this; const i = ref ? this.children.indexOf(ref) : -1; if (i < 0) this.children.push(c); else this.children.splice(i, 0, c); return c; }
43
+ remove() { if (this.parentNode) this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1); this.parentNode = null; }
44
+ addEventListener() {}
45
+ querySelector() { return null; }
46
+ querySelectorAll() { return []; }
47
+ contains() { return false; }
48
+ focus() {}
49
+ closest() { return null; }
50
+ }
51
+ function makeDoc(barSelector) {
52
+ const bar = new El('div');
53
+ const byId = new Map();
54
+ const find = (node, id) => { if (node.id === id) return node; for (const c of node.children) { const f = find(c, id); if (f) return f; } return null; };
55
+ const doc = {
56
+ bar,
57
+ cookie: '',
58
+ activeElement: null,
59
+ createElement: (t) => new El(t),
60
+ createElementNS: (ns, t) => new El(t),
61
+ getElementById(id) { return find(bar, id) || (id === 'ghChip' || id === 'gh-chip' ? null : (byId.get(id) || byId.set(id, new El('div', id)).get(id))); },
62
+ querySelector: (sel) => (sel === barSelector ? bar : null),
63
+ querySelectorAll: () => [],
64
+ addEventListener() {},
65
+ };
66
+ return doc;
67
+ }
68
+ const settle = async () => { for (let i = 0; i < 8; i++) await new Promise((r) => setImmediate(r)); };
69
+ const chipOf = (doc, id) => doc.bar.children.find((c) => c.id === id) || null;
70
+ const svgPath = (a) => a.children.find((c) => c.tagName === 'SVG').children[0].attrs.d;
71
+ const labelOf = (a) => a.children.find((c) => c.tagName === 'SPAN').textContent;
72
+ const GOOGLE_ONLY = { methods: [{ provider: 'google', email: 'o@example.com' }], providers: ['github', 'google'] };
73
+ const BOTH = { methods: [{ provider: 'github' }, { provider: 'google' }], providers: ['github', 'google'] };
74
+
75
+ // ── the hall's copy ──────────────────────────────────────────────────────────
76
+ test('the hall: who needs GitHub — only a stand-in login, and the ways in decide where they can be read', () => {
77
+ assert.equal(chip.needsGithub([{ provider: 'google' }], { github_login: '~octo' }), true);
78
+ assert.equal(chip.needsGithub([{ provider: 'github' }, { provider: 'google' }], { github_login: '~octo' }), false, 'a linked GitHub wins over the stand-in login');
79
+ assert.equal(chip.needsGithub(null, { github_login: '~octo' }), true, 'a project hall with no ways-in route: the stand-in says Google-only');
80
+ assert.equal(chip.needsGithub(null, { github_login: 'octo' }), false);
81
+ assert.equal(chip.needsGithub([{ provider: 'google' }], { github_login: 'octo' }), false, 'a real login means GitHub was linked (linking writes it over the stand-in)');
82
+ assert.equal(chip.needsGithub(null, null), false, 'nothing known, nothing drawn');
83
+ assert.equal(chip.chipHref(null), null, 'no hub, no linking, no chip');
84
+ assert.equal(chip.chipHref('https://example.com'), 'https://example.com/api/bongos/auth/link/github/start');
85
+ for (const bad of ['http://example.com', 'https://example.com/x', 'https://evil.example?x=', 'javascript:alert(1)//', '']) assert.equal(chip.chipHref(bad), null, `refused: ${bad}`);
86
+ });
87
+
88
+ async function mountHall(answer, builder, hub = 'https://example.com') {
89
+ const doc = makeDoc('#app-topbar .topbar-side');
90
+ const calls = [];
91
+ const api = { request: (m, p) => { calls.push(`${m} ${p}`); return typeof answer === 'function' ? answer() : Promise.resolve(answer); } };
92
+ const ctx = { document: doc, window: {}, Array, Promise };
93
+ ctx.window = ctx;
94
+ vm.runInNewContext(CHIP_SRC, ctx);
95
+ const shown = await ctx.OTBGithubChip.mount({ api, builder, hub });
96
+ return { doc, calls, shown, a: chipOf(doc, 'gh-chip') };
97
+ }
98
+
99
+ test('the hall, executed: Google only draws one small link with the engineer glyph, to the hub’s link start', async () => {
100
+ const { a, calls, shown } = await mountHall({ ok: true, data: GOOGLE_ONLY }, { github_login: '~octo' });
101
+ assert.equal(shown, true);
102
+ assert.deepEqual(calls, ['GET /api/bongos/me/sign-in-methods'], 'one read');
103
+ assert.equal(a.tagName, 'A', 'a plain link: no pop-up, no dialog');
104
+ assert.equal(a.href, 'https://example.com/api/bongos/auth/link/github/start');
105
+ assert.equal(a.className, 'gh-chip');
106
+ assert.equal(labelOf(a), 'Link GitHub');
107
+ assert.equal(svgPath(a), BUILDING, 'the engineer craft’s own glyph');
108
+ assert.match(a.attrs['aria-label'], /coding/);
109
+ });
110
+
111
+ test('the hall, executed: GitHub linked, a failed read for a real login, or no hub draws nothing', async () => {
112
+ assert.equal((await mountHall({ ok: true, data: BOTH }, { github_login: '~octo' })).a, null);
113
+ assert.equal((await mountHall({ ok: false, status: 404 }, { github_login: 'octo' })).a, null);
114
+ assert.equal((await mountHall(() => Promise.reject(new Error('offline')), { github_login: 'octo' })).a, null);
115
+ const noHub = await mountHall({ ok: true, data: GOOGLE_ONLY }, {}, null);
116
+ assert.equal(noHub.a, null);
117
+ assert.deepEqual(noHub.calls, [], 'no hub: not even the read');
118
+ const fallback = await mountHall({ ok: false, status: 404 }, { github_login: '~octo' });
119
+ assert.ok(fallback.a, 'a project hall: the stand-in login draws it');
120
+ const real = await mountHall({ ok: true, data: GOOGLE_ONLY }, { github_login: 'octo' });
121
+ assert.equal(real.a, null);
122
+ assert.deepEqual(real.calls, [], 'a real login costs no read at all');
123
+ });
124
+
125
+ test('the shell loads the hall’s copy once, for a signed-in builder, only where there is a hub', () => {
126
+ assert.match(SHELL, /if \(state\.builder && access\.authed === true\) \{ loadPositions\(state\.builder, state\.positions\); loadGithubChip\(state\.builder\); \}/);
127
+ const fn = /function loadGithubChip\(builder\) \{[\s\S]*?\n {2}\}/.exec(SHELL)[0];
128
+ assert.match(fn, /hubOrigin\(\)/);
129
+ assert.match(fn, /if \(githubChipAsked \|\| !hub\) return;/);
130
+ assert.match(fn, /loadHallScript\('github-chip\.js'/);
131
+ assert.ok(SHELL.split('\n').length <= 1500, 'shell.js stays inside the 1500-line ratchet');
132
+ });
133
+
134
+ // ── the hub's two copies ─────────────────────────────────────────────────────
135
+ function bootBar(answers) {
136
+ const doc = makeDoc('.barRight');
137
+ const calls = [];
138
+ const fetch = (url) => {
139
+ const p = url.replace(/^\/api\/bongos/, '');
140
+ calls.push(p);
141
+ const { status, data } = answers(p);
142
+ return Promise.resolve({ status, ok: status >= 200 && status < 300, json: () => Promise.resolve(data) });
143
+ };
144
+ const window = {};
145
+ vm.runInNewContext(BAR, { document: doc, fetch, window, location: { hostname: 'cloudbongos.com', pathname: '/account' }, Array, Promise });
146
+ return { doc, calls, window };
147
+ }
148
+ const ME = { builder: { id: '1', github_login: '~octo' } };
149
+
150
+ test('the hub bar, executed: signed in with Google only, the chip heads the bar’s right side', async () => {
151
+ const { doc, calls } = bootBar((p) => (p === '/me' ? { status: 200, data: ME } : { status: 200, data: GOOGLE_ONLY }));
152
+ await settle();
153
+ assert.deepEqual(calls, ['/me', '/me/sign-in-methods']);
154
+ const a = chipOf(doc, 'ghChip');
155
+ assert.ok(a);
156
+ assert.equal(doc.bar.children[0], a, 'first in the row');
157
+ assert.equal(a.href, '/api/bongos/auth/link/github/start');
158
+ assert.equal(labelOf(a), chip.LABEL);
159
+ assert.equal(svgPath(a), BUILDING);
160
+ });
161
+
162
+ test('the hub bar, executed: linked, signed out, a child (404) or a failed read draws nothing', async () => {
163
+ for (const answers of [
164
+ (p) => (p === '/me' ? { status: 200, data: ME } : { status: 200, data: BOTH }),
165
+ (p) => (p === '/me' ? { status: 200, data: ME } : { status: 404, data: null }),
166
+ (p) => (p === '/me' ? { status: 200, data: ME } : { status: 500, data: null }),
167
+ ]) {
168
+ const { doc } = bootBar(answers);
169
+ await settle();
170
+ assert.equal(chipOf(doc, 'ghChip'), null);
171
+ }
172
+ const out = bootBar(() => ({ status: 401, data: null }));
173
+ await settle();
174
+ assert.deepEqual(out.calls, ['/me'], 'signed out: no ways-in read at all');
175
+ assert.equal(chipOf(out.doc, 'ghChip'), null);
176
+ const real = bootBar((p) => (p === '/me' ? { status: 200, data: { builder: { id: '2', github_login: 'octocat' } } } : { status: 200, data: GOOGLE_ONLY }));
177
+ await settle();
178
+ assert.deepEqual(real.calls, ['/me'], 'a real login (GitHub linked): no ways-in read on any page');
179
+ assert.equal(chipOf(real.doc, 'ghChip'), null);
180
+ });
181
+
182
+ test('projects.html keeps its own copy, executed: the same chip from the same read', async () => {
183
+ const fn = /\n {2}var GH_TITLE = [^\n]+\n {2}function githubChip\(\) \{[\s\S]*?\n {2}\}\n/.exec(HUB);
184
+ assert.ok(fn, 'projects.html carries githubChip()');
185
+ assert.match(HUB, /paintShellIdentity\(\);\n\s+githubChip\(\);/, 'drawn after the identity read');
186
+ for (const [answer, who, drawn] of [[GOOGLE_ONLY, ME.builder, true], [BOTH, ME.builder, false], [GOOGLE_ONLY, { github_login: 'octocat' }, false]]) {
187
+ const doc = makeDoc('.barRight');
188
+ let reads = 0;
189
+ const fetch = () => { reads++; return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(answer) }); };
190
+ const ctx = { document: doc, fetch, API: '/api/bongos', me: who, $: (id) => doc.getElementById(id), Array, Promise };
191
+ vm.runInNewContext(fn[0] + '\ngithubChip();', ctx);
192
+ await settle();
193
+ const a = chipOf(doc, 'ghChip');
194
+ assert.equal(!!a, drawn);
195
+ assert.equal(reads, who.github_login.startsWith('~') ? 1 : 0, 'only a stand-in login is asked about');
196
+ if (a) { assert.equal(a.href, '/api/bongos/auth/link/github/start'); assert.equal(labelOf(a), chip.LABEL); assert.equal(svgPath(a), BUILDING); }
197
+ }
198
+ });
199
+
200
+ test('the three copies say the same words and build no markup from strings', () => {
201
+ const title = /var TITLE = '([^']+)'/.exec(CHIP_SRC)[1];
202
+ assert.match(BAR, new RegExp(`var GH_TITLE = '${title}'`));
203
+ assert.match(HUB, new RegExp(`var GH_TITLE = '${title}'`));
204
+ for (const src of [CHIP_SRC, BAR]) assert.doesNotMatch(src, /innerHTML|insertAdjacentHTML|outerHTML/);
205
+ assert.doesNotMatch(/function githubChip\(\) \{[\s\S]*?\n {2}\}\n/.exec(HUB)[0], /innerHTML/);
206
+ });
207
+
208
+ // ── the sheets: very small, but never under the 24px floor ───────────────────
209
+ test('both sheets keep the chip quiet and at least 24px, its words folding away on a phone', () => {
210
+ const cosmos = read(path.join(LANDING, 'assets', 'cosmos.css'));
211
+ const hall = read(path.join(HALL, 'style.css'));
212
+ assert.match(cosmos, /\.ghChip\{[^}]*min-height:24px;min-width:24px;/);
213
+ assert.match(cosmos, /@media \(max-width:900px\)\{[\s\S]*?\.ghChip__label\{display:none;\}/);
214
+ assert.match(hall, /\.gh-chip \{[^}]*min-height: 24px;[^}]*min-width: 24px;/);
215
+ assert.match(hall, /@media \(max-width: 640px\) \{\n {2}\.gh-chip \{[^}]*\}\n {2}\.gh-chip__label \{ display: none; \}/);
216
+ for (const css of [cosmos, hall]) assert.doesNotMatch(/\.gh-?[Cc]hip[^{]*\{[^}]*\}/.exec(css)[0], /#[0-9a-f]{3,8}\b|rgba?\(/i, 'tokens only');
217
+ });
@@ -131,6 +131,8 @@ function makeEl(id) {
131
131
  removeAttribute(k) { delete el.attrs[k]; },
132
132
  addEventListener(type, fn) { (el.handlers[type] = el.handlers[type] || []).push(fn); },
133
133
  focus() {},
134
+ clicks: 0,
135
+ click() { el.clicks += 1; },
134
136
  async fire(type, extra = {}) {
135
137
  const ev = { preventDefault() {}, currentTarget: el, target: el, ...extra };
136
138
  for (const fn of el.handlers[type] || []) await fn(ev);
@@ -157,8 +159,27 @@ function makeEl(id) {
157
159
 
158
160
  const settle = async () => { for (let i = 0; i < 30; i++) await new Promise((r) => setImmediate(r)); };
159
161
 
160
- function bootPage(search, { routes = {} } = {}) {
161
- const els = { 'ais-page': makeEl('ais-page'), 'hall-main': makeEl('hall-main'), 'ais-file': makeEl('ais-file') };
162
+ // A browser File, as much of it as the page reads.
163
+ class FakeFile {
164
+ constructor(parts, name, opts = {}) { this.name = name; this.type = opts.type || ''; this.size = parts.reduce((n, p) => n + (p.size || 3), 0); }
165
+ }
166
+
167
+ // The device the page runs on: a phone (coarse pointer) or a computer, and
168
+ // whether its webcam answers. Each track records whether it was stopped.
169
+ function makeDevice({ touch = false, webcam = 'yes' } = {}) {
170
+ const tracks = [{ stopped: false, stop() { this.stopped = true; } }];
171
+ return {
172
+ tracks,
173
+ matchMedia: (q) => ({ matches: touch && q.includes('coarse') }),
174
+ navigator: { mediaDevices: webcam === 'none' ? undefined : {
175
+ getUserMedia: async () => { if (webcam === 'refused') throw new Error('NotAllowedError'); return { getTracks: () => tracks }; },
176
+ } },
177
+ };
178
+ }
179
+
180
+ function bootPage(search, { routes = {}, device = makeDevice() } = {}) {
181
+ const els = { 'ais-page': makeEl('ais-page'), 'hall-main': makeEl('hall-main'), 'ais-file': makeEl('ais-file'), 'ais-camera-file': makeEl('ais-camera-file') };
182
+ const winHandlers = {};
162
183
  const calls = [];
163
184
  const store = new Map();
164
185
  const loc = { search, href: `/builders/ai-studio${search}` };
@@ -201,7 +222,15 @@ function bootPage(search, { routes = {} } = {}) {
201
222
  clearInterval: () => {},
202
223
  Date,
203
224
  Promise,
204
- document: { getElementById: (id) => els[id] || null, body: makeEl('body') },
225
+ document: {
226
+ getElementById: (id) => els[id] || null,
227
+ body: makeEl('body'),
228
+ // The snapshot canvas: draws nothing, hands back a 3-byte JPEG.
229
+ createElement: () => ({ getContext: () => ({ drawImage() {} }), toBlob: (cb, type) => cb({ size: 3, type }) }),
230
+ },
231
+ File: FakeFile,
232
+ matchMedia: device.matchMedia,
233
+ navigator: device.navigator,
205
234
  location: loc,
206
235
  history: {
207
236
  pushState(_s, _t, url) { loc.href = url; loc.search = url.includes('?') ? url.slice(url.indexOf('?')) : ''; },
@@ -210,7 +239,7 @@ function bootPage(search, { routes = {} } = {}) {
210
239
  sessionStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) },
211
240
  fetch: async (url) => { calls.push({ method: 'FETCH', url }); return { ok: true, blob: async () => ({ size: 3, type: 'image/png' }) }; },
212
241
  FileReader: class { readAsDataURL() { this.result = 'data:image/png;base64,QUJD'; setImmediate(() => this.onload()); } },
213
- addEventListener() {},
242
+ addEventListener(type, fn) { (winHandlers[type] = winHandlers[type] || []).push(fn); },
214
243
  };
215
244
  ctx.window = ctx;
216
245
  ctx.window.BongosClient = { createClient: () => client };
@@ -220,7 +249,18 @@ function bootPage(search, { routes = {} } = {}) {
220
249
  const src = f === 'studio-lib.js' ? 'window.OTBStudioLib = { skyAt: () => "night" };' : fs.readFileSync(path.join(PUB, f), 'utf8');
221
250
  vm.runInContext(src, ctx, { filename: f });
222
251
  }
223
- return { els, page: els['ais-page'], main: els['hall-main'], calls, loc, store };
252
+ const fireWindow = async (type, ev) => { for (const fn of winHandlers[type] || []) await fn(ev); };
253
+ return { els, page: els['ais-page'], main: els['hall-main'], calls, loc, store, device, fireWindow };
254
+ }
255
+
256
+ // A paste event carrying these clipboard files; records whether the page took it.
257
+ function pasteEvent(files) {
258
+ const ev = {
259
+ taken: false,
260
+ preventDefault() { ev.taken = true; },
261
+ clipboardData: { items: files.map((f) => ({ kind: 'file', getAsFile: () => f })) },
262
+ };
263
+ return ev;
224
264
  }
225
265
 
226
266
  test('page: step 1 opens on an earlier request\'s files, carried by address, with Next', async () => {
@@ -234,6 +274,90 @@ test('page: step 1 opens on an earlier request\'s files, carried by address, wit
234
274
  assert.equal(p.main.getAttribute('data-view'), 'pick');
235
275
  });
236
276
 
277
+ const uploadsOf = (p) => p.calls.filter((c) => c.method === 'POST' && c.url.endsWith('/ai-studio/uploads'));
278
+
279
+ test('page: a pasted picture joins the files on step 1, named so it can be told apart', async () => {
280
+ const p = bootPage('?step=pick');
281
+ await settle();
282
+ assert.match(p.page.innerHTML, /paste a picture/);
283
+ const ev = pasteEvent([new FakeFile([{ size: 3 }], 'image.png', { type: 'image/png' })]);
284
+ await p.fireWindow('paste', ev);
285
+ await settle();
286
+ assert.equal(ev.taken, true);
287
+ const sent = uploadsOf(p);
288
+ assert.equal(sent.length, 1);
289
+ assert.match(sent[0].body.name, /^pasted-\d+-1\.png$/, 'the browser\'s generic image.png is renamed');
290
+ assert.equal(sent[0].body.content_type, 'image/png');
291
+ assert.match(p.page.innerHTML, /1 file ready/);
292
+ // A pasted file that already has a real name keeps it.
293
+ await p.fireWindow('paste', pasteEvent([new FakeFile([{ size: 3 }], 'tower.jpg', { type: 'image/jpeg' })]));
294
+ await settle();
295
+ assert.equal(uploadsOf(p)[1].body.name, 'tower.jpg');
296
+ });
297
+
298
+ test('page: pasting anywhere but step 1, or pasting words, is left to the browser', async () => {
299
+ const p = bootPage('?step=describe&from=1001');
300
+ await settle();
301
+ const pic = pasteEvent([new FakeFile([{ size: 3 }], 'image.png', { type: 'image/png' })]);
302
+ await p.fireWindow('paste', pic);
303
+ assert.equal(pic.taken, false, 'words pasted into the request box still paste');
304
+ const q = bootPage('?step=pick');
305
+ await settle();
306
+ const words = { taken: false, preventDefault() { words.taken = true; }, clipboardData: { items: [{ kind: 'string', getAsFile: () => null }] } };
307
+ await q.fireWindow('paste', words);
308
+ assert.equal(words.taken, false);
309
+ assert.equal(uploadsOf(p).length + uploadsOf(q).length, 0);
310
+ });
311
+
312
+ test('page: on a phone, "take a photo" opens the phone\'s own camera', async () => {
313
+ const p = bootPage('?step=pick', { device: makeDevice({ touch: true }) });
314
+ await settle();
315
+ await p.page.querySelector('#ais-camera').fire('click');
316
+ assert.equal(p.els['ais-camera-file'].clicks, 1);
317
+ assert.equal(p.page.querySelector('#ais-cam').hidden, false, 'no webcam panel on a phone');
318
+ });
319
+
320
+ test('page: on a computer, the webcam shows on the page, a photo is taken and sent, and the camera turns off', async () => {
321
+ const p = bootPage('?step=pick');
322
+ await settle();
323
+ await p.page.querySelector('#ais-camera').fire('click');
324
+ await settle();
325
+ assert.equal(p.els['ais-camera-file'].clicks, 0);
326
+ const view = p.page.querySelector('#ais-cam-view');
327
+ assert.ok(view.srcObject, 'the webcam feeds the page');
328
+ view.videoWidth = 640;
329
+ view.videoHeight = 480;
330
+ await p.page.querySelector('#ais-snap').fire('click');
331
+ await settle();
332
+ assert.equal(p.device.tracks[0].stopped, true, 'taking the photo turns the camera off');
333
+ const sent = uploadsOf(p);
334
+ assert.equal(sent.length, 1);
335
+ assert.match(sent[0].body.name, /^photo-\d+\.jpg$/);
336
+ assert.equal(sent[0].body.content_type, 'image/jpeg');
337
+ });
338
+
339
+ test('page: leaving step 1 with the webcam open turns the camera off', async () => {
340
+ const p = bootPage('?step=pick&from=1001');
341
+ await settle();
342
+ await p.page.querySelector('#ais-camera').fire('click');
343
+ await settle();
344
+ assert.equal(p.device.tracks[0].stopped, false);
345
+ await p.page.querySelector('#ais-next').fire('click');
346
+ await settle();
347
+ assert.equal(p.loc.search, '?step=describe');
348
+ assert.equal(p.device.tracks[0].stopped, true, 'Next must not leave the camera light on');
349
+ });
350
+
351
+ test('page: a refused or missing webcam falls back to the camera picker', async () => {
352
+ for (const webcam of ['refused', 'none']) {
353
+ const p = bootPage('?step=pick', { device: makeDevice({ webcam }) });
354
+ await settle();
355
+ await p.page.querySelector('#ais-camera').fire('click');
356
+ await settle();
357
+ assert.equal(p.els['ais-camera-file'].clicks, 1, webcam);
358
+ }
359
+ });
360
+
237
361
  test('page: step 3 sends the request on the card pressed, with copies of the carried files', async () => {
238
362
  const p = bootPage('?step=make&from=1001');
239
363
  await settle();
@@ -49,6 +49,7 @@ const ALLOWED = {
49
49
  run_node_reroute: '.claude/hooks/run-node.sh (package-excluded)',
50
50
  run_routine_two_root: '.claude/hooks/_core-resolve.js (package-excluded)',
51
51
  'session-cards': '.claude/hooks/session-cards.js (package-excluded)',
52
+ self_healing_start: '.claude/hooks/session-start.js (package-excluded)',
52
53
  session_start_freshness: '.claude/hooks/session-start.js (package-excluded)',
53
54
  shell_hooks: '.claude/hooks/shell-hooks.js (package-excluded)',
54
55
  stop_refcheck: '.claude/hooks/stop-refcheck.js (package-excluded; the task-1002234 precedent)',