@bongos/core 1.19.900 → 1.19.901

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.900",
6
- "core_contract": "1.19.900",
7
- "source_commit": "b8386b789a905d1c25afd561989ef249965fd27f",
5
+ "core_version": "1.19.901",
6
+ "core_contract": "1.19.901",
7
+ "source_commit": "eaeb90f6f672cebb32a56069fe0f4cdab41c983a",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-20T05:47:20.635Z",
9
+ "built_at": "2026-09-20T06:23:47.726Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 502,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2281,
14
+ "functional_verbatim": 2283,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2809,
20
- "tree_sha256": "4b02605822334dafaf22a01bd8c5d34a16066b4f1afa4b9ee147b59d385f3c3b",
19
+ "file_count": 2811,
20
+ "tree_sha256": "c3fd3f2ffac72933f622b7cb06be2429710627f38d9a3d541cb1ce7f595cdf6a",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2097,12 +2097,12 @@
2097
2097
  {
2098
2098
  "path": "docs/copy-inventory.md",
2099
2099
  "mode": "0000644",
2100
- "sha256": "f1b9049057c56a02af6031f472a2634050753ca1539aa79549f663851db22e54"
2100
+ "sha256": "bee7d1411eaf22aaaf7887b53e1151a0654055f8b4a7f6d78802b1eea766aa71"
2101
2101
  },
2102
2102
  {
2103
2103
  "path": "docs/copy-registry.json",
2104
2104
  "mode": "0000644",
2105
- "sha256": "969894adbb9bc33d4e6f1656aaaa3ba863aba18a5fa7cdce15922916e4d43a6d"
2105
+ "sha256": "120d68ae5ad77ce670704ccd385b14b523ff7a530742b0038788e480084c6e34"
2106
2106
  },
