@bongos/core 1.19.301 → 1.19.302
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 +18 -13
- package/.claude/skills/idea-triage/SKILL.md +19 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/render-ideas.js +62 -9
- package/scripts/gds/triage.js +19 -2
- package/src/module-api.js +3 -1
- package/tests/idea_triage_grade.mjs +167 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
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.302",
|
|
6
|
+
"core_contract": "1.19.302",
|
|
7
|
+
"source_commit": "d2bc099b7707a0e3e29c2b48b796bc95785d7c74",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-22T23:12:06.332Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 322,
|
|
13
13
|
"agent_docs_stubbed": 22,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1411,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1755,
|
|
20
|
+
"tree_sha256": "f1bb517fc8d0b3ac176f07c721aaa32bec280b6937905b78fdde9966e084d99e",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -172,7 +172,7 @@
|
|
|
172
172
|
{
|
|
173
173
|
"path": ".claude/skills/idea-triage/SKILL.md",
|
|
174
174
|
"mode": "0000644",
|
|
175
|
-
"sha256": "
|
|
175
|
+
"sha256": "cbecde1c3fe60e02e6b008b80232e8866d7924aaefa9614377044dce5973d1d0"
|
|
176
176
|
},
|
|
177
177
|
{
|
|
178
178
|
"path": ".claude/skills/ideate/SKILL.md",
|
|
@@ -4577,12 +4577,12 @@
|
|
|
4577
4577
|
{
|
|
4578
4578
|
"path": "package-lock.json",
|
|
4579
4579
|
"mode": "0000644",
|
|
4580
|
-
"sha256": "
|
|
4580
|
+
"sha256": "06de1a2274d6eafe7a544b7e0c1f157b42191eb0ad151d6e7f821180604d74bb"
|
|
4581
4581
|
},
|
|
4582
4582
|
{
|
|
4583
4583
|
"path": "package.json",
|
|
4584
4584
|
"mode": "0000644",
|
|
4585
|
-
"sha256": "
|
|
4585
|
+
"sha256": "45ca3b2302b14730ce004fdc64f37ab82cc0dc85518c55c4c3119b85851ddf20"
|
|
4586
4586
|
},
|
|
4587
4587
|
{
|
|
4588
4588
|
"path": "public-docs/index.html",
|
|
@@ -5572,7 +5572,7 @@
|
|
|
5572
5572
|
{
|
|
5573
5573
|
"path": "scripts/gds/render-ideas.js",
|
|
5574
5574
|
"mode": "0000644",
|
|
5575
|
-
"sha256": "
|
|
5575
|
+
"sha256": "3f72b5fb4740550965da5417de7bb83464a26f59edabe4bd52e14f50499cc5ee"
|
|
5576
5576
|
},
|
|
5577
5577
|
{
|
|
5578
5578
|
"path": "scripts/gds/repo-metrics.js",
|
|
@@ -5847,7 +5847,7 @@
|
|
|
5847
5847
|
{
|
|
5848
5848
|
"path": "scripts/gds/triage.js",
|
|
5849
5849
|
"mode": "0000644",
|
|
5850
|
-
"sha256": "
|
|
5850
|
+
"sha256": "5255bafa71699cadd345c5bb2ba6e99700cdd5d90fd165b949ef6595c5a83386"
|
|
5851
5851
|
},
|
|
5852
5852
|
{
|
|
5853
5853
|
"path": "scripts/gds/update-channel.js",
|
|
@@ -6237,7 +6237,7 @@
|
|
|
6237
6237
|
{
|
|
6238
6238
|
"path": "src/module-api.js",
|
|
6239
6239
|
"mode": "0000644",
|
|
6240
|
-
"sha256": "
|
|
6240
|
+
"sha256": "938f69ad6350189382990706f63b41c1781f9c7b19610d7275144152c65412d7"
|
|
6241
6241
|
},
|
|
6242
6242
|
{
|
|
6243
6243
|
"path": "src/module-loader/catalog.js",
|
|
@@ -7609,6 +7609,11 @@
|
|
|
7609
7609
|
"mode": "0000644",
|
|
7610
7610
|
"sha256": "836ee90dd24736c84fede53394d0d724915f73540c9b99c41762da1d37c447b2"
|
|
7611
7611
|
},
|
|
7612
|
+
{
|
|
7613
|
+
"path": "tests/idea_triage_grade.mjs",
|
|
7614
|
+
"mode": "0000644",
|
|
7615
|
+
"sha256": "4e75030cf2320c2715c5e764394e9d56f66861712176261c878bc99183622b69"
|
|
7616
|
+
},
|
|
7612
7617
|
{
|
|
7613
7618
|
"path": "tests/idp_device_exchange.mjs",
|
|
7614
7619
|
"mode": "0000644",
|
|
@@ -72,6 +72,25 @@ walk interactively instead.)
|
|
|
72
72
|
|
|
73
73
|
If `open_count` is 0, print "inbox empty — nothing to triage today" and exit.
|
|
74
74
|
|
|
75
|
+
**Read the GRADE on every row, and walk in two groups** (task 1002924 / BV1.R148).
|
|
76
|
+
Each idea carries `grade` (`quick` | `spark` | `full`) and, for a Full Idea, a
|
|
77
|
+
`completeness_score` out of 100. The grade decides what kind of verdict the idea
|
|
78
|
+
even needs, so it is the first thing to read, not a detail:
|
|
79
|
+
|
|
80
|
+
| Grade | What it is | What triage does with it |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `full` | five answered fields, scored | **Do not auto-convert.** Reason about the right shape — a goal, a task, or a merge — and *recommend* it (that is R149's rule; an ideator often lacks the full picture). |
|
|
83
|
+
| `spark` | a quick idea its **author** offered up for development | Leave it for someone to develop — it is already in the spark queue at `/builders/ideas`. Promoting it discards the author's invitation. |
|
|
84
|
+
| `quick` | a thought filed fast | Promotes straight to a task, as it always did. |
|
|
85
|
+
|
|
86
|
+
Walk **Full Ideas first, best-formed first (highest completeness), then quick
|
|
87
|
+
ideas and sparks newest-first.** Two runs, not one list sorted by score: a single
|
|
88
|
+
score-sorted list puts every unscored idea after every scored one, and in a long
|
|
89
|
+
inbox nobody reaches them. `node scripts/gds/triage.js` already walks in exactly
|
|
90
|
+
this order and prints the grade on each card, and `render-ideas.js` renders the
|
|
91
|
+
same two sections — so the interactive walk, the generated file and this skill all
|
|
92
|
+
show the same idea the same way.
|
|
93
|
+
|
|
75
94
|
**Step 1.5 — develop the open ideas in parallel (task 930).**
|
|
76
95
|
|
|
77
96
|
**Before spawning any subagents, run a cheap staleness pass over the list first** — a quick skim of each idea's title/body against the current codebase/CLAUDE.md to catch ideas that are already implemented, already superseded, or otherwise obviously stale. A recent triage found 47 of 170 open ideas already stale; filtering those out first means the expensive multi-agent development pass below is only spent on ideas that still need it.
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.302",
|
|
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.302",
|
|
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.302",
|
|
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",
|
|
@@ -51,12 +51,29 @@ function daysAgo(iso) {
|
|
|
51
51
|
return ms / (1000 * 60 * 60 * 24);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// The grade, in words, for a triage surface (task 1002924 / BV1.R148). An idea
|
|
55
|
+
// with no ideas_grades row reads as 'quick' — the R140 absence-is-quick design —
|
|
56
|
+
// so a missing grade is a real answer here, not a gap to hide.
|
|
57
|
+
function gradeLabel(idea) {
|
|
58
|
+
const g = idea.grade || 'quick';
|
|
59
|
+
if (g === 'full') {
|
|
60
|
+
const s = idea.completeness_score;
|
|
61
|
+
return Number.isInteger(s) ? `FULL ${s}/100` : 'FULL (unscored)';
|
|
62
|
+
}
|
|
63
|
+
if (g === 'spark') return 'SPARK — awaiting development';
|
|
64
|
+
return 'quick';
|
|
65
|
+
}
|
|
66
|
+
|
|
54
67
|
function renderIdea(idea) {
|
|
55
68
|
const lines = [];
|
|
56
69
|
const titleSuffix = idea.kind ? ` _(${idea.kind})_` : '';
|
|
57
70
|
lines.push(`### #${idea.id} — ${idea.title}${titleSuffix}`);
|
|
58
71
|
lines.push('');
|
|
59
72
|
const meta = [];
|
|
73
|
+
// FIRST in the meta run, deliberately: the grade decides what kind of verdict
|
|
74
|
+
// this idea even needs (a Full Idea gets a recommended shape, R149; a quick one
|
|
75
|
+
// promotes straight to a task), so it is the first thing a triager should read.
|
|
76
|
+
meta.push(`**${gradeLabel(idea)}**`);
|
|
60
77
|
meta.push(`captured ${fmtTimestamp(idea.captured_at)}`);
|
|
61
78
|
if (idea.reviewed_at) meta.push(`reviewed ${fmtTimestamp(idea.reviewed_at)}`);
|
|
62
79
|
if (idea.suggested_version) meta.push(`suggested → ${idea.suggested_version}`);
|
|
@@ -96,6 +113,17 @@ async function fetchAllIdeas() {
|
|
|
96
113
|
return data.ideas || [];
|
|
97
114
|
}
|
|
98
115
|
|
|
116
|
+
// Full ideas rank by how completely they were answered; everything else by
|
|
117
|
+
// recency. Kept as one comparator per bucket rather than one clever composite:
|
|
118
|
+
// mixing a 0-100 score and a timestamp into a single ordering is how a quick idea
|
|
119
|
+
// ends up sorted by a number it does not have.
|
|
120
|
+
const byCompletenessDesc = (a, b) => {
|
|
121
|
+
const sa = Number.isInteger(a.completeness_score) ? a.completeness_score : -1;
|
|
122
|
+
const sb = Number.isInteger(b.completeness_score) ? b.completeness_score : -1;
|
|
123
|
+
if (sa !== sb) return sb - sa;
|
|
124
|
+
return new Date(b.captured_at).getTime() - new Date(a.captured_at).getTime();
|
|
125
|
+
};
|
|
126
|
+
|
|
99
127
|
function partition(ideas) {
|
|
100
128
|
const open = [];
|
|
101
129
|
const promoted = [];
|
|
@@ -116,24 +144,41 @@ function partition(ideas) {
|
|
|
116
144
|
// Newest first within each bucket.
|
|
117
145
|
const byCapturedDesc = (a, b) =>
|
|
118
146
|
new Date(b.captured_at).getTime() - new Date(a.captured_at).getTime();
|
|
119
|
-
open.sort(byCapturedDesc);
|
|
120
147
|
promoted.sort(byCapturedDesc);
|
|
121
148
|
discarded.sort(byCapturedDesc);
|
|
122
149
|
merged.sort(byCapturedDesc);
|
|
123
|
-
|
|
150
|
+
|
|
151
|
+
// TWO OPEN SECTIONS, NOT ONE SORTED LIST (task 1002924 / R148). The done-when
|
|
152
|
+
// wants full ideas ordered by completeness AND quick ideas and sparks still
|
|
153
|
+
// visible rather than buried under scored ideas — and a single list sorted by
|
|
154
|
+
// score cannot do both: every unscored idea lands after every scored one, which
|
|
155
|
+
// for a long inbox means nobody ever reads them. Two labelled runs give each
|
|
156
|
+
// kind its own ordering and its own top.
|
|
157
|
+
const openFull = open.filter((i) => (i.grade || 'quick') === 'full').sort(byCompletenessDesc);
|
|
158
|
+
const openRest = open.filter((i) => (i.grade || 'quick') !== 'full').sort(byCapturedDesc);
|
|
159
|
+
return { open, openFull, openRest, promoted, discarded, merged };
|
|
124
160
|
}
|
|
125
161
|
|
|
126
162
|
function render(ideas) {
|
|
127
|
-
const { open, promoted, discarded, merged } = partition(ideas);
|
|
163
|
+
const { open, openFull, openRest, promoted, discarded, merged } = partition(ideas);
|
|
128
164
|
const renderedAt = new Date().toISOString().replace('T', ' ').slice(0, 16) + ' UTC';
|
|
129
165
|
|
|
130
166
|
const parts = [
|
|
131
167
|
HEADER,
|
|
132
|
-
`_Last rendered: ${renderedAt} · ${open.length} open · ${promoted.length} promoted (last ${RECENT_DAYS}d) · ${discarded.length} discarded (last ${RECENT_DAYS}d) · ${merged.length} merged (last ${RECENT_DAYS}d)_`,
|
|
168
|
+
`_Last rendered: ${renderedAt} · ${open.length} open (${openFull.length} full, ${openRest.length} quick/spark) · ${promoted.length} promoted (last ${RECENT_DAYS}d) · ${discarded.length} discarded (last ${RECENT_DAYS}d) · ${merged.length} merged (last ${RECENT_DAYS}d)_`,
|
|
133
169
|
'',
|
|
134
170
|
'---',
|
|
135
171
|
'',
|
|
136
|
-
renderSection(
|
|
172
|
+
renderSection(
|
|
173
|
+
'Open — Full Ideas (best-formed first)',
|
|
174
|
+
openFull,
|
|
175
|
+
'No Full Ideas open. One is written at /builders/ideas, or by developing a spark.'
|
|
176
|
+
),
|
|
177
|
+
renderSection(
|
|
178
|
+
'Open — quick ideas and sparks (newest first)',
|
|
179
|
+
openRest,
|
|
180
|
+
'No quick ideas open. Inbox zero — capture one with node scripts/gds/capture.js.'
|
|
181
|
+
),
|
|
137
182
|
renderSection(
|
|
138
183
|
`Recently Promoted (last ${RECENT_DAYS} days)`,
|
|
139
184
|
promoted,
|
|
@@ -167,7 +212,15 @@ async function main() {
|
|
|
167
212
|
console.log(`Wrote ${OUT_PATH} (${ideas.length} idea${ideas.length === 1 ? '' : 's'} fetched)`);
|
|
168
213
|
}
|
|
169
214
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
215
|
+
// Exported so the R148 ordering + grade label are unit-testable without a
|
|
216
|
+
// session or the network — they are the whole substance of this file now, and the
|
|
217
|
+
// pure functions are the right seam. Guarded main(), the capture.js pattern:
|
|
218
|
+
// without it a require() for the test would run the renderer.
|
|
219
|
+
module.exports = { gradeLabel, partition };
|
|
220
|
+
|
|
221
|
+
if (require.main === module) {
|
|
222
|
+
main().catch((err) => {
|
|
223
|
+
console.error(err.stack || err.message || err);
|
|
224
|
+
process.exit(1);
|
|
225
|
+
});
|
|
226
|
+
}
|
package/scripts/gds/triage.js
CHANGED
|
@@ -243,10 +243,19 @@ async function createTask({ versionId, title, description, priority, sourceRef,
|
|
|
243
243
|
return r.data?.task;
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
// The grade, spelled out (task 1002924 / BV1.R148). Reused from render-ideas.js
|
|
247
|
+
// rather than restated so the two triage surfaces cannot describe the same idea
|
|
248
|
+
// differently — the whole point of a grade at a glance is that it reads the same
|
|
249
|
+
// everywhere.
|
|
250
|
+
const { gradeLabel } = require('./render-ideas');
|
|
251
|
+
|
|
246
252
|
function printIdea(idea, idx, total) {
|
|
247
253
|
const sep = '─'.repeat(72);
|
|
248
254
|
console.log(`\n${sep}`);
|
|
249
|
-
|
|
255
|
+
// The grade leads the line: it decides what kind of verdict this idea needs at
|
|
256
|
+
// all (a Full Idea gets a recommended shape — R149; a quick one promotes
|
|
257
|
+
// straight to a task), so a triager should not have to hunt for it.
|
|
258
|
+
console.log(`Idea ${idx + 1}/${total} #${idea.id} [${gradeLabel(idea)}] kind=${idea.kind || '—'} suggested_version=${idea.suggested_version || '—'} priority=${idea.suggested_priority ?? '—'}`);
|
|
250
259
|
console.log(`Captured: ${idea.captured_at} by builder #${idea.captured_by ?? '—'}`);
|
|
251
260
|
console.log(`Title: ${idea.title}`);
|
|
252
261
|
if (idea.body_md && idea.body_md.trim()) {
|
|
@@ -280,7 +289,15 @@ async function main() {
|
|
|
280
289
|
process.exit(0);
|
|
281
290
|
}
|
|
282
291
|
|
|
283
|
-
|
|
292
|
+
// WALK ORDER (task 1002924 / R148): Full Ideas first, best-formed first, then
|
|
293
|
+
// quick ideas and sparks newest-first. Two runs rather than one score-sorted
|
|
294
|
+
// list, for the reason render-ideas.js records: a single list sorted by score
|
|
295
|
+
// puts every unscored idea after every scored one, and in a long inbox nobody
|
|
296
|
+
// reaches them. Grouping gives each kind its own top.
|
|
297
|
+
const { partition } = require('./render-ideas');
|
|
298
|
+
const grouped = partition(ideas);
|
|
299
|
+
ideas = grouped.openFull.concat(grouped.openRest);
|
|
300
|
+
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).`);
|
|
284
301
|
|
|
285
302
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
286
303
|
const summary = { promoted: 0, discarded: 0, merged: 0, skipped: 0 };
|
package/src/module-api.js
CHANGED
|
@@ -1029,8 +1029,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1029
1029
|
// landed since 1.19.299 with no explicit bump. run 32601521458. (task 1002620)
|
|
1030
1030
|
// 1.19.301 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1031
1031
|
// landed since 1.19.300 with no explicit bump. run 32602146266. (task 1002620)
|
|
1032
|
+
// 1.19.302 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1033
|
+
// landed since 1.19.301 with no explicit bump. run 32604538465. (task 1002620)
|
|
1032
1034
|
// ---------------------------------------------------------------------------
|
|
1033
|
-
const CORE_VERSION = '1.19.
|
|
1035
|
+
const CORE_VERSION = '1.19.302'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1034
1036
|
|
|
1035
1037
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1036
1038
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// tests/idea_triage_grade.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1002924 (BV1.R148, goal 1000062) — the grade on the triage surfaces, and
|
|
4
|
+
// the ordering.
|
|
5
|
+
//
|
|
6
|
+
// The done-when has three clauses and the THIRD is the one with a trap in it:
|
|
7
|
+
// 1. each idea in triage shows its grade at a glance;
|
|
8
|
+
// 2. full ideas sort by completeness;
|
|
9
|
+
// 3. quick ideas and sparks remain visible rather than being buried under
|
|
10
|
+
// scored ideas.
|
|
11
|
+
//
|
|
12
|
+
// (2) and (3) cannot both be satisfied by one list sorted on completeness: every
|
|
13
|
+
// unscored idea lands after every scored one, so in a long inbox nobody reaches
|
|
14
|
+
// them. The answer is two ordered groups, and that is what these assertions pin —
|
|
15
|
+
// including that the group split is on GRADE and not on "has a score", which
|
|
16
|
+
// would misfile an unscored Full Idea.
|
|
17
|
+
//
|
|
18
|
+
// Pure require of the two exported helpers; no session, no network.
|
|
19
|
+
//
|
|
20
|
+
// Run: node tests/idea_triage_grade.mjs
|
|
21
|
+
|
|
22
|
+
import { strict as assert } from 'node:assert';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
30
|
+
const { gradeLabel, partition } = require('../scripts/gds/render-ideas.js');
|
|
31
|
+
|
|
32
|
+
const open = (o) => ({ status: 'open', captured_at: '2026-08-01T00:00:00Z', ...o });
|
|
33
|
+
|
|
34
|
+
let passed = 0;
|
|
35
|
+
let failed = 0;
|
|
36
|
+
function test(name, fn) {
|
|
37
|
+
try { fn(); passed++; console.log(` ok ${name}`); }
|
|
38
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// --- (1) the grade at a glance ------------------------------------------
|
|
42
|
+
|
|
43
|
+
test('a Full Idea shows its grade AND its score', () => {
|
|
44
|
+
assert.equal(gradeLabel({ grade: 'full', completeness_score: 88 }), 'FULL 88/100');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a Full Idea with no score says unscored rather than implying zero', () => {
|
|
48
|
+
// R142 returns null for anything it cannot score, and that is a legal stored
|
|
49
|
+
// state — "unscorable" and "not yet scored" deliberately look the same.
|
|
50
|
+
assert.equal(gradeLabel({ grade: 'full', completeness_score: null }), 'FULL (unscored)');
|
|
51
|
+
assert.doesNotMatch(gradeLabel({ grade: 'full', completeness_score: null }), /0/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('a spark says what it is waiting for', () => {
|
|
55
|
+
assert.match(gradeLabel({ grade: 'spark' }), /awaiting development/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('a missing grade reads as quick — the R140 absence-is-quick design', () => {
|
|
59
|
+
// Not a gap to hide: an idea with no ideas_grades row IS quick.
|
|
60
|
+
assert.equal(gradeLabel({}), 'quick');
|
|
61
|
+
assert.equal(gradeLabel({ grade: null }), 'quick');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// --- (2) full ideas sort by completeness --------------------------------
|
|
65
|
+
|
|
66
|
+
test('Full Ideas are ordered best-formed first', () => {
|
|
67
|
+
const g = partition([
|
|
68
|
+
open({ id: 1, grade: 'full', completeness_score: 40 }),
|
|
69
|
+
open({ id: 2, grade: 'full', completeness_score: 95 }),
|
|
70
|
+
open({ id: 3, grade: 'full', completeness_score: 70 }),
|
|
71
|
+
]);
|
|
72
|
+
assert.deepEqual(g.openFull.map((i) => i.id), [2, 3, 1]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('ties among Full Ideas fall back to recency, so the order is stable', () => {
|
|
76
|
+
const g = partition([
|
|
77
|
+
open({ id: 1, grade: 'full', completeness_score: 70, captured_at: '2026-07-01T00:00:00Z' }),
|
|
78
|
+
open({ id: 2, grade: 'full', completeness_score: 70, captured_at: '2026-08-01T00:00:00Z' }),
|
|
79
|
+
]);
|
|
80
|
+
assert.deepEqual(g.openFull.map((i) => i.id), [2, 1]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('an UNSCORED Full Idea sorts last among full ideas, but stays a full idea', () => {
|
|
84
|
+
// The split is on GRADE, not on "has a score" — filing it with the quick ideas
|
|
85
|
+
// would hide a five-field idea from the group a triager reads for five-field
|
|
86
|
+
// ideas, purely because the scorer could not score it.
|
|
87
|
+
const g = partition([
|
|
88
|
+
open({ id: 1, grade: 'full', completeness_score: null }),
|
|
89
|
+
open({ id: 2, grade: 'full', completeness_score: 10 }),
|
|
90
|
+
]);
|
|
91
|
+
assert.deepEqual(g.openFull.map((i) => i.id), [2, 1]);
|
|
92
|
+
assert.equal(g.openRest.length, 0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// --- (3) quick ideas and sparks are NOT buried --------------------------
|
|
96
|
+
|
|
97
|
+
test('quick ideas and sparks get their OWN group, newest first', () => {
|
|
98
|
+
const g = partition([
|
|
99
|
+
open({ id: 1, grade: 'quick', captured_at: '2026-07-01T00:00:00Z' }),
|
|
100
|
+
open({ id: 2, grade: 'spark', captured_at: '2026-08-10T00:00:00Z' }),
|
|
101
|
+
open({ id: 3, captured_at: '2026-08-05T00:00:00Z' }),
|
|
102
|
+
]);
|
|
103
|
+
assert.deepEqual(g.openRest.map((i) => i.id), [2, 3, 1]);
|
|
104
|
+
assert.equal(g.openFull.length, 0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('a quick idea is NEVER pushed behind scored ideas — it has its own top', () => {
|
|
108
|
+
// The trap the done-when names. With one score-sorted list, id 99 would be
|
|
109
|
+
// 50th; in its own group it is first.
|
|
110
|
+
const many = Array.from({ length: 49 }, (_, i) => open({ id: i + 1, grade: 'full', completeness_score: 50 + (i % 40) }));
|
|
111
|
+
const g = partition([...many, open({ id: 99, grade: 'quick', captured_at: '2026-08-20T00:00:00Z' })]);
|
|
112
|
+
assert.equal(g.openRest[0].id, 99, 'first in its own group, not last in a merged one');
|
|
113
|
+
assert.equal(g.openFull.length, 49);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('every open idea lands in exactly one group — none is dropped', () => {
|
|
117
|
+
const ideas = [
|
|
118
|
+
open({ id: 1, grade: 'full', completeness_score: 80 }),
|
|
119
|
+
open({ id: 2, grade: 'spark' }),
|
|
120
|
+
open({ id: 3, grade: 'quick' }),
|
|
121
|
+
open({ id: 4 }),
|
|
122
|
+
open({ id: 5, grade: 'full', completeness_score: null }),
|
|
123
|
+
];
|
|
124
|
+
const g = partition(ideas);
|
|
125
|
+
const seen = [...g.openFull, ...g.openRest].map((i) => i.id).sort((a, b) => a - b);
|
|
126
|
+
assert.deepEqual(seen, [1, 2, 3, 4, 5]);
|
|
127
|
+
assert.equal(g.open.length, 5, 'the undivided open list is still returned for the count');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('a non-open idea is not in either open group', () => {
|
|
131
|
+
const g = partition([
|
|
132
|
+
open({ id: 1, grade: 'full', completeness_score: 90 }),
|
|
133
|
+
{ id: 2, status: 'discarded', grade: 'full', completeness_score: 99, captured_at: new Date().toISOString() },
|
|
134
|
+
]);
|
|
135
|
+
assert.deepEqual(g.openFull.map((i) => i.id), [1]);
|
|
136
|
+
assert.ok(!g.openRest.some((i) => i.id === 2));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// --- the surfaces agree --------------------------------------------------
|
|
140
|
+
|
|
141
|
+
test('the interactive walk and the generated file share ONE grade label', () => {
|
|
142
|
+
// Two triage surfaces describing the same idea differently defeats the point of
|
|
143
|
+
// a grade at a glance, so triage.js imports the label rather than restating it.
|
|
144
|
+
const triage = fs.readFileSync(path.join(ROOT, 'scripts/gds/triage.js'), 'utf8');
|
|
145
|
+
assert.match(triage, /require\('\.\/render-ideas'\)/, 'triage.js imports the shared helpers');
|
|
146
|
+
assert.match(triage, /gradeLabel\(idea\)/, 'and prints the shared label on each card');
|
|
147
|
+
assert.ok(!/FULL \$\{|SPARK —/.test(triage), 'triage.js does not restate the label text');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('the walk is ordered by the shared partition, not re-sorted locally', () => {
|
|
151
|
+
const triage = fs.readFileSync(path.join(ROOT, 'scripts/gds/triage.js'), 'utf8');
|
|
152
|
+
assert.match(triage, /partition\(ideas\)/);
|
|
153
|
+
assert.match(triage, /openFull\.concat\(grouped\.openRest\)/, 'full ideas first, then the rest');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('the skill documents the grade and the two-group walk', () => {
|
|
157
|
+
const skill = fs.readFileSync(path.join(ROOT, '.claude/skills/idea-triage/SKILL.md'), 'utf8');
|
|
158
|
+
assert.match(skill, /completeness_score/);
|
|
159
|
+
assert.match(skill, /Full Ideas first, best-formed first/);
|
|
160
|
+
// And it must carry the REASON, not just the instruction — the next person to
|
|
161
|
+
// "simplify" this into one sorted list needs to know why it is two.
|
|
162
|
+
assert.match(skill, /nobody reaches them/);
|
|
163
|
+
assert.match(skill, /Do not auto-convert/, 'and R149\'s rule for a Full Idea');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
console.log(`\nidea_triage_grade: ${passed} passed, ${failed} failed`);
|
|
167
|
+
if (failed > 0) process.exit(1);
|