@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,166 @@
1
+ 'use strict';
2
+
3
+ // modules/economy/project-account.js — THE PROJECT ACCOUNT'S RECORD AND VIEW
4
+ // (task 1003182, goal 1000092; ADR 0385 decision 1, ADR 0397 decision 2).
5
+ //
6
+ // WHY. "Where did my money go" is the question this goal exists to answer, and a
7
+ // balance that moved for a reason nobody recorded is exactly what an owner cannot
8
+ // audit. So every movement on the project's account is one append-only row that
9
+ // names its cause: which payout, to which builder, for which task, under which
10
+ // processor transfer.
11
+ //
12
+ // WHAT THIS IS NOT. The processor holds the money in the project's connected account
13
+ // (ADR 0397); this module holds none, moves none and has no balance column. The
14
+ // "recorded balance" is a sum over our own record, shown beside the processor's own
15
+ // figure when a processor port supplies one, with the gap between them named. It
16
+ // follows the credit_log idiom (ADR 0023/0054): insert-only, a correction is a new row.
17
+ //
18
+ // Amounts are integer micro-dollars (the unit ai-studio and its pricing already use).
19
+ // The table, its direction-by-kind rule and the cause rule are enforced by Postgres
20
+ // (migrations/economy_002_project_account.sql); the checks here only turn a refusal
21
+ // into a plain reason before the INSERT.
22
+
23
+ const KINDS = Object.freeze(['funding', 'payout', 'payout_return', 'fee', 'adjustment', 'hold', 'hold_release']);
24
+ const FIXED_DIRECTION = Object.freeze({
25
+ funding: 'in', payout_return: 'in', payout: 'out', fee: 'out', hold: 'none', hold_release: 'none',
26
+ });
27
+ const NOTE_MAX = 300;
28
+
29
+ class MovementRefusal extends Error {
30
+ constructor(code, message) { super(message); this.code = code; }
31
+ }
32
+
33
+ const isId = (v) => v !== undefined && v !== null && /^\d+$/.test(String(v));
34
+ const present = (v) => typeof v === 'string' && v.trim().length > 0;
35
+
36
+ // checkMovement(input) → the row to insert, or throws MovementRefusal naming what is
37
+ // missing. Mirrors the table's CHECKs so a caller sees "a payout needs a task", not a
38
+ // constraint name.
39
+ function checkMovement(input) {
40
+ const i = input || {};
41
+ if (!KINDS.includes(i.kind)) throw new MovementRefusal('bad_kind', `kind must be one of ${KINDS.join(', ')}`);
42
+ const amount = Number(i.amountMicro);
43
+ if (!Number.isSafeInteger(amount) || amount <= 0) throw new MovementRefusal('bad_amount', 'amount must be a positive whole number of micro-dollars');
44
+ const note = String(i.note || '');
45
+ if (note.length > NOTE_MAX) throw new MovementRefusal('note_too_long', `note is at most ${NOTE_MAX} characters`);
46
+ let direction = FIXED_DIRECTION[i.kind];
47
+ if (i.kind === 'adjustment') {
48
+ direction = i.direction;
49
+ if (direction !== 'in' && direction !== 'out') throw new MovementRefusal('bad_direction', 'an adjustment says whether it is "in" or "out"');
50
+ }
51
+ const need = (cond, what) => { if (!cond) throw new MovementRefusal('cause_missing', `a ${i.kind} must name ${what}`); };
52
+ if (i.kind === 'payout') {
53
+ need(isId(i.builderId), 'the builder paid'); need(isId(i.taskId), 'the task it was for'); need(present(i.processorRef), 'the processor transfer');
54
+ } else if (i.kind === 'payout_return') {
55
+ need(isId(i.builderId), 'the builder it came back from'); need(present(i.processorRef), 'the processor reference');
56
+ } else if (i.kind === 'funding' || i.kind === 'fee') {
57
+ need(present(i.processorRef), 'the processor reference');
58
+ } else if (i.kind === 'adjustment') {
59
+ need(isId(i.correctsId), 'the movement it corrects'); need(isId(i.actorId), 'who made it'); need(present(note), 'why, in the note');
60
+ } else {
61
+ need(isId(i.taskId), 'the task it is held for');
62
+ }
63
+ return {
64
+ kind: i.kind, direction, amount_micro: amount,
65
+ builder_id: isId(i.builderId) ? String(i.builderId) : null,
66
+ task_id: isId(i.taskId) ? String(i.taskId) : null,
67
+ corrects_id: isId(i.correctsId) ? String(i.correctsId) : null,
68
+ processor_ref: present(i.processorRef) ? i.processorRef.trim() : null,
69
+ actor_id: isId(i.actorId) ? String(i.actorId) : null,
70
+ note,
71
+ };
72
+ }
73
+
74
+ // recordMovement(db, input) → { recorded, row }. One processor reference books one
75
+ // movement of its kind: a second call with the same kind and reference is a no-op that
76
+ // returns recorded:false and the row already there, so a retried payout cannot pay twice.
77
+ async function recordMovement(db, input) {
78
+ const r = checkMovement(input);
79
+ const ins = await db.query(
80
+ `INSERT INTO economy_project_account_movements
81
+ (kind, direction, amount_micro, builder_id, task_id, corrects_id, processor_ref, actor_id, note)
82
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
83
+ ON CONFLICT (kind, processor_ref) WHERE processor_ref IS NOT NULL DO NOTHING
84
+ RETURNING *`,
85
+ [r.kind, r.direction, r.amount_micro, r.builder_id, r.task_id, r.corrects_id, r.processor_ref, r.actor_id, r.note]);
86
+ if (ins.rows[0]) return { recorded: true, row: ins.rows[0] };
87
+ // Only the one-per-reference rule is absorbed above, so a skipped insert always has a reference.
88
+ const had = await db.query(
89
+ 'SELECT * FROM economy_project_account_movements WHERE kind = $1 AND processor_ref = $2', [r.kind, r.processor_ref]);
90
+ return { recorded: false, row: had.rows[0] || null };
91
+ }
92
+
93
+ const TOTALS_SQL = `
94
+ SELECT
95
+ COALESCE(SUM(CASE direction WHEN 'in' THEN amount_micro WHEN 'out' THEN -amount_micro ELSE 0 END), 0) AS recorded_balance_micro,
96
+ COALESCE(SUM(CASE WHEN direction = 'in' THEN amount_micro ELSE 0 END), 0) AS in_micro,
97
+ COALESCE(SUM(CASE WHEN direction = 'out' THEN amount_micro ELSE 0 END), 0) AS out_micro,
98
+ COALESCE(SUM(CASE kind WHEN 'hold' THEN amount_micro WHEN 'hold_release' THEN -amount_micro ELSE 0 END), 0) AS held_micro,
99
+ count(*) AS movements
100
+ FROM economy_project_account_movements`;
101
+
102
+ const RECENT_SQL = `
103
+ SELECT m.id, m.kind, m.direction, m.amount_micro, m.builder_id, b.display_name AS builder_name,
104
+ m.task_id, t.title AS task_title, m.corrects_id, m.processor_ref, m.note, m.created_at
105
+ FROM economy_project_account_movements m
106
+ LEFT JOIN builders b ON b.id = m.builder_id
107
+ LEFT JOIN tasks t ON t.id = m.task_id
108
+ ORDER BY m.created_at DESC, m.id DESC
109
+ LIMIT $1 OFFSET $2`;
110
+
111
+ // shapeAccount — pure. totals is the one aggregate row, processorMicro the processor's own
112
+ // balance or null when no processor is connected. available = what the project could still
113
+ // commit: the recorded balance less what is held. drift is only named when both figures exist.
114
+ function shapeAccount(totals, movements, processorMicro) {
115
+ const n = (v) => Number(v);
116
+ const recorded = n(totals.recorded_balance_micro);
117
+ const held = n(totals.held_micro);
118
+ const hasProcessor = processorMicro !== null && processorMicro !== undefined;
119
+ return {
120
+ unit: 'micro_usd',
121
+ holds_money: false,
122
+ recorded_balance_micro: recorded,
123
+ held_micro: held,
124
+ available_micro: recorded - held,
125
+ in_micro: n(totals.in_micro),
126
+ out_micro: n(totals.out_micro),
127
+ movement_count: n(totals.movements),
128
+ processor_balance_micro: hasProcessor ? n(processorMicro) : null,
129
+ drift_micro: hasProcessor ? n(processorMicro) - recorded : null,
130
+ movements: movements.map((m) => ({ ...m, amount_micro: n(m.amount_micro) })),
131
+ };
132
+ }
133
+
134
+ // The processor read is time-boxed so a hanging processor cannot hold the owner view open; its
135
+ // error text stays in the log and never reaches the client (it can carry account identifiers).
136
+ const PROCESSOR_TIMEOUT_MS = 5000;
137
+ async function readProcessor(processor, log) {
138
+ if (!processor || typeof processor.balanceMicro !== 'function') return { micro: null, failed: false };
139
+ let timer;
140
+ try {
141
+ const micro = await Promise.race([
142
+ processor.balanceMicro(),
143
+ new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('processor timed out')), PROCESSOR_TIMEOUT_MS); }),
144
+ ]);
145
+ return { micro, failed: false };
146
+ } catch (e) {
147
+ if (log) log.error({ err: e.message }, 'project account: processor balance unreadable');
148
+ return { micro: null, failed: true };
149
+ } finally { clearTimeout(timer); }
150
+ }
151
+
152
+ // readAccount(db, { limit, offset, processor }) — the owner's view. processor is the
153
+ // optional port { balanceMicro() } over the processor's connected account; a failure to
154
+ // read it leaves the figure null (named by processor_error) and never blocks the record.
155
+ async function readAccount(db, { limit = 50, offset = 0, processor = null, log = null } = {}) {
156
+ const lim = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200);
157
+ const off = Math.max(parseInt(offset, 10) || 0, 0);
158
+ const [totals, recent, read] = await Promise.all([
159
+ db.query(TOTALS_SQL), db.query(RECENT_SQL, [lim, off]), readProcessor(processor, log),
160
+ ]);
161
+ const out = shapeAccount(totals.rows[0], recent.rows, read.micro);
162
+ if (read.failed) out.processor_error = 'The processor balance could not be read just now.';
163
+ return out;
164
+ }
165
+
166
+ module.exports = { MovementRefusal, checkMovement, recordMovement, shapeAccount, readAccount };
@@ -0,0 +1,40 @@
1
+ // modules/economy/routes/project-account.js — GET /project-account: the owner's view
2
+ // of the project's account (task 1003182, goal 1000092; ADR 0385, ADR 0397).
3
+ //
4
+ // → { unit, holds_money:false, recorded_balance_micro, held_micro, available_micro,
5
+ // in_micro, out_micro, movement_count, processor_balance_micro|null, drift_micro|null,
6
+ // movements:[{ kind, amount_micro, builder_name, task_id, task_title, processor_ref, … }] }
7
+ //
8
+ // READ ONLY. There is deliberately no write route: movements are booked by the code that
9
+ // moves money through the processor (the payout task), in the same step, never by a
10
+ // person typing a figure. `../project-account.js` owns the SQL; this file is the doorway.
11
+ //
12
+ // MODULE BOUNDARY: reaches core only through `../../../src/module-api`.
13
+
14
+ 'use strict';
15
+
16
+ const express = require('express');
17
+ const api = require('../../../src/module-api');
18
+ const { readAccount } = require('../project-account');
19
+
20
+ const log = api.logger('economy');
21
+
22
+ module.exports = function buildProjectAccountRouter({ processor = null } = {}) {
23
+ const router = express.Router();
24
+
25
+ // rank: payout.approve (archon floor) — the owner. The account's balance and who was paid what
26
+ // is the project's own money, so only the payout authority reads it (ADR 0016: enforced here, server-side).
27
+ router.get('/project-account', api.requireBuilder, api.requirePermission('payout.approve'), async (req, res) => {
28
+ try {
29
+ const { limit, offset } = api.parsePagination(req, { defaultLimit: 50, maxLimit: 200 });
30
+ const out = await readAccount(api.pool, { limit, offset, processor, log });
31
+ res.set('Cache-Control', 'no-store');
32
+ res.json({ ...out, page: api.pageMeta(limit, offset, out.movement_count) });
33
+ } catch (err) {
34
+ log.error({ err: err.message }, 'GET /project-account failed');
35
+ if (!res.headersSent) res.fail('project_account_read_failed', { status: 500, message: 'internal error' });
36
+ }
37
+ });
38
+
39
+ return router;
40
+ };
@@ -45,6 +45,9 @@
45
45
  display: inline-flex; align-items: center; min-height: 24px; min-width: 24px; padding: 0; border: 0; background: none;
