@bongos/core 1.24.7 → 1.24.9

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 (57) hide show
  1. package/.bongos-core.json +115 -50
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +13 -1
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +1 -1
  8. package/docs/api/openapi.json +267 -7
  9. package/docs/api-reference.md +7 -3
  10. package/docs/architecture.md +2 -2
  11. package/docs/copy-inventory.md +38 -13
  12. package/docs/copy-registry.json +271 -15
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/modules-contract.md +3 -3
  15. package/docs/page-inventory.json +40 -4
  16. package/docs/page-readings.json +64 -4
  17. package/migrations/core_287_module_prices_cash_only.sql +24 -0
  18. package/modules/government/decision-board.js +5 -0
  19. package/modules/government/decision-rules.js +3 -0
  20. package/modules/government/routes/government.js +2 -0
  21. package/modules/hall-ui/public/market-watch-rules-lib.js +207 -0
  22. package/modules/hall-ui/public/market-watch-rules.css +68 -0
  23. package/modules/hall-ui/public/market-watch-rules.html +54 -0
  24. package/modules/hall-ui/public/market-watch-rules.js +129 -0
  25. package/modules/hall-ui/public/market-watch-rules.states.json +17 -0
  26. package/modules/hall-ui/public/modules.js +5 -0
  27. package/modules/market-watch/matter-acts.js +59 -0
  28. package/modules/market-watch/module.json +5 -4
  29. package/modules/market-watch/proposals.js +214 -0
  30. package/modules/market-watch/research.js +49 -8
  31. package/modules/market-watch/routes/rules.js +202 -0
  32. package/modules/market-watch/rules-logic.js +264 -0
  33. package/modules/market-watch/rules-view.js +165 -0
  34. package/package-lock.json +2 -2
  35. package/package.json +1 -1
  36. package/release-notes.json +12 -0
  37. package/scripts/bongos/government-walk.js +18 -2
  38. package/scripts/bongos/module-store-cli.js +21 -21
  39. package/scripts/bongos/module.js +8 -8
  40. package/scripts/bongos/run-unit-tests.js +3 -0
  41. package/src/bongos/module-listing.js +47 -20
  42. package/src/bongos/module-scope-map.js +2 -0
  43. package/src/bongos/module-store.js +20 -12
  44. package/src/bongos/routes/modules.js +31 -21
  45. package/src/bongos/serve-internal.js +2 -1
  46. package/src/module-api.js +1 -1
  47. package/tests/government_act_alone_buildings.mjs +3 -0
  48. package/tests/government_decision_rules.mjs +4 -1
  49. package/tests/governor_matter_acts.mjs +5 -1
  50. package/tests/hall_page_gate_map.mjs +3 -0
  51. package/tests/market_watch_module.mjs +1 -0
  52. package/tests/market_watch_rules.mjs +496 -0
  53. package/tests/market_watch_rules_db.mjs +133 -0
  54. package/tests/module_store_cli.mjs +14 -3
  55. package/tests/module_store_listing.mjs +65 -14
  56. package/tests/module_store_price.mjs +97 -26
  57. package/tests/module_store_registry_migration.mjs +11 -0
