@bongos/core 1.19.714 → 1.19.715

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.714",
6
- "core_contract": "1.19.714",
7
- "source_commit": "a5a66a5abc084908ffbcb70583aa9effa9bc86d7",
5
+ "core_version": "1.19.715",
6
+ "core_contract": "1.19.715",
7
+ "source_commit": "b62e25ab61b1f2c4994e6b53ae9958cd34596927",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-13T07:48:24.895Z",
9
+ "built_at": "2026-09-13T08:14:55.124Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 478,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2147,
14
+ "functional_verbatim": 2149,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2649,
20
- "tree_sha256": "51e98dc1aa5041914570019a3903f1b1c0cce050fff1b530c41da04241a719a7",
19
+ "file_count": 2651,
20
+ "tree_sha256": "7ec11a13e61036eaa680d8780521289c206117336ca95013e22bddde2dc83a1f",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -1957,12 +1957,12 @@
1957
1957
  {
1958
1958
  "path": "docs/copy-inventory.md",
1959
1959
  "mode": "0000644",
1960
- "sha256": "ab5c4798a75b2278e0fefd99d80cb5e5ce2be07034cc95f14d35abe1a458e2cf"
1960
+ "sha256": "8a54b0082d0eddeb63878440f6b42fb0a701e65284c54b7e4475a0685e943c88"
1961
1961
  },
1962
1962
  {
1963
1963
  "path": "docs/copy-registry.json",
1964
1964
  "mode": "0000644",
1965
- "sha256": "708114251e3317f48d581da8ab3df316a5a6e4300afb87749296cfa8f767fc7b"
1965
+ "sha256": "fe218fd29cc8f03ceb3ff3ea8dd2bcb36df81a75a73bcdfe73e7af90fa6e3ad0"
1966
1966
  },
