@bongos/core 1.19.294 → 1.19.295

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.294",
6
- "core_contract": "1.19.294",
7
- "source_commit": "fffe6738492ac7d113228cc4f9b1d5eae0ed16d3",
5
+ "core_version": "1.19.295",
6
+ "core_contract": "1.19.295",
7
+ "source_commit": "a9c6feaf0d724e69e2c74fae890f66887a1f6052",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-22T17:51:40.637Z",
9
+ "built_at": "2026-08-22T18:22:26.661Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 321,
13
13
  "agent_docs_stubbed": 22,
14
- "functional_verbatim": 1395,
14
+ "functional_verbatim": 1397,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1738,
20
- "tree_sha256": "70e835883854335f17289f54d647911185cfc4cfb1ec5b17424a88e6bbd8156f",
19
+ "file_count": 1740,
20
+ "tree_sha256": "0d0f553d620644b95da343b98d6a1bb50e5e934ea015e43de5f7630c1d505842",
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": "aaf0bb7dae10d271e9999093935fef5ae8d85c3a2e3d5b4c52e0c11fed8c581e"
350
+ "sha256": "c5008815c70e20b7bbc49668082bd6886f1ac406d948a4d27f5f9fd4ff70188e"
351
351
  },
352
352
  {
353
353
  "path": "clients/bongos-client/bongos-client.global.js",
354
354
  "mode": "0000644",
355
- "sha256": "372a5584ddad52fb524815fc7b50d1aab9163e974aac6f2c64f479e4034914a1"
355
+ "sha256": "91cc8b99c22e82ef7bbcb93f4b1596e88d772bc50f1baafdd373c8fe0e9c8ce5"
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": "7faac7fc48e714c76c2b19feaaf9c730b2c9b10ab71ddc5697c21497d02f10ef"
365
+ "sha256": "a7cd2351a67f71cf889601718f792b299bbbf663c62d0fbdb43babc83a83b8ba"
366
366
  },
367
367
  {
368
368
  "path": "clients/bongos-client/index.d.ts",
369
369
  "mode": "0000644",
370
- "sha256": "7aba0fe1d782cb6308f57dc2e5f6fe0d5ffe0f537d4ffe846bdc7a03e0f8064d"
370
+ "sha256": "57dc78f1c14c72595b1efe6b269161732be2e84caa7823ebd82f045e26e4d76f"
371
371
  },
372
372
  {
373
373
  "path": "clients/bongos-client/index.mjs",
374
374
  "mode": "0000644",
375
- "sha256": "5f1d221bfbf8d329f883a5dc3e84414728c67201d66faceff04d48732e44b13c"
375
+ "sha256": "a077e8810feaa0efbfe25234b4153db206089deaed9071b45c2ae2f42f06fdaa"
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": "8fb83f272ef8810df2c517d9514495f44016da2e23cdd67937463f40c0d1605d"
1415
+ "sha256": "d501dff7e10bbb0cee28717f652c26fca962f8f756a98b3be6ff7f361a92d967"
1416
1416
  },
1417
1417
  {
1418
1418
  "path": "docs/api/openapi.json",
1419
1419
  "mode": "0000644",
1420
- "sha256": "f51be43a281c9609980fca57d2fa11cafb460a414e506174715d53c08cef6965"
1420
+ "sha256": "ebef1d04e8bc3e299c24a6113b2bc0ec43a92a0e93ff60fc801087bbdbb08fab"
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": "9762b7f69397c28e103cdb39a9987483e23bd800a827f0d83983b62ef962616b"
1555
+ "sha256": "a1c109f23cb4916b89bbc5ba522ebee6413bcb812569a30cbc1e0a7a00f8e9bc"
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": "7faac7fc48e714c76c2b19feaaf9c730b2c9b10ab71ddc5697c21497d02f10ef"
2810
+ "sha256": "a7cd2351a67f71cf889601718f792b299bbbf663c62d0fbdb43babc83a83b8ba"
2811
2811
  },
