@bongos/core 1.19.549 → 1.19.551

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,292 @@
1
+ #!/usr/bin/env node
2
+ // /bug-triage — walk every open kind=bug task and verdict it one-at-a-time
3
+ // (task 1001478). The idea-triage precedent for a queue with no gate: bugs now
4
+ // land directly as kind=bug tasks with no triage step (task 1003071), so
5
+ // near-duplicate reports accumulate with nothing to fold them together.
6
+ //
7
+ // Usage:
8
+ // node scripts/gds/bug-triage.js
9
+ //
10
+ // BATCH CLUSTER PASS, mirroring /idea-triage's (task 1001465): before the
11
+ // one-at-a-time walk, near-duplicate open bug tasks are grouped (shared
12
+ // `goal_id`, high title/description word overlap — `clusterRecords`, shared
13
+ // with triage.js so the union-find/Jaccard core is written once) and offered
14
+ // ONE verdict per group: keep a canonical task and merge the rest into it,
15
+ // dismiss the whole group as won't-fix, walk them individually, or skip.
16
+ // Partitioned by `goal_id` rather than `kind` (every row here is already
17
+ // kind=bug, so that gate would be a no-op) — two bug reports in unrelated
18
+ // goals describing similar-sounding symptoms are not the same triage
19
+ // decision, the same reasoning idea-triage applies to its own `kind` gate.
20
+ //
21
+ // Prompts per bug task:
22
+ // [m]erge with #N → POST /tasks/:id/merge {into_task_id:N} (N is the
23
+ // canonical TASK to keep; this task's status becomes
24
+ // 'abandoned' with merged_into_task_id=N — no new
25
+ // 'merged' status, see db-ship.js mergeTask's header)
26
+ // [w]on't-fix → POST /tasks/:id/abandon {reason} (not a bug / already
27
+ // fixed / can't reproduce — the report itself is closed,
28
+ // not folded into another task)
29
+ // [s]kip → no write; the task surfaces again next triage
30
+ // [q]uit → exit early, print partial summary
31
+ //
32
+ // Prompts per duplicate cluster (before the per-task walk):
33
+ // [k]eep #N → merge every other member into #N (default: the
34
+ // oldest); #N itself is NOT verdicted here — it still
35
+ // gets the normal per-task walk below
36
+ // [d]iscard → won't-fix every member in the cluster
37
+ // [i]ndividually → ignore the cluster grouping; walk each member normally
38
+ // [s]kip → no write; the whole cluster surfaces again next triage
39
+ //
40
+ // Exit codes:
41
+ // 0 walked cleanly (incl. nothing open)
42
+ // 1 api / network error
43
+ // 2 no session
44
+
45
+ const readline = require('node:readline');
46
+ const { cliClient, requireSession, cliExit } = require('./cli-lib');
47
+ const { clusterRecords } = require('./triage');
48
+
49
+ function prompt(rl, question) {
50
+ return new Promise((resolve) => rl.question(question, resolve));
51
+ }
52
+
53
+ function trim(s, n) {
54
+ const str = String(s || '');
55
+ return str.length > n ? `${str.slice(0, n - 1)}…` : str;
56
+ }
57
+
58
+ // Statuses a bug can still be triaged FROM. 'shipped'/'abandoned' are terminal
59
+ // (the latter includes already-merged rows, since merge reuses that status —
60
+ // see db-ship.js mergeTask's header), so neither belongs in an "open" walk.
61
+ const OPEN_STATUSES = new Set(['backlog', 'ready', 'active', 'blocked', 'completed', 'confirmed']);
62
+
63
+ async function listOpenBugTasks() {
64
+ const api = await cliClient();
65
+ const r = await api.tasks.getTasks({ query: { kind: 'bug', limit: 1000 } });
66
+ if (!r.ok) throw new Error(`GET /tasks?kind=bug failed (${r.status}): ${JSON.stringify(r.data)}`);
67
+ // GET /tasks responds { tasks: [...] } (modules/lifecycle/routes/tasks.js),
68
+ // not a bare array — triage.js:163's `r.data?.ideas ?? []` is the same shape
69
+ // one route over. Grade finding: this was `(r.data || [])`, an object with
70
+ // no .filter, so every real run threw before listing a single bug.
71
+ return (r.data?.tasks ?? []).filter((t) => OPEN_STATUSES.has(t.status));
72
+ }
73
+
74
+ async function mergeTaskInto(id, intoTaskId, reason) {
75
+ const api = await cliClient();
76
+ const r = await api.tasks.postTasksIdMerge({ id, body: { into_task_id: Number(intoTaskId), reason: reason || '' } });
77
+ if (!r.ok) throw Object.assign(new Error(`merge failed (${r.status}): ${JSON.stringify(r.data)}`), { code: r.data?.error?.code || r.data?.error });
78
+ return r.data;
79
+ }
80
+
81
+ async function dismissTask(id, reason) {
82
+ const api = await cliClient();
83
+ const r = await api.tasks.postTasksIdAbandon({ id, body: { reason: reason || '' } });
84
+ if (!r.ok) throw Object.assign(new Error(`won't-fix failed (${r.status}): ${JSON.stringify(r.data)}`), { code: r.data?.error?.code || r.data?.error });
85
+ return r.data;
86
+ }
87
+
88
+ function printTask(task, idx, total) {
89
+ console.log(`\n[${idx + 1}/${total}] #${task.id} [${task.status}] ${task.title}`);
90
+ if (task.description) console.log(` ${trim(task.description, 300)}`);
91
+ if (task.goal_id) console.log(` goal ${task.goal_id}`);
92
+ }
93
+
94
+ function printCluster(cluster) {
95
+ const sep = '┄'.repeat(72);
96
+ console.log(`\n${sep}`);
97
+ console.log(`Cluster of ${cluster.length} likely-duplicate bug reports (oldest first — the suggested keeper):`);
98
+ for (const m of cluster) console.log(` #${m.id} [${m.status}] ${trim(m.title, 80)}`);
99
+ console.log(sep);
100
+ }
101
+
102
+ // The interactive batch pass over one cluster. Mutates `state` (summary
103
+ // counts) and returns nothing; `clusteredIds` is mutated in place so a choice
104
+ // of "individually" or "keep #N" puts the affected task(s) back into the
105
+ // normal per-task walk that runs after every cluster has been offered.
106
+ // Mirrors triage.js's handleCluster exactly, task-shaped.
107
+ async function handleCluster(rl, cluster, clusteredIds, state) {
108
+ printCluster(cluster);
109
+ let handled = false;
110
+ while (!handled) {
111
+ const ans = (await prompt(rl, ` [k]eep #${cluster[0].id} & merge the rest / [d]iscard all / [i]ndividually / [s]kip cluster > `)).trim().toLowerCase();
112
+
113
+ if (ans === 's' || ans === 'skip' || ans === '') { handled = true; break; }
114
+
115
+ if (ans === 'i' || ans === 'individually') {
116
+ for (const m of cluster) clusteredIds.delete(m.id);
117
+ console.log(' → walking this cluster\'s bugs individually');
118
+ handled = true; break;
119
+ }
120
+
121
+ if (ans === 'd' || ans === 'discard') {
122
+ const reason = (await prompt(rl, ' reason (optional, enter to skip): ')).trim();
123
+ for (const m of cluster) {
124
+ try {
125
+ await dismissTask(m.id, reason || 'won\'t-fix as part of a batch-verdicted duplicate cluster');
126
+ state.summary.dismissed++;
127
+ state.dismissedIds.push(m.id);
128
+ } catch (err) {
129
+ console.error(` ! #${m.id}: ${err.message}`);
130
+ }
131
+ }
132
+ console.log(` ✖ dismissed ${cluster.length} duplicate bug reports`);
133
+ handled = true; break;
134
+ }
135
+
136
+ if (ans === 'k' || ans === 'keep' || ans.startsWith('k ') || ans.startsWith('k#')) {
137
+ let canonicalId = cluster[0].id;
138
+ const inline = ans.replace(/^k(eep)?(\s|#)*/, '').trim();
139
+ if (inline) {
140
+ const n = Number(inline.replace(/^#/, ''));
141
+ if (Number.isFinite(n) && n > 0 && cluster.some((m) => String(m.id) === String(n))) canonicalId = n;
142
+ else if (Number.isFinite(n) && n > 0) console.log(` ! #${n} isn't in this cluster; keeping #${canonicalId}`);
143
+ }
144
+ for (const m of cluster) {
145
+ if (String(m.id) === String(canonicalId)) continue;
146
+ try {
147
+ await mergeTaskInto(m.id, canonicalId, `duplicate cluster batch-merged into #${canonicalId}`);
148
+ state.summary.merged++;
149
+ state.mergedIds.push(m.id);
150
+ } catch (err) {
151
+ console.error(` ! #${m.id}: ${err.message}`);
152
+ // A failed merge must not silently vanish the bug for this session:
153
+ // without this, the member stays in clusteredIds and the later
154
+ // `tasks.filter((t) => !clusteredIds.has(t.id))` drops it from BOTH
155
+ // the batch verdict (it never merged) and the individual walk —
156
+ // one stderr line and the report is skipped entirely.
157
+ clusteredIds.delete(m.id);
158
+ }
159
+ }
160
+ // The keeper is untouched by the batch verdict — it still needs its own
161
+ // won't-fix/merge/skip decision, so it goes back into the normal walk.
162
+ clusteredIds.delete(canonicalId);
163
+ console.log(` ⇢ merged ${cluster.length - 1} duplicate(s) into #${canonicalId} — #${canonicalId} still needs its own verdict below`);
164
+ handled = true; break;
165
+ }
166
+
167
+ console.log(' ? unknown — type k, d, i, or s');
168
+ }
169
+ }
170
+
171
+ async function main() {
172
+ await requireSession();
173
+
174
+ let tasks;
175
+ try {
176
+ tasks = await listOpenBugTasks();
177
+ } catch (err) {
178
+ console.error(err.message);
179
+ cliExit(1);
180
+ return;
181
+ }
182
+
183
+ if (tasks.length === 0) {
184
+ console.log('bug queue empty — nothing to triage.');
185
+ cliExit(0);
186
+ return;
187
+ }
188
+
189
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
190
+ const summary = { merged: 0, dismissed: 0, skipped: 0 };
191
+ const mergedIds = [];
192
+ const dismissedIds = [];
193
+ let quit = false;
194
+
195
+ // BATCH CLUSTER PASS — partitioned by goal_id, not kind (every row here is
196
+ // already kind=bug, so a kind gate would be a no-op; see module header).
197
+ const { clusters, clusteredIds } = clusterRecords(tasks, {
198
+ textOf: (t) => `${t.title || ''} ${t.description || ''}`,
199
+ kindOf: (t) => t.goal_id || null,
200
+ timeOf: (t) => new Date(t.created_at),
201
+ });
202
+ if (clusters.length > 0) {
203
+ console.log(`\n🔗 ${clusters.length} likely-duplicate cluster${clusters.length === 1 ? '' : 's'} found (${clusteredIds.size} bug reports) — batch verdict available before the individual walk.`);
204
+ const state = { summary, mergedIds, dismissedIds };
205
+ for (const cluster of clusters) {
206
+ await handleCluster(rl, cluster, clusteredIds, state);
207
+ }
208
+ }
209
+ tasks = tasks.filter((t) => !clusteredIds.has(t.id));
210
+ if (tasks.length === 0) {
211
+ console.log('\nnothing left to walk individually — every open bug report was resolved in a batch cluster verdict.');
212
+ rl.close();
213
+ printSummary(summary, mergedIds, dismissedIds);
214
+ cliExit(0);
215
+ return;
216
+ }
217
+
218
+ console.log(`\n🐛 ${tasks.length} open bug report${tasks.length === 1 ? '' : 's'} to triage individually.`);
219
+
220
+ let walked = 0;
221
+ for (let i = 0; i < tasks.length && !quit; i++) {
222
+ const task = tasks[i];
223
+ printTask(task, i, tasks.length);
224
+
225
+ let handled = false;
226
+ while (!handled) {
227
+ const ans = (await prompt(rl, '[m]erge / [w]on\'t-fix / [s]kip / [q]uit > ')).trim().toLowerCase();
228
+
229
+ if (ans === 'q' || ans === 'quit') { quit = true; handled = true; break; }
230
+
231
+ if (ans === 's' || ans === 'skip' || ans === '') {
232
+ summary.skipped++;
233
+ handled = true; break;
234
+ }
235
+
236
+ if (ans === 'w' || ans === 'wontfix' || ans === "won't-fix" || ans === 'dismiss') {
237
+ const reason = (await prompt(rl, ' reason (not a bug / already fixed / cannot reproduce): ')).trim();
238
+ try {
239
+ await dismissTask(task.id, reason);
240
+ summary.dismissed++;
241
+ dismissedIds.push(task.id);
242
+ console.log(` ✖ dismissed #${task.id}`);
243
+ } catch (err) {
244
+ console.error(` ! ${err.message}`);
245
+ continue;
246
+ }
247
+ handled = true; break;
248
+ }
249
+
250
+ if (ans === 'm' || ans === 'merge' || ans.startsWith('m ') || ans.startsWith('m#')) {
251
+ const inline = ans.replace(/^m(erge)?(\s|#)*/, '').trim();
252
+ const target = inline || (await prompt(rl, ' merge into which task id? #')).trim().replace(/^#/, '');
253
+ const n = Number(target);
254
+ if (!Number.isFinite(n) || n <= 0) { console.log(' ? not a valid task id'); continue; }
255
+ try {
256
+ await mergeTaskInto(task.id, n, '');
257
+ summary.merged++;
258
+ mergedIds.push(task.id);
259
+ console.log(` ⇢ merged #${task.id} into #${n}`);
260
+ } catch (err) {
261
+ console.error(` ! ${err.message}`);
262
+ continue;
263
+ }
264
+ handled = true; break;
265
+ }
266
+
267
+ console.log(' ? unknown — type m, w, s, or q');
268
+ }
269
+ if (handled && !quit) walked++;
270
+ }
271
+
272
+ rl.close();
273
+ printSummary(summary, mergedIds, dismissedIds, tasks.length - walked);
274
+ cliExit(0);
275
+ }
276
+
277
+ function printSummary(summary, mergedIds, dismissedIds, unseen = 0) {
278
+ console.log('\n── bug triage summary ──');
279
+ console.log(` merged: ${summary.merged}${mergedIds.length ? ` (${mergedIds.map((i) => `#${i}`).join(', ')})` : ''}`);
280
+ console.log(` dismissed: ${summary.dismissed}${dismissedIds.length ? ` (${dismissedIds.map((i) => `#${i}`).join(', ')})` : ''}`);
281
+ console.log(` skipped: ${summary.skipped}`);
282
+ if (unseen > 0) console.log(` ${unseen} unseen (quit early) — will surface again next triage`);
283
+ }
284
+
285
+ if (require.main === module) {
286
+ main().catch((err) => {
287
+ console.error('fatal:', err);
288
+ cliExit(1);
289
+ });
290
+ }
291
+
292
+ module.exports = { listOpenBugTasks, mergeTaskInto, dismissTask, OPEN_STATUSES };
@@ -10,14 +10,26 @@
10
10
  // node scripts/gds/triage.js --development <path> (or env OTB_IDEA_DEVELOPMENT)
11
11
  //
12
12
  // --development <path> loads a "development cache" — a JSON blob the
13
- // /idea-triage skill writes after its parallel idea-development subagents run
14
- // (task 930). When present, each idea's developed block (summary, integration
15
- // surfaces, open questions, challenges, risks, rough effort, recommendation)
16
- // is printed above the verdict prompt, so a human at a terminal verdicts on a
17
- // developed idea instead of the one-liner — the same context the skill's
18
- // Claude-driven walk holds in-session. This script never *generates* that
19
- // analysis (LLM fan-out is the skill's job); it only surfaces it. Backward-
20
- // compatible: with no flag/env and no file, the walk is exactly as before.
13
+ // /idea-triage skill writes after a (now OPT-IN, task 1001465) parallel
14
+ // idea-development subagent fan-out runs (task 930). When present, each
15
+ // idea's developed block (summary, integration surfaces, open questions,
16
+ // challenges, risks, rough effort, recommendation) is printed above the
17
+ // verdict prompt, so a human at a terminal verdicts on a developed idea
18
+ // instead of the one-liner — the same context the skill's Claude-driven walk
19
+ // holds in-session. This script never *generates* that analysis (LLM fan-out
20
+ // is the skill's job, and only for the FEW ideas it targets); it only
21
+ // surfaces it. Backward-compatible: with no flag/env and no file, the walk is
22
+ // exactly as before.
23
+ //
24
+ // BATCH CLUSTER PASS (task 1001465). Before the one-at-a-time walk, near-
25
+ // duplicate open ideas (same `kind`, high title/body word overlap — see
26
+ // clusterIdeas below) are grouped and offered ONE verdict for the whole
27
+ // group: keep a canonical idea and merge the rest into it, discard the whole
28
+ // group, walk them individually anyway, or skip the cluster (leave all open).
29
+ // This is the actual credit-cost fix idea 377 asked for — the unconditional
30
+ // 12-subagent development fan-out this replaced was expensive PRECISION on
31
+ // each idea; it never reduced the NUMBER of verdicts a triager had to make on
32
+ // a backlog of near-identical asks, which batching does directly.
21
33
  //
22
34
  // Prompts per idea:
23
35
  // [p]romote → create a backlog task, mark idea 'promoted',
@@ -30,6 +42,14 @@
30
42
  // [s]kip → no patch; idea surfaces again next triage
31
43
  // [q]uit → exit early, print partial summary
32
44
  //
45
+ // Prompts per duplicate cluster (before the per-idea walk):
46
+ // [k]eep #N → merge every other member into #N (default: the
47
+ // oldest); #N itself is NOT verdicted here — it still
48
+ // gets the normal per-idea walk below
49
+ // [d]iscard → mark every member in the cluster 'discarded'
50
+ // [i]ndividually → ignore the cluster grouping; walk each member normally
51
+ // [s]kip → no patch; the whole cluster surfaces again next triage
52
+ //
33
53
  // Exit codes:
34
54
  // 0 walked cleanly (incl. empty inbox)
35
55
  // 1 api / network error
@@ -266,6 +286,180 @@ function printIdea(idea, idx, total) {
266
286
  console.log(sep);
267
287
  }
268
288
 
289
+ // ── duplicate clustering (task 1001465) ─────────────────────────────────────
290
+ //
291
+ // Words that carry no topical signal — stripped before comparing two ideas so
292
+ // two totally unrelated asks that both happen to say "should" and "add" don't
293
+ // register as similar.
294
+ const STOPWORDS = new Set([
295
+ 'a', 'an', 'and', 'the', 'of', 'to', 'in', 'on', 'for', 'is', 'are', 'with',
296
+ 'this', 'that', 'it', 'as', 'be', 'at', 'by', 'from', 'or', 'not', 'into',
297
+ 'out', 'up', 'can', 'we', 'should', 'also', 'add', 'support', 'would',
298
+ 'have', 'has', 'when', 'so', 'if', 'but', 'than', 'then', 'over',
299
+ ]);
300
+
301
+ function significantWords(text) {
302
+ const words = String(text || '').toLowerCase().match(/[a-z0-9]+/g) || [];
303
+ return new Set(words.filter((w) => w.length > 2 && !STOPWORDS.has(w)));
304
+ }
305
+
306
+ // Jaccard similarity over two word sets: |intersection| / |union|. 0 when
307
+ // either set is empty (an idea with no significant words can't be "similar"
308
+ // to anything — that would be a false match, not a true one).
309
+ function jaccard(a, b) {
310
+ if (a.size === 0 || b.size === 0) return 0;
311
+ let shared = 0;
312
+ for (const w of a) if (b.has(w)) shared++;
313
+ return shared / (a.size + b.size - shared);
314
+ }
315
+
316
+ // Group likely-duplicate records so a walk can offer one batch verdict per
317
+ // group instead of re-litigating each member individually. Heuristic, not
318
+ // semantic: two records cluster only when `kindOf` agrees (a bug and a
319
+ // feature asking the same underlying question are not the same triage
320
+ // decision) AND `textOf`'s word sets clear both a minimum SHARED-word floor
321
+ // and a Jaccard similarity threshold — the floor stops two long, unrelated
322
+ // records from clustering on a borderline ratio built from a single
323
+ // coincidental word. Pure (no I/O, no randomness, nothing mutated) — directly
324
+ // unit-testable; the interactive merge/discard/skip choice lives in each
325
+ // caller's own walk. Returns { clusters, clusteredIds }: clusters is an array
326
+ // of record-arrays (each length >= 2, sorted oldest-`timeOf`-first — the
327
+ // natural "keep this one" default), clusteredIds is a Set of every id (by
328
+ // `.id`) inside some cluster.
329
+ //
330
+ // Extracted from the idea-only `clusterIdeas` below (task 1001478) so
331
+ // /bug-triage's kind=bug tasks can reuse the same union-find/Jaccard core
332
+ // against `description`/`created_at` instead of `body_md`/`captured_at` —
333
+ // duplicating the algorithm would be the one thing worth avoiding twice.
334
+ function clusterRecords(records, { textOf, kindOf, timeOf, threshold = 0.34, minShared = 2 }) {
335
+ const words = records.map((r) => significantWords(textOf(r)));
336
+ const kinds = records.map((r) => kindOf(r));
337
+ const n = records.length;
338
+ const parent = Array.from({ length: n }, (_, i) => i);
339
+ function find(x) { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; }
340
+ function union(a, b) { const ra = find(a); const rb = find(b); if (ra !== rb) parent[ra] = rb; }
341
+
342
+ for (let i = 0; i < n; i++) {
343
+ for (let j = i + 1; j < n; j++) {
344
+ if (kinds[i] !== kinds[j]) continue;
345
+ let shared = 0;
346
+ for (const w of words[i]) if (words[j].has(w)) shared++;
347
+ if (shared < minShared) continue;
348
+ if (jaccard(words[i], words[j]) >= threshold) union(i, j);
349
+ }
350
+ }
351
+
352
+ const groups = new Map();
353
+ for (let i = 0; i < n; i++) {
354
+ const root = find(i);
355
+ if (!groups.has(root)) groups.set(root, []);
356
+ groups.get(root).push(records[i]);
357
+ }
358
+ const clusters = [];
359
+ const clusteredIds = new Set();
360
+ for (const members of groups.values()) {
361
+ if (members.length < 2) continue;
362
+ members.sort((a, b) => timeOf(a) - timeOf(b));
363
+ clusters.push(members);
364
+ for (const m of members) clusteredIds.add(m.id);
365
+ }
366
+ return { clusters, clusteredIds };
367
+ }
368
+
369
+ // The idea-shaped call: Full Ideas are never eligible (they get their own
370
+ // shape recommendation, SKILL.md Step 2.5, and batching one away would skip
371
+ // that judgment), clustered on title+body_md / kind / captured_at. Same
372
+ // signature and behavior as before the clusterRecords extraction.
373
+ function clusterIdeas(ideas, { threshold = 0.34, minShared = 2 } = {}) {
374
+ const eligible = ideas.filter((i) => (i.grade || 'quick') !== 'full');
375
+ return clusterRecords(eligible, {
376
+ textOf: (i) => `${i.title || ''} ${i.body_md || ''}`,
377
+ kindOf: (i) => i.kind || null,
378
+ timeOf: (i) => new Date(i.captured_at),
379
+ threshold, minShared,
380
+ });
381
+ }
382
+
383
+ function printCluster(cluster) {
384
+ const sep = '┄'.repeat(72);
385
+ console.log(`\n${sep}`);
386
+ console.log(`Cluster of ${cluster.length} likely duplicates (oldest first — the suggested keeper):`);
387
+ for (const m of cluster) console.log(` #${m.id} [${m.kind || '—'}] ${trim(m.title, 80)}`);
388
+ console.log(sep);
389
+ }
390
+
391
+ // The interactive batch pass over one cluster. Mutates `state` (summary
392
+ // counts + the promoted/discarded/merged id lists main() already tracks) and
393
+ // returns nothing; `clusteredIds` is mutated in place so a choice of
394
+ // "individually" or "keep #N" puts the affected idea(s) back into the normal
395
+ // per-idea walk that runs after every cluster has been offered.
396
+ async function handleCluster(rl, cluster, clusteredIds, state) {
397
+ printCluster(cluster);
398
+ let handled = false;
399
+ while (!handled) {
400
+ const ans = (await prompt(rl, ` [k]eep #${cluster[0].id} & merge the rest / [d]iscard all / [i]ndividually / [s]kip cluster > `)).trim().toLowerCase();
401
+
402
+ if (ans === 's' || ans === 'skip' || ans === '') { handled = true; break; }
403
+
404
+ if (ans === 'i' || ans === 'individually') {
405
+ for (const m of cluster) clusteredIds.delete(m.id);
406
+ console.log(' → walking this cluster\'s ideas individually');
407
+ handled = true; break;
408
+ }
409
+
410
+ if (ans === 'd' || ans === 'discard') {
411
+ const reason = (await prompt(rl, ' reason (optional, enter to skip): ')).trim();
412
+ for (const m of cluster) {
413
+ try {
414
+ await patchIdea(m.id, {
415
+ status: 'discarded',
416
+ reviewed_at: new Date().toISOString(),
417
+ triage_note: reason ? trim(reason, 60) : 'discarded as part of a batch-verdicted duplicate cluster',
418
+ });
419
+ state.summary.discarded++;
420
+ state.discardedIds.push(m.id);
421
+ } catch (err) {
422
+ console.error(` ! #${m.id}: ${err.message}`);
423
+ }
424
+ }
425
+ console.log(` ✖ discarded ${cluster.length} duplicate ideas`);
426
+ handled = true; break;
427
+ }
428
+
429
+ if (ans === 'k' || ans === 'keep' || ans.startsWith('k ') || ans.startsWith('k#')) {
430
+ let canonicalId = cluster[0].id;
431
+ const inline = ans.replace(/^k(eep)?(\s|#)*/, '').trim();
432
+ if (inline) {
433
+ const n = Number(inline.replace(/^#/, ''));
434
+ if (Number.isFinite(n) && n > 0 && cluster.some((m) => String(m.id) === String(n))) canonicalId = n;
435
+ else if (Number.isFinite(n) && n > 0) console.log(` ! #${n} isn't in this cluster; keeping #${canonicalId}`);
436
+ }
437
+ for (const m of cluster) {
438
+ if (String(m.id) === String(canonicalId)) continue;
439
+ try {
440
+ await patchIdea(m.id, {
441
+ status: 'merged',
442
+ merged_into_idea_id: Number(canonicalId),
443
+ reviewed_at: new Date().toISOString(),
444
+ triage_note: `duplicate cluster batch-merged into #${canonicalId}`,
445
+ });
446
+ state.summary.merged++;
447
+ state.mergedIds.push(m.id);
448
+ } catch (err) {
449
+ console.error(` ! #${m.id}: ${err.message}`);
450
+ }
451
+ }
452
+ // The keeper is untouched by the batch verdict — it still needs its own
453
+ // promote/discard/merge decision, so it goes back into the normal walk.
454
+ clusteredIds.delete(canonicalId);
455
+ console.log(` ⇢ merged ${cluster.length - 1} duplicate(s) into #${canonicalId} — #${canonicalId} still needs its own verdict below`);
456
+ handled = true; break;
457
+ }
458
+
459
+ console.log(' ? unknown — type k, d, i, or s');
460
+ }
461
+ }
462
+
269
463
  async function main() {
270
464
  await requireSession();
271
465
 
@@ -290,6 +484,32 @@ async function main() {
290
484
  process.exit(0);
291
485
  }
292
486
 
487
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
488
+ const summary = { promoted: 0, discarded: 0, merged: 0, skipped: 0 };
489
+ const promotedIds = [];
490
+ const discardedIds = [];
491
+ const mergedIds = [];
492
+ let quit = false;
493
+
494
+ // BATCH CLUSTER PASS (task 1001465) — before the per-idea walk, so a
495
+ // near-duplicate group is offered ONE verdict instead of N. Runs on the RAW
496
+ // open-idea list (not yet ordered/filtered) since it operates on identity
497
+ // (kind + word overlap), not on the grade-based walk order below.
498
+ const { clusters, clusteredIds } = clusterIdeas(ideas);
499
+ if (clusters.length > 0) {
500
+ console.log(`\n🔗 ${clusters.length} likely-duplicate cluster${clusters.length === 1 ? '' : 's'} found (${clusteredIds.size} ideas) — batch verdict available before the individual walk.`);
501
+ const state = { summary, discardedIds, mergedIds };
502
+ for (const cluster of clusters) {
503
+ await handleCluster(rl, cluster, clusteredIds, state);
504
+ }
505
+ }
506
+ ideas = ideas.filter((i) => !clusteredIds.has(i.id));
507
+ if (ideas.length === 0) {
508
+ console.log('\nnothing left to walk individually — every open idea was resolved in a batch cluster verdict.');
509
+ rl.close();
510
+ process.exit(0);
511
+ }
512
+
293
513
  // WALK ORDER (task 1002924 / R148): Full Ideas first, best-formed first, then
294
514
  // quick ideas and sparks newest-first. Two runs rather than one score-sorted
295
515
  // list, for the reason render-ideas.js records: a single list sorted by score
@@ -298,14 +518,7 @@ async function main() {
298
518
  const { partition } = require('./render-ideas');
299
519
  const grouped = partition(ideas);
300
520
  ideas = grouped.openFull.concat(grouped.openRest);
301
- console.log(`📥 ${ideas.length} open idea${ideas.length === 1 ? '' : 's'} to triage — ${grouped.openFull.length} Full Idea${grouped.openFull.length === 1 ? '' : 's'} first (best-formed first), then ${grouped.openRest.length} quick/spark (newest first).`);
302
-
303
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
304
- const summary = { promoted: 0, discarded: 0, merged: 0, skipped: 0 };
305
- const promotedIds = [];
306
- const discardedIds = [];
307
- const mergedIds = [];
308
- let quit = false;
521
+ console.log(`📥 ${ideas.length} open idea${ideas.length === 1 ? '' : 's'} to triage individually — ${grouped.openFull.length} Full Idea${grouped.openFull.length === 1 ? '' : 's'} first (best-formed first), then ${grouped.openRest.length} quick/spark (newest first).`);
309
522
 
310
523
  try {
311
524
  for (let i = 0; i < ideas.length && !quit; i++) {
@@ -475,4 +688,8 @@ module.exports = {
475
688
  parseDevelopment,
476
689
  loadDevelopment,
477
690
  formatDevelopment,
691
+ clusterIdeas,
692
+ clusterRecords,
693
+ significantWords,
694
+ jaccard,
478
695
  };
package/src/module-api.js CHANGED
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
49
49
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
50
50
  // the entry to that file. Look for a version's history there, not here.
51
51
  // ---------------------------------------------------------------------------
52
- const CORE_VERSION = '1.19.549'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
52
+ const CORE_VERSION = '1.19.551'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
53
53
 
54
54
  // A namespaced logger so a module's log lines are attributable + consistent.
55
55
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -151,6 +151,9 @@ await test('restore: an abandoned task flips to backlog, COMMITs, reports previo
151
151
  });
152
152
 
153
153
  await test("restore: clears only an 'Abandoned: …' value_summary, leaving any other note", async () => {
154
+ // mergeTask (task 1001478) preserves value_summary rather than synthesizing
155
+ // a "Merged into #N" default, so there is no merge-specific stamp for
156
+ // restore to clean up here beyond the abandon one.
154
157
  const pool = fakePool({ status: 'abandoned', updatedStatus: 'backlog' });
155
158
  await restoreTask({ taskId: TASK }, { pool });
156
159
  const upd = find(pool._client.queries, /^UPDATE tasks/);
@@ -161,6 +164,20 @@ await test("restore: clears only an 'Abandoned: …' value_summary, leaving any
161
164
  assert.equal(upd.params.length, 1, 'only the task id is bound');
162
165
  });
163
166
 
167
+ await test('restore: clears merged_into_task_id + triage_note (task 1001478 grade finding)', async () => {
168
+ // Without this, restoring a MERGED task (status flips 'abandoned'→'backlog'
169
+ // while merged_into_task_id stays set) violates core_232's CHECK
170
+ // (merged_into_task_id IS NULL OR status='abandoned') — the exact 23514
171
+ // mergeTask's own header prescribes this route to fix ("retarget by
172
+ // restoring first").
173
+ const pool = fakePool({ status: 'abandoned', updatedStatus: 'backlog' });
174
+ await restoreTask({ taskId: TASK }, { pool });
175
+ const upd = find(pool._client.queries, /^UPDATE tasks/);
176
+ const sql = String(upd.sql).replace(/\s+/g, ' ');
177
+ assert.match(sql, /merged_into_task_id = NULL/, 'must clear the merge target so the CHECK stays satisfied');
178
+ assert.match(sql, /triage_note = NULL/, 'must clear the triage note left by the merge it undoes');
179
+ });
180
+
164
181
  await test('restore: a task that is not abandoned is refused and rolls back', async () => {
165
182
  for (const status of ['ready', 'backlog', 'shipped']) {
166
183
  const pool = fakePool({ status });