@bongos/core 1.21.78 → 1.21.80

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.
Files changed (77) hide show
  1. package/.bongos-core.json +108 -78
  2. package/.claude/skills/goal-create/SKILL.md +2 -1
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +8 -0
  5. package/clients/bongos-client/index.cjs +8 -0
  6. package/clients/bongos-client/index.d.ts +13 -0
  7. package/clients/bongos-client/index.mjs +8 -0
  8. package/docs/adr/0155-adopt-private-repo-widen-oauth-scope.md +1 -0
  9. package/docs/adr/0370-a-repo-we-create-is-private.md +29 -0
  10. package/docs/adr/README.md +1 -0
  11. package/docs/api/openapi.json +285 -3
  12. package/docs/api-reference.md +6 -2
  13. package/docs/copy-inventory.md +40 -33
  14. package/docs/copy-registry.json +125 -61
  15. package/docs/module-api-changelog.md +4 -0
  16. package/docs/page-inventory.json +4 -2
  17. package/docs/page-readings.json +216 -207
  18. package/migrations/core_273_module_install_reports.sql +66 -0
  19. package/modules/hall-ui/public/atlas.html +1 -1
  20. package/modules/hall-ui/public/blockers.html +1 -1
  21. package/modules/hall-ui/public/board-room.html +1 -1
  22. package/modules/hall-ui/public/city.html +1 -1
  23. package/modules/hall-ui/public/collab.html +1 -1
  24. package/modules/hall-ui/public/commands.html +1 -1
  25. package/modules/hall-ui/public/copy-desk.html +1 -1
  26. package/modules/hall-ui/public/deploy.html +1 -1
  27. package/modules/hall-ui/public/diagrams.html +1 -1
  28. package/modules/hall-ui/public/drachmae.html +1 -1
  29. package/modules/hall-ui/public/fleet.html +1 -1
  30. package/modules/hall-ui/public/gate.html +1 -1
  31. package/modules/hall-ui/public/goal-map.html +1 -1
  32. package/modules/hall-ui/public/goals.html +1 -1
  33. package/modules/hall-ui/public/government.html +1 -1
  34. package/modules/hall-ui/public/idea.html +1 -1
  35. package/modules/hall-ui/public/ideas.html +1 -1
  36. package/modules/hall-ui/public/index.html +1 -1
  37. package/modules/hall-ui/public/modules.html +1 -1
  38. package/modules/hall-ui/public/primer.html +1 -1
  39. package/modules/hall-ui/public/profile.html +1 -1
  40. package/modules/hall-ui/public/project-settings.html +1 -1
  41. package/modules/hall-ui/public/ranks.html +1 -1
  42. package/modules/hall-ui/public/roadmap.html +1 -1
  43. package/modules/hall-ui/public/roster.html +1 -1
  44. package/modules/hall-ui/public/sessions.html +1 -1
  45. package/modules/hall-ui/public/settings-usage-reports.js +96 -0
  46. package/modules/hall-ui/public/settings.html +19 -1
  47. package/modules/hall-ui/public/settings.states.json +1 -0
  48. package/modules/hall-ui/public/shell.js +61 -29
  49. package/modules/hall-ui/public/street.html +1 -1
  50. package/modules/hall-ui/public/studio.html +1 -1
  51. package/modules/hall-ui/public/task.html +1 -1
  52. package/modules/hall-ui/public/thinking.html +1 -1
  53. package/modules/hall-ui/public/tweak-editor-lib.js +22 -3
  54. package/modules/hall-ui/public/tweak-editor.html +1 -1
  55. package/modules/hall-ui/public/watch.html +1 -1
  56. package/modules/hall-ui/public/work.html +1 -1
  57. package/modules/hall-ui/records/tweak-editor.md +1 -1
  58. package/modules/public-landing/public/projects.html +5 -5
  59. package/package-lock.json +2 -2
  60. package/package.json +1 -1
  61. package/release-notes.json +28 -0
  62. package/scripts/gds/module-assess-installs.js +143 -0
  63. package/scripts/gds/module.js +29 -5
  64. package/scripts/hall-preview/live-identity.js +1 -0
  65. package/src/bongos/github-repos.js +7 -6
  66. package/src/bongos/module-usage-reports.js +112 -0
  67. package/src/bongos/routes/modules.js +96 -0
  68. package/src/module-api.js +1 -1
  69. package/tests/github_repos_helpers.mjs +5 -5
  70. package/tests/goal_map_page.mjs +1 -1
  71. package/tests/hall_commands.mjs +1 -1
  72. package/tests/hall_nav.mjs +42 -0
  73. package/tests/hall_tweak_editor.mjs +13 -1
  74. package/tests/module_assess_installs.mjs +272 -0
  75. package/tests/module_install.mjs +3 -0
  76. package/tests/module_update.mjs +3 -0
  77. package/tests/nav_permission_atoms.mjs +1 -1
@@ -84,27 +84,59 @@
84
84
  }
85
85
 
86
86
  // Small stroke icon set (18px grid, currentColor) — hand-rolled, no framework.
