@bongos/core 1.21.64 → 1.21.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.21.64",
6
- "core_contract": "1.21.64",
7
- "source_commit": "989db61abf104847bf56030af9ddc05491e37a12",
5
+ "core_version": "1.21.65",
6
+ "core_contract": "1.21.65",
7
+ "source_commit": "bf961553e46095e169f2fb5218e409ff2ce03a8f",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-10-06T14:15:46.208Z",
9
+ "built_at": "2026-10-06T14:51:44.072Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 586,
12
+ "docs_redacted": 587,
13
13
  "agent_docs_stubbed": 28,
14
- "functional_verbatim": 2952,
14
+ "functional_verbatim": 2954,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3567,
20
- "tree_sha256": "0bd06cb774988693cbf1b62578dd311191c77ce478ec0192e22e97e050565899",
19
+ "file_count": 3570,
20
+ "tree_sha256": "bbb40e44796d6e7f5f34f46813add3554ad9d3a8ec722731a46bf1fa2ea1022f",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2289,10 +2289,15 @@
2289
2289
  "mode": "0000644",
2290
2290
  "sha256": "29c841d193efa7b6f6479e14a6056057138574fcd7b526f667da6f219dece5a7"
2291
2291
  },
2292
+ {
2293
+ "path": "docs/adr/0367-the-support-bot-answers-from-public-docs-only.md",
2294
+ "mode": "0000644",
2295
+ "sha256": "6d02d69a8d1bc0378f143960b280e06b4d7777b0ef4d3e7aa8c2d5a4337f85b8"
2296
+ },
2292
2297
  {
2293
2298
  "path": "docs/adr/README.md",
2294
2299
  "mode": "0000644",
2295
- "sha256": "08f66fecefc58d9c09e45841dd9d47514e807934cfcd4621a8bfeaa390afb334"
2300
+ "sha256": "2d9e4ea00f57234d18adc9a8b95b1ccabb3fca6b61b859d629eb8e8f533cc9f8"
2296
2301
  },
