@bongos/core 1.19.506 → 1.19.507

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.506",
6
- "core_contract": "1.19.506",
7
- "source_commit": "0c5102c78cf4acf0c5ab19a0f34ab43caa0a53d0",
5
+ "core_version": "1.19.507",
6
+ "core_contract": "1.19.507",
7
+ "source_commit": "f4008a4f0c0527a72d19cfc989889a3610a164d1",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-03T17:56:16.813Z",
9
+ "built_at": "2026-09-03T18:09:01.161Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 427,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 1945,
14
+ "functional_verbatim": 1946,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2396,
20
- "tree_sha256": "d0ec8df4b7c843085a5692c55b908977f2bbc25023e21dc62e246e62cfdfbd0d",
19
+ "file_count": 2397,
20
+ "tree_sha256": "cf9525f9de033ff0382bbc7986cc416062ed1a629caabc30849cc4efa1065398",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -382,7 +382,7 @@
382
382
  {
383
383
  "path": "clients/bongos-client/index.d.ts",
384
384
  "mode": "0000644",
385
- "sha256": "036e1064bb9513d7acbf0dafd0850deac9f45a38f31c3cbb8c1d7493c9159b76"
385
+ "sha256": "afe60dae1ca8b55c5f9521b4f23eaad4eb194627a2ee105cae1513745ae71544"
386
386
  },