87
+ //
88
+ // ONE GLYPH PER TAB (task 1004626). The rail is a 64px icon strip until it
89
+ // opens, so at rest the icon IS the tab's name — two tabs wearing one glyph
90
+ // are two tabs a builder cannot tell apart (nine Government tabs once shared a
91
+ // shield, all eight Docs tabs one page). Every NAV item names its own key here
92
+ // and no two items share one; tests/hall_nav.mjs fails on a repeat, on a key
93
+ // missing from this set, and on two keys drawing the same markup.
87
94
  var ICONS = {
95
+ // the project
88
96
  home: '<path d="M3 8.5 9 3l6 5.5V15a1 1 0 0 1-1 1h-3.5v-4h-3v4H4a1 1 0 0 1-1-1V8.5Z"/>',
89
- work: '<rect x="2.5" y="5.5" width="13" height="9" rx="1.5"/><path d="M6.5 5.5V4a1.5 1.5 0 0 1 1.5-1.5h2A1.5 1.5 0 0 1 11.5 4v1.5M2.5 9.5h13"/>',
90
- standing: '<circle cx="9" cy="6" r="3"/><path d="M3.5 15.5c.6-2.9 2.9-4.5 5.5-4.5s4.9 1.6 5.5 4.5"/>',
91
- profile: '<rect x="2.5" y="4" width="13" height="10" rx="1.5"/><circle cx="6.3" cy="8" r="1.6"/><path d="M4.1 12c.3-1.1 1.1-1.7 2.2-1.7s1.9.6 2.2 1.7"/><path d="M10.4 7.5h3M10.4 10.3h3"/>',
92
- goals: '<circle cx="9" cy="9" r="6.5"/><circle cx="9" cy="9" r="3.5"/><circle cx="9" cy="9" r="0.8" fill="currentColor"/>',
93
97
  roadmap: '<path d="M3 15.5V3.5l4 2 4-2 4 2v10l-4-2-4 2-4-2Z"/><path d="M7 3.5v10M11 5.5v10"/>',
94
- leaderboard: '<path d="M3.5 15.5v-6M9 15.5V3.5m5.5 12v-9"/>',
95
- docs: '<path d="M4 3.5h7l3 3v10H4v-13Z"/><path d="M11 3.5v3h3"/>',
98
+ goals: '<circle cx="9" cy="9" r="6.5"/><circle cx="9" cy="9" r="3.5"/><circle cx="9" cy="9" r="0.8" fill="currentColor"/>',
99
+ sky: '<circle cx="3.8" cy="12.5" r="1.3"/><circle cx="8.5" cy="5" r="1.3"/><circle cx="14.2" cy="8.5" r="1.3"/><circle cx="12" cy="14.8" r="1.3"/><path d="M4.5 11.4 7.8 6.1M9.6 5.7l3.5 2.1M13.7 9.8l-1.2 3.7"/>',
100
+ work: '<rect x="2.5" y="5.5" width="13" height="9" rx="1.5"/><path d="M6.5 5.5V4a1.5 1.5 0 0 1 1.5-1.5h2A1.5 1.5 0 0 1 11.5 4v1.5M2.5 9.5h13"/>',
101
+ blockers: '<path d="M6.3 2.5h5.4l3.8 3.8v5.4l-3.8 3.8H6.3l-3.8-3.8V6.3Z"/><path d="M6 9h6"/>',
102
+ boardroom: '<path d="M2.5 9h13v5.5a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1V9Z"/><path d="M5.5 9V2.5h7V9"/><path d="M7.3 5.8l1.2 1.2 2.2-2.3"/>',
103
+ thinking: '<path d="M9 2.5a4.5 4.5 0 0 0-2.7 8.1c.5 .4 .7 .9 .7 1.4v.5h4V12c0-.5.2-1 .7-1.4A4.5 4.5 0 0 0 9 2.5Z"/><path d="M7 15h4M7.8 16.8h2.4"/>',
96
104
  // task 1003117 (R06 of goal 1000074): the Studio (artist mode). A sprout —
97
105
  // the zen-garden reading of "a place an artist wants to be", so it does not
98
106
  // reuse a glyph that would read as one more docs/goals page.
99
107
  studio: '<path d="M9 15.5V8.5"/><path d="M9 8.5C9 5.5 6.5 3.5 3.5 3.5c0 3 2.5 5 5.5 5Z"/><path d="M9 10C9 7.5 11.2 5.5 14 5.5c0 2.7-2.2 4.5-5 4.5Z"/>',
108
+ // You
109
+ standing: '<circle cx="9" cy="6" r="3"/><path d="M3.5 15.5c.6-2.9 2.9-4.5 5.5-4.5s4.9 1.6 5.5 4.5"/>',
110
+ profile: '<rect x="2.5" y="4" width="13" height="10" rx="1.5"/><circle cx="6.3" cy="8" r="1.6"/><path d="M4.1 12c.3-1.1 1.1-1.7 2.2-1.7s1.9.6 2.2 1.7"/><path d="M10.4 7.5h3M10.4 10.3h3"/>',
111
+ // Community
112
+ collab: '<path d="M2.5 4a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v4.5a1 1 0 0 1-1 1H6.5L4 11.5v-2h-.5a1 1 0 0 1-1-1V4Z"/><path d="M11.5 6.5h3a1 1 0 0 1 1 1V12a1 1 0 0 1-1 1H14v2l-2.5-2H8.5a1 1 0 0 1-1-1V9.5"/>',
113
+ roster: '<circle cx="6.5" cy="6.3" r="2.3"/><path d="M2.5 15c.4-2.3 1.9-3.7 4-3.7s3.6 1.4 4 3.7"/><circle cx="12.5" cy="5.8" r="2"/><path d="M11.4 10.5c.4-.1.7-.2 1.1-.2 1.9 0 3.3 1.3 3.7 3.5"/>',
114
+ // Docs
115
+ primer: '<path d="M9 5C7.5 3.9 5.5 3.5 2.5 3.5v10c3 0 5 .4 6.5 1.5 1.5-1.1 3.5-1.5 6.5-1.5v-10c-3 0-5 .4-6.5 1.5Z"/><path d="M9 5v10"/>',
116
+ ranks: '<path d="M4 7.5 9 4l5 3.5M4 11 9 7.5l5 3.5M4 14.5 9 11l5 3.5"/>',
117
+ economy: '<circle cx="9" cy="9" r="6.5"/><path d="M11 6.7c-.4-.6-1.1-.9-2-.9-1.2 0-2 .6-2 1.5 0 2 4 1 4 3.1 0 .9-.9 1.5-2.1 1.5-.9 0-1.7-.4-2.1-1M9 4.7v1.1M9 12.2v1.1"/>',
118
+ commands: '<rect x="2.5" y="3.5" width="13" height="11" rx="1.5"/><path d="M5.5 7.3 7.7 9l-2.2 1.7M9.5 11.2h3"/>',
119
+ diagrams: '<rect x="2.5" y="2.5" width="5.5" height="4" rx="1"/><rect x="10" y="11.5" width="5.5" height="4" rx="1"/><path d="M5.2 6.5v4a1.5 1.5 0 0 0 1.5 1.5H10"/>',
120
+ atlas: '<circle cx="9" cy="9" r="6.5"/><path d="M2.5 9h13M9 2.5c1.7 1.8 2.6 4 2.6 6.5s-.9 4.7-2.6 6.5c-1.7-1.8-2.6-4-2.6-6.5S7.3 4.3 9 2.5Z"/>',
121
+ modules: '<rect x="2.5" y="2.5" width="5.5" height="5.5" rx="1"/><rect x="10" y="2.5" width="5.5" height="5.5" rx="1"/><rect x="2.5" y="10" width="5.5" height="5.5" rx="1"/><path d="M12.75 10.5v4.5M10.5 12.75h4.5"/>',
122
+ apidocs: '<path d="M6.5 3C5 3 4.5 3.7 4.5 5v2c0 1-.6 2-1.5 2 .9 0 1.5 1 1.5 2v2c0 1.3.5 2 2 2M11.5 3c1.5 0 2 .7 2 2v2c0 1 .6 2 1.5 2-.9 0-1.5 1-1.5 2v2c0 1.3-.5 2-2 2"/>',
123
+ // Government
124
+ watch: '<path d="M1.8 9S4.5 4 9 4s7.2 5 7.2 5-2.7 5-7.2 5S1.8 9 1.8 9Z"/><circle cx="9" cy="9" r="2.2"/>',
125
+ gate: '<path d="M9 2.5 15 5v4c0 3.5-2.4 6-6 7-3.6-1-6-3.5-6-7V5l6-2.5Z"/><path d="M6.5 9l1.8 1.8 3.4-3.6"/>',
126
+ sessions: '<path d="M7 5h8M7 9h8M7 13h8"/><circle cx="3.8" cy="5" r=".7" fill="currentColor"/><circle cx="3.8" cy="9" r=".7" fill="currentColor"/><circle cx="3.8" cy="13" r=".7" fill="currentColor"/>',
100
127
  settings: '<circle cx="9" cy="9" r="2.5"/><path d="M9 2.5v2m0 9v2M2.5 9h2m9 0h2M4.4 4.4l1.4 1.4m6.4 6.4 1.4 1.4m0-9.2-1.4 1.4M5.8 12.2l-1.4 1.4"/>',
101
- admin: '<path d="M9 2.5 15 5v4c0 3.5-2.4 6-6 7-3.6-1-6-3.5-6-7V5l6-2.5Z"/>',
128
+ deploy: '<path d="M9 12V3M5.5 6.5 9 3l3.5 3.5M3 12.5v2a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-2"/>',
129
+ fleet: '<path d="M4 2.5h10v13l-1.7-1-1.6 1-1.7-1-1.7 1-1.6-1-1.7 1v-13Z"/><path d="M6.5 6h5M6.5 9h5M6.5 12h3"/>',
130
+ permissions: '<circle cx="6" cy="12" r="3.5"/><path d="M8.5 9.5 15 3M12.5 5.5l2 2M10.5 7.5l1.5 1.5"/>',
102
131
  // The governor city: a pediment over columns, the Governing mode's own glyph.
103
132
  city: '<path d="M2.5 7 9 3l6.5 4M4 7.5v6.5M7 7.5v6.5M11 7.5v6.5M14 7.5v6.5M2.5 15.5h13"/>',
133
+ // the foot
104
134
  signout: '<path d="M7 3.5H4a1 1 0 0 0-1 1v9a1 1 0 0 0 1 1h3M12 12l3-3-3-3M15 9H7"/>',
105
135
  };
