@bongos/core 1.19.296 → 1.19.298

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.
@@ -19,7 +19,17 @@
19
19
  // are one mental model, and a queue nobody can read is not a pickup surface. The
20
20
  // queue is readable by ANY builder: the author's mark is already the consent.
21
21
  //
22
+ // --develop is the PICKUP (task 1002922 / BV1.R146): fill in the five template
23
+ // fields and the spark becomes a Full Idea, in place, with the original author
24
+ // preserved and you recorded as its developer. Open to any builder — the author's
25
+ // mark was the consent. The five fields are prose, so they come from a JSON file
26
+ // rather than five shell flags; --template prints the shape to fill in. The hall
27
+ // form (R152) and conversational authoring (R155) are the richer surfaces; this
28
+ // is the terminal path, and all three hit the same route.
29
+ //
22
30
  // Usage:
31
+ // node scripts/gds/spark.js --template # print the five fields to fill in
32
+ // node scripts/gds/spark.js --develop 123 --fields-file idea.json
23
33
  // node scripts/gds/spark.js --list # the spark queue: what awaits development
24
34
  // node scripts/gds/spark.js --list --limit 10
25
35
  // node scripts/gds/spark.js 123 # mark idea 123 as a spark
@@ -30,7 +40,12 @@
30
40
  // 1 api / network error, or the server refused (not yours / not a quick idea)
31
41
  // 2 bad invocation
32
42
 
43
+ const fs = require('node:fs/promises');
33
44
  const { cliClient, requireSession, hasFlag, arg, hallBase } = require('./cli-lib');
45
+ // A CLI may reach into its own domain module — the capture.js / autonomy-gate.js
46
+ // precedent. Pure and DB-free: this only reads the R141 template declaration so
47
+ // the prompt below cannot drift from what the server validates against.
48
+ const { BASE_TEMPLATE } = require('../../modules/ideas/templates');
34
49
 
35
50
  // The server's refusals, in the ideator's own words. Anything unmapped falls
36
51
  // through to the raw envelope — a new server-side rule should be visible, not
