@bongos/core 1.24.8 → 1.24.10

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 (88) hide show
  1. package/.bongos-core.json +131 -86
  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 +11 -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 +185 -6
  9. package/docs/api-reference.md +7 -3
  10. package/docs/architecture.md +2 -2
  11. package/docs/copy-inventory.md +58 -28
  12. package/docs/copy-registry.json +333 -33
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/modules-contract.md +3 -3
  15. package/docs/page-inventory.json +34 -4
  16. package/docs/page-readings.json +294 -237
  17. package/migrations/core_287_module_prices_cash_only.sql +24 -0
  18. package/modules/hall-ui/public/ai-studio.html +2 -2
  19. package/modules/hall-ui/public/atlas.html +2 -2
  20. package/modules/hall-ui/public/blockers.html +2 -2
  21. package/modules/hall-ui/public/board-room.html +2 -2
  22. package/modules/hall-ui/public/city.html +2 -2
  23. package/modules/hall-ui/public/collab.html +2 -2
  24. package/modules/hall-ui/public/commands.html +2 -2
  25. package/modules/hall-ui/public/copy-desk.html +2 -2
  26. package/modules/hall-ui/public/craft-mode.js +1 -1
  27. package/modules/hall-ui/public/deploy.html +2 -2
  28. package/modules/hall-ui/public/diagrams.html +2 -2
  29. package/modules/hall-ui/public/drachmae.html +2 -2
  30. package/modules/hall-ui/public/fleet.html +2 -2
  31. package/modules/hall-ui/public/gate.html +2 -2
  32. package/modules/hall-ui/public/goal-map.html +2 -2
  33. package/modules/hall-ui/public/goals.html +2 -2
  34. package/modules/hall-ui/public/government.html +2 -2
  35. package/modules/hall-ui/public/idea.html +2 -2
  36. package/modules/hall-ui/public/ideas.html +2 -2
  37. package/modules/hall-ui/public/index.html +2 -2
  38. package/modules/hall-ui/public/market-watch-rules.html +2 -2
  39. package/modules/hall-ui/public/market-watch-settings.css +217 -0
  40. package/modules/hall-ui/public/market-watch-settings.html +113 -0
  41. package/modules/hall-ui/public/market-watch-settings.js +230 -0
  42. package/modules/hall-ui/public/market-watch-settings.states.json +14 -0
  43. package/modules/hall-ui/public/modules.html +2 -2
  44. package/modules/hall-ui/public/modules.js +5 -0
  45. package/modules/hall-ui/public/my-agents.html +2 -2
  46. package/modules/hall-ui/public/primer.html +2 -2
  47. package/modules/hall-ui/public/profile.html +2 -2
  48. package/modules/hall-ui/public/project-settings.html +2 -2
  49. package/modules/hall-ui/public/ranks.html +2 -2
  50. package/modules/hall-ui/public/roadmap.html +2 -2
  51. package/modules/hall-ui/public/roster.html +2 -2
  52. package/modules/hall-ui/public/sessions.html +2 -2
  53. package/modules/hall-ui/public/settings.html +2 -2
  54. package/modules/hall-ui/public/shell.js +3 -0
  55. package/modules/hall-ui/public/street.html +2 -2
  56. package/modules/hall-ui/public/studio.html +2 -2
  57. package/modules/hall-ui/public/task.html +2 -2
  58. package/modules/hall-ui/public/thinking.html +2 -2
  59. package/modules/hall-ui/public/tweak-editor.html +2 -2
  60. package/modules/hall-ui/public/watch.html +2 -2
  61. package/modules/hall-ui/public/work.html +2 -2
  62. package/modules/hall-ui/records/market-watch-settings.md +11 -0
  63. package/modules/market-watch/module.json +3 -2
  64. package/modules/market-watch/routes/settings.js +128 -0
  65. package/modules/market-watch/settings.js +207 -0
  66. package/modules/provisioning/starter-bundles.js +11 -6
  67. package/modules/ui-design/kit/serve.js +1 -0
  68. package/package-lock.json +2 -2
  69. package/package.json +1 -1
  70. package/release-notes.json +12 -0
  71. package/scripts/bongos/module-store-cli.js +21 -21
  72. package/scripts/bongos/module.js +8 -8
  73. package/src/bongos/module-listing.js +47 -20
  74. package/src/bongos/module-scope-map.js +1 -0
  75. package/src/bongos/module-store.js +20 -12
  76. package/src/bongos/routes/modules.js +31 -21
  77. package/src/bongos/serve-internal.js +2 -2
  78. package/src/module-api.js +1 -1
  79. package/tests/hall_nav.mjs +4 -2
  80. package/tests/hall_page_gate_map.mjs +4 -0
  81. package/tests/market_watch_data_db.mjs +27 -0
  82. package/tests/market_watch_module.mjs +8 -4
  83. package/tests/market_watch_settings.mjs +392 -0
  84. package/tests/module_store_cli.mjs +14 -3
  85. package/tests/module_store_listing.mjs +65 -14
  86. package/tests/module_store_price.mjs +97 -26
  87. package/tests/module_store_registry_migration.mjs +11 -0
  88. package/tests/nav_permission_atoms.mjs +3 -2
