@bongos/core 1.19.485 → 1.19.487

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 (43) hide show
  1. package/.bongos-core.json +78 -43
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +7 -0
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0232-rot-is-derived-not-swept-and-water-is-the-only-new-verb.md +155 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +148 -4
  10. package/docs/api-reference.md +7 -4
  11. package/docs/copy-inventory.md +64 -61
  12. package/docs/copy-registry.json +97 -70
  13. package/docs/module-api-changelog.md +4 -0
  14. package/migrations/core_231_project_settings_and_attention_water.sql +163 -0
  15. package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
  16. package/modules/hall-ui/public/dom-utils.js +23 -1
  17. package/modules/hall-ui/public/goal-inbox.js +119 -1
  18. package/modules/hall-ui/public/hall-render.js +7 -8
  19. package/modules/hall-ui/public/settings.js +8 -7
  20. package/modules/lifecycle/help-requests.js +3 -3
  21. package/modules/lifecycle/migrations/lifecycle_006_help_requests_ui_craft.sql +38 -0
  22. package/modules/lifecycle/module.json +2 -1
  23. package/modules/lifecycle/rot.js +210 -0
  24. package/modules/lifecycle/routes/rot.js +160 -0
  25. package/modules/lifecycle/ship-card.js +4 -1
  26. package/modules/onboarding/newcomer-restock.js +53 -22
  27. package/modules/ui-design/module.json +1 -1
  28. package/package-lock.json +2 -2
  29. package/package.json +1 -1
  30. package/scripts/gds/fitness.js +9 -0
  31. package/scripts/gds/newcomer-floor.config.json +2 -1
  32. package/scripts/gds/newcomer-floor.js +7 -1
  33. package/scripts/gds/newcomer-floor.templates.json +43 -1
  34. package/src/bongos/db.js +3 -3
  35. package/src/bongos/project-settings.js +193 -0
  36. package/src/bongos/routes/me.js +2 -1
  37. package/src/module-api.js +29 -1
  38. package/tests/help_requests.mjs +21 -2
  39. package/tests/module_api.mjs +1 -0
  40. package/tests/module_contributions.mjs +67 -0
  41. package/tests/modules.mjs +19 -1
  42. package/tests/newcomer_restock.mjs +76 -0
  43. package/tests/rot_inbox.mjs +532 -0
