@bongos/core 1.19.295 → 1.19.296

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.295",
6
- "core_contract": "1.19.295",
7
- "source_commit": "a9c6feaf0d724e69e2c74fae890f66887a1f6052",
5
+ "core_version": "1.19.296",
6
+ "core_contract": "1.19.296",
7
+ "source_commit": "a0c264baf117184273d9a83f7c158844920f1d48",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-22T18:22:26.661Z",
9
+ "built_at": "2026-08-22T18:35:34.578Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 321,
13
13
  "agent_docs_stubbed": 22,
14
- "functional_verbatim": 1397,
14
+ "functional_verbatim": 1398,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1740,
20
- "tree_sha256": "0d0f553d620644b95da343b98d6a1bb50e5e934ea015e43de5f7630c1d505842",
19
+ "file_count": 1741,
20
+ "tree_sha256": "a34c536a2753b233ccd552b62e7fcfbe3768304013f4130c641a9bb51d7e51d5",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -347,12 +347,12 @@
347
347
  {
348
348
  "path": "clients/bongos-client/README.md",
349
349
  "mode": "0000644",
350
- "sha256": "c5008815c70e20b7bbc49668082bd6886f1ac406d948a4d27f5f9fd4ff70188e"
350
+ "sha256": "cc15a1c79d2dd761b8b50190f72d69b2ccec0a56bdd812a82913213a4b811da6"
351
351
  },
352
352
  {
353
353
  "path": "clients/bongos-client/bongos-client.global.js",
354
354
  "mode": "0000644",
355
- "sha256": "91cc8b99c22e82ef7bbcb93f4b1596e88d772bc50f1baafdd373c8fe0e9c8ce5"
355
+ "sha256": "b2add6cdda6bf67a34a33864f5c6c0a1bdaf7f3ac21314800d222b5f4be55eb5"
356
356
  },