2297
2302
  {
2298
2303
  "path": "docs/api-reference.md",
@@ -2832,7 +2837,7 @@
2832
2837
  {
2833
2838
  "path": "docs/file-map.md",
2834
2839
  "mode": "0000644",
2835
- "sha256": "f9943f6b4d4859358b4d2d71af9f043d47b2f5a70532bbb9489f34423c596ff8"
2840
+ "sha256": "30be6ac52963f8b85a7b608b21505279e0560f208aba36d52cfc42350be663b5"
2836
2841
  },
2837
2842
  {
2838
2843
  "path": "docs/handoff-template.md",
@@ -2842,7 +2847,7 @@
2842
2847
  {
2843
2848
  "path": "docs/module-api-changelog.md",
2844
2849
  "mode": "0000644",
2845
- "sha256": "3d046a129353cef8cc88102f4fff67f11dcd048e83eeca9c5152ab2159f68c81"
2850
+ "sha256": "3e70987df8e2ab36308b3ad31db5f3d8de001ee085b77c0093fb8962511cd07f"
2846
2851
  },
2847
2852
  {
2848
2853
  "path": "docs/modules-contract.md",
@@ -4647,7 +4652,7 @@
4647
4652
  {
4648
4653
  "path": "modules/discord/db.js",
4649
4654
  "mode": "0000644",
4650
- "sha256": "e265e853cf153a76e7b84f304e6f4338bd2bbfc2098383c358a0df7ac02a6817"
4655
+ "sha256": "130cdbc84ce8298c6dffd03bc125d5c3b102ccff11fb95d24d6e7e999ac2aaa7"
4651
4656
  },
4652
4657
  {
4653
4658
  "path": "modules/discord/discord-approvals.js",
@@ -4662,7 +4667,7 @@
4662
4667
  {
4663
4668
  "path": "modules/discord/discord-bot.js",
4664
4669
  "mode": "0000644",
4665
- "sha256": "15d3bf047731fa50cca8bc3b06ef1c2b27e9b8a0067f3151a00974f99eb2ac3d"
4670
+ "sha256": "a96d9e55db1d66813fd552419bee6548edf2587e9510ae0c2e61640d1e80fc34"
4666
4671
  },
4667
4672
  {
4668
4673
  "path": "modules/discord/discord-bugs.js",
@@ -4694,6 +4699,11 @@
4694
4699
  "mode": "0000644",
4695
4700
  "sha256": "852abc58bdec011b6d3bf222a35e198c6ea98a761d57a76be85477ef85ae54f5"
4696
4701
  },
4702
+ {
4703
+ "path": "modules/discord/discord-support.js",
4704
+ "mode": "0000644",
4705
+ "sha256": "68c32e054d07133f685ce9dd80a4b6d53fada4b1e97939491b958b1bb8994a9e"
4706
+ },
4697
4707
  {
4698
4708
  "path": "modules/discord/lifecycle-port.js",
4699
4709
  "mode": "0000644",
@@ -4707,7 +4717,7 @@
4707
4717
  {
4708
4718
  "path": "modules/discord/module.json",
4709
4719
  "mode": "0000644",
4710
- "sha256": "3d9e8fc9dd7991b3636904bb4680a92be08f1dfc698d1f87452581412048911f"
4720
+ "sha256": "f3b7314eaea0977841d71a02fdc032c2fea7f4cf242450161b2d17824409b7f7"
4711
4721
  },
4712
4722
  {
4713
4723
  "path": "modules/discord/pollers/discord-bot.js",
@@ -7447,7 +7457,7 @@
7447
7457
  {
7448
7458
  "path": "modules/memory/module.json",
7449
7459
  "mode": "0000644",
7450
- "sha256": "997d6c1ce5707c64f8ab74ae51b34234ecf975fbf62ee7160e9e6750233cbf96"
7460
+ "sha256": "f04e13545b12ef46a1233bf295cc696f64bad72866207b6be9c8166528bf8208"
7451
7461
  },
7452
7462
  {
7453
7463
  "path": "modules/memory/pollers/search-index.js",
@@ -7467,7 +7477,7 @@
7467
7477
  {
7468
7478
  "path": "modules/memory/routes/search.js",
7469
7479
  "mode": "0000644",
7470
- "sha256": "a79f61f7906393fcb0bffb67f224c47b7129223e3a6806357de091a9339fa42e"
7480
+ "sha256": "3b8fffea8e90c461c9fdc2957ca60300734b44257f9b3d5aca49e73d04019b0e"
7471
7481
  },
7472
7482
  {
7473
7483
  "path": "modules/memory/search-chunker.js",
@@ -7477,7 +7487,7 @@
7477
7487
  {
7478
7488
  "path": "modules/memory/search.js",
7479
7489
  "mode": "0000644",
7480
- "sha256": "43ede2e90d0124c42f6f9dfc3695898b83c8e876c01f70b0ae6e9d2f61d41085"
7490
+ "sha256": "0540c9034480a9ee78159449b9cbc86b8415a9c70b9d108f1d4fafa66bd997a5"
7481
7491
  },
7482
7492
  {
7483
7493
  "path": "modules/npm-release/module.json",
@@ -9967,12 +9977,12 @@
9967
9977
  {
9968
9978
  "path": "package-lock.json",
9969
9979
  "mode": "0000644",
9970
- "sha256": "622b9fe38de1079f4150047cedf1d615273be488b9ccb33a3e2b58bb84036169"
9980
+ "sha256": "f74efc3baf5ed890ece42d60d96e89fcaf6a798f44558b400c079839ff46e3e8"
9971
9981
  },
9972
9982
  {
9973
9983
  "path": "package.json",
9974
9984
  "mode": "0000644",
9975
- "sha256": "fec42e573a0cce4a73b7c37f312ab057bb1841245ef8f92d8242d6f33380083b"
9985
+ "sha256": "33e94ab67e0bf024b19d2a59ac82e00a76e2878e28e5d962c5222fbec419cbb9"
9976
9986
  },
9977
9987
  {
9978
9988
  "path": "public-docs/index.html",
@@ -9992,7 +10002,7 @@
9992
10002
  {
9993
10003
  "path": "release-notes.json",
9994
10004
  "mode": "0000644",
9995
- "sha256": "6180605070bb9857896a02f2e94b3f1001c52940e9994a5097fe25ed36a4d9bd"
10005
+ "sha256": "f5e7adc2ee05b1a16498418766dba9f6ca27c708c750ba9dfbb501a899d476c1"
9996
10006
  },
9997
10007
  {
9998
10008
  "path": "scripts/cron/style_guide_audit.sh",
@@ -10372,7 +10382,7 @@
10372
10382
  {
10373
10383
  "path": "scripts/gds/credential-presence.js",
10374
10384
  "mode": "0000644",
10375
- "sha256": "9b78c4d3459474fe4b89355a0d3fe0aa67e4fabfe7ef10b4bf061abdcb339b63"
10385
+ "sha256": "36ecef8da86e9905c8c3e8431881828aba0ecca7e10b63db014f86658c5bf58b"
10376
10386
  },
10377
10387
  {
10378
10388
  "path": "scripts/gds/cutover-codemod.js",
@@ -12212,7 +12222,7 @@
12212
12222
  {
12213
12223
  "path": "src/module-api.js",
12214
12224
  "mode": "0000644",
12215
- "sha256": "99eb52e4e8e5f3abe31a577c53aa434a106c171c22294e64d851ee41d71d2c63"
12225
+ "sha256": "be6c5f14da4c58d5449936a2c21aa984d00db13cb51c804e26dcdc25e36a18e3"
12216
12226
  },
12217
12227
  {
12218
12228
  "path": "src/module-loader/catalog.js",
@@ -13539,6 +13549,11 @@
13539
13549
  "mode": "0000644",
13540
13550
  "sha256": "858e20374f392b3444ea6ef65253eb6563425ab772c4988a33434f350effac54"
13541
13551
  },
13552
+ {
13553
+ "path": "tests/discord_support.mjs",
13554
+ "mode": "0000644",
13555
+ "sha256": "3823845cfaf33c108d96f23d2f652a51df0ae859aff827fb18556a2cd7110cca"
13556
+ },
13542
13557
  {
13543
13558
  "path": "tests/display_name.mjs",
13544
13559
  "mode": "0000644",
@@ -16777,7 +16792,7 @@
16777
16792
  {
16778
16793
  "path": "tests/search_isolation.mjs",
16779
16794
  "mode": "0000644",
16780
- "sha256": "6bc8729f739c83383c859b464c1cde2e7cfc3d148b09ea6478017ee135089267"
16795
+ "sha256": "5833e5642257d933a7245e44a8dd9b8365d4e050df2deb0bde7a1456901cbcd2"
16781
16796
  },
16782
16797
  {
16783
16798
  "path": "tests/search_route.mjs",
@@ -0,0 +1,69 @@
1
+ # ADR 0367 — The Discord support bot answers from public docs only, under a monthly cap that fails closed
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-10-06
5
+ - **Task:** [task 1002534](https://cloudbongos.com/builders#/task/1002534) (goal 1000110, BONGOS-V2), promoted from idea 1000440
6
+ - **Builds on:** [ADR 0033](0033-discord-bot-service-principal.md) (the bot is a service principal and a mirror, never authority; every bot action is audited), [ADR 0060](0060-gds-retrieval-layer.md) (recall: `search_chunks`, scoped in SQL), [ADR 0080](0080-llm-cache-api-transport-and-trust-model.md) (anything a stranger can type is untrusted input to a model), [ADR 0083](0083-modular-architecture-strangler-build-plan.md) (modules talk through ports, never a require)
7
+ - **Decided with:** the owner. Scope at the 2026-07-29 inbox triage: read-only, public docs only, one support channel, no writes. The rest on 2026-10-06, all five the recommended option: a $5/month cap, `claude-haiku-4-5`, answer only when @-mentioned, the newcomer docs as the corpus, and ship it switched off until a key is on the server.
8
+
9
+ ## Context
10
+
11
+ Support questions in Discord are answered by whoever happens to be around. The answers mostly already exist in the docs. A bot can point people at them, but it sits outside the trust boundary in a channel anyone can read and type into. That raises three risks the triage named: leaking rank-gated content into a public channel, prompt injection from channel text, and per-message model spend on a bootstrapped budget.
12
+
13
+ Two facts shaped the design. First, `search_chunks` at `min_rank='xenos'` is not "public". Every tracked `.md` is ingested at xenos unless it is in the protected set: session logs, every ADR, `.claude/` skills, `docs/credentials.md`, `docs/architecture.md`, the red-team recipe. Xenos is still a signed-in builder, and `#support` is visible to everyone. Second, nothing on the server calls a model today. The Claude login lives on builders' machines (`docs/credentials.md`), never on the host.
14
+
15
+ ## Decision
16
+
17
+ **D1 — Where and when.** The bot answers in ONE channel (`DISCORD_SUPPORT_CHANNEL_ID`, the guild's existing `support` channel) and only when a message @-mentions it. A reply ping does not count. Bots, webhooks and DMs are ignored. This is the first feature that replies through the gateway (every other post is a webhook), so the bot needs Send Messages in that channel.
18
+
19
+ **D2 — What it may read: an allowlist, enforced in SQL.** The corpus is `SUPPORT_DOC_REFS` in `modules/discord/discord-support.js`: `README.md`, `docs/onboarding/`, and `docs/api-reference.md`. The memory module serves it through a new port, `memory.searchPublicDocs`, whose scope is fixed inside the function and never taken from the caller:
20
+ - `source_kind='doc'`;
21
+ - `min_rank='xenos'`;
22
+ - no owner;
23
+ - `source_ref` on the allowlist.
24
+
25
+ The allowlist predicate rides into the same scoped CTE as the rank predicate, never a post-filter, and the body read re-applies the whole scope. An empty allowlist returns nothing. Widening the corpus is a code change reviewed by merge, because the list *is* the privacy boundary. The asker is never looked up: who they are cannot change what the bot reads.
26
+
27
+ **D3 — What it may write: nothing but the reply and its audit row.** It does not file an idea, a bug, a task or a blocker, and it requires no port that does. Each answer writes one `audit_log` row:
28
+ - `route='discord:support'`, `surface='discord_bot'`;
29
+ - booked to the bot's own principal (migration 067);
30
+ - carrying the cost, token counts, message and channel ids, and the doc refs.
31
+
32
+ ADR 0033 already audits every bot action, and the `discord:bugs` rate cap reads audit_log the same way. That row is also the spend ledger (D5), so the feature needs no migration.
33
+
34
+ **D4 — Untrusted text.**
35
+ - The question and the passages are fenced with a per-call random marker, and any fence-shaped run in either is rewritten first (the page-notes split's posture).
36
+ - The model gets no tools, so a jailbroken reply can only be one wrong message.
37
+ - That message cannot ping anyone. Mentions are rewritten in the text, and the reply is sent with `allowedMentions: { parse: [] }`.
38
+ - An injection's most useful payload is a link under the bot's name ("reset your account here"). So a masked link is rewritten to show its address, any URL not found verbatim in the passages the model was given is replaced with `[link removed]`, and the reply goes out with embeds suppressed.
39
+ - A rate limit (D5) applies before every reply, including the fixed lines, so repeating a bare mention cannot farm a reply flood.
40
+ - The model never sees anything private, so there is nothing for an injection to extract.
41
+
42
+ **D5 — Spend: Haiku, a monthly cap, fail closed.**
43
+ - **Model and price.** The model is `claude-haiku-4-5` (about $0.008 an answer at ≤600 output tokens), called through the official SDK. The SDK is lazily required, so an instance with the feature off never loads it.
44
+ - **The cap.** `DISCORD_SUPPORT_MONTHLY_CAP_USD`, default $5, is read before every call from month-to-date spend: the sum of the D3 rows in the current UTC month.
45
+ - **Failing closed.** No answer is given when the cap is set to anything but a positive number, when the ledger read fails, or when there is no bot principal to book against. The rule is the agents spend-ceiling's (task 1001393).
46
+ - **The ledger read is indexed.** Its `route = 'discord:support' AND recorded_at >= <month start>` is the shape of migration 033's `idx_audit_log_route (route, recorded_at DESC)`, so it reads one month of one route, not the whole log.
47
+ - **Concurrency.** Answers in flight are reserved at a worst case derived from the price table, and at most two run at once, so concurrency cannot overshoot. The reservation is per process, which holds because there is one: a bot token is one gateway connection, run in the single web process. A second answering process would need a shared reservation first.
48
+ - **Unbooked spend.** A cost that could not be booked counts in memory until the month turns. So does a call that died after it may have been billed (a timeout or a 5xx). A 4xx costs nothing.
49
+ - **Reaching the cap.** The bot posts one "out of answers for this month" line, then stays quiet until the month turns.
50
+ - **Per-asker limit.** A silent in-memory rate limit (4 per 10 minutes) bounds a burst. It is not the bill.
51
+
52
+ **D6 — Off by default, with its own switch and its own key.**
53
+ - **The switch.** `DISCORD_SUPPORT_ENABLED` must be exactly `true`, on top of `DISCORD_BOT_ENABLED`.
54
+ - **The key.** `DISCORD_SUPPORT_ANTHROPIC_API_KEY` is read from env only and namespaced on purpose. A host-wide `ANTHROPIC_API_KEY` would also be picked up by the `claude` CLI the grader spawns, and bill that key for grading.
55
+ - **Inert states.** Missing the switch, the channel or the key makes the feature inert, and the boot log says which one.
56
+
57
+ **D7 — Who reviews wrong answers.** The humans in the channel, as before. Each answer names its sources and has an audit row with the message id. The fix for a wrong answer is the doc it was drawn from, not the bot.
58
+
59
+ ## Consequences
60
+
61
+ - On cloudbongos.com it does nothing until the bot itself runs there ([task 1002506](https://cloudbongos.com/builders#/task/1002506)) and the owner sets the switch, the channel and the key (`docs/discord/support.md`).
62
+ - A question the newcomer docs do not cover gets "I couldn't find that". That is the intended answer, not a gap to fill by widening the corpus to internal docs.
63
+ - The memory module now provides a port. A future public surface that wants recall must go through `searchPublicDocs` and bring its own allowlist; there is still no unauthenticated HTTP search route.
64
+ - The audit-log ledger is approximate in one direction only: a lost booking undercounts until a restart forgets it. The in-flight reservation and the small per-answer cost keep that inside cents.
65
+
66
+ ## Reproduce / verify
67
+
68
+ - `node tests/discord_support.mjs`: config and kill switch, the message guard, the fence, mention-proofing, the cap, the reservation and fail-closed paths, the model request (no tools), read-only by source, and the port's fixed SQL scope.
69
+ - `node tests/search_isolation.mjs`, Layer C (needs a Postgres): the public port never returns a metic chunk, an owned chunk, a DB-prose row, or a doc off the allowlist.
@@ -485,3 +485,4 @@ These 20 numbers are each shared by exactly two files. They are **accepted histo
485
485
  | 0364 | [**Repo access is asked at sign-in on the hub, and held for as long as the builder stays signed in** ([task 1004549](https://cloudbongos.com/builders#/task/1004549)). Amends ADR 0143 and 0155. **D1:** every sign-in door on the hub's own pages carries `repos=1`; the hall and CLI stay identity-only. **D2:** the repo token's TTL is the web session's remaining life; a web sign-out deletes it, a CLI logout does not. **D3:** a draft at a wizard step beside a stored create keeps its place on a reload or a GitHub round-trip.](0364-repo-access-is-asked-at-sign-in-and-held-for-the-session.md) | auth / GitHub / create wizard |
486
486
  | 0365 | [**A guild engages a project through each member's own door** ([task 1002303](https://cloudbongos.com/builders#/task/1002303)). Amends ADR 0336 D7's invite half after ADR 0353. **D1:** the engagement row as D7 specified, plus `guild_id` on the echo (the latest application decides the label). **D2:** a guild applies = each member applies as themselves through the relay, naming the engagement. **D3:** a project invites a guild = the hub records it and returns one hall hand-off link per public-roster member; no hub-written invites, the one-step relay stays deferred. **D4:** a project is named only when on the map and public, for both kinds. **D5:** the label rides the applicant-profile read; the queue is R28's. **D6:** the D4.4 guild cards are their own task.](0365-a-guild-engages-a-project-through-each-members-own-door.md) | guilds / invites / applications |
487
487
  | 0366 | [**Hall planning is forms; a goal made there carries its own spec task, and the interviews stay in the CLI** ([task 1003461](https://cloudbongos.com/builders#/task/1003461), goal 1000089). Spike answer. **D1:** form-shaped, build in the hall: create a task under a goal, create a goal (name + subtitle, version, category, no scope field), attach a criterion to a goal you hold; every server refusal rendered as copy. **D2:** interview-shaped, deliberately absent from the hall: version planning, scope inference, drafting criteria and seed tasks, reuse-vs-create judgement, carry-forward. **D3 (owner):** a hall-made goal is created with a "Spec goal N" task (`POST /goals` `spec_task: true`, `source_ref goal-<id>-spec`) and every task in the goal depends on it until it ships; no new permission, no migration. **D4:** an optional "Plan with Claude" hand-off button.](0366-hall-planning-is-forms-and-a-goal-made-there-carries-its-own-spec-task.md) | lifecycle / planning |
488
+ | 0367 | [**The Discord support bot answers from public docs only, under a monthly cap that fails closed** ([task 1002534](https://cloudbongos.com/builders#/task/1002534), goal 1000110). **D1:** one pinned channel, only when @-mentioned; the first gateway reply. **D2:** the corpus is an allowlist (`README.md`, `docs/onboarding/`, `docs/api-reference.md`) served by a new `memory.searchPublicDocs` port whose scope (doc, xenos, no owner, allowlisted ref) is fixed in SQL; the asker is never looked up. **D3:** no writes but the reply and one `audit_log` row per answer, which is also the spend ledger, so no migration. **D4:** a nonce-fenced prompt, no tools, mentions disabled. **D5:** `claude-haiku-4-5`, `DISCORD_SUPPORT_MONTHLY_CAP_USD` default $5, fail closed, with in-flight answers reserved at worst case. **D6:** off by default; its own switch and an env-only, namespaced key. **D7:** the channel's humans review wrong answers; the fix is the doc.](0367-the-support-bot-answers-from-public-docs-only.md) | discord / support / spend |
package/docs/file-map.md CHANGED
@@ -98,7 +98,7 @@
98
98
  │ └── <redacted>.sql ← task 615: rename disciplines engineering→engineer, creative→artist|ideator; swaps both CHECK constraints + backfills tasks.discipline & builders.preferred_disciplines
99
99
  ├── modules/ ← self-contained optional feature modules (ADR 0083 / docs/modules-contract.md). Each `modules/<key>/` is a full vertical slice — routes, migrations, skills, seam registrations — reached only through src/module-api.js. The loader discovers them at boot; no core file changes to add one.
100
100
  │ ├── game/ ← the playable Example world — Phaser client, Colyseus rooms (WorldRoom/QueueRoom), world/terrain model, game DB tables, static assets. Moved BV1.R49 (task 1418). CLAUDE.md, module.json, routes/game.js, rooms/, world/, public/, migrations/.
101
- │ ├── discord/ ← outbound ship broadcast + inbound #ideas/#bugs bot + role-sync + channel management + account-link state + the Board Room mirror. Mirror only, never authority (ADR 0033). CLAUDE.md, module.json, routes/discord.js, routes/bug-attachments.js, discord-bot.js, discord-lib.js, board-broadcast.js (announce a window / edit the outcome onto it — R21/R22 of goal 1000069), migrations/discord_001_board_messages.sql.
101
+ │ ├── discord/ ← outbound ship broadcast + inbound #ideas/#bugs bot + role-sync + channel management + account-link state + the Board Room mirror. Mirror only, never authority (ADR 0033). CLAUDE.md, module.json, routes/discord.js, routes/bug-attachments.js, discord-bot.js, discord-lib.js, board-broadcast.js (announce a window / edit the outcome onto it — R21/R22 of goal 1000069), discord-support.js (the #support answerer: @-mention → public-docs answer, read-only, monthly cap — ADR 0367), migrations/discord_001_board_messages.sql.
102
102
  │ ├── art-pipeline/ ← self-improving pixel-art generation pipeline (its otb-* skills are the pixel-art module's since task 1004470; artist is a core discipline since ADR 0272). CLAUDE.md, module.json, routes/art-key.js, art/ (lives under art/ at repo root; module owns the route and discipline entry).
103
103
  │ ├── memory/ ← CORE-DOMAIN carve (BV1.R72, default-on): per-builder session memory + the recall/search retrieval layer (ADR 0060) + learnings. CLAUDE.md, module.json, routes/{memory,search,learnings}.js, memory.js, search.js, search-chunker.js, learnings.js.
104
104
  │ ├── grading/ ← CORE-DOMAIN carve (BV1.R73, default-on): the grade-a-diff adversarial worker panel + deterministic pre-passes + rubric; registers the `grade` kernel port. CLAUDE.md, module.json, routes/grade.js, grader.js, grader-rubric.json.
@@ -2960,5 +2960,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2960
2960
  stays and still answers the profile alone.
2961
2961
  1.21.64 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2962
2962
  landed since 1.21.63 with no explicit bump. run 37477390735. (task 1002620)
2963
+ 1.21.65 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2964
+ landed since 1.21.64 with no explicit bump. run 37482378254. (task 1002620)
2963
2965
  ---------------------------------------------------------------------------
2964
2966
  ```
@@ -79,6 +79,36 @@ async function countRecentDiscordBugFilings(builderId, sinceIso) {
79
79
  return rows[0].n;
80
80
  }
81
81
 
82
+ // --- the #support answerer's ledger (task 1002534, ADR 0367) -----------------
83
+ //
84
+ // The bot's own builders row (migration 067, system_role='discord_bot'): the
85
+ // principal an answer is booked to. The asker is never looked up.
86
+ async function getBotPrincipalId() {
87
+ const { rows } = await pool.query(
88
+ `SELECT id FROM builders WHERE system_role = 'discord_bot' ORDER BY id LIMIT 1`
89
+ );
90
+ return rows.length ? Number(rows[0].id) : null;
91
+ }
92
+
93
+ // Month-to-date #support spend, the UTC calendar month, summed from the cost each
94
+ // answer's audit row carries. The monthly cap reads this, so it survives a
95
+ // restart. Throws on a DB error; the caller treats that as "no answer".
96
+ // INDEXED: `route = $1 AND recorded_at >= <month start>` is exactly the shape of
97
+ // migrations/033_audit_log.sql's idx_audit_log_route (route, recorded_at DESC), so
98
+ // this reads one month of one route's rows, never a scan of the whole log. It
99
+ // runs once per answer, after the free checks (rate limit, cap, concurrency).
100
+ async function sumSupportSpendThisMonth(route = 'discord:support') {
101
+ const { rows } = await pool.query(
102
+ `SELECT COALESCE(SUM((request_body_redacted->>'cost_usd')::numeric), 0)::float8 AS usd
103
+ FROM audit_log
104
+ WHERE route = $1
105
+ AND surface = 'discord_bot'
106
+ AND recorded_at >= date_trunc('month', now() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`,
107
+ [route]
108
+ );
109
+ return Number(rows[0].usd) || 0;
110
+ }
111
+
82
112
  // --- the board mirror's own bookkeeping (R22 of goal 1000069) ----------------
83
113
  //
84
114
  // Which message announced which board item, so the close can be EDITED onto the
@@ -137,6 +167,8 @@ module.exports = {
137
167
  getDiscordLink,
138
168
  getDiscordLinks,
139
169
  countRecentDiscordBugFilings,
170
+ getBotPrincipalId,
171
+ sumSupportSpendThisMonth,
140
172
  recordApplause,
141
173
  recordBoardMessage,
142
174
  getBoardMessage,
@@ -471,6 +471,20 @@ async function start() {
471
471
  channelId: bugsChannelId, aggregator: bugAggregator, logLabel: 'bug handler',
472
472
  }));
473
473
 
474
+ // #support answers (task 1002534, ADR 0367): an @-mention in the pinned
475
+ // support channel gets a short answer from the public docs. Read-only, its
476
+ // own kill switch (DISCORD_SUPPORT_ENABLED) on top of this one, and a
477
+ // monthly spend cap. Everything lives in discord-support.js; it is the
478
+ // first feature that REPLIES through the gateway, so the bot needs Send
479
+ // Messages in that one channel.
480
+ const support = require('./discord-support').createLiveSupport({ client: c, db });
481
+ if (support.handler) {
482
+ c.on(Events.MessageCreate, support.handler);
483
+ log.info(`[discord-bot] answering @-mentions in #support (${support.channelId}), cap $${support.capUsd}/month.`);
484
+ } else {
485
+ log.info(`[discord-bot] #support answers are inert (${support.inertReason}).`);
486
+ }
487
+
474
488
  // Role-on-arrival (F7, ADR 0033 — the link-first onboarding gate's safety
475
489
  // net). The auto-join path (routes/auth.js) already sets the rank role at
476
490
  // add-time, so a builder who linked-then-joined is correct immediately. This