@@ -1,5 +1,5 @@
1
1
  {
2
- "_doc": "The evergreen newcomer chores, grouped BY DISCIPLINE. Each discipline maps to a LIST of chore templates (was a single object before task 1488 — a discipline can now offer several first-builder chores so newcomers are not funnelled into one). The deterministic replenisher (scripts/gds/newcomer-floor.js) and the on-claim restock (modules/onboarding/newcomer-restock.js) stamp disposable claimable COPIES from each template; the TEMPLATE is never claimed and never ships. Each copy's source_ref is tmpl:<discipline>:<template_id>:<seq> (monotonic), so identical-title copies never collide with the dedup. Per-template `floor` sets that chore’s standing pool depth; if a template omits it, the discipline default in newcomer-floor.config.json applies. credits_reward, est_minutes and floor all live here (per chore). version_id is per-template so each chore lands on the correct track; it MUST point at an open (non-shipped) version — update it at version close, or the replenisher's open-version guard refuses to stamp and the floor silently stops refilling (task 1002958: the engineer pins still read the long-shipped GDS-V4 and artist+ideator read V2, a version this ladder never had, so NO discipline could restock). touches[] is intentionally empty so multiple same-discipline newcomers can claim copies concurrently; git-merge is the real collision detector. To ADD a chore: append an entry to the discipline's array. To CHANGE one: edit it here — no code change needed. Engineer offers a SAFE coding chore (add a unit test — touches only tests/, which auto-ships) and the docs-hygiene sweep (constrained to non-protected docs so a sub-Metic ship does not stall on the authority core).",
2
+ "_doc": "The evergreen newcomer chores, grouped BY DISCIPLINE. Each discipline maps to a LIST of chore templates (was a single object before task 1488 — a discipline can now offer several first-builder chores so newcomers are not funnelled into one). The deterministic replenisher (scripts/gds/newcomer-floor.js) and the on-claim restock (modules/onboarding/newcomer-restock.js) stamp disposable claimable COPIES from each template; the TEMPLATE is never claimed and never ships. Each copy's source_ref is tmpl:<discipline>:<template_id>:<seq> (monotonic), so identical-title copies never collide with the dedup. Per-template `floor` sets that chore’s standing pool depth; if a template omits it, the discipline default in newcomer-floor.config.json applies. credits_reward, est_minutes and floor all live here (per chore). version_id is per-template so each chore lands on the correct track; it MUST point at an open (non-shipped) version — update it at version close, or the replenisher's open-version guard refuses to stamp and the floor silently stops refilling (task 1002958: the engineer pins still read the long-shipped GDS-V4 and artist+ideator read V2, a version this ladder never had, so NO discipline could restock). touches[] is intentionally empty so multiple same-discipline newcomers can claim copies concurrently; git-merge is the real collision detector. To ADD a chore: append an entry to the discipline's array. To CHANGE one: edit it here — no code change needed. UI offers two browser-free chores (a token-drift fix measured by validate-design.js, and an accessible-name fix) so a newcomer whose only craft is ui is never starved for work on a machine with no Chrome/Edge for the kit — task 1003487. Engineer offers a SAFE coding chore (add a unit test — touches only tests/, which auto-ships) and the docs-hygiene sweep (constrained to non-protected docs so a sub-Metic ship does not stall on the authority core).",
3
3
  "engineer": [
4
4
  {
5
5
  "template_id": "unit-test-pass",
@@ -82,5 +82,47 @@
82
82
  "touches": [],
83
83
  "floor": 2
84
84
  }
85
+ ],
86
+ "ui": [
87
+ {
88
+ "template_id": "ui-token-drift-pass",
89
+ "version_id": "BONGOS-V1",
90
+ "discipline": "ui",
91
+ "kind": "cleanup",
92
+ "est_minutes": 40,
93
+ "credits_reward": 15,
94
+ "floor": 2,
95
+ "title": "Token-drift pass: replace one hardcoded colour in a platform stylesheet with the branding token it should have used",
96
+ "description": "## What you're doing\n\nEvery colour on a platform surface must come from the instance's branding pack — **the Fifteen Rule**: thirteen colours plus two font stacks in `theme.ui`, reached as a `var()` or a `color-mix()` of them. The only legal literal is pure black or white with alpha, used as a scrim, mask or halo. Drift happens anyway: someone types `#3b82f6` in a hurry and the surface stops following the instance's world. Your first ship as a UI builder: find **one** real hardcoded colour in **one** stylesheet and replace it with the right token.\n\nIt is identical for every newcomer and **always valuable** — the sheets are large, drift is continuous, and every literal you convert is one more thing that follows the pack when an instance changes its look. It is also **safe**: you change one declaration in one CSS file, which is not a protected surface.\n\n## Learn the fifteen first (five minutes, and the whole job depends on it)\n\n1. Open `config/branding.json` (falling back to `config/branding.neutral.json`) and read `theme.ui` — those thirteen colours and two font stacks are your entire palette.\n2. Open the root `DESIGN.md` — it names the derived tiers and which token means what (surface, ink, accent, hairline, danger…). If it names a rule, it wins.\n3. Skim `docs/recipes/ui-look-before-you-ship.md` for how the floors are measured.\n\n## Find a real target\n\nGrep a platform stylesheet for a raw colour and pick ONE that is genuinely wrong:\n\n```\ngrep -rnE '#[0-9a-fA-F]{3,8}\\b|rgba?\\(' modules/hall-ui/public/ modules/status-ui/public/ --include=*.css\n```\n\nGood targets: a hex or `rgb()` that duplicates a token's value, or one that is close to a token but slightly off (the worst kind — it looks fine until the pack changes).\n\n**Not a target, leave these alone:** `#000` / `#fff` / `rgba(0,0,0,…)` / `rgba(255,255,255,…)` used as a scrim, shadow, mask or halo — those are explicitly legal. A colour inside a vendored or generated file. Anything under `config/` (changing the pack itself is the owner's decision and an ADR, never a first ship).\n\n## Make the change\n\nReplace the literal with the token that carries the same MEANING, not merely the nearest number — `var(--ui-accent)` because the thing is an accent, not because the hex was blue-ish. If no token means what the literal meant, you have found a design question rather than drift: pick a different target and, if you like, file the question with `node scripts/gds/capture.js`.\n\n**Both modes.** If the rule you touch has a dark twin, the twin changes too and stays byte-identical — `:root[data-mode=\"dark\"]` and `@media (prefers-color-scheme: dark) { :root:not([data-mode=\"light\"]) }`. Never fix one and leave the other.\n\n## Verify + ship\n\n- `node scripts/gds/validate-design.js --strict` — exits 0.\n- `node scripts/gds/run-unit-tests.js` — still green.\n- Say in the ship notes which literal you replaced, with which token, and why that token is the right MEANING.\n\nHave a browser (Edge or Chrome) on the machine? Then also run `node modules/ui-design/kit/render.js --page <the page>` and look at the before/after — the work is judged by looking at it. Not required for this chore.\n\n## Stay clear of\n\nDo not edit `config/branding.json`, `config/branding.neutral.json`, `config/design-tokens*.json`, or the root `DESIGN.md` — the world is the owner's, and changing it needs an ADR. Do not add a sixteenth token. Do not restyle the component while you are in there: one literal, one meaning, one commit. Touch no permission, pipeline, migration or infra file.",
97
+ "done_when": [
98
+ "Exactly one hardcoded colour literal in ONE non-protected stylesheet was replaced with a branding token (a var() or a color-mix() of theme.ui) — a real literal, not a legal black/white scrim, shadow, mask or halo, and not a cosmetic no-op.",
99
+ "The token chosen matches the MEANING of what was styled (the ship notes say which token and why), and no sixteenth token was invented.",
100
+ "If the rule had a dark twin, the twin was changed too and the two remain byte-identical.",
101
+ "`node scripts/gds/validate-design.js --strict` exits 0 and `node scripts/gds/run-unit-tests.js` is green.",
102
+ "config/branding*.json, config/design-tokens*.json and the root DESIGN.md were NOT modified, and no permission, pipeline, migration or infra path was touched.",
103
+ "The ship notes name the file, the literal removed and the token that replaced it."
104
+ ],
105
+ "touches": []
106
+ },
107
+ {
108
+ "template_id": "ui-alt-text-pass",
109
+ "version_id": "BONGOS-V1",
110
+ "discipline": "ui",
111
+ "kind": "cleanup",
112
+ "est_minutes": 35,
113
+ "credits_reward": 15,
114
+ "floor": 1,
115
+ "title": "Accessibility pass: give one unlabelled control or image on a platform page a real accessible name",
116
+ "description": "## What you're doing\n\nA control a screen reader cannot name is a control some people cannot use. Icon-only buttons are the usual offenders — a `<button>` whose entire content is an `<i class=\"ti ti-x\">` announces as \"button\" and nothing else. Your first ship as a UI builder: find **one** genuinely unlabelled control or image on a platform page and give it a real accessible name.\n\nIt is identical for every newcomer and **always valuable** — the hall and status surfaces carry many icon buttons, new ones arrive constantly, and each label you add is permanent.\n\n## Find a real target\n\nLook in `modules/hall-ui/public/*.html` and `*.js`, and `modules/status-ui/public/`:\n\n```\ngrep -rnE '<button[^>]*>\\s*<i class=\"ti' modules/hall-ui/public/ modules/status-ui/public/\ngrep -rn '<img' modules/hall-ui/public/ modules/status-ui/public/ | grep -v 'alt='\n```\n\nA target is genuine when it has **no** `aria-label`, **no** `title`, **no** visible text inside it, and **no** `aria-labelledby`. Check before you \"fix\" it — many already have one.\n\n## Make the change\n\n- Icon-only button → add `aria-label=\"<what it does>\"`. Name the ACTION, not the icon: `aria-label=\"Close the dialog\"`, never `aria-label=\"x icon\"`.\n- Decorative icon sitting beside text that already says it → the right fix is `aria-hidden=\"true\"`, so it is not announced twice.\n- `<img>` → a real `alt` describing what it conveys; `alt=\"\"` only when it is purely decorative.\n- Text built by JavaScript goes through the same `escapeHtml` the surrounding code uses — match the file's existing idiom.\n\nOne control. Do not restyle it, do not rename its class, do not refactor the component.\n\n## Verify + ship\n\n- `node scripts/gds/run-unit-tests.js` — green.\n- Read the label back to yourself with the screen off: does it say what pressing this does? If not, rewrite it.\n- The ship notes name the file, the control, and the label you gave it.\n\n## Stay clear of\n\nDo not \"fix\" a control that already has an accessible name — that is a no-op ship. Do not touch the branding pack, `DESIGN.md`, or any permission, pipeline, migration or infra file.",
117
+ "done_when": [
118
+ "Exactly one control or image that genuinely had NO accessible name (no aria-label, no title, no visible text, no aria-labelledby, no alt) was given one, in one non-protected file.",
119
+ "The name describes the ACTION or the MEANING, not the icon ('Close the dialog', not 'x icon'); a purely decorative icon beside existing text was marked aria-hidden=\"true\" instead.",
120
+ "Nothing else about the control changed — no restyle, no class rename, no component refactor — and any generated text uses the file's existing escaping idiom.",
121
+ "`node scripts/gds/run-unit-tests.js` is green.",
122
+ "The branding pack, DESIGN.md, and every permission/pipeline/migration/infra path were untouched.",
123
+ "The ship notes name the file, the control and the accessible name added."
124
+ ],
125
+ "touches": []
126
+ }
85
127
  ]
86
128
  }