1967
1967
  {
1968
1968
  "path": "docs/design/apex-pass-2-direction.md",
@@ -2807,7 +2807,7 @@
2807
2807
  {
2808
2808
  "path": "docs/module-api-changelog.md",
2809
2809
  "mode": "0000644",
2810
- "sha256": "3484277ad5363b99cffe978b168fcdadd40051cb9fc45c51a2aab58dd43fe601"
2810
+ "sha256": "b057c7c856c96a08e25c433ae816ac6ff9762da2f510ee74defdefbf6151ca14"
2811
2811
  },
2812
2812
  {
2813
2813
  "path": "docs/modules-contract.md",
@@ -5522,7 +5522,7 @@
5522
5522
  {
5523
5523
  "path": "modules/hall-ui/public/watch.css",
5524
5524
  "mode": "0000644",
5525
- "sha256": "ea2f7870bdd9383cfded8b2b09724486faa0793438dfa17bc52af189c8a81c6f"
5525
+ "sha256": "f11f73ec3649c7547cb59b7d5fdf090a1e52ad96f6fb28bdb3b5a14691153de1"
5526
5526
  },
5527
5527
  {
5528
5528
  "path": "modules/hall-ui/public/watch.html",
@@ -5532,7 +5532,7 @@
5532
5532
  {
5533
5533
  "path": "modules/hall-ui/public/watch.js",
5534
5534
  "mode": "0000644",
5535
- "sha256": "e6808a692963759ca0942bcf28f4ee1479ba0b0ef727c2ace010bbcf5159043c"
5535
+ "sha256": "671cce72893d85971cc81ed7a64f7c3796bcb1f3bbdf7b28ab68e7e4e1d5b11c"
5536
5536
  },
5537
5537
  {
5538
5538
  "path": "modules/hall-ui/public/watch.states.json",
@@ -7817,12 +7817,12 @@
7817
7817
  {
7818
7818
  "path": "package-lock.json",
7819
7819
  "mode": "0000644",
7820
- "sha256": "732fe7dc9bc6b9c48aabb5fb6dcf35aa9a71f50fcf16d6548e183a1092c40853"
7820
+ "sha256": "76f1c9e8c1f6a12a9857eff4b538e700c337252afd4ecb40536088a7c967f81b"
7821
7821
  },
7822
7822
  {
7823
7823
  "path": "package.json",
7824
7824
  "mode": "0000644",
7825
- "sha256": "6543f1d328faab7d974585c1ad7f0f1b79ba653563bf5d75b7948f01501ce322"
7825
+ "sha256": "c65aae0250d7c1c324a3355a81d0bb5004a1bf7b339e0c6b69f819640e33eac4"
7826
7826
  },
7827
7827
  {
7828
7828
  "path": "public-docs/index.html",
@@ -7982,7 +7982,7 @@
7982
7982
  {
7983
7983
  "path": "scripts/gds/backfill-task-rewards.js",
7984
7984
  "mode": "0000644",
7985
- "sha256": "a9f1064fbb683297e775c455ef65249cf809018a38a5aa2f975a62cbba16cd9c"
7985
+ "sha256": "6328a73f8ce38754280d8f7b6234c0eea83c5e5061534550f44b0f7d78b12224"
7986
7986
  },
7987
7987
  {
7988
7988
  "path": "scripts/gds/backlog-review.js",
@@ -9602,7 +9602,7 @@
9602
9602
  {
9603
9603
  "path": "src/module-api.js",
9604
9604
  "mode": "0000644",
9605
- "sha256": "e836253fa5b1c1b117c308430b19b2129a19904b21371ad2c033f0613951d0c2"
9605
+ "sha256": "b0482a0410f077b4c9a41a19cf85a46dc527a20d7a897023cddff8d89787e39f"
9606
9606
  },
9607
9607
  {
9608
9608
  "path": "src/module-loader/catalog.js",
@@ -10384,6 +10384,11 @@
10384
10384
  "mode": "0000644",
10385
10385
  "sha256": "d7c96a67f1eda974a9387a47d12bf70d7f2125ea77c1ddb313e5a3feb47ae04f"
10386
10386
  },
10387
+ {
10388
+ "path": "tests/credit_log_single_writer.mjs",
10389
+ "mode": "0000644",
10390
+ "sha256": "e1ce127d39f8f6aad3f819ab828d66de3d211484174849b1a0ce6a6ab7546b36"
10391
+ },
10387
10392
  {
10388
10393
  "path": "tests/criterion_authoring_gate.mjs",
10389
10394
  "mode": "0000644",
@@ -11312,7 +11317,7 @@
11312
11317
  {
11313
11318
  "path": "tests/helpers.mjs",
11314
11319
  "mode": "0000644",
11315
- "sha256": "6a2360675b70ff2b32ca514e2baafa2aecb10e397a2f88630928ee93b351b34b"
11320
+ "sha256": "2c5ffa3ed447fdbbbc1b44e553f493bf05ab2848b154836defe3fe40e1909592"
11316
11321
  },
11317
11322
  {
11318
11323
  "path": "tests/hierarchy-config.mjs",
@@ -13199,6 +13204,11 @@
13199
13204
  "mode": "0000644",
13200
13205
  "sha256": "bca521caccef7c1c147bfa678203369b246f786d4d8cb5a485f15a9d11f925ba"
13201
13206
  },
13207
+ {
13208
+ "path": "tests/watch_roster_self_row.mjs",
13209
+ "mode": "0000644",
13210
+ "sha256": "a582a7dc1490038b9d5fed9551b00d6cc1ded2758f06d4c2f21c36e4aea12a9c"
13211
+ },
13202
13212
  {
13203
13213
  "path": "tests/watch_sealed_floor.mjs",
13204
13214
  "mode": "0000644",
@@ -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
- **1749 strings** across 3 surfaces · 1513 distinct pieces of copy · 1749 certain, 0 heuristic · 164 shown in more than one place.
7
+ **1751 strings** across 3 surfaces · 1515 distinct pieces of copy · 1751 certain, 0 heuristic · 164 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` | 80 | 1270 |
13
+ | `builders-hall` | `modules/hall-ui/public` | 80 | 1272 |
14
14
  | `status-dashboard` | `modules/status-ui/public` | 4 | 100 |
15
15
  | `landing` | `modules/public-landing/public` | 5 | 379 |
16
16
 
@@ -78,7 +78,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
78
78
  | `a072071b893c` | 2x | All time | modules/status-ui/public/index.html:286, modules/status-ui/public/index.html:313 |
79
79
  | … | | _124 more in the JSON registry_ | |
80
80
 
81
- ## `builders-hall` — 1270 strings
81
+ ## `builders-hall` — 1272 strings
82
82
 
83
83
  | id | conf | text | where |
84
84
  |---|---|---|---|
@@ -285,8 +285,8 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
285
285
  | `7bcaeffb742f` | certain | Could not approve: {…} | `modules/hall-ui/public/gate.js:108` |
286
286
  | `c922293e668c` | certain | Could not assign: {…} | `modules/hall-ui/public/government.js:468` |
287
287
  | `94763c4d5a94` | certain | Could not block: {…} | `modules/hall-ui/public/harbor.js:167` |
288
- | `e1fc7cedf4e7` | certain | Could not change rank: {…} | `modules/hall-ui/public/watch.js:819` |
289
- | `b9e53fbd90e6` | certain | Could not change standing: {…} | `modules/hall-ui/public/watch.js:845` |
288
+ | `e1fc7cedf4e7` | certain | Could not change rank: {…} | `modules/hall-ui/public/watch.js:860` |
289
+ | `b9e53fbd90e6` | certain | Could not change standing: {…} | `modules/hall-ui/public/watch.js:886` |
290
290
  | `1ef1efc15b6c` | certain | Could not change that just now. | `modules/hall-ui/public/goals-render.js:1235` |
291
291
  | `78e18ea59b9f` | certain | Could not close: {…} | `modules/hall-ui/public/harbor.js:136` |
292
292
  | `c3f99e7c6e6b` | certain | Could not copy automatically — select and copy manually. ({…}) | `modules/hall-ui/public/settings.js:1017` |
@@ -310,7 +310,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
310
310
  | `8b69e445592a` | certain | Could not load the question labels; showing the answers as stored. | `modules/hall-ui/public/idea.js:244` |
311
311
  | `edd5bafbe075` | certain | Could not load the queue just now. | `modules/hall-ui/public/ideas.js:565` |
312
312
  | `02bb0570996a` | certain | Could not load the ranks. | `modules/hall-ui/public/ranks.js:197` |
313
- | `2878778cf9f3` | certain | Could not load this page. | `modules/hall-ui/public/watch.js:1311` |
313
+ | `2878778cf9f3` | certain | Could not load this page. | `modules/hall-ui/public/watch.js:1357` |
314
314
  | `4b0c8d94541c` | certain | Could not load your account. Reload the page to try again. | `modules/hall-ui/public/settings.js:1396` |
315
315
  | `2a9fb4046bc4` | certain | Could not load your rank. Return to the hall and try again. | `modules/hall-ui/public/ranks.js:196` |
316
316
  | `9d533e811dec` | certain | Could not load your sessions: {…} | `modules/hall-ui/public/settings-sessions.js:114` |
@@ -348,7 +348,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
348
348
  | `d4601887a058` | certain | Could not sign out everywhere: {…} | `modules/hall-ui/public/settings-sessions.js:102` |
349
349
  | `b1602d01f4bb` | certain | Could not unblock: {…} | `modules/hall-ui/public/harbor.js:187` |
350
350
  | `3db890b074a7` | certain | Could not update visibility just now. | `modules/hall-ui/public/goals-render.js:1219` |
351
- | `112d12b24879` | certain | Could not verify your access. Go back and try again. | `modules/hall-ui/public/watch.js:1310` |
351
+ | `112d12b24879` | certain | Could not verify your access. Go back and try again. | `modules/hall-ui/public/watch.js:1356` |
352
352
  | `6bd74fd20311` | certain | Could not verify your access. Try again. | `modules/hall-ui/public/sessions.js:280` |
353
353
  | `278e8281c82f` | certain | Could not vote: {…} | `modules/hall-ui/public/board-room.js:304` |
354
354
  | `a48e9f098392` | certain | Could not {…} just now. | `modules/hall-ui/public/goals-render.js:1205` |
@@ -691,7 +691,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
691
691
  | `d9c802f5d85e` | certain | Opening the studio… | `modules/hall-ui/public/studio.html:45` |
692
692
  | `569980a263ae` | certain | Optional. This is the ledger link — a copy change ships as a task like every other change. | `modules/hall-ui/public/copy-desk.html:130` |
693
693
  | `c78d7388ffd7` | certain | Or just stay in the online terminal | `modules/hall-ui/public/hall-render.js:779` |
694
- | `d89d1923ecf9` | certain | Over the last {…} days | `modules/hall-ui/public/watch.js:1153` |
694
+ | `d89d1923ecf9` | certain | Over the last {…} days | `modules/hall-ui/public/watch.js:1194` |
695
695
  | `695ffff13730` | certain | Over time | `modules/hall-ui/public/profile.html:72` |
696
696
  | `5ded44da0cc5` | certain | Override requests | `modules/hall-ui/public/watch.html:143` |
697
697
  | `15a49bf15007` | certain | Page {…} of {…} | `modules/hall-ui/public/hall-kit.js:112` |
@@ -737,7 +737,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
737
737
  | `3f85bdc47099` | certain | Protected module scope — join by request; an Archon admits members | `modules/hall-ui/public/goals-render.js:848` |
738
738
  | `e5c292c8b361` | certain | Provides (seams) | `modules/hall-ui/public/atlas.js:288` |
739
739
  | `4de232a05888` | certain | Raise after | `modules/hall-ui/public/project-settings.js:113` |
740
- | `b9c6def6b120` | certain | Rank set to {…}. | `modules/hall-ui/public/watch.js:813` |
740
+ | `b9c6def6b120` | certain | Rank set to {…}. | `modules/hall-ui/public/watch.js:854` |
741
741
  | `ff50a1395cbc` | certain | Rank: {…} | `modules/hall-ui/public/profile.js:50` |
742
742
  | `6824ec470f99` | certain | Ranks — {{worldName}} | `modules/hall-ui/public/ranks.html:6` |
743
743
  | `6824ec470f99` | certain | Ranks — {{worldName}} | `modules/hall-ui/public/ranks.html:6` |
@@ -745,7 +745,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
745
745
  | `101a51902f53` | certain | Re-issue failed: {…} | `modules/hall-ui/public/settings.js:1021` |
746
746
  | `fae2f90927bd` | certain | Read an idea, then decide. An objection needs a reason and the section it faults. | `modules/hall-ui/public/board-room.html:51` |
747
747
  | `b03e17693dcd` | certain | Read it as a table | `modules/hall-ui/public/drachmae.js:148` |
748
- | `b03e17693dcd` | certain | Read it as a table | `modules/hall-ui/public/watch.js:1126` |
748
+ | `b03e17693dcd` | certain | Read it as a table | `modules/hall-ui/public/watch.js:1167` |
749
749
  | `b6fcfdd652a7` | certain | Read it here | `modules/hall-ui/public/work.html:45` |
750
750
  | `c5f19a888a15` | certain | Read the handoff notes | `modules/hall-ui/public/task.js:441` |
751
751
  | `f9e998f4af3d` | certain | Read to the end and the button below unlocks. It is an honour system — nothing here checks your comprehension. | `modules/hall-ui/public/primer.html:100` |
@@ -798,7 +798,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
798
798
  | `0887725744f2` | certain | Restricted. | `modules/hall-ui/public/gate.js:219` |
799
799
  | `0887725744f2` | certain | Restricted. | `modules/hall-ui/public/government.js:879` |
800
800
  | `0887725744f2` | certain | Restricted. | `modules/hall-ui/public/harbor.js:337` |
801
- | `0887725744f2` | certain | Restricted. | `modules/hall-ui/public/watch.js:1265` |
801
+ | `0887725744f2` | certain | Restricted. | `modules/hall-ui/public/watch.js:1306` |
802
802
  | `b33dde402434` | certain | Retire this goal — you will be asked what happens to any unfinished work | `modules/hall-ui/public/goals-render.js:927` |
803
803
  | `feb48b0d462c` | certain | Return to the Dev Box app and click “Connect” again for a fresh code. | `modules/hall-ui/public/pair.js:32` |
804
804
  | `908a0d5d0685` | certain | Review how it judged this work, and teach it what good looked like. | `modules/hall-ui/public/studio.js:181` |
@@ -884,7 +884,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
884
884
  | `f79e1fb8ebd4` | certain | Suggested as you type; leave it on "no goal" and it waits for triage. | `modules/hall-ui/public/ideas.html:88` |
885
885
  | `1e40f33483eb` | certain | Switch theme | `modules/hall-ui/public/shell.js:461` |
886
886
  | `93fa2e97dd35` | certain | System default for this skill | `modules/hall-ui/public/settings.js:74` |
887
- | `4894b1d9b0de` | certain | System oversight: people, operations and security. | `modules/hall-ui/public/watch.js:1324` |
887
+ | `4894b1d9b0de` | certain | System oversight: people, operations and security. | `modules/hall-ui/public/watch.js:1370` |
888
888
  | `dfc967a63d57` | certain | Task lifecycle from backlog to shipped, showing states and transitions and the edge where credits are awarded. | `modules/hall-ui/public/diagrams.html:51` |
889
889
  | `10cc12f37636` | certain | Task sections | `modules/hall-ui/public/work.html:53` |
890
890
  | `0e636ea41b29` | certain | Task status | `modules/hall-ui/public/goals-render.js:340` |
@@ -905,6 +905,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
905
905
  | `282c3fa2d456` | certain | That is the wording it already has. Change something, or file a flag instead if you only want to mark it. | `modules/hall-ui/public/copy-desk.js:438` |
906
906
  | `c0e28f30524d` | certain | That project's own standing, as reported by that project. | `modules/hall-ui/public/profile.js:314` |
907
907
  | `e99d4a811e92` | certain | That request is no longer available. | `modules/hall-ui/public/profile-connections.js:270` |
908
+ | `6e5a324b2972` | certain | That's you | `modules/hall-ui/public/watch.js:803` |
908
909
  | `50198c465225` | certain | The 16-value rank enum in three bands, which ranks are live, and the permissions each rank holds. | `modules/hall-ui/public/diagrams.html:59` |
909
910
  | `c5e6bdd32fe3` | certain | The Board Room is open to the builders this project’s constitution seats on its board. | `modules/hall-ui/public/board-room.js:436` |
910
911
  | `1569b8838862` | certain | The Dev Box app now acts as | `modules/hall-ui/public/pair.js:40` |
@@ -1087,6 +1088,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
1087
1088
  | `6dadadffa36e` | certain | Your name cannot be blank. | `modules/hall-ui/public/settings.js:832` |
1088
1089
  | `bd77210a8c90` | certain | Your name must be {…} characters or fewer. | `modules/hall-ui/public/settings.js:837` |
1089
1090
  | `c0a01a95e97e` | certain | Your own idea. You may vote, but it can’t be decided by your vote alone, and a vote on your own idea earns no | `modules/hall-ui/public/board-room.js:274` |
1091
+ | `5e0ec4cd1cfe` | certain | Your own row carries no controls. Nobody can change their own rank or standing — that guard is what stops the | `modules/hall-ui/public/watch.js:835` |
1090
1092
  | `8fc605026acf` | certain | Your pending join {…}: {…} — awaiting approval. | `modules/hall-ui/public/goal-inbox.js:88` |
1091
1093
  | `e91418db3962` | certain | Your preferences, {…}. | `modules/hall-ui/public/settings.js:856` |
1092
1094
  | `e91418db3962` | certain | Your preferences, {…}. | `modules/hall-ui/public/settings.js:1413` |
@@ -1231,7 +1233,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
1231
1233
  | `4d8ee7d68974` | certain | task {…} | `modules/hall-ui/public/hall-render.js:623` |
1232
1234
  | `4d8ee7d68974` | certain | task {…} | `modules/hall-ui/public/idea.js:207` |
1233
1235
  | `4d8ee7d68974` | certain | task {…} | `modules/hall-ui/public/ideas.js:476` |
1234
- | `4d8ee7d68974` | certain | task {…} | `modules/hall-ui/public/watch.js:1236` |
1236
+ | `4d8ee7d68974` | certain | task {…} | `modules/hall-ui/public/watch.js:1277` |
1235
1237
  | `a41659dc2bee` | certain | tasks remaining | `modules/hall-ui/public/roadmap.js:305` |
1236
1238
  | `bd3402efa59e` | certain | task{…} shipped | `modules/hall-ui/public/goals-render.js:793` |
1237
1239
  | `95854f8315ca` | certain | the page everyone else reads | `modules/hall-ui/public/profile.html:32` |
@@ -3,9 +3,9 @@
3
3
  "generator": "scripts/gds/copy-inventory.js",
4
4
  "goal": 1000074,
5
5
  "counts": {
6
- "strings": 1749,
7
- "unique_ids": 1513,
8
- "certain": 1749,
6
+ "strings": 1751,
7
+ "unique_ids": 1515,
8
+ "certain": 1751,
9
9
  "heuristic": 0,
10
10
  "duplicate_ids": 164
11
11
  },
@@ -13,7 +13,7 @@
13
13
  "builders-hall": {
14
14
  "at": "modules/hall-ui/public",
15
15
  "files": 80,
16
- "strings": 1270
16
+ "strings": 1272
17
17
  },
18
18
  "status-dashboard": {
19
19
  "at": "modules/status-ui/public",
@@ -147,7 +147,7 @@
147
147
  "modules/hall-ui/public/gate.js:219",
148
148
  "modules/hall-ui/public/government.js:879",
149
149
  "modules/hall-ui/public/harbor.js:337",
150
- "modules/hall-ui/public/watch.js:1265"
150
+ "modules/hall-ui/public/watch.js:1306"
151
151
  ]
152
152
  },
153
153
  {
@@ -173,7 +173,7 @@
173
173
  "modules/hall-ui/public/hall-render.js:623",
174
174
  "modules/hall-ui/public/idea.js:207",
175
175
  "modules/hall-ui/public/ideas.js:476",
176
- "modules/hall-ui/public/watch.js:1236"
176
+ "modules/hall-ui/public/watch.js:1277"
177
177
  ]
178
178
  },
179
179
  {
@@ -1167,7 +1167,7 @@
1167
1167
  "occurrences": 2,
1168
1168
  "at": [
1169
1169
  "modules/hall-ui/public/drachmae.js:148",
1170
- "modules/hall-ui/public/watch.js:1126"
1170
+ "modules/hall-ui/public/watch.js:1167"
1171
1171
  ]
1172
1172
  },
1173
1173
  {
@@ -3584,7 +3584,7 @@
3584
3584
  "surface": "builders-hall",
3585
3585
  "text": "Could not change rank: {…}",
3586
3586
  "file": "modules/hall-ui/public/watch.js",
3587
- "line": 819,
3587
+ "line": 860,
3588
3588
  "origin": "js-notice",
3589
3589
  "confidence": "certain"
3590
3590
  },
@@ -3593,7 +3593,7 @@
3593
3593
  "surface": "builders-hall",
3594
3594
  "text": "Could not change standing: {…}",
3595
3595
  "file": "modules/hall-ui/public/watch.js",
3596
- "line": 845,
3596
+ "line": 886,
3597
3597
  "origin": "js-notice",
3598
3598
  "confidence": "certain"
3599
3599
  },
@@ -3809,7 +3809,7 @@
3809
3809
  "surface": "builders-hall",
3810
3810
  "text": "Could not load this page.",
3811
3811
  "file": "modules/hall-ui/public/watch.js",
3812
- "line": 1311,
3812
+ "line": 1357,
3813
3813
  "origin": "js-notice",
3814
3814
  "confidence": "certain"
3815
3815
  },
@@ -4151,7 +4151,7 @@
4151
4151
  "surface": "builders-hall",
4152
4152
  "text": "Could not verify your access. Go back and try again.",
4153
4153
  "file": "modules/hall-ui/public/watch.js",
4154
- "line": 1310,
4154
+ "line": 1356,
4155
4155
  "origin": "js-text-assign",
4156
4156
  "confidence": "certain"
4157
4157
  },
@@ -7238,7 +7238,7 @@
7238
7238
  "surface": "builders-hall",
7239
7239
  "text": "Over the last {…} days",
7240
7240
  "file": "modules/hall-ui/public/watch.js",
7241
- "line": 1153,
7241
+ "line": 1194,
7242
7242
  "origin": "js-markup",
7243
7243
  "confidence": "certain"
7244
7244
  },
@@ -7652,7 +7652,7 @@
7652
7652
  "surface": "builders-hall",
7653
7653
  "text": "Rank set to {…}.",
7654
7654
  "file": "modules/hall-ui/public/watch.js",
7655
- "line": 813,
7655
+ "line": 854,
7656
7656
  "origin": "js-notice",
7657
7657
  "confidence": "certain"
7658
7658
  },
@@ -7724,7 +7724,7 @@
7724
7724
  "surface": "builders-hall",
7725
7725
  "text": "Read it as a table",
7726
7726
  "file": "modules/hall-ui/public/watch.js",
7727
- "line": 1126,
7727
+ "line": 1167,
7728
7728
  "origin": "js-markup",
7729
7729
  "confidence": "certain"
7730
7730
  },
@@ -8201,7 +8201,7 @@
8201
8201
  "surface": "builders-hall",
8202
8202
  "text": "Restricted.",
8203
8203
  "file": "modules/hall-ui/public/watch.js",
8204
- "line": 1265,
8204
+ "line": 1306,
8205
8205
  "origin": "js-text-assign",
8206
8206
  "confidence": "certain"
8207
8207
  },
@@ -8975,7 +8975,7 @@
8975
8975
  "surface": "builders-hall",
8976
8976
  "text": "System oversight: people, operations and security.",
8977
8977
  "file": "modules/hall-ui/public/watch.js",
8978
- "line": 1324,
8978
+ "line": 1370,
8979
8979
  "origin": "js-text-assign",
8980
8980
  "confidence": "certain"
8981
8981
  },
@@ -9159,6 +9159,15 @@
9159
9159
  "origin": "js-notice",
9160
9160
  "confidence": "certain"
9161
9161
  },
9162
+ {
9163
+ "id": "6e5a324b2972",
9164
+ "surface": "builders-hall",
9165
+ "text": "That's you",
9166
+ "file": "modules/hall-ui/public/watch.js",
9167
+ "line": 803,
9168
+ "origin": "js-markup",
9169
+ "confidence": "certain"
9170
+ },
9162
9171
  {
9163
9172
  "id": "50198c465225",
9164
9173
  "surface": "builders-hall",
@@ -10797,6 +10806,15 @@
10797
10806
  "origin": "js-markup",
10798
10807
  "confidence": "certain"
10799
10808
  },
10809
+ {
10810
+ "id": "5e0ec4cd1cfe",
10811
+ "surface": "builders-hall",
10812
+ "text": "Your own row carries no controls. Nobody can change their own rank or standing — that guard is what stops the last {…} locking everyone out — so another {…} does it.",
10813
+ "file": "modules/hall-ui/public/watch.js",
10814
+ "line": 835,
10815
+ "origin": "js-text-assign",
10816
+ "confidence": "certain"
10817
+ },
10800
10818
  {
10801
10819
  "id": "8fc605026acf",
10802
10820
  "surface": "builders-hall",
@@ -12098,7 +12116,7 @@
12098
12116
  "surface": "builders-hall",
12099
12117
  "text": "task {…}",
12100
12118
  "file": "modules/hall-ui/public/watch.js",
12101
- "line": 1236,
12119
+ "line": 1277,
12102
12120
  "origin": "js-markup",
12103
12121
  "confidence": "certain"
12104
12122
  },
@@ -1887,5 +1887,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1887
1887
  landed since 1.19.712 with no explicit bump. run 34745667268. (task 1002620)
1888
1888
  1.19.714 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1889
1889
  landed since 1.19.713 with no explicit bump. run 34746102135. (task 1002620)
1890
+ 1.19.715 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1891
+ landed since 1.19.714 with no explicit bump. run 34747181910. (task 1002620)
1890
1892
  ---------------------------------------------------------------------------
1891
1893
  ```
@@ -21,6 +21,12 @@
21
21
  /* Row actions inside a ledger cell: ghost pills in a row. */
22
22
  .watch-actions { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
23
23
 
24
+ /* The reader's own roster row (task 1003720). Nobody may change their own rank
25
+ or standing — the server refuses both outright — so that row states a fact
26
+ where the others carry a control, and a fact is written in the faint ink, not
27
+ painted as a disabled pill. The foot line under the table says why. */
28
+ .watch-self { color: var(--ink-faint); }
29
+
24
30
  /* A sealed or failed section says so in place. Three states, three sentences:
25
31
  "nothing here" is the kit's empty state, this is "you may not see this" and
26
32
  "could not load". */
@@ -745,6 +745,13 @@
745
745
  // sits at a rank outside this set, we inject it so the select still shows it.
746
746
  const RANK_OPTIONS = ['xenos', 'thetes', 'metic', 'archon'];
747
747
 
748
+ // Who is reading this roster (task 1003720). `load()` already fetches /me to
749
+ // resolve the page's floor and then threw the id away, so the roster — which
750
+ // returns EVERY builder, the viewer included — could not tell which row was
751
+ // the reader's own. See renderRoster for why that mattered.
752
+ let viewerId = null;
753
+ const isViewer = (b) => viewerId != null && b && b.id != null && String(b.id) === String(viewerId);
754
+
748
755
  function renderRoster(data, target) {
749
756
  const builders = Array.isArray(data.builders) ? data.builders : [];
750
757
  setAttention('att-stale', builders.filter((b) => b.stale_claim).length);
@@ -762,16 +769,39 @@
762
769
  ],
763
770
  pager: makePager('watch-roster', 'otb.watch.rosterPerPage'),
764
771
  rowHtml: (b) => {
772
+ // THE VIEWER'S OWN ROW OFFERS NOTHING (task 1003720, re-filed from 1002951
773
+ // — "I cannot press offboard", reported by the owner from Discord).
774
+ //
775
+ // The roster returns every builder, so the reader's own row is in it. Both
776
+ // routes behind that row refuse a self-change OUTRIGHT and always will:
777
+ // PATCH /builders/:id/rank answers cannot_change_own_rank and
778
+ // /builders/:id/status answers cannot_change_own_status — the guard that
779
+ // stops the last Archon demoting or offboarding themselves and locking
780
+ // everyone out. The row painted a live rank <select> and a live Offboard
781
+ // pill anyway, so from the owner's seat the page carried a control that
782
+ // could only ever answer with a raw refusal slug in a toast. That is what
783
+ // "I cannot press offboard" describes.
784
+ //
785
+ // This is not a SOFT gate (the primer's acknowledge control, which opens
786
+ // once you have read enough and so stays in the tab order as
787
+ // aria-disabled) — it is an action that can never open for this reader.
788
+ // So the row renders no control at all and the section's foot line says
789
+ // why, rather than painting a dead one.
790
+ const self = isViewer(b);
765
791
  const opts = RANK_OPTIONS.slice();
766
792
  if (b.rank && !opts.includes(b.rank)) opts.unshift(b.rank);
767
- const rankSelect =
768
- `<select data-roster-rank data-id="${escapeHtml(b.id)}" data-current="${escapeHtml(b.rank || '')}" aria-label="Rank">` +
769
- opts.map((rk) => `<option value="${escapeHtml(rk)}"${rk === b.rank ? ' selected' : ''}>${escapeHtml(rankLabel(rk))}</option>`).join('') +
770
- '</select>';
793
+ const rankCell = self
794
+ ? `<span class="watch-self">${escapeHtml(rankLabel(b.rank))}</span>`
795
+ : `<select data-roster-rank data-id="${escapeHtml(b.id)}" data-current="${escapeHtml(b.rank || '')}" aria-label="Rank">` +
796
+ opts.map((rk) => `<option value="${escapeHtml(rk)}"${rk === b.rank ? ' selected' : ''}>${escapeHtml(rankLabel(rk))}</option>`).join('') +
797
+ '</select>';
771
798
  const isInactive = String(b.status) === 'inactive';
772
799
  const statusBtn = isInactive
773
800
  ? ghost(`data-roster-status="active" data-id="${escapeHtml(b.id)}"`, 'Reactivate')
774
801
  : ghost(`data-roster-status="inactive" data-id="${escapeHtml(b.id)}"`, 'Offboard', true);
802
+ const actionCell = self
803
+ ? '<span class="watch-self">That\'s you</span>'
804
+ : `<div class="watch-actions">${statusBtn}</div>`;
775
805
  const claims = num(b.active_claim_count);
776
806
  const staleFlag = b.stale_claim ? ' <span class="badge badge--sev-medium">stale</span>' : '';
777
807
  // task 1467: the roster name links to the builder's public profile page.
@@ -784,17 +814,28 @@
784
814
  `<td>${rosterNameCell}` +
785
815
  (b.github_login ? ` <span class="ov-mono">@${escapeHtml(b.github_login)}</span>` : '') +
786
816
  '</td>' +
787
- `<td>${rankSelect}</td>` +
817
+ `<td>${rankCell}</td>` +
788
818
  `<td class="num">${fmtInt(b.total_credits)}</td>` +
789
819
  `<td class="num">${fmtInt(b.karma)}</td>` +
790
820
  `<td class="num${b.stale_claim ? ' warn' : ''}">${fmtInt(claims)}${staleFlag}</td>` +
791
821
  `<td><span class="badge badge--status">${escapeHtml(b.status || '—')}</span></td>` +
792
- `<td><div class="watch-actions">${statusBtn}</div></td>` +
822
+ `<td>${actionCell}</td>` +
793
823
  '</tr>'
794
824
  );
795
825
  },
796
826
  }).render(builders);
797
827
 
828
+ // The one asymmetry in the table, stated once under it rather than repeated
829
+ // on the row. Appended as an ELEMENT, not with `innerHTML +=`: re-parsing the
830
+ // body would drop the pager's own wiring. The rank is written from
831
+ // rankLabel(), never a hardcoded "Archon", so the floor cannot drift.
832
+ if (builders.some(isViewer)) {
833
+ const note = document.createElement('p');
834
+ note.className = 'ov-foot';
835
+ note.textContent = `Your own row carries no controls. Nobody can change their own rank or standing — that guard is what stops the last ${rankLabel('archon')} locking everyone out — so another ${rankLabel('archon')} does it.`;
836
+ target.appendChild(note);
837
+ }
838
+
798
839
  // Rank change via the <select>. Bespoke: a 'change' listener that rolls the
799
840
  // select back to its previous value on failure, delegated so a page flip
800
841
  // cannot strand a row without one.
@@ -1305,6 +1346,11 @@
1305
1346
  if (!res.ok) throw new Error(`me → ${res.status}`);
1306
1347
  const payload = res.data;
1307
1348
  builder = (payload && payload.builder) ? payload.builder : payload;
1349
+ // Kept for the roster, which must know which row is the reader's own
1350
+ // (task 1003720). Null when /me answered without one — isViewer then
1351
+ // matches nothing, so the roster degrades to the old every-row-acts shape
1352
+ // rather than marking the wrong row as yours.
1353
+ viewerId = (builder && builder.id != null) ? builder.id : null;
1308
1354
  } catch (err) {
1309
1355
  console.error('[watch] failed to read viewer', err);
1310
1356
  $('#watch-sub').textContent = 'Could not verify your access. Go back and try again.';
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.714",
3
+ "version": "1.19.715",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.714",
9
+ "version": "1.19.715",
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.714",
3
+ "version": "1.19.715",
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",
@@ -295,10 +295,15 @@ async function applyPlan(pool, plans) {
295
295
  [p.builderId, r.taskId, delta, BACKFILL_REASON,
296
296
  `${r.description} (net-capped ${delta} of ${r.effective}; builder cost-plus already paid ${p.costPlusAlreadyPaid})`]
297
297
  );
298
- await client.query(
299
- `UPDATE builders SET total_credits = total_credits + $1 WHERE id = $2`,
300
- [delta, p.builderId]
301
- );
298
+ // NO `UPDATE builders SET total_credits = ...` HERE, DELIBERATELY.
299
+ // trg_credit_log_apply applies the delta (migrations/003_pms.sql:169
300
+ // pms_apply_credit, AFTER INSERT ON credit_log, total_credits += NEW.delta),
301
+ // so the INSERT above has ALREADY moved the balance. This function used to
302
+ // add it a second time, doubling every credit it minted — a real
303
+ // financial-ledger bug (task 1003641). Every other credit_log writer in the
304
+ // repo relies on the trigger alone; backfill-subagent-rewards.js is the
305
+ // sibling exemplar. If you are adding a balance update here, the trigger is
306
+ // the thing to change, once, not this call site.
302
307
  remaining -= delta;
303
308
  mintedRows++;
304
309
  mintedDrachmae += delta;
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.714'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.715'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,94 @@
1
+ // tests/credit_log_single_writer.mjs
2
+ //
3
+ // Task 1003641 — the balance is moved by the TRIGGER, and by nothing else.
4
+ //
5
+ // THE BUG. scripts/gds/backfill-task-rewards.js applyPlan() did both: INSERT INTO
6
+ // credit_log (which fires trg_credit_log_apply AFTER INSERT) *and* a separate
7
+ // `UPDATE builders SET total_credits = total_credits + $1`. The trigger had already
8
+ // applied that delta, so every credit the script minted landed twice. A real
9
+ // financial-ledger bug, and the kind that is invisible in review because each half
10
+ // looks correct on its own.
11
+ //
12
+ // WHY THIS FILE IS NOT JUST A TEST OF THAT ONE LINE. The invariant is repo-wide:
13
+ // exactly one place applies a credit_log delta to builders.total_credits. Every
14
+ // other writer already relied on the trigger; only the backfill script double-wrote.
15
+ // So the durable guard is "no credit_log writer also updates the balance", which
16
+ // catches the NEXT script someone writes as well as this one.
17
+ //
18
+ // The existing tests/backfill_task_rewards.mjs never referenced applyPlan,
19
+ // total_credits or the trigger at all, which is how this survived. Source
20
+ // assertions, because the DB-free gate cannot run SQL; runtime behaviour is proven
21
+ // against real Postgres at deploy.
22
+ //
23
+ // Run: node tests/credit_log_single_writer.mjs
24
+
25
+ import { strict as assert } from 'node:assert';
26
+ import { test } from 'node:test';
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+
31
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32
+ const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
33
+ // Comments carry the rationale; strip them before asserting on the executable body
34
+ // so a comment ABOUT the bug can never satisfy an assertion against it.
35
+ const code = (src) => src
36
+ .replace(/^\s*\/\/.*$/gm, '')
37
+ .replace(/\/\*[\s\S]*?\*\//g, '');
38
+
39
+ // Every file in the repo that inserts a credit_log row.
40
+ const WRITERS = [
41
+ ['scripts', 'gds', 'backfill-task-rewards.js'],
42
+ ['scripts', 'gds', 'backfill-subagent-rewards.js'],
43
+ ['modules', 'economy', 'achievements.js'],
44
+ ['modules', 'economy', 'credits.js'],
45
+ ['modules', 'lifecycle', 'db-rank-authz.js'],
46
+ ['src', 'bongos', 'db.js'],
47
+ ];
48
+
49
+ test('the trigger is the ONE place a credit_log delta reaches total_credits', () => {
50
+ const mig = read('migrations', '003_pms.sql');
51
+ assert.match(mig, /CREATE OR REPLACE FUNCTION pms_apply_credit\(\)/);
52
+ assert.match(mig, /SET total_credits = total_credits \+ NEW\.delta/,
53
+ 'the trigger applies the delta');
54
+ assert.match(mig, /AFTER INSERT ON credit_log/,
55
+ 'on insert, so any INSERT has already moved the balance');
56
+ });
57
+
58
+ test('no credit_log writer ALSO updates builders.total_credits', () => {
59
+ // The invariant. A writer that does both double-credits, silently.
60
+ const offenders = [];
61
+ for (const parts of WRITERS) {
62
+ const src = code(read(...parts));
63
+ if (!/INSERT INTO credit_log/i.test(src)) continue; // moved on; not our business
64
+ // Match an ASSIGNMENT, not a mention. `UPDATE builders SET rank = $2 ...
65
+ // RETURNING id, ..., total_credits, ...` READS the column and must not trip
66
+ // this — two files do exactly that, and an earlier draft of this test flagged
67
+ // both as double-writers. The two shapes that actually move a balance are the
68
+ // self-referential increment and a direct set.
69
+ const writesBalance = /\btotal_credits\s*=\s*total_credits/i.test(src)
70
+ || /SET\s+total_credits\s*=/i.test(src);
71
+ if (writesBalance) offenders.push(parts.join('/'));
72
+ }
73
+ assert.deepEqual(offenders, [],
74
+ `these insert a credit_log row AND move the balance themselves, so every credit lands twice: ${offenders.join(', ')}`);
75
+ });
76
+
77
+ test('backfill-task-rewards.js specifically — the regression that cost real credits', () => {
78
+ const src = read('scripts', 'gds', 'backfill-task-rewards.js');
79
+ assert.match(src, /INSERT INTO credit_log/, 'it still mints the audit row');
80
+ assert.doesNotMatch(code(src), /\btotal_credits\s*=/i,
81
+ 'and must never assign total_credits — the trigger owns the balance');
82
+ // The reason WHY must survive too, or the next reader re-adds it.
83
+ assert.match(src, /trg_credit_log_apply applies the delta/,
84
+ 'the call site explains why there is no balance update here');
85
+ });
86
+
87
+ test('the idempotency key still guards re-runs', () => {
88
+ // Removing the double-write must not disturb the other half of this script's
89
+ // safety: a second run of the same backfill must mint nothing.
90
+ const src = code(read('scripts', 'gds', 'backfill-task-rewards.js'));
91
+ assert.match(src, /SELECT 1 FROM credit_log WHERE task_id = \$1 AND reason = \$2/,
92
+ 'the per-task idempotency re-check inside the transaction');
93
+ assert.match(src, /BACKFILL_REASON/, 'keyed on the backfill reason');
94
+ });
package/tests/helpers.mjs CHANGED
@@ -330,6 +330,14 @@ export function makeEl(tagName, attrs = {}) {
330
330
  while (node) { if (matches(node, sel)) return node; node = node.parentNode; }
331
331
  return null;
332
332
  },
333
+ // Node.contains — true for the node itself and any descendant. A DELEGATED
334
+ // listener guards on it (`!root.contains(el)`), so without this a test that
335
+ // dispatches on the section throws instead of exercising the handler.
336
+ contains(node) {
337
+ let n = node;
338
+ while (n) { if (n === this) return true; n = n.parentNode; }
339
+ return false;
340
+ },
333
341
  addEventListener(type, fn) { (this.listeners[type] = this.listeners[type] || []).push(fn); },
334
342
  focus() {},
335
343
  fire(type, event) { (this.listeners[type] || []).forEach((fn) => fn(event)); },
@@ -0,0 +1,289 @@
1
+ // tests/watch_roster_self_row.mjs — the reader's OWN row on the Watch roster,
2
+ // EXECUTED (task 1003720, re-filed from task 1002951).
3
+ //
4
+ // The bug, as the owner reported it from Discord #bugs: "I cannot press
5
+ // offboard". GET /builders/roster returns EVERY builder — the reader included —
6
+ // and the row painted a live rank <select> and a live Offboard pill on all of
7
+ // them. But both routes behind that row refuse a self-change categorically and
8
+ // always will:
9
+ //
10
+ // PATCH /builders/:id/rank → 403 cannot_change_own_rank
11
+ // PATCH /builders/:id/status → 403 cannot_change_own_status
12
+ //
13
+ // (src/bongos/routes/builders.js — the guard that stops the last Archon demoting
14
+ // or offboarding themselves and locking everyone out.) res.fail() defaults the
15
+ // envelope's message to the CODE, so pressing your own Offboard produced a toast
16
+ // reading "Could not change standing: cannot_change_own_status" and nothing else.
17
+ // From the owner's seat that is a button that does not work.
18
+ //
19
+ // The page now renders no control on that row and states why once under the
20
+ // table. These are assertions about what a human sees and clicks, so this runs
21
+ // the real watch.js inside `vm` against the real watch.html, with a makeLedger
22
+ // that actually calls rowHtml — a source-text grep cannot see which row got
23
+ // which cell, and the no-op ledger stub in tests/watch_applications_queue.mjs
24
+ // renders no roster rows at all.
25
+ //
26
+ // Run: node tests/watch_roster_self_row.mjs
27
+
28
+ import { strict as assert } from 'node:assert';
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import vm from 'node:vm';
32
+ import { fileURLToPath } from 'node:url';
33
+ import { makeRunner, makeEl, parseInto } from './helpers.mjs';
34
+
35
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
36
+ const HALL = path.join(ROOT, 'modules/hall-ui/public');
37
+ const SRC = fs.readFileSync(path.join(HALL, 'watch.js'), 'utf8');
38
+ const PAGE = fs.readFileSync(path.join(HALL, 'watch.html'), 'utf8');
39
+
40
+ const { test, summary } = makeRunner();
41
+
42
+ const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g,
43
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
44
+
45
+ const tick = () => new Promise((r) => setTimeout(r, 0));
46
+ async function settle() { for (let i = 0; i < 40; i++) await tick(); }
47
+
48
+ const ok = (data) => ({ status: 200, ok: true, data });
49
+
50
+ // The branding pack renames every rank (the neutral pack calls archon "Owner"),
51
+ // which is why a hardcoded rank word in page copy is banned. This stub renames
52
+ // them too, so a test for "the foot line does not spell Archon" is real.
53
+ const RANK_LABELS = { xenos: 'Newcomer', thetes: 'Contributor', metic: 'Trusted', archon: 'Owner' };
54
+ const rankLabel = (r) => RANK_LABELS[r] || String(r || '');
55
+
56
+ // The viewer is builder 1. Rows as GET /builders/roster returns them — note the
57
+ // viewer sits in the MIDDLE, which is where the live roster puts the owner: the
58
+ // order is inactive-last, then still-onboarding, then created_at DESC.
59
+ const VIEWER_ID = '1';
60
+ const ROSTER = [
61
+ { id: '90', display_name: 'Alpha', github_login: 'alpha', rank: 'archon', total_credits: 9200, karma: 4, active_claim_count: 1, stale_claim: false, status: 'active' },
62
+ { id: '1', display_name: 'The Owner', github_login: 'theowner', rank: 'archon', total_credits: 6896, karma: 68, active_claim_count: 2, stale_claim: false, status: 'active' },
63
+ { id: '92', display_name: 'Gamma', github_login: 'gamma', rank: 'thetes', total_credits: 830, karma: 1, active_claim_count: 0, stale_claim: false, status: 'active' },
64
+ { id: '93', display_name: 'Delta', github_login: 'delta', rank: 'xenos', total_credits: 40, karma: 0, active_claim_count: 0, stale_claim: false, status: 'inactive' },
65
+ ];
66
+
67
+ // ===========================================================================
68
+ // Boot the real page against a scripted API and hand back handles.
69
+ // ===========================================================================
70
+
71
+ async function boot(opts = {}) {
72
+ const {
73
+ builders = ROSTER,
74
+ me = { id: VIEWER_ID, rank: 'archon', github_login: 'theowner' },
75
+ patchResult = null,
76
+ } = opts;
77
+
78
+ const requests = [];
79
+ const toasts = [];
80
+ const prompts = [];
81
+
82
+ const api = {
83
+ async request(method, p, init) {
84
+ const body = init && init.body;
85
+ requests.push({ method, path: p, body });
86
+ const route = String(p).replace('/api/bongos', '').split('?')[0];
87
+ if (method === 'GET' && route === '/me') return ok({ builder: me });
88
+ if (method === 'GET' && route === '/builders/roster') return ok({ builders });
89
+ if (method === 'PATCH' && route.startsWith('/builders/')) {
90
+ return patchResult || ok({ builder: { id: route.split('/')[2] } });
91
+ }
92
+ // Every other panel on this page loads independently and degrades on its
93
+ // own; an unscripted shape is exactly the failure `section` swallows.
94
+ return ok({});
95
+ },
96
+ };
97
+
98
+ const documentObj = {
99
+ readyState: 'complete',
100
+ body: makeEl('body'),
101
+ documentElement: makeEl('html'),
102
+ createElement: (tag) => makeEl(tag),
103
+ getElementById(id) { return this.body.querySelector(`#${id}`); },
104
+ querySelector(sel) { return this.body.querySelector(sel); },
105
+ querySelectorAll(sel) { return this.body.querySelectorAll(sel); },
106
+ addEventListener() {},
107
+ };
108
+ parseInto(documentObj.body, PAGE);
109
+
110
+ const locationObj = { hostname: 'builders.example.test', pathname: '/builders/watch', search: '', hash: '', assign() {} };
111
+
112
+ const kit = {
113
+ makeTabs: () => ({}),
114
+ // A ledger that REALLY renders: the whole question here is which row got
115
+ // which cell, and a no-op render answers none of it.
116
+ makeLedger: ({ mount, columns, rowHtml }) => ({
117
+ render(rows) {
118
+ mount.innerHTML =
119
+ '<table class="ledger"><thead><tr>' +
120
+ columns.map((c) => `<th>${escapeHtml(typeof c === 'string' ? c : c.label)}</th>`).join('') +
121
+ '</tr></thead><tbody>' + rows.map((r) => rowHtml(r)).join('') + '</tbody></table>';
122
+ },
123
+ }),
124
+ makePager: () => ({ page: 1, perPage: 25 }),
125
+ emptyStateHtml: (msg) => `<div class="empty-state"><p>${escapeHtml(msg)}</p></div>`,
126
+ makeFilterBar: (o) => { o.mount.innerHTML = '<div class="filters"></div>'; return { el: o.mount, values: () => ({}) }; },
127
+ };
128
+
129
+ const windowObj = {
130
+ __BRANDING__: { project: { joinability: 'apply' } },
131
+ OTB: {
132
+ escapeHtml,
133
+ $: (sel) => documentObj.querySelector(sel),
134
+ num: (v) => Number(v),
135
+ fmtInt: (n) => String(n),
136
+ fmtCompact: (n) => String(n),
137
+ fmtDate: (iso) => String(iso).slice(0, 10),
138
+ rankLabel,
139
+ toast: (m) => toasts.push(m),
140
+ },
141
+ OTBKit: kit,
142
+ OTBBranding: { currencyLabel: () => 'Credits' },
143
+ BongosClient: { createClient: () => api },
144
+ location: locationObj,
145
+ prompt: (msg) => { prompts.push(msg); return 'left for a new job'; },
146
+ addEventListener() {},
147
+ };
148
+
149
+ const sandbox = {
150
+ window: windowObj, document: documentObj, location: locationObj,
151
+ console: { log() {}, warn() {}, error() {} },
152
+ setTimeout, clearTimeout, Promise, Date, Math, JSON, Error, isNaN,
153
+ Number, String, Object, Array, Boolean, Set, Map, RegExp,
154
+ encodeURIComponent, decodeURIComponent, parseInt, parseFloat,
155
+ };
156
+ sandbox.globalThis = sandbox;
157
+ vm.createContext(sandbox);
158
+ vm.runInContext(SRC, sandbox, { filename: 'watch.js' });
159
+ await settle();
160
+
161
+ const rosterBody = () => documentObj.getElementById('roster-body');
162
+ const rows = () => rosterBody().querySelectorAll('tr').filter((tr) => tr.querySelectorAll('td').length);
163
+ const rowFor = (login) => rows().find((tr) => tr.querySelectorAll('td')[0].textContent.includes('@' + login));
164
+ const patches = () => requests.filter((r) => r.method === 'PATCH');
165
+ const footText = () => rosterBody().querySelectorAll('p').map((p) => p.textContent).join(' ');
166
+
167
+ return { requests, toasts, prompts, documentObj, rosterBody, rows, rowFor, patches, footText, settle };
168
+ }
169
+
170
+ const cells = (tr) => tr.querySelectorAll('td');
171
+
172
+ // ===========================================================================
173
+ // 1. The viewer's own row offers neither control.
174
+ // ===========================================================================
175
+
176
+ await test('the viewer\'s own row renders no Offboard button', async () => {
177
+ const h = await boot();
178
+ const mine = h.rowFor('theowner');
179
+ assert.ok(mine, 'the roster did not render the viewer\'s own row');
180
+ assert.equal(mine.querySelectorAll('[data-roster-status]').length, 0,
181
+ 'the viewer\'s own row still carries a status button the server refuses outright');
182
+ });
183
+
184
+ await test('the viewer\'s own row renders no rank select', async () => {
185
+ const h = await boot();
186
+ const mine = h.rowFor('theowner');
187
+ assert.equal(mine.querySelectorAll('[data-roster-rank]').length, 0,
188
+ 'the viewer\'s own row still carries a rank select the server refuses outright');
189
+ });
190
+
191
+ await test('the own row still SHOWS the rank and says whose row it is', async () => {
192
+ const h = await boot();
193
+ const c = cells(h.rowFor('theowner'));
194
+ assert.equal(c[1].textContent.trim(), 'Owner', 'the own row lost the rank it should still state');
195
+ assert.match(c[6].textContent, /you/i, 'the own row\'s action cell says nothing about why it is empty');
196
+ });
197
+
198
+ // ===========================================================================
199
+ // 2. Every OTHER row keeps both controls — the fix must not disarm the roster.
200
+ // ===========================================================================
201
+
202
+ await test('every other row keeps its Offboard / Reactivate button', async () => {
203
+ const h = await boot();
204
+ for (const login of ['alpha', 'gamma', 'delta']) {
205
+ const tr = h.rowFor(login);
206
+ assert.equal(tr.querySelectorAll('[data-roster-status]').length, 1,
207
+ `row @${login} lost its status control`);
208
+ }
209
+ // The inactive builder offers the OTHER verb, as before.
210
+ assert.equal(h.rowFor('delta').querySelector('[data-roster-status]').getAttribute('data-roster-status'), 'active');
211
+ assert.equal(h.rowFor('alpha').querySelector('[data-roster-status]').getAttribute('data-roster-status'), 'inactive');
212
+ });
213
+
214
+ await test('every other row keeps its rank select', async () => {
215
+ const h = await boot();
216
+ for (const login of ['alpha', 'gamma', 'delta']) {
217
+ assert.equal(h.rowFor(login).querySelectorAll('[data-roster-rank]').length, 1,
218
+ `row @${login} lost its rank select`);
219
+ }
220
+ });
221
+
222
+ await test('another builder\'s Offboard still reaches the route', async () => {
223
+ const h = await boot();
224
+ const btn = h.rowFor('gamma').querySelector('[data-roster-status]');
225
+ // The roster DELEGATES: the listener is on #roster-body, not on the button, so
226
+ // a list that pages cannot strand a row without one. The DOM stub's fire()
227
+ // does not bubble, so firing on the button reaches nothing — dispatch on the
228
+ // section and name the button as the target, the way a real click arrives.
229
+ h.rosterBody().fire('click', { target: btn });
230
+ await h.settle();
231
+ const p = h.patches();
232
+ assert.equal(p.length, 1, `expected one PATCH, got ${p.length}`);
233
+ assert.match(p[0].path, /\/builders\/92\/status$/);
234
+ assert.equal(p[0].body.status, 'inactive');
235
+ assert.equal(p[0].body.reason, 'left for a new job', 'the offboarding reason was dropped');
236
+ assert.equal(h.prompts.length, 1, 'the reason prompt no longer runs');
237
+ });
238
+
239
+ // ===========================================================================
240
+ // 3. The foot line says why, in the pack's own words.
241
+ // ===========================================================================
242
+
243
+ await test('a foot line under the roster explains the empty row', async () => {
244
+ const h = await boot();
245
+ assert.match(h.footText(), /your own row carries no controls/i,
246
+ 'nothing under the roster explains why one row has no controls');
247
+ });
248
+
249
+ await test('the foot line names the rank from the pack, never a hardcoded "Archon"', async () => {
250
+ const h = await boot();
251
+ const foot = h.footText();
252
+ assert.match(foot, /Owner/, 'the foot line does not name the rank through rankLabel()');
253
+ assert.ok(!/archon/i.test(foot), `the foot line hardcodes a rank word: ${foot}`);
254
+ });
255
+
256
+ await test('no foot line when the viewer is not on the roster', async () => {
257
+ // A Metic reading the roster of a project they are not listed on: there is no
258
+ // own row, so there is nothing to explain.
259
+ const h = await boot({ builders: ROSTER.filter((b) => b.id !== VIEWER_ID) });
260
+ assert.ok(!/your own row/i.test(h.footText()), 'the foot line rendered with no own row on the table');
261
+ });
262
+
263
+ // ===========================================================================
264
+ // 4. Degrade: an unidentifiable viewer must not mark the WRONG row.
265
+ // ===========================================================================
266
+
267
+ await test('no id from /me leaves every row exactly as it was', async () => {
268
+ const h = await boot({ me: { rank: 'archon', github_login: 'theowner' } });
269
+ assert.equal(h.rows().length, ROSTER.length);
270
+ for (const b of ROSTER) {
271
+ assert.equal(h.rowFor(b.github_login).querySelectorAll('[data-roster-status]').length, 1,
272
+ `row @${b.github_login} was disarmed although the viewer could not be identified`);
273
+ }
274
+ assert.ok(!/your own row/i.test(h.footText()));
275
+ });
276
+
277
+ await test('the match is by value, not by type — a numeric id still matches', async () => {
278
+ // node-postgres hands bigints back as TEXT, so /me may answer with a string
279
+ // where the roster answers with a number (or the reverse). A `===` comparison
280
+ // here is the same bug the /rank route's own guard was fixed for in #348.
281
+ const h = await boot({
282
+ me: { id: 1, rank: 'archon', github_login: 'theowner' },
283
+ builders: ROSTER.map((b) => (b.id === VIEWER_ID ? { ...b, id: '1' } : b)),
284
+ });
285
+ assert.equal(h.rowFor('theowner').querySelectorAll('[data-roster-status]').length, 0,
286
+ 'a numeric /me id did not match the roster\'s string id — the own row is armed again');
287
+ });
288
+
289
+ summary('watch roster — the viewer\'s own row');