@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
@@ -0,0 +1,163 @@
1
+ -- core_231_project_settings_and_attention_water.sql — the rot clock and the knob
2
+ -- that sets it (task 1003279, goal 1000072, criterion legibility-sort-and-staleness).
3
+ --
4
+ -- THE GAP. Goal 1000072's rotting half asks that "untouched-for-X triggers a
5
+ -- configured action rather than a silent decay". Two things were missing to make
6
+ -- that sentence true, and this migration adds exactly those two:
7
+ --
8
+ -- 1. X had nowhere to live. src/stale-timer.js (task 1003476, ADR 0226) owns the
9
+ -- CLAIM-silence timer — hours, env-configured, and the sweep that ACTS on it
10
+ -- releases someone's claim. The rot timer is a different measurement with a
11
+ -- different unit and a different consequence (days; it asks a human a
12
+ -- question), and task 1003280 requires it be "live, DB-backed" and warns
13
+ -- "do not fork a second store". So: one generic project_settings table, with
14
+ -- the rot timer as its first key.
15
+ --
16
+ -- 2. "Water" had nothing to write. The owner's four verbs are prune, kill,
17
+ -- prioritise and WATER — leave the thing alone and reset its clock. Without
18
+ -- somewhere to record that, watering would have to fake a real edit, and the
19
+ -- same line would come back on the next read; the inbox teaches people to
20
+ -- ignore it within a week. attention_watered_at IS the reset.
21
+ --
22
+ -- WHY A COLUMN AND NOT AN ITEMS TABLE. There is deliberately no `attention_items`
23
+ -- row anywhere in this design. The inbox line is DERIVED on read from the rot clock
24
+ -- (below) vs the timer, which makes "exactly one open item per stale thing, not one
25
+ -- per sweep" true by construction rather than by a de-duplicating sweep that has to
26
+ -- be got right.
27
+ -- Acting on the line (prune/kill/prioritise) changes status and the row stops
28
+ -- matching; watering pushes the clock forward and it stops matching. Nothing to
29
+ -- reconcile, nothing to garbage-collect, and no way to accrete duplicates.
30
+ --
31
+ -- Additive + forward-safe: every column is nullable with no backfill, nothing is
32
+ -- renamed, and no existing reader changes meaning (tests/migrations_forward_safe.mjs).
33
+
34
+ BEGIN;
35
+
36
+ -- ---------------------------------------------------------------------------
37
+ -- 1. project_settings — the instance's live, DB-backed knobs.
38
+ -- ---------------------------------------------------------------------------
39
+ --
40
+ -- Deliberately a generic key/value table rather than one column per knob: task
41
+ -- 1003280 puts five project fundamentals on one page, and a table that grows a
42
+ -- migration per knob would make that page a schema change every time. Values are
43
+ -- stored as TEXT and parsed by a typed accessor at the read edge
44
+ -- (src/bongos/project-settings.js), the same shape src/stale-timer.js already uses
45
+ -- for env values — one parser, one range check, one documented default.
46
+ --
47
+ -- A key that has never been set has NO ROW. Absence means "use the code default",
48
+ -- which is what lets a fresh instance boot correctly with an empty table, and what
49
+ -- lets a settings page distinguish "never configured" from "deliberately set to
50
+ -- the same value the default happens to be".
51
+ CREATE TABLE IF NOT EXISTS project_settings (
52
+ key text PRIMARY KEY,
53
+ value text NOT NULL,
54
+ updated_at timestamptz NOT NULL DEFAULT now(),
55
+ updated_by bigint REFERENCES builders(id)
56
+ );
57
+
58
+ COMMENT ON TABLE project_settings IS
59
+ 'Live, DB-backed per-project knobs (task 1003279). Key/value so task 1003280''s settings page can grow a row without a migration. No row = never configured = use the code default; that absence is meaningful, so do not seed defaults here.';
60
+ COMMENT ON COLUMN project_settings.value IS
61
+ 'Stored as text and parsed by a typed accessor at the read edge (src/bongos/project-settings.js). A value that fails its range check is IGNORED with a receipt, never coerced silently — the ADR 0226 posture.';
62
+ COMMENT ON COLUMN project_settings.updated_by IS
63
+ 'The builder who last set this knob. NULL = set by a system path with no builder in the request (the goals.created_by convention, migration 160).';
64
+
65
+ -- ---------------------------------------------------------------------------
66
+ -- 2. The water mark, on both tiers that can rot.
67
+ -- ---------------------------------------------------------------------------
68
+ --
69
+ -- NULL = never watered, and the thing's own activity timestamp is the whole clock.
70
+ -- This is why the read expression is a GREATEST and not a bare COALESCE: a real edit
71
+ -- after a watering must move the clock forward again, and a watering must never make
72
+ -- a thing look OLDER than its last genuine touch.
73
+ --
74
+ -- THE ROT CLOCK, AND WHY IT IS SHAPED LIKE THIS:
75
+ --
76
+ -- tasks: GREATEST(updated_at, COALESCE(shipped_at, updated_at),
77
+ -- COALESCE(attention_watered_at, updated_at))
78
+ -- goals: GREATEST(updated_at, COALESCE(attention_watered_at, updated_at))
79
+ --
80
+ -- The first two terms are NOT a new definition of "touched" — they are exactly the
81
+ -- one modules/hall-ui/public/task-lib.js already publishes for the Tasks board and
82
+ -- the Goals page (`taskActivityMs = max(updated_at, shipped_at)`, task 1003278, this
83
+ -- same criterion), which folds in shipped_at because a ship path can stamp it
84
+ -- without touching updated_at. This migration adds a THIRD term to that definition
85
+ -- and nothing else. Had the server invented its own two-term clock, the board and
86
+ -- the inbox would disagree about which rows are stale — which is the 6h-vs-24h
87
+ -- failure of ADR 0226 reappearing one tier up, and the reason the indexes below
88
+ -- spell the expression out in full rather than approximating it.
89
+ -- (Goals carry no shipped_at column, so their clock has two terms, not three.)
90
+ ALTER TABLE tasks
91
+ ADD COLUMN IF NOT EXISTS attention_watered_at timestamptz,
92
+ ADD COLUMN IF NOT EXISTS attention_watered_by bigint REFERENCES builders(id);
93
+
94
+ ALTER TABLE goals
95
+ ADD COLUMN IF NOT EXISTS attention_watered_at timestamptz,
96
+ ADD COLUMN IF NOT EXISTS attention_watered_by bigint REFERENCES builders(id);
97
+
98
+ COMMENT ON COLUMN tasks.attention_watered_at IS
99
+ 'When a human last said "leave it, it is fine" about this task rotting (task 1003279). Resets the rot clock without touching the work. NULL = never watered.';
100
+ COMMENT ON COLUMN goals.attention_watered_at IS
101
+ 'When a human last said "leave it, it is fine" about this goal rotting (task 1003279). Resets the rot clock without touching the work. NULL = never watered.';
102
+
103
+ -- The rot read is "open things whose clock is older than N days", so the index is
104
+ -- on (status, clock) — status first because it is the equality predicate and it
105
+ -- cuts the table to the open rows before the range scan ever runs.
106
+ CREATE INDEX IF NOT EXISTS idx_tasks_attention_rot
107
+ ON tasks (status, (GREATEST(updated_at,
108
+ COALESCE(shipped_at, updated_at),
109
+ COALESCE(attention_watered_at, updated_at))));
110
+ CREATE INDEX IF NOT EXISTS idx_goals_attention_rot
111
+ ON goals (status, (GREATEST(updated_at, COALESCE(attention_watered_at, updated_at))));
112
+
113
+ -- ---------------------------------------------------------------------------
114
+ -- 3. Rebuild the two `t.*` views so the new columns are projected.
115
+ -- ---------------------------------------------------------------------------
116
+ --
117
+ -- A Postgres view created with `SELECT t.*` pins its column list at CREATE VIEW
118
+ -- time. core_227's header records six columns that silently never reached the claim
119
+ -- feed exactly this way; bodies below are identical to core_227's (the wildcard
120
+ -- re-expands to every current column).
121
+
122
+ DROP VIEW IF EXISTS claimable_tasks;
123
+ CREATE VIEW claimable_tasks AS
124
+ SELECT t.*,
125
+ pms_calibrated_minutes(t.est_minutes, t.kind) AS est_minutes_calibrated
126
+ FROM tasks t
127
+ WHERE t.status = 'ready'
128
+ AND t.title NOT LIKE '\_\_smoke\_\_%' ESCAPE '\'
129
+ AND NOT EXISTS (
130
+ SELECT 1 FROM task_blockers tb WHERE tb.task_id = t.id
131
+ )
132
+ AND NOT EXISTS (
133
+ SELECT 1 FROM claims c
134
+ JOIN tasks t2 ON t2.id = c.task_id
135
+ WHERE c.released_at IS NULL
136
+ AND t2.touches && t.touches
137
+ );
138
+
139
+ DROP VIEW IF EXISTS tasks_session_fit;
140
+ CREATE VIEW tasks_session_fit AS
141
+ SELECT t.*,
142
+ CASE
143
+ WHEN t.automation_tag = 'overnight-safe' AND COALESCE(t.manual_degree, 5) <= 2
144
+ AND NOT EXISTS (SELECT 1 FROM task_blockers tb WHERE tb.task_id = t.id)
145
+ THEN ARRAY['autonomous','check-in','active']
146
+ WHEN t.automation_tag = 'live-only' OR COALESCE(t.manual_degree, 5) >= 4
147
+ THEN ARRAY['active']
148
+ ELSE ARRAY['check-in','active']
149
+ END AS fits_session_modes,
150
+ pms_calibrated_minutes(t.est_minutes, t.kind) AS est_minutes_calibrated,
151
+ CASE
152
+ WHEN t.credits_reward > 0
153
+ AND pms_calibrated_minutes(t.est_minutes, t.kind) IS NOT NULL
154
+ AND pms_calibrated_minutes(t.est_minutes, t.kind) > 0
155
+ THEN ROUND(t.credits_reward::numeric / pms_calibrated_minutes(t.est_minutes, t.kind), 3)
156
+ ELSE NULL
157
+ END AS credits_per_minute
158
+ FROM tasks t;
159
+
160
+ COMMIT;
161
+
162
+ INSERT INTO schema_migrations (version) VALUES ('core_231_project_settings_and_attention_water')
163
+ ON CONFLICT (version) DO NOTHING;
@@ -386,6 +386,8 @@ function createClient(opts = {}) {
386
386
  postGoalsIdTransfer: (args) => request("POST", "/goals/{id}/transfer", { hasBody: true }, args),
387
387
  // PATCH /goals/{id}/visibility — rank: any-builder — PATCH /goals/:id/visibility
388
388
  patchGoalsIdVisibility: (args) => request("PATCH", "/goals/{id}/visibility", { hasBody: true }, args),
389
+ // POST /goals/{id}/water — rank: any-builder — POST /goals/:id/water
390
+ postGoalsIdWater: (args) => request("POST", "/goals/{id}/water", { hasBody: true }, args),
389
391
  // GET /goals/inbox — rank: any-builder — GET /goals/inbox
390
392
  getGoalsInbox: (args) => request("GET", "/goals/inbox", { hasBody: false }, args),
391
393
  // POST /goals/reorder — rank: metic+archon — POST /goals/reorder
@@ -466,6 +468,8 @@ function createClient(opts = {}) {
466
468
  getInboxAwaitingNod: (args) => request("GET", "/inbox/awaiting-nod", { hasBody: false }, args),
467
469
  // GET /inbox/by-builder/{id} — rank: any-builder — GET /inbox/by-builder/:id
468
470
  getInboxByBuilderId: (args) => request("GET", "/inbox/by-builder/{id}", { hasBody: false }, args),
471
+ // GET /inbox/rotting — rank: any-builder — GET /inbox/rotting
472
+ getInboxRotting: (args) => request("GET", "/inbox/rotting", { hasBody: false }, args),
469
473
  // POST /inbox/score — rank: any-builder — POST /inbox/score
470
474
  postInboxScore: (args) => request("POST", "/inbox/score", { hasBody: true }, args),
471
475
  // GET /inbox/sparks — rank: any-builder — GET /inbox/sparks
@@ -838,6 +842,8 @@ function createClient(opts = {}) {
838
842
  postTasksIdVisual: (args) => request("POST", "/tasks/{id}/visual", { hasBody: true }, args),
839
843
  // POST /tasks/{id}/vote — rank: metic+archon — POST /tasks/:id/vote
840
844
  postTasksIdVote: (args) => request("POST", "/tasks/{id}/vote", { hasBody: true }, args),
845
+ // POST /tasks/{id}/water — rank: any-builder — POST /tasks/:id/water
846
+ postTasksIdWater: (args) => request("POST", "/tasks/{id}/water", { hasBody: true }, args),
841
847
  // GET /tasks/claimable — rank: any-builder — GET /tasks/claimable
842
848
  getTasksClaimable: (args) => request("GET", "/tasks/claimable", { hasBody: false }, args),
843
849
  // GET /tasks/newcomer-floor — rank: any-builder — GET /tasks/newcomer-floor
@@ -106,6 +106,28 @@
106
106
  return m.enabled[name] === true;
107
107
  }
108
108
 
109
+ // The crafts THIS instance offers, in the server's own order (task 1003487).
110
+ // serve-internal.js injects it beside `enabled` from clientModules(), and it is
111
+ // the very list `db.updateBuilderDisciplines` validates a PATCH against — so a
112
+ // picker built from it can never offer a craft the API would 422. A module adds
113
+ // its own via `contributes.disciplines` (art-pipeline → artist, ui-design → ui),
114
+ // which is why no picker needs a moduleOn() branch per craft any more. Falls back
115
+ // to the two core crafts when an older server sent no list, never to an empty
116
+ // picker.
117
+ function disciplines() {
118
+ const m = window.__MODULES__;
119
+ const list = m && Array.isArray(m.disciplines) ? m.disciplines : null;
120
+ return list && list.length ? list.slice() : ['engineer', 'ideator'];
121
+ }
122
+
123
+ // How a craft is named in a picker. An acronym stays upper-case ('ui' → 'UI');
124
+ // anything else is Title Case, so a craft a module adds later needs no new entry.
125
+ function disciplineLabel(d) {
126
+ const s = String(d || '');
127
+ if (!s) return '';
128
+ return s === 'ui' ? 'UI' : s.charAt(0).toUpperCase() + s.slice(1);
129
+ }
130
+
109
131
  // Module→core settings-panel contribution seam (BV1.R42, ADR 0083) — the
110
132
  // browser twin of the server's 'profile.fields' seam (src/module-seams.js). A
111
133
  // module that has left core registers a Settings panel from its own client
@@ -301,7 +323,7 @@
301
323
  }
302
324
 
303
325
  window.OTB = {
304
- escapeHtml, $, fmtNum, num, fmtInt, fmtCompact, fmtDate, rankLabel, toast, moduleOn,
326
+ escapeHtml, $, fmtNum, num, fmtInt, fmtCompact, fmtDate, rankLabel, toast, moduleOn, disciplines, disciplineLabel,
305
327
  errCode, errMessage, errDetails, // task 1002462 — ADR 0116 envelope readers
306
328
  contributeSettingsPanel, settingsPanels,
307
329
  contributeHallWidget, hallWidgets, hallWidgetIds,
@@ -167,6 +167,19 @@
167
167
  }
168
168
 
169
169
  function onClick(e) {
170
+ // task 1003279: a rot verb. Handled FIRST because its rows carry no data-goal —
171
+ // the path is server-declared and already tier-correct, so this branch needs to
172
+ // know nothing about which of the four verbs was pressed beyond what to say
173
+ // afterwards. Refreshes the ROT slice only: repainting the goals list would
174
+ // leave the acted-on row sitting there (the task-1003066 lesson).
175
+ const rotBtn = e.target.closest('[data-rot-path]');
176
+ if (rotBtn) {
177
+ const verb = rotBtn.dataset.rotVerb;
178
+ act(() => postJSON(`${API}${rotBtn.dataset.rotPath}`, {}),
179
+ ROT_TOASTS[verb] || 'Done.', loadRot);
180
+ return;
181
+ }
182
+
170
183
  // task 1003066 (BV1.R05): the nod. It reuses the EXISTING promote route —
171
184
  // POST /tasks/:id/promote, already Metic+ via task.promote — so giving a nod
172
185
  // adds no endpoint and no permission. Handled before the membership branches
@@ -262,6 +275,104 @@
262
275
  .catch(() => { /* a non-triager's 403, or a hiccup: never break the hall */ });
263
276
  }
264
277
 
278
+ // ---- the rot queue (task 1003279, goal 1000072) ---------------------------
279
+ //
280
+ // "Untouched-for-X triggers a configured action rather than a silent decay."
281
+ // The owner's decision (2026-08-25): the action is a LINE IN THIS CARD, not an
282
+ // automatic status change and not a nightly digest. Auto-demoting mutates real
283
+ // work with no human in the loop, and a wrong timer then silently freezes it; a
284
+ // digest is a notification, not an action, and gets scrolled past.
285
+ //
286
+ // THE BUTTONS ARE SERVER-DECLARED. Each row arrives carrying its own `verbs`
287
+ // array — label, method and path — and this renders exactly those. It is not
288
+ // decoration: three of the four verbs reuse EXISTING routes (promote, demote,
289
+ // abandon, archive) whose permissions differ by tier, and a hardcoded button list
290
+ // here would be a second copy of that mapping, free to drift into offering a
291
+ // button the server will refuse. A goal legitimately gets two verbs where a task
292
+ // gets four; this file does not need to know why.
293
+ //
294
+ // No client-side rank gate, for the same reason the nod slice has none: the read
295
+ // is scoped server-side, so a builder is only ever handed rows that are theirs to
296
+ // act on and the button list is already narrowed to what they can press.
297
+ const MAX_ROT_ROWS = 5;
298
+
299
+ function untouchedFor(iso) {
300
+ const t = iso ? Date.parse(iso) : NaN;
301
+ if (Number.isNaN(t)) return 'untouched';
302
+ const days = Math.floor((Date.now() - t) / 86400000);
303
+ if (days < 1) return 'untouched today';
304
+ return `untouched ${days} day${days === 1 ? '' : 's'}`;
305
+ }
306
+
307
+ function rotRowHtml(r) {
308
+ const href = r.tier === 'goal'
309
+ ? goalHref(r.id)
310
+ : `/task/${encodeURIComponent(r.id)}`;
311
+ const where = r.tier === 'goal'
312
+ ? 'goal'
313
+ : (r.goal_title ? escapeHtml(r.goal_title) : `goal ${escapeHtml(String(r.goal_id || '—'))}`);
314
+ // `watered` is worth showing: a row that is back DESPITE having been watered is
315
+ // a different message from one nobody has ever looked at, and hiding that would
316
+ // make the second watering feel like the first one did nothing.
317
+ const again = r.ever_watered ? ' · watered before' : '';
318
+ const buttons = (Array.isArray(r.verbs) ? r.verbs : []).map((v) => {
319
+ const path = String(v.path || '').replace(':id', encodeURIComponent(r.id));
320
+ return `<button class="btn-ghost" type="button" data-rot-path="${escapeHtml(path)}"`
321
+ + ` data-rot-verb="${escapeHtml(String(v.verb || ''))}"`
322
+ + ` title="${escapeHtml(String(v.hint || ''))}">${escapeHtml(String(v.label || v.verb || '?'))}</button>`;
323
+ }).join('');
324
+ return `<li class="ginbox__item" data-rot-tier="${escapeHtml(String(r.tier))}">
325
+ <div class="ginbox__main">
326
+ <a class="ginbox__goal" href="${href}">${escapeHtml(r.title || `${r.tier} #${r.id}`)}</a>
327
+ <span class="ginbox__sub">${escapeHtml(String(r.status || ''))} · ${where} · ${escapeHtml(untouchedFor(r.last_touched_at))}${again}</span>
328
+ </div>
329
+ <div class="ginbox__acts">${buttons}</div>
330
+ </li>`;
331
+ }
332
+
333
+ // What each verb says when it lands. Keyed by the SERVER's verb name so a new verb
334
+ // degrades to a neutral confirmation instead of a blank toast.
335
+ const ROT_TOASTS = {
336
+ prioritise: 'Prioritised — it’s claimable now.',
337
+ prune: 'Pruned — back in the backlog.',
338
+ kill: 'Killed — it won’t come back.',
339
+ water: 'Watered — the clock is reset.',
340
+ };
341
+
342
+ function renderRot(data) {
343
+ const box = host && host.querySelector('#attention-rot');
344
+ if (!box) return;
345
+ const rows = (data && Array.isArray(data.rows)) ? data.rows : [];
346
+ if (!rows.length) {
347
+ box.innerHTML = '';
348
+ window.OTBAttention?.refresh();
349
+ return;
350
+ }
351
+ const shown = rows.slice(0, MAX_ROT_ROWS);
352
+ const more = rows.length > shown.length
353
+ ? `<p class="ginbox__pending">…${rows.length - shown.length} more going quiet.</p>`
354
+ : '';
355
+ // The heading names the TIMER, not just the count. A reader who disagrees with
356
+ // the line ("30 days is too short for a backlog") needs to know which number
357
+ // produced it before they can go change it — and when that number came from a
358
+ // value the server could not use, the receipt says so here, on the screen of
359
+ // somebody who can fix it.
360
+ const t = (data && data.timer) || {};
361
+ const timerNote = t.invalid
362
+ ? `<span class="ginbox__sub"> — ${escapeHtml(String(t.describe || ''))}</span>`
363
+ : `<span class="ginbox__sub"> — untouched ${escapeHtml(String(t.rot_days ?? '?'))}+ days</span>`;
364
+ box.innerHTML = '<h3 class="ginbox__sec-h">Going quiet '
365
+ + `<span class="count-pill">${rows.length}</span>${timerNote}</h3>`
366
+ + `<ul class="ginbox__list">${shown.map(rotRowHtml).join('')}</ul>${more}`;
367
+ window.OTBAttention?.refresh();
368
+ }
369
+
370
+ function loadRot() {
371
+ return getJSON(`${API}/inbox/rotting`)
372
+ .then((data) => renderRot(data))
373
+ .catch(() => { /* signed out, older server, or a hiccup: never break the hall */ });
374
+ }
375
+
265
376
  // task 2169: this widget IS the "Needs your attention" card — ONE capped
266
377
  // summary hosting every actionable item class: goal invitations +
267
378
  // join-requests (this file) and ideas awaiting triage (builders.js renders
@@ -294,10 +405,17 @@
294
405
  // awaiting a nod inside a goal sits nearer the goals slice than idea
295
406
  // triage. Its rows carry the shared .ginbox__item class, so refresh()
296
407
  // counts them with no change to the selector below.
297
- '<div id="attention-nod"></div>';
408
+ '<div id="attention-nod"></div>' +
409
+ // task 1003279 (goal 1000072): work going quiet — a goal or task untouched
410
+ // past the project's rot timer, with the owner's four verbs on it. LAST in
411
+ // the card on purpose: every slice above is somebody waiting on YOU right
412
+ // now, while rot is a standing question that has already waited weeks and
413
+ // can wait until they are read.
414
+ '<div id="attention-rot"></div>';
298
415
  host.addEventListener('click', onClick);
299
416
  load();
300
417
  loadNodQueue();
418
+ loadRot();
301
419
  // task 1002802 (P06): the profile slice lives in its own file (builders.js is
302
420
  // over the size budget); this card owns its div, so it boots it here.
303
421
  window.OTBProfileNudge?.load();
@@ -923,17 +923,16 @@
923
923
  ? ctx.lastMeState.builder.preferred_disciplines : [];
924
924
  const discBtn = (value, label) => {
925
925
  const on = prefs.includes(value);
926
- return `<button type="button" class="btn-ghost onboarding-item__disc${on ? ' onboarding-item__disc--on' : ''}" data-disc="${value}" aria-pressed="${on}">${label}</button>`;
926
+ return `<button type="button" class="btn-ghost onboarding-item__disc${on ? ' onboarding-item__disc--on' : ''}" data-disc="${escapeHtml(value)}" aria-pressed="${on}">${escapeHtml(label)}</button>`;
927
927
  };
928
- // R59 (task 1198): 'artist' is offered only when the art-pipeline module
929
- // is enabled — engineer + ideator are methodology core. moduleOn defaults
930
- // to true if the flag is missing, so OTB (art on) is unchanged; a vanilla
931
- // instance hides the button the server would reject anyway.
928
+ // The offered crafts come from the server (task 1003487): window.__MODULES__
929
+ // .disciplines IS enabledDisciplines(), the same list the PATCH validates
930
+ // against, so this can never render a button the API would reject. Was a
931
+ // hardcoded triple with a moduleOn('art-pipeline') branch for 'artist', so
932
+ // a module-contributed craft (ui-design → ui) never appeared here at all.
932
933
  action = `
933
934
  <div class="onboarding-item__action onboarding-item__disc-pick">
934
- ${discBtn('engineer', 'Engineer')}
935
- ${OTB.moduleOn('art-pipeline') ? discBtn('artist', 'Artist') : ''}
936
- ${discBtn('ideator', 'Ideator')}
935
+ ${OTB.disciplines().map((d) => discBtn(d, OTB.disciplineLabel(d))).join('')}
937
936
  </div>`;
938
937
  } else if (s.id === 'ship_three') {
939
938
  action = `
@@ -732,12 +732,13 @@
732
732
 
733
733
  // ---- section 2: CRAFT (discipline preferences) ---------------------------
734
734
 
735
- // Valid disciplines are limited (see me.js + db.updateBuilderDisciplines):
736
- // engineer | ideator (core) + artist when the art-pipeline module is on
737
- // (R59 / task 1198). moduleOn defaults true if the flag is missing, so OTB
738
- // (art on) keeps all three; a vanilla instance offers only the two it accepts.
739
- const DISCIPLINES = ['engineer', 'artist', 'ideator']
740
- .filter((d) => (d === 'artist' ? window.OTB.moduleOn('art-pipeline') : true));
735
+ // The crafts this instance offers, straight from the server (task 1003487):
736
+ // window.__MODULES__.disciplines IS enabledDisciplines(), which is also what
737
+ // db.updateBuilderDisciplines validates against, so what this renders and what
738
+ // the PATCH accepts cannot drift. Was a hardcoded triple gated on
739
+ // moduleOn('art-pipeline'), which silently dropped a module-contributed craft
740
+ // (ui-design → ui) from BOTH the chips and the saved-state echo below.
741
+ const DISCIPLINES = window.OTB.disciplines();
741
742
 
742
743
  // Current selection mutated in place as the builder toggles chips; saved on
743
744
  // the "Save" button (the hall's profile card behaviour) via PATCH.
@@ -751,7 +752,7 @@
751
752
  return (
752
753
  `<label class="craft-chip" data-disc="${escapeHtml(d)}" data-on="${on ? '1' : '0'}">` +
753
754
  `<input type="checkbox" class="craft-pref" value="${escapeHtml(d)}"${on ? ' checked' : ''}>` +
754
- `${escapeHtml(d)}</label>`
755
+ `${escapeHtml(window.OTB.disciplineLabel(d))}</label>`
755
756
  );
756
757
  }).join('');
