@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
@@ -8,7 +8,11 @@
8
8
  // hand. Two shapes hold nothing:
9
9
  //
10
10
  // blank `ready`, no draft and no frozen batch: opened in the
11
- // studio, nothing written, the claim dropped.
11
+ // studio, nothing written, the claim dropped. Closes once
12
+ // untouched for page-status's BLANK_CLOSES_AFTER (1 day, the
13
+ // owner's answer on task 1004971; task 1004972). Until then
14
+ // it reads `blank` ("Opened, nothing written yet"), never
15
+ // `unknown`.
12
16
  // nothing_to_apply `ready` with a frozen batch whose `lines` is empty: every
13
17
  // rewrite left the batch for an engineer task at submit
14
18
  // (unplaced, shared or removed lines), so /tweak has no line
@@ -32,18 +36,47 @@ const pageStatus = require('./page-status');
32
36
  const GRACE_MS = Number(process.env.COPY_AUTO_CLOSE_GRACE_MS) || 60 * 60_000;
33
37
 
34
38
  const NOTES = Object.freeze({
35
- blank: 'Closed by itself: the round was opened but nothing was written in it.',
39
+ blank: 'Closed: nothing was written. The round was opened and left blank for a day; claim the page again to start one.',
36
40
  nothing_to_apply: 'Closed by itself: every rewritten line went to an engineer task, so nothing was left to apply on the page.',
37
41
  draft_expired: 'Closed by itself: the draft sat untouched for two weeks. Its words stay on this task; claim the page again to start over.',
42
+ orphaned_apply: 'Released: the apply stopped without finishing. Nobody holds this round any more, so it is back in the queue.',
38
43
  });
39
44
 
