@bongos/core 1.19.506 → 1.19.508

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.508",
6
+ "core_contract": "1.19.508",
7
+ "source_commit": "f80c3ed5ba139cbecb704b5cd064990b3ff9aaaf",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-03T17:56:16.813Z",
9
+ "built_at": "2026-09-03T18:24:24.662Z",
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": 1947,
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": 2398,
20
+ "tree_sha256": "8482fdb735f30dbc4866dbf952a55196a50e643fd932b0ef0bb5b701ada05eab",
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": "4af1ded195d83c7060e26bf72c6aa27144bfba92e2ce679e2e90d57b866a0c93"
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": "e9270218a7b9b405011ac398777c9eb70fca7f0cdf3010649ea7450f25d399bb"
7231
7231
  },
7232
7232
  {
7233
7233
  "path": "package.json",
7234
7234
  "mode": "0000644",
7235
- "sha256": "26b904a0eb436c1b3654cac79c075a39ccd61bd9db7d3eab8c96f43507fd2686"
7235
+ "sha256": "6a1614277a1b71103a1882256a82e138cbdb4bfbc28be233e69cc751d7f24eed"
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": "eef34fbd7a43f793b876c07999581137f4e8e37cc0b3f03d7db41759b0271693"
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",
@@ -10317,7 +10317,7 @@
10317
10317
  {
10318
10318
  "path": "tests/hall_page_gate_map.mjs",
10319
10319
  "mode": "0000644",
10320
- "sha256": "d529685063de8c4aa6676abf86f1180bb9cd13d27a3c372d8a3d9cf078af1999"
10320
+ "sha256": "e65e22471469a9bf4551925f0549cfd6720aa7d5b7b7cf9df17984604e07c3cb"
10321
10321
  },
10322
10322
  {
10323
10323
  "path": "tests/hall_palette.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",
@@ -10874,6 +10879,11 @@
10874
10879
  "mode": "0000644",
10875
10880
  "sha256": "079d7eef5828912e306e9dbbe081fef6004526067bd15aa6433dc2ff1873a4be"
10876
10881
  },
10882
+ {
10883
+ "path": "tests/my_projects_surface.mjs",
10884
+ "mode": "0000644",
10885
+ "sha256": "03b61cb8de6713b03fe17b2b3c5d70af7c511b33b17a42295f49c36e27771470"
10886
+ },
10877
10887
  {
10878
10888
  "path": "tests/needs_rebase.mjs",
10879
10889
  "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,9 @@ 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)
1454
+ 1.19.508 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1455
+ landed since 1.19.507 with no explicit bump. run 33790206682. (task 1002620)
1452
1456
  ---------------------------------------------------------------------------
1453
1457
  ```
@@ -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.508",
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.508",
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.508",
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.508'; // 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');
@@ -47,6 +47,13 @@ const ORACLE = {
47
47
  '/builders/modules': 'requireBuilderPage',
48
48
  '/builders/goals': 'requireBuilderPage',
49
49
  '/builders/roadmap': 'requireBuilderPage',
50
+ // Gated at this tier all along, but with no row until task 1003448 walked the
51
+ // module's pages: copy-desk (task 1003113) and ideas (task 1002928) are open to
52
+ // every rank by design, and collab's reads (task 1003172) are scoped to the
53
+ // caller by the server — so for all three, sign-in IS the whole gate.
54
+ '/builders/copy-desk': 'requireBuilderPage',
55
+ '/builders/ideas': 'requireBuilderPage',
56
+ '/builders/collab': 'requireBuilderPage',
50
57
  // task 1002710: the orientation reading rooms moved DOWN to this tier from
51
58
  // requireNonXenosPage (thetes+). They are the onboarding documents, so gating
52
59
  // them above xenos turned away the exact reader they were written for. The
@@ -92,10 +99,33 @@ const ORACLE = {
92
99
  '/builders/drachmae': null,
93
100
  '/builders/profile': null,
94
101
  '/builders/not-ready': null,
102
+
103
+ // NOT deliberate — recorded because it is true, and filed as idea 1000907
104
+ // (task 1003448). The detail pages are addressed by a second segment, and
105
+ // /builders/task/:id and /builders/idea/:id are both gated at signed-in; the
106
+ // id-less slugs still resolve to the same shells through BUILDERS_PAGE_RE and
107
+ // match no gate. No data rides the shell and every read behind it is gated
108
+ // server-side, so this is an inconsistency rather than an exposure — but it is
109
+ // a row that should turn into a real gate (or a redirect) when that is decided.
110
+ '/builders/task': null,
111
+ '/builders/idea': null,
112
+
113
+ // Gone, not public — the hall ships no my-projects page at all since task
114
+ // 1003308 (owner's decision: "my projects" is a user-level page the hub owns,
115
+ // so the hall ships no page, no link and no redirect). Nothing is served here,
116
+ // so there is nothing to gate. Recorded rather than left out, because "absent
117
+ // from the map" is indistinguishable from the oversight this file exists to
118
+ // catch. GONE_PAGES below keeps the row honest if the file ever returns.
119
+ '/builders/my-projects': null,
95
120
  };
96
121
 
97
122
  // ─── reconstruct the URL→gate map from the source ───────────────────────────
98
123
  // 1. every `const NAME_RE = /…/;` (the page-matching regexes)
124
+ // The eval below turns a matched regex LITERAL back into a RegExp. Its only
125
+ // input is this repo's own serve-internal.js, read from a path fixed at the top
126
+ // of this file — never a caller's string, never anything fetched. Keep it that
127
+ // way: sourcing SRC from anywhere an outsider can influence makes this arbitrary
128
+ // code execution rather than a parser.
99
129
  function readRegexes(src) {
100
130
  const out = new Map();
101
131
  const re = /const\s+([A-Z0-9_]+_RE)\s*=\s*(\/(?:\\.|\[(?:\\.|[^\]])*\]|[^/\\])+\/[gimsuy]*)\s*;/g;
@@ -170,6 +200,47 @@ test('path-normalization tricks do not slip past a gate', () => {
170
200
  }
171
201
  });
172
202
 
203
+ // ═══ exhaustiveness — the reason a page can go missing from the oracle ═══════
204
+ // The ORACLE above is hand-written, so until now a page could ship with NO gate
205
+ // and NO row and nothing would notice — the same blind spot that let /sessions
206
+ // and /scouting ship ungated, and that left /my-projects out of the map entirely
207
+ // (task 1003448). Walking the module's own pages turns "someone remembered to
208
+ // add a row" into "the tree is walked", which is the half this test was missing.
209
+ const HALL_PAGES_DIR = path.join(ROOT, 'modules/hall-ui/public');
210
+
211
+ function hallPageUrls() {
212
+ return fs.readdirSync(HALL_PAGES_DIR)
213
+ .filter((f) => f.endsWith('.html'))
214
+ .map((f) => (f === 'index.html' ? '/builders/' : `/builders/${f.replace(/\.html$/, '')}`));
215
+ }
216
+
217
+ test('every hall page the module ships has a row in the oracle', () => {
218
+ const missing = hallPageUrls().filter((url) => !(url in ORACLE));
219
+ assert.deepEqual(missing, [],
220
+ `hall pages with no ORACLE row: ${missing.join(', ')} — give each a gate, or a deliberate null with the reason`);
221
+ });
222
+
223
+ // A page can also LEAVE the hall, and then `null` claims something weaker than
224
+ // "the shell is public": nothing is served at that URL at all. Spelling those out
225
+ // keeps the row honest — if the file ever comes back, the null stops describing
226
+ // reality, and this fails instead of quietly waving an ungated shell through.
227
+ const GONE_PAGES = {
228
+ // Removed by an owner decision in task 1003308 — "my projects" is a user-level
229
+ // page the hub already owns, so the hall ships no page, no link and no
230
+ // redirect. tests/hall_settings_world.mjs pins the deletion itself; this row
231
+ // pins what the deletion means for the gate map.
232
+ '/builders/my-projects': 'task 1003308 — the hub owns "my projects"',
233
+ };
234
+
235
+ test('a row that is null because the page left the hall serves no page', () => {
236
+ for (const [url, why] of Object.entries(GONE_PAGES)) {
237
+ assert.equal(ORACLE[url], null, `${url} is gone (${why}), so it can carry no gate`);
238
+ const file = path.join(HALL_PAGES_DIR, `${url.replace('/builders/', '')}.html`);
239
+ assert.ok(!fs.existsSync(file),
240
+ `${url} is back, so "${why}" no longer holds — give it a real gate and a real row`);
241
+ }
242
+ });
243
+
173
244
  test('no gated page is left matching only a REMOVED gate function', () => {
174
245
  // requireNonXenosPage still EXISTS in auth.js (page.view.builder is a live
175
246
  // catalog atom an Archon can attach to a custom role) but task 1002710 left it
@@ -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();
@@ -0,0 +1,112 @@
1
+ // tests/my_projects_surface.mjs
2
+ //
3
+ // GUARD (task 1003448): pins the three answers the "/my-projects is unreachable"
4
+ // audit finding actually has, so that none of them is a claim in a session log
5
+ // that the next reader has to take on faith.
6
+ //
7
+ // The audit (2026-08-30) reported the hall's my-projects page as unreachable and
8
+ // ungated, and flagged the second half as "NOT CONFIRMED AS A LEAK — check the
9
+ // API gates first, then decide whether the fix is a gate, a nav entry, or both".
10
+ // By the time the task was worked, the answer to "a gate or a nav entry" had
11
+ // become "neither":
12
+ //
13
+ // 1. WHERE THE PAGE WENT. Task 1003308 deleted it on an owner decision — "my
14
+ // projects" is a user-level page the hub already owns, so the hall ships no
15
+ // page, no link and no redirect. A nav entry would reverse that decision and
16
+ // a page gate would guard a URL that resolves to no file. (The deletion
17
+ // itself is pinned by tests/hall_settings_world.mjs; the gate map's row for
18
+ // it by tests/hall_page_gate_map.mjs.)
19
+ // 2. WHETHER ANYTHING LEAKED. No: every /my-projects* route is sign-in-gated
20
+ // and reads the caller's OWN identity off the session. This is the half the
21
+ // audit could not confirm, so it is asserted here rather than described.
22
+ // 3. WHAT IS STILL MISSING. The invitations half has no caller anywhere — no
23
+ // surface fetches the invite list, so a builder holding an invitation still
24
+ // cannot act on it. That is tracked work (task 1002970, idea 1000903), and
25
+ // pinning it keeps a known gap from decaying back into an unknown one.
26
+ //
27
+ // Static reads only (no import of the app, no DB) → runs in the `unit` gate.
28
+ //
29
+ // Run: node --test --test-reporter=tap tests/my_projects_surface.mjs
30
+
31
+ import { test } from 'node:test';
32
+ import assert from 'node:assert/strict';
33
+ import fs from 'node:fs';
34
+ import path from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
36
+
37
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
38
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
39
+
40
+ const ROUTES = 'modules/platform-identity/routes/my-projects.js';
41
+
42
+ // ── 1. the page is gone from the hall, by decision ──────────────────────────
43
+ // Kept to the ONE fact this file's other assertions lean on: there is no hall
44
+ // page, so "unreachable from the hall" is a decision to honour and not a defect
45
+ // to fix. tests/hall_settings_world.mjs owns the full deletion pin.
46
+ test('the hall ships no my-projects page, so there is nothing to link or gate', () => {
47
+ const dir = path.join(ROOT, 'modules/hall-ui/public');
48
+ const strays = fs.readdirSync(dir).filter((f) => f.startsWith('my-projects'));
49
+ assert.deepEqual(strays, [],
50
+ 'task 1003308 removed the hall page (the hub owns "my projects") — if it is back, that decision changed and this file, the nav and the page gate all need revisiting');
51
+ });
52
+
53
+ // ── 2. the "not confirmed as a leak" half, confirmed ────────────────────────
54
+ // The audit's worry was a shell served to anyone who asked. The shell is gone,
55
+ // but the routes behind it outlived it, so the gate is what matters now.
56
+ const MY_PROJECTS_ROUTES = [
57
+ "router.get('/my-projects'",
58
+ "router.post('/my-projects/join'",
59
+ "router.get('/my-projects/invites'",
60
+ "router.post('/my-projects/invites/:clientId/accept'",
61
+ "router.post('/my-projects/invites/:clientId/decline'",
62
+ "router.post('/my-projects/:clientId/leave'",
63
+ ];
64
+
65
+ test('every /my-projects route is sign-in gated', () => {
66
+ const src = read(ROUTES);
67
+ for (const decl of MY_PROJECTS_ROUTES) {
68
+ const at = src.indexOf(decl);
69
+ assert.notEqual(at, -1, `${decl} — route missing; this list is stale`);
70
+ // The gate is the argument immediately after the path, so read the rest of
71
+ // that line rather than the whole file: a requireBuilder somewhere else in
72
+ // the module must not vouch for a route that carries no gate of its own.
73
+ const line = src.slice(at, src.indexOf('\n', at));
74
+ assert.match(line, /,\s*requireBuilder\s*,/,
75
+ `${decl} must carry requireBuilder — an ungated route here exposes cross-project membership`);
76
+ }
77
+ });
78
+
79
+ test('every /my-projects handler reads the caller identity from the session', () => {
80
+ // The gate only proves SOMEONE is signed in. What keeps one builder out of
81
+ // another's projects is that the github_id comes off req.builder and is never
82
+ // accepted from the caller — no :githubId param, no body-supplied account.
83
+ const src = read(ROUTES);
84
+ assert.ok(src.includes('req.builder.github_id'), 'identity must come off the session');
85
+ assert.doesNotMatch(src, /\/my-projects[^']*:githubId/,
86
+ 'no /my-projects route may take an account id as a path parameter');
87
+ assert.doesNotMatch(src, /githubId:\s*(?:req\.body|req\.query)/,
88
+ 'no /my-projects handler may take the account id from the request body or query');
89
+ });
90
+
91
+ // ── 3. the gap that is left, named and tracked ─────────────────────────────
92
+ test('the invitations API still has no caller — the tracked gap, pinned', () => {
93
+ // Deliberately asserts a MISSING feature. Task 1003308 removed the only
94
+ // surface that fetched invitations, so a builder who holds one has nowhere to
95
+ // accept or decline it. Task 1002970 (blocked on task 1002969) and idea
96
+ // 1000903 own the replacement; this pin exists so the gap stays visible until
97
+ // one of them lands, and so it is impossible to close this quietly.
98
+ const surfaces = [];
99
+ const modules = path.join(ROOT, 'modules');
100
+ for (const key of fs.readdirSync(modules)) {
101
+ const pub = path.join(modules, key, 'public');
102
+ if (!fs.existsSync(pub)) continue;
103
+ for (const f of fs.readdirSync(pub)) {
104
+ if (!/\.(js|html)$/.test(f)) continue;
105
+ if (fs.readFileSync(path.join(pub, f), 'utf8').includes('/my-projects/invites')) {
106
+ surfaces.push(`modules/${key}/public/${f}`);
107
+ }
108
+ }
109
+ }
110
+ assert.deepEqual(surfaces, [],
111
+ `an invitations surface exists now (${surfaces.join(', ')}) — good: delete this pin and close task 1002970 / idea 1000903`);
112
+ });