757
758
  }
@@ -20,10 +20,10 @@ const { pool } = require('../../src/module-api');
20
20
  // spells this same craft 'engineer', not 'builder'. THIS FUNCTION IS THE ONE PLACE
21
21
  // that knows the mapping — the matching surface (C02) must consume it rather than
22
22
  // re-deriving, or the two spellings drift and a builder silently matches nothing.
23
- const HELP_CRAFTS = Object.freeze(['artist', 'builder', 'ideator']);
24
- const CRAFT_TO_DISCIPLINE = Object.freeze({ artist: 'artist', ideator: 'ideator', builder: 'engineer' });
23
+ const HELP_CRAFTS = Object.freeze(['artist', 'builder', 'ideator', 'ui']);
24
+ const CRAFT_TO_DISCIPLINE = Object.freeze({ artist: 'artist', ideator: 'ideator', builder: 'engineer', ui: 'ui' });
25
25
 
26
- const DISCIPLINE_TO_CRAFT = Object.freeze({ artist: 'artist', ideator: 'ideator', engineer: 'builder' });
26
+ const DISCIPLINE_TO_CRAFT = Object.freeze({ artist: 'artist', ideator: 'ideator', engineer: 'builder', ui: 'ui' });
27
27
 
28
28
  function helpCrafts() { return [...HELP_CRAFTS]; }
