@bongos/core 1.19.616 → 1.19.618
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 +51 -21
- package/.claude/skills/backlog-review/SKILL.md +175 -0
- package/docs/adr/0268-the-constitution-comes-from-two-roots.md +132 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +36 -33
- package/docs/copy-registry.json +61 -34
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/government/board.js +58 -0
- package/modules/government/config.js +34 -9
- package/modules/hall-ui/public/government.js +54 -0
- package/modules/hall-ui/public/oversight.css +25 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/backlog-review.js +399 -0
- package/src/module-api.js +27 -1
- package/tests/backlog_review.mjs +183 -0
- package/tests/government_config_roots.mjs +159 -0
- package/tests/government_constitution_divergence.mjs +123 -0
- package/tests/module_api.mjs +1 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// /backlog-review — walk the tasks sitting at status='backlog' and give each one
|
|
3
|
+
// the nod, the axe, or a reset clock (task 1003746).
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS EXISTS. `backlog` is the PRE-WORKABLE state: a task lands there when
|
|
6
|
+
// the system will not put it in the claimable queue without a human saying go
|
|
7
|
+
// (`modules/ideas/routing.js` — "'backlog' means a human still says go"), and
|
|
8
|
+
// ADR 0234 §2 records why: a feature/cleanup/refactor filing "reshape[s] what
|
|
9
|
+
// gets built and a filer should not be able to set the queue's agenda alone".
|
|
10
|
+
// Every OTHER queue in the methodology has a walkable cadence — /idea-triage for
|
|
11
|
+
// idea_inbox, /blocker-review for blockers, /bug-triage for kind=bug,
|
|
12
|
+
// /goal-review for criteria. The backlog had none. Its only pull surfaces were
|
|
13
|
+
// the nod queue (`GET /inbox/awaiting-nod`, scoped `WHERE origin='route'`, so it
|
|
14
|
+
// sees only filings that came through the idea inbox) and the 30-day rot timer.
|
|
15
|
+
// This is the missing walk.
|
|
16
|
+
//
|
|
17
|
+
// Usage:
|
|
18
|
+
// node scripts/gds/backlog-review.js [--limit N] [--goal ID] [--all]
|
|
19
|
+
//
|
|
20
|
+
// THE FOUR BUCKETS, AND WHY ONLY TWO GET WALKED. One read
|
|
21
|
+
// (`GET /tasks?status=backlog&include=deps`) partitions into four populations,
|
|
22
|
+
// and conflating them is the whole trap this script exists to avoid — a row
|
|
23
|
+
// waiting on a TRIGGER is not a row waiting on a PERSON, and asking a human to
|
|
24
|
+
// verdict the former wastes the cadence on rows it cannot move:
|
|
25
|
+
//
|
|
26
|
+
// stranded — dep-gated, but at least one dependency is ABANDONED. That edge
|
|
27
|
+
// can never satisfy, so the migration-163 auto-promote trigger
|
|
28
|
+
// will NEVER fire: the row is stuck, not waiting. WALKED FIRST,
|
|
29
|
+
// because it is the only bucket that gets worse by being left.
|
|
30
|
+
// nod — no unsatisfied dependency: nothing is gating it but a human.
|
|
31
|
+
// THE REVIEW SUBJECT. This is what "review the backlog" means.
|
|
32
|
+
// dep-gated — an unsatisfied dependency that is still alive. COUNTED, NEVER
|
|
33
|
+
// WALKED: shipping the dependency auto-promotes it (migration
|
|
34
|
+
// 163), so a human verdict here is noise at best and, if it
|
|
35
|
+
// promotes, races the trigger.
|
|
36
|
+
// spikes — kind='spike' rows live at backlog BY CONVENTION (migration 020:
|
|
37
|
+
// "Spikes stay in backlog by convention"), so they are not
|
|
38
|
+
// un-nodded work. COUNTED, NEVER WALKED.
|
|
39
|
+
//
|
|
40
|
+
// THE VERBS ARE STATUS-DERIVED, NOT A FIXED LIST. The rot card publishes four
|
|
41
|
+
// task verbs (prioritise/prune/kill/water — `modules/lifecycle/rot.js`), but two
|
|
42
|
+
// of them are status-conditional at the route: `POST /tasks/:id/promote` accepts
|
|
43
|
+
// only backlog|blocked|abandoned, and `POST /tasks/:id/demote` accepts only
|
|
44
|
+
// 'ready' (`modules/lifecycle/routes/tasks.js`). So on a BACKLOG row "prune" is
|
|
45
|
+
// a guaranteed 409 `cannot_demote` — it is already at the bottom. `verbsFor`
|
|
46
|
+
// below derives the offer from the row's actual status instead of hardcoding a
|
|
47
|
+
// list, so this walk can never offer a button the server will refuse.
|
|
48
|
+
//
|
|
49
|
+
// Prompts per row:
|
|
50
|
+
// [p]romote → POST /tasks/:id/promote (backlog → ready; the nod. Also the
|
|
51
|
+
// point the reward gate auto-assigns credits_reward if it is
|
|
52
|
+
// 0/NULL — ADR 0096 — so the assigned price is echoed back)
|
|
53
|
+
// [k]ill → POST /tasks/:id/abandon {reason} (it is not going to happen)
|
|
54
|
+
// [w]ater → POST /tasks/:id/water (leave it; reset the rot clock. The one
|
|
55
|
+
// verb that changes no status and destroys nothing)
|
|
56
|
+
// [s]kip → no write; the row surfaces again next review
|
|
57
|
+
// [q]uit → exit early, print the partial summary
|
|
58
|
+
//
|
|
59
|
+
// Exit codes:
|
|
60
|
+
// 0 walked cleanly (incl. an empty queue)
|
|
61
|
+
// 1 api / network error
|
|
62
|
+
// 2 no session
|
|
63
|
+
|
|
64
|
+
const readline = require('node:readline');
|
|
65
|
+
const { cliClient, requireSession, cliExit, hasFlag, arg } = require('./cli-lib');
|
|
66
|
+
|
|
67
|
+
// The API caps ?limit at 1000 (`routes/tasks.js` — "limit must be a positive
|
|
68
|
+
// integer up to 1000"). Spelled here rather than passed larger-and-hoped:
|
|
69
|
+
// scripts/gds/audit-deps.js asks for 3000 and every run of it dies on a 400,
|
|
70
|
+
// which is exactly how the stranded rows below went unnoticed.
|
|
71
|
+
const MAX_API_LIMIT = 1000;
|
|
72
|
+
|
|
73
|
+
// How many rows one sitting offers by default. The queue is routinely in the
|
|
74
|
+
// hundreds; a walk that demands all of them gets abandoned halfway, which is
|
|
75
|
+
// worse than a walk that reliably clears twenty. --all overrides.
|
|
76
|
+
const DEFAULT_WALK_LIMIT = 20;
|
|
77
|
+
|
|
78
|
+
// A dependency in one of these statuses can never satisfy, so anything gated on
|
|
79
|
+
// it is stranded rather than waiting. Mirrors modules/lifecycle/dead-deps.js
|
|
80
|
+
// DEAD_DEP_STATUSES — kept as its own constant because this script must run from
|
|
81
|
+
// a plain checkout without loading the server's module tree.
|
|
82
|
+
const DEAD_DEP_STATUSES = new Set(['abandoned']);
|
|
83
|
+
|
|
84
|
+
function prompt(rl, question) {
|
|
85
|
+
return new Promise((resolve) => rl.question(question, resolve));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function trim(s, n) {
|
|
89
|
+
const str = String(s || '');
|
|
90
|
+
return str.length > n ? `${str.slice(0, n - 1)}…` : str;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// unsatisfiedDeps(task) — the dependency edges still holding this row down.
|
|
94
|
+
// `include=deps` returns blocked_by[] with a per-edge `satisfied` flag already
|
|
95
|
+
// computed server-side (modules/lifecycle/db-deps-criteria.js), so this trusts
|
|
96
|
+
// that flag rather than re-deriving "shipped" from a status string here.
|
|
97
|
+
function unsatisfiedDeps(task) {
|
|
98
|
+
return (task.blocked_by || []).filter((d) => d && !d.satisfied);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function deadDeps(task) {
|
|
102
|
+
return (task.blocked_by || []).filter((d) => d && DEAD_DEP_STATUSES.has(String(d.status)));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// partitionBacklog(tasks) — the four buckets, from one list of backlog rows.
|
|
106
|
+
// Pure and exported: tests/backlog_review.mjs pins the bucket rules without
|
|
107
|
+
// standing up Postgres, and the ordering below IS the review's priority claim.
|
|
108
|
+
//
|
|
109
|
+
// Order of tests matters and is deliberate: a stranded row is reported as
|
|
110
|
+
// stranded even though it is also dep-gated, because "your gate is dead" is the
|
|
111
|
+
// actionable fact and "you have a gate" is not.
|
|
112
|
+
function partitionBacklog(tasks) {
|
|
113
|
+
const out = { stranded: [], nod: [], depGated: [], spikes: [] };
|
|
114
|
+
for (const t of Array.isArray(tasks) ? tasks : []) {
|
|
115
|
+
if (!t || String(t.status) !== 'backlog') continue;
|
|
116
|
+
const dead = deadDeps(t);
|
|
117
|
+
if (dead.length) { out.stranded.push({ ...t, dead_deps: dead }); continue; }
|
|
118
|
+
if (String(t.kind) === 'spike') { out.spikes.push(t); continue; }
|
|
119
|
+
if (unsatisfiedDeps(t).length) { out.depGated.push(t); continue; }
|
|
120
|
+
out.nod.push(t);
|
|
121
|
+
}
|
|
122
|
+
// Oldest first in both walked buckets: the row that has waited longest for a
|
|
123
|
+
// human is the one the cadence exists to catch, and a stable order means an
|
|
124
|
+
// interrupted walk resumes roughly where it stopped.
|
|
125
|
+
const byAge = (a, b) => String(a.created_at || '').localeCompare(String(b.created_at || '')) || Number(a.id) - Number(b.id);
|
|
126
|
+
out.stranded.sort(byAge);
|
|
127
|
+
out.nod.sort(byAge);
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// groupByGoal(rows) — cluster rows into goal groups so the walk is presented
|
|
132
|
+
// goal-by-goal instead of as one flat list.
|
|
133
|
+
//
|
|
134
|
+
// WHY GROUP AT ALL. The nod decision is mostly a SCOPE judgment — "does this goal
|
|
135
|
+
// want this work next" — and that judgment is made once per goal, not once per
|
|
136
|
+
// row. Walking a flat age-ordered list makes the reviewer rebuild the same goal
|
|
137
|
+
// context every few rows and re-decide the same question; clustering lets one
|
|
138
|
+
// context answer five rows in a row.
|
|
139
|
+
//
|
|
140
|
+
// The ORDER still honours oldest-first, one level up: goals are ordered by their
|
|
141
|
+
// own oldest waiting row, and rows inside a goal by age. So the goal that has
|
|
142
|
+
// been ignored longest comes first, and nothing is buried by a chatty neighbour.
|
|
143
|
+
// Returns [{ goal_id, rows }] — pure, exported, and the shape the walk iterates.
|
|
144
|
+
function groupByGoal(rows) {
|
|
145
|
+
const byAge = (a, b) => String(a.created_at || '').localeCompare(String(b.created_at || '')) || Number(a.id) - Number(b.id);
|
|
146
|
+
const groups = new Map();
|
|
147
|
+
for (const t of Array.isArray(rows) ? rows : []) {
|
|
148
|
+
if (!t) continue;
|
|
149
|
+
// A goal-less row is its own group rather than being dropped or merged into
|
|
150
|
+
// some other goal's context. ADR 0250 D4 makes goal_id required on new tasks,
|
|
151
|
+
// so this is the legacy/system-created tail — small, but it must still be
|
|
152
|
+
// walkable or the queue would quietly retain rows the walk never offers.
|
|
153
|
+
const key = t.goal_id == null ? '(no goal)' : String(t.goal_id);
|
|
154
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
155
|
+
groups.get(key).push(t);
|
|
156
|
+
}
|
|
157
|
+
const out = [];
|
|
158
|
+
for (const [goal_id, groupRows] of groups) {
|
|
159
|
+
groupRows.sort(byAge);
|
|
160
|
+
out.push({ goal_id, rows: groupRows });
|
|
161
|
+
}
|
|
162
|
+
// Goals ordered by their oldest row — the age rule, lifted one level.
|
|
163
|
+
out.sort((a, b) => byAge(a.rows[0], b.rows[0]));
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// verbsFor(task) — what the server will ACTUALLY accept on this row, derived
|
|
168
|
+
// from its status. See the header note: promote and demote are each valid for
|
|
169
|
+
// disjoint status sets, so a fixed four-verb list is wrong for every row.
|
|
170
|
+
function verbsFor(task) {
|
|
171
|
+
const status = String(task && task.status);
|
|
172
|
+
const verbs = [];
|
|
173
|
+
if (status === 'backlog' || status === 'blocked' || status === 'abandoned') verbs.push('promote');
|
|
174
|
+
if (status === 'ready') verbs.push('demote');
|
|
175
|
+
if (status !== 'shipped' && status !== 'abandoned') verbs.push('kill');
|
|
176
|
+
verbs.push('water');
|
|
177
|
+
return verbs;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function listBacklog({ goalId } = {}) {
|
|
181
|
+
const api = await cliClient();
|
|
182
|
+
const query = { status: 'backlog', include: 'deps', limit: MAX_API_LIMIT };
|
|
183
|
+
if (goalId) query.goal_id = goalId;
|
|
184
|
+
const r = await api.tasks.getTasks({ query });
|
|
185
|
+
if (!r.ok) throw new Error(`GET /tasks?status=backlog failed (${r.status}): ${JSON.stringify(r.data)}`);
|
|
186
|
+
// GET /tasks responds { tasks: [...] }, not a bare array — the same shape trap
|
|
187
|
+
// that made bug-triage.js throw before listing anything (see its listOpenBugTasks).
|
|
188
|
+
return r.data?.tasks ?? [];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Goal id -> title, for the walk's group headers. Fails OPEN: a header that shows
|
|
192
|
+
// a bare id is a cosmetic loss, and a review that refuses to start because the
|
|
193
|
+
// goal list did not load would be a much worse trade.
|
|
194
|
+
async function fetchGoalTitles() {
|
|
195
|
+
try {
|
|
196
|
+
const api = await cliClient();
|
|
197
|
+
const r = await api.goals.getGoals({ query: { limit: 200 } });
|
|
198
|
+
if (!r.ok) return new Map();
|
|
199
|
+
return new Map((r.data?.goals ?? []).map((g) => [String(g.id), g.title]));
|
|
200
|
+
} catch (_) {
|
|
201
|
+
return new Map();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function promoteTask(id) {
|
|
206
|
+
const api = await cliClient();
|
|
207
|
+
const r = await api.tasks.postTasksIdPromote({ id, body: {} });
|
|
208
|
+
if (!r.ok) throw Object.assign(new Error(`promote failed (${r.status}): ${JSON.stringify(r.data)}`), { code: r.data?.error?.code || r.data?.error });
|
|
209
|
+
return r.data;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function killTask(id, reason) {
|
|
213
|
+
const api = await cliClient();
|
|
214
|
+
const r = await api.tasks.postTasksIdAbandon({ id, body: { reason: reason || '' } });
|
|
215
|
+
if (!r.ok) throw Object.assign(new Error(`kill failed (${r.status}): ${JSON.stringify(r.data)}`), { code: r.data?.error?.code || r.data?.error });
|
|
216
|
+
return r.data;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// The gentlest verb: it changes no status and destroys nothing (routes/rot.js).
|
|
220
|
+
// Its route is `requireBuilder` plus an ownership-or-atom check in the handler —
|
|
221
|
+
// authorized iff you CREATED the task or you hold the see-all-rot atom — so it is
|
|
222
|
+
// not unconditionally open, and a caller who is neither gets a 403 naming both
|
|
223
|
+
// paths. Every caller of THIS script is already Metic+ (the skill's rank gate), who
|
|
224
|
+
// hold that atom per ADR 0157, so in practice it always passes here; the 403 is
|
|
225
|
+
// surfaced verbatim rather than swallowed in case that ever stops being true.
|
|
226
|
+
async function waterTask(id) {
|
|
227
|
+
const api = await cliClient();
|
|
228
|
+
const r = await api.tasks.postTasksIdWater({ id, body: {} });
|
|
229
|
+
if (!r.ok) throw Object.assign(new Error(`water failed (${r.status}): ${JSON.stringify(r.data)}`), { code: r.data?.error?.code || r.data?.error });
|
|
230
|
+
return r.data;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function printCounts(buckets) {
|
|
234
|
+
const { stranded, nod, depGated, spikes } = buckets;
|
|
235
|
+
const total = stranded.length + nod.length + depGated.length + spikes.length;
|
|
236
|
+
console.log(`\nBacklog: ${total} rows`);
|
|
237
|
+
console.log(` ${nod.length} awaiting a nod — a human is the only thing gating these (walked)`);
|
|
238
|
+
console.log(` ${stranded.length} stranded — gated by an ABANDONED dependency; the auto-promote trigger can never fire (walked first)`);
|
|
239
|
+
console.log(` ${depGated.length} waiting on a dependency — shipping the dep auto-promotes them (migration 163); not a human decision, not walked`);
|
|
240
|
+
console.log(` ${spikes.length} spikes — live at backlog by convention (migration 020); not walked`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function printRow(task, idx, total, label) {
|
|
244
|
+
console.log(`\n[${idx + 1}/${total}] ${label} #${task.id} ${task.title}`);
|
|
245
|
+
if (task.description) console.log(` ${trim(task.description, 300)}`);
|
|
246
|
+
const bits = [];
|
|
247
|
+
if (task.goal_id) bits.push(`goal ${task.goal_id}`);
|
|
248
|
+
if (task.kind) bits.push(task.kind);
|
|
249
|
+
if (task.discipline && task.discipline !== 'unclassified') bits.push(task.discipline);
|
|
250
|
+
if (task.est_minutes) bits.push(`~${task.est_minutes}m`);
|
|
251
|
+
bits.push(task.credits_reward ? `${task.credits_reward} credits` : 'no reward set — promote assigns one');
|
|
252
|
+
if (task.created_at) bits.push(`filed ${String(task.created_at).slice(0, 10)}`);
|
|
253
|
+
console.log(` ${bits.join(' · ')}`);
|
|
254
|
+
for (const d of task.dead_deps || []) {
|
|
255
|
+
console.log(` ⚠ dependency ${d.id} is ${d.status}: "${trim(d.title, 60)}" — this edge can NEVER satisfy.`);
|
|
256
|
+
}
|
|
257
|
+
if (task.dead_deps && task.dead_deps.length) {
|
|
258
|
+
const first = task.dead_deps[0].id;
|
|
259
|
+
console.log(' → Resolve it deliberately: DROP the dead edge (DELETE /api/bongos/dependencies '
|
|
260
|
+
+ `{"from_kind":"task","from_id":${task.id},"to_kind":"task","to_id":${first}}) `
|
|
261
|
+
+ `or RESTORE the dependency (POST /api/bongos/tasks/${first}/promote returns an abandoned task to backlog).`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// The per-row walk. Mutates `state.summary`; returns false when the user quits.
|
|
266
|
+
async function walkRow(rl, task, idx, total, label, state) {
|
|
267
|
+
printRow(task, idx, total, label);
|
|
268
|
+
for (;;) {
|
|
269
|
+
const offer = verbsFor(task).includes('promote')
|
|
270
|
+
? ' [p]romote (make claimable) / [k]ill / [w]ater (leave it, reset the clock) / [s]kip / [q]uit > '
|
|
271
|
+
: ' [k]ill / [w]ater / [s]kip / [q]uit > ';
|
|
272
|
+
const ans = (await prompt(rl, offer)).trim().toLowerCase();
|
|
273
|
+
|
|
274
|
+
if (ans === 'q' || ans === 'quit') return false;
|
|
275
|
+
if (ans === 's' || ans === 'skip' || ans === '') { state.summary.skipped++; return true; }
|
|
276
|
+
|
|
277
|
+
if (ans === 'p' || ans === 'promote') {
|
|
278
|
+
if (!verbsFor(task).includes('promote')) { console.log(` ? #${task.id} is '${task.status}' — promote does not apply`); continue; }
|
|
279
|
+
try {
|
|
280
|
+
const data = await promoteTask(task.id);
|
|
281
|
+
state.summary.promoted++;
|
|
282
|
+
const assigned = data && data.assigned_credits_reward;
|
|
283
|
+
console.log(` ✓ #${task.id} → ready${assigned ? ` (reward gate assigned ${assigned} credits)` : ''}`);
|
|
284
|
+
} catch (err) { console.error(` ! #${task.id}: ${err.message}`); }
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (ans === 'k' || ans === 'kill') {
|
|
289
|
+
const reason = (await prompt(rl, ' reason (optional, enter to skip): ')).trim();
|
|
290
|
+
try {
|
|
291
|
+
await killTask(task.id, reason || 'retired during /backlog-review — not going to happen');
|
|
292
|
+
state.summary.killed++;
|
|
293
|
+
console.log(` ✖ #${task.id} abandoned (restorable: POST /tasks/${task.id}/promote returns it to backlog)`);
|
|
294
|
+
} catch (err) { console.error(` ! #${task.id}: ${err.message}`); }
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (ans === 'w' || ans === 'water') {
|
|
299
|
+
try {
|
|
300
|
+
await waterTask(task.id);
|
|
301
|
+
state.summary.watered++;
|
|
302
|
+
console.log(` ~ #${task.id} watered — the rot clock is reset; nothing else changed`);
|
|
303
|
+
} catch (err) { console.error(` ! #${task.id}: ${err.message}`); }
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
console.log(' ? unknown — type p, k, w, s, or q');
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function main() {
|
|
312
|
+
await requireSession();
|
|
313
|
+
|
|
314
|
+
const goalId = arg('--goal');
|
|
315
|
+
const walkAll = hasFlag('--all');
|
|
316
|
+
const limitRaw = Number(arg('--limit'));
|
|
317
|
+
const walkLimit = walkAll ? Infinity : (Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : DEFAULT_WALK_LIMIT);
|
|
318
|
+
|
|
319
|
+
let rows;
|
|
320
|
+
try {
|
|
321
|
+
rows = await listBacklog({ goalId });
|
|
322
|
+
} catch (err) {
|
|
323
|
+
console.error(`backlog-review: ${err.message}`);
|
|
324
|
+
return cliExit(1);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const buckets = partitionBacklog(rows);
|
|
328
|
+
printCounts(buckets);
|
|
329
|
+
|
|
330
|
+
// Goal titles for the group headers — one extra read so a header names the goal
|
|
331
|
+
// instead of showing a bare id the reviewer has to go look up.
|
|
332
|
+
const goalTitles = await fetchGoalTitles();
|
|
333
|
+
|
|
334
|
+
// Stranded first, then the nod queue — see partitionBacklog's header. Each
|
|
335
|
+
// bucket is walked goal-by-goal (groupByGoal), and the flattened result keeps
|
|
336
|
+
// that grouping so the walk's own loop stays a simple sequence.
|
|
337
|
+
const queue = [];
|
|
338
|
+
for (const [bucket, label] of [[buckets.stranded, 'STRANDED'], [buckets.nod, 'awaiting a nod']]) {
|
|
339
|
+
for (const group of groupByGoal(bucket)) {
|
|
340
|
+
queue.push(...group.rows.map((t, i) => ({
|
|
341
|
+
task: t,
|
|
342
|
+
label,
|
|
343
|
+
// The header rides on the FIRST row of each group, so it prints exactly
|
|
344
|
+
// once even after the --limit slice cuts the queue mid-group.
|
|
345
|
+
groupHeader: i === 0
|
|
346
|
+
? `${label === 'STRANDED' ? 'Stranded in' : 'Goal'} ${group.goal_id}${goalTitles.get(group.goal_id) ? ` — ${goalTitles.get(group.goal_id)}` : ''} (${group.rows.length} row${group.rows.length === 1 ? '' : 's'})`
|
|
347
|
+
: null,
|
|
348
|
+
})));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (!queue.length) {
|
|
353
|
+
console.log('\nNothing to review — no backlog row is waiting on a person today.');
|
|
354
|
+
return cliExit(0);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const slice = queue.slice(0, walkLimit === Infinity ? queue.length : walkLimit);
|
|
358
|
+
if (slice.length < queue.length) {
|
|
359
|
+
console.log(`\nWalking the ${slice.length} oldest of ${queue.length} (--limit N or --all for more).`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const state = { summary: { promoted: 0, killed: 0, watered: 0, skipped: 0 } };
|
|
363
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
364
|
+
try {
|
|
365
|
+
for (let i = 0; i < slice.length; i++) {
|
|
366
|
+
if (slice[i].groupHeader) {
|
|
367
|
+
console.log(`\n${'─'.repeat(72)}\n${slice[i].groupHeader}\n${'─'.repeat(72)}`);
|
|
368
|
+
}
|
|
369
|
+
const keepGoing = await walkRow(rl, slice[i].task, i, slice.length, slice[i].label, state);
|
|
370
|
+
if (!keepGoing) { console.log('\n(quit early)'); break; }
|
|
371
|
+
}
|
|
372
|
+
} finally {
|
|
373
|
+
rl.close();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const s = state.summary;
|
|
377
|
+
console.log(`\nReviewed: ${s.promoted} promoted · ${s.killed} killed · ${s.watered} watered · ${s.skipped} skipped`);
|
|
378
|
+
const left = queue.length - (s.promoted + s.killed + s.watered);
|
|
379
|
+
if (left > 0) console.log(`${left} row(s) still awaiting a human — run /backlog-review again to continue.`);
|
|
380
|
+
return cliExit(0);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (require.main === module) {
|
|
384
|
+
main().catch((err) => {
|
|
385
|
+
console.error(`backlog-review: ${err && err.message}`);
|
|
386
|
+
process.exit(1);
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
module.exports = {
|
|
391
|
+
groupByGoal,
|
|
392
|
+
partitionBacklog,
|
|
393
|
+
verbsFor,
|
|
394
|
+
unsatisfiedDeps,
|
|
395
|
+
deadDeps,
|
|
396
|
+
DEAD_DEP_STATUSES,
|
|
397
|
+
MAX_API_LIMIT,
|
|
398
|
+
DEFAULT_WALK_LIMIT,
|
|
399
|
+
};
|
package/src/module-api.js
CHANGED
|
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
|
|
|
55
55
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
56
56
|
// the entry to that file. Look for a version's history there, not here.
|
|
57
57
|
// ---------------------------------------------------------------------------
|
|
58
|
-
const CORE_VERSION = '1.19.
|
|
58
|
+
const CORE_VERSION = '1.19.618'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
59
59
|
|
|
60
60
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
61
61
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -288,6 +288,32 @@ module.exports = {
|
|
|
288
288
|
// it instead of reaching into src/branding.js directly (BV1.R86).
|
|
289
289
|
userAgent: branding.userAgent,
|
|
290
290
|
resolveEnv: instanceConfig.resolveEnv,
|
|
291
|
+
// --- the two ROOTS (ADR 0108 §1), added for task 1003739 because a module
|
|
292
|
+
// that reads BOTH a core default and a host override cannot tell them
|
|
293
|
+
// apart without these — and getting that wrong is silent, not loud.
|
|
294
|
+
//
|
|
295
|
+
// resolveCoreRoot() — where the core PACKAGE's own files live: core
|
|
296
|
+
// modules/, core migrations/, the *.neutral.json
|
|
297
|
+
// defaults. In a single checkout it is the repo
|
|
298
|
+
// root; once an instance installs the core as a
|
|
299
|
+
// dependency it is node_modules/@cloudbongos/core.
|
|
300
|
+
// resolveInstanceRoot() — where HOST content lives: config/, instance
|
|
301
|
+
// modules/, identity. The instance repo, always.
|
|
302
|
+
//
|
|
303
|
+
// THE FAILURE THEY EXIST TO PREVENT, stated because it already happened.
|
|
304
|
+
// modules/government/config.js resolved BOTH from `path.resolve(__dirname,
|
|
305
|
+
// '..', '..')` — correct while the two roots coincided, and wrong the day
|
|
306
|
+
// cloudbongos.com started running a PINNED core. Its instance pack then
|
|
307
|
+
// resolved to node_modules/@cloudbongos/core/config/government.json: the
|
|
308
|
+
// host's real file was never read, and a ratified constitutional amendment
|
|
309
|
+
// was written into node_modules, where the next `npm ci` erased it. The
|
|
310
|
+
// board had decided and the decision quietly did not take. There is no
|
|
311
|
+
// error to catch in that shape — both paths exist and both are writable —
|
|
312
|
+
// so the only defence is resolving each from the root that owns it.
|
|
313
|
+
// src/branding.js is the pattern to copy: NEUTRAL from the core root,
|
|
314
|
+
// INSTANCE from the instance root.
|
|
315
|
+
resolveCoreRoot: instanceConfig.resolveCoreRoot,
|
|
316
|
+
resolveInstanceRoot: instanceConfig.resolveInstanceRoot,
|
|
291
317
|
// --- the project's STALE-CLAIM TIMER (task 1003476, goal 1000072) — how long a
|
|
292
318
|
// claim may go silent before this project calls it abandoned. ONE number for
|
|
293
319
|
// every surface that reports or enforces it: before this, the sweeper that
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// tests/backlog_review.mjs — the /backlog-review bucket rules and the
|
|
2
|
+
// status-derived verb offer (task 1003746).
|
|
3
|
+
//
|
|
4
|
+
// These are the two claims the skill rests on, so they are the two things a test
|
|
5
|
+
// has to execute rather than paraphrase: (1) a row waiting on a live TRIGGER is
|
|
6
|
+
// never presented as waiting on a PERSON, and (2) the walk never offers a verb
|
|
7
|
+
// the route will refuse. The `skill names a command it never runs` learning
|
|
8
|
+
// (r155-documented-nonfunctional-command) is why the last test here actually
|
|
9
|
+
// requires and calls the module the SKILL.md tells a builder to run.
|
|
10
|
+
import { strict as assert } from 'node:assert';
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
+
const br = require('../scripts/gds/backlog-review.js');
|
|
20
|
+
|
|
21
|
+
const task = (over = {}) => ({
|
|
22
|
+
id: 1, status: 'backlog', kind: 'feature', title: 't', created_at: '2026-01-01T00:00:00Z',
|
|
23
|
+
blocked_by: [], ...over,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('a row with no unsatisfied dependency is awaiting a nod', () => {
|
|
27
|
+
const p = br.partitionBacklog([task({ id: 7 })]);
|
|
28
|
+
assert.deepEqual(p.nod.map((t) => t.id), [7]);
|
|
29
|
+
assert.equal(p.depGated.length, 0);
|
|
30
|
+
assert.equal(p.stranded.length, 0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('a satisfied dependency does NOT make a row dep-gated', () => {
|
|
34
|
+
const p = br.partitionBacklog([task({ id: 8, blocked_by: [{ id: 2, status: 'shipped', satisfied: true }] })]);
|
|
35
|
+
assert.deepEqual(p.nod.map((t) => t.id), [8], 'a shipped dep is not a gate');
|
|
36
|
+
assert.equal(p.depGated.length, 0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('a live unsatisfied dependency is dep-gated and is NOT walked as a nod', () => {
|
|
40
|
+
const p = br.partitionBacklog([task({ id: 9, blocked_by: [{ id: 3, status: 'ready', satisfied: false }] })]);
|
|
41
|
+
assert.deepEqual(p.depGated.map((t) => t.id), [9]);
|
|
42
|
+
assert.equal(p.nod.length, 0, 'the migration-163 trigger owns this row, not a human');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('an ABANDONED dependency makes a row stranded, not merely dep-gated', () => {
|
|
46
|
+
const p = br.partitionBacklog([task({ id: 10, blocked_by: [{ id: 4, status: 'abandoned', satisfied: false }] })]);
|
|
47
|
+
assert.deepEqual(p.stranded.map((t) => t.id), [10]);
|
|
48
|
+
assert.equal(p.depGated.length, 0, 'stranded wins: "your gate is dead" is the actionable fact');
|
|
49
|
+
assert.deepEqual(p.stranded[0].dead_deps.map((d) => d.id), [4], 'the dead edges ride along for the hint');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('stranded wins over spike too — a dead gate outranks the backlog-by-convention rule', () => {
|
|
53
|
+
const p = br.partitionBacklog([task({ id: 11, kind: 'spike', blocked_by: [{ id: 5, status: 'abandoned', satisfied: false }] })]);
|
|
54
|
+
assert.deepEqual(p.stranded.map((t) => t.id), [11]);
|
|
55
|
+
assert.equal(p.spikes.length, 0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('a spike lives at backlog by convention and is never walked', () => {
|
|
59
|
+
const p = br.partitionBacklog([task({ id: 12, kind: 'spike' })]);
|
|
60
|
+
assert.deepEqual(p.spikes.map((t) => t.id), [12]);
|
|
61
|
+
assert.equal(p.nod.length, 0, 'migration 020: spikes stay in backlog by convention');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('rows that are not at backlog are ignored entirely', () => {
|
|
65
|
+
const p = br.partitionBacklog([task({ id: 13, status: 'ready' }), task({ id: 14, status: 'shipped' }), task({ id: 15, status: 'blocked' })]);
|
|
66
|
+
assert.equal(p.nod.length + p.depGated.length + p.stranded.length + p.spikes.length, 0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('partitionBacklog tolerates junk without throwing', () => {
|
|
70
|
+
assert.doesNotThrow(() => br.partitionBacklog(null));
|
|
71
|
+
assert.doesNotThrow(() => br.partitionBacklog([null, undefined, {}]));
|
|
72
|
+
const p = br.partitionBacklog([task({ id: 16, blocked_by: null })]);
|
|
73
|
+
assert.deepEqual(p.nod.map((t) => t.id), [16], 'a null blocked_by is no gate, not a crash');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('both walked buckets are ordered oldest-first so an interrupted walk resumes in place', () => {
|
|
77
|
+
const p = br.partitionBacklog([
|
|
78
|
+
task({ id: 30, created_at: '2026-03-01T00:00:00Z' }),
|
|
79
|
+
task({ id: 31, created_at: '2026-01-01T00:00:00Z' }),
|
|
80
|
+
task({ id: 32, created_at: '2026-02-01T00:00:00Z' }),
|
|
81
|
+
]);
|
|
82
|
+
assert.deepEqual(p.nod.map((t) => t.id), [31, 32, 30]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// --- goal grouping --------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
test('rows are clustered by goal, not left interleaved', () => {
|
|
88
|
+
const g = br.groupByGoal([
|
|
89
|
+
task({ id: 40, goal_id: 1, created_at: '2026-01-01T00:00:00Z' }),
|
|
90
|
+
task({ id: 41, goal_id: 2, created_at: '2026-01-02T00:00:00Z' }),
|
|
91
|
+
task({ id: 42, goal_id: 1, created_at: '2026-01-03T00:00:00Z' }),
|
|
92
|
+
]);
|
|
93
|
+
assert.equal(g.length, 2, 'two goals, two groups');
|
|
94
|
+
assert.deepEqual(g[0].rows.map((t) => t.id), [40, 42], 'goal 1 rows are adjacent');
|
|
95
|
+
assert.deepEqual(g[1].rows.map((t) => t.id), [41]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('goals are ordered by their OWN oldest row, so the longest-ignored goal comes first', () => {
|
|
99
|
+
const g = br.groupByGoal([
|
|
100
|
+
task({ id: 50, goal_id: 'new', created_at: '2026-05-01T00:00:00Z' }),
|
|
101
|
+
task({ id: 51, goal_id: 'old', created_at: '2026-01-01T00:00:00Z' }),
|
|
102
|
+
task({ id: 52, goal_id: 'new', created_at: '2026-05-02T00:00:00Z' }),
|
|
103
|
+
]);
|
|
104
|
+
assert.deepEqual(g.map((x) => x.goal_id), ['old', 'new']);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('rows inside a group stay oldest-first', () => {
|
|
108
|
+
const g = br.groupByGoal([
|
|
109
|
+
task({ id: 60, goal_id: 1, created_at: '2026-03-01T00:00:00Z' }),
|
|
110
|
+
task({ id: 61, goal_id: 1, created_at: '2026-01-01T00:00:00Z' }),
|
|
111
|
+
task({ id: 62, goal_id: 1, created_at: '2026-02-01T00:00:00Z' }),
|
|
112
|
+
]);
|
|
113
|
+
assert.deepEqual(g[0].rows.map((t) => t.id), [61, 62, 60]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('a goal-less row is walkable in its own group, never dropped', () => {
|
|
117
|
+
const g = br.groupByGoal([task({ id: 70, goal_id: null }), task({ id: 71, goal_id: 5 })]);
|
|
118
|
+
const total = g.reduce((n, x) => n + x.rows.length, 0);
|
|
119
|
+
assert.equal(total, 2, 'every input row reaches a group');
|
|
120
|
+
assert.ok(g.some((x) => x.goal_id === '(no goal)'));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('grouping loses no rows and duplicates none, on a mixed batch', () => {
|
|
124
|
+
const rows = [];
|
|
125
|
+
for (let i = 0; i < 25; i++) rows.push(task({ id: 100 + i, goal_id: i % 4, created_at: `2026-01-${String((i % 28) + 1).padStart(2, '0')}T00:00:00Z` }));
|
|
126
|
+
const g = br.groupByGoal(rows);
|
|
127
|
+
const ids = g.flatMap((x) => x.rows.map((t) => t.id)).sort((a, b) => a - b);
|
|
128
|
+
assert.equal(new Set(ids).size, 25);
|
|
129
|
+
assert.deepEqual(ids, rows.map((t) => t.id).sort((a, b) => a - b));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('groupByGoal tolerates junk', () => {
|
|
133
|
+
assert.deepEqual(br.groupByGoal(null), []);
|
|
134
|
+
assert.deepEqual(br.groupByGoal([null, undefined]), []);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// --- the verb offer -------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
test('a backlog row is offered promote, never demote', () => {
|
|
140
|
+
const v = br.verbsFor(task({ status: 'backlog' }));
|
|
141
|
+
assert.ok(v.includes('promote'));
|
|
142
|
+
assert.ok(!v.includes('demote'), 'POST /tasks/:id/demote requires status=ready — it would 409 cannot_demote here');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('a ready row is offered demote, never promote', () => {
|
|
146
|
+
const v = br.verbsFor(task({ status: 'ready' }));
|
|
147
|
+
assert.ok(v.includes('demote'));
|
|
148
|
+
assert.ok(!v.includes('promote'), 'POST /tasks/:id/promote accepts only backlog|blocked|abandoned');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('blocked and abandoned rows accept promote (the blocked→ready and un-abandon paths)', () => {
|
|
152
|
+
assert.ok(br.verbsFor(task({ status: 'blocked' })).includes('promote'));
|
|
153
|
+
assert.ok(br.verbsFor(task({ status: 'abandoned' })).includes('promote'), 'ADR 0152: abandoned → backlog restore');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('water is offered on every row and kill is never offered on a terminal one', () => {
|
|
157
|
+
for (const status of ['backlog', 'ready', 'blocked', 'active', 'shipped', 'abandoned']) {
|
|
158
|
+
assert.ok(br.verbsFor(task({ status })).includes('water'), `water missing for ${status}`);
|
|
159
|
+
}
|
|
160
|
+
assert.ok(!br.verbsFor(task({ status: 'shipped' })).includes('kill'));
|
|
161
|
+
assert.ok(!br.verbsFor(task({ status: 'abandoned' })).includes('kill'));
|
|
162
|
+
assert.ok(br.verbsFor(task({ status: 'backlog' })).includes('kill'));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('the API limit the script asks for is within the route cap of 1000', () => {
|
|
166
|
+
assert.ok(br.MAX_API_LIMIT <= 1000, 'routes/tasks.js: "limit must be a positive integer up to 1000"');
|
|
167
|
+
assert.ok(br.MAX_API_LIMIT > 0);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// --- the skill names a command that exists and runs -----------------------
|
|
171
|
+
|
|
172
|
+
test('SKILL.md names scripts/gds/backlog-review.js and that file loads and partitions', () => {
|
|
173
|
+
const skill = fs.readFileSync(path.join(ROOT, '.claude', 'skills', 'backlog-review', 'SKILL.md'), 'utf8');
|
|
174
|
+
assert.match(skill, /scripts\/gds\/backlog-review\.js/, 'the skill must name the script it runs');
|
|
175
|
+
// Executed, not paraphrased: the module the prose points at really answers.
|
|
176
|
+
const p = br.partitionBacklog([task({ id: 99 })]);
|
|
177
|
+
assert.deepEqual(p.nod.map((t) => t.id), [99]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('SKILL.md does not tell a builder to demote a backlog row', () => {
|
|
181
|
+
const skill = fs.readFileSync(path.join(ROOT, '.claude', 'skills', 'backlog-review', 'SKILL.md'), 'utf8');
|
|
182
|
+
assert.ok(!/\[p\]rune/.test(skill), 'prune/demote is a guaranteed 409 on a backlog row');
|
|
183
|
+
});
|