@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.
- package/.bongos-core.json +78 -43
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0232-rot-is-derived-not-swept-and-water-is-the-only-new-verb.md +155 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +148 -4
- package/docs/api-reference.md +7 -4
- package/docs/copy-inventory.md +64 -61
- package/docs/copy-registry.json +97 -70
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_231_project_settings_and_attention_water.sql +163 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
- package/modules/hall-ui/public/dom-utils.js +23 -1
- package/modules/hall-ui/public/goal-inbox.js +119 -1
- package/modules/hall-ui/public/hall-render.js +7 -8
- package/modules/hall-ui/public/settings.js +8 -7
- package/modules/lifecycle/help-requests.js +3 -3
- package/modules/lifecycle/migrations/lifecycle_006_help_requests_ui_craft.sql +38 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/rot.js +210 -0
- package/modules/lifecycle/routes/rot.js +160 -0
- package/modules/lifecycle/ship-card.js +4 -1
- package/modules/onboarding/newcomer-restock.js +53 -22
- package/modules/ui-design/module.json +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +9 -0
- package/scripts/gds/newcomer-floor.config.json +2 -1
- package/scripts/gds/newcomer-floor.js +7 -1
- package/scripts/gds/newcomer-floor.templates.json +43 -1
- package/src/bongos/db.js +3 -3
- package/src/bongos/project-settings.js +193 -0
- package/src/bongos/routes/me.js +2 -1
- package/src/module-api.js +29 -1
- package/tests/help_requests.mjs +21 -2
- package/tests/module_api.mjs +1 -0
- package/tests/module_contributions.mjs +67 -0
- package/tests/modules.mjs +19 -1
- package/tests/newcomer_restock.mjs +76 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
|
44
|
-
if (
|
|
45
|
-
const list = Array.isArray(
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
const
|
|
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
|
-
|
|
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
|
|
182
|
-
//
|
|
183
|
-
//
|
|
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
|
|
188
|
-
const
|
|
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
|
|
191
|
-
if (offered.has(d))
|
|
192
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -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
|
|
@@ -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
|
-
|
|
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'];
|