@@ -0,0 +1,128 @@
1
+ // modules/market-watch/routes/settings.js — the owner's Market Watch settings (task 1004938,
2
+ // goal 1000137). The page is the hall's /market-watch-settings, built to
3
+ // docs/design/mocks/market-watch/settings.html (option B, the fuel gauge).
4
+ //
5
+ // GET /market-watch/settings the switch, cap, cadence, this month's spend, the next
6
+ // run, recent runs, and whether a key is set
7
+ // PATCH /market-watch/settings change any of { enabled, monthly_cap_usd, cadence }
8
+ // PUT /market-watch/settings/key save or replace the project's Anthropic key { key }
9
+ // DELETE /market-watch/settings/key clear it
10
+ //
11
+ // rank: archon — the owner seat. Two server-side gates on every route, the read included
12
+ // (ADR 0016; the page hiding a control is never the wall):
13
+ // 1. requirePermission('project.settings.manage') — the catalog atom for "the project's
14
+ // own live knobs" (modules/government/catalog.js, floor metic). Module routes gate on
15
+ // atoms, never on a rank gate (tests/government_require_permission.mjs).
16
+ // 2. ownerSeat — the owner seat is every active Archon (government leads.ownerOf), and
17
+ // the task says owner-only, so a Metic who holds the atom is still refused 403
18
+ // rank_forbidden. No archon-floor atom names this authority, and borrowing an
19
+ // unrelated one would name the wrong authority (the scouting holdout's reasoning in
20
+ // that test). The security_sensitive check in lifecycle's task-write-routes is the
21
+ // precedent for an archon check behind a wider atom.
22
+ // The read is owner-only too: it carries the project's AI spend and whether its key is set.
23
+ //
24
+ // The key is write-only. No route returns it or any part of it; the body field is named
25
+ // `key`, which the audit middleware redacts, and no log line here carries the body.
26
+
27
+ 'use strict';
28
+
29
+ const express = require('express');
30
+ const api = require('../../../src/module-api');
31
+ const settings = require('../settings');
32
+ const { projectKey } = require('../seed');
33
+
34
+ const log = api.logger('market-watch');
35
+
36
+ const MAX_KEY_BODY = 512;
37
+ const KEY_MESSAGES = {
38
+ key_required: 'Paste the key.',
39
+ key_wrong_length: 'That does not look like an Anthropic key. Paste the whole key.',
40
+ key_has_spaces: 'That does not look like an Anthropic key. Paste it with no spaces.',
41
+ };
42
+
43
+ // The project is this instance's own public origin, as the run and the seed read it.
44
+ function currentProject() {
45
+ try { return projectKey(api.branding().domains.publicOrigin); } catch (_) { return null; }
46
+ }
47
+
48
+ function ceilingUsd() {
49
+ try { return require('../research').itemCeilingUsd(); } catch (_) { return null; }
50
+ }
51
+
52
+ function storageConfigured() {
53
+ try { return api.secretBox.isConfigured() === true; } catch (_) { return false; }
54
+ }
55
+
56
+ module.exports = function buildMarketWatchSettingsRouter(deps = {}) {
57
+ const router = express.Router();
58
+ // Seams for the tests: the pool, the project and the box. Production passes nothing.
59
+ const pool = () => deps.pool || api.pool;
60
+ const project = () => (deps.project !== undefined ? deps.project : currentProject());
61
+ const box = () => deps.secretBox || api.secretBox;
62
+ const isStorageConfigured = () => (deps.secretBox ? deps.secretBox.isConfigured() === true : storageConfigured());
63
+
64
+ // The owner seat, checked after the atom: holding project.settings.manage is not enough.
65
+ function ownerSeat(req, res, next) {
66
+ const actual = req.builder && req.builder.rank;
67
+ if (!api.rankMeetsThreshold(actual, ['archon'])) {
68
+ return res.fail('rank_forbidden', 403, { required: ['archon'], actual: actual || null, reason: 'Only the project owner can see or change Market Watch settings.' });
69
+ }
70
+ return next();
71
+ }
72
+
73
+ function withProject(res) {
74
+ const p = project();
75
+ if (!p) res.fail('project_origin_unset', { status: 409, message: 'This project has no public address set, so Market Watch cannot tell which project it is.' });
76
+ return p;
77
+ }
78
+
79
+ async function view(p) {
80
+ return settings.readView(pool(), p, {
81
+ now: deps.now ? new Date(deps.now()) : new Date(),
82
+ ceilingUsd: deps.ceilingUsd !== undefined ? deps.ceilingUsd : ceilingUsd(),
83
+ storageConfigured: isStorageConfigured(),
84
+ });
85
+ }
86
+
87
+ router.get('/market-watch/settings', api.requireBuilder, api.requirePermission('project.settings.manage'), ownerSeat, api.asyncHandler('GET /market-watch/settings', async (req, res) => {
88
+ const p = withProject(res);
89
+ if (!p) return;
90
+ res.json(await view(p));
91
+ }));
92
+
93
+ router.patch('/market-watch/settings', api.requireBuilder, api.requirePermission('project.settings.manage'), ownerSeat, api.asyncHandler('PATCH /market-watch/settings', async (req, res) => {
94
+ // Strict field list (unknown fields refused by the envelope); the values are checked by
95
+ // settings.parsePatch, the one validator the tests also run.
96
+ if (api.validateOrRespond(req, res, { enabled: {}, monthly_cap_usd: {}, cadence: {} })) return;
97
+ const parsed = settings.parsePatch(req.body);
98
+ if (parsed.error) return res.fail(parsed.error, { status: 400, message: parsed.message });
99
+ const p = withProject(res);
100
+ if (!p) return;
101
+ const saved = await settings.writeSettings(pool(), p, parsed.patch, req.builder.id);
102
+ log.info({ builder_id: req.builder.id, fields: Object.keys(parsed.patch), enabled: saved.enabled }, 'market watch settings changed');
103
+ res.json(await view(p));
104
+ }));
105
+
106
+ router.put('/market-watch/settings/key', api.requireBuilder, api.requirePermission('project.settings.manage'), ownerSeat, api.asyncHandler('PUT /market-watch/settings/key', async (req, res) => {
107
+ if (api.validateOrRespond(req, res, { key: { required: true, type: 'string', maxLength: MAX_KEY_BODY } })) return;
108
+ const p = withProject(res);
109
+ if (!p) return;
110
+ if (!isStorageConfigured()) {
111
+ return res.fail('storage_not_configured', { status: 409, message: 'Key storage is not set up on this project yet.' });
112
+ }
113
+ const out = await settings.setKey(pool(), p, req.body.key, req.builder.id, { encrypt: (s) => box().encrypt(s) });
114
+ if (out.error) return res.fail(out.error, { status: 400, message: KEY_MESSAGES[out.error] || 'That key could not be saved.' });
115
+ log.info({ builder_id: req.builder.id }, 'market watch key saved');
116
+ res.json(await view(p));
117
+ }));
118
+
119
+ router.delete('/market-watch/settings/key', api.requireBuilder, api.requirePermission('project.settings.manage'), ownerSeat, api.asyncHandler('DELETE /market-watch/settings/key', async (req, res) => {
120
+ const p = withProject(res);
121
+ if (!p) return;
122
+ const out = await settings.clearKey(pool(), p, req.builder.id);
123
+ log.info({ builder_id: req.builder.id, removed: out.removed }, 'market watch key cleared');
124
+ res.json(await view(p));
125
+ }));
126
+
127
+ return router;
128
+ };
@@ -0,0 +1,207 @@
1
+ 'use strict';
2
+
3
+ // modules/market-watch/settings.js — the owner's Market Watch settings (task 1004938, goal
4
+ // 1000137). The design of record is docs/design/mocks/market-watch/settings.html, option B,
5
+ // the fuel gauge.
6
+ //
7
+ // What the owner sets here, and what reads it back:
8
+ //
9
+ // enabled the watch switch. The weekly run's first pre-check (run.js precheck)
10
+ // reads it through store.readSettings: off means the next due tick
11
+ // records a skipped run with reason 'off' and spends nothing.
12
+ // monthly_cap_usd the monthly AI cap. The run checks it before the run and before every
13
+ // item (run.js capAllows).
14
+ // cadence weekly | biweekly | monthly (run.js isDue, db.CADENCE_DAYS).
15
+ // the key the project's own Anthropic key, sealed by key.js sealProjectKey into
16
+ // the columns key.js readProjectKey opens (market_watch_002_run.sql).
17
+ //
18
+ // THE KEY IS WRITE-ONLY. It can be replaced or cleared, never read back: the view answers
19
+ // key_set true or false and nothing else, not the last four characters the table keeps
20
+ // for its CHECK constraint, not the date. Nothing in this file decrypts.
21
+ //
22
+ // Every statement here filters on the project key, like db.js and store.js. Validation is
23
+ // pure (parsePatch) so the route and the tests share it.
24
+
25
+ const db = require('./db');
26
+ const { sealProjectKey } = require('./key');
27
+
28
+ const CADENCES = Object.freeze(Object.keys(db.CADENCE_DAYS));
29
+ // The cap's bounds, in dollars. The page's slider walks 5 to 60 in steps of 5 (the
30
+ // mockup); the server accepts any whole-cent amount in this range so a cap set by the API
31
+ // is not refused for being off the slider's grid.
32
+ const MIN_CAP_USD = 1;
33
+ const MAX_CAP_USD = 1000;
34
+ const PATCH_FIELDS = Object.freeze(['enabled', 'monthly_cap_usd', 'cadence']);
35
+ // How many recent runs the strip shows, and how many of them the run estimate averages.
36
+ const RECENT_RUNS = 6;
37
+ const ESTIMATE_FROM = 3;
38
+
39
+ // parsePatch(body) → { patch } | { error, message }. At least one known field; no others.
40
+ function parsePatch(body) {
41
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return { error: 'bad_settings', message: 'Send the settings to change.' };
42
+ const unknown = Object.keys(body).filter((k) => !PATCH_FIELDS.includes(k));
43
+ if (unknown.length) return { error: 'bad_settings', message: `Unknown setting: ${unknown.join(', ')}.` };
44
+ const patch = {};
45
+ if (body.enabled !== undefined) {
46
+ if (typeof body.enabled !== 'boolean') return { error: 'bad_settings', message: 'enabled is true or false.' };
47
+ patch.enabled = body.enabled;
48
+ }
49
+ if (body.monthly_cap_usd !== undefined) {
50
+ const n = body.monthly_cap_usd;
51
+ if (typeof n !== 'number' || !Number.isFinite(n) || n < MIN_CAP_USD || n > MAX_CAP_USD) {
52
+ return { error: 'bad_cap', message: `The monthly cap is between $${MIN_CAP_USD} and $${MAX_CAP_USD}.` };
53
+ }
54
+ patch.monthly_cap_usd = Math.round(n * 100) / 100;
55
+ }
56
+ if (body.cadence !== undefined) {
57
+ if (!CADENCES.includes(body.cadence)) return { error: 'bad_cadence', message: `cadence is one of ${CADENCES.join(', ')}.` };
58
+ patch.cadence = body.cadence;
59
+ }
60
+ if (!Object.keys(patch).length) return { error: 'bad_settings', message: 'Send at least one setting to change.' };
61
+ return { patch };
62
+ }
63
+
64
+ // One row per project, created on the first write with the defaults every unconfigured
65
+ // project already reads (db.DEFAULT_SETTINGS), so a first write of one field never
66
+ // invents the others.
67
+ const UPSERT_SQL = `
68
+ INSERT INTO market_watch_settings (project, enabled, monthly_cap_usd, cadence, updated_by, updated_at)
69
+ VALUES ($1, COALESCE($2::boolean, $6::boolean), COALESCE($3::numeric, $7::numeric), COALESCE($4::text, $8::text), $5, now())
70
+ ON CONFLICT (project) DO UPDATE SET
71
+ enabled = COALESCE($2::boolean, market_watch_settings.enabled),
72
+ monthly_cap_usd = COALESCE($3::numeric, market_watch_settings.monthly_cap_usd),
73
+ cadence = COALESCE($4::text, market_watch_settings.cadence),
74
+ updated_by = $5,
75
+ updated_at = now()
76
+ RETURNING enabled, monthly_cap_usd, cadence`;
77
+
78
+ async function writeSettings(pool, project, patch, builderId) {
79
+ const d = db.DEFAULT_SETTINGS;
80
+ const v = (k) => (patch[k] === undefined ? null : patch[k]);
81
+ const { rows: [r] } = await pool.query(UPSERT_SQL, [
82
+ need(project), v('enabled'), v('monthly_cap_usd'), v('cadence'), builderId,
83
+ d.enabled, d.monthly_cap_usd, d.cadence,
84
+ ]);
85
+ return { enabled: r.enabled === true, monthly_cap_usd: Number(r.monthly_cap_usd), cadence: r.cadence };
86
+ }
87
+
88
+ const SET_KEY_SQL = `
89
+ INSERT INTO market_watch_settings (project, anthropic_key_enc, anthropic_key_last4, anthropic_key_set_at, updated_by, updated_at)
90
+ VALUES ($1, $2, $3, now(), $4, now())
91
+ ON CONFLICT (project) DO UPDATE SET
92
+ anthropic_key_enc = EXCLUDED.anthropic_key_enc,
93
+ anthropic_key_last4 = EXCLUDED.anthropic_key_last4,
94
+ anthropic_key_set_at = now(),
95
+ updated_by = EXCLUDED.updated_by,
96
+ updated_at = now()
97
+ RETURNING project`;
98
+
99
+ // setKey(pool, project, raw, builderId, { encrypt }) → { key_set: true } | { error }.
100
+ // The key goes through key.js sealProjectKey, the writer's half of the one reader the
101
+ // run uses, so what is stored is exactly what readProjectKey opens.
102
+ async function setKey(pool, project, raw, builderId, { encrypt } = {}) {
103
+ const sealed = sealProjectKey(raw, { encrypt });
104
+ if (sealed.error) return { error: sealed.error };
105
+ await pool.query(SET_KEY_SQL, [need(project), sealed.key_enc, sealed.last4, builderId]);
106
+ return { key_set: true };
107
+ }
108
+
109
+ const CLEAR_KEY_SQL = `
110
+ UPDATE market_watch_settings
111
+ SET anthropic_key_enc = NULL, anthropic_key_last4 = NULL, anthropic_key_set_at = NULL,
112
+ updated_by = $2, updated_at = now()
113
+ WHERE project = $1 AND anthropic_key_enc IS NOT NULL
114
+ RETURNING project`;
115
+
116
+ async function clearKey(pool, project, builderId) {
117
+ const { rows } = await pool.query(CLEAR_KEY_SQL, [need(project), builderId]);
118
+ return { key_set: false, removed: rows.length > 0 };
119
+ }
120
+
121
+ const KEY_SET_SQL = `SELECT (anthropic_key_enc IS NOT NULL) AS key_set FROM market_watch_settings WHERE project = $1`;
122
+ const ACTIVE_ITEMS_SQL = `SELECT count(*)::int AS n FROM market_watch_items WHERE project = $1 AND status = 'active'`;
123
+ // The newest run of ANY kind, the seed included: what the poller's clock counts from
124
+ // (store.js lastRun). The settings strip hides the seed run, the clock does not.
125
+ const LAST_RUN_SQL = `SELECT created_at FROM market_watch_runs WHERE project = $1 ORDER BY created_at DESC, id DESC LIMIT 1`;
126
+
127
+ // runEstimate(recentRuns, activeItems, ceilingUsd) → { usd, basis }. What the next run is
128
+ // likely to spend: the mean of the last few runs that ran and cost something, or, before
129
+ // there are any, every active item at its whole ceiling (an upper bound, said so).
130
+ function runEstimate(recentRuns, activeItems, ceilingUsd) {
131
+ const ran = recentRuns.filter((r) => (r.status === 'done' || r.status === 'partial') && r.cost_usd > 0).slice(0, ESTIMATE_FROM);
132
+ if (ran.length) return { usd: round2(ran.reduce((s, r) => s + r.cost_usd, 0) / ran.length), basis: 'recent' };
133
+ const n = Number(activeItems) || 0;
134
+ if (n > 0 && Number.isFinite(ceilingUsd) && ceilingUsd > 0) return { usd: round2(n * ceilingUsd), basis: 'ceiling' };
135
+ return { usd: null, basis: 'none' };
136
+ }
137
+
138
+ // nextRun(enabled, lastRunAt, cadence, now) → { state, due_at }. 'paused' when the switch
139
+ // is off; 'soon' when a run is already due (the poller looks every hour); else the date.
140
+ function nextRun(enabled, lastRunAt, cadence, now) {
141
+ if (!enabled) return { state: 'paused', due_at: null };
142
+ const due = db.nextRunDue(lastRunAt, cadence);
143
+ if (!due || due.getTime() <= new Date(now).getTime()) return { state: 'soon', due_at: null };
144
+ return { state: 'scheduled', due_at: due.toISOString() };
145
+ }
146
+
147
+ // readView(pool, project, { now, ceilingUsd, storageConfigured }) — everything the page
148
+ // draws, and nothing secret: the key is a boolean.
149
+ async function readView(pool, project, { now = new Date(), ceilingUsd = null, storageConfigured = false } = {}) {
150
+ need(project);
151
+ const [view, { rows: [k] }, { rows: [items] }, { rows: [last] }] = await Promise.all([
152
+ db.settingsView(pool, { project, now, recent: RECENT_RUNS }),
153
+ pool.query(KEY_SET_SQL, [project]),
154
+ pool.query(ACTIVE_ITEMS_SQL, [project]),
155
+ pool.query(LAST_RUN_SQL, [project]),
156
+ ]);
157
+ const s = view.settings;
158
+ const recent = view.recentRuns.map((r) => ({
159
+ id: r.id,
160
+ status: r.status,
161
+ reason: r.reason || null,
162
+ created_at: r.created_at,
163
+ items_total: r.items_total,
164
+ items_done: r.items_done,
165
+ cost_usd: r.cost_usd,
166
+ }));
167
+ const activeItems = items ? items.n : 0;
168
+ const estimate = runEstimate(recent, activeItems, ceilingUsd);
169
+ const spent = round2(view.spentThisMonthUsd);
170
+ const left = Math.max(0, s.monthly_cap_usd - spent);
171
+ return {
172
+ enabled: s.enabled === true,
173
+ monthly_cap_usd: s.monthly_cap_usd,
174
+ cadence: s.cadence,
175
+ configured: s.configured === true,
176
+ key_set: !!(k && k.key_set === true),
177
+ storage_configured: storageConfigured === true,
178
+ spent_this_month_usd: spent,
179
+ left_this_month_usd: round2(left),
180
+ month: monthLabel(now),
181
+ active_items: activeItems,
182
+ run_estimate_usd: estimate.usd,
183
+ run_estimate_basis: estimate.basis,
184
+ runs_left: estimate.usd ? Math.floor(left / estimate.usd) : null,
185
+ next_run: nextRun(s.enabled === true, last ? last.created_at : null, s.cadence, now),
186
+ recent_runs: recent,
187
+ cadences: CADENCES,
188
+ cap_bounds: { min: MIN_CAP_USD, max: MAX_CAP_USD },
189
+ };
190
+ }
191
+
192
+ function monthLabel(now) {
193
+ return new Date(now).toLocaleString('en-US', { month: 'long', timeZone: 'UTC' }).toLowerCase();
194
+ }
195
+ function round2(n) { return Math.round(Number(n) * 100) / 100; }
196
+ function need(project) {
197
+ if (typeof project !== 'string' || !project) throw new Error('market-watch settings: a project key is required');
198
+ return project;
199
+ }
200
+
201
+ module.exports = {
202
+ parsePatch, writeSettings, setKey, clearKey, readView,
203
+ // pure, for the tests
204
+ runEstimate, nextRun,
205
+ CADENCES, MIN_CAP_USD, MAX_CAP_USD, PATCH_FIELDS,
206
+ SQL: { UPSERT_SQL, SET_KEY_SQL, CLEAR_KEY_SQL, KEY_SET_SQL },
207
+ };
@@ -80,12 +80,9 @@ const ALWAYS_ON_CORE = Object.freeze([
80
80
  // investor page: its copy is Cloud Bongos's raise, so offering it to a new project would hand
81
81
  // that project someone else's fundraise (task 1004852). `ai-studio` (task 1004916, ADR 0392)
82
82
  // bills AI generation through Cloud Bongos's own payment processor account, so it stays on
83
- // the hub until a project can bring its own billing. `market-watch` (task 1004935) is not
84
- // hub-only in purpose: it is here because it is still EMPTY (no routes), and a picker entry
85
- // for a switch that does nothing is worse than none. This list is the one that keeps a
86
- // default:false module out of the opt-in catalog; the goal 1000137 task that ships the
87
- // owner's on/off switch moves it to OPTIONAL_MODULES with owner-facing copy.
88
- const HUB_ONLY_MODULES = Object.freeze(['platform-identity', 'provisioning', 'npm-release', 'render-deploy', 'raise', 'ai-studio', 'market-watch']);
83
+ // the hub until a project can bring its own billing. `market-watch` sat here while it was
84
+ // empty; task 1004938 shipped the owner's on/off switch and moved it to OPTIONAL_MODULES.
85
+ const HUB_ONLY_MODULES = Object.freeze(['platform-identity', 'provisioning', 'npm-release', 'render-deploy', 'raise', 'ai-studio']);
89
86
 
90
87
  // The OPT-IN catalog as the creation picker renders it (BV1.R20, task 1002339):
91
88
  // every module an owner may actually choose, in the order the picker lists them,
@@ -148,6 +145,14 @@ const OPTIONAL_MODULES = Object.freeze([
148
145
  summary: 'Commands to generate game tiles and sprites, score them against your art rules, and keep a style guide.',
149
146
  aliases: Object.freeze(['tileset', 'graphics', 'artwork', 'retro', 'drawing', 'gamedev']),
150
147
  }),
148
+ // task 1004938 (goal 1000137): offered once the owner's switch existed. It runs on the
149
+ // project's own Anthropic key, under a monthly cap the owner sets, and starts off.
150
+ Object.freeze({
151
+ key: 'market-watch',
152
+ label: 'Market Watch',
153
+ summary: 'A weekly AI look at your competitors, partners and topics, with a sourced log for each. It runs on your own Anthropic key, under a monthly cap you set.',
154
+ aliases: Object.freeze(['research', 'rivals', 'intelligence', 'analysis', 'trends', 'landscape']),
155
+ }),
151
156
  ]);
