@bongos/core 1.19.772 → 1.19.774

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 (97) hide show
  1. package/.bongos-core.json +204 -79
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +5 -1
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0083-modular-architecture-strangler-build-plan.md +1 -0
  8. package/docs/adr/0177-goal-membership-kind.md +26 -2
  9. package/docs/adr/0181-teardown-state-gate-and-address-retention.md +2 -0
  10. package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +1 -1
  11. package/docs/adr/0288-the-goal-wall-has-a-shared-floor-for-paths-no-module-owns.md +33 -0
  12. package/docs/adr/0289-an-unconsumed-contribution-is-a-ci-failure-not-a-boot-refusal.md +33 -0
  13. package/docs/adr/0290-disconnect-is-the-owner-initiated-exception-to-address-retention.md +40 -0
  14. package/docs/adr/README.md +3 -0
  15. package/docs/api/openapi.json +102 -11
  16. package/docs/api-reference.md +5 -4
  17. package/docs/copy-inventory.md +205 -190
  18. package/docs/copy-registry.json +435 -269
  19. package/docs/file-map.md +1 -0
  20. package/docs/module-api-changelog.md +4 -0
  21. package/docs/modules-contract.md +1 -1
  22. package/modules/builder-settings/render-prefs.js +281 -33
  23. package/modules/builder-settings/routes/settings.js +20 -12
  24. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  25. package/modules/dev-box/module.json +4 -0
  26. package/modules/hall-ui/public/settings-render.js +138 -0
  27. package/modules/hall-ui/public/settings.html +16 -9
  28. package/modules/hall-ui/public/settings.js +3 -70
  29. package/modules/lifecycle/db-tasks.js +7 -1
  30. package/modules/lifecycle/dead-deps.js +10 -2
  31. package/modules/lifecycle/ship-card.js +49 -13
  32. package/modules/lifecycle/task-list-limits.js +44 -0
  33. package/modules/platform-identity/migrations/platform_identity_021_drop_legacy_role.sql +94 -0
  34. package/modules/platform-identity/platform-identity.js +8 -19
  35. package/modules/platform-identity/project-catalog.js +24 -0
  36. package/modules/platform-identity/routes/sso.js +11 -14
  37. package/modules/platform-identity/tests/platform-identity.mjs +7 -9
  38. package/modules/provisioning/capacity-gate.js +54 -0
  39. package/modules/provisioning/catalog-bridge.js +2 -0
  40. package/modules/provisioning/disconnect.js +35 -0
  41. package/modules/provisioning/migrations/provisioning_016_disconnect.sql +21 -0
  42. package/modules/provisioning/module.json +1 -1
  43. package/modules/provisioning/provisioning.js +3 -3
  44. package/modules/provisioning/routes/disconnect.js +63 -0
  45. package/modules/provisioning/routes/provisioning.js +6 -6
  46. package/modules/public-landing/public/projects.html +116 -11
  47. package/modules/public-landing/public/projects.probes.json +3 -0
  48. package/modules/ui-design/kit/serve.js +3 -2
  49. package/modules/ui-design/module.json +1 -0
  50. package/package-lock.json +2 -2
  51. package/package.json +1 -1
  52. package/scripts/gds/address-park-sweep.sh +78 -0
  53. package/scripts/gds/audit-deps.js +38 -4
  54. package/scripts/gds/cli-lib.js +37 -0
  55. package/scripts/gds/fitness-migration-contraction.js +50 -0
  56. package/scripts/gds/fitness.js +18 -0
  57. package/scripts/gds/land-watch.js +8 -2
  58. package/scripts/gds/provision-config.js +4 -4
  59. package/scripts/gds/provision-disconnect.js +90 -0
  60. package/scripts/gds/provision-teardown.js +108 -0
  61. package/scripts/gds/provision.js +6 -65
  62. package/scripts/gds/seam-wiring-guard.js +273 -0
  63. package/scripts/gds/ship-finish.js +17 -10
  64. package/scripts/gds/ship-flow.js +28 -6
  65. package/scripts/gds/ship-resume.js +32 -4
  66. package/scripts/gds/start.js +13 -7
  67. package/src/bongos/auth-admission.js +7 -8
  68. package/src/bongos/auth-config.js +3 -2
  69. package/src/bongos/goal-scope-check.js +23 -8
  70. package/src/bongos/module-scope-map.js +40 -0
  71. package/src/module-api.js +1 -1
  72. package/src/module-loader/manifest-schema.js +79 -1
  73. package/tests/catalog_only_client.mjs +8 -5
  74. package/tests/fitness.mjs +22 -0
  75. package/tests/goal_scope_check.mjs +63 -1
  76. package/tests/hall_settings_world.mjs +57 -1
  77. package/tests/hub_disconnect_ui.mjs +105 -0
  78. package/tests/land_watch.mjs +44 -0
  79. package/tests/membership_kind_lockstep.mjs +86 -27
  80. package/tests/module-scope-map.mjs +39 -0
  81. package/tests/module_exports_unique_keys.mjs +70 -0
  82. package/tests/module_manifest.mjs +85 -0
  83. package/tests/project_catalog_forget.mjs +83 -0
  84. package/tests/projects_hub.mjs +10 -4
  85. package/tests/projects_hub_pre_uat.mjs +5 -3
  86. package/tests/provision_disconnect.mjs +231 -0
  87. package/tests/provision_teardown_address.mjs +230 -0
  88. package/tests/provisioning_capacity_gate.mjs +192 -0
  89. package/tests/provisioning_disconnect.mjs +231 -0
  90. package/tests/provisioning_restart.mjs +5 -5
  91. package/tests/public_landing_projects.mjs +4 -3
  92. package/tests/render_prefs.mjs +152 -0
  93. package/tests/seam_wiring_guard.mjs +234 -0
  94. package/tests/ship_card.mjs +67 -1
  95. package/tests/ship_resume.mjs +86 -12
  96. package/tests/sso_checkin_legacy_field_gone.mjs +246 -0
  97. package/tests/task_list_paging.mjs +178 -0
