@bongos/core 1.19.159 → 1.19.161

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.159",
6
- "core_contract": "1.19.159",
7
- "source_commit": "bb03edbdf692de7f7e5a0f93e86cb6b9b8c47804",
5
+ "core_version": "1.19.161",
6
+ "core_contract": "1.19.161",
7
+ "source_commit": "206359b5cf85472d7b4def6050a2766f001569ea",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-16T16:34:56.328Z",
9
+ "built_at": "2026-08-16T16:41:02.340Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 305,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1257,
14
+ "functional_verbatim": 1259,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1583,
20
- "tree_sha256": "12635cda248f239af4ee99dedef72a321b7af7a827734f397701b7fc4f6a7794",
19
+ "file_count": 1585,
20
+ "tree_sha256": "67b93c7d90a238fa234b0e809aee4545bf5190f1cd5f86513a294c4adb4244db",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -3479,6 +3479,11 @@
3479
3479
  "mode": "0000644",
3480
3480
  "sha256": "6c57608d1eae4159e967f9b471b20cb4c518c2b4acdf9d5ab88cad2afaf7c197"
3481
3481
  },
3482
+ {
3483
+ "path": "modules/ideas/templates.js",
3484
+ "mode": "0000644",
3485
+ "sha256": "d2816db988207f9f2c9305971ebca9bcd87d62e594facaea115d9f6db4b5d9f0"
3486
+ },
3482
3487
  {
3483
3488
  "path": "modules/lifecycle/CLAUDE.md",
3484
3489
  "mode": "0000644",
@@ -4192,12 +4197,12 @@
4192
4197
  {
4193
4198
  "path": "package-lock.json",
4194
4199
  "mode": "0000644",
4195
- "sha256": "a5c34ddb4aced104656a9e6210d64de0326743b6f66fda7992a44a95378e42c3"
4200
+ "sha256": "8bd2f7c052801d8b86e46c98ea962ecb4b23164a82ae96282e9789fb64da10c9"
4196
4201
  },
4197
4202
  {
4198
4203
  "path": "package.json",
4199
4204
  "mode": "0000644",
4200
- "sha256": "2b3da2ee3ca98ac2af38a25c4608e5755cd0953427afc818b96773082ec7d9db"
4205
+ "sha256": "113553d6b43e63c5c34c77934179cb762626a81c13d63865e02de74c8c361633"
4201
4206
  },
4202
4207
  {
4203
4208
  "path": "public-docs/index.html",
@@ -5792,7 +5797,7 @@
5792
5797
  {
5793
5798
  "path": "src/module-api.js",
5794
5799
  "mode": "0000644",
5795
- "sha256": "7d48fba3da62904e7f98a55a4617fa4c67d47e9184f83eb3c5ddbb1ca973efd3"
5800
+ "sha256": "9c0be130573a1045b0a96d8db4b8ede624b304cdb5095c209fbec1c9c0d1248c"
5796
5801
  },
5797
5802
  {
5798
5803
  "path": "src/module-loader/catalog.js",
@@ -6904,6 +6909,11 @@
6904
6909
  "mode": "0000644",
6905
6910
  "sha256": "07cacc97990f0e775342f8b30171c87128e0acb1abd38d64c683c3f257927ebd"
6906
6911
  },
6912
+ {
6913
+ "path": "tests/idea_templates.mjs",
6914
+ "mode": "0000644",
6915
+ "sha256": "3360dcfcccd6ada6375124108a2570d84172977046aa47e1704d4be303ad41f3"
6916
+ },
6907
6917
  {
6908
6918
  "path": "tests/idea_triage.mjs",
6909
6919
  "mode": "0000644",
@@ -0,0 +1,137 @@
1
+ // modules/ideas/templates.js — the Full Idea template declarations (task 1002917,
2
+ // BV1.R141, goal 1000062).
3
+ //
4
+ // THIS FILE IS WHAT 'base-v1' MEANS. ideas_001_grades.sql stores `field_values`
5
+ // jsonb against a `template_key` and deliberately declares nothing about shape —
6
+ // its header says "R141 declares what 'base-v1' means". This is that declaration:
7
+ // the five base fields, in the owner's own wording from the Holy Workshop Trinity
8
+ // brief, in the order they display.
9
+ //
10
+ // THE TEMPLATE IS DATA, READ BY BOTH VALIDATION AND SCORING. Capture (R143)
11
+ // validates a submitted `field_values` against a template with
12
+ // `validateFieldValues`; the deterministic completeness scorer (R142) walks the
13
+ // same declaration (`fields`, each field's `substanceMin`) instead of hard-coding
14
+ // five names of its own. Swapping the template changes what both accept — no
15
+ // migration, no code change in either consumer. That property is the task's
16
+ // done-when, and tests/idea_templates.mjs proves it by injecting a different
17
+ // registry and watching validation follow it.
18
+ //
19
+ // DISPLAY ORDER COMES FROM HERE, NEVER FROM THE ROW. jsonb does not preserve key
20
+ // order (the schema comment calls this out), so any renderer that iterates
21
+ // Object.keys(field_values) shows the five fields in whatever order Postgres
22
+ // chose. `fieldOrder(template)` is the one legal source of order.
23
+ //
24
+ // THE PER-PROJECT SEAM, NOT THE FEATURE (owner philosophy, recorded on goal
25
+ // 1000062: the best possible simple base, already designed to be altered).
26
+ // Every function takes an optional `registry`, defaulting to the built-in one.
27
+ // A project-defined template (R156, a spike — deliberately NOT built here) will
28
+ // resolve its own registry and pass it through; nothing below cares where a
29
+ // registry came from. No customization UI, no config plumbing — just the seam.
30
+ //
31
+ // Same-module import: DEFAULT_TEMPLATE_KEY and the payload cap live in inbox.js
32
+ // (declared there for the route validators, mirroring the DB constraints) — one
33
+ // source, so this file can never drift from what the store accepts.
34
+
35
+ 'use strict';
36
+
37
+ const { DEFAULT_TEMPLATE_KEY, FIELD_VALUES_MAX } = require('./inbox');
38
+
39
+ // The five base fields, verbatim from the owner's brief. `key` is the stable
40
+ // snake_case identity `field_values` is stored under (renaming a label is free;
41
+ // renaming a key is a data migration — that is why they differ). `substanceMin`
42
+ // is the character floor the R142 scorer treats as "this field was actually
43
+ // answered, not padded past" — declared here as data so the scorer stays
44
+ // template-agnostic; validation deliberately does NOT enforce it (a thin answer
45
+ // is a scoring problem, not a capture error — scoring never blocks capture).
46
+ const BASE_TEMPLATE = Object.freeze({
47
+ key: 'base-v1',
48
+ label: 'Full Idea',
49
+ fields: Object.freeze([
50
+ Object.freeze({ key: 'purpose', label: 'Purpose of Idea', required: true, substanceMin: 40 }),
51
+ Object.freeze({ key: 'big_picture', label: 'What does the Big Picture look like', required: true, substanceMin: 40 }),
52
+ Object.freeze({ key: 'why_had', label: 'Why did you have the Idea', required: true, substanceMin: 40 }),
53
+ Object.freeze({ key: 'why_matters', label: 'Why does the Idea Matter', required: true, substanceMin: 40 }),
54
+ Object.freeze({ key: 'fit', label: 'How does the Idea Fit into the big picture of the project', required: true, substanceMin: 40 }),
55
+ ]),
56
+ });
57
+
58
+ // The built-in registry. One entry today; a second built-in template would be
59
+ // added here. Frozen so a consumer can never mutate the base declaration into
60
+ // a de-facto fork — a different shape is a different registry (the seam above).
61
+ const BUILTIN_TEMPLATES = Object.freeze({
62
+ [BASE_TEMPLATE.key]: BASE_TEMPLATE,
63
+ });
64
+
65
+ // Sanity pin, not a runtime branch: the store's default key and this file's
66
+ // base declaration are two statements of the same fact, and requiring them to
67
+ // agree at load time turns a rename-drift into a boot failure instead of a
68
+ // silent "every new row validates against a template that does not exist".
69
+ if (BASE_TEMPLATE.key !== DEFAULT_TEMPLATE_KEY) {
70
+ throw new Error(`templates: BASE_TEMPLATE.key (${BASE_TEMPLATE.key}) !== inbox DEFAULT_TEMPLATE_KEY (${DEFAULT_TEMPLATE_KEY})`);
71
+ }
72
+
73
+ // Resolve a template by key. Null for an unknown key — the caller decides
74
+ // whether that is a 400 (capture) or a render-as-raw fallback (display).
75
+ function templateFor(key, registry = BUILTIN_TEMPLATES) {
76
+ if (typeof key !== 'string' || !key) return null;
77
+ return registry[key] || null;
78
+ }
79
+
80
+ // The one legal source of field display order (see header). Returns key order
81
+ // as DECLARED, never as stored.
82
+ function fieldOrder(template) {
83
+ return template.fields.map((f) => f.key);
84
+ }
85
+
86
+ // Validate a submitted field_values object against a template. PURE — no DB, no
87
+ // clock — so capture (R143) can 400 with named fields before any query runs, and
88
+ // the tests exercise every path without a harness.
89
+ //
90
+ // Returns { ok, template, errors } where each error is { field, reason }:
91
+ // unknown_template — templateKey resolves to nothing in the registry
92
+ // not_an_object — field_values is not a plain object
93
+ // unknown_field — a key the template does not declare (refused, never
94
+ // silently stored: an unknown key in jsonb is invisible
95
+ // debt the scorer and renderer would each trip on later)
96
+ // not_a_string — a declared field carrying a non-string value
97
+ // missing_required — a required field absent or blank (whitespace = blank)
98
+ // too_large — serialized payload over the store's byte cap (mirrored
99
+ // from inbox.js so the refusal is a named 400 here rather
100
+ // than a constraint-violation 500 there)
101
+ //
102
+ // Substance (substanceMin) is deliberately NOT validated — thin answers score
103
+ // low (R142); they do not fail capture.
104
+ function validateFieldValues(fieldValues, { templateKey = DEFAULT_TEMPLATE_KEY, registry = BUILTIN_TEMPLATES } = {}) {
105
+ const template = templateFor(templateKey, registry);
106
+ if (!template) return { ok: false, template: null, errors: [{ field: null, reason: 'unknown_template' }] };
107
+ if (!fieldValues || typeof fieldValues !== 'object' || Array.isArray(fieldValues)) {
108
+ return { ok: false, template, errors: [{ field: null, reason: 'not_an_object' }] };
109
+ }
110
+ const errors = [];
111
+ const declared = new Set(fieldOrder(template));
112
+ for (const key of Object.keys(fieldValues)) {
113
+ if (!declared.has(key)) errors.push({ field: key, reason: 'unknown_field' });
114
+ else if (typeof fieldValues[key] !== 'string') errors.push({ field: key, reason: 'not_a_string' });
115
+ }
116
+ for (const f of template.fields) {
117
+ if (!f.required) continue;
118
+ const v = fieldValues[f.key];
119
+ if (typeof v !== 'string' || v.trim() === '') {
120
+ // a non-string already carries not_a_string above; don't double-report it
121
+ if (typeof v !== 'string' && v !== undefined) continue;
122
+ errors.push({ field: f.key, reason: 'missing_required' });
123
+ }
124
+ }
125
+ if (errors.length === 0 && Buffer.byteLength(JSON.stringify(fieldValues), 'utf8') > FIELD_VALUES_MAX) {
126
+ errors.push({ field: null, reason: 'too_large' });
127
+ }
128
+ return { ok: errors.length === 0, template, errors };
129
+ }
130
+
131
+ module.exports = {
132
+ BASE_TEMPLATE,
133
+ BUILTIN_TEMPLATES,
134
+ templateFor,
135
+ fieldOrder,
136
+ validateFieldValues,
137
+ };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.159",
3
+ "version": "1.19.161",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.159",
9
+ "version": "1.19.161",
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.159",
3
+ "version": "1.19.161",
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
@@ -745,8 +745,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
745
745
  // landed since 1.19.157 with no explicit bump. run 31958753412. (task 1002620)
746
746
  // 1.19.159 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
747
747
  // landed since 1.19.158 with no explicit bump. run 31958999646. (task 1002620)
748
+ // 1.19.160 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
749
+ // landed since 1.19.159 with no explicit bump. run 31959145128. (task 1002620)
750
+ // 1.19.161 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
751
+ // landed since 1.19.160 with no explicit bump. run 31959299652. (task 1002620)
748
752
  // ---------------------------------------------------------------------------
749
- const CORE_VERSION = '1.19.159'; // CI auto-patch carrier (ADR 0161); see changelog above
753
+ const CORE_VERSION = '1.19.161'; // CI auto-patch carrier (ADR 0161); see changelog above
750
754
 
751
755
  // A namespaced logger so a module's log lines are attributable + consistent.
752
756
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,139 @@
1
+ // tests/idea_templates.mjs
2
+ //
3
+ // task 1002917 (BV1.R141, goal 1000062) — the Full Idea base template.
4
+ //
5
+ // The claim these tests protect: THE TEMPLATE IS DATA. Validation follows the
6
+ // declaration, order comes from the declaration, and swapping the registry
7
+ // changes what is accepted with no code change — that is the task's done-when,
8
+ // and the "swap" test at the bottom is the proof (the R156 per-project seam).
9
+ //
10
+ // Pure module — no DB, no doorway, no harness. inbox.js is required transitively
11
+ // (templates.js reads DEFAULT_TEMPLATE_KEY / FIELD_VALUES_MAX from it), and it
12
+ // destructures `pool` off the doorway at require time without querying, so a
13
+ // bare require is safe here the same way tests/idea_grades.mjs relies on.
14
+ //
15
+ // Run: node tests/idea_templates.mjs
16
+
17
+ import { strict as assert } from 'node:assert';
18
+ import { createRequire } from 'node:module';
19
+
20
+ const require = createRequire(import.meta.url);
21
+ const T = require('../modules/ideas/templates.js');
22
+ const { DEFAULT_TEMPLATE_KEY } = require('../modules/ideas/inbox.js');
23
+
24
+ let passed = 0;
25
+ let failed = 0;
26
+ function test(name, fn) {
27
+ try { fn(); passed++; console.log(` ok ${name}`); }
28
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
29
+ }
30
+
31
+ // A complete, well-formed base payload — reused across tests.
32
+ const FULL = {
33
+ purpose: 'A concrete purpose, written out.',
34
+ big_picture: 'What the world looks like once this exists.',
35
+ why_had: 'The observation that sparked it.',
36
+ why_matters: 'Why anyone should spend hours on it.',
37
+ fit: 'Where it sits in the project arc.',
38
+ };
39
+
40
+ // ── the declaration ─────────────────────────────────────────────────────────
41
+
42
+ test('base-v1 declares exactly the five owner fields, in brief order', () => {
43
+ assert.equal(T.BASE_TEMPLATE.key, DEFAULT_TEMPLATE_KEY, 'the base key IS the store default');
44
+ assert.deepEqual(T.fieldOrder(T.BASE_TEMPLATE),
45
+ ['purpose', 'big_picture', 'why_had', 'why_matters', 'fit']);
46
+ for (const f of T.BASE_TEMPLATE.fields) {
47
+ assert.ok(f.label && f.required === true, `${f.key} carries a label and is required`);
48
+ assert.ok(Number.isInteger(f.substanceMin) && f.substanceMin > 0,
49
+ `${f.key} declares the substance floor the R142 scorer reads`);
50
+ }
51
+ });
52
+
53
+ test('the declaration is frozen — a consumer cannot fork it in place', () => {
54
+ assert.throws(() => { T.BASE_TEMPLATE.fields.push({ key: 'rogue' }); });
55
+ assert.throws(() => { 'use strict'; T.BUILTIN_TEMPLATES['base-v1'] = null; });
56
+ });
57
+
58
+ test('templateFor: base key resolves, unknown/blank/non-string do not', () => {
59
+ assert.equal(T.templateFor('base-v1'), T.BASE_TEMPLATE);
60
+ assert.equal(T.templateFor('no-such-template'), null);
61
+ assert.equal(T.templateFor(''), null);
62
+ assert.equal(T.templateFor(null), null);
63
+ });
64
+
65
+ // ── validation follows the declaration ──────────────────────────────────────
66
+
67
+ test('a complete five-field payload validates', () => {
68
+ const r = T.validateFieldValues(FULL);
69
+ assert.equal(r.ok, true);
70
+ assert.deepEqual(r.errors, []);
71
+ assert.equal(r.template, T.BASE_TEMPLATE);
72
+ });
73
+
74
+ test('an unknown field is refused, never silently stored', () => {
75
+ const r = T.validateFieldValues({ ...FULL, budget: 'not a base field' });
76
+ assert.equal(r.ok, false);
77
+ assert.deepEqual(r.errors, [{ field: 'budget', reason: 'unknown_field' }]);
78
+ });
79
+
80
+ test('a missing or blank required field is named, per field', () => {
81
+ const { fit, ...four } = FULL;
82
+ let r = T.validateFieldValues(four);
83
+ assert.deepEqual(r.errors, [{ field: 'fit', reason: 'missing_required' }]);
84
+ r = T.validateFieldValues({ ...FULL, purpose: ' ' });
85
+ assert.deepEqual(r.errors, [{ field: 'purpose', reason: 'missing_required' }]);
86
+ });
87
+
88
+ test('a non-string value is refused once, not double-reported', () => {
89
+ const r = T.validateFieldValues({ ...FULL, why_had: 42 });
90
+ assert.deepEqual(r.errors, [{ field: 'why_had', reason: 'not_a_string' }]);
91
+ });
92
+
93
+ test('a non-object payload and an unknown template are structured refusals', () => {
94
+ assert.deepEqual(T.validateFieldValues(null).errors, [{ field: null, reason: 'not_an_object' }]);
95
+ assert.deepEqual(T.validateFieldValues([]).errors, [{ field: null, reason: 'not_an_object' }]);
96
+ const r = T.validateFieldValues(FULL, { templateKey: 'nope' });
97
+ assert.deepEqual(r.errors, [{ field: null, reason: 'unknown_template' }]);
98
+ assert.equal(r.template, null);
99
+ });
100
+
101
+ test('a payload over the store byte cap is a named refusal, not a DB 500', () => {
102
+ const r = T.validateFieldValues({ ...FULL, purpose: 'x'.repeat(40000) });
103
+ assert.deepEqual(r.errors, [{ field: null, reason: 'too_large' }]);
104
+ });
105
+
106
+ test('substance is scored, not gated: a thin answer still validates', () => {
107
+ const thin = Object.fromEntries(Object.keys(FULL).map((k) => [k, 'ok']));
108
+ assert.equal(T.validateFieldValues(thin).ok, true,
109
+ 'scoring never blocks capture — substanceMin is the scorer\'s, not the validator\'s');
110
+ });
111
+
112
+ // ── the done-when: swap the registry, behaviour follows (the R156 seam) ─────
113
+
114
+ test('a different registry changes what is accepted — no code change', () => {
115
+ const custom = {
116
+ 'econ-v1': {
117
+ key: 'econ-v1',
118
+ label: 'Economics Idea',
119
+ fields: [
120
+ { key: 'market', label: 'The market', required: true, substanceMin: 20 },
121
+ { key: 'unit_econ', label: 'Unit economics', required: false, substanceMin: 20 },
122
+ ],
123
+ },
124
+ };
125
+ const opts = { templateKey: 'econ-v1', registry: custom };
126
+ // the custom shape validates…
127
+ assert.equal(T.validateFieldValues({ market: 'builders paying for modules' }, opts).ok, true);
128
+ // …the BASE fields are now the unknown ones…
129
+ const r = T.validateFieldValues({ purpose: 'base field' }, opts);
130
+ assert.equal(r.ok, false);
131
+ assert.ok(r.errors.some((e) => e.field === 'purpose' && e.reason === 'unknown_field'));
132
+ // …and order comes from the new declaration
133
+ assert.deepEqual(T.fieldOrder(custom['econ-v1']), ['market', 'unit_econ']);
134
+ // the built-in registry is untouched by any of it
135
+ assert.equal(T.validateFieldValues(FULL).ok, true);
136
+ });
137
+
138
+ console.log(`\nidea_templates: ${passed} passed, ${failed} failed`);
139
+ if (failed > 0) process.exit(1);