@bongos/core 1.19.301 → 1.19.303

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.303",
6
+ "core_contract": "1.19.303",
7
+ "source_commit": "fd08cc750b621521b54d357645136366c89f0316",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-22T22:20:55.084Z",
9
+ "built_at": "2026-08-22T23:32:40.774Z",
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": 1412,
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": 1756,
20
+ "tree_sha256": "42829db3492386930a68c9936d55f1e7d7895fc1e6194a65e6d659e81be98c05",
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": "b64fe42bb2a542304802b48bea348ae817608ac47f026d98c4de9bea5aa90174"
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": "5721cb7973d1dc844692348aeb2462d3adfcba9929f7146f6a872c6de15dd710"
4581
4581
  },
4582
4582
  {
4583
4583
  "path": "package.json",
4584
4584
  "mode": "0000644",
4585
- "sha256": "85fdb6e71f1b13ed512137e259e3f6019a2bb71f50b8558db745f609b5f11d00"
4585
+ "sha256": "441e6e4bea0f538d4c0e63eb17cee0f65077775be47c930b8876cb022bc0966b"
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": "dcb27806a84ffee473f8e69ca78530535b7d7c51e30780e09982ba8489768f9c"
6241
6241
  },
6242
6242
  {
6243
6243
  "path": "src/module-loader/catalog.js",
@@ -7609,6 +7609,16 @@
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
+ },
7617
+ {
7618
+ "path": "tests/idea_triage_recommends.mjs",
7619
+ "mode": "0000644",
7620
+ "sha256": "c7ba20839a7b33df0eb7e8bfc2f463217e7d3a538d888e89774107d55d34556d"
7621
+ },
7612
7622
  {
7613
7623
  "path": "tests/idp_device_exchange.mjs",
7614
7624
  "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.
@@ -115,6 +134,54 @@ Then ask the user (concise prompt — Lars is an EE, not a software engineer; fr
115
134
 
116
135
  > Idea #N: "{title}". {one-sentence developed summary}. **Open questions:** {the subagent's open_questions}. **Promote, discard, or merge with another idea?**
117
136
 
137
+ **Step 2.5 — for a FULL IDEA, work out its SHAPE and recommend one (task 1002925 / BV1.R149).**
138
+
139
+ Only for `grade='full'`. A quick idea promotes straight to a task — that is what a
140
+ quick idea is *for* — and a spark is not yours to convert at all (its author
141
+ offered it up for development, and promoting it discards that invitation).
142
+
143
+ **Why this step exists, in the owner's words:** *for ideators it is hard to say
144
+ what is a task or a goal, because it is often hard to have the full picture.* So a
145
+ Full Idea gets **thought about** at triage instead of being forced down a fixed
146
+ path. The person who wrote it was not required to know whether it was one task or
147
+ a quarter of work; that is the judgement triage adds.
148
+
149
+ **Three shapes it can take.** Read the five answers — especially *how does it fit*
150
+ — and decide which one it actually is:
151
+
152
+ | Shape | It looks like this | What you would create |
153
+ |---|---|---|
154
+ | **Goal** | several distinct pieces of work, or an outcome you would measure rather than finish; the "big picture" answer names more than one surface | a goal, plus **draft done-when criteria** taken from what the idea says "done" means |
155
+ | **Task** | one concrete change with an obvious end | a single task in whatever goal already owns that area |
156
+ | **Merge** | work that already exists — an open goal, a live task, or another idea says the same thing | nothing; point it at the existing record |
157
+
158
+ **Do the legwork before recommending.** A recommendation with nothing behind it is
159
+ worse than no recommendation, because it looks like an answer. Run
160
+ `/recall <the idea's subject>`, check the goal board
161
+ (`node scripts/gds/goal.js list`), and look for an existing task before proposing
162
+ anything new — "merge" is the recommendation people skip, and it is often the right
163
+ one.
164
+
165
+ **Then RECOMMEND, and show the reasoning.** Say which shape and *why*, name the
166
+ alternative you rejected and why, and — for a goal — sketch the criteria you would
167
+ draft. One short paragraph, not a report:
168
+
169
+ > Idea #N (Full, 84/100) — **I'd make this a goal.** Its "big picture" answer names
170
+ > three separate surfaces (the CLI, the hall, and the Discord mirror), so a single
171
+ > task would either be a month long or quietly deliver only one of them. Draft
172
+ > criteria: *(1) the CLI can do X; (2) the same is reachable in the hall; (3) the
173
+ > Discord path stops needing the old flag.* I considered a task in goal 1000071
174
+ > since the routing work overlaps, but this outlives that goal's scope.
175
+ > **Goal, task, or merge?**
176
+
177
+ **NOTHING CONVERTS UNTIL THEY ANSWER.** The recommendation is a recommendation:
178
+ do not create the goal, do not create the task, do not mark the idea promoted
179
+ until the human has chosen. If they pick a different shape than you recommended,
180
+ that is the step working — build what they chose, not what you argued for. (The
181
+ server backs this up at the capture end: a Full Idea never auto-routes,
182
+ `full_idea_never_routes` in `modules/ideas/routing.js`. This step is the same rule
183
+ at the triage end, where the judgement is.)
184
+
118
185
  **Step 3 — apply the verdict.**
119
186
 
120
187
  **Promote → pick the tier (BV1.R64 / ADR 0086).** An idea no longer only becomes a *task* — it can promote into any tier of the work hierarchy: a **goal** (a whole module-scoped workspace), a **criterion** (a new done-when test), or a **task** (a single unit of work). Choose by the idea's size: a broad objective → goal; a "we should also require X to be done" → criterion; a concrete piece of work → task. Frame the choice to the user in those terms, then create the right record and mark the idea promoted.
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.303",
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.303",
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.303",
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,12 @@ 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)
1034
+ // 1.19.303 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1035
+ // landed since 1.19.302 with no explicit bump. run 32605477232. (task 1002620)
1032
1036
  // ---------------------------------------------------------------------------
1033
- const CORE_VERSION = '1.19.301'; // CI auto-patch carrier (ADR 0161); see changelog above
1037
+ const CORE_VERSION = '1.19.303'; // CI auto-patch carrier (ADR 0161); see changelog above
1034
1038
 
1035
1039
  // A namespaced logger so a module's log lines are attributable + consistent.
1036
1040
  // 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);
@@ -0,0 +1,155 @@
1
+ // tests/idea_triage_recommends.mjs
2
+ //
3
+ // task 1002925 (BV1.R149, goal 1000062) — a Full Idea's SHAPE is recommended, not
4
+ // decided for the builder.
5
+ //
6
+ // The done-when: "triage presents a recommendation plus its reasoning; the human
7
+ // decides; nothing converts without that decision."
8
+ //
9
+ // Two halves, and they live in different places:
10
+ // • the SERVER half already exists and is enforced — a Full Idea never
11
+ // auto-routes at capture (routing.js). This file re-asserts it, because R149's
12
+ // rule is meaningless if the row can convert itself before triage ever sees
13
+ // it, and an invariant relied on by a prompt should be pinned next to that
14
+ // prompt's test too.
15
+ // • the TRIAGE half is a prompt, so what is checkable is the CONTRACT: a
16
+ // recommendation is required to carry reasoning, the three shapes are named,
17
+ // the legwork is required before recommending, and nothing converts until the
18
+ // human answers.
19
+ //
20
+ // Prose assertions run against a whitespace-normalised copy — markdown is
21
+ // hard-wrapped, and a phrase worth asserting will straddle a newline.
22
+ //
23
+ // Run: node tests/idea_triage_recommends.mjs
24
+
25
+ import { strict as assert } from 'node:assert';
26
+ import { createRequire } from 'node:module';
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+
31
+ const require = createRequire(import.meta.url);
32
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
33
+ const SKILL = fs.readFileSync(path.join(ROOT, '.claude/skills/idea-triage/SKILL.md'), 'utf8');
34
+ const NORM = SKILL.replace(/\s+/g, ' ');
35
+ const { decideLanding } = require('../modules/ideas/routing.js');
36
+
37
+ let passed = 0;
38
+ let failed = 0;
39
+ function test(name, fn) {
40
+ try { fn(); passed++; console.log(` ok ${name}`); }
41
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
42
+ }
43
+
44
+ // --- the server half: it cannot convert itself before triage looks -------
45
+
46
+ test('a Full Idea never auto-routes, whatever else is true of it', () => {
47
+ // R149's whole premise is that a human judges the shape. If capture-time
48
+ // routing could turn a Full Idea into a task, that judgement would arrive too
49
+ // late to matter.
50
+ for (const grade of ['full', 'FULL', 'Full']) {
51
+ const d = decideLanding({ goalId: 1000062, kind: 'feature', grade, filerRank: 'archon' });
52
+ assert.equal(d.route, false, `grade ${grade}`);
53
+ assert.equal(d.reason, 'full_idea_never_routes');
54
+ }
55
+ });
56
+
57
+ test('and a quick idea still routes — R149 is not a brake on everything', () => {
58
+ // The contrast is the point: quick ideas promoting straight through is what a
59
+ // quick idea is FOR, and slowing that down would be a regression dressed as care.
60
+ const d = decideLanding({ goalId: 1000062, kind: 'bug', grade: 'quick', filerRank: 'archon' });
61
+ assert.equal(d.route, true);
62
+ });
63
+
64
+ // --- the triage half: recommend, with reasoning --------------------------
65
+
66
+ test('the step is scoped to a Full Idea, and says what the other grades do', () => {
67
+ assert.match(NORM, /Only for `grade='full'`/);
68
+ assert.match(NORM, /quick idea promotes straight to a task/);
69
+ // A spark is not triage's to convert: promoting it discards its author's offer.
70
+ assert.match(NORM, /discards that invitation/);
71
+ });
72
+
73
+ test('it carries the owner\'s REASON, not just the instruction', () => {
74
+ // Without the why, "recommend instead of convert" reads as ceremony and gets
75
+ // optimised away by the next person trying to make triage faster.
76
+ assert.match(NORM, /hard to say what is a task or a goal/);
77
+ assert.match(NORM, /full picture/);
78
+ });
79
+
80
+ test('all three shapes are named — including MERGE', () => {
81
+ assert.match(NORM, /\*\*Goal\*\*/);
82
+ assert.match(NORM, /\*\*Task\*\*/);
83
+ assert.match(NORM, /\*\*Merge\*\*/);
84
+ // Merge is the one people skip, and it is often right.
85
+ assert.match(NORM, /"merge" is the recommendation people skip/);
86
+ });
87
+
88
+ test('a goal recommendation drafts criteria rather than just naming the tier', () => {
89
+ assert.match(NORM, /draft.{0,4}done-when criteria/i);
90
+ });
91
+
92
+ test('the legwork is REQUIRED before recommending', () => {
93
+ // A recommendation with nothing behind it is worse than none, because it looks
94
+ // like an answer.
95
+ assert.match(NORM, /worse than no recommendation/);
96
+ assert.match(NORM, /\/recall/);
97
+ assert.match(NORM, /goal\.js list/);
98
+ });
99
+
100
+ test('the reasoning must include the REJECTED alternative', () => {
101
+ // "I recommend a goal" is an assertion; "a task would deliver only one of the
102
+ // three surfaces" is a reason someone can disagree with.
103
+ assert.match(NORM, /name the alternative you rejected/i);
104
+ });
105
+
106
+ // --- nothing converts without the human ---------------------------------
107
+
108
+ test('the no-convert rule is stated absolutely, and enumerated', () => {
109
+ assert.match(NORM, /NOTHING CONVERTS UNTIL THEY ANSWER/);
110
+ // Enumerated because "do not convert" is vague about the three separate writes
111
+ // an eager agent might make.
112
+ assert.match(NORM, /do not create the goal, do not create the task, do not mark the idea promoted/);
113
+ });
114
+
115
+ test('a human choosing differently is framed as the step WORKING', () => {
116
+ // Otherwise the recommendation becomes something to defend, and the next turn
117
+ // is an argument instead of a build.
118
+ assert.match(NORM, /that is the step working/);
119
+ assert.match(NORM, /build what they chose, not what you argued for/);
120
+ });
121
+
122
+ test('it names the server invariant it leans on', () => {
123
+ // So the next reader knows the two halves are one rule in two places, and does
124
+ // not "simplify" either half away believing the other covers it.
125
+ assert.match(NORM, /full_idea_never_routes/);
126
+ });
127
+
128
+ // --- it does not restate the template ----------------------------------
129
+
130
+ test('the skill restates no field label, and no key as a code identifier', () => {
131
+ const { BASE_TEMPLATE } = require('../modules/ideas/templates.js');
132
+ // LABELS are checked over the whole file: they are distinctive canonical
133
+ // question text, so any occurrence is a restatement.
134
+ for (const f of BASE_TEMPLATE.fields) {
135
+ assert.ok(!SKILL.includes(f.label), `restates the label "${f.label}"`);
136
+ }
137
+ // KEYS are checked only inside CODE — backticks and fenced blocks. One of them
138
+ // is `fit`, an ordinary English word, and this skill legitimately says "how does
139
+ // it fit" in prose. What would actually be a forked template is a key written as
140
+ // an identifier: a hard-coded field list, a JSON example, a payload sketch. So
141
+ // the assertion follows the meaning rather than the substring.
142
+ const code = [
143
+ ...[...SKILL.matchAll(/```[\s\S]*?```/g)].map((m) => m[0]),
144
+ ...[...SKILL.matchAll(/`([^`\n]+)`/g)].map((m) => m[1]),
145
+ ].join('\n');
146
+ for (const f of BASE_TEMPLATE.fields) {
147
+ assert.ok(
148
+ !new RegExp(`\\b${f.key}\\b`).test(code),
149
+ `the key "${f.key}" appears as a code identifier — the template is being restated`,
150
+ );
151
+ }
152
+ });
153
+
154
+ console.log(`\nidea_triage_recommends: ${passed} passed, ${failed} failed`);
155
+ if (failed > 0) process.exit(1);