387
387
  {
388
388
  "path": "clients/bongos-client/index.mjs",
@@ -1702,7 +1702,7 @@
1702
1702
  {
1703
1703
  "path": "docs/api/openapi.json",
1704
1704
  "mode": "0000644",
1705
- "sha256": "c02241e6fd8372fb80339eaa385f227b642754ff127dd529cfdc94ed87d077a4"
1705
+ "sha256": "0b5eab5940ae6cf0e8691c6ec59757e8bbfb719a660ec3e8492d9870c95a8c62"
1706
1706
  },
1707
1707
  {
1708
1708
  "path": "docs/architecture.md",
@@ -1727,12 +1727,12 @@
1727
1727
  {
1728
1728
  "path": "docs/copy-inventory.md",
1729
1729
  "mode": "0000644",
1730
- "sha256": "6f3476adddccabea94bb3bb8af804ec1e6dfc1710a9e3f76fb4ba062b44e3234"
1730
+ "sha256": "b6b680304b158480b87c61679bafa3431d6e492aa9323933abf68621ce243365"
1731
1731
  },
1732
1732
  {
1733
1733
  "path": "docs/copy-registry.json",
1734
1734
  "mode": "0000644",
1735
- "sha256": "d6f13f1ab6d579e195b3240511d68181aabd64591933f1911fc0009fbdf875d8"
1735
+ "sha256": "ed8c0d7c6ab7edd3ae61cd97c530323ed915f32cfa14a4f4ed1bca4d52437a01"
1736
1736
  },
1737
1737
  {
1738
1738
  "path": "docs/design/apex-pass-2-direction.md",
@@ -2577,7 +2577,7 @@
2577
2577
  {
2578
2578
  "path": "docs/module-api-changelog.md",
2579
2579
  "mode": "0000644",
2580
- "sha256": "130aad5d413e749405b9f5104259cdfb9dfde539ae35326649c64e79c5ff6604"
2580
+ "sha256": "ca0326ffd2f4996d2c4f42940ba5c2a2b40b18f9a676023918fd2cde9cd85634"
2581
2581
  },
2582
2582
  {
2583
2583
  "path": "docs/modules-contract.md",
@@ -4487,7 +4487,7 @@
4487
4487
  {
4488
4488
  "path": "modules/hall-ui/public/builders.js",
4489
4489
  "mode": "0000644",
4490
- "sha256": "1a8bf6e502311a22ac937ffbf05214d5a0286b4f94b5275e580b7fb68911e7f7"
4490
+ "sha256": "18e113894ddb8d8e86faa01d20edde978f8710f8eb546e0baa8b7732d46cf00c"
4491
4491
  },
4492
4492
  {
4493
4493
  "path": "modules/hall-ui/public/chime.wav",
@@ -4707,7 +4707,7 @@
4707
4707
  {
4708
4708
  "path": "modules/hall-ui/public/hall-widgets.js",
4709
4709
  "mode": "0000644",
4710
- "sha256": "8aa90c0bb15a19f13295da6225d270bbbdaf4b5336240f03cbc05abe192e87c0"
4710
+ "sha256": "6a3a19788c6e39497f32c130086c812fad15a5a7cf01d91b8a68f673b9ea6437"
4711
4711
  },
4712
4712
  {
4713
4713
  "path": "modules/hall-ui/public/harbor.css",
@@ -5512,7 +5512,7 @@
5512
5512
  {
5513
5513
  "path": "modules/lifecycle/routes/tasks.js",
5514
5514
  "mode": "0000644",
5515
- "sha256": "0bbf26870c5959ef6101c535e9a3987d2177d2a8d1b6c91bded9c31350836165"
5515
+ "sha256": "0ba020342e32eb552532a3a34a57b19aa4bbe87f0cb89ace0c45f4c301d7a015"
5516
5516
  },
5517
5517
  {
5518
5518
  "path": "modules/lifecycle/routes/versions.js",
@@ -7227,12 +7227,12 @@
7227
7227
  {
7228
7228
  "path": "package-lock.json",
7229
7229
  "mode": "0000644",
7230
- "sha256": "7a6dd18246bdbc4fbf57c52d54246a17ce2f5fb144bb256a6a163804175a8be3"
7230
+ "sha256": "e59012a1aaea2c2e25ac2a7f0c40f096bfe2cae832502cdabfe3519fdb37322d"
7231
7231
  },
7232
7232
  {
7233
7233
  "path": "package.json",
7234
7234
  "mode": "0000644",
7235
- "sha256": "26b904a0eb436c1b3654cac79c075a39ccd61bd9db7d3eab8c96f43507fd2686"
7235
+ "sha256": "aae944a6300fb887bd38a4692813e2f03965d7bb044c843fe7f23a26715233d8"
7236
7236
  },
7237
7237
  {
7238
7238
  "path": "public-docs/index.html",
@@ -8902,7 +8902,7 @@
8902
8902
  {
8903
8903
  "path": "src/module-api.js",
8904
8904
  "mode": "0000644",
8905
- "sha256": "818b1e08d902b65dedaf1d670b920a12f8eedbc3191051c4977675ec34a4e8d6"
8905
+ "sha256": "c8e168b4ae8a7dc956802de03248b6509b871e98bf0315dbd94d8dc7cc16be50"
8906
8906
  },
8907
8907
  {
8908
8908
  "path": "src/module-loader/catalog.js",
@@ -10287,7 +10287,7 @@
10287
10287
  {
10288
10288
  "path": "tests/hall_board_world.mjs",
10289
10289
  "mode": "0000644",
10290
- "sha256": "61c5fc8a46807653801315d14711b7f11f52ce80e27908fe59518e032582228f"
10290
+ "sha256": "b0ef3e407d7bd9266231dc089f1288ee40efb113e40333f79d3de4436db6cc21"
10291
10291
  },
10292
10292
  {
10293
10293
  "path": "tests/hall_catalog_world.mjs",
@@ -10389,6 +10389,11 @@
10389
10389
  "mode": "0000644",
10390
10390
  "sha256": "e2d482ca08d151719f61b52bcbb8056d5c91c9447b5bba7c9f7682d9582e34b8"
10391
10391
  },
10392
+ {
10393
+ "path": "tests/home_bounded_reads.mjs",
10394
+ "mode": "0000644",
10395
+ "sha256": "186a8ebd04cd4dc8021b3a36699468e9c0bcf78ca61ce9f7be7c3e25c632fb3e"
10396
+ },
10392
10397
  {
10393
10398
  "path": "tests/hook_core_resolve.mjs",
10394
10399
  "mode": "0000644",
@@ -123,7 +123,7 @@ export interface GetSessionsSearchResponse { sessions: unknown; count: unknown;
123
123
  export interface GetSsoPubkeyResponse { alg: unknown; format: unknown; public_key_b64: unknown }
124
124
  export interface GetTaskRecommendationsForMeResponse { recommendations: unknown; count: unknown }
125
125
  export interface GetTaskRecommendationsMineResponse { recommendations: unknown; count: unknown }
126
- export interface GetTasksClaimableResponse { claimable: unknown; active_claims: unknown }
126
+ export interface GetTasksClaimableResponse { claimable: unknown; claimable_total: unknown; active_claims: unknown }
127
127
  export interface GetTasksIdCriteriaResponse { criteria: unknown }
128
128
  export interface GetTasksIdDependenciesResponse { dependencies: unknown; dependents: unknown }
129
129
  export interface GetTasksIdRecommendationsResponse { recommendations: unknown; count: unknown }
@@ -17273,10 +17273,12 @@
17273
17273
  "type": "object",
17274
17274
  "properties": {
17275
17275
  "claimable": {},
17276
+ "claimable_total": {},
17276
17277
  "active_claims": {}
17277
17278
  },
17278
17279
  "required": [
17279
17280
  "claimable",
17281
+ "claimable_total",
17280
17282
  "active_claims"
17281
17283
  ]
17282
17284
  },
@@ -131,7 +131,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
131
131
  | `daf73127afff` | certain | A word on why | `modules/hall-ui/public/hall-render.js:278` |
132
132
  | `996edffc1966` | certain | Access requests | `modules/hall-ui/public/watch.html:46` |
133
133
  | `996edffc1966` | certain | Access requests | `modules/hall-ui/public/watch.html:61` |
134
- | `303f8d90266f` | certain | Achievements could not be loaded. | `modules/hall-ui/public/builders.js:1123` |
134
+ | `303f8d90266f` | certain | Achievements could not be loaded. | `modules/hall-ui/public/builders.js:1150` |
135
135
  | `6ca31dd5eb91` | certain | Acknowledge the primer | `modules/hall-ui/public/primer.html:99` |
136
136
  | `4461b13f95c2` | certain | Actions apply at once. Closing stops and deletes the box; you can start a fresh one anytime. | `modules/hall-ui/public/settings.html:209` |
137
137
  | `524188fe0078` | certain | Active sessions | `modules/hall-ui/public/settings.html:162` |
@@ -864,7 +864,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
864
864
  | `381f29be7497` | certain | The most recently shipped tasks. | `modules/hall-ui/public/work.html:108` |
865
865
  | `82479c539fca` | certain | The name shown beside your work — in the roster, the leaderboard, and the work feed. Your sign-in and the cred | `modules/hall-ui/public/settings.html:75` |
866
866
  | `b88dd8b96b72` | certain | The next person to notice this sentence reads your note instead of re-litigating it. | `modules/hall-ui/public/copy-desk.html:135` |
867
- | `aa716f3dd551` | certain | The page could not be loaded. Try again. | `modules/hall-ui/public/builders.js:1365` |
867
+ | `aa716f3dd551` | certain | The page could not be loaded. Try again. | `modules/hall-ui/public/builders.js:1392` |
868
868
  | `6c3d88de42de` | certain | The primer | `modules/hall-ui/public/primer.html:41` |
869
869
  | `dea378cd4837` | certain | The primer did not load | `modules/hall-ui/public/primer.html:88` |
870
870
  | `3bc5ec5f35ec` | certain | The ranks, lowest to highest | `modules/hall-ui/public/ranks.html:46` |
@@ -2056,7 +2056,7 @@
2056
2056
  "surface": "builders-hall",
2057
2057
  "text": "Achievements could not be loaded.",
2058
2058
  "file": "modules/hall-ui/public/builders.js",
2059
- "line": 1123,
2059
+ "line": 1150,
2060
2060
  "origin": "js-markup",
2061
2061
  "confidence": "certain"
2062
2062
  },
@@ -8653,7 +8653,7 @@
8653
8653
  "surface": "builders-hall",
8654
8654
  "text": "The page could not be loaded. Try again.",
8655
8655
  "file": "modules/hall-ui/public/builders.js",
8656
- "line": 1365,
8656
+ "line": 1392,
8657
8657
  "origin": "js-notice",
8658
8658
  "confidence": "certain"
8659
8659
  },
@@ -1449,5 +1449,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1449
1449
  landed since 1.19.504 with no explicit bump. run 33785121177. (task 1002620)
1450
1450
  1.19.506 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1451
1451
  landed since 1.19.505 with no explicit bump. run 33787453336. (task 1002620)
1452
+ 1.19.507 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1453
+ landed since 1.19.506 with no explicit bump. run 33788726591. (task 1002620)
1452
1454
  ---------------------------------------------------------------------------
1453
1455
  ```
@@ -866,7 +866,10 @@
866
866
  let canTriage = false;
867
867
  function loadTriageInbox() {
868
868
  if (!canTriage) return;
869
- getJSON(`${API}/inbox`)
869
+ // ?limit=5 — the card renders five rows; the route's default is 50, and the
870
+ // open_count that drives the "N waiting" line is a separate count, so asking for
871
+ // what is rendered costs the reader nothing (goal 1000072).
872
+ getJSON(`${API}/inbox?limit=5`)
870
873
  .then((data) => renderTriageInbox(data))
871
874
  .catch(() => { /* nice-to-have; never break the hall */ });
872
875
  }
@@ -895,13 +898,28 @@
895
898
  const subhead = document.getElementById('home-recent-subh');
896
899
  if (!wrap || !list) return;
897
900
  try {
898
- const resp = await getJSON(`${API}/tasks/claimable`, { softAuth: true });
899
- // task 1003464: the same feed answers the greeting's "N tasks are
900
- // claimable right now" — rank-scoped, so it is THIS builder's number.
901
- lastClaimableCount = resp && Array.isArray(resp.claimable) ? resp.claimable.length : null;
901
+ // ?limit=2 — this card is TWO lines. It used to download the entire claimable
902
+ // feed, every row with its full description, and `.slice(0, 2)` it: the same
903
+ // shape goal 1000072's criterion names, and the one goal-rollup.js was carved to
904
+ // fix on the Goals page (5,229,455 bytes to produce forty counters). The server
905
+ // already orders by priority then calibrated estimate, so the first two rows ARE
906
+ // the top two. `claimable_total` comes back so the foot can say how many are
907
+ // waiting instead of leaving two rows to imply there are only two.
908
+ const resp = await getJSON(`${API}/tasks/claimable?limit=2`, { softAuth: true });
909
+ // task 1003464: the greeting's "N tasks are claimable right now" — rank-scoped,
910
+ // so it is THIS builder's number. It reads claimable_total rather than the length
911
+ // of a fully-downloaded feed: the same number, without the wire cost that this
912
+ // commit exists to remove. The .length fallback covers a server that predates
913
+ // the field (a deploy skew), so the greeting degrades to correct-but-costly
914
+ // rather than to blank.
915
+ const claimableTotal = Number(resp && resp.claimable_total);
916
+ lastClaimableCount = Number.isFinite(claimableTotal)
917
+ ? claimableTotal
918
+ : (resp && Array.isArray(resp.claimable) ? resp.claimable.length : null);
902
919
  renderGreeting();
903
- const rows = (resp && Array.isArray(resp.claimable) ? resp.claimable : []).slice(0, 2);
920
+ const rows = resp && Array.isArray(resp.claimable) ? resp.claimable : [];
904
921
  if (!rows.length) { wrap.hidden = true; if (subhead) subhead.hidden = true; return; }
922
+ const total = Number(resp.claimable_total);
905
923
  const cur = (window.__BRANDING__ && window.__BRANDING__.currency && window.__BRANDING__.currency.label) || 'credits';
906
924
  list.innerHTML = rows.map((t) => {
907
925
  const credits = window.OTBBranding?.taskCreditValue?.(t) ?? t.credits_reward;
@@ -911,6 +929,15 @@
911
929
  <span class="next-up__meta">#${escapeHtml(String(t.id))}${mins} · ${fmtNum(credits)} ${escapeHtml(cur)} est.</span>
912
930
  </li>`;
913
931
  }).join('');
932
+ // Name the size of the queue these two came off. Silent when the count is
933
+ // missing or not bigger than what is shown — "See all 2" under two rows is
934
+ // noise, and a made-up number is worse than none.
935
+ const foot = document.getElementById('home-next-foot');
936
+ if (foot) {
937
+ foot.textContent = Number.isFinite(total) && total > rows.length
938
+ ? `See all ${fmtNum(total)} ready work →`
939
+ : 'See all ready work →';
940
+ }
914
941
  wrap.hidden = false;
915
942
  if (subhead) subhead.hidden = false;
916
943
  } catch (_) {
@@ -102,7 +102,7 @@
102
102
  '<div id="home-next" hidden>' +
103
103
  '<h3 class="ginbox__sec-h">Next up — ready to claim</h3>' +
104
104
  '<ul class="next-up" id="home-next-list"></ul>' +
105
- '<p class="next-up__foot"><a href="/work">See all ready work →</a></p>' +
105
+ '<p class="next-up__foot"><a href="/work" id="home-next-foot">See all ready work →</a></p>' +
106
106
  '</div>' +
107
107
  '<h3 class="ginbox__sec-h" id="home-recent-subh" hidden>Recently shipped</h3>' +
108
108
  '<table class="ledger" id="home-recent-table"><thead><tr><th>№</th><th>Work</th><th data-currency-label="caps">Credits</th><th>Shipped</th></tr></thead>' +
@@ -161,7 +161,12 @@ module.exports = function buildTasksRouter() {
161
161
  if (claimCreatedBy && claimCreatedBy.bad) return res.fail('bad_created_by', { status: 400, message: CREATED_BY_MSG });
162
162
  const claimStaleDays = posInt(req.query.stale_days, 'bad_stale_days', 3650);
163
163
  if (claimStaleDays && claimStaleDays.bad) return res.fail('bad_stale_days', { status: 400, message: 'stale_days must be a positive integer number of days.' });
164
- const [claimable, active] = await Promise.all([
164
+ // ?limit=N — opt-in, for a caller that renders N rows (goal 1000072; the hall's
165
+ // two-line Home card was pulling this whole feed, descriptions and all). It slices
166
+ // the RETURNED feed, never the query — lifecycle/CLAUDE.md has the why.
167
+ const claimLimit = posInt(req.query.limit, 'bad_limit', 1000);
168
+ if (claimLimit && claimLimit.bad) return res.fail('bad_limit', { status: 400, message: 'limit must be a positive integer (max 1000).' });
169
+ const [feed, active] = await Promise.all([
165
170
  db.listClaimableTasks({
166
171
  versionId: version || null,
167
172
  builderId: req.builder.id,
@@ -172,13 +177,17 @@ module.exports = function buildTasksRouter() {
172
177
  }),
173
178
  db.listActiveClaims(),
174
179
  ]);
180
+ const claimable = claimLimit ? feed.slice(0, claimLimit) : feed;
175
181
  // V3.R75 (#294): tick the onboarding state machine for first_start. Fire
176
182
  // and forget — the response should never wait on or fail because of the
177
183
  // onboarding write. safeMarkStage swallows errors and handles the
178
184
  // graduation broadcast. Through the `onboarding` kernel port (BV1.R81 / ADR
179
185
  // 0093 §1); ?. degrades to a no-op when the module is off.
180
186
  seams.resolveOptional('onboarding')?.safeMarkStage(pool, req.builder.id, 'first_start', req.builder);
181
- res.json({ claimable, active_claims: active });
187
+ // `claimable_total` is what makes the limit honest — two rows with no count reads
188
+ // as "there are only two". It is the length of the array the slice came from, never
189
+ // a second count query (that is how a total and its own list drift apart, ADR 0197).
190
+ res.json({ claimable, claimable_total: feed.length, active_claims: active });
182
191
  });
183
192
 
184
193
  // GET /tasks/newcomer-floor — supply gauge for the newcomer queue (task 614).
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.506",
3
+ "version": "1.19.507",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.506",
9
+ "version": "1.19.507",
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.506",
3
+ "version": "1.19.507",
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
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
45
45
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
46
46
  // the entry to that file. Look for a version's history there, not here.
47
47
  // ---------------------------------------------------------------------------
48
- const CORE_VERSION = '1.19.506'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.507'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
49
49
 
50
50
  // A namespaced logger so a module's log lines are attributable + consistent.
51
51
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -294,8 +294,18 @@ test('the system notices are inbox LINES, first in the card, and counted by its
294
294
 
295
295
  test('the greeting reads /me, the public progress and the claimable feed, and builders.js feeds all three', () => {
296
296
  assert.match(buildersJs, /getJSON\(`\$\{API\}\/public\/progress`\)/, 'Home fetches the public progress once');
297
- assert.match(buildersJs, /lastClaimableCount = resp && Array\.isArray\(resp\.claimable\) \? resp\.claimable\.length : null;/,
297
+ // The count comes off the SAME Next-up response — one request, not a second
298
+ // fetch. It reads `claimable_total` rather than the length of the returned array,
299
+ // because that request is now bounded (?limit=2, task 1003545): the array holds two
300
+ // rows and the total holds the whole rank-scoped queue. Asserting the derivation
301
+ // rather than one literal expression is deliberate — the previous version pinned
302
+ // `resp.claimable.length`, which read as a requirement to download the feed.
303
+ assert.match(buildersJs, /lastClaimableCount = Number\.isFinite\(claimableTotal\)/,
298
304
  'the claimable feed Home already fetches for Next-up answers the count');
305
+ assert.match(buildersJs, /const claimableTotal = Number\(resp && resp\.claimable_total\);/,
306
+ 'and it takes the count from the response total, so a bounded read still knows it');
307
+ assert.doesNotMatch(buildersJs, /getJSON\(`\$\{API\}\/tasks\/claimable`/,
308
+ 'no unbounded claimable fetch on Home — the count must never cost the whole queue');
299
309
  assert.match(buildersJs, /get lastProgressState\(\)/, 'the renderer reads progress through a getter, so it cannot freeze at boot');
300
310
  assert.match(buildersJs, /get lastClaimableCount\(\)/, 'and the count the same way');
301
311
  assert.equal((buildersJs.match(/renderGreeting\(\)/g) || []).length, 3, 'rendered after /me, after progress and after the claimable feed — each half as it lands');
@@ -0,0 +1,159 @@
1
+ // tests/home_bounded_reads.mjs — the Home card asks for what it renders
2
+ // (goal 1000072, "Human legibility").
3
+ //
4
+ // THE PATTERN THIS CLOSES. This goal's criterion `legibility-sort-and-staleness` set
5
+ // the rule in its own words — "~1,357 tasks on BONGOS-V1 alone. A view that does not
6
+ // survive that number is not a solution" — and `modules/lifecycle/goal-rollup.js`
7
+ // exists because the Goals page broke it: **5,229,455 bytes for ~1,400 tasks, measured
8
+ // 2026-08-25, to produce roughly forty small counter objects.**
9
+ //
10
+ // The same shape was still live on the page every builder lands on first. Home's
11
+ // "Next up" card called `GET /tasks/claimable` — no limit, `SELECT v.*`, every
12
+ // description on every claimable row — and then `.slice(0, 2)` to fill TWO lines. Its
13
+ // triage card pulled the route's default fifty ideas to render five.
14
+ //
15
+ // So this pins the rule rather than the two instances: a Home card may not download a
16
+ // feed and throw most of it away, and the limit it asks for must come back with a
17
+ // total, or two rows are indistinguishable from "there are only two".
18
+ //
19
+ // Run: node tests/home_bounded_reads.mjs
20
+ import assert from 'node:assert/strict';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { createRequire } from 'node:module';
25
+ import { makeRunner } from './helpers.mjs';
26
+
27
+ const require = createRequire(import.meta.url);
28
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
29
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
30
+ const BUILDERS_JS = read('modules/hall-ui/public/builders.js');
31
+ const WIDGETS_JS = read('modules/hall-ui/public/hall-widgets.js');
32
+ const ROUTE = read('modules/lifecycle/routes/tasks.js');
33
+ const DB_TASKS = read('modules/lifecycle/db-tasks.js');
34
+
35
+ // Comments stripped before any "is the bad pattern gone" assertion — the same rule
36
+ // scripts/gds/category-advisory-guard.js applies. A comment EXPLAINING the pattern that
37
+ // was removed is documentation; matching it would make the fix un-documentable.
38
+ const noComments = (src) => src.replace(/^\s*\/\/.*$/gm, '');
39
+
40
+ const { test, summary } = makeRunner();
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // 1. the rule, over Home's own boot reads
44
+ // ---------------------------------------------------------------------------
45
+
46
+ await test('no Home read downloads a feed and slices it in the browser', () => {
47
+ // The tell is a `.slice(0, N)` applied to a response body. Narrowing what is
48
+ // DISPLAYED is fine; narrowing what was FETCHED is the bug, and the two look
49
+ // identical at the call site — which is why this is a test and not a code review.
50
+ const src = noComments(BUILDERS_JS);
51
+ for (const fn of ['loadNextUp', 'loadTriageInbox']) {
52
+ const from = src.indexOf(`function ${fn}(`);
53
+ assert.ok(from > 0, `${fn} must exist`);
54
+ const body = src.slice(from, src.indexOf('\n }\n', from));
55
+ const name = fn;
56
+ const url = body.match(/\$\{API\}\/[^`'"]+/);
57
+ assert.ok(url, `${name} must issue a request`);
58
+ assert.match(url[0], /[?&]limit=\d+/, `${name} must ask the server for its page size`);
59
+ }
60
+ });
61
+
62
+ await test('the two cards ask for exactly the rows they draw', () => {
63
+ assert.match(BUILDERS_JS, /\$\{API\}\/tasks\/claimable\?limit=2/, 'Next up draws two rows');
64
+ assert.match(BUILDERS_JS, /\$\{API\}\/inbox\?limit=5/, 'the triage card draws five');
65
+ // …and no longer re-slices what came back.
66
+ const src = noComments(BUILDERS_JS);
67
+ const from = src.indexOf('function loadNextUp(');
68
+ const body = src.slice(from, src.indexOf('\n }\n', from));
69
+ assert.ok(body.length > 0, 'loadNextUp body found');
70
+ assert.doesNotMatch(body, /\.slice\(0,\s*2\)/, 'the client-side slice is gone');
71
+ });
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // 2. the limit is honest — it comes back with a total
75
+ // ---------------------------------------------------------------------------
76
+
77
+ await test('GET /tasks/claimable validates ?limit and answers with a total', () => {
78
+ assert.match(ROUTE, /posInt\(req\.query\.limit, 'bad_limit', 1000\)/, 'same validator as GET /tasks');
79
+ assert.match(ROUTE, /res\.fail\('bad_limit'/);
80
+ assert.match(ROUTE, /claimable_total: feed\.length/);
81
+ // The total must be the length of the very array the rows were sliced from. A
82
+ // companion count query would be a second copy of a filter chain that runs half in
83
+ // SQL and half in JS — the ADR 0197 failure, one release later.
84
+ assert.doesNotMatch(ROUTE, /countClaimableTasks/,
85
+ 'no second count query: the sliced array reports its own length');
86
+ assert.match(ROUTE, /const claimable = claimLimit \? feed\.slice\(0, claimLimit\) : feed;/);
87
+ });
88
+
89
+ await test('the limit is OPT-IN — the Work Board and /builder-start are untouched', () => {
90
+ // Their whole job is the full feed. A default limit would silently truncate the board
91
+ // and the CLI's claim menu, which is a worse bug than the one being fixed. `posInt`
92
+ // returns undefined for an absent param, so `claimLimit ? … : feed` hands back
93
+ // everything exactly as before.
94
+ assert.match(ROUTE, /claimLimit \? feed\.slice/);
95
+ assert.doesNotMatch(DB_TASKS, /listClaimableTasks\([^)]*limit/,
96
+ 'the db helper gained no limit — nothing below the route changed');
97
+ });
98
+
99
+ await test('every db.* the claimable handler calls actually exists', () => {
100
+ // The bug this catches, caught the hard way: an earlier cut pushed the limit into the
101
+ // db layer, was reverted, and left the route calling `db.listClaimableTasksPaged` — a
102
+ // function that no longer existed. Every assertion in this file is source text, and
103
+ // source text cannot see a missing symbol; a neighbouring suite found it instead.
104
+ const from = ROUTE.indexOf("router.get('/tasks/claimable'");
105
+ const body = ROUTE.slice(from, ROUTE.indexOf('res.json({ claimable', from));
106
+ const called = [...new Set([...body.matchAll(/\bdb\.([A-Za-z0-9_]+)\s*\(/g)].map((m) => m[1]))];
107
+ assert.ok(called.length > 0, 'the handler calls the db layer');
108
+ const dbExports = read('modules/lifecycle/db.js');
109
+ const block = dbExports.slice(dbExports.lastIndexOf('module.exports'));
110
+ for (const fn of called) {
111
+ assert.ok(new RegExp(`\\b${fn}\\b`).test(block), `db.js must export ${fn} — the route calls it`);
112
+ }
113
+ });
114
+
115
+ await test('the db layer is untouched: no new export, no second feed function', () => {
116
+ // The first cut pushed the limit into db-tasks.js and exported a paged twin. It
117
+ // bought nothing — a SQL LIMIT is unsafe here either way, so the query does the same
118
+ // work — and it widened the hand-mirrored export lists in db.js/db-tasks.js until
119
+ // they shared a 30-line run and tripped the duplication ratchet. The wire is the
120
+ // thing being saved, and the route is where the wire is.
121
+ assert.doesNotMatch(DB_TASKS, /listClaimableTasksPaged/);
122
+ assert.doesNotMatch(read('modules/lifecycle/db.js'), /listClaimableTasksPaged/);
123
+ });
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // 3. the trap a SQL LIMIT would have walked into
127
+ // ---------------------------------------------------------------------------
128
+
129
+ await test('the limit is applied AFTER the JS post-filters, not as a SQL LIMIT', () => {
130
+ // listClaimableTasks filters twice in JS after the query returns: the autonomous
131
+ // runner's protected-path + membership drops, and capNewcomerTasks' display cap.
132
+ // `LIMIT 2` in SQL can hand back two rows that both get dropped, leaving an empty
133
+ // feed with claimable work sitting right behind it. Slicing the RETURNED feed is
134
+ // what makes the limit safe, and it is why the query is unchanged.
135
+ const fn = DB_TASKS.slice(
136
+ DB_TASKS.indexOf('async function listClaimableTasks('),
137
+ DB_TASKS.indexOf('function taskTouchesProtectedPath(')
138
+ );
139
+ assert.ok(fn.length > 0, 'found listClaimableTasks');
140
+ assert.doesNotMatch(fn, /LIMIT \$/, 'no SQL LIMIT in the claimable query');
141
+ assert.match(fn, /return capNewcomerTasks\(rows\);/, 'the JS post-filters still have the last word');
142
+ const routeFrom = ROUTE.indexOf("router.get('/tasks/claimable'");
143
+ const routeBody = ROUTE.slice(routeFrom, ROUTE.indexOf('res.json({ claimable', routeFrom));
144
+ assert.match(routeBody, /feed\.slice\(0, claimLimit\)/, 'the slice happens on the finished feed');
145
+ });
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // 4. what the reader sees
149
+ // ---------------------------------------------------------------------------
150
+
151
+ await test('the foot names the queue the two rows came off', () => {
152
+ assert.match(WIDGETS_JS, /id="home-next-foot"/, 'the link has an id to write into');
153
+ assert.match(BUILDERS_JS, /See all \$\{fmtNum\(total\)\} ready work/);
154
+ // Silent rather than wrong when the count is missing or not larger than what is
155
+ // shown: "See all 2" under two rows is noise, and a guessed number is worse.
156
+ assert.match(BUILDERS_JS, /Number\.isFinite\(total\) && total > rows\.length/);
157
+ });
158
+
159
+ summary();