2107
2107
  {
2108
2108
  "path": "docs/design/apex-pass-2-direction.md",
@@ -2632,7 +2632,7 @@
2632
2632
  {
2633
2633
  "path": "docs/module-api-changelog.md",
2634
2634
  "mode": "0000644",
2635
- "sha256": "180322d6ddd7b22d93aa7fada4e8124cc9949afc06b97995a8394c1d472a31be"
2635
+ "sha256": "3ff6099cba4fdc3c31689aa4f007bdb23a8aa5fca100f118eb817e44cf8986c0"
2636
2636
  },
2637
2637
  {
2638
2638
  "path": "docs/modules-contract.md",
@@ -5037,7 +5037,7 @@
5037
5037
  {
5038
5038
  "path": "modules/hall-ui/public/profile.js",
5039
5039
  "mode": "0000644",
5040
- "sha256": "201199cc05841179b975cd46f22a9b82a312a748256733aa758f8fe55bb99aa6"
5040
+ "sha256": "0ca0433fa846d12e2ea2d69084ad406d6e23fbe92e20c7364da4e92e54e26cf4"
5041
5041
  },
5042
5042
  {
5043
5043
  "path": "modules/hall-ui/public/profile.states.json",
@@ -5312,7 +5312,7 @@
5312
5312
  {
5313
5313
  "path": "modules/ideas/descent.js",
5314
5314
  "mode": "0000644",
5315
- "sha256": "b735a1a1c6bf892ca631b1ae8d1bac7643ea9adf39d243ef8a6c27d83451dbca"
5315
+ "sha256": "778558f148d8b403b64f0714b27436b08c41ae0d21ed18b4ed73939928e05cd4"
5316
5316
  },
5317
5317
  {
5318
5318
  "path": "modules/ideas/homeless-home.js",
@@ -5377,7 +5377,7 @@
5377
5377
  {
5378
5378
  "path": "modules/ideas/routes/inbox.js",
5379
5379
  "mode": "0000644",
5380
- "sha256": "0f5997e58ca3f8ee33e84948136697519f095670387f0ae26cadb325f2389268"
5380
+ "sha256": "5324f8d2eb8d55decadd371f2c48ee141e88b9aa3d62de0921d467aef401c2ae"
5381
5381
  },
5382
5382
  {
5383
5383
  "path": "modules/ideas/routing.js",
@@ -5394,6 +5394,11 @@
5394
5394
  "mode": "0000644",
5395
5395
  "sha256": "eade9a2d07da0a39af17c086d188a195be00c544889d61454ed3c6368d39ea04"
5396
5396
  },
5397
+ {
5398
+ "path": "modules/ideas/trail-descent.js",
5399
+ "mode": "0000644",
5400
+ "sha256": "2d0693b00dded081b6a0082c450762763d324d54f098a8f42a9aa25ec27370c4"
5401
+ },
5397
5402
  {
5398
5403
  "path": "modules/lifecycle/CLAUDE.md",
5399
5404
  "mode": "0000644",
@@ -7757,12 +7762,12 @@
7757
7762
  {
7758
7763
  "path": "package-lock.json",
7759
7764
  "mode": "0000644",
7760
- "sha256": "a64d0eb048b0bbaebc11226da0ba59ecf3724630719a4c1e6f598d4e93f7fb64"
7765
+ "sha256": "81176248d1045cd137a823208e30bdf4907b4f08ae1c0c0ff85750a38d2aeed3"
7761
7766
  },
7762
7767
  {
7763
7768
  "path": "package.json",
7764
7769
  "mode": "0000644",
7765
- "sha256": "0e217566767957cb7ec7607c94c10c1e99bf7b5aba1605e886058ce7929b1c2f"
7770
+ "sha256": "bff9286aa9803a2eedaffb0f0798fcd15a27791208c786a6544424aa04db4a16"
7766
7771
  },
7767
7772
  {
7768
7773
  "path": "public-docs/index.html",
@@ -9772,7 +9777,7 @@
9772
9777
  {
9773
9778
  "path": "src/module-api.js",
9774
9779
  "mode": "0000644",
9775
- "sha256": "633c325acecc72b19dc2c6758f4bcd8e5712bd4251681bbd25b1aff4e9e907bd"
9780
+ "sha256": "5ac46b294e25abc7c2a076f538a123991a638f3069dd4980ba2b75392942b710"
9776
9781
  },
9777
9782
  {
9778
9783
  "path": "src/module-loader/catalog.js",
@@ -11722,7 +11727,7 @@
11722
11727
  {
11723
11728
  "path": "tests/helpers.mjs",
11724
11729
  "mode": "0000644",
11725
- "sha256": "d90ec6d640f3a9a389169180d4a852c85e7c3a5336900f93b871673681b8718a"
11730
+ "sha256": "40b3377e8f1e69f5f3465297018c9e148582a23aca85d5ea5802c119620562c6"
11726
11731
  },
11727
11732
  {
11728
11733
  "path": "tests/hierarchy-config.mjs",
@@ -11887,7 +11892,7 @@
11887
11892
  {
11888
11893
  "path": "tests/idea_task_descent.mjs",
11889
11894
  "mode": "0000644",
11890
- "sha256": "3adfccf9381985ad2fbf4e8911ef6cd0fcb66714a13c77de5a8318d185000d66"
11895
+ "sha256": "36674ef87ccf2d10fca66f4999e62ec8c3518798618c1abfc77f3a6dcc9c330f"
11891
11896
  },
11892
11897
  {
11893
11898
  "path": "tests/idea_task_lineage.mjs",
@@ -11904,6 +11909,11 @@
11904
11909
  "mode": "0000644",
11905
11910
  "sha256": "943afa2366f92c4d6188ce9f8d68f499aac8d67930f91d6860a2cb83020642ab"
11906
11911
  },
11912
+ {
11913
+ "path": "tests/idea_trail_descent.mjs",
11914
+ "mode": "0000644",
11915
+ "sha256": "00701012dbdc9c4d9cb2484aeff4426cb1af0a4643bd979211b361b00af80c69"
11916
+ },
11907
11917
  {
11908
11918
  "path": "tests/idea_triage.mjs",
11909
11919
  "mode": "0000644",
@@ -12577,7 +12587,7 @@
12577
12587
  {
12578
12588
  "path": "tests/profile_activity.mjs",
12579
12589
  "mode": "0000644",
12580
- "sha256": "840aad045289d95ef44c011934e782da23e5ba687d4b2163da99a54062bbc676"
12590
+ "sha256": "483d249339dd696809a4d9ac53aa10d7b67a35ce064884ef5590cc7249418047"
12581
12591
  },
12582
12592
  {
12583
12593
  "path": "tests/profile_connections_ui.mjs",
@@ -4,13 +4,13 @@
4
4
  > Deterministic on a given tree — regenerate and commit rather than hand-editing.
5
5
  > Machine-readable twin: [`copy-registry.json`](copy-registry.json).
6
6
 
7
- **1826 strings** across 3 surfaces · 1584 distinct pieces of copy · 1826 certain, 0 heuristic · 166 shown in more than one place.
7
+ **1827 strings** across 3 surfaces · 1585 distinct pieces of copy · 1827 certain, 0 heuristic · 166 shown in more than one place.
8
8
 
9
9
  ## Surfaces
10
10
 
11
11
  | surface | lives at | files | strings |
12
12
  |---|---|---|---|
13
- | `builders-hall` | `modules/hall-ui/public` | 84 | 1336 |
13
+ | `builders-hall` | `modules/hall-ui/public` | 84 | 1337 |
14
14
  | `status-dashboard` | `modules/status-ui/public` | 4 | 100 |
15
15
  | `landing` | `modules/public-landing/public` | 5 | 390 |
16
16
 
@@ -78,7 +78,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
78
78
  | `996edffc1966` | 2x | Access requests | modules/hall-ui/public/watch.html:46, modules/hall-ui/public/watch.html:61 |
79
79
  | … | | _126 more in the JSON registry_ | |
80
80
 
81
- ## `builders-hall` — 1336 strings
81
+ ## `builders-hall` — 1337 strings
82
82
 
83
83
  | id | conf | text | where |
84
84
  |---|---|---|---|
@@ -309,7 +309,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
309
309
  | `7775fec0fa85` | certain | Could not load sessions. | `modules/hall-ui/public/sessions.js:437` |
310
310
  | `a2f2e8babef5` | certain | Could not load that page. Try again. | `modules/hall-ui/public/blockers.js:274` |
311
311
  | `b2147cc90e36` | certain | Could not load the idea template. Reload, or file from the terminal with capture.js. | `modules/hall-ui/public/ideas.js:626` |
312
- | `7604af13c40e` | certain | Could not load the profile. | `modules/hall-ui/public/profile.js:790` |
312
+ | `7604af13c40e` | certain | Could not load the profile. | `modules/hall-ui/public/profile.js:836` |
313
313
  | `8b69e445592a` | certain | Could not load the question labels; showing the answers as stored. | `modules/hall-ui/public/idea.js:244` |
314
314
  | `edd5bafbe075` | certain | Could not load the queue just now. | `modules/hall-ui/public/ideas.js:565` |
315
315
  | `02bb0570996a` | certain | Could not load the ranks. | `modules/hall-ui/public/ranks.js:197` |
@@ -601,7 +601,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
601
601
  | `6e73716aec6c` | certain | May be offered for sale later | `modules/hall-ui/public/settings.html:297` |
602
602
  | `df335a1224ae` | certain | Member since {…} | `modules/hall-ui/public/hall-render.js:563` |
603
603
  | `df335a1224ae` | certain | Member since {…} | `modules/hall-ui/public/profile.js:94` |
604
- | `c7a670e9552d` | certain | Merged into | `modules/hall-ui/public/profile.js:406` |
604
+ | `c7a670e9552d` | certain | Merged into | `modules/hall-ui/public/profile.js:452` |
605
605
  | `dce0546c9e34` | certain | Merging work does not put it in front of anyone. This is the step that does: it moves this platform — or any p | `modules/hall-ui/public/deploy.html:39` |
606
606
  | `7637613c231b` | certain | Metic rank and above may vote | `modules/hall-ui/public/builders.js:781` |
607
607
  | `91328a97fe68` | certain | Mingle pairs you with someone from another role. Saying no costs nothing. | `modules/hall-ui/public/collab.html:87` |
@@ -919,7 +919,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
919
919
  | `9d4372b173cf` | certain | Standing sections | `modules/hall-ui/public/index.html:79` |
920
920
  | `ec1c2dae4a90` | certain | Standing there | `modules/hall-ui/public/profile.js:314` |
921
921
  | `eaad12cacb9d` | certain | Start over | `modules/hall-ui/public/studio.js:235` |
922
- | `efe530c0e3a7` | certain | Still open | `modules/hall-ui/public/profile.js:411` |
922
+ | `efe530c0e3a7` | certain | Still open | `modules/hall-ui/public/profile.js:457` |
923
923
  | `9a4ced1afb6c` | certain | Stop waiting on task {…} | `modules/hall-ui/public/task.js:278` |
924
924
  | `b0861c874a2f` | certain | Stopped using it. | `modules/hall-ui/public/settings-specialities.js:200` |
925
925
  | `6c6544f5c011` | certain | Stored encrypted; never shown again after saving — only its last four digits. | `modules/hall-ui/public/settings.html:193` |
@@ -1180,6 +1180,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
1180
1180
  | `bc960f460250` | certain | and install dependencies ( | `modules/hall-ui/public/hall-render.js:864` |
1181
1181
  | `2177da93be9b` | certain | and leave this alone. | `modules/hall-ui/public/settings.html:339` |
1182
1182
  | `b867876e8b6c` | certain | and press Enter. | `modules/hall-ui/public/hall-render.js:825` |
1183
+ | `0257cb508ae7` | certain | and {…} more below this thought | `modules/hall-ui/public/profile.js:415` |
1183
1184
  | `d86265f99668` | certain | and {…} more waiting{…} | `modules/hall-ui/public/studio.js:277` |
1184
1185
  | `520853131591` | certain | artist mode | `modules/hall-ui/public/studio.html:36` |
1185
1186
  | `b4033000c537` | certain | as a table | `modules/hall-ui/public/profile-charts.js:205` |
@@ -1352,7 +1353,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
1352
1353
  | `cc5106af4b35` | certain | {…} of {…} criteria satisfied | `modules/hall-ui/public/goals-lib.js:32` |
1353
1354
  | `cc5106af4b35` | certain | {…} of {…} criteria satisfied | `modules/hall-ui/public/goals-render.js:682` |
1354
1355
  | `cc5106af4b35` | certain | {…} of {…} criteria satisfied | `modules/hall-ui/public/goals.js:45` |
1355
- | `c3d9687e2375` | certain | {…} of {…} shipped | `modules/hall-ui/public/profile.js:512` |
1356
+ | `c3d9687e2375` | certain | {…} of {…} shipped | `modules/hall-ui/public/profile.js:558` |
1356
1357
  | `f32710d0ef37` | certain | {…} of {…} steps cleared. | `modules/hall-ui/public/hall-render.js:1037` |
1357
1358
  | `60743fe1e40d` | certain | {…} per page | `modules/hall-ui/public/hall-kit.js:74` |
1358
1359
  | `154bc518bab4` | certain | {…} proposed | `modules/hall-ui/public/government.js:695` |
@@ -3,9 +3,9 @@
3
3
  "generator": "scripts/gds/copy-inventory.js",
4
4
  "goal": 1000074,
5
5
  "counts": {
6
- "strings": 1826,
7
- "unique_ids": 1584,
8
- "certain": 1826,
6
+ "strings": 1827,
7
+ "unique_ids": 1585,
8
+ "certain": 1827,
9
9
  "heuristic": 0,
10
10
  "duplicate_ids": 166
11
11
  },
@@ -13,7 +13,7 @@
13
13
  "builders-hall": {
14
14
  "at": "modules/hall-ui/public",
15
15
  "files": 84,
16
- "strings": 1336
16
+ "strings": 1337
17
17
  },
18
18
  "status-dashboard": {
19
19
  "at": "modules/status-ui/public",
@@ -3824,7 +3824,7 @@
3824
3824
  "surface": "builders-hall",
3825
3825
  "text": "Could not load the profile.",
3826
3826
  "file": "modules/hall-ui/public/profile.js",
3827
- "line": 790,
3827
+ "line": 836,
3828
3828
  "origin": "js-notice",
3829
3829
  "confidence": "certain"
3830
3830
  },
@@ -6452,7 +6452,7 @@
6452
6452
  "surface": "builders-hall",
6453
6453
  "text": "Merged into",
6454
6454
  "file": "modules/hall-ui/public/profile.js",
6455
- "line": 406,
6455
+ "line": 452,
6456
6456
  "origin": "js-markup",
6457
6457
  "confidence": "certain"
6458
6458
  },
@@ -9314,7 +9314,7 @@
9314
9314
  "surface": "builders-hall",
9315
9315
  "text": "Still open",
9316
9316
  "file": "modules/hall-ui/public/profile.js",
9317
- "line": 411,
9317
+ "line": 457,
9318
9318
  "origin": "js-markup",
9319
9319
  "confidence": "certain"
9320
9320
  },
@@ -11658,6 +11658,15 @@
11658
11658
  "origin": "js-markup",
11659
11659
  "confidence": "certain"
11660
11660
  },
11661
+ {
11662
+ "id": "0257cb508ae7",
11663
+ "surface": "builders-hall",
11664
+ "text": "and {…} more below this thought",
11665
+ "file": "modules/hall-ui/public/profile.js",
11666
+ "line": 415,
11667
+ "origin": "js-markup",
11668
+ "confidence": "certain"
11669
+ },
11661
11670
  {
11662
11671
  "id": "d86265f99668",
11663
11672
  "surface": "builders-hall",
@@ -13211,7 +13220,7 @@
13211
13220
  "surface": "builders-hall",
13212
13221
  "text": "{…} of {…} shipped",
13213
13222
  "file": "modules/hall-ui/public/profile.js",
13214
- "line": 512,
13223
+ "line": 558,
13215
13224
  "origin": "js-markup",
13216
13225
  "confidence": "certain"
13217
13226
  },
@@ -2274,5 +2274,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2274
2274
  landed since 1.19.898 with no explicit bump. run 35488237998. (task 1002620)
2275
2275
  1.19.900 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2276
2276
  landed since 1.19.899 with no explicit bump. run 35492534307. (task 1002620)
2277
+ 1.19.901 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2278
+ landed since 1.19.900 with no explicit bump. run 35494090938. (task 1002620)
2277
2279
  ---------------------------------------------------------------------------
2278
2280
  ```
@@ -378,9 +378,55 @@
378
378
  const ideaHref = (id) => href(`${hallBase()}/idea/${encodeURIComponent(id)}`);
379
379
  const goalHref = (id) => href(`${hallBase()}/goals#/goal/${encodeURIComponent(id)}`);
380
380
 
381
+ // THE DESCENT — everything an idea's tasks went on to cause, at any depth and
382
+ // across version boundaries (task 1004089, BV2.R09; ADR 0312). Served as
383
+ // `descent_tasks`, already ordered nearest-first by the server, each entry at
384
+ // its shortest depth from one of the idea's own tasks.
385
+ //
386
+ // WHY IT RENDERS HERE, under the outcome and not beside it. The outcome line
387
+ // answers "what became of my idea" with the FIRST thing it became, which is
388
+ // all the trail could ever say while the read was one hop. The descent is the
389
+ // rest of that sentence, so it belongs in the same cell, subordinate to it.
390
+ //
391
+ // CAPPED, and the cap says so. The whole descent is the idea page's job
392
+ // (BV2.R10); a ledger row that paints forty tasks stops being a row. The
393
+ // overflow is a LINK to that page, never a silent truncation.
394
+ const DESCENT_SHOWN = 6;
395
+ function descentHtml(idea) {
396
+ const rows = Array.isArray(idea.descent_tasks) ? idea.descent_tasks : [];
397
+ if (!rows.length) return '';
398
+ const cells = rows.slice(0, DESCENT_SHOWN).map((t) => {
399
+ // The version is printed rather than compared away. A descendant living
400
+ // in a LATER version is the interesting case — it is the proof that an
401
+ // origin tag survives a version boundary — so it is labelled, not
402
+ // filtered and not silently flattened into the idea's own version.
403
+ const meta = [
404
+ `${escapeHtml(String(t.relation || 'descends'))}`,
405
+ `depth ${escapeHtml(String(t.depth))}`,
406
+ t.version_id ? escapeHtml(String(t.version_id)) : null,
407
+ ].filter(Boolean).join(' · ');
408
+ return `<span style="display:block;margin-top:2px">`
409
+ + `<a href="${taskHref(t.task_id)}">#${escapeHtml(String(t.task_id))} ${escapeHtml(t.title || '')}</a>`
410
+ + ` <span style="font-size:11px;color:var(--ink-faint)">${meta}</span>`
411
+ + `</span>`;
412
+ }).join('');
413
+ const rest = rows.length - DESCENT_SHOWN;
414
+ const more = rest > 0
415
+ ? `<span style="display:block;margin-top:2px;font-size:12px"><a href="${ideaHref(idea.id)}">and ${escapeHtml(String(rest))} more below this thought</a></span>`
416
+ : '';
417
+ return `<span style="display:block;margin-top:4px;font-size:12px;color:var(--ink-faint)">`
418
+ + `Then caused ${escapeHtml(String(rows.length))} ${rows.length === 1 ? 'task' : 'tasks'}</span>`
419
+ + cells + more;
420
+ }
421
+
381
422
  // What became of one idea, in a sentence a person can read. The four outcomes
382
423
  // the criterion names, each linked through to the thing it became.
383
424
  function ideaOutcomeHtml(idea) {
425
+ // The descent rides under EVERY outcome, not just a promotion: a merged
426
+ // idea inherits the descent of the idea it merged into (that is what
427
+ // "nothing is lost" means), and an idea whose first task was later split
428
+ // has a descent whatever its own status says.
429
+ const descent = descentHtml(idea);
384
430
  const note = idea.triage_note
385
431
  ? `<span style="display:block;margin-top:2px;font-size:12px;color:var(--ink-faint)">${escapeHtml(idea.triage_note)}</span>`
386
432
  : '';
@@ -396,19 +442,19 @@
396
442
  : []);
397
443
  // 'promoted' with nothing to point at is a real (old) state, not a bug to
398
444
  // paper over — say what is known rather than inventing a link.
399
- if (!links.length) return `<span class="task__tag">Promoted</span>${note}`;
400
- return `<span class="task__tag">Became ${links.length === 1 ? 'a task' : `${links.length} tasks`}</span> ${links.join(' · ')}${note}`;
445
+ if (!links.length) return `<span class="task__tag">Promoted</span>${note}${descent}`;
446
+ return `<span class="task__tag">Became ${links.length === 1 ? 'a task' : `${links.length} tasks`}</span> ${links.join(' · ')}${note}${descent}`;
401
447
  }
402
448
  if (idea.status === 'merged') {
403
449
  const target = idea.merged_into_idea_id
404
450
  ? ` <a href="${ideaHref(idea.merged_into_idea_id)}">#${escapeHtml(String(idea.merged_into_idea_id))} ${escapeHtml(idea.merged_into_idea_title || '')}</a>`
405
451
  : '';
406
- return `<span class="task__tag">Merged into</span>${target}${note}`;
452
+ return `<span class="task__tag">Merged into</span>${target}${note}${descent}`;
407
453
  }
408
454
  if (idea.status === 'discarded') {
409
- return `<span class="task__tag">Discarded</span>${note}`;
455
+ return `<span class="task__tag">Discarded</span>${note}${descent}`;
410
456
  }
411
- return `<span class="task__tag">Still open</span>${note}`;
457
+ return `<span class="task__tag">Still open</span>${note}${descent}`;
412
458
  }
413
459
 
414
460
  // One ledger driven by one server-paged endpoint. `fetchPage(offset, perPage)`
@@ -22,8 +22,94 @@ const DESCENT_MAX_DEPTH = 64;
22
22
 
23
23
  const DESCENT_RELATIONS = Object.freeze(['rewrite', 'split', 'merge', 'fold', 'cascade']);
24
24
 
25
- // walk — breadth-first over the edge table, one query PER LEVEL.
25
+ // ── the walk: one implementation, batched and single-root ─────────────────
26
+ // walkMany — a breadth-first walk from MANY roots at once: ONE query per LEVEL
27
+ // for the whole set, rather than one walk per root.
26
28
  //
29
+ // WHY THE BATCH IS THE POINT OF THIS FUNCTION (task 1004089, BV2.R09). The
30
+ // descent is read for a PAGE of ideas, not for one. Walking each root on its
31
+ // own costs (roots x depth) round trips, which is the 4-6 serial round trips
32
+ // per read that sank the previous attempt at this read. Batched, the cost is
33
+ // depth round trips for the whole page — INDEPENDENT of how many ideas the page
34
+ // carries, which is the property tests/idea_trail_descent.mjs pins.
35
+ //
36
+ // WHY NOT A RECURSIVE CTE, which collapses it to literally one query: the walk
37
+ // comment above says it, and nothing about batching changes that argument. A
38
+ // CTE re-expands every path, so a convergent DAG costs work exponential in
39
+ // depth; here the whole page costs at most one query per level whatever the
40
+ // shape. One query per level is the floor a walk that must not blow up can
41
+ // reach, and this is that floor.
42
+ //
43
+ // PER-ROOT ATTRIBUTION is why the row carries `from_id`. The union of every
44
+ // root's frontier goes to the database in one array, so a row coming back has
45
+ // to say which frontier node produced it or the answer cannot be split back
46
+ // apart. Depth is per-root and must stay that way: the payout decays by the
47
+ // depth from THAT origin, so one shared answer would pay the wrong rate.
48
+ //
49
+ // Returns Map<rootKey, rows> — rootKey is String(rootId), rows exactly as the
50
+ // single-root walk returns them.
51
+ async function walkMany(startIds, direction, { maxDepth = DESCENT_MAX_DEPTH, client = null } = {}) {
52
+ const q = client || pool;
53
+ // Two literal statements rather than one with an interpolated column name:
54
+ // nothing here is caller-controlled, and a query built by concatenation is a
55
+ // shape a reader (and a scanner) has to stop and prove safe.
56
+ const SQL_UP = `SELECT parent_task_id AS id, task_id AS from_id, relation
57
+ FROM ideas_task_descent
58
+ WHERE task_id = ANY($1::bigint[])`;
59
+ const SQL_DOWN = `SELECT task_id AS id, parent_task_id AS from_id, relation
60
+ FROM ideas_task_descent
61
+ WHERE parent_task_id = ANY($1::bigint[])`;
62
+ const sql = direction === 'up' ? SQL_UP : SQL_DOWN;
63
+
64
+ // A root that is not a positive integer is dropped here rather than sent to
65
+ // the database as part of a bigint[]. linkTaskDescent already rejects a bad
66
+ // id by name before it ever walks; this is the second line, for the readers
67
+ // that take their roots from a row rather than from an argument.
68
+ const roots = [...new Set((startIds || []).map((r) => String(r)))].filter((r) => /^[1-9]\d*$/.test(r));
69
+
70
+ // One independent walk state per root, stepped in lockstep so that every
71
+ // root's frontier is expanded by the same query.
72
+ const state = new Map();
73
+ for (const r of roots) {
74
+ state.set(r, { found: new Map(), visited: new Set([r]), frontier: [Number(r)] });
75
+ }
76
+
77
+ for (let depth = 1; depth <= maxDepth; depth += 1) {
78
+ const union = new Set();
79
+ for (const s of state.values()) for (const n of s.frontier) union.add(n);
80
+ if (union.size === 0) break;
81
+ const { rows } = await q.query(sql, [[...union]]);
82
+ // from_id -> the edges it produced, so each root can expand only the nodes
83
+ // that were actually on ITS frontier.
84
+ const adjacency = new Map();
85
+ for (const row of rows) {
86
+ const from = String(row.from_id);
87
+ if (!adjacency.has(from)) adjacency.set(from, []);
88
+ adjacency.get(from).push(row);
89
+ }
90
+ for (const s of state.values()) {
91
+ const next = [];
92
+ for (const node of s.frontier) {
93
+ for (const edge of adjacency.get(String(node)) || []) {
94
+ const key = String(edge.id);
95
+ if (s.visited.has(key)) continue; // already reached at a shallower depth
96
+ s.visited.add(key);
97
+ s.found.set(key, { id: edge.id, depth, relation: edge.relation });
98
+ next.push(Number(edge.id));
99
+ }
100
+ }
101
+ s.frontier = next;
102
+ }
103
+ }
104
+
105
+ const out = new Map();
106
+ for (const [root, s] of state) {
107
+ out.set(root, [...s.found.values()].sort((a, b) => a.depth - b.depth || Number(a.id) - Number(b.id)));
108
+ }
109
+ return out;
110
+ }
111
+
112
+ // Both of the above are breadth-first over the edge table, one query PER LEVEL.
27
113
  // WHY NOT A RECURSIVE CTE, which is the obvious shape and was the first cut.
28
114
  // A recursive CTE's working table re-expands every path independently, so a
29
115
  // densely-connected DAG — paths that converge and diverge again, which is
@@ -43,37 +129,13 @@ const DESCENT_RELATIONS = Object.freeze(['rewrite', 'split', 'merge', 'fold', 'c
43
129
  // The cost is one round trip per level instead of one per walk. Depth decay
44
130
  // halves each hop (spec decision 16), so a descent deep enough for that to
45
131
  // matter is already worth almost nothing.
46
- async function walk(startId, direction, { maxDepth = DESCENT_MAX_DEPTH, client = null } = {}) {
47
- const q = client || pool;
48
- // Two literal statements rather than one with an interpolated column name:
49
- // nothing here is caller-controlled, and a query built by concatenation is a
50
- // shape a reader (and a scanner) has to stop and prove safe.
51
- const SQL_UP = `SELECT parent_task_id AS id, relation
52
- FROM ideas_task_descent
53
- WHERE task_id = ANY($1::bigint[])`;
54
- const SQL_DOWN = `SELECT task_id AS id, relation
55
- FROM ideas_task_descent
56
- WHERE parent_task_id = ANY($1::bigint[])`;
57
- const sql = direction === 'up' ? SQL_UP : SQL_DOWN;
58
-
59
- const found = new Map();
60
- const visited = new Set([String(startId)]);
61
- let frontier = [Number(startId)];
62
-
63
- for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth += 1) {
64
- const { rows } = await q.query(sql, [frontier]);
65
- const next = [];
66
- for (const r of rows) {
67
- const key = String(r.id);
68
- if (visited.has(key)) continue; // already reached at a shallower depth
69
- visited.add(key);
70
- found.set(key, { id: r.id, depth, relation: r.relation });
71
- next.push(Number(r.id));
72
- }
73
- frontier = next;
74
- }
75
-
76
- return [...found.values()].sort((a, b) => a.depth - b.depth || Number(a.id) - Number(b.id));
132
+ // walk — the single-root walk, which is walkMany with one root. ONE
133
+ // implementation on purpose: the payout (R11) walks up and the descent surface
134
+ // (R10) walks down, and a second copy of the de-duplication would be free to
135
+ // drift from this one — a drift that shows up as money, not as a render bug.
136
+ async function walk(startId, direction, opts = {}) {
137
+ const byRoot = await walkMany([startId], direction, opts);
138
+ return byRoot.get(String(startId)) || [];
77
139
  }
78
140
 
79
141
  // ancestorsOfTask — every task this one descends from, nearest first, each at
@@ -87,6 +149,14 @@ function descendantsOfTask(taskId, opts = {}) {
87
149
  return walk(taskId, 'down', opts);
88
150
  }
89
151
 
152
+ // descendantsOfTasks — the descent below SEVERAL roots, in one batch. What the
153
+ // trail read (modules/ideas/trail-descent.js) consumes: a page of ideas has a
154
+ // root task per promotion, and the whole page must cost one query per level,
155
+ // not one walk per idea. Returns Map<String(rootId), rows>.
156
+ function descendantsOfTasks(taskIds, opts = {}) {
157
+ return walkMany(taskIds, 'down', opts);
158
+ }
159
+
90
160
  // linkTaskDescent — record that `taskId` came out of `parentTaskId`.
91
161
  //
92
162
  // Idempotent on the pair, because the derivation below runs on every ship and
@@ -209,6 +279,7 @@ module.exports = {
209
279
  linkTaskDescent,
210
280
  ancestorsOfTask,
211
281
  descendantsOfTask,
282
+ descendantsOfTasks,
212
283
  deriveEdgesForTask,
213
284
  syncDescentForTask,
214
285
  };
@@ -35,6 +35,10 @@
35
35
  const express = require('express');
36
36
  const api = require('../../../src/module-api');
37
37
  const inbox = require('../inbox');
38
+ // The trail read to the FULL depth of the descent (task 1004089, BV2.R09). Its
39
+ // own file because inbox.js is AT the size ratchet and because a task->task
40
+ // walk is a different relation from the idea->task trail.
41
+ const { attachDescentToTrail } = require('../trail-descent');
38
42
  // Full Idea capture (task 1002919 / BV1.R143): the template validator (R141)
39
43
  // and the deterministic scorer (R142) — both pure, both walk the declaration.
40
44
  const { validateFieldValues, templateFor, fieldOrder } = require('../templates');
@@ -552,6 +556,12 @@ module.exports = function buildInboxRouter() {
552
556
  inbox.listIdeaTrailForBuilder(builderId, { limit, offset, viewerRank }),
553
557
  inbox.countIdeaTrailForBuilder(builderId, { viewerRank }),
554
558
  ]);
559
+ // Everything those tasks went on to cause, to any depth and across version
560
+ // boundaries (BV2.R09, ADR 0312). Composed here rather than inside the trail
561
+ // read: the descent is a task->task walk, the trail is an idea->task read,
562
+ // and the walk is batched across the WHOLE page — so it stays one query per
563
+ // level however many ideas the window carries, never one walk per idea.
564
+ await attachDescentToTrail(ideas);
555
565
  return res.json({ builder_id: builderId, ideas, total, limit, offset });
556
566
  }, { errorCode: 'idea_trail_failed' }));
557
567