@bongos/core 1.24.3 → 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 (52) hide show
  1. package/.bongos-core.json +106 -46
  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 -2
  13. package/modules/discord/ship-broadcast.js +7 -0
  14. package/modules/economy/credit-wording.js +93 -0
  15. package/modules/economy/credits.js +26 -12
  16. package/modules/economy/reward-policy.js +49 -3
  17. package/modules/economy/reward.js +5 -0
  18. package/modules/government/routes/government.js +7 -0
  19. package/modules/ideas/routes/inbox.js +6 -0
  20. package/modules/lifecycle/routes/project-settings.js +6 -4
  21. package/modules/market-watch/ask.js +425 -0
  22. package/modules/market-watch/migrations/market_watch_003_ask_pool.sql +21 -0
  23. package/modules/market-watch/module.json +10 -3
  24. package/modules/market-watch/pollers/weekly-ask.js +89 -0
  25. package/modules/market-watch/public/hall-widget.js +271 -0
  26. package/modules/market-watch/public/weekly-ask.css +39 -0
  27. package/modules/market-watch/routes/ask.js +95 -0
  28. package/modules/onboarding/routes/access-requests.js +38 -10
  29. package/modules/platform-identity/account-logins.js +13 -0
  30. package/modules/platform-identity/routes/my-projects.js +10 -7
  31. package/modules/platform-identity/routes/sso.js +11 -6
  32. package/package-lock.json +2 -2
  33. package/package.json +1 -1
  34. package/release-notes.json +17 -1
  35. package/scripts/bongos/backfill-subagent-rewards.js +7 -7
  36. package/scripts/bongos/run-unit-tests.js +4 -0
  37. package/scripts/hall-preview/server.js +13 -0
  38. package/src/bongos/auth-idp.js +17 -5
  39. package/src/bongos/db-kernel.js +14 -1
  40. package/src/bongos/module-scope-map.js +1 -0
  41. package/src/module-api.js +1 -1
  42. package/tests/account_logins.mjs +6 -4
  43. package/tests/backfill_subagent_rewards.mjs +7 -7
  44. package/tests/credit_grant.mjs +7 -7
  45. package/tests/credit_wording.mjs +154 -0
  46. package/tests/google_halls.mjs +211 -0
  47. package/tests/market_watch_ask.mjs +369 -0
  48. package/tests/market_watch_ask_db.mjs +122 -0
  49. package/tests/market_watch_data_db.mjs +4 -0
  50. package/tests/market_watch_module.mjs +9 -5
  51. package/tests/project_settings_page.mjs +1 -1
  52. package/tests/reward_cost_scale.mjs +200 -0
@@ -10812,7 +10812,23 @@
10812
10812
  "text": "The payments decision record now carries the owner's answers: credits can be tuned but not switched off, pay rules live on the settings page, and Verro is Verro Labs."
10813
10813
  }
10814
10814
  ],
