@bongos/core 1.19.617 → 1.19.619
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 +64 -24
- 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/0269-the-cli-session-store-is-host-keyed-at-a-fixed-anchor.md +104 -0
- package/docs/adr/README.md +2 -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/scripts/gds/build-cli-package.js +1 -1
- package/scripts/gds/cli-lib.js +110 -0
- package/scripts/gds/login.js +40 -2
- package/src/module-api.js +27 -1
- package/tests/backlog_review.mjs +183 -0
- package/tests/cli_sessions.mjs +168 -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
|
+
};
|
|
@@ -35,7 +35,7 @@ const REPO_ROOT = path.join(__dirname, '..', '..');
|
|
|
35
35
|
|
|
36
36
|
// The package's OWN version — deliberately independent of the core's. The core moves many times
|
|
37
37
|
// a day (CI auto-patch, ADR 0161); the CLI's public contract should not.
|
|
38
|
-
const PACKAGE_VERSION = '0.1.
|
|
38
|
+
const PACKAGE_VERSION = '0.1.3';
|
|
39
39
|
const PACKAGE_NAME = '@cloudbongos/cli';
|
|
40
40
|
|
|
41
41
|
// ── What the package carries ────────────────────────────────────────────────────────────────
|
package/scripts/gds/cli-lib.js
CHANGED
|
@@ -229,12 +229,117 @@ async function loadSession() {
|
|
|
229
229
|
return null;
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
+
// ---- the shared session store (task 1003741, ADR 0269) ---------------------
|
|
233
|
+
// ONE FILE PER INSTANCE, at a FIXED anchor — deliberately NOT configHome().
|
|
234
|
+
//
|
|
235
|
+
// WHY A FIXED ANCHOR. configHome() is brand-derived, and the brand is read out of whatever checkout
|
|
236
|
+
// the process is standing in. That gave the CLI exactly one session slot, and the slot MOVED: run
|
|
237
|
+
// standalone (the public @cloudbongos/cli's whole situation) the brand cannot resolve, so every
|
|
238
|
+
// instance shared one gds-session.json and signing into a second DESTROYED the first; run from
|
|
239
|
+
// inside an instance repo and the session landed in ~/.config/<slug>/ where the standalone CLI
|
|
240
|
+
// could never find it. The owner's own config dir carries a hand-made `hermeslines-clobber` backup
|
|
241
|
+
// from the first mode.
|
|
242
|
+
//
|
|
243
|
+
// WHY HERE AND NOT instance-config.js. That module is generic config-path machinery shared with the
|
|
244
|
+
// server; a CLI session store is not its concern, and cli-lib already owns SESSION_PATH and
|
|
245
|
+
// SESSION_READ_PATHS. Keeping the two together means one file describes where a session lives.
|
|
246
|
+
function sessionStoreDir() {
|
|
247
|
+
// Directory name deliberately NOT 'sessions': that is a live module key, and ADR 0083
|
|
248
|
+
// §Decision #4 forbids naming one in shared machinery. 'instances' is the truer name anyway —
|
|
249
|
+
// one file per instance is exactly what this holds.
|
|
250
|
+
return path.join(os.homedir(), '.config', ic.FALLBACK_DIR, 'instances');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// The store's filename for an instance, derived from its api_base host. Returns null when the base
|
|
254
|
+
// names no host — an unkeyable session is simply not stored, never stored under a guessed name.
|
|
255
|
+
function sessionHostKey(apiBase) {
|
|
256
|
+
const raw = String(apiBase || '').trim();
|
|
257
|
+
if (!raw) return null;
|
|
258
|
+
let host = null;
|
|
259
|
+
try { host = new URL(raw).host; } catch (_) { host = null; }
|
|
260
|
+
if (!host) return null;
|
|
261
|
+
// Filename-safe, and collision-free for real hosts: ':' (a port) is the only character a valid
|
|
262
|
+
// host adds beyond the safe set, and it maps to '_' which cannot otherwise appear in one.
|
|
263
|
+
const safe = host.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
264
|
+
return safe && safe !== '.' && safe !== '..' ? safe : null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function sessionStorePath(apiBase) {
|
|
268
|
+
const key = sessionHostKey(apiBase);
|
|
269
|
+
return key ? path.join(sessionStoreDir(), `${key}.json`) : null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Write one session into the shared, host-keyed store. No-op when the session names no host —
|
|
273
|
+
// an unkeyable session is skipped, never filed under a guessed name.
|
|
274
|
+
async function writeSessionToStore(session) {
|
|
275
|
+
const target = session && sessionStorePath(session.api_base);
|
|
276
|
+
if (!target) return null;
|
|
277
|
+
await fsp.mkdir(path.dirname(target), { recursive: true });
|
|
278
|
+
await fsp.chmod(path.dirname(target), 0o700).catch(() => {});
|
|
279
|
+
await fsp.writeFile(target, JSON.stringify(session, null, 2), { mode: 0o600 });
|
|
280
|
+
return target;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Every instance's session is kept, not just the last one (task 1003741).
|
|
284
|
+
//
|
|
285
|
+
// This used to write ONE file, so `bongos login <another-instance>` silently DESTROYED the session
|
|
286
|
+
// you already had — the owner's own config dir carries a hand-made `hermeslines-clobber` backup
|
|
287
|
+
// from exactly that. Three steps now, and the order matters:
|
|
288
|
+
//
|
|
289
|
+
// 1. archive the OUTGOING session first. That is what rescues a session written before the store
|
|
290
|
+
// existed: it was never in the store, so without this step the very first login after
|
|
291
|
+
// upgrading would still lose it.
|
|
292
|
+
// 2. store the incoming one under its own host.
|
|
293
|
+
// 3. write the ACTIVE pointer exactly where it has always gone, so every existing reader —
|
|
294
|
+
// in-repo skills, hooks, the dev box — behaves identically.
|
|
232
295
|
async function saveSession(session) {
|
|
233
296
|
const dir = path.dirname(SESSION_PATH);
|
|
234
297
|
await fsp.mkdir(dir, { recursive: true });
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
const prev = await loadSession();
|
|
301
|
+
const prevKey = prev && prev.token ? sessionHostKey(prev.api_base) : null;
|
|
302
|
+
const nextKey = sessionHostKey(session && session.api_base);
|
|
303
|
+
if (prevKey && prevKey !== nextKey) await writeSessionToStore(prev);
|
|
304
|
+
} catch (_) {
|
|
305
|
+
// Archiving is best-effort — a lost previous session must never block a real sign-in.
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
await writeSessionToStore(session).catch(() => null);
|
|
235
309
|
await fsp.writeFile(SESSION_PATH, JSON.stringify(session, null, 2), { mode: 0o600 });
|
|
236
310
|
}
|
|
237
311
|
|
|
312
|
+
// The stored session for one instance, or null. Used by `bongos login` to switch back to an
|
|
313
|
+
// instance you are already signed into without a fresh device flow.
|
|
314
|
+
async function loadStoredSession(apiBase) {
|
|
315
|
+
const target = sessionStorePath(apiBase);
|
|
316
|
+
if (!target) return null;
|
|
317
|
+
return readSessionFile(target);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Every instance this machine has a stored session for — so the CLI can say which one it is
|
|
321
|
+
// targeting and what else it remembers. Sorted for a stable listing; never throws.
|
|
322
|
+
function listStoredSessions() {
|
|
323
|
+
let names = [];
|
|
324
|
+
try { names = fs.readdirSync(sessionStoreDir()); } catch (_) { return []; }
|
|
325
|
+
const out = [];
|
|
326
|
+
for (const name of names) {
|
|
327
|
+
if (!name.endsWith('.json')) continue;
|
|
328
|
+
try {
|
|
329
|
+
const j = JSON.parse(fs.readFileSync(path.join(sessionStoreDir(), name), 'utf8'));
|
|
330
|
+
if (j && j.token) {
|
|
331
|
+
out.push({
|
|
332
|
+
host: name.slice(0, -5),
|
|
333
|
+
api_base: j.api_base || null,
|
|
334
|
+
login: (j.builder && j.builder.github_login) || null,
|
|
335
|
+
instance: (j.instance && j.instance.name) || null,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
} catch (_) { /* a corrupt entry is skipped, never fatal */ }
|
|
339
|
+
}
|
|
340
|
+
return out.sort((a, b) => a.host.localeCompare(b.host));
|
|
341
|
+
}
|
|
342
|
+
|
|
238
343
|
function loadSessionSync() {
|
|
239
344
|
for (const p of SESSION_READ_PATHS) {
|
|
240
345
|
if (!fs.existsSync(p)) continue;
|
|
@@ -1006,6 +1111,11 @@ module.exports = {
|
|
|
1006
1111
|
loadSession,
|
|
1007
1112
|
loadSessionSync,
|
|
1008
1113
|
saveSession,
|
|
1114
|
+
sessionStoreDir,
|
|
1115
|
+
sessionHostKey,
|
|
1116
|
+
sessionStorePath,
|
|
1117
|
+
loadStoredSession,
|
|
1118
|
+
listStoredSessions,
|
|
1009
1119
|
arg,
|
|
1010
1120
|
argText,
|
|
1011
1121
|
hasFlag,
|
package/scripts/gds/login.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
const readline = require('node:readline');
|
|
24
24
|
const { spawnSync } = require('node:child_process');
|
|
25
|
-
const { saveSession } = require('./cli-lib');
|
|
25
|
+
const { saveSession, loadStoredSession, listStoredSessions } = require('./cli-lib');
|
|
26
26
|
const { branding } = require('../../src/branding');
|
|
27
27
|
|
|
28
28
|
// --- pure helpers (unit-tested) ---------------------------------------------
|
|
@@ -204,6 +204,30 @@ async function main() {
|
|
|
204
204
|
const idp = (man.data.auth && man.data.auth.idp) || null;
|
|
205
205
|
log(` Signing in to ${instanceName}${doorWord}${idp ? ' — via Cloud Bongos platform sign-in' : ''}`);
|
|
206
206
|
|
|
207
|
+
// 1b. Already signed in to THIS instance? Switch back instead of re-running a browser flow
|
|
208
|
+
// (task 1003741). Sessions are now kept per instance, so moving between two projects should
|
|
209
|
+
// cost nothing. The stored token is VERIFIED against the instance before it is trusted — an
|
|
210
|
+
// expired or revoked one falls straight through to the real flow below.
|
|
211
|
+
if (!process.argv.includes('--force')) {
|
|
212
|
+
let stored = null;
|
|
213
|
+
try { stored = await loadStoredSession(base); } catch (_) { stored = null; }
|
|
214
|
+
if (stored && stored.token) {
|
|
215
|
+
const me = await fetchJson(`${base}/api/gds/me`, { headers: { Authorization: `Bearer ${stored.token}` } });
|
|
216
|
+
if (me.ok && me.data && me.data.builder) {
|
|
217
|
+
await saveSession({ ...stored, builder: me.data.builder, api_base: base,
|
|
218
|
+
instance: { name: instanceName, origin: base } });
|
|
219
|
+
const back = me.data.builder.github_login || me.data.builder.display_name || 'you';
|
|
220
|
+
log('');
|
|
221
|
+
log(` ✓ Already signed in to ${instanceName} as ${back} — switched, no browser needed.`);
|
|
222
|
+
logOtherInstances(base, log);
|
|
223
|
+
log(' Next: bongos start (see what you can claim)');
|
|
224
|
+
log(' (Re-run with --force to sign in as somebody else.)');
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
log(' Your saved session for this instance has expired — signing in again.');
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
207
231
|
// 2. Device flow.
|
|
208
232
|
let flow = await runDeviceFlow(base, { log, idp });
|
|
209
233
|
|
|
@@ -280,9 +304,23 @@ async function main() {
|
|
|
280
304
|
const who = (flow.builder && (flow.builder.github_login || flow.builder.display_name)) || 'you';
|
|
281
305
|
log('');
|
|
282
306
|
log(` ✓ Signed in to ${instanceName} as ${who}.`);
|
|
307
|
+
logOtherInstances(base, log);
|
|
283
308
|
log(' Next: bongos start (see what you can claim) · bongos code / bongos shell (get on your dev box)');
|
|
284
309
|
}
|
|
285
310
|
|
|
311
|
+
// Name the OTHER instances this machine is signed into, and how to move between them (task
|
|
312
|
+
// 1003741). Signing in used to destroy the session you already had, so there was never anything
|
|
313
|
+
// to say; now that every instance is kept, silence would leave a builder unable to find them.
|
|
314
|
+
function logOtherInstances(base, log) {
|
|
315
|
+
let others = [];
|
|
316
|
+
try {
|
|
317
|
+
others = listStoredSessions().filter((s) => s.api_base && s.api_base !== base);
|
|
318
|
+
} catch (_) { return; }
|
|
319
|
+
if (!others.length) return;
|
|
320
|
+
const names = others.map((s) => s.instance || s.host);
|
|
321
|
+
log(` Also signed in to ${names.join(', ')} — switch with \`bongos login <url>\` (no browser needed).`);
|
|
322
|
+
}
|
|
323
|
+
|
|
286
324
|
if (require.main === module) {
|
|
287
325
|
main().catch((err) => {
|
|
288
326
|
console.error(`✖ ${err.message}`);
|
|
@@ -290,4 +328,4 @@ if (require.main === module) {
|
|
|
290
328
|
});
|
|
291
329
|
}
|
|
292
330
|
|
|
293
|
-
module.exports = { normalizeInstanceBase, pollDecision, runDeviceFlow };
|
|
331
|
+
module.exports = { normalizeInstanceBase, pollDecision, runDeviceFlow, logOtherInstances };
|
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.619'; // 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
|