@bongos/core 1.24.2 → 1.24.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.bongos-core.json +111 -51
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +8 -0
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/config/branding.neutral.json +0 -1
  8. package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +11 -0
  9. package/docs/api/openapi.json +121 -4
  10. package/docs/api-reference.md +8 -1
  11. package/docs/branding-contract.md +1 -1
  12. package/docs/module-api-changelog.md +2 -0
  13. package/docs/page-readings.json +7 -7
  14. package/modules/discord/ship-broadcast.js +7 -0
  15. package/modules/economy/credit-wording.js +93 -0
  16. package/modules/economy/credits.js +26 -12
  17. package/modules/economy/governor-docket.js +12 -6
  18. package/modules/economy/reward-policy.js +49 -3
  19. package/modules/economy/reward.js +5 -0
  20. package/modules/government/routes/government.js +7 -0
  21. package/modules/hall-ui/public/street-inside.js +1 -1
  22. package/modules/ideas/routes/inbox.js +6 -0
  23. package/modules/lifecycle/routes/project-settings.js +6 -4
  24. package/modules/market-watch/ask.js +425 -0
  25. package/modules/market-watch/migrations/market_watch_003_ask_pool.sql +21 -0
  26. package/modules/market-watch/module.json +10 -3
  27. package/modules/market-watch/pollers/weekly-ask.js +89 -0
  28. package/modules/market-watch/public/hall-widget.js +271 -0
  29. package/modules/market-watch/public/weekly-ask.css +39 -0
  30. package/modules/market-watch/routes/ask.js +95 -0
  31. package/modules/onboarding/routes/access-requests.js +38 -10
  32. package/modules/platform-identity/account-logins.js +13 -0
  33. package/modules/platform-identity/routes/my-projects.js +10 -7
  34. package/modules/platform-identity/routes/sso.js +11 -6
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/release-notes.json +22 -0
  38. package/scripts/bongos/backfill-subagent-rewards.js +7 -7
  39. package/scripts/bongos/run-unit-tests.js +4 -0
  40. package/scripts/hall-preview/server.js +13 -0
  41. package/src/bongos/auth-idp.js +17 -5
  42. package/src/bongos/db-kernel.js +14 -1
  43. package/src/bongos/module-scope-map.js +1 -0
  44. package/src/module-api.js +1 -1
  45. package/tests/account_logins.mjs +6 -4
  46. package/tests/backfill_subagent_rewards.mjs +7 -7
  47. package/tests/credit_grant.mjs +7 -7
  48. package/tests/credit_wording.mjs +154 -0
  49. package/tests/google_halls.mjs +211 -0
  50. package/tests/governor_docket.mjs +7 -3
  51. package/tests/hall_street_inside.mjs +2 -2
  52. package/tests/market_watch_ask.mjs +369 -0
  53. package/tests/market_watch_ask_db.mjs +122 -0
  54. package/tests/market_watch_data_db.mjs +4 -0
  55. package/tests/market_watch_module.mjs +9 -5
  56. package/tests/project_settings_page.mjs +1 -1
  57. package/tests/reward_cost_scale.mjs +200 -0
@@ -197,6 +197,10 @@
197
197
  "name": "llm-cache",
198
198
  "description": "Endpoints under `/api/bongos/llm-cache`."
199
199
  },
200
+ {
201
+ "name": "market-watch",
202
+ "description": "Endpoints under `/api/bongos/market-watch`."
203
+ },
200
204
  {
201
205
  "name": "me",
202
206
  "description": "Endpoints under `/api/bongos/me`."
@@ -13952,6 +13956,86 @@
13952
13956
  ]
13953
13957
  }
13954
13958
  },