136
+ // An unknown name draws an EMPTY frame rather than borrowing another tab's
137
+ // glyph: a silent fallback is how a typo becomes a duplicate icon.
106
138
  function icon(name) {
107
- return '<svg class="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + (ICONS[name] || ICONS.docs) + '</svg>';
139
+ return '<svg class="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + (ICONS[name] || '') + '</svg>';
108
140
  }
109
141
  function chevronIcon() {
110
142
  return '<svg class="nav-group__chevron" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 7l4 4 4-4"/></svg>';
@@ -166,7 +198,7 @@
166
198
  // (requireBuilderPage, the tier the map sat on). The id stays 'goal-map'
167
199
  // because the href does: Goals' own "see which goals wait on each other"
168
200
  // link and every bookmark land here unchanged.
169
- { id: 'goal-map', label: 'Sky', icon: 'goals', href: '/goal-map', gate: 'member' },
201
+ { id: 'goal-map', label: 'Sky', icon: 'sky', href: '/goal-map', gate: 'member' },
170
202
  // task 1966: Tasks (the board formerly labelled "Work") is visible to ANY
171
203
  // signed-in builder (incl. newcomer Xenos). Claimability is controlled
172
204
  // per-task, not by hiding the page — every row carries a rank tag and the
@@ -185,7 +217,7 @@
185
217
  // page can still gate cosmetically wider in the sidebar than the API it
186
218
  // fronts, showing the link to signed-in builders while a stranger who
187
219
  // already has the URL can still open it.
188
- { id: 'blockers', label: 'Blockers', icon: 'admin', href: '/blockers', gate: 'signed' },
220
+ { id: 'blockers', label: 'Blockers', icon: 'blockers', href: '/blockers', gate: 'signed' },
189
221
  // task 1003744 (goal 1000111): the Board Room lives HERE, in the project's
190
222
  // own group, and gates on the atom its page gates on.
191
223
  //
@@ -211,7 +243,7 @@
211
243
  // /me (boardVotesNeed), pre-filtered to what the vote route would accept —
212
244
  // so the badge counts ballots this viewer can actually cast, never "items
213
245
  // open somewhere".
214
- { id: 'board-room', label: 'Board Room', icon: 'admin', href: '/board-room', gate: 'perm:board.vote.cast', count: 'board_votes' },
246
+ { id: 'board-room', label: 'Board Room', icon: 'boardroom', href: '/board-room', gate: 'perm:board.vote.cast', count: 'board_votes' },
215
247
  // The governor city (task 1004524): the owner put it on the sidebar on
216
248
  // 2026-10-08, reversing "leave it off". It is the Governing mode's lead tab
217
249
  // (craft-mode.js folds that mode's other tabs into a dropdown under it).
@@ -239,7 +271,7 @@
239
271
  // that caused it. gate 'signed' — there is no permission gate on having a
240
272
  // thought, and the route reads the VIEWER's own trail, so there is
241
273
  // nothing here a rank could protect.
242
- { id: 'thinking', label: 'Your thinking', icon: 'goals', href: '/thinking', gate: 'signed' },
274
+ { id: 'thinking', label: 'Your thinking', icon: 'thinking', href: '/thinking', gate: 'signed' },
243
275
  // task 1003117 (R06 of goal 1000074): the Studio — artist mode, "a place an
244
276
  // artist wants to be, not a form". Here and not in Docs (a place you ACT,
245
277
  // not reference) or You (its subject is the product, not a person).
@@ -286,12 +318,12 @@
286
318
  // other builders reaching you — not your own numbers (that is You).
287
319
  // task 1003942: the second consumer of the count badge. `help_requests` is
288
320
  // the need id builder-needs.js emits with a `count`; applyCounts reads it.
289
- { id: 'collab', label: 'Collab', icon: 'leaderboard', href: '/collab', gate: 'signed', count: 'help_requests' },
321
+ { id: 'collab', label: 'Collab', icon: 'collab', href: '/collab', gate: 'signed', count: 'help_requests' },
290
322
  // task 1003441: was the #leaderboard hash view on the home page, which is
291
323
  // why this group read as one real page plus a link into index.html. Now a
292
324
  // page, so no `view` key. Labelled Builders — 'roster' is the word
293
325
  // modules/hall-ui/CLAUDE.md uses, but Builders is what a reader scans for.
294
- { id: 'roster', label: 'Builders', icon: 'leaderboard', href: '/roster', gate: 'signed' },
326
+ { id: 'roster', label: 'Builders', icon: 'roster', href: '/roster', gate: 'signed' },
295
327
  // task 1003444: 'People', the cross-project directory, was deleted (owner
296
328
  // call 2026-09-25, ADR 0337 D5). Cross-project discovery is the hub's, and
297
329
  // /people redirects to /roster.
@@ -302,8 +334,8 @@
302
334
  // are for the first-session newcomer above all — so the links follow it
303
335
  // down to 'signed'. Leaving them at 'member' would have ungated the pages
304
336
  // while still hiding them from the only readers the change was for.
305
- { id: 'primer', label: 'Primer', icon: 'docs', href: '/primer', gate: 'signed' },
306
- { id: 'ranks', label: 'Ranks', icon: 'docs', href: '/ranks', gate: 'signed' },
337
+ { id: 'primer', label: 'Primer', icon: 'primer', href: '/primer', gate: 'signed' },
338
+ { id: 'ranks', label: 'Ranks', icon: 'ranks', href: '/ranks', gate: 'signed' },
307
339
  // task 2175: /drachmae had no nav entry at all — reachable only from prose
308
340
  // links since task 2174 moved the bounty rate card onto it. It is reference
309
341
  // material (the reward schedule + how karma differs), so it belongs in Docs,
@@ -311,16 +343,16 @@
311
343
  // currency: the page covers karma too, which is not currency, and "Economy"
312
344
  // stays right whatever `branding.currency.label` is set to. Page is public
313
345
  // like /ranks — 'signed' is the same cosmetic "show it once you're in".
314
- { id: 'economy', label: 'Economy', icon: 'docs', href: '/drachmae', gate: 'signed' },
346
+ { id: 'economy', label: 'Economy', icon: 'economy', href: '/drachmae', gate: 'signed' },
315
347
  // task 1004473: every skill and every bongos CLI verb, read live. Its page and
316
348
  // both reads behind it are sign-in gated, so 'signed' is the real floor here.
317
- { id: 'commands', label: 'Commands', icon: 'docs', href: '/commands', gate: 'signed' },
318
- { id: 'diagrams', label: 'Diagrams', icon: 'docs', href: '/diagrams', gate: 'signed' },
319
- { id: 'atlas', label: 'Repo Atlas', icon: 'docs', href: '/atlas', gate: 'member' },
320
- { id: 'modules', label: 'Modules', icon: 'docs', href: '/modules', gate: 'member' },
349
+ { id: 'commands', label: 'Commands', icon: 'commands', href: '/commands', gate: 'signed' },
350
+ { id: 'diagrams', label: 'Diagrams', icon: 'diagrams', href: '/diagrams', gate: 'signed' },
351
+ { id: 'atlas', label: 'Repo Atlas', icon: 'atlas', href: '/atlas', gate: 'member' },
352
+ { id: 'modules', label: 'Modules', icon: 'modules', href: '/modules', gate: 'member' },
321
353
  // The rendered OpenAPI reference lives on the APEX (/docs), not this builders
322
354
  // subdomain — external so the rewrite shim doesn't turn it into /builders/docs.
323
- { id: 'apidocs', label: 'API docs', icon: 'docs', href: '/docs', gate: 'signed', external: true },
355
+ { id: 'apidocs', label: 'API docs', icon: 'apidocs', href: '/docs', gate: 'signed', external: true },
324
356
  ] },
