@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.
- package/.bongos-core.json +106 -46
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +8 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/config/branding.neutral.json +0 -1
- package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +11 -0
- package/docs/api/openapi.json +121 -4
- package/docs/api-reference.md +8 -1
- package/docs/branding-contract.md +1 -1
- package/docs/module-api-changelog.md +2 -2
- package/modules/discord/ship-broadcast.js +7 -0
- package/modules/economy/credit-wording.js +93 -0
- package/modules/economy/credits.js +26 -12
- package/modules/economy/reward-policy.js +49 -3
- package/modules/economy/reward.js +5 -0
- package/modules/government/routes/government.js +7 -0
- package/modules/ideas/routes/inbox.js +6 -0
- package/modules/lifecycle/routes/project-settings.js +6 -4
- package/modules/market-watch/ask.js +425 -0
- package/modules/market-watch/migrations/market_watch_003_ask_pool.sql +21 -0
- package/modules/market-watch/module.json +10 -3
- package/modules/market-watch/pollers/weekly-ask.js +89 -0
- package/modules/market-watch/public/hall-widget.js +271 -0
- package/modules/market-watch/public/weekly-ask.css +39 -0
- package/modules/market-watch/routes/ask.js +95 -0
- package/modules/onboarding/routes/access-requests.js +38 -10
- package/modules/platform-identity/account-logins.js +13 -0
- package/modules/platform-identity/routes/my-projects.js +10 -7
- package/modules/platform-identity/routes/sso.js +11 -6
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +17 -1
- package/scripts/bongos/backfill-subagent-rewards.js +7 -7
- package/scripts/bongos/run-unit-tests.js +4 -0
- package/scripts/hall-preview/server.js +13 -0
- package/src/bongos/auth-idp.js +17 -5
- package/src/bongos/db-kernel.js +14 -1
- package/src/bongos/module-scope-map.js +1 -0
- package/src/module-api.js +1 -1
- package/tests/account_logins.mjs +6 -4
- package/tests/backfill_subagent_rewards.mjs +7 -7
- package/tests/credit_grant.mjs +7 -7
- package/tests/credit_wording.mjs +154 -0
- package/tests/google_halls.mjs +211 -0
- package/tests/market_watch_ask.mjs +369 -0
- package/tests/market_watch_ask_db.mjs +122 -0
- package/tests/market_watch_data_db.mjs +4 -0
- package/tests/market_watch_module.mjs +9 -5
- package/tests/project_settings_page.mjs +1 -1
- package/tests/reward_cost_scale.mjs +200 -0
package/release-notes.json
CHANGED
|
@@ -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.
|
|
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`/`
|
|
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,
|
|
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,
|
|
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
|
|
180
|
+
const costScale = opts.costScale;
|
|
181
181
|
|
|
182
182
|
const sessions = (Array.isArray(rows) ? rows : [])
|
|
183
|
-
.map((r) => classifySession(r, { cutoffMs, marginPct,
|
|
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,
|
|
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,
|
|
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) {
|
package/src/bongos/auth-idp.js
CHANGED
|
@@ -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
|
|
290
|
-
//
|
|
291
|
-
|
|
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,
|
package/src/bongos/db-kernel.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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');
|
package/tests/account_logins.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
assert.equal(
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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/);
|
package/tests/credit_grant.mjs
CHANGED
|
@@ -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/
|
|
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.
|
|
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,
|
|
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.
|
|
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 +
|
|
550
|
-
const p = rewardPolicy({ mode: 'cost-plus-only', marginPct: 35,
|
|
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.
|
|
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
|
+
});
|