40
- function closeReason(task, { now = Date.now(), graceMs = GRACE_MS, draftExpiresMs = pageStatus.DRAFT_EXPIRES_AFTER } = {}) {
45
+ // task 1004972 T2 (task 1004973): an ORPHANED round is `active` with no open
46
+ // claim. The applier's claim was released (the runner's reconcile, a crashed
47
+ // worker, a dropped session) but the status never moved, so it read `unknown`
48
+ // and nothing ever picked it up again. A round someone still holds, a live and
49
+ // heartbeating apply, is never touched: holder_id is the open claim, and the
50
+ // lifecycle port re-checks that no claim was taken under its row lock.
51
+ function orphanReason(task, { now = Date.now(), graceMs = GRACE_MS } = {}) {
52
+ if (!task || task.status !== 'active' || task.holder_id != null) return null;
53
+ const touched = Date.parse(task.updated_at || task.created_at || '');
54
+ if (!Number.isFinite(touched) || now - touched < graceMs) return null;
55
+ return 'orphaned_apply';
56
+ }
57
+
58
+ // The orphan goes back to `ready` with its description kept whole plus the
59
+ // reason, as a sent-back block (pages.appendSentBack, the one writer of that
60
+ // block, which only APPENDS). A frozen batch then reads `submitted` again and the
61
+ // next applier reads why; a draft stays a draft. No word of the draft or the
62
+ // batch is changed.
63
+ function releaseKeeping(note, at, by) {
64
+ return (task) => pages.appendSentBack(task.description, { at, by, note });
65
+ }
66
+
67
+ function closeReason(task, {
68
+ now = Date.now(), graceMs = GRACE_MS,
69
+ draftExpiresMs = pageStatus.DRAFT_EXPIRES_AFTER, blankClosesMs = pageStatus.BLANK_CLOSES_AFTER,
70
+ } = {}) {
41
71
  if (!task || task.status !== 'ready' || task.holder_id != null) return null;
42
72
  const touched = Date.parse(task.updated_at || task.created_at || '');
43
73
  if (!Number.isFinite(touched) || now - touched < graceMs) return null;
44
74
  const p = pages.blockPresence(task.description);
45
- // task 1004901: a draft its artist left untouched for two weeks expires.
46
- if (!p.batch) return p.draft ? (now - touched >= draftExpiresMs ? 'draft_expired' : null) : 'blank';
75
+ const age = now - touched;
76
+ // task 1004901: a draft its artist left untouched expires. task 1004972: a
77
+ // blank round closes after a day. A batch with lines is never touched here,
78
+ // and a draft only by its own expiry.
79
+ if (!p.batch) return p.draft ? (age >= draftExpiresMs ? 'draft_expired' : null) : (age >= blankClosesMs ? 'blank' : null);
47
80
  const b = pages.parseBatchBlock(task.description);
48
81
  if (b.ok && b.batch.lines.length === 0) return 'nothing_to_apply';
49
82
  return null;
@@ -58,16 +91,21 @@ async function runAutoClose({ now = Date.now(), resolve = (k) => (api.resolveOpt
58
91
  return { closed: [], skipped: 'no_lifecycle' };
59
92
  }
60
93
  const tasks = await lifecycle.listPageTweakTasks();
61
- const due = tasks.map((t) => ({ t, why: closeReason(t, { now }) })).filter((x) => x.why);
94
+ const due = tasks.map((t) => ({ t, why: closeReason(t, { now }) || orphanReason(t, { now }) })).filter((x) => x.why);
62
95
  if (!due.length) return { closed: [] };
63
96
  const owner = await founder();
64
97
  if (!owner) return { closed: [], skipped: 'no_owner' };
65
98
  const closed = [];
99
+ const at = new Date(now).toISOString();
66
100
  for (const { t, why } of due) {
67
- const r = await lifecycle.releaseStuckTweak({ taskId: t.id, builderId: owner.id, expectedStatus: 'ready', note: NOTES[why] });
101
+ const orphan = why === 'orphaned_apply';
102
+ const r = await lifecycle.releaseStuckTweak({
103
+ taskId: t.id, builderId: owner.id, expectedStatus: orphan ? 'active' : 'ready', note: NOTES[why],
104
+ ...(orphan ? { compose: releaseKeeping(NOTES[why], at, String(owner.id)) } : {}),
105
+ });
68
106
  closed.push({ task_id: String(t.id), why, outcome: r.outcome });
69
107
  }
70
108
  return { closed };
71
109
  }
72
110
 
73
- module.exports = { closeReason, runAutoClose };
111
+ module.exports = { closeReason, orphanReason, runAutoClose };
@@ -193,7 +193,11 @@ function roundState(round) {
193
193
  if (p.last_sent_back_at && (!p.applied_at || p.applied_at <= p.last_sent_back_at)) return 'submitted';
194
194
  return 'unknown';
195
195
  }
196
- return p.draft ? 'draft' : 'unknown';
196
+ // task 1004972: opened and nothing written is a state of its own, not one
197
+ // the reads cannot name. It waits on its artist and closes itself after
198
+ // BLANK_CLOSES_AFTER (the auto-close sweep), so it never reads "Needs to be
199
+ // reviewed" or asks an applier to release it.
200
+ return p.draft ? 'draft' : 'blank';
197
201
  case 'completed':
198
202
  // A held pass waits for the artist; a failed grade stays with the applier.
199
203
  return round.grade_passed === true ? 'waiting_for_artist' : 'applying';
@@ -319,7 +323,10 @@ const DAY_MS = 24 * 60 * 60 * 1000;
319
323
  // Home and in the daily digest). A draft nobody touches also EXPIRES
320
324
  // (expires_after): the auto-close sweep abandons it, its words kept on the task.
321
325
  const DRAFT_EXPIRES_AFTER = 14 * DAY_MS;
326
+ // task 1004972 (owner, task 1004971): a round opened and left blank closes after a day.
327
+ const BLANK_CLOSES_AFTER = DAY_MS;
322
328
  const IN_FLIGHT = Object.freeze({
329
+ blank: { word: 'Opened, nothing written yet', stuck_after: BLANK_CLOSES_AFTER, waits_on: 'artist', expires_after: BLANK_CLOSES_AFTER },
323
330
  draft: { word: 'Being written', stuck_after: 7 * DAY_MS, waits_on: 'artist', expires_after: DRAFT_EXPIRES_AFTER },
324
331
  writing: { word: 'Being written', stuck_after: 7 * DAY_MS, waits_on: 'artist' },
325
332
  words_review: { word: 'Words waiting for review', stuck_after: 3 * DAY_MS, waits_on: 'artist' },
@@ -330,7 +337,7 @@ const IN_FLIGHT = Object.freeze({
330
337
  landing: { word: 'Approved, going live', stuck_after: DAY_MS, waits_on: 'appliers' },
331
338
  unknown: { word: 'Needs to be reviewed', stuck_after: 0, waits_on: 'appliers' },
332
339
  });
333
- const IN_FLIGHT_ORDER = ['unknown', 'landing', 'applying', 'submitted', 'waiting_for_artist', 'words_review', 'writing', 'draft'];
340
+ const IN_FLIGHT_ORDER = ['unknown', 'landing', 'applying', 'submitted', 'waiting_for_artist', 'words_review', 'writing', 'draft', 'blank'];
334
341
 
335
342
  function inFlightFor(list, byPage, nowMs) {
336
343
  const out = [];
@@ -980,4 +987,6 @@ module.exports = {
980
987
  stuckDigestText,
981
988
  // task 1004901: when an untouched draft expires (the auto-close sweep reads it).
982
989
  DRAFT_EXPIRES_AFTER,
990
+ // task 1004972: when a blank round closes (the auto-close sweep reads it).
991
+ BLANK_CLOSES_AFTER,
983
992
  };
@@ -312,8 +312,15 @@ await test('the module manifest declares no seam another module could write copy
312
312
  assert.equal(/writeFile|\.query\(|UPDATE |INSERT /.test(stuckSrc), false, 'the stuck-tweaks port writes nothing');
313
313
  // task 1004899: the auto-close sweep only ABANDONS an empty round through the
314
314
  // lifecycle port; it never hands a compose, so it cannot write a description.
315
+ // task 1004973: it may hand ONE compose, for an orphaned apply released back to
316
+ // the queue, and that compose is pages.appendSentBack: it appends the reason and
317
+ // changes no word (copy_page_auto_close.mjs proves the description is kept whole).
315
318
  const closeSrc = stripComments(read('auto-close.js'));
316
- assert.equal(/writeFile|\.query\(|UPDATE |INSERT |compose/.test(closeSrc), false, 'the auto-close sweep abandons and writes nothing');
319
+ assert.equal(/writeFile|\.query\(|UPDATE |INSERT /.test(closeSrc), false, 'the auto-close sweep writes nothing itself');
320
+ const composes = closeSrc.match(/\bcompose\b[^\n]*/g) || [];
321
+ assert.deepEqual(composes.length, 1, 'one compose, for the orphan release');
322
+ assert.match(closeSrc, /\(task\) => pages\.appendSentBack\(task\.description, \{ at, by, note \}\)/, 'and it only appends the sent-back note');
323
+ assert.equal(/composeDraftBlock|composeBatchBlock|saveDraftInto|replaceBlock/.test(closeSrc), false, 'no draft or batch writer');
317
324
  const stats = read('artist-stats.js');
318
325
  assert.equal(/\b(pool|query|writeFile|appendFile|INSERT|UPDATE|DELETE)\b/.test(stats.replace(/\/\/.*$/gm, '')), false,
319
326
  'the artist-stats port reads through other ports and writes nothing');
@@ -65,9 +65,35 @@ await test('a draft left untouched for two weeks expires; a younger one stays (t
65
65
  });
66
66
 
67
67
  await test('the grace period is honoured: a round touched within it stays', () => {
68
- const t = round({ updated_at: new Date(NOW - 59 * 60_000).toISOString() });
68
+ const t = round({ description: `${draft}\n\n${batch([])}`, updated_at: new Date(NOW - 59 * 60_000).toISOString() });
69
69
  assert.equal(autoClose.closeReason(t, { now: NOW, graceMs: 60 * 60_000 }), null);
70
- assert.equal(autoClose.closeReason(t, { now: NOW, graceMs: 30 * 60_000 }), 'blank');
70
+ assert.equal(autoClose.closeReason(t, { now: NOW, graceMs: 30 * 60_000 }), 'nothing_to_apply');
71
+ });
72
+
73
+ // task 1004972 (owner, task 1004971): a blank round closes after one day, not an hour.
74
+ await test('a blank round closes once a day old, and a round with a draft or a batch never closes as blank', () => {
75
+ const D = 86400000;
76
+ const at = (days, over = {}) => round({ updated_at: new Date(NOW - days * D).toISOString(), ...over });
77
+ assert.equal(autoClose.closeReason(at(0.9), { now: NOW }), null, 'under a day: it stays, reading "Opened, nothing written yet"');
78
+ assert.equal(autoClose.closeReason(at(1.1), { now: NOW }), 'blank');
79
+ assert.equal(autoClose.closeReason(at(1.1, { holder_id: '7' }), { now: NOW }), null, 'a held round is being written');
80
+ for (const [name, description] of [['draft', draft], ['batch with lines', `${draft}\n\n${batch([line])}`],
81
+ ['approved batch', `${batch([line])}\n\n${pages.composeWordsApprovedBlock({ at: OLD, by: '7' })}`]]) {
82
+ assert.notEqual(autoClose.closeReason(at(1.1, { description }), { now: NOW }), 'blank', `${name} is not blank`);
83
+ }
84
+ assert.equal(autoClose.closeReason(at(30, { description: `${draft}\n\n${batch([line])}` }), { now: NOW }), null, 'a batch with lines is never closed, however old');
85
+ });
86
+
87
+ await test('the blank note says nothing was written, and the sweep never writes words', async () => {
88
+ const calls = [];
89
+ const lifecycle = {
90
+ listPageTweakTasks: async () => [ROUNDS.blank],
91
+ releaseStuckTweak: async (args) => { calls.push(args); return { outcome: 'abandoned' }; },
92
+ };
93
+ await autoClose.runAutoClose({ now: NOW, resolve: () => lifecycle, founder: async () => ({ id: '1' }) });
94
+ assert.equal(calls.length, 1);
95
+ assert.match(calls[0].note, /^Closed: nothing was written\./);
96
+ assert.deepEqual(Object.keys(calls[0]).sort(), ['builderId', 'expectedStatus', 'note', 'taskId'], 'no compose, no description, no wording');
71
97
  });
72
98
 
73
99
  await test('the sweep abandons through the lifecycle port, as the owner, with a plain note and no rewrite', async () => {
@@ -86,6 +112,42 @@ await test('the sweep abandons through the lifecycle port, as the owner, with a
86
112
  }
87
113
  });
88
114
 
115
+ // task 1004973: an apply that stopped without finishing is released with a reason.
116
+ await test('an orphaned apply (active, nobody holds it) is released back to the queue with the reason; a live apply is not', async () => {
117
+ const approved = `${draft}\n\n${batch([line])}\n\n${pages.composeWordsApprovedBlock({ at: OLD, by: '7' })}`;
118
+ const orphan = round({ id: '21', status: 'active', holder_id: null, description: approved });
119
+ const orphanDraft = round({ id: '22', status: 'active', holder_id: null, description: draft });
120
+ const live = round({ id: '23', status: 'active', holder_id: '7', holder_worktree: true, description: approved });
121
+ const fresh = round({ id: '24', status: 'active', holder_id: null, description: approved, updated_at: new Date(NOW - 5 * 60_000).toISOString() });
122
+ assert.deepEqual([orphan, orphanDraft, live, fresh].map((t) => autoClose.orphanReason(t, { now: NOW })), ['orphaned_apply', 'orphaned_apply', null, null]);
123
+ assert.equal(autoClose.closeReason(orphan, { now: NOW }), null, 'the ready-round rules never see an active round');
124
+
125
+ const calls = [];
126
+ const lifecycle = {
127
+ listPageTweakTasks: async () => [orphan, orphanDraft, live, fresh, ROUNDS.real_batch],
128
+ releaseStuckTweak: async (args) => { calls.push(args); return { outcome: 'released' }; },
129
+ };
130
+ const r = await autoClose.runAutoClose({ now: NOW, resolve: () => lifecycle, founder: async () => ({ id: '1' }) });
131
+ assert.deepEqual(r.closed.map((c) => [c.task_id, c.why, c.outcome]), [['21', 'orphaned_apply', 'released'], ['22', 'orphaned_apply', 'released']],
132
+ 'after one sweep no round sits active with nobody holding it; the live apply and the fresh one are left alone');
133
+ for (const c of calls) {
134
+ assert.equal(c.expectedStatus, 'active');
135
+ assert.match(c.note, /^Released: the apply stopped without finishing\./);
136
+ assert.equal(typeof c.compose, 'function', 'released back to the queue, not abandoned');
137
+ }
138
+ // The reason is on the round, and the words are kept whole: the compose only appends.
139
+ const [batchOut, draftOut] = calls.map((c, i) => c.compose([orphan, orphanDraft][i]));
140
+ assert.equal(batchOut.ok, true);
141
+ assert.ok(batchOut.description.startsWith(approved), 'the draft, batch and approval are unchanged');
142
+ assert.ok(draftOut.description.startsWith(draft), 'a draft stays as it was');
143
+ const p = pages.blockPresence(batchOut.description);
144
+ assert.equal(p.batch && p.words_approved && !!p.last_sent_back_at, true, 'the batch carries the sent-back reason');
145
+ const pageStatus = require('../page-status.js');
146
+ assert.equal(pageStatus.roundState({ status: 'ready', holder_id: null, description: batchOut.description }), 'submitted', 'back in the /tweak queue');
147
+ assert.equal(pageStatus.roundState({ status: 'ready', holder_id: null, description: draftOut.description }), 'draft', 'a draft is its artist\'s again');
148
+ assert.match(draftOut.description, /the apply stopped without finishing/);
149
+ });
150
+
89
151
  await test('nothing due, no owner read; no owner, no sweep; no lifecycle, no sweep', async () => {
90
152
  let asked = 0;
91
153
  const quiet = { listPageTweakTasks: async () => [ROUNDS.real_batch], releaseStuckTweak: async () => { throw new Error('must not run'); } };
@@ -199,6 +199,8 @@ await test('every row of the D7 state table', () => {
199
199
  ${pages.composeWordsApprovedBlock({ at: 't2', by: '7' })}` }, 'submitted'],
200
200
  [{ status: 'ready', description: sentBackAfterApply }, 'submitted'],
201
201
  [{ status: 'ready', description: applied }, 'unknown'],
202
+ // task 1004972: opened and nothing written is its own state, never "Needs to be reviewed".
203
+ [{ status: 'ready', description: 'Page tweak round 1.' }, 'blank'],
202
204
  [{ status: 'completed', grade_passed: true }, 'waiting_for_artist'],
203
205
  [{ status: 'completed', grade_passed: false }, 'applying'],
204
206
  [{ status: 'confirmed' }, 'landing'],
@@ -258,7 +260,9 @@ ${pages.composeWordsApprovedBlock({ at: ago(9), by: '7' })}`;
258
260
  round(3, 'landing:c', { status: 'confirmed', updated_at: ago(17) }),
259
261
  round(4, 'builders:d', { status: 'completed', grade_passed: true, updated_at: ago(20) }),
260
262
  round(5, 'builders:e', { status: 'active', holder_id: '7', holder_login: 'me', holder_worktree: false, updated_at: ago(5) }),
261
- round(6, 'builders:f', { status: 'ready', updated_at: ago(0.2) }),
263
+ // An active round nobody holds is one the reads cannot name. (A blank ready
264
+ // round is no longer that: it reads 'blank', task 1004972.)
265
+ round(6, 'builders:f', { status: 'active', holder_id: null, updated_at: ago(0.2) }),
262
266
  round(7, 'builders:g', { status: 'shipped', shipped_at: ago(3), updated_at: ago(3) }),
263
267
  round(8, 'status:h', { status: 'active', holder_id: '8', holder_worktree: true, updated_at: ago(2) }),
264
268
  ];
@@ -96,6 +96,13 @@ async function postShipBroadcast(task, deps = {}) {
96
96
 
97
97
  const postWebhook = deps.postWebhook || require('./webhook').postWebhook;
98
98
  const log = deps.log || console.log;
99
+ // task 1004969 (ADR 0385 decision 9): an AI-written summary that calls credits
100
+ // or models an investment is FLAGGED in the log and posted unchanged — recorded,
101
+ // never blocked or rewritten. No economy module, no check.
102
+ const reward = deps.reward !== undefined ? deps.reward : require('../../src/module-api').resolveOptional('reward');
103
+ if (reward && typeof reward.flagInvestmentWording === 'function') {
104
+ result.wording_flags = reward.flagInvestmentWording(summary, { where: 'ship summary', ref: `task ${task.id}`, log }).length;
105
+ }
99
106
  const pageTweak = task.source === PAGE_TWEAK_SOURCE;
100
107
  const pageArtist = pageTweak && sec === false ? await pageArtistName(task, deps) : null;
101
108
  const targets = chooseBroadcastTargets({ securitySensitive: sec, taskId: task.id, summary, kind: task.kind, pageTweak, pageArtist });
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ // modules/economy/credit-wording.js — finds investment wording next to credits
4
+ // or models (task 1004969; ADR 0385 decision 9).
5
+ //
6
+ // WHY. Credits are a contribution meter, not money and not a stake (ADR 0385,
7
+ // ADR 0387), and a module or a model is not something a builder invests in.
8
+ // Wording that says otherwise keeps coming back — in code comments, onboarding
9
+ // docs and AI-written ship summaries — and every time it does the project reads
10
+ // as if it were selling a security. This is the one check for it.
11
+ //
12
+ // THE RULE. A sentence is flagged when an investment word (investment, return,
13
+ // appreciation, passive income, equity, ownership) sits within NEAR_WORDS words
14
+ // of a credit word or a model word. A sentence that DENIES it ("credits are not
15
+ // an investment", "never equity") is the wording we want, so a negation shortly
16
+ // before the investment word clears it.
17
+ //
18
+ // It only FINDS. Callers decide what to do: the wording test fails on a hit, and
19
+ // the server publish paths log a flag and publish the text unchanged — never a
20
+ // silent rewrite, never a block. PURE: no I/O, so it is safe on any path.
21
+
22
+ const NEAR_WORDS = 12;
23
+ const NEGATION_WORDS = 4;
24
+
25
+ const ANCHOR_RE = /^(credits?|drachmae|models?)$/i;
26
+ const NEGATION_RE = /^(not|never|no|nor|isn't|aren't|without|neither)$/i;
27
+
28
+ // One entry per investment idea; `words` is the phrase as lower-case tokens.
29
+ // "return" is the noun only: "returns" alone, or "return on"/"a return" — the
30
+ // verb ("the task returns to ready") is everywhere and says nothing about money.
31
+ const TERMS = [
32
+ { term: 'investment', match: (w) => /^invest(ment|ments|ing|or|ors|ed|s)?$/.test(w[0]) && 1 },
33
+ { term: 'return', match: (w, prev) => {
34
+ if (w[0] === 'return' && w[1] === 'on') return 2;
35
+ if (/^returns?$/.test(w[0]) && /^(a|any|financial|guaranteed|expected|your|their|high|higher)$/.test(prev || '')) return 1;
36
+ return 0;
37
+ } },
38
+ { term: 'appreciation', match: (w) => (w[0] === 'appreciation' ? 1 : (/^appreciat(e|es|ing)$/.test(w[0]) && w[1] === 'in' && w[2] === 'value' ? 3 : 0)) },
39
+ { term: 'passive income', match: (w) => (w[0] === 'passive' && w[1] === 'income' ? 2 : 0) },
40
+ { term: 'equity', match: (w) => (w[0] === 'equity' ? 1 : 0) },
41
+ { term: 'ownership', match: (w) => (w[0] === 'ownership' ? 1 : 0) },
42
+ ];
43
+
44
+ function sentences(text) {
45
+ return String(text == null ? '' : text).split(/(?<=[.!?;])\s+|\n\s*\n|\n(?=\s*[-*|#>])/);
46
+ }
47
+
48
+ function tokens(sentence) {
49
+ return (sentence.toLowerCase().match(/[a-z][a-z']*/g) || []).map((t) => t.replace(/'s$/, ''));
50
+ }
51
+
52
+ // Every offending sentence in `text`: [{ term, phrase, anchor, sentence }]. Pure.
53
+ function findInvestmentWording(text) {
54
+ const out = [];
55
+ for (const raw of sentences(text)) {
56
+ const sentence = raw.replace(/\s+/g, ' ').trim();
57
+ if (!sentence) continue;
58
+ const w = tokens(sentence);
59
+ const anchors = [];
60
+ w.forEach((t, i) => { if (ANCHOR_RE.test(t)) anchors.push(i); });
61
+ if (!anchors.length) continue;
62
+ for (let i = 0; i < w.length; i++) {
63
+ for (const T of TERMS) {
64
+ const len = T.match(w.slice(i, i + 3), w[i - 1]);
65
+ if (!len) continue;
66
+ const near = anchors.find((a) => Math.abs(a - i) <= NEAR_WORDS);
67
+ if (near === undefined) continue;
68
+ const negated = w.slice(Math.max(0, i - NEGATION_WORDS), i).some((t) => NEGATION_RE.test(t));
69
+ if (negated) continue;
70
+ out.push({ term: T.term, phrase: w.slice(i, i + len).join(' '), anchor: w[near], sentence });
71
+ }
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+
77
+ // The server publish paths' half: find, write one log line per hit, return the
78
+ // hits. The text is published unchanged whatever this finds, and it never
79
+ // throws — a wording check must not be the reason a summary or an idea is lost.
80
+ function flagInvestmentWording(text, { where = 'text', ref = null, log = console.warn } = {}) {
81
+ try {
82
+ const hits = findInvestmentWording(text);
83
+ for (const h of hits) {
84
+ log(`[credit-wording] flagged ${where}${ref == null ? '' : ` ${ref}`}: "${h.phrase}" near "${h.anchor}" in "${h.sentence.slice(0, 200)}" `
85
+ + '(ADR 0385 decision 9; published unchanged)');
86
+ }
87
+ return hits;
88
+ } catch (_) {
89
+ return [];
90
+ }
91
+ }
92
+
93
+ module.exports = { findInvestmentWording, flagInvestmentWording, NEAR_WORDS, NEGATION_WORDS };
@@ -9,7 +9,7 @@
9
9
  // (db.applyGrade/confirmTask/upsertSessionRecordWithReward) resolves these
10
10
  // through the `reward` kernel PORT rather than importing this module.
11
11
 
12
- const { pool, branding, resolveOptional } = require('../../src/module-api');
12
+ const { pool, branding, resolveOptional, logger } = require('../../src/module-api');
13
13
  const { priceModelUsage, PRICES } = require('../../src/module-api').llmPricing; // cost-plus session reward (task 1005)
14
14
  const achievements = require('./achievements');
15
15
 
@@ -143,12 +143,13 @@ const IDEA_GOVERNANCE_PASS_FLOOR = 30; // owner: floor ~30 — see the note on
143
143
  // Both accrue to builders.total_credits (the contribution meter); ADR 0054 (+ its
144
144
  // 2026-06-15 amendment) records the scale and why the cents form existed.
145
145
  //
146
- // REWARD_MARGIN_PCT (and the 1:1 cost-to-drachma scale, `usdPeg`) are the code-default reward
146
+ // REWARD_MARGIN_PCT (and the 1:1 sizing factor, `costScale` — formerly `usdPeg`,
147
+ // task 1004968) are the code-default reward
147
148
  // POLICY. They stay a code-reviewed default — not a live UI knob — for the same
148
149
  // reasons KIND_MULTIPLIERS does: a policy decision deserves a commit trail and
149
150
  // the grader reads it deterministically. But the reward policy is also
150
151
  // PER-INSTANCE config (task 2009 / BONGOS-V1): an instance overrides the margin
151
- // + the drachmae:USD peg in `config/branding.json` `reward.{marginPct,usdPeg}`
152
+ // + the sizing factor in `config/branding.json` `reward.{marginPct,costScale}`
152
153
  // (still a committed, code-reviewed change — just in the instance's config, not
153
154
  // the vendored core module). `rewardConfig()` resolves the instance value over
154
155
  // these defaults; the pure helper keeps its code defaults so tests stay
@@ -161,8 +162,13 @@ const IDEA_GOVERNANCE_PASS_FLOOR = 30; // owner: floor ~30 — see the note on
161
162
  // (ADR 0146) and every existing caller is unchanged.
162
163
  const {
163
164
  REWARD_MARGIN_PCT, REWARD_MODE_DEFAULT, REWARD_MODE_COST_PLUS_ONLY, IDEA_DESCENT_DECAY, rewardPolicy,
165
+ rewardPolicyDeprecation,
164
166
  } = require('./reward-policy');
165
167
 
168
+ // The usdPeg deprecation note prints ONCE per process, not once per award: this
169
+ // resolver runs on every ship, and a line per ship would bury the logs.
170
+ let usdPegNoteShown = false;
171
+
166
172
  // Per-instance reward policy, resolved over the code defaults. Stays HERE, not in
167
173
  // reward-policy.js: the branding read must go through the module port (ADR 0083),
168
174
  // and a file the CLI can share may not import the port. Best-effort — a branding
@@ -170,6 +176,11 @@ const {
170
176
  function rewardConfig() {
171
177
  let r = {};
172
178
  try { r = (branding().reward) || {}; } catch { r = {}; }
179
+ const note = rewardPolicyDeprecation(r);
180
+ if (note && !usdPegNoteShown) {
181
+ usdPegNoteShown = true;
182
+ logger('economy').warn(note);
183
+ }
173
184
  return rewardPolicy(r);
174
185
  }
175
186
 
@@ -205,22 +216,25 @@ function rewardConfig() {
205
216
  // absent/any-non-false → the default TRUE (a pre-ADR-0172 config keeps paying).
206
217
 
207
218
  // Pure helper — exported for tests. Returns { trueCostUsd, marginPct, drachmae,
208
- // description }. drachmae = round(cost × (1+margin) × usdPeg); at the 1:1 scale
219
+ // description }. drachmae = round(cost × (1+margin) × costScale); at the 1:1 scale
209
220
  // one drachma is earned per dollar of cost-plus (task 1138) — sized by USD, not
210
221
  // worth USD (ADR 0387), e.g.
211
222
  // "$15.00 true cost × 1.20 = 18 drachmae (token reward)"
212
- // NOTE a sub-$0.42 session (at the 1:1 peg) rounds to 0 drachmae — intended:
223
+ // NOTE a sub-$0.42 session (at the 1:1 scale) rounds to 0 drachmae — intended:
213
224
  // sub-dollar sessions earn nothing. Kept pure (code-default params); the call
214
225
  // site resolves per-instance policy via rewardConfig() and passes it in.
215
- function computeSessionTokenReward(trueCostUsd, marginPct = REWARD_MARGIN_PCT, usdPeg = 1) {
226
+ // A non-1 factor is written into the description as "× N scale" (it said
227
+ // "× N peg" before task 1004968; only NEW rows carry the new word — no existing
228
+ // credit_log row is rewritten). At the default 1 the description is unchanged.
229
+ function computeSessionTokenReward(trueCostUsd, marginPct = REWARD_MARGIN_PCT, costScale = 1) {
216
230
  const cost = Number(trueCostUsd) || 0;
217
231
  const pct = Number(marginPct) || 0;
218
- const peg = Number.isFinite(Number(usdPeg)) && Number(usdPeg) > 0 ? Number(usdPeg) : 1;
232
+ const scale = Number.isFinite(Number(costScale)) && Number(costScale) > 0 ? Number(costScale) : 1;
219
233
  const mult = 1 + pct / 100;
220
- const drachmae = Math.round(cost * mult * peg);
221
- const pegNote = peg !== 1 ? ` × ${peg} peg` : '';
234
+ const drachmae = Math.round(cost * mult * scale);
235
+ const scaleNote = scale !== 1 ? ` × ${scale} scale` : '';
222
236
  const description =
223
- `$${cost.toFixed(2)} true cost × ${mult.toFixed(2)}${pegNote} = ${drachmae} drachmae (token reward)`;
237
+ `$${cost.toFixed(2)} true cost × ${mult.toFixed(2)}${scaleNote} = ${drachmae} drachmae (token reward)`;
224
238
  return { trueCostUsd: cost, marginPct: pct, drachmae, description };
225
239
  }
226
240
 
@@ -1026,8 +1040,8 @@ async function awardSessionTokenReward(
1026
1040
  const extraRaw = Number(extraCostUsd);
1027
1041
  const extra = Number.isFinite(extraRaw) ? Math.max(0, extraRaw) : 0;
1028
1042
  const trueCostUsd = Math.round((pricedCostUsd + extra) * 10000) / 10000;
1029
- const { marginPct, usdPeg } = rewardConfig();
1030
- const { drachmae, description } = computeSessionTokenReward(trueCostUsd, marginPct, usdPeg);
1043
+ const { marginPct, costScale } = rewardConfig();
1044
+ const { drachmae, description } = computeSessionTokenReward(trueCostUsd, marginPct, costScale);
1031
1045
 
1032
1046
  // Nothing priced (no real tokens) — record nothing, report cleanly.
1033
1047
  if (!(drachmae > 0)) {
@@ -2,7 +2,7 @@
2
2
  // (task 1003676).
3
3
  //
4
4
  // What it is: the reward defaults and the PURE function that resolves a branding
5
- // pack's `reward` block over them — margin, USD peg, mode, whether the per-task
5
+ // pack's `reward` block over them — margin, cost scale, mode, whether the per-task
6
6
  // credit and the ideator lane are on.
7
7
  //
8
8
  // Why it is its own file, and why it imports NOTHING: this is the one piece of
@@ -50,13 +50,56 @@ const REWARD_MODE_COST_PLUS_ONLY = 'cost-plus-only';
50
50
  // code path to disable separately.
51
51
  const IDEA_DESCENT_DECAY = 0.5;
52
52
 
53
+ // THE SIZING FACTOR IS `costScale` (task 1004968). It multiplies a session's
54
+ // cost-plus figure into credits and is 1 by default. It used to be called
55
+ // `usdPeg`, which read as an exchange rate between a credit and a dollar —
56
+ // exactly what ADR 0387 rules out (a credit has no redemption price). The OLD
57
+ // key is still accepted so no host pack breaks before every hosted pin has moved
58
+ // past this release (ADR 0161); when both are set, `costScale` wins. Removing
59
+ // `usdPeg` support is a later task. The formula itself does not change, and no
60
+ // existing credit_log row is touched — this only renames how a pack spells it.
61
+ // config/branding.neutral.json carries NEITHER key, on purpose: a host pack
62
+ // deep-merges over neutral, so a neutral `costScale: 1` would beat a host's old
63
+ // `usdPeg` and silently reset its awards. The default 1 lives here instead.
64
+ const LEGACY_SCALE_KEY = 'usdPeg';
65
+ const USD_PEG_DEPRECATION =
66
+ 'branding reward.usdPeg is deprecated: rename it to reward.costScale (same value, same awards; ADR 0387).';
67
+
68
+ function validScale(v) {
69
+ if (v == null) return NaN;
70
+ const n = Number(v);
71
+ return Number.isFinite(n) && n > 0 ? n : NaN;
72
+ }
73
+
74
+ // PURE. Which sizing factor a raw block carries: costScale if it is valid, else
75
+ // the legacy usdPeg, else the default 1. An INVALID costScale falls through to
76
+ // usdPeg rather than to 1, so a typo in the new key cannot silently change the
77
+ // awards of a pack that still carries a working old one.
78
+ function resolveCostScale(rawReward) {
79
+ const r = rawReward || {};
80
+ const fromNew = validScale(r.costScale);
81
+ if (Number.isFinite(fromNew)) return fromNew;
82
+ const fromOld = validScale(r[LEGACY_SCALE_KEY]);
83
+ if (Number.isFinite(fromOld)) return fromOld;
84
+ return 1;
85
+ }
86
+
87
+ // PURE. The one-line deprecation note for a pack that still spells the factor the
88
+ // old way, or null. Only when costScale is absent: a pack that has moved and kept
89
+ // the old key beside it is not using it, because costScale wins.
90
+ function rewardPolicyDeprecation(rawReward) {
91
+ const r = rawReward || {};
92
+ if (r[LEGACY_SCALE_KEY] != null && r.costScale == null) return USD_PEG_DEPRECATION;
93
+ return null;
94
+ }
95
+
53
96
  // PURE. Resolves a raw branding `reward` block over the code defaults. Every field
54
97
  // is validated rather than trusted: a malformed pack degrades to the defaults
55
98
  // instead of poisoning an award.
56
99
  function rewardPolicy(rawReward) {
57
100
  const r = rawReward || {};
58
101
  const marginPct = Number.isFinite(Number(r.marginPct)) ? Number(r.marginPct) : REWARD_MARGIN_PCT;
59
- const usdPeg = Number.isFinite(Number(r.usdPeg)) && Number(r.usdPeg) > 0 ? Number(r.usdPeg) : 1;
102
+ const costScale = resolveCostScale(r);
60
103
  const mode = r.mode === REWARD_MODE_COST_PLUS_ONLY ? REWARD_MODE_COST_PLUS_ONLY : REWARD_MODE_DEFAULT;
61
104
  const perTaskCredit = mode !== REWARD_MODE_COST_PLUS_ONLY;
62
105
  // Default-on, opt-out-only: only an explicit `false` withholds the ideator lane.
@@ -77,7 +120,7 @@ function rewardPolicy(rawReward) {
77
120
  const ideaDescentDecay = Number.isFinite(rawDecay) && rawDecay >= 0 && rawDecay < 1
78
121
  ? rawDecay
79
122
  : IDEA_DESCENT_DECAY;
80
- return { marginPct, usdPeg, mode, perTaskCredit, ideatorCredit, artistCredit, ideaDescentDecay };
123
+ return { marginPct, costScale, mode, perTaskCredit, ideatorCredit, artistCredit, ideaDescentDecay };
81
124
  }
82
125
 
83
126
  module.exports = {
@@ -85,5 +128,8 @@ module.exports = {
85
128
  REWARD_MODE_DEFAULT,
86
129
  REWARD_MODE_COST_PLUS_ONLY,
87
130
  IDEA_DESCENT_DECAY,
131
+ USD_PEG_DEPRECATION,
132
+ resolveCostScale,
133
+ rewardPolicyDeprecation,
88
134
  rewardPolicy,
89
135
  };
@@ -24,6 +24,7 @@ const ideaSizeHistorical = require('./idea-size-historical');
24
24
  const ideaBrightnessLib = require('./idea-brightness');
25
25
  const creditsByCraftLib = require('./credits-by-craft');
26
26
  const goalReward = require('./goal-reward');
27
+ const creditWording = require('./credit-wording');
27
28
 
28
29
  module.exports = {
29
30
  // credit allocation — transaction-participant: caller passes its tx `client`
@@ -147,4 +148,8 @@ module.exports = {
147
148
  wouldExtendStreak: (...a) => streak.wouldExtendStreak(...a),
148
149
  // task 1004853: /me's batched form — one streak + one task query per request.
149
150
  streakPreviewsForTasks: (...a) => streak.streakPreviewsForTasks(...a),
151
+ // task 1004969 (ADR 0385 decision 9): investment wording next to credits or
152
+ // models. The publish paths flag and log it; they never block or rewrite.
153
+ findInvestmentWording: (...a) => creditWording.findInvestmentWording(...a),
154
+ flagInvestmentWording: (...a) => creditWording.flagInvestmentWording(...a),
150
155
  };
@@ -988,6 +988,13 @@ module.exports = function buildGovernmentRouter() {
988
988
  }
989
989
  return res.fail('invalid_proposal', 400, { reason: out.reason, ...(out.field ? { field: out.field } : {}), ...(out.expected ? { expected: out.expected } : {}) });
990
990
  }
991
+ // task 1004969 (ADR 0385 decision 9): a board rationale that calls credits
992
+ // or models an investment is FLAGGED in the log; the item opens unchanged.
993
+ // Optional and undeclared (the ideas.descent precedent): no economy, no check.
994
+ const wording = api.resolveOptional && api.resolveOptional('reward');
995
+ if (wording && typeof wording.flagInvestmentWording === 'function' && typeof req.body.rationale_md === 'string') {
996
+ wording.flagInvestmentWording(req.body.rationale_md, { where: 'board item', ref: out.item && out.item.id });
997
+ }
991
998
  res.status(201).json({ ok: true, amendment: out.amendment, item: out.item });
992
999
  } catch (err) {
993
1000
  log.error('[gds] POST /government/board/items', err);
@@ -319,7 +319,8 @@
319
319
  notice('<p>The page changed after your draft was saved, so the draft is not shown over its new words. Rewrite the lines again here.</p>');
320
320
  }
321
321
  if (st.writable) { if (!st.ed.draft_on_older_reading) notice(''); return; }
322
- if (!r || r.state === 'draft' || (r.state === 'writing' && !r.holder)) {
322
+ // A blank round (opened, nothing written; task 1004972) is free to take, like a draft.
323
+ if (!r || r.state === 'draft' || r.state === 'blank' || (r.state === 'writing' && !r.holder)) {
323
324
  notice('<p>Nobody is writing this page. Take it, and every line here is yours to type over.</p>'
324
325
  + '<button class="tw-notice__act" id="tw-claim" type="button">take this page</button>');
325
326
  return;
@@ -287,6 +287,12 @@ module.exports = function buildInboxRouter() {
287
287
  // hand every untrusted door the same power by typing one word.
288
288
  vector: 'api',
289
289
  });
290
+ // task 1004969 (ADR 0385 decision 9): an idea that calls credits or models
291
+ // an investment is FLAGGED in the log; the idea is stored unchanged.
292
+ const wording = api.resolveOptional && api.resolveOptional('reward');
293
+ if (wording && typeof wording.flagInvestmentWording === 'function') {
294
+ wording.flagInvestmentWording([body.title, body.body_md, ...Object.values(body.field_values || {})].filter((v) => typeof v === 'string').join('\n\n'), { where: 'idea', ref: idea.id });
295
+ }
290
296
  let grade_warning;
291
297
  if (grade === 'full') {
292
298
  // The idea is captured; from here every failure is FAIL-OPEN (the
@@ -39,7 +39,7 @@ const { unsatisfiedDeps } = require('./db-deps-criteria.js');
39
39
  const { depRefusalRow } = require('./dead-deps.js');
40
40
  const { siblingOverlaps, RESOLVED_STATUSES } = require('./goal-conflicts.js');
41
41
  const { claimGate, claimPermissionsFor, disambiguateWorktreeName, foldWorktreeName } = require('./db-rank-authz.js');
42
- const { sealSessionLog } = require('./db-shared.js');
42
+ const { idKey, sealSessionLog } = require('./db-shared.js');
43
43
  const { gatingRebaseTasksForBuilder, shouldRefuseClaimResolve } = require('./db-ship.js');
44
44
  const { splitRebaseGate, rebaseRefusalTasks } = require('./rebase-gate.js');
45
45
  const { REWARD_SUGGESTION_CAP, rewardGateAssignment } = require('./db-tasks.js');
@@ -898,7 +898,7 @@ async function releaseClaimsBatch({ claimIds, builderId, resolverSessionId, reso
898
898
  for (const claimId of uniqIds) {
899
899
  const claim = byId.get(claimId);
900
900
  if (!claim) { failures.push({ claim_id: claimId, reason: 'CLAIM_NOT_FOUND' }); continue; }
901
- if (claim.builder_id !== builderId) { failures.push({ claim_id: claimId, reason: 'NOT_YOUR_CLAIM' }); continue; }
901
+ if (idKey(claim.builder_id) !== idKey(builderId)) { failures.push({ claim_id: claimId, reason: 'NOT_YOUR_CLAIM' }); continue; }
902
902
  if (claim.released_at) { failures.push({ claim_id: claimId, reason: 'ALREADY_RESOLVED' }); continue; }
903
903
  // task 1003767: the SAME decision resolveClaim uses, not a third copy of
904
904
  // the auth-session compare (this was the second of three). Batch release