325
357
  // Relabeled 'Archon' → 'Governance' in ADR 0157, then → 'Government' by ADR 0174: these pages are no longer
326
358
  // Archon-only, so a group header reading "Archon" over items a Metic can open
@@ -331,12 +363,12 @@
331
363
  // xenos floor) rather than this group's rank, and a group whose audience is
332
364
  // Metic+ oversight could not honestly hold it. Everything below IS a rank
333
365
  // surface, so this group's gate and audience are unchanged.
334
- { id: 'watch', label: 'Watch', icon: 'admin', href: '/watch', gate: 'government' },
335
- { id: 'gate', label: 'Gate', icon: 'admin', href: '/gate', gate: 'government' },
336
- { id: 'sessions', label: 'Sessions', icon: 'admin', href: '/sessions', gate: 'government' },
366
+ { id: 'watch', label: 'Watch', icon: 'watch', href: '/watch', gate: 'government' },
367
+ { id: 'gate', label: 'Gate', icon: 'gate', href: '/gate', gate: 'government' },
368
+ { id: 'sessions', label: 'Sessions', icon: 'sessions', href: '/sessions', gate: 'government' },
337
369
  // The project's own fundamentals (task 1003280, goal 1000072). Mirrors the
338
370
  // server's requireGovernmentPage floor; cosmetic only, as every item here is.
339
- { id: 'project-settings', label: 'Project settings', icon: 'admin', href: '/project-settings', gate: 'government' },
371
+ { id: 'project-settings', label: 'Project settings', icon: 'settings', href: '/project-settings', gate: 'government' },
340
372
  // The deploy door (task 1003159, ADR 0293). Gated 'archon' here to mirror the
341
373
  // `core.pin.move` atom its page and both its routes check — cosmetic only, as
342
374
  // every item in this list is; the wall is server-side.
@@ -346,18 +378,18 @@
346
378
  // dropped where the module is off — and since task 1004296 it STAYS dropped: /deploy
347
379
  // is the platform hall's page alone, and every other hall takes its update from
348
380
  // Settings → Software update, whose Update button carries the hub hand-off.
349
- { id: 'deploy', label: 'Deploy', icon: 'admin', href: '/deploy', gate: 'archon', module: 'provisioning render-deploy' },
381
+ { id: 'deploy', label: 'Deploy', icon: 'deploy', href: '/deploy', gate: 'archon', module: 'provisioning render-deploy' },
350
382
  // task 1001949: what each hosted project costs. 'government' mirrors the metic floor
351
383
  // of `provisioning.fleet.manage`, the atom its page and its one read check (the
352
384
  // Deploy precedent above); dropped where the provisioning module is not enabled.
353
- { id: 'fleet', label: 'Fleet costs', icon: 'admin', href: '/fleet', gate: 'government', module: 'provisioning' },
385
+ { id: 'fleet', label: 'Fleet costs', icon: 'fleet', href: '/fleet', gate: 'government', module: 'provisioning' },
354
386
  // BV1.R113: the roles-and-permissions tab (ADR 0151). The one genuinely
