@bongos/core 1.24.2 → 1.24.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.bongos-core.json +111 -51
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +8 -0
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/config/branding.neutral.json +0 -1
  8. package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +11 -0
  9. package/docs/api/openapi.json +121 -4
  10. package/docs/api-reference.md +8 -1
  11. package/docs/branding-contract.md +1 -1
  12. package/docs/module-api-changelog.md +2 -0
  13. package/docs/page-readings.json +7 -7
  14. package/modules/discord/ship-broadcast.js +7 -0
  15. package/modules/economy/credit-wording.js +93 -0
  16. package/modules/economy/credits.js +26 -12
  17. package/modules/economy/governor-docket.js +12 -6
  18. package/modules/economy/reward-policy.js +49 -3
  19. package/modules/economy/reward.js +5 -0
  20. package/modules/government/routes/government.js +7 -0
  21. package/modules/hall-ui/public/street-inside.js +1 -1
  22. package/modules/ideas/routes/inbox.js +6 -0
  23. package/modules/lifecycle/routes/project-settings.js +6 -4
  24. package/modules/market-watch/ask.js +425 -0
  25. package/modules/market-watch/migrations/market_watch_003_ask_pool.sql +21 -0
  26. package/modules/market-watch/module.json +10 -3
  27. package/modules/market-watch/pollers/weekly-ask.js +89 -0
  28. package/modules/market-watch/public/hall-widget.js +271 -0
  29. package/modules/market-watch/public/weekly-ask.css +39 -0
  30. package/modules/market-watch/routes/ask.js +95 -0
  31. package/modules/onboarding/routes/access-requests.js +38 -10
  32. package/modules/platform-identity/account-logins.js +13 -0
  33. package/modules/platform-identity/routes/my-projects.js +10 -7
  34. package/modules/platform-identity/routes/sso.js +11 -6
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/release-notes.json +22 -0
  38. package/scripts/bongos/backfill-subagent-rewards.js +7 -7
  39. package/scripts/bongos/run-unit-tests.js +4 -0
  40. package/scripts/hall-preview/server.js +13 -0
  41. package/src/bongos/auth-idp.js +17 -5
  42. package/src/bongos/db-kernel.js +14 -1
  43. package/src/bongos/module-scope-map.js +1 -0
  44. package/src/module-api.js +1 -1
  45. package/tests/account_logins.mjs +6 -4
  46. package/tests/backfill_subagent_rewards.mjs +7 -7
  47. package/tests/credit_grant.mjs +7 -7
  48. package/tests/credit_wording.mjs +154 -0
  49. package/tests/google_halls.mjs +211 -0
  50. package/tests/governor_docket.mjs +7 -3
  51. package/tests/hall_street_inside.mjs +2 -2
  52. package/tests/market_watch_ask.mjs +369 -0
  53. package/tests/market_watch_ask_db.mjs +122 -0
  54. package/tests/market_watch_data_db.mjs +4 -0
  55. package/tests/market_watch_module.mjs +9 -5
  56. package/tests/project_settings_page.mjs +1 -1
  57. package/tests/reward_cost_scale.mjs +200 -0