2812
2812
  {
2813
2813
  "path": "modules/dev-box/app/test/unit.mjs",
@@ -3742,7 +3742,7 @@
3742
3742
  {
3743
3743
  "path": "modules/ideas/routes/inbox.js",
3744
3744
  "mode": "0000644",
3745
- "sha256": "47c2a3beb6bed3b206ec36344950fe115189debf75a38cf202bfafaa5dbb2adb"
3745
+ "sha256": "8b832303a87a6caf9fd4f7779967576bb615bcb893640171beeef175c8783174"
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": "4555259c80ad21945412441d859ab9ff5f68be5adb28d7c02b2d405b42339398"
4540
+ "sha256": "930aad90fce5c0ac0d66ec5e44c4948f3b704a790037b9b4856fe4a022e9a08f"
4541
4541
  },
4542
4542
  {
4543
4543
  "path": "package.json",
4544
4544
  "mode": "0000644",
4545
- "sha256": "26ce6a3328f57b80487d9b5fd31db9cb1c200c93d8f5ec307f763b39e8b28e6e"
4545
+ "sha256": "cbd88d46d805881d4eb8cb4f35b34b77bb1d0b22be90682bc8cd47699b445a58"
4546
4546
  },
4547
4547
  {
4548
4548
  "path": "public-docs/index.html",
@@ -5779,6 +5779,11 @@
5779
5779
  "mode": "0000644",
5780
5780
  "sha256": "9c3a382def8c074a37bad66d7e3deef41c7f2bb9406f02f107be0e9a7dc7bfb3"
5781
5781
  },
5782
+ {
5783
+ "path": "scripts/gds/spark.js",
5784
+ "mode": "0000755",
5785
+ "sha256": "27a98a84ad1682bc9d17286c675fb75d14e2ff529d7ce43f848f54a631ee99bc"
5786
+ },
5782
5787
  {
5783
5788
  "path": "scripts/gds/start.js",
5784
5789
  "mode": "0000644",
@@ -6057,7 +6062,7 @@
6057
6062
  {
6058
6063
  "path": "src/bongos/route-rank-check.js",
6059
6064
  "mode": "0000644",
6060
- "sha256": "97b8345a1d891151c3bc134926124a0ac929db661e0cfb26c6760c8c6030bafc"
6065
+ "sha256": "f2d893e0de84433cc99411e59aba98c1ca392fa8d5bd24449e1e9200f06d9ff8"
6061
6066
  },
6062
6067
  {
6063
6068
  "path": "src/bongos/routes.js",
@@ -6192,7 +6197,7 @@
6192
6197
  {
6193
6198
  "path": "src/module-api.js",
6194
6199
  "mode": "0000644",
6195
- "sha256": "ea4af468628d2a5b45ee1e1916622975f263fc4059cff13a49cadb898f35a4db"
6200
+ "sha256": "e7b52e29073dcbe62ed6c069753c22f9c7cf3a69cf8987bc2f91a7b4b9571d99"
6196
6201
  },
6197
6202
  {
6198
6203
  "path": "src/module-loader/catalog.js",
@@ -7509,6 +7514,11 @@
7509
7514
  "mode": "0000644",
7510
7515
  "sha256": "7bea2d388fad9a6b186c4712d6921a554f1d24c70dcae5760b381782149068e7"
7511
7516
  },
7517
+ {
7518
+ "path": "tests/idea_spark_mark.mjs",
7519
+ "mode": "0000644",
7520
+ "sha256": "fb5af0aa1b34116114a1c734c548cafaa52eefda57bd145c0008995bc69addee"
7521
+ },
7512
7522
  {
7513
7523
  "path": "tests/idea_task_lineage.mjs",
7514
7524
  "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
- - 294 operations across 47 resource groups
8
+ - 296 operations across 47 resource groups
9
9
 
10
10
  ## Install
11
11
 
@@ -409,6 +409,10 @@ function createClient(opts = {}) {
409
409
  postInboxIdRatify: (args) => request("POST", "/inbox/{id}/ratify", { hasBody: true }, args),
410
410
  // POST /inbox/{id}/resubmit — rank: any-builder — POST /inbox/:id/resubmit
411
411
  postInboxIdResubmit: (args) => request("POST", "/inbox/{id}/resubmit", { hasBody: true }, args),
412
+ // DELETE /inbox/{id}/spark — rank: any-builder — DELETE /inbox/:id/spark
413
+ deleteInboxIdSpark: (args) => request("DELETE", "/inbox/{id}/spark", { hasBody: false }, args),
414
+ // POST /inbox/{id}/spark — rank: any-builder — POST /inbox/:id/spark
415
+ postInboxIdSpark: (args) => request("POST", "/inbox/{id}/spark", { hasBody: true }, args),
412
416
  // GET /inbox/awaiting-nod — rank: metic+archon — GET /inbox/awaiting-nod
413
417
  getInboxAwaitingNod: (args) => request("GET", "/inbox/awaiting-nod", { hasBody: false }, args),
414
418
  },
@@ -408,6 +408,10 @@ function createClient(opts = {}) {
408
408
  postInboxIdRatify: (args) => request("POST", "/inbox/{id}/ratify", { hasBody: true }, args),
409
409
  // POST /inbox/{id}/resubmit — rank: any-builder — POST /inbox/:id/resubmit
410
410
  postInboxIdResubmit: (args) => request("POST", "/inbox/{id}/resubmit", { hasBody: true }, args),
411
+ // DELETE /inbox/{id}/spark — rank: any-builder — DELETE /inbox/:id/spark
412
+ deleteInboxIdSpark: (args) => request("DELETE", "/inbox/{id}/spark", { hasBody: false }, args),
413
+ // POST /inbox/{id}/spark — rank: any-builder — POST /inbox/:id/spark
414
+ postInboxIdSpark: (args) => request("POST", "/inbox/{id}/spark", { hasBody: true }, args),
411
415
  // GET /inbox/awaiting-nod — rank: metic+archon — GET /inbox/awaiting-nod
412
416
  getInboxAwaitingNod: (args) => request("GET", "/inbox/awaiting-nod", { hasBody: false }, args),
413
417
  },
@@ -9,6 +9,7 @@ export interface DeleteBoxSshKeyIdResponse { ok: boolean; deleted: unknown }
9
9
  export interface DeleteDependenciesResponse { ok: boolean }
10
10
  export interface DeleteGovernmentAssignmentsBuilderIdRankKeyResponse { ok: boolean; builder_id: unknown; rank_key: unknown; removed: unknown }
11
11
  export interface DeleteGovernmentRanksRankKeyPermissionsPermissionKeyResponse { ok: boolean; rank_key: unknown; permission_key: unknown; removed: unknown }
12
+ export interface DeleteInboxIdSparkResponse { idea: unknown; sparked: boolean; unchanged: boolean }
12
13
  export interface DeleteMemoryFileResponse { deleted: boolean; builder_id: unknown; path: unknown }
13
14
  export interface DeleteTasksIdCriteriaCriterionIdResponse { ok: boolean; removed: unknown }
14
15
  export interface DeleteTasksIdDependenciesDepIdResponse { ok: boolean; removed: unknown }
@@ -246,6 +247,7 @@ export interface PostGovernmentRanksRequest { rank_key: string; label: string; d
246
247
  export interface PostGovernmentRanksResponse { rank: unknown }
247
248
  export interface PostInboxIdRatifyRequest { notes_md?: string }
248
249
  export interface PostInboxIdResubmitRequest { field_values: Record<string, unknown>; template_key?: string }
250
+ export interface PostInboxIdSparkResponse { idea: unknown; sparked: boolean; unchanged: boolean }
249
251
  export interface PostInboxRequest { title: string; body_md?: string; kind?: string; suggested_version?: string; suggested_priority?: number; goal_id?: number; grade?: string; template_key?: string; field_values?: Record<string, unknown> }
250
252
  export interface PostLearningsRequest { title: string; body_md: string; tags?: string[]; related_files?: string[]; source?: string; source_ref?: string; task_id?: number }
251
253
  export interface PostLearningsResponse { skipped?: boolean; reason?: unknown; learning?: unknown }
@@ -669,6 +671,10 @@ export interface BongosClient {
669
671
  postInboxIdRatify(args?: RequestArgs & { body?: PostInboxIdRatifyRequest }): Promise<ApiResponse>;
670
672
  /** POST /inbox/{id}/resubmit — rank: any-builder */
671
673
  postInboxIdResubmit(args: RequestArgs & { body: PostInboxIdResubmitRequest }): Promise<ApiResponse>;
674
+ /** DELETE /inbox/{id}/spark — rank: any-builder */
675
+ deleteInboxIdSpark(args?: RequestArgs): Promise<DeleteInboxIdSparkResponse>;
676
+ /** POST /inbox/{id}/spark — rank: any-builder */
677
+ postInboxIdSpark(args?: RequestArgs): Promise<PostInboxIdSparkResponse>;
672
678
  /** GET /inbox/awaiting-nod — rank: metic+archon */
673
679
  getInboxAwaitingNod(args?: RequestArgs): Promise<GetInboxAwaitingNodResponse>;
674
680
  };
@@ -405,6 +405,10 @@ export function createClient(opts = {}) {
405
405
  postInboxIdRatify: (args) => request("POST", "/inbox/{id}/ratify", { hasBody: true }, args),
406
406
  // POST /inbox/{id}/resubmit — rank: any-builder — POST /inbox/:id/resubmit
407
407
  postInboxIdResubmit: (args) => request("POST", "/inbox/{id}/resubmit", { hasBody: true }, args),
408
+ // DELETE /inbox/{id}/spark — rank: any-builder — DELETE /inbox/:id/spark
409
+ deleteInboxIdSpark: (args) => request("DELETE", "/inbox/{id}/spark", { hasBody: false }, args),
410
+ // POST /inbox/{id}/spark — rank: any-builder — POST /inbox/:id/spark
411
+ postInboxIdSpark: (args) => request("POST", "/inbox/{id}/spark", { hasBody: true }, args),
408
412
  // GET /inbox/awaiting-nod — rank: metic+archon — GET /inbox/awaiting-nod
409
413
  getInboxAwaitingNod: (args) => request("GET", "/inbox/awaiting-nod", { hasBody: false }, args),
410
414
  },
@@ -6601,6 +6601,108 @@
6601
6601
  ]
6602
6602
  }
6603
6603
  },
6604
+ "/inbox/{id}/spark": {
6605
+ "post": {
6606
+ "operationId": "post_inbox_id_spark",
6607
+ "tags": [
6608
+ "inbox"
6609
+ ],
6610
+ "summary": "POST /inbox/:id/spark",
6611
+ "description": "Mark a Quick Idea as a Big Idea Spark — \"I have the spark but not the full picture, someone develop this\". AUTHOR-ONLY: the mark is the author's consent to have their idea developed, so only they can set it. A Full Idea is refused (409) and a non-open idea cannot be newly marked; re-marking is a 200 no-op.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
6612
+ "x-rank": "any-builder",
6613
+ "x-source": "modules/ideas/routes/inbox.js",
6614
+ "parameters": [
6615
+ {
6616
+ "name": "id",
6617
+ "in": "path",
6618
+ "required": true,
6619
+ "schema": {
6620
+ "type": "string"
6621
+ },
6622
+ "description": "Path parameter `id`."
6623
+ }
6624
+ ],
6625
+ "responses": {
6626
+ "200": {
6627
+ "description": "Success.",
6628
+ "content": {
6629
+ "application/json": {
6630
+ "schema": {
6631
+ "$ref": "#/components/schemas/PostInboxIdSparkResponse"
6632
+ }
6633
+ }
6634
+ }
6635
+ },
6636
+ "400": {
6637
+ "$ref": "#/components/responses/BadRequest"
6638
+ },
6639
+ "401": {
6640
+ "$ref": "#/components/responses/Unauthorized"
6641
+ },
6642
+ "403": {
6643
+ "$ref": "#/components/responses/Forbidden"
6644
+ },
6645
+ "404": {
6646
+ "$ref": "#/components/responses/NotFound"
6647
+ }
6648
+ },
6649
+ "security": [
6650
+ {
6651
+ "builderSession": []
6652
+ }
6653
+ ]
6654
+ },
6655
+ "delete": {
6656
+ "operationId": "delete_inbox_id_spark",
6657
+ "tags": [
6658
+ "inbox"
6659
+ ],
6660
+ "summary": "DELETE /inbox/:id/spark",
6661
+ "description": "Withdraw the Big Idea Spark mark — the reversibility half of R144. AUTHOR-ONLY like the mark itself, and legal at ANY status so a stale mark on a promoted or discarded idea can always be cleared. Writes an explicit grade='quick' row, which reads identically to no grade row at all. Un-marking a quick idea is a 200 no-op; a Full Idea is refused (409) — it is not a withdrawn spark.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
6662
+ "x-rank": "any-builder",
6663
+ "x-source": "modules/ideas/routes/inbox.js",
6664
+ "parameters": [
6665
+ {
6666
+ "name": "id",
6667
+ "in": "path",
6668
+ "required": true,
6669
+ "schema": {
6670
+ "type": "string"
6671
+ },
6672
+ "description": "Path parameter `id`."
6673
+ }
6674
+ ],
6675
+ "responses": {
6676
+ "200": {
6677
+ "description": "Success.",
6678
+ "content": {
6679
+ "application/json": {
6680
+ "schema": {
6681
+ "$ref": "#/components/schemas/DeleteInboxIdSparkResponse"
6682
+ }
6683
+ }
6684
+ }
6685
+ },
6686
+ "400": {
6687
+ "$ref": "#/components/responses/BadRequest"
6688
+ },
6689
+ "401": {
6690
+ "$ref": "#/components/responses/Unauthorized"
6691
+ },
6692
+ "403": {
6693
+ "$ref": "#/components/responses/Forbidden"
6694
+ },
6695
+ "404": {
6696
+ "$ref": "#/components/responses/NotFound"
6697
+ }
6698
+ },
6699
+ "security": [
6700
+ {
6701
+ "builderSession": []
6702
+ }
6703
+ ]
6704
+ }
6705
+ },
6604
6706
  "/instance": {
6605
6707
  "get": {
6606
6708
  "operationId": "get_instance",
@@ -13412,6 +13514,23 @@
13412
13514
  "removed"
13413
13515
  ]
13414
13516
  },
13517
+ "DeleteInboxIdSparkResponse": {
13518
+ "type": "object",
13519
+ "properties": {
13520
+ "idea": {},
13521
+ "sparked": {
13522
+ "type": "boolean"
13523
+ },
13524
+ "unchanged": {
13525
+ "type": "boolean"
13526
+ }
13527
+ },
13528
+ "required": [
13529
+ "idea",
13530
+ "sparked",
13531
+ "unchanged"
13532
+ ]
13533
+ },
13415
13534
  "DeleteMemoryFileResponse": {
13416
13535
  "type": "object",
13417
13536
  "properties": {
@@ -16855,6 +16974,23 @@
16855
16974
  ],
16856
16975
  "additionalProperties": false
16857
16976
  },
16977
+ "PostInboxIdSparkResponse": {
16978
+ "type": "object",
16979
+ "properties": {
16980
+ "idea": {},
16981
+ "sparked": {
16982
+ "type": "boolean"
16983
+ },
16984
+ "unchanged": {
16985
+ "type": "boolean"
16986
+ }
16987
+ },
16988
+ "required": [
16989
+ "idea",
16990
+ "sparked",
16991
+ "unchanged"
16992
+ ]
16993
+ },
16858
16994
  "PostInboxRequest": {
16859
16995
  "type": "object",
16860
16996
  "properties": {
@@ -18571,9 +18707,9 @@
18571
18707
  "description": "A required dependency/feature is not configured or is temporarily down."
18572
18708
  }
18573
18709
  },
18574
- "x-endpoint-count": 294,
18575
- "x-schema-count": 327,
18710
+ "x-endpoint-count": 296,
18711
+ "x-schema-count": 329,
18576
18712
  "x-undocumented-bodies": 8,
18577
- "x-response-schemas": 218,
18713
+ "x-response-schemas": 220,
18578
18714
  "x-generated-by": "scripts/gds/gen-api-docs.js"
18579
18715
  }
@@ -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. 294 endpoints across 47 route files.
5
+ > **Generated from the live route files** — the route file is authoritative. 296 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` (8)
261
+ ## `inbox` (10)
262
262
 
263
263
  | Method | Path | Rank | Body | Description |
264
264
  |---|---|---|---|---|
@@ -269,6 +269,8 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
269
269
  | PATCH | `/api/bongos/inbox/:id/body` | `any-builder` | `body_md` | Metic+ (task 360 / ADR 0018): idea triage (promote/discard/merge) shapes the backlog. |
270
270
  | POST | `/api/bongos/inbox/:id/ratify` | `metic+archon` | `notes_md` | V3.R31 (task 252) — Archon ratifies a criterion-proposal idea, atomically: - INSERT done_when_criteria row on the target version - UPDATE… |
271
271
  | POST | `/api/bongos/inbox/:id/resubmit` | `any-builder` | `field_values`, `template_key` | RESUBMIT a Full Idea after a rejection (R13 of goal 1000069, criterion C2: an idea never dies and never stalls). |
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
+ | DELETE | `/api/bongos/inbox/:id/spark` | `any-builder` | — | Withdraw the Big Idea Spark mark — the reversibility half of R144. |
272
274
  | GET | `/api/bongos/inbox/awaiting-nod` | `metic+archon` | — | GET /inbox/awaiting-nod — the NOD QUEUE (task 1003066, BV1.R05). |
273
275
 
274
276
  ## `instance` (1)
package/docs/file-map.md CHANGED
@@ -175,6 +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
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`
179
180
  │ ├── learning-capture.js ← file a `learnings` row (DB-backed short-form rule capture, replacing hand-edited recipe files for a single gotcha)
180
181
  │ ├── docs-entropy.js ← ([#…] docs-entropy) markdown-corpus linter: broken internal links + cross-file duplicate-prose detector across every tracked `.md`; `--verbose` / `--file-ideas`
@@ -408,6 +408,10 @@ function createClient(opts = {}) {
408
408
  postInboxIdRatify: (args) => request("POST", "/inbox/{id}/ratify", { hasBody: true }, args),
409
409
  // POST /inbox/{id}/resubmit — rank: any-builder — POST /inbox/:id/resubmit
410
410
  postInboxIdResubmit: (args) => request("POST", "/inbox/{id}/resubmit", { hasBody: true }, args),
411
+ // DELETE /inbox/{id}/spark — rank: any-builder — DELETE /inbox/:id/spark
412
+ deleteInboxIdSpark: (args) => request("DELETE", "/inbox/{id}/spark", { hasBody: false }, args),
413
+ // POST /inbox/{id}/spark — rank: any-builder — POST /inbox/:id/spark
414
+ postInboxIdSpark: (args) => request("POST", "/inbox/{id}/spark", { hasBody: true }, args),
411
415
  // GET /inbox/awaiting-nod — rank: metic+archon — GET /inbox/awaiting-nod
412
416
  getInboxAwaitingNod: (args) => request("GET", "/inbox/awaiting-nod", { hasBody: false }, args),
413
417
  },
@@ -443,6 +443,119 @@ module.exports = function buildInboxRouter() {
443
443
  }
444
444
  });
445
445
 
446
+ // -----------------------------------------------------------------------
447
+ // The Big Idea Spark mark (task 1002920 / BV1.R144, goal 1000062)
448
+ //
449
+ // "I have the spark but not the full picture — someone develop this."
450
+ //
451
+ // AUTHOR-ONLY, AND THAT IS THE WHOLE POINT. The owner's decision: this mark IS
452
+ // the consent that makes cross-builder development fair. Without it a Quick
453
+ // Idea is simply NOT available for someone else to develop — a quick idea is
454
+ // for turning a thought into a task fast, and it is explicitly *not* an
455
+ // unfinished Full Idea waiting to be finished by a stranger. So the gate is
456
+ // not "who is senior enough", it is "whose thought is this": enforced
457
+ // in-handler against `captured_by`, the same ownership posture as
458
+ // POST /inbox/:id/resubmit above and PATCH /inbox/:id/body below.
459
+ //
460
+ // A MARK, MODELLED AS A RESOURCE: POST puts it on, DELETE takes it off. The
461
+ // done-when requires the mark be REVERSIBLE, and un-sparking is the plain
462
+ // reading of that — an author who marked a thought as needing development may
463
+ // decide they had the full picture after all. Un-sparking writes an EXPLICIT
464
+ // grade='quick' row, which the R140 design states reads identically to having
465
+ // no row at all, so a reversal leaves no residue a reader has to interpret.
466
+ //
467
+ // ONLY A QUICK IDEA CAN BE SPARKED. A Full Idea already carries the developed
468
+ // picture (the five template fields + a score); asking for it to be developed
469
+ // is a contradiction, and sparking it would hand setIdeaGrade a `spark` write
470
+ // whose empty fieldValues would WIPE that work — exactly the stale/lost-field
471
+ // hazard setIdeaGrade's whole-row contract warns about. Refused 409, before
472
+ // any write.
473
+ //
474
+ // Both directions are IDEMPOTENT: sparking a spark, or un-sparking a quick,
475
+ // reports `unchanged` at 200 rather than erroring. A double-click on the R154
476
+ // hall control is not a failure, and the caller's intent is already satisfied.
477
+ //
478
+ // rank: any-builder (requireBuilder) + the in-handler author gate.
479
+ // Pinned in route-rank-check.MODULE_EXPECTED_RANKS['ideas'].
480
+
481
+ // Shared by both directions: load the idea, prove ownership, and refuse a
482
+ // grade this mark has no business touching. Returns the idea, or null when it
483
+ // has already answered the request (so the caller just returns).
484
+ async function loadSparkable(req, res, { requireOpen }) {
485
+ const id = parseId(req, res, { code: 'bad_id', positiveInt: true });
486
+ if (id === null) return null;
487
+ const idea = await inbox.getIdea(id);
488
+ if (!idea) { res.fail('idea_not_found', 404); return null; }
489
+ // Authority first, and it leaks nothing: any builder can already read this
490
+ // idea from the inbox, so a 403 here reveals only that they are not its author.
491
+ if (idea.captured_by == null || String(idea.captured_by) !== String(req.builder.id)) {
492
+ res.fail('not_your_idea', 403, {
493
+ reason: 'the spark mark is the AUTHOR\'s consent to have their idea developed; only they can give or withdraw it',
494
+ });
495
+ return null;
496
+ }
497
+ if (idea.grade === 'full') {
498
+ res.fail('not_a_quick_idea', 409, {
499
+ grade: idea.grade,
500
+ reason: 'a Full Idea already carries the developed picture; the spark mark asks for one',
501
+ });
502
+ return null;
503
+ }
504
+ // Only MARKING requires an open idea. A promoted or discarded idea is not
505
+ // awaiting development, and admitting it would seed the R145 spark queue
506
+ // with work nobody can pick up. WITHDRAWING the mark stays legal at any
507
+ // status — clearing a stale mark is housekeeping, and blocking it would be
508
+ // the one way to leave a permanent spark on a dead idea.
509
+ if (requireOpen && idea.status !== 'open') {
510
+ res.fail('idea_not_open', 409, {
511
+ status: idea.status,
512
+ reason: 'only an open idea can be marked as awaiting development',
513
+ });
514
+ return null;
515
+ }
516
+ return idea;
517
+ }
518
+
519
+ // Mark a Quick Idea as a Big Idea Spark — "I have the spark but not the full
520
+ // picture, someone develop this". AUTHOR-ONLY: the mark is the author's consent
521
+ // to have their idea developed, so only they can set it. A Full Idea is refused
522
+ // (409) and a non-open idea cannot be newly marked; re-marking is a 200 no-op.
523
+ router.post('/inbox/:id/spark', api.requireBuilder, api.asyncHandler('POST /inbox/:id/spark', async (req, res) => {
524
+ const idea = await loadSparkable(req, res, { requireOpen: true });
525
+ if (!idea) return;
526
+ if (idea.grade === 'spark') {
527
+ return res.json({ idea, sparked: true, unchanged: true });
528
+ }
529
+ // grade only — a spark carries no template fields and no score (the R140
530
+ // migration's own shape: field_values is for a Full Idea). setIdeaGrade's
531
+ // defaults say exactly that, so they are left to speak for themselves.
532
+ const row = await inbox.setIdeaGrade(idea.id, { grade: 'spark' });
533
+ return res.json({
534
+ idea: { ...idea, grade: row.grade, template_key: row.template_key, field_values: row.field_values, completeness_score: row.completeness_score },
535
+ sparked: true,
536
+ unchanged: false,
537
+ });
538
+ }, { errorCode: 'idea_spark_failed' }));
539
+
540
+ // Withdraw the Big Idea Spark mark — the reversibility half of R144. AUTHOR-ONLY
541
+ // like the mark itself, and legal at ANY status so a stale mark on a promoted or
542
+ // discarded idea can always be cleared. Writes an explicit grade='quick' row,
543
+ // which reads identically to no grade row at all. Un-marking a quick idea is a
544
+ // 200 no-op; a Full Idea is refused (409) — it is not a withdrawn spark.
545
+ router.delete('/inbox/:id/spark', api.requireBuilder, api.asyncHandler('DELETE /inbox/:id/spark', async (req, res) => {
546
+ const idea = await loadSparkable(req, res, { requireOpen: false });
547
+ if (!idea) return;
548
+ if (idea.grade !== 'spark') {
549
+ return res.json({ idea, sparked: false, unchanged: true });
550
+ }
551
+ const row = await inbox.setIdeaGrade(idea.id, { grade: 'quick' });
552
+ return res.json({
553
+ idea: { ...idea, grade: row.grade, template_key: row.template_key, field_values: row.field_values, completeness_score: row.completeness_score },
554
+ sparked: false,
555
+ unchanged: false,
556
+ });
557
+ }, { errorCode: 'idea_unspark_failed' }));
558
+
446
559
  // Metic+ (#360 / ADR 0018): idea triage (promote/discard/merge) shapes the
447
560
  // backlog. Criterion C2 assigns triage to Metic, so this widened from
448
561
  // archon-only to metic+archon when the three-rank model went live. Any
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.294",
3
+ "version": "1.19.295",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.294",
9
+ "version": "1.19.295",
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.294",
3
+ "version": "1.19.295",
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",
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ // Mark one of YOUR OWN quick ideas as a Big Idea Spark — "I have the spark but
3
+ // not the full picture, someone develop this" (task 1002920 / BV1.R144, goal
4
+ // 1000062).
5
+ //
6
+ // The mark is the CONSENT that makes cross-builder development fair: until an
7
+ // author sets it, their quick idea is not available for someone else to develop.
8
+ // So this command only ever works on ideas you filed — the server enforces that
9
+ // against idea_inbox.captured_by, and no rank can override it.
10
+ //
11
+ // It exists as its own command rather than a flag on capture.js because sparking
12
+ // is an act on an EXISTING idea, not a way to file one (capture refuses
13
+ // grade='spark' for the same reason). The builders-hall control is R154; this is
14
+ // the terminal path, and the two hit the same route.
15
+ //
16
+ // Usage:
17
+ // node scripts/gds/spark.js 123 # mark idea 123 as a spark
18
+ // node scripts/gds/spark.js 123 --undo # withdraw the mark (back to quick)
19
+ //
20
+ // Exit codes:
21
+ // 0 the idea now carries the grade you asked for (including when it already did)
22
+ // 1 api / network error, or the server refused (not yours / not a quick idea)
23
+ // 2 bad invocation
24
+
25
+ const { cliClient, requireSession, hasFlag } = require('./cli-lib');
26
+
27
+ // The server's refusals, in the ideator's own words. Anything unmapped falls
28
+ // through to the raw envelope — a new server-side rule should be visible, not
29
+ // swallowed by a generic "failed".
30
+ const REFUSALS = {
31
+ not_your_idea: (d) =>
32
+ 'That idea is not yours to mark. The spark mark is the author\'s consent to have their\n'
33
+ + 'idea developed, so only the builder who filed it can give or withdraw it.',
34
+ not_a_quick_idea: (d) =>
35
+ `That idea is already a Full Idea (grade=${d.grade || 'full'}). It carries the developed\n`
36
+ + 'picture the spark mark asks for — there is nothing to hand off.',
37
+ idea_not_open: (d) =>
38
+ `That idea is ${d.status || 'no longer open'}, so it is not awaiting development.\n`
39
+ + '(You can still withdraw an existing mark with --undo.)',
40
+ idea_not_found: () => 'No idea with that id.',
41
+ };
42
+
43
+ // The envelope is ADR 0116's { error: { code, message, details } } — the extras a
44
+ // route passes as res.fail's third argument land under `details`, NOT spread onto
45
+ // `error`. Reading them off the wrong level is a silent "undefined" in the
46
+ // message rather than a crash, so tests/idea_spark_mark.mjs pins explain()
47
+ // against the real envelope() builder.
48
+ function explain(status, data) {
49
+ const err = data && data.error;
50
+ const code = err && typeof err === 'object' ? err.code : err;
51
+ const detail = (err && typeof err === 'object' && err.details) || {};
52
+ const say = typeof code === 'string' ? REFUSALS[code] : null;
53
+ if (say) return say(detail);
54
+ let rendered;
55
+ try { rendered = JSON.stringify(data, null, 2); } catch { rendered = String(data); }
56
+ return `spark failed (${status}): ${rendered}`;
57
+ }
58
+
59
+ async function main() {
60
+ await requireSession();
61
+
62
+ const positional = process.argv.slice(2).filter((a) => !a.startsWith('-'));
63
+ const id = Number(positional[0]);
64
+ const undo = hasFlag('--undo');
65
+
66
+ if (!Number.isInteger(id) || id <= 0) {
67
+ console.error('usage: node scripts/gds/spark.js <idea-id> [--undo]');
68
+ process.exit(2);
69
+ }
70
+
71
+ const api = await cliClient();
72
+ const r = undo
73
+ ? await api.inbox.deleteInboxIdSpark({ id })
74
+ : await api.inbox.postInboxIdSpark({ id, body: {} });
75
+
76
+ if (!r.ok) {
77
+ console.error(explain(r.status, r.data));
78
+ process.exit(1);
79
+ }
80
+
81
+ const { idea, sparked, unchanged } = r.data;
82
+ const title = idea && idea.title ? ` — ${idea.title}` : '';
83
+ if (unchanged) {
84
+ console.log(`Idea ${id} was already ${sparked ? 'a spark' : 'a quick idea'}${title}. Nothing changed.`);
85
+ } else if (sparked) {
86
+ console.log(`Idea ${id} is now a Big Idea Spark${title}.`);
87
+ console.log('Another builder can develop it into a Full Idea; you keep the attribution.');
88
+ } else {
89
+ console.log(`Idea ${id} is a quick idea again${title}. The mark is withdrawn.`);
90
+ }
91
+ }
92
+
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 };
97
+
98
+ if (require.main === module) {
99
+ main().catch((err) => {
100
+ console.error('fatal:', err);
101
+ process.exit(1);
102
+ });
103
+ }
@@ -416,6 +416,15 @@ const MODULE_EXPECTED_RANKS = {
416
416
  // requireBuilder + an in-handler author|triage gate. Deliberately a separate
417
417
  // route from the triage-gated PATCH /inbox/:id, which keeps its own rank.
418
418
  'PATCH /inbox/:id/body': 'any-builder',
419
+ // task 1002920 (BV1.R144): the Big Idea Spark mark is AUTHOR-ONLY, which is a
420
+ // narrower rule than any rank expresses — the gate is "whose thought is this",
421
+ // enforced in-handler against captured_by. Pinned 'any-builder' for the same
422
+ // reason POST /goals/:id/members is: the pin's job here is to catch an
423
+ // accidental TIGHTENING to a rank floor (which would lock authors out of their
424
+ // own ideas and break the consent the mark exists to record) as loudly as it
425
+ // catches a gate being dropped.
426
+ 'POST /inbox/:id/spark': 'any-builder',
427
+ 'DELETE /inbox/:id/spark': 'any-builder',
419
428
  'POST /inbox/:id/ratify': 'perm:idea.ratify',
420
429
  },