355
387
  // Archon-only item the 'government' gate comment anticipated — cosmetic
356
388
  // only. The wall is the `government.manage` atom checked on each of the
357
389
  // tab's nine data routes (route-rank-check.js); the page SHELL itself is
358
390
  // metic+, because R16 made /government three rooms and only this one is
359
391
  // the archon surface.
360
- { id: 'government', label: 'Permissions', icon: 'admin', href: '/government', gate: 'archon' } // NOT 'Ranks': /ranks is the signed-in ladder explainer and two nav items
392
+ { id: 'government', label: 'Permissions', icon: 'permissions', href: '/government', gate: 'archon' } // NOT 'Ranks': /ranks is the signed-in ladder explainer and two nav items
361
393
  // reading 'Ranks' is the confusion this rename exists to remove. This tab
362
394
  // is where a rank's permissions are granted, so it says what it does.,
363
395
  ] },
@@ -205,7 +205,7 @@
205
205
 
206
206
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
207
207
  <script src="/builders/craft-mode.js?v=2026-10-08-city-tab"></script>
208
- <script src="/builders/shell.js?v=2026-10-08-city-tab"></script>
208
+ <script src="/builders/shell.js?v=2026-10-08-glyph-fix"></script>
209
209
  <script src="/builders/palette.js" defer></script>
210
210
  <script src="/builders/city-draw.js?v=2026-10-04-pins1"></script>
211
211
  <script src="/builders/street-draw.js?v=2026-10-04-pins1"></script>
@@ -184,7 +184,7 @@
184
184
 
185
185
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
186
186
  <script src="/builders/craft-mode.js?v=2026-10-08-city-tab"></script>
187
- <script src="/builders/shell.js?v=2026-10-08-city-tab"></script>
187
+ <script src="/builders/shell.js?v=2026-10-08-glyph-fix"></script>
188
188
  <script src="/builders/palette.js" defer></script>
189
189
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
190
190
  <!-- The kit, for its ONE renderer: the full-size ship-time visual figure
@@ -56,7 +56,7 @@
56
56
  time so the chrome exists before the deferred page scripts execute. -->
57
57
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
58
58
  <script src="/builders/craft-mode.js?v=2026-10-08-city-tab"></script>
59
- <script src="/builders/shell.js?v=2026-10-08-city-tab"></script>
59
+ <script src="/builders/shell.js?v=2026-10-08-glyph-fix"></script>
60
60
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
61
61
  <script src="/builders/palette.js" defer></script>
62
62
  <!-- shared frontend toolkit (window.OTB) — loaded FIRST so task.js can pull from it. -->
@@ -140,7 +140,7 @@
140
140
 
141
141
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
142
142
  <script src="/builders/craft-mode.js?v=2026-10-08-city-tab"></script>
143
- <script src="/builders/shell.js?v=2026-10-08-city-tab"></script>
143
+ <script src="/builders/shell.js?v=2026-10-08-glyph-fix"></script>
144
144
  <script src="/builders/palette.js" defer></script>
145
145
 
146
146
  <script src="/builders/idea-objects.js?v=2026-09-27-split"></script>
@@ -189,8 +189,27 @@
189
189
  // another host than the editor's cannot be framed: a hall page can, a landing
190
190
  // or status page cannot. For those the split view is a second window docked to
191
191
  // the bottom half of the screen.
192
- function canFrame(url, loc) {
193
- try { return new URL(url, loc.href || loc.origin).origin === (loc.origin || new URL(loc.href).origin); } catch (_) { return false; }
192
+ //
193
+ // The SIBLINGS (task 1004393): once the proxy answers the apex and status hosts
194
+ // with `frame-ancestors 'self' https://builders.<apex>` instead of SAMEORIGIN,
195
+ // those two pages may split in place too. Until then framing them shows the
196
+ // browser's refusal page, so FRAME_SIBLINGS stays false and flipping it is the
197
+ // step after the proxy change (the owner's steps are on task 1004393). Only the
198
+ // apex and status.<apex> are siblings, never another subdomain: the proxy keeps
199
+ // every other host unframeable, sign-in and settings included.
200
+ const FRAME_SIBLINGS = false;
201
+ function siblingOrigins(loc) {
202
+ const here = new URL(loc.href || loc.origin);
203
+ if (!/^builders\./.test(here.hostname)) return [];
204
+ const apex = here.host.replace(/^builders\./, '');
205
+ return [`${here.protocol}//${apex}`, `${here.protocol}//status.${apex}`];
206
+ }
207
+ function canFrame(url, loc, siblings = FRAME_SIBLINGS) {
208
+ try {
209
+ const origin = new URL(url, loc.href || loc.origin).origin;
210
+ if (origin === (loc.origin || new URL(loc.href).origin)) return true;
211
+ return siblings === true && siblingOrigins(loc).includes(origin);
212
+ } catch (_) { return false; }
194
213
  }
195
214
  function splitWindowFeatures(scr) {
196
215
  const s = scr || {};
@@ -229,7 +248,7 @@
229
248
  return null;
230
249
  }
231
250
 
232
- const api = { OPEN_WORDS, openChoice, clampSplit, splitFromPointer, splitFromKey, canFrame, splitWindowFeatures, HINT, nextLineKey, confirmedCount, hintText, confirmStoreKey, parseConfirmed, NOTES, sectionsOf, isChanged, noteFor, draftBody, changedCount, counterText, initialValues, rowsFor, liveUrl, uploadedLabel, uploadRefusal, displayTitle, pageIdFrom };
251
+ const api = { OPEN_WORDS, openChoice, clampSplit, splitFromPointer, splitFromKey, canFrame, FRAME_SIBLINGS, splitWindowFeatures, HINT, nextLineKey, confirmedCount, hintText, confirmStoreKey, parseConfirmed, NOTES, sectionsOf, isChanged, noteFor, draftBody, changedCount, counterText, initialValues, rowsFor, liveUrl, uploadedLabel, uploadRefusal, displayTitle, pageIdFrom };
233
252
  if (typeof window !== 'undefined') window.OTBTweakEditor = api;
234
253
  if (typeof module !== 'undefined' && module.exports) module.exports = api;
235
254
  })();
@@ -144,7 +144,7 @@
144
144
 
145
145
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
146
146
  <script src="/builders/craft-mode.js?v=2026-10-08-city-tab"></script>
147
- <script src="/builders/shell.js?v=2026-10-08-city-tab"></script>
147
+ <script src="/builders/shell.js?v=2026-10-08-glyph-fix"></script>
148
148
  <script src="/builders/palette.js" defer></script>