package/src/bongos/db.js CHANGED
@@ -144,9 +144,9 @@ async function linkAnthropicAccount(builderId, { email, userId }) {
144
144
  // Replace a builder's preferred_disciplines (migrations 027 + 086). Input is
145
145
  // deduped, order-normalized, and validated against the disciplines THIS INSTANCE
146
146
  // offers — core (engineer, ideator) plus any enabled feature module's disciplines
147
- // (the art-pipeline module adds 'artist'), resolved via src/modules.js (R59 /
148
- // task 1198). The DB CHECK still enforces the full known enum
149
- // (engineer|artist|ideator) as a backstop; this is the instance-aware
147
+ // (art-pipeline adds 'artist', ui-design adds 'ui'), resolved via src/modules.js
148
+ // (R59 / task 1198; task 1003487). The DB CHECK still enforces the full known enum
149
+ // (engineer|artist|ideator|ui, migration 176) as a backstop; this is the instance-aware
150
150
  // friendly-error layer, so a vanilla instance with the art pipeline off rejects
151
151
  // 'artist' instead of silently storing an unofferable discipline. Returns the
152
152
  // updated builder row, or throws { code: 'invalid_discipline', bad } if any
@@ -0,0 +1,193 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/project-settings.js — the instance's live, DB-backed knobs, and the
4
+ // typed accessor every reader goes through (task 1003279, goal 1000072).
5
+ //
6
+ // WHY THIS FILE EXISTS. Goal 1000072's rotting half needs a number — "untouched
7
+ // for how long?" — and task 1003280 states where it must live: "STALE TIMER —
8
+ // live, DB-backed", followed by "do not fork a second store". A knob an owner can
9
+ // change from a page cannot be an env var that needs a redeploy, and it must not
10
+ // become a THIRD place staleness is configured. So there is one table
11
+ // (project_settings, migration core_231) and one accessor, here.
12
+ //
13
+ // HOW THIS RELATES TO src/stale-timer.js — READ THIS BEFORE ADDING A THIRD TIMER.
14
+ // ADR 0226 collapsed four disagreeing answers to "how long may a CLAIM go silent"
15
+ // into one resolver. This file does not reopen that. The two timers measure
16
+ // different things, are owned by different surfaces, and have different
17
+ // consequences:
18
+ //
19
+ // src/stale-timer.js a CLAIM goes silent hours env the sweep RELEASES it
20
+ // this file work goes UNTOUCHED days DB a human is ASKED
21
+ //
22
+ // The distinction that keeps them from collapsing into each other: the claim timer
23
+ // is about a builder who stopped responding, and the machine acts alone. The rot
24
+ // timer is about work nobody picked up, and the machine only ever raises a
25
+ // question. One enforces, one reports — which is exactly the asymmetry ADR 0226's
26
+ // header names, applied one level up.
27
+ //
28
+ // This is why the rot read deliberately EXCLUDES tasks at 'active': a claimed task
29
+ // that has gone quiet is the claim timer's question, and reporting it here too
30
+ // would put the same rot in two inboxes with two different numbers — the precise
31
+ // failure ADR 0226 exists to prevent.
32
+ //
33
+ // FAIL POSTURE: REPORTING, SO IT DEGRADES. Every caller of this file is a read
34
+ // surface (the needs-attention inbox, the settings page). A knob typed wrong shows
35
+ // up ON the screen of the person who can fix it, labelled, instead of 500-ing the
36
+ // inbox they were using to find the problem. Nothing here throws on a bad value;
37
+ // it reports one. (An enforcing caller would have to refuse instead — see the
38
+ // stale-timer header. There is no enforcing caller of the rot timer, by design.)
39
+
40
+ const { pool } = require('./pool');
41
+ // The structured logger rather than console.* — both are kernel files (this one is on
42
+ // fitness.js's KERNEL_FILES roster), a structured line is attributable and rides
43
+ // ADR 0022's redaction, and the quality ratchet counts bare console calls.
44
+ const { logger } = require('./logger');
45
+
46
+ // The knob names. Namespaced by the surface that owns them so a settings page can
47
+ // group rows without a second column, and so a future knob from another surface
48
+ // cannot collide with this one.
49
+ const SETTING_KEYS = {
50
+ ROT_DAYS: 'attention.rot_days',
51
+ };
52
+
53
+ // The last-resort rot timer, in days. 30 because that is the shortest window in
54
+ // which "nobody has touched this" is evidence of rot rather than evidence of a
55
+ // normal week — a fortnight would fire on every ordinary holiday, and a quarter is
56
+ // long enough that the backlog has already stopped being read.
57
+ const DEFAULT_ROT_DAYS = 30;
58
+
59
+ // The accepted range, in days. 1 because "yesterday" is the shortest question worth
60
+ // asking. 365 because past a year the timer is an off switch, and an off switch
61
+ // should be asked for out loud rather than typed as a big number — the same
62
+ // reasoning, and the same sentence, as the 720h bound in src/stale-timer.js.
63
+ const MIN_ROT_DAYS = 1;
64
+ const MAX_ROT_DAYS = 365;
65
+
66
+ // Parse one candidate into whole days, or say why it is unusable. Pure, never
67
+ // throws: both the DB tier and the override tier want to decide for themselves what
68
+ // an unusable value means. Mirrors parseHours in src/stale-timer.js deliberately —
69
+ // two timers with two different range checks would drift into two different ideas
70
+ // of what "valid" means.
71
+ function parseDays(raw) {
72
+ if (raw == null || raw === '') return { reason: 'empty' };
73
+ const n = Number(raw);
74
+ if (!Number.isFinite(n)) return { reason: 'not a number' };
75
+ if (!Number.isInteger(n)) return { reason: 'must be a whole number of days' };
76
+ if (n <= 0) return { reason: 'must be positive' };
77
+ if (n < MIN_ROT_DAYS || n > MAX_ROT_DAYS) {
78
+ return { reason: `out of range (${MIN_ROT_DAYS}..${MAX_ROT_DAYS})` };
79
+ }
80
+ return { days: n };
81
+ }
82
+
83
+ // getSetting(key) — the raw row, or null when the key has never been set.
84
+ // NULL IS MEANINGFUL: absence means "never configured, use the code default", which
85
+ // is what lets a fresh instance boot with an empty table and what lets the settings
86
+ // page tell "never touched" apart from "deliberately set to the default value".
87
+ // Never seed a default row to avoid the null.
88
+ async function getSetting(key) {
89
+ const { rows } = await pool.query(
90
+ 'SELECT key, value, updated_at, updated_by FROM project_settings WHERE key = $1',
91
+ [key]
92
+ );
93
+ return rows[0] || null;
94
+ }
95
+
96
+ // setSetting(key, value, builderId) — upsert one knob, returning the stored row.
97
+ // The value is stored verbatim as text and validated by the typed resolver at READ
98
+ // time, not here, so a value that was valid when written and later fell outside a
99
+ // tightened range degrades on the screen rather than becoming unreadable.
100
+ // builderId may be null for a system path with no builder in the request.
101
+ async function setSetting(key, value, builderId = null) {
102
+ const { rows } = await pool.query(
103
+ `INSERT INTO project_settings (key, value, updated_by, updated_at)
104
+ VALUES ($1, $2, $3, now())
105
+ ON CONFLICT (key) DO UPDATE
106
+ SET value = EXCLUDED.value,
107
+ updated_by = EXCLUDED.updated_by,
108
+ updated_at = now()
109
+ RETURNING key, value, updated_at, updated_by`,
110
+ [key, String(value), builderId]
111
+ );
112
+ return rows[0];
113
+ }
114
+
115
+ // resolveRotDays({ override }) — the project's rot timer and where the number came
116
+ // from, for any surface that reports staleness.
117
+ //
118
+ // Precedence, widest-scope last (the same shape as resolveStaleClaimHours):
119
+ // 1. override — a per-request lens (?rot_days=). Someone asking "what would 7
120
+ // days show me?" is not reconfiguring the project, so this never
121
+ // becomes the project's answer and is never written back.
122
+ // 2. the DB — project_settings['attention.rot_days']. THIS is the project's
123
+ // configured timer, the row task 1003280's page writes.
124
+ // 3. default — DEFAULT_ROT_DAYS.
125
+ //
126
+ // Returns { days, source, invalid }:
127
+ // days — always a usable positive integer, whatever went wrong
128
+ // source — 'override' | 'project_settings' | 'default'
129
+ // invalid — null, or { where, raw, reason }: something was SET and unusable.
130
+ // `days` has already fallen through to the next tier; a reporting
131
+ // caller should say so on screen.
132
+ //
133
+ // A DB that is unreachable is NOT an invalid value — it is a failure to read, and
134
+ // this returns the default with source 'default' rather than taking the inbox down.
135
+ // The inbox is how someone would notice the database is unhappy.
136
+ async function resolveRotDays({ override } = {}) {
137
+ if (override != null && override !== '') {
138
+ const got = parseDays(override);
139
+ if (got.days != null) return { days: got.days, source: 'override', invalid: null };
140
+ return {
141
+ days: DEFAULT_ROT_DAYS,
142
+ source: 'default',
143
+ invalid: { where: 'override', raw: String(override), reason: got.reason },
144
+ };
145
+ }
146
+
147
+ let row = null;
148
+ try {
149
+ row = await getSetting(SETTING_KEYS.ROT_DAYS);
150
+ } catch (err) {
151
+ logger.error(`project_settings read failed; falling back to the default rot timer: ${err && err.message}`);
152
+ return { days: DEFAULT_ROT_DAYS, source: 'default', invalid: null };
153
+ }
154
+
155
+ if (row && row.value != null && row.value !== '') {
156
+ const got = parseDays(row.value);
157
+ if (got.days != null) return { days: got.days, source: 'project_settings', invalid: null };
158
+ return {
159
+ days: DEFAULT_ROT_DAYS,
160
+ source: 'default',
161
+ invalid: { where: `project_settings['${SETTING_KEYS.ROT_DAYS}']`, raw: String(row.value), reason: got.reason },
162
+ };
163
+ }
164
+
165
+ return { days: DEFAULT_ROT_DAYS, source: 'default', invalid: null };
166
+ }
167
+
168
+ // describeRotTimer(resolved) — one sentence naming the timer and its provenance, for
169
+ // an API receipt or a settings row. A number with no provenance is what let a 6h and
170
+ // a 24h coexist unnoticed for months (ADR 0226); every surface that prints the rot
171
+ // timer prints this instead of printing the bare number.
172
+ function describeRotTimer(resolved) {
173
+ const r = resolved || {};
174
+ const base = `${r.days}d (${r.source})`;
175
+ if (!r.invalid) return base;
176
+ return `${base} — ignoring ${r.invalid.where}="${r.invalid.raw}" (${r.invalid.reason})`;
177
+ }
178
+
179
+ // parseDays is exported for the tests only — the range check and the default are
180
+ // asserted against the SAME constants the resolver reads, because a test that
181
+ // re-typed 30 or 365 would be the second copy of the number this file exists to
182
+ // keep singular.
183
+ module.exports = {
184
+ SETTING_KEYS,
185
+ DEFAULT_ROT_DAYS,
186
+ MIN_ROT_DAYS,
187
+ MAX_ROT_DAYS,
188
+ parseDays,
189
+ getSetting,
190
+ setSetting,
191
+ resolveRotDays,
192
+ describeRotTimer,
193
+ };
@@ -373,7 +373,8 @@ module.exports = function buildMeRouter() {
373
373
 
374
374
  // Self-service edit of the authenticated builder's preferred disciplines
375
375
  // (migrations 027 + 086 / V3.R80). Body: { preferred_disciplines: ["artist", ...] }.
376
- // Valid elements are engineer|artist|ideator; [] clears the preference (a
376
+ // Valid elements are whatever enabledDisciplines() offers — engineer|ideator core
377
+ // plus any enabled module's (artist, ui); [] clears the preference (a
377
378
  // multi-element set just means several lanes). No rank gate — a builder owns their own
378
379
  // profile. 422 invalid_discipline if any element is out of range.
379
380
  // rank: any-builder — self-service profile edit; documented "no rank gate" in the
package/src/module-api.js CHANGED
@@ -17,6 +17,17 @@ const { pool, instanceDbName } = require('./bongos/pool');
17
17
  const branding = require('./branding');
18
18
  const instanceConfig = require('./instance-config');
19
19
  const staleTimer = require('./stale-timer');
20
+ // Destructured, not namespaced, deliberately — the same form src/bongos/routes/
21
+ // builders.js uses for stale-timer.js. knip attributes a named export to a
22
+ // destructured binding but not to a property read off a required namespace, so the
23
+ // namespace form reports every export here as dead and regresses the ratchet.
24
+ const {
25
+ resolveRotDays,
26
+ SETTING_KEYS: PROJECT_SETTING_KEYS,
27
+ getSetting: getProjectSetting,
28
+ setSetting: setProjectSetting,
29
+ describeRotTimer,
30
+ } = require('./bongos/project-settings');
20
31
  const seams = require('./module-seams');
21
32
  const { buildInfo } = require('./build-info');
22
33
  const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePagination, pageMeta, PAGINATION } = require('./bongos/routes/_helpers');
@@ -34,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
34
45
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
35
46
  // the entry to that file. Look for a version's history there, not here.
36
47
  // ---------------------------------------------------------------------------
37
- const CORE_VERSION = '1.19.485'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.487'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
38
49
 
39
50
  // A namespaced logger so a module's log lines are attributable + consistent.
40
51
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -250,6 +261,23 @@ module.exports = {
250
261
  // a direct require in scripts/ rather than joining the published surface.
251
262
  resolveStaleClaimHours: staleTimer.resolveStaleClaimHours,
252
263
  staleClaimMinutes: staleTimer.staleClaimMinutes,
264
+ // --- the project's ROT TIMER + the knob store behind it (task 1003279, goal
265
+ // 1000072) — how long a GOAL or TASK may go untouched before the hall's
266
+ // needs-attention inbox raises a line about it. A DIFFERENT question from the
267
+ // stale-claim timer directly above, and the two must not be confused: that one
268
+ // counts hours, is read from the env, is about a builder who went quiet, and
269
+ // the sweeper ACTS on it alone. This one counts days, is read from the DB
270
+ // (task 1003280 puts it on a settings page, and "do not fork a second store"),
271
+ // is about work nobody picked up, and it only ever ASKS a human. The rot read
272
+ // deliberately skips tasks at 'active' so the two never report the same rot
273
+ // with two different numbers. See src/bongos/project-settings.js.
274
+ resolveRotDays,
275
+ projectSettings: {
276
+ KEYS: PROJECT_SETTING_KEYS,
277
+ get: getProjectSetting,
278
+ set: setProjectSetting,
279
+ describeRotTimer,
280
+ },
253
281
  readConfigFileSync: instanceConfig.readConfigFileSync,
254
282
  configHome: instanceConfig.configHome,
255
283
  configPath: instanceConfig.configPath,
@@ -42,8 +42,27 @@ test('an unknown craft is refused', () => {
42
42
  assert.ok(reasons({ needs_craft: 'wizard', what_is_stuck: 'help' }).includes('unknown_craft'));
43
43
  });
44
44
 
45
- test('the crafts are ADR 0174\'s three', () => {
46
- assert.deepEqual(help.helpCrafts().sort(), ['artist', 'builder', 'ideator']);
45
+ test('the crafts are ADR 0174\'s three, plus the ui craft the ui-design module contributes', () => {
46
+ // 'ui' joined the vocabulary with task 1003487, which made the discipline
47
+ // selectable everywhere a builder chooses work. The DB CHECK is widened in
48
+ // lockstep by modules/lifecycle/migrations/lifecycle_006_help_requests_ui_craft.sql.
49
+ assert.deepEqual(help.helpCrafts().sort(), ['artist', 'builder', 'ideator', 'ui']);
50
+ assert.equal(help.disciplineForCraft('ui'), 'ui', 'ui is spelled the same on both sides of the seam');
51
+ assert.equal(help.craftForDiscipline('ui'), 'ui');
52
+ });
53
+
54
+ test('every craft the app offers is permitted by the help_requests CHECK', () => {
55
+ // The regression this pins: the vocabulary lives BOTH in JS (HELP_CRAFTS) and in
56
+ // a compiled-in CHECK, and widening one without the other is invisible until an
57
+ // INSERT fails in production as a 500 on a feature that reads as supported.
58
+ const sql = fs.readFileSync(
59
+ path.join(ROOT, 'modules', 'lifecycle', 'migrations', 'lifecycle_006_help_requests_ui_craft.sql'), 'utf8');
60
+ const m = sql.match(/needs_craft IN \(([^)]*)\)/);
61
+ assert.ok(m, 'the migration states the permitted craft list');
62
+ const allowed = m[1].split(',').map((x) => x.trim().replace(/^'|'$/g, ''));
63
+ for (const c of help.helpCrafts()) {
64
+ assert.ok(allowed.includes(c), `craft '${c}' is offered by helpCrafts() but the CHECK forbids it`);
65
+ }
47
66
  });
48
67
 
49
68
  test('THE VOCABULARY SEAM: builder maps to the discipline spelled engineer', () => {
@@ -34,6 +34,7 @@ const PUBLISHED_SURFACE = [
34
34
  'llmPricing', // BV1.R77: shared token→USD pricing (1.7.0; tracked domain leak — economy carve)
35
35
  'branding', 'clientBranding', 'userAgent', 'resolveEnv', 'readConfigFileSync', 'configHome', 'configPath', // userAgent added 1.12.0 (BV1.R86; github-push UA)
36
36
  'resolveStaleClaimHours', 'staleClaimMinutes', // added by task 1003476 (ADR 0226; goal 1000072): the PROJECT's stale-claim timer — the sweep that releases a silent claim and the Archon roster that reports one read the same number through here, after eighteen hours of disagreeing (6 vs 24)
37
+ 'resolveRotDays', 'projectSettings', // added by task 1003279 (ADR 0232; goal 1000072): the ROT timer + the live knob store behind it. A DIFFERENT question from the stale-claim timer above — days not hours, DB not env, work nobody picked up rather than a builder who went quiet, and it only ever ASKS a human where that one ACTS. Both ride the doorway so no module hardcodes either.
37
38
  'joinabilityMode', // added by task 1003044 (ADR 0194; the carrier after 1.19.374): the project's membership door, one reader for the sign-in gate AND the onboarding module's public POST
38
39
  'enabledDisciplines', // BV1.R81: instance offered-disciplines (1.9.0; onboarding restock backstop)
39
40
  'registerProvider', 'hasProvider', 'resolve', 'resolveOptional', 'listPorts', 'verifyPortsSatisfied',
@@ -9,6 +9,7 @@
9
9
  import assert from 'node:assert/strict';
10
10
  import { test } from 'node:test';
11
11
  import { createRequire } from 'node:module';
12
+ import { readFileSync } from 'node:fs';
12
13
  import path from 'node:path';
13
14
  import { fileURLToPath } from 'node:url';
14
15
 
@@ -153,6 +154,72 @@ test('settings-panel seam: ABSENT → empty; REGISTERED → present, sorted by o
153
154
  // third-party widget can't blank the hall — so these assert the RETURN VALUE and
154
155
  // that the widget never reached the registry.
155
156
 
157
+ // ---- browser twin: the craft picker's options (task 1003487) ----------------
158
+ // Both hall pickers (the onboarding pick_disciplines step and the Settings craft
159
+ // chips) build their options from OTB.disciplines(), so this IS the rendered
160
+ // option list. It used to be a hardcoded triple in each file, which is how a
161
+ // module-contributed craft could be offered by the server and appear in neither.
162
+
163
+ test('craft picker options: OTB.disciplines() mirrors the server list, and falls back to the core two', () => {
164
+ global.window = {};
165
+ // the IIFE assigns window.OTB once at load — evict it for a fresh binding
166
+ const domUtils = path.join(ROOT, 'modules', 'hall-ui', 'public', 'dom-utils.js');
167
+ delete require.cache[require.resolve(domUtils)];
168
+ require(domUtils);
169
+ const OTB = global.window.OTB;
170
+
171
+ // the server's list wins, in the server's order
172
+ global.window.__MODULES__ = { enabled: {}, disciplines: ['engineer', 'ideator', 'ui'] };
173
+ assert.deepEqual(OTB.disciplines(), ['engineer', 'ideator', 'ui']);
174
+ // a module-contributed craft is offered without any per-craft moduleOn() branch
175
+ global.window.__MODULES__ = { enabled: { 'art-pipeline': true }, disciplines: ['engineer', 'ideator', 'artist', 'ui'] };
176
+ assert.ok(OTB.disciplines().includes('ui') && OTB.disciplines().includes('artist'));
177
+ // withdrawn with its module
178
+ global.window.__MODULES__ = { enabled: {}, disciplines: ['engineer', 'ideator'] };
179
+ assert.deepEqual(OTB.disciplines(), ['engineer', 'ideator']);
180
+
181
+ // an older server that sends no list degrades to the core two, never to an
182
+ // empty picker (which would strand a builder with no way to pick a craft)
183
+ for (const m of [undefined, {}, { enabled: {} }, { disciplines: [] }, { disciplines: 'nope' }]) {
184
+ global.window.__MODULES__ = m;
185
+ assert.deepEqual(OTB.disciplines(), ['engineer', 'ideator'], `fallback for ${JSON.stringify(m)}`);
186
+ }
187
+
188
+ // returns a COPY — a picker mutating its options cannot corrupt the global
189
+ global.window.__MODULES__ = { disciplines: ['engineer', 'ideator', 'ui'] };
190
+ OTB.disciplines().push('bogus');
191
+ assert.deepEqual(OTB.disciplines(), ['engineer', 'ideator', 'ui']);
192
+ });
193
+
194
+ test('craft picker options: disciplineLabel names an acronym correctly', () => {
195
+ global.window = {};
196
+ // the IIFE assigns window.OTB once at load — evict it for a fresh binding
197
+ const domUtils = path.join(ROOT, 'modules', 'hall-ui', 'public', 'dom-utils.js');
198
+ delete require.cache[require.resolve(domUtils)];
199
+ require(domUtils);
200
+ const OTB = global.window.OTB;
201
+ assert.equal(OTB.disciplineLabel('ui'), 'UI', 'not "Ui"');
202
+ assert.equal(OTB.disciplineLabel('engineer'), 'Engineer');
203
+ assert.equal(OTB.disciplineLabel('artist'), 'Artist');
204
+ assert.equal(OTB.disciplineLabel('ideator'), 'Ideator');
205
+ // a craft a module adds later needs no new entry here
206
+ assert.equal(OTB.disciplineLabel('archivist'), 'Archivist');
207
+ for (const bad of ['', null, undefined]) assert.equal(OTB.disciplineLabel(bad), '');
208
+ });
209
+
210
+ test('craft picker options: neither picker hardcodes the craft list any more', () => {
211
+ // A regression pin with teeth: the bug was two independent literal triples that
212
+ // silently dropped a module-contributed craft. Whichever picker is edited next
213
+ // must keep reading the server list.
214
+ const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
215
+ for (const p of ['modules/hall-ui/public/settings.js', 'modules/hall-ui/public/hall-render.js']) {
216
+ const src = read(p);
217
+ assert.ok(/OTB\.disciplines\(\)/.test(src), `${p} builds its options from OTB.disciplines()`);
218
+ assert.ok(!/\['engineer',\s*'artist',\s*'ideator'\]/.test(src), `${p} no longer hardcodes the craft triple`);
219
+ assert.ok(!/discBtn\('engineer'/.test(src), `${p} no longer names a craft literally`);
220
+ }
221
+ });
222
+
156
223
  test('hall-widget registry: placement rules reject bad registrations, accept good ones', () => {
157
224
  global.window = {};
158
225
  // The settings-panel test above already required dom-utils.js, and the IIFE
package/tests/modules.mjs CHANGED
@@ -140,6 +140,21 @@ test('disciplines: vanilla offers engineer + ideator (core); art-pipeline adds a
140
140
  assert.deepEqual(enabledDisciplines(m), ['engineer', 'ideator', 'artist']);
141
141
  });
142
142
 
143
+ test('disciplines: ui-design contributes `ui` — the manifest key IS the unlock (task 1003487)', () => {
144
+ // enabledDisciplines() already merged contributes.disciplines; ui-design simply
145
+ // never declared any, so the craft existed in the DB CHECK, in discipline-modes
146
+ // and on tasks while staying unreachable from every surface a builder chooses with.
147
+ if (!EFFECTIVE['ui-design']) return; // only meaningful where the module is installed
148
+ assert.deepEqual(EFFECTIVE['ui-design'].contributes.disciplines, ['ui'],
149
+ 'the module declares the craft it owns');
150
+ const on = resolveModules({ instance: { modules: { 'ui-design': true } }, registry: EFFECTIVE });
151
+ assert.ok(enabledDisciplines(on).includes('ui'),
152
+ 'a builder can save it, because updateBuilderDisciplines validates against this very list');
153
+ // and it withdraws with the module, exactly as artist does with art-pipeline
154
+ const off = resolveModules({ instance: { modules: { 'ui-design': false } }, registry: EFFECTIVE });
155
+ assert.ok(!enabledDisciplines(off).includes('ui'), 'module off => craft withdrawn');
156
+ });
157
+
143
158
  test('clientModules is a safe boolean projection covering every known module + disciplines', () => {
144
159
  const c = clientModules(resolveModules({ instance: OTB, registry: SAMPLE_REG }));
145
160
  for (const k of KNOWN_MODULES) assert.equal(typeof c.enabled[k], 'boolean');
@@ -180,5 +195,8 @@ test('a branded sibling (GDS_BRANDING_FILE set, no GDS_MODULES_FILE) defaults to
180
195
  // A non-default brand must fall through to the neutral (all-off) module set.
181
196
  const m = loadModules({ env: { GDS_BRANDING_FILE: 'config/branding.cloudbongos.json' } });
182
197
  for (const k of KNOWN_MODULES) assert.equal(m[k], false, `${k} must be off for a branded sibling`);
183
- assert.deepEqual(enabledDisciplines(m), ['engineer', 'ideator']);
198
+ // The two core crafts, plus 'ui' from ui-design — which is default-on for EVERY
199
+ // instance ("every instance has a face") and is not one of KNOWN_MODULES, so the
200
+ // all-off assertion above never reaches it (task 1003487).
201
+ assert.deepEqual(enabledDisciplines(m), ['engineer', 'ideator', 'ui']);
184
202
  });
@@ -68,6 +68,82 @@ t('loadFloors: real config keeps floor >= 2 per discipline (a spare on every cla
68
68
  }
69
69
  });
70
70
 
71
+ // ---- the ui craft is stocked, so a ui-only newcomer is never starved (task 1003487) ----
72
+ t('ui: the craft is offered, has chores, and none of them needs a browser', () => {
73
+ assert.ok(r.validDisciplines().has('ui'),
74
+ 'ui-design is default-on, so enabledDisciplines() offers ui and restockAll will stamp it');
75
+ assert.ok(r.templateDisciplines().has('ui'), 'ui has chores defined, so restockDiscipline accepts it');
76
+ const chores = r.loadTemplates().ui;
77
+ assert.ok(Array.isArray(chores) && chores.length >= 1, 'at least one ui chore exists');
78
+ for (const c of chores) {
79
+ assert.ok(c.template_id && c.version_id && c.title, `${c.template_id || '?'} has id/version/title`);
80
+ assert.equal(c.discipline, 'ui');
81
+ assert.ok(c.est_minutes <= 60, `${c.template_id} is inside the newcomer scope ceiling`);
82
+ // Xenos-safe: a design chore necessarily READS the branding pack, so the
83
+ // property that matters is that every one of them says, in its done_when,
84
+ // that the world and the pipeline core are not what gets edited.
85
+ const dw = (c.done_when || []).join(' ');
86
+ assert.ok(/NOT modified|were untouched|untouched/i.test(dw),
87
+ `${c.template_id} states what must stay untouched`);
88
+ assert.ok(/permission|infra|migration/i.test(dw),
89
+ `${c.template_id} names the protected core as off-limits`);
90
+ }
91
+ // The kit needs Chrome/Edge, which a newcomer's machine may not have — so at
92
+ // least one ui chore must be verifiable without a browser, or the craft starves
93
+ // exactly the people it was opened for.
94
+ assert.ok(chores.some((c) => !/kit\/render\.js/.test((c.done_when || []).join(' '))),
95
+ 'a ui newcomer with no browser still has a chore they can finish');
96
+ });
97
+
98
+ t('ui: the floor config stocks the craft', () => {
99
+ assert.ok(r.loadFloors().ui >= 1, 'ui declares a standing pool, so the shelf refills');
100
+ });
101
+
102
+ // ---- one file read per operation (task 1003487 re-grade: the efficiency finding) ----
103
+ // The templates file was being re-read and re-parsed by every helper on the path —
104
+ // templateDisciplines() and loadTemplates() back to back per call, and once per
105
+ // craft inside restockAll. These pin the single-read contract so it cannot regress.
106
+ {
107
+ const nodefs = require('node:fs');
108
+ const realRead = nodefs.readFileSync;
109
+ let reads = 0;
110
+ nodefs.readFileSync = (p, ...rest) => {
111
+ if (String(p).includes('newcomer-floor.templates.json')) reads += 1;
112
+ return realRead(p, ...rest);
113
+ };
114
+ try {
115
+ reads = 0;
116
+ await r.restockForClaim({ source: 'not-a-chore', discipline: 'ui' });
117
+ assert.equal(reads, 0, 'a non-chore task is rejected before any file read');
118
+
119
+ reads = 0;
120
+ const byDisc = r.loadTemplates();
121
+ assert.equal(reads, 1, 'loadTemplates reads once');
122
+ r.templateDisciplines(byDisc);
123
+ assert.equal(reads, 1, 'templateDisciplines re-uses an already-loaded map');
124
+ await r.restockDiscipline('bogus-craft', byDisc);
125
+ assert.equal(reads, 1, 'restockDiscipline re-uses it too');
126
+ } finally {
127
+ nodefs.readFileSync = realRead;
128
+ }
129
+ passed += 1;
130
+ console.log(' PASS restock reads newcomer-floor.templates.json once per operation');
131
+ }
132
+
133
+ t('restockForClaim threads its loaded map into the no-match fallback', () => {
134
+ // The read-counting block above drives the helpers directly; this pins the real
135
+ // call chain it cannot reach without a database — the fallback branch taken when
136
+ // a chore's source_ref does not resolve to a known template.
137
+ const src = require('node:fs').readFileSync(
138
+ new URL('../modules/onboarding/newcomer-restock.js', import.meta.url), 'utf8');
139
+ const body = src.slice(src.indexOf('async function restockForClaim'),
140
+ src.indexOf('async function restockAll'));
141
+ assert.ok(/const byDiscipline = loadTemplates\(\);/.test(body), 'it loads the templates once');
142
+ assert.ok(!/loadTemplates\(\)\[/.test(body), 'and never re-indexes a second fresh load');
143
+ assert.ok(/restockDiscipline\(task\.discipline, byDiscipline\)/.test(body),
144
+ 'the fallback re-uses that map rather than letting the default param re-read the file');
145
+ });
146
+
71
147
  // ---- restockForClaim: skip path is DB-free (awaited) ----
72
148
  // (returns an array of per-template results; the skip path is a 1-element array.)
73
149
  assert.deepEqual(await r.restockForClaim(null), [{ skipped: true }]);