13959
+ "/market-watch/ask": {
13960
+ "get": {
13961
+ "operationId": "get_market_watch_ask",
13962
+ "tags": [
13963
+ "market-watch"
13964
+ ],
13965
+ "summary": "GET /market-watch/ask",
13966
+ "description": "every member reads their own card; nobody else's (isMember in gate()).\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
13967
+ "x-rank": "any-builder",
13968
+ "x-source": "modules/market-watch/routes/ask.js",
13969
+ "responses": {
13970
+ "200": {
13971
+ "description": "Success.",
13972
+ "content": {
13973
+ "application/json": {
13974
+ "schema": {
13975
+ "$ref": "#/components/schemas/GetMarketWatchAskResponse"
13976
+ }
13977
+ }
13978
+ }
13979
+ },
13980
+ "400": {
13981
+ "$ref": "#/components/responses/BadRequest"
13982
+ },
13983
+ "401": {
13984
+ "$ref": "#/components/responses/Unauthorized"
13985
+ },
13986
+ "403": {
13987
+ "$ref": "#/components/responses/Forbidden"
13988
+ }
13989
+ },
13990
+ "security": [
13991
+ {
13992
+ "builderSession": []
13993
+ }
13994
+ ]
13995
+ }
13996
+ },
13997
+ "/market-watch/ask/answer": {
13998
+ "post": {
13999
+ "operationId": "post_market_watch_ask_answer",
14000
+ "tags": [
14001
+ "market-watch"
14002
+ ],
14003
+ "summary": "POST /market-watch/ask/answer",
14004
+ "description": "a member answers for themselves only (isMember in gate()).\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
14005
+ "x-rank": "any-builder",
14006
+ "x-source": "modules/market-watch/routes/ask.js",
14007
+ "requestBody": {
14008
+ "required": false,
14009
+ "content": {
14010
+ "application/json": {
14011
+ "schema": {
14012
+ "$ref": "#/components/schemas/PostMarketWatchAskAnswerRequest"
14013
+ }
14014
+ }
14015
+ },
14016
+ "x-validated": true
14017
+ },
14018
+ "responses": {
14019
+ "200": {
14020
+ "description": "Success."
14021
+ },
14022
+ "400": {
14023
+ "$ref": "#/components/responses/ValidationFailed"
14024
+ },
14025
+ "401": {
14026
+ "$ref": "#/components/responses/Unauthorized"
14027
+ },
14028
+ "403": {
14029
+ "$ref": "#/components/responses/Forbidden"
14030
+ }
14031
+ },
14032
+ "security": [
14033
+ {
14034
+ "builderSession": []
14035
+ }
14036
+ ]
14037
+ }
14038
+ },
13955
14039
  "/me": {
13956
14040
  "get": {
13957
14041
  "operationId": "get_me",
@@ -19493,7 +19577,7 @@
19493
19577
  "project-settings"
19494
19578
  ],
19495
19579
  "summary": "GET /project-settings",
19496
- "description": "GET /project-settings — all five rows in ONE read. rank: project.settings.manage (metic+, government_010), the SAME floor as the page it serves (requireGovernmentPage). The first cut gated this any-builder on the reasoning that it is \"the project's own published terms\" — but the payload also carries reward-policy internals (marginPct, usdPeg, mode), a raw rejected project_settings value, and internal file/migration paths, and a xenos who cannot even load the page could still curl those. Matching the read floor to the page's (task 1003280 grade, hacker lens) closes that asymmetry; the two WRITES already gate on this same atom, so the whole surface is now one floor. One read, not five: the page draws five rows, so it asks for five rows. Fetching per-row would put the hall's own settings page in the shape goal 1000072's criterion names (hall-ui/CLAUDE.md, \"a card asks the server for the rows it draws\").\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `project.settings.manage` (all required).",
19580
+ "description": "GET /project-settings — all five rows in ONE read. rank: project.settings.manage (metic+, government_010), the SAME floor as the page it serves (requireGovernmentPage). The first cut gated this any-builder on the reasoning that it is \"the project's own published terms\" — but the payload also carries reward-policy internals (marginPct, costScale, mode), a raw rejected project_settings value, and internal file/migration paths, and a xenos who cannot even load the page could still curl those. Matching the read floor to the page's (task 1003280 grade, hacker lens) closes that asymmetry; the two WRITES already gate on this same atom, so the whole surface is now one floor. One read, not five: the page draws five rows, so it asks for five rows. Fetching per-row would put the hall's own settings page in the shape goal 1000072's criterion names (hall-ui/CLAUDE.md, \"a card asks the server for the rows it draws\").\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `project.settings.manage` (all required).",
19497
19581
  "x-rank": "metic+archon",
19498
19582
  "x-source": "modules/lifecycle/routes/project-settings.js",
19499
19583
  "x-permissions": [
@@ -31051,6 +31135,15 @@
31051
31135
  "learnings"
31052
31136
  ]
31053
31137
  },