152
157
 
153
158
  // The opt-in keys alone, for the membership tests the normalizer runs. Derived,
@@ -144,6 +144,7 @@ const STARTER_BUNDLES_FIXTURE = {
144
144
  { key: 'discord', label: 'Discord', summary: 'A Discord server that carries your build news, and lets people send ideas and bug reports back.', aliases: ['chatroom', 'talk', 'messaging', 'community', 'announcements', 'notifications'] },
145
145
  { key: 'design-styles', label: 'Design styles', summary: 'Extra ways for the AI to style your pages: ready-made looks, a polish pass, and a way to author your own look.', aliases: ['theme', 'branding', 'visual', 'taste', 'aesthetic', 'layout'] },
146
146
  { key: 'pixel-art', label: 'Pixel-art tools', summary: 'Commands to generate game tiles and sprites, score them against your art rules, and keep a style guide.', aliases: ['tileset', 'graphics', 'artwork', 'retro', 'drawing', 'gamedev'] },
147
+ { key: 'market-watch', label: 'Market Watch', summary: 'A weekly AI look at your competitors, partners and topics, with a sourced log for each. It runs on your own Anthropic key, under a monthly cap you set.', aliases: ['research', 'rivals', 'intelligence', 'analysis', 'trends', 'landscape'] },
147
148
  ],
