@bongos/core 1.24.6 → 1.24.8
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.
- package/.bongos-core.json +106 -46
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +13 -1
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0371-auto-pickup-applies-a-submitted-tweak-on-the-runner.md +1 -0
- package/docs/api/openapi.json +264 -4
- package/docs/api-reference.md +7 -3
- package/docs/copy-inventory.md +41 -13
- package/docs/copy-registry.json +319 -35
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +40 -4
- package/docs/page-readings.json +69 -9
- package/modules/government/decision-board.js +5 -0
- package/modules/government/decision-rules.js +3 -0
- package/modules/government/routes/government.js +2 -0
- package/modules/hall-ui/public/market-watch-rules-lib.js +207 -0
- package/modules/hall-ui/public/market-watch-rules.css +68 -0
- package/modules/hall-ui/public/market-watch-rules.html +54 -0
- package/modules/hall-ui/public/market-watch-rules.js +129 -0
- package/modules/hall-ui/public/market-watch-rules.states.json +17 -0
- package/modules/hall-ui/public/project-settings.html +4 -4
- package/modules/hall-ui/public/project-settings.js +51 -1
- package/modules/lifecycle/autopickup.js +26 -9
- package/modules/lifecycle/db-claims.js +1 -1
- package/modules/lifecycle/routes/project-settings.js +71 -10
- package/modules/market-watch/matter-acts.js +59 -0
- package/modules/market-watch/module.json +5 -4
- package/modules/market-watch/proposals.js +214 -0
- package/modules/market-watch/research.js +49 -8
- package/modules/market-watch/routes/rules.js +202 -0
- package/modules/market-watch/rules-logic.js +264 -0
- package/modules/market-watch/rules-view.js +165 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/bongos/government-walk.js +18 -2
- package/scripts/bongos/run-unit-tests.js +3 -0
- package/src/bongos/module-scope-map.js +2 -0
- package/src/bongos/project-settings.js +59 -15
- package/src/bongos/serve-internal.js +2 -1
- package/src/module-api.js +1 -1
- package/tests/autopickup_cap.mjs +203 -11
- package/tests/government_act_alone_buildings.mjs +3 -0
- package/tests/government_decision_rules.mjs +4 -1
- package/tests/governor_matter_acts.mjs +5 -1
- package/tests/hall_page_gate_map.mjs +3 -0
- package/tests/market_watch_module.mjs +1 -0
- package/tests/market_watch_rules.mjs +496 -0
- package/tests/market_watch_rules_db.mjs +133 -0
- package/tests/project_settings_page.mjs +8 -5
|
@@ -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.
|
|
3
|
+
"version": "1.24.8",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.24.
|
|
9
|
+
"version": "1.24.8",
|
|
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.
|
|
3
|
+
"version": "1.24.8",
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -10871,5 +10871,17 @@
|
|
|
10871
10871
|
"id": "1004995",
|
|
10872
10872
|
"text": "On the account page, people can now link GitHub and Google to one account, both ways, so either one opens the same account with the same credits, ranks and history. They can remove one too, but never their last way in. Two exi"
|
|
10873
10873
|
}
|
|
10874
|
+
],
|
|
10875
|
+
"1.24.7": [
|
|
10876
|
+
{
|
|
10877
|
+
"id": "1005003",
|
|
10878
|
+
"text": "The automatic tweak limit is now 200 rounds a month, and a project owner can change it, for the whole project and separately for each builder rank, on the project settings page."
|
|
10879
|
+
}
|
|
10880
|
+
],
|
|
10881
|
+
"1.24.8": [
|
|
10882
|
+
{
|
|
10883
|
+
"id": "1004941",
|
|
10884
|
+
"text": "Every member can now read the Market Watch rules step by step, propose a change with a reason, and the owner (or the government, by vote) approves it into a new rules version that the next weekly run uses."
|
|
10885
|
+
}
|
|
10874
10886
|
]
|
|
10875
10887
|
}
|
|
@@ -84,6 +84,7 @@ const REQUESTS = Object.freeze({
|
|
|
84
84
|
'PATCH /project-settings/tweak-autopickup': ['PATCH', '/project-settings/tweak-autopickup', { mode: 'sometimes' }],
|
|
85
85
|
'PATCH /project-settings/spend-cap': ['PATCH', '/project-settings/spend-cap', { usd: 'lots' }],
|
|
86
86
|
'PATCH /work-categories/:id': ['PATCH', `/work-categories/${BAD}`, {}],
|
|
87
|
+
'POST /market-watch/proposals/:id/decide': ['POST', `/market-watch/proposals/${BAD}/decide`, { decision: 'approve' }],
|
|
87
88
|
'POST /security/reports/:id/confirm': ['POST', `/security/reports/${BAD}/confirm`, {}],
|
|
88
89
|
'POST /security/reports/:id/dispute': ['POST', `/security/reports/${BAD}/dispute`, {}],
|
|
89
90
|
'POST /security/reports/:id/fix': ['POST', `/security/reports/${BAD}/fix`, {}],
|
|
@@ -123,6 +124,7 @@ const MATTER_PLAN = Object.freeze([
|
|
|
123
124
|
['release', 'Approve override request 5', { type: 'approve_override', request_id: 5 }, 'request 5 approved'],
|
|
124
125
|
['review', 'Sign off criterion 9', { type: 'satisfy_criterion', criterion_id: 9 }, 'criterion 9 satisfied'],
|
|
125
126
|
['treasury', 'Dee gets $50 a month', { type: 'set_budget', builder_id: 45, monthly_usd: 50 }, 'Dee\'s limit is $50'],
|
|
127
|
+
['rules', 'Market Watch: + bounty boards as a search', { type: 'market_watch_proposal', proposal_id: 12 }, 'proposal 12 approved'],
|
|
126
128
|
]);
|
|
127
129
|
|
|
128
130
|
// Every router the walk drives, mounted as the server mounts them.
|
|
@@ -145,6 +147,8 @@ const ROUTERS = Object.freeze([
|
|
|
145
147
|
// the new machinery's other doors: a blocker and its ask, a builder's settings
|
|
146
148
|
'modules/ideas/routes/blockers.js',
|
|
147
149
|
'modules/builder-settings/routes/settings.js',
|
|
150
|
+
// task 1004941: the owner's decide route (a Rules act) and the Rules matter act
|
|
151
|
+
'modules/market-watch/routes/rules.js',
|
|
148
152
|
]);
|
|
149
153
|
|
|
150
154
|
// ── the in-memory government store ──────────────────────────────────────────
|
|
@@ -633,7 +637,17 @@ async function runWalk() {
|
|
|
633
637
|
projectSettings.setSetting = async (k, v) => { settings.set(k, String(v)); return { key: k, value: String(v) }; };
|
|
634
638
|
|
|
635
639
|
// What passed matters write into other modules, recorded.
|
|
636
|
-
const effects = { fence: null, allowedGoals: [], overrides: [], criteria: new Map([['9', false]]), budgets: new Map() };
|
|
640
|
+
const effects = { fence: null, allowedGoals: [], overrides: [], criteria: new Map([['9', false]]), budgets: new Map(), mwApproved: [] };
|
|
641
|
+
// task 1004941: a passed Rules matter approves a Market Watch proposal through the
|
|
642
|
+
// module's own decideProposal; here it is recorded instead of written.
|
|
643
|
+
const mwActs = req('modules/market-watch/matter-acts.js');
|
|
644
|
+
const mwProposals = req('modules/market-watch/proposals.js');
|
|
645
|
+
mwActs.runtime.project = () => 'https://walk.example';
|
|
646
|
+
mwActs.runtime.withTx = async (fn) => fn(null);
|
|
647
|
+
mwProposals.decideProposal = async ({ id, decision }) => {
|
|
648
|
+
if (decision === 'approve') effects.mwApproved.push(String(id));
|
|
649
|
+
return { ok: true, status: 'applied', applied: true };
|
|
650
|
+
};
|
|
637
651
|
autoDb.setFenceEnabled = async ({ enabled }) => { effects.fence = enabled; return { enabled }; };
|
|
638
652
|
autoDb.allowGoal = async ({ goalId }) => { effects.allowedGoals.push(String(goalId)); return { goal_id: goalId }; };
|
|
639
653
|
lifeDb.decideOverrideRequest = async ({ requestId, decision }) => { effects.overrides.push([String(requestId), decision]); return {}; };
|
|
@@ -943,6 +957,7 @@ async function runWalk() {
|
|
|
943
957
|
section('M Matters');
|
|
944
958
|
const SITTINGS = {
|
|
945
959
|
people: 'consent', automation: 'majority', release: 'unanimous', review: 'signoff', treasury: 'first',
|
|
960
|
+
rules: 'majority',
|
|
946
961
|
};
|
|
947
962
|
await check('Each acting building is put to the council by one passed amendment', Object.entries(SITTINGS).map(([a, r]) => `${a} ${r}`).join(', '), async () => {
|
|
948
963
|
const out = await setRules(Object.entries(SITTINGS).map(([area, rule]) => ({ area, decided_by: 'rank:council', rule })));
|
|
@@ -971,12 +986,13 @@ async function runWalk() {
|
|
|
971
986
|
'release.approve_override': () => effects.overrides.some(([id, d]) => id === '5' && d === 'approve'),
|
|
972
987
|
'review.satisfy_criterion': () => effects.criteria.get('9') === true,
|
|
973
988
|
'treasury.set_budget': () => effects.budgets.get('45') === 50,
|
|
989
|
+
'rules.market_watch_proposal': () => effects.mwApproved.includes('12'),
|
|
974
990
|
};
|
|
975
991
|
const MATTERS = MATTER_PLAN.map(([area, title, action, effect]) => [area, title, action, effect, HAPPENED[`${area}.${action.type}`]]);
|
|
976
992
|
// How each building's sitting closes as Ben then Cy say yes, under its rule: the
|
|
977
993
|
// two-member consent, majority and unanimous sittings need both; first yes and
|
|
978
994
|
// sign-off (by someone who did not propose it) close on the first.
|
|
979
|
-
const CLOSES = { people: 'open, passed', automation: 'open, passed', release: 'open, passed', review: 'passed', treasury: 'passed' };
|
|
995
|
+
const CLOSES = { people: 'open, passed', automation: 'open, passed', release: 'open, passed', review: 'passed', treasury: 'passed', rules: 'open, passed' };
|
|
980
996
|
await check('Someone the building\'s row does not seat cannot vote on its matter', '403 not_board_member', async () => {
|
|
981
997
|
const r = await call(FOUNDER, 'POST', '/government/board/matters', { area: 'automation', title: 'Who may vote here?' });
|
|
982
998
|
const itemId = r.json && r.json.item && r.json.item.id;
|
|
@@ -93,6 +93,9 @@ const INTEGRATION = new Set([
|
|
|
93
93
|
// idempotent and a resumed run unable to record an item twice. DB-free sibling:
|
|
94
94
|
// market_watch_data.
|
|
95
95
|
'market_watch_data_db',
|
|
96
|
+
// task 1004941: a rules proposal round-trips to a new version the weekly run's loader
|
|
97
|
+
// reads, on real SQL (the one-active index, the CHECKs). DB-free sibling: market_watch_rules.
|
|
98
|
+
'market_watch_rules_db',
|
|
96
99
|
// task 1004940: the weekly ask's SQL store — once per week by constraint, one pooled
|
|
97
100
|
// proposal per similar answer, the project guard on an answer. DB-free sibling:
|
|
98
101
|
// market_watch_ask.
|
|
@@ -198,6 +198,8 @@ const MODULE_GLOBS = {
|
|
|
198
198
|
'modules/market-watch/', 'tests/market_watch_module.mjs',
|
|
199
199
|
'tests/market_watch_data.mjs', 'tests/market_watch_data_db.mjs',
|
|
200
200
|
'tests/market_watch_run.mjs',
|
|
201
|
+
// task 1004941: the rules page (proposals, decisions, the run's prompt fence)
|
|
202
|
+
'tests/market_watch_rules.mjs', 'tests/market_watch_rules_db.mjs',
|
|
201
203
|
'tests/market_watch_ask.mjs', 'tests/market_watch_ask_db.mjs',
|
|
202
204
|
],
|
|
203
205
|
// --- builder prefs (wandering/skill/sound/render) + builder-needs ---
|