@bongos/core 1.19.549 → 1.19.550
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 +12 -12
- package/.claude/skills/idea-triage/SKILL.md +28 -14
- package/docs/module-api-changelog.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/triage.js +214 -16
- package/src/module-api.js +1 -1
- package/tests/triage_development.mjs +105 -0
package/.bongos-core.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.550",
|
|
6
|
+
"core_contract": "1.19.550",
|
|
7
|
+
"source_commit": "c1759a04d89ed06abdc8dfe92880259633704aeb",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-04T18:
|
|
9
|
+
"built_at": "2026-09-04T18:42:59.133Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 441,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
19
|
"file_count": 2478,
|
|
20
|
-
"tree_sha256": "
|
|
20
|
+
"tree_sha256": "d36204b8544ce3a80ec2ac8a90bd182e3157116d8944fddb8b72468558c0244b",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -177,7 +177,7 @@
|
|
|
177
177
|
{
|
|
178
178
|
"path": ".claude/skills/idea-triage/SKILL.md",
|
|
179
179
|
"mode": "0000644",
|
|
180
|
-
"sha256": "
|
|
180
|
+
"sha256": "2519755269ff2d55e3c060569f7d16c440428ba005d206a3bf12fe2275830285"
|
|
181
181
|
},
|
|
182
182
|
{
|
|
183
183
|
"path": ".claude/skills/ideate/SKILL.md",
|
|
@@ -2647,7 +2647,7 @@
|
|
|
2647
2647
|
{
|
|
2648
2648
|
"path": "docs/module-api-changelog.md",
|
|
2649
2649
|
"mode": "0000644",
|
|
2650
|
-
"sha256": "
|
|
2650
|
+
"sha256": "d3af686f65da15d8a3c4e8e291b7c4e2a9c025e2cc78305d509165978412329f"
|
|
2651
2651
|
},
|
|
2652
2652
|
{
|
|
2653
2653
|
"path": "docs/modules-contract.md",
|
|
@@ -7452,12 +7452,12 @@
|
|
|
7452
7452
|
{
|
|
7453
7453
|
"path": "package-lock.json",
|
|
7454
7454
|
"mode": "0000644",
|
|
7455
|
-
"sha256": "
|
|
7455
|
+
"sha256": "a0768e258949174041e701e168aeab239b06a4e1ccc58298a0438c241a34cc58"
|
|
7456
7456
|
},
|
|
7457
7457
|
{
|
|
7458
7458
|
"path": "package.json",
|
|
7459
7459
|
"mode": "0000644",
|
|
7460
|
-
"sha256": "
|
|
7460
|
+
"sha256": "24085dcaa56b6f1085b7f75682d68d4f124c7ff51a8eef763a9e2296dfee9e10"
|
|
7461
7461
|
},
|
|
7462
7462
|
{
|
|
7463
7463
|
"path": "public-docs/index.html",
|
|
@@ -8702,7 +8702,7 @@
|
|
|
8702
8702
|
{
|
|
8703
8703
|
"path": "scripts/gds/triage.js",
|
|
8704
8704
|
"mode": "0000644",
|
|
8705
|
-
"sha256": "
|
|
8705
|
+
"sha256": "39cbb3b02f4dd8fab80ad53d39b95c3d94666c65b3eed5fbb38995551d170617"
|
|
8706
8706
|
},
|
|
8707
8707
|
{
|
|
8708
8708
|
"path": "scripts/gds/turn-end-sound.js",
|
|
@@ -9147,7 +9147,7 @@
|
|
|
9147
9147
|
{
|
|
9148
9148
|
"path": "src/module-api.js",
|
|
9149
9149
|
"mode": "0000644",
|
|
9150
|
-
"sha256": "
|
|
9150
|
+
"sha256": "9ac49d0373037ef2fa93837e2483adb5bda5c40514a2c8458489092c03f4d9fc"
|
|
9151
9151
|
},
|
|
9152
9152
|
{
|
|
9153
9153
|
"path": "src/module-loader/catalog.js",
|
|
@@ -12302,7 +12302,7 @@
|
|
|
12302
12302
|
{
|
|
12303
12303
|
"path": "tests/triage_development.mjs",
|
|
12304
12304
|
"mode": "0000644",
|
|
12305
|
-
"sha256": "
|
|
12305
|
+
"sha256": "56ff1fab014462291349ac200aab9d12e11a73c028aebeb564866b7a1b309fbd"
|
|
12306
12306
|
},
|
|
12307
12307
|
{
|
|
12308
12308
|
"path": "tests/ui_design_kit.mjs",
|
|
@@ -53,9 +53,10 @@ capture-time suggester (R08/R09/R11 surfaces) is where the fix belongs.
|
|
|
53
53
|
## What this skill does
|
|
54
54
|
|
|
55
55
|
1. Lists open ideas via `GET /api/gds/inbox`.
|
|
56
|
-
2. **
|
|
57
|
-
3.
|
|
58
|
-
4.
|
|
56
|
+
2. **Batches near-duplicate ideas** into one verdict per cluster (`node scripts/gds/triage.js`'s `clusterIdeas`), so a backlog of similar asks doesn't cost one decision each (task 1001465).
|
|
57
|
+
3. **Develops what's left with a LEAN inline pass by default** — reasoning from context already in the session, no subagent spend — and escalates to a FEW targeted subagents only where repo grounding would actually flip the verdict (task 1001465; the old unconditional 12-subagent fan-out is now opt-in, task 930's original mechanism).
|
|
58
|
+
4. For each, prompts the user with: **promote** (create a task and link it), **discard** (closed, no action), or **merge** (this duplicates idea N, mark merged into N).
|
|
59
|
+
5. Updates each idea via `PATCH /api/gds/inbox/:id` with the verdict.
|
|
59
60
|
|
|
60
61
|
## How to use
|
|
61
62
|
|
|
@@ -91,33 +92,46 @@ this order and prints the grade on each card, and `render-ideas.js` renders the
|
|
|
91
92
|
same two sections — so the interactive walk, the generated file and this skill all
|
|
92
93
|
show the same idea the same way.
|
|
93
94
|
|
|
94
|
-
**Step 1.
|
|
95
|
+
**Step 1.4 — a cheap staleness pass, before anything else costs a token.** Quick-skim each open idea's title/body against the current codebase/CLAUDE.md to catch ones already implemented, already superseded, or otherwise obviously stale. A recent triage found 47 of 170 open ideas already stale — filtering those first means neither the batching pass nor the development pass below is spent on an idea that just needs a discard.
|
|
95
96
|
|
|
96
|
-
**
|
|
97
|
+
**Step 1.5a — batch near-duplicate ideas BEFORE developing anything (task 1001465).**
|
|
97
98
|
|
|
98
|
-
|
|
99
|
+
Run `clusterIdeas` (`scripts/gds/triage.js` — pure, no I/O): it groups open quick/spark ideas that share a `kind` and a high title/body word overlap. For each cluster it returns, offer the user ONE decision covering the whole group instead of walking each member — this is the actual fix for "triage costs a ton of credits" (idea 377): a backlog of near-identical asks used to cost one full development + verdict PER idea, and now costs one:
|
|
99
100
|
|
|
100
|
-
|
|
101
|
-
- **Fan-out cap:** develop up to **12** ideas per session (oldest-first — `captured_at` ascending). If there are more than 12 open, note "developed the 12 oldest; the rest are walked undeveloped" so nothing is silently dropped.
|
|
102
|
-
- **One idea per subagent.** Give each subagent the idea's `id`, `title`, `body_md`, `kind`, and `captured_by`, and this instruction:
|
|
101
|
+
> Cluster of 3 likely duplicates: #101 "Add dark mode toggle", #145 "Dark mode support", #203 "Support a dark theme". **Keep #101 and merge the rest, discard all, walk them individually, or skip?**
|
|
103
102
|
|
|
104
|
-
|
|
103
|
+
- **Keep #N** → merge every other member into #N (`merged_into_idea_id`), leaving #N itself open — it still gets the normal Step 2 walk below, since "which of these duplicates is real" and "should the real one become a task" are different decisions.
|
|
104
|
+
- **Discard all** → every member closed, one shared `triage_note`.
|
|
105
|
+
- **Individually** → ignore the grouping for this cluster; each member returns to the normal walk.
|
|
106
|
+
- **Skip** → leave the whole cluster open; it clusters again next triage.
|
|
105
107
|
|
|
106
|
-
|
|
108
|
+
`node scripts/gds/triage.js` already runs this pass automatically before its interactive walk — if a human is running the deterministic walker instead of you, they get it for free with no flag.
|
|
109
|
+
|
|
110
|
+
**Step 1.5b — develop what's LEFT with a lean inline pass by default (task 1001465).**
|
|
111
|
+
|
|
112
|
+
**The subagent fan-out below is now OPT-IN, not the default.** The prior design ran a 12-Opus-subagent fan-out unconditionally on every session — the main cost driver idea 377 was filed about. Default instead to a **lean inline pass**: for each remaining open idea (after batching), reason about it yourself using context you already have — CLAUDE.md, `/recall`, a quick grep if genuinely needed — and write a short inline note (one or two lines: what it touches, the one open question that matters) directly in the session. No `Agent` tool call, no JSON cache file, no per-idea cost.
|
|
113
|
+
|
|
114
|
+
**Escalate to a targeted subagent only when the inline pass can't tell whether repo grounding would flip the verdict** — a technical/GDS idea where "is this already implemented?", "does this surface still exist?", or "would this conflict with X" needs an actual code read to answer, not a guess. Cap this at a **FEW** ideas per session (3, not 12) — if more than 3 are genuinely ambiguous, develop the 3 oldest and note the rest are walked on the inline pass alone.
|
|
115
|
+
|
|
116
|
+
- **Model:** `opus` for a targeted subagent — this is still research/design analysis against the live repo (CLAUDE.md §2's routing table), never downgrade to Haiku just because there are fewer of them now.
|
|
117
|
+
- **One idea per subagent**, same instruction as before: give it the idea's `id`, `title`, `body_md`, `kind`, `captured_by`, and ask for the same JSON shape (`summary`, `integration_surfaces`, `open_questions`, `challenges`, `risks`, `rough_effort`, `recommendation`) — only the FAN-OUT WIDTH changed, not the shape a developed idea returns.
|
|
118
|
+
- **Collect results into the same development cache**, written to `${TMPDIR:-/tmp}/otb-idea-development.json` (Windows: `%TEMP%\otb-idea-development.json`):
|
|
107
119
|
|
|
108
120
|
```json
|
|
109
121
|
{ "generated_at": "<ISO>", "ideas": { "228": { "summary": "...", "integration_surfaces": ["..."], "open_questions": ["..."], "challenges": ["..."], "risks": ["..."], "rough_effort": "M", "recommendation": "needs-info" } } }
|
|
110
122
|
```
|
|
111
123
|
|
|
112
|
-
|
|
124
|
+
A subagent that fails or returns unparseable JSON is simply omitted — that idea is walked on the inline pass alone, not blocked.
|
|
125
|
+
|
|
126
|
+
**The full 12-idea fan-out is still available, explicitly, when the queue genuinely needs it** — a big backlog day, or the user asks for "the deep pass" / "develop all of these with subagents". When asked for it: spawn one Opus subagent per open idea (not just the ambiguous few), up to the same 12-idea cap (oldest-first), same instruction and cache shape as above. This is the OLD default, now a deliberate choice rather than something that runs unasked every time.
|
|
113
127
|
|
|
114
|
-
If a human will run the interactive walker instead of the Claude-driven walk below, hand it the cache:
|
|
128
|
+
If a human will run the interactive walker instead of the Claude-driven walk below, hand it the cache (works the same whether it came from the lean pass's few subagents or the full deep pass):
|
|
115
129
|
|
|
116
130
|
```
|
|
117
131
|
node scripts/gds/triage.js --development "${TMPDIR:-/tmp}/otb-idea-development.json"
|
|
118
132
|
```
|
|
119
133
|
|
|
120
|
-
`triage.js` prints each idea's developed block above its verdict prompt. It never *generates* the analysis (that's this skill's
|
|
134
|
+
`triage.js` prints each idea's developed block above its verdict prompt, and runs its own batch-cluster pass (Step 1.5a) automatically first. It never *generates* the analysis (that's this skill's job); it only displays what the cache holds, and runs exactly as before when no cache is present.
|
|
121
135
|
|
|
122
136
|
> **Not in this cut (follow-up leg).** The next step is to let the Archon push the developed `open_questions` *back to the submitter* — a round-trip in the Discord `#ideas` thread / DM before promotion — so the submitter says how they'd want it integrated. That needs the Discord bot interaction + idea↔thread linking (idea `#216` / [ADR 0033](../../../docs/adr/<redacted>.md)) and is tracked separately. For now the open questions are answered by the Archon in-session.
|
|
123
137
|
|
|
@@ -1547,5 +1547,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1547
1547
|
landed since 1.19.547 with no explicit bump. run 33905411089. (task 1002620)
|
|
1548
1548
|
1.19.549 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1549
1549
|
landed since 1.19.548 with no explicit bump. run 33906859934. (task 1002620)
|
|
1550
|
+
1.19.550 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1551
|
+
landed since 1.19.549 with no explicit bump. run 33907331050. (task 1002620)
|
|
1550
1552
|
---------------------------------------------------------------------------
|
|
1551
1553
|
```
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.550",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.550",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.550",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
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,162 @@ 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 OPEN ideas so the walk can offer one batch verdict
|
|
317
|
+
// per group instead of re-litigating each member individually. Heuristic, not
|
|
318
|
+
// semantic: two ideas cluster only when they share the same `kind` (a bug and
|
|
319
|
+
// a feature asking the same underlying question are not the same triage
|
|
320
|
+
// decision) AND their title+body word sets clear both a minimum SHARED-word
|
|
321
|
+
// floor and a Jaccard similarity threshold — the floor stops two long,
|
|
322
|
+
// unrelated ideas from clustering on a borderline ratio built from a single
|
|
323
|
+
// coincidental word. Full Ideas are never eligible: they get their own shape
|
|
324
|
+
// recommendation (SKILL.md Step 2.5), and batching one away would skip that
|
|
325
|
+
// judgment. Pure (no I/O, no randomness, no idea mutated) — directly
|
|
326
|
+
// unit-testable; the interactive merge/discard/skip choice lives in main().
|
|
327
|
+
// Returns { clusters, clusteredIds }: clusters is an array of idea-arrays
|
|
328
|
+
// (each length >= 2, oldest member first — the natural "keep this one"
|
|
329
|
+
// default), clusteredIds is a Set of every idea id inside some cluster.
|
|
330
|
+
function clusterIdeas(ideas, { threshold = 0.34, minShared = 2 } = {}) {
|
|
331
|
+
const eligible = ideas.filter((i) => (i.grade || 'quick') !== 'full');
|
|
332
|
+
const words = eligible.map((i) => significantWords(`${i.title || ''} ${i.body_md || ''}`));
|
|
333
|
+
const n = eligible.length;
|
|
334
|
+
const parent = Array.from({ length: n }, (_, i) => i);
|
|
335
|
+
function find(x) { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; }
|
|
336
|
+
function union(a, b) { const ra = find(a); const rb = find(b); if (ra !== rb) parent[ra] = rb; }
|
|
337
|
+
|
|
338
|
+
for (let i = 0; i < n; i++) {
|
|
339
|
+
for (let j = i + 1; j < n; j++) {
|
|
340
|
+
if ((eligible[i].kind || null) !== (eligible[j].kind || null)) continue;
|
|
341
|
+
let shared = 0;
|
|
342
|
+
for (const w of words[i]) if (words[j].has(w)) shared++;
|
|
343
|
+
if (shared < minShared) continue;
|
|
344
|
+
if (jaccard(words[i], words[j]) >= threshold) union(i, j);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const groups = new Map();
|
|
349
|
+
for (let i = 0; i < n; i++) {
|
|
350
|
+
const root = find(i);
|
|
351
|
+
if (!groups.has(root)) groups.set(root, []);
|
|
352
|
+
groups.get(root).push(eligible[i]);
|
|
353
|
+
}
|
|
354
|
+
const clusters = [];
|
|
355
|
+
const clusteredIds = new Set();
|
|
356
|
+
for (const members of groups.values()) {
|
|
357
|
+
if (members.length < 2) continue;
|
|
358
|
+
members.sort((a, b) => new Date(a.captured_at) - new Date(b.captured_at));
|
|
359
|
+
clusters.push(members);
|
|
360
|
+
for (const m of members) clusteredIds.add(m.id);
|
|
361
|
+
}
|
|
362
|
+
return { clusters, clusteredIds };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function printCluster(cluster) {
|
|
366
|
+
const sep = '┄'.repeat(72);
|
|
367
|
+
console.log(`\n${sep}`);
|
|
368
|
+
console.log(`Cluster of ${cluster.length} likely duplicates (oldest first — the suggested keeper):`);
|
|
369
|
+
for (const m of cluster) console.log(` #${m.id} [${m.kind || '—'}] ${trim(m.title, 80)}`);
|
|
370
|
+
console.log(sep);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// The interactive batch pass over one cluster. Mutates `state` (summary
|
|
374
|
+
// counts + the promoted/discarded/merged id lists main() already tracks) and
|
|
375
|
+
// returns nothing; `clusteredIds` is mutated in place so a choice of
|
|
376
|
+
// "individually" or "keep #N" puts the affected idea(s) back into the normal
|
|
377
|
+
// per-idea walk that runs after every cluster has been offered.
|
|
378
|
+
async function handleCluster(rl, cluster, clusteredIds, state) {
|
|
379
|
+
printCluster(cluster);
|
|
380
|
+
let handled = false;
|
|
381
|
+
while (!handled) {
|
|
382
|
+
const ans = (await prompt(rl, ` [k]eep #${cluster[0].id} & merge the rest / [d]iscard all / [i]ndividually / [s]kip cluster > `)).trim().toLowerCase();
|
|
383
|
+
|
|
384
|
+
if (ans === 's' || ans === 'skip' || ans === '') { handled = true; break; }
|
|
385
|
+
|
|
386
|
+
if (ans === 'i' || ans === 'individually') {
|
|
387
|
+
for (const m of cluster) clusteredIds.delete(m.id);
|
|
388
|
+
console.log(' → walking this cluster\'s ideas individually');
|
|
389
|
+
handled = true; break;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (ans === 'd' || ans === 'discard') {
|
|
393
|
+
const reason = (await prompt(rl, ' reason (optional, enter to skip): ')).trim();
|
|
394
|
+
for (const m of cluster) {
|
|
395
|
+
try {
|
|
396
|
+
await patchIdea(m.id, {
|
|
397
|
+
status: 'discarded',
|
|
398
|
+
reviewed_at: new Date().toISOString(),
|
|
399
|
+
triage_note: reason ? trim(reason, 60) : 'discarded as part of a batch-verdicted duplicate cluster',
|
|
400
|
+
});
|
|
401
|
+
state.summary.discarded++;
|
|
402
|
+
state.discardedIds.push(m.id);
|
|
403
|
+
} catch (err) {
|
|
404
|
+
console.error(` ! #${m.id}: ${err.message}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
console.log(` ✖ discarded ${cluster.length} duplicate ideas`);
|
|
408
|
+
handled = true; break;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (ans === 'k' || ans === 'keep' || ans.startsWith('k ') || ans.startsWith('k#')) {
|
|
412
|
+
let canonicalId = cluster[0].id;
|
|
413
|
+
const inline = ans.replace(/^k(eep)?(\s|#)*/, '').trim();
|
|
414
|
+
if (inline) {
|
|
415
|
+
const n = Number(inline.replace(/^#/, ''));
|
|
416
|
+
if (Number.isFinite(n) && n > 0 && cluster.some((m) => String(m.id) === String(n))) canonicalId = n;
|
|
417
|
+
else if (Number.isFinite(n) && n > 0) console.log(` ! #${n} isn't in this cluster; keeping #${canonicalId}`);
|
|
418
|
+
}
|
|
419
|
+
for (const m of cluster) {
|
|
420
|
+
if (String(m.id) === String(canonicalId)) continue;
|
|
421
|
+
try {
|
|
422
|
+
await patchIdea(m.id, {
|
|
423
|
+
status: 'merged',
|
|
424
|
+
merged_into_idea_id: Number(canonicalId),
|
|
425
|
+
reviewed_at: new Date().toISOString(),
|
|
426
|
+
triage_note: `duplicate cluster batch-merged into #${canonicalId}`,
|
|
427
|
+
});
|
|
428
|
+
state.summary.merged++;
|
|
429
|
+
state.mergedIds.push(m.id);
|
|
430
|
+
} catch (err) {
|
|
431
|
+
console.error(` ! #${m.id}: ${err.message}`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
// The keeper is untouched by the batch verdict — it still needs its own
|
|
435
|
+
// promote/discard/merge decision, so it goes back into the normal walk.
|
|
436
|
+
clusteredIds.delete(canonicalId);
|
|
437
|
+
console.log(` ⇢ merged ${cluster.length - 1} duplicate(s) into #${canonicalId} — #${canonicalId} still needs its own verdict below`);
|
|
438
|
+
handled = true; break;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
console.log(' ? unknown — type k, d, i, or s');
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
269
445
|
async function main() {
|
|
270
446
|
await requireSession();
|
|
271
447
|
|
|
@@ -290,6 +466,32 @@ async function main() {
|
|
|
290
466
|
process.exit(0);
|
|
291
467
|
}
|
|
292
468
|
|
|
469
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
470
|
+
const summary = { promoted: 0, discarded: 0, merged: 0, skipped: 0 };
|
|
471
|
+
const promotedIds = [];
|
|
472
|
+
const discardedIds = [];
|
|
473
|
+
const mergedIds = [];
|
|
474
|
+
let quit = false;
|
|
475
|
+
|
|
476
|
+
// BATCH CLUSTER PASS (task 1001465) — before the per-idea walk, so a
|
|
477
|
+
// near-duplicate group is offered ONE verdict instead of N. Runs on the RAW
|
|
478
|
+
// open-idea list (not yet ordered/filtered) since it operates on identity
|
|
479
|
+
// (kind + word overlap), not on the grade-based walk order below.
|
|
480
|
+
const { clusters, clusteredIds } = clusterIdeas(ideas);
|
|
481
|
+
if (clusters.length > 0) {
|
|
482
|
+
console.log(`\n🔗 ${clusters.length} likely-duplicate cluster${clusters.length === 1 ? '' : 's'} found (${clusteredIds.size} ideas) — batch verdict available before the individual walk.`);
|
|
483
|
+
const state = { summary, discardedIds, mergedIds };
|
|
484
|
+
for (const cluster of clusters) {
|
|
485
|
+
await handleCluster(rl, cluster, clusteredIds, state);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
ideas = ideas.filter((i) => !clusteredIds.has(i.id));
|
|
489
|
+
if (ideas.length === 0) {
|
|
490
|
+
console.log('\nnothing left to walk individually — every open idea was resolved in a batch cluster verdict.');
|
|
491
|
+
rl.close();
|
|
492
|
+
process.exit(0);
|
|
493
|
+
}
|
|
494
|
+
|
|
293
495
|
// WALK ORDER (task 1002924 / R148): Full Ideas first, best-formed first, then
|
|
294
496
|
// quick ideas and sparks newest-first. Two runs rather than one score-sorted
|
|
295
497
|
// list, for the reason render-ideas.js records: a single list sorted by score
|
|
@@ -298,14 +500,7 @@ async function main() {
|
|
|
298
500
|
const { partition } = require('./render-ideas');
|
|
299
501
|
const grouped = partition(ideas);
|
|
300
502
|
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;
|
|
503
|
+
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
504
|
|
|
310
505
|
try {
|
|
311
506
|
for (let i = 0; i < ideas.length && !quit; i++) {
|
|
@@ -475,4 +670,7 @@ module.exports = {
|
|
|
475
670
|
parseDevelopment,
|
|
476
671
|
loadDevelopment,
|
|
477
672
|
formatDevelopment,
|
|
673
|
+
clusterIdeas,
|
|
674
|
+
significantWords,
|
|
675
|
+
jaccard,
|
|
478
676
|
};
|
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.550'; // 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');
|
|
@@ -16,6 +16,9 @@ const {
|
|
|
16
16
|
parseDevelopment,
|
|
17
17
|
loadDevelopment,
|
|
18
18
|
formatDevelopment,
|
|
19
|
+
clusterIdeas,
|
|
20
|
+
significantWords,
|
|
21
|
+
jaccard,
|
|
19
22
|
} = require('../scripts/gds/triage.js');
|
|
20
23
|
|
|
21
24
|
let passed = 0;
|
|
@@ -135,6 +138,108 @@ test('formatDevelopment: empty arrays render nothing', () => {
|
|
|
135
138
|
assert.doesNotMatch(out, /challenges:/);
|
|
136
139
|
});
|
|
137
140
|
|
|
141
|
+
// ---------- significantWords / jaccard ----------
|
|
142
|
+
|
|
143
|
+
test('significantWords: lowercases, strips stopwords + short tokens', () => {
|
|
144
|
+
const s = significantWords('Add a Dark Mode Toggle to the Settings Page');
|
|
145
|
+
assert.ok(s.has('dark'));
|
|
146
|
+
assert.ok(s.has('mode'));
|
|
147
|
+
assert.ok(s.has('toggle'));
|
|
148
|
+
assert.ok(s.has('settings'));
|
|
149
|
+
assert.ok(!s.has('a'), 'stopword "a" leaked through');
|
|
150
|
+
assert.ok(!s.has('the'), 'stopword "the" leaked through');
|
|
151
|
+
assert.ok(!s.has('to'), 'stopword "to" leaked through');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('jaccard: identical sets → 1, disjoint sets → 0, empty set → 0', () => {
|
|
155
|
+
assert.equal(jaccard(new Set(['a', 'b']), new Set(['a', 'b'])), 1);
|
|
156
|
+
assert.equal(jaccard(new Set(['a']), new Set(['b'])), 0);
|
|
157
|
+
assert.equal(jaccard(new Set(), new Set(['a'])), 0);
|
|
158
|
+
assert.equal(jaccard(new Set(), new Set()), 0);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('jaccard: partial overlap is intersection / union', () => {
|
|
162
|
+
// {a,b,c} ∩ {b,c,d} = {b,c} (2); ∪ = {a,b,c,d} (4) → 0.5
|
|
163
|
+
assert.equal(jaccard(new Set(['a', 'b', 'c']), new Set(['b', 'c', 'd'])), 0.5);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ---------- clusterIdeas ----------
|
|
167
|
+
|
|
168
|
+
const mkIdea = (id, title, { kind = 'feature', body_md = '', capturedAt = '2026-01-01T00:00:00Z', grade } = {}) =>
|
|
169
|
+
({ id, title, body_md, kind, grade, captured_at: capturedAt });
|
|
170
|
+
|
|
171
|
+
test('clusterIdeas: near-duplicate titles of the SAME kind cluster together', () => {
|
|
172
|
+
const ideas = [
|
|
173
|
+
mkIdea(1, 'Add dark mode toggle to settings', { capturedAt: '2026-01-03T00:00:00Z' }),
|
|
174
|
+
mkIdea(2, 'Dark mode toggle for settings page', { capturedAt: '2026-01-01T00:00:00Z' }),
|
|
175
|
+
mkIdea(3, 'Support a dark mode toggle in settings', { capturedAt: '2026-01-02T00:00:00Z' }),
|
|
176
|
+
];
|
|
177
|
+
const { clusters, clusteredIds } = clusterIdeas(ideas);
|
|
178
|
+
assert.equal(clusters.length, 1, 'expected exactly one cluster');
|
|
179
|
+
assert.equal(clusters[0].length, 3);
|
|
180
|
+
assert.deepEqual(clusteredIds, new Set([1, 2, 3]));
|
|
181
|
+
// oldest first — the suggested keeper
|
|
182
|
+
assert.equal(clusters[0][0].id, 2);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('clusterIdeas: unrelated ideas never cluster', () => {
|
|
186
|
+
const ideas = [
|
|
187
|
+
mkIdea(1, 'Add dark mode toggle to settings'),
|
|
188
|
+
mkIdea(2, 'Fix flaky CI test for the deploy pipeline'),
|
|
189
|
+
mkIdea(3, 'Write an ADR about the credit reward formula'),
|
|
190
|
+
];
|
|
191
|
+
const { clusters, clusteredIds } = clusterIdeas(ideas);
|
|
192
|
+
assert.equal(clusters.length, 0);
|
|
193
|
+
assert.equal(clusteredIds.size, 0);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('clusterIdeas: same-topic ideas of DIFFERENT kind do not cluster (a bug is not a dup of a feature)', () => {
|
|
197
|
+
const ideas = [
|
|
198
|
+
mkIdea(1, 'Dark mode toggle is broken on mobile', { kind: 'bug' }),
|
|
199
|
+
mkIdea(2, 'Add a dark mode toggle', { kind: 'feature' }),
|
|
200
|
+
];
|
|
201
|
+
const { clusters, clusteredIds } = clusterIdeas(ideas);
|
|
202
|
+
assert.equal(clusters.length, 0, 'a bug report and a feature ask must not be treated as the same duplicate');
|
|
203
|
+
assert.equal(clusteredIds.size, 0);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('clusterIdeas: a Full Idea is never eligible for batch clustering', () => {
|
|
207
|
+
const ideas = [
|
|
208
|
+
mkIdea(1, 'Add dark mode toggle to settings', { grade: 'full' }),
|
|
209
|
+
mkIdea(2, 'Dark mode toggle for settings page', { grade: 'full' }),
|
|
210
|
+
];
|
|
211
|
+
const { clusters, clusteredIds } = clusterIdeas(ideas);
|
|
212
|
+
assert.equal(clusters.length, 0, 'Full Ideas must go through the shape recommendation, never a batch merge');
|
|
213
|
+
assert.equal(clusteredIds.size, 0);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('clusterIdeas: a single overlapping word alone is not enough (minShared floor)', () => {
|
|
217
|
+
const ideas = [
|
|
218
|
+
mkIdea(1, 'Improve the deploy pipeline reliability with retries'),
|
|
219
|
+
mkIdea(2, 'Improve onboarding copy for new builders'),
|
|
220
|
+
];
|
|
221
|
+
const { clusters } = clusterIdeas(ideas);
|
|
222
|
+
assert.equal(clusters.length, 0, 'sharing only "improve" must not cluster two unrelated ideas');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('clusterIdeas: three ideas, two of which duplicate — only the pair clusters', () => {
|
|
226
|
+
const ideas = [
|
|
227
|
+
mkIdea(1, 'Add dark mode toggle to settings', { capturedAt: '2026-01-02T00:00:00Z' }),
|
|
228
|
+
mkIdea(2, 'Dark mode toggle for the settings page', { capturedAt: '2026-01-01T00:00:00Z' }),
|
|
229
|
+
mkIdea(3, 'Write an ADR about the credit reward formula'),
|
|
230
|
+
];
|
|
231
|
+
const { clusters, clusteredIds } = clusterIdeas(ideas);
|
|
232
|
+
assert.equal(clusters.length, 1);
|
|
233
|
+
assert.deepEqual(new Set(clusters[0].map((i) => i.id)), new Set([1, 2]));
|
|
234
|
+
assert.ok(!clusteredIds.has(3));
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('clusterIdeas: empty input → no clusters', () => {
|
|
238
|
+
const { clusters, clusteredIds } = clusterIdeas([]);
|
|
239
|
+
assert.equal(clusters.length, 0);
|
|
240
|
+
assert.equal(clusteredIds.size, 0);
|
|
241
|
+
});
|
|
242
|
+
|
|
138
243
|
// ---------- summary ----------
|
|
139
244
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
140
245
|
process.exit(failed === 0 ? 0 : 1);
|