31138
+ "GetMarketWatchAskResponse": {
31139
+ "type": "object",
31140
+ "properties": {
31141
+ "card": {}
31142
+ },
31143
+ "required": [
31144
+ "card"
31145
+ ]
31146
+ },
31054
31147
  "GetMeAchievementsResponse": {
31055
31148
  "type": "object",
31056
31149
  "properties": {
@@ -36533,6 +36626,30 @@
36533
36626
  "stored"
36534
36627
  ]
36535
36628
  },
36629
+ "PostMarketWatchAskAnswerRequest": {
36630
+ "type": "object",
36631
+ "properties": {
36632
+ "week_of": {
36633
+ "type": "string",
36634
+ "maxLength": 10
36635
+ },
36636
+ "picks": {
36637
+ "type": "array",
36638
+ "maxItems": 20
36639
+ },
36640
+ "names": {
36641
+ "type": "array",
36642
+ "maxItems": 6
36643
+ },
36644
+ "nothing": {
36645
+ "type": "boolean"
36646
+ },
36647
+ "builder_id": {
36648
+ "type": "integer"
36649
+ }
36650
+ },
36651
+ "additionalProperties": false
36652
+ },
36536
36653
  "PostMeAnthropicLinkRequest": {
36537
36654
  "type": "object",
36538
36655
  "properties": {
@@ -40319,9 +40436,9 @@
40319
40436
  "description": "A required dependency/feature is not configured or is temporarily down."
40320
40437
  }
40321
40438
  },
40322
- "x-endpoint-count": 634,
40323
- "x-schema-count": 684,
40439
+ "x-endpoint-count": 636,
40440
+ "x-schema-count": 686,
40324
40441
  "x-undocumented-bodies": 12,
40325
- "x-response-schemas": 440,
40442
+ "x-response-schemas": 441,
40326
40443
  "x-generated-by": "scripts/bongos/gen-api-docs.js"
40327
40444
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Bongos API reference
4
4
 
5
- > **Generated from the live route files** — the route file is authoritative. 634 endpoints across 104 route files.
5
+ > **Generated from the live route files** — the route file is authoritative. 636 endpoints across 105 route files.
6
6
  > Machine-readable spec: [`docs/api/openapi.json`](api/openapi.json) (OpenAPI 3.1). Rendered docs site: **`/docs`** (e.g. `cloudbongos.com/docs`).
7
7
 
8
8
  Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust-boundary-server-enforced-permissions.md)): `public` < `any-builder` < `metic+archon` < `archon`.
@@ -518,6 +518,13 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust
518
518
  | POST | `/api/bongos/llm-cache/lookup` | `metic+archon` | `cache_key`, `tokens_saved_est` | POST /llm-cache/lookup — hit/miss + atomic hit_count bump. |
519
519
  | POST | `/api/bongos/llm-cache/store` | `metic+archon` | `spec`, `result_stdout`, `result_cost_usd` | POST /llm-cache/store — upsert a freshly-produced result. |
520
520
 
521
+ ## `market-watch` (2)
522
+
523
+ | Method | Path | Rank | Body | Description |
524
+ |---|---|---|---|---|
525
+ | GET | `/api/bongos/market-watch/ask` | `any-builder` | — | every member reads their own card; nobody else's (isMember in gate()). |
526
+ | POST | `/api/bongos/market-watch/ask/answer` | `any-builder` | `week_of`, `picks`, `names`, `nothing`, `builder_id` | a member answers for themselves only (isMember in gate()). |
527
+
521
528
  ## `me` (54)
522
529
 
523
530
  | Method | Path | Rank | Body | Description |
@@ -33,7 +33,7 @@ env override → instance config (config/branding.json) → neutral star
33
33
  | `repo` | `{ owner, name }` GitHub binding. |
34
34
  | `db.database` | DB name Bongos pool connects to when neither `DATABASE_URL` nor `PGHOST`/`PGDATABASE` is set (`src/bongos/pool.js`, ADR 0062 §5). `null` in the neutral starter = **fail loud** rather than silently target a host DB. |
35
35
  | `currency` | `{ label, symbol }` — the reward unit label (e.g. "example"). Display only. |
