@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,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
+ };
@@ -159,7 +159,10 @@ function fmtMins(n) {
159
159
  return m ? `~${h}h${m}m` : `~${h}h`;
160
160
  }
161
161
 
162
- const DISCIPLINE_SKILL = { engineer: 'dev', artist: 'paint', ideator: 'ideate' };
162
+ // Mirrors scripts/gds/discipline-modes.json, which is what claim.js prints. 'ui'
163
+ // was missing here, so a UI task's card sent the builder to /dev while the claim
164
+ // had just sent them to /design (task 1003487).
165
+ const DISCIPLINE_SKILL = { engineer: 'dev', artist: 'paint', ideator: 'ideate', ui: 'design' };
163
166
 
164
167
  // The next-action per stage: { kind:'prompt', label, cmd } or { kind:'link', label, url }.
165
168
  function nextActionFor(stage, { taskId, discipline, sandboxUrl } = {}) {
@@ -32,22 +32,48 @@ const TEMPLATES_PATH = path.join(REPO_ROOT, 'scripts/gds/newcomer-floor.template
32
32
  const CONFIG_PATH = path.join(REPO_ROOT, 'scripts/gds/newcomer-floor.config.json');
33
33
  const SOURCE_TAG = 'newcomer-template';
34
34
  const HARD_MAX_MINUTES = 60;
35
- const VALID_DISCIPLINES = new Set(['engineer', 'artist', 'ideator']);
35
+ // The crafts THIS instance offers, read through the published doorway (the one
36
+ // core file a module may require, ADR 0083). This was a hardcoded Set that did not
37
+ // track enabledDisciplines(), so it silently diverged the moment a module
38
+ // contributed a craft: the picker could offer it, a builder could save it, and
39
+ // restock would still never stamp its chores — a newcomer whose only craft was that
40
+ // one saw an empty board with nothing to self-heal it (task 1003487).
41
+ function validDisciplines() {
42
+ const { enabledDisciplines } = require('../../src/module-api');
43
+ return new Set(enabledDisciplines());
44
+ }
36
45
 
37
46
  // Each discipline -> ARRAY of chore templates. A bare object (the pre-1488 single-
38
47
  // chore shape) is tolerated and wrapped, so an older/other instance's templates.json
39
48
  // still loads. Only entries with the required fields survive.
49
+ //
50
+ // Keyed by whatever the FILE declares (a leading-underscore key is metadata, not a
51
+ // craft) and deliberately NOT narrowed to what this instance offers: this is the
52
+ // catalogue of chores that EXIST, and choosing which of them to restock is
53
+ // restockAll's job. Narrowing here would drop a disabled craft's chores from the
54
+ // catalogue outright, which is a different question from "should we stamp them".
40
55
  function loadTemplates() {
41
56
  const raw = JSON.parse(fs.readFileSync(TEMPLATES_PATH, 'utf8'));
42
57
  const out = {};
43
- for (const d of VALID_DISCIPLINES) {
44
- if (!raw[d]) continue;
45
- const list = Array.isArray(raw[d]) ? raw[d] : [raw[d]];
58
+ for (const [d, v] of Object.entries(raw)) {
59
+ if (d.startsWith('_') || !v) continue;
60
+ const list = Array.isArray(v) ? v : [v];
46
61
  out[d] = list.filter((t) => t && t.template_id && t.version_id && t.title);
47
62
  }
48
63
  return out;
49
64
  }
50
65
 
66
+ // The crafts that HAVE chores defined — the guard for "can this discipline be
67
+ // restocked at all", as distinct from validDisciplines() ("does this instance
68
+ // offer it"), which is what decides whether we actually stamp.
69
+ //
70
+ // Takes an ALREADY-LOADED map: every hot path reads the templates once and passes
71
+ // it here and to restockDiscipline, so a single restock does one readFileSync of
72
+ // newcomer-floor.templates.json rather than one per helper per call.
73
+ function templateDisciplines(byDiscipline = loadTemplates()) {
74
+ return new Set(Object.keys(byDiscipline));
75
+ }
76
+
51
77
  // Per-discipline DEFAULT floor from config (fallback when a template omits its own).
52
78
  function loadFloors() {
53
79
  try {
@@ -150,9 +176,9 @@ async function restockTemplate(discipline, template, floors) {
150
176
 
151
177
  // Restock EVERY chore of a discipline to its floor. Returns an ARRAY of per-template
152
178
  // results (so a discipline with several chores tops each one up independently).
153
- async function restockDiscipline(discipline) {
154
- if (!VALID_DISCIPLINES.has(discipline)) return [{ discipline, skipped: 'invalid discipline' }];
155
- const templates = loadTemplates()[discipline] || [];
179
+ async function restockDiscipline(discipline, byDiscipline = loadTemplates()) {
180
+ const templates = byDiscipline[discipline];
181
+ if (!templates) return [{ discipline, skipped: 'invalid discipline' }];
156
182
  if (!templates.length) return [{ discipline, skipped: 'no usable template' }];
157
183
  const floors = loadFloors();
158
184
  const out = [];
@@ -166,31 +192,35 @@ async function restockDiscipline(discipline) {
166
192
  // be matched to a known template. Returns an array of per-template results, or
167
193
  // [{ skipped:true }] for any non-chore task (callers ignore the value).
168
194
  async function restockForClaim(task) {
169
- if (!task || task.source !== SOURCE_TAG || !VALID_DISCIPLINES.has(task.discipline)) {
170
- return [{ skipped: true }];
171
- }
172
- const templates = loadTemplates()[task.discipline] || [];
195
+ // The cheap rejections first: a non-chore task is the common case and must not
196
+ // pay a file read at all.
197
+ if (!task || task.source !== SOURCE_TAG) return [{ skipped: true }];
198
+ const byDiscipline = loadTemplates();
199
+ const templates = byDiscipline[task.discipline];
200
+ if (!templates || !templates.length) return [{ skipped: true }];
173
201
  const tid = templateIdFromRef(task.source_ref, task.discipline);
174
202
  const match = tid ? templates.find((t) => t.template_id === tid) : null;
175
203
  if (match) return [await restockTemplate(task.discipline, match, loadFloors())];
176
204
  // ref didn't resolve to a known template — top the whole discipline up (safe).
177
- return restockDiscipline(task.discipline);
205
+ // Hand the map down: restockDiscipline's default param would otherwise re-read
206
+ // and re-parse the file we are already holding.
207
+ return restockDiscipline(task.discipline, byDiscipline);
178
208
  }
179
209
 
180
210
  // Seed / backstop: bring every chore THIS INSTANCE offers to floor in one shot.
181
- // R59 (task 1198): the restocked set is the intersection of the chore disciplines
182
- // with the modules this instance enables (a vanilla instance with the art pipeline
183
- // off offers no 'artist' discipline). Returns a FLAT array of per-template results.
211
+ // R59 (task 1198): the restocked set is exactly the crafts this instance enables —
212
+ // a vanilla instance with the art pipeline off offers no 'artist', one with
213
+ // ui-design on offers 'ui'. A craft with no template in templates.json is simply
214
+ // skipped by restockDiscipline. Returns a FLAT array of per-template results.
184
215
  async function restockAll() {
185
216
  // enabledDisciplines reaches through the published doorway (the one core file a
186
217
  // module may require, ADR 0083) — never a direct require of src/modules.js.
187
- const { enabledDisciplines } = require('../../src/module-api');
188
- const offered = new Set(enabledDisciplines());
218
+ const offered = validDisciplines();
219
+ const byDiscipline = loadTemplates(); // one read for the whole sweep, not one per craft
189
220
  const out = [];
190
- for (const d of VALID_DISCIPLINES) {
191
- if (offered.has(d)) {
192
- for (const r of await restockDiscipline(d)) out.push(r);
193
- }
221
+ for (const d of templateDisciplines(byDiscipline)) {
222
+ if (!offered.has(d)) continue;
223
+ for (const r of await restockDiscipline(d, byDiscipline)) out.push(r);
194
224
  }
195
225
  return out;
196
226
  }
@@ -207,6 +237,7 @@ module.exports = {
207
237
  templateIdFromRef,
208
238
  buildDescription,
209
239
  SOURCE_TAG,
210
- VALID_DISCIPLINES,
240
+ validDisciplines,
241
+ templateDisciplines,
211
242
  HARD_MAX_MINUTES,
212
243
  };
@@ -5,7 +5,7 @@
5
5
  "coreVersion": "^1.19.0",
6
6
  "default": true,
7
7
  "maintenance": { "status": "core-maintained" },
8
- "contributes": { "skills": ["design", "design-sync", "figma-design-sync", "design-taste-frontend", "design-taste-frontend-v1", "high-end-visual-design", "redesign-existing-projects", "impeccable", "style", "minimalist-ui", "industrial-brutalist-ui", "gpt-taste", "stitch-design-taste", "image-to-code", "imagegen-frontend-web", "imagegen-frontend-mobile", "brandkit"] },
8
+ "contributes": { "skills": ["design", "design-sync", "figma-design-sync", "design-taste-frontend", "design-taste-frontend-v1", "high-end-visual-design", "redesign-existing-projects", "impeccable", "style", "minimalist-ui", "industrial-brutalist-ui", "gpt-taste", "stitch-design-taste", "image-to-code", "imagegen-frontend-web", "imagegen-frontend-mobile", "brandkit"], "disciplines": ["ui"] },
9
9
  "provides": [],
10
10
  "consumes": []
11
11
  }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.485",
3
+ "version": "1.19.487",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.485",
9
+ "version": "1.19.487",
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.485",
3
+ "version": "1.19.487",
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",
@@ -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
@@ -8,6 +8,7 @@
8
8
  "disciplines": {
9
9
  "engineer": { "floor": 2 },
10
10
  "artist": { "floor": 3 },
11
- "ideator": { "floor": 2 }
11
+ "ideator": { "floor": 2 },
12
+ "ui": { "floor": 2 }
12
13
  }
13
14
  }
@@ -58,7 +58,13 @@ const { cliClient, requireSession } = require('./cli-lib');
58
58
  const DEFAULT_CONFIG_PATH = path.join(__dirname, 'newcomer-floor.config.json');
59
59
  const SOURCE_TAG = 'newcomer-template';
60
60
  const HARD_MAX_MINUTES = 60; // newcomer scope ceiling, never exceeded (task 614)
61
- const VALID_DISCIPLINES = new Set(['engineer', 'artist', 'ideator']);
61
+ // The crafts this seeder will stamp chores for. Deliberately a literal and NOT
62
+ // enabledDisciplines(): this CLI seeds a REMOTE instance, whose enabled modules are
63
+ // not this checkout's, so gating on the local module set would seed the wrong shelf.
64
+ // It is the craft VOCABULARY, which is why 'unclassified' is absent. Keep in step
65
+ // with the keys in newcomer-floor.templates.json ('ui' added by task 1003487); the
66
+ // server-side restock reads the live offered set instead (newcomer-restock.js).
67
+ const VALID_DISCIPLINES = new Set(['engineer', 'artist', 'ideator', 'ui']);
62
68
  // Every status a copy's source_ref could occupy — scanned so `seq` is globally
63
69
  // monotonic and never recycles a ref already used by a shipped/abandoned copy.
64
70
  const ALL_STATUSES = ['backlog', 'ready', 'active', 'completed', 'confirmed', 'shipped', 'blocked', 'abandoned'];