@bongos/core 1.24.13 → 1.24.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/.bongos-core.json +302 -137
  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/adr/0397-the-payment-rail-is-stripe-connect-with-one-connected-account-per-project.md +71 -0
  12. package/docs/adr/README.md +1 -0
  13. package/docs/api/openapi.json +369 -6
  14. package/docs/api-reference.md +19 -4
  15. package/docs/architecture.md +6 -0
  16. package/docs/copy-inventory.md +138 -123
  17. package/docs/copy-registry.json +296 -171
  18. package/docs/file-map.md +1 -0
  19. package/docs/module-api-changelog.md +2 -0
  20. package/docs/page-inventory.json +6 -1
  21. package/docs/page-readings.json +1040 -1021
  22. package/migrations/core_289_task_short_name.sql +34 -0
  23. package/modules/ai-studio/db.js +10 -0
  24. package/modules/ai-studio/gemini.js +73 -5
  25. package/modules/ai-studio/openrouter.js +17 -2
  26. package/modules/ai-studio/routes/ai-studio.js +7 -1
  27. package/modules/ai-studio/studio.js +8 -0
  28. package/modules/builder-settings/checkout-freshness.js +71 -0
  29. package/modules/builder-settings/routes/settings.js +26 -0
  30. package/modules/builder-settings/settings.js +2 -0
  31. package/modules/economy/migrations/economy_002_project_account.sql +83 -0
  32. package/modules/economy/module.json +1 -1
  33. package/modules/economy/project-account.js +166 -0
  34. package/modules/economy/routes/project-account.js +40 -0
  35. package/modules/hall-ui/public/ai-studio.css +3 -0
  36. package/modules/hall-ui/public/ai-studio.html +2 -1
  37. package/modules/hall-ui/public/ai-studio.js +72 -1
  38. package/modules/hall-ui/public/atlas.html +1 -1
  39. package/modules/hall-ui/public/blockers.html +1 -1
  40. package/modules/hall-ui/public/board-room.html +1 -1
  41. package/modules/hall-ui/public/city.html +1 -1
  42. package/modules/hall-ui/public/collab.html +1 -1
  43. package/modules/hall-ui/public/commands.html +1 -1
  44. package/modules/hall-ui/public/copy-desk.html +1 -1
  45. package/modules/hall-ui/public/deploy.html +1 -1
  46. package/modules/hall-ui/public/diagrams.html +1 -1
  47. package/modules/hall-ui/public/drachmae.html +1 -1
  48. package/modules/hall-ui/public/fleet.html +1 -1
  49. package/modules/hall-ui/public/gate.html +1 -1
  50. package/modules/hall-ui/public/github-chip.js +85 -0
  51. package/modules/hall-ui/public/goal-map.html +1 -1
  52. package/modules/hall-ui/public/goals.html +1 -1
  53. package/modules/hall-ui/public/government.html +1 -1
  54. package/modules/hall-ui/public/idea.html +1 -1
  55. package/modules/hall-ui/public/ideas.html +1 -1
  56. package/modules/hall-ui/public/index.html +1 -1
  57. package/modules/hall-ui/public/market-watch-item.html +2 -2
  58. package/modules/hall-ui/public/market-watch-item.js +10 -3
  59. package/modules/hall-ui/public/market-watch-item.states.json +41 -0
  60. package/modules/hall-ui/public/market-watch-lib.js +36 -5
  61. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  62. package/modules/hall-ui/public/market-watch-settings.html +1 -1
  63. package/modules/hall-ui/public/market-watch.css +5 -0
  64. package/modules/hall-ui/public/market-watch.html +2 -2
  65. package/modules/hall-ui/public/modules.html +1 -1
  66. package/modules/hall-ui/public/my-agents.html +1 -1
  67. package/modules/hall-ui/public/my-agents.js +3 -0
  68. package/modules/hall-ui/public/primer.html +1 -1
  69. package/modules/hall-ui/public/profile.html +1 -1
  70. package/modules/hall-ui/public/project-settings.html +1 -1
  71. package/modules/hall-ui/public/ranks.html +1 -1
  72. package/modules/hall-ui/public/roadmap.html +1 -1
  73. package/modules/hall-ui/public/roster.html +1 -1
  74. package/modules/hall-ui/public/sessions.html +1 -1
  75. package/modules/hall-ui/public/settings.html +1 -1
  76. package/modules/hall-ui/public/shell.js +22 -22
  77. package/modules/hall-ui/public/street.html +1 -1
  78. package/modules/hall-ui/public/studio.html +1 -1
  79. package/modules/hall-ui/public/style.css +28 -0
  80. package/modules/hall-ui/public/task.html +1 -1
  81. package/modules/hall-ui/public/thinking.html +1 -1
  82. package/modules/hall-ui/public/tweak-editor.html +1 -1
  83. package/modules/hall-ui/public/watch.html +1 -1
  84. package/modules/hall-ui/public/work.html +1 -1
  85. package/modules/ideas/blockers.js +13 -7
  86. package/modules/lifecycle/db-claim-reads.js +1 -1
  87. package/modules/lifecycle/db-ship.js +2 -2
  88. package/modules/lifecycle/db-tasks.js +4 -4
  89. package/modules/lifecycle/migrations/lifecycle_019_pay_type_gates.sql +50 -0
  90. package/modules/lifecycle/module.json +1 -0
  91. package/modules/lifecycle/pay-type-gates.js +180 -0
  92. package/modules/lifecycle/publish-reconciler.js +31 -1
  93. package/modules/lifecycle/rebase-gate.js +3 -2
  94. package/modules/lifecycle/routes/claims.js +3 -2
  95. package/modules/lifecycle/routes/pay-type-gates.js +59 -0
  96. package/modules/lifecycle/routes/project-settings.js +4 -0
  97. package/modules/lifecycle/routes/task-write-routes.js +43 -0
  98. package/modules/lifecycle/ship-card.js +4 -1
  99. package/modules/lifecycle/task-name-db.js +55 -0
  100. package/modules/lifecycle/task-name.js +140 -0
  101. package/modules/market-watch/db.js +10 -1
  102. package/modules/market-watch/migrations/market_watch_004_entry_corrections.sql +20 -0
  103. package/modules/market-watch/pages.js +20 -1
  104. package/modules/market-watch/proposals.js +57 -7
  105. package/modules/market-watch/routes/pages.js +2 -0
  106. package/modules/market-watch/routes/rules.js +4 -0
  107. package/modules/platform-identity/terms-acceptance.js +9 -9
  108. package/modules/public-landing/public/account-google.states.json +14 -0
  109. package/modules/public-landing/public/assets/cosmos.css +12 -0
  110. package/modules/public-landing/public/assets/hub-bar.js +44 -1
  111. package/modules/public-landing/public/privacy.html +5 -1
  112. package/modules/public-landing/public/projects-google.states.json +14 -0
  113. package/modules/public-landing/public/projects.html +39 -0
  114. package/modules/public-landing/public/terms.html +12 -2
  115. package/modules/ui-design/kit/fixtures/me-google.json +10 -0
  116. package/modules/ui-design/kit/fixtures/me-sign-in-methods-google.json +4 -0
  117. package/modules/ui-design/kit/serve.js +7 -1
  118. package/package-lock.json +2 -2
  119. package/package.json +1 -1
  120. package/release-notes.json +54 -0
  121. package/scripts/bongos/claim.js +4 -2
  122. package/scripts/bongos/land-watch.js +1 -0
  123. package/scripts/bongos/report-checkout.js +30 -0
  124. package/scripts/bongos/run-unit-tests.js +11 -0
  125. package/scripts/bongos/sessions-board.js +264 -0
  126. package/scripts/bongos/ship-finish.js +1 -0
  127. package/scripts/bongos/ship-flow.js +5 -2
  128. package/scripts/bongos/strand-lease.js +5 -3
  129. package/scripts/bongos/strand-watch.js +38 -7
  130. package/scripts/gds/report-checkout.js +7 -0
  131. package/scripts/gds/sessions-board.js +7 -0
  132. package/src/bongos/agent-standing-rules.js +13 -3
  133. package/src/bongos/routes/me.js +7 -1
  134. package/src/module-api.js +1 -1
  135. package/tests/agent_standing_rules.mjs +18 -1
  136. package/tests/ai_studio_billing.mjs +13 -0
  137. package/tests/ai_studio_gemini_models.mjs +81 -0
  138. package/tests/ai_studio_requests.mjs +24 -0
  139. package/tests/blocker_detail_hydration.mjs +5 -3
  140. package/tests/blocker_tasks_named_db.mjs +74 -0
  141. package/tests/claim_gate_ci_unblock.mjs +1 -1
  142. package/tests/github_chip.mjs +217 -0
  143. package/tests/hall_ai_studio.mjs +129 -5
  144. package/tests/host_topology_skips.mjs +1 -0
  145. package/tests/hub_account_page.mjs +3 -2
  146. package/tests/landing_legal_pages.mjs +3 -2
  147. package/tests/market_watch_data_db.mjs +6 -0
  148. package/tests/market_watch_pages.mjs +87 -2
  149. package/tests/market_watch_rules.mjs +101 -5
  150. package/tests/market_watch_rules_db.mjs +42 -0
  151. package/tests/nav_permission_atoms.mjs +1 -1
  152. package/tests/pay_terms.mjs +8 -0
  153. package/tests/pay_type_gates.mjs +234 -0
  154. package/tests/pay_type_gates_db.mjs +84 -0
  155. package/tests/project_account.mjs +125 -0
  156. package/tests/project_account_db.mjs +97 -0
  157. package/tests/self_healing_start.mjs +162 -0
  158. package/tests/sessions_board.mjs +153 -0
  159. package/tests/stop_refcheck.mjs +40 -0
  160. package/tests/task_name.mjs +237 -0
  161. package/tests/task_name_db.mjs +79 -0
  162. package/tests/terms_acceptance.mjs +25 -3
  163. package/tests/terms_grandfather_db.mjs +11 -4