@@ -0,0 +1,122 @@
1
+ // tests/market_watch_ask_db.mjs — the weekly ask's SQL store against a REAL Postgres (task
2
+ // 1004940, goal 1000137).
3
+ //
4
+ // What only a real engine can say: the week's ask is issued once per project per week (the
5
+ // UNIQUE constraint, not the caller's care); an answer cannot land on another project's ask
6
+ // (the guard is in the INSERT ... SELECT); similar answers from three members file ONE
7
+ // proposal whose agree count is three; a second tap is a no-op; and the rows satisfy the
8
+ // proposals table's own CHECKs (a weekly_ask proposal carries its answer, a rule_change its
9
+ // rules version).
10
+ //
11
+ // The module is default:false, so CI's migrate.sh does not apply its migrations; this file
12
+ // applies market_watch_001, 002 and 003 itself. Its rows live under a project origin no real
13
+ // instance has and are deleted before and after. Self-skips without a reachable Postgres;
14
+ // in the INTEGRATION set (scripts/bongos/run-unit-tests.js). DB-free sibling:
15
+ // tests/market_watch_ask.mjs.
16
+ //
17
+ // Run: DATABASE_URL=... node tests/market_watch_ask_db.mjs
18
+
19
+ import { createRequire } from 'node:module';
20
+ import { readFileSync } from 'node:fs';
21
+ import { strict as assert } from 'node:assert';
22
+ import { insertTestBuilders } from './helpers.mjs';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const { Pool } = require('pg');
26
+ const ask = require('../modules/market-watch/ask.js');
27
+
28
+ const M1 = readFileSync(new URL('../modules/market-watch/migrations/market_watch_001_tables.sql', import.meta.url), 'utf8');
29
+ const M2 = readFileSync(new URL('../modules/market-watch/migrations/market_watch_002_run.sql', import.meta.url), 'utf8');
30
+ const M3 = readFileSync(new URL('../modules/market-watch/migrations/market_watch_003_ask_pool.sql', import.meta.url), 'utf8');
31
+
32
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
33
+ try {
34
+ await pool.query('SELECT 1');
35
+ } catch (e) {
36
+ console.log(`market_watch_ask_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
37
+ await pool.end().catch(() => {});
38
+ process.exit(0);
39
+ }
40
+
41
+ const P = 'https://t1004940-ask.example';
42
+ const OTHER = 'https://t1004940-other.example';
43
+ const ADA = 91004940, BEN = 91004941, CY = 91004942;
44
+ const NOW = new Date('2026-10-29T12:00:00Z');
45
+ const q = (sql, params) => pool.query(sql, params);
46
+
47
+ async function cleanup() {
48
+ for (const project of [P, OTHER]) {
49
+ await q(`DELETE FROM market_watch_proposals WHERE project = $1`, [project]).catch(() => {});
50
+ await q(`DELETE FROM market_watch_ask_answers WHERE ask_id IN (SELECT id FROM market_watch_weekly_asks WHERE project = $1)`, [project]).catch(() => {});
51
+ await q(`DELETE FROM market_watch_weekly_asks WHERE project = $1`, [project]).catch(() => {});
52
+ await q(`DELETE FROM market_watch_items WHERE project = $1`, [project]).catch(() => {});
53
+ await q(`DELETE FROM market_watch_rule_versions WHERE project = $1`, [project]).catch(() => {});
54
+ }
55
+ await q(`DELETE FROM builders WHERE id = ANY($1::bigint[])`, [[ADA, BEN, CY]]).catch(() => {});
56
+ }
57
+
58
+ let failed = 0;
59
+ async function check(name, fn) {
60
+ try { await fn(); console.log(`ok - ${name}`); } catch (e) { failed += 1; console.log(`not ok - ${name}\n ${e && e.stack}`); }
61
+ }
62
+
63
+ try {
64
+ await q(M1);
65
+ await q(M2);
66
+ await q(M3);
67
+ await q(M3);
68
+ await cleanup();
69
+ await insertTestBuilders(pool, [{ id: ADA, login: 't1004940-ada' }, { id: BEN, login: 't1004940-ben' }, { id: CY, login: 't1004940-cy' }]);
70
+ await q(`INSERT INTO market_watch_rule_versions (project, version, status, reason) VALUES ($1, 1, 'active', 'test rules')`, [P]);
71
+ for (const [kind, name, category] of [
72
+ ['competitor', 'Lovable', 'AI app builder'], ['competitor', 'Bolt.new', 'AI app builder'],
73
+ ['competitor', 'Factory', 'AI software-engineering agent'], ['partner', 'Colony', 'DAO tooling'],
74
+ ]) await q(`INSERT INTO market_watch_items (project, kind, name, url, category) VALUES ($1, $2, $3, '', $4)`, [P, kind, name, category]);
75
+ const store = ask.createAskStore(pool);
76
+
77
+ await check('the week is issued once per project, with chips from the project\'s own items', async () => {
78
+ assert.equal((await ask.issueWeeklyAsk(store, { project: P, now: NOW })).created, 2);
79
+ assert.equal((await ask.issueWeeklyAsk(store, { project: P, now: NOW })).created, 0);
80
+ // Even a direct second insert is refused by the constraint, not by the caller.
81
+ assert.equal(await store.insertAsk(P, { weekOf: '2026-10-26', questionKey: 'rival', prompt: 'x', options: [], closesAt: NOW }), false);
82
+ const asks = await store.asksForWeek(P, '2026-10-26');
83
+ assert.deepEqual(asks.map((a) => a.question_key), ['rival', 'watch']);
84
+ assert.deepEqual(asks[0].options.map((o) => o.label), ['AI app builder', 'AI software-engineering agent']);
85
+ assert.deepEqual(asks[1].options.map((o) => o.label), ['DAO tooling']);
86
+ });
87
+
88
+ await check('three members, one pool: one proposal, three agree, and the card is gone for each', async () => {
89
+ const r1 = await ask.answer(store, { project: P, builderId: ADA, now: NOW, body: { picks: [{ question: 'rival', choice: 'AI app builder' }], names: [{ question: 'rival', text: 'Kilo Code' }] } });
90
+ assert.equal(r1.status, 200, JSON.stringify(r1.body));
91
+ assert.deepEqual([r1.body.answers, r1.body.proposals], [2, 2]);
92
+ const r2 = await ask.answer(store, { project: P, builderId: BEN, now: NOW, body: { names: [{ question: 'rival', text: 'kilo-code' }] } });
93
+ assert.deepEqual([r2.body.proposals, r2.body.pooled], [0, 1]);
94
+ const r3 = await ask.answer(store, { project: P, builderId: CY, now: NOW, body: { names: [{ question: 'rival', text: 'The Kilo Code' }] } });
95
+ assert.deepEqual([r3.body.proposals, r3.body.pooled], [0, 1]);
96
+ const again = await ask.answer(store, { project: P, builderId: ADA, now: NOW, body: { picks: [{ question: 'rival', choice: 'AI app builder' }] } });
97
+ assert.equal(again.body.answers, 0, 'a second tap is a no-op');
98
+ const team = await store.teamProposals(P, '2026-10-26');
99
+ assert.equal(team.length, 2);
100
+ assert.deepEqual([team[0].payload.name, team[0].agree, team[0].who], ['Kilo Code', 3, 't1004940-ada']);
101
+ const { rows } = await q(`SELECT kind, origin, answer_id, rule_version_id FROM market_watch_proposals WHERE project = $1 ORDER BY id`, [P]);
102
+ assert.equal(rows[0].kind, 'rule_change');
103
+ assert.ok(rows[0].rule_version_id && rows.every((r) => r.origin === 'weekly_ask' && r.answer_id));
104
+ for (const b of [ADA, BEN, CY]) assert.equal(await ask.cardFor(store, { project: P, builderId: b, now: NOW }), null);
105
+ });
106
+
107
+ await check('an answer cannot land on another project\'s ask', async () => {
108
+ await ask.issueWeeklyAsk(store, { project: OTHER, now: NOW });
109
+ const [other] = await store.asksForWeek(OTHER, '2026-10-26');
110
+ const id = await store.tx((t) => t.insertAnswer({ project: P, askId: other.id, builderId: ADA, choice: 'x', freeText: null }));
111
+ assert.equal(id, null);
112
+ });
113
+ } finally {
114
+ await cleanup();
115
+ await pool.end();
116
+ }
117
+
118
+ if (failed) {
119
+ console.log(`market_watch_ask_db.mjs: ${failed} failed`);
120
+ process.exit(1);
121
+ }
122
+ console.log('market_watch_ask_db.mjs: all passed');
@@ -28,6 +28,7 @@ const db = require('../modules/market-watch/db.js');
28
28
 
29
29
  const MIGRATION = readFileSync(new URL('../modules/market-watch/migrations/market_watch_001_tables.sql', import.meta.url), 'utf8');
30
30
  const MIGRATION_002 = readFileSync(new URL('../modules/market-watch/migrations/market_watch_002_run.sql', import.meta.url), 'utf8');
31
+ const MIGRATION_003 = readFileSync(new URL('../modules/market-watch/migrations/market_watch_003_ask_pool.sql', import.meta.url), 'utf8');
31
32
  const run = require('../modules/market-watch/run.js');
32
33
  const store = require('../modules/market-watch/store.js');
33
34
 
@@ -80,6 +81,9 @@ try {
80
81
  // 'run' proposal origin. A new migration here joins this file (CI never applies it).
81
82
  await q(MIGRATION_002);
82
83
  await q(MIGRATION_002);
84
+ // market_watch_003 (task 1004940): the weekly ask's pool-lookup index.
85
+ await q(MIGRATION_003);
86
+ await q(MIGRATION_003);
83
87
  const { rows } = await q(`SELECT count(*)::int AS n FROM information_schema.tables WHERE table_name LIKE 'market\\_watch\\_%'`);
84
88
  assert.ok(rows[0].n >= 10, `tables: ${rows[0].n}`);
85
89
  });
@@ -2,7 +2,7 @@
2
2
  // the loader, and an instance boots with it present and DISABLED (task 1004935, goal
3
3
  // 1000137). DB-free, so it runs in the unit lane.
4
4
  //
5
- // The module mounts no routes yet; task 1004936 gave it its tables and a one-shot hub seed
5
+ // Task 1004940 mounted its first routes (the weekly ask); task 1004936 gave it its tables and a one-shot hub seed
6
6
  // poller (tests/market_watch_data.mjs covers those). What this file
7
7
  // pins is everything the later Market Watch tasks will stand on:
8
8
  // 1. the manifest validates, ships default:false, and declares spend.requiresPayer;
@@ -26,7 +26,7 @@ const mods = require('../src/modules.js');
26
26
  const bundles = require('../modules/provisioning/starter-bundles.js');
27
27
  const scopeMap = require('../src/bongos/module-scope-map.js');
28
28
 
29
- test('the manifest validates, ships disabled, declares a payer, and contributes only its data', () => {
29
+ test('the manifest validates, ships disabled, declares a payer, and contributes what the goal has built', () => {
30
30
  const v = validateManifest(manifest);
31
31
  assert.equal(v.valid, true, `manifest errors: ${v.errors.join('; ')}`);
32
32
  assert.equal(manifest.key, KEY);
@@ -34,9 +34,13 @@ test('the manifest validates, ships disabled, declares a payer, and contributes
34
34
  assert.equal(manifest.spend && manifest.spend.requiresPayer, true,
35
35
  'every run spends the project AI budget, so the payer guard is declared before the first route');
36
36
  // Task 1004936 added its tables and the one-shot hub seed; task 1004937 the weekly-run
37
- // poller. There are still no routes.
38
- assert.deepEqual(manifest.contributes, { migrations: true, pollers: ['hub-seed', 'weekly-run'] },
39
- 'its tables, the hub seed and the weekly run, nothing to mount');
37
+ // poller; task 1004940 the weekly ask (its poller, its routes and its hall slice). Each
38
+ // screen task adds its own entries, so this pins what must be there, not the whole list.
39
+ const c = manifest.contributes;
40
+ assert.equal(c.migrations, true);
41
+ for (const p of ['hub-seed', 'weekly-run', 'weekly-ask']) assert.ok(c.pollers.includes(p), `poller ${p}`);
42
+ assert.ok(c.routes.includes('ask'), 'the weekly ask routes');
43
+ assert.ok(c.uiSections.includes('weekly-ask'), 'the weekly ask hall slice (hallWidgetScripts needs a uiSection)');
40
44
  });
41
45
 
42
46
  test('the loader discovers it from the live tree — loaded, not an error, not skipped', () => {
@@ -57,7 +57,7 @@ const fakeRes = () => ({
57
57
  // The router is built against the REAL module-api and the REAL goal-category; only
58
58
  // the two calls that would reach Postgres are swapped. Stubbing the row builders
59
59
  // instead would prove nothing about the shape the page actually receives.
60
- const WORKING_REWARD = { rewardConfig: () => ({ marginPct: 20, usdPeg: 1, mode: 'cost-plus-only', perTaskCredit: false, ideatorCredit: true }) };
60
+ const WORKING_REWARD = { rewardConfig: () => ({ marginPct: 20, costScale: 1, mode: 'cost-plus-only', perTaskCredit: false, ideatorCredit: true }) };
61
61
  function buildRouterWithStubs({ timer, categories, reward = WORKING_REWARD }) {
62
62
  const saved = {
63
63
  resolveRotDays: api.resolveRotDays,
@@ -0,0 +1,200 @@
1
+ // tests/reward_cost_scale.mjs — task 1004968 (goal 1000092, ADR 0387).
2
+ //
3
+ // The reward sizing factor was called `usdPeg`, which read as an exchange rate
4
+ // between a credit and a dollar. It is now `costScale`. This file pins three
5
+ // things the rename must not change:
6
+ //
7
+ // 1. AWARDS ARE BYTE-IDENTICAL. The amounts below are LITERALS computed with
8
+ // the pre-rename formula (round(cost × 1.20 × peg)), not re-derived from the
9
+ // code under test, so a change to the formula reds here. They are checked
10
+ // for the neutral pack and for a host pack that sets ONLY the old key.
11
+ // 2. THE OLD KEY STILL WORKS. A pack carrying only `usdPeg` resolves to the
12
+ // same factor, through the REAL branding read (a child process per pack,
13
+ // because branding() is memoised), with a one-line deprecation note printed
14
+ // once. When both keys are set, `costScale` wins.
15
+ // 3. NO LEDGER ROW IS REWRITTEN. No migration names either key: the rename
16
+ // lives in config and code only, so nothing converts, rescales or rewrites
17
+ // an existing credit_log row.
18
+
19
+ import { createRequire } from 'node:module';
20
+ import { strict as assert } from 'node:assert';
21
+ import { execFileSync } from 'node:child_process';
22
+ import fs from 'node:fs';
23
+ import os from 'node:os';
24
+ import path from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { makeRunner } from './helpers.mjs';
27
+
28
+ const require = createRequire(import.meta.url);
29
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
30
+ const {
31
+ rewardPolicy, resolveCostScale, rewardPolicyDeprecation, USD_PEG_DEPRECATION,
32
+ } = require('../modules/economy/reward-policy.js');
33
+ const { computeSessionTokenReward } = require('../modules/economy/credits.js');
34
+
35
+ const { test, summary } = makeRunner();
36
+
37
+ // [true cost USD, awarded credits at scale 1, awarded credits at scale 2.5].
38
+ // Computed with the PRE-rename formula; literals on purpose.
39
+ const PINNED = [
40
+ [0, 0, 0],
41
+ [0.40, 0, 1],
42
+ [0.42, 1, 1],
43
+ [1.65, 2, 5],
44
+ [7.3333, 9, 22],
45
+ [15, 18, 45],
46
+ [100, 120, 300],
47
+ ];
48
+
49
+ function award(policy, cost) {
50
+ return computeSessionTokenReward(cost, policy.marginPct, policy.costScale);
51
+ }
52
+
53
+ await test('neutral pack: awards and descriptions are byte-identical to before the rename', () => {
54
+ const neutral = JSON.parse(fs.readFileSync(path.join(ROOT, 'config/branding.neutral.json'), 'utf8'));
55
+ // The neutral pack carries NEITHER key: a host pack deep-merges over it, so a
56
+ // neutral `costScale: 1` would win over a host's `usdPeg` and silently reset
57
+ // that host's awards to scale 1. The default 1 lives in code instead.
58
+ assert.equal(neutral.reward.costScale, undefined, 'neutral must not shadow a host usdPeg');
59
+ assert.equal(neutral.reward.usdPeg, undefined, 'and no longer carries the old key');
60
+ const p = rewardPolicy(neutral.reward);
61
+ for (const [cost, at1] of PINNED) {
62
+ const r = award(p, cost);
63
+ assert.equal(r.drachmae, at1, `$${cost} at the neutral pack`);
64
+ }
65
+ // At scale 1 the description carries no factor at all, so it is unchanged too.
66
+ assert.equal(award(p, 15).description, '$15.00 true cost × 1.20 = 18 drachmae (token reward)');
67
+ });
68
+
69
+ await test('a pack that sets ONLY usdPeg awards exactly what it did before', () => {
70
+ const p = rewardPolicy({ marginPct: 20, usdPeg: 2.5 });
71
+ assert.equal(p.costScale, 2.5, 'the old key is still read');
72
+ for (const [cost, , at25] of PINNED) {
73
+ assert.equal(award(p, cost).drachmae, at25, `$${cost} at usdPeg 2.5`);
74
+ }
75
+ // A pack using the old key and one using the new key are indistinguishable.
76
+ const q = rewardPolicy({ marginPct: 20, costScale: 2.5 });
77
+ for (const [cost] of PINNED) assert.deepEqual(award(q, cost), award(p, cost));
78
+ });
79
+
80
+ await test('the factor is written as a scale, never a peg (ADR 0387)', () => {
81
+ const d = award(rewardPolicy({ usdPeg: 2 }), 15).description;
82
+ assert.equal(d, '$15.00 true cost × 1.20 × 2 scale = 36 drachmae (token reward)');
83
+ assert.doesNotMatch(d, /peg/i);
84
+ });
85
+
86
+ await test('both keys set: costScale wins', () => {
87
+ assert.equal(resolveCostScale({ costScale: 3, usdPeg: 2 }), 3);
88
+ assert.equal(rewardPolicy({ costScale: 3, usdPeg: 2 }).costScale, 3);
89
+ });
90
+
91
+ await test('an invalid costScale falls back to a working usdPeg, then to 1', () => {
92
+ for (const bad of [0, -1, 'x', null, undefined, NaN, Infinity]) {
93
+ assert.equal(resolveCostScale({ costScale: bad, usdPeg: 2 }), 2, `costScale=${String(bad)} → usdPeg`);
94
+ assert.equal(resolveCostScale({ costScale: bad }), 1, `costScale=${String(bad)} alone → 1`);
95
+ assert.equal(resolveCostScale({ usdPeg: bad }), 1, `usdPeg=${String(bad)} alone → 1 (as before)`);
96
+ }
97
+ assert.equal(resolveCostScale(null), 1);
98
+ });
99
+
100
+ await test('the deprecation note: only for a pack still spelling usdPeg alone', () => {
101
+ assert.equal(rewardPolicyDeprecation({ usdPeg: 1 }), USD_PEG_DEPRECATION);
102
+ assert.equal(rewardPolicyDeprecation({ costScale: 1 }), null);
103
+ assert.equal(rewardPolicyDeprecation({ costScale: 1, usdPeg: 1 }), null, 'costScale wins, so the old key is unused');
104
+ assert.equal(rewardPolicyDeprecation({}), null);
105
+ assert.equal(rewardPolicyDeprecation(null), null);
106
+ assert.equal(USD_PEG_DEPRECATION.split('\n').length, 1, 'one line');
107
+ assert.match(USD_PEG_DEPRECATION, /costScale/);
108
+ });
109
+
110
+ // The REAL branding read: rewardConfig() through the module port, with a host
111
+ // pack that deep-merges over neutral. One child per pack, because branding() is
112
+ // memoised for the life of a process.
113
+ function resolveInChild(reward) {
114
+ const file = path.join(os.tmpdir(), `bongos-cost-scale-${process.pid}-${Object.keys(reward).join('-')}.json`);
115
+ fs.writeFileSync(file, JSON.stringify({ reward }));
116
+ const script = `
117
+ const credits = require(${JSON.stringify(path.join(ROOT, 'modules/economy/credits.js'))});
118
+ const a = credits.rewardConfig();
119
+ const b = credits.rewardConfig();
120
+ process.stdout.write('\\nRESULT=' + JSON.stringify({ a, b, award: credits.computeSessionTokenReward(1.65, a.marginPct, a.costScale) }) + '\\n');
121
+ `;
122
+ try {
123
+ const out = execFileSync(process.execPath, ['-e', script], {
124
+ cwd: ROOT,
125
+ env: { ...process.env, BONGOS_BRANDING_FILE: file },
126
+ encoding: 'utf8',
127
+ stdio: ['ignore', 'pipe', 'pipe'],
128
+ });
129
+ // The deprecation note goes through the module logger, which may share
130
+ // stdout, so the result line is marked rather than being the whole output.
131
+ const line = out.split('\n').find((l) => l.startsWith('RESULT='));
132
+ return { result: JSON.parse(line.slice('RESULT='.length)) };
133
+ } finally {
134
+ fs.rmSync(file, { force: true });
135
+ }
136
+ }
137
+
138
+ function stderrOfChild(reward) {
139
+ const file = path.join(os.tmpdir(), `bongos-cost-scale-err-${process.pid}-${Object.keys(reward).join('-')}.json`);
140
+ fs.writeFileSync(file, JSON.stringify({ reward }));
141
+ const errFile = `${file}.err`;
142
+ const script = `
143
+ const credits = require(${JSON.stringify(path.join(ROOT, 'modules/economy/credits.js'))});
144
+ credits.rewardConfig(); credits.rewardConfig(); credits.rewardConfig();
145
+ `;
146
+ const fd = fs.openSync(errFile, 'w');
147
+ try {
148
+ execFileSync(process.execPath, ['-e', script], {
149
+ cwd: ROOT,
150
+ env: { ...process.env, BONGOS_BRANDING_FILE: file },
151
+ // Both streams: the note goes through the module logger, and the test must
152
+ // not depend on which stream the logger writes to.
153
+ stdio: ['ignore', fd, fd],
154
+ });
155
+ return fs.readFileSync(errFile, 'utf8');
156
+ } finally {
157
+ fs.closeSync(fd);
158
+ fs.rmSync(file, { force: true });
159
+ fs.rmSync(errFile, { force: true });
160
+ }
161
+ }
162
+
163
+ await test('real branding read: a usdPeg-only host pack resolves and awards as before', () => {
164
+ const { result } = resolveInChild({ usdPeg: 2.5 });
165
+ assert.equal(result.a.costScale, 2.5);
166
+ assert.equal(result.award.drachmae, 5, 'the pinned $1.65 award at 2.5');
167
+ });
168
+
169
+ await test('real branding read: costScale wins over usdPeg in a host pack', () => {
170
+ const { result } = resolveInChild({ costScale: 3, usdPeg: 2 });
171
+ assert.equal(result.a.costScale, 3);
172
+ });
173
+
174
+ await test('real branding read: the note prints ONCE for a usdPeg pack, never for a costScale pack', () => {
175
+ const legacy = stderrOfChild({ usdPeg: 2 });
176
+ const hits = legacy.split(USD_PEG_DEPRECATION).length - 1;
177
+ assert.equal(hits, 1, `printed exactly once across three resolutions (stderr: ${legacy})`);
178
+ const moved = stderrOfChild({ costScale: 2 });
179
+ assert.ok(!moved.includes(USD_PEG_DEPRECATION), 'a moved pack is not nagged');
180
+ });
181
+
182
+ await test('no migration converts, rescales or rewrites a credit_log row for this rename', () => {
183
+ const dirs = [path.join(ROOT, 'migrations')];
184
+ const modulesDir = path.join(ROOT, 'modules');
185
+ for (const m of fs.readdirSync(modulesDir)) {
186
+ const d = path.join(modulesDir, m, 'migrations');
187
+ if (fs.existsSync(d)) dirs.push(d);
188
+ }
189
+ let scanned = 0;
190
+ for (const d of dirs) {
191
+ for (const f of fs.readdirSync(d).filter((n) => n.endsWith('.sql'))) {
192
+ scanned += 1;
193
+ const sql = fs.readFileSync(path.join(d, f), 'utf8');
194
+ assert.doesNotMatch(sql, /costScale|cost_scale|usdPeg|usd_peg/i, `${f} must not touch the sizing factor`);
195
+ }
196
+ }
197
+ assert.ok(scanned > 100, `the scan saw the migration set (${scanned} files), not an empty directory`);
198
+ });
199
+
200
+ summary();