@bongos/core 1.19.484 → 1.19.486

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 (48) hide show
  1. package/.bongos-core.json +102 -52
  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 +147 -3
  10. package/docs/api-reference.md +7 -4
  11. package/docs/copy-inventory.md +169 -178
  12. package/docs/copy-registry.json +295 -405
  13. package/docs/file-map.md +1 -1
  14. package/docs/module-api-changelog.md +4 -0
  15. package/migrations/core_231_project_settings_and_attention_water.sql +163 -0
  16. package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
  17. package/modules/hall-ui/public/goal-inbox.js +119 -1
  18. package/modules/hall-ui/public/not-ready.html +14 -10
  19. package/modules/hall-ui/public/not-ready.states.json +14 -0
  20. package/modules/hall-ui/public/pair.css +17 -67
  21. package/modules/hall-ui/public/pair.html +8 -1
  22. package/modules/hall-ui/public/pair.states.json +14 -0
  23. package/modules/hall-ui/public/panel.css +333 -0
  24. package/modules/hall-ui/public/profile.css +85 -0
  25. package/modules/hall-ui/public/profile.html +12 -5
  26. package/modules/hall-ui/public/profile.states.json +15 -0
  27. package/modules/hall-ui/public/settings.css +119 -336
  28. package/modules/hall-ui/public/settings.html +28 -19
  29. package/modules/hall-ui/public/settings.js +44 -35
  30. package/modules/hall-ui/public/settings.states.json +16 -0
  31. package/modules/hall-ui/public/style.css +5 -2
  32. package/modules/lifecycle/module.json +2 -1
  33. package/modules/lifecycle/rot.js +210 -0
  34. package/modules/lifecycle/routes/rot.js +160 -0
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/scripts/gds/fitness-checks-vocabulary.js +0 -1
  38. package/scripts/gds/fitness.js +9 -0
  39. package/src/bongos/project-settings.js +193 -0
  40. package/src/module-api.js +29 -1
  41. package/tests/hall_settings_world.mjs +274 -0
  42. package/tests/join_application_note.mjs +7 -15
  43. package/tests/module-scope-map.mjs +1 -1
  44. package/tests/module_api.mjs +1 -0
  45. package/tests/rot_inbox.mjs +532 -0
  46. package/modules/hall-ui/public/my-projects.css +0 -62
  47. package/modules/hall-ui/public/my-projects.html +0 -73
  48. package/modules/hall-ui/public/my-projects.js +0 -153
@@ -134,6 +134,15 @@
134
134
 
135
135
  // Render one row per sound from `effective`. Each row: label + description and
136
136
  // an On/Off toggle button whose data-on reflects the effective enabled-state.
137
+ // The family switch (panel.css `.sw`): a role=switch button whose state is
138
+ // aria-checked, drawn as a track and a knob. Keeps the `.sound-toggle` class
139
+ // and `data-on`, which every delegated click handler on this page reads.
140
+ function switchHtml(on, label, extraAttrs = '') {
141
+ return `<button type="button" class="sound-toggle sw" role="switch" aria-checked="${on ? 'true' : 'false'}" ` +
142
+ `aria-label="${escapeHtml(label)}" data-on="${on ? '1' : '0'}"${extraAttrs}>` +
143
+ '<span class="sw__track"></span><span class="sw__knob"></span></button>';
144
+ }
145
+
137
146
  function renderSounds(data) {
138
147
  const wrap = $('#sound-rows');
139
148
  if (!wrap) return;
@@ -150,9 +159,7 @@
150
159
  `<div class="sound-row__label">${escapeHtml(row.label || row.sound)}</div>` +
151
160
  `<div class="sound-row__desc">${escapeHtml(row.desc || '')}</div>` +
152
161
  '</div>' +
153
- `<button type="button" class="sound-toggle" data-sound="${escapeHtml(row.sound)}" ` +
154
- `data-on="${on ? '1' : '0'}" aria-pressed="${on ? 'true' : 'false'}">` +
155
- `${on ? 'On' : 'Off'}</button>` +
162
+ switchHtml(on, `Sound: ${row.label || row.sound}`, ` data-sound="${escapeHtml(row.sound)}"`) +
156
163
  '</div>'
157
164
  );
158
165
  }).join('');
@@ -405,25 +412,23 @@
405
412
  wrap.innerHTML = window.OTBKit.emptyStateHtml('No levels registered.');
406
413
  return;
407
414
  }
