@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 +23 -13
- package/.claude/skills/idea-triage/SKILL.md +67 -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 +5 -1
- package/tests/idea_triage_grade.mjs +167 -0
- package/tests/idea_triage_recommends.mjs +155 -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.303",
|
|
6
|
+
"core_contract": "1.19.303",
|
|
7
|
+
"source_commit": "fd08cc750b621521b54d357645136366c89f0316",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
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":
|
|
14
|
+
"functional_verbatim": 1412,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
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": "
|
|
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": "
|
|
4580
|
+
"sha256": "5721cb7973d1dc844692348aeb2462d3adfcba9929f7146f6a872c6de15dd710"
|
|
4581
4581
|
},
|
|
4582
4582
|
{
|
|
4583
4583
|
"path": "package.json",
|
|
4584
4584
|
"mode": "0000644",
|
|
4585
|
-
"sha256": "
|
|
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": "
|
|
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": "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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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,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.
|
|
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);
|