@@ -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)',
@@ -85,9 +85,10 @@ test('the door returns here, and the page calls only its own-scoped routes', ()
85
85
  assert.match(PAGE, /href="\/api\/bongos\/auth\/web\/start\?return=\/account&amp;repos=1"/, 'sign-in lands back on /account, asking for repo access once (task 1004549)');
86
86
  const calls = [...SCRIPT.matchAll(/request\('(GET|PATCH|POST|DELETE)', '([^']+)'/g)].map((m) => `${m[1]} ${m[2]}`);
87
87
  assert.deepEqual([...new Set(calls)].sort(), ['GET /me/cross-project', 'PATCH /me/cross-project', 'PATCH /me/handle', 'PATCH /me/profile', 'GET /my-projects', 'POST /my-projects/', 'GET /me/guilds', 'GET /me/sign-in-methods', 'DELETE /me/sign-in-methods/'].sort());
88
- // the bar's two calls live in the shared asset: the identity read and sign out
88
+ // the bar's calls live in the shared asset: the identity read, sign out, and the
89
+ // ways in the Link GitHub chip asks (task 1004996)
89
90
  const barCalls = [...BAR.matchAll(/fetch\(API \+ '([^']+)', \{ method: '(GET|POST)'/g)].map((m) => `${m[2]} ${m[1]}`);
90
- assert.deepEqual(barCalls.sort(), ['GET /me', 'POST /auth/logout']);
91
+ assert.deepEqual(barCalls.sort(), ['GET /me', 'GET /me/sign-in-methods', 'POST /auth/logout']);
91
92
  assert.match(BAR, /var API = '\/api\/bongos';/);
92
93
  });
93
94
 
@@ -239,10 +239,11 @@ test('cosmos.css still carries the bar and the bands the doors lean on', () => {
239
239
  for (const rule of ['.bar{', '.barMark{', '.trig{', '.signinDoor{', '.menuBand{']) assert.ok(css.includes(rule), `cosmos.css still carries ${rule}`);
240
240
  });
241
241
 
242
- test('assets/hub-bar.js builds no markup from strings and makes only the bar’s two calls', () => {
242
+ test('assets/hub-bar.js builds no markup from strings and makes only the bar’s three calls', () => {
243
243
  assert.doesNotMatch(BAR, /innerHTML|insertAdjacentHTML|outerHTML/);
244
244
  const calls = [...BAR.matchAll(/fetch\(API \+ '([^']+)', \{ method: '(GET|POST)'/g)].map((m) => `${m[2]} ${m[1]}`);
245
- assert.deepEqual(calls.sort(), ['GET /me', 'POST /auth/logout']);
245
+ // the identity read, sign out, and the ways in the Link GitHub chip asks (task 1004996)
246
+ assert.deepEqual(calls.sort(), ['GET /me', 'GET /me/sign-in-methods', 'POST /auth/logout']);
246
247
  assert.doesNotMatch(BAR, /gtag|plausible|googletagmanager/, 'the privacy page promises no trackers');
247
248
  });
248
249
 
@@ -29,6 +29,7 @@ const db = require('../modules/market-watch/db.js');
29
29
  const MIGRATION = readFileSync(new URL('../modules/market-watch/migrations/market_watch_001_tables.sql', import.meta.url), 'utf8');
30
30
  const MIGRATION_002 = readFileSync(new URL('../modules/market-watch/migrations/market_watch_002_run.sql', import.meta.url), 'utf8');
31
31
  const MIGRATION_003 = readFileSync(new URL('../modules/market-watch/migrations/market_watch_003_ask_pool.sql', import.meta.url), 'utf8');
32
+ const MIGRATION_004 = readFileSync(new URL('../modules/market-watch/migrations/market_watch_004_entry_corrections.sql', import.meta.url), 'utf8');
32
33
  const run = require('../modules/market-watch/run.js');
33
34
  const store = require('../modules/market-watch/store.js');
34
35
  const settings = require('../modules/market-watch/settings.js');
@@ -86,6 +87,11 @@ try {
86
87
  // market_watch_003 (task 1004940): the weekly ask's pool-lookup index.
87
88
  await q(MIGRATION_003);
88
89
  await q(MIGRATION_003);
90
+ // market_watch_004 (task 1005016): the applied-correction lookup index.
91
+ await q(MIGRATION_004);
92
+ await q(MIGRATION_004);
93
+ const { rows: [ix] } = await q(`SELECT count(*)::int AS n FROM pg_indexes WHERE indexname = 'market_watch_proposals_entry_corrections'`);
94
+ assert.equal(ix.n, 1, 'the correction lookup index exists');
89
95
  const { rows } = await q(`SELECT count(*)::int AS n FROM information_schema.tables WHERE table_name LIKE 'market\\_watch\\_%'`);
90
96
  assert.ok(rows[0].n >= 10, `tables: ${rows[0].n}`);
91
97
  });
@@ -134,7 +134,10 @@ test('each of the four changes, with a one-line reason, becomes a proposal the s
134
134
  'the direction comes from the item, never from the client');
135
135
  assert.deepEqual(norm({ kind: 'move_kind', reason: 'r' }, { id: 9, kind: 'partner' }).value.payload, { from: 'partner', to: 'competitor' });
136
136
  assert.deepEqual(norm({ kind: 'add_source', url: 'https://example.org/changelog', reason: 'r' }).value.payload, { source_url: 'https://example.org/changelog' });
137
- assert.deepEqual(norm({ kind: 'correct_entry', log_entry_id: '15', reason: 'r' }).value, { kind: 'correct_entry', item_id: 4, log_entry_id: 15, reason: 'r', payload: {} });
137
+ // task 1005016: the proposal carries the correction itself, or a wrong flag
138
+ assert.deepEqual(norm({ kind: 'correct_entry', log_entry_id: '15', takeaway: ' Raised a seed,\n not a Series B. ', reason: 'r' }).value,
139
+ { kind: 'correct_entry', item_id: 4, log_entry_id: 15, reason: 'r', payload: { takeaway: 'Raised a seed, not a Series B.', summary: 'Correct an entry to read "Raised a seed, not a Series B."' } });
140
+ assert.deepEqual(norm({ kind: 'correct_entry', log_entry_id: 15, wrong: true, reason: 'r' }).value.payload, { wrong: true, summary: 'Mark an entry as wrong' });
138
141
  assert.deepEqual(norm({ kind: 'remove_item', reason: 'shut down' }).value.payload, {}, 'stop tracking');
139
142
  assert.deepEqual(pages.normalizeProposal({ kind: 'add_item', scout: 'topic', name: ' agent pay models ', reason: 'r' }).value.payload,
140
143
  { kind: 'topic', name: 'agent pay models', url: null });
@@ -151,7 +154,11 @@ test('a proposal is refused without a reason, with a bad value, or about somethi
151
154
  assert.equal(err({ kind: 'rescore', dimension: 'human_gates', value: '', reason: 'r' }), 'invalid_value');
152
155
  assert.equal(err({ kind: 'move_kind', reason: 'r' }, { id: 3, kind: 'topic' }), 'cannot_move_topic');
153
156
  assert.equal(err({ kind: 'add_source', url: 'javascript:alert(1)', reason: 'r' }), 'invalid_url');
154
- assert.equal(err({ kind: 'correct_entry', log_entry_id: '0', reason: 'r' }), 'invalid_entry');
157
+ assert.equal(err({ kind: 'correct_entry', log_entry_id: '0', takeaway: 'x', reason: 'r' }), 'invalid_entry');
158
+ assert.equal(err({ kind: 'correct_entry', log_entry_id: '15', reason: 'r' }), 'correction_required', 'the reason alone is no correction');
159
+ assert.equal(err({ kind: 'correct_entry', log_entry_id: '15', takeaway: ' ', reason: 'r' }), 'correction_required');
160
+ assert.equal(err({ kind: 'correct_entry', log_entry_id: '15', takeaway: 'x', wrong: true, reason: 'r' }), 'invalid_correction', 'not both');
161
+ assert.equal(err({ kind: 'correct_entry', log_entry_id: '15', takeaway: 'x'.repeat(501), reason: 'r' }), 'correction_too_long');
155
162
  assert.equal(err({ kind: 'rule_change', reason: 'r' }), 'invalid_kind', 'a rules change is the rules page\'s, not this one\'s');
156
163
  assert.equal(pages.normalizeProposal({ kind: 'rescore', reason: 'r' }).error, 'item_not_found');
157
164
  assert.equal(pages.normalizeProposal({ kind: 'add_item', scout: 'friend', name: 'x', reason: 'r' }).error, 'invalid_scout');
@@ -166,10 +173,79 @@ test('the propose dialog offers exactly the four changes, and posts what the ser
166
173
  assert.deepEqual(MW.bodyFor('change', form({ kind: 'rescore', dimension: 'human_gates', value: '8', reason: ' r ' }), { id: 4 }),
167
174
  { kind: 'rescore', item_id: 4, reason: 'r', dimension: 'human_gates', value: '8' });
168
175
  assert.deepEqual(MW.bodyFor('stop', form({ reason: 'gone' }), { id: 4 }), { kind: 'remove_item', item_id: 4, reason: 'gone' });
176
+ // correct_entry (task 1005016): the corrected words, or the wrong flag instead
177
+ assert.match(html, /name="takeaway"/);
178
+ assert.match(html, /name="wrong" type="checkbox"/);
179
+ const fix = form({ kind: 'correct_entry', log_entry_id: '15', takeaway: ' seed ', reason: 'r' });
180
+ fix.elements.wrong = { value: 'true', checked: false };
181
+ assert.deepEqual(MW.bodyFor('change', fix, { id: 4 }), { kind: 'correct_entry', item_id: 4, reason: 'r', log_entry_id: '15', takeaway: 'seed' });
182
+ fix.elements.wrong.checked = true;
183
+ assert.deepEqual(MW.bodyFor('change', fix, { id: 4 }), { kind: 'correct_entry', item_id: 4, reason: 'r', log_entry_id: '15', wrong: true });
184
+ for (const b of [MW.bodyFor('change', fix, { id: 4 }), { ...MW.bodyFor('change', fix, { id: 4 }), wrong: undefined, takeaway: 'seed' }]) {
185
+ assert.equal(pages.normalizeProposal(b, { item: COP }).ok, true, 'the dialog posts what the server reads');
186
+ }
187
+ });
188
+
189
+ test('a corrected entry reads corrected on the item page; one marked wrong is no report on the home', () => {
190
+ const it = byName('GitHub Copilot', 'competitor');
191
+ const cf = rows.caseFileFrom(R, RUNS, it.id);
192
+ cf[0] = { ...cf[0], corrected_at: new Date('2026-10-30T10:00:00Z'), correction_reason: '<b>their post</b>', marked_wrong: false };
193
+ cf[1] = { ...cf[1], corrected_at: new Date('2026-10-30T11:00:00Z'), correction_reason: 'still running', marked_wrong: true };
194
+ const v = pages.shapeItem({ item: it, entries: cf, history: { series: {}, runs: [] }, rules: RULES });
195
+ assert.deepEqual(v.entries[0].corrected, { on: '2026-10-30', reason: '<b>their post</b>', wrong: false });
196
+ assert.deepEqual(v.entries[1].corrected, { on: '2026-10-30', reason: 'still running', wrong: true });
197
+ assert.equal(v.entries[2].corrected, null, 'an entry nobody corrected carries no note');
198
+ // the read behind it: the latest APPLIED correct_entry of this project, per entry
199
+ const caseSql = readFileSync(new URL('../modules/market-watch/db.js', import.meta.url), 'utf8');
200
+ assert.match(caseSql, /p\.project = i\.project AND p\.log_entry_id = e\.id AND p\.kind = 'correct_entry' AND p\.status = 'applied'/);
201
+ const pagesSrc = readFileSync(new URL('../modules/market-watch/pages.js', import.meta.url), 'utf8');
202
+ assert.match(pagesSrc, /AND NOT COALESCE\(\(SELECT p\.payload->>'wrong' = 'true' FROM market_watch_proposals p/, 'the home skips an entry marked wrong');
203
+ // both lookups are served by market_watch_004's partial index, in the order they read
204
+ const m4 = readFileSync(new URL('../modules/market-watch/migrations/market_watch_004_entry_corrections.sql', import.meta.url), 'utf8');
205
+ assert.match(m4, /CREATE INDEX IF NOT EXISTS market_watch_proposals_entry_corrections\s+ON market_watch_proposals \(log_entry_id, decided_at DESC, id DESC\)\s+WHERE kind = 'correct_entry' AND status = 'applied';/);
206
+ assert.match(m4, /^[\s\S]*BEGIN;[\s\S]*COMMIT;\s*$/, 'transactional');
207
+ assert.ok(/^[\x00-\x7F]*$/.test(m4), 'ASCII only');
208
+ for (const src of [caseSql, pagesSrc]) assert.match(src, /ORDER BY p\.decided_at DESC, p\.id DESC/);
169
209
  });
170
210
 
171
211
  // ---- untrusted text becomes markup only through esc --------------------------------
172
212
 
213
+ // task 1005018: the dialog's screen-reader name follows the action that opened it.
214
+ test('the propose dialog is named by its own title: propose a change, stop tracking or send a new scout', () => {
215
+ const titleOf = (html) => {
216
+ const all = [...html.matchAll(/<span[^>]*id="mw-propose-title"[^>]*>([^<]*)<\/span>/g)];
217
+ assert.equal(all.length, 1, 'exactly one title to point at');
218
+ return all[0][1];
219
+ };
220
+ const item = { id: 4, kind: 'competitor', name: 'Copilot' };
221
+ const expected = { change: 'propose a change', stop: 'stop tracking', scout: 'send a new scout' };
222
+ for (const [mode, words] of Object.entries(expected)) {
223
+ // a fake <dialog> that starts with the old fixed label, as a page served before this fix did
224
+ const attrs = new Map([['aria-label', 'Propose a change']]);
225
+ const stub = () => ({ value: '', checked: false, hidden: false, addEventListener() {}, focus() {} });
226
+ const fields = new Proxy({}, { get: (o, k) => (o[k] = o[k] || stub()) });
227
+ const form = { elements: fields, addEventListener() {}, querySelector: () => stub() };
228
+ let html = '';
229
+ const dlg = {
230
+ set innerHTML(v) { html = v; }, get innerHTML() { return html; },
231
+ setAttribute: (k, v) => attrs.set(k, String(v)), removeAttribute: (k) => attrs.delete(k), getAttribute: (k) => attrs.get(k) ?? null,
232
+ querySelector: (s) => (s === 'form' ? form : stub()), querySelectorAll: () => [], showModal() {}, close() {},
233
+ };
234
+ const saved = globalThis.document;
235
+ globalThis.document = { getElementById: (id) => (id === 'mw-propose' ? dlg : null) };
236
+ try { MW.openPropose(mode, { item, dimensions: [], entries: [] }, () => {}); } finally { globalThis.document = saved; }
237
+ assert.equal(attrs.has('aria-label'), false, `${mode}: no fixed label left to override the title`);
238
+ assert.equal(attrs.get('aria-labelledby'), 'mw-propose-title', mode);
239
+ assert.equal(titleOf(html), words, `${mode}: the name a screen reader hears`);
240
+ }
241
+ for (const page of ['market-watch.html', 'market-watch-item.html']) {
242
+ const src = readFileSync(new URL(`../modules/hall-ui/public/${page}`, import.meta.url), 'utf8');
243
+ const tag = src.match(/<dialog[^>]*id="mw-propose"[^>]*>/)[0];
244
+ assert.doesNotMatch(tag, /aria-label=/, `${page}: no fixed name on the dialog`);
245
+ assert.match(tag, /aria-labelledby="mw-propose-title"/, page);
246
+ }
247
+ });
248
+
173
249
  test('an item name or takeaway with markup in it is escaped on the landscape and in the dialog', () => {
174
250
  const evil = '<img src=x onerror=alert(1)>"\'';
175
251
  const h = home();
@@ -307,6 +383,15 @@ test('a member files a proposal: one row, origin member, their id, this project;
307
383
  assert.equal((await call('POST', '/market-watch/proposals', { kind: 'remove_item', item_id: 4, reason: 'r', status: 'approved' })).status, 400,
308
384
  'an unknown field (a member trying to approve their own proposal) is refused');
309
385
  assert.equal(inserts.length, 2, 'refused proposals write nothing');
386
+ // task 1005016: the route takes the correction (or the wrong flag) and stores it on the row
387
+ const fix = await call('POST', '/market-watch/proposals', { kind: 'correct_entry', item_id: 4, log_entry_id: 15, takeaway: 'Raised a seed.', reason: 'their post' });
388
+ assert.equal(fix.status, 201);
389
+ assert.deepEqual([inserts[2].params[3], JSON.parse(inserts[2].params[4]).takeaway], [15, 'Raised a seed.']);
390
+ const wrong = await call('POST', '/market-watch/proposals', { kind: 'correct_entry', item_id: 4, log_entry_id: 15, wrong: true, reason: 'still running' });
391
+ assert.equal(wrong.status, 201);
392
+ assert.equal(JSON.parse(inserts[3].params[4]).wrong, true);
393
+ assert.equal((await call('POST', '/market-watch/proposals', { kind: 'correct_entry', item_id: 4, log_entry_id: 15, reason: 'r' })).status, 400, 'no correction, no proposal');
394
+ assert.equal(inserts.length, 4);
310
395
  });
311
396
 
312
397
  // ---- the quiet off state -----------------------------------------------------------