package/docs/file-map.md CHANGED
@@ -189,6 +189,7 @@
189
189
  │ ├── smoke-write.sh ← write-path smoke (create→claim→ship round-trip)
190
190
  │ ├── backup-dropin-sweep.sh ← (task 1003930) control-plane sweep: retires the temporary task-1003925 backup drop-in from each project whose pinned core carries the fix (1.19.750+), proving one backup succeeds without it first; survey by default, `--apply` to act; re-run after each pin moves
191
191
  │ ├── stray-unit-sweep.sh ← (task 1003939) control-plane sweep: the ledger's torn-down slugs that still have a service/backup unit loaded or on disk (a torn-down project holding memory outside the ADR 0285 count); survey by default, `--apply` disables, removes, reloads, reset-failed's; DB/dumps/checkout/Caddy left alone
192
+ │ ├── address-park-sweep.sh ← (task 1003947) control-plane sweep: the ledger's torn-down rows that still have a `<slug>.caddy` block or a hostname not yet parked (rows torn down before task 1002897 taught teardown to do both); survey by default, `--apply` removes the snippets, reloads Caddy and parks each non-reserved hostname at 192.0.2.1 via the DNS hook; the reserved rule, zone and apex come from the installed core
192
193
  │ ├── smoke-gds.js ← public-surface + 401-check smoke test (asserts /api/pms/* now 404s — removed in V3.R01-followup #308)
193
194
  │ ├── box.js ← (#598/ADR 0031) per-builder dev box lifecycle CLI: provision·park·wake·deprovision·sweep-idle·sweep-dormant·status (dry-run default, --apply to act; rank-gated)
194
195
  │ ├── smoke-box.sh ← (#598) hermetic box-lifecycle smoke (no DB/DO/network): runs tests/boxes.mjs + tests/cf_tunnel.mjs (#771 named tunnel) + route-rank + module-load
@@ -2018,5 +2018,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2018
2018
  landed since 1.19.770 with no explicit bump. run 35116254725. (task 1002620)
2019
2019
  1.19.772 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2020
2020
  landed since 1.19.771 with no explicit bump. run 35117652607. (task 1002620)
2021
+ 1.19.773 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2022
+ landed since 1.19.772 with no explicit bump. run 35118731311. (task 1002620)
2023
+ 1.19.774 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2024
+ landed since 1.19.773 with no explicit bump. run 35132992149. (task 1002620)
2021
2025
  ---------------------------------------------------------------------------
2022
2026
  ```
@@ -249,7 +249,7 @@ A module puts its SQL files under `modules/<key>/migrations/NNN_description.sql`
249
249
 
250
250
  Rules (enforced by the `BV1.R45` fitness check):
251
251
  - **Own your namespace** — every table must be prefixed `<key>_*`.
252
- - **Additive only** — `DROP TABLE`, `DROP COLUMN`, and destructive `ALTER` are rejected by the fitness check.
252
+ - **Additive only** — `DROP TABLE`, `DROP COLUMN`, and destructive `ALTER` are rejected by the fitness check. **One acknowledged exception** (task 1003092): the *contract phase* of an expand-then-contract rename ([ADR 0177](adr/0177-goal-membership-kind.md)) may carry exactly `ALTER TABLE <own_table> DROP COLUMN <legacy>` when the file bears the same `-- forward-unsafe: <reason>` pragma core migrations use — one statement, one column, your own table; `DROP TABLE`, `TRUNCATE` and a bare `DROP CONSTRAINT` stay rejected even under the pragma (`scripts/gds/fitness-migration-contraction.js`).
253
253
  - **Disable preserves data** — there is no down-migration; a disabled module's tables simply are never created (or are left untouched).
254
254
 
255
255
  ---
@@ -1,63 +1,280 @@
1
- // src/bongos/render-prefs.js — the per-builder "render widgets" knob (task 1279).
1
+ // modules/builder-settings/render-prefs.js — the per-builder CARD RICHNESS knob
2
+ // (task 1279 as a boolean; levels + profile derivation by task 1002518).
2
3
  //
3
- // Governs whether the deterministic session cards (start.js's /builder-start
4
+ // Governs how much the deterministic session cards (start.js's /builder-start
4
5
  // card + the ship-card lifecycle card: claimed → review → confirmed → shipped)
5
- // render as a rich in-feed HTML widget (via the app's show_widget tool) or as
6
- // the plain markdown / monospace fallback.
6
+ // spend on the reader: a rich in-feed HTML widget (via the app's show_widget
7
+ // tool), the plain monospace fallback, or nothing but the guaranteed one-line
8
+ // confirmation.
7
9
  //
8
10
  // Why it's a knob: the widget is nicer, but the agent must relay its full markup
9
11
  // VERBATIM into show_widget — reading it once and re-emitting it once — so every
10
12
  // byte of HTML is shuttled through the model twice, at every session-start,
11
13
  // claim, and ship stage. Measured: the /builder-start widget is ~4.7K tokens vs
12
14
  // ~0.5K for the markdown form (9×); the lifecycle card ~0.9K vs ~0.12K monospace
13
- // (7×). This knob lets a builder turn that token cost off. When off, the CLIs
14
- // emit ONLY the text form (no HTML is ever written or relayed) — so it's a real
15
- // saving, not just a visual change.
15
+ // (7×). This knob lets a builder spend or save that.
16
16
  //
17
17
  // Unlike the wandering knob ([[wandering-prefs]]) this is pure cost/taste, NOT a
18
- // trust gate — so there is NO rank cap: any builder may pick either value, and
19
- // the default is ON (the experience tasks 1273/1275 deliberately shipped).
18
+ // trust gate — so there is NO rank cap: any builder may pick any level.
19
+ //
20
+ // ---------------------------------------------------------------------------
21
+ // WHY LEVELS AND NOT A BOOLEAN (task 1002518, clause 6 of criterion
22
+ // `accessibility-fluency-profile`).
23
+ //
24
+ // The boolean conflated two independent questions — "how much explanation do I
25
+ // want?" and "how many tokens will I spend?" — and answered both with one
26
+ // switch. That left no way to ask for LESS noise without dropping to the plain
27
+ // text card, and no way to ask for MORE explanation than the widget's default
28
+ // (whose summary ships collapsed, so the part that actually explains the work is
29
+ // behind a click the reader has to know to make).
30
+ //
31
+ // The deeper problem is that it was the ONE surface knob that ignored the
32
+ // interaction profile. The builder who most needs an explanatory card — the
33
+ // non-technical one, who by definition has not opened Settings — got exactly the
34
+ // same card as an engineer who wants a one-liner and nothing else. That is the
35
+ // same failure interaction-prefs.js names in its own preamble: a profile that
36
+ // worked for everyone who had already opted out of it. Richness is not cost/taste
37
+ // alone; it is a fluency question, and fluency already has a profile. So the
38
+ // level DERIVES from that profile, and the knob becomes the override rather than
39
+ // the only input.
40
+ // ---------------------------------------------------------------------------
41
+ //
42
+ // THE LADDER, least to most. The ordering is the contract: index is richness,
43
+ // and every consumer asks a predicate (showsCard / showsWidget / widgetExpanded)
44
+ // rather than comparing level strings, so inserting a rung later is additive.
45
+ //
46
+ // minimal the guaranteed [otb-confirm] line only — no card at all.
47
+ // text the monospace / markdown card. No HTML is written or relayed.
48
+ // standard the HTML widget, summary collapsed. (the historical `true`)
49
+ // rich the HTML widget, summary open on arrival — the explanation is
50
+ // the point for this reader, so it is not hidden behind a click.
51
+ //
52
+ // `text` is the historical `false` and `standard` the historical `true`, so a
53
+ // builder who already flipped the toggle keeps precisely the card they chose.
20
54
  //
21
55
  // Storage: one reserved key ('widgets') inside the `behavior_prefs` jsonb column
22
- // on `builders` (migration 119 — the same blob the wandering knob uses). Value
23
- // is a scalar boolean; a missing key falls back to DEFAULT_ENABLED, so the
24
- // column stays sparse. applyPatch leaves sibling keys (e.g. 'wandering') alone.
56
+ // on `builders` (migration 119 — the same blob the wandering and interaction
57
+ // knobs use). The value is a level STRING; the legacy booleans are still read
58
+ // and still mean what they meant. A missing key falls back to the DERIVED level,
59
+ // so the column stays sparse and a builder who never touches this gets a card
60
+ // pitched at the profile they did set.
25
61
  //
26
62
  // This module is the single source of truth: GET/PATCH /api/gds/me/render + the
27
63
  // /me payload surface it, the /settings UI reads it, and the start/claim/ship
28
- // CLIs read the resolved boolean to decide whether to emit HTML at all.
64
+ // CLIs read the resolved level to decide what to emit.
65
+
66
+ const interactionPrefs = require('./interaction-prefs');
29
67
 
30
68
  const RESERVED_KEY = 'widgets';
69
+
70
+ // Ordered least → most rich. Index IS the richness rank; see the ladder note.
71
+ const LEVELS = ['minimal', 'text', 'standard', 'rich'];
72
+
73
+ // The historical boolean default (ON). Kept exported because the legacy boolean
74
+ // surface is still live: describeForApi still ships `widgets_enabled`, and CLIs
75
+ // pinned to an older core still read only that. It is also the honest answer for
76
+ // the unset default builder, whose derived level is `rich` — i.e. enabled.
31
77
  const DEFAULT_ENABLED = true;
32
78
 
33
- // ---- pure helpers ----
79
+ // The level each legacy boolean means. Chosen so an existing override is a
80
+ // no-op: whoever set the toggle keeps the exact card they picked.
81
+ const LEGACY_LEVEL = { true: 'standard', false: 'text' };
34
82
 
35
- // The raw override a builder has stored (the 'widgets' key off behavior_prefs),
36
- // or null when unset/invalid. Kept separate from the effective value so the UI
37
- // can show "you chose X" vs "the default (Y)".
38
- function overrideEnabled(behaviorPrefs) {
83
+ // ---- the ladder ------------------------------------------------------------
84
+
85
+ function isLevel(value) {
86
+ return typeof value === 'string' && LEVELS.includes(value);
87
+ }
88
+
89
+ function levelRank(level) {
90
+ const i = LEVELS.indexOf(level);
91
+ return i === -1 ? LEVELS.indexOf('standard') : i;
92
+ }
93
+
94
+ function clampRank(rank) {
95
+ return Math.max(0, Math.min(LEVELS.length - 1, rank));
96
+ }
97
+
98
+ // ---- predicates: what a level actually means to a card emitter -------------
99
+ // Consumers ask these, never `level === 'rich'`, so the ladder can grow.
100
+
101
+ // Does anything beyond the one-line [otb-confirm] get printed?
102
+ function showsCard(level) {
103
+ return levelRank(level) >= levelRank('text');
104
+ }
105
+
106
+ // Is HTML written and relayed into show_widget at all? This is the predicate the
107
+ // historical boolean was, and widgetsEnabled() is its alias.
108
+ function showsWidget(level) {
109
+ return levelRank(level) >= levelRank('standard');
110
+ }
111
+
112
+ // Does the widget arrive with its summary already open?
113
+ function widgetExpanded(level) {
114
+ return levelRank(level) >= levelRank('rich');
115
+ }
116
+
117
+ // The three predicates as one object, which is how a card emitter consumes them.
118
+ //
119
+ // WHY THIS SHAPE EXISTS: modules/lifecycle/ship-card.js draws the cards but may
120
+ // not require this file — a module imports only src/module-api.js, never a
121
+ // sibling module (ADR 0083, enforced by fitness.js). So the ladder cannot travel
122
+ // as a level string that lifecycle would have to interpret; it travels as
123
+ // answers. The CLI scripts (outside modules/, free to require both) call this
124
+ // and hand the result to emitCard. One ladder, one owner, no duplicated rungs.
125
+ function cardFlags(level) {
126
+ return {
127
+ level,
128
+ showCard: showsCard(level),
129
+ showWidget: showsWidget(level),
130
+ expanded: widgetExpanded(level),
131
+ };
132
+ }
133
+
134
+ // ---- derivation from the interaction profile -------------------------------
135
+ //
136
+ // The interaction profile lives in the SAME behavior_prefs blob (reserved key
137
+ // 'interaction'), so nothing here needs a second argument or a second read.
138
+ //
139
+ // The rule, stated so it can be argued with:
140
+ // base technical_level is the primary axis — it is the field that says how
141
+ // much explanation the reader wants. non_technical → rich, mixed →
142
+ // standard, engineer → text.
143
+ // -1 machinery: quiet. That knob's whole content is "report outcomes, not
144
+ // mechanism", and a lifecycle card IS mechanism — stage ribbons, claim
145
+ // bookkeeping, grade rows. A reader who asked for less of that should
146
+ // not be handed the richest possible rendering of it.
147
+ // +1 verbosity: detailed. Asks for fuller explanation; the card is where
148
+ // this knob can actually deliver some.
149
+ //
150
+ // verbosity: concise is deliberately NOT -1. It is the DEFAULT, so subtracting
151
+ // for it would push the default builder from `rich` down to `standard` — the
152
+ // accessible-by-default reader would get the less explanatory card, which is the
153
+ // exact inversion this task exists to fix. technical_level already sets the base
154
+ // low for readers who want terseness; concise must not tax them twice.
155
+ const BASE_BY_TECHNICAL_LEVEL = {
156
+ non_technical: 'rich',
157
+ mixed: 'standard',
158
+ engineer: 'text',
159
+ };
160
+
161
+ // The level a builder gets with no override — derived from their interaction
162
+ // profile. Pure; takes the same behavior_prefs blob as everything else here.
163
+ function derivedLevel(behaviorPrefs) {
164
+ const profile = interactionPrefs.effectiveProfile(behaviorPrefs);
165
+ const base = BASE_BY_TECHNICAL_LEVEL[profile.technical_level] || 'standard';
166
+ let rank = levelRank(base);
167
+ if (profile.machinery === 'quiet') rank -= 1;
168
+ if (profile.verbosity === 'detailed') rank += 1;
169
+ return LEVELS[clampRank(rank)];
170
+ }
171
+
172
+ // A short, human sentence saying WHY the derived level is what it is — the
173
+ // /settings panel shows it so "default" is never a black box.
174
+ function derivedReason(behaviorPrefs) {
175
+ const profile = interactionPrefs.effectiveProfile(behaviorPrefs);
176
+ const bits = [`technical level: ${profile.technical_level.replace(/_/g, '-')}`];
177
+ if (profile.machinery === 'quiet') bits.push('machinery: quiet');
178
+ if (profile.verbosity === 'detailed') bits.push('reply length: detailed');
179
+ return `From your interaction profile (${bits.join(', ')}).`;
180
+ }
181
+
182
+ // ---- overrides -------------------------------------------------------------
183
+
184
+ // The raw override a builder has stored, as a LEVEL, or null when unset/invalid.
185
+ // Legacy booleans are read through LEGACY_LEVEL so an old stored value keeps its
186
+ // meaning. Kept separate from the effective value so the UI can show
187
+ // "you chose X" vs "the default (Y)".
188
+ function overrideLevel(behaviorPrefs) {
39
189
  const raw = behaviorPrefs && behaviorPrefs[RESERVED_KEY];
40
- return typeof raw === 'boolean' ? raw : null;
190
+ if (isLevel(raw)) return raw;
191
+ if (typeof raw === 'boolean') return LEGACY_LEVEL[String(raw)];
192
+ return null;
193
+ }
194
+
195
+ // The resolved level — the value the CLIs act on. Override if set, else derived.
196
+ function resolveLevel(behaviorPrefs) {
197
+ return overrideLevel(behaviorPrefs) || derivedLevel(behaviorPrefs);
41
198
  }
42
199
 
43
- // The resolved boolean — the value the CLIs act on. Override if set, else the
44
- // default. This is the one function the start/claim/ship scripts care about.
200
+ // ---- the legacy boolean surface --------------------------------------------
201
+ // Unchanged in meaning and still the shape every pinned CLI reads: true exactly
202
+ // when the resolved level writes and relays HTML.
45
203
  function widgetsEnabled(behaviorPrefs) {
46
- const o = overrideEnabled(behaviorPrefs);
47
- return o === null ? DEFAULT_ENABLED : o;
204
+ return showsWidget(resolveLevel(behaviorPrefs));
205
+ }
206
+
207
+ // The legacy override accessor: the boolean a caller would have seen before
208
+ // levels existed. `minimal`/`text` → false, `standard`/`rich` → true, unset →
209
+ // null. Preserved so nothing reading `override` has to learn the ladder.
210
+ function overrideEnabled(behaviorPrefs) {
211
+ const lvl = overrideLevel(behaviorPrefs);
212
+ return lvl === null ? null : showsWidget(lvl);
48
213
  }
49
214
 
50
- // Everything the /me payload + the /settings UI need in one object.
215
+ // ---- the API payload -------------------------------------------------------
216
+
217
+ const LEVEL_META = {
218
+ minimal: {
219
+ label: 'Minimal',
220
+ blurb: 'One confirmation line and nothing else. The cheapest possible surface.',
221
+ },
222
+ text: {
223
+ label: 'Plain text',
224
+ blurb: 'The monospace card. Same information as the widget, no HTML relayed — cheaper.',
225
+ },
226
+ standard: {
227
+ label: 'Rich card',
228
+ blurb: 'The in-feed widget, with the detail folded behind a summary toggle.',
229
+ },
230
+ rich: {
231
+ label: 'Rich card, expanded',
232
+ blurb: 'The in-feed widget with the summary already open — most explanatory, most tokens.',
233
+ },
234
+ };
235
+
236
+ function levelCatalog() {
237
+ return LEVELS.map((value) => ({
238
+ value,
239
+ label: LEVEL_META[value].label,
240
+ blurb: LEVEL_META[value].blurb,
241
+ }));
242
+ }
243
+
244
+ // Everything the /me payload + the /settings UI need in one object. The four
245
+ // legacy keys stay first and stay exactly what they were: a CLI pinned to an
246
+ // older core reads `widgets_enabled` and is correct.
51
247
  function describeForApi(behaviorPrefs) {
248
+ // Resolve each fact ONCE. resolveLevel/overrideEnabled/derivedReason each
249
+ // re-walk the same prefs blob, so calling them field-by-field re-derived the
250
+ // override and the interaction profile two or three times per request.
251
+ const override = overrideLevel(behaviorPrefs);
252
+ const derived = derivedLevel(behaviorPrefs);
253
+ const level = override || derived;
52
254
  return {
53
- widgets_enabled: widgetsEnabled(behaviorPrefs),
255
+ widgets_enabled: showsWidget(level),
54
256
  default_enabled: DEFAULT_ENABLED,
55
- override: overrideEnabled(behaviorPrefs),
257
+ override: override === null ? null : showsWidget(override),
258
+ // --- levels (task 1002518) ---
259
+ level,
260
+ // The three booleans a card emitter needs, RESOLVED SERVER-SIDE. This is not
261
+ // a convenience: scripts/gds/start.js runs on a task-scoped dev box that
262
+ // carries scripts/ and src/bongos/ but not every modules/ directory (task
263
+ // 1003098), so a CLI cannot require this file to learn what a rung draws —
264
+ // tree-preflight fails the entry point that tries. Shipping the answers
265
+ // keeps the ladder in one place AND keeps the CLI dependency-free.
266
+ flags: cardFlags(level),
267
+ levels: levelCatalog(),
268
+ derived_level: derived,
269
+ derived_reason: derivedReason(behaviorPrefs),
270
+ override_level: override,
56
271
  };
57
272
  }
58
273
 
59
- // Validate a PATCH body: { widgets: <bool> | null }. `null` clears the override
60
- // (fall back to the default). Unknown keys → 422. Non-boolean values → 422.
274
+ // Validate a PATCH body: { widgets: <level> | <bool> | null }. `null` clears the
275
+ // override (fall back to the derived level). Booleans are still accepted and
276
+ // still mean what they meant — an older hall build PATCHing `{widgets:false}`
277
+ // must keep working against a newer core. Unknown keys → 422.
61
278
  // Returns { ok, patch } or { ok:false, errors }. An empty body is a valid no-op.
62
279
  function validatePatch(body) {
63
280
  if (body == null || typeof body !== 'object' || Array.isArray(body)) {
@@ -70,19 +287,34 @@ function validatePatch(body) {
70
287
  errors.push({ key, error: 'unknown_key', allowed: [RESERVED_KEY] });
71
288
  continue;
72
289
  }
73
- if (value === null || typeof value === 'boolean') {
74
- patch[RESERVED_KEY] = value; // null = clear → default
290
+ if (value === null) {
291
+ patch[RESERVED_KEY] = null; // null = clear → derived
292
+ continue;
293
+ }
294
+ if (isLevel(value)) {
295
+ patch[RESERVED_KEY] = value;
296
+ continue;
297
+ }
298
+ // Normalise the legacy boolean on the way IN, so storage only ever holds a
299
+ // level and the compatibility shim stays at this one boundary.
300
+ if (typeof value === 'boolean') {
301
+ patch[RESERVED_KEY] = LEGACY_LEVEL[String(value)];
75
302
  continue;
76
303
  }
77
- errors.push({ key, error: 'invalid_value', value, allowed: 'boolean or null' });
304
+ errors.push({
305
+ key,
306
+ error: 'invalid_value',
307
+ value,
308
+ allowed: `one of ${LEVELS.join(', ')} (or a boolean, or null to clear)`,
309
+ });
78
310
  }
79
311
  if (errors.length > 0) return { ok: false, errors };
80
312
  return { ok: true, patch };
81
313
  }
82
314
 
83
315
  // Apply a validated patch to a builder's existing behavior_prefs. Pure. null
84
- // deletes the key (fall back to default); a boolean overwrites. Other keys in
85
- // the blob (e.g. the wandering knob) are left untouched.
316
+ // deletes the key (fall back to derived); a level overwrites. Other keys in the
317
+ // blob (e.g. the wandering and interaction knobs) are left untouched.
86
318
  function applyPatch(existing, patch) {
87
319
  const next = { ...(existing || {}) };
88
320
  for (const [key, value] of Object.entries(patch)) {
@@ -95,8 +327,24 @@ function applyPatch(existing, patch) {
95
327
  module.exports = {
96
328
  RESERVED_KEY,
97
329
  DEFAULT_ENABLED,
330
+ LEVELS,
331
+ // the ladder
332
+ isLevel,
333
+ levelRank,
334
+ showsCard,
335
+ showsWidget,
336
+ widgetExpanded,
337
+ cardFlags,
338
+ levelCatalog,
339
+ // resolution
340
+ derivedLevel,
341
+ derivedReason,
342
+ overrideLevel,
343
+ resolveLevel,
344
+ // the legacy boolean surface
98
345
  overrideEnabled,
99
346
  widgetsEnabled,
347
+ // api
100
348
  describeForApi,
101
349
  validatePatch,
102
350
  applyPatch,
@@ -337,14 +337,19 @@ module.exports = function buildBuilderSettingsRouter() {
337
337
  }
338
338
  });
339
339
 
340
- // Per-builder "render widgets" knob (task 1279, shares the behavior_prefs
341
- // jsonb / migration 119). Whether the deterministic session cards render as a
342
- // rich in-feed HTML widget or the plain text fallback. The widget is nicer but
343
- // the agent must relay its full markup verbatim into show_widget (read +
344
- // re-emit) every session-start / claim / ship stage — a real token cost. This
345
- // toggle turns that off; when off the CLIs emit only text (no HTML relayed).
346
- // No rank cap (pure cost/taste, not a trust gate); default ON. render-prefs.js
347
- // is the single source of truth.
340
+ // Per-builder CARD RICHNESS knob (task 1279, a four-rung ladder since task
341
+ // 1002518; shares the behavior_prefs jsonb / migration 119). How much the
342
+ // deterministic session cards spend on the reader: nothing but the guaranteed
343
+ // confirmation line, the plain text card, the in-feed HTML widget, or that
344
+ // widget with its summary already open. The widget is nicer but the agent must
345
+ // relay its full markup verbatim into show_widget (read + re-emit) every
346
+ // session-start / claim / ship stage — a real token cost.
347
+ //
348
+ // No rank cap (pure cost/taste, not a trust gate). The DEFAULT is derived from
349
+ // the builder's interaction profile rather than being a constant, so a builder
350
+ // who never opens this panel still gets a card pitched at the profile they did
351
+ // set. render-prefs.js is the single source of truth for both the ladder and
352
+ // the derivation.
348
353
  //
349
354
  // rank: any-builder — own profile config; mirrors the skill/sound/wandering routes.
350
355
  router.get('/me/render', api.requireBuilder, async (req, res) => {
@@ -357,10 +362,13 @@ module.exports = function buildBuilderSettingsRouter() {
357
362
  }
358
363
  });
359
364
 
360
- // PATCH the render knob. Body: { widgets: <bool> | null } where null clears the
361
- // override (fall back to the default ON). Any other key, or a non-boolean
362
- // value → 422. Empty body is a no-op (returns current). Sibling behavior keys
363
- // (e.g. wandering) are preserved by applyPatch.
365
+ // PATCH the render knob. Body: { widgets: <level> | <bool> | null } where null
366
+ // clears the override (fall back to the DERIVED level). The legacy boolean is
367
+ // still accepted and normalised to the rung it always meant, so an older hall
368
+ // build keeps working against this core. Any other key, or a value that is
369
+ // neither a known level nor a boolean nor null → 422. Empty body is a no-op
370
+ // (returns current). Sibling behavior keys (wandering, interaction) are
371
+ // preserved by applyPatch.
364
372
  //
365
373
  // rank: any-builder — self-service profile edit; same posture as /me/wandering.
366
374
  router.patch('/me/render', api.requireBuilder, async (req, res) => {
@@ -704,6 +704,8 @@ function createClient(opts = {}) {
704
704
  getProvisioningInstancesId: (args) => request("GET", "/provisioning/instances/{id}", { hasBody: false }, args),
705
705
  // PATCH /provisioning/instances/{id}/detail — rank: any-builder — PATCH /provisioning/instances/:id/detail
706
706
  patchProvisioningInstancesIdDetail: (args) => request("PATCH", "/provisioning/instances/{id}/detail", { hasBody: true }, args),
707
+ // POST /provisioning/instances/{id}/disconnect — rank: any-builder — POST /provisioning/instances/:id/disconnect
708
+ postProvisioningInstancesIdDisconnect: (args) => request("POST", "/provisioning/instances/{id}/disconnect", { hasBody: true }, args),
707
709
  // POST /provisioning/instances/{id}/domain — rank: any-builder — POST /provisioning/instances/:id/domain
708
710
  postProvisioningInstancesIdDomain: (args) => request("POST", "/provisioning/instances/{id}/domain", { hasBody: true }, args),
709
711
  // POST /provisioning/instances/{id}/force-teardown — rank: metic+archon — POST /provisioning/instances/:id/force-teardown
@@ -10,6 +10,10 @@
10
10
  "pollers": ["box-lifecycle"],
11
11
  "uiSections": ["boxes"]
12
12
  },
13
+ "declarativeSeams": {
14
+ "pollers": ["box-lifecycle"],
15
+ "uiSections": ["boxes"]
16
+ },
13
17
  "provides": ["box.hasEverConnected"],
14
18
  "consumes": []
15
19
  }
@@ -0,0 +1,138 @@
1
+ // modules/hall-ui/public/settings-render.js — the "Rendering" panel on
2
+ // /builders/settings: how rich the deterministic session cards should be
3
+ // (task 1279's on/off toggle, a four-rung ladder since task 1002518).
4
+ //
5
+ // WHY ITS OWN FILE. It began as one row with one switch inside settings.js. As a
6
+ // ladder it needs a labelled option per rung plus a line explaining what the
7
+ // default resolved to and why — and settings.js sits against the 1500-line size
8
+ // ratchet, so the panel moved out on the settings-sessions.js / settings-
9
+ // interaction.js precedent. Extracting it took settings.js from 1493 to 1425.
10
+ //
11
+ // THE SHAPE WORTH KNOWING: unlike every other picker on this page, "default"
12
+ // here is not a constant — it is DERIVED from the builder's interaction profile
13
+ // (render-prefs.js maps technical level, machinery and reply length onto a
14
+ // rung). So the default option cannot just say "default"; it has to say which
15
+ // rung it landed on and which profile fields put it there, or the reader sees a
16
+ // choice the system made for reasons it never stated. The server ships both
17
+ // facts (`derived_level`, `derived_reason`) precisely so this panel can.
18
+ //
19
+ // PATCH body is { widgets: <level> | null } — null clears the override and
20
+ // returns the builder to the derived rung. The route still accepts the legacy
21
+ // boolean, which is what lets an older hall build keep working against a newer
22
+ // core; this panel only ever sends levels or null.
23
+
24
+ (() => {
25
+ 'use strict';
26
+
27
+ const API = '/api/bongos';
28
+ const { escapeHtml, $ } = window.OTB;
29
+ const toast = (msg) => window.OTB.toast(msg);
30
+ const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
31
+
32
+ const toAuth = () => window.location.assign(`${API}/auth/web/start`);
33
+
34
+ // The "let the profile decide" option carries the sentinel value '' rather
35
+ // than a level, so the change handler can tell "clear my override" from
36
+ // "pin me to the rung that happens to be the derived one today". They differ:
37
+ // a pinned rung survives a later change to the interaction profile.
38
+ const USE_DEFAULT = '';
39
+
40
+ function labelForLevel(data, level) {
41
+ const found = (Array.isArray(data && data.levels) ? data.levels : []).find((l) => l.value === level);
42
+ return (found && found.label) || level;
43
+ }
44
+
45
+ function render(data) {
46
+ const wrap = $('#render-rows');
47
+ if (!wrap) return;
48
+ const levels = Array.isArray(data && data.levels) ? data.levels : [];
49
+ if (levels.length === 0) {
50
+ wrap.innerHTML = window.OTBKit.emptyStateHtml('No rendering options registered.');
51
+ return;
52
+ }
53
+ const override = data.override_level || null;
54
+ const derived = data.derived_level;
55
+
56
+ // The default row first, so the reader meets "you already have a sensible
57
+ // answer, here is what it is" before the list of ways to overrule it.
58
+ const defaultRow =
59
+ `<label class="prow prow--pick" for="render-default">` +
60
+ `<input class="pradio" type="radio" name="render-level" id="render-default"` +
61
+ ` value="${USE_DEFAULT}"${override === null ? ' checked' : ''}>` +
62
+ '<span class="prow__main">' +
63
+ `<span class="prow__label">Match my interaction profile` +
64
+ `<span class="prow__tag">default (${escapeHtml(labelForLevel(data, derived))})</span></span>` +
65
+ `<span class="prow__desc">${escapeHtml(data.derived_reason || '')} Change that profile and this follows it.</span>` +
66
+ '</span></label>';
67
+
68
+ const rows = levels.map((l) => {
69
+ const id = `render-level-${l.value}`;
70
+ return (
71
+ `<label class="prow prow--pick" for="${id}">` +
72
+ `<input class="pradio" type="radio" name="render-level" id="${id}"` +
73
+ ` value="${escapeHtml(l.value)}"${override === l.value ? ' checked' : ''}>` +
74
+ '<span class="prow__main">' +
75
+ `<span class="prow__label">${escapeHtml(l.label)}</span>` +
76
+ `<span class="prow__desc">${escapeHtml(l.blurb)}</span>` +
77
+ '</span></label>'
78
+ );
79
+ }).join('');
80
+
81
+ wrap.innerHTML =
82
+ '<fieldset class="pgroup" id="render-level-group">' +
83
+ '<legend class="prow__label">Session card richness</legend>' +
84
+ '<p class="prow__desc">How much the claim and ship cards show. Richer cards explain more and cost more tokens; the confirmation line is always there whichever you pick.</p>' +
85
+ defaultRow + rows +
86
+ '</fieldset>';
87
+ }
88
+
89
+ async function save(value, input) {
90
+ const status = $('#render-status');
91
+ if (status) status.textContent = 'Saving…';
92
+ if (input) input.disabled = true;
93
+ try {
94
+ // The sentinel becomes an explicit null — the route's clear-the-override
95
+ // signal. Anything else is a level and is stored as the override.
96
+ const body = { widgets: value === USE_DEFAULT ? null : value };
97
+ const res = await api.request('PATCH', `${API}/me/render`, { body });
98
+ if (res.status === 401) { toAuth(); return; }
99
+ if (!res.ok) throw new Error(`render PATCH → ${res.status}`);
100
+ render(res.data);
101
+ const msg = value === USE_DEFAULT
102
+ ? `Saved — following your interaction profile (${labelForLevel(res.data, res.data.derived_level)}).`
103
+ : `Saved — ${labelForLevel(res.data, value)}.`;
104
+ if (status) status.textContent = msg;
105
+ toast(msg);
106
+ } catch (err) {
107
+ console.error('[settings] save render failed', err);
108
+ if (status) status.textContent = 'Could not save. Try again.';
109
+ toast('Could not save your rendering preference. Try again.');
110
+ if (input) input.disabled = false;
111
+ }
112
+ }
113
+
114
+ async function setup() {
115
+ try {
116
+ const res = await api.request('GET', `${API}/me/render`);
117
+ if (res.status === 401) { toAuth(); return; }
118
+ if (!res.ok) throw new Error(`render GET → ${res.status}`);
119
+ render(res.data);
120
+ $('#render-scroll').hidden = false;
121
+ // Delegate the change so post-render radios stay wired.
122
+ $('#render-rows').addEventListener('change', (e) => {
123
+ const r = e.target.closest('.pradio');
124
+ if (!r || r.disabled || !r.checked) return;
125
+ save(r.value, r);
126
+ });
127
+ } catch (err) {
128
+ // Its own try/catch, like every other section: a failing endpoint shows
129
+ // this panel's own error rather than taking the settings page down.
130
+ console.error('[settings] failed to load render', err);
131
+ $('#render-scroll').hidden = false;
132
+ $('#render-rows').innerHTML =
133
+ '<p class="voices-empty">Your rendering preference could not be loaded. Reload the page to try again.</p>';
134
+ }
135
+ }
136
+
137
+ setup();
138
+ })();