421
430
  // Cross-builder memory READS — the counterpart to the BFG cross-builder WRITE the
package/src/module-api.js CHANGED
@@ -1015,8 +1015,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1015
1015
  // landed since 1.19.292 with no explicit bump. run 32586174253. (task 1002620)
1016
1016
  // 1.19.294 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1017
1017
  // landed since 1.19.293 with no explicit bump. run 32588977219. (task 1002620)
1018
+ // 1.19.295 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1019
+ // landed since 1.19.294 with no explicit bump. run 32590508145. (task 1002620)
1018
1020
  // ---------------------------------------------------------------------------
1019
- const CORE_VERSION = '1.19.294'; // CI auto-patch carrier (ADR 0161); see changelog above
1021
+ const CORE_VERSION = '1.19.295'; // CI auto-patch carrier (ADR 0161); see changelog above
1020
1022
 
1021
1023
  // A namespaced logger so a module's log lines are attributable + consistent.
1022
1024
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,231 @@
1
+ // tests/idea_spark_mark.mjs
2
+ //
3
+ // task 1002920 (BV1.R144, goal 1000062) — mark a Quick Idea as a Big Idea Spark.
4
+ //
5
+ // The done-when, as assertions: ONLY the original author can mark their idea
6
+ // (the mark is the consent that makes cross-builder development fair, so a
7
+ // non-author is a 403 and writes NOTHING); the mark is REVERSIBLE (DELETE puts
8
+ // the idea back to quick); and a spark is visibly DISTINCT from both a quick and
9
+ // a full idea (the grade comes back on the row, and a Full Idea is refused
10
+ // rather than being flattened into a spark).
11
+ //
12
+ // DB-free the idea_full_capture.mjs way (pool.query patched on the doorway
13
+ // before the modules load), driving the REAL route handlers through a stub
14
+ // req/res — the same author gate, the same error map a client sees.
15
+ //
16
+ // Run: node tests/idea_spark_mark.mjs
17
+
18
+ import { strict as assert } from 'node:assert';
19
+ import { createRequire } from 'node:module';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const api = require('../src/module-api.js');
23
+
24
+ // The one idea every test reads, reshaped per-test. `captured_by: '9'` is the
25
+ // stub builder below, so the default row IS author-owned.
26
+ let idea = null;
27
+ let queries = [];
28
+
29
+ api.pool.query = async (sql, params = []) => {
30
+ queries.push({ sql, params });
31
+ if (/INSERT INTO ideas_grades/i.test(sql)) {
32
+ return { rows: [{ idea_id: params[0], grade: params[1], template_key: params[2], field_values: JSON.parse(params[3]), completeness_score: params[4] }] };
33
+ }
34
+ // Any read of idea_inbox is getIdea — return the row under test (or nothing).
35
+ if (/FROM idea_inbox/i.test(sql)) {
36
+ return { rows: idea ? [idea] : [] };
37
+ }
38
+ return { rows: [] };
39
+ };
40
+
41
+ const routesFactory = require('../modules/ideas/routes/inbox.js');
42
+ const router = routesFactory({});
43
+
44
+ // Pull the real handlers off the mounted router — the last handler in each
45
+ // stack, i.e. after requireBuilder.
46
+ function handlerFor(pathStr, method) {
47
+ const layer = router.stack.find((l) => l.route && l.route.path === pathStr && l.route.methods[method]);
48
+ assert.ok(layer, `route ${method.toUpperCase()} ${pathStr} is not mounted`);
49
+ const stack = layer.route.stack.map((s) => s.handle);
50
+ return stack[stack.length - 1];
51
+ }
52
+ const postSpark = handlerFor('/inbox/:id/spark', 'post');
53
+ const deleteSpark = handlerFor('/inbox/:id/spark', 'delete');
54
+
55
+ function call(handler, { builderId = '9' } = {}) {
56
+ return new Promise((resolve, reject) => {
57
+ const req = { params: { id: '77' }, body: {}, query: {}, builder: { id: builderId, rank: 'xenos' } };
58
+ const res = {
59
+ statusCode: 200,
60
+ status(c) { this.statusCode = c; return this; },
61
+ json(payload) { resolve({ status: this.statusCode, payload }); },
62
+ fail(code, status, extra) {
63
+ const s = typeof status === 'number' ? status : (status && status.status) || 500;
64
+ resolve({ status: s, payload: { error: code, ...(extra || {}) } });
65
+ },
66
+ };
67
+ // asyncHandler owns the catch in production; surface a throw as a failure here.
68
+ Promise.resolve(handler(req, res, (err) => reject(err || new Error('next() called')))).catch(reject);
69
+ });
70
+ }
71
+
72
+ const gradeWrites = () => queries.filter((q) => /INSERT INTO ideas_grades/i.test(q.sql));
73
+
74
+ let passed = 0;
75
+ let failed = 0;
76
+ async function test(name, fn) {
77
+ queries = [];
78
+ idea = { id: '77', title: 'a thought', status: 'open', captured_by: '9', grade: 'quick', template_key: null, field_values: null, completeness_score: null };
79
+ try { await fn(); passed++; console.log(` ok ${name}`); }
80
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
81
+ }
82
+
83
+ // --- the author gate: the whole point of the mark ------------------------
84
+
85
+ await test('the AUTHOR can mark their quick idea as a spark', async () => {
86
+ const r = await call(postSpark);
87
+ assert.equal(r.status, 200);
88
+ assert.equal(r.payload.sparked, true);
89
+ assert.equal(r.payload.unchanged, false);
90
+ assert.equal(r.payload.idea.grade, 'spark', 'the row comes back carrying its new grade');
91
+ const w = gradeWrites();
92
+ assert.equal(w.length, 1);
93
+ assert.equal(w[0].params[1], 'spark');
94
+ });
95
+
96
+ await test('a NON-author is a 403 and nothing is written — the consent is not theirs to give', async () => {
97
+ const r = await call(postSpark, { builderId: '12' });
98
+ assert.equal(r.status, 403);
99
+ assert.equal(r.payload.error, 'not_your_idea');
100
+ assert.equal(gradeWrites().length, 0);
101
+ });
102
+
103
+ await test('an idea with NO author cannot be sparked by anyone (no consent to read)', async () => {
104
+ idea.captured_by = null;
105
+ const r = await call(postSpark);
106
+ assert.equal(r.status, 403);
107
+ assert.equal(r.payload.error, 'not_your_idea');
108
+ assert.equal(gradeWrites().length, 0);
109
+ });
110
+
111
+ await test('a non-author cannot WITHDRAW the mark either', async () => {
112
+ idea.grade = 'spark';
113
+ const r = await call(deleteSpark, { builderId: '12' });
114
+ assert.equal(r.status, 403);
115
+ assert.equal(gradeWrites().length, 0);
116
+ });
117
+
118
+ // --- reversible ----------------------------------------------------------
119
+
120
+ await test('the mark is REVERSIBLE: DELETE puts the idea back to quick', async () => {
121
+ idea.grade = 'spark';
122
+ const r = await call(deleteSpark);
123
+ assert.equal(r.status, 200);
124
+ assert.equal(r.payload.sparked, false);
125
+ assert.equal(r.payload.unchanged, false);
126
+ assert.equal(r.payload.idea.grade, 'quick');
127
+ const w = gradeWrites();
128
+ assert.equal(w.length, 1);
129
+ assert.equal(w[0].params[1], 'quick', 'un-sparking writes an EXPLICIT quick row (R140: it reads as quick either way)');
130
+ });
131
+
132
+ await test('both directions are idempotent and report `unchanged` rather than erroring', async () => {
133
+ idea.grade = 'spark';
134
+ const again = await call(postSpark);
135
+ assert.equal(again.status, 200);
136
+ assert.equal(again.payload.unchanged, true);
137
+ assert.equal(gradeWrites().length, 0, 'no write for a no-op');
138
+
139
+ queries = [];
140
+ idea.grade = 'quick';
141
+ const off = await call(deleteSpark);
142
+ assert.equal(off.status, 200);
143
+ assert.equal(off.payload.sparked, false);
144
+ assert.equal(off.payload.unchanged, true);
145
+ assert.equal(gradeWrites().length, 0);
146
+ });
147
+
148
+ // --- distinct from a FULL idea ------------------------------------------
149
+
150
+ await test('a FULL idea is refused 409 — and its field_values are never touched', async () => {
151
+ const fields = { purpose: 'x'.repeat(60), big_picture: 'y'.repeat(60) };
152
+ idea.grade = 'full';
153
+ idea.field_values = fields;
154
+ idea.completeness_score = 80;
155
+ const r = await call(postSpark);
156
+ assert.equal(r.status, 409);
157
+ assert.equal(r.payload.error, 'not_a_quick_idea');
158
+ assert.equal(r.payload.grade, 'full');
159
+ assert.equal(gradeWrites().length, 0, 'a spark write would have WIPED the five fields');
160
+ assert.deepEqual(idea.field_values, fields);
161
+ });
162
+
163
+ await test('un-sparking a FULL idea is refused too — it is not a withdrawn spark', async () => {
164
+ idea.grade = 'full';
165
+ const r = await call(deleteSpark);
166
+ assert.equal(r.status, 409);
167
+ assert.equal(r.payload.error, 'not_a_quick_idea');
168
+ assert.equal(gradeWrites().length, 0);
169
+ });
170
+
171
+ // --- only an OPEN idea awaits development -------------------------------
172
+
173
+ await test('a promoted idea cannot be newly marked — the spark queue must stay pickable', async () => {
174
+ idea.status = 'promoted';
175
+ const r = await call(postSpark);
176
+ assert.equal(r.status, 409);
177
+ assert.equal(r.payload.error, 'idea_not_open');
178
+ assert.equal(r.payload.status, 'promoted');
179
+ assert.equal(gradeWrites().length, 0);
180
+ });
181
+
182
+ await test('but a stale mark can always be WITHDRAWN, whatever the status', async () => {
183
+ idea.status = 'discarded';
184
+ idea.grade = 'spark';
185
+ const r = await call(deleteSpark);
186
+ assert.equal(r.status, 200);
187
+ assert.equal(r.payload.idea.grade, 'quick');
188
+ assert.equal(gradeWrites().length, 1, 'housekeeping a dead idea\'s mark is never blocked');
189
+ });
190
+
191
+ // --- plumbing -----------------------------------------------------------
192
+
193
+ await test('a missing idea is a 404, not a 500', async () => {
194
+ idea = null;
195
+ const r = await call(postSpark);
196
+ assert.equal(r.status, 404);
197
+ assert.equal(r.payload.error, 'idea_not_found');
198
+ assert.equal(gradeWrites().length, 0);
199
+ });
200
+
201
+ // --- the CLI's message layer, against the REAL envelope -----------------
202
+ //
203
+ // The stub res.fail above spreads its extras onto the payload for terseness (the
204
+ // idea_full_capture.mjs idiom). Production does NOT: ADR 0116's envelope nests
205
+ // them under error.details. So the CLI's explain() is checked against the real
206
+ // builder, not the stub — that mismatch is a silent "grade=undefined" in an
207
+ // ideator's terminal, which no route-level assertion above would catch.
208
+ const { explain } = require('../scripts/gds/spark.js');
209
+ const { envelope } = require('../src/bongos/api-errors.js');
210
+
211
+ await test('the CLI reads each refusal off the REAL envelope shape', async () => {
212
+ const full = explain(409, envelope('not_a_quick_idea', undefined, { grade: 'full' }));
213
+ assert.match(full, /already a Full Idea \(grade=full\)/);
214
+ assert.doesNotMatch(full, /undefined/);
215
+
216
+ const closed = explain(409, envelope('idea_not_open', undefined, { status: 'promoted' }));
217
+ assert.match(closed, /is promoted, so it is not awaiting development/);
218
+ assert.doesNotMatch(closed, /undefined/);
219
+
220
+ assert.match(explain(403, envelope('not_your_idea')), /not yours to mark/);
221
+ assert.match(explain(404, envelope('idea_not_found')), /No idea with that id/);
222
+ });
223
+
224
+ await test('an UNMAPPED server refusal is shown raw, never swallowed', async () => {
225
+ const out = explain(409, envelope('some_future_rule', undefined, { hint: 'x' }));
226
+ assert.match(out, /spark failed \(409\)/);
227
+ assert.match(out, /some_future_rule/, 'a new server-side rule must stay visible');
228
+ });
229
+
230
+ console.log(`\nidea_spark_mark: ${passed} passed, ${failed} failed`);
231
+ if (failed > 0) process.exit(1);