@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 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.301",
6
- "core_contract": "1.19.301",
7
- "source_commit": "5c6946b0ce2a4a6744b21c6d69a318d5a73180aa",
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-22T22:20:55.084Z",
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": 1410,
14
+ "functional_verbatim": 1411,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1754,
20
- "tree_sha256": "b6f2413f697c124e4166c36184b7e875a371c45b35f8703f13190354b3533f51",
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": "c74c93659c4576b32d829ebb2774feb8771125b31b677e070841f4cc25ed0232"
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": "c7582438fd23701a58ddcc6a35f6b4d0ea388fe47be83d5df29f0e79961a7b6c"
4580
+ "sha256": "06de1a2274d6eafe7a544b7e0c1f157b42191eb0ad151d6e7f821180604d74bb"
4581
4581
  },
4582
4582
  {
4583
4583
  "path": "package.json",
4584
4584
  "mode": "0000644",
4585
- "sha256": "85fdb6e71f1b13ed512137e259e3f6019a2bb71f50b8558db745f609b5f11d00"
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": "55dd4f844b1a4b2b898731f4d6c9fb03676037a10cc42473a67d0214b2aadabd"
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": "1c43db4e69ff4966addd91314638b3b2a0c3f7c91f832f09e4a162e5fd0789aa"
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": "f40f5b93ecdce691250c1d263c9e68d6bd9b06a93ce2a3e3ef70520faad961b9"
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.301",
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.301",
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.301",
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
- return { open, promoted, discarded, merged };
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('Open', open, 'No open ideas. Inbox zero — capture one with /builder-capture.'),
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
- main().catch((err) => {
171
- console.error(err.stack || err.message || err);
172
- process.exit(1);
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
+ }
@@ -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
- console.log(`Idea ${idx + 1}/${total} #${idea.id} kind=${idea.kind || '—'} suggested_version=${idea.suggested_version || '—'} priority=${idea.suggested_priority ?? '—'}`);
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
- console.log(`📥 ${ideas.length} open idea${ideas.length === 1 ? '' : 's'} to triage.`);
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.301'; // CI auto-patch carrier (ADR 0161); see changelog above
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);