29
29
  function disciplineForCraft(craft) { return CRAFT_TO_DISCIPLINE[craft] || null; }
@@ -0,0 +1,38 @@
1
+ -- lifecycle_006_help_requests_ui_craft.sql — the `ui` craft becomes addressable in
2
+ -- a help request (task 1003487, the routing half of goal 1000025's UI-reachability
3
+ -- pair).
4
+ --
5
+ -- WHY THIS MIGRATION EXISTS AT ALL. lifecycle_004 fixed the craft vocabulary to
6
+ -- ADR 0174's three (artist, builder, ideator) in a CHECK. `ui` became a real
7
+ -- discipline later (migration 176 widened `builders.preferred_disciplines`, and
8
+ -- discipline-modes.json routes a ui task to /design), and task 1003487 makes it
9
+ -- selectable everywhere a builder chooses work. Every OTHER surface inherits that
10
+ -- automatically because it validates against enabledDisciplines() at run time —
11
+ -- this one does not, because a CHECK is compiled into the table. Without this the
12
+ -- app would accept a help request addressed to `ui` (helpCrafts() now offers it)
13
+ -- and Postgres would reject the INSERT, which is the worst shape of all: a
14
+ -- constraint violation surfacing as a 500 on a feature that reads as supported.
15
+ --
16
+ -- THE VOCABULARY SEAM IS UNCHANGED. `builders.preferred_disciplines` spells the
17
+ -- engineering craft 'engineer' while a help request spells it 'builder'; `ui` is
18
+ -- spelled the same on both sides, so it simply joins the list. help-requests.js
19
+ -- stays the single place that knows the mapping (CRAFT_TO_DISCIPLINE /
20
+ -- DISCIPLINE_TO_CRAFT), and craftMapSql() generates its SQL CASE from that same
21
+ -- object — so nothing here needs to restate it.
22
+ --
23
+ -- SAFE TO RE-RUN. Dropping by name and re-adding is idempotent in effect; existing
24
+ -- rows all carry one of the three older values (or NULL) and satisfy the wider
25
+ -- CHECK, so the ADD VALIDATE cannot fail on live data. No data is rewritten.
26
+
27
+ ALTER TABLE lifecycle_help_requests
28
+ DROP CONSTRAINT IF EXISTS lifecycle_help_requests_craft_chk;
29
+
30
+ ALTER TABLE lifecycle_help_requests
31
+ ADD CONSTRAINT lifecycle_help_requests_craft_chk
32
+ CHECK (needs_craft IS NULL OR needs_craft IN ('artist', 'builder', 'ideator', 'ui'));
33
+
34
+ COMMENT ON COLUMN lifecycle_help_requests.needs_craft IS
35
+ 'The craft this request is addressed to: ADR 0174''s three (artist, builder, ideator) plus ui, which the ui-design module contributes as a discipline (task 1003487). helpCrafts() in modules/lifecycle/help-requests.js is the single place that knows this vocabulary and its spelling seam with builders.preferred_disciplines.';
36
+
37
+ INSERT INTO schema_migrations (version) VALUES ('lifecycle_006_help_requests_ui_craft')
38
+ ON CONFLICT (version) DO NOTHING;
@@ -22,7 +22,8 @@
22
22
  "recommendations",
23
23
  "closeness",
24
24
  "visuals",
25
- "kickoff-seed"
25
+ "kickoff-seed",
26
+ "rot"
26
27
  ],
27
28
  "pollers": [
28
29
  "publish-reconciler"