@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.
@@ -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.2';
38
+ const PACKAGE_VERSION = '0.1.3';
39
39
  const PACKAGE_NAME = '@cloudbongos/cli';
40
40
 
41
41
  // ── What the package carries ────────────────────────────────────────────────────────────────
@@ -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,
@@ -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.617'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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