@bongos/core 1.19.843 → 1.19.844

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.19.843",
6
- "core_contract": "1.19.843",
7
- "source_commit": "7c57c44f66a63fda88681f46ac67857f8c8ed1e7",
5
+ "core_version": "1.19.844",
6
+ "core_contract": "1.19.844",
7
+ "source_commit": "980c92b65de8a99f2772b86fb7f69e365eed4dc6",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-17T18:30:58.977Z",
9
+ "built_at": "2026-09-17T18:59:26.454Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 495,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2270,
14
+ "functional_verbatim": 2272,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2791,
20
- "tree_sha256": "9f6bfba9532caa1edab6600efaf86e006add511e45378f5cf2ccf8ad81d81231",
19
+ "file_count": 2793,
20
+ "tree_sha256": "af2219bd2cdea4b5ebb159bd2cc60e9d015182c15121196f16e1cbb9b4ac7bf5",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2577,7 +2577,7 @@
2577
2577
  {
2578
2578
  "path": "docs/module-api-changelog.md",
2579
2579
  "mode": "0000644",
2580
- "sha256": "e2083366af3229b576d6d034f1d2e1c6e7e4fa61fba384ba0d0fca9b7911087f"
2580
+ "sha256": "185ae1211c5c546bf5d0a57acd5c97ad56b4e98133ba2aceded36bf2cc95c4ca"
2581
2581
  },
