@bongos/core 1.24.3 → 1.24.5
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 +142 -67
- 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/copy-inventory.md +11 -11
- package/docs/copy-registry.json +12 -12
- package/docs/file-map.md +3 -2
- package/docs/module-api-changelog.md +2 -2
- package/docs/page-readings.json +1 -1
- package/modules/copy-desk/auto-close.js +46 -8
- package/modules/copy-desk/page-status.js +11 -2
- package/modules/copy-desk/tests/copy_no_cms.mjs +8 -1
- package/modules/copy-desk/tests/copy_page_auto_close.mjs +64 -2
- package/modules/copy-desk/tests/copy_page_status.mjs +5 -1
- 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/hall-ui/public/tweak-editor.js +2 -1
- package/modules/ideas/routes/inbox.js +6 -0
- package/modules/lifecycle/db-claims.js +2 -2
- package/modules/lifecycle/db-ship.js +2 -2
- 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 +37 -1
- package/scripts/bongos/backfill-subagent-rewards.js +7 -7
- package/scripts/bongos/codemod-rename-scripts-gds.js +1 -3
- package/scripts/bongos/dev-box-guard.js +0 -1
- package/scripts/bongos/publish-manifest.js +1 -1
- package/scripts/bongos/run-unit-tests.js +4 -0
- package/scripts/hall-preview/server.js +13 -0
- package/src/bongos/agent-standing-rules.js +44 -0
- package/src/bongos/auth-idp.js +17 -5
- package/src/bongos/db-kernel.js +14 -1
- package/src/bongos/module-scope-map.js +4 -2
- package/src/bongos/routes/me.js +4 -1
- package/src/module-api.js +1 -1
- package/tests/account_logins.mjs +6 -4
- package/tests/agent_standing_rules.mjs +142 -0
- package/tests/backfill_subagent_rewards.mjs +7 -7
- package/tests/claims_batch_routes.mjs +36 -13
- package/tests/copy_stuck_page_tweaks.mjs +3 -1
- package/tests/credit_grant.mjs +7 -7
- package/tests/credit_wording.mjs +154 -0
- package/tests/google_halls.mjs +211 -0
- package/tests/government_laws_session.mjs +2 -1
- package/tests/hall_tweak_editor.mjs +11 -0
- package/tests/host_topology_skips.mjs +2 -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/tests/run_node_reroute.mjs +136 -0
package/release-notes.json
CHANGED
|
@@ -10812,7 +10812,43 @@
|
|
|
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.5": [
|
|
10816
|
+
{
|
|
10817
|
+
"id": "1004973",
|
|
10818
|
+
"text": "A page tweak whose apply stopped partway, with nobody holding it any more, now goes back in the queue by itself with a note saying the apply stopped, instead of sitting stuck forever."
|
|
10819
|
+
},
|
|
10820
|
+
{
|
|
10821
|
+
"id": "1005004",
|
|
10822
|
+
"text": "Fixed a check that could wrongly stop a builder from handing back or finishing their own task when their ID arrived written as a number instead of text."
|
|
10823
|
+
},
|
|
10824
|
+
{
|
|
10825
|
+
"id": "1005015",
|
|
10826
|
+
"text": "AI agents now reliably flag when they need you (HUMAN TASK / HUMAN DECISION) and own up when a doc or tool misled them, on every machine, even ones running out-of-date copies."
|
|
10827
|
+
},
|
|
10828
|
+
{
|
|
10829
|
+
"id": "1004972",
|
|
10830
|
+
"text": "A page tweak someone opened but never wrote in now shows as \"Opened, nothing written yet\" instead of \"Needs to be reviewed\", and closes itself after one day with a note saying nothing was written."
|
|
10831
|
+
},
|
|
10832
|
+
{
|
|
10833
|
+
"id": "1004978",
|
|
10834
|
+
"text": "Deleted an old, out-of-date manifest file from the repository root that nothing used, so old scripts it listed no longer look like they are still in use."
|
|
10835
|
+
},
|
|
10836
|
+
{
|
|
10837
|
+
"id": "1004968",
|
|
10838
|
+
"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."
|
|
10839
|
+
},
|
|
10840
|
+
{
|
|
10841
|
+
"id": "1004940",
|
|
10842
|
+
"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"
|
|
10843
|
+
},
|
|
10844
|
+
{
|
|
10845
|
+
"id": "1004969",
|
|
10846
|
+
"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."
|
|
10847
|
+
},
|
|
10848
|
+
{
|
|
10849
|
+
"id": "1004994",
|
|
10850
|
+
"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"
|
|
10851
|
+
},
|
|
10816
10852
|
{
|
|
10817
10853
|
"id": "1005006",
|
|
10818
10854
|
"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(() => {});
|
|
@@ -52,8 +52,7 @@
|
|
|
52
52
|
// - files that deliberately name BOTH spellings or the old one: this codemod, forwarder.js,
|
|
53
53
|
// the forwarder test, modules/lifecycle/executed-closure.js (lists both on purpose),
|
|
54
54
|
// rename-ledger-prose.js (masks `scripts/gds` as a protected identifier in ledger prose),
|
|
55
|
-
// the task-2004 codemod (a historical tool)
|
|
56
|
-
// core build cut from an older commit — its paths describe THAT build).
|
|
55
|
+
// and the task-2004 codemod (a historical tool).
|
|
57
56
|
//
|
|
58
57
|
// Usage:
|
|
59
58
|
// node scripts/bongos/codemod-rename-scripts-gds.js # apply
|
|
@@ -81,7 +80,6 @@ const EXCLUDE = new Set([
|
|
|
81
80
|
'tests/scripts_gds_forwarders.mjs',
|
|
82
81
|
'modules/lifecycle/executed-closure.js',
|
|
83
82
|
'scripts/bongos/rename-ledger-prose.js',
|
|
84
|
-
'.bongos-core.json',
|
|
85
83
|
]);
|
|
86
84
|
|
|
87
85
|
const FROZEN_PATHS = [
|
|
@@ -85,7 +85,6 @@ const DEV_BOX_EXEMPT = new Map([
|
|
|
85
85
|
// Captured data and seed history that name real records.
|
|
86
86
|
['scripts/bongos/seed-', 'one-shot task seeders: their subject is the historical tasks they filed'],
|
|
87
87
|
['scripts/hall-preview/fixtures/', 'captured API responses: the ledger still holds these task and goal rows'],
|
|
88
|
-
['.bongos-core.json', 'a 1.0.0 seed manifest from July (commit b6ee5d29) that this repo never regenerates'],
|
|
89
88
|
]);
|
|
90
89
|
|
|
91
90
|
const TEXT_EXT = /\.(js|mjs|cjs|json|md|sql|sh|html|css|yml|yaml|txt|toml)$/;
|
|
@@ -316,7 +316,7 @@ const INSTANCE_EXCLUDE = [
|
|
|
316
316
|
// the instance-recipe / game-design carve-outs above. (The portable ADRs + the README index still publish.)
|
|
317
317
|
// The instance's OWN name + trademark decision. Its prose redacts cleanly, but the FILENAME
|
|
318
318
|
// carries the brand and the publish manifest lists every path — so the literal shipped in
|
|
319
|
-
// .bongos-core.json no matter how well the body was redacted. It also genericizes to nothing:
|
|
319
|
+
// the tarball's inner .bongos-core.json no matter how well the body was redacted. It also genericizes to nothing:
|
|
320
320
|
// "accepting trademark risk on <our name>" is not a decision another instance inherits.
|
|
321
321
|
// (task 1003869 — the last literal in the artifact.)
|
|
322
322
|
'docs/adr/0004-amazonprimea-name-and-trademark-acceptance.md',
|
|
@@ -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) {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// src/bongos/agent-standing-rules.js — the rules every AI session obeys whoever the
|
|
3
|
+
// builder is, served by the SERVER so a stale checkout still hears them (task 1005015).
|
|
4
|
+
//
|
|
5
|
+
// WHY THE SERVER. The human signal (task 1004922) lived only in the local Conductor
|
|
6
|
+
// hook, told once on a session's first prompt. A hook runs from whatever checkout the
|
|
7
|
+
// machine has, and on the owner's box that was 823 commits behind, so the markers never
|
|
8
|
+
// appeared at all. Slop detection lived only as a CLAUDE.md table row nothing ever
|
|
9
|
+
// triggered. GET /me's interaction contract is relayed verbatim by EVERY Conductor
|
|
10
|
+
// since task 2010, stale ones included, so appending these rules to it reaches every
|
|
11
|
+
// active user the moment the instance serving them runs this code.
|
|
12
|
+
//
|
|
13
|
+
// The wording is the rule's home for sessions; docs/voice.md is its home for people.
|
|
14
|
+
// conductor.js and stop-slopcheck.js carry their own copies (they must work on a
|
|
15
|
+
// checkout whose core cannot load), and tests/agent_standing_rules.mjs holds every
|
|
16
|
+
// copy equal to the contract this file serves. Only withStandingRules is exported:
|
|
17
|
+
// the hooks' dynamic core resolve is invisible to the dead-code scan.
|
|
18
|
+
|
|
19
|
+
const HUMAN_SIGNAL_TEXT = 'When you need the person, start that line with `🙋 **HUMAN TASK:**` if they must DO something '
|
|
20
|
+
+ 'only a person can do (try it on the live site, add a key, sign in, approve), or `🔀 **HUMAN DECISION:**` if they must '
|
|
21
|
+
+ 'CHOOSE (list the options and mark your pick). Put the same marker at the start of a question-box question. Never use them for anything else.';
|
|
22
|
+
|
|
23
|
+
const SLOP_TEXT = 'The moment you find you were wrong because something told you so (a doc, a hook\'s output, a script\'s message, '
|
|
24
|
+
+ 'a stale file) — or the person corrects you — say `🐛 Bongo Slop Detected!!, Filing Bug 🐛`, name the source that misled you, '
|
|
25
|
+
+ 'and file it: `node scripts/bongos/capture.js "<file>: says X, truth is Y" --kind bug`. The misleading source is the bug. '
|
|
26
|
+
+ 'Your own plain mistake, with nothing misleading behind it, is not slop.';
|
|
27
|
+
|
|
28
|
+
const HEADER = 'Standing rules for every session:';
|
|
29
|
+
|
|
30
|
+
function standingRulesBlock() {
|
|
31
|
+
return `${HEADER}\n- ${HUMAN_SIGNAL_TEXT}\n- ${SLOP_TEXT}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The interaction slice of GET /me with the standing rules appended. `interaction` is
|
|
35
|
+
// describeInteraction's object or null (builder-settings module off); either way the
|
|
36
|
+
// result injects, because these rules do not depend on any preference. Idempotent.
|
|
37
|
+
function withStandingRules(interaction) {
|
|
38
|
+
const base = interaction && typeof interaction === 'object' ? interaction : {};
|
|
39
|
+
const contract = typeof base.contract === 'string' ? base.contract : '';
|
|
40
|
+
if (contract.includes(HEADER)) return { ...base, inject: true };
|
|
41
|
+
return { ...base, contract: contract ? `${contract}\n${standingRulesBlock()}` : standingRulesBlock(), inject: true };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { withStandingRules };
|
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
|
|
@@ -614,8 +615,9 @@ const SHARED_GLOBS = Object.freeze(
|
|
|
614
615
|
// Assigning nineteen roots in one pass is the one-at-a-time churn task 1004132 was
|
|
615
616
|
// filed to avoid; freezing the set and naming the arguable ones is the cheap half.
|
|
616
617
|
const UNOWNED_ROOTS = Object.freeze({
|
|
617
|
-
// Generated packaging
|
|
618
|
-
|
|
618
|
+
// Generated packaging manifest — written by package-core.js, never hand-edited.
|
|
619
|
+
// (The repo-root .bongos-core.json, a stale July 1.0.0 seed, was deleted in task 1004978;
|
|
620
|
+
// the live one is the INNER manifest package-core writes into the tarball.)
|
|
619
621
|
'.cloudbongos-core.json': 'generated package manifest (scripts/bongos/package-core.js)',
|
|
620
622
|
// Governance + legal. Deliberately not methodology: methodology owns docs/, and
|
|
621
623
|
// these are the project's constitutional documents rather than its handbook.
|
package/src/bongos/routes/me.js
CHANGED
|
@@ -34,6 +34,7 @@ const db = require('../db');
|
|
|
34
34
|
// prefs-module-less instance so the slices simply render their unset/default shape.
|
|
35
35
|
const { validateOrRespond } = require('./_helpers');
|
|
36
36
|
const { readModes: readCraftModes, describeCraftMode } = require('../craft-mode'); // task 1002992
|
|
37
|
+
const { withStandingRules } = require('../agent-standing-rules'); // task 1005015
|
|
37
38
|
const { branding } = require('../../branding'); // task 2011: instance model-allocation default
|
|
38
39
|
// The onboarding state machine carved into modules/onboarding/ (BV1.R81 / ADR
|
|
39
40
|
// 0093 §1): the GET /me aggregator + PATCH /me/disciplines resolve the
|
|
@@ -565,7 +566,9 @@ module.exports = function buildMeRouter() {
|
|
|
565
566
|
// contract. The old §2 "The Prompter" owner-defaults, now per-builder. The
|
|
566
567
|
// session-start injection + /settings UI read this; describeInteraction is
|
|
567
568
|
// the SSOT (builder-settings port). null when the prefs module is off.
|
|
568
|
-
|
|
569
|
+
// task 1005015: the standing rules (human-task markers, slop detection) ride
|
|
570
|
+
// this contract because every Conductor, stale ones included, relays it.
|
|
571
|
+
interaction: withStandingRules(lawed ? lawed.interaction : (settings && settings.describeInteraction ? settings.describeInteraction(behaviorPrefs) : null)),
|
|
569
572
|
// task 1004707: the project's LAWS, read into every AI session. The
|
|
570
573
|
// Conductor hook relays `contract` verbatim beside the interaction profile
|
|
571
574
|
// (inject:false, and no block, when no law is in force). `laws` is the book
|
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.5'; // 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
|
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// tests/agent_standing_rules.mjs — the human-task markers and the Slop Bongo rule reach
|
|
2
|
+
// every session (task 1005015).
|
|
3
|
+
//
|
|
4
|
+
// They used to live only in the local Conductor hook (markers, first prompt only) and
|
|
5
|
+
// a CLAUDE.md table row (slop, never triggered), so a stale checkout or a long session
|
|
6
|
+
// lost them. Now: the SERVER appends them to the interaction contract every Conductor
|
|
7
|
+
// relays; the Conductor re-says them when the person corrects the agent or is about to
|
|
8
|
+
// be asked something; a Stop hook asks once when the agent reverses itself.
|
|
9
|
+
//
|
|
10
|
+
// .claude/hooks/ is package-excluded, so a clean core checkout skips this file
|
|
11
|
+
// (tests/host_topology_skips.mjs carries the allowlist entry).
|
|
12
|
+
//
|
|
13
|
+
// Run: node tests/agent_standing_rules.mjs
|
|
14
|
+
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { requireHostModule } from './helpers.mjs';
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
25
|
+
const rules = require('../src/bongos/agent-standing-rules.js');
|
|
26
|
+
const conductor = requireHostModule(require, '../.claude/hooks/conductor.js');
|
|
27
|
+
const slopcheck = requireHostModule(require, '../.claude/hooks/stop-slopcheck.js');
|
|
28
|
+
|
|
29
|
+
// The words as the server serves them: a header line, then one '- ' line per rule.
|
|
30
|
+
const [HEADER, HUMAN, SLOP] = rules.withStandingRules(null).contract.split('\n').map((l, i) => (i ? l.slice(2) : l));
|
|
31
|
+
|
|
32
|
+
let passed = 0;
|
|
33
|
+
let failed = 0;
|
|
34
|
+
function t(name, fn) {
|
|
35
|
+
try { fn(); passed++; console.log(` PASS ${name}`); } catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
t('the server appends both rules to a builder\'s interaction contract and keeps it injecting', () => {
|
|
39
|
+
const out = rules.withStandingRules({ inject: true, contract: 'Interaction profile — P', fields: [1] });
|
|
40
|
+
assert.ok(out.contract.startsWith('Interaction profile — P\n'), 'the profile stays first');
|
|
41
|
+
assert.ok(out.contract.includes(HUMAN));
|
|
42
|
+
assert.ok(out.contract.includes(SLOP));
|
|
43
|
+
assert.equal(out.inject, true);
|
|
44
|
+
assert.deepEqual(out.fields, [1], 'the rest of the slice is untouched');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
t('with the settings module off (null) the rules still reach the session', () => {
|
|
48
|
+
const out = rules.withStandingRules(null);
|
|
49
|
+
assert.equal(out.inject, true);
|
|
50
|
+
assert.ok(out.contract.startsWith(HEADER));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
t('appending is idempotent', () => {
|
|
54
|
+
const once = rules.withStandingRules({ inject: true, contract: 'P' });
|
|
55
|
+
assert.equal(rules.withStandingRules(once).contract, once.contract);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
t('GET /me wraps the interaction slice (wiring pin)', () => {
|
|
59
|
+
const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'routes', 'me.js'), 'utf8');
|
|
60
|
+
assert.match(src, /interaction: withStandingRules\(/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
t('the rules mark both markers and the slop line exactly as the owner set them', () => {
|
|
64
|
+
assert.ok(HUMAN.includes('🙋 **HUMAN TASK:**'));
|
|
65
|
+
assert.ok(HUMAN.includes('🔀 **HUMAN DECISION:**'));
|
|
66
|
+
assert.ok(SLOP.includes('🐛 Bongo Slop Detected!!, Filing Bug 🐛'));
|
|
67
|
+
assert.ok(fs.readFileSync(path.join(ROOT, 'CLAUDE.md'), 'utf8').includes('🐛 Bongo Slop Detected!!, Filing Bug 🐛'), 'CLAUDE.md says the same line');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
t('the Conductor\'s copy says exactly what the server sends', () => {
|
|
71
|
+
assert.equal(conductor.HUMAN_SIGNAL_TEXT, HUMAN);
|
|
72
|
+
assert.equal(conductor.SLOP_TEXT, SLOP);
|
|
73
|
+
assert.equal(conductor.STANDING_HEADER, HEADER);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
t('the first-prompt marker line is skipped when the server already sent the rules', () => {
|
|
77
|
+
const served = rules.withStandingRules({ inject: true, contract: 'P' });
|
|
78
|
+
const a = conductor.evaluateTriggers({ firstPrompt: true, interaction: served });
|
|
79
|
+
assert.ok(!a.some((x) => x.id === 'human-signal'));
|
|
80
|
+
assert.ok(a.some((x) => x.id === 'interaction' && x.text.includes('HUMAN TASK')));
|
|
81
|
+
const old = conductor.evaluateTriggers({ firstPrompt: true, interaction: { inject: true, contract: 'P' } });
|
|
82
|
+
assert.ok(old.some((x) => x.id === 'human-signal'), 'an older server still gets the local line');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
t('an ask names the markers', () => {
|
|
86
|
+
const a = conductor.evaluateTriggers({ prompt: 'file a blocker for this' }).find((x) => x.id === 'ask-shape');
|
|
87
|
+
assert.ok(a && a.text.includes('🔀 **HUMAN DECISION:**') && a.text.includes('🙋 **HUMAN TASK:**'));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
t('a person correcting the agent triggers the slop check, every time', () => {
|
|
91
|
+
for (const p of ["that's wrong, it is live", 'you said it needs a dev box', 'actually it already works', "it's already deployed", 'not true', 'is slop detection firing?']) {
|
|
92
|
+
const a = conductor.evaluateTriggers({ prompt: p }).find((x) => x.id === 'slop-check');
|
|
93
|
+
assert.ok(a, `no slop check for: ${p}`);
|
|
94
|
+
assert.equal(a.always, true, 'renders even when the advisory set repeats');
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
t('ordinary prompts do not trigger the slop check', () => {
|
|
99
|
+
for (const p of ['add a dark mode toggle', 'run the tests', 'what can I work on', 'make the button wider']) {
|
|
100
|
+
assert.ok(!conductor.evaluateTriggers({ prompt: p }).some((x) => x.id === 'slop-check'), p);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
t('the Stop hook asks once when the agent reverses itself', () => {
|
|
105
|
+
for (const m of ['Turns out the deploy needs no ssh.', 'I was wrong about the branch.', 'My mistake — it is live.', 'I said it was stale but it is current.']) {
|
|
106
|
+
const r = slopcheck.evaluate(m);
|
|
107
|
+
assert.equal(r.block, true, m);
|
|
108
|
+
assert.ok(r.reason.includes('Bongo Slop Detected'));
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
t('the Stop hook stays quiet on ordinary text and when the rule was already followed', () => {
|
|
113
|
+
assert.equal(slopcheck.evaluate('Done. All 12 tests pass.').block, false);
|
|
114
|
+
assert.equal(slopcheck.evaluate('The old test was wrong about the count; I fixed it.').block, false);
|
|
115
|
+
assert.equal(slopcheck.evaluate('🐛 Bongo Slop Detected!!, Filing Bug 🐛 — turns out the doc was stale.').block, false);
|
|
116
|
+
assert.equal(slopcheck.evaluate('').block, false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
t('the Stop hook\'s copy says exactly what the server sends', () => {
|
|
120
|
+
assert.equal(slopcheck.SLOP_TEXT, SLOP);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
t('the Stop hook reads only the transcript tail, and still finds the last message past a cut line', () => {
|
|
124
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'slopcheck-'));
|
|
125
|
+
const tp = path.join(dir, 't.jsonl');
|
|
126
|
+
const filler = JSON.stringify({ type: 'user', message: { content: 'x'.repeat(1000) } });
|
|
127
|
+
const lines = Array.from({ length: 600 }, () => filler); // ~600 KB, past the 256 KB tail
|
|
128
|
+
lines.push(JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: 'Turns out it was live.' }] } }));
|
|
129
|
+
lines.push(JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'x' }] } }));
|
|
130
|
+
fs.writeFileSync(tp, lines.join('\n') + '\n');
|
|
131
|
+
assert.equal(slopcheck.lastAssistantText(tp), 'Turns out it was live.');
|
|
132
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
t('the Stop hook is wired through the node resolver', () => {
|
|
136
|
+
const s = JSON.parse(fs.readFileSync(path.join(ROOT, '.claude', 'settings.json'), 'utf8'));
|
|
137
|
+
const cmds = s.hooks.Stop.flatMap((e) => e.hooks.map((h) => h.command));
|
|
138
|
+
assert.ok(cmds.some((c) => /run-node\.sh.*stop-slopcheck\.js/.test(c)));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
console.log(`\nagent_standing_rules: ${passed} passed, ${failed} failed`);
|
|
142
|
+
process.exitCode = failed ? 1 : 0;
|
|
@@ -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/);
|