@bongos/core 1.19.293 → 1.19.295

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,103 @@
1
+ #!/usr/bin/env node
2
+ // Mark one of YOUR OWN quick ideas as a Big Idea Spark — "I have the spark but
3
+ // not the full picture, someone develop this" (task 1002920 / BV1.R144, goal
4
+ // 1000062).
5
+ //
6
+ // The mark is the CONSENT that makes cross-builder development fair: until an
7
+ // author sets it, their quick idea is not available for someone else to develop.
8
+ // So this command only ever works on ideas you filed — the server enforces that
9
+ // against idea_inbox.captured_by, and no rank can override it.
10
+ //
11
+ // It exists as its own command rather than a flag on capture.js because sparking
12
+ // is an act on an EXISTING idea, not a way to file one (capture refuses
13
+ // grade='spark' for the same reason). The builders-hall control is R154; this is
14
+ // the terminal path, and the two hit the same route.
15
+ //
16
+ // Usage:
17
+ // node scripts/gds/spark.js 123 # mark idea 123 as a spark
18
+ // node scripts/gds/spark.js 123 --undo # withdraw the mark (back to quick)
19
+ //
20
+ // Exit codes:
21
+ // 0 the idea now carries the grade you asked for (including when it already did)
22
+ // 1 api / network error, or the server refused (not yours / not a quick idea)
23
+ // 2 bad invocation
24
+
25
+ const { cliClient, requireSession, hasFlag } = require('./cli-lib');
26
+
27
+ // The server's refusals, in the ideator's own words. Anything unmapped falls
28
+ // through to the raw envelope — a new server-side rule should be visible, not
29
+ // swallowed by a generic "failed".
30
+ const REFUSALS = {
31
+ not_your_idea: (d) =>
32
+ 'That idea is not yours to mark. The spark mark is the author\'s consent to have their\n'
33
+ + 'idea developed, so only the builder who filed it can give or withdraw it.',
34
+ not_a_quick_idea: (d) =>
35
+ `That idea is already a Full Idea (grade=${d.grade || 'full'}). It carries the developed\n`
36
+ + 'picture the spark mark asks for — there is nothing to hand off.',
37
+ idea_not_open: (d) =>
38
+ `That idea is ${d.status || 'no longer open'}, so it is not awaiting development.\n`
39
+ + '(You can still withdraw an existing mark with --undo.)',
40
+ idea_not_found: () => 'No idea with that id.',
41
+ };
42
+
43
+ // The envelope is ADR 0116's { error: { code, message, details } } — the extras a
44
+ // route passes as res.fail's third argument land under `details`, NOT spread onto
45
+ // `error`. Reading them off the wrong level is a silent "undefined" in the
46
+ // message rather than a crash, so tests/idea_spark_mark.mjs pins explain()
47
+ // against the real envelope() builder.
48
+ function explain(status, data) {
49
+ const err = data && data.error;
50
+ const code = err && typeof err === 'object' ? err.code : err;
51
+ const detail = (err && typeof err === 'object' && err.details) || {};
52
+ const say = typeof code === 'string' ? REFUSALS[code] : null;
53
+ if (say) return say(detail);
54
+ let rendered;
55
+ try { rendered = JSON.stringify(data, null, 2); } catch { rendered = String(data); }
56
+ return `spark failed (${status}): ${rendered}`;
57
+ }
58
+
59
+ async function main() {
60
+ await requireSession();
61
+
62
+ const positional = process.argv.slice(2).filter((a) => !a.startsWith('-'));
63
+ const id = Number(positional[0]);
64
+ const undo = hasFlag('--undo');
65
+
66
+ if (!Number.isInteger(id) || id <= 0) {
67
+ console.error('usage: node scripts/gds/spark.js <idea-id> [--undo]');
68
+ process.exit(2);
69
+ }
70
+
71
+ const api = await cliClient();
72
+ const r = undo
73
+ ? await api.inbox.deleteInboxIdSpark({ id })
74
+ : await api.inbox.postInboxIdSpark({ id, body: {} });
75
+
76
+ if (!r.ok) {
77
+ console.error(explain(r.status, r.data));
78
+ process.exit(1);
79
+ }
80
+
81
+ const { idea, sparked, unchanged } = r.data;
82
+ const title = idea && idea.title ? ` — ${idea.title}` : '';
83
+ if (unchanged) {
84
+ console.log(`Idea ${id} was already ${sparked ? 'a spark' : 'a quick idea'}${title}. Nothing changed.`);
85
+ } else if (sparked) {
86
+ console.log(`Idea ${id} is now a Big Idea Spark${title}.`);
87
+ console.log('Another builder can develop it into a Full Idea; you keep the attribution.');
88
+ } else {
89
+ console.log(`Idea ${id} is a quick idea again${title}. The mark is withdrawn.`);
90
+ }
91
+ }
92
+
93
+ // Only explain() is exported — tests/idea_spark_mark.mjs pins its message layer
94
+ // against the real envelope. REFUSALS stays private: it is the message table,
95
+ // reachable through explain(), and a second export would be dead surface.
96
+ module.exports = { explain };
97
+
98
+ if (require.main === module) {
99
+ main().catch((err) => {
100
+ console.error('fatal:', err);
101
+ process.exit(1);
102
+ });
103
+ }
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ //
4
+ // scripts/gds/strand-watch.js — say out loud that a ship got stuck (task 1003163).
5
+ //
6
+ // THE GAP. Nothing watched for a stranded ship. On 2026-08-22 a manual status sweep found
7
+ // task 1002483 parked at `completed` for THREE DAYS carrying 651 lines of tested work on a
8
+ // local branch, task 1002792 parked two days behind it, and task 1002459 sitting at
9
+ // `confirmed` for FOUR DAYS with no artifact anywhere. Not one of the three had produced a
10
+ // single signal; all were found by accident.
11
+ //
12
+ // WHY land-watch.js IS NOT THIS (it looks like it should be). land-watch.js renders the
13
+ // SHIPPED banner after an async CI land (task 1687). It cannot detect a strand: it reads
14
+ // machine-local breadcrumbs that ship.js writes only at CONFIRMED-exit — so a task parked at
15
+ // `completed` never gets one — it DROPS a breadcrumb after 30 minutes by design, and it is
16
+ // per-machine, so a strand on a box nobody reopens is invisible forever.
17
+ //
18
+ // THE SIGNAL ALREADY EXISTS. This is the part worth knowing: the publish-reconciler ALREADY
19
+ // diagnoses the worst case and already writes it down. Task 1002459 has carried
20
+ // `needs_rebase_reason = 'strand:land_not_proven'` since 2026-08-18 — the reconciler probed
21
+ // it, could not prove a land, gave up, and recorded exactly that. Nobody ever read the field.
22
+ // So this module is not a detector. It is the READOUT of a diagnosis the system performs and
23
+ // then discards, which is why it is small.
24
+ //
25
+ // SHAPE COPIED FROM deploy-divergence.js, deliberately. That check was written after the
26
+ // ten-day staleness incident (blocker 1000097 / task 1002521), is wired into SessionStart in
27
+ // .claude/settings.json, and on 2026-08-22 it reported the stale pin in the first message of
28
+ // the session — which is how that whole day's work started. A cheap check, on a path that
29
+ // already runs, reporting loudly, beats a monitoring subsystem that can itself silently stop.
30
+ // So: fail-open, bounded, and SILENT when the board is clean.
31
+ //
32
+ // Usage:
33
+ // node scripts/gds/strand-watch.js # human-readable report
34
+ // node scripts/gds/strand-watch.js --line # ONE line, or nothing; always exit 0
35
+ // node scripts/gds/strand-watch.js --json # machine shape
36
+ // node scripts/gds/strand-watch.js --hours N # override the `completed` threshold
37
+ //
38
+ // Exit codes: 0 always on --line (fail-open). Otherwise 0 clean, 1 strands found, 2 undetermined.
39
+
40
+ const { apiCall, arg, hasFlag } = require('./cli-lib');
41
+
42
+ // ---- thresholds -------------------------------------------------------------
43
+ // Reasoned, not magic. Written down because a future reader will want to retune them.
44
+ //
45
+ // `confirmed` means the grade PASSED and only the land is outstanding. The publish-reconciler
46
+ // sweeps every ~5 minutes, so one hour is twelve missed sweeps — already anomalous, while
47
+ // still leaving room for a slow CI queue or a transient GitHub failure.
48
+ const CONFIRMED_STALE_MS = 60 * 60 * 1000;
49
+ // `completed` means the grade did NOT pass. A builder may legitimately be mid-recovery for
50
+ // hours (see the /grade-recover loop), so this is deliberately generous: 12 hours means the
51
+ // strand survived a night, which no active recovery does. The three real cases were 2–3 DAYS.
52
+ const COMPLETED_STALE_MS = 12 * 60 * 60 * 1000;
53
+
54
+ // A reconciler-recorded strand needs NO threshold. The reconciler already probed every branch
55
+ // convention, failed to prove a land, and wrote the reason down; waiting longer cannot change
56
+ // that verdict, and task 1002459 proves the alternative is sitting for days.
57
+ const STRAND_REASON_PREFIX = 'strand:';
58
+
59
+ const WATCHED = ['completed', 'confirmed'];
60
+ const DEFAULT_TIMEOUT_MS = 8000;
61
+ // Page size per watched status. A queue past this would silently lose the OLDEST strands —
62
+ // exactly the class of quiet cap this whole check exists to abolish — so a full page is
63
+ // reported as `truncated` rather than passed off as complete. 200 is far above any healthy
64
+ // board (the real numbers were 1 and 2).
65
+ const PAGE_LIMIT = 200;
66
+
67
+ // ---- classification (pure — this is the unit-testable core) -----------------
68
+
69
+ // Task ids are numeric and reconciler reasons come from a fixed vocabulary, so both are safe
70
+ // to print. TITLES ARE NOT, and that omission is deliberate — see the trust-boundary note on
71
+ // `line()` below.
72
+ const safeId = (v) => (/^\d{1,19}$/.test(String(v)) ? String(v) : null);
73
+ const safeReason = (v) => (typeof v === 'string' && /^[a-z0-9_:.-]{1,64}$/i.test(v) ? v : null);
74
+
75
+ function humanAge(ms) {
76
+ if (!Number.isFinite(ms) || ms < 0) return 'unknown';
77
+ const h = ms / 3600000;
78
+ if (h < 1) return `${Math.max(1, Math.round(ms / 60000))}m`;
79
+ if (h < 48) return `${Math.round(h)}h`;
80
+ return `${Math.round(h / 24)}d`;
81
+ }
82
+
83
+ // Decide whether ONE task is a strand, and if so which shape and what fixes it. The two
84
+ // shapes need opposite responses, which is the whole reason to distinguish them:
85
+ // completed -> the GRADE parked it; re-grade or fix findings (/grade-recover).
86
+ // confirmed -> the grade passed and the LAND is outstanding (/merge-mode is the fallback).
87
+ // A reconciler-recorded strand is called out separately because it is terminal without a
88
+ // human: the automated healers have already refused it.
89
+ function classifyStrand(task, { nowMs, confirmedStaleMs = CONFIRMED_STALE_MS, completedStaleMs = COMPLETED_STALE_MS } = {}) {
90
+ const t = task || {};
91
+ const status = String(t.status || '');
92
+ if (!WATCHED.includes(status)) return null;
93
+
94
+ const id = safeId(t.id);
95
+ if (!id) return null;
96
+
97
+ const updated = Date.parse(t.updated_at || '');
98
+ const ageMs = Number.isFinite(updated) && Number.isFinite(nowMs) ? nowMs - updated : NaN;
99
+
100
+ const reason = safeReason(t.needs_rebase_reason);
101
+ const reconcilerGaveUp = status === 'confirmed'
102
+ && t.needs_rebase === true
103
+ && !!reason
104
+ && reason.toLowerCase().startsWith(STRAND_REASON_PREFIX);
105
+
106
+ if (reconcilerGaveUp) {
107
+ return {
108
+ id, status, ageMs, age: humanAge(ageMs), shape: 'unprovable', reason,
109
+ remedy: 'check whether any artifact exists at all — a confirm with no branch and nothing on main was codeless',
110
+ };
111
+ }
112
+
113
+ const limit = status === 'confirmed' ? confirmedStaleMs : completedStaleMs;
114
+ // An unreadable updated_at must not manufacture a strand — fail QUIET on bad data here,
115
+ // because this text is injected into every session and a false alarm trains people to
116
+ // scroll past the real one.
117
+ if (!Number.isFinite(ageMs) || ageMs <= limit) return null;
118
+
119
+ return status === 'confirmed'
120
+ ? { id, status, ageMs, age: humanAge(ageMs), shape: 'unlanded', reason: null,
121
+ remedy: '/merge-mode is the documented manual fallback' }
122
+ : { id, status, ageMs, age: humanAge(ageMs), shape: 'ungraded', reason: null,
123
+ remedy: '/grade-recover diagnoses which of the three shapes parked it' };
124
+ }
125
+
126
+ // Sort worst-first so a truncated line shows the most urgent: reconciler-refused, then oldest.
127
+ const SHAPE_RANK = { unprovable: 0, unlanded: 1, ungraded: 2 };
128
+ function rankStrands(strands) {
129
+ return strands.slice().sort((a, b) => {
130
+ const d = (SHAPE_RANK[a.shape] ?? 9) - (SHAPE_RANK[b.shape] ?? 9);
131
+ return d !== 0 ? d : (b.ageMs || 0) - (a.ageMs || 0);
132
+ });
133
+ }
134
+
135
+ // ---- fetch ------------------------------------------------------------------
136
+
137
+ async function check({ nowMs = Date.now(), timeoutMs = DEFAULT_TIMEOUT_MS, confirmedStaleMs, completedStaleMs, call = apiCall } = {}) {
138
+ // CONCURRENT, not sequential. This runs on the SessionStart path beside deploy-divergence,
139
+ // so a serial loop would double the latency the builder waits through for no reason.
140
+ const withTimeout = (p, label) => Promise.race([
141
+ p,
142
+ new Promise((_, rej) => setTimeout(() => rej(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs)),
143
+ ]).catch((e) => { throw new Error(`could not read ${label} tasks: ${e.message || e}`); });
144
+
145
+ let pages;
146
+ try {
147
+ pages = await Promise.all(WATCHED.map(async (status) => {
148
+ const res = await withTimeout(call('GET', `/api/gds/tasks?status=${status}&limit=${PAGE_LIMIT}`), status);
149
+ const body = res && (res.data || res);
150
+ const tasks = body && Array.isArray(body.tasks) ? body.tasks : null;
151
+ if (!tasks) throw new Error(`unexpected shape from /tasks?status=${status}`);
152
+ return { status, tasks };
153
+ }));
154
+ } catch (e) {
155
+ return { undetermined: true, reason: e.message || String(e), strands: [], truncated: [] };
156
+ }
157
+
158
+ const strands = [];
159
+ const truncated = [];
160
+ for (const { status, tasks } of pages) {
161
+ if (tasks.length >= PAGE_LIMIT) truncated.push(status);
162
+ for (const t of tasks) {
163
+ const s = classifyStrand(t, { nowMs, confirmedStaleMs, completedStaleMs });
164
+ if (s) strands.push(s);
165
+ }
166
+ }
167
+ return { undetermined: false, ok: strands.length === 0 && !truncated.length, strands: rankStrands(strands), truncated };
168
+ }
169
+
170
+ // ---- rendering --------------------------------------------------------------
171
+
172
+ // ONE line for SessionStart, or null. Null IS the silence: a clean board must say nothing,
173
+ // or the line becomes wallpaper and the real warning gets scrolled past.
174
+ //
175
+ // TRUST BOUNDARY — why no task titles. A task title is builder-authored free text, and this
176
+ // string is injected into an agent's context at the start of every session. Interpolating a
177
+ // title raw would hand anyone who can file a task a standing prompt-injection channel, which
178
+ // is the same reasoning deploy-divergence.js applies to the /version response it reads (it
179
+ // accepts only a strict semver for exactly this purpose). Ids and a fixed reason vocabulary
180
+ // carry all the signal that is needed and none of the risk — the agent can GET the task.
181
+ function line(r, { max = 3 } = {}) {
182
+ if (!r || r.undetermined) return null;
183
+ const trunc = Array.isArray(r.truncated) && r.truncated.length
184
+ ? ` (the ${r.truncated.join(' and ')} queue${r.truncated.length > 1 ? 's' : ''} filled a full ${PAGE_LIMIT}-row page, so older strands may be unlisted)`
185
+ : '';
186
+ if (!r.strands.length) {
187
+ // A full page with no strand still means coverage was capped — say so rather than imply
188
+ // a clean board, which is the silent-cap failure this check is against.
189
+ return trunc ? `Strand check could not see the whole board${trunc}. Run: node scripts/gds/strand-watch.js` : null;
190
+ }
191
+ const shown = r.strands.slice(0, max)
192
+ .map((s) => `task ${s.id} (${s.status}, ${s.age}${s.reason ? `, ${s.reason}` : ''})`);
193
+ const more = (r.strands.length > shown.length ? ` +${r.strands.length - shown.length} more` : '') + trunc;
194
+ const worst = r.strands[0];
195
+ // Lead with what the WORST shape actually means. "work is finished but not landed" is true
196
+ // of a confirmed strand and false of a completed one (the grade parked that, so the work may
197
+ // not be finished at all) — one generic sentence for both would be wrong half the time.
198
+ const lead = {
199
+ unprovable: 'the reconciler already probed this and gave up, so it will never self-heal',
200
+ unlanded: 'the grade passed and only the land is outstanding',
201
+ ungraded: 'the grade parked this; it is not waiting on a land',
202
+ }[worst.shape] || 'parked mid-pipeline';
203
+ return `Stranded ship${r.strands.length === 1 ? '' : 's'}: ${shown.join('; ')}${more}. `
204
+ + `Worst: ${lead} — ${worst.remedy}. `
205
+ + 'A strand produces no signal of its own; this line is the only one.';
206
+ }
207
+
208
+ function report(r) {
209
+ if (!r) { console.error('strand-watch: no result'); return; }
210
+ if (r.undetermined) { console.error(`strand-watch: undetermined — ${r.reason}`); return; }
211
+ console.log('');
212
+ console.log(' Stranded ships');
213
+ console.log(' ──────────────────────────────────────────────────────────');
214
+ if (Array.isArray(r.truncated) && r.truncated.length) {
215
+ console.log(` ! coverage capped: the ${r.truncated.join(' and ')} queue(s) returned a full ${PAGE_LIMIT}-row page,`);
216
+ console.log(' so strands older than the cutoff are NOT listed below.');
217
+ }
218
+ if (r.ok) {
219
+ console.log(' ✓ none — nothing parked at completed or confirmed past its threshold.');
220
+ console.log('');
221
+ return;
222
+ }
223
+ for (const s of r.strands) {
224
+ console.log(` task ${s.id} ${s.status.padEnd(9)} ${String(s.age).padStart(4)} ${s.shape}${s.reason ? ` (${s.reason})` : ''}`);
225
+ console.log(` ${s.remedy}`);
226
+ }
227
+ console.log('');
228
+ console.log(' "completed" means the grade parked it. "confirmed" means it passed and the');
229
+ console.log(' land never finished. Neither state produces a signal on its own.');
230
+ console.log('');
231
+ }
232
+
233
+ async function main() {
234
+ const hours = Number(arg('--hours'));
235
+ const opts = Number.isFinite(hours) && hours > 0 ? { completedStaleMs: hours * 3600000 } : {};
236
+
237
+ // --line: the SessionStart shape. FAIL-OPEN — a strand is context for the agent, and an
238
+ // unreachable API is nothing at all; neither is a failure of the session this rides into.
239
+ // Note it sets exitCode and RETURNS rather than calling process.exit: an explicit exit here
240
+ // races libuv's teardown of the request socket and aborts the process on Windows/Node 24
241
+ // (the documented `UV_HANDLE_CLOSING` assertion — see cli-lib's cliExit note and
242
+ // deploy-divergence's identical comment).
243
+ if (hasFlag('--line')) {
244
+ try {
245
+ const one = line(await check(opts));
246
+ if (one) console.log(one);
247
+ } catch { /* no session, offline, unreadable — all silence */ }
248
+ process.exitCode = 0;
249
+ return;
250
+ }
251
+
252
+ const r = await check(opts);
253
+ if (hasFlag('--json')) console.log(JSON.stringify(r, null, 2));
254
+ else report(r);
255
+ process.exitCode = r.undetermined ? 2 : (r.ok ? 0 : 1);
256
+ }
257
+
258
+ if (require.main === module) {
259
+ main().catch((e) => { console.error('strand-watch: failed —', e.message || e); process.exit(2); });
260
+ }
261
+
262
+ module.exports = {
263
+ check, line, report, classifyStrand, rankStrands, humanAge,
264
+ CONFIRMED_STALE_MS, COMPLETED_STALE_MS, STRAND_REASON_PREFIX, WATCHED, PAGE_LIMIT,
265
+ };
@@ -416,6 +416,15 @@ const MODULE_EXPECTED_RANKS = {
416
416
  // requireBuilder + an in-handler author|triage gate. Deliberately a separate
417
417
  // route from the triage-gated PATCH /inbox/:id, which keeps its own rank.
418
418
  'PATCH /inbox/:id/body': 'any-builder',
419
+ // task 1002920 (BV1.R144): the Big Idea Spark mark is AUTHOR-ONLY, which is a
420
+ // narrower rule than any rank expresses — the gate is "whose thought is this",
421
+ // enforced in-handler against captured_by. Pinned 'any-builder' for the same
422
+ // reason POST /goals/:id/members is: the pin's job here is to catch an
423
+ // accidental TIGHTENING to a rank floor (which would lock authors out of their
424
+ // own ideas and break the consent the mark exists to record) as loudly as it
425
+ // catches a gate being dropped.
426
+ 'POST /inbox/:id/spark': 'any-builder',
427
+ 'DELETE /inbox/:id/spark': 'any-builder',
419
428
  'POST /inbox/:id/ratify': 'perm:idea.ratify',
420
429
  },
421
430
  // Cross-builder memory READS — the counterpart to the BFG cross-builder WRITE the
package/src/module-api.js CHANGED
@@ -1013,8 +1013,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1013
1013
  // landed since 1.19.291 with no explicit bump. run 32582800234. (task 1002620)
1014
1014
  // 1.19.293 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1015
1015
  // landed since 1.19.292 with no explicit bump. run 32586174253. (task 1002620)
1016
+ // 1.19.294 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1017
+ // landed since 1.19.293 with no explicit bump. run 32588977219. (task 1002620)
1018
+ // 1.19.295 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1019
+ // landed since 1.19.294 with no explicit bump. run 32590508145. (task 1002620)
1016
1020
  // ---------------------------------------------------------------------------
1017
- const CORE_VERSION = '1.19.293'; // CI auto-patch carrier (ADR 0161); see changelog above
1021
+ const CORE_VERSION = '1.19.295'; // CI auto-patch carrier (ADR 0161); see changelog above
1018
1022
 
1019
1023
  // A namespaced logger so a module's log lines are attributable + consistent.
1020
1024
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,231 @@
1
+ // tests/idea_spark_mark.mjs
2
+ //
3
+ // task 1002920 (BV1.R144, goal 1000062) — mark a Quick Idea as a Big Idea Spark.
4
+ //
5
+ // The done-when, as assertions: ONLY the original author can mark their idea
6
+ // (the mark is the consent that makes cross-builder development fair, so a
7
+ // non-author is a 403 and writes NOTHING); the mark is REVERSIBLE (DELETE puts
8
+ // the idea back to quick); and a spark is visibly DISTINCT from both a quick and
9
+ // a full idea (the grade comes back on the row, and a Full Idea is refused
10
+ // rather than being flattened into a spark).
11
+ //
12
+ // DB-free the idea_full_capture.mjs way (pool.query patched on the doorway
13
+ // before the modules load), driving the REAL route handlers through a stub
14
+ // req/res — the same author gate, the same error map a client sees.
15
+ //
16
+ // Run: node tests/idea_spark_mark.mjs
17
+
18
+ import { strict as assert } from 'node:assert';
19
+ import { createRequire } from 'node:module';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const api = require('../src/module-api.js');
23
+
24
+ // The one idea every test reads, reshaped per-test. `captured_by: '9'` is the
25
+ // stub builder below, so the default row IS author-owned.
26
+ let idea = null;
27
+ let queries = [];
28
+
29
+ api.pool.query = async (sql, params = []) => {
30
+ queries.push({ sql, params });
31
+ if (/INSERT INTO ideas_grades/i.test(sql)) {
32
+ return { rows: [{ idea_id: params[0], grade: params[1], template_key: params[2], field_values: JSON.parse(params[3]), completeness_score: params[4] }] };
33
+ }
34
+ // Any read of idea_inbox is getIdea — return the row under test (or nothing).
35
+ if (/FROM idea_inbox/i.test(sql)) {
36
+ return { rows: idea ? [idea] : [] };
37
+ }
38
+ return { rows: [] };
39
+ };
40
+
41
+ const routesFactory = require('../modules/ideas/routes/inbox.js');
42
+ const router = routesFactory({});
43
+
44
+ // Pull the real handlers off the mounted router — the last handler in each
45
+ // stack, i.e. after requireBuilder.
46
+ function handlerFor(pathStr, method) {
47
+ const layer = router.stack.find((l) => l.route && l.route.path === pathStr && l.route.methods[method]);
48
+ assert.ok(layer, `route ${method.toUpperCase()} ${pathStr} is not mounted`);
49
+ const stack = layer.route.stack.map((s) => s.handle);
50
+ return stack[stack.length - 1];
51
+ }
52
+ const postSpark = handlerFor('/inbox/:id/spark', 'post');
53
+ const deleteSpark = handlerFor('/inbox/:id/spark', 'delete');
54
+
55
+ function call(handler, { builderId = '9' } = {}) {
56
+ return new Promise((resolve, reject) => {
57
+ const req = { params: { id: '77' }, body: {}, query: {}, builder: { id: builderId, rank: 'xenos' } };
58
+ const res = {
59
+ statusCode: 200,
60
+ status(c) { this.statusCode = c; return this; },
61
+ json(payload) { resolve({ status: this.statusCode, payload }); },
62
+ fail(code, status, extra) {
63
+ const s = typeof status === 'number' ? status : (status && status.status) || 500;
64
+ resolve({ status: s, payload: { error: code, ...(extra || {}) } });
65
+ },
66
+ };
67
+ // asyncHandler owns the catch in production; surface a throw as a failure here.
68
+ Promise.resolve(handler(req, res, (err) => reject(err || new Error('next() called')))).catch(reject);
69
+ });
70
+ }
71
+
72
+ const gradeWrites = () => queries.filter((q) => /INSERT INTO ideas_grades/i.test(q.sql));
73
+
74
+ let passed = 0;
75
+ let failed = 0;
76
+ async function test(name, fn) {
77
+ queries = [];
78
+ idea = { id: '77', title: 'a thought', status: 'open', captured_by: '9', grade: 'quick', template_key: null, field_values: null, completeness_score: null };
79
+ try { await fn(); passed++; console.log(` ok ${name}`); }
80
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
81
+ }
82
+
83
+ // --- the author gate: the whole point of the mark ------------------------
84
+
85
+ await test('the AUTHOR can mark their quick idea as a spark', async () => {
86
+ const r = await call(postSpark);
87
+ assert.equal(r.status, 200);
88
+ assert.equal(r.payload.sparked, true);
89
+ assert.equal(r.payload.unchanged, false);
90
+ assert.equal(r.payload.idea.grade, 'spark', 'the row comes back carrying its new grade');
91
+ const w = gradeWrites();
92
+ assert.equal(w.length, 1);
93
+ assert.equal(w[0].params[1], 'spark');
94
+ });
95
+
96
+ await test('a NON-author is a 403 and nothing is written — the consent is not theirs to give', async () => {
97
+ const r = await call(postSpark, { builderId: '12' });
98
+ assert.equal(r.status, 403);
99
+ assert.equal(r.payload.error, 'not_your_idea');
100
+ assert.equal(gradeWrites().length, 0);
101
+ });
102
+
103
+ await test('an idea with NO author cannot be sparked by anyone (no consent to read)', async () => {
104
+ idea.captured_by = null;
105
+ const r = await call(postSpark);
106
+ assert.equal(r.status, 403);
107
+ assert.equal(r.payload.error, 'not_your_idea');
108
+ assert.equal(gradeWrites().length, 0);
109
+ });
110
+
111
+ await test('a non-author cannot WITHDRAW the mark either', async () => {
112
+ idea.grade = 'spark';
113
+ const r = await call(deleteSpark, { builderId: '12' });
114
+ assert.equal(r.status, 403);
115
+ assert.equal(gradeWrites().length, 0);
116
+ });
117
+
118
+ // --- reversible ----------------------------------------------------------
119
+
120
+ await test('the mark is REVERSIBLE: DELETE puts the idea back to quick', async () => {
121
+ idea.grade = 'spark';
122
+ const r = await call(deleteSpark);
123
+ assert.equal(r.status, 200);
124
+ assert.equal(r.payload.sparked, false);
125
+ assert.equal(r.payload.unchanged, false);
126
+ assert.equal(r.payload.idea.grade, 'quick');
127
+ const w = gradeWrites();
128
+ assert.equal(w.length, 1);
129
+ assert.equal(w[0].params[1], 'quick', 'un-sparking writes an EXPLICIT quick row (R140: it reads as quick either way)');
130
+ });
131
+
132
+ await test('both directions are idempotent and report `unchanged` rather than erroring', async () => {
133
+ idea.grade = 'spark';
134
+ const again = await call(postSpark);
135
+ assert.equal(again.status, 200);
136
+ assert.equal(again.payload.unchanged, true);
137
+ assert.equal(gradeWrites().length, 0, 'no write for a no-op');
138
+
139
+ queries = [];
140
+ idea.grade = 'quick';
141
+ const off = await call(deleteSpark);
142
+ assert.equal(off.status, 200);
143
+ assert.equal(off.payload.sparked, false);
144
+ assert.equal(off.payload.unchanged, true);
145
+ assert.equal(gradeWrites().length, 0);
146
+ });
147
+
148
+ // --- distinct from a FULL idea ------------------------------------------
149
+
150
+ await test('a FULL idea is refused 409 — and its field_values are never touched', async () => {
151
+ const fields = { purpose: 'x'.repeat(60), big_picture: 'y'.repeat(60) };
152
+ idea.grade = 'full';
153
+ idea.field_values = fields;
154
+ idea.completeness_score = 80;
155
+ const r = await call(postSpark);
156
+ assert.equal(r.status, 409);
157
+ assert.equal(r.payload.error, 'not_a_quick_idea');
158
+ assert.equal(r.payload.grade, 'full');
159
+ assert.equal(gradeWrites().length, 0, 'a spark write would have WIPED the five fields');
160
+ assert.deepEqual(idea.field_values, fields);
161
+ });
162
+
163
+ await test('un-sparking a FULL idea is refused too — it is not a withdrawn spark', async () => {
164
+ idea.grade = 'full';
165
+ const r = await call(deleteSpark);
166
+ assert.equal(r.status, 409);
167
+ assert.equal(r.payload.error, 'not_a_quick_idea');
168
+ assert.equal(gradeWrites().length, 0);
169
+ });
170
+
171
+ // --- only an OPEN idea awaits development -------------------------------
172
+
173
+ await test('a promoted idea cannot be newly marked — the spark queue must stay pickable', async () => {
174
+ idea.status = 'promoted';
175
+ const r = await call(postSpark);
176
+ assert.equal(r.status, 409);
177
+ assert.equal(r.payload.error, 'idea_not_open');
178
+ assert.equal(r.payload.status, 'promoted');
179
+ assert.equal(gradeWrites().length, 0);
180
+ });
181
+
182
+ await test('but a stale mark can always be WITHDRAWN, whatever the status', async () => {
183
+ idea.status = 'discarded';
184
+ idea.grade = 'spark';
185
+ const r = await call(deleteSpark);
186
+ assert.equal(r.status, 200);
187
+ assert.equal(r.payload.idea.grade, 'quick');
188
+ assert.equal(gradeWrites().length, 1, 'housekeeping a dead idea\'s mark is never blocked');
189
+ });
190
+
191
+ // --- plumbing -----------------------------------------------------------
192
+
193
+ await test('a missing idea is a 404, not a 500', async () => {
194
+ idea = null;
195
+ const r = await call(postSpark);
196
+ assert.equal(r.status, 404);
197
+ assert.equal(r.payload.error, 'idea_not_found');
198
+ assert.equal(gradeWrites().length, 0);
199
+ });
200
+
201
+ // --- the CLI's message layer, against the REAL envelope -----------------
202
+ //
203
+ // The stub res.fail above spreads its extras onto the payload for terseness (the
204
+ // idea_full_capture.mjs idiom). Production does NOT: ADR 0116's envelope nests
205
+ // them under error.details. So the CLI's explain() is checked against the real
206
+ // builder, not the stub — that mismatch is a silent "grade=undefined" in an
207
+ // ideator's terminal, which no route-level assertion above would catch.
208
+ const { explain } = require('../scripts/gds/spark.js');
209
+ const { envelope } = require('../src/bongos/api-errors.js');
210
+
211
+ await test('the CLI reads each refusal off the REAL envelope shape', async () => {
212
+ const full = explain(409, envelope('not_a_quick_idea', undefined, { grade: 'full' }));
213
+ assert.match(full, /already a Full Idea \(grade=full\)/);
214
+ assert.doesNotMatch(full, /undefined/);
215
+
216
+ const closed = explain(409, envelope('idea_not_open', undefined, { status: 'promoted' }));
217
+ assert.match(closed, /is promoted, so it is not awaiting development/);
218
+ assert.doesNotMatch(closed, /undefined/);
219
+
220
+ assert.match(explain(403, envelope('not_your_idea')), /not yours to mark/);
221
+ assert.match(explain(404, envelope('idea_not_found')), /No idea with that id/);
222
+ });
223
+
224
+ await test('an UNMAPPED server refusal is shown raw, never swallowed', async () => {
225
+ const out = explain(409, envelope('some_future_rule', undefined, { hint: 'x' }));
226
+ assert.match(out, /spark failed \(409\)/);
227
+ assert.match(out, /some_future_rule/, 'a new server-side rule must stay visible');
228
+ });
229
+
230
+ console.log(`\nidea_spark_mark: ${passed} passed, ${failed} failed`);
231
+ if (failed > 0) process.exit(1);