@bongos/core 1.19.162 → 1.19.164

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.162",
6
- "core_contract": "1.19.162",
7
- "source_commit": "1db6240e60bde4c45db842d783f1a109da3684e5",
5
+ "core_version": "1.19.164",
6
+ "core_contract": "1.19.164",
7
+ "source_commit": "779753af3493b89014e91088a53931afe771ec00",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-16T16:44:59.964Z",
9
+ "built_at": "2026-08-16T17:05:29.044Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 305,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1260,
14
+ "functional_verbatim": 1263,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1586,
20
- "tree_sha256": "b21ff15e6dc8e250c96a99b03f62b79b69a99a870c2aca7d2db4bbc8a1e1f0f9",
19
+ "file_count": 1589,
20
+ "tree_sha256": "ea5a8e73501fb69d4e1d6e808d63a241df8b2ccf76728a49282a328b14b63337",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -367,7 +367,7 @@
367
367
  {
368
368
  "path": "clients/bongos-client/index.d.ts",
369
369
  "mode": "0000644",
370
- "sha256": "bca3cfecb85aa504c645ef360eb2370e72c417e34d171b40936b6e01d0d94e95"
370
+ "sha256": "4245c4fb82c1c4f5c87788030dc4640987fe22e0d588072d4549e9982cea7ecc"
371
371
  },