357
357
  {
358
358
  "path": "clients/bongos-client/examples/hello-world.mjs",
@@ -362,17 +362,17 @@
362
362
  {
363
363
  "path": "clients/bongos-client/index.cjs",
364
364
  "mode": "0000644",
365
- "sha256": "a7cd2351a67f71cf889601718f792b299bbbf663c62d0fbdb43babc83a83b8ba"
365
+ "sha256": "b6f803fa3594804e6781eab762eb91241c9146c1e3aa09a8883dd8015a55dc3b"
366
366
  },
367
367
  {
368
368
  "path": "clients/bongos-client/index.d.ts",
369
369
  "mode": "0000644",
370
- "sha256": "57dc78f1c14c72595b1efe6b269161732be2e84caa7823ebd82f045e26e4d76f"
370
+ "sha256": "ee350739d64eb2ddce22fa0378ece2eebc363eae96c7755f087363b87e9581fe"
371
371
  },
372
372
  {
373
373
  "path": "clients/bongos-client/index.mjs",
374
374
  "mode": "0000644",
375
- "sha256": "a077e8810feaa0efbfe25234b4153db206089deaed9071b45c2ae2f42f06fdaa"
375
+ "sha256": "9f4db5e5579a3e401a9d56ee473436b8c6cd00e82f209de784c321131f6e420d"
376
376
  },
377
377
  {
378
378
  "path": "clients/bongos-client/package.json",
@@ -1412,12 +1412,12 @@
1412
1412
  {
1413
1413
  "path": "docs/api-reference.md",
1414
1414
  "mode": "0000644",
1415
- "sha256": "d501dff7e10bbb0cee28717f652c26fca962f8f756a98b3be6ff7f361a92d967"
1415
+ "sha256": "f7102579bfe64bb798e9ebc826849857218da439113c6f7b049cfc9a580f863f"
1416
1416
  },
1417
1417
  {
1418
1418
  "path": "docs/api/openapi.json",
1419
1419
  "mode": "0000644",
1420
- "sha256": "ebef1d04e8bc3e299c24a6113b2bc0ec43a92a0e93ff60fc801087bbdbb08fab"
1420
+ "sha256": "a3d866c17cdb60660fb7603de26faccb0fd0fa345e47c26d9703e2fc1fdbfd40"
1421
1421
  },
1422
1422
  {
1423
1423
  "path": "docs/architecture.md",
@@ -1552,7 +1552,7 @@
1552
1552
  {
1553
1553
  "path": "docs/file-map.md",
1554
1554
  "mode": "0000644",
1555
- "sha256": "a1c109f23cb4916b89bbc5ba522ebee6413bcb812569a30cbc1e0a7a00f8e9bc"
1555
+ "sha256": "9a2f67ab5989d068060bfe336d17ae88a33484fa8cbb5a92f23534b1c6de6289"
1556
1556
  },
1557
1557
  {
1558
1558
  "path": "docs/handoff-template.md",
@@ -2807,7 +2807,7 @@
2807
2807
  {
2808
2808
  "path": "modules/dev-box/app/src/vendor/bongos-client.cjs",
2809
2809
  "mode": "0000644",
2810
- "sha256": "a7cd2351a67f71cf889601718f792b299bbbf663c62d0fbdb43babc83a83b8ba"
2810
+ "sha256": "b6f803fa3594804e6781eab762eb91241c9146c1e3aa09a8883dd8015a55dc3b"
2811
2811
  },
2812
2812
  {
2813
2813
  "path": "modules/dev-box/app/test/unit.mjs",
@@ -3707,7 +3707,7 @@
3707
3707
  {
3708
3708
  "path": "modules/ideas/inbox.js",
3709
3709
  "mode": "0000644",
3710
- "sha256": "19af72245517260ac6ee670c93fa3cda99a678da25a7fa6a5d6ec565995201ce"
3710
+ "sha256": "33a25e6bd24ee0952c03e2aeab3e1edb68f8cf84989ee04206c9efdb7d202fa7"
3711
3711
  },
3712
3712
  {
3713
3713
  "path": "modules/ideas/migrations/ideas_001_grades.sql",
@@ -3742,7 +3742,7 @@
3742
3742
  {
3743
3743
  "path": "modules/ideas/routes/inbox.js",
3744
3744
  "mode": "0000644",
3745
- "sha256": "8b832303a87a6caf9fd4f7779967576bb615bcb893640171beeef175c8783174"
3745
+ "sha256": "7de2735020ddd526ded8ca252fbfba157ee5eb174895660d37ac713b9d0d6fd2"
3746
3746
  },
3747
3747
  {
3748
3748
  "path": "modules/ideas/routing.js",
@@ -4537,12 +4537,12 @@
4537
4537
  {
4538
4538
  "path": "package-lock.json",
4539
4539
  "mode": "0000644",
4540
- "sha256": "930aad90fce5c0ac0d66ec5e44c4948f3b704a790037b9b4856fe4a022e9a08f"
4540
+ "sha256": "b937444a6338c17ae2692d47f126d612f87c2429783a3d9a95e4c5d6076f3f5b"
4541
4541
  },
4542
4542
  {
4543
4543
  "path": "package.json",
4544
4544
  "mode": "0000644",
4545
- "sha256": "cbd88d46d805881d4eb8cb4f35b34b77bb1d0b22be90682bc8cd47699b445a58"
4545
+ "sha256": "ce72a3e5a70ae07f431f223dccf12d2c896474bcb1f0d62724aea22206b23dde"
4546
4546
  },
4547
4547
  {
4548
4548
  "path": "public-docs/index.html",
@@ -5782,7 +5782,7 @@
5782
5782
  {
5783
5783
  "path": "scripts/gds/spark.js",
5784
5784
  "mode": "0000755",
5785
- "sha256": "27a98a84ad1682bc9d17286c675fb75d14e2ff529d7ce43f848f54a631ee99bc"
5785
+ "sha256": "61ad614ec3fb8cbfc98d815629fd3ccafd179a8f2c058539103c9bf4e23e9504"
5786
5786
  },
5787
5787
  {
5788
5788
  "path": "scripts/gds/start.js",
@@ -6197,7 +6197,7 @@
6197
6197
  {
6198
6198
  "path": "src/module-api.js",
6199
6199
  "mode": "0000644",
6200
- "sha256": "e7b52e29073dcbe62ed6c069753c22f9c7cf3a69cf8987bc2f91a7b4b9571d99"
6200
+ "sha256": "e5f489e0d2b9cc6d9c73cc853132a8a7a7e3fbcd8ea2325284d58143d4020a05"
6201
6201
  },
6202
6202
  {
6203
6203
  "path": "src/module-loader/catalog.js",
@@ -7519,6 +7519,11 @@
7519
7519
  "mode": "0000644",
7520
7520
  "sha256": "fb5af0aa1b34116114a1c734c548cafaa52eefda57bd145c0008995bc69addee"
7521
7521
  },
7522
+ {
7523
+ "path": "tests/idea_spark_queue.mjs",
7524
+ "mode": "0000644",
7525
+ "sha256": "6bea289f0473d07e21acd0b45b50511f061d8d1f0d09b7437a909081ea83344a"
7526
+ },
7522
7527
  {
7523
7528
  "path": "tests/idea_task_lineage.mjs",
7524
7529
  "mode": "0000644",
@@ -5,7 +5,7 @@ A **generated**, zero-dependency typed client for the Bongos API — produced fr
5
5
  by hand; it regenerates when the spec changes, so it can never drift from the routes.
6
6
 
7
7
  - API version: **v1** (served at `/api/bongos/v1`)
8
- - 296 operations across 47 resource groups
8
+ - 297 operations across 47 resource groups
9
9
 
10
10
  ## Install
11
11
 
@@ -415,6 +415,8 @@ function createClient(opts = {}) {
415
415
  postInboxIdSpark: (args) => request("POST", "/inbox/{id}/spark", { hasBody: true }, args),
416
416
  // GET /inbox/awaiting-nod — rank: metic+archon — GET /inbox/awaiting-nod
417
417
  getInboxAwaitingNod: (args) => request("GET", "/inbox/awaiting-nod", { hasBody: false }, args),
418
+ // GET /inbox/sparks — rank: any-builder — GET /inbox/sparks
419
+ getInboxSparks: (args) => request("GET", "/inbox/sparks", { hasBody: false }, args),
418
420
  },
419
421
  "instance": {
420
422
  // GET /instance — rank: public — GET /instance
@@ -414,6 +414,8 @@ function createClient(opts = {}) {
414
414
  postInboxIdSpark: (args) => request("POST", "/inbox/{id}/spark", { hasBody: true }, args),
415
415
  // GET /inbox/awaiting-nod — rank: metic+archon — GET /inbox/awaiting-nod
416
416
  getInboxAwaitingNod: (args) => request("GET", "/inbox/awaiting-nod", { hasBody: false }, args),
417
+ // GET /inbox/sparks — rank: any-builder — GET /inbox/sparks
418
+ getInboxSparks: (args) => request("GET", "/inbox/sparks", { hasBody: false }, args),
417
419
  },
418
420
  "instance": {
419
421
  // GET /instance — rank: public — GET /instance
@@ -50,6 +50,7 @@ export interface GetHealthzResponse { ok: boolean; auth_configured: unknown }
50
50
  export interface GetInboxAwaitingNodResponse { awaiting: unknown; count: unknown }
51
51
  export interface GetInboxIdResponse { idea: unknown }
52
52
  export interface GetInboxResponse { ideas: unknown; open_count: unknown }
53
+ export interface GetInboxSparksResponse { sparks: unknown; count: unknown }
53
54
  export interface GetLeaderboardResponse { leaderboard: unknown; page: unknown }
54
55
  export interface GetLearningsResponse { learnings: unknown }
55
56
  export interface GetMeAchievementsResponse { unlocked: unknown }
@@ -677,6 +678,8 @@ export interface BongosClient {
677
678
  postInboxIdSpark(args?: RequestArgs): Promise<PostInboxIdSparkResponse>;
678
679
  /** GET /inbox/awaiting-nod — rank: metic+archon */
679
680
  getInboxAwaitingNod(args?: RequestArgs): Promise<GetInboxAwaitingNodResponse>;
681
+ /** GET /inbox/sparks — rank: any-builder */
682
+ getInboxSparks(args?: RequestArgs): Promise<GetInboxSparksResponse>;
680
683
  };
681
684
  "instance": {
682
685
  /** GET /instance — rank: public */
@@ -411,6 +411,8 @@ export function createClient(opts = {}) {
411
411
  postInboxIdSpark: (args) => request("POST", "/inbox/{id}/spark", { hasBody: true }, args),
412
412
  // GET /inbox/awaiting-nod — rank: metic+archon — GET /inbox/awaiting-nod
413
413
  getInboxAwaitingNod: (args) => request("GET", "/inbox/awaiting-nod", { hasBody: false }, args),
414
+ // GET /inbox/sparks — rank: any-builder — GET /inbox/sparks
415
+ getInboxSparks: (args) => request("GET", "/inbox/sparks", { hasBody: false }, args),
414
416
  },
415
417
  "instance": {
416
418
  // GET /instance — rank: public — GET /instance
@@ -6307,6 +6307,44 @@
6307
6307
  ]
6308
6308
  }
6309
6309
  },
6310
+ "/inbox/sparks": {
6311
+ "get": {
6312
+ "operationId": "get_inbox_sparks",
6313
+ "tags": [
6314
+ "inbox"
6315
+ ],
6316
+ "summary": "GET /inbox/sparks",
6317
+ "description": "GET /inbox/sparks — THE SPARK QUEUE (task 1002921 / BV1.R145, goal 1000062). Big Idea Sparks awaiting development: the ideator craft's pickup surface, and the first place one craft sources work from another craft's output. READABLE BY ANY BUILDER, by design and by the done-when. A pickup surface that only trusted builders could read would defeat its own purpose — the whole point is that someone OTHER than the author comes along and develops the thought. The author's consent is already recorded (the R144 mark is what put the row here), so no further gate belongs on the read. Contrast /inbox/awaiting-nod directly above, which IS gated: that queue exists to be ACTED on by a triager, this one to be BROWSED by anyone. DECLARED BEFORE `GET /inbox/:id` DELIBERATELY — and this ordering is load-bearing, not stylistic. Express matches in declaration order, so with the routes the other way round `/inbox/sparks` binds `:id = 'sparks'` and parseId turns the whole queue into a 400 bad_id. Same reason /inbox/awaiting-nod sits above it. tests/idea_spark_queue.mjs pins the relative order so a future insertion cannot quietly re-shadow it. rank: any-builder — a pickup surface is public to builders (the R144 mark is the consent). Deliberately NOT pinned in route-rank-check: a read with no gate to protect has no downgrade to catch, and pinning it would only assert that a read stays open.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
6318
+ "x-rank": "any-builder",
6319
+ "x-source": "modules/ideas/routes/inbox.js",
6320
+ "responses": {
6321
+ "200": {
6322
+ "description": "Success.",
6323
+ "content": {
6324
+ "application/json": {
6325
+ "schema": {
6326
+ "$ref": "#/components/schemas/GetInboxSparksResponse"
6327
+ }
6328
+ }
6329
+ }
6330
+ },
6331
+ "400": {
6332
+ "$ref": "#/components/responses/BadRequest"
6333
+ },
6334
+ "401": {
6335
+ "$ref": "#/components/responses/Unauthorized"
6336
+ },
6337
+ "403": {
6338
+ "$ref": "#/components/responses/Forbidden"
6339
+ }
6340
+ },
6341
+ "security": [
6342
+ {
6343
+ "builderSession": []
6344
+ }
6345
+ ]
6346
+ }
6347
+ },
6310
6348
  "/inbox/{id}": {
6311
6349
  "get": {
6312
6350
  "operationId": "get_inbox_id",
@@ -14028,6 +14066,17 @@
14028
14066
  "open_count"
14029
14067
  ]
14030
14068
  },
14069
+ "GetInboxSparksResponse": {
14070
+ "type": "object",
14071
+ "properties": {
14072
+ "sparks": {},
14073
+ "count": {}
14074
+ },
14075
+ "required": [
14076
+ "sparks",
14077
+ "count"
14078
+ ]
14079
+ },
14031
14080
  "GetLeaderboardResponse": {
14032
14081
  "type": "object",
14033
14082
  "properties": {
@@ -18707,9 +18756,9 @@
18707
18756
  "description": "A required dependency/feature is not configured or is temporarily down."
18708
18757
  }
18709
18758
  },
18710
- "x-endpoint-count": 296,
18711
- "x-schema-count": 329,
18759
+ "x-endpoint-count": 297,
18760
+ "x-schema-count": 330,
18712
18761
  "x-undocumented-bodies": 8,
18713
- "x-response-schemas": 220,
18762
+ "x-response-schemas": 221,
18714
18763
  "x-generated-by": "scripts/gds/gen-api-docs.js"
18715
18764
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Bongos API reference
4
4
 
5
- > **Generated from the live route files** — the route file is authoritative. 296 endpoints across 47 route files.
5
+ > **Generated from the live route files** — the route file is authoritative. 297 endpoints across 47 route files.
6
6
  > Machine-readable spec: [`docs/api/openapi.json`](api/openapi.json) (OpenAPI 3.1). Rendered docs site: **`/docs`** (e.g. `cloudbongos.com/docs`).
7
7
 
8
8
  Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>.md)): `public` < `any-builder` < `metic+archon` < `archon`.
@@ -258,7 +258,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
258
258
  |---|---|---|---|---|
259
259
  | GET | `/api/bongos/healthz` | `public` | — | liveness probe; status.example.com hits this anonymously. |
260
260
 
261
- ## `inbox` (10)
261
+ ## `inbox` (11)
262
262
 
263
263
  | Method | Path | Rank | Body | Description |
264
264
  |---|---|---|---|---|
@@ -272,6 +272,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
272
272
  | POST | `/api/bongos/inbox/:id/spark` | `any-builder` | — | Mark a Quick Idea as a Big Idea Spark — "I have the spark but not the full picture, someone develop this". |
273
273
  | DELETE | `/api/bongos/inbox/:id/spark` | `any-builder` | — | Withdraw the Big Idea Spark mark — the reversibility half of R144. |
274
274
  | GET | `/api/bongos/inbox/awaiting-nod` | `metic+archon` | — | GET /inbox/awaiting-nod — the NOD QUEUE (task 1003066, BV1.R05). |
275
+ | GET | `/api/bongos/inbox/sparks` | `any-builder` | — | GET /inbox/sparks — THE SPARK QUEUE (task 1002921 / BV1.R145, goal 1000062). |
275
276
 
276
277
  ## `instance` (1)
277
278
 
package/docs/file-map.md CHANGED
@@ -175,7 +175,7 @@
175
175
  │ ├── smoke-box.sh ← (#598) hermetic box-lifecycle smoke (no DB/DO/network): runs tests/boxes.mjs + tests/cf_tunnel.mjs (#771 named tunnel) + route-rank + module-load
176
176
  │ ├── recall.js ← `/recall` CLI: two-call progressive-disclosure search (summary → `--expand <id>`) over Bongos retrieval layer (ADR 0060); `node scripts/gds/recall.js <query>`
177
177
  │ ├── capture.js ← file an idea_inbox row from the CLI (the ideas.capture kernel port); backs the "capture a tangent" flow across skills
178
- │ ├── spark.js ← (task 1002920 / R144) mark one of your OWN quick ideas as a Big Idea Spark — "someone develop this"; `--undo` withdraws it. Author-only server-side (the mark is the consent); its own command because sparking acts on an EXISTING idea, which is why capture.js refuses grade='spark'
178
+ │ ├── spark.js ← (tasks 1002920 / R144 + 1002921 / R145) mark one of your OWN quick ideas as a Big Idea Spark — "someone develop this"; `--undo` withdraws it; `--list` is the SPARK QUEUE (what awaits development, readable by any builder). Author-only server-side for the mark (it is the consent); its own command because sparking acts on an EXISTING idea, which is why capture.js refuses grade='spark'
179
179
  │ ├── worktree.js ← (task 1002809) junction-safe worktree add/remove/list — outside-repo worktrees get a node_modules junction via node (no shell quoting), removed BEFORE `git worktree remove`
180
180
  │ ├── learning-capture.js ← file a `learnings` row (DB-backed short-form rule capture, replacing hand-edited recipe files for a single gotcha)
181
181
  │ ├── docs-entropy.js ← ([#…] docs-entropy) markdown-corpus linter: broken internal links + cross-file duplicate-prose detector across every tracked `.md`; `--verbose` / `--file-ideas`
@@ -414,6 +414,8 @@ function createClient(opts = {}) {
414
414
  postInboxIdSpark: (args) => request("POST", "/inbox/{id}/spark", { hasBody: true }, args),
415
415
  // GET /inbox/awaiting-nod — rank: metic+archon — GET /inbox/awaiting-nod
416
416
  getInboxAwaitingNod: (args) => request("GET", "/inbox/awaiting-nod", { hasBody: false }, args),
417
+ // GET /inbox/sparks — rank: any-builder — GET /inbox/sparks
418
+ getInboxSparks: (args) => request("GET", "/inbox/sparks", { hasBody: false }, args),
417
419
  },
418
420
  "instance": {
419
421
  // GET /instance — rank: public — GET /instance
@@ -782,6 +782,91 @@ async function countRoutedAwaitingNod(deps = {}) {
782
782
  return rows[0] ? rows[0].n : 0;
783
783
  }
784
784
 
785
+ // -------------------------------------------------------------------------
786
+ // The spark queue (task 1002921 / BV1.R145, goal 1000062)
787
+ //
788
+ // "What Big Idea Sparks are awaiting development?" — the ideator craft's pickup
789
+ // surface, and the first place in this codebase where one craft sources work
790
+ // from another craft's output.
791
+ //
792
+ // THE PREDICATE IS grade='spark' AND status='open', AND THAT IS THE WHOLE
793
+ // QUEUE. There is deliberately no `developed` flag and no separate queue table:
794
+ // R146 develops a spark into a Full Idea IN PLACE on the same idea_inbox row
795
+ // (same id, same captured_by), which flips its grade spark→full — so a developed
796
+ // spark leaves this queue as a CONSEQUENCE of being developed, not because
797
+ // anything remembered to remove it. A second source of truth for "still
798
+ // waiting" is a thing that can disagree with the grade; this cannot.
799
+ //
800
+ // AN INNER JOIN, WHICH INVERTS THIS MODULE'S USUAL RULE ON PURPOSE. Every other
801
+ // idea read LEFT JOINs ideas_grades and COALESCEs to 'quick', because an
802
+ // un-graded idea must still appear. Here the opposite is correct and safe:
803
+ // grade='spark' can never be implicit — absence of a row means quick — so a
804
+ // spark ALWAYS has a real row, and starting from ideas_grades lets
805
+ // ideas_grades_grade_score_idx (ideas_001, written for this very read) drive the
806
+ // scan instead of filtering a full idea_inbox pass afterwards.
807
+ //
808
+ // ORDERING — "newest-or-best first", and only the newest half is real. A spark
809
+ // carries no completeness score by construction (it has no template fields to
810
+ // score), so there is no "best" signal to sort by: upvotes live on builders as
811
+ // karma, not per-idea, and inventing a quality metric for an undeveloped thought
812
+ // is exactly the guess this queue should not be making. captured_at DESC, id
813
+ // DESC as a stable tiebreak for same-timestamp rows. When a real per-idea
814
+ // signal exists, this ORDER BY is the one line that changes.
815
+ //
816
+ // The skill-friction visibility gate (V3.R70) is applied here too, on the same
817
+ // title-prefix rule listOpenIdeas uses. A routine-filed friction row is unlikely
818
+ // to ever be sparked (marking requires an author), but the two idea listings
819
+ // agreeing by construction beats them agreeing by accident.
820
+ function sparkQueueWhere(viewerRank, params) {
821
+ const where = [`g.grade = 'spark'`, `i.status = 'open'`];
822
+ if (!rankCanSeeSkillFriction(viewerRank)) {
823
+ params.push(`${SKILL_FRICTION_TAG}%`);
824
+ where.push(`i.title NOT LIKE $${params.length}`);
825
+ }
826
+ return where;
827
+ }
828
+
829
+ async function listSparkQueue({ limit = 50, viewerRank = null } = {}, deps = {}) {
830
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
831
+ const params = [];
832
+ const where = sparkQueueWhere(viewerRank, params);
833
+ params.push(limit);
834
+ const { rows } = await activePool.query(
835
+ // `b.github_login` because attribution is the POINT of this surface: an
836
+ // ideator picking work up should see whose thought they are developing, and
837
+ // R146 has to preserve that name. LEFT JOIN — a builder row can be missing.
838
+ `SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q},
839
+ b.github_login AS filed_by_login
840
+ FROM ideas_grades g
841
+ JOIN idea_inbox i ON i.id = g.idea_id
842
+ LEFT JOIN builders b ON b.id = i.captured_by
843
+ WHERE ${where.join(' AND ')}
844
+ ORDER BY i.captured_at DESC, i.id DESC
845
+ LIMIT $${params.length}`,
846
+ params
847
+ );
848
+ return rows;
849
+ }
850
+
851
+ // The count behind the queue's pill — the SAME predicate as the list (the
852
+ // countRoutedAwaitingNod / countOpenIdeas precedent), so the number a viewer is
853
+ // shown can never disagree with the rows they can actually see. It deliberately
854
+ // takes viewerRank too: a count that ignored the visibility gate would tell a
855
+ // Xenos there are sparks they cannot find.
856
+ async function countSparkQueue({ viewerRank = null } = {}, deps = {}) {
857
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
858
+ const params = [];
859
+ const where = sparkQueueWhere(viewerRank, params);
860
+ const { rows } = await activePool.query(
861
+ `SELECT COUNT(*)::int AS n
862
+ FROM ideas_grades g
863
+ JOIN idea_inbox i ON i.id = g.idea_id
864
+ WHERE ${where.join(' AND ')}`,
865
+ params
866
+ );
867
+ return rows[0] ? rows[0].n : 0;
868
+ }
869
+
785
870
  // -------------------------------------------------------------------------
786
871
  // Grade writes (task 1002916 / BV1.R140)
787
872
  //
@@ -1109,6 +1194,9 @@ module.exports = {
1109
1194
  // the nod queue (task 1003066, BV1.R05)
1110
1195
  listRoutedAwaitingNod,
1111
1196
  countRoutedAwaitingNod,
1197
+ // the spark queue (task 1002921, BV1.R145) — sparks awaiting development
1198
+ listSparkQueue,
1199
+ countSparkQueue,
1112
1200
  // criterion-proposal ratification (V3.R31 / #252)
1113
1201
  ratifyCriterionProposal,
1114
1202
  parseProposalBody,
@@ -312,6 +312,43 @@ module.exports = function buildInboxRouter() {
312
312
  }
313
313
  });
314
314
 
315
+
316
+ // GET /inbox/sparks — THE SPARK QUEUE (task 1002921 / BV1.R145, goal 1000062).
317
+ //
318
+ // Big Idea Sparks awaiting development: the ideator craft's pickup surface,
319
+ // and the first place one craft sources work from another craft's output.
320
+ //
321
+ // READABLE BY ANY BUILDER, by design and by the done-when. A pickup surface
322
+ // that only trusted builders could read would defeat its own purpose — the
323
+ // whole point is that someone OTHER than the author comes along and develops
324
+ // the thought. The author's consent is already recorded (the R144 mark is what
325
+ // put the row here), so no further gate belongs on the read. Contrast
326
+ // /inbox/awaiting-nod directly above, which IS gated: that queue exists to be
327
+ // ACTED on by a triager, this one to be BROWSED by anyone.
328
+ //
329
+ // DECLARED BEFORE `GET /inbox/:id` DELIBERATELY — and this ordering is
330
+ // load-bearing, not stylistic. Express matches in declaration order, so with
331
+ // the routes the other way round `/inbox/sparks` binds `:id = 'sparks'` and
332
+ // parseId turns the whole queue into a 400 bad_id. Same reason
333
+ // /inbox/awaiting-nod sits above it. tests/idea_spark_queue.mjs pins the
334
+ // relative order so a future insertion cannot quietly re-shadow it.
335
+ //
336
+ // rank: any-builder — a pickup surface is public to builders (the R144 mark is
337
+ // the consent). Deliberately NOT pinned in route-rank-check: a read with no
338
+ // gate to protect has no downgrade to catch, and pinning it would only assert
339
+ // that a read stays open.
340
+ router.get('/inbox/sparks', api.requireBuilder, api.asyncHandler('GET /inbox/sparks', async (req, res) => {
341
+ const limitRaw = Number(req.query.limit);
342
+ const limit = Number.isInteger(limitRaw) && limitRaw > 0 && limitRaw <= 100 ? limitRaw : 50;
343
+ // Both halves take the viewer's rank so the pill can never claim sparks the
344
+ // list will not show (the countRoutedAwaitingNod precedent).
345
+ const viewerRank = req.builder && req.builder.rank;
346
+ const [sparks, count] = await Promise.all([
347
+ inbox.listSparkQueue({ limit, viewerRank }),
348
+ inbox.countSparkQueue({ viewerRank }),
349
+ ]);
350
+ return res.json({ sparks, count });
351
+ }, { errorCode: 'spark_queue_failed' }));
315
352
  // rank: any-builder — single idea read for the "#NNN" deep-link resolver
316
353
  // (#1047). Mirrors GET /blockers/:id. The hall resolves /#/idea/N by id, but
317
354
  // the /inbox LIST returns only the open-first-50, so a closed/older idea ref
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.295",
3
+ "version": "1.19.296",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.295",
9
+ "version": "1.19.296",
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.295",
3
+ "version": "1.19.296",
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",
@@ -13,7 +13,15 @@
13
13
  // grade='spark' for the same reason). The builders-hall control is R154; this is
14
14
  // the terminal path, and the two hit the same route.
15
15
  //
16
+ // --list is the other side of the same idea: the SPARK QUEUE (task 1002921 /
17
+ // BV1.R145) — what other builders have marked as needing development. It lives
18
+ // here rather than in its own script because "mark it" and "see what is marked"
19
+ // are one mental model, and a queue nobody can read is not a pickup surface. The
20
+ // queue is readable by ANY builder: the author's mark is already the consent.
21
+ //
16
22
  // Usage:
23
+ // node scripts/gds/spark.js --list # the spark queue: what awaits development
24
+ // node scripts/gds/spark.js --list --limit 10
17
25
  // node scripts/gds/spark.js 123 # mark idea 123 as a spark
18
26
  // node scripts/gds/spark.js 123 --undo # withdraw the mark (back to quick)
19
27
  //
@@ -22,7 +30,7 @@
22
30
  // 1 api / network error, or the server refused (not yours / not a quick idea)
23
31
  // 2 bad invocation
24
32
 
25
- const { cliClient, requireSession, hasFlag } = require('./cli-lib');
33
+ const { cliClient, requireSession, hasFlag, arg, hallBase } = require('./cli-lib');
26
34
 
27
35
  // The server's refusals, in the ideator's own words. Anything unmapped falls
28
36
  // through to the raw envelope — a new server-side rule should be visible, not
@@ -56,15 +64,58 @@ function explain(status, data) {
56
64
  return `spark failed (${status}): ${rendered}`;
57
65
  }
58
66
 
67
+ // Render the queue. Kept separate from the fetch so it is testable without a
68
+ // session, and so the empty case reads as an answer ("nothing is waiting")
69
+ // rather than as a blank screen that looks like a failure.
70
+ function renderQueue({ sparks, count }) {
71
+ const lines = [];
72
+ if (!sparks || sparks.length === 0) {
73
+ lines.push('No sparks are awaiting development.');
74
+ lines.push('A spark is a quick idea whose AUTHOR marked it as needing development —');
75
+ lines.push('mark one of your own with: node scripts/gds/spark.js <idea-id>');
76
+ return lines.join('\n');
77
+ }
78
+ const shown = sparks.length;
79
+ const total = Number.isInteger(count) ? count : shown;
80
+ lines.push(`${total} spark${total === 1 ? '' : 's'} awaiting development${shown < total ? ` (showing ${shown})` : ''}:`);
81
+ lines.push('');
82
+ for (const s of sparks) {
83
+ // The hall link, never a bare "#NNN" — that autolinks to a GitHub issue and
84
+ // 404s, since our idea ids are not GitHub's (the CLAUDE.md refs rule).
85
+ lines.push(` ${hallBase()}/#/idea/${s.id}`);
86
+ lines.push(` ${s.title}`);
87
+ const who = s.filed_by_login ? `@${s.filed_by_login}` : 'an unnamed builder';
88
+ lines.push(` had by ${who}${s.captured_at ? ` · ${String(s.captured_at).slice(0, 10)}` : ''}`);
89
+ lines.push('');
90
+ }
91
+ lines.push('Develop one into a Full Idea and the author keeps the attribution.');
92
+ return lines.join('\n');
93
+ }
94
+
95
+ async function listQueue() {
96
+ const api = await cliClient();
97
+ const limitRaw = arg('--limit');
98
+ const limit = limitRaw != null && /^[0-9]+$/.test(limitRaw) ? Number(limitRaw) : null;
99
+ const r = await api.inbox.getInboxSparks(limit ? { query: { limit } } : {});
100
+ if (!r.ok) {
101
+ console.error(explain(r.status, r.data));
102
+ process.exit(1);
103
+ }
104
+ console.log(renderQueue(r.data || {}));
105
+ }
106
+
59
107
  async function main() {
60
108
  await requireSession();
61
109
 
110
+ if (hasFlag('--list')) return listQueue();
111
+
62
112
  const positional = process.argv.slice(2).filter((a) => !a.startsWith('-'));
63
113
  const id = Number(positional[0]);
64
114
  const undo = hasFlag('--undo');
65
115
 
66
116
  if (!Number.isInteger(id) || id <= 0) {
67
117
  console.error('usage: node scripts/gds/spark.js <idea-id> [--undo]');
118
+ console.error(' node scripts/gds/spark.js --list [--limit N]');
68
119
  process.exit(2);
69
120
  }
70
121
 
@@ -90,10 +141,11 @@ async function main() {
90
141
  }
91
142
  }
92
143
 
93
- // Only explain() is exported — tests/idea_spark_mark.mjs pins its message layer
94
- // against the real envelope. REFUSALS stays private: it is the message table,
95
- // reachable through explain(), and a second export would be dead surface.
96
- module.exports = { explain };
144
+ // explain() and renderQueue() are exported because both are pinned by tests —
145
+ // explain against the real envelope shape, renderQueue for the empty case and the
146
+ // hall-link form. REFUSALS stays private: it is the message table, reachable
147
+ // through explain(), and a third export would be dead surface.
148
+ module.exports = { explain, renderQueue };
97
149
 
98
150
  if (require.main === module) {
99
151
  main().catch((err) => {
package/src/module-api.js CHANGED
@@ -1017,8 +1017,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1017
1017
  // landed since 1.19.293 with no explicit bump. run 32588977219. (task 1002620)
1018
1018
  // 1.19.295 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1019
1019
  // landed since 1.19.294 with no explicit bump. run 32590508145. (task 1002620)
1020
+ // 1.19.296 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1021
+ // landed since 1.19.295 with no explicit bump. run 32591155887. (task 1002620)
1020
1022
  // ---------------------------------------------------------------------------
1021
- const CORE_VERSION = '1.19.295'; // CI auto-patch carrier (ADR 0161); see changelog above
1023
+ const CORE_VERSION = '1.19.296'; // CI auto-patch carrier (ADR 0161); see changelog above
1022
1024
 
1023
1025
  // A namespaced logger so a module's log lines are attributable + consistent.
1024
1026
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,225 @@
1
+ // tests/idea_spark_queue.mjs
2
+ //
3
+ // task 1002921 (BV1.R145, goal 1000062) — the spark queue.
4
+ //
5
+ // The done-when, as assertions: the GET returns OPEN sparks NOT YET DEVELOPED,
6
+ // newest first; a DEVELOPED spark leaves the queue; and the queue is readable by
7
+ // ANY builder (a Xenos included — it is a pickup surface, and the author's R144
8
+ // mark is the consent).
9
+ //
10
+ // It also pins two things that are load-bearing but invisible in a passing app:
11
+ // the DECLARATION ORDER against `GET /inbox/:id` (which would otherwise capture
12
+ // the literal "sparks" and 400 the whole queue), and the fact that the count and
13
+ // the list share one predicate — a pill that disagrees with its rows is the
14
+ // classic version of this bug.
15
+ //
16
+ // DB-free the idea_full_capture.mjs way: pool.query patched on the doorway before
17
+ // the modules load, so the REAL SQL is captured and the REAL handler runs.
18
+ //
19
+ // Run: node tests/idea_spark_queue.mjs
20
+
21
+ import { strict as assert } from 'node:assert';
22
+ import { createRequire } from 'node:module';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const api = require('../src/module-api.js');
26
+
27
+ let queries = [];
28
+ let listRows = [];
29
+ let countN = 0;
30
+
31
+ api.pool.query = async (sql, params = []) => {
32
+ queries.push({ sql, params });
33
+ if (/COUNT\(\*\)/i.test(sql)) return { rows: [{ n: countN }] };
34
+ return { rows: listRows };
35
+ };
36
+
37
+ const inbox = require('../modules/ideas/inbox.js');
38
+ const routesFactory = require('../modules/ideas/routes/inbox.js');
39
+ const router = routesFactory({});
40
+
41
+ const mounted = router.stack.filter((l) => l.route)
42
+ .map((l) => `${Object.keys(l.route.methods)[0].toUpperCase()} ${l.route.path}`);
43
+
44
+ function handlerFor(pathStr, method) {
45
+ const layer = router.stack.find((l) => l.route && l.route.path === pathStr && l.route.methods[method]);
46
+ assert.ok(layer, `route ${method.toUpperCase()} ${pathStr} is not mounted`);
47
+ const stack = layer.route.stack.map((s) => s.handle);
48
+ return stack[stack.length - 1];
49
+ }
50
+ const getSparks = handlerFor('/inbox/sparks', 'get');
51
+
52
+ function call({ query = {}, rank = 'metic' } = {}) {
53
+ return new Promise((resolve, reject) => {
54
+ const req = { params: {}, body: {}, query, builder: { id: '9', rank } };
55
+ const res = {
56
+ statusCode: 200,
57
+ status(c) { this.statusCode = c; return this; },
58
+ json(payload) { resolve({ status: this.statusCode, payload }); },
59
+ fail(code, status, extra) {
60
+ const s = typeof status === 'number' ? status : (status && status.status) || 500;
61
+ resolve({ status: s, payload: { error: code, ...(extra || {}) } });
62
+ },
63
+ };
64
+ Promise.resolve(getSparks(req, res, (err) => reject(err || new Error('next() called')))).catch(reject);
65
+ });
66
+ }
67
+
68
+ const listSql = () => queries.find((q) => !/COUNT\(\*\)/i.test(q.sql));
69
+ const countSql = () => queries.find((q) => /COUNT\(\*\)/i.test(q.sql));
70
+
71
+ let passed = 0;
72
+ let failed = 0;
73
+ async function test(name, fn) {
74
+ queries = [];
75
+ listRows = [];
76
+ countN = 0;
77
+ try { await fn(); passed++; console.log(` ok ${name}`); }
78
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
79
+ }
80
+
81
+ // --- declaration order: the bug that would 400 the whole queue -----------
82
+
83
+ await test('GET /inbox/sparks is declared BEFORE GET /inbox/:id', () => {
84
+ const iSparks = mounted.indexOf('GET /inbox/sparks');
85
+ const iId = mounted.indexOf('GET /inbox/:id');
86
+ assert.ok(iSparks >= 0, 'the spark queue route is mounted');
87
+ assert.ok(iId >= 0, 'the by-id read is mounted');
88
+ assert.ok(
89
+ iSparks < iId,
90
+ `declaration order is load-bearing: with :id first, "/inbox/sparks" binds id="sparks" and parseId 400s the queue (sparks@${iSparks}, :id@${iId})`,
91
+ );
92
+ });
93
+
94
+ // --- the predicate: open, undeveloped sparks, newest first ---------------
95
+
96
+ await test('the queue asks for grade=spark AND status=open — nothing else', async () => {
97
+ const r = await call();
98
+ assert.equal(r.status, 200);
99
+ const sql = listSql().sql;
100
+ assert.match(sql, /g\.grade = 'spark'/, 'only sparks');
101
+ assert.match(sql, /i\.status = 'open'/, 'only open ideas');
102
+ });
103
+
104
+ await test('a DEVELOPED spark leaves the queue by GRADE, with no `developed` flag', async () => {
105
+ await call();
106
+ const sql = listSql().sql;
107
+ // R146 develops in place and flips the grade spark→full, so grade='spark' IS
108
+ // "not yet developed". A second source of truth could disagree with the grade.
109
+ assert.doesNotMatch(sql, /developed/i, 'no separate developed column/flag');
110
+ assert.doesNotMatch(sql, /promoted_to_task_id\s*IS\s*NULL/i, 'exit is the grade flip, not a promote link');
111
+ });
112
+
113
+ await test('newest first, with a stable tiebreak', async () => {
114
+ await call();
115
+ assert.match(listSql().sql, /ORDER BY i\.captured_at DESC, i\.id DESC/);
116
+ });
117
+
118
+ await test('an INNER join from ideas_grades — a spark is never implicit', async () => {
119
+ await call();
120
+ const sql = listSql().sql;
121
+ // Inverted from every other idea read on purpose: absence of a grade row means
122
+ // 'quick', so a spark ALWAYS has a real row, and starting from ideas_grades
123
+ // lets ideas_grades_grade_score_idx drive the scan.
124
+ assert.match(sql, /FROM ideas_grades g/, 'driven by the graded table');
125
+ assert.match(sql, /JOIN idea_inbox i ON i\.id = g\.idea_id/);
126
+ assert.doesNotMatch(sql, /LEFT JOIN ideas_grades/, 'not the COALESCE-to-quick read shape');
127
+ });
128
+
129
+ await test('the author is named — attribution is the point of a pickup surface', async () => {
130
+ listRows = [{ id: '5', title: 'a spark', captured_by: '3', grade: 'spark', filed_by_login: 'someone' }];
131
+ const r = await call();
132
+ assert.match(listSql().sql, /github_login/);
133
+ assert.equal(r.payload.sparks[0].filed_by_login, 'someone');
134
+ });
135
+
136
+ // --- the count cannot disagree with the list ----------------------------
137
+
138
+ await test('count and list share ONE predicate', async () => {
139
+ countN = 7;
140
+ const r = await call();
141
+ assert.equal(r.payload.count, 7);
142
+ const l = listSql().sql;
143
+ const c = countSql().sql;
144
+ for (const clause of [/g\.grade = 'spark'/, /i\.status = 'open'/]) {
145
+ assert.match(l, clause);
146
+ assert.match(c, clause, 'the pill must not claim sparks the list will not show');
147
+ }
148
+ });
149
+
150
+ // --- readable by ANY builder -------------------------------------------
151
+
152
+ await test('a XENOS can read the queue — the R144 mark is the consent', async () => {
153
+ listRows = [{ id: '5', title: 'a spark', grade: 'spark' }];
154
+ const r = await call({ rank: 'xenos' });
155
+ assert.equal(r.status, 200, 'no rank gate on a pickup surface');
156
+ assert.equal(r.payload.sparks.length, 1);
157
+ });
158
+
159
+ await test('but the skill-friction visibility gate still applies, to BOTH halves', async () => {
160
+ await call({ rank: 'xenos' });
161
+ const l = listSql();
162
+ const c = countSql();
163
+ assert.match(l.sql, /i\.title NOT LIKE/, 'a Xenos does not see routine-filed friction rows');
164
+ assert.match(c.sql, /i\.title NOT LIKE/, 'nor are they counted for them');
165
+ assert.ok(l.params.some((p) => typeof p === 'string' && p.endsWith('%')));
166
+
167
+ queries = [];
168
+ await call({ rank: 'archon' });
169
+ assert.doesNotMatch(listSql().sql, /i\.title NOT LIKE/, 'a trusted rank sees everything');
170
+ });
171
+
172
+ // --- limit -------------------------------------------------------------
173
+
174
+ await test('limit defaults to 50 and is clamped to 1..100', async () => {
175
+ await call();
176
+ assert.equal(listSql().params.at(-1), 50, 'default');
177
+
178
+ for (const [given, want] of [['10', 10], ['0', 50], ['-5', 50], ['1000', 50], ['abc', 50], ['100', 100]]) {
179
+ queries = [];
180
+ await call({ query: { limit: given } });
181
+ assert.equal(listSql().params.at(-1), want, `limit=${given}`);
182
+ }
183
+ });
184
+
185
+ await test('the count query takes no limit — it counts the whole queue', async () => {
186
+ await call({ query: { limit: '5' } });
187
+ assert.equal(countSql().params.length, 0, 'a trusted viewer adds no filter param, and the count never takes the limit');
188
+ });
189
+
190
+ // --- the CLI's rendering of the queue ------------------------------------
191
+
192
+ const { renderQueue } = require('../scripts/gds/spark.js');
193
+
194
+ await test('the empty queue reads as an ANSWER, not a blank screen', () => {
195
+ const out = renderQueue({ sparks: [], count: 0 });
196
+ assert.match(out, /No sparks are awaiting development/);
197
+ assert.match(out, /mark one of your own/, 'it says what to do next');
198
+ });
199
+
200
+ await test('each row links to the HALL, never a bare #NNN', () => {
201
+ const out = renderQueue({
202
+ sparks: [{ id: '812', title: 'a thought', filed_by_login: 'someone', captured_at: '2026-08-20T10:11:12Z' }],
203
+ count: 1,
204
+ });
205
+ // A bare "#NNN" autolinks to a GitHub issue and 404s — our ids are not
206
+ // GitHub's. The hall deep-link resolver form is /builders/#/idea/N.
207
+ assert.match(out, /\/builders\/#\/idea\/812/);
208
+ assert.doesNotMatch(out, /(^|[^/])#812\b/, 'no bare ref');
209
+ assert.match(out, /@someone/, 'the author is named');
210
+ assert.match(out, /2026-08-20/);
211
+ });
212
+
213
+ await test('a missing author degrades to prose, not "@undefined"', () => {
214
+ const out = renderQueue({ sparks: [{ id: '1', title: 't', filed_by_login: null }], count: 1 });
215
+ assert.match(out, /an unnamed builder/);
216
+ assert.doesNotMatch(out, /undefined|null/);
217
+ });
218
+
219
+ await test('the header distinguishes "showing N" from the true total', () => {
220
+ assert.match(renderQueue({ sparks: [{ id: '1', title: 't' }], count: 5 }), /5 sparks awaiting development \(showing 1\)/);
221
+ assert.match(renderQueue({ sparks: [{ id: '1', title: 't' }], count: 1 }), /^1 spark awaiting development:/m, 'singular, no "showing"');
222
+ });
223
+
224
+ console.log(`\nidea_spark_queue: ${passed} passed, ${failed} failed`);
225
+ if (failed > 0) process.exit(1);