@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.
- package/.bongos-core.json +59 -29
- package/.claude/skills/bug-triage/SKILL.md +191 -0
- package/.claude/skills/idea-triage/SKILL.md +28 -14
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +77 -2
- package/docs/api-reference.md +3 -2
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_232_task_merge_provenance.sql +64 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/government/catalog.js +1 -0
- package/modules/government/migrations/government_011_task_merge_metic.sql +38 -0
- package/modules/lifecycle/db-ship.js +147 -0
- package/modules/lifecycle/db.js +2 -0
- package/modules/lifecycle/routes/tasks.js +42 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/bug-triage.js +292 -0
- package/scripts/gds/triage.js +233 -16
- package/src/module-api.js +1 -1
- package/tests/abandon_task.mjs +17 -0
- package/tests/bug_triage.mjs +205 -0
- package/tests/task_merge.mjs +198 -0
- package/tests/triage_development.mjs +105 -0
|
@@ -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 };
|
package/scripts/gds/triage.js
CHANGED
|
@@ -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
|
|
14
|
-
// (task 930). When present, each
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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.
|
|
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');
|
package/tests/abandon_task.mjs
CHANGED
|
@@ -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 });
|