@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.
- package/.bongos-core.json +115 -50
- 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/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +1 -1
- package/docs/api/openapi.json +267 -7
- package/docs/api-reference.md +7 -3
- package/docs/architecture.md +2 -2
- package/docs/copy-inventory.md +38 -13
- package/docs/copy-registry.json +271 -15
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +3 -3
- package/docs/page-inventory.json +40 -4
- package/docs/page-readings.json +64 -4
- package/migrations/core_287_module_prices_cash_only.sql +24 -0
- 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/modules.js +5 -0
- 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/module-store-cli.js +21 -21
- package/scripts/bongos/module.js +8 -8
- package/scripts/bongos/run-unit-tests.js +3 -0
- package/src/bongos/module-listing.js +47 -20
- package/src/bongos/module-scope-map.js +2 -0
- package/src/bongos/module-store.js +20 -12
- package/src/bongos/routes/modules.js +31 -21
- package/src/bongos/serve-internal.js +2 -1
- package/src/module-api.js +1 -1
- 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/module_store_cli.mjs +14 -3
- package/tests/module_store_listing.mjs +65 -14
- package/tests/module_store_price.mjs +97 -26
- 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.
|
|
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.
|
|
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.
|
|
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",
|