@@ -39,6 +54,24 @@ const REFUSALS = {
39
54
  not_your_idea: (d) =>
40
55
  'That idea is not yours to mark. The spark mark is the author\'s consent to have their\n'
41
56
  + 'idea developed, so only the builder who filed it can give or withdraw it.',
57
+ not_a_spark: (d) =>
58
+ d.grade === 'full'
59
+ ? `That idea has already been developed into a Full Idea${d.developed_by ? ` (by builder ${d.developed_by})` : ''}.\n`
60
+ + 'A spark can only be developed once.'
61
+ : 'That idea is not a Big Idea Spark, so it is not available to develop.\n'
62
+ + 'Its author has to mark it first — that mark is what makes developing it fair.',
63
+ spark_already_developed: () =>
64
+ 'Another builder developed this spark first, moments ago. Their version stands.\n'
65
+ + 'Run --list to see what else is waiting.',
66
+ invalid_field_values: (d) => {
67
+ const errs = Array.isArray(d) ? d : (d.errors || []);
68
+ const lines = ['That is not a complete Full Idea yet:'];
69
+ for (const e of errs) {
70
+ lines.push(` - ${e.field || '(the whole set)'}: ${String(e.reason || '').replace(/_/g, ' ')}`);
71
+ }
72
+ lines.push('Run --template to see the five fields and re-run with the file fixed.');
73
+ return lines.join('\n');
74
+ },
42
75
  not_a_quick_idea: (d) =>
43
76
  `That idea is already a Full Idea (grade=${d.grade || 'full'}). It carries the developed\n`
44
77
  + 'picture the spark mark asks for — there is nothing to hand off.',
@@ -104,10 +137,89 @@ async function listQueue() {
104
137
  console.log(renderQueue(r.data || {}));
105
138
  }
106
139
 
140
+ // The five fields, printed as a fill-in-the-blanks JSON skeleton. Read from the
141
+ // R141 declaration rather than restated, so it cannot drift from what the server
142
+ // validates — and it names the substance floor, because "present but three words"
143
+ // is the failure the scorer actually punishes.
144
+ function templateSkeleton() {
145
+ const lines = ['The five fields of a Full Idea (template ' + BASE_TEMPLATE.key + '):', ''];
146
+ for (const f of BASE_TEMPLATE.fields) {
147
+ lines.push(` ${f.key}`);
148
+ lines.push(` ${f.label}`);
149
+ if (f.substanceMin) lines.push(` at least ~${f.substanceMin} characters of real content — padding scores nothing`);
150
+ }
151
+ lines.push('');
152
+ lines.push('Write them into a JSON file, then develop a spark with it:');
153
+ lines.push('');
154
+ lines.push(' ' + JSON.stringify(Object.fromEntries(BASE_TEMPLATE.fields.map((f) => [f.key, '...'])), null, 2).replace(/\n/g, '\n '));
155
+ lines.push('');
156
+ lines.push(' node scripts/gds/spark.js --develop <idea-id> --fields-file <that-file.json>');
157
+ return lines.join('\n');
158
+ }
159
+
160
+ // What the developer is told once it lands. The completeness score is the number
161
+ // the R147 credit split and the board bar both read, so it is the headline.
162
+ function renderDeveloped(data) {
163
+ const { idea, author_id, developer_id, self_developed, board_window } = data || {};
164
+ const lines = [];
165
+ lines.push(`Idea ${idea.id} is now a Full Idea — completeness ${idea.completeness_score ?? 'unscored'}/100.`);
166
+ lines.push(` ${hallBase()}/#/idea/${idea.id}`);
167
+ lines.push(self_developed
168
+ ? ' You had the thought and you developed it.'
169
+ : ` The thought stays credited to builder ${author_id}; you are recorded as its developer (${developer_id}).`);
170
+ if (board_window) {
171
+ lines.push(` A board ratification window is open until ${board_window.closes_at}.`);
172
+ } else if (idea.rejection) {
173
+ lines.push(` Not yet at the bar — thinnest section: ${idea.rejection.section_key}.`);
174
+ if (idea.rejection.reason_md) lines.push(` ${idea.rejection.reason_md}`);
175
+ }
176
+ return lines.join('\n');
177
+ }
178
+
179
+ async function developSpark(ideaId) {
180
+ const file = arg('--fields-file');
181
+ if (!file) {
182
+ console.error('--develop needs --fields-file <path.json> (the five fields).');
183
+ console.error('Run: node scripts/gds/spark.js --template');
184
+ process.exit(2);
185
+ }
186
+ let fieldValues;
187
+ try {
188
+ fieldValues = JSON.parse(await fs.readFile(file, 'utf8'));
189
+ } catch (err) {
190
+ console.error(`could not read ${file} as JSON: ${err.message}`);
191
+ process.exit(2);
192
+ }
193
+ const api = await cliClient();
194
+ const templateKey = arg('--template-key');
195
+ const r = await api.inbox.postInboxIdDevelop({
196
+ id: ideaId,
197
+ body: { field_values: fieldValues, ...(templateKey ? { template_key: templateKey } : {}) },
198
+ });
199
+ if (!r.ok) {
200
+ console.error(explain(r.status, r.data));
201
+ process.exit(1);
202
+ }
203
+ console.log(renderDeveloped(r.data));
204
+ }
205
+
107
206
  async function main() {
108
207
  await requireSession();
109
208
 
209
+ if (hasFlag('--template')) {
210
+ console.log(templateSkeleton());
211
+ return;
212
+ }
110
213
  if (hasFlag('--list')) return listQueue();
214
+ const developIdRaw = arg('--develop');
215
+ if (developIdRaw != null) {
216
+ const devId = Number(developIdRaw);
217
+ if (!Number.isInteger(devId) || devId <= 0) {
218
+ console.error('usage: node scripts/gds/spark.js --develop <idea-id> --fields-file <path.json>');
219
+ process.exit(2);
220
+ }
221
+ return developSpark(devId);
222
+ }
111
223
 
112
224
  const positional = process.argv.slice(2).filter((a) => !a.startsWith('-'));
113
225
  const id = Number(positional[0]);
@@ -116,6 +228,8 @@ async function main() {
116
228
  if (!Number.isInteger(id) || id <= 0) {
117
229
  console.error('usage: node scripts/gds/spark.js <idea-id> [--undo]');
118
230
  console.error(' node scripts/gds/spark.js --list [--limit N]');
231
+ console.error(' node scripts/gds/spark.js --develop <idea-id> --fields-file <path.json>');
232
+ console.error(' node scripts/gds/spark.js --template');
119
233
  process.exit(2);
120
234
  }
121
235
 
@@ -141,11 +255,11 @@ async function main() {
141
255
  }
142
256
  }
143
257
 
144
- // explain() and renderQueue() are exported because both are pinned by tests —
145
- // explain against the real envelope shape, renderQueue for the empty case and the
146
- // hall-link form. REFUSALS stays private: it is the message table, reachable
147
- // through explain(), and a third export would be dead surface.
148
- module.exports = { explain, renderQueue };
258
+ // The four renderers are exported because each is pinned by a test — explain
259
+ // against the real envelope shape, and the three renderers for the shapes a
260
+ // human actually reads. REFUSALS stays private: it is the message table,
261
+ // reachable through explain(), and a fifth export would be dead surface.
262
+ module.exports = { explain, renderQueue, renderDeveloped, templateSkeleton };
149
263
 
150
264
  if (require.main === module) {
151
265
  main().catch((err) => {
package/src/module-api.js CHANGED
@@ -1019,8 +1019,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1019
1019
  // landed since 1.19.294 with no explicit bump. run 32590508145. (task 1002620)
1020
1020
  // 1.19.296 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1021
1021
  // landed since 1.19.295 with no explicit bump. run 32591155887. (task 1002620)
1022
+ // 1.19.297 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1023
+ // landed since 1.19.296 with no explicit bump. run 32592502621. (task 1002620)
1024
+ // 1.19.298 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1025
+ // landed since 1.19.297 with no explicit bump. run 32597859474. (task 1002620)
1022
1026
  // ---------------------------------------------------------------------------
1023
- const CORE_VERSION = '1.19.296'; // CI auto-patch carrier (ADR 0161); see changelog above
1027
+ const CORE_VERSION = '1.19.298'; // CI auto-patch carrier (ADR 0161); see changelog above
1024
1028
 
1025
1029
  // A namespaced logger so a module's log lines are attributable + consistent.
1026
1030
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,224 @@
1
+ // tests/idea_credit_split.mjs
2
+ //
3
+ // task 1002923 (BV1.R147, goal 1000062; ADR 0185) — the spark-handoff credit
4
+ // split: 70% developer / 30% spark author, on both ideator streams.
5
+ //
6
+ // The three properties that are REAL BUGS if missed, asserted first:
7
+ // 1. the total is CONSERVED under rounding (round(3.5)+round(1.5) = 6 from a
8
+ // 5-credit payout is the naive mistake);
9
+ // 2. a SELF-DEVELOPED idea collapses to ONE payee — two rows for one builder
10
+ // would be silently UNDER-paid, because the idempotency predicate is
11
+ // (builder_id, reason) and the second insert is refused;
12
+ // 3. zero-value rows are DROPPED, not booked as 0-credit ledger noise.
13
+ //
14
+ // Then the two streams end-to-end against a stub client, and the develop route
15
+ // actually paying (the gap R146 deliberately left open).
16
+ //
17
+ // Run: node tests/idea_credit_split.mjs
18
+
19
+ import { strict as assert } from 'node:assert';
20
+ import { createRequire } from 'node:module';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const credits = require('../modules/economy/credits.js');
24
+ const { splitIdeaCredit, IDEA_DEVELOPER_SHARE, IDEA_FULL_PASS_CREDIT, IDEA_TASK_SHIP_BASE } = credits;
25
+
26
+ let passed = 0;
27
+ let failed = 0;
28
+ async function test(name, fn) {
29
+ try { await fn(); passed++; console.log(` ok ${name}`); }
30
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
31
+ }
32
+
33
+ const sum = (ps) => ps.reduce((n, p) => n + p.delta, 0);
34
+
35
+ // --- property 1: the total is conserved ---------------------------------
36
+
37
+ await test('the split CONSERVES the total at every payout size', () => {
38
+ for (let total = 1; total <= 120; total += 1) {
39
+ const ps = splitIdeaCredit(total, { authorId: 9, developedBy: 42 });
40
+ assert.equal(sum(ps), total, `total ${total} must not gain or lose a credit`);
41
+ }
42
+ });
43
+
44
+ await test('a 5-credit payout splits 4/1, not 4/2 — the rounding trap', () => {
45
+ const ps = splitIdeaCredit(5, { authorId: 9, developedBy: 42 });
46
+ assert.equal(sum(ps), 5);
47
+ const dev = ps.find((p) => p.role === 'developer');
48
+ const author = ps.find((p) => p.role === 'spark_author');
49
+ assert.equal(dev.delta, 4, 'round(5 * 0.70) = 4');
50
+ assert.equal(author.delta, 1, 'the author gets the REMAINDER, not round(1.5)=2');
51
+ });
52
+
53
+ await test('the developer gets the larger share, and it tracks the constant', () => {
54
+ assert.equal(IDEA_DEVELOPER_SHARE, 0.70, 'the owner decision, 2026-08-22');
55
+ const ps = splitIdeaCredit(100, { authorId: 9, developedBy: 42 });
56
+ assert.equal(ps.find((p) => p.role === 'developer').delta, 70);
57
+ assert.equal(ps.find((p) => p.role === 'spark_author').delta, 30);
58
+ });
59
+
60
+ // --- property 2: self-development collapses -----------------------------
61
+
62
+ await test('a SELF-developed idea is ONE payee at the full amount', () => {
63
+ const ps = splitIdeaCredit(5, { authorId: 9, developedBy: 9 });
64
+ assert.equal(ps.length, 1, 'two rows for one builder would be silently under-paid');
65
+ assert.equal(ps[0].delta, 5);
66
+ assert.equal(ps[0].builderId, 9);
67
+ assert.equal(ps[0].role, 'author_developer');
68
+ });
69
+
70
+ await test('string/number id mismatch still counts as the same person', () => {
71
+ // captured_by and developed_by come back from pg as strings on a bigint column.
72
+ const ps = splitIdeaCredit(5, { authorId: '9', developedBy: 9 });
73
+ assert.equal(ps.length, 1, 'a type difference must not become a phantom second payee');
74
+ });
75
+
76
+ await test('an UNDEVELOPED idea pays its author everything', () => {
77
+ const ps = splitIdeaCredit(5, { authorId: 9, developedBy: null });
78
+ assert.deepEqual(ps, [{ builderId: 9, delta: 5, role: 'author' }]);
79
+ });
80
+
81
+ // --- property 3: no zero rows -------------------------------------------
82
+
83
+ await test('a 1-credit payout pays the developer 1 and books NOTHING for the author', () => {
84
+ const ps = splitIdeaCredit(1, { authorId: 9, developedBy: 42 });
85
+ assert.equal(sum(ps), 1);
86
+ assert.equal(ps.length, 1, 'a 0-credit ledger row reads like a payment that was not made');
87
+ assert.equal(ps[0].role, 'developer');
88
+ });
89
+
90
+ await test('a zero or negative payout pays nobody', () => {
91
+ assert.deepEqual(splitIdeaCredit(0, { authorId: 9, developedBy: 42 }), []);
92
+ assert.deepEqual(splitIdeaCredit(-5, { authorId: 9, developedBy: 42 }), []);
93
+ });
94
+
95
+ await test('no author means no payment at all', () => {
96
+ assert.deepEqual(splitIdeaCredit(5, { authorId: null, developedBy: 42 }), []);
97
+ });
98
+
99
+ // --- stream 2, end to end ----------------------------------------------
100
+
101
+ // A stub client that records every INSERT and answers the parties lookup.
102
+ function stubClient({ authorId, developedBy, alreadyPaid = [] }) {
103
+ const inserts = [];
104
+ return {
105
+ inserts,
106
+ query: async (sql, params = []) => {
107
+ if (/FROM idea_inbox i/i.test(sql) && /developed_by/i.test(sql)) {
108
+ return { rows: [{ captured_by: authorId, developed_by: developedBy }] };
109
+ }
110
+ if (/FROM ideas_task_lineage/i.test(sql)) {
111
+ return { rows: [{ idea_id: '77', captured_by: authorId, grade: 'full', completeness: 100, developed_by: developedBy }] };
112
+ }
113
+ if (/INSERT INTO credit_log/i.test(sql)) {
114
+ const [builderId, , delta, reason] = params;
115
+ if (alreadyPaid.some((a) => String(a.builderId) === String(builderId) && a.reason === reason)) {
116
+ return { rowCount: 0 };
117
+ }
118
+ inserts.push({ builderId, delta, reason, description: params[4] });
119
+ return { rowCount: 1 };
120
+ }
121
+ return { rows: [], rowCount: 0 };
122
+ },
123
+ };
124
+ }
125
+
126
+ const ON = { ideatorCredit: true };
127
+
128
+ await test('stream 2: a developed Full Idea pays BOTH parties under one reason key', async () => {
129
+ const c = stubClient({ authorId: '9', developedBy: '42' });
130
+ const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 100, config: ON });
131
+ assert.equal(r.credited, IDEA_FULL_PASS_CREDIT, 'the whole payout is booked, split across two rows');
132
+ assert.equal(c.inserts.length, 2);
133
+ // One reason key for both — which is exactly why the idempotency predicate
134
+ // must include builder_id, or the second payee would be refused.
135
+ assert.equal(new Set(c.inserts.map((i) => i.reason)).size, 1);
136
+ assert.equal(c.inserts.find((i) => i.builderId === '42').delta, 4);
137
+ assert.equal(c.inserts.find((i) => i.builderId === '9').delta, 1);
138
+ assert.match(c.inserts.find((i) => i.builderId === '42').description, /developer/);
139
+ assert.match(c.inserts.find((i) => i.builderId === '9').description, /spark author/);
140
+ });
141
+
142
+ await test('stream 2: the PARTIES come from the DB, not from the caller', async () => {
143
+ // A caller naming the wrong author cannot redirect the money — the whole
144
+ // reason R146 could not safely pay.
145
+ const c = stubClient({ authorId: '9', developedBy: '42' });
146
+ await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '999', completenessScore: 100, config: ON });
147
+ assert.ok(!c.inserts.some((i) => i.builderId === '999'), 'the caller-supplied author is ignored when the row is readable');
148
+ assert.deepEqual(c.inserts.map((i) => i.builderId).sort(), ['42', '9']);
149
+ });
150
+
151
+ await test('stream 2: an undeveloped Full Idea still pays its author the full 5', async () => {
152
+ const c = stubClient({ authorId: '9', developedBy: null });
153
+ const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 100, config: ON });
154
+ assert.equal(r.credited, IDEA_FULL_PASS_CREDIT);
155
+ assert.equal(c.inserts.length, 1);
156
+ assert.equal(c.inserts[0].builderId, '9');
157
+ assert.equal(c.inserts[0].delta, IDEA_FULL_PASS_CREDIT);
158
+ });
159
+
160
+ await test('stream 2: a PARTIALLY booked split completes on a re-run, never double-pays', async () => {
161
+ const reason = 'idea.credit.full_pass:77';
162
+ // The developer's row landed; the author's did not (a crash between inserts).
163
+ const c = stubClient({ authorId: '9', developedBy: '42', alreadyPaid: [{ builderId: '42', reason }] });
164
+ const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 100, config: ON });
165
+ assert.equal(c.inserts.length, 1, 'only the missing row is written');
166
+ assert.equal(c.inserts[0].builderId, '9');
167
+ assert.equal(r.credited, 1, 'credited reports only what was NEWLY booked');
168
+ });
169
+
170
+ await test('stream 2: a fully booked split reports already_paid and writes nothing', async () => {
171
+ const reason = 'idea.credit.full_pass:77';
172
+ const c = stubClient({ authorId: '9', developedBy: '42', alreadyPaid: [{ builderId: '42', reason }, { builderId: '9', reason }] });
173
+ const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 100, config: ON });
174
+ assert.equal(c.inserts.length, 0);
175
+ assert.equal(r.credited, 0);
176
+ assert.equal(r.reason, 'already_paid');
177
+ });
178
+
179
+ await test('stream 2: below the bar nobody is paid, developed or not', async () => {
180
+ const c = stubClient({ authorId: '9', developedBy: '42' });
181
+ const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 10, config: ON });
182
+ assert.equal(r.credited, 0);
183
+ assert.equal(r.reason, 'below_bar');
184
+ assert.equal(c.inserts.length, 0);
185
+ });
186
+
187
+ await test('stream 2: the lane can still be switched off entirely', async () => {
188
+ const c = stubClient({ authorId: '9', developedBy: '42' });
189
+ const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 100, config: { ideatorCredit: false } });
190
+ assert.equal(r.reason, 'ideator_lane_off');
191
+ assert.equal(c.inserts.length, 0);
192
+ });
193
+
194
+ // --- stream 1, end to end ----------------------------------------------
195
+
196
+ await test('stream 1: a task shipped under a DEVELOPED idea splits the same way', async () => {
197
+ const c = stubClient({ authorId: '9', developedBy: '42' });
198
+ const r = await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: ON });
199
+ assert.equal(r.credited, IDEA_TASK_SHIP_BASE, 'a 100% full idea pays the full base, split');
200
+ assert.equal(c.inserts.length, 2);
201
+ assert.equal(sum(c.inserts.map((i) => ({ delta: i.delta }))), IDEA_TASK_SHIP_BASE);
202
+ assert.equal(c.inserts.find((i) => i.builderId === '42').delta, 4);
203
+ assert.equal(c.inserts.find((i) => i.builderId === '9').delta, 1);
204
+ // Same rule, so the same wording — one rule to explain, not two.
205
+ assert.match(c.inserts.find((i) => i.builderId === '42').description, /stream 1/);
206
+ assert.match(c.inserts.find((i) => i.builderId === '42').description, /developer/);
207
+ });
208
+
209
+ await test('stream 1: an undeveloped idea is unchanged — the author gets it all', async () => {
210
+ const c = stubClient({ authorId: '9', developedBy: null });
211
+ const r = await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: ON });
212
+ assert.equal(r.credited, IDEA_TASK_SHIP_BASE);
213
+ assert.equal(c.inserts.length, 1);
214
+ assert.equal(c.inserts[0].builderId, '9');
215
+ });
216
+
217
+ await test('stream 1: the per-(idea,task) reason key still scopes idempotency', async () => {
218
+ const c = stubClient({ authorId: '9', developedBy: '42' });
219
+ await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: ON });
220
+ for (const i of c.inserts) assert.equal(i.reason, 'idea.credit.task_ship:77:100');
221
+ });
222
+
223
+ console.log(`\nidea_credit_split: ${passed} passed, ${failed} failed`);
224
+ if (failed > 0) process.exit(1);