36
- | `reward` | `{ marginPct, usdPeg, mode }` — the per-instance reward POLICY the economy module reads ([ADR 0054](adr/0054-session-token-reward-cost-plus.md) + ADR 0146, tasks 2009/1002270): the cost-plus margin percent (default `20`), the example-per-USD peg (default `1`), and `mode` — `"cost-plus-and-estimate"` (default; pay both the per-task author estimate AND the cost-plus session reward) or `"cost-plus-only"` (suppress the per-task estimate payout; the cost-plus session reward is the sole equity reward). Two per-craft lanes sit beside `mode` and are not derived from it: `ideatorCredit` ([ADR 0172](adr/0172-per-craft-compensation-ideator-credit-lane.md)) and `artistCredit` ([ADR 0341](adr/0341-the-page-is-the-unit-of-tweak-mode.md) D10, an approved page tweak pays its artist), each default on and withheld only by an explicit `false`. A committed, code-reviewed change — not a live UI knob. Omit ⇒ code defaults (20% margin, 1:1 peg, pay both streams, both lanes on). |
36
+ | `reward` | `{ marginPct, costScale, mode }` — the per-instance reward POLICY the economy module reads ([ADR 0054](adr/0054-session-token-reward-cost-plus.md) + ADR 0146, tasks 2009/1002270): the cost-plus margin percent (default `20`), the sizing factor `costScale` (default `1`; it sizes credits from cost and is not an exchange rate — a credit has no price, [ADR 0387](adr/0387-two-ledgers-credits-measure-work-money-stays-with-the-processor.md)), and `mode` — `"cost-plus-and-estimate"` (default; pay both the per-task author estimate AND the cost-plus session reward) or `"cost-plus-only"` (suppress the per-task estimate payout; the cost-plus session reward is the sole equity reward). Two per-craft lanes sit beside `mode` and are not derived from it: `ideatorCredit` ([ADR 0172](adr/0172-per-craft-compensation-ideator-credit-lane.md)) and `artistCredit` ([ADR 0341](adr/0341-the-page-is-the-unit-of-tweak-mode.md) D10, an approved page tweak pays its artist), each default on and withheld only by an explicit `false`. A committed, code-reviewed change — not a live UI knob. Omit ⇒ code defaults (20% margin, scale 1, pay both streams, both lanes on). **`usdPeg` is the deprecated old name of `costScale`** (task 1004968): it is still read, with a one-line deprecation note in the server log, so no host pack breaks; when both are set `costScale` wins. The neutral pack sets neither key (the default `1` lives in code), so a host pack's old `usdPeg` is never shadowed by the deep-merge. Rename it at your next pack edit — support for the old key goes in a later release. |
37
37
  | `models` | `{ main, subagentDefault, subagentRoutine }` — the per-instance subagent model-allocation DEFAULT the builder-settings module reads (task 2011): the tier for the main session, subagent-default work, and routine plumbing (tiers: `haiku`/`sonnet`/`opus`/`fable`). A per-builder override resolves over this. Omit ⇒ code defaults (`opus`/`opus`/`haiku`). |
38
38
  | `tracks` | The configurable version-track set (replaces the hardcoded `product\|internal`). |
39
39
  | `theme` | `{ palette, mode, ui }` — palette file ref + theme mode, plus `ui`, the WEB look `themeMarkup()` splices into every served page as a plain `:root` after the page's stylesheet: `colors` (the thirteen contract tokens — **light-mode values**; each stylesheet derives its own dark tier from them at `:root[data-mode="dark"]` + its `prefers-color-scheme` twin, which outrank the pack), `fonts` (`display`, `body`, `links`), `favicon`, and `backdrop` — a `body{background:…}` rule that outranks every sheet in **both** modes, so a dual-mode world sets it to `var(--bg)` (the neutral pack does since [task 1003298](https://cloudbongos.com/builders#/task/1003298), ADR 0196) rather than a literal. The neutral values are the chrome world's; `config/design-tokens.neutral.json` must mirror `colors` + `fonts` key for key (`tests/design_tokens_sync.mjs`), and the world's derived recipes are recorded in `DESIGN.md`. The ui-design module's **style library** (`modules/ui-design/styles/`, [ADR 0219](adr/0219-a-look-is-a-branding-pack-the-style-library.md)) ships ready-made `theme.ui` blocks (looks) an instance copies into this field; every served sheet reads only these fifteen, so a look re-themes every surface with no page edit. |
@@ -3266,5 +3266,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
3266
3266
  landed since 1.24.0 with no explicit bump. run 40985289858. (task 1002620)
3267
3267
  1.24.2 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3268
