@bongos/core 1.24.11 → 1.24.13

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 (93) hide show
  1. package/.bongos-core.json +160 -85
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +8 -0
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0371-auto-pickup-applies-a-submitted-tweak-on-the-runner.md +1 -0
  8. package/docs/api/openapi.json +175 -3
  9. package/docs/api-reference.md +5 -2
  10. package/docs/copy-inventory.md +92 -20
  11. package/docs/copy-registry.json +777 -33
  12. package/docs/module-api-changelog.md +4 -0
  13. package/docs/page-inventory.json +68 -4
  14. package/docs/page-readings.json +856 -802
  15. package/migrations/core_288_credit_log_idea_unique.sql +51 -0
  16. package/modules/economy/credits.js +6 -5
  17. package/modules/hall-ui/public/ai-studio.html +1 -1
  18. package/modules/hall-ui/public/atlas.html +1 -1
  19. package/modules/hall-ui/public/blockers.html +1 -1
  20. package/modules/hall-ui/public/board-room.html +1 -1
  21. package/modules/hall-ui/public/city.html +1 -1
  22. package/modules/hall-ui/public/collab.html +1 -1
  23. package/modules/hall-ui/public/commands.html +1 -1
  24. package/modules/hall-ui/public/copy-desk.html +1 -1
  25. package/modules/hall-ui/public/craft-mode.js +2 -2
  26. package/modules/hall-ui/public/deploy.html +1 -1
  27. package/modules/hall-ui/public/diagrams.html +1 -1
  28. package/modules/hall-ui/public/drachmae.html +1 -1
  29. package/modules/hall-ui/public/fleet.html +1 -1
  30. package/modules/hall-ui/public/gate.html +1 -1
  31. package/modules/hall-ui/public/goal-map.html +1 -1
  32. package/modules/hall-ui/public/goals.html +1 -1
  33. package/modules/hall-ui/public/government.html +1 -1
  34. package/modules/hall-ui/public/idea.html +1 -1
  35. package/modules/hall-ui/public/ideas.html +1 -1
  36. package/modules/hall-ui/public/index.html +1 -1
  37. package/modules/hall-ui/public/market-watch-item.html +83 -0
  38. package/modules/hall-ui/public/market-watch-item.js +150 -0
  39. package/modules/hall-ui/public/market-watch-item.states.json +143 -0
  40. package/modules/hall-ui/public/market-watch-lib.js +243 -0
  41. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  42. package/modules/hall-ui/public/market-watch-settings.html +1 -1
  43. package/modules/hall-ui/public/market-watch.css +169 -0
  44. package/modules/hall-ui/public/market-watch.html +111 -0
  45. package/modules/hall-ui/public/market-watch.js +129 -0
  46. package/modules/hall-ui/public/market-watch.states.json +14 -0
  47. package/modules/hall-ui/public/modules.html +1 -1
  48. package/modules/hall-ui/public/my-agents.html +1 -1
  49. package/modules/hall-ui/public/primer.html +1 -1
  50. package/modules/hall-ui/public/profile.html +1 -1
  51. package/modules/hall-ui/public/project-settings.html +1 -1
  52. package/modules/hall-ui/public/project-settings.js +2 -2
  53. package/modules/hall-ui/public/ranks.html +1 -1
  54. package/modules/hall-ui/public/roadmap.html +1 -1
  55. package/modules/hall-ui/public/roster.html +1 -1
  56. package/modules/hall-ui/public/sessions.html +1 -1
  57. package/modules/hall-ui/public/settings.html +1 -1
  58. package/modules/hall-ui/public/shell.js +3 -2
  59. package/modules/hall-ui/public/street.html +1 -1
  60. package/modules/hall-ui/public/studio.html +1 -1
  61. package/modules/hall-ui/public/task.html +1 -1
  62. package/modules/hall-ui/public/thinking.html +1 -1
  63. package/modules/hall-ui/public/tweak-editor.html +1 -1
  64. package/modules/hall-ui/public/watch.html +1 -1
  65. package/modules/hall-ui/public/work.html +1 -1
  66. package/modules/hall-ui/records/market-watch.md +40 -0
  67. package/modules/lifecycle/autopickup.js +46 -24
  68. package/modules/lifecycle/db-claims.js +1 -1
  69. package/modules/lifecycle/routes/project-settings.js +24 -16
  70. package/modules/market-watch/db.js +15 -10
  71. package/modules/market-watch/module.json +2 -1
  72. package/modules/market-watch/pages.js +323 -0
  73. package/modules/market-watch/proposals.js +66 -4
  74. package/modules/market-watch/research.js +27 -0
  75. package/modules/market-watch/routes/pages.js +116 -0
  76. package/package-lock.json +2 -2
  77. package/package.json +1 -1
  78. package/release-notes.json +20 -0
  79. package/scripts/bongos/run-unit-tests.js +4 -0
  80. package/src/bongos/module-scope-map.js +1 -0
  81. package/src/bongos/project-settings.js +2 -2
  82. package/src/module-api.js +1 -1
  83. package/tests/autopickup_cap.mjs +122 -60
  84. package/tests/credit_log_idea_unique_db.mjs +98 -0
  85. package/tests/fixtures/market-watch-seed-rows.cjs +60 -0
  86. package/tests/hall_nav.mjs +4 -1
  87. package/tests/hall_page_gate_map.mjs +4 -0
  88. package/tests/market_watch_data_db.mjs +1 -0
  89. package/tests/market_watch_module.mjs +2 -1
  90. package/tests/market_watch_pages.mjs +356 -0
  91. package/tests/market_watch_rules.mjs +63 -3
  92. package/tests/market_watch_run.mjs +44 -0
  93. package/tests/nav_permission_atoms.mjs +1 -1
