@bongos/core 1.24.10 → 1.24.12

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 (96) hide show
  1. package/.bongos-core.json +171 -86
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +12 -0
  4. package/clients/bongos-client/index.cjs +12 -0
  5. package/clients/bongos-client/index.d.ts +18 -1
  6. package/clients/bongos-client/index.mjs +12 -0
  7. package/docs/api/openapi.json +304 -5
  8. package/docs/api-reference.md +14 -4
  9. package/docs/architecture.md +4 -0
  10. package/docs/copy-inventory.md +154 -76
  11. package/docs/copy-registry.json +923 -125
  12. package/docs/module-api-changelog.md +4 -0
  13. package/docs/page-inventory.json +68 -4
  14. package/docs/page-readings.json +873 -819
  15. package/modules/government/decision-rules.js +1 -0
  16. package/modules/hall-ui/public/ai-studio.html +1 -1
  17. package/modules/hall-ui/public/atlas.html +1 -1
  18. package/modules/hall-ui/public/blockers.html +1 -1
  19. package/modules/hall-ui/public/board-room.html +1 -1
  20. package/modules/hall-ui/public/city.html +1 -1
  21. package/modules/hall-ui/public/collab.html +1 -1
  22. package/modules/hall-ui/public/commands.html +1 -1
  23. package/modules/hall-ui/public/copy-desk.html +1 -1
  24. package/modules/hall-ui/public/craft-mode.js +2 -2
  25. package/modules/hall-ui/public/deploy.html +1 -1
  26. package/modules/hall-ui/public/diagrams.html +1 -1
  27. package/modules/hall-ui/public/drachmae.html +1 -1
  28. package/modules/hall-ui/public/fleet.html +1 -1
  29. package/modules/hall-ui/public/gate.html +1 -1
  30. package/modules/hall-ui/public/goal-map.html +1 -1
  31. package/modules/hall-ui/public/goals.html +1 -1
  32. package/modules/hall-ui/public/government.html +1 -1
  33. package/modules/hall-ui/public/idea.html +1 -1
  34. package/modules/hall-ui/public/ideas.html +1 -1
  35. package/modules/hall-ui/public/index.html +1 -1
  36. package/modules/hall-ui/public/market-watch-item.html +83 -0
  37. package/modules/hall-ui/public/market-watch-item.js +150 -0
  38. package/modules/hall-ui/public/market-watch-item.states.json +143 -0
  39. package/modules/hall-ui/public/market-watch-lib.js +243 -0
  40. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  41. package/modules/hall-ui/public/market-watch-settings.html +1 -1
  42. package/modules/hall-ui/public/market-watch.css +169 -0
  43. package/modules/hall-ui/public/market-watch.html +111 -0
  44. package/modules/hall-ui/public/market-watch.js +129 -0
  45. package/modules/hall-ui/public/market-watch.states.json +14 -0
  46. package/modules/hall-ui/public/modules.html +1 -1
  47. package/modules/hall-ui/public/my-agents.html +1 -1
  48. package/modules/hall-ui/public/primer.html +1 -1
  49. package/modules/hall-ui/public/profile.html +1 -1
  50. package/modules/hall-ui/public/project-settings.css +12 -0
  51. package/modules/hall-ui/public/project-settings.html +1 -1
  52. package/modules/hall-ui/public/project-settings.js +63 -1
  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/task.js +34 -1
  63. package/modules/hall-ui/public/thinking.html +1 -1
  64. package/modules/hall-ui/public/tweak-editor.html +1 -1
  65. package/modules/hall-ui/public/watch.html +1 -1
  66. package/modules/hall-ui/public/work.html +1 -1
  67. package/modules/hall-ui/records/market-watch.md +40 -0
  68. package/modules/lifecycle/migrations/lifecycle_018_pay_terms.sql +64 -0
  69. package/modules/lifecycle/pay-terms.js +171 -0
  70. package/modules/lifecycle/routes/project-settings.js +94 -0
  71. package/modules/lifecycle/routes/task-write-routes.js +35 -4
  72. package/modules/market-watch/db.js +10 -8
  73. package/modules/market-watch/module.json +2 -1
  74. package/modules/market-watch/pages.js +323 -0
  75. package/modules/market-watch/proposals.js +66 -4
  76. package/modules/market-watch/routes/pages.js +116 -0
  77. package/package-lock.json +2 -2
  78. package/package.json +1 -1
  79. package/release-notes.json +12 -0
  80. package/scripts/bongos/government-walk.js +1 -0
  81. package/scripts/bongos/run-unit-tests.js +4 -0
  82. package/scripts/bongos/start.js +42 -2
  83. package/src/bongos/module-scope-map.js +1 -0
  84. package/src/module-api.js +1 -1
  85. package/tests/fixtures/market-watch-seed-rows.cjs +60 -0
  86. package/tests/government_act_alone_buildings.mjs +1 -0
  87. package/tests/hall_nav.mjs +4 -1
  88. package/tests/hall_page_gate_map.mjs +4 -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/nav_permission_atoms.mjs +1 -1
  93. package/tests/patch_gate_ordering.mjs +3 -0
  94. package/tests/pay_terms.mjs +254 -0
  95. package/tests/pay_terms_db.mjs +86 -0
  96. package/tests/project_settings_page.mjs +25 -6