3268
  landed since 1.24.1 with no explicit bump. run 41000649913. (task 1002620)
3269
+ 1.24.4 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3270
+ landed since 1.24.2 with no explicit bump. run 41004393103. (task 1002620)
3269
3271
  ---------------------------------------------------------------------------
3270
3272
  ```
@@ -5,7 +5,7 @@
5
5
  "adr": "0341 D3",
6
6
  "reread": "node scripts/bongos/page-reader.js --stale",
7
7
  "how": ["Generated by node scripts/bongos/page-reader.js (BV2.TW04, task 1004315; ADR 0341 D3). Do not hand-edit.","One entry per page of docs/page-inventory.json. Lines are in page order: the default state first, and a line only another state shows sits where that state shows it, with its state named.","placement: placed = a docs/copy-registry.json row renders it (file, line, string_id); shared = shell text every page of the surface carries (top bar, sidebar, footer), with its row when the registry has one; unplaced = the registry cannot place it (a rewrite becomes an engineer task, ADR 0341 D4).","text keeps {…} where the registry row has a hole, or where a value the page read from its API was filled in. A string that is only such data is counted under omitted.data, not listed.","reading_hash is over the non-shared lines (key, section, text); files_hash is over the page's inventory files. --check compares files_hash only and never opens a browser.","Rendered through the hall-preview harness (hall) and the ui-design kit stub (landing, status), at 1280x800 with the clock pinned, so a reading is the fixture world's page."],
8
- "counts": {"pages":48,"lines":5456,"placed":1683,"shared":427,"unplaced":3346,"omitted_data":3730},
8
+ "counts": {"pages":48,"lines":5456,"placed":1683,"shared":427,"unplaced":3346,"omitted_data":3731},
9
9
  "pages": [
10
10
  {
11
11
  "id": "builders:ai-studio",
@@ -271,7 +271,7 @@
271
271
  "surface": "builders",
272
272
  "title": "The city",
273
273
  "states_read": ["city","fullscreen","switched","focus","pins","living","card","card-ask"],
274
- "files_hash": "a3b905698a39c7bc",
274
+ "files_hash": "6a8a96e1f9f6084a",
275
275
  "reading_hash": "77470c4eb3047a05",
276
276
  "counts": {"lines":43,"placed":10,"shared":8,"unplaced":25},
277
277
  "omitted": {"data":17},
@@ -3370,10 +3370,10 @@
3370
3370
  "surface": "builders",
3371
3371
  "title": "The street",
3372
3372
  "states_read": ["street","fullscreen","card","switched","focus","here","inside-rules","inside-disputes","inside-people","inside-planning","inside-automation","inside-review","inside-release","inside-treasury","floor","amend","district","desk","desk-facts","desk-quiet","desk-people-builders","desk-people-tier","desk-disputes","desk-automation-goals","desk-release-core","desk-rules","desk-rules-category","desk-planning-priorities","desk-planning-ideas","desk-planning-blockers","pins","pins-pager","living","deck","deck-treasury","sitting","object","voted","admission","settled","blocker","blocker-more","report","report-more","core","core-more","recheck","recheck-more","idea","idea-more","queue","queue-more","automation","automation-more"],
3373
- "files_hash": "5c081c34c9e70e53",
3374
- "reading_hash": "f9a1c5189c877bb0",
3373
+ "files_hash": "6180e15ad9dcf4d6",
3374
+ "reading_hash": "b5cd1724efa21f28",
3375
3375
  "counts": {"lines":650,"placed":75,"shared":8,"unplaced":567},
3376
- "omitted": {"data":552},
3376
+ "omitted": {"data":553},
3377
3377
  "notes": ["sitting: an action failed (page.click: Timeout 6000ms exceeded.); read as it stood","object: an action failed (page.evaluate: Error: dispatchClick: nothing matches .street-pin[data-matter=\"board:47\"]); read as it stood","voted: an action failed (page.click: Timeout 6000ms exceeded.); read as it stood","admission: an action failed (page.click: Timeout 6000ms exceeded.); read as it stood","settled: an action failed (page.click: Timeout 6000ms exceeded.); read as it stood","blocker: an action failed (page.evaluate: Error: dispatchClick: nothing matches .street-pin[data-matter=\"blockers:11\"]); read as it stood","blocker-more: an action failed (page.evaluate: Error: dispatchClick: nothing matches .street-pin[data-matter=\"blockers:11\"]); read as it stood","report: an action failed (page.evaluate: Error: dispatchClick: nothing matches .street-pin[data-matter=\"redteam_reports:4\"]); read as it stood","report-more: an action failed (page.evaluate: Error: dispatchClick: nothing matches .street-pin[data-matter=\"redteam_reports:4\"]); read as it stood","core: an action failed (page.evaluate: Error: dispatchClick: nothing matches .street-pin[data-matter=\"core_update:1.21.20\"]); read as it stood","core-more: an action failed (page.evaluate: Error: dispatchClick: nothing matches .street-pin[data-matter=\"core_update:1.21.20\"]); read as it stood","recheck: an action failed (page.evaluate: Error: dispatchClick: nothing matches .street-pin[data-matter=\"module_rechecks:7\"]); read as it stood","recheck-more: an action failed (page.evaluate: Error: dispatchClick: nothing matches .street-pin[data-matter=\"module_rechecks:7\"]); read as it stood","idea: an action failed (page.evaluate: Error: dispatchClick: nothing matches .street-pin[data-matter=\"board:41\"]); read as it stood","idea-more: an action failed (page.evaluate: Error: dispatchClick: nothing matches .street-pin[data-matter=\"board:41\"]); read as it stood"],
3378
3378
  "lines": [
3379
3379
  {"key":"L0001","section":"skip link","text":"Skip to content","placement":"shared","file":"modules/hall-ui/public/shell.js","line":868,"string_id":"87cee78ca134"},
@@ -3597,7 +3597,7 @@
3597
3597
  {"key":"L0219","section":"Spending matters","text":"{…} of {…}","placement":"placed","file":"modules/hall-ui/public/sky-panel.js","line":120,"string_id":"73c1372b43d9","state":"inside-treasury"},
3598
3598
  {"key":"L0220","section":"Spending matters","text":"{…} from the reserve?","placement":"unplaced","state":"inside-treasury"},
3599
3599
  {"key":"L0221","section":"Spending matters","text":"{…} · today","placement":"unplaced","state":"inside-treasury"},
3600
- {"key":"L0222","section":"Spending matters","text":"Paid $5,549 to 7 buil… · today","placement":"unplaced","state":"inside-treasury"},
3600
+ {"key":"L0222","section":"Spending matters","text":"7 builders earned 5,5… · today","placement":"unplaced","state":"inside-treasury"},
3601
3601
  {"key":"L0223","section":"Spending matters","text":"{…} · 4w","placement":"unplaced","state":"inside-treasury","occurrences":2},
3602
3602
  {"key":"L0224","section":"Spending matters","text":"the record","placement":"placed","file":"modules/hall-ui/public/street.html","line":192,"string_id":"4bde7b562cf4","state":"inside-treasury"},
3603
3603
  {"key":"L0225","section":"Spending matters","text":"1 matter on the docket · 16 on the {…}","placement":"unplaced","state":"inside-treasury"},
@@ -3832,7 +3832,7 @@
3832
3832
  {"key":"L0454","section":"Priorities","text":"standing law","placement":"unplaced","state":"desk-planning-priorities"},
3833
3833
  {"key":"L0455","section":"Waiting on {…}","text":"Waiting on {…}","placement":"placed","file":"modules/hall-ui/public/goals-lib.js","line":91,"string_id":"b18832e219dc","state":"inside-planning"},
3834
3834
  {"key":"L0456","section":"Waiting on {…}","text":"9 waiting · oldest 3 weeks","placement":"unplaced","state":"inside-planning"},
3835
- {"key":"L0457","section":"Waiting on {…}","text":"waiting","placement":"placed","file":"modules/hall-ui/public/work.js","line":431,"string_id":"8186b562553a","state":"inside-planning"},
3835
+ {"key":"L0457","section":"Waiting on {…}","text":"waiting","placement":"placed","file":"modules/hall-ui/public/work.js","line":438,"string_id":"8186b562553a","state":"inside-planning"},
3836
3836
  {"key":"L0458","section":"Waiting on {…}","text":"on {…}","placement":"unplaced","state":"inside-planning"},
3837
3837
  {"key":"L0459","section":"Waiting on {…}","text":"WA","placement":"unplaced","state":"inside-planning"},
3838
3838
  {"key":"L0460","section":"Waiting on {…}","text":"Working area 2…","placement":"unplaced","state":"inside-planning"},
@@ -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)) {
@@ -5,7 +5,7 @@
5
5
  // modules/government/docket.js).
6
6
  //
7
7
  // WHY. Inside the Treasury building three floors are the project's real
8
- // spending lines — rewards paid for shipped work, the AI grader, and hosting —
8
+ // lines — credits earned for shipped work, the AI grader, and hosting —
9
9
  // and its record drawers are those same lines month by month. They come from
10
10
  // the two ledgers this module owns (credit_log, cost_log) through ONE bounded
11
11
  // read, cost.treasuryMonths. The ledgers are already published unauthenticated
@@ -28,22 +28,28 @@ function usd(n) {
28
28
  const v = Math.round(Number(n) || 0);
29
29
  return `$${v.toLocaleString('en-US')}`;
30
30
  }
31
+ // Credits are a contribution meter, not money (ADR 0385, ADR 0387): the rewards
32
+ // line counts them and never wears a $ sign (task 1005006).
33
+ function credits(n) {
34
+ const v = Math.round(Number(n) || 0);
35
+ return `${v.toLocaleString('en-US')} ${v === 1 ? 'credit' : 'credits'}`;
36
+ }
31
37
  function monthKey(now) { const d = new Date(now); return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`; }