@@ -0,0 +1,202 @@
1
+ 'use strict';
2
+
3
+ // modules/market-watch/routes/rules.js — the rules page's server half (task 1004941,
4
+ // goal 1000137): read the watch rules in plain words, propose a change, decide it.
5
+ //
6
+ // GET /market-watch/rules any signed-in member: the page model
7
+ // (rules-view.js), plus who may decide
8
+ // POST /market-watch/rules/proposals any signed-in member: file a change to one
9
+ // step, with a reason (rules-logic.checkChange)
10
+ // POST /market-watch/proposals/:id/decide the PROJECT OWNER only (the founding builder,
11
+ // the doorway's getFoundingBuilder), checked
12
+ // here on the server: approve or decline ANY
13
+ // open proposal, including the item page's and
14
+ // the weekly ask's (tasks 1004939, 1004940)
15
+ // POST /market-watch/proposals/:id/vote put an open proposal to the government's vote
16
+ //
17
+ // WHO DECIDES. With no government module, or a government whose Rules building is
18
+ // decided as today, the owner approves or declines alone. Where the project's decision
19
+ // table hands Rules to a sitting, the decide route's act-alone gate (the doorway's,
20
+ // naming the Rules building) refuses one person acting alone (409 decided_by_sitting) and the proposal goes to a vote: the
21
+ // vote route files a Rules matter through the government PORT (resolved optionally, so a
22
+ // project without a government still works), and the passed matter approves it through
23
+ // matter-acts.js. The government's own wall for opening a matter, the board.item.open
24
+ // atom, gates the vote route.
25
+ //
26
+ // Member text (reasons, names, queries) is stored as typed and escaped where it is shown
27
+ // (the hall page); it reaches the weekly run's prompt only fenced as untrusted data
28
+ // (research.js).
29
+
30
+ const express = require('express');
31
+ const api = require('../../../src/module-api');
32
+ const proposals = require('../proposals');
33
+ const view = require('../rules-view');
34
+ const research = require('../research');
35
+ const { projectKey } = require('../seed');
36
+
37
+ const log = api.logger('market-watch');
38
+
39
+ // Seams the test replaces: the project key, the owner, the transaction runner.
40
+ const deps = {
41
+ project: () => projectKey(api.branding().domains.publicOrigin),
42
+ owner: () => api.getFoundingBuilder(),
43
+ pool: () => api.pool,
44
+ withTx: (fn) => api.withTx(fn, { pool: api.pool }),
45
+ government: () => (typeof api.resolveOptional === 'function' ? api.resolveOptional('government') : null),
46
+ };
47
+
48
+ async function isOwner(builderId) {
49
+ const owner = await deps.owner();
50
+ return !!owner && builderId != null && String(owner.id) === String(builderId);
51
+ }
52
+
53
+ // How decisions are made right now, for this viewer. Read per request, never cached.
54
+ async function decideMode(viewerId) {
55
+ const owner = await isOwner(viewerId).catch(() => false);
56
+ const gov = deps.government();
57
+ if (!gov || typeof gov.checkActAlone !== 'function') {
58
+ return { mode: 'owner', viewer_is_owner: owner, viewer_may_decide: owner, may_put_to_vote: false };
59
+ }
60
+ try {
61
+ const out = await gov.checkActAlone('rules', viewerId);
62
+ if (out && out.ok === true) {
63
+ return { mode: 'owner', viewer_is_owner: owner, viewer_may_decide: owner, may_put_to_vote: false };
64
+ }
65
+ return {
66
+ mode: out && out.open_with ? 'vote' : 'vacant',
67
+ viewer_is_owner: owner,
68
+ viewer_may_decide: false,
69
+ may_put_to_vote: !!(out && out.open_with) && typeof gov.openMatter === 'function',
70
+ who: (out && out.who) || null,
71
+ what_this_means: (out && out.what_this_means) || null,
72
+ };
73
+ } catch (err) {
74
+ log.warn(`[market-watch] who decides at rules could not be read: ${err && err.message}`);
75
+ return { mode: 'unknown', viewer_is_owner: owner, viewer_may_decide: false, may_put_to_vote: false };
76
+ }
77
+ }
78
+
79
+ function noProject(res) {
80
+ return res.fail('no_project', { status: 503, message: 'this project has no public address set, so its watch has no key' });
81
+ }
82
+
83
+ const DECIDE_REFUSALS = Object.freeze({
84
+ not_found: [404, 'There is no such proposal on this project.'],
85
+ not_open: [409, 'That proposal was already decided.'],
86
+ no_active_rules: [409, 'This project has no rules in use yet.'],
87
+ no_longer_applies: [409, 'The rules changed since this was proposed, so it no longer fits. Decline it and propose again.'],
88
+ bad_payload: [409, 'This proposal does not name anything to add.'],
89
+ item_gone: [409, 'That item is no longer on this project.'],
90
+ });
91
+
92
+ module.exports = function buildMarketWatchRulesRouter() {
93
+ require('../matter-acts').contributeMarketWatchMatterActs();
94
+ const router = express.Router();
95
+
96
+ // rank: any-builder — every member reads the rules (the task); the read grants nothing.
97
+ router.get('/market-watch/rules', api.requireBuilder, async (req, res) => {
98
+ const project = deps.project();
99
+ if (!project) return noProject(res);
100
+ try {
101
+ const decide = await decideMode(req.builder.id);
102
+ const out = await view.readView(deps.pool(), { project, decide, pricing: api.llmPricing, model: research.MODEL });
103
+ res.json(out);
104
+ } catch (err) {
105
+ log.error('[market-watch] GET /market-watch/rules', err);
106
+ res.fail('rules_unreadable', { status: 503, message: 'could not read the watch rules' });
107
+ }
108
+ });
109
+
110
+ // Any signed-in member may propose (the task: "every member can read the page and can
111
+ // propose"). The change's own fields are checked in rules-logic.checkChange.
112
+ // rank: any-builder — any member may propose; filing decides nothing.
113
+ router.post('/market-watch/rules/proposals', api.requireBuilder, async (req, res) => {
114
+ if (api.validateOrRespond(req, res, {
115
+ step: { required: true, type: 'string', maxLength: 16 },
116
+ change: { required: true, type: 'object' },
117
+ reason: { required: true, type: 'string', maxLength: 500 },
118
+ })) return;
119
+ const project = deps.project();
120
+ if (!project) return noProject(res);
121
+ try {
122
+ const out = await proposals.createProposal(deps.pool(), {
123
+ project, step: req.body.step, change: req.body.change, reason: req.body.reason, proposedBy: req.builder.id,
124
+ });
125
+ if (!out.ok) {
126
+ const status = out.reason === 'too_many_open' ? 429
127
+ : (out.reason === 'no_active_rules' || out.reason === 'already_proposed') ? 409 : 400;
128
+ return res.fail(out.reason, status, out.field ? { field: out.field } : undefined);
129
+ }
130
+ res.status(201).json({ ok: true, proposal: out.proposal });
131
+ } catch (err) {
132
+ log.error('[market-watch] POST /market-watch/rules/proposals', err);
133
+ res.fail('proposal_failed', { status: 500, message: 'could not file the proposal' });
134
+ }
135
+ });
136
+
137
+ // THE OWNER'S DECISION. The Rules act-alone gate first, so a project whose government
138
+ // hands Rules to a sitting refuses this before anything else (the vote decides there);
139
+ // then the owner check, server-side, per request: anyone else gets 403.
140
+ // rank: any-builder — signed in to reach it; the owner check below is the wall (403).
141
+ router.post('/market-watch/proposals/:id/decide', api.requireBuilder, api.requireActAlone('rules'), async (req, res) => {
142
+ const id = api.parseId(req, res, { positiveInt: true });
143
+ if (id == null) return;
144
+ if (api.validateOrRespond(req, res, { decision: { required: true, type: 'string', enum: ['approve', 'decline'] } })) return;
145
+ const project = deps.project();
146
+ if (!project) return noProject(res);
147
+ try {
148
+ if (!(await isOwner(req.builder.id))) {
149
+ return res.fail('owner_only', { status: 403, message: 'Only the project owner approves or declines proposals.' });
150
+ }
151
+ const out = await proposals.decideProposal({ project, id, decision: req.body.decision, decidedBy: req.builder.id, withTx: deps.withTx });
152
+ if (!out.ok) {
153
+ const [status, message] = DECIDE_REFUSALS[out.reason] || [409, 'That proposal could not be decided.'];
154
+ return res.fail(out.reason, { status, message });
155
+ }
156
+ res.json(out);
157
+ } catch (err) {
158
+ log.error('[market-watch] POST /market-watch/proposals/:id/decide', err);
159
+ res.fail('decide_failed', { status: 500, message: 'could not decide the proposal' });
160
+ }
161
+ });
162
+
163
+ // PUT TO A VOTE. Only where the government hands Rules to a sitting; the matter's act
164
+ // (matter-acts.js) approves the proposal if it passes.
165
+ router.post('/market-watch/proposals/:id/vote', api.requireBuilder, api.requirePermission('board.item.open'), async (req, res) => {
166
+ const id = api.parseId(req, res, { positiveInt: true });
167
+ if (id == null) return;
168
+ const project = deps.project();
169
+ if (!project) return noProject(res);
170
+ const gov = deps.government();
171
+ if (!gov || typeof gov.openMatter !== 'function') {
172
+ return res.fail('no_government', { status: 409, message: 'This project has no government to vote on it; the owner decides.' });
173
+ }
174
+ try {
175
+ const p = await proposals.getOpen(deps.pool(), { project, id });
176
+ if (!p) return res.fail('not_open', { status: 409, message: 'That proposal is not open.' });
177
+ if (p.payload && p.payload.matter_id) return res.fail('already_at_vote', { status: 409, message: 'That proposal is already at a vote.' });
178
+ const summary = String((p.payload && p.payload.summary) || p.kind).slice(0, 150);
179
+ const out = await gov.openMatter({
180
+ area: 'rules',
181
+ title: `Market Watch: ${summary}`,
182
+ // the reason is a member's words; the matter shows it as their proposal, quoted
183
+ detailMd: `A member proposed this change to the Market Watch rules.\n\n> ${String(p.reason || '').replace(/\s+/g, ' ').slice(0, 500)}`,
184
+ action: { type: 'market_watch_proposal', proposal_id: id },
185
+ proposedBy: req.builder.id,
186
+ });
187
+ if (!out || !out.ok) {
188
+ return res.fail((out && out.reason) || 'matter_refused', { status: 409, message: (out && out.what_this_means) || 'The government did not take the vote.' });
189
+ }
190
+ await proposals.markAtVote(deps.pool(), { project, id, matterId: out.matter && out.matter.id });
191
+ res.status(201).json({ ok: true, matter_id: out.matter ? String(out.matter.id) : null, item_id: out.item ? String(out.item.id) : null });
192
+ } catch (err) {
193
+ log.error('[market-watch] POST /market-watch/proposals/:id/vote', err);
194
+ res.fail('vote_failed', { status: 500, message: 'could not put the proposal to a vote' });
195
+ }
196
+ });
197
+
198
+ return router;
199
+ };
200
+
201
+ module.exports.deps = deps;
202
+ module.exports.decideMode = decideMode;
@@ -0,0 +1,264 @@
1
+ 'use strict';
2
+
3
+ // modules/market-watch/rules-logic.js — the rules page's arithmetic, with no database
4
+ // (task 1004941, goal 1000137). The page is the pipeline map of
5
+ // docs/design/mocks/market-watch/rules.html (option C): searches, then sources, then
6
+ // names, then scoring, then the brief. Everything the page shows about a rules version,
7
+ // and every change a member may propose to one, is decided here, so the route, the
8
+ // approval and the tests all read one definition.
9
+ //
10
+ // A CHANGE IS A SMALL, CLOSED DELTA, never a whole new rules object. A member proposes
11
+ // one step's change ("add this search", "count this score double", "rewrite the brief"),
12
+ // with a reason. Approving it applies the delta to the version IN USE at that moment, so
13
+ // a proposal made against v6 and approved after v7 still lands on v7 and loses nothing
14
+ // v7 added. A delta that no longer applies (the search it removes is already gone) is
15
+ // refused at approval with `no_longer_applies`, never applied half-way.
16
+ //
17
+ // searches { op: 'add' | 'remove', text }
18
+ // sources { op: 'add' | 'remove', text }
19
+ // scoring { op: 'weight', kind, key, weight } weight 0..3 (0 = not counted)
20
+ // { op: 'define', kind, key, definition } what a 0 and a 10 mean
21
+ // brief { op: 'set', text } the AI instructions
22
+ // names { op: 'add', name, url, kind, why } → an add_item proposal
23
+ // { op: 'remove', item_id } → a remove_item proposal
24
+ //
25
+ // The names step changes WHO is watched, not the rules, so its two ops become item
26
+ // proposals (the kinds the item page and the weekly ask also file) rather than rules
27
+ // versions. Every other step's change becomes, once approved, a new rules version.
28
+
29
+ const STEPS = Object.freeze([
30
+ Object.freeze({ key: 'searches', label: 'searches', title: 'what we search for' }),
31
+ Object.freeze({ key: 'sources', label: 'sources', title: 'what we read' }),
32
+ Object.freeze({ key: 'names', label: 'names', title: 'who we watch' }),
33
+ Object.freeze({ key: 'scoring', label: 'scoring', title: 'how we score' }),
34
+ Object.freeze({ key: 'brief', label: 'brief', title: 'what we ask the AI to write' }),
35
+ ]);
36
+ const STEP_KEYS = Object.freeze(STEPS.map((s) => s.key));
37
+ const RULE_STEPS = Object.freeze(['searches', 'sources', 'scoring', 'brief']);
38
+ const ITEM_KINDS = Object.freeze(['competitor', 'partner', 'topic']);
39
+
40
+ const LIMITS = Object.freeze({ text: 300, brief: 4000, name: 200, url: 500, why: 500, reason: 500, maxWeight: 3, maxList: 40 });
41
+
42
+ const clean = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
43
+ const same = (a, b) => clean(a).toLowerCase() === clean(b).toLowerCase();
44
+ const isHttp = (u) => /^https?:\/\/[^\s<>"']+$/i.test(u);
45
+ const fail = (reason, field) => ({ ok: false, reason, ...(field ? { field } : {}) });
46
+
47
+ // The rules fields a version carries, normalised (a row from the database, or a
48
+ // version built here).
49
+ function fieldsOf(v) {
50
+ const r = v || {};
51
+ return {
52
+ search_queries: Array.isArray(r.search_queries) ? r.search_queries.map(String) : [],
53
+ sources: Array.isArray(r.sources) ? r.sources.map(String) : [],
54
+ rubric: r.rubric && typeof r.rubric === 'object' ? JSON.parse(JSON.stringify(r.rubric)) : {},
55
+ instructions: typeof r.instructions === 'string' ? r.instructions : '',
56
+ };
57
+ }
58
+
59
+ // checkReason(text) → { ok, reason } — every proposal says why, in one line.
60
+ function checkReason(text) {
61
+ const r = clean(text);
62
+ if (!r) return fail('reason_required', 'reason');
63
+ if (r.length > LIMITS.reason) return fail('reason_too_long', 'reason');
64
+ return { ok: true, reason: r };
65
+ }
66
+
67
+ // checkChange(step, change, rules) → { ok: true, step, change, kind } | { ok: false, reason, field }
68
+ // `kind` is the proposal kind it files as: rule_change, add_item or remove_item. The
69
+ // change is rebuilt from known fields only, so nothing a caller adds rides along.
70
+ function checkChange(step, change, rules) {
71
+ if (!STEP_KEYS.includes(step)) return fail('unknown_step', 'step');
72
+ const c = change && typeof change === 'object' && !Array.isArray(change) ? change : null;
73
+ if (!c) return fail('bad_change', 'change');
74
+ const f = fieldsOf(rules);
75
+ if (step === 'searches' || step === 'sources') {
76
+ const list = step === 'searches' ? f.search_queries : f.sources;
77
+ const text = clean(c.text);
78
+ if (!text) return fail('text_required', 'change.text');
79
+ if (text.length > LIMITS.text) return fail('text_too_long', 'change.text');
80
+ if (c.op === 'add') {
81
+ if (list.some((x) => same(x, text))) return fail('already_there', 'change.text');
82
+ if (list.length >= LIMITS.maxList) return fail('list_full', 'change.text');
83
+ } else if (c.op === 'remove') {
84
+ if (!list.some((x) => same(x, text))) return fail('not_there', 'change.text');
85
+ } else return fail('bad_op', 'change.op');
86
+ return { ok: true, step, kind: 'rule_change', change: { op: c.op, text } };
87
+ }
88
+ if (step === 'scoring') {
89
+ const dims = Array.isArray(f.rubric[c.kind]) ? f.rubric[c.kind] : null;
90
+ if (!dims) return fail('unknown_kind', 'change.kind');
91
+ const dim = dims.find((d) => d && d.key === c.key);
92
+ if (!dim) return fail('unknown_dimension', 'change.key');
93
+ if (c.op === 'weight') {
94
+ const w = Number(c.weight);
95
+ if (!Number.isInteger(w) || w < 0 || w > LIMITS.maxWeight) return fail('bad_weight', 'change.weight');
96
+ if (w === weightOf(dim)) return fail('no_change', 'change.weight');
97
+ return { ok: true, step, kind: 'rule_change', change: { op: 'weight', kind: c.kind, key: c.key, weight: w } };
98
+ }
99
+ if (c.op === 'define') {
100
+ const d = clean(c.definition);
101
+ if (!d) return fail('text_required', 'change.definition');
102
+ if (d.length > LIMITS.text) return fail('text_too_long', 'change.definition');
103
+ if (same(d, dim.definition)) return fail('no_change', 'change.definition');
104
+ return { ok: true, step, kind: 'rule_change', change: { op: 'define', kind: c.kind, key: c.key, definition: d } };
105
+ }
106
+ return fail('bad_op', 'change.op');
107
+ }
108
+ if (step === 'brief') {
109
+ if (c.op !== 'set') return fail('bad_op', 'change.op');
110
+ const text = String(c.text == null ? '' : c.text).trim();
111
+ if (!text) return fail('text_required', 'change.text');
112
+ if (text.length > LIMITS.brief) return fail('text_too_long', 'change.text');
113
+ if (same(text, f.instructions)) return fail('no_change', 'change.text');
114
+ return { ok: true, step, kind: 'rule_change', change: { op: 'set', text } };
115
+ }
116
+ // names
117
+ if (c.op === 'add') {
118
+ const name = clean(c.name);
119
+ if (!name) return fail('name_required', 'change.name');
120
+ if (name.length > LIMITS.name) return fail('name_too_long', 'change.name');
121
+ const kind = ITEM_KINDS.includes(c.kind) ? c.kind : null;
122
+ if (!kind) return fail('unknown_kind', 'change.kind');
123
+ const url = clean(c.url);
124
+ if (url && (url.length > LIMITS.url || !isHttp(url))) return fail('bad_url', 'change.url');
125
+ if (!url && kind !== 'topic') return fail('url_required', 'change.url');
126
+ const why = clean(c.why).slice(0, LIMITS.why);
127
+ return { ok: true, step, kind: 'add_item', change: { op: 'add', name, url, kind, why } };
128
+ }
129
+ if (c.op === 'remove') {
130
+ const id = Number(c.item_id);
131
+ if (!Number.isSafeInteger(id) || id <= 0) return fail('bad_item', 'change.item_id');
132
+ return { ok: true, step, kind: 'remove_item', change: { op: 'remove', item_id: id } };
133
+ }
134
+ return fail('bad_op', 'change.op');
135
+ }
136
+
137
+ const weightOf = (d) => (d && Number.isInteger(d.weight) ? d.weight : 1);
138
+
139
+ // applyChange(rules, step, change) → { ok: true, fields } | { ok: false, reason }.
140
+ // Re-checks the change against THESE rules first (they may be newer than the ones it was
141
+ // proposed against), so an approval never writes a version the change does not fit.
142
+ function applyChange(rules, step, change) {
143
+ if (!RULE_STEPS.includes(step)) return fail('not_a_rule_change');
144
+ const check = checkChange(step, change, rules);
145
+ if (!check.ok) return { ok: false, reason: 'no_longer_applies', why: check.reason };
146
+ const f = fieldsOf(rules);
147
+ const c = check.change;
148
+ if (step === 'searches' || step === 'sources') {
149
+ const field = step === 'searches' ? 'search_queries' : 'sources';
150
+ f[field] = c.op === 'add' ? [...f[field], c.text] : f[field].filter((x) => !same(x, c.text));
151
+ } else if (step === 'scoring') {
152
+ f.rubric[c.kind] = f.rubric[c.kind].map((d) => (d.key !== c.key ? d
153
+ : (c.op === 'weight' ? { ...d, weight: c.weight } : { ...d, definition: c.definition })));
154
+ } else {
155
+ f.instructions = c.text;
156
+ }
157
+ return { ok: true, fields: f };
158
+ }
159
+
160
+ const short = (s, n = 48) => { const t = clean(s); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
161
+ function dimLabel(rules, kind, key) {
162
+ const d = (fieldsOf(rules).rubric[kind] || []).find((x) => x.key === key);
163
+ return d ? (d.label || d.key) : key;
164
+ }
165
+
166
+ // summarize(step, change, rules) → the slider's one line ("+ GitHub releases as a source").
167
+ function summarize(step, change, rules) {
168
+ const c = change || {};
169
+ switch (step) {
170
+ case 'searches': return c.op === 'add' ? `+ "${short(c.text)}" as a search` : `dropped the "${short(c.text)}" search`;
171
+ case 'sources': return c.op === 'add' ? `+ ${short(c.text)} as a source` : `stopped reading ${short(c.text)}`;
172
+ case 'scoring': return c.op === 'weight'
173
+ ? `"${dimLabel(rules, c.kind, c.key)}" counts ×${c.weight}`
174
+ : `"${dimLabel(rules, c.kind, c.key)}" redefined`;
175
+ case 'brief': return 'brief rewritten';
176
+ case 'names': return c.op === 'add' ? `+ ${short(c.name)} watched` : 'stop watching a name';
177
+ default: return 'rules changed';
178
+ }
179
+ }
180
+
181
+ // changedSteps(prev, next) → the steps whose rules differ between two versions: the
182
+ // slider marks those nodes when it lands on `next`.
183
+ function changedSteps(prev, next) {
184
+ if (!prev) return [];
185
+ const a = fieldsOf(prev);
186
+ const b = fieldsOf(next);
187
+ const out = [];
188
+ if (JSON.stringify(a.search_queries) !== JSON.stringify(b.search_queries)) out.push('searches');
189
+ if (JSON.stringify(a.sources) !== JSON.stringify(b.sources)) out.push('sources');
190
+ if (JSON.stringify(a.rubric) !== JSON.stringify(b.rubric)) out.push('scoring');
191
+ if (a.instructions !== b.instructions) out.push('brief');
192
+ return out;
193
+ }
194
+
195
+ // proposedNext(active, openRuleProposals) → the slider's "v8?": the version in use with
196
+ // every open rule change applied in the order they were made. A change that would not
197
+ // apply on top of the others is left out and counted, so the preview never lies about
198
+ // what approving all of them would give.
199
+ function proposedNext(active, open) {
200
+ if (!active) return null;
201
+ let fields = fieldsOf(active);
202
+ let applied = 0;
203
+ let skipped = 0;
204
+ const list = [...(open || [])].sort((x, y) => Number(x.id) - Number(y.id));
205
+ for (const p of list) {
206
+ const payload = p.payload || {};
207
+ const out = applyChange(fields, payload.step, payload.change);
208
+ if (out.ok) { fields = out.fields; applied += 1; } else skipped += 1;
209
+ }
210
+ if (!applied) return null;
211
+ return { version: Number(active.version) + 1, ...fields, applied, skipped, changed: changedSteps(active, fields) };
212
+ }
213
+
214
+ // ---- the cost of one run, by step -------------------------------------------------
215
+ //
216
+ // The weekly run makes ONE Messages API call per tracked item (research.js): the
217
+ // searches, the page reads, the scoring and the written lines all happen in that call,
218
+ // and the API bills them as searches plus tokens in and out. So no stored number says
219
+ // what each step cost. The SPLIT is always an estimate, from ADR 0394's per-item figure
220
+ // (2 searches; about 25k tokens of search and page content read; about 2k tokens out,
221
+ // shared here evenly between the scores with their reasons and the written takeaways).
222
+ // The TOTAL is real when a run has finished with a cost (market_watch_run_items, the
223
+ // accounting the cap sums, and its cost_log mirror), and the page says which.
224
+ const EXPECTED_PER_ITEM = Object.freeze({ searches: 2, inputTokens: 25000, outputTokens: 2000 });
225
+
226
+ function estimatePerItem(pricing, model) {
227
+ const searches = pricing.priceWebSearches(EXPECTED_PER_ITEM.searches);
228
+ const reading = pricing.priceUsage(model, { input_tokens: EXPECTED_PER_ITEM.inputTokens }).usd;
229
+ const output = pricing.priceUsage(model, { output_tokens: EXPECTED_PER_ITEM.outputTokens }).usd;
230
+ return { read: searches + reading, score: output / 2, brief: output / 2 };
231
+ }
232
+
233
+ const cents = (n) => Math.round(n * 100) / 100;
234
+
235
+ // costByStep({ items, lastRun, pricing, model }) →
236
+ // { basis: 'run' | 'estimate', total, run: {n, items, cost_log_usd} | null,
237
+ // rows: [{ step, label, usd }], split: 'estimate' }
238
+ // lastRun: { n, items, cost_usd, cost_log_usd } of the newest run that ran with a cost.
239
+ function costByStep({ items, lastRun = null, pricing, model }) {
240
+ const per = estimatePerItem(pricing, model);
241
+ const perTotal = per.read + per.score + per.brief;
242
+ const count = Math.max(0, Number(items) || 0);
243
+ const real = lastRun && Number(lastRun.cost_usd) > 0 ? lastRun : null;
244
+ const scoredNames = real ? Number(real.items) || count : count;
245
+ const total = real ? Number(real.cost_usd) : perTotal * count;
246
+ const share = (part) => (perTotal > 0 ? total * (part / perTotal) : 0);
247
+ return {
248
+ basis: real ? 'run' : 'estimate',
249
+ split: 'estimate',
250
+ total: cents(total),
251
+ run: real ? { n: real.n, items: scoredNames, cost_log_usd: real.cost_log_usd == null ? null : cents(Number(real.cost_log_usd)) } : null,
252
+ rows: [
253
+ { step: 'read', label: 'searches + reading pages', usd: cents(share(per.read)) },
254
+ { step: 'score', label: `scoring ${scoredNames} names`, usd: cents(share(per.score)) },
255
+ { step: 'brief', label: 'writing the brief', usd: cents(share(per.brief)) },
256
+ ],
257
+ };
258
+ }
259
+
260
+ module.exports = {
261
+ STEPS, STEP_KEYS, RULE_STEPS, ITEM_KINDS,
262
+ checkReason, checkChange, applyChange, summarize, changedSteps, proposedNext,
263
+ costByStep, fieldsOf, weightOf,
264
+ };
@@ -0,0 +1,165 @@
1
+ 'use strict';
2
+
3
+ // modules/market-watch/rules-view.js — everything the rules page shows, in one read
4
+ // (task 1004941, goal 1000137). The page is the pipeline map
5
+ // (docs/design/mocks/market-watch/rules.html, option C): five steps with their counts,
6
+ // each step's rules in plain words, the open proposals on each step, the version slider
7
+ // (v1 to the version in use, plus the proposed next one) and the cost of one run by step.
8
+ //
9
+ // The view is built by the PURE `buildView` from rows the route reads, so the test can
10
+ // hand it rows and read the page model without a database. Who may decide is passed in
11
+ // (the route asks the owner check and the government port), never worked out here.
12
+
13
+ const logic = require('./rules-logic');
14
+
15
+ const DAY_MS = 24 * 60 * 60 * 1000;
16
+
17
+ const LAST_RUN_COST_SQL = `-- mw:last-run-cost
18
+ SELECT r.id, r.created_at,
19
+ COALESCE(sum(ri.cost_usd), 0) AS cost_usd,
20
+ (count(ri.item_id) FILTER (WHERE ri.status = 'done'))::int AS items,
21
+ (SELECT sum(c.amount_usd) FROM cost_log c WHERE c.source = 'market-watch:' || r.id) AS cost_log_usd
22
+ FROM market_watch_runs r
23
+ JOIN market_watch_run_items ri ON ri.run_id = r.id
24
+ WHERE r.project = $1 AND r.status IN ('done', 'partial') AND r.trigger <> 'seed'
25
+ GROUP BY r.id
26
+ HAVING COALESCE(sum(ri.cost_usd), 0) > 0
27
+ ORDER BY r.created_at DESC, r.id DESC
28
+ LIMIT 1`;
29
+
30
+ const NAMES_SQL = `-- mw:builder-names
31
+ SELECT id, github_login, display_name FROM builders WHERE id = ANY($1::bigint[])`;
32
+
33
+ // One person, as the page shows them: a login, never an email or an id alone.
34
+ const who = (names, id) => {
35
+ if (id == null) return null;
36
+ const b = names.get(String(id));
37
+ return b ? (b.display_name || b.github_login || `builder ${id}`) : `builder ${id}`;
38
+ };
39
+
40
+ // The step a proposal is shown on. A rule change names its own; every item proposal
41
+ // (added or removed here, or the item page's and weekly ask's kinds) is about who we
42
+ // watch, so the owner decides all of them on the names step.
43
+ function stepOf(p) {
44
+ if (p.kind === 'rule_change') return (p.payload && p.payload.step) || null;
45
+ return 'names';
46
+ }
47
+
48
+ // The words a step's panel shows: the rules themselves, in plain words.
49
+ function stepRules(step, fields, items) {
50
+ switch (step) {
51
+ case 'searches': return { list: fields.search_queries };
52
+ case 'sources': return { list: fields.sources };
53
+ case 'names': return {
54
+ top: items.filter((i) => i.status === 'active')
55
+ .slice().sort((a, b) => (b.overall ?? -1) - (a.overall ?? -1) || a.name.localeCompare(b.name))
56
+ .slice(0, 6)
57
+ .map((i) => ({ id: i.id, name: i.name, kind: i.kind, overall: i.overall })),
58
+ };
59
+ case 'scoring': return {
60
+ kinds: Object.keys(fields.rubric).sort().map((kind) => ({
61
+ kind,
62
+ dims: (fields.rubric[kind] || []).map((d) => ({ key: d.key, label: d.label || d.key, definition: d.definition || '', weight: logic.weightOf(d) })),
63
+ })),
64
+ };
65
+ case 'brief': return { text: fields.instructions };
66
+ default: return {};
67
+ }
68
+ }
69
+
70
+ // buildView({ versions, items, proposals, lastRun, names, decide, now, pricing, model })
71
+ function buildView({ versions = [], items = [], proposals = [], lastRun = null, names = new Map(), decide = {}, now = new Date(), pricing, model }) {
72
+ const ordered = versions.filter((v) => v.status === 'active' || v.status === 'superseded')
73
+ .slice().sort((a, b) => a.version - b.version);
74
+ const active = ordered.find((v) => v.status === 'active') || null;
75
+ const fields = logic.fieldsOf(active);
76
+ const live = items.filter((i) => i.status === 'active');
77
+ const weekAgo = new Date(new Date(now).getTime() - 7 * DAY_MS);
78
+ const openRule = proposals.filter((p) => p.status === 'open' && p.kind === 'rule_change');
79
+ const next = logic.proposedNext(active, openRule);
80
+
81
+ const open = proposals.filter((p) => p.status === 'open').map((p) => ({
82
+ id: p.id,
83
+ kind: p.kind,
84
+ origin: p.origin,
85
+ step: stepOf(p),
86
+ summary: (p.payload && p.payload.summary) || logic.summarize(stepOf(p), p.payload && p.payload.change, active) || p.kind,
87
+ reason: p.reason || '',
88
+ by: who(names, p.proposed_by),
89
+ created_at: p.created_at,
90
+ change: p.payload && p.payload.change ? p.payload.change : null,
91
+ at_vote: !!(p.payload && p.payload.matter_id),
92
+ name: p.kind === 'add_item' && p.payload ? (p.payload.name || (p.payload.change && p.payload.change.name) || null) : null,
93
+ }));
94
+
95
+ const countBy = (step) => open.filter((p) => p.step === step).length;
96
+ const dims = Object.values(fields.rubric).reduce((n, d) => Math.max(n, Array.isArray(d) ? d.length : 0), 0);
97
+ const briefLines = fields.instructions ? fields.instructions.split(/(?<=[.!?])\s+/).filter(Boolean).length : 0;
98
+ const steps = logic.STEPS.map((s) => {
99
+ const base = { key: s.key, label: s.label, title: s.title, open: countBy(s.key), rules: stepRules(s.key, fields, items) };
100
+ if (s.key === 'searches') return { ...base, value: fields.search_queries.length, sub: 'queries a run' };
101
+ if (s.key === 'sources') return { ...base, value: fields.sources.length, sub: 'kinds of page' };
102
+ if (s.key === 'names') {
103
+ // new = added this week by a proposal or a member, never the seeded research
104
+ const fresh = live.filter((i) => i.added_via !== 'seed' && i.created_at && new Date(i.created_at) >= weekAgo).length;
105
+ return { ...base, value: live.length, sub: fresh ? `${fresh} new this week` : 'being watched' };
106
+ }
107
+ if (s.key === 'scoring') return { ...base, value: dims, sub: 'things, 0–10' };
108
+ return { ...base, value: briefLines, sub: briefLines === 1 ? 'line of instructions' : 'lines of instructions' };
109
+ });
110
+
111
+ const history = ordered.map((v, i) => ({
112
+ version: v.version,
113
+ status: v.status,
114
+ summary: v.summary || '',
115
+ reason: v.reason || '',
116
+ by: who(names, v.author_id),
117
+ approved_by: who(names, v.approved_by),
118
+ approved_at: v.approved_at || null,
119
+ changed: logic.changedSteps(i ? ordered[i - 1] : null, v),
120
+ rules: logic.fieldsOf(v),
121
+ }));
122
+
123
+ return {
124
+ steps,
125
+ active: active ? { id: active.id, version: active.version } : null,
126
+ history,
127
+ next: next ? {
128
+ version: next.version,
129
+ applied: next.applied,
130
+ skipped: next.skipped,
131
+ changed: next.changed,
132
+ rules: { search_queries: next.search_queries, sources: next.sources, rubric: next.rubric, instructions: next.instructions },
133
+ } : null,
134
+ proposals: open,
135
+ cost: logic.costByStep({ items: live.length, lastRun, pricing, model }),
136
+ decide,
137
+ };
138
+ }
139
+
140
+ // readView(pool, { project, now, decide, pricing, model }) — the rows, then buildView.
141
+ async function readView(pool, { project, now = new Date(), decide = {}, pricing, model, db = require('./db') }) {
142
+ const [rules, items, proposals, { rows: [last] }] = await Promise.all([
143
+ db.rules(pool, { project }),
144
+ db.listItems(pool, { project }),
145
+ db.proposals(pool, { project, status: 'open', limit: 200 }),
146
+ pool.query(LAST_RUN_COST_SQL, [project]),
147
+ ]);
148
+ let lastRun = null;
149
+ if (last) {
150
+ const runs = await db.numberedRuns(pool, { project });
151
+ const n = (runs.find((r) => String(r.id) === String(last.id)) || {}).n || null;
152
+ lastRun = { n, items: last.items, cost_usd: Number(last.cost_usd), cost_log_usd: last.cost_log_usd == null ? null : Number(last.cost_log_usd) };
153
+ }
154
+ const ids = new Set();
155
+ for (const v of rules.versions) { if (v.author_id) ids.add(String(v.author_id)); if (v.approved_by) ids.add(String(v.approved_by)); }
156
+ for (const p of proposals) if (p.proposed_by) ids.add(String(p.proposed_by));
157
+ const names = new Map();
158
+ if (ids.size) {
159
+ const { rows } = await pool.query(NAMES_SQL, [[...ids]]);
160
+ for (const b of rows) names.set(String(b.id), b);
161
+ }
162
+ return buildView({ versions: rules.versions, items, proposals, lastRun, names, decide, now, pricing, model });
163
+ }
164
+
165
+ module.exports = { buildView, readView };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.7",
3
+ "version": "1.24.9",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.24.7",
9
+ "version": "1.24.9",
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.7",
3
+ "version": "1.24.9",
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",