2582
2582
  {
2583
2583
  "path": "docs/modules-contract.md",
@@ -3712,7 +3712,7 @@
3712
3712
  {
3713
3713
  "path": "modules/agents/lib/spend-ceiling.js",
3714
3714
  "mode": "0000644",
3715
- "sha256": "97bfcea1cd580df4765bff92a1b26f56bfe467501bfd1c566ff7df9ec4ac746b"
3715
+ "sha256": "1144ee9be9cb3df57e876a868529ddbf78864fac1fdabc90ed2e010bd8d376a8"
3716
3716
  },
3717
3717
  {
3718
3718
  "path": "modules/agents/lib/validate.js",
@@ -3734,6 +3734,11 @@
3734
3734
  "mode": "0000644",
3735
3735
  "sha256": "7a9763f90da032c81aa0b1a8461a2e13b58d9f529c1b857b048648feeadc6fdd"
3736
3736
  },
3737
+ {
3738
+ "path": "modules/agents/migrations/agents_004_spend_cap.sql",
3739
+ "mode": "0000644",
3740
+ "sha256": "31a5ac35536cecd264bc989907f4cf094e050db0b8de85e7e3e7831cf7c25a9d"
3741
+ },
3737
3742
  {
3738
3743
  "path": "modules/agents/module.json",
3739
3744
  "mode": "0000644",
@@ -3742,17 +3747,17 @@
3742
3747
  {
3743
3748
  "path": "modules/agents/pollers/event-router.js",
3744
3749
  "mode": "0000644",
3745
- "sha256": "de64902d694acfc7d32aaf4d8629921f64cf851d600deb077a278b528d34c57d"
3750
+ "sha256": "ddd903f3702a3dd14e714dcf352e7c69f4bfbce5206e71892142aa8a81d44cde"
3746
3751
  },
3747
3752
  {
3748
3753
  "path": "modules/agents/routes/agents.js",
3749
3754
  "mode": "0000644",
3750
- "sha256": "8bfdaf26077e1bc005b6b2b764e0912ac7e5becb373a8a4371cb3cd6fa7c163f"
3755
+ "sha256": "a5d80d87486e8c6e928fe4ac563643ceb5255df14fd89e7e20194e2d8196b301"
3751
3756
  },
3752
3757
  {
3753
3758
  "path": "modules/agents/spawn.js",
3754
3759
  "mode": "0000644",
3755
- "sha256": "6d656333f03de1c7e1f798101dc7fcc6ddcf595b9dcb0001a110682f7e6e91f8"
3760
+ "sha256": "4e5e9ac97bd443d4091d8a6e4e246e080a2fe67793a77f4c4139b19019e37005"
3756
3761
  },
3757
3762
  {
3758
3763
  "path": "modules/autonomy/CLAUDE.md",
@@ -4297,7 +4302,7 @@
4297
4302
  {
4298
4303
  "path": "modules/economy/cost-classify.js",
4299
4304
  "mode": "0000644",
4300
- "sha256": "22b0b50ae46850a8d0a9de40eeced47df6edc671eada63529d48a1486aea4d75"
4305
+ "sha256": "fefd4908bd22aa3b747a7c8b4146e5a7e53731fe92612db105ea3512ae34300c"
4301
4306
  },
4302
4307
  {
4303
4308
  "path": "modules/economy/cost-summary-shape.js",
@@ -4307,7 +4312,7 @@
4307
4312
  {
4308
4313
  "path": "modules/economy/cost.js",
4309
4314
  "mode": "0000644",
4310
- "sha256": "01bc7725fe16388971fd4def2544d0de9af6b3a522973df7ef86386737933d40"
4315
+ "sha256": "6cc28fe57bb50b6c3f4b7d4485838427fa5f12b5a79848f0358fdaa2874f6c1e"
4311
4316
  },
4312
4317
  {
4313
4318
  "path": "modules/economy/credits.js",
@@ -4337,7 +4342,7 @@
4337
4342
  {
4338
4343
  "path": "modules/economy/reward.js",
4339
4344
  "mode": "0000644",
4340
- "sha256": "e026156d1578bc6c0ce43a197575601e9663e608819d8c92ca713e17ecbf0b3d"
4345
+ "sha256": "c4245b1b7c361eb44e0034f19227e208b956e9eb9ac03924e22b3017dd4a343a"
4341
4346
  },
4342
4347
  {
4343
4348
  "path": "modules/economy/routes/cost.js",
@@ -4957,7 +4962,7 @@
4957
4962
  {
4958
4963
  "path": "modules/hall-ui/public/goals.states.json",
4959
4964
  "mode": "0000644",
4960
- "sha256": "8e955be7fb6aef55b6aac0c586b27963f032bab9bec743b56a5987b6276cc8d3"
4965
+ "sha256": "14aefbb8b94ebc3df68744052f465b63ceaf3361f98a94972781ae01bc616735"
4961
4966
  },
4962
4967
  {
4963
4968
  "path": "modules/hall-ui/public/government.css",
@@ -5037,7 +5042,7 @@
5037
5042
  {
5038
5043
  "path": "modules/hall-ui/public/idea.states.json",
5039
5044
  "mode": "0000644",
5040
- "sha256": "4b8ea0bb4eac805b8d85556830104b07b2b0612ba1b8337bab6fb7368a887995"
5045
+ "sha256": "2299490186e19f2369069562dc8ba8dd837a2e89bfa94c7797f350cc653659c9"
5041
5046
  },
5042
5047
  {
5043
5048
  "path": "modules/hall-ui/public/ideas.css",
@@ -5057,7 +5062,7 @@
5057
5062
  {
5058
5063
  "path": "modules/hall-ui/public/ideas.states.json",
5059
5064
  "mode": "0000644",
5060
- "sha256": "f75230d5d770d4780b7c9fb28c40c8f44a91354e61f3d0a9a6281c90904a80c8"
5065
+ "sha256": "889041e8bcff6e7fa53f6bd09d0bed670e6b58cd68cda2ff7147a1a253e33e8d"
5061
5066
  },
5062
5067
  {
5063
5068
  "path": "modules/hall-ui/public/index.html",
@@ -5067,7 +5072,7 @@
5067
5072
  {
5068
5073
  "path": "modules/hall-ui/public/index.states.json",
5069
5074
  "mode": "0000644",
5070
- "sha256": "ac0bd95920b845d11ba24bf3e75ac169c13820861ad73776a6b174a5386e8b5f"
5075
+ "sha256": "74b6c43bb12a85e7d944069548c3e3a5de54f37267bbdc179157e1b164101399"
5071
5076
  },
5072
5077
  {
5073
5078
  "path": "modules/hall-ui/public/md-reader.js",
@@ -5117,7 +5122,7 @@
5117
5122
  {
5118
5123
  "path": "modules/hall-ui/public/pair.states.json",
5119
5124
  "mode": "0000644",
5120
- "sha256": "10d19605f074dedd85634fe1ed2eb06f06189e90778b1ce7d87d7b369ba428c6"
5125
+ "sha256": "3c48d1588f8512f0f9a0d5774acdf88c9a761e6b9f5f3da2d544a32bc06b4ab8"
5121
5126
  },
5122
5127
  {
5123
5128
  "path": "modules/hall-ui/public/palette.js",
@@ -5347,7 +5352,7 @@
5347
5352
  {
5348
5353
  "path": "modules/hall-ui/public/settings.states.json",
5349
5354
  "mode": "0000644",
5350
- "sha256": "02c0591b014806e991478c720658b0e74129e2e8c1ffcdd8e3384c681201a295"
5355
+ "sha256": "2d9f55bb007891b5d5b4511d7f29a20d01477a8395c84c259a42a7e6157aced9"
5351
5356
  },
5352
5357
  {
5353
5358
  "path": "modules/hall-ui/public/shell.js",
@@ -5412,7 +5417,7 @@
5412
5417
  {
5413
5418
  "path": "modules/hall-ui/public/task.states.json",
5414
5419
  "mode": "0000644",
5415
- "sha256": "fb7112aff3834f88305e9705335dd6b0527cc1cea8f4a1b7d5842b377437da9e"
5420
+ "sha256": "1be94e892012740aae899aa28425f4be9c94a5ae777c8c77e5e1b787791f5ec9"
5416
5421
  },
5417
5422
  {
5418
5423
  "path": "modules/hall-ui/public/triumph.wav",
@@ -5457,7 +5462,7 @@
5457
5462
  {
5458
5463
  "path": "modules/hall-ui/public/work.states.json",
5459
5464
  "mode": "0000644",
5460
- "sha256": "929512f085f138f93170f643dbd68615dfbf6d5fcd5f0bf1abf30a081154b536"
5465
+ "sha256": "786373b132587480ff58452286f950d246eae430f7531f0ccf4d01f290fdc435"
5461
5466
  },
5462
5467
  {
5463
5468
  "path": "modules/ideas/CLAUDE.md",
@@ -7897,12 +7902,12 @@
7897
7902
  {
7898
7903
  "path": "package-lock.json",
7899
7904
  "mode": "0000644",
7900
- "sha256": "090497aae4ab83f6ab3f821417ece5fcc50b86a09e026b3e4644aa660333d191"
7905
+ "sha256": "a6133e7d6f098080ea0b2d2d21d3bbf35c90a716dc20a4436a68c74c6416da84"
7901
7906
  },
7902
7907
  {
7903
7908
  "path": "package.json",
7904
7909
  "mode": "0000644",
7905
- "sha256": "e7d47a1c39751137250a2cc087fb288d3c349b44d8faebc38f70e841f6475560"
7910
+ "sha256": "65a0092e9c2f20b87c7de5d89e552e005820a70cc5c00fb228d904363d842b22"
7906
7911
  },
7907
7912
  {
7908
7913
  "path": "public-docs/index.html",
@@ -9837,7 +9842,7 @@
9837
9842
  {
9838
9843
  "path": "src/module-api.js",
9839
9844
  "mode": "0000644",
9840
- "sha256": "a24a8e5c67584fff7c871ad60afaa3bc9f81a2c77189629597dd4fd63cd43c31"
9845
+ "sha256": "a97295bdd0b3f0fe6b6c903b910a2512e7fffcc4a98f6d2844096cbc6015d7a7"
9841
9846
  },
9842
9847
  {
9843
9848
  "path": "src/module-loader/catalog.js",
@@ -9997,7 +10002,7 @@
9997
10002
  {
9998
10003
  "path": "tests/agents_spend_guard.mjs",
9999
10004
  "mode": "0000644",
10000
- "sha256": "8bed5bc6e737f17038b3fba4f5c73a0478b0dd581b506e5a0bbd31754ff8a758"
10005
+ "sha256": "dde31247e6cb995487e21f78d5b44bfb24bc087bd7736c5a130ed4d8822d0cb7"
10001
10006
  },
10002
10007
  {
10003
10008
  "path": "tests/agents_sync.mjs",
@@ -11689,6 +11694,11 @@
11689
11694
  "mode": "0000644",
11690
11695
  "sha256": "82bf1cc68150c247c108308d360d6408511f31a7af11d5e7a4721e7ea282f2bb"
11691
11696
  },
11697
+ {
11698
+ "path": "tests/hall_states_regex_escapes.mjs",
11699
+ "mode": "0000644",
11700
+ "sha256": "906786d9728fd1d94449674422401ef1b53a460d5c84998565b1ebeab5e47c7a"
11701
+ },
11692
11702
  {
11693
11703
  "path": "tests/hall_studio_world.mjs",
11694
11704
  "mode": "0000644",
@@ -2159,6 +2159,8 @@ is load-bearing: the script throws rather than guess if it is missing, and
2159
2159
  1.19.842 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2160
2160
  landed since 1.19.841 with no explicit bump. run 35258288930. (task 1002620)
2161
2161
  1.19.843 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2162
- landed since 1.19.842 with no explicit bump. run 35259228465. (task 1002620)
2162
+ landed since 1.19.842 with no explicit bump. run 35259608793. (task 1002620)
2163
+ 1.19.844 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2164
+ landed since 1.19.843 with no explicit bump. run 35262101669. (task 1002620)
2163
2165
  ---------------------------------------------------------------------------
2164
2166
  ```
@@ -59,6 +59,18 @@
59
59
  // be saved by a generous default.
60
60
  const DEFAULT_CAP_USD = 25;
61
61
 
62
+ // The PER-AGENT default (task 1002499, owner decision 2026-07-05: ~$5/month per
63
+ // agent under the ~$30/month global brake). Deliberately much smaller than the
64
+ // per-payer cap above, because it bounds a different thing: that one bounds the
65
+ // WALLET, this one bounds ONE DEFINITION. Under a single project payer the two
66
+ // were the same number, which meant a runaway agent could eat the whole instance
67
+ // ceiling and every other agent would then be refused for a bill it did not run
68
+ // up. At the historian exemplar's ~$0.90 a fire, $5 is about five fires a month —
69
+ // enough for a routine that answers a few times a week, and not enough for a loop.
70
+ // A definition sets its own with agents_definitions.spend_cap_usd; an instance
71
+ // moves every unset one with BONGOS_AGENT_SPEND_CAP_USD.
72
+ const DEFAULT_AGENT_CAP_USD = 5;
73
+
62
74
  // Calendar month, UTC. It matches the window agents_001's spend index was built
63
75
  // for ("What did agents cost this month?") and the $/month cap fire-budget's
64
76
  // header defers to — and a calendar month is the unit a bill arrives in, which is
@@ -96,12 +108,116 @@ function payerFor(spendPayer, requestedByBuilderId) {
96
108
  return { payer_kind: 'project', payer_builder_id: null };
97
109
  }
98
110
 
111
+ // Read one SUM(cost_usd) and return a TRUSTWORTHY number, or null for "I don't
112
+ // know". Shared by the two ceilings below so the fail-closed reading of a ledger
113
+ // is written once — but note that sharing it is not itself the guarantee: the
114
+ // tests exercise BOTH ceilings against a throwing pool, a NULL sum and a garbage
115
+ // sum independently, because a helper's documented safety property is a claim
116
+ // until each caller is shown to honour it.
117
+ //
118
+ // Every "I don't know" collapses to null here, and every caller turns null into a
119
+ // REFUSAL. The one thing this must never do is return 0 for an absent value —
120
+ // Number(null) is 0, and a zero reads as "has spent nothing", which is the
121
+ // fail-OPEN direction wearing a plausible number. That is the whole of task
122
+ // 1001393, so the absence is rejected before it can be coerced.
123
+ async function sumOrNull(pool, sql, params) {
124
+ if (!pool || typeof pool.query !== 'function') return null;
125
+ let raw;
126
+ try {
127
+ const { rows } = await pool.query(sql, params);
128
+ raw = rows && rows[0] ? rows[0].spent : undefined;
129
+ } catch (_) {
130
+ // THE 1001393 CASE. An unreadable ledger is the one this file exists for.
131
+ return null;
132
+ }
133
+ if (raw === null || raw === undefined) return null;
134
+ // NUMERIC comes back as a string from pg, so this catches a genuinely
135
+ // unparseable value, not the ordinary one.
136
+ const n = Number(raw);
137
+ return (Number.isFinite(n) && n >= 0) ? n : null;
138
+ }
139
+
140
+ // Summed BY PAYER, so a builder-paid instance gives each builder their own month
141
+ // and a project-paid one sums the instance. Rows written before agents_002 carry
142
+ // no payer_kind and match neither, which is the conservative direction: nobody is
143
+ // charged for spend that was not attributed to them.
144
+ const PAYER_SPEND_SQL = `SELECT COALESCE(SUM(cost_usd), 0) AS spent
145
+ FROM agents_runs
146
+ WHERE cost_usd IS NOT NULL
147
+ AND created_at >= $1
148
+ AND payer_kind = $2
149
+ AND payer_builder_id IS NOT DISTINCT FROM $3`;
150
+
151
+ // Summed BY AGENT NAME, across every payer. The name is the identity that
152
+ // survives a definition being re-synced, disabled or re-authored — agents_001
153
+ // built agents_runs_agent_idx (agent_name, created_at DESC) for exactly this
154
+ // read. Deliberately NOT keyed on definition_id: that column is ON DELETE SET
155
+ // NULL, so deleting a definition would zero its month and hand a fresh cap to a
156
+ // re-created agent of the same name.
157
+ const AGENT_SPEND_SQL = `SELECT COALESCE(SUM(cost_usd), 0) AS spent
158
+ FROM agents_runs
159
+ WHERE cost_usd IS NOT NULL
160
+ AND created_at >= $1
161
+ AND agent_name = $2`;
162
+
163
+ // The per-agent cap for one definition: its own spend_cap_usd (agents_004) when it
164
+ // declares one, else the instance default. A stored cap is schema-guaranteed
165
+ // positive, so anything else in that column is a row this code did not write and
166
+ // gets the default rather than a guess.
167
+ function agentCapFor(definition, fallback) {
168
+ const raw = definition && definition.spend_cap_usd;
169
+ if (raw === null || raw === undefined) return fallback;
170
+ const n = Number(raw);
171
+ return (Number.isFinite(n) && n > 0) ? n : fallback;
172
+ }
173
+
99
174
  function createSpendCeiling({
100
175
  capUsd = envNumber('BONGOS_AGENTS_SPEND_CAP_USD', DEFAULT_CAP_USD),
176
+ agentCapUsd = envNumber('BONGOS_AGENT_SPEND_CAP_USD', DEFAULT_AGENT_CAP_USD),
101
177
  now = () => Date.now(),
102
178
  } = {}) {
103
179
  return {
104
180
  capUsd,
181
+ agentCapUsd,
182
+
183
+ /**
184
+ * May THIS AGENT spend right now? (task 1002499)
185
+ *
186
+ * The sibling of check() below, and deliberately a separate call rather than
187
+ * a wider one: they refuse for different reasons and an operator must be able
188
+ * to tell "this instance's money is gone" from "this one agent has had its
189
+ * share". Both run on every fire, cheapest first.
190
+ *
191
+ * Records nothing, for the same reason check() does not: agents_runs is the
192
+ * accounting, and a ceiling that kept its own tally would be a second,
193
+ * disagreeing answer to what agents cost.
194
+ *
195
+ * Fails closed on every unhappy path, exactly like check(): no usable cap, no
196
+ * agent name, an unreadable ledger, or the cap already spent. The KNOWN
197
+ * OVERSHOOT in this file's header applies here too — a fire in flight carries
198
+ * cost_usd NULL and is invisible to this read.
199
+ *
200
+ * @returns {{ok: boolean, spentUsd: number|null, capUsd: number, remainingUsd: number, reason: string|null}}
201
+ */
202
+ async checkAgent(pool, definition) {
203
+ const cap = agentCapFor(definition, Number(agentCapUsd));
204
+ const deny = (reason) => ({ ok: false, spentUsd: null, capUsd: cap, remainingUsd: 0, reason });
205
+
206
+ if (!Number.isFinite(cap) || cap <= 0) return deny('no_agent_spend_cap_configured');
207
+ const name = definition && definition.name;
208
+ // An unnamed definition cannot be summed, so its month is unknowable — the
209
+ // same "I don't know" a throwing ledger gives, and it gets the same answer.
210
+ if (typeof name !== 'string' || !name.trim()) return deny('no_agent_identity');
211
+
212
+ const spent = await sumOrNull(pool, AGENT_SPEND_SQL, [monthStartUtc(now()), name]);
213
+ if (spent === null) return deny('ledger_unreadable');
214
+
215
+ const remaining = cap - spent;
216
+ if (remaining <= 0) {
217
+ return { ok: false, spentUsd: spent, capUsd: cap, remainingUsd: 0, reason: 'agent_spend_cap_reached' };
218
+ }
219
+ return { ok: true, spentUsd: spent, capUsd: cap, remainingUsd: remaining, reason: null };
220
+ },
105
221
 
106
222
  /**
107
223
  * May this payer spend right now?
@@ -121,42 +237,12 @@ function createSpendCeiling({
121
237
  if (kind !== 'project' && kind !== 'builder') return deny('no_payer');
122
238
  const builderId = payer.payer_builder_id ?? null;
123
239
  if (kind === 'builder' && builderId === null) return deny('no_payer');
124
- if (!pool || typeof pool.query !== 'function') return deny('ledger_unreadable');
125
-
126
- const since = monthStartUtc(now());
127
- let spent;
128
- try {
129
- // Summed BY PAYER, so a builder-paid instance gives each builder their own
130
- // month and a project-paid one sums the instance. Rows written before
131
- // agents_002 carry no payer_kind and match neither, which is the
132
- // conservative direction: nobody is charged for spend that was not
133
- // attributed to them.
134
- const { rows } = await pool.query(
135
- `SELECT COALESCE(SUM(cost_usd), 0) AS spent
136
- FROM agents_runs
137
- WHERE cost_usd IS NOT NULL
138
- AND created_at >= $1
139
- AND payer_kind = $2
140
- AND payer_builder_id IS NOT DISTINCT FROM $3`,
141
- [since, kind, builderId],
142
- );
143
- const raw = rows && rows[0] ? rows[0].spent : undefined;
144
- // NOT `Number(raw)` alone. Number(null) is 0, so a sum that came back as
145
- // SQL NULL — a shape COALESCE should make impossible, and would therefore
146
- // only appear if this query or that table changed under us — would read
147
- // as "this payer has spent nothing" and admit the fire. That is the
148
- // fail-OPEN direction wearing a plausible number, which is the whole
149
- // failure mode task 1001393 was filed about, so an absent value is
150
- // rejected BEFORE it can be coerced into a permissive one.
151
- spent = (raw === null || raw === undefined) ? Number.NaN : Number(raw);
152
- } catch (_) {
153
- // THE 1001393 CASE. An unreadable ledger is the one this file exists for.
154
- return deny('ledger_unreadable');
155
- }
156
- // A sum that is not a number is the same "I don't know" as a throw, and gets
157
- // the same answer. NUMERIC comes back as a string from pg, so this catches a
158
- // genuinely unparseable value, not the ordinary one.
159
- if (!Number.isFinite(spent) || spent < 0) return deny('ledger_unreadable');
240
+
241
+ // sumOrNull carries the fail-closed reading (a missing pool, a throw, a NULL
242
+ // sum and an unparseable sum all become null) — see its header for why an
243
+ // absent value must never be coerced to 0.
244
+ const spent = await sumOrNull(pool, PAYER_SPEND_SQL, [monthStartUtc(now()), kind, builderId]);
245
+ if (spent === null) return deny('ledger_unreadable');
160
246
 
161
247
  const remaining = cap - spent;
162
248
  if (remaining <= 0) {
@@ -167,4 +253,7 @@ function createSpendCeiling({
167
253
  };
168
254
  }
169
255
 
170
- module.exports = { createSpendCeiling, payerFor, monthStartUtc, DEFAULT_CAP_USD };
256
+ module.exports = {
257
+ createSpendCeiling, payerFor, monthStartUtc, agentCapFor,
258
+ DEFAULT_CAP_USD, DEFAULT_AGENT_CAP_USD,
259
+ };
@@ -0,0 +1,53 @@
1
+ -- agents_004_spend_cap.sql — the PER-AGENT monthly spend cap (task 1002499).
2
+ --
3
+ -- agents_002 recorded who PAYS for a fire, and modules/agents/lib/spend-ceiling.js
4
+ -- bounds what a payer may spend in a month. Both ask "how much has this WALLET
5
+ -- spent", and neither can answer "how much has this AGENT spent" — so under a
6
+ -- single project payer one runaway definition consumes the whole instance ceiling
7
+ -- and every other agent is refused for a bill it did not run up. The owner's
8
+ -- decision (2026-07-05, recorded on task 1002499) is ~$5/month per agent, layered
9
+ -- under the ~$30/month global autonomy brake: a per-agent cap bounds the blast
10
+ -- radius of ONE bad definition, and the global brake bounds the fleet.
11
+ --
12
+ -- NULL MEANS "USE THE INSTANCE DEFAULT", NOT "UNCAPPED". The default lives in
13
+ -- lib/spend-ceiling.js (BONGOS_AGENT_SPEND_CAP_USD, default $5) so a definition
14
+ -- that never thought about money still has a ceiling, and an instance can move
15
+ -- every unset agent with one env var. Storing the default on each row instead
16
+ -- would freeze today's number into every definition ever synced — the same trap
17
+ -- agents_002's header names for deriving payer_kind, in the other direction.
18
+ -- Nothing may read this column as "no cap": the CHECK below forbids a zero or a
19
+ -- negative, so the only two states are a positive number and "ask the default".
20
+ --
21
+ -- WHY NO NEW INDEX. The read this backs — SUM(cost_usd) for one agent this month
22
+ -- — is already served by agents_runs_agent_idx (agent_name, created_at DESC),
23
+ -- which agents_001 created with the comment "the read behind a per-agent spend
24
+ -- rollup". This migration is the rollup that index was built for.
25
+ --
26
+ -- Module migrations are ADDITIVE and NAMESPACED (ADR 0083 §Decision #5): every
27
+ -- object is agents_-prefixed, there is no down-migration, and the filename is
28
+ -- agents_-prefixed (ADR 0108 §5). Idempotent: safe to re-run.
29
+
30
+ BEGIN;
31
+
32
+ -- The per-agent monthly ceiling in USD. NULL = fall back to the instance default.
33
+ -- numeric(10,4) mirrors agents_runs.cost_usd, so a cap and the spend it bounds are
34
+ -- the same kind of number and a comparison cannot lose a fraction of a cent.
35
+ ALTER TABLE agents_definitions ADD COLUMN IF NOT EXISTS spend_cap_usd numeric(10, 4);
36
+
37
+ DO $$
38
+ BEGIN
39
+ -- A stored cap must be a real ceiling. Zero would read as "this agent may never
40
+ -- fire", which is what `enabled = false` already says and says better; a
41
+ -- negative is meaningless. Both are refused at the schema rather than left for
42
+ -- the guard to interpret, because a guard that has to decide what 0 means is a
43
+ -- guard with two possible answers.
44
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agents_definitions_spend_cap_positive_chk') THEN
45
+ ALTER TABLE agents_definitions ADD CONSTRAINT agents_definitions_spend_cap_positive_chk
46
+ CHECK (spend_cap_usd IS NULL OR spend_cap_usd > 0);
47
+ END IF;
48
+ END $$;
49
+
50
+ COMMENT ON COLUMN agents_definitions.spend_cap_usd IS
51
+ 'Per-agent monthly spend ceiling in USD (task 1002499). NULL = use the instance default from lib/spend-ceiling.js (BONGOS_AGENT_SPEND_CAP_USD, default $5). Never "uncapped" — a positive value is the only storable cap.';
52
+
53
+ COMMIT;
@@ -113,8 +113,13 @@ const FANOUT_MAX = envInt('BONGOS_AGENTS_EVENT_FANOUT_MAX', DEFAULT_FANOUT_MAX);
113
113
  // `enabled = false` by agents_definitions_flagged_not_armed_chk, so the WHERE
114
114
  // clause below already excludes it and a second check here would only be a
115
115
  // second place to get it wrong.
116
+ // `spend_cap_usd` joins the list for step (g2) — the per-agent ceiling reads the
117
+ // definition's own cap (task 1002499). It has to be selected here or every agent
118
+ // silently takes the instance default, which is the quiet-wrong-answer shape this
119
+ // spelled-out list otherwise protects against: a cap set on a definition would be
120
+ // stored, shown by the API, and ignored by the only code that enforces it.
116
121
  const DEF_COLUMNS = `
117
- id, name, persona, trigger_type, trigger_spec, model_tier
122
+ id, name, persona, trigger_type, trigger_spec, model_tier, spend_cap_usd
118
123
  `;
119
124
 
120
125
  // ---------------------------------------------------------------------------
@@ -590,10 +595,32 @@ async function fanOut(payload, deps = {}) {
590
595
  return report;
591
596
  }
592
597
 
598
+ // (g2) THE PER-AGENT CEILING (task 1002499), once PER AGENT rather than once
599
+ // for the fan-out. That is the whole difference from (g) above: the payer
600
+ // ceiling asks a question about the wallet, so one answer covers every agent
601
+ // sharing it, while this one asks about each definition and so has as many
602
+ // answers as there are agents. An agent over its share is skipped with its own
603
+ // ledger row; its siblings still fire, which is the behaviour a per-agent cap
604
+ // exists to produce — one runaway definition must not silence the fleet.
605
+ const stillAdmitted = [];
606
+ for (const a of admitted) {
607
+ const agentRoom = await ceiling.checkAgent(pool, a.definition);
608
+ if (agentRoom.ok) { stillAdmitted.push(a); continue; }
609
+ const reason = agentRoom.reason === 'agent_spend_cap_reached'
610
+ ? `agent '${a.definition && a.definition.name}' has reached its $${agentRoom.capUsd} monthly cap — the instance still has room, but this agent has spent its share`
611
+ : `this agent's spend ceiling could not be established (${agentRoom.reason}) — a ceiling that cannot be read must not be assumed`;
612
+ await ledgerNoGo(deps, { definition: a.definition, taskId, reason, spendPayer });
613
+ report.skipped += 1;
614
+ }
615
+ if (!stillAdmitted.length) {
616
+ report.reason = 'agent_spend_ceiling';
617
+ return report;
618
+ }
619
+
593
620
  // (h) FIRE. Sequentially, so N agents on one ship are N model calls in a row
594
621
  // rather than N at once — the spend ceiling's own blind spot is fires already
595
622
  // in flight, and serialising is what keeps that blind spot one fire wide.
596
- for (const a of admitted) {
623
+ for (const a of stillAdmitted) {
597
624
  const fired = await fireOne(a, { task, taskId, spendPayer }, deps);
598
625
  if (fired && fired.ran) report.fired += 1; else report.skipped += 1;
599
626
  }
@@ -122,7 +122,7 @@ const COLUMNS = `
122
122
  id, name, title, trigger_type, trigger_spec, model_tier,
123
123
  scope_modules, scope_paths, scope_violation,
124
124
  source, provenance, author_rank,
125
- source_path, last_synced_at, enabled, created_at, updated_at
125
+ source_path, last_synced_at, enabled, spend_cap_usd, created_at, updated_at
126
126
  `;
127
127
 
128
128
  // PURE. Row → wire shape, so the projection is unit-testable without a database.
@@ -168,6 +168,14 @@ function serializeDefinition(row, { includePersona = false } = {}) {
168
168
  // is the one disarm the registry can explain; anything else is simply off.
169
169
  disarmed_reason: row.enabled === true ? null : (row.scope_violation ? 'scope_violation' : null),
170
170
  last_synced_at: row.last_synced_at || null,
171
+ // The agent's own monthly ceiling (task 1002499), or null when it takes the
172
+ // instance default. Served as a NUMBER, not the pg NUMERIC string, so a caller
173
+ // comparing it against a spend does not silently compare strings. Null here
174
+ // never means "uncapped" — agents_004's CHECK makes a positive value the only
175
+ // storable cap, and an unset one resolves to BONGOS_AGENT_SPEND_CAP_USD.
176
+ spend_cap_usd: row.spend_cap_usd === null || row.spend_cap_usd === undefined
177
+ ? null
178
+ : Number(row.spend_cap_usd),
171
179
  created_at: row.created_at,
172
180
  updated_at: row.updated_at,
173
181
  };
@@ -605,6 +613,35 @@ module.exports = function agentsRoutes() {
605
613
  );
606
614
  if (!rows.length) return res.fail('agent_not_found', 404);
607
615
  const definition = rows[0];
616
+
617
+ // THE PER-AGENT CEILING (task 1002499), after the payer's and before the
618
+ // gate. It cannot run any earlier: it needs the definition, both for its own
619
+ // spend_cap_usd and for the name it sums by.
620
+ //
621
+ // A THIRD refusal with a THIRD code, for the same reason the first two are
622
+ // separate: 429 says "too fast", `spend_cap_reached` says "this wallet's
623
+ // month is gone", and this one says "this wallet has room, but THIS AGENT
624
+ // has had its share". Collapsing them would tell an operator staring at a
625
+ // refused fire to go raise the wrong number — and under a single project
626
+ // payer the wrong number is the one that refuses every OTHER agent too.
627
+ const agentRoom = await spendCeiling.checkAgent(api.pool, definition);
628
+ if (!agentRoom.ok) {
629
+ return res.fail('agent_spend_cap_reached', {
630
+ status: 402,
631
+ message: agentRoom.reason === 'agent_spend_cap_reached'
632
+ ? `agent '${definition.name}' has reached its $${agentRoom.capUsd} monthly cap — the instance still has room, but this agent has spent its share`
633
+ : `this fire is refused because the agent's spend ceiling could not be established (${agentRoom.reason}) — a ceiling that cannot be read must not be assumed`,
634
+ details: {
635
+ scope: 'agent-spend-per-agent',
636
+ reason: agentRoom.reason,
637
+ agent: definition.name,
638
+ cap_usd: agentRoom.capUsd,
639
+ // null whenever the refusal was a failure to READ, never a claimed sum.
640
+ spent_usd: agentRoom.spentUsd,
641
+ },
642
+ });
643
+ }
644
+
608
645
  const gate = gateFor(definition);
609
646
  const trigger = { type: 'on-demand', ref: `on-demand:${req.builder.github_login || req.builder.id}` };
610
647
 
@@ -36,6 +36,11 @@
36
36
  const api = require('../../src/module-api');
37
37
  const { payerFor } = require('./lib/spend-ceiling');
38
38
 
39
+ // Namespaced + structured, like the module's other two log sites (routes/agents.js,
40
+ // pollers/event-router.js). A bare console.warn here would be the module's only
41
+ // unstructured line, and the fitness console ratchet counts it.
42
+ const log = api.logger('agents');
43
+
39
44
  // tier → concrete model id. The mapping is BRANDING, not a constant here: an
40
45
  // instance retunes its own tiers, and the DB constraint already limits the
41
46
  // vocabulary to these two.
@@ -139,6 +144,68 @@ async function insertRun(pool, row) {
139
144
  return r.rows[0].id;
140
145
  }
141
146
 
147
+ // The cost_log SOURCE TAG for one agent's spend (task 1002499). The `agent:`
148
+ // family is what modules/economy/cost.js's autonomous MTD brake matches on, beside
149
+ // `cron:`, so the prefix is a contract and not a label — see that function's header.
150
+ // The name is already schema-constrained to ^[a-z][a-z0-9-]*$ (agents_001), so
151
+ // nothing here has to sanitise it, but the tag is built in ONE place so a future
152
+ // rename cannot leave the brake matching a prefix nobody writes any more.
153
+ function costSourceTag(agentName) {
154
+ return `agent:${agentName}`;
155
+ }
156
+
157
+ // Mirror one completed fire's cost into cost_log, so the project-wide brake can
158
+ // see it (task 1002499).
159
+ //
160
+ // WHY A MIRROR AND NOT A MOVE. agents_runs.cost_usd stays the ACCOUNTING — it is
161
+ // what the per-payer and per-agent ceilings sum, it carries the payer, and it is
162
+ // the row the schema's invariants are written about. cost_log is the project-wide
163
+ // ROLL-UP: the autonomy spend-brake and the cost dashboard's by_source breakdown
164
+ // both read it, and neither knows agents_runs exists. Before this, agent spend
165
+ // appeared in exactly one of those two places, which is why the brake was blind
166
+ // to it. The mirror is a projection of the ledger, never a second ledger: it is
167
+ // written once, after the cost is final, keyed so a retry cannot double-post.
168
+ //
169
+ // IDEMPOTENT BY CONSTRUCTION. logCost is insert-once on (source, source_ref), and
170
+ // source_ref is the run id — the one value that is unique per fire and already
171
+ // exists. A re-run of this function for the same fire is a no-op at the database,
172
+ // not a second row, which matters because the alternative to a stable key here is
173
+ // double-counting money.
174
+ //
175
+ // BEST-EFFORT, DELIBERATELY. A fire that ran and was ledgered in agents_runs has
176
+ // already happened and already cost money; failing it now because the roll-up
177
+ // could not be written would discard a completed fire's result to fix a reporting
178
+ // row. The direction of that trade is the opposite of the ceilings' — they fail
179
+ // CLOSED because they decide whether to spend, this fails OPEN because the spend
180
+ // is already past. What it must never do is stay silent: an unwritten mirror
181
+ // means the brake is under-counting, so the failure is logged with the run id.
182
+ async function mirrorCostToLedger({ runId, definition, costUsd, builderId = null }, deps = {}) {
183
+ if (!(typeof costUsd === 'number' && costUsd > 0)) return false;
184
+ const name = definition && definition.name;
185
+ if (!runId || typeof name !== 'string' || !name) return false;
186
+ const economy = deps.economy
187
+ || (typeof api.resolveOptional === 'function' ? api.resolveOptional('reward', null) : null);
188
+ if (!economy || typeof economy.logCost !== 'function') return false;
189
+ try {
190
+ await economy.logCost({
191
+ amountUsd: costUsd,
192
+ category: 'api',
193
+ source: costSourceTag(name),
194
+ sourceRef: `agent-run-${runId}`,
195
+ // Who ASKED, when anyone did. Not who PAYS — payer_kind lives on the
196
+ // agents_runs row and is that question's answer; cost_log's builder_id is
197
+ // per-builder attribution, and an event fire legitimately has neither.
198
+ builderId,
199
+ description: `agent ${name} — fire ${runId}`.slice(0, 1000),
200
+ });
201
+ return true;
202
+ } catch (e) {
203
+ (deps.log || log).warn({ run_id: runId, agent: name, err: (e && e.message) || String(e) },
204
+ 'cost mirror failed — agents_runs still has the cost, but the autonomous MTD brake is under-counting by it');
205
+ return false;
206
+ }
207
+ }
208
+
142
209
  async function finishRun(pool, id, patch) {
143
210
  const cols = Object.keys(patch);
144
211
  const set = cols.map((c, i) => `${c} = $${i + 2}`).join(', ');
@@ -218,6 +285,11 @@ async function spawnAgent({
218
285
  outputRef: ok ? (deps.outputRef || (res && res.output_ref) || null) : null,
219
286
  });
220
287
  await finishRun(pool, runId, patch);
288
+ // AFTER the ledger write, never before: the mirror projects a cost that is
289
+ // already recorded, so a row in cost_log with no agents_runs row behind it
290
+ // must not be possible. A failed or zero-cost fire mirrors nothing — the
291
+ // guard is inside mirrorCostToLedger so both call shapes share it.
292
+ await mirrorCostToLedger({ runId, definition, costUsd, builderId: requestedByBuilderId }, deps);
221
293
  return { ok, ran: true, runId, model, cost_usd: costUsd, duration_ms: durationMs };
222
294
  } catch (e) {
223
295
  const durationMs = Math.max(0, now() - started);
@@ -228,4 +300,4 @@ async function spawnAgent({
228
300
  }
229
301
  }
230
302
 
231
- module.exports = { spawnAgent, resolveModel, plannedRow, completedRow, composePrompt };
303
+ module.exports = { spawnAgent, resolveModel, plannedRow, completedRow, composePrompt, mirrorCostToLedger, costSourceTag };
@@ -46,6 +46,17 @@ const COST_WRITE_SURFACES = [
46
46
  file: 'scripts/gds/session-cost.js',
47
47
  builder_id: 'the authed builder; category=api, source_ref=<session-id>, task_id=null. Idempotent per session.',
48
48
  },
49
+ {
50
+ // task 1002499: an agent fire mirrors its cost here so the autonomous MTD
51
+ // spend-brake can see it. agents_runs.cost_usd stays the accounting; this is
52
+ // the project-wide roll-up, written once per fire after the cost is final and
53
+ // keyed source_ref=agent-run-<id> so a retry cannot double-post. It reaches
54
+ // logCost through the economy PORT (ADR 0083) — still one writer, which is the
55
+ // property this list exists to state.
56
+ surface: 'modules/agents/spawn.js (fire mirror) → reward port → db.logCost() (source=agent:<name>)',
57
+ file: 'modules/agents/spawn.js',
58
+ builder_id: 'whoever REQUESTED the fire, or null for an event fire, which nobody asked for. Not the PAYER — that is payer_kind on the agents_runs row, a different question.',
59
+ },
49
60
  {
50
61
  surface: 'migrations / direct DB',
51
62
  file: '—',
@@ -55,16 +55,34 @@ async function getBuilderCostStatus(builderId) {
55
55
  return classifyCostStatus(row.spend_mtd_usd, row.budget_usd);
56
56
  }
57
57
 
58
- // System 3 (task 1351): project-wide AUTONOMOUS api spend month-to-date — the
59
- // `cron:%`-sourced rows (System 1 / task 1344 tags cron-launched sessions). The
58
+ // System 3 (task 1351): project-wide AUTONOMOUS api spend month-to-date. The
60
59
  // autonomy precheck's hard spend-brake compares this against a ceiling so a
61
60
  // runaway autonomous fleet self-throttles. Returns a plain number (USD).
61
+ //
62
+ // TWO SOURCE FAMILIES, because there are two ways this project spends money with
63
+ // nobody watching (task 1002499):
64
+ // cron:% — cron-launched SESSIONS (System 1 / task 1344).
65
+ // agent:% — agent fires (modules/agents). Each one is a `claude -p` the server
66
+ // spawns on its own, and POST /agents/:name/invoke answers 202
67
+ // without waiting, so the caller is not present for the spend either.
68
+ //
69
+ // The agent family was the hole this closes. Agent fires recorded their cost in
70
+ // `agents_runs.cost_usd` and nowhere else, so the brake — which reads cost_log —
71
+ // could not see a dollar of it: the per-payer and per-agent ceilings in
72
+ // modules/agents/lib/spend-ceiling.js bounded each wallet and each definition,
73
+ // while the fleet-wide brake they sit under was measuring only half the fleet.
74
+ //
75
+ // THE PREFIX MATCH IS THE CONTRACT, not a convenience. `agent:<name>` is written
76
+ // by exactly one place (the mirror in modules/agents/spawn.js) and the shape is
77
+ // asserted there, so widening the brake to a family rather than a list means a
78
+ // new agent is inside the ceiling the day it is authored, with nothing to
79
+ // remember to add here.
62
80
  async function autonomousApiSpendMtd() {
63
81
  const { rows } = await pool.query(
64
82
  `SELECT COALESCE(SUM(amount_usd), 0) AS usd
65
83
  FROM cost_log
66
84
  WHERE category = 'api'
67
- AND source LIKE 'cron:%'
85
+ AND (source LIKE 'cron:%' OR source LIKE 'agent:%')
68
86
  AND recorded_at >= date_trunc('month', now())`
69
87
  );
70
88
  return Number(rows[0]?.usd) || 0;
@@ -67,6 +67,13 @@ module.exports = {
67
67
  setBuilderMonthlyBudget: (...a) => cost.setBuilderMonthlyBudget(...a),
68
68
  reconcileSessionCosts: (...a) => cost.reconcileSessionCosts(...a),
69
69
  autonomousApiSpendMtd: (...a) => cost.autonomousApiSpendMtd(...a),
70
+ // The one WRITE the agents module needs (task 1002499). cost_log is economy's
71
+ // table and modules may only cross the wall through a port (ADR 0083 / 0093 §2),
72
+ // so an agent fire mirrors its cost through here rather than by importing cost.js
73
+ // or writing the INSERT itself. It is the same logCost every other writer uses —
74
+ // not an agent-shaped variant — so there is still exactly one writer, which is
75
+ // the property modules/economy/cost-classify.js's COST_WRITE_SURFACES enumerates.
76
+ logCost: (...a) => cost.logCost(...a),
70
77
  leaderboard: (...a) => credits.leaderboard(...a),
71
78
  getLeaderboardWithAchievements: (...a) => achievements.getLeaderboardWithAchievements(...a),
72
79
  listAchievements: (...a) => achievements.listAchievements(...a),
@@ -18,8 +18,8 @@
18
18
  ],
19
19
  "ignoreConsole": [
20
20
  "/api/(gds|bongos)/",
21
- "\b401\b",
22
- "\b404\b",
21
+ "\\b401\\b",
22
+ "\\b404\\b",
23
23
  "Unauthorized",
24
24
  "Not Found",
25
25
  "load failed"
@@ -5,7 +5,7 @@
5
5
  "stub": { "prefix": "/builders/" },
6
6
  "modeQuery": false,
7
7
  "ignoreRequests": ["/api/(gds|bongos)/"],
8
- "ignoreConsole": ["/api/(gds|bongos)/", "\b401\b", "\b404\b", "Unauthorized", "Not Found", "load failed"],
8
+ "ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
9
9
  "states": {
10
10
  "record": { "auth": true, "actions": [["wait", 600]], "expect": { "visible": ["#idea-body", "#idea-h"] } },
11
11
  "missing": { "auth": true, "url": "/idea/999999", "actions": [["wait", 600]], "expect": { "visible": ["#idea-h"] } }
@@ -6,7 +6,7 @@
6
6
  "modeQuery": false,
7
7
  "ignoreRequests": ["/api/(gds|bongos)/"],
8
8
  "_console": "The 501 is the harness, not the page: the meter re-scores through POST /inbox/score on every keystroke and scripts/hall-preview/server.js answers every write with preview_readonly by design. Declared rather than hidden.",
9
- "ignoreConsole": ["/api/(gds|bongos)/", "\b401\b", "\b404\b", "\b501\b", "Unauthorized", "Not Found", "Not Implemented", "load failed"],
9
+ "ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "\\b501\\b", "Unauthorized", "Not Found", "Not Implemented", "load failed"],
10
10
  "states": {
11
11
  "compose": { "auth": true, "actions": [["wait", 700]], "expect": { "visible": ["#fi-form", "#fi-fields", "#fi-queue"] } },
12
12
  "develop": { "auth": true, "url": "/ideas?develop=1000884", "actions": [["wait", 700]], "expect": { "visible": ["#fi-develop"] } }
@@ -6,7 +6,7 @@
6
6
  "modeQuery": false,
7
7
  "_feeds": "As work.states.json: the kit's stub is not the hall's feed set, so Home renders its skeletons and empty states here. The populated dashboard is the hall-preview harness.",
8
8
  "ignoreRequests": ["/api/(gds|bongos)/me$", "/api/(gds|bongos)/me/", "/api/(gds|bongos)/tasks", "/api/(gds|bongos)/claims", "/api/(gds|bongos)/goals", "/api/(gds|bongos)/inbox", "/api/(gds|bongos)/live", "/api/(gds|bongos)/analytics/", "/api/(gds|bongos)/achievements", "/api/(gds|bongos)/versions", "/api/(gds|bongos)/leaderboard"],
9
- "ignoreConsole": ["/api/(gds|bongos)/", "\b401\b", "\b404\b", "Unauthorized", "Not Found", "load failed"],
9
+ "ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
10
10
  "_1003441": "The `leaderboard` state left with the view: the Roll is the /roster page now and #leaderboard REDIRECTS there, so there is no #view-leaderboard on this page to make visible. The redirect is proven in tests/hall_nav.mjs (it asserts location.replace('/roster') fires on the index and nowhere else) rather than by a render state, because a state whose page navigates away measures the destination, not this page. `standing-karma` replaces the coverage: task 2178 made each Standing rail section addressable and none had a measured state.",
11
11
  "states": {
12
12
  "home": { "auth": true, "expect": { "visible": ["#view-home"], "hidden": ["#view-standing"] } },
@@ -5,7 +5,7 @@
5
5
  "stub": { "prefix": "/builders/" },
6
6
  "modeQuery": false,
7
7
  "ignoreRequests": ["/api/(gds|bongos)/"],
8
- "ignoreConsole": ["/api/(gds|bongos)/", "\b401\b", "\b404\b", "Unauthorized", "Not Found", "load failed"],
8
+ "ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
9
9
  "states": {
10
10
  "request": { "auth": true, "url": "/pair?code=K7QD-4M2X", "actions": [["wait", 700]], "expect": { "visible": ["#pair-h1", "#pair-approve-btn"] } },
11
11
  "nocode": { "auth": true, "actions": [["wait", 700]], "expect": { "visible": ["#pair-h1", "#pair-scroll .pair-status--err"] } }
@@ -5,7 +5,7 @@
5
5
  "stub": { "prefix": "/builders/" },
6
6
  "modeQuery": false,
7
7
  "ignoreRequests": ["/api/(gds|bongos)/", "/downloads/devbox/"],
8
- "ignoreConsole": ["/api/(gds|bongos)/", "/downloads/devbox/", "\b401\b", "\b404\b", "Unauthorized", "Not Found", "load failed", "Password field"],
8
+ "ignoreConsole": ["/api/(gds|bongos)/", "/downloads/devbox/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed", "Password field"],
9
9
  "states": {
10
10
  "account": { "auth": true, "actions": [["wait", 900]], "expect": { "visible": ["#settings-h1", "#display-name-input", "#craft-chips", "#privacy-rows"] } },
11
11
  "access": { "auth": true, "url": "/settings#access", "actions": [["wait", 900]], "expect": { "visible": ["#cli-reissue-btn", "#sessions-list", "#artkey-card"] } },
@@ -5,7 +5,7 @@
5
5
  "stub": { "prefix": "/builders/" },
6
6
  "modeQuery": false,
7
7
  "ignoreRequests": ["/api/(gds|bongos)/"],
8
- "ignoreConsole": ["/api/(gds|bongos)/", "\b401\b", "\b404\b", "Unauthorized", "Not Found", "load failed"],
8
+ "ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
9
9
  "states": {
10
10
  "record": { "auth": true, "actions": [["wait", 600]], "expect": { "visible": ["#task-body", "#task-grade", "#task-sessions", "#copy-prompt-btn"] } },
11
11
  "from-goal": { "auth": true, "url": "/task/2166?from=%2Fgoals%23%2Fgoal%2F1000081", "actions": [["wait", 600]], "expect": { "visible": ["#task-back", "#copy-prompt-btn"], "attr": [["#task-back", "href", "/goals#/goal/1000081"]] } },
@@ -6,7 +6,7 @@
6
6
  "modeQuery": false,
7
7
  "_feeds": "The kit's stub carries a small fixture set, not the hall's task feeds, so the board renders its EMPTY and SKELETON states here — which is the point: an empty queue is a state the floors must hold in. The feed 404s are declared, never hidden.",
8
8
  "ignoreRequests": ["/api/(gds|bongos)/me$", "/api/(gds|bongos)/me/", "/api/(gds|bongos)/tasks", "/api/(gds|bongos)/claims", "/api/(gds|bongos)/goals", "/api/(gds|bongos)/inbox/", "/api/(gds|bongos)/versions"],
9
- "ignoreConsole": ["/api/(gds|bongos)/", "\b401\b", "\b404\b", "Unauthorized", "Not Found", "load failed"],
9
+ "ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
10
10
  "states": {
11
11
  "ready": { "auth": true, "expect": { "visible": ["#claim-scroll"], "hidden": ["#wip-scroll", "#backlog-scroll", "#history-scroll"] } },
12
12
  "in-progress": { "auth": true, "url": "/builders/work#in-progress", "actions": [["wait", 400]], "expect": { "visible": ["#wip-scroll"] } },
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.843",
3
+ "version": "1.19.844",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.843",
9
+ "version": "1.19.844",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.843",
3
+ "version": "1.19.844",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.843'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.844'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -25,8 +25,11 @@ import { validateManifest } from '../src/module-loader/manifest-schema.js';
25
25
  import ceilingMod from '../modules/agents/lib/spend-ceiling.js';
26
26
  import spawnMod from '../modules/agents/spawn.js';
27
27
 
28
- const { createSpendCeiling, payerFor, monthStartUtc, DEFAULT_CAP_USD } = ceilingMod;
29
- const { plannedRow } = spawnMod;
28
+ const {
29
+ createSpendCeiling, payerFor, monthStartUtc, agentCapFor,
30
+ DEFAULT_CAP_USD, DEFAULT_AGENT_CAP_USD,
31
+ } = ceilingMod;
32
+ const { plannedRow, mirrorCostToLedger, costSourceTag } = spawnMod;
30
33
 
31
34
  // A registry with one metered module and one ordinary one, so every assertion can
32
35
  // show that the guard bites the declaring module and NOTHING else.
@@ -270,3 +273,201 @@ test('the ceiling RECORDS NOTHING — the ledger is the only tally', async () =>
270
273
  assert.doesNotMatch(sql, /INSERT|UPDATE|DELETE/i);
271
274
  }
272
275
  });
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // 4. the per-AGENT monthly ceiling (task 1002499)
279
+ //
280
+ // Its own section, and its own tests against the same hostile pools section 3
281
+ // uses, DELIBERATELY. checkAgent and check now share sumOrNull, and a shared
282
+ // helper's documented safety property is a claim until each caller is shown to
283
+ // honour it — a refactor that made checkAgent swallow a null would pass every
284
+ // test written only against check.
285
+ // ---------------------------------------------------------------------------
286
+
287
+ const HISTORIAN = { name: 'historian', spend_cap_usd: null };
288
+
289
+ test('a per-agent cap is SMALLER than the payer cap — it bounds a definition, not a wallet', () => {
290
+ assert.ok(DEFAULT_AGENT_CAP_USD < DEFAULT_CAP_USD,
291
+ 'if the per-agent default ever reaches the payer default it stops bounding anything: '
292
+ + 'one agent could spend the whole instance ceiling, which is the case this exists for');
293
+ assert.equal(DEFAULT_AGENT_CAP_USD, 5, 'the owner decision of 2026-07-05, recorded on task 1002499');
294
+ });
295
+
296
+ test('under its cap: allowed, with the arithmetic shown', async () => {
297
+ const c = createSpendCeiling({ agentCapUsd: 5, now: () => AT });
298
+ const r = await c.checkAgent(poolOf('1.8000'), HISTORIAN);
299
+ assert.equal(r.ok, true);
300
+ assert.equal(r.spentUsd, 1.8);
301
+ assert.equal(r.capUsd, 5);
302
+ assert.equal(r.remainingUsd, 3.2);
303
+ assert.equal(r.reason, null);
304
+ });
305
+
306
+ test('at or past its cap: refused with its OWN code, never the payer one', async () => {
307
+ const c = createSpendCeiling({ agentCapUsd: 5, now: () => AT });
308
+ for (const spent of ['5', '5.0001', '90']) {
309
+ const r = await c.checkAgent(poolOf(spent), HISTORIAN);
310
+ assert.equal(r.ok, false, spent);
311
+ assert.equal(r.reason, 'agent_spend_cap_reached', spent);
312
+ assert.notEqual(r.reason, 'spend_cap_reached',
313
+ 'the two refusals must stay distinguishable: one says raise the instance cap, '
314
+ + 'the other says raise THIS agent — and under a project payer the wrong advice '
315
+ + 'raises a number that was never the constraint');
316
+ assert.equal(r.remainingUsd, 0, spent);
317
+ }
318
+ });
319
+
320
+ test('a definition own cap beats the instance default, in BOTH directions', async () => {
321
+ const c = createSpendCeiling({ agentCapUsd: 5, now: () => AT });
322
+ const generous = await c.checkAgent(poolOf('7'), { name: 'a', spend_cap_usd: '20.0000' });
323
+ assert.equal(generous.ok, true, '$7 spent against its own $20 cap is fine, though it is past the default');
324
+ assert.equal(generous.capUsd, 20);
325
+ const stingy = await c.checkAgent(poolOf('2'), { name: 'b', spend_cap_usd: '1.5000' });
326
+ assert.equal(stingy.ok, false, '$2 spent against its own $1.50 cap is over, though it is under the default');
327
+ assert.equal(stingy.reason, 'agent_spend_cap_reached');
328
+ });
329
+
330
+ test('agentCapFor: only a positive number overrides; anything else takes the default', () => {
331
+ assert.equal(agentCapFor({ spend_cap_usd: '12.5' }, 5), 12.5, 'pg NUMERIC arrives as a string');
332
+ assert.equal(agentCapFor({ spend_cap_usd: 12.5 }, 5), 12.5);
333
+ assert.equal(agentCapFor({ spend_cap_usd: null }, 5), 5, 'NULL means use the default, never uncapped');
334
+ assert.equal(agentCapFor({}, 5), 5, 'a projection that dropped the column must not read as uncapped');
335
+ for (const bad of [0, -1, 'nonsense', NaN, Infinity]) {
336
+ assert.equal(agentCapFor({ spend_cap_usd: bad }, 5), 5,
337
+ String(bad) + ' is not a cap this code wrote (agents_004 CHECK forbids it), so it takes the default');
338
+ }
339
+ });
340
+
341
+ test('AN UNREADABLE LEDGER REFUSES HERE TOO — checkAgent does not inherit the fix, it must honour it', async () => {
342
+ const c = createSpendCeiling({ agentCapUsd: 5, now: () => AT });
343
+ const throwing = { query: async () => { throw new Error('connection terminated'); } };
344
+ assert.equal((await c.checkAgent(throwing, HISTORIAN)).reason, 'ledger_unreadable');
345
+ // The shapes that would coerce to a permissive 0. Each is asserted against
346
+ // checkAgent specifically, because the whole hazard is a sum that LOOKS fine.
347
+ for (const rows of [[{ spent: null }], [{ spent: undefined }], [{}], [], [{ spent: 'nonsense' }], [{ spent: '-1' }]]) {
348
+ const r = await c.checkAgent({ query: async () => ({ rows }) }, HISTORIAN);
349
+ assert.equal(r.ok, false, JSON.stringify(rows));
350
+ assert.equal(r.reason, 'ledger_unreadable', JSON.stringify(rows));
351
+ assert.equal(r.spentUsd, null, 'a refusal that could not read must not claim a sum');
352
+ }
353
+ assert.equal((await c.checkAgent(null, HISTORIAN)).reason, 'ledger_unreadable', 'no pool at all');
354
+ });
355
+
356
+ test('no usable cap and no agent identity both REFUSE — an unbounded agent is the defect wearing a cap name', async () => {
357
+ for (const cap of [0, -5, NaN, 'nonsense']) {
358
+ const c = createSpendCeiling({ agentCapUsd: cap, now: () => AT });
359
+ const r = await c.checkAgent(poolOf('0'), { name: 'x', spend_cap_usd: null });
360
+ assert.equal(r.ok, false, String(cap));
361
+ assert.equal(r.reason, 'no_agent_spend_cap_configured', String(cap));
362
+ }
363
+ const c = createSpendCeiling({ agentCapUsd: 5, now: () => AT });
364
+ for (const definition of [null, {}, { name: '' }, { name: ' ' }, { name: 42 }]) {
365
+ const r = await c.checkAgent(poolOf('0'), definition);
366
+ assert.equal(r.ok, false, JSON.stringify(definition));
367
+ assert.equal(r.reason, 'no_agent_identity', JSON.stringify(definition));
368
+ }
369
+ });
370
+
371
+ test('the agent sum is keyed on agent_name and the calendar month — not definition_id', async () => {
372
+ const c = createSpendCeiling({ agentCapUsd: 5, now: () => AT });
373
+ let sql = null; let params = null;
374
+ await c.checkAgent({ query: async (s, p) => { sql = s; params = p; return { rows: [{ spent: '0' }] }; } }, HISTORIAN);
375
+ assert.match(sql, /agent_name\s*=\s*\$2/, 'keyed on the NAME');
376
+ assert.doesNotMatch(sql, /definition_id/,
377
+ 'definition_id is ON DELETE SET NULL, so keying on it would zero an agent month '
378
+ + 'when its definition is deleted and hand a re-created agent a fresh cap');
379
+ assert.doesNotMatch(sql, /payer_kind/, 'a per-agent cap spans every payer — that is the point');
380
+ assert.equal(params[0].toISOString(), '2026-09-01T00:00:00.000Z');
381
+ assert.equal(params[1], 'historian');
382
+ });
383
+
384
+ test('checkAgent RECORDS NOTHING either — agents_runs stays the only tally', async () => {
385
+ const c = createSpendCeiling({ agentCapUsd: 5, now: () => AT });
386
+ const statements = [];
387
+ const pool = { query: async (s) => { statements.push(s); return { rows: [{ spent: '1' }] }; } };
388
+ await c.checkAgent(pool, HISTORIAN);
389
+ await c.checkAgent(pool, HISTORIAN);
390
+ assert.equal(statements.length, 2);
391
+ for (const s of statements) {
392
+ assert.match(s, /^\s*SELECT/i);
393
+ assert.doesNotMatch(s, /INSERT|UPDATE|DELETE/i);
394
+ }
395
+ });
396
+
397
+ // ---------------------------------------------------------------------------
398
+ // 5. the cost mirror (task 1002499) — what makes the fleet-wide brake able to
399
+ // see agent spend at all
400
+ // ---------------------------------------------------------------------------
401
+
402
+ const mirrorPort = () => {
403
+ const rows = [];
404
+ return { rows, logCost: async (r) => { rows.push(r); return { id: 1 }; } };
405
+ };
406
+
407
+ test('the source tag is the agent: family the MTD brake matches on', () => {
408
+ assert.equal(costSourceTag('historian'), 'agent:historian');
409
+ assert.ok(costSourceTag('x').startsWith('agent:'),
410
+ 'the prefix is a CONTRACT: modules/economy/cost.js widens the autonomous MTD sum to '
411
+ + 'source LIKE agent:%, so a tag outside the family is spend the brake cannot see');
412
+ });
413
+
414
+ test('a completed fire mirrors its cost, keyed so a retry cannot double-post', async () => {
415
+ const economy = mirrorPort();
416
+ const wrote = await mirrorCostToLedger(
417
+ { runId: 77, definition: { name: 'historian' }, costUsd: 0.9123, builderId: 25 }, { economy },
418
+ );
419
+ assert.equal(wrote, true);
420
+ assert.equal(economy.rows.length, 1);
421
+ const row = economy.rows[0];
422
+ assert.equal(row.amountUsd, 0.9123);
423
+ assert.equal(row.category, 'api', 'the category the brake filters on, beside the source');
424
+ assert.equal(row.source, 'agent:historian');
425
+ assert.equal(row.sourceRef, 'agent-run-77',
426
+ 'logCost is insert-once on (source, source_ref), and the run id is the one value '
427
+ + 'unique per fire — the alternative to a stable key here is double-counting money');
428
+ assert.equal(row.builderId, 25, 'who ASKED; the PAYER lives on the agents_runs row');
429
+ });
430
+
431
+ test('nothing to mirror is not an error, and nothing is written', async () => {
432
+ for (const args of [
433
+ { runId: 1, definition: { name: 'a' }, costUsd: 0 },
434
+ { runId: 1, definition: { name: 'a' }, costUsd: null },
435
+ { runId: 1, definition: { name: 'a' }, costUsd: -1 },
436
+ { runId: 1, definition: { name: 'a' }, costUsd: '0.9' },
437
+ { runId: null, definition: { name: 'a' }, costUsd: 0.9 },
438
+ { runId: 1, definition: {}, costUsd: 0.9 },
439
+ { runId: 1, definition: null, costUsd: 0.9 },
440
+ ]) {
441
+ const economy = mirrorPort();
442
+ assert.equal(await mirrorCostToLedger(args, { economy }), false, JSON.stringify(args));
443
+ assert.equal(economy.rows.length, 0, 'a cost_log row with no fire behind it must not be possible');
444
+ }
445
+ });
446
+
447
+ test('the mirror FAILS OPEN and says so — the opposite direction from the ceilings, on purpose', async () => {
448
+ // The ceilings fail closed because they decide whether to SPEND. This runs after
449
+ // the money is gone and the agents_runs row is written, so refusing here would
450
+ // discard a completed fire result to fix a reporting row.
451
+ const economy = { logCost: async () => { throw new Error('cost_log unreachable'); } };
452
+ const warnings = [];
453
+ const log = { warn: (fields, msg) => warnings.push({ fields, msg }) };
454
+ assert.equal(await mirrorCostToLedger(
455
+ { runId: 9, definition: { name: 'historian' }, costUsd: 0.9 }, { economy, log },
456
+ ), false);
457
+ assert.equal(warnings.length, 1, 'silence would leave the brake under-counting with nothing to notice it');
458
+ // STRUCTURED, not a formatted string: the run id and the agent are fields an
459
+ // operator can filter the journal by, which is the point of naming them at all.
460
+ assert.equal(warnings[0].fields.run_id, 9, 'names the run');
461
+ assert.equal(warnings[0].fields.agent, 'historian', 'names the agent');
462
+ assert.match(warnings[0].fields.err, /cost_log unreachable/, 'and what actually went wrong');
463
+ assert.match(warnings[0].msg, /under-counting/, 'and says what the consequence is');
464
+ });
465
+
466
+ test('no economy port: no throw, no write — an instance may run agents without economy', async () => {
467
+ assert.equal(await mirrorCostToLedger(
468
+ { runId: 1, definition: { name: 'a' }, costUsd: 0.9 }, { economy: null },
469
+ ), false);
470
+ assert.equal(await mirrorCostToLedger(
471
+ { runId: 1, definition: { name: 'a' }, costUsd: 0.9 }, { economy: {} },
472
+ ), false, 'a port without logCost is the same as no port — ADR 0083 degrades, it does not crash');
473
+ });
@@ -0,0 +1,98 @@
1
+ // tests/hall_states_regex_escapes.mjs — a states file's ignore lists are REGEX
2
+ // SOURCES, so a JSON escape that produces a control character is a silently dead
3
+ // pattern (task 1003638).
4
+ //
5
+ // THE DEFECT. `modules/ui-design/kit/lib.js` does:
6
+ //
7
+ // const con = ignoreConsole.map((s) => new RegExp(s));
8
+ //
9
+ // so every entry is a regex source. In JSON, `"\b"` is the escape for BACKSPACE
10
+ // (U+0008) — not the two characters a regex needs for a word boundary. Eight states
11
+ // files wrote `"\b401\b"` where they meant `"\\b401\\b"`, compiling a pattern that
12
+ // matches a literal backspace beside "401". Console text never contains one, so the
13
+ // declared noise suppression did nothing and nothing ever said so.
14
+ //
15
+ // MEASURED across modules/hall-ui/public/*.states.json on 2026-09-17: 8 files, 17
16
+ // entries — goals, idea, ideas, index, pair, settings, task, work. (The row that
17
+ // filed this named only `work`, and listed `task`, `idea` and `ideas` among the
18
+ // HEALTHY files; all three were broken. The fix followed the computed list.)
19
+ //
20
+ // Run: node --test tests/hall_states_regex_escapes.mjs
21
+
22
+ import { test } from 'node:test';
23
+ import assert from 'node:assert/strict';
24
+ import { readFileSync, readdirSync } from 'node:fs';
25
+ import path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
29
+ const DIR = path.join(REPO_ROOT, 'modules/hall-ui/public');
30
+ const REGEX_LISTS = ['ignoreConsole', 'ignoreRequests', 'ignoreControls'];
31
+ const CONTROL = /[\x00-\x1f]/;
32
+
33
+ const files = readdirSync(DIR).filter((n) => n.endsWith('.states.json'));
34
+
35
+ test('there are states files to check — an empty sweep must not read as a pass', () => {
36
+ assert.ok(files.length > 10, `expected the hall states files, found ${files.length}`);
37
+ });
38
+
39
+ test('no ignore-list entry contains a control character', () => {
40
+ const offenders = [];
41
+ for (const name of files) {
42
+ const json = JSON.parse(readFileSync(path.join(DIR, name), 'utf8'));
43
+ for (const key of REGEX_LISTS) {
44
+ if (!Array.isArray(json[key])) continue;
45
+ for (const entry of json[key]) {
46
+ if (CONTROL.test(entry)) {
47
+ const codes = [...entry].map((c) => c.charCodeAt(0)).filter((n) => n < 32)
48
+ .map((n) => `U+${n.toString(16).padStart(4, '0')}`).join(',');
49
+ offenders.push(`${name} ${key}: ${JSON.stringify(entry)} (${codes})`);
50
+ }
51
+ }
52
+ }
53
+ }
54
+ assert.deepEqual(offenders, [],
55
+ `a JSON escape produced a control character in a regex source — almost certainly \\b meant as a word boundary:\n ${offenders.join('\n ')}`);
56
+ });
57
+
58
+ test('every ignore-list entry compiles as a regex', () => {
59
+ // lib.js calls new RegExp(s) with no try/catch, so an uncompilable entry is a
60
+ // crash in the walk rather than a dead pattern.
61
+ const bad = [];
62
+ for (const name of files) {
63
+ const json = JSON.parse(readFileSync(path.join(DIR, name), 'utf8'));
64
+ for (const key of REGEX_LISTS) {
65
+ if (!Array.isArray(json[key])) continue;
66
+ for (const entry of json[key]) {
67
+ try { new RegExp(entry); } catch (e) { bad.push(`${name} ${key}: ${JSON.stringify(entry)} — ${e.message}`); }
68
+ }
69
+ }
70
+ }
71
+ assert.deepEqual(bad, [], `uncompilable regex source:\n ${bad.join('\n ')}`);
72
+ });
73
+
74
+ test('the word-boundary patterns actually match a real console line', () => {
75
+ // The behavioural half: shape alone would still pass if someone "fixed" the control
76
+ // character by deleting it (leaving "401", which over-matches, e.g. "1401ms").
77
+ const line = 'Failed to load resource: the server responded with a status of 404 (Not Found)';
78
+ const work = JSON.parse(readFileSync(path.join(DIR, 'work.states.json'), 'utf8'));
79
+ const matched = work.ignoreConsole.some((s) => new RegExp(s).test(line));
80
+ assert.equal(matched, true, 'the declared 404 suppression must match a real 404 console line');
81
+
82
+ // And the boundary must still be a boundary — not a bare number.
83
+ const boundary = work.ignoreConsole.find((s) => s.includes('404'));
84
+ assert.ok(boundary, 'a 404 entry must exist');
85
+ assert.equal(new RegExp(boundary).test('elapsed 1404ms'), false,
86
+ 'the pattern must not match a 404 embedded in a longer number — that is what the word boundary is for');
87
+ });
88
+
89
+ test('the broken form is genuinely inert — this is what the bug cost', () => {
90
+ // Pins WHY it was invisible: the old value silently matched nothing rather than
91
+ // erroring, so every gate stayed green while the suppression did nothing.
92
+ const broken = '\x08404\x08';
93
+ const line = 'Failed to load resource: the server responded with a status of 404 (Not Found)';
94
+ assert.equal(new RegExp(broken).test(line), false,
95
+ 'the backspace form matches nothing — silent, which is why it survived');
96
+ assert.doesNotThrow(() => new RegExp(broken),
97
+ 'and it compiles, so nothing ever complained');
98
+ });