415
+ // One pick among four: a radio row per level (panel.css `.prow--pick`), the
416
+ // whole row its label. A level above the rank ceiling is a disabled radio
417
+ // that says so in the label voice rather than a dead "Rank-locked" button.
408
418
  wrap.innerHTML = levels.map((lv) => {
409
419
  const chosen = lv.n === eff;
410
- const tag = lv.n === def ? ' <span class="voices__default">· default for your rank</span>' : '';
411
- let btn;
412
- if (!lv.allowed) {
413
- btn = `<button type="button" class="sound-toggle" disabled data-level="${lv.n}" data-on="0">Rank-locked</button>`;
414
- } else if (chosen) {
415
- btn = `<button type="button" class="sound-toggle" data-level="${lv.n}" data-on="1" aria-pressed="true">Chosen</button>`;
416
- } else {
417
- btn = `<button type="button" class="sound-toggle" data-level="${lv.n}" data-on="0" aria-pressed="false">Choose</button>`;
418
- }
420
+ const locked = !lv.allowed;
421
+ const id = `wander-${lv.n}`;
422
+ const tags = (lv.n === def ? '<span class="prow__tag">default for your rank</span>' : '') +
423
+ (locked ? '<span class="prow__tag">rank-locked</span>' : '');
419
424
  return (
420
- `<div class="sound-row" data-level="${lv.n}">` +
421
- '<div class="sound-row__text">' +
422
- `<div class="sound-row__label">${escapeHtml(lv.label)}${tag}</div>` +
423
- `<div class="sound-row__desc">${escapeHtml(lv.blurb || '')}</div>` +
424
- '</div>' +
425
- btn +
426
- '</div>'
425
+ `<label class="prow prow--pick" for="${id}" data-level="${lv.n}"${locked ? ' aria-disabled="true"' : ''}>` +
426
+ `<input class="pradio" type="radio" name="wandering" id="${id}" value="${lv.n}"` +
427
+ `${chosen ? ' checked' : ''}${locked ? ' disabled' : ''}>` +
428
+ '<span class="prow__main">' +
429
+ `<span class="prow__label">${escapeHtml(lv.label)}${tags}</span>` +
430
+ `<span class="prow__desc">${escapeHtml(lv.blurb || '')}</span>` +
431
+ '</span></label>'
427
432
  );
428
433
  }).join('');
429
434
  const note = $('#wandering-note');
@@ -466,13 +471,13 @@
466
471
  const data = res.data;
467
472
  renderWandering(data);
468
473
  $('#wandering-scroll').hidden = false;
469
- // Delegate clicks so post-render buttons stay wired (single-select).
474
+ // Delegate the change so post-render radios stay wired (single-select).
470
475
  const wrap = $('#wander-rows');
471
- wrap.addEventListener('click', (e) => {
472
- const b = e.target.closest('.sound-toggle');
473
- if (!b || b.disabled || b.dataset.on === '1') return; // ignore locked + already-chosen
474
- const level = Number(b.dataset.level);
475
- if (Number.isInteger(level)) saveWandering(level, b);
476
+ wrap.addEventListener('change', (e) => {
477
+ const r = e.target.closest('.pradio');
478
+ if (!r || r.disabled || !r.checked) return; // ignore locked; a radio only fires on becoming checked
479
+ const level = Number(r.value);
480
+ if (Number.isInteger(level)) saveWandering(level, r);
476
481
  });
477
482
  } catch (err) {
478
483
  console.error('[settings] failed to load wandering', err);
@@ -497,11 +502,11 @@
497
502
  '<div class="sound-row" data-render="widgets">' +
498
503
  '<div class="sound-row__text">' +
499
504
  '<div class="sound-row__label">Rich in-feed cards' +
500
- (isDefault ? ' <span class="voices__default">· default (on)</span>' : '') +
505
+ (isDefault ? '<span class="prow__tag">default (on)</span>' : '') +
501
506
  '</div>' +
502
507
  '<div class="sound-row__desc">Render session cards as widgets (prettier, costs tokens) instead of plain text (cheaper, identical info).</div>' +
503
508
  '</div>' +
504
- `<button type="button" class="sound-toggle" data-on="${on ? '1' : '0'}" aria-pressed="${on ? 'true' : 'false'}">${on ? 'On' : 'Off'}</button>` +
509
+ switchHtml(on, 'Rich in-feed cards') +
505
510
  '</div>';
506
511
  }
507
512
 
@@ -579,7 +584,7 @@
579
584
  '<div class="sound-row__label">Show my cross-project record</div>' +
580
585
  `<div class="sound-row__desc">${escapeHtml(totalsLine + consentNote)} You always see your own numbers here.</div>` +
581
586
  '</div>' +
582
- `<button type="button" class="sound-toggle" data-on="${on ? '1' : '0'}" aria-pressed="${on ? 'true' : 'false'}">${on ? 'Shown' : 'Hidden'}</button>` +
587
+ switchHtml(on, 'Show my cross-project record') +
583
588
  '</div>';
584
589
  }
585
590
 
@@ -637,10 +642,10 @@
637
642
  function privacyRow(key, label, state, on, desc, note) {
638
643
  return `<div class="sound-row" data-privacy="${key}">` +
639
644
  '<div class="sound-row__text">' +
640
- `<div class="sound-row__label">${escapeHtml(label)}${note}</div>` +
645
+ `<div class="sound-row__label">${escapeHtml(label)}<span class="prow__tag">${escapeHtml(state)}</span>${note}</div>` +
641
646
  `<div class="sound-row__desc">${escapeHtml(desc)}</div>` +
642
647
  '</div>' +
643
- `<button type="button" class="sound-toggle" data-on="${on ? '1' : '0'}" aria-pressed="${on ? 'true' : 'false'}">${state}</button>` +
648
+ switchHtml(on, `${label}: ${state}`) +
644
649
  '</div>';
645
650
  }