149
149
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
150
150
  <script src="/builders/brand-holes.js?v=2026-09-29-aqfix"></script>
@@ -167,7 +167,7 @@
167
167
  chrome exists before the page script runs. -->
168
168
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
169
169
  <script src="/builders/craft-mode.js?v=2026-10-08-city-tab"></script>
170
- <script src="/builders/shell.js?v=2026-10-08-city-tab"></script>
170
+ <script src="/builders/shell.js?v=2026-10-08-glyph-fix"></script>
171
171
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
172
172
  <script src="/builders/palette.js" defer></script>
173
173
  <!-- shared frontend toolkit (window.OTB) — loaded before watch.js, which pulls from it. -->
@@ -173,7 +173,7 @@
173
173
  time so the chrome exists before the deferred page scripts execute. -->
174
174
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
175
175
  <script src="/builders/craft-mode.js?v=2026-10-08-city-tab"></script>
176
- <script src="/builders/shell.js?v=2026-10-08-city-tab"></script>
176
+ <script src="/builders/shell.js?v=2026-10-08-glyph-fix"></script>
177
177
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
178
178
  <script src="/builders/palette.js" defer></script>
179
179
  <!-- shared frontend toolkit (window.OTB) — loaded FIRST so work.js can pull from it. -->
@@ -27,7 +27,7 @@ The paper is paper and the room is the room whatever the hall's theme: the stage
27
27
  - **Boxes grow to their words** rather than the artboard's rows 1 or 2.
28
28
  - **A refused upload opens a panel** (task 1004354, `#tw-refused`): the "is in" panel's glass and scrim over the whole window, a headline per refusal (`lib.uploadRefusal`), the server's words, the lines it names, "what to do", and "download this page again" or close (Escape too). An artist uploads from the bar, often far down the page, where the paper's notice was off screen. The notice stays on the paper after the panel closes. The kit has no file-upload action, so the panel is proven in `tests/hall_tweak_editor.mjs` and by shots of a real browser at 1440 and 390, not by a states.json state.
29
29
  - **The check, the restore and Shift + Enter (task 1004385, owner-asked).** Beside each line a round restore button (only on a changed line: it puts the page's words back and takes the check off) and a round green check. A checked line takes a light green wash; the owner picked the check over a tint alone. **Shift + Enter** in a line checks it and lands in the next, and a green hint above the words says so (with "N of M checked"), because it is the quickest way through a page. Enter alone is unchanged. The checks are the artist's place-keeper and stay in the browser (`localStorage`, keyed by page and reading hash, so a page that moved never ticks the wrong words); the server has no such field and `WRITES` is unchanged. A page the caller cannot write has none of it. Rules in `tweak-editor-lib.js` (`nextLineKey`, `confirmedCount`, `parseConfirmed`, `confirmStoreKey`, `hintText`).
30
- - **No page title or sub text at the top of the paper, and "open the page" is a menu (task 1004386, owner-asked).** The bar already names the page, so the paper opens on its first section; the h1 stays, visually hidden, for the one-h1 floor. "open the page" is a **split button** (owner-amended mid-build): the main half repeats the artist's last choice ("open the page ↗" for a new tab, "open in split view"), and the ▾ beside it opens **open in new tab** and **open in split view**. Click, keyboard and touch; hover alone opens nothing, because a hover menu closes when the pointer wanders and does not exist on touch. Split view has a **draggable divider** (mouse, touch, or the arrow keys and Home/End on the focused divider; the live page takes 20% to 80% of the window, `lib.clampSplit`), and the frame stops taking the pointer while it is dragged. The choice, the size and whether split view was open are remembered (`hall-tweak-open-as`, `hall-tweak-split-size`, `hall-tweak-split`), so the next page opens the same way; a split that would be a second window never reopens on load, because a browser blocks a popup nobody clicked. At phone width the live page takes at most half the screen, whatever the saved size, because the two-row bar is tall there. Split view pins the live page under the words (words above, page below, both scroll; "close split view" unloads it; the frame loads only when opened). **The live site answers `X-Frame-Options: SAMEORIGIN`** (set at the proxy in front of the app, not in this repo), so only a page on the editor's own host can be framed. A landing or status page is on another host, so its split view opens the page in a second window docked to the bottom half of the screen (`lib.canFrame`, `lib.splitWindowFeatures`). Allowing framing from the builders host (a `frame-ancestors` rule at the proxy) would let those pages split in place too.
30
+ - **No page title or sub text at the top of the paper, and "open the page" is a menu (task 1004386, owner-asked).** The bar already names the page, so the paper opens on its first section; the h1 stays, visually hidden, for the one-h1 floor. "open the page" is a **split button** (owner-amended mid-build): the main half repeats the artist's last choice ("open the page ↗" for a new tab, "open in split view"), and the ▾ beside it opens **open in new tab** and **open in split view**. Click, keyboard and touch; hover alone opens nothing, because a hover menu closes when the pointer wanders and does not exist on touch. Split view has a **draggable divider** (mouse, touch, or the arrow keys and Home/End on the focused divider; the live page takes 20% to 80% of the window, `lib.clampSplit`), and the frame stops taking the pointer while it is dragged. The choice, the size and whether split view was open are remembered (`hall-tweak-open-as`, `hall-tweak-split-size`, `hall-tweak-split`), so the next page opens the same way; a split that would be a second window never reopens on load, because a browser blocks a popup nobody clicked. At phone width the live page takes at most half the screen, whatever the saved size, because the two-row bar is tall there. Split view pins the live page under the words (words above, page below, both scroll; "close split view" unloads it; the frame loads only when opened). **The live site answers `X-Frame-Options: SAMEORIGIN`** (set at the proxy in front of the app, not in this repo), so only a page on the editor's own host can be framed. A landing or status page is on another host, so its split view opens the page in a second window docked to the bottom half of the screen (`lib.canFrame`, `lib.splitWindowFeatures`). **Letting them split in place (task 1004393)** is two steps, in this order. First the proxy answers the apex and `status.<apex>` with `<redacted>: frame-ancestors 'self' https://builders.<apex>` in place of `X-Frame-Options: SAMEORIGIN`, and every other host keeps SAMEORIGIN (sign-in and settings stay unframeable). Then `FRAME_SIBLINGS` in `tweak-editor-lib.js` flips to `true`. `lib.canFrame` then also admits exactly those two origins, same scheme and port, and only from the builders host; never another subdomain or a lookalike. Flipping it before the proxy changes would put the browser's refusal page in the split, which is why it ships off.
31
31
  - **Not on the artboard at all:** the phone layout (the bar becomes a two-row glass card, notes drop under their lines), the shell's full-screen button top right, the read-only and refusal notices on the paper, the chrome view (the switch off), and light mode.
32
32
 
33
33
  ## The proof
@@ -1696,7 +1696,7 @@ summary{min-height:24px;padding:3px 0;}
1696
1696
  <div class="choices" role="group" aria-label="Starting point">
1697
1697
  <button type="button" class="choice" id="c-green" aria-pressed="true">
1698
1698
  <b>A new repo</b>
1699
- <p>Public, set up for you</p>
1699
+ <p>Private, set up for you</p>
1700
1700
  </button>
1701
1701
  <button type="button" class="choice" id="c-adopt" aria-pressed="false">
1702
1702
  <b>A repo I have</b>
@@ -1726,7 +1726,7 @@ summary{min-height:24px;padding:3px 0;}
1726
1726
  <input type="text" id="ghNewName" placeholder="moonbase" autocomplete="off" maxlength="100" aria-label="New repository name">
1727
1727
  <!-- visibility is stated on EVERY repo path (task 1002721); updateFreshPreview()
1728
1728
  replaces this floor with the same claim plus the resolved repo URL -->
1729
- <div class="hint" id="ghFreshHint">A new <strong>public</strong> repo. For private code, pick “A repo I have”.</div>
1729
+ <div class="hint" id="ghFreshHint">A new <strong>private</strong> repo. You can make it public later.</div>
1730
1730
  </div>
1731
1731
  <div class="row2" id="manualHome">
1732
1732
  <label class="wizField"><span>GitHub owner <small>(user or org)</small></span>
@@ -5291,9 +5291,9 @@ summary{min-height:24px;padding:3px 0;}
5291
5291
  hint.className = 'hint err'; hint.innerHTML = 'You already have a repo named <strong>' + esc(state.repo) + '</strong> — pick another name, or adopt it instead.';
5292
5292
  } else {
5293
5293
  /* Visibility stated up front (task 1002694 / idea 1000684): repos WE create are
5294
- public by design (github-repos.js — open-source, capture-resistant); the
5295
- private path is adopting a private repo the owner makes themselves. */
5296
- hint.className = 'hint'; hint.innerHTML = 'A new <strong>public</strong> repo at <code style="font-family:var(--font-mono)">github.com/' + esc(myLogin()) + '/' + esc(state.repo || 'name') + '</code>. For private code, make the repo on GitHub and pick “A repo I have”.';
5294
+ private by default (github-repos.js, ADR 0370); the owner can make one
5295
+ public later from the project's manage page. */
5296
+ hint.className = 'hint'; hint.innerHTML = 'A new <strong>private</strong> repo at <code style="font-family:var(--font-mono)">github.com/' + esc(myLogin()) + '/' + esc(state.repo || 'name') + '</code>. You can make it public later.';
5297
5297
  }
