@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
@@ -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&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 -----------------------------------------------------------
@@ -45,7 +45,7 @@ const V1 = {
45
45
 
46
46
  // ── the in-memory database ─────────────────────────────────────────────────────
47
47
  function makeDb() {
48
- const t = { versions: [], proposals: [], items: [], scores: [], runs: [{ id: 50, project: P, status: 'done' }], seq: 100 };
48
+ const t = { versions: [], proposals: [], items: [], scores: [], entries: [], runs: [{ id: 50, project: P, status: 'done' }], seq: 100 };
49
49
  const nextId = () => ++t.seq;
50
50
  t.versions.push({ id: nextId(), project: P, ...JSON.parse(JSON.stringify(V1)), approved_at: new Date('2026-10-28'), created_at: new Date('2026-10-28') });
51
51
  t.items.push({ id: nextId(), project: P, kind: 'competitor', name: 'OpenHands', url: 'https://openhands.dev', status: 'active', why_tracked: '' });
@@ -76,7 +76,11 @@ function makeDb() {
76
76
  case 'mw:proposal-lock':
77
77
  case 'mw:proposal-get-open': {
78
78
  const p = prop(a, b);
79
- return one(p && (tag === 'mw:proposal-lock' || p.status === 'open') ? p : null);
79
+ if (!p || !(tag === 'mw:proposal-lock' || p.status === 'open')) return one(null);
80
+ // Hand back only the columns the real SELECT names (task 1005026): returning the
81
+ // whole row hid a lock that never read log_entry_id, so every correction failed.
82
+ const cols = String(sql).match(/SELECT\s+([\s\S]+?)\s+FROM/)[1].split(',').map((x) => x.trim());
83
+ return one(Object.fromEntries(cols.map((k) => [k, p[k]])));
80
84
  }
81
85
  case 'mw:rules-max-version':
82
86
  return one({ v: Math.max(0, ...t.versions.filter((v) => v.project === a).map((v) => v.version)) });
@@ -131,6 +135,18 @@ function makeDb() {
131
135
  Object.assign(p, { status: c, decided_by: d, decided_at: new Date(), rule_version_id: e ?? p.rule_version_id, item_id: f ?? p.item_id, payload: { ...p.payload, ...JSON.parse(g) } });
132
136
  return one(p);
133
137
  }
138
+ case 'mw:entry-lock': {
139
+ // the entry, only through an item of this project, and only the proposal's item
140
+ const e = t.entries.find((x) => String(x.id) === String(a) && String(x.item_id) === String(c));
141
+ const it = e && t.items.find((x) => x.id === e.item_id && x.project === b);
142
+ return one(e && it ? { ...e } : null);
143
+ }
144
+ case 'mw:entry-correct': {
145
+ const e = t.entries.find((x) => String(x.id) === String(a));
146
+ if (!e || t.entries.some((o) => o.id !== e.id && o.item_id === e.item_id && o.run_id === e.run_id && o.takeaway === b)) return one(null);
147
+ e.takeaway = b;
148
+ return one({ id: e.id });
149
+ }
134
150
  case 'mw:proposal-at-vote': {
135
151
  const p = prop(a, b);
136
152
  if (p && p.status === 'open') p.payload = { ...p.payload, matter_id: c };
@@ -282,15 +298,71 @@ test('an approved add-item proposal creates the tracked item; the run\'s and the
282
298
  assert.equal(proposals.itemFromPayload({}), null);
283
299
  });
284
300
 
285
- test('remove_item takes the item off the watch; a correct_entry is recorded as approved, not applied', async () => {
301
+ test('remove_item takes the item off the watch', async () => {
286
302
  const { t, pool, withTx } = makeDb();
287
303
  const itemId = t.items[0].id;
288
304
  const filed = await proposals.createProposal(pool, { project: P, step: 'names', change: { op: 'remove', item_id: itemId }, reason: 'not a rival', proposedBy: ADA });
289
305
  await proposals.decideProposal({ project: P, id: filed.proposal.id, decision: 'approve', decidedBy: OWNER, withTx });
290
306
  assert.equal(t.items[0].status, 'removed');
291
- t.proposals.push({ id: 900, project: P, kind: 'correct_entry', origin: 'member', item_id: itemId, log_entry_id: 7, payload: {}, status: 'open', proposed_by: ADA });
307
+ });
308
+
309
+ // task 1005016: an approved correct_entry corrects the entry it names.
310
+ function withEntries() {
311
+ const d = makeDb();
312
+ const itemId = d.t.items[0].id;
313
+ d.t.items.push({ id: 300, project: P, kind: 'competitor', name: 'Other', url: 'https://other.dev', status: 'active' });
314
+ d.t.items.push({ id: 400, project: 'https://other.example', kind: 'competitor', name: 'Theirs', url: 'https://theirs.dev', status: 'active' });
315
+ d.t.entries.push(
316
+ { id: 7, item_id: itemId, run_id: 50, takeaway: 'OpenHands raised a Series B.' },
317
+ { id: 8, item_id: itemId, run_id: 50, takeaway: 'OpenHands shut down.' },
318
+ { id: 9, item_id: 300, run_id: 50, takeaway: 'Another item\'s entry.' },
319
+ { id: 10, item_id: 400, run_id: 50, takeaway: 'Another project\'s entry.' },
320
+ );
321
+ const file = (id, payload, { item = itemId, entry = 7 } = {}) => d.t.proposals.push({
322
+ id, project: P, kind: 'correct_entry', origin: 'member', item_id: item, log_entry_id: entry, payload, reason: 'their post says seed', status: 'open', proposed_by: ADA,
323
+ });
324
+ return { ...d, itemId, file };
325
+ }
326
+
327
+ test('CORRECT AN ENTRY: approving rewrites the entry, and keeps who, when, why and the old text', async () => {
328
+ const { t, withTx, file } = withEntries();
329
+ file(900, { takeaway: 'OpenHands raised a seed round, not a Series B.' });
292
330
  const out = await proposals.decideProposal({ project: P, id: 900, decision: 'approve', decidedBy: OWNER, withTx });
293
- assert.deepEqual([out.status, out.applied], ['approved', false]);
331
+ assert.deepEqual([out.ok, out.status, out.applied, out.log_entry_id], [true, 'applied', true, 7]);
332
+ assert.equal(t.entries.find((e) => e.id === 7).takeaway, 'OpenHands raised a seed round, not a Series B.', 'the entry itself now reads corrected');
333
+ const p = t.proposals.find((x) => x.id === 900);
334
+ assert.equal(p.status, 'applied');
335
+ assert.equal(p.proposed_by, ADA, 'who proposed it');
336
+ assert.equal(p.decided_by, OWNER, 'who approved it');
337
+ assert.ok(p.decided_at instanceof Date, 'when');
338
+ assert.equal(p.reason, 'their post says seed', 'why');
339
+ assert.equal(p.payload.was, 'OpenHands raised a Series B.', 'the text it replaced is never lost');
340
+ });
341
+
342
+ test('CORRECT AN ENTRY: a wrong flag leaves the text as found and applies the flag', async () => {
343
+ const { t, withTx, file } = withEntries();
344
+ file(901, { wrong: true }, { entry: 8 });
345
+ const out = await proposals.decideProposal({ project: P, id: 901, decision: 'approve', decidedBy: OWNER, withTx });
346
+ assert.deepEqual([out.status, out.applied], ['applied', true]);
347
+ assert.equal(t.entries.find((e) => e.id === 8).takeaway, 'OpenHands shut down.');
348
+ assert.deepEqual([t.proposals.find((x) => x.id === 901).payload.wrong, t.proposals.find((x) => x.id === 901).payload.was], [true, 'OpenHands shut down.']);
349
+ });
350
+
351
+ test('CORRECT AN ENTRY: refused, and left open, when it names nothing it may change', async () => {
352
+ const { t, withTx, file } = withEntries();
353
+ const decide = (id) => proposals.decideProposal({ project: P, id, decision: 'approve', decidedBy: OWNER, withTx });
354
+ file(902, {}); // filed before task 1005016: the reason was the correction
355
+ assert.equal((await decide(902)).reason, 'no_correction');
356
+ file(903, { takeaway: 'x' }, { entry: 9 }); // another item's entry
357
+ assert.equal((await decide(903)).reason, 'entry_gone');
358
+ file(904, { takeaway: 'x' }, { item: 400, entry: 10 }); // another project's item and entry
359
+ assert.equal((await decide(904)).reason, 'entry_gone');
360
+ file(905, { takeaway: 'OpenHands shut down.' }); // would duplicate entry 8 in the same run
361
+ assert.equal((await decide(905)).reason, 'same_as_another_entry');
362
+ for (const id of [902, 903, 904, 905]) assert.equal(t.proposals.find((x) => x.id === id).status, 'open', `proposal ${id} stays open`);
363
+ assert.deepEqual(t.entries.map((e) => e.takeaway), ['OpenHands raised a Series B.', 'OpenHands shut down.', 'Another item\'s entry.', 'Another project\'s entry.'], 'no entry changed');
364
+ // a decline of the old kind still works
365
+ assert.equal((await proposals.decideProposal({ project: P, id: 902, decision: 'decline', decidedBy: OWNER, withTx })).status, 'rejected');
294
366
  });
295
367
 
296
368
  test('a project cannot decide another project\'s proposal', async () => {
@@ -422,6 +494,24 @@ test('OWNER ONLY: a member who is not the owner gets 403 and nothing changes; th
422
494
  assert.equal((await call(OWNER, 'POST', `/market-watch/proposals/${id}/decide`, { decision: 'maybe' })).status, 400);
423
495
  });
424
496
 
497
+ test('OWNER ONLY, correcting an entry: a member gets 403 and the entry is untouched; the owner\'s approval corrects it', async () => {
498
+ const itemId = RT.t.items[0].id;
499
+ RT.t.entries.push({ id: 70, item_id: itemId, run_id: 50, takeaway: 'Old words.' });
500
+ RT.t.proposals.push({ id: 970, project: P, kind: 'correct_entry', origin: 'member', item_id: itemId, log_entry_id: 70, payload: { takeaway: 'New words.' }, reason: 'r', status: 'open', proposed_by: ADA });
501
+ const denied = await call(ADA, 'POST', '/market-watch/proposals/970/decide', { decision: 'approve' });
502
+ assert.deepEqual([denied.status, denied.code], [403, 'owner_only']);
503
+ assert.equal(RT.t.entries.find((e) => e.id === 70).takeaway, 'Old words.');
504
+ assert.equal(RT.t.proposals.find((p) => p.id === 970).status, 'open');
505
+ assert.equal((await call(null, 'POST', '/market-watch/proposals/970/decide', { decision: 'approve' })).status, 401);
506
+ const ok = await call(OWNER, 'POST', '/market-watch/proposals/970/decide', { decision: 'approve' });
507
+ assert.equal(ok.status, 200, JSON.stringify(ok.json));
508
+ assert.equal(RT.t.entries.find((e) => e.id === 70).takeaway, 'New words.');
509
+ RT.t.proposals.push({ id: 971, project: P, kind: 'correct_entry', origin: 'member', item_id: itemId, log_entry_id: 70, payload: {}, reason: 'r', status: 'open', proposed_by: ADA });
510
+ const old = await call(OWNER, 'POST', '/market-watch/proposals/971/decide', { decision: 'approve' });
511
+ assert.deepEqual([old.status, old.code], [409, 'no_correction'], 'an old proposal with no correction says so in words');
512
+ assert.match(old.json.error.message, /does not say how the entry should read/);
513
+ });
514
+
425
515
  test('a government that hands Rules to a sitting: the owner cannot decide alone (409), the page offers the vote', async () => {
426
516
  const filed = await proposals.createProposal(RT.pool, { project: P, step: 'brief', change: { op: 'set', text: 'Five lines.' }, reason: 'r', proposedBy: ADA });
427
517
  const sitting = {
@@ -553,4 +643,10 @@ test('the payloads the item page files are the ones an approval applies', () =>
553
643
  assert.ok(n.ok, body.kind);
554
644
  assert.ok(proposals.itemChange(n.value.kind, n.value.payload), `${body.kind}: pages.js writes a payload proposals.js applies`);
555
645
  }
646
+ // correct_entry (task 1005016): both shapes the item page files are ones an approval applies
647
+ const fix = pages.normalizeProposal({ kind: 'correct_entry', log_entry_id: '7', takeaway: ' seed,\nnot B ', reason: 'r' }, { item });
648
+ assert.deepEqual(proposals.entryCorrection(fix.value.payload), { takeaway: 'seed, not B' });
649
+ const wrong = pages.normalizeProposal({ kind: 'correct_entry', log_entry_id: '7', wrong: true, reason: 'r' }, { item });
650
+ assert.deepEqual(proposals.entryCorrection(wrong.value.payload), { wrong: true, takeaway: null });
651
+ assert.equal(proposals.entryCorrection({}), null);
556
652
  });
@@ -27,11 +27,14 @@ const seed = require('../modules/market-watch/seed.js');
27
27
  const store = require('../modules/market-watch/store.js');
28
28
  const proposals = require('../modules/market-watch/proposals.js');
29
29
  const view = require('../modules/market-watch/rules-view.js');
30
+ const pages = require('../modules/market-watch/pages.js');
31
+ const db = require('../modules/market-watch/db.js');
30
32
  const research = require('../modules/market-watch/research.js');
31
33
  const pricing = require('../src/bongos/llm-pricing.js');
32
34
 
33
35
  const MIGRATION = readFileSync(new URL('../modules/market-watch/migrations/market_watch_001_tables.sql', import.meta.url), 'utf8');
34
36
  const MIGRATION_002 = readFileSync(new URL('../modules/market-watch/migrations/market_watch_002_run.sql', import.meta.url), 'utf8');
37
+ const MIGRATION_004 = readFileSync(new URL('../modules/market-watch/migrations/market_watch_004_entry_corrections.sql', import.meta.url), 'utf8');
35
38
 
36
39
  const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
37
40
  try {
@@ -60,6 +63,8 @@ const withTx = async (fn) => {
60
63
 
61
64
  async function cleanup() {
62
65
  await q(`DELETE FROM market_watch_proposals WHERE project = $1`, [P]).catch(() => {});
66
+ await q(`DELETE FROM market_watch_log_entries WHERE item_id IN (SELECT id FROM market_watch_items WHERE project = $1)`, [P]).catch(() => {});
67
+ await q(`DELETE FROM market_watch_runs WHERE project = $1`, [P]).catch(() => {});
63
68
  await q(`DELETE FROM market_watch_items WHERE project = $1`, [P]).catch(() => {});
64
69
  await q(`DELETE FROM market_watch_rule_versions WHERE project = $1`, [P]).catch(() => {});
65
70
  await q(`DELETE FROM builders WHERE id = ANY($1::bigint[])`, [[OWNER, ADA]]).catch(() => {});
@@ -73,6 +78,7 @@ async function check(name, fn) {
73
78
  try {
74
79
  await q(MIGRATION);
75
80
  await q(MIGRATION_002);
81
+ await q(MIGRATION_004);
76
82
  await cleanup();
77
83
  await insertTestBuilders(pool, [{ id: OWNER, login: 't1004941-owner' }, { id: ADA, login: 't1004941-ada' }]);
78
84
  const r = seed.RULES_V1;
@@ -112,6 +118,42 @@ try {
112
118
  assert.deepEqual([it.kind, it.url, it.added_via, String(it.added_by), it.status], ['competitor', 'https://algora.io', 'proposal', String(ADA), 'active']);
113
119
  });
114
120
 
121
+ // task 1005016: an approved correct_entry rewrites the entry, keeps who/when/why and
122
+ // the old text on the proposal, and the case file reads the correction; a wrong flag
123
+ // leaves the text and reads as marked wrong.
124
+ await check('an approved correct_entry corrects the log entry, and the case file shows it', async () => {
125
+ const { rows: [it] } = await q(`SELECT id FROM market_watch_items WHERE project = $1 AND name = 'Algora'`, [P]);
126
+ const { rows: [run] } = await q(`INSERT INTO market_watch_runs (project, status, trigger) VALUES ($1, 'done', 'manual') RETURNING id`, [P]);
127
+ const { rows: [e1] } = await q(`INSERT INTO market_watch_log_entries (item_id, run_id, type, takeaway, source_url, confidence)
128
+ VALUES ($1, $2, 'news', 'Algora raised a Series B.', 'https://algora.io/blog', 2) RETURNING id`, [it.id, run.id]);
129
+ const { rows: [e2] } = await q(`INSERT INTO market_watch_log_entries (item_id, run_id, type, takeaway, source_url, confidence)
130
+ VALUES ($1, $2, 'news', 'Algora shut down.', 'https://algora.io/x', 1) RETURNING id`, [it.id, run.id]);
131
+ const file = async (body) => {
132
+ const n = pages.normalizeProposal({ kind: 'correct_entry', item_id: it.id, ...body }, { item: { id: Number(it.id), kind: 'competitor' } });
133
+ assert.equal(n.ok, true, JSON.stringify(n));
134
+ return pages.insertProposal(pool, { project: P, proposal: n.value, builderId: ADA });
135
+ };
136
+ const fix = await file({ log_entry_id: String(e1.id), takeaway: 'Algora raised a seed round, not a Series B.', reason: 'their post says seed' });
137
+ const out = await proposals.decideProposal({ project: P, id: fix.id, decision: 'approve', decidedBy: OWNER, withTx });
138
+ assert.deepEqual([out.ok, out.status], [true, 'applied'], JSON.stringify(out));
139
+ const { rows: [e] } = await q(`SELECT takeaway FROM market_watch_log_entries WHERE id = $1`, [e1.id]);
140
+ assert.equal(e.takeaway, 'Algora raised a seed round, not a Series B.');
141
+ const { rows: [p] } = await q(`SELECT status, proposed_by, decided_by, decided_at, reason, payload FROM market_watch_proposals WHERE id = $1`, [fix.id]);
142
+ assert.deepEqual([p.status, String(p.proposed_by), String(p.decided_by), p.reason, p.payload.was],
143
+ ['applied', String(ADA), String(OWNER), 'their post says seed', 'Algora raised a Series B.']);
144
+ assert.ok(p.decided_at instanceof Date);
145
+ const wrong = await file({ log_entry_id: String(e2.id), wrong: true, reason: 'they are still running' });
146
+ assert.equal((await proposals.decideProposal({ project: P, id: wrong.id, decision: 'approve', decidedBy: OWNER, withTx })).status, 'applied');
147
+ const cf = await db.caseFile(pool, { project: P, itemId: it.id });
148
+ const byId = new Map(cf.map((x) => [String(x.id), x]));
149
+ assert.equal(byId.get(String(e1.id)).correction_reason, 'their post says seed');
150
+ assert.equal(byId.get(String(e1.id)).marked_wrong, false);
151
+ assert.equal(byId.get(String(e2.id)).marked_wrong, true);
152
+ assert.equal(byId.get(String(e2.id)).takeaway, 'Algora shut down.', 'a wrong entry keeps its text');
153
+ const shaped = pages.shapeItem({ item: { id: it.id }, entries: cf, history: {}, rules: null });
154
+ assert.equal(shaped.entries.find((x) => x.id === Number(e2.id)).corrected.wrong, true);
155
+ });
156
+
115
157
  await check('the page read runs on the real schema', async () => {
116
158
  await proposals.createProposal(pool, { project: P, step: 'brief', change: { op: 'set', text: 'Write five lines.' }, reason: 'shorter', proposedBy: ADA });
117
159
  const page = await view.readView(pool, { project: P, decide: { mode: 'owner' }, pricing, model: research.MODEL });
@@ -94,7 +94,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
94
94
  // "Upgraded to core X" notice after a move; task 1004826: Standing is Accomplishments;
95
95
  // task 1004846: the new logo (its bump landed late, in task 1004852); task 1004938: the
96
96
  // Market Watch settings nav item and its fuel glyph.
97
- const SHELL_SHA = 'b89fd877947ae9f8';
97
+ const SHELL_SHA = '1a23b1cf13567eff';
98
98
 
99
99
  test('shell.js has not changed without its cache-bust being bumped', () => {
100
100
  const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
@@ -23,6 +23,14 @@ let statements = [];
23
23
  const query = async (sql, params = []) => {
24
24
  statements.push(sql);
25
25
  if (/^\s*(BEGIN|COMMIT|ROLLBACK)/i.test(sql)) return { rows: [] };
26
+ if (/FROM lifecycle_pay_type_gates/.test(sql)) {
27
+ // task 1004991: these cases are about the terms, so every gate reads as cleared;
28
+ // tests/pay_type_gates.mjs is where an open gate refuses.
29
+ const gates = require('../modules/lifecycle/pay-type-gates.js');
30
+ const at = new Date(Date.UTC(2026, 9, 30));
31
+ return { rows: params[0].flatMap((pay_type) => gates.requirementIds(pay_type).map((requirement) => (
32
+ { pay_type, requirement, state: 'cleared', evidence_ref: 'test', decided_by: 1, decided_at: at }))) };
33
+ }
26
34
  if (/INSERT INTO lifecycle_pay_term_sets/.test(sql)) {
27
35
  const row = { id: sets.length + 1, task_id: params[0], set_by: params[1], set_at: new Date(Date.UTC(2026, 9, 30, 0, sets.length)) };
28
36
  sets.push(row);