646
651
 
@@ -976,11 +981,14 @@
976
981
  const body = (res.data || {});
977
982
  if (!res.ok) throw new Error(body.message || `HTTP ${res.status}`);
978
983
  const expires = body.expires_at ? new Date(body.expires_at).toLocaleString() : 'unknown';
984
+ // The secret is QUOTED (settings.css `.quote`): a 64-character token cannot
985
+ // be a line of prose, so it sits one tonal step down with its own foot.
979
986
  result.innerHTML =
980
- '<p>Fresh CLI token issued. Paste it in your terminal:</p>' +
981
- `<pre>${escapeHtml(body.paste_command || '')}</pre>` +
982
- '<p><button type="button" class="settings-btn" id="cli-reissue-copy">Copy</button>' +
983
- `<span class="settings-card__hint" style="margin-left:0.6rem;">Expires ${escapeHtml(expires)} · refreshes on use.</span></p>`;
987
+ '<p class="cli-result__say">Fresh CLI token issued. Paste it in your terminal:</p>' +
988
+ '<div class="quote"><p class="quote__eyebrow">paste command</p>' +
989
+ `<pre class="quote__pre">${escapeHtml(body.paste_command || '')}</pre>` +
990
+ '<div class="quote__foot"><button type="button" class="settings-btn settings-btn--quiet" id="cli-reissue-copy">Copy</button>' +
991
+ `<span class="settings-card__hint">Expires ${escapeHtml(expires)} · refreshes on use.</span></div></div>`;
984
992
  result.hidden = false;
985
993
  const copyBtn = $('#cli-reissue-copy');