5298
5298
  }
5299
5299
  $('ghNewName').addEventListener('input', function () { state.repo = this.value.trim(); updateFreshPreview(); saveDraft(); });
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.78",
3
+ "version": "1.21.80",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.78",
9
+ "version": "1.21.80",
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.21.78",
3
+ "version": "1.21.80",
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",
@@ -9278,5 +9278,33 @@
9278
9278
  "id": "1004587",
9279
9279
  "text": "Goal pages now have a Plan with Claude button that copies a ready-made planning prompt."
9280
9280
  }
9281
+ ],
9282
+ "1.21.79": [
9283
+ {
9284
+ "id": "1004618",
9285
+ "text": "Dry-ran the hall-goal spec flow end to end; it works. The skill now documents the two rough edges found."
9286
+ },
9287
+ {
9288
+ "id": "1004639",
9289
+ "text": "The hall's new sidebar icons were stuck: one picture's drawing contained a run of numbers that looked like an internet address, so the release safety scan refused to package the update. The picture is redrawn identically witho"
9290
+ },
9291
+ {
9292
+ "id": "1003793",
9293
+ "text": "Store modules now get an Install score from real installs: each install or update quietly reports whether it worked (just yes/no and a short reason, nothing personal). Module authors can't pad their own score, and anyone can s"
9294
+ },
9295
+ {
9296
+ "id": "1004626",
9297
+ "text": "Every tab in the hall sidebar now has its own icon. Before, nineteen tabs shared just four pictures - all nine Government pages wore the same shield and every Docs page the same sheet of paper - so the collapsed sidebar was ha"
9298
+ }
9299
+ ],
9300
+ "1.21.80": [
9301
+ {
9302
+ "id": "1004645",
9303
+ "text": "New projects now get a private GitHub repository by default. Before, the code of every new project was public on the internet from the first save. Owners can still make their repo public later from the project page, and existi"
9304
+ },
9305
+ {
9306
+ "id": "1004393",
9307
+ "text": "The tweak editor is ready to show landing and status pages side by side with the words, instead of in a separate window. It stays off until a small web-server change is made on the host; those steps are waiting in the owner's"
9308
+ }
9281
9309
  ]