148
149
  };
149
150
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.8",
3
+ "version": "1.24.10",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.24.8",
9
+ "version": "1.24.10",
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.8",
3
+ "version": "1.24.10",
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",
@@ -10883,5 +10883,17 @@
10883
10883
  "id": "1004941",
10884
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
10885
  }
10886
+ ],
10887
+ "1.24.9": [
10888
+ {
10889
+ "id": "1004989",
10890
+ "text": "Modules can now only be priced in cash: credits can no longer price or buy one, and modules that still have an old credit price stay listed as \"price coming\" while their authors are asked to set a cash price."
10891
+ }
10892
+ ],
10893
+ "1.24.10": [
10894
+ {
10895
+ "id": "1004938",
10896
+ "text": "The project owner now has a Market Watch settings page where they can switch it on or off, set the monthly AI spending cap and how often it runs, see this month's spend and recent runs, and paste the project's Anthropic key, w"
10897
+ }
10886
10898
  ]
10887
10899
  }
@@ -63,13 +63,17 @@ function storeError(res) {
63
63
  // bongos module store — what the store lists (task 1003811)
64
64
  // ---------------------------------------------------------------------------
65
65
 
66
- const STORE_USAGE = 'usage: bongos module store [--sort <part>] [--max-cents N] [--max-credits N] [--json]';
66
+ const STORE_USAGE = 'usage: bongos module store [--sort <part>] [--max-cents N] [--json]';
67
+
68
+ // Credits never price or buy a module (task 1004989, ADR 0385 decision 2).
69
+ const NO_CREDIT_PRICE = 'credits do not price modules: use cents';
67
70
 
68
71
  // The query GET /store/modules takes, from argv. Returns { query } or { error }.
69
72
  // The server checks the values too; this only refuses what is not a value at all.
70
73
  function storeQuery(args) {
74
+ if (args.includes('--max-credits')) return { error: NO_CREDIT_PRICE };
71
75
  const params = [];
72
- for (const [flag, param] of [['--sort', 'sort'], ['--max-cents', 'max_cents'], ['--max-credits', 'max_credits']]) {
76
+ for (const [flag, param] of [['--sort', 'sort'], ['--max-cents', 'max_cents']]) {
73
77
  const i = args.indexOf(flag);
74
78
  if (i === -1) continue;
75
79
  const v = args[i + 1];
@@ -79,12 +83,12 @@ function storeQuery(args) {
79
83
  return { query: params.length ? `?${params.join('&')}` : '' };
80
84
  }
81
85
 
86
+ // A cash price, "free", or "price coming" for a module that still carries only an
87
+ // old credit price. Never a credit amount.
82
88
  function formatPrice(price) {
83
89
  if (!price || price.free) return 'free';
84
- const parts = [];
85
- if (price.cents != null) parts.push(`$${(price.cents / 100).toFixed(2)}`);
86
- if (price.credits != null) parts.push(`${price.credits} credits`);
87
- return parts.join(' or ') || 'free';
90
+ if (price.cents != null) return `$${(price.cents / 100).toFixed(2)}`;
91
+ return 'price coming';
88
92
  }
89
93
 
90
94
  function formatPlace(category) {
@@ -104,7 +108,7 @@ function formatStore(body) {
104
108
  out.push(` sorted by ${part ? part.title : body.sort}`);
105
109
  }
106
110
  const max = body.max_price;
107
- if (max) out.push(` at most ${[max.cents != null ? `$${(max.cents / 100).toFixed(2)}` : null, max.credits != null ? `${max.credits} credits` : null].filter(Boolean).join(' or ')}`);
111
+ if (max && max.cents != null) out.push(` at most $${(max.cents / 100).toFixed(2)}`);
108
112
  out.push('');
109
113
  const listed = body.listed || [];
110
114
  if (!listed.length) out.push(' Nothing is listed yet.');
@@ -141,22 +145,18 @@ async function cmdStore(args, { log = console.log, errlog = console.error, reque
141
145
  // bongos module price — the author sets a module's price (task 1004894)
142
146
  // ---------------------------------------------------------------------------
143
147
 
144
- const PRICE_USAGE = 'usage: bongos module price <key> [--cents N] [--credits N] (0 in either makes it free)';
148
+ const PRICE_USAGE = 'usage: bongos module price <key> --cents N (0 makes it free)';
145
149
 
146
- // argv -> { key, cents, credits } or { error }. Pure.
150
+ // argv -> { key, cents } or { error }. Pure.
147
151
  function priceArgs(args) {
148
152
  const key = args[0];
149
153
  if (!key || key.startsWith('--') || !KEY_RE.test(key)) return { error: 'name the module: a lowercase kebab-case key' };
150
- const out = { key, cents: null, credits: null };
151
- for (const [flag, field] of [['--cents', 'cents'], ['--credits', 'credits']]) {
152
- const i = args.indexOf(flag);
153
- if (i === -1) continue;
154
- const v = args[i + 1];
155
- if (!v || !/^\d{1,9}$/.test(v)) return { error: `${flag} needs a whole number, 0 or more` };
156
- out[field] = Number(v);
157
- }
158
- if (out.cents === null && out.credits === null) return { error: 'give --cents, --credits or both' };
159
- return out;
154
+ if (args.includes('--credits')) return { error: NO_CREDIT_PRICE };
155
+ const i = args.indexOf('--cents');
156
+ if (i === -1) return { error: 'give --cents' };
157
+ const v = args[i + 1];
158
+ if (!v || !/^\d{1,9}$/.test(v)) return { error: '--cents needs a whole number, 0 or more' };
159
+ return { key, cents: Number(v) };
160
160
  }
161
161
 
162
162
  async function cmdPrice(args, { log = console.log, errlog = console.error, request } = {}) {
@@ -166,10 +166,10 @@ async function cmdPrice(args, { log = console.log, errlog = console.error, reque
166
166
  try { call = request || storeRequest(); }
167
167
  catch (e) { errlog(`bongos module price: ${e.message}`); return 1; }
168
168
  let res;
169
- try { res = await call('POST', `/store/modules/${encodeURIComponent(p.key)}/price`, { json: { cents: p.cents, credits: p.credits } }); }
169
+ try { res = await call('POST', `/store/modules/${encodeURIComponent(p.key)}/price`, { json: { cents: p.cents } }); }
170
170
  catch (e) { errlog(`bongos module price: the store could not be reached — ${e.message}`); return 1; }
171
171
  if (!res.ok) { errlog(`bongos module price: the store answered ${storeError(res)}`); return res.status === 400 ? 2 : 1; }
172
- const withFree = (p) => ({ ...p, free: p.cents === 0 || p.credits === 0 });
172
+ const withFree = (p) => ({ ...p, free: p.cents === 0 });
173
173
  const shown = formatPrice(withFree(res.data.price));
174
174
  const was = res.data.previous ? formatPrice(withFree(res.data.previous)) : 'free (no price set)';
175
175
  log(`✓ "${p.key}" now costs ${shown} (was ${was}).`);
@@ -31,7 +31,7 @@
31
31
  // model as the hall's Modules tab (src/module-loader/catalog.js), so the two
32
32
  // surfaces cannot disagree about who built what.
33
33
  //
34
- // bongos module store [--sort <part>] [--max-cents N] [--max-credits N] [--json]
34
+ // bongos module store [--sort <part>] [--max-cents N] [--json]
35
35
  // Task 1003811 (criterion wa5-curated-listing) — the STORE from the terminal:
36
36
  // what the catalog lists, in its order. It asks GET /store/modules, the same
37
37
  // read the hall's "In the store" group makes, and never filters, ranks or caps
@@ -39,9 +39,9 @@
39
39
  // sub-category live in one place (src/bongos/module-listing.js), so the hall
40
40
  // and the terminal cannot disagree about which modules are the best.
41
41
  //
42
- // bongos module price <key> [--cents N] [--credits N]
42
+ // bongos module price <key> --cents N (credits never price a module)
43
43
  // Task 1004894 — the module's author sets its price in the store, in money
44
- // (cents), credits or both; 0 in either makes it free. The store keeps every
44
+ // (cents); 0 makes it free. A credit price is refused (task 1004989). The store keeps every
45
45
  // change and shows the newest. A paid module is listed as not yet buyable until
46
46
  // buying arrives (task 1003788).
47
47
  //
@@ -72,7 +72,7 @@
72
72
  // bongos module new <key> scaffold modules/<key>/
73
73
  // bongos module new <key> --force overwrite an existing modules/<key>/
74
74
  // bongos module list [--catalog] browse the module catalog (author + origin credit)
75
- // bongos module store [--sort <part>] [--max-cents N] [--max-credits N] what the store lists, best first
75
+ // bongos module store [--sort <part>] [--max-cents N] what the store lists, best first
76
76
  // bongos upgrade [--no-store] pre-check enabled modules vs the core version + list store updates
77
77
  // bongos module check <key> [--sign-off "Name <email>"] AGPL/publish pre-check + DCO sign-off
78
78
  // bongos module install <key> [--version X.Y.Z] acquire from the store, verify, place (not enabled)
@@ -968,7 +968,7 @@ async function cmdPublish(args, {
968
968
  log = console.log, errlog = console.error,
969
969
  modulesDir = MODULES_DIR, pack, upload, writeFile = fs.writeFileSync, priceRequest,
970
970
  } = {}) {
971
- const PUBLISH_USAGE = 'usage: bongos module publish <key> [--dry-run] [--out <dir>] [--cents N] [--credits N]';
971
+ const PUBLISH_USAGE = 'usage: bongos module publish <key> [--dry-run] [--out <dir>] [--cents N]';
972
972
  const VALUED = new Set(['--out', '--cents', '--credits']);
973
973
  const key = args.find((a, i) => !a.startsWith('-') && !VALUED.has(args[i - 1]));
974
974
  if (!key) {
@@ -1335,9 +1335,9 @@ function printHelp(out = console.log) {
1335
1335
  out(' bongos module new <key> Scaffold modules/<key>/ (manifest + skeleton + sample route + HOWTO.md + CLAUDE.md)');
1336
1336
  out(' bongos module list [--catalog] [--search <text>] [--json]');
1337
1337
  out(' Browse the module catalog — what you can enable, with author + origin credit');
1338
- out(' bongos module store [--sort <part>] [--max-cents N] [--max-credits N] [--json]');
1338
+ out(' bongos module store [--sort <part>] [--max-cents N] [--json]');
1339
1339
  out(' What the store lists, best first: above the quality floor, ranked, ten per sub-category');
1340
- out(' bongos module price <key> [--cents N] [--credits N]');
1340
+ out(' bongos module price <key> --cents N');
1341
1341
  out(' Set your module\'s price in the store (0 in either makes it free)');
1342
1342
  out(' bongos module remove <key> Take a store module off this project (switch it off first; its folder is kept aside)');
1343
1343
  out(' bongos upgrade [--no-store] Pre-check enabled modules against the core version (fail-closed),');
@@ -1346,7 +1346,7 @@ function printHelp(out = console.log) {
1346
1346
  out(' AGPL/publish pre-check before upstreaming + record a DCO-style sign-off');
1347
1347
  out(' bongos module submit <key> [--sign-off "Name <email>"]');
1348
1348
  out(' Pre-check + sign-off, then file into the rank-gated upstreaming review queue');
1349
- out(' bongos module publish <key> [--dry-run] [--out <dir>] [--cents N] [--credits N]');
1349
+ out(' bongos module publish <key> [--dry-run] [--out <dir>] [--cents N]');
1350
1350
  out(' Pack a module version (tarball + hash manifest) and upload it to the module store');
1351
1351
  out(' bongos module install <key> [--version X.Y.Z]');
1352
1352
  out(' Acquire a module from the store, verify it and place it under modules/ (not enabled)');