@@ -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
+ };
@@ -15,9 +15,17 @@
15
15
  // add_item the tracked item is created (added_via 'proposal', added_by the proposer).
16
16
  // An item already there under the same key (a removed one) is re-activated.
17
17
  // remove_item the item's status becomes 'removed' (never a DELETE; its log stays).
18
- // any other (rescore, move_kind, add_source, correct_entry) the decision is recorded
19
- // as 'approved'. Those kinds belong to the item page, which says what each
20
- // payload means; this file does not guess at a payload it did not define.
18
+ // rescore payload { dimension, value }: the item's score on that dimension in the
19
+ // latest run that ran is set to the value (a snapshot upsert), so the card,
20
+ // the landscape and the sparkline's last point read it.
21
+ // move_kind payload { from, to }: the item moves between partner and competitor. It
22
+ // does not apply when the item is no longer the 'from' kind, or the same
23
+ // name and link are already tracked as the 'to' kind.
24
+ // add_source payload { source_url }: the link joins the item's facts.watch_sources
25
+ // (shown on the item page; the weekly run does not read it yet).
26
+ // correct_entry payload {} with log_entry_id: the reason IS the correction, written for
27
+ // a person to act on, so the decision is recorded as 'approved' only.
28
+ // The four item-page payloads are written by modules/market-watch/pages.js (task 1004939).
21
29
  // A carried-out approval is status 'applied'; a recorded one is 'approved'. A decline is
22
30
  // 'rejected'. Every decision names who made it and when (decided_by, decided_at).
23
31
  //