372
372
  {
373
373
  "path": "clients/bongos-client/index.mjs",
@@ -1347,12 +1347,12 @@
1347
1347
  {
1348
1348
  "path": "docs/api-reference.md",
1349
1349
  "mode": "0000644",
1350
- "sha256": "600c7a58bb59803bea046d379bf1f9c42a03a1c5f05514f4b2edac3b71956b12"
1350
+ "sha256": "879ef6fd06bde11c040c5ef67ccf495f0be6eb95e11be6f8979c9d560a33da64"
1351
1351
  },
1352
1352
  {
1353
1353
  "path": "docs/api/openapi.json",
1354
1354
  "mode": "0000644",
1355
- "sha256": "0b2a7a1534b1bc4b92b3a2e939e5d5e6a7b8907fa0cd7513e5a3a6311d4318e1"
1355
+ "sha256": "9382373e7be837c0ce4e26f23c3cd32bf20ab09858650bdee318dd90a06dedbe"
1356
1356
  },
1357
1357
  {
1358
1358
  "path": "docs/architecture.md",
@@ -2662,7 +2662,7 @@
2662
2662
  {
2663
2663
  "path": "modules/dev-box/app/src/vendor/bongos-client.cjs",
2664
2664
  "mode": "0000644",
2665
- "sha256": "2c199a1ad61c8ca8a015b0918368da4c3bf4a8e6196edfd46b93048dda8e9eb5"
2665
+ "sha256": "257ac54c66f7969090065a34c29e2874fa6f9d111b6e75f0510ab1a2fcb3ef17"
2666
2666
  },
2667
2667
  {
2668
2668
  "path": "modules/dev-box/app/test/unit.mjs",
@@ -3454,6 +3454,11 @@
3454
3454
  "mode": "0000644",
3455
3455
  "sha256": "73349f0209190305a61e2f4d771fa466c12be4f9034b4134b60a9e4a560e1c5a"
3456
3456
  },
3457
+ {
3458
+ "path": "modules/ideas/completeness.js",
3459
+ "mode": "0000644",
3460
+ "sha256": "84d977372c79a46f8481176b7cdc740cbaca9480af3464b7b66183aad6d6e180"
3461
+ },
3457
3462
  {
3458
3463
  "path": "modules/ideas/inbox.js",
3459
3464
  "mode": "0000644",
@@ -3477,12 +3482,12 @@
3477
3482
  {
3478
3483
  "path": "modules/ideas/routes/inbox.js",
3479
3484
  "mode": "0000644",
3480
- "sha256": "6c57608d1eae4159e967f9b471b20cb4c518c2b4acdf9d5ab88cad2afaf7c197"
3485
+ "sha256": "8604c627617cc90fa3197dcf3776ce708546d754b9acc2d45c16dfea97a478cb"
3481
3486
  },
3482
3487
  {
3483
3488
  "path": "modules/ideas/templates.js",
3484
3489
  "mode": "0000644",
3485
- "sha256": "d2816db988207f9f2c9305971ebca9bcd87d62e594facaea115d9f6db4b5d9f0"
3490
+ "sha256": "eade9a2d07da0a39af17c086d188a195be00c544889d61454ed3c6368d39ea04"
3486
3491
  },
3487
3492
  {
3488
3493
  "path": "modules/lifecycle/CLAUDE.md",
@@ -4197,12 +4202,12 @@
4197
4202
  {
4198
4203
  "path": "package-lock.json",
4199
4204
  "mode": "0000644",
4200
- "sha256": "0fa4ce8b1bb49791c487d1b90ac7f0ddea7577cf4c14db1b6366ccdccaed06ad"
4205
+ "sha256": "098af85c69e5a9f9aa4dcfd69292d9bbdecfa6bb4294a344740568344fd36673"
4201
4206
  },
4202
4207
  {
4203
4208
  "path": "package.json",
4204
4209
  "mode": "0000644",
4205
- "sha256": "33e29cccdddaea282c452b7296e2be6ec3fd06d61b6a8d35c60a98aa86d61bf2"
4210
+ "sha256": "6efe95b7773147ac8801e5d03fe75b7143f1eda728d123dfa6a19dd41deb5f27"
4206
4211
  },
4207
4212
  {
4208
4213
  "path": "public-docs/index.html",
@@ -5797,7 +5802,7 @@
5797
5802
  {
5798
5803
  "path": "src/module-api.js",
5799
5804
  "mode": "0000644",
5800
- "sha256": "6e7fcf60af8a8abcc9e1a193c5ecf49fed08c9b410c9e3a1dd031f406bf1dea3"
5805
+ "sha256": "575799bb117e5a9166aabc3abbba42f9b737fda008bc08e1461147a57831683a"
5801
5806
  },
5802
5807
  {
5803
5808
  "path": "src/module-loader/catalog.js",
@@ -6904,6 +6909,16 @@
6904
6909
  "mode": "0000644",
6905
6910
  "sha256": "7d384cd84cdc93b8e7b57df291a4600a40f43346fd2a52168324ecde219f1388"
6906
6911
  },
6912
+ {
6913
+ "path": "tests/idea_completeness.mjs",
6914
+ "mode": "0000644",
6915
+ "sha256": "e7b6af463185ea77f40d62408026c55c7fb077f18999b972564b3b5bc7656ca5"
6916
+ },
6917
+ {
6918
+ "path": "tests/idea_full_capture.mjs",
6919
+ "mode": "0000644",
6920
+ "sha256": "95b53f1acfa61f723741b4d33b5e882525903ccc549ae01f3094a788251a60ec"
6921
+ },
6907
6922
  {
6908
6923
  "path": "tests/idea_grades.mjs",
6909
6924
  "mode": "0000644",
@@ -6912,12 +6927,12 @@
6912
6927
  {
6913
6928
  "path": "tests/idea_quick_guard.mjs",
6914
6929
  "mode": "0000644",
6915
- "sha256": "9be4414bbbbb92513a7737d4494265b24c5f5db71a67e840016cc275442df1fb"
6930
+ "sha256": "729c511f9bb3d4a9b7fb76c7b3843e1900c8d4c9cfa30fe08e482d1e41113839"
6916
6931
  },
6917
6932
  {
6918
6933
  "path": "tests/idea_templates.mjs",
6919
6934
  "mode": "0000644",
6920
- "sha256": "3360dcfcccd6ada6375124108a2570d84172977046aa47e1704d4be303ad41f3"
6935
+ "sha256": "943afa2366f92c4d6188ce9f8d68f499aac8d67930f91d6860a2cb83020642ab"
6921
6936
  },
6922
6937
  {
6923
6938
  "path": "tests/idea_triage.mjs",
@@ -222,8 +222,7 @@ export interface PostGovernanceRolesRequest { role_key: string; label: string; d
222
222
  export interface PostGovernanceRolesResponse { role: unknown }
223
223
  export interface PostGovernanceRolesRoleKeyResetResponse { ok: boolean; role_key: unknown; seeded_rank: unknown; permission_count: unknown }
224
224
  export interface PostInboxIdRatifyRequest { notes_md?: string }
225
- export interface PostInboxRequest { title: string; body_md?: string; kind?: string; suggested_version?: string; suggested_priority?: number }
226
- export interface PostInboxResponse { idea: unknown; open_count: unknown; kind_auto: unknown }
225
+ export interface PostInboxRequest { title: string; body_md?: string; kind?: string; suggested_version?: string; suggested_priority?: number; grade?: string; template_key?: string; field_values?: Record<string, unknown> }
227
226
  export interface PostLearningsRequest { title: string; body_md: string; tags?: string[]; related_files?: string[]; source?: string; source_ref?: string; task_id?: number }
228
227
  export interface PostLearningsResponse { skipped?: boolean; reason?: unknown; learning?: unknown }
229
228
  export interface PostLlmCacheLookupRequest { cache_key?: unknown; tokens_saved_est?: unknown }
@@ -607,7 +606,7 @@ export interface BongosClient {
607
606
  /** GET /inbox — rank: any-builder */
608
607
  getInbox(args?: RequestArgs): Promise<GetInboxResponse>;
609
608
  /** POST /inbox — rank: any-builder */
610
- postInbox(args: RequestArgs & { body: PostInboxRequest }): Promise<PostInboxResponse>;
609
+ postInbox(args: RequestArgs & { body: PostInboxRequest }): Promise<ApiResponse>;
611
610
  /** GET /inbox/{id} — rank: any-builder */
612
611
  getInboxId(args?: RequestArgs): Promise<GetInboxIdResponse>;
613
612
  /** PATCH /inbox/{id} — rank: metic+archon */
@@ -5686,14 +5686,7 @@
5686
5686
  },
5687
5687
  "responses": {
5688
5688
  "200": {
5689
- "description": "Success.",
5690
- "content": {
5691
- "application/json": {
5692
- "schema": {
5693
- "$ref": "#/components/schemas/PostInboxResponse"
5694
- }
5695
- }
5696
- }
5689
+ "description": "Success."
5697
5690
  },
5698
5691
  "400": {
5699
5692
  "$ref": "#/components/responses/ValidationFailed"
@@ -15422,6 +15415,16 @@
15422
15415
  "type": "integer",
15423
15416
  "minimum": 0,
15424
15417
  "maximum": 100
15418
+ },
15419
+ "grade": {
15420
+ "type": "string",
15421
+ "maxLength": 16
15422
+ },
15423
+ "template_key": {
15424
+ "type": "string"
15425
+ },
15426
+ "field_values": {
15427
+ "type": "object"
15425
15428
  }
15426
15429
  },
15427
15430
  "required": [
@@ -15429,19 +15432,6 @@
15429
15432
  ],
15430
15433
  "additionalProperties": false
15431
15434
  },
15432
- "PostInboxResponse": {
15433
- "type": "object",
15434
- "properties": {
15435
- "idea": {},
15436
- "open_count": {},
15437
- "kind_auto": {}
15438
- },
15439
- "required": [
15440
- "idea",
15441
- "open_count",
15442
- "kind_auto"
15443
- ]
15444
- },
15445
15435
  "PostLearningsRequest": {
15446
15436
  "type": "object",
15447
15437
  "properties": {
@@ -17109,8 +17099,8 @@
17109
17099
  }
17110
17100
  },
17111
17101
  "x-endpoint-count": 271,
17112
- "x-schema-count": 304,
17102
+ "x-schema-count": 303,
17113
17103
  "x-undocumented-bodies": 8,
17114
- "x-response-schemas": 206,
17104
+ "x-response-schemas": 205,
17115
17105
  "x-generated-by": "scripts/gds/gen-api-docs.js"
17116
17106
  }
@@ -245,7 +245,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
245
245
  | Method | Path | Rank | Body | Description |
246
246
  |---|---|---|---|---|
247
247
  | GET | `/api/bongos/inbox` | `any-builder` | — | read open ideas; skill-friction rows filtered by viewer rank. |
248
- | POST | `/api/bongos/inbox` | `any-builder` | `title`, `body_md`, `kind`, `suggested_version`, `suggested_priority` | for ordinary ideas; metic+ for kind='criterion-proposal'. |
248
+ | POST | `/api/bongos/inbox` | `any-builder` | `title`, `body_md`, `kind`, `suggested_version`, `suggested_priority`, `grade`, `template_key`, `field_values` | for ordinary ideas; metic+ for kind='criterion-proposal'. |
249
249
  | GET | `/api/bongos/inbox/:id` | `any-builder` | — | single idea read for the "#NNN" deep-link resolver (task 1047). |
250
250
  | PATCH | `/api/bongos/inbox/:id` | `metic+archon` | `status`, `kind`, `promoted_to_task_id`, `reviewed_at`, `merged_into_idea_id`, `triage_note` | Metic+ (task 360 / ADR 0018): idea triage (promote/discard/merge) shapes the backlog. |
251
251
  | 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… |
@@ -354,6 +354,8 @@ function createClient(opts = {}) {
354
354
  deleteGovernanceRolesRoleKeyPermissionsPermissionKey: (args) => request("DELETE", "/governance/roles/{roleKey}/permissions/{permissionKey}", { hasBody: false }, args),
355
355
  // PUT /governance/roles/{roleKey}/permissions/{permissionKey} — rank: archon — PUT /governance/roles/:roleKey/permissions/:permissionKey
356
356
  putGovernanceRolesRoleKeyPermissionsPermissionKey: (args) => request("PUT", "/governance/roles/{roleKey}/permissions/{permissionKey}", { hasBody: true }, args),
357
+ // POST /governance/roles/{roleKey}/reset — rank: archon — POST /governance/roles/:roleKey/reset
358
+ postGovernanceRolesRoleKeyReset: (args) => request("POST", "/governance/roles/{roleKey}/reset", { hasBody: true }, args),
357
359
  },
358
360
  "grades": {
359
361
  // GET /grades/by-builder — rank: metic+archon — GET /grades/by-builder
@@ -0,0 +1,72 @@
1
+ // modules/ideas/completeness.js — the deterministic Full Idea completeness
2
+ // scorer (task 1002918, BV1.R142, goal 1000062).
3
+ //
4
+ // WHY DETERMINISTIC (the owner's call, recorded on the goal): the score feeds
5
+ // karma (R147), so it must be predictable, free and reproducible — the same
6
+ // input scores the same number on every machine, every day, with no model
7
+ // having a good day. An AI depth-judge is a deliberately LATER seam; nothing
8
+ // here calls a network or reads a clock.
9
+ //
10
+ // WHAT IT MEASURES — PRESENCE + SUBSTANCE, NOTHING SMARTER. It walks the R141
11
+ // template declaration (never five hard-coded names): each declared field
12
+ // contributes equally; a field earns its share in proportion to how much
13
+ // actual text it carries against the template's `substanceMin` floor, capped
14
+ // at 1. "Actual text" is the trimmed, whitespace-collapsed length, so a page
15
+ // of blank lines scores as the nothing it is. That is the whole algorithm —
16
+ // honest about its limit: a determined padder can beat a character counter,
17
+ // and catching THAT is the future AI seam (goal notes), not a cleverness this
18
+ // number should pretend to.
19
+ //
20
+ // NEVER THROWS INTO THE CAPTURE PATH (the task's hard requirement): malformed
21
+ // input — unknown template, non-object values, non-string fields — returns a
22
+ // null score, not an exception. A null completeness_score is a legal stored
23
+ // state (ideas_001_grades.sql: scoring never blocks capture), so "could not
24
+ // score" and "not scored yet" deliberately look the same downstream.
25
+ //
26
+ // The write side already exists (inbox.js setIdeaCompleteness, 0–100 int);
27
+ // capture (R143) computes-and-stores via this module, and the triage ordering
28
+ // (R148) reads the stored number — neither re-implements the arithmetic.
29
+
30
+ 'use strict';
31
+
32
+ const { templateFor, BUILTIN_TEMPLATES } = require('./templates');
33
+ const { DEFAULT_TEMPLATE_KEY } = require('./inbox');
34
+
35
+ // The substance of a value: the length of its trimmed, whitespace-collapsed
36
+ // text. Non-strings carry no substance (validation refuses them upstream; the
37
+ // scorer just declines to credit them).
38
+ function substanceOf(value) {
39
+ if (typeof value !== 'string') return 0;
40
+ return value.trim().replace(/\s+/g, ' ').length;
41
+ }
42
+
43
+ // Score field_values against a template. Returns
44
+ // { score, fields: [{ key, substance, floor, ratio }] }
45
+ // where score is an integer 0–100 (ready for setIdeaCompleteness) or NULL when
46
+ // the input cannot be scored. `fields` is the per-field breakdown in TEMPLATE
47
+ // ORDER (fieldOrder's contract) — the hall's live meter (R152) and the triage
48
+ // view (R148) render it without redoing the arithmetic.
49
+ function scoreFieldValues(fieldValues, { templateKey = DEFAULT_TEMPLATE_KEY, registry = BUILTIN_TEMPLATES } = {}) {
50
+ const template = templateFor(templateKey, registry);
51
+ if (!template || !template.fields.length) return { score: null, fields: [] };
52
+ if (!fieldValues || typeof fieldValues !== 'object' || Array.isArray(fieldValues)) {
53
+ return { score: null, fields: [] };
54
+ }
55
+ const fields = template.fields.map((f) => {
56
+ const substance = substanceOf(fieldValues[f.key]);
57
+ const floor = f.substanceMin > 0 ? f.substanceMin : 1;
58
+ return { key: f.key, substance, floor, ratio: Math.min(1, substance / floor) };
59
+ });
60
+ const mean = fields.reduce((sum, f) => sum + f.ratio, 0) / fields.length;
61
+ return { score: Math.round(mean * 100), fields };
62
+ }
63
+
64
+ // The one-number convenience for callers that only store: integer 0–100, or
65
+ // null when unscorable. This is what R143's capture writes through
66
+ // setIdeaCompleteness — and because it never throws, the capture path needs no
67
+ // guard around it.
68
+ function completenessScore(fieldValues, opts) {
69
+ return scoreFieldValues(fieldValues, opts).score;
70
+ }
71
+
72
+ module.exports = { completenessScore, scoreFieldValues, substanceOf };
@@ -4,6 +4,10 @@ const express = require('express');
4
4
  // sibling — a module never requires a core internal.
5
5
  const api = require('../../../src/module-api');
6
6
  const inbox = require('../inbox');
7
+ // Full Idea capture (task 1002919 / BV1.R143): the template validator (R141)
8
+ // and the deterministic scorer (R142) — both pure, both walk the declaration.
9
+ const { validateFieldValues } = require('../templates');
10
+ const { completenessScore } = require('../completeness');
7
11
  const { validateOrRespond, LIMITS, parseId } = api;
8
12
 
9
13
  const INBOX_STATUS_VALUES = ['open', 'promoted', 'discarded', 'merged'];
@@ -42,6 +46,12 @@ module.exports = function buildInboxRouter() {
42
46
  kind: { type: 'string', maxLength: 64 },
43
47
  suggested_version: { type: 'string', maxLength: 64 },
44
48
  suggested_priority: { type: 'integer', min: 0, max: 100 },
49
+ // Full Idea capture (R143) — ALL OPTIONAL: omitting every one of these is
50
+ // exactly the pre-grade quick capture (tests/idea_quick_guard.mjs pins
51
+ // that title stays the only required field).
52
+ grade: { type: 'string', maxLength: 16 },
53
+ template_key: { type: 'string', maxLength: inbox.TEMPLATE_KEY_MAX },
54
+ field_values: { type: 'object' },
45
55
  })) return;
46
56
  const body = req.body || {};
47
57
  if (body.kind === 'criterion-proposal') {
@@ -50,6 +60,40 @@ module.exports = function buildInboxRouter() {
50
60
  return res.fail('rank_forbidden', 403, { required: ['metic', 'archon'], actual: rank, reason: "kind='criterion-proposal' is a planning-session write; Metic+ only." });
51
61
  }
52
62
  }
63
+ // Full Idea capture (task 1002919 / BV1.R143). Only 'full' means anything
64
+ // at capture: 'quick' is the default (explicit or omitted — no grade row
65
+ // either way, the R140 absence-is-quick design), and 'spark' is REFUSED
66
+ // here because sparking is the author's later, deliberate act on an
67
+ // EXISTING quick idea (R144's surface) — an idea does not arrive pre-marked.
68
+ const grade = body.grade ?? null;
69
+ if (grade !== null && grade !== 'quick' && grade !== 'full') {
70
+ return res.fail('bad_grade_at_capture', 400, {
71
+ grade, allowed: ['quick', 'full'],
72
+ reason: "capture accepts grade 'quick' (the default) or 'full'; a spark is made from an existing idea, not captured as one.",
73
+ });
74
+ }
75
+ const templateKey = body.template_key ?? undefined;
76
+ if (grade === 'full') {
77
+ // Validate BEFORE any write, so a bad payload is a named 400 and no
78
+ // orphan idea lands. Each error carries { field, reason } — the
79
+ // "clear error naming the missing field" the task's done-when demands.
80
+ //
81
+ // WRAPPED IN try/catch as defence in depth. templateFor's own-property
82
+ // guard already keeps validateFieldValues from throwing on a hostile
83
+ // template_key (the R143 grader's blocker), but this validation runs
84
+ // OUTSIDE the capture try/catch below, so a future consumer that
85
+ // reintroduces a throw would again escape as an unhandled rejection and
86
+ // crash the process. Catching here means the boundary holds regardless:
87
+ // a validator that throws is a 400, never a downed server.
88
+ let v;
89
+ try {
90
+ v = validateFieldValues(body.field_values ?? {}, templateKey ? { templateKey } : undefined);
91
+ } catch (validationErr) {
92
+ console.error('[gds] POST /inbox field-values validation threw:', validationErr);
93
+ return res.fail('invalid_field_values', 400, { errors: [{ field: null, reason: 'unvalidatable' }], template_key: templateKey ?? null });
94
+ }
95
+ if (!v.ok) return res.fail('invalid_field_values', 400, { errors: v.errors, template_key: templateKey ?? null });
96
+ }
53
97
  try {
54
98
  const idea = await inbox.captureIdea({
55
99
  title: body.title,
@@ -59,8 +103,30 @@ module.exports = function buildInboxRouter() {
59
103
  suggestedPriority: body.suggested_priority ?? null,
60
104
  capturedBy: req.builder.id,
61
105
  });
106
+ let grade_warning;
107
+ if (grade === 'full') {
108
+ // The idea is captured; from here every failure is FAIL-OPEN (the
109
+ // task's contract: a scoring/grading hiccup never blocks capture —
110
+ // the idea still lands, as quick, with a named warning).
111
+ try {
112
+ // completenessScore never throws (R142's contract) — null = unscorable.
113
+ const row = await inbox.setIdeaGrade(idea.id, {
114
+ grade: 'full',
115
+ ...(templateKey ? { templateKey } : {}),
116
+ fieldValues: body.field_values,
117
+ completenessScore: completenessScore(body.field_values, templateKey ? { templateKey } : undefined),
118
+ });
119
+ idea.grade = row.grade;
120
+ idea.template_key = row.template_key;
121
+ idea.field_values = row.field_values;
122
+ idea.completeness_score = row.completeness_score;
123
+ } catch (gradeErr) {
124
+ console.error('[gds] POST /inbox full-grade write failed (idea captured, landing as quick):', gradeErr);
125
+ grade_warning = 'captured_but_ungraded';
126
+ }
127
+ }
62
128
  const open_count = await inbox.countOpenIdeas({ viewerRank: req.builder?.rank ?? null });
63
- res.status(201).json({ idea, open_count, kind_auto: !body.kind });
129
+ res.status(201).json({ idea, open_count, kind_auto: !body.kind, ...(grade_warning ? { grade_warning } : {}) });
64
130
  } catch (err) {
65
131
  if (err && err.code) {
66
132
  const status =
@@ -74,7 +74,21 @@ if (BASE_TEMPLATE.key !== DEFAULT_TEMPLATE_KEY) {
74
74
  // whether that is a 400 (capture) or a render-as-raw fallback (display).
75
75
  function templateFor(key, registry = BUILTIN_TEMPLATES) {
76
76
  if (typeof key !== 'string' || !key) return null;
77
- return registry[key] || null;
77
+ // OWN-PROPERTY LOOKUP, never a bare `registry[key]`. `template_key` is
78
+ // attacker-controlled at capture (POST /inbox, R143) and only length-checked,
79
+ // so a key like 'constructor' / '__proto__' / 'toString' would otherwise
80
+ // resolve up the prototype chain to an Object.prototype member — a truthy
81
+ // non-template whose `.fields` is undefined, and fieldOrder(template) then
82
+ // throws a synchronous TypeError from inside the pure validator, OUTSIDE the
83
+ // route's try/catch → an unhandled rejection that crashes the process. One
84
+ // authenticated request, any rank. Object.hasOwn confines resolution to keys
85
+ // the registry actually declares. (Found by the R143 grader.)
86
+ if (!Object.hasOwn(registry, key)) return null;
87
+ const t = registry[key];
88
+ // Defence in depth: even an own key must resolve to a real template shape
89
+ // before any consumer reads `.fields`. A malformed entry is a null, never a
90
+ // throw.
91
+ return t && Array.isArray(t.fields) ? t : null;
78
92
  }
79
93
 
80
94
  // The one legal source of field display order (see header). Returns key order
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.162",
3
+ "version": "1.19.164",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.162",
9
+ "version": "1.19.164",
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.162",
3
+ "version": "1.19.164",
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",
package/src/module-api.js CHANGED
@@ -751,8 +751,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
751
751
  // landed since 1.19.160 with no explicit bump. run 31959299652. (task 1002620)
752
752
  // 1.19.162 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
753
753
  // landed since 1.19.161 with no explicit bump. run 31959496815. (task 1002620)
754
+ // 1.19.163 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
755
+ // landed since 1.19.162 with no explicit bump. run 31959864781. (task 1002620)
756
+ // 1.19.164 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
757
+ // landed since 1.19.163 with no explicit bump. run 31960518012. (task 1002620)
754
758
  // ---------------------------------------------------------------------------
755
- const CORE_VERSION = '1.19.162'; // CI auto-patch carrier (ADR 0161); see changelog above
759
+ const CORE_VERSION = '1.19.164'; // CI auto-patch carrier (ADR 0161); see changelog above
756
760
 
757
761
  // A namespaced logger so a module's log lines are attributable + consistent.
758
762
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,98 @@
1
+ // tests/idea_completeness.mjs
2
+ //
3
+ // task 1002918 (BV1.R142, goal 1000062) — the deterministic completeness scorer.
4
+ //
5
+ // The three claims under guard, straight from the task's done-when:
6
+ // 1. SAME INPUT, SAME SCORE — determinism is what lets the number feed karma.
7
+ // 2. The empty / thin / padded / complete shapes score sensibly, with padding
8
+ // (whitespace, blank lines) earning exactly nothing.
9
+ // 3. IT NEVER THROWS — malformed input is a null score, because scoring must
10
+ // never block capture (the R140 schema's contract).
11
+ //
12
+ // Pure module, pure tests — no DB, no harness.
13
+ //
14
+ // Run: node tests/idea_completeness.mjs
15
+
16
+ import { strict as assert } from 'node:assert';
17
+ import { createRequire } from 'node:module';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const C = require('../modules/ideas/completeness.js');
21
+ const { BASE_TEMPLATE } = require('../modules/ideas/templates.js');
22
+
23
+ let passed = 0;
24
+ let failed = 0;
25
+ function test(name, fn) {
26
+ try { fn(); passed++; console.log(` ok ${name}`); }
27
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
28
+ }
29
+
30
+ // Five answers comfortably past every substanceMin (40 chars).
31
+ const COMPLETE = Object.fromEntries(
32
+ BASE_TEMPLATE.fields.map((f) => [f.key, `A real, substantive answer for ${f.key} that clears the declared floor easily.`]),
33
+ );
34
+
35
+ test('a complete idea scores 100, an empty one 0', () => {
36
+ assert.equal(C.completenessScore(COMPLETE), 100);
37
+ assert.equal(C.completenessScore({}), 0);
38
+ });
39
+
40
+ test('same input, same score — byte-for-byte determinism', () => {
41
+ const a = C.completenessScore(COMPLETE);
42
+ for (let i = 0; i < 5; i++) assert.equal(C.completenessScore({ ...COMPLETE }), a);
43
+ assert.deepEqual(C.scoreFieldValues(COMPLETE), C.scoreFieldValues(COMPLETE));
44
+ });
45
+
46
+ test('a thin answer earns its proportion, not the full share', () => {
47
+ const oneThin = { ...COMPLETE, purpose: 'ten chars.' }; // 10 of the 40-char floor
48
+ const s = C.completenessScore(oneThin);
49
+ assert.ok(s > 80 && s < 100, `four full + one quarter-credit field ≈ 85, got ${s}`);
50
+ const breakdown = C.scoreFieldValues(oneThin).fields.find((f) => f.key === 'purpose');
51
+ assert.equal(breakdown.substance, 10);
52
+ assert.equal(breakdown.ratio, 0.25);
53
+ });
54
+
55
+ test('padding earns exactly nothing: whitespace and blank lines are not substance', () => {
56
+ const padded = { ...COMPLETE, purpose: ' \n\n\t \n \n\n ' };
57
+ const b = C.scoreFieldValues(padded).fields.find((f) => f.key === 'purpose');
58
+ assert.equal(b.substance, 0, 'a page of blank lines is the nothing it is');
59
+ const spaced = { ...COMPLETE, purpose: 'a b\n\n\nc d e' }; // collapses to 9
60
+ const b2 = C.scoreFieldValues(spaced).fields.find((f) => f.key === 'purpose');
61
+ assert.equal(b2.substance, 9, 'runs of whitespace collapse to one');
62
+ });
63
+
64
+ test('one missing field costs exactly its equal share', () => {
65
+ const { fit, ...four } = COMPLETE;
66
+ assert.equal(C.completenessScore(four), 80, 'five equal fields: one absent = -20');
67
+ });
68
+
69
+ test('the breakdown comes back in TEMPLATE order, ready to render', () => {
70
+ const keys = C.scoreFieldValues(COMPLETE).fields.map((f) => f.key);
71
+ assert.deepEqual(keys, ['purpose', 'big_picture', 'why_had', 'why_matters', 'fit']);
72
+ });
73
+
74
+ test('it never throws: malformed input is a null score, not an exception', () => {
75
+ for (const bad of [null, undefined, [], 'a string', 42]) {
76
+ assert.equal(C.completenessScore(bad), null, `completenessScore(${JSON.stringify(bad)})`);
77
+ }
78
+ assert.equal(C.completenessScore(COMPLETE, { templateKey: 'no-such-template' }), null);
79
+ // a non-string FIELD does not throw either — it just earns nothing
80
+ assert.equal(typeof C.completenessScore({ ...COMPLETE, purpose: 42 }), 'number');
81
+ });
82
+
83
+ test('the scorer walks the declaration — a different template scores by ITS fields', () => {
84
+ const registry = {
85
+ 'two-v1': { key: 'two-v1', label: 'Two', fields: [
86
+ { key: 'a', label: 'A', required: true, substanceMin: 10 },
87
+ { key: 'b', label: 'B', required: true, substanceMin: 10 },
88
+ ] },
89
+ };
90
+ const opts = { templateKey: 'two-v1', registry };
91
+ assert.equal(C.completenessScore({ a: 'ten chars!', b: 'ten chars!' }, opts), 100);
92
+ assert.equal(C.completenessScore({ a: 'ten chars!' }, opts), 50);
93
+ // base fields mean nothing to it
94
+ assert.equal(C.completenessScore(COMPLETE, opts), 0);
95
+ });
96
+
97
+ console.log(`\nidea_completeness: ${passed} passed, ${failed} failed`);
98
+ if (failed > 0) process.exit(1);
@@ -0,0 +1,146 @@
1
+ // tests/idea_full_capture.mjs
2
+ //
3
+ // task 1002919 (BV1.R143, goal 1000062) — POST /inbox accepts a Full Idea.
4
+ //
5
+ // The done-when, as assertions: a Full Idea round-trips with its fields and
6
+ // score intact; an invalid field set is a 400 NAMING the field, refused BEFORE
7
+ // any write; a grade-write failure never blocks capture (fail-open: the idea
8
+ // lands as quick with a named warning); 'spark' is refused at capture (sparking
9
+ // is R144's act on an EXISTING idea); and the quick path stays byte-identical
10
+ // (the deep version of that lives in tests/idea_quick_guard.mjs).
11
+ //
12
+ // DB-free the idea_grades.mjs way (pool.query patched on the doorway before the
13
+ // modules load), driving the REAL route handler through a stub req/res — the
14
+ // same validator, the same error map a client sees.
15
+ //
16
+ // Run: node tests/idea_full_capture.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
+ let queries = [];
25
+ let failNextGradeWrite = false;
26
+ api.pool.query = async (sql, params = []) => {
27
+ queries.push({ sql, params });
28
+ if (/INSERT INTO ideas_grades/i.test(sql)) {
29
+ if (failNextGradeWrite) { failNextGradeWrite = false; throw new Error('synthetic grade-write outage'); }
30
+ return { rows: [{ idea_id: params[0], grade: params[1], template_key: params[2], field_values: JSON.parse(params[3]), completeness_score: params[4] }] };
31
+ }
32
+ if (/INSERT INTO idea_inbox/i.test(sql)) {
33
+ return { rows: [{ id: '77', title: params[0], status: 'open' }] };
34
+ }
35
+ if (/SELECT COUNT/i.test(sql) || /count/i.test(sql)) return { rows: [{ n: '3', count: '3' }] };
36
+ return { rows: [] };
37
+ };
38
+
39
+ const { BASE_TEMPLATE } = require('../modules/ideas/templates.js');
40
+ const routesFactory = require('../modules/ideas/routes/inbox.js');
41
+
42
+ // Pull the real POST /inbox handler off the mounted router.
43
+ const router = routesFactory({});
44
+ const layer = router.stack.find((l) => l.route && l.route.path === '/inbox' && l.route.methods.post);
45
+ const handlers = layer.route.stack.map((s) => s.handle);
46
+ const postInbox = handlers[handlers.length - 1]; // after requireBuilder
47
+
48
+ function call(bodyIn) {
49
+ return new Promise((resolve) => {
50
+ const req = { body: bodyIn, builder: { id: '9', rank: 'metic' }, query: {} };
51
+ const res = {
52
+ statusCode: 200,
53
+ status(c) { this.statusCode = c; return this; },
54
+ json(payload) { resolve({ status: this.statusCode, payload }); },
55
+ fail(code, status, extra) {
56
+ const s = typeof status === 'number' ? status : (status && status.status) || 500;
57
+ resolve({ status: s, payload: { error: code, ...(extra || {}) } });
58
+ },
59
+ };
60
+ postInbox(req, res);
61
+ });
62
+ }
63
+
64
+ const FULL = Object.fromEntries(
65
+ BASE_TEMPLATE.fields.map((f) => [f.key, `A substantive answer for ${f.key}, long enough to clear the floor.`]),
66
+ );
67
+
68
+ let passed = 0;
69
+ let failed = 0;
70
+ async function test(name, fn) {
71
+ queries = [];
72
+ try { await fn(); passed++; console.log(` ok ${name}`); }
73
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
74
+ }
75
+
76
+ await test('a Full Idea round-trips: 201 with grade, fields and score intact', async () => {
77
+ const r = await call({ title: 'a full idea', grade: 'full', field_values: FULL });
78
+ assert.equal(r.status, 201);
79
+ assert.equal(r.payload.idea.grade, 'full');
80
+ assert.deepEqual(r.payload.idea.field_values, FULL);
81
+ assert.equal(r.payload.idea.completeness_score, 100, 'scored via R142 at capture');
82
+ assert.ok(queries.some((q) => /INSERT INTO idea_inbox/i.test(q.sql)));
83
+ assert.ok(queries.some((q) => /INSERT INTO ideas_grades/i.test(q.sql)));
84
+ });
85
+
86
+ await test('an invalid field set is a 400 naming the field — and NOTHING is written', async () => {
87
+ const r = await call({ title: 'bad full', grade: 'full', field_values: { ...FULL, rogue: 'x' } });
88
+ assert.equal(r.status, 400);
89
+ assert.equal(r.payload.error, 'invalid_field_values');
90
+ assert.deepEqual(r.payload.errors, [{ field: 'rogue', reason: 'unknown_field' }]);
91
+ assert.equal(queries.length, 0, 'refused before any write — no orphan idea');
92
+ });
93
+
94
+ await test('a missing required field is named the same way', async () => {
95
+ const { fit, ...four } = FULL;
96
+ const r = await call({ title: 'incomplete full', grade: 'full', field_values: four });
97
+ assert.equal(r.status, 400);
98
+ assert.ok(r.payload.errors.some((e) => e.field === 'fit' && e.reason === 'missing_required'));
99
+ });
100
+
101
+ await test("'spark' is refused at capture — sparking is an act on an existing idea (R144)", async () => {
102
+ const r = await call({ title: 'pre-marked?', grade: 'spark' });
103
+ assert.equal(r.status, 400);
104
+ assert.equal(r.payload.error, 'bad_grade_at_capture');
105
+ assert.deepEqual(r.payload.allowed, ['quick', 'full']);
106
+ assert.equal(queries.length, 0);
107
+ });
108
+
109
+ await test('a grade-write failure never blocks capture: idea lands, named warning returned', async () => {
110
+ failNextGradeWrite = true;
111
+ const r = await call({ title: 'resilient full', grade: 'full', field_values: FULL });
112
+ assert.equal(r.status, 201, 'the capture still succeeds');
113
+ assert.equal(r.payload.grade_warning, 'captured_but_ungraded');
114
+ assert.ok(queries.some((q) => /INSERT INTO idea_inbox/i.test(q.sql)), 'the idea landed');
115
+ });
116
+
117
+ await test('the quick path is untouched: a bare capture writes idea_inbox only', async () => {
118
+ const r = await call({ title: 'just a thought' });
119
+ assert.equal(r.status, 201);
120
+ // since R140 every new idea reads grade='quick' BY DEFINITION (the absence
121
+ // design) — what the guard forbids is a grade-table statement, not the label
122
+ assert.equal(r.payload.idea.grade, 'quick');
123
+ assert.ok(!queries.some((q) => /ideas_grades/i.test(q.sql)), 'zero grade statements');
124
+ });
125
+
126
+ await test("an explicit grade:'quick' is the same no-op quick capture", async () => {
127
+ const r = await call({ title: 'explicitly quick', grade: 'quick' });
128
+ assert.equal(r.status, 201);
129
+ assert.ok(!queries.some((q) => /ideas_grades/i.test(q.sql)));
130
+ });
131
+
132
+ await test('SECURITY (R143 grader blocker): a hostile template_key is a 400, the process survives', async () => {
133
+ // The exact crash the grader found: template_key:'constructor' with grade:full.
134
+ // Pre-fix this threw a synchronous TypeError out of the async handler (an
135
+ // unhandled rejection → process down). Now it is a clean 400 and NOTHING is
136
+ // written. Proven at the route layer, over the real handler.
137
+ for (const hostile of ['constructor', '__proto__', 'toString']) {
138
+ const r = await call({ title: 'attack', grade: 'full', field_values: FULL, template_key: hostile });
139
+ assert.equal(r.status, 400, `template_key='${hostile}' must be a 400, not a crash`);
140
+ assert.equal(r.payload.error, 'invalid_field_values');
141
+ assert.equal(queries.length, 0, 'refused before any write');
142
+ }
143
+ });
144
+
145
+ console.log(`\nidea_full_capture: ${passed} passed, ${failed} failed`);
146
+ if (failed > 0) process.exit(1);
@@ -13,9 +13,10 @@
13
13
  // title is the only requirement, and its INSERT touches idea_inbox alone:
14
14
  // ZERO statements against ideas_grades. A quick idea is quick because no
15
15
  // grade row exists (the R140 design), not because one was written.
16
- // 2. THE ROUTE SCHEMA — POST /inbox's validator declares exactly the pre-grade
17
- // field set. No grade / template_key / field_values key exists in it, so no
18
- // client is asked for (or may smuggle) grade-era input on the quick path.
16
+ // 2. THE ROUTE SCHEMA — the five pre-grade keys survive and title stays the
17
+ // ONLY required field. (R143 later added grade-era keys as OPTIONALS —
18
+ // the promise was never "the schema can't grow", it is "no new REQUIRED
19
+ // field appears on the quick path".)
19
20
  // 3. THE READ — an idea with no grade row reads back grade='quick' with null
20
21
  // template/score, via the LEFT JOIN + COALESCE (idea_grades.mjs owns the
21
22
  // deep version of this; here it is asserted once as part of the contract).
@@ -97,15 +98,18 @@ await test('capture: the title requirement is the SAME refusal it always was', a
97
98
 
98
99
  // ── 2. the route schema ─────────────────────────────────────────────────────
99
100
 
100
- await test('POST /inbox declares the pre-grade field set — nothing grade-era, nothing newly required', () => {
101
+ await test('POST /inbox: the pre-grade fields survive and title stays the ONLY requirement', () => {
102
+ // R143 legitimately added grade / template_key / field_values as OPTIONAL
103
+ // keys — the promise this guard protects (R150's done-when) was never "the
104
+ // schema can't grow", it is "no new REQUIRED field appears on the quick
105
+ // path". So the pin is: the five original keys remain, and required is
106
+ // exactly ['title'] — a grade-era key turning required fails here by name.
101
107
  const src = fs.readFileSync(path.join(__dirname, '..', 'modules', 'ideas', 'routes', 'inbox.js'), 'utf8');
102
108
  const post = src.slice(src.indexOf("router.post('/inbox'"), src.indexOf('captureIdea('));
103
109
  const schema = post.slice(post.indexOf('{', post.indexOf('validateOrRespond')), post.indexOf('})') + 1);
104
110
  for (const key of ['title', 'body_md', 'kind', 'suggested_version', 'suggested_priority']) {
105
111
  assert.ok(schema.includes(key), `the quick path still accepts ${key}`);
106
112
  }
107
- assert.doesNotMatch(schema, /grade|template|field_values|completeness/,
108
- 'no grade-era key in the quick capture schema');
109
113
  const required = [...schema.matchAll(/(\w+):\s*\{[^}]*required:\s*true/g)].map((m) => m[1]);
110
114
  assert.deepEqual(required, ['title'], 'title is the ONLY required field, as it always was');
111
115
  });
@@ -62,6 +62,22 @@ test('templateFor: base key resolves, unknown/blank/non-string do not', () => {
62
62
  assert.equal(T.templateFor(null), null);
63
63
  });
64
64
 
65
+ test('SECURITY (R143 grader blocker): a prototype-chain key resolves to null, never a crash', () => {
66
+ // template_key is attacker-controlled at capture and only length-checked, so
67
+ // a bare registry[key] would resolve 'constructor' / '__proto__' / 'toString'
68
+ // up the prototype chain to a truthy Object.prototype member — a non-template
69
+ // whose .fields is undefined — and validateFieldValues would then throw a
70
+ // synchronous TypeError, crashing the process. Own-property lookup returns
71
+ // null for every one of these, and the validator path stays a clean refusal.
72
+ for (const hostile of ['constructor', '__proto__', 'toString', 'valueOf', 'hasOwnProperty', 'isPrototypeOf']) {
73
+ assert.equal(T.templateFor(hostile), null, `templateFor('${hostile}') must be null`);
74
+ assert.doesNotThrow(() => T.validateFieldValues({}, { templateKey: hostile }),
75
+ `validateFieldValues must not throw on template_key='${hostile}'`);
76
+ assert.deepEqual(T.validateFieldValues({}, { templateKey: hostile }).errors,
77
+ [{ field: null, reason: 'unknown_template' }]);
78
+ }
79
+ });
80
+
65
81
  // ── validation follows the declaration ──────────────────────────────────────
66
82
 
67
83
  test('a complete five-field payload validates', () => {