9282
9310
  }
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/module-assess-installs.js — the Install and Reliability parts of a
3
+ // module's assessment, worked out from what real installs report (task 1003793,
4
+ // ADR 0343 D1; reports from core_273, signal rows into core_265).
5
+ //
6
+ // WHY these two parts. Tests and Docs are things an author writes; Install and
7
+ // Reliability are what happens when OTHER people run the module, which is the
8
+ // one signal an author cannot fake. So two rules keep it honest:
9
+ // - the module's own author is never counted, and
10
+ // - each holder counts once per version for Install (core_273 keeps only their
11
+ // latest attempt), so one account retrying cannot outweigh many — and neither part is scored at
12
+ // all until MIN_HOLDERS different holders have reported (`pending` before,
13
+ // never a number resting on one friend's install).
14
+ //
15
+ // Install = round(100 × holders whose latest install worked / holders).
16
+ // Reliability = round(100 × holder-days with zero runtime errors / holder-days
17
+ // reported), over the last ERROR_DAY_WINDOW days.
18
+ // No reports → no_data (ADR 0343 D3: left out of the average, never a zero).
19
+ //
20
+ // What the reports may contain is fixed by the owner's privacy posture and
21
+ // enforced at intake (src/bongos/module-usage-reports.js): counts and short codes
22
+ // only, and nothing from a holder who switched reporting off.
23
+ //
24
+ // A new signal row is appended only when a part's result actually moves, so the
25
+ // append-only history (core_265) is not one row per report.
26
+ //
27
+ // node scripts/gds/module-assess-installs.js <store_module_versions.id> re-score + print
28
+
29
+ const { ERROR_DAY_WINDOW } = require('../../src/bongos/module-usage-reports');
30
+
31
+ const MIN_HOLDERS = 3;
32
+
33
+ // The not-yet-scored outcomes, shared by both parts. Pure.
34
+ function notYet(holders, what) {
35
+ if (!holders) return { outcome: 'no_data', score: null, sample_size: 0, reason: `no ${what} reported yet` };
36
+ return {
37
+ outcome: 'pending', score: null, sample_size: holders,
38
+ reason: `${holders} of the ${MIN_HOLDERS} installs needed have reported ${what}`,
39
+ };
40
+ }
41
+
42
+ // Install from each holder's LATEST attempt ({ ok, error_code } per holder). Pure.
43
+ function assessInstall(latest = []) {
44
+ const n = latest.length;
45
+ const failedCodes = {};
46
+ for (const r of latest) if (!r.ok) failedCodes[r.error_code] = (failedCodes[r.error_code] || 0) + 1;
47
+ const worked = n - Object.values(failedCodes).reduce((a, b) => a + b, 0);
48
+ const detail = { holders: n, worked, failed: n - worked, failed_codes: failedCodes };
49
+ if (n < MIN_HOLDERS) return { ...notYet(n, 'an install'), detail };
50
+ return {
51
+ outcome: 'scored', score: Math.round((100 * worked) / n), sample_size: n,
52
+ reason: `${worked} of ${n} installs worked`, detail,
53
+ };
54
+ }
55
+
56
+ // Reliability from daily counts ({ holder_kind, holder_ref, error_count } per
57
+ // holder-day). sample_size is distinct holders, the same unit as Install. Pure.
58
+ function assessReliability(days = []) {
59
+ const holders = new Set(days.map((d) => `${d.holder_kind}:${d.holder_ref}`)).size;
60
+ const clean = days.filter((d) => Number(d.error_count) === 0).length;
61
+ const errors = days.reduce((sum, d) => sum + Number(d.error_count), 0);
62
+ const detail = { holders, days: days.length, clean_days: clean, errors, window_days: ERROR_DAY_WINDOW };
63
+ if (holders < MIN_HOLDERS) return { ...notYet(holders, 'runtime errors'), detail };
64
+ return {
65
+ outcome: 'scored', score: Math.round((100 * clean) / days.length), sample_size: holders,
66
+ reason: `${clean} of ${days.length} days ran with no errors, across ${holders} installs`, detail,
67
+ };
68
+ }
69
+
70
+ // The author's own reports never count. Shared by both reads.
71
+ const NOT_AUTHOR = `NOT (r.holder_kind = 'builder' AND r.holder_ref = m.author_id::text)`;
72
+
73
+ async function readReports(versionId, pool) {
74
+ const { rows: latest } = await pool.query(
75
+ `SELECT r.ok, r.error_code
76
+ FROM module_install_reports r
77
+ JOIN store_module_versions v ON v.id = r.version_id
78
+ JOIN store_modules m ON m.module_key = v.module_key
79
+ WHERE r.version_id = $1 AND ${NOT_AUTHOR}`, [versionId]);
80
+ const { rows: days } = await pool.query(
81
+ `SELECT r.holder_kind, r.holder_ref, r.error_count
82
+ FROM module_runtime_error_counts r
83
+ JOIN store_module_versions v ON v.id = r.version_id
84
+ JOIN store_modules m ON m.module_key = v.module_key
85
+ WHERE r.version_id = $1 AND r.day >= current_date - $2::int AND ${NOT_AUTHOR}`,
86
+ [versionId, ERROR_DAY_WINDOW]);
87
+ return { latest, days };
88
+ }
89
+
90
+ const sameResult = (cur, s) => !!cur && cur.outcome === s.outcome
91
+ && (cur.score == null ? null : Number(cur.score)) === s.score
92
+ && Number(cur.sample_size) === s.sample_size;
93
+
94
+ // Re-score Install and Reliability for one version from its reports, append a
95
+ // signal row for each part whose result moved, then re-compose the overall score
96
+ // (module-assess-score.js). Resolves { ok, written: [part…], score }.
97
+ async function recordInstallSignals(versionId, { db } = {}) {
98
+ const pool = db || require('../../src/bongos/pool').pool;
99
+ const { latest, days } = await readReports(versionId, pool);
100
+ const next = { install: assessInstall(latest), reliability: assessReliability(days) };
101
+
102
+ const { rows: current } = await pool.query(
103
+ `SELECT DISTINCT ON (part) part, outcome, score, sample_size
104
+ FROM module_assessment_signals WHERE version_id = $1 AND part IN ('install', 'reliability')
105
+ ORDER BY part, measured_at DESC, id DESC`, [versionId]);
106
+ const byPart = Object.fromEntries(current.map((r) => [r.part, r]));
107
+
108
+ const written = [];
109
+ for (const part of ['install', 'reliability']) {
110
+ const s = next[part];
111
+ if (sameResult(byPart[part], s)) continue;
112
+ await pool.query(
113
+ `INSERT INTO module_assessment_signals (version_id, part, outcome, score, sample_size, reason, detail)
114
+ VALUES ($1, $2, $3, $4, $5, $6, $7)`,
115
+ [versionId, part, s.outcome, s.score, s.sample_size, s.reason, JSON.stringify(s.detail)]);
116
+ written.push(part);
117
+ }
118
+ const { score } = written.length
119
+ ? await require('./module-assess-score').recomposeScore(versionId, { db: pool })
120
+ : { score: null };
121
+ return { ok: true, written, signals: next, score };
122
+ }
123
+
124
+ async function main(argv) {
125
+ const id = argv[0];
126
+ if (!/^\d+$/.test(String(id || ''))) {
127
+ console.error('usage: node scripts/gds/module-assess-installs.js <store_module_versions.id>');
128
+ return 2;
129
+ }
130
+ const res = await recordInstallSignals(id);
131
+ for (const part of ['install', 'reliability']) {
132
+ const s = res.signals[part];
133
+ console.log(`${part}: ${s.outcome}${s.score != null ? ` ${s.score}/100` : ''} — ${s.reason}${res.written.includes(part) ? '' : ' (unchanged)'}`);
134
+ }
135
+ await require('./module-assess-score').printRecomposed(id);
136
+ return 0;
137
+ }
138
+
139
+ if (require.main === module) {
140
+ main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (e) => { console.error(e.stack || e.message); process.exitCode = 1; });
141
+ }
142
+
143
+ module.exports = { MIN_HOLDERS, assessInstall, assessReliability, recordInstallSignals };