46
46
  font: inherit; color: var(--st-link); text-decoration: underline; text-underline-offset: 3px; cursor: pointer;
47
47
  }
48
+ .ais-cam { display: flex; flex-direction: column; gap: 12px; }
49
+ .ais-cam[hidden] { display: none; }
50
+ .ais-cam__view { width: 100%; max-height: 360px; border-radius: 18px; background: var(--st-track); object-fit: cover; }
48
51
  .ais-rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
49
52
  .ais-row {
50
53
  display: flex; align-items: center; gap: 14px; min-height: 48px; padding: 0 18px; box-sizing: border-box;
@@ -76,6 +76,7 @@
76
76
  <p class="ais-quiet">Opening…</p>
77
77
  </div>
78
78
  <input type="file" id="ais-file" class="ais-file" multiple accept="image/png,image/jpeg,image/gif,image/webp,application/pdf" tabindex="-1" aria-hidden="true">
79
+ <input type="file" id="ais-camera-file" class="ais-file" accept="image/*" capture="environment" tabindex="-1" aria-hidden="true">
79
80
  </main>
80
81
 
81
82
  <footer class="app-foot" id="app-foot"></footer>
@@ -88,7 +89,7 @@
88
89
  <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
89
90
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
90
91
  <script src="/builders/craft-mode.js?v=2026-10-30-market-watch"></script>
91
- <script src="/builders/shell.js?v=2026-10-30-market-watch-pages"></script>
92
+ <script src="/builders/shell.js?v=2026-10-30-github-chip"></script>
92
93
  <script src="/builders/palette.js" defer></script>
93
94
  <script src="/builders/tweak-editor-lib.js?v=2026-09-28-tw23"></script>
94
95
  <script src="/builders/studio-lib.js?v=2026-10-09-day"></script>
@@ -54,6 +54,7 @@
54
54
  const page = document.getElementById('ais-page');
55
55
  const main = document.getElementById('hall-main');
56
56
  const fileInput = document.getElementById('ais-file');
57
+ const cameraInput = document.getElementById('ais-camera-file');
57
58
 
58
59
  const state = {
59
60
  signedIn: false,
@@ -105,6 +106,7 @@
105
106
 
106
107
  async function render({ focus = false } = {}) {
107
108
  stopTimers();
109
+ closeCamera(); // leaving the pick step by any route turns the camera off
108
110
  main.removeAttribute('data-making');
109
111
  state.route = lib.routeOf(location.search);
110
112
  main.setAttribute('data-view', state.route.view === 'step' ? state.route.step : state.route.view);
@@ -165,6 +167,7 @@
165
167
  }
166
168
 
167
169
  function paintPick() {
170
+ closeCamera(); // a repaint drops the panel, so its stream must go too
168
171
  const files = state.draft.files;
169
172
  const rows = files.map((f, i) => {
170
173
  const right = f.status === 'uploading' ? '<span class="ais-dim">uploading…</span>'
@@ -177,15 +180,25 @@
177
180
  page.innerHTML = `${stepHead('pick')}
178
181
  <h1 class="ais-ask" data-head tabindex="-1">What are you starting from?</h1>
179
182
  <div class="ais-drop" id="ais-drop">
180
- <span>Drop sketches, pictures or a PDF here, or <button type="button" class="ais-link" id="ais-choose">choose files</button></span>
183
+ <span>Drop sketches, pictures or a PDF here, paste a picture, <button type="button" class="ais-link" id="ais-choose">choose files</button> or <button type="button" class="ais-link" id="ais-camera">take a photo</button></span>
181
184
  <span class="ais-dim">Up to ${lib.MAX_FILES}. Pictures up to 4 MB, PDFs up to 8 MB.</span>
182
185
  </div>
186
+ <div class="ais-cam" id="ais-cam" hidden>
187
+ <video class="ais-cam__view" id="ais-cam-view" autoplay playsinline muted></video>
188
+ <div class="ais-go">
189
+ <button type="button" class="ais-btn" id="ais-snap">Take it</button>
190
+ <button type="button" class="ais-ghost" id="ais-cam-close">Cancel</button>
191
+ </div>
192
+ </div>
183
193
  ${rows ? `<ul class="ais-rows">${rows}</ul>` : ''}
184
194
  <div class="ais-go">
185
195
  <button type="button" class="ais-btn" id="ais-next" ${ready && !uploading ? '' : 'disabled'}>Next</button>
186
196
  <span class="ais-faint">${ready ? `${ready} ${ready === 1 ? 'file' : 'files'} ready` : ''}</span>
187
197
  </div>`;
188
198
  page.querySelector('#ais-choose').addEventListener('click', () => fileInput.click());
199
+ page.querySelector('#ais-camera').addEventListener('click', openCamera);
200
+ page.querySelector('#ais-snap').addEventListener('click', snapPhoto);
201
+ page.querySelector('#ais-cam-close').addEventListener('click', closeCamera);
189
202
  const drop = page.querySelector('#ais-drop');
190
203
  drop.addEventListener('dragover', (ev) => { ev.preventDefault(); drop.classList.add('ais-drop--over'); });
191
204
  drop.addEventListener('dragleave', () => drop.classList.remove('ais-drop--over'));
@@ -202,6 +215,54 @@
202
215
  page.querySelector('#ais-next').addEventListener('click', () => go('step=describe'));
203
216
  }
204
217
 
218
+ // The camera. A phone opens its own camera app through the capture input;
219
+ // a computer shows its webcam here, and falls back to the same input if the
220
+ // browser has no camera access or the person says no.
221
+ let camStream = null;
222
+ function openCamera() {
223
+ const touch = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
224
+ const media = navigator.mediaDevices && navigator.mediaDevices.getUserMedia;
225
+ if (touch || !media) { if (cameraInput) cameraInput.click(); return; }
226
+ navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false }).then((stream) => {
227
+ closeCamera();
228
+ camStream = stream;
229
+ const panel = page.querySelector('#ais-cam');
230
+ const view = page.querySelector('#ais-cam-view');
231
+ if (!panel || !view) { closeCamera(); return; }
232
+ view.srcObject = stream;
233
+ panel.hidden = false;
234
+ }).catch(() => { if (cameraInput) cameraInput.click(); });
235
+ }
236
+
237
+ function closeCamera() {
238
+ if (camStream) camStream.getTracks().forEach((t) => t.stop());
239
+ camStream = null;
240
+ const panel = page.querySelector('#ais-cam');
241
+ if (panel) panel.hidden = true;
242
+ }
243
+
244
+ function snapPhoto() {
245
+ const view = page.querySelector('#ais-cam-view');
246
+ if (!view || !view.videoWidth) return;
247
+ const canvas = document.createElement('canvas');
248
+ canvas.width = view.videoWidth;
249
+ canvas.height = view.videoHeight;
250
+ canvas.getContext('2d').drawImage(view, 0, 0);
251
+ closeCamera();
252
+ canvas.toBlob((blob) => {
253
+ if (blob) addFiles([new File([blob], `photo-${Date.now()}.jpg`, { type: 'image/jpeg' })]);
254
+ }, 'image/jpeg', 0.9);
255
+ }
256
+
257
+ // A pasted picture (a screenshot, an image copied from anywhere) joins the
258
+ // files on the pick step; pasting words anywhere else is left alone.
259
+ function pastedFiles(ev) {
260
+ const items = Array.from((ev.clipboardData && ev.clipboardData.items) || []);
261
+ return items.filter((it) => it.kind === 'file').map((it) => it.getAsFile()).filter(Boolean)
262
+ .map((f, i) => (f.name && f.name !== 'image.png' ? f
263
+ : new File([f], `pasted-${Date.now()}-${i + 1}.${(f.type.split('/')[1] || 'png').replace('jpeg', 'jpg')}`, { type: f.type })));
264
+ }
265
+
205
266
  function readBase64(blob) {
206
267
  return new Promise((resolve, reject) => {
207
268
  const r = new FileReader();
@@ -241,6 +302,7 @@
241
302
  <h1 class="ais-ask"><label for="ais-prompt" data-head tabindex="-1">What should it make?</label></h1>
242
303
  <textarea class="ais-field ais-field--tall" id="ais-prompt" maxlength="4000" placeholder="Say what you want, in your own words."></textarea>
243
304
  <p class="ais-faint">Your project's look and words are added for you, so you don't need to describe them.</p>
305
+ ${state.terms ? `<p class="ais-faint">${esc(state.terms)}</p>` : ''}
244
306
  <div class="ais-go">
245
307
  <button type="button" class="ais-btn" id="ais-next">Next</button>
246
308
  <button type="button" class="ais-ghost" id="ais-back">Back</button>
@@ -540,6 +602,14 @@
540
602
  }
541
603
 
542
604
  fileInput.addEventListener('change', () => { addFiles(fileInput.files); fileInput.value = ''; });
605
+ if (cameraInput) cameraInput.addEventListener('change', () => { addFiles(cameraInput.files); cameraInput.value = ''; });
606
+ window.addEventListener('paste', (ev) => {
607
+ if (!state.route || state.route.view !== 'step' || state.route.step !== 'pick') return;
608
+ const pasted = pastedFiles(ev);
609
+ if (!pasted.length) return;
610
+ ev.preventDefault();
611
+ addFiles(pasted);
612
+ });
543
613
  window.addEventListener('popstate', () => render({ focus: true }));
544
614
 
545
615
  (async () => {
@@ -550,6 +620,7 @@
550
620
  if (state.signedIn) {
551
621
  const status = await api.request('GET', `${API}/ai-studio/status`);
552
622
  state.off = !status.ok;
623
+ state.terms = (status.data && status.data.terms) || '';
553
624
  }
554
625
  await render();
555
626
  })();
@@ -92,7 +92,7 @@
92
92
  <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
93
93
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
94
94
  <script src="/builders/craft-mode.js?v=2026-10-30-market-watch"></script>
95
- <script src="/builders/shell.js?v=2026-10-30-market-watch-pages"></script>
95
+ <script src="/builders/shell.js?v=2026-10-30-github-chip"></script>
96
96
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
97
97
  <script src="/builders/palette.js" defer></script>
98
98
  <script src="/builders/atlas.js?v=2026-09-02-index" defer></script>
@@ -61,7 +61,7 @@
61
61
  <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
62
62
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
63
63
  <script src="/builders/craft-mode.js?v=2026-10-30-market-watch"></script>
64
- <script src="/builders/shell.js?v=2026-10-30-market-watch-pages"></script>
64
+ <script src="/builders/shell.js?v=2026-10-30-github-chip"></script>
65
65
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
66
66
  <script src="/builders/palette.js" defer></script>
67
67
  <!-- the branding pack's currency label, loaded BEFORE blockers.js. -->
@@ -61,7 +61,7 @@
61
61
  <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
62
62
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
63
63
  <script src="/builders/craft-mode.js?v=2026-10-30-market-watch"></script>
64
- <script src="/builders/shell.js?v=2026-10-30-market-watch-pages"></script>
64
+ <script src="/builders/shell.js?v=2026-10-30-github-chip"></script>
65
65
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
66
66
  <script src="/builders/palette.js" defer></script>
67
67
  <script src="/builders/hall-kit.js"></script>
@@ -108,7 +108,7 @@
108
108
  <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
109
109
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab" defer></script>
110
110
  <script src="/builders/craft-mode.js?v=2026-10-30-market-watch" defer></script>
111
- <script src="/builders/shell.js?v=2026-10-30-market-watch-pages" defer></script>
111
+ <script src="/builders/shell.js?v=2026-10-30-github-chip" defer></script>
112
112
  <script src="/builders/palette.js" defer></script>
113
113
  <script src="/builders/gov-living.js?v=2026-10-09-positions1" defer></script>
114
114
  <script src="/builders/gov-feeds.js?v=2026-10-09-living3" defer></script>
@@ -162,7 +162,7 @@
162
162
  <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
163
163
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
164
164
  <script src="/builders/craft-mode.js?v=2026-10-30-market-watch"></script>
165
- <script src="/builders/shell.js?v=2026-10-30-market-watch-pages"></script>
165
+ <script src="/builders/shell.js?v=2026-10-30-github-chip"></script>
166
166
  <script src="/builders/palette.js" defer></script>
167
167
  <script src="/builders/collab-lib.js?v=2026-09-24-archive"></script>
168
168
  <script src="/builders/collab.js?v=2026-09-24-archive"></script>
@@ -54,7 +54,7 @@
54
54
  <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
55
55
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
56
56
  <script src="/builders/craft-mode.js?v=2026-10-30-market-watch"></script>
57
- <script src="/builders/shell.js?v=2026-10-30-market-watch-pages"></script>
57
+ <script src="/builders/shell.js?v=2026-10-30-github-chip"></script>
58
58
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
59
59
  <script src="/builders/palette.js" defer></script>
60
60
  <script src="/builders/hall-kit.js"></script>
@@ -180,7 +180,7 @@
180
180
  <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
181
181
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
182
182
  <script src="/builders/craft-mode.js?v=2026-10-30-market-watch"></script>
183
- <script src="/builders/shell.js?v=2026-10-30-market-watch-pages"></script>
183
+ <script src="/builders/shell.js?v=2026-10-30-github-chip"></script>
184
184
  <script src="/builders/palette.js" defer></script>
185
185
  <script src="/builders/hall-kit.js"></script>
186
186
  <script src="/builders/copy-desk.js?v=2026-09-01-redesign"></script>
@@ -79,7 +79,7 @@
79
79
  <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
80
80
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
81
81
  <script src="/builders/craft-mode.js?v=2026-10-30-market-watch"></script>
82
- <script src="/builders/shell.js?v=2026-10-30-market-watch-pages"></script>
82
+ <script src="/builders/shell.js?v=2026-10-30-github-chip"></script>
83
83
  <script src="/builders/palette.js" defer></script>
84
84
  <script src="/builders/hall-kit.js"></script>
85
85
  <!-- the shared ask card the move is drawn with (task 1004829) -->
@@ -97,7 +97,7 @@
97
97
  <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
98
98
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
99
99
  <script src="/builders/craft-mode.js?v=2026-10-30-market-watch"></script>
100
- <script src="/builders/shell.js?v=2026-10-30-market-watch-pages"></script>
100
+ <script src="/builders/shell.js?v=2026-10-30-github-chip"></script>
101
101
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
102
102
  <script src="/builders/palette.js" defer></script>
103
103
  <script src="/builders/diagram-viewer.js?v=2026-06-14-diagviewer-1" defer></script>
@@ -97,7 +97,7 @@
97
97
  <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
98
98
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
99
99
  <script src="/builders/craft-mode.js?v=2026-10-30-market-watch"></script>
100
- <script src="/builders/shell.js?v=2026-10-30-market-watch-pages"></script>
100
+ <script src="/builders/shell.js?v=2026-10-30-github-chip"></script>
101
101
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
102
102
  <script src="/builders/palette.js" defer></script>
103
103
  <script src="/builders/md-reader.js?v=2026-09-02-mdreader-2"></script>
@@ -58,7 +58,7 @@
58
58
  <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
59
59
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
60
60
  <script src="/builders/craft-mode.js?v=2026-10-30-market-watch"></script>
61
- <script src="/builders/shell.js?v=2026-10-30-market-watch-pages"></script>
61
+ <script src="/builders/shell.js?v=2026-10-30-github-chip"></script>
62
62
  <script src="/builders/palette.js" defer></script>
63
63
  <script src="/builders/hall-kit.js"></script>
64
64
  <script src="/builders/fleet-costs.js"></script>
@@ -65,7 +65,7 @@
65
65
  <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
66
66
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
67
67
  <script src="/builders/craft-mode.js?v=2026-10-30-market-watch"></script>
68
- <script src="/builders/shell.js?v=2026-10-30-market-watch-pages"></script>
68
+ <script src="/builders/shell.js?v=2026-10-30-github-chip"></script>
69
69
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
70
70
  <script src="/builders/palette.js" defer></script>
71
71
  <script src="/builders/hall-kit.js"></script>
@@ -0,0 +1,85 @@
1
+ // github-chip.js — the Link GitHub chip in the hall's top bar (task 1004996,
2
+ // goal 1000095 WA6; owner ask 2026-10-29).
3
+ //
4
+ // Someone who signed in with Google and has no GitHub account linked sees a
5
+ // very small chip, carrying the engineer's icon (craft-mode.js ICONS.building),
6
+ // so it reads as "needed for coding". A press starts the link on the hub
7
+ // (GET /auth/link/github/start, ADR 0396), whose answer lands on the hub's
8
+ // account page. Anyone with GitHub linked sees nothing. It never nags: no
9
+ // pop-up, no banner, nothing in the way of ideating or art — one quiet link.
10
+ //
11
+ // Who has GitHub: a builder row carries the stand-in login `~<handle>` until
12
+ // GitHub is linked, and linking writes the real login over it (auth-link.js
13
+ // linkToSession) — so a login without the `~` needs no read at all, which is
14
+ // the common case. Only a `~` login asks GET /me/sign-in-methods, the answer
15
+ // where the hall can read it; a hall that cannot (a project's own hall) goes by
16
+ // the stand-in. Unlinking GitHub later keeps the real login, so that rare person
17
+ // finds Link GitHub on the account page rather than here. No hub origin (a
18
+ // vanilla instance) means no linking, so no chip. Cosmetic only (ADR 0016).
19
+ //
20
+ // shell.js loads this once a signed-in builder is known and calls mount().
21
+ (function (root) {
22
+ 'use strict';
23
+ var START = '/api/bongos/auth/link/github/start';
24
+ var LABEL = 'Link GitHub';
25
+ var TITLE = 'Link GitHub — needed for coding work';
26
+ var ICON = 'M2 13.5h12M3.5 13.5V8.5h4v5M7.5 13.5V4.5h5v9';
27
+ var SVG_NS = 'http://www.w3.org/2000/svg';
28
+
29
+ function standIn(builder) {
30
+ var login = builder && builder.github_login;
31
+ return typeof login === 'string' && login.charAt(0) === '~';
32
+ }
33
+ // methods: the sign-in-methods list, or null when it could not be read.
34
+ function needsGithub(methods, builder) {
35
+ if (!standIn(builder)) return false;
36
+ if (Array.isArray(methods)) return !methods.some(function (m) { return m && m.provider === 'github'; });
37
+ return true;
38
+ }
39
+ // A bare https origin only (OTB.hubOrigin's rule, held again here): never a path,
40
+ // a query or another scheme spliced in front of the start route.
41
+ function chipHref(hub) { return typeof hub === 'string' && /^https:\/\/[A-Za-z0-9.-]+(:\d+)?$/.test(hub) ? hub + START : null; }
42
+
43
+ function draw(href) {
44
+ var a = document.createElement('a');
45
+ a.className = 'gh-chip';
46
+ a.id = 'gh-chip';
47
+ a.href = href;
48
+ a.title = TITLE;
49
+ a.setAttribute('aria-label', TITLE);
50
+ var svg = document.createElementNS(SVG_NS, 'svg');
51
+ [['viewBox', '0 0 16 16'], ['fill', 'none'], ['stroke', 'currentColor'], ['stroke-width', '1.4'], ['stroke-linecap', 'round'], ['stroke-linejoin', 'round'], ['aria-hidden', 'true']]
52
+ .forEach(function (p) { svg.setAttribute(p[0], p[1]); });
53
+ var path = document.createElementNS(SVG_NS, 'path');
54
+ path.setAttribute('d', ICON);
55
+ svg.appendChild(path);
56
+ var label = document.createElement('span');
57
+ label.className = 'gh-chip__label';
58
+ label.textContent = LABEL;
59
+ a.appendChild(svg);
60
+ a.appendChild(label);
61
+ return a;
62
+ }
63
+
64
+ // mount({ api, builder, hub }): reads the ways in, then draws or removes the chip.
65
+ function mount(opts) {
66
+ var side = document.querySelector('#app-topbar .topbar-side');
67
+ var href = chipHref(opts && opts.hub);
68
+ if (!side || !href || !opts.api || !standIn(opts.builder)) return Promise.resolve(false);
69
+ return opts.api.request('GET', '/api/bongos/me/sign-in-methods').then(function (res) {
70
+ return res && res.ok && res.data && Array.isArray(res.data.methods) ? res.data.methods : null;
71
+ }, function () { return null; }).then(function (methods) {
72
+ var old = document.getElementById('gh-chip');
73
+ var show = needsGithub(methods, opts.builder);
74
+ if (!show) { if (old) old.remove(); return false; }
75
+ if (old) return true;
76
+ // first in the row: before the page note and the jump slot, after the title
77
+ side.insertBefore(draw(href), side.firstChild);
78
+ return true;
79
+ });
80
+ }
81
+
82
+ var api = { mount: mount, needsGithub: needsGithub, chipHref: chipHref, START: START, LABEL: LABEL, ICON: ICON };
83
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
84
+ if (root) root.OTBGithubChip = api;
85
+ })(typeof window !== 'undefined' ? window : null);
@@ -140,7 +140,7 @@
140
140
  <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
141
141
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
142
142
  <script src="/builders/craft-mode.js?v=2026-10-30-market-watch"></script>
143
- <script src="/builders/shell.js?v=2026-10-30-market-watch-pages"></script>
143
+ <script src="/builders/shell.js?v=2026-10-30-github-chip"></script>
144
144
  <script src="/builders/palette.js" defer></script>
145
145
 
146
146
  <script src="/builders/idea-objects.js?v=2026-09-27-split"></script>
@@ -244,7 +244,7 @@
244
244
  <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
245
245
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
246
246
  <script src="/builders/craft-mode.js?v=2026-10-30-market-watch"></script>
247
- <script src="/builders/shell.js?v=2026-10-30-market-watch-pages"></script>
247
+ <script src="/builders/shell.js?v=2026-10-30-github-chip"></script>
248
248
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
249
249
  <script src="/builders/palette.js" defer></script>
250
250
  <!-- task 1004727: the ONE karma vote control every task/idea list renders -->
@@ -155,7 +155,7 @@
155
155
  <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
156
156
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
157
157
  <script src="/builders/craft-mode.js?v=2026-10-30-market-watch"></script>
158
- <script src="/builders/shell.js?v=2026-10-30-market-watch-pages"></script>
158
+ <script src="/builders/shell.js?v=2026-10-30-github-chip"></script>
159
159
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
160
160
  <script src="/builders/palette.js" defer></script>
161
161
  <script src="/builders/hall-kit.js"></script>
@@ -53,7 +53,7 @@
53
53
  <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
54
54
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
55
55
  <script src="/builders/craft-mode.js?v=2026-10-30-market-watch"></script>
56
- <script src="/builders/shell.js?v=2026-10-30-market-watch-pages"></script>
56
+ <script src="/builders/shell.js?v=2026-10-30-github-chip"></script>
57
57
  <script src="/builders/palette.js" defer></script>
58
58
  <!-- task-detail.js still owns the #/idea/N and #/blocker/N overlay, so a ref
59
59
  written inside an idea's own prose keeps resolving from this page. -->