@@ -86,6 +94,29 @@ const SQL = Object.freeze({
86
94
  getOpen: `-- mw:proposal-get-open
87
95
  SELECT id, kind, payload, reason FROM market_watch_proposals
88
96
  WHERE id = $1 AND project = $2 AND status = 'open'`,
97
+ itemRescore: `-- mw:item-rescore
98
+ INSERT INTO market_watch_score_snapshots (item_id, run_id, dimension, value)
99
+ SELECT i.id, r.id, $3, $4
100
+ FROM market_watch_items i
101
+ JOIN market_watch_runs r ON r.project = i.project AND r.status IN ('done', 'partial')
102
+ WHERE i.id = $1 AND i.project = $2 AND i.status <> 'removed'
103
+ ORDER BY r.created_at DESC, r.id DESC
104
+ LIMIT 1
105
+ ON CONFLICT (item_id, run_id, dimension) DO UPDATE SET value = EXCLUDED.value
106
+ RETURNING item_id`,
107
+ itemMove: `-- mw:item-move
108
+ UPDATE market_watch_items i SET kind = $3, updated_at = now()
109
+ WHERE i.id = $1 AND i.project = $2 AND i.kind = $4 AND i.status <> 'removed'
110
+ AND NOT EXISTS (SELECT 1 FROM market_watch_items o
111
+ WHERE o.project = i.project AND o.kind = $3 AND o.url = i.url AND o.name = i.name)
112
+ RETURNING i.id`,
113
+ itemAddSource: `-- mw:item-add-source
114
+ UPDATE market_watch_items
115
+ SET facts = CASE WHEN COALESCE(facts->'watch_sources', '[]'::jsonb) ? $3 THEN facts
116
+ ELSE jsonb_set(facts, '{watch_sources}', COALESCE(facts->'watch_sources', '[]'::jsonb) || to_jsonb($3::text)) END,
117
+ updated_at = now()
118
+ WHERE id = $1 AND project = $2 AND status <> 'removed'
119
+ RETURNING id`,
89
120
  atVote: `-- mw:proposal-at-vote
90
121
  UPDATE market_watch_proposals SET payload = payload || jsonb_build_object('matter_id', $3::text)
91
122
  WHERE id = $1 AND project = $2 AND status = 'open'`,
@@ -152,6 +183,29 @@ function itemFromPayload(payload) {
152
183
  return name ? { name, kind, url: /^https?:\/\//i.test(url) ? url : '', why } : null;
153
184
  }
154
185
 
186
+ // itemChange(kind, payload) → { sql, params } | null — the item page's three kinds an
187
+ // approval applies, re-checked here: a payload is only as good as the row it was read from.
188
+ const DIM_RE = /^[a-z][a-z0-9_]{0,59}$/;
189
+ function itemChange(kind, payload) {
190
+ const p = payload || {};
191
+ if (kind === 'rescore') {
192
+ const v = Number(p.value);
193
+ if (!DIM_RE.test(String(p.dimension || '')) || p.value == null || !Number.isFinite(v) || v < 0 || v > 10) return null;
194
+ return { sql: 'itemRescore', params: [p.dimension, Math.round(v * 10) / 10] };
195
+ }
196
+ if (kind === 'move_kind') {
197
+ const pair = ['competitor', 'partner'];
198
+ if (!pair.includes(p.from) || !pair.includes(p.to) || p.from === p.to) return null;
199
+ return { sql: 'itemMove', params: [p.to, p.from] };
200
+ }
201
+ if (kind === 'add_source') {
202
+ const u = String(p.source_url || '').trim();
203
+ if (!/^https?:\/\/\S+$/i.test(u) || u.length > 500) return null;
204
+ return { sql: 'itemAddSource', params: [u] };
205
+ }
206
+ return null;
207
+ }
208
+
155
209
  // decideProposal(pool, { project, id, decision, decidedBy, via, withTx }) →
156
210
  // { ok: true, status, applied, version?, item_id? } |
157
211
  // { ok: false, reason: 'not_found' | 'not_open' | 'no_active_rules' | 'no_longer_applies' | 'bad_payload' | 'item_gone' }
@@ -206,9 +260,17 @@ async function decideProposal({ project, id, decision, decidedBy, via = null, wi
206
260
  await decide('applied');
207
261
  return { ok: true, status: 'applied', applied: true, item_id: Number(it.id) };
208
262
  }
263
+ if (p.kind === 'rescore' || p.kind === 'move_kind' || p.kind === 'add_source') {
264
+ const a = itemChange(p.kind, p.payload);
265
+ if (!a) return { ok: false, reason: 'bad_payload' };
266
+ const { rows: [it] } = await client.query(SQL[a.sql], [p.item_id, project, ...a.params]);
267
+ if (!it) return { ok: false, reason: p.kind === 'move_kind' ? 'no_longer_applies' : 'item_gone' };
268
+ await decide('applied');
269
+ return { ok: true, status: 'applied', applied: true, item_id: Number(p.item_id) };
270
+ }
209
271
  await decide('approved');
210
272
  return { ok: true, status: 'approved', applied: false };
211
273
  });
212
274
  }
213
275
 
214
- module.exports = { createProposal, decideProposal, getOpen, markAtVote, itemFromPayload, MAX_OPEN_PER_MEMBER };
276
+ module.exports = { createProposal, decideProposal, getOpen, markAtVote, itemFromPayload, itemChange, MAX_OPEN_PER_MEMBER };
@@ -0,0 +1,116 @@
1
+ // modules/market-watch/routes/pages.js — the reads and the one write behind the Market
2
+ // Watch home and tracked-item pages (task 1004939, goal 1000137).
3
+ //
4
+ // GET /market-watch/home the watchtower desk: scouts, streak, next sweep,
5
+ // budget, the landscape and this week's reports
6
+ // GET /market-watch/items/:id one tracked item: its card, sparklines and case file
7
+ // POST /market-watch/proposals a member proposes a change (re-score, move between
8
+ // partner and competitor, add a source, correct an
9
+ // entry, stop tracking, or a new scout)
10
+ //
11
+ // rank: any-builder — every route needs a signed-in builder, and every builder on this
12
+ // instance is a member of its project (each project is its own instance, ADR 0393). A
13
+ // visitor with no session gets a 401 and no data (tests/market_watch_pages.mjs). These
14
+ // routes mount only while the module is on; where it is off they do not exist, and the
15
+ // page shows its quiet off state instead.
16
+ //
17
+ // A proposal changes nothing it names. It is a row in market_watch_proposals for the
18
+ // government to decide on the rules page (task 1004941). Its text is untrusted member
19
+ // input: it is stored as typed, bounded to one line, and escaped wherever it is shown.
20
+
21
+ 'use strict';
22
+
23
+ const express = require('express');
24
+ const api = require('../../../src/module-api');
25
+ const pages = require('../pages');
26
+ const db = require('../db');
27
+ const { projectKey } = require('../seed');
28
+
29
+ const ID_RE = /^[1-9]\d{0,17}$/;
30
+
31
+ function currentProject() {
32
+ try { return projectKey(api.branding().domains.publicOrigin); } catch { return null; }
33
+ }
34
+
35
+ module.exports = function buildMarketWatchPagesRouter(deps = {}) {
36
+ const router = express.Router();
37
+ const pool = () => deps.pool || api.pool;
38
+ const project = () => (deps.project !== undefined ? deps.project : currentProject());
39
+
40
+ // The in-handler membership check: every active builder of this instance is a member of
41
+ // its project (ADR 0393), so a member is a signed-in builder with an id who is not
42
+ // offboarded. requireBuilder already refuses 'inactive'; this holds it per handler too.
43
+ const isMember = (req, res) => {
44
+ const b = req.builder;
45
+ if (!b || b.id == null || b.status === 'inactive') { res.fail('not_a_member', 403); return false; }
46
+ return true;
47
+ };
48
+
49
+ const needProject = (res) => {
50
+ const p = project();
51
+ if (!p) res.fail('no_project_origin', { status: 503, message: 'This project has no public address set, so Market Watch cannot tell its rows apart.' });
52
+ return p;
53
+ };
54
+
55
+ // rank: any-builder — every member reads the desk.
56
+ router.get('/market-watch/home', api.requireBuilder, api.asyncHandler('GET /market-watch/home', async (req, res) => {
57
+ if (!isMember(req, res)) return;
58
+ const p = needProject(res);
59
+ if (!p) return;
60
+ res.set('Cache-Control', 'no-store');
61
+ res.json(await pages.homeView(pool(), { project: p }));
62
+ }));
63
+
64
+ // rank: any-builder — every member reads an item's case file.
65
+ router.get('/market-watch/items/:id', api.requireBuilder, api.asyncHandler('GET /market-watch/items/:id', async (req, res) => {
66
+ if (!isMember(req, res)) return;
67
+ const id = String(req.params.id || '');
68
+ if (!ID_RE.test(id)) return res.fail('invalid_item_id', 400);
69
+ const p = needProject(res);
70
+ if (!p) return;
71
+ const view = await pages.itemView(pool(), { project: p, id: Number(id) });
72
+ if (!view) return res.fail('item_not_found', 404);
73
+ res.set('Cache-Control', 'no-store');
74
+ res.json(view);
75
+ }));
76
+
77
+ // rank: any-builder — every member may propose; the government decides (task 1004941).
78
+ router.post('/market-watch/proposals', api.requireBuilder, api.asyncHandler('POST /market-watch/proposals', async (req, res) => {
79
+ if (!isMember(req, res)) return;
80
+ // Strict: an unknown field is refused. The ids and the score arrive as a number or as
81
+ // the form's string, so they carry no type here; normalizeProposal checks each.
82
+ if (api.validateOrRespond(req, res, {
83
+ kind: { required: true, type: 'string', enum: pages.PROPOSAL_KINDS },
84
+ reason: { type: 'string', maxLength: 2000 },
85
+ item_id: {},
86
+ dimension: { type: 'string', maxLength: 60 },
87
+ value: {},
88
+ url: { type: 'string', maxLength: 2000 },
89
+ log_entry_id: {},
90
+ scout: { type: 'string', enum: pages.KINDS },
91
+ name: { type: 'string', maxLength: 2000 },
92
+ })) return;
93
+ const p = needProject(res);
94
+ if (!p) return;
95
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
96
+ let item = null;
97
+ let rubricKeys = null;
98
+ if (body.kind !== 'add_item') {
99
+ const id = String(body.item_id || '');
100
+ if (!ID_RE.test(id)) return res.fail('invalid_item_id', 400);
101
+ item = await db.getItem(pool(), { project: p, id: Number(id) });
102
+ if (!item || item.status === 'removed') return res.fail('item_not_found', 404);
103
+ if (body.kind === 'rescore') {
104
+ rubricKeys = new Set(Object.keys(pages.rubricLabels(await db.rules(pool(), { project: p }), item.kind)));
105
+ if (!rubricKeys.size) rubricKeys = null;
106
+ }
107
+ }
108
+ const n = pages.normalizeProposal(body, { item, rubricKeys });
109
+ if (!n.ok) return res.fail(n.error, { status: 400, message: n.message });
110
+ const row = await pages.insertProposal(pool(), { project: p, proposal: n.value, builderId: req.builder.id });
111
+ if (!row) return res.fail('item_not_found', 404); // the entry to correct is not this item's
112
+ res.status(201).json({ proposal: { id: row.id, kind: row.kind, status: row.status, created_at: row.created_at } });
113
+ }));
114
+
115
+ return router;
116
+ };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.10",
3
+ "version": "1.24.12",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.24.10",
9
+ "version": "1.24.12",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.10",
3
+ "version": "1.24.12",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -10895,5 +10895,17 @@
10895
10895
  "id": "1004938",
10896
10896
  "text": "The project owner now has a Market Watch settings page where they can switch it on or off, set the monthly AI spending cap and how often it runs, see this month's spend and recent runs, and paste the project's Anthropic key, w"
10897
10897
  }