986
994
  if (copyBtn) copyBtn.addEventListener('click', async () => {
@@ -1037,10 +1045,11 @@
1037
1045
 
1038
1046
  function artKeyEntryHtml() {
1039
1047
  return (
1048
+ '<label class="field-label" for="artkey-input">Your key</label>' +
1040
1049
  '<div class="artkey-entry-row">' +
1041
1050
  '<input type="password" class="artkey-input" id="artkey-input" autocomplete="off" ' +
1042
1051
  'spellcheck="false" placeholder="Paste your Google AI Studio key">' +
1043
- '<button type="button" class="settings-btn" id="artkey-save">Save key</button>' +
1052
+ '<button type="button" class="settings-btn craft-save" id="artkey-save">Save key</button>' +
1044
1053
  '</div>' +
1045
1054
  `<p class="settings-card__hint">${getOneLink} · Stored encrypted; never shown again after saving.</p>`
1046
1055
  );
@@ -0,0 +1,16 @@
1
+ {
2
+ "_": "The hall's SETTINGS page through modules/ui-design/kit/render.js (task 1003308). Every panel on it is a form the page script fills from a dozen /me/* reads, so the floors run against the hall-preview harness rather than the kit's own stub:\n\n node scripts/hall-preview/server.js --port 4621 --fixture-me\n node modules/ui-design/kit/render.js --states modules/hall-ui/public/settings.states.json --base-in http://builders.localhost:4621 --base-out http://builders.localhost:4621\n\nOne group renders at a time behind the section rail, so each rail group is its own state. There is no `out` state on purpose: settings.js answers a 401 by navigating to the sign-in flow, and a state whose page navigates away measures the destination, not this page (the not-signed-in gate panel is the non-401 failure render, pinned statically in tests/hall_settings_world.mjs).",
3
+ "page": "/settings",
4
+ "surface": "hall-ui",
5
+ "stub": { "prefix": "/builders/" },
6
+ "modeQuery": false,
7
+ "ignoreRequests": ["/api/(gds|bongos)/", "/downloads/devbox/"],
8
+ "ignoreConsole": ["/api/(gds|bongos)/", "/downloads/devbox/", "\b401\b", "\b404\b", "Unauthorized", "Not Found", "load failed", "Password field"],
9
+ "states": {
10
+ "account": { "auth": true, "actions": [["wait", 900]], "expect": { "visible": ["#settings-h1", "#display-name-input", "#craft-chips", "#privacy-rows"] } },
11
+ "access": { "auth": true, "url": "/settings#access", "actions": [["wait", 900]], "expect": { "visible": ["#cli-reissue-btn", "#sessions-list", "#artkey-card"] } },
12
+ "preferences": { "auth": true, "url": "/settings#preferences", "actions": [["wait", 900]], "expect": { "visible": ["#wander-rows", "#render-rows"] } },
13
+ "sound": { "auth": true, "url": "/settings#sound", "actions": [["wait", 900]], "expect": { "visible": ["#voices-table", "#sound-rows", "#event-sound-rows"] } }
14
+ },
15
+ "reducedMotion": { "auth": true, "actions": [["wait", 900]] }
16
+ }
@@ -1693,7 +1693,10 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
1693
1693
  color: var(--ink-soft);
1694
1694
  }
1695
1695
  .profile__disc-label { color: var(--ink-faint); }
1696
- .profile__disc-edit { font-size: 12.5px; }
1696
+ /* task 1003308: a link in the Standing card's fact line is a control and owes
1697
+ the 24px floor (it measured 123x19); the prose-link recipe buys the height
1698
+ with padding and hands the width back with the negative margin. */
1699
+ .profile__disc-edit { display: inline-block; min-width: 24px; min-height: 24px; padding: 3px 4px; margin: -3px -4px; font-size: 12.5px; }
1697
1700
  .profile__credits {
1698
1701
  display: flex;
1699
1702
  flex-direction: column;
@@ -1731,7 +1734,7 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
1731
1734
  .profile__budget--over { background: var(--danger-weak); color: var(--danger); border-color: color-mix(in srgb, var(--danger) 25%, transparent); }
1732
1735
  .profile__needs--action { background: var(--warn-weak); color: var(--warn); border-color: color-mix(in srgb, var(--warn) 25%, transparent); }
1733
1736
  .profile__needs--covered { background: var(--ok-weak); color: var(--ok); border-color: color-mix(in srgb, var(--ok) 22%, transparent); }
1734
- .profile__needs-act { font-weight: 600; white-space: nowrap; }
1737
+ .profile__needs-act { display: inline-block; min-width: 24px; min-height: 24px; padding: 3px 4px; margin: -3px -4px; font-weight: 600; white-space: nowrap; }
1735
1738
  /* ADR 0120 part 5: owed-rebase gate banner — danger-tinted (it blocks new claims). */
1736
1739
  .profile__rebase { background: var(--danger-weak); color: var(--danger); border-color: color-mix(in srgb, var(--danger) 25%, transparent); }
1737
1740
 
@@ -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"
@@ -0,0 +1,210 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/rot.js — what is quietly rotting, and the one write that says
4
+ // "leave it alone" (task 1003279, goal 1000072, criterion legibility-sort-and-staleness).
5
+ //
6
+ // WHY THIS EXISTS. The goal's rotting half: "Untouched-for-X triggers a configured
7
+ // action rather than a silent decay." The owner's decision (2026-08-25) fixed what
8
+ // the action is — a NEEDS-ATTENTION ITEM, not an automatic status change and not a
9
+ // nightly digest. A goal or task past the timer raises one line in the hall's
10
+ // existing inbox, addressed to whoever created it and to the owner, offering the
11
+ // four verbs the goal's own prose asks for: prune, kill, prioritise, water.
12
+ // Rejected, and why: auto-demoting mutates real work with no human in the loop, and
13
+ // a wrong timer then silently freezes it; a digest is a notification, not an action,
14
+ // and gets scrolled past.
15
+ //
16
+ // THERE IS NO SWEEP, AND THAT IS THE DESIGN. Every line here is DERIVED on read from
17
+ // the rot clock against the timer. Nothing is stored, so "exactly one open item per
18
+ // stale thing, not one per sweep" is true by construction rather than by a
19
+ // deduplicating poller that has to be written correctly and then stay correct. The
20
+ // three consequences the owner's note asks for all fall out of the same fact:
21
+ // • acting on a line (prune/kill/prioritise) changes status → the row stops matching
22
+ // • watering pushes attention_watered_at forward → the row stops matching
23
+ // • a real edit moves updated_at → the row stops matching
24
+ // Nothing to reconcile, nothing to garbage-collect, no way to accrete duplicates,
25
+ // and no window in which the inbox and the database disagree.
26
+ //
27
+ // WHAT CAN ROT, AND WHAT DELIBERATELY CANNOT. Rot is *work nobody has picked up* —
28
+ // not work in flight. Each exclusion below is excluded because some OTHER surface
29
+ // already owns that question, and two surfaces asking the same question with two
30
+ // different timers is the ADR 0226 failure:
31
+ // 'active' → the CLAIM-silence timer owns it (src/stale-timer.js);
32
+ // the sweeper releases it and the Archon roster reports it.
33
+ // 'blocked' → a blocker owns it; /blocker-review is its cadence.
34
+ // 'completed','confirmed' → the ship pipeline owns it; the publish reconciler and
35
+ // the "Stranded ships" line report it.
36
+ // 'shipped','abandoned' → terminal. Nothing rots after it is done.
37
+ // That leaves the queue nobody is working, which is exactly the pile the four verbs
38
+ // are verbs for.
39
+
40
+ const api = require('../../src/module-api');
41
+
42
+ const { pool } = api;
43
+
44
+ // The statuses that can rot, per tier. Exported so the tests assert against the same
45
+ // set the query reads — a second copy of this list is how the inbox and its tests
46
+ // drift into disagreeing about what rot even is.
47
+ const TASK_ROTTABLE_STATUSES = Object.freeze(['backlog', 'ready']);
48
+ const GOAL_ROTTABLE_STATUSES = Object.freeze(['open']);
49
+
50
+ // The rot clock, spelled once. Both strings are duplicated VERBATIM in the index
51
+ // expressions in migration core_231 — if you change one, change both, or the query
52
+ // silently stops using the index. See that migration's header for why the first two
53
+ // terms are shaped the way they are (they are task-lib.js's published definition of
54
+ // "touched"; the water mark is the third term this task adds).
55
+ const TASK_ROT_CLOCK = 'GREATEST(t.updated_at, COALESCE(t.shipped_at, t.updated_at), COALESCE(t.attention_watered_at, t.updated_at))';
56
+ const GOAL_ROT_CLOCK = 'GREATEST(g.updated_at, COALESCE(g.attention_watered_at, g.updated_at))';
57
+
58
+ // The verbs a line offers, per tier — served WITH the rows so the client renders
59
+ // what the server will actually accept, instead of a button list that can drift out
60
+ // of step with the routes behind it.
61
+ //
62
+ // Three of the four are EXISTING routes, on purpose: the nod slice in this same card
63
+ // established the precedent (task 1003066 reused POST /tasks/:id/promote rather than
64
+ // inventing an endpoint), and a verb that reuses a route also reuses its permission,
65
+ // so this feature adds no new authority. Only `water` is new, because nothing in the
66
+ // system could previously say "I looked at this, it is fine, ask me again later".
67
+ //
68
+ // A GOAL gets two verbs, not four, and the omission is honest rather than lazy:
69
+ // promote/demote are task-lifecycle transitions that a goal has no equivalent of
70
+ // (a goal is open, achieved or archived). Rendering a dead "prioritise" button on a
71
+ // goal row would be worse than not offering it.
72
+ const TASK_VERBS = Object.freeze([
73
+ Object.freeze({ verb: 'prioritise', label: 'Prioritise', method: 'POST', path: '/tasks/:id/promote', hint: 'make it claimable now' }),
74
+ Object.freeze({ verb: 'prune', label: 'Prune', method: 'POST', path: '/tasks/:id/demote', hint: 'back to the backlog, not dead' }),
75
+ Object.freeze({ verb: 'kill', label: 'Kill', method: 'POST', path: '/tasks/:id/abandon', hint: 'it is not going to happen' }),
76
+ Object.freeze({ verb: 'water', label: 'Water', method: 'POST', path: '/tasks/:id/water', hint: 'leave it — reset the clock' }),
77
+ ]);
78
+ const GOAL_VERBS = Object.freeze([
79
+ Object.freeze({ verb: 'kill', label: 'Archive', method: 'POST', path: '/goals/:id/archive', hint: 'close it out' }),
80
+ Object.freeze({ verb: 'water', label: 'Water', method: 'POST', path: '/goals/:id/water', hint: 'leave it — reset the clock' }),
81
+ ]);
82
+
83
+ const MAX_ROT_ROWS = 200;
84
+
85
+ // rottingFor({ builderId, canSeeAll, rotDays, limit }) — the rot lines this caller
86
+ // should see, oldest first.
87
+ //
88
+ // SCOPE. The owner's note addresses the item "to its creator, and to the owner", so:
89
+ // a builder sees what they created; a caller with `canSeeAll` (the owner) sees
90
+ // everything. A thing whose creator is NULL — created before attribution existed, or
91
+ // by a system path (the core_227 convention) — is visible ONLY to canSeeAll, because
92
+ // there is no person it could honestly be addressed to. Silently showing orphaned
93
+ // rot to every builder would make the inbox everyone's problem and therefore
94
+ // nobody's.
95
+ //
96
+ // Returns { rows, rotDays, cutoff } — `cutoff` is the timestamp the clock was
97
+ // compared against, echoed so a reader can tell WHY a row is here without redoing
98
+ // the arithmetic.
99
+ async function rottingFor({ builderId, canSeeAll = false, rotDays, limit = 50 } = {}) {
100
+ const days = Number(rotDays);
101
+ if (!Number.isInteger(days) || days <= 0) {
102
+ throw new Error('rottingFor: rotDays must be a positive whole number of days');
103
+ }
104
+ const id = Number(builderId);
105
+ if (!Number.isInteger(id) || id <= 0) return { rows: [], rotDays: days, cutoff: null };
106
+ const cap = Math.max(1, Math.min(MAX_ROT_ROWS, Number(limit) || 50));
107
+
108
+ // One round trip for both tiers. The UNION arms are shaped to the same column list
109
+ // so the client renders one list of lines, not two lists it has to interleave.
110
+ const { rows } = await pool.query(
111
+ `WITH cutoff AS (SELECT now() - make_interval(days => $1::int) AS at)
112
+ SELECT * FROM (
113
+ SELECT 'task'::text AS tier,
114
+ t.id::text AS id,
115
+ t.title AS title,
116
+ t.status AS status,
117
+ t.goal_id::text AS goal_id,
118
+ g.title AS goal_title,
119
+ t.created_by::text AS created_by,
120
+ ${TASK_ROT_CLOCK} AS last_touched_at,
121
+ (t.attention_watered_at IS NOT NULL) AS ever_watered
122
+ FROM tasks t
123
+ LEFT JOIN goals g ON g.id = t.goal_id
124
+ WHERE t.status = ANY($2::text[])
125
+ AND ${TASK_ROT_CLOCK} < (SELECT at FROM cutoff)
126
+ AND ($4::boolean OR t.created_by = $3::bigint)
127
+ UNION ALL
128
+ SELECT 'goal'::text AS tier,
129
+ g.id::text AS id,
130
+ g.title AS title,
131
+ g.status AS status,
132
+ NULL::text AS goal_id,
133
+ NULL::text AS goal_title,
134
+ g.created_by::text AS created_by,
135
+ ${GOAL_ROT_CLOCK} AS last_touched_at,
136
+ (g.attention_watered_at IS NOT NULL) AS ever_watered
137
+ FROM goals g
138
+ WHERE g.status = ANY($5::text[])
139
+ AND ${GOAL_ROT_CLOCK} < (SELECT at FROM cutoff)
140
+ AND ($4::boolean OR g.created_by = $3::bigint)
141
+ ) rot
142
+ ORDER BY last_touched_at ASC, tier ASC, id ASC
143
+ LIMIT $6`,
144
+ [days, TASK_ROTTABLE_STATUSES, id, canSeeAll, GOAL_ROTTABLE_STATUSES, cap]
145
+ );
146
+
147
+ const cutoffAt = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
148
+ return {
149
+ rows: rows.map((r) => ({ ...r, verbs: r.tier === 'task' ? TASK_VERBS : GOAL_VERBS })),
150
+ rotDays: days,
151
+ cutoff: cutoffAt,
152
+ };
153
+ }
154
+
155
+ // water(tier, id, builderId) — the clock reset, and the only write this file makes.
156
+ //
157
+ // IDEMPOTENT BY NATURE: watering an already-watered thing just moves the mark
158
+ // forward again. There is no "already watered" error to handle, because a second
159
+ // watering is a perfectly sensible thing for a human to do.
160
+ //
161
+ // Returns the new mark, or null when no such open row exists — the caller turns that
162
+ // into a 404. Note the status filter: watering something that is already shipped,
163
+ // abandoned or archived is refused, because the clock on a finished thing means
164
+ // nothing and silently succeeding would tell the caller a lie.
165
+ async function water(tier, id, builderId) {
166
+ const rowId = Number(id);
167
+ if (!Number.isInteger(rowId) || rowId <= 0) return null;
168
+ const by = Number.isInteger(Number(builderId)) && Number(builderId) > 0 ? Number(builderId) : null;
169
+
170
+ const table = tier === 'goal' ? 'goals' : 'tasks';
171
+ const statuses = tier === 'goal' ? GOAL_ROTTABLE_STATUSES : TASK_ROTTABLE_STATUSES;
172
+ const { rows } = await pool.query(
173
+ `UPDATE ${table}
174
+ SET attention_watered_at = now(),
175
+ attention_watered_by = $2
176
+ WHERE id = $1
177
+ AND status = ANY($3::text[])
178
+ RETURNING id::text AS id, attention_watered_at, attention_watered_by::text AS attention_watered_by`,
179
+ [rowId, by, statuses]
180
+ );
181
+ return rows[0] || null;
182
+ }
183
+
184
+ // createdBy(tier, id) — who a rot line is addressed to, for the authorization check
185
+ // on `water`. Returns { created_by, status } or null. Separate from water() so the
186
+ // route can refuse BEFORE writing, rather than writing and then discovering it
187
+ // should not have.
188
+ async function createdBy(tier, id) {
189
+ const rowId = Number(id);
190
+ if (!Number.isInteger(rowId) || rowId <= 0) return null;
191
+ const table = tier === 'goal' ? 'goals' : 'tasks';
192
+ const { rows } = await pool.query(
193
+ `SELECT created_by::text AS created_by, status FROM ${table} WHERE id = $1`,
194
+ [rowId]
195
+ );
196
+ return rows[0] || null;
197
+ }
198
+
199
+ module.exports = {
200
+ TASK_ROTTABLE_STATUSES,
201
+ GOAL_ROTTABLE_STATUSES,
202
+ TASK_ROT_CLOCK,
203
+ GOAL_ROT_CLOCK,
204
+ TASK_VERBS,
205
+ GOAL_VERBS,
206
+ MAX_ROT_ROWS,
207
+ rottingFor,
208
+ water,
209
+ createdBy,
210
+ };
@@ -0,0 +1,160 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/routes/rot.js — GET /inbox/rotting + the two water writes
4
+ // (task 1003279, goal 1000072, criterion legibility-sort-and-staleness).
5
+ //
6
+ // WHY. Goal 1000072's rotting half: work must not decay in silence. This is the read
7
+ // the hall's needs-attention inbox calls to find out what is rotting, plus the one
8
+ // write the owner's fourth verb needs — "water": leave it alone and reset its clock.
9
+ //
10
+ // The other three verbs (prune, kill, prioritise) are NOT here, and their absence is
11
+ // the design. They reuse the routes that already do those jobs — promote, demote,
12
+ // abandon, archive — which is the precedent the nod slice set in this same card
13
+ // (task 1003066). A verb that reuses a route reuses its permission, so three
14
+ // quarters of this feature adds no new authority to the system at all.
15
+ //
16
+ // The route is only the doorway; ../rot.js owns the SQL and the definition of rot.
17
+ // Keep it that way — this file must never grow a query.
18
+ //
19
+ // MODULE BOUNDARY: reaches core only through the published doorway
20
+ // `../../../src/module-api`; `../rot` is an intra-module sibling.
21
+
22
+ const express = require('express');
23
+ const api = require('../../../src/module-api');
24
+ const rot = require('../rot');
25
+
26
+ // The module's namespaced pino child (task 1003208) rather than console.* — a
27
+ // structured line is attributable and rides ADR 0022's redaction; a bare console
28
+ // call is neither, which is why the quality ratchet counts them.
29
+ const log = api.logger('lifecycle');
30
+
31
+ // Who may see rot that is addressed to someone else.
32
+ //
33
+ // The owner's note addresses each item "to its creator, and to the owner". The
34
+ // creator half is an ownership fact the query already applies. This atom is the
35
+ // other half — `builder.roster.read` is "may read another builder's record off the
36
+ // roster", which is exactly what seeing someone else's rot is, and it is the atom
37
+ // /builders/:id/active-claims and /builders/:id/credits already carry for the same
38
+ // act. ADR 0157 delegated it archon→metic; the owner holds it a fortiori, so
39
+ // "addressed to the owner" is satisfied without hardcoding an Archon identity
40
+ // anywhere (the rule audit-authorship.sh enforces).
41
+ const SEE_ALL_ROT = 'builder.roster.read';
42
+
43
+ // Programmatic permission check — the middleware form cannot be used here because a
44
+ // CREATOR with no atom at all must still be allowed through, and a gate mounted in
45
+ // front of the handler would refuse them before the ownership fact is known.
46
+ // Returns false when the governance port is absent or throws: authority that cannot
47
+ // be established is a denial, never a pass (ADR 0016 fail-closed).
48
+ async function holdsSeeAll(builderId) {
49
+ try {
50
+ const governance = api.resolveOptional('government');
51
+ if (!governance || typeof governance.builderHasPermissions !== 'function') return false;
52
+ return await governance.builderHasPermissions(Number(builderId), [SEE_ALL_ROT]);
53
+ } catch (err) {
54
+ log.error(`rot: permission resolve failed — denying fail-closed: ${err && err.message}`);
55
+ return false;
56
+ }
57
+ }
58
+
59
+ module.exports = function buildRotRouter() {
60
+ const router = express.Router();
61
+
62
+ // GET /inbox/rotting — what is quietly rotting, for this caller.
63
+ //
64
+ // rank: any-builder — own-scoped by default. The builder id comes from the
65
+ // authenticated session and is never read off the request, so a caller can only
66
+ // ever widen the result by HOLDING an atom, never by naming a different builder.
67
+ // A Xenos sees exactly the things they created and nothing else.
68
+ //
69
+ // ?rot_days= is a LENS, not a setting: it answers "what would 7 days show me?"
70
+ // without reconfiguring the project, and is never written back. The response
71
+ // reports the timer and where the number came from, so a reader can always tell a
72
+ // lens apart from the project's real answer (the src/stale-timer.js receipt
73
+ // convention — a number with no provenance is what let 6h and 24h coexist).
74
+ router.get('/inbox/rotting', api.requireBuilder, async (req, res) => {
75
+ try {
76
+ const builderId = Number(req.builder.id);
77
+ const timer = await api.resolveRotDays({ override: req.query.rot_days });
78
+ const canSeeAll = await holdsSeeAll(builderId);
79
+ const { limit } = api.parsePagination(req, { defaultLimit: 50, maxLimit: rot.MAX_ROT_ROWS });
80
+ const found = await rot.rottingFor({ builderId, canSeeAll, rotDays: timer.days, limit });
81
+
82
+ // A rot read is a clock comparison — a cached one is a wrong one the moment
83
+ // somebody waters a row.
84
+ res.set('Cache-Control', 'no-store');
85
+ res.json({
86
+ rows: found.rows,
87
+ count: found.rows.length,
88
+ scope: canSeeAll ? 'all' : 'mine',
89
+ timer: {
90
+ rot_days: timer.days,
91
+ source: timer.source,
92
+ // Present ONLY when a configured value could not be used. The inbox shows
93
+ // it to the person who can fix it rather than 500-ing (the reporting-
94
+ // degrades half of ADR 0226's asymmetric fail posture).
95
+ invalid: timer.invalid,
96
+ describe: api.projectSettings.describeRotTimer(timer),
97
+ },
98
+ cutoff: found.cutoff,
99
+ });
100
+ } catch (err) {
101
+ log.error(`GET /inbox/rotting: ${err && err.message}`);
102
+ res.fail('rot_read_failed', { status: 500, message: 'internal error' });
103
+ }
104
+ });
105
+
106
+ // The two water writes share everything but their tier, so they share a handler.
107
+ // Two separate routes rather than one /:tier/ route on purpose: an explicit path
108
+ // per tier is what lets the route-rank auditor and the generated OpenAPI describe
109
+ // each one, and it keeps a tier name out of the URL where a typo would 404 into
110
+ // something confusing.
111
+ //
112
+ // rank: any-builder + an ownership-or-atom check in the handler. Watering is the
113
+ // gentlest of the four verbs — it changes no status and destroys nothing; it says
114
+ // "I looked, it is fine, ask me later". Gating it any harder than seeing the line
115
+ // would mean showing a builder a button they are not allowed to press.
116
+ function waterHandler(tier) {
117
+ return async (req, res) => {
118
+ const id = api.parseId(req, res, { code: tier === 'goal' ? 'bad_goal_id' : 'bad_task_id', positiveInt: true });
119
+ if (id === null) return;
120
+ try {
121
+ const row = await rot.createdBy(tier, id);
122
+ if (!row) return res.fail(tier === 'goal' ? 'goal_not_found' : 'task_not_found', 404);
123
+
124
+ // Authorized iff the line was addressed to you: you created the thing, or you
125
+ // hold the atom that lets you see other people's rot.
126
+ const mine = row.created_by != null && String(row.created_by) === String(req.builder.id);
127
+ if (!mine && !(await holdsSeeAll(Number(req.builder.id)))) {
128
+ return res.fail('permission_forbidden', {
129
+ status: 403,
130
+ message: `only the ${tier}'s creator, or a builder holding ${SEE_ALL_ROT}, can water it`,
131
+ });
132
+ }
133
+
134
+ const watered = await rot.water(tier, id, Number(req.builder.id));
135
+ if (!watered) {
136
+ // The row exists but is not in a rottable status — already shipped,
137
+ // abandoned or archived. Say so rather than succeeding vacuously: a clock
138
+ // on a finished thing means nothing, and a silent 200 would tell the caller
139
+ // their click did something it did not.
140
+ return res.fail('not_rottable', {
141
+ status: 409,
142
+ message: `${tier} ${id} is '${row.status}' — a finished ${tier} has no rot clock to reset`,
143
+ });
144
+ }
145
+ res.json({ ok: true, tier, id: watered.id, watered_at: watered.attention_watered_at });
146
+ } catch (err) {
147
+ log.error(`POST /${tier}s/:id/water: ${err && err.message}`);
148
+ res.fail('water_failed', { status: 500, message: 'internal error' });
149
+ }
150
+ };
151
+ }
152
+
153
+ // POST /tasks/:id/water — reset a rotting task's clock.
154
+ router.post('/tasks/:id/water', api.requireBuilder, waterHandler('task'));
155
+
156
+ // POST /goals/:id/water — reset a rotting goal's clock.
157
+ router.post('/goals/:id/water', api.requireBuilder, waterHandler('goal'));
158
+
159
+ return router;
160
+ };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.484",
3
+ "version": "1.19.486",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.484",
9
+ "version": "1.19.486",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.484",
3
+ "version": "1.19.486",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -118,7 +118,6 @@ const GOAL_VOCAB_SURFACE = [
118
118
  // The FIFTH use (task 1003090): the hub's coarse project membership.
119
119
  'modules/platform-identity/platform-identity.js',
120
120
  'modules/platform-identity/routes/my-projects.js',
121
- 'modules/hall-ui/public/my-projects.js',
122
121
  ];
123
122
  // modules/lifecycle/db.js is 5k+ lines and legitimately says "role" about ranks
124
123
  // (the rank-sync port, builders.system_role). Scanning it whole would need an
@@ -515,6 +515,15 @@ const KERNEL_FILES = [
515
515
  // this roster would read as a kernel->non-kernel leak; it imports only
516
516
  // instance-config + node built-ins, so joining costs nothing.
517
517
  'src/stale-timer.js',
518
+ // task 1003279 (goal 1000072, ADR 0232): the project's live knob store and the ROT
519
+ // timer resolver over it — the same category as stale-timer.js directly above (host
520
+ // configuration a module reads rather than hardcodes), differing only in living in
521
+ // the DB instead of the env, because task 1003280 requires that knob be changeable
522
+ // from a page without a redeploy. The doorway re-exports it, so leaving it off this
523
+ // roster reads as a kernel->non-kernel leak; it imports only pool.js (itself on this
524
+ // roster) + node built-ins, so joining costs nothing and buys the import-direction
525
+ // guarantee on the file every future project knob will be read through.
526
+ 'src/bongos/project-settings.js',
518
527
  // BV1.R86: the git-remote/slug + publish-target identity the doorway exposes
519
528
  // (api.repoInfo) for the carved lifecycle github-push/conflict-resolve. It is
520
529
  // KERNEL (already in the module-scope-map kernel key) and imports only the