@@ -11,6 +11,7 @@
11
11
  // - the claim is marked but the task is not a page-tweak round (AUTO_PICKUP_NOT_A_TWEAK)
12
12
  // - the project has tweak.autopickup off (AUTO_PICKUP_OFF)
13
13
  // - this calendar month already holds `cap` marked claims (AUTO_PICKUP_CAP_REACHED)
14
+ // - the claimer already used their rank's per-person allowance (AUTO_PICKUP_CAP_REACHED, cap_from rank)
14
15
  // - this round already has two marked claims (AUTO_PICKUP_ROUND_LIMIT)
15
16
  //
16
17
  // THE LOCK. The count and the claim insert must not interleave with another
@@ -56,26 +57,37 @@ async function countForRound(exec, taskId) {
56
57
  return Number(rows[0] && rows[0].n) || 0;
57
58
  }
58
59
 
59
- // effectiveCap(settings, rank) — { cap, from: 'rank' | 'project' } (task 1005003).
60
- // The claimer's rank's own cap when the project set one, else the project cap. The
61
- // cap is measured against the project's monthly count either way: a rank cap says
62
- // how far into the month a runner of that rank may still pick up, not a separate
63
- // allowance per builder, so the ledger count stays one project-wide number. Pure.
64
- function effectiveCap(settings, rank) {
65
- const s = settings || {};
66
- const caps = s.rank_caps || {};
67
- if (rank && Object.prototype.hasOwnProperty.call(caps, rank) && Number.isInteger(caps[rank])) {
68
- return { cap: caps[rank], from: 'rank' };
69
- }
70
- return { cap: s.cap, from: 'project' };
60
+ // The same month, narrowed to ONE builder's marked claims (task 1005019): the
61
+ // ledger count a per-person rank allowance is measured against.
62
+ const BUILDER_MONTH_COUNT_SQL = `${MONTH_COUNT_SQL}
63
+ AND c.builder_id = $1`;
64
+
65
+ async function countThisMonthFor(exec, builderId) {
66
+ const { rows } = await exec.query(BUILDER_MONTH_COUNT_SQL, [builderId]);
67
+ return Number(rows[0] && rows[0].n) || 0;
71
68
  }
72
69
 
73
- // autoPickupRefusal(client, task, settings, rank) — null when the marked claim may
74
- // proceed, else the refusal object claimTask throws. `settings` is the resolved
75
- // { mode, cap, rank_caps }, read BEFORE the transaction opens (the
70
+ // rankAllowance(settings, rank) — the per-PERSON monthly allowance the project set
71
+ // for this rank, or null when it set none (task 1005019, the owner's 2026-10-30
72
+ // ruling on task 1005003). Each builder of the rank gets this many rounds of their
73
+ // own; the project cap still bounds everyone's total. Pure.
74
+ function rankAllowance(settings, rank) {
75
+ const caps = (settings && settings.rank_caps) || {};
76
+ if (rank && Object.prototype.hasOwnProperty.call(caps, rank) && Number.isInteger(caps[rank])) return caps[rank];
77
+ return null;
78
+ }
79
+
80
+ // autoPickupRefusal(client, task, settings, rank, builderId) — null when the marked
81
+ // claim may proceed, else the refusal object claimTask throws. `settings` is the
82
+ // resolved { mode, cap, rank_caps }, read BEFORE the transaction opens (the
76
83
  // claimPermissionsFor precedent: a settings read is not part of the claim's
77
- // consistency). `rank` is the claimer's rank, read inside it.
78
- async function autoPickupRefusal(client, task, settings, rank = null) {
84
+ // consistency). `rank` and `builderId` are the claimer's, read inside it.
85
+ //
86
+ // Two limits, checked in this order, both under the lock:
87
+ // 1. the PROJECT cap, on everyone's marked claims this month (cap_from 'project');
88
+ // 2. the claimer's RANK allowance, on THEIR OWN marked claims this month
89
+ // (cap_from 'rank') — only when the project set one for their rank.
90
+ async function autoPickupRefusal(client, task, settings, rank = null, builderId = null) {
79
91
  if (!task || task.source !== PAGE_TWEAK_SOURCE) {
80
92
  return { code: 'AUTO_PICKUP_NOT_A_TWEAK', message: 'Only a page tweak round can be claimed with the auto_pickup mark.' };
81
93
  }
@@ -92,12 +104,21 @@ async function autoPickupRefusal(client, task, settings, rank = null) {
92
104
  };
93
105
  }
94
106
  const used = await countThisMonth(client);
95
- const { cap, from } = effectiveCap(s, rank);
96
- if (used >= cap) {
97
- const message = from === 'rank'
98
- ? `This month's auto-pickups have reached ${cap}, the limit this project sets for a ${rank} builder's runner, so submitted pages wait for a person until next month.`
99
- : `Auto-pickup has used this month's ${cap} rounds, so submitted pages wait for a person until next month.`;
100
- return { code: 'AUTO_PICKUP_CAP_REACHED', used, cap, cap_from: from, message };
107
+ if (used >= s.cap) {
108
+ return {
109
+ code: 'AUTO_PICKUP_CAP_REACHED', used, cap: s.cap, cap_from: 'project',
110
+ message: `The project limit is reached: auto-pickup has used this month's ${s.cap} rounds for the whole project, so submitted pages wait for a person until next month.`,
111
+ };
112
+ }
113
+ const allowance = rankAllowance(s, rank);
114
+ if (allowance != null) {
115
+ const mine = await countThisMonthFor(client, builderId);
116
+ if (mine >= allowance) {
117
+ return {
118
+ code: 'AUTO_PICKUP_CAP_REACHED', used: mine, cap: allowance, cap_from: 'rank',
119
+ message: `Your own limit is reached: each ${rank} builder gets ${allowance} auto-pickup rounds a month, and you have used ${mine}, so submitted pages wait for a person until next month.`,
120
+ };
121
+ }
101
122
  }
102
123
  return null;
103
124
  }
@@ -106,6 +127,7 @@ module.exports = {
106
127
  LOCK_KEY,
107
128
  MAX_AUTO_APPLIES_PER_ROUND,
108
129
  countThisMonth,
109
- effectiveCap,
130
+ countThisMonthFor,
131
+ rankAllowance,
110
132
  autoPickupRefusal,
111
133
  };
@@ -374,7 +374,7 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
374
374
  // limit are counted under a project-wide advisory lock that this transaction
375
375
  // holds through the insert below, so two runners cannot both take the last slot.
376
376
  if (autoPickup) {
377
- const refusal = await autoPickupRefusal(client, task, autoPickupSettings, builderRank);
377
+ const refusal = await autoPickupRefusal(client, task, autoPickupSettings, builderRank, builderId);
378
378
  if (refusal) throw refusal;
379
379
  }
380
380
 
@@ -102,9 +102,10 @@ function shipModeRow(resolved) {
102
102
 
103
103
  // --- the monthly tweak-round cap (task 1005003) -----------------------------
104
104
  //
105
- // The project cap and one optional cap per builder rank, editable on the settings
106
- // page. The ranks come from the kernel's live ladder, never re-typed here or on the
107
- // page; a rank with no number follows the project cap.
105
+ // The project cap and one optional per-PERSON limit per builder rank (task
106
+ // 1005019), editable on the settings page. The ranks come from the kernel's live
107
+ // ladder, never re-typed here or on the page; a rank with no number has no limit
108
+ // of its own, only the project's.
108
109
  function tweakCapRow(resolved) {
109
110
  const r = resolved || {};
110
111
  const rankCaps = r.rank_caps || {};
@@ -118,8 +119,9 @@ function tweakCapRow(resolved) {
118
119
  max: api.projectSettings.MAX_AUTOPICKUP_CAP,
119
120
  ranks: api.LIVE_RANK_LADDER.map((rank) => ({ rank, cap: Object.prototype.hasOwnProperty.call(rankCaps, rank) ? rankCaps[rank] : null })),
120
121
  },
121
- sentence: `Tweak auto-pickup may apply up to ${r.cap} submitted pages a month. A builder rank can have its own limit; `
122
- + 'a rank left blank follows the project limit. When the limit is reached, pages wait for a person.',
122
+ sentence: `Tweak auto-pickup may apply up to ${r.cap} submitted pages a month for the whole project. A builder rank can `
123
+ + 'also have a limit per person: each builder of that rank gets that many rounds of their own, within the project total. '
124
+ + 'A rank left blank has only the project limit. When a limit is reached, pages wait for a person.',
123
125
  source: r.source ? r.source.cap : 'default',
124
126
  // Only a cap value this row draws; an unusable on/off switch is not this row's.
125
127
  invalid: (r.invalid || []).find((i) => /autopickup_(monthly_cap|rank_cap\.)/.test(String(i && i.where))) || null,
@@ -567,22 +569,28 @@ module.exports = function buildProjectSettingsRouter() {
567
569
  // rank: any signed-in builder. The runner reads it before it tries a round, and
568
570
  // the studio shows the count; it carries no reward internals or file paths.
569
571
  //
570
- // cap is the project's; rank_caps the per-rank caps the project set (task
571
- // 1005003). my_cap is the one that applies to the CALLER's rank, and
572
- // remaining_this_month is measured against it, so a runner reads the number the
573
- // claim gate will hold it to.
574
- async function autopickupBody(rank = null) {
572
+ // cap is the project's total; rank_caps the per-PERSON allowance each rank gets
573
+ // (tasks 1005003, 1005019). my_rank_cap is the CALLER's own allowance (null when
574
+ // their rank has none) and used_by_me_this_month their own marked claims, and
575
+ // remaining_this_month is the smaller of what is left of the project total and
576
+ // of their own allowance — the number the claim gate will hold them to.
577
+ async function autopickupBody(builder = null) {
575
578
  const resolved = await api.projectSettings.resolveTweakAutopickup();
576
579
  const used = await autopickup.countThisMonth();
577
- const mine = autopickup.effectiveCap(resolved, rank);
580
+ const allowance = autopickup.rankAllowance(resolved, builder && builder.rank);
581
+ const usedByMe = allowance != null && builder ? await autopickup.countThisMonthFor(api.pool, builder.id) : null;
582
+ const remaining = Math.max(0, Math.min(
583
+ resolved.cap - used,
584
+ allowance != null ? allowance - (usedByMe || 0) : Infinity,
585
+ ));
578
586
  return {
579
587
  mode: resolved.mode,
580
588
  cap: resolved.cap,
581
589
  rank_caps: resolved.rank_caps || {},
582
- my_cap: mine.cap,
583
- my_cap_from: mine.from,
590
+ my_rank_cap: allowance,
591
+ used_by_me_this_month: usedByMe,
584
592
  used_this_month: used,
585
- remaining_this_month: Math.max(0, mine.cap - used),
593
+ remaining_this_month: remaining,
586
594
  per_round_limit: autopickup.MAX_AUTO_APPLIES_PER_ROUND,
587
595
  source: resolved.source,
588
596
  invalid: resolved.invalid,
@@ -593,7 +601,7 @@ module.exports = function buildProjectSettingsRouter() {
593
601
  router.get('/project-settings/tweak-autopickup', api.requireBuilder, async (req, res) => {
594
602
  try {
595
603
  res.set('Cache-Control', 'no-store');
596
- res.json(await autopickupBody(req.builder && req.builder.rank));
604
+ res.json(await autopickupBody(req.builder || null));
597
605
  } catch (err) {
598
606
  log.error(`GET /project-settings/tweak-autopickup: ${err && err.message}`);
599
607
  res.fail('project_settings_read_failed', { status: 500, message: 'internal error' });
@@ -640,7 +648,7 @@ module.exports = function buildProjectSettingsRouter() {
640
648
  for (const [rank, n] of rankEdits) {
641
649
  await api.projectSettings.set(`${api.projectSettings.KEYS.TWEAK_AUTOPICKUP_RANK_CAP_PREFIX}${rank}`, n === null ? '' : n, req.builder.id);
642
650
  }
643
- res.json({ ok: true, ...(await autopickupBody(req.builder && req.builder.rank)) });
651
+ res.json({ ok: true, ...(await autopickupBody(req.builder || null)) });
644
652
  } catch (err) {
645
653
  log.error(`PATCH /project-settings/tweak-autopickup: ${err && err.message}`);
646
654
  res.fail('project_settings_write_failed', { status: 500, message: 'internal error' });
@@ -130,14 +130,16 @@ const FOUND_IN_SQL = `
130
130
  JOIN market_watch_items i ON i.id = e.item_id
131
131
  WHERE i.project = $1 AND e.run_id IS NOT NULL AND ($2::bigint IS NULL OR e.item_id = $2)`;
132
132
 
133
+ // `knownRuns` (here, in getItem, caseFile and scoreHistory): the numbered runs when the caller
134
+ // already read them, so one page load reads market_watch_runs once (task 1004939).
133
135
  // listItems — the home desk: every tracked item with its latest overall score, last
134
136
  // finding, level and streak.
135
- async function listItems(pool, { project, kind = null, includeRemoved = false, id = null } = {}) {
137
+ async function listItems(pool, { project, kind = null, includeRemoved = false, id = null, knownRuns = null } = {}) {
136
138
  need(project);
137
139
  const [{ rows: items }, { rows: found }, runs] = await Promise.all([
138
140
  pool.query(ITEMS_SQL, [project, kind, includeRemoved, id]),
139
141
  pool.query(FOUND_IN_SQL, [project, id]),
140
- numberedRuns(pool, { project }),
142
+ knownRuns || numberedRuns(pool, { project }),
141
143
  ]);
142
144
  const byItem = new Map();
143
145
  for (const f of found) {
@@ -154,8 +156,8 @@ async function listItems(pool, { project, kind = null, includeRemoved = false, i
154
156
  }
155
157
 
156
158
  // getItem — one item, with removed ones included (its page still opens).
157
- async function getItem(pool, { project, id }) {
158
- const rows = await listItems(pool, { project, includeRemoved: true, id });
159
+ async function getItem(pool, { project, id, knownRuns = null }) {
160
+ const rows = await listItems(pool, { project, includeRemoved: true, id, knownRuns });
159
161
  return rows[0] || null;
160
162
  }
161
163
 
@@ -168,11 +170,11 @@ const CASE_FILE_SQL = `
168
170
  ORDER BY e.pinned DESC, e.found_on DESC, e.id DESC`;
169
171
 
170
172
  // caseFile — the item page's sortable table: found, run, type, takeaway, confidence, source.
171
- async function caseFile(pool, { project, itemId }) {
173
+ async function caseFile(pool, { project, itemId, knownRuns = null }) {
172
174
  need(project);
173
175
  const [{ rows }, runs] = await Promise.all([
174
176
  pool.query(CASE_FILE_SQL, [project, itemId]),
175
- numberedRuns(pool, { project }),
177
+ knownRuns || numberedRuns(pool, { project }),
176
178
  ]);
177
179
  const runN = new Map(runs.map((r) => [String(r.id), r.n]));
178
180
  return rows.map((e) => ({ ...e, id: Number(e.id), run: e.run_id == null ? null : runN.get(String(e.run_id)) ?? null }));
@@ -185,11 +187,11 @@ const SCORES_SQL = `
185
187
  WHERE i.project = $1 AND s.item_id = $2`;
186
188
 
187
189
  // scoreHistory — the item page's sparklines, one per dimension plus 'overall'.
188
- async function scoreHistory(pool, { project, itemId }) {
190
+ async function scoreHistory(pool, { project, itemId, knownRuns = null }) {
189
191
  need(project);
190
192
  const [{ rows }, runs] = await Promise.all([
191
193
  pool.query(SCORES_SQL, [project, itemId]),
192
- numberedRuns(pool, { project }),
194
+ knownRuns || numberedRuns(pool, { project }),
193
195
  ]);
194
196
  return sparklines(rows, runs);
195
197
  }
@@ -312,7 +314,8 @@ async function settingsView(pool, { project, now = new Date(), recent = 8 } = {}
312
314
  }
313
315
 
314
316
  const REMAINING_SQL = `
315
- SELECT i.id, i.project, i.kind, i.name, i.url, i.description, i.why_tracked
317
+ SELECT i.id, i.project, i.kind, i.name, i.url, i.description, i.why_tracked,
318
+ COALESCE(i.facts->'watch_sources', '[]'::jsonb) AS watch_sources
316
319
  FROM market_watch_items i
317
320
  JOIN market_watch_runs r ON r.id = $2 AND r.project = i.project
318
321
  WHERE i.project = $1 AND i.status = 'active'
@@ -324,7 +327,9 @@ const REMAINING_SQL = `
324
327
  // no finished row in this run. An item already done is never returned, so it is never
325
328
  // researched, or charged, twice (ADR 0393 §2). An item that ERRORED is finished too: the
326
329
  // model ran and was paid for (task 1004937), so a resume does not buy it a second call.
327
- // Each row carries its project, so the run can refuse one that is not its own.
330
+ // Each row carries its project, so the run can refuse one that is not its own, and the
331
+ // links members added through an approved add_source proposal (facts.watch_sources,
332
+ // task 1005017), so the run reads those too.
328
333
  async function remainingItems(pool, { project, runId }) {
329
334
  const { rows } = await pool.query(REMAINING_SQL, [need(project), runId]);
330
335
  return rows.map((r) => ({ ...r, id: Number(r.id) }));
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "key": "market-watch",
3
3
  "title": "Market Watch (a weekly AI watch on a project's market)",
4
- "description": "A weekly AI watch on a project's competitors, partners and topics, with a sourced log per tracked item and a weekly question to members (goal 1000137). Its data model (tables, read queries and the hub seed of the 2026-10-28 research), its weekly run (an hourly poller that researches each tracked item on the project's own Anthropic key, under the monthly cap), the weekly ask card in the hall's attention inbox (task 1004940), its rules page (the watch rules in plain words, members' proposed changes, and the owner's or the government's decision on each, task 1004941) and the owner's settings (on/off, monthly cap, cadence, the project key; task 1004938) are in. Off by default, and it cannot be turned on behind an open join door with no payer, because every run will spend the project's AI budget.",
4
+ "description": "A weekly AI watch on a project's competitors, partners and topics, with a sourced log per tracked item and a weekly question to members (goal 1000137). Its data model (tables, read queries and the hub seed of the 2026-10-28 research), its weekly run (an hourly poller that researches each tracked item on the project's own Anthropic key, under the monthly cap), the weekly ask card in the hall's attention inbox (task 1004940), its home and tracked-item pages (task 1004939), its rules page (the watch rules in plain words, members' proposed changes, and the owner's or the government's decision on each, task 1004941) and the owner's settings (on/off, monthly cap, cadence, the project key; task 1004938) are in. Off by default, and it cannot be turned on behind an open join door with no payer, because every run will spend the project's AI budget.",
5
5
  "version": "0.4.0",
6
6
  "coreVersion": "^1.22.0",
7
7
  "category": {
@@ -15,6 +15,7 @@
15
15
  "migrations": true,
16
16
  "routes": [
17
17
  "ask",
18
+ "pages",
18
19
  "rules",
19
20
  "settings"
20
21
  ],
@@ -0,0 +1,323 @@
1
+ 'use strict';
2
+
3
+ // modules/market-watch/pages.js — what the Market Watch home (the watchtower desk) and
4
+ // the tracked-item page (the dossier) read and write (task 1004939, goal 1000137). The
5
+ // designs of record are docs/design/mocks/market-watch/home.html (option D) and item.html
6
+ // (option B).
7
+ //
8
+ // READS go through db.js (levels, streaks, run numbers, sparklines, spend and the next
9
+ // due date are derived there) plus the two queries below that only these pages need: the
10
+ // latest score per item and dimension (the landscape scope's dots) and the entries found
11
+ // in the last seven days (this week's reports). Every query filters on the project key.
12
+ //
13
+ // SHAPES are pure (shapeHome, shapeItem): the routes call them on what the reads return,
14
+ // and tests/market_watch_pages.mjs calls them on the committed seed, so the page's
15
+ // numbers are checked without a database.
16
+ //
17
+ // THE ONE WRITE is a proposal (market_watch_proposals, origin 'member'). Every button on
18
+ // the item page that would change what is watched files one: re-score, move between
19
+ // partner and competitor, add a source, correct an entry, and stop tracking (a
20
+ // remove_item). The home's "send a new scout" files an add_item. Approving them is the
21
+ // rules page's (task 1004941); nothing here changes an item, a score or an entry.
22
+
23
+ const db = require('./db');
24
+
25
+ const DAY_MS = 24 * 60 * 60 * 1000;
26
+ const WEEK_DAYS = 7;
27
+ const KINDS = Object.freeze(['competitor', 'partner', 'topic']);
28
+ const REPORT_LIMIT = 8;
29
+ const STREAK_SLOTS = 9;
30
+ const REASON_MAX = 280;
31
+ const NAME_MAX = 120;
32
+ const URL_MAX = 500;
33
+ const DIM_RE = /^[a-z][a-z0-9_]{0,59}$/;
34
+ // The four the item page's "Propose a change" offers, then the two the other buttons file.
35
+ const ITEM_KINDS = Object.freeze(['rescore', 'move_kind', 'add_source', 'correct_entry', 'remove_item']);
36
+ const PROPOSAL_KINDS = Object.freeze([...ITEM_KINDS, 'add_item']);
37
+
38
+ const num = (v) => (v == null ? null : Number(v));
39
+ const isoDay = (d) => (d instanceof Date ? d.toISOString().slice(0, 10) : String(d).slice(0, 10));
40
+
41
+ // ---- reads only these pages need -------------------------------------------
42
+
43
+ const LATEST_SCORES_SQL = `
44
+ SELECT DISTINCT ON (s.item_id, s.dimension) s.item_id, s.dimension, s.value
45
+ FROM market_watch_score_snapshots s
46
+ JOIN market_watch_items i ON i.id = s.item_id
47
+ JOIN market_watch_runs r ON r.id = s.run_id
48
+ WHERE i.project = $1 AND i.status <> 'removed'
49
+ ORDER BY s.item_id, s.dimension, r.created_at DESC, r.id DESC`;
50
+
51
+ async function latestScores(pool, { project }) {
52
+ const { rows } = await pool.query(LATEST_SCORES_SQL, [project]);
53
+ return rows;
54
+ }
55
+
56
+ const WEEK_ENTRIES_SQL = `
57
+ SELECT e.id, e.item_id, e.run_id, e.type, e.found_on, e.takeaway, e.confidence,
58
+ e.source_url, e.source_label, e.pinned
59
+ FROM market_watch_log_entries e
60
+ JOIN market_watch_items i ON i.id = e.item_id
61
+ WHERE i.project = $1 AND i.status <> 'removed' AND e.found_on >= $2::date
62
+ ORDER BY e.found_on DESC, e.id DESC
63
+ LIMIT 1000`;
64
+
65
+ async function weekEntries(pool, { project, now = new Date() }) {
66
+ const since = isoDay(new Date(new Date(now).getTime() - WEEK_DAYS * DAY_MS));
67
+ const { rows } = await pool.query(WEEK_ENTRIES_SQL, [project, since]);
68
+ return rows;
69
+ }
70
+
71
+ // ---- pure shapes ------------------------------------------------------------
72
+
73
+ // The labels a kind's dimensions carry, from the rules in use: { <key>: label }.
74
+ function rubricLabels(rules, kind) {
75
+ const rubric = (rules && rules.active && rules.active.rubric) || {};
76
+ const out = {};
77
+ for (const d of Array.isArray(rubric[kind]) ? rubric[kind] : []) if (d && d.key) out[d.key] = String(d.label || d.key);
78
+ return out;
79
+ }
80
+
81
+ // One report per item: a pinned entry first, then the surest, then an insight over a
82
+ // datapoint, then the newest. Items ordered by their overall score, best first.
83
+ const TYPE_RANK = { insight: 0, news: 1, datapoint: 2 };
84
+ function pickReports(entries, itemsById, limit = REPORT_LIMIT) {
85
+ const best = new Map();
86
+ const better = (a, b) => (Number(!!b.pinned) - Number(!!a.pinned))
87
+ || (Number(b.confidence) - Number(a.confidence))
88
+ || ((TYPE_RANK[a.type] ?? 3) - (TYPE_RANK[b.type] ?? 3))
89
+ || (isoDay(b.found_on) > isoDay(a.found_on) ? 1 : isoDay(b.found_on) < isoDay(a.found_on) ? -1 : 0)
90
+ || (Number(b.id) - Number(a.id));
91
+ for (const e of entries) {
92
+ const it = itemsById.get(String(e.item_id));
93
+ if (!it) continue;
94
+ const k = String(e.item_id);
95
+ if (!best.has(k) || better(e, best.get(k)) < 0) best.set(k, e);
96
+ }
97
+ const all = [...best.values()]
98
+ .map((e) => {
99
+ const it = itemsById.get(String(e.item_id));
100
+ return {
101
+ id: Number(e.id),
102
+ item_id: Number(e.item_id),
103
+ item_name: it.name,
104
+ kind: it.kind,
105
+ overall: it.overall,
106
+ type: e.type,
107
+ takeaway: e.takeaway,
108
+ confidence: Number(e.confidence),
109
+ found_on: isoDay(e.found_on),
110
+ source_url: e.source_url,
111
+ };
112
+ })
113
+ .sort((a, b) => ((b.overall ?? -1) - (a.overall ?? -1)) || a.item_name.localeCompare(b.item_name));
114
+ // Competitors and partners are scored on different scales, so one ranking across both
115
+ // would be a false comparison: take each kind's best in turn instead.
116
+ const queues = KINDS.map((k) => all.filter((r) => r.kind === k));
117
+ const out = [];
118
+ while (out.length < limit && queues.some((q) => q.length)) {
119
+ for (const q of queues) if (q.length && out.length < limit) out.push(q.shift());
120
+ }
121
+ return out;
122
+ }
123
+
124
+ // shapeHome — the watchtower desk: scouts, the run streak, the next sweep and budget,
125
+ // the landscape's axes and dots, and this week's reports.
126
+ function shapeHome({ items, runs, settings, scores, entries, rules, now = new Date() }) {
127
+ const live = items.filter((it) => it.status !== 'removed');
128
+ const itemsById = new Map(live.map((it) => [String(it.id), it]));
129
+ const reports = pickReports(entries, itemsById);
130
+ const weekByKind = {};
131
+ for (const e of entries) {
132
+ const it = itemsById.get(String(e.item_id));
133
+ if (it) (weekByKind[it.kind] = weekByKind[it.kind] || new Set()).add(String(it.id));
134
+ }
135
+ const scouts = KINDS.map((kind) => {
136
+ const mine = live.filter((it) => it.kind === kind);
137
+ return {
138
+ kind,
139
+ watched: mine.filter((it) => it.status === 'active').length,
140
+ level: mine.reduce((m, it) => Math.max(m, it.level || 0), 0),
141
+ reports: weekByKind[kind] ? weekByKind[kind].size : 0,
142
+ };
143
+ });
144
+ const ranRuns = runs.filter((r) => r.n != null);
145
+ const streak = runs.slice(-STREAK_SLOTS).map((r) => ({ n: r.n, ran: r.n != null, date: isoDay(r.created_at), reason: r.reason || null }));
146
+
147
+ const labels = rubricLabels(rules, 'competitor');
148
+ const byItem = new Map();
149
+ for (const s of scores) {
150
+ const k = String(s.item_id);
151
+ if (!byItem.has(k)) byItem.set(k, {});
152
+ byItem.get(k)[s.dimension] = num(s.value);
153
+ }
154
+ const seen = new Set(Object.keys(labels));
155
+ const dots = live.filter((it) => it.kind === 'competitor').map((it) => {
156
+ const sc = byItem.get(String(it.id)) || {};
157
+ for (const k of Object.keys(sc)) if (k !== 'overall') seen.add(k);
158
+ return { id: it.id, name: it.name, overall: it.overall, level: it.level, scores: sc };
159
+ });
160
+ const axes = [...seen].map((key) => ({ key, label: labels[key] || key.replace(/_/g, ' ') }));
161
+ axes.push({ key: 'overall', label: 'overall likeness' });
162
+
163
+ const s = settings.settings;
164
+ const cap = Number(s.monthly_cap_usd) || 0;
165
+ const spent = Number(settings.spentThisMonthUsd) || 0;
166
+ return {
167
+ generated_at: new Date(now).toISOString(),
168
+ watch: { enabled: !!s.enabled, configured: !!s.configured, cadence: s.cadence },
169
+ weeks_watched: ranRuns.length,
170
+ scouts,
171
+ streak,
172
+ next_sweep: { due: settings.nextRunDue ? new Date(settings.nextRunDue).toISOString() : null, enabled: !!s.enabled },
173
+ budget: { cap_usd: cap, spent_usd: Math.round(spent * 100) / 100, left_usd: Math.max(0, Math.round((cap - spent) * 100) / 100) },
174
+ axes,
175
+ dots,
176
+ reports,
177
+ hot: reports.map((r) => r.item_id),
178
+ // Every tracked item by name: the desk's "open a file" picker, the way to any item
179
+ // page that is not a dot (a partner, a topic) or a report this week.
180
+ index: live.map((it) => ({ id: it.id, kind: it.kind, name: it.name }))
181
+ .sort((a, b) => (KINDS.indexOf(a.kind) - KINDS.indexOf(b.kind)) || a.name.localeCompare(b.name)),
182
+ };
183
+ }
184
+
185
+ // shapeItem — the dossier: the card (level, streak, overall line, one sparkline per
186
+ // dimension), why it is watched, its known facts and the sortable case file.
187
+ function shapeItem({ item, entries, history, rules, openProposals = 0 }) {
188
+ const labels = rubricLabels(rules, item.kind);
189
+ const series = history.series || {};
190
+ const keys = [...Object.keys(labels), ...Object.keys(series).filter((k) => k !== 'overall' && !(k in labels))];
191
+ const point = (vals) => {
192
+ const v = (vals || []).filter((x) => x != null);
193
+ return { first: v.length ? v[0] : null, latest: v.length ? v[v.length - 1] : null };
194
+ };
195
+ const dimensions = keys.map((key) => ({ key, label: labels[key] || key.replace(/_/g, ' '), series: series[key] || [], ...point(series[key]) }));
196
+ return {
197
+ item: {
198
+ id: item.id, kind: item.kind, name: item.name, url: item.url, category: item.category,
199
+ description: item.description, why_tracked: item.why_tracked, facts: item.facts || {},
200
+ status: item.status, added_via: item.added_via, created_at: item.created_at,
201
+ overall: item.overall, level: item.level, streak: item.streak,
202
+ },
203
+ runs: (history.runs || []).map((r) => ({ n: r.n, date: isoDay(r.date) })),
204
+ overall: { series: series.overall || [], ...point(series.overall) },
205
+ dimensions,
206
+ moved: dimensions.filter((d) => d.first != null && d.latest != null && d.first !== d.latest).length,
207
+ entries: entries.map((e) => ({
208
+ id: Number(e.id), run: e.run, type: e.type, found_on: isoDay(e.found_on), takeaway: e.takeaway,
209
+ dimension: e.dimension, source_url: e.source_url, source_label: e.source_label,
210
+ confidence: Number(e.confidence), pinned: !!e.pinned,
211
+ })),
212
+ open_proposals: Number(openProposals) || 0,
213
+ };
214
+ }
215
+
216
+ // ---- the views --------------------------------------------------------------
217
+
218
+ // Each view reads the numbered runs ONCE and hands them to every db.js read that would
219
+ // otherwise read market_watch_runs again (listItems, getItem, caseFile, scoreHistory).
220
+ async function homeView(pool, { project, now = new Date() }) {
221
+ const runs = await db.numberedRuns(pool, { project });
222
+ const [items, settings, scores, entries, rules] = await Promise.all([
223
+ db.listItems(pool, { project, knownRuns: runs }),
224
+ db.settingsView(pool, { project, now }),
225
+ latestScores(pool, { project }),
226
+ weekEntries(pool, { project, now }),
227
+ db.rules(pool, { project }),
228
+ ]);
229
+ return shapeHome({ items, runs, settings, scores, entries, rules, now });
230
+ }
231
+
232
+ async function itemView(pool, { project, id }) {
233
+ const runs = await db.numberedRuns(pool, { project });
234
+ const item = await db.getItem(pool, { project, id, knownRuns: runs });
235
+ if (!item) return null;
236
+ const [entries, history, rules, open] = await Promise.all([
237
+ db.caseFile(pool, { project, itemId: id, knownRuns: runs }),
238
+ db.scoreHistory(pool, { project, itemId: id, knownRuns: runs }),
239
+ db.rules(pool, { project }),
240
+ db.proposals(pool, { project, status: 'open', itemId: id, limit: 100 }),
241
+ ]);
242
+ return shapeItem({ item, entries, history, rules, openProposals: open.length });
243
+ }
244
+
245
+ // ---- proposals --------------------------------------------------------------
246
+
247
+ const oneLine = (s) => String(s == null ? '' : s).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
248
+ const isHttp = (u) => /^https?:\/\/[^\s/]+\.[^\s]+$/i.test(u);
249
+
250
+ // normalizeProposal(body, { item, rubricKeys }) → { ok, value } | { ok: false, error, message }.
251
+ // `item` is the tracked item the proposal is about (null for add_item); rubricKeys is the
252
+ // set of dimensions the item's kind is scored on. Everything a member typed is trimmed
253
+ // to one line and bounded; nothing here trusts the client's idea of the item's kind.
254
+ function normalizeProposal(body, { item = null, rubricKeys = null } = {}) {
255
+ const b = body && typeof body === 'object' ? body : {};
256
+ const kind = String(b.kind || '');
257
+ if (!PROPOSAL_KINDS.includes(kind)) return { ok: false, error: 'invalid_kind', message: 'Pick what should change.' };
258
+ const reason = oneLine(b.reason);
259
+ if (!reason) return { ok: false, error: 'reason_required', message: 'Say why, in one line.' };
260
+ if (reason.length > REASON_MAX) return { ok: false, error: 'reason_too_long', message: `Keep the reason to ${REASON_MAX} characters.` };
261
+ if (kind === 'add_item') {
262
+ const name = oneLine(b.name);
263
+ const scout = String(b.scout || '');
264
+ const url = oneLine(b.url);
265
+ if (!KINDS.includes(scout)) return { ok: false, error: 'invalid_scout', message: 'Pick a competitor, a partner or a topic.' };
266
+ if (!name || name.length > NAME_MAX) return { ok: false, error: 'invalid_name', message: `Name it in up to ${NAME_MAX} characters.` };
267
+ if (url && (url.length > URL_MAX || !isHttp(url))) return { ok: false, error: 'invalid_url', message: 'A link starts with http:// or https://.' };
268
+ return { ok: true, value: { kind, item_id: null, log_entry_id: null, reason, payload: { kind: scout, name, url: url || null } } };
269
+ }
270
+ if (!item) return { ok: false, error: 'item_not_found', message: 'That item is not tracked here.' };
271
+ const base = { kind, item_id: item.id, log_entry_id: null, reason };
272
+ if (kind === 'rescore') {
273
+ const dimension = String(b.dimension || '');
274
+ const value = Number(b.value);
275
+ const known = dimension === 'overall' || (rubricKeys ? rubricKeys.has(dimension) : DIM_RE.test(dimension));
276
+ if (!DIM_RE.test(dimension) || !known) return { ok: false, error: 'invalid_dimension', message: 'Pick a score to change.' };
277
+ if (b.value === '' || b.value == null || !Number.isFinite(value) || value < 0 || value > 10) return { ok: false, error: 'invalid_value', message: 'A score is from 0 to 10.' };
278
+ return { ok: true, value: { ...base, payload: { dimension, value: Math.round(value * 10) / 10 } } };
279
+ }
280
+ if (kind === 'move_kind') {
281
+ if (item.kind === 'topic') return { ok: false, error: 'cannot_move_topic', message: 'A topic is not a partner or a competitor.' };
282
+ return { ok: true, value: { ...base, payload: { from: item.kind, to: item.kind === 'partner' ? 'competitor' : 'partner' } } };
283
+ }
284
+ if (kind === 'add_source') {
285
+ const url = oneLine(b.url);
286
+ if (!url || url.length > URL_MAX || !isHttp(url)) return { ok: false, error: 'invalid_url', message: 'A source is a link that starts with http:// or https://.' };
287
+ return { ok: true, value: { ...base, payload: { source_url: url } } };
288
+ }
289
+ if (kind === 'correct_entry') {
290
+ const entry = String(b.log_entry_id || '');
291
+ if (!/^[1-9]\d{0,17}$/.test(entry)) return { ok: false, error: 'invalid_entry', message: 'Pick the entry to correct.' };
292
+ return { ok: true, value: { ...base, log_entry_id: Number(entry), payload: {} } };
293
+ }
294
+ return { ok: true, value: { ...base, payload: {} } }; // remove_item: stop tracking
295
+ }
296
+
297
+ // The INSERT checks ownership in SQL: the item must be this project's, and an entry to
298
+ // correct must be that item's. A row from another project inserts nothing.
299
+ const INSERT_ITEM_PROPOSAL_SQL = `
300
+ INSERT INTO market_watch_proposals (project, kind, origin, item_id, log_entry_id, payload, reason, proposed_by)
301
+ SELECT $1, $2, 'member', i.id, $4, $5::jsonb, $6, $7
302
+ FROM market_watch_items i
303
+ WHERE i.id = $3 AND i.project = $1
304
+ AND ($4::bigint IS NULL OR EXISTS (SELECT 1 FROM market_watch_log_entries e WHERE e.id = $4 AND e.item_id = i.id))
305
+ RETURNING id, kind, status, created_at`;
306
+ const INSERT_NEW_ITEM_PROPOSAL_SQL = `
307
+ INSERT INTO market_watch_proposals (project, kind, origin, payload, reason, proposed_by)
308
+ VALUES ($1, 'add_item', 'member', $2::jsonb, $3, $4)
309
+ RETURNING id, kind, status, created_at`;
310
+
311
+ async function insertProposal(pool, { project, proposal, builderId }) {
312
+ const p = proposal;
313
+ const { rows: [row] } = p.kind === 'add_item'
314
+ ? await pool.query(INSERT_NEW_ITEM_PROPOSAL_SQL, [project, JSON.stringify(p.payload), p.reason, builderId])
315
+ : await pool.query(INSERT_ITEM_PROPOSAL_SQL, [project, p.kind, p.item_id, p.log_entry_id, JSON.stringify(p.payload), p.reason, builderId]);
316
+ return row ? { ...row, id: Number(row.id) } : null;
317
+ }
318
+
319
+ module.exports = {
320
+ homeView, itemView, insertProposal,
321
+ shapeHome, shapeItem, pickReports, rubricLabels, normalizeProposal,
322
+ PROPOSAL_KINDS, KINDS,
323
+ };