10898
+ ],
10899
+ "1.24.11": [
10900
+ {
10901
+ "id": "1004990",
10902
+ "text": "A project can now say in its own words what a task pays, and set its standing pay terms on the settings page with a record of who changed them; builders see the pay on the task page and in their task list before claiming, and"
10903
+ }
10904
+ ],
10905
+ "1.24.12": [
10906
+ {
10907
+ "id": "1004939",
10908
+ "text": "Every member of a project can now open Market Watch to see its scouts on competitors, partners and topics, the landscape chart, the next sweep, the budget left and this week's reports, and open any tracked item's card and case"
10909
+ }
10898
10910
  ]
10899
10911
  }
@@ -82,6 +82,7 @@ const REQUESTS = Object.freeze({
82
82
  'PATCH /project-settings/rot-days': ['PATCH', '/project-settings/rot-days', { days: 'soon' }],
83
83
  'PATCH /project-settings/ship-mode': ['PATCH', '/project-settings/ship-mode', { mode: 'sometimes' }],
84
84
  'PATCH /project-settings/tweak-autopickup': ['PATCH', '/project-settings/tweak-autopickup', { mode: 'sometimes' }],
85
+ 'PATCH /project-settings/pay-terms': ['PATCH', '/project-settings/pay-terms', { terms: 'not a list' }],
85
86
  'PATCH /project-settings/spend-cap': ['PATCH', '/project-settings/spend-cap', { usd: 'lots' }],
86
87
  'PATCH /work-categories/:id': ['PATCH', `/work-categories/${BAD}`, {}],
87
88
  'POST /market-watch/proposals/:id/decide': ['POST', `/market-watch/proposals/${BAD}/decide`, { decision: 'approve' }],
@@ -262,6 +262,10 @@ const INTEGRATION = new Set([
262
262
  // task 1004914 (ADR 0385 D8): the payout gate tables from the real migration —
263
263
  // append-only, no silent hold, who may appeal, decide and co-sign, the gate end to end.
264
264
  'payout_gates_db',
265
+ // task 1004990 (ADR 0385 D3): pay terms from the real migration — the history
266
+ // cannot be edited, deleted or truncated; unknown types and blank amounts are
267
+ // refused; the reads' SQL resolves a task's own set over the project's.
268
+ 'pay_terms_db',
265
269
  // task 1004988 (ADR 0385 D2): credit_log from the real migration — Postgres
266
270
  // refuses a spend, redemption or transfer row; only a reversal of an existing
267
271
  // row may be negative, and core_194's clawback still books.