32
38
  function monthWord(key) { const m = Number(String(key).slice(5, 7)); return MONTHS[m - 1] || String(key); }
33
39
 
34
40
  // One spending line's floor from the month rows. Pure.
35
- function lineFloor(months, now, field, { what, meta }) {
41
+ function lineFloor(months, now, field, { what, meta, fmt = usd }) {
36
42
  const rows = Array.isArray(months) ? months : [];
37
43
  const cur = rows.find((r) => r.month === monthKey(now));
38
44
  const prev = rows.find((r) => r.month < monthKey(now));
39
45
  const v = cur ? Number(cur[field]) || 0 : 0;
40
46
  const lines = [[what, meta]];
41
- if (prev) lines.push([`${usd(prev[field])} in ${monthWord(prev.month)}.`, 'last month']);
42
- return { count: usd(v), line: `${usd(v)} this month`, lines, hot: false };
47
+ if (prev) lines.push([`${fmt(prev[field])} in ${monthWord(prev.month)}.`, 'last month']);
48
+ return { count: fmt(v), line: `${fmt(v)} this month`, lines, hot: false };
43
49
  }
44
50
 
45
51
  function rewardsFloor(months, now) {
46
- const f = lineFloor(months, now, 'rewards_credits', { what: 'Paid to builders for shipped work, one credit to the dollar.', meta: 'each ship' });
52
+ const f = lineFloor(months, now, 'rewards_credits', { what: 'Credits earned for shipped work. They count contribution; they are not money.', meta: 'each ship', fmt: credits });
47
53
  const cur = (months || []).find((r) => r.month === monthKey(now));
48
54
  const n = cur ? cur.rewarded_builders : 0;
49
55
  return { ...f, line: `${f.count} this month · ${n} ${n === 1 ? 'builder' : 'builders'}` };
@@ -56,7 +62,7 @@ function treasuryHistory(months) {
56
62
  const out = [];
57
63
  for (const r of months || []) {
58
64
  const at = `${r.month}-01T00:00:00.000Z`;
59
- if (r.rewards_credits > 0) out.push({ id: `rewards:${r.month}`, title: `Paid ${usd(r.rewards_credits)} to ${r.rewarded_builders} ${r.rewarded_builders === 1 ? 'builder' : 'builders'}`, at, outcome: 'rewards' });
65
+ if (r.rewards_credits > 0) out.push({ id: `rewards:${r.month}`, title: `${r.rewarded_builders} ${r.rewarded_builders === 1 ? 'builder' : 'builders'} earned ${credits(r.rewards_credits)}`, at, outcome: 'rewards' });
60
66
  if (r.grading_usd > 0) out.push({ id: `grading:${r.month}`, title: `AI grading cost ${usd(r.grading_usd)}`, at, outcome: 'grading' });
61
67
  if (r.hosting_usd > 0) out.push({ id: `hosting:${r.month}`, title: `Hosting cost ${usd(r.hosting_usd)}`, at, outcome: 'hosting' });
62
68
  }
@@ -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
  };