10815
- "1.24.3": [
10815
+ "1.24.4": [
10816
+ {
10817
+ "id": "1004968",
10818
+ "text": "The setting that sizes credit rewards is now called a cost scale instead of a dollar peg, so it no longer reads like an exchange rate; old project settings keep working and nobody's rewards change."
10819
+ },
10820
+ {
10821
+ "id": "1004940",
10822
+ "text": "Every member of a project with Market Watch on now gets a 30-second weekly card in the hall asking what counts as a competitor and what else to watch, and each tap becomes a proposal the owner sees, with matching answers count"
10823
+ },
10824
+ {
10825
+ "id": "1004969",
10826
+ "text": "AI-written ship summaries, new ideas and board proposals that describe credits or models as an investment are now flagged for review, and a test fails if the project's own pages or guides ever say it."
10827
+ },
10828
+ {
10829
+ "id": "1004994",
10830
+ "text": "Someone who signed up with Google, with no GitHub account, can now apply to a project and sign in to its hall like anyone else, so they can work as an ideator or artist, earn credits and hold a rank there. Projects still on an"
10831
+ },
10816
10832
  {
10817
10833
  "id": "1005006",
10818
10834
  "text": "The Treasury screen now counts builder rewards in credits instead of dollars, and no longer says one credit is a dollar."
@@ -111,9 +111,9 @@ const round4 = (n) => Math.round((Number(n) || 0) * 10000) / 10000;
111
111
  // unit-tested with no DB (tests/backfill_subagent_rewards.mjs).
112
112
  //
113
113
  // `row` is the shape fetchCandidates() returns. `cutoffMs` is the dedup-fix
114
- // boundary; `marginPct`/`usdPeg` come from the instance reward policy so the
114
+ // boundary; `marginPct`/`costScale` come from the instance reward policy so the
115
115
  // arithmetic matches what actually pays.
116
- function classifySession(row, { cutoffMs, marginPct, usdPeg } = {}) {
116
+ function classifySession(row, { cutoffMs, marginPct, costScale } = {}) {
117
117
  const sessionId = String(row.session_id);
118
118
  const builderId = Number(row.builder_id);
119
119
  const paid = Number(row.paid) || 0;
@@ -159,7 +159,7 @@ function classifySession(row, { cutoffMs, marginPct, usdPeg } = {}) {
159
159
  row.model_usage, row.total_tokens
160
160
  );
161
161
  const trueCostUsd = round4(interactiveUsd + subagentUsd);
162
- const { drachmae: owed } = credits.computeSessionTokenReward(trueCostUsd, marginPct, usdPeg);
162
+ const { drachmae: owed } = credits.computeSessionTokenReward(trueCostUsd, marginPct, costScale);
163
163
  const delta = owed - paid;
164
164
 
165
165
  if (delta > 0) {
@@ -177,10 +177,10 @@ function classifySession(row, { cutoffMs, marginPct, usdPeg } = {}) {
177
177
  function buildBackfillPlan(rows, opts = {}) {
178
178
  const cutoffMs = Date.parse(opts.since || DEDUP_FIX_AT);
179
179
  const marginPct = opts.marginPct;
180
- const usdPeg = opts.usdPeg;
180
+ const costScale = opts.costScale;
181
181
 
182
182
  const sessions = (Array.isArray(rows) ? rows : [])
183
- .map((r) => classifySession(r, { cutoffMs, marginPct, usdPeg }));
183
+ .map((r) => classifySession(r, { cutoffMs, marginPct, costScale }));
184
184
 
185
185
  const counts = {};
186
186
  for (const k of Object.values(CLASSES)) counts[k] = 0;
@@ -393,13 +393,13 @@ async function main() {
393
393
  return 2;
394
394
  }
395
395
 
396
- const { marginPct, usdPeg } = credits.rewardConfig();
396
+ const { marginPct, costScale } = credits.rewardConfig();
397
397
  const pool = loadPool();
398
398
  let plan;
399
399
  let rows;
400
400
  try {
401
401
  rows = await fetchCandidates(pool);
402
- plan = buildBackfillPlan(rows, { since, marginPct, usdPeg });
402
+ plan = buildBackfillPlan(rows, { since, marginPct, costScale });
403
403
  } catch (err) {
404
404
  console.error(`could not read the ledger: ${err && err.message ? err.message : err}`);
405
405
  await pool.end().catch(() => {});
@@ -93,6 +93,10 @@ const INTEGRATION = new Set([
93
93
  // idempotent and a resumed run unable to record an item twice. DB-free sibling:
94
94
  // market_watch_data.
95
95
  'market_watch_data_db',
96
+ // task 1004940: the weekly ask's SQL store — once per week by constraint, one pooled
97
+ // proposal per similar answer, the project guard on an answer. DB-free sibling:
98
+ // market_watch_ask.
99
+ 'market_watch_ask_db',
96
100
  // bfg, bfg_write, memory_isolation, memory_pull and setup_smoke LEFT this set under task
97
101
  // 1004865: none of them ever needed a database. Each failed on a stale test harness (a fake
98
102
  // server without res.fail, a stub on the wrong module, an old error shape, a renamed session
@@ -188,6 +188,8 @@ const CANNED_WRITES = [
188
188
  // the owner choosing what a speciality offers (task 1004404): on create, and
189
189
  // afterwards from the card's "Offered skills" editor.
190
190
  ['POST', /^specialities$/, 'specialities.post'],
191
+ // the Market Watch weekly ask's answer (task 1004940), so its "sent." state renders.
192
+ ['POST', /^market-watch\/ask\/answer$/, 'market-watch__ask__answer.post'],
191
193
  ['PATCH', /^specialities\/\d+$/, 'specialities__12.patch'],
192
194
  // a vote cast from the street's matter card (task 1004528), so the card's
193
195
  // answer (the toast, the building lighting) renders here too.
@@ -423,6 +425,17 @@ for (const key of MODULES_ON) {
423
425
  const isOn = (k) => !!(mods.enabled && mods.enabled[k]);
424
426
  if (stamped['index.html']) stamped['index.html'] = injectModuleHallWidgets(stamped['index.html'], hallWidgetScripts({ isEnabled: isOn }));
425
427
  if (stamped['task.html']) stamped['task.html'] = injectModuleHallWidgets(stamped['task.html'], hallWidgetScripts({ isEnabled: isOn, file: 'task-widget.js' }), 'task');
428
+ // ...and their assets, for the same set. The real asset route (serve-internal.js) gates on
429
+ // the instance config's enabled set, which ignores --modules-on, so a widget injected for
430
+ // a module this checkout's config leaves off 404'd and the slot stayed empty (task 1004940).
431
+ app.get(/^\/modules\/([a-z][a-z0-9-]*)\/public\/([A-Za-z0-9_-][A-Za-z0-9._-]*)$/, (req, res, next) => {
432
+ // One file name, never a dot-led segment: `..` (or a dotfile) cannot match.
433
+ const key = req.params[0];
434
+ if (!isOn(key)) return next();
435
+ const abs = path.join(ROOT, 'modules', key, 'public', req.params[1]);
436
+ if (!fs.existsSync(abs)) return next();
437
+ return res.sendFile(abs);
438
+ });
426
439
  }
427
440
 
428
441
  function sendPage(res, name) {
@@ -31,11 +31,19 @@ const { loadIdpConfig } = require('./auth-config.js');
31
31
  const { newSessionToken } = require('./auth-github.js');
32
32
  const log = require('./logger').logger.child({ src: 'auth-idp' });
33
33
 
34
+ // This hall can take a person who joined with Google: a negative person key and
35
+ // a stand-in `~handle` login (ADR 0393/0396, task 1004994). The hub sends such a
36
+ // person only to a hall whose authorize link says so, because an older core must
37
+ // never receive one. Mirrored, boundary-forced, by the hub's PERSON_KEYS_PARAM
38
+ // (modules/platform-identity/account-logins.js); tests/google_halls.mjs pins both.
39
+ const PERSON_KEYS_PARAM = 'person_keys';
40
+
34
41
  function idpAuthorizeUrl(origin, clientId, state, redirectUri) {
35
42
  const u = new URL(`${String(origin).replace(/\/+$/, '')}/api/bongos/sso/authorize`);
36
43
  u.searchParams.set('client_id', clientId);
37
44
  u.searchParams.set('redirect_uri', redirectUri);
38
45
  u.searchParams.set('state', state);
46
+ u.searchParams.set(PERSON_KEYS_PARAM, '1');
39
47
  return u.toString();
40
48
  }
41
49
 
@@ -88,7 +96,7 @@ async function completeIdpFlow({ code, redirectUri, userAgent: ua }) {
88
96
  const ghLogin = identity.github_login;
89
97
  const existing = await getBuilderByGithubId(ghId);
90
98
  if (!existing && !openEnrollmentEnabled()) {
91
- const approved = await hasInvitedAccessRequest(ghLogin);
99
+ const approved = await hasInvitedAccessRequest(ghLogin, ghId);
92
100
  if (firstTimeAdmissionBlocked({ existing, approved, login: ghLogin })) {
93
101
  const err = new Error('not_approved');
94
102
  err.code = 'NOT_APPROVED';
@@ -286,9 +294,12 @@ function applicationVouchIdentity(token, { githubLogin, publicKey, clientId, now
286
294
  }
287
295
  // The body's login is a string anyone may type: a vouch for another account is no vouch.
288
296
  if (String(payload.applicant_login).toLowerCase() !== String(githubLogin || '').toLowerCase()) return null;
289
- // The hub mints only for a numeric GitHub id; anything else is not one, and the id
290
- // feeds readApplicantProfileFromHub (defence in depth, the ADR 0171 D2 guard).
291
- if (!/^\d+$/.test(String(payload.sub))) return null;
297
+ // The hub mints only for a numeric GitHub id, or for a person who joined with
298
+ // Google: a negative key that always travels with a stand-in `~handle` login
299
+ // (task 1004994). They come as a pair, so neither can borrow the other's shape.
300
+ // The id feeds readApplicantProfileFromHub (defence in depth, the ADR 0171 D2 guard).
301
+ const standIn = String(payload.applicant_login || '').startsWith('~');
302
+ if (!(standIn ? /^-\d+$/ : /^\d+$/).test(String(payload.sub))) return null;
292
303
  if (vouchJtiSeenAndRecord(payload.jti, payload.exp, nowMs)) return null;
293
304
  return {
294
305
  githubId: String(payload.sub),
@@ -321,7 +332,7 @@ async function completeDeviceExchange({ assertion, userAgent: ua }) {
321
332
  const ghLogin = payload.github_login;
322
333
  const existing = await getBuilderByGithubId(ghId);
323
334
  if (!existing && !openEnrollmentEnabled()) {
324
- const approved = await hasInvitedAccessRequest(ghLogin);
335
+ const approved = await hasInvitedAccessRequest(ghLogin, ghId);
325
336
  if (firstTimeAdmissionBlocked({ existing, approved, login: ghLogin })) {
326
337
  const err = new Error('not_approved');
327
338
  err.code = 'NOT_APPROVED';
@@ -380,6 +391,7 @@ module.exports = {
380
391
  idpAuthorizeUrl,
381
392
  loadHubPublicKey,
382
393
  logoutTokenJtiSeenAndRecord,
394
+ PERSON_KEYS_PARAM,
383
395
  readApplicationVouch,
384
396
  verifyHubApplicationVouch,
385
397
  verifyHubAssertion,
@@ -239,7 +239,20 @@ async function getBuilderByGithubId(githubId) {
239
239
  // row at status='invited', case-insensitive)? The approval signal that lets a
240
240
  // first-time web sign-in through. An invite older than 30 days no longer counts
241
241
  // (inviteLiveSql, task 1002969 / ADR 0356); an approved application never expires.
242
- async function hasInvitedAccessRequest(login) {
242
+ //
243
+ // A person who joined with Google (a negative person key, ADR 0393) is matched by
244
+ // the key the hub vouched for, never by their stand-in `~handle`: a handle can be
245
+ // renamed and later taken by someone else, so an approval of `~ana` must not
246
+ // admit whoever is `~ana` next (task 1004994).
247
+ async function hasInvitedAccessRequest(login, githubId) {
248
+ if (/^-\d+$/.test(String(githubId ?? ''))) {
249
+ const { rows } = await pool.query(
250
+ `SELECT 1 FROM access_requests
251
+ WHERE applicant_github_id = $1 AND status = 'invited' AND ${inviteLiveSql()} LIMIT 1`,
252
+ [String(githubId)]
253
+ );
254
+ return rows.length > 0;
255
+ }
243
256
  const { rows } = await pool.query(
244
257
  `SELECT 1 FROM access_requests
245
258
  WHERE lower(github_login) = lower($1) AND status = 'invited' AND ${inviteLiveSql()} LIMIT 1`,
@@ -198,6 +198,7 @@ const MODULE_GLOBS = {
198
198
  'modules/market-watch/', 'tests/market_watch_module.mjs',
199
199
  'tests/market_watch_data.mjs', 'tests/market_watch_data_db.mjs',
200
200
  'tests/market_watch_run.mjs',
201
+ 'tests/market_watch_ask.mjs', 'tests/market_watch_ask_db.mjs',
201
202
  ],
202
203
  // --- builder prefs (wandering/skill/sound/render) + builder-needs ---
203
204
  // Carved into modules/builder-settings/ (BV1.R80 / ADR 0093 §1): the five pref
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.3'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.4'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -307,7 +307,7 @@ test('GET /me/sign-in-methods fails closed on a read error', async () => {
307
307
  }
308
308
  });
309
309
 
310
- test('a Google-only person is not relayed to a project until halls can take one (task 1004994)', async () => {
310
+ test('a Google-only person goes on to the project lookup like anyone else (task 1004994)', async () => {
311
311
  ME = '-3';
312
312
  scriptRoute([]);
313
313
  try {
@@ -319,8 +319,10 @@ test('a Google-only person is not relayed to a project until halls can take one
319
319
  });
320
320
  r.on('error', reject); r.end(body);
321
321
  });
322
- assert.equal(res.status, 409);
323
- assert.equal(res.body.error.code, 'google_join_not_yet');
324
- assert.equal(routePool.log.length, 0, 'nothing was read or relayed');
322
+ // No project is scripted, so the lookup answers unknown_project: the point is
323
+ // that the join reached it rather than refusing the person first.
324
+ assert.equal(res.status, 404);
325
+ assert.equal(res.body.error.code, 'unknown_project');
326
+ assert.ok(routePool.log.length > 0, 'the registered project was looked up');
325
327
  } finally { ME = '4242'; }
326
328
  });
@@ -26,7 +26,7 @@ const { test, summary } = makeRunner();
26
26
 
27
27
  const AFTER = '2026-08-25T00:00:00Z'; // safely after the de-dup cutoff
28
28
  const BEFORE = '2026-08-20T00:00:00Z'; // safely before it
29
- const OPTS = { cutoffMs: Date.parse(DEDUP_FIX_AT), marginPct: 20, usdPeg: 1 };
29
+ const OPTS = { cutoffMs: Date.parse(DEDUP_FIX_AT), marginPct: 20, costScale: 1 };
30
30
 
31
31
  // 1M opus input tokens = $5.00 interactive (PRICES.opus.input = $5/M).
32
32
  const OPUS_1M = { 'claude-opus-4-8': { input: 1_000_000 } };
@@ -115,7 +115,7 @@ await test('no_subagent_record: the reason does not blame a failed cost hook for
115
115
  assert.doesNotMatch(note, /: the cost hook never ran/, 'does not assert the hook failure as the cause');
116
116
  assert.match(note, /cannot (be )?tell|cannot tell|indistinguishable|same trace/i, 'says the two cases look the same in the data');
117
117
  const plan = buildBackfillPlan([row({ subagent_usd: null, subagent_recorded_at: null })],
118
- { since: DEDUP_FIX_AT, marginPct: 20, usdPeg: 1 });
118
+ { since: DEDUP_FIX_AT, marginPct: 20, costScale: 1 });
119
119
  const { text } = renderReport(plan);
120
120
  assert.doesNotMatch(text, /Sessions whose cost hook never ran left no trace at all/,
121
121
  'the closing summary no longer reads every no-record session as a lost hook');
@@ -164,7 +164,7 @@ await test('a folded session never reaches the mint, and is reported', () => {
164
164
  const plan = buildBackfillPlan([
165
165
  row({ session_id: 's1' }), // +4
166
166
  row({ session_id: 's2', has_folded_usage: true }), // excluded
167
- ], { since: DEDUP_FIX_AT, marginPct: 20, usdPeg: 1 });
167
+ ], { since: DEDUP_FIX_AT, marginPct: 20, costScale: 1 });
168
168
  assert.equal(plan.grandTotal, 4, 'only the un-folded session is paid');
169
169
  assert.equal(plan.counts[CLASSES.ALREADY_FOLDED], 1);
170
170
  assert.match(renderReport(plan).text, /already_folded/);
@@ -193,7 +193,7 @@ await test('the plan groups payables by builder and totals them', () => {
193
193
  row({ session_id: 's3', builder_id: 9 }), // +4
194
194
  row({ session_id: 's4', builder_id: 9, legacy_rows: 1 }), // not paid
195
195
  row({ session_id: 's5', builder_id: 9, subagent_recorded_at: BEFORE }), // not paid
196
- ], { since: DEDUP_FIX_AT, marginPct: 20, usdPeg: 1 });
196
+ ], { since: DEDUP_FIX_AT, marginPct: 20, costScale: 1 });
197
197
 
198
198
  assert.equal(plan.totalCandidates, 5);
199
199
  assert.equal(plan.plans.length, 2);
@@ -208,14 +208,14 @@ await test('the plan reports what it does NOT settle (ADR 0173 objected to a sil
208
208
  const plan = buildBackfillPlan([
209
209
  row({ session_id: 's1', subagent_usd: 7.5, subagent_recorded_at: BEFORE }),
210
210
  row({ session_id: 's2', paid: 25 }),
211
- ], { since: DEDUP_FIX_AT, marginPct: 20, usdPeg: 1 });
211
+ ], { since: DEDUP_FIX_AT, marginPct: 20, costScale: 1 });
212
212
  assert.equal(plan.unpaidSubagentUsd, 7.5, 'inflated-but-real spend is surfaced, not hidden');
213
213
  assert.equal(plan.overpaidExcess, 15, 'correcting in one direction only is stated');
214
214
  assert.equal(plan.grandTotal, 0);
215
215
  });
216
216
 
217
217
  await test('an empty ledger plans nothing and totals zero', () => {
218
- const plan = buildBackfillPlan([], { since: DEDUP_FIX_AT, marginPct: 20, usdPeg: 1 });
218
+ const plan = buildBackfillPlan([], { since: DEDUP_FIX_AT, marginPct: 20, costScale: 1 });
219
219
  assert.equal(plan.grandTotal, 0);
220
220
  assert.equal(plan.plans.length, 0);
221
221
  assert.match(renderReport(plan).text, /NOTHING PAYABLE/);
@@ -223,7 +223,7 @@ await test('an empty ledger plans nothing and totals zero', () => {
223
223
 
224
224
  await test('the report leads with the grand total and names every refusal reason', () => {
225
225
  const plan = buildBackfillPlan([row(), row({ session_id: 's2', legacy_rows: 1 })],
226
- { since: DEDUP_FIX_AT, marginPct: 20, usdPeg: 1 });
226
+ { since: DEDUP_FIX_AT, marginPct: 20, costScale: 1 });
227
227
  const { text, total } = renderReport(plan, { loginFor: (id) => (id === 7 ? 'jaxri' : null) });
228
228
  assert.equal(total, 4);
229
229
  assert.match(text, /GRAND TOTAL to mint: 4 credits/);
@@ -490,20 +490,20 @@ await test('REWARD_MARGIN_PCT exported as 20', () => {
490
490
  // per-task ESTIMATE payout on. The cost-plus session reward is untouched.
491
491
  // =========================================================================
492
492
 
493
- await test('rewardPolicy: default (no mode) → pay both streams; margin/peg resolve', () => {
493
+ await test('rewardPolicy: default (no mode) → pay both streams; margin/scale resolve', () => {
494
494
  const p = rewardPolicy({});
495
495
  assert.equal(p.mode, REWARD_MODE_DEFAULT);
496
496
  assert.equal(p.perTaskCredit, true, 'a default instance pays the per-task estimate');
497
497
  assert.equal(p.marginPct, 20);
498
- assert.equal(p.usdPeg, 1);
498
+ assert.equal(p.costScale, 1);
499
499
  });
500
500
 
501
501
  await test('rewardPolicy: mode "cost-plus-only" → per-task estimate payout OFF, cost-plus untouched', () => {
502
- const p = rewardPolicy({ mode: 'cost-plus-only', marginPct: 20, usdPeg: 1 });
502
+ const p = rewardPolicy({ mode: 'cost-plus-only', marginPct: 20, costScale: 1 });
503
503
  assert.equal(p.mode, REWARD_MODE_COST_PLUS_ONLY);
504
504
  assert.equal(p.perTaskCredit, false, 'cost-plus-only suppresses the per-task estimate payout');
505
505
  assert.equal(p.marginPct, 20, 'the cost-plus margin is unaffected — that stream is still paid');
506
- assert.equal(p.usdPeg, 1);
506
+ assert.equal(p.costScale, 1);
507
507
  });
508
508
 
509
509
  await test('rewardPolicy: unknown/garbage mode → default (a typo never silently zeroes rewards)', () => {
@@ -546,11 +546,11 @@ await test('rewardPolicy: ideatorCredit is opt-out-only — only an explicit fal
546
546
  }
547
547
  });
548
548
 
549
- await test('rewardPolicy: cost-plus-only still honors a custom margin + peg', () => {
550
- const p = rewardPolicy({ mode: 'cost-plus-only', marginPct: 35, usdPeg: 2 });
549
+ await test('rewardPolicy: cost-plus-only still honors a custom margin + scale', () => {
550
+ const p = rewardPolicy({ mode: 'cost-plus-only', marginPct: 35, costScale: 2 });
551
551
  assert.equal(p.perTaskCredit, false);
552
552
  assert.equal(p.marginPct, 35);
553
- assert.equal(p.usdPeg, 2);
553
+ assert.equal(p.costScale, 2);
554
554
  });
555
555
 
556
556
  await test('insertMultipliedConfirmCredit: cost-plus-only mode writes NO credit_log row', async () => {
@@ -0,0 +1,154 @@
1
+ // tests/credit_wording.mjs — no investment wording next to credits or models
2
+ // (task 1004969; ADR 0385 decision 9).
3
+ //
4
+ // Credits are a contribution meter, not money and not a stake. This fails when
5
+ // the hall's words, the role packs, the onboarding docs or the branding pack
6
+ // describe credits or models as an investment, a return, appreciation, passive
7
+ // income, equity or ownership — and proves the server's publish paths FLAG such
8
+ // text in the log while publishing it unchanged.
9
+ //
10
+ // Run: node tests/credit_wording.mjs
11
+
12
+ import { test } from 'node:test';
13
+ import { strict as assert } from 'node:assert';
14
+ import { createRequire } from 'node:module';
15
+ import { fileURLToPath } from 'node:url';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const ROOT = fileURLToPath(new URL('..', import.meta.url));
21
+ const wording = require('../modules/economy/credit-wording.js');
22
+ const reward = require('../modules/economy/reward.js');
23
+ const shipBroadcast = require('../modules/discord/ship-broadcast.js');
24
+
25
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
26
+ function walk(rel) {
27
+ const out = [];
28
+ for (const e of fs.readdirSync(path.join(ROOT, rel), { withFileTypes: true })) {
29
+ const p = `${rel}/${e.name}`;
30
+ if (e.isDirectory()) out.push(...walk(p));
31
+ else if (/\.(md|mmd|json|txt|html)$/.test(e.name)) out.push(p);
32
+ }
33
+ return out;
34
+ }
35
+
36
+ // The corpus the task names: hall copy (the generated copy registry — every
37
+ // string the hall, status page and landing render), the role packs, the
38
+ // onboarding docs and the branding pack.
39
+ function corpus() {
40
+ const docs = [
41
+ 'config/branding.neutral.json',
42
+ ...fs.readdirSync(path.join(ROOT, 'docs/packs')).filter((f) => f.endsWith('.md')).map((f) => `docs/packs/${f}`),
43
+ ...walk('docs/onboarding'),
44
+ ].map((rel) => ({ where: rel, text: read(rel) }));
45
+ const registry = JSON.parse(read('docs/copy-registry.json'));
46
+ const copy = registry.entries.map((e) => ({ where: `hall copy ${e.file}:${e.line}`, text: e.text }));
47
+ return { docs, copy };
48
+ }
49
+
50
+ test('the corpus is really read (a scan that finds nothing to scan is not a pass)', () => {
51
+ const { docs, copy } = corpus();
52
+ assert.ok(docs.length >= 8, `expected the packs, onboarding docs and branding pack, read ${docs.length}`);
53
+ assert.ok(copy.length > 1000, `expected the hall copy registry, read ${copy.length} strings`);
54
+ assert.ok(docs.some((d) => /credit/i.test(d.text)) && copy.some((c) => /credit/i.test(c.text)), 'the corpus talks about credits at all');
55
+ });
56
+
57
+ test('no investment wording next to credits or models in hall copy, packs, onboarding or branding', () => {
58
+ const { docs, copy } = corpus();
59
+ const hits = [];
60
+ for (const { where, text } of [...docs, ...copy]) {
61
+ for (const h of wording.findInvestmentWording(text)) hits.push(`${where}: "${h.phrase}" near "${h.anchor}" — ${h.sentence.slice(0, 160)}`);
62
+ }
63
+ assert.deepEqual(hits, [], `credits and models are not an investment (ADR 0385 decision 9):\n ${hits.join('\n ')}`);
64
+ });
65
+
66
+ test('a planted phrase is caught — the probe that shows the scan can go red', () => {
67
+ const planted = [
68
+ ['Credits are a smart investment in the project.', 'investment'],
69
+ ['Hold your credits and watch for a return on them.', 'return'],
70
+ ['Your credits earn a return every month.', 'return'],
71
+ ['Expect appreciation of credits as the project grows.', 'appreciation'],
72
+ ['Credits will appreciate in value over time.', 'appreciation'],
73
+ ['Turn your credits into passive income.', 'passive income'],
74
+ ['Credits give you equity in the project.', 'equity'],
75
+ ['Owning the model means ownership of what it builds.', 'ownership'],
76
+ ['Investing in models pays off.', 'investment'],
77
+ ];
78
+ for (const [text, term] of planted) {
79
+ const hits = wording.findInvestmentWording(text);
80
+ assert.deepEqual(hits.map((h) => h.term), [term], `"${text}" should flag ${term}`);
81
+ }
82
+ // The same corpus with one planted sentence goes red.
83
+ const { docs } = corpus();
84
+ const doctored = `${docs[0].text}\n\nCredits are a smart investment.`;
85
+ assert.equal(wording.findInvestmentWording(doctored).length, 1);
86
+ });
87
+
88
+ test('the wording we want is left alone: denials, the verb "return", and investment words with no credit nearby', () => {
89
+ const fine = [
90
+ 'Credits are not an investment.',
91
+ 'They are not money, not redeemable and not equity.',
92
+ 'Credits measure contribution, never money, never equity.',
93
+ 'When a claim is released the task returns to ready, and no credits are awarded.',
94
+ 'Return to the hall to see your credits.',
95
+ 'Equity, if a project offers any, is granted by the project separately.',
96
+ 'We appreciate every credit you earn.',
97
+ ];
98
+ for (const text of fine) assert.deepEqual(wording.findInvestmentWording(text), [], text);
99
+ // Too far apart to be talking about each other.
100
+ const far = 'Credits are what you earn for shipped work. '.repeat(1) + 'Some unrelated paragraph about how a company may raise money, take outside investment and sell equity.';
101
+ assert.deepEqual(wording.findInvestmentWording(far), []);
102
+ });
103
+
104
+ test('flagInvestmentWording logs one line per hit, returns the hits, and never throws', () => {
105
+ const lines = [];
106
+ const hits = wording.flagInvestmentWording('Credits are an investment. Credits are not equity.', { where: 'ship summary', ref: 'task 7', log: (l) => lines.push(l) });
107
+ assert.equal(hits.length, 1);
108
+ assert.equal(lines.length, 1);
109
+ assert.match(lines[0], /^\[credit-wording\] flagged ship summary task 7: "investment" near "credits"/);
110
+ assert.match(lines[0], /published unchanged/);
111
+ assert.deepEqual(wording.flagInvestmentWording(null, { log: () => { throw new Error('x'); } }), []);
112
+ assert.deepEqual(wording.flagInvestmentWording('Credits are an investment.', { log: () => { throw new Error('log down'); } }), []);
113
+ });
114
+
115
+ test('the reward port carries both, so other modules reach it without importing economy', () => {
116
+ assert.equal(typeof reward.findInvestmentWording, 'function');
117
+ assert.equal(typeof reward.flagInvestmentWording, 'function');
118
+ assert.equal(reward.findInvestmentWording('Credits are an investment.').length, 1);
119
+ });
120
+
121
+ test('a flagged ship summary is RECORDED and posted unchanged — never blocked, never rewritten', async () => {
122
+ const summary = 'Builders can now treat their credits as an investment in the project.';
123
+ const posts = [];
124
+ const logs = [];
125
+ const r = await shipBroadcast.postShipBroadcast(
126
+ { id: 42, value_summary: summary, security_sensitive: false, kind: 'feature' },
127
+ { postWebhook: async (channel, line) => { posts.push([channel, line]); }, log: (l) => logs.push(l), reward },
128
+ );
129
+ assert.deepEqual(r.posted, ['ship_feed', 'ship_news']);
130
+ assert.equal(r.wording_flags, 1);
131
+ assert.deepEqual(posts, [['ship_feed', `#42 shipped — ${summary}`], ['ship_news', summary]], 'the text goes out exactly as written');
132
+ assert.equal(logs.filter((l) => l.startsWith('[credit-wording] flagged ship summary task 42')).length, 1);
133
+
134
+ // A clean summary logs nothing; no economy module means no check, still posted.
135
+ const quiet = [];
136
+ const clean = await shipBroadcast.postShipBroadcast({ id: 43, value_summary: 'The Treasury counts credits.', security_sensitive: false, kind: 'bug' }, { postWebhook: async () => {}, log: (l) => quiet.push(l), reward });
137
+ assert.equal(clean.wording_flags, 0);
138
+ assert.deepEqual(quiet, []);
139
+ const none = await shipBroadcast.postShipBroadcast({ id: 44, value_summary: summary, security_sensitive: false, kind: 'bug' }, { postWebhook: async () => {}, log: () => {}, reward: null });
140
+ assert.deepEqual(none.posted, ['ship_feed', 'ship_news']);
141
+ assert.equal(none.wording_flags, undefined);
142
+ });
143
+
144
+ test('the idea and board publish paths flag through the port and never return early on it', () => {
145
+ const inbox = read('modules/ideas/routes/inbox.js');
146
+ const gov = read('modules/government/routes/government.js');
147
+ for (const [src, where] of [[inbox, "where: 'idea'"], [gov, "where: 'board item'"]]) {
148
+ const at = src.indexOf(where);
149
+ assert.ok(at > 0, `${where} is wired`);
150
+ const block = src.slice(src.lastIndexOf("resolveOptional('reward')", at), at + 80);
151
+ assert.match(block, /flagInvestmentWording\(/);
152
+ assert.doesNotMatch(block, /return\s+res\.|res\.fail|throw /, 'a flag never refuses or rewrites the text');
153
+ }
154
+ });