@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.
Files changed (76) hide show
  1. package/.bongos-core.json +142 -67
  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/copy-inventory.md +11 -11
  13. package/docs/copy-registry.json +12 -12
  14. package/docs/file-map.md +3 -2
  15. package/docs/module-api-changelog.md +2 -2
  16. package/docs/page-readings.json +1 -1
  17. package/modules/copy-desk/auto-close.js +46 -8
  18. package/modules/copy-desk/page-status.js +11 -2
  19. package/modules/copy-desk/tests/copy_no_cms.mjs +8 -1
  20. package/modules/copy-desk/tests/copy_page_auto_close.mjs +64 -2
  21. package/modules/copy-desk/tests/copy_page_status.mjs +5 -1
  22. package/modules/discord/ship-broadcast.js +7 -0
  23. package/modules/economy/credit-wording.js +93 -0
  24. package/modules/economy/credits.js +26 -12
  25. package/modules/economy/reward-policy.js +49 -3
  26. package/modules/economy/reward.js +5 -0
  27. package/modules/government/routes/government.js +7 -0
  28. package/modules/hall-ui/public/tweak-editor.js +2 -1
  29. package/modules/ideas/routes/inbox.js +6 -0
  30. package/modules/lifecycle/db-claims.js +2 -2
  31. package/modules/lifecycle/db-ship.js +2 -2
  32. package/modules/lifecycle/routes/project-settings.js +6 -4
  33. package/modules/market-watch/ask.js +425 -0
  34. package/modules/market-watch/migrations/market_watch_003_ask_pool.sql +21 -0
  35. package/modules/market-watch/module.json +10 -3
  36. package/modules/market-watch/pollers/weekly-ask.js +89 -0
  37. package/modules/market-watch/public/hall-widget.js +271 -0
  38. package/modules/market-watch/public/weekly-ask.css +39 -0
  39. package/modules/market-watch/routes/ask.js +95 -0
  40. package/modules/onboarding/routes/access-requests.js +38 -10
  41. package/modules/platform-identity/account-logins.js +13 -0
  42. package/modules/platform-identity/routes/my-projects.js +10 -7
  43. package/modules/platform-identity/routes/sso.js +11 -6
  44. package/package-lock.json +2 -2
  45. package/package.json +1 -1
  46. package/release-notes.json +37 -1
  47. package/scripts/bongos/backfill-subagent-rewards.js +7 -7
  48. package/scripts/bongos/codemod-rename-scripts-gds.js +1 -3
  49. package/scripts/bongos/dev-box-guard.js +0 -1
  50. package/scripts/bongos/publish-manifest.js +1 -1
  51. package/scripts/bongos/run-unit-tests.js +4 -0
  52. package/scripts/hall-preview/server.js +13 -0
  53. package/src/bongos/agent-standing-rules.js +44 -0
  54. package/src/bongos/auth-idp.js +17 -5
  55. package/src/bongos/db-kernel.js +14 -1
  56. package/src/bongos/module-scope-map.js +4 -2
  57. package/src/bongos/routes/me.js +4 -1
  58. package/src/module-api.js +1 -1
  59. package/tests/account_logins.mjs +6 -4
  60. package/tests/agent_standing_rules.mjs +142 -0
  61. package/tests/backfill_subagent_rewards.mjs +7 -7
  62. package/tests/claims_batch_routes.mjs +36 -13
  63. package/tests/copy_stuck_page_tweaks.mjs +3 -1
  64. package/tests/credit_grant.mjs +7 -7
  65. package/tests/credit_wording.mjs +154 -0
  66. package/tests/google_halls.mjs +211 -0
  67. package/tests/government_laws_session.mjs +2 -1
  68. package/tests/hall_tweak_editor.mjs +11 -0
  69. package/tests/host_topology_skips.mjs +2 -0
  70. package/tests/market_watch_ask.mjs +369 -0
  71. package/tests/market_watch_ask_db.mjs +122 -0
  72. package/tests/market_watch_data_db.mjs +4 -0
  73. package/tests/market_watch_module.mjs +9 -5
  74. package/tests/project_settings_page.mjs +1 -1
  75. package/tests/reward_cost_scale.mjs +200 -0
  76. package/tests/run_node_reroute.mjs +136 -0
@@ -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.3": [
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`/`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(() => {});
@@ -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), and .bongos-core.json (the manifest of a
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 };
@@ -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
@@ -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 manifests — written by package-core.js, never hand-edited.
618
- '.bongos-core.json': 'generated package manifest (scripts/bongos/package-core.js)',
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.
@@ -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
- interaction: lawed ? lawed.interaction : (settings && settings.describeInteraction ? settings.describeInteraction(behaviorPrefs) : null),
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.3'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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');
@@ -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
  });
@@ -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, 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/);