@bongos/core 1.19.296 → 1.19.297

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.
@@ -593,6 +593,160 @@ module.exports = function buildInboxRouter() {
593
593
  });
594
594
  }, { errorCode: 'idea_unspark_failed' }));
595
595
 
596
+ // POST /inbox/:id/develop — DEVELOP A SPARK INTO A FULL IDEA
597
+ // (task 1002922 / BV1.R146, goal 1000062).
598
+ //
599
+ // The pickup half of the handoff R144 and R145 set up: the author consented by
600
+ // marking the spark, the queue made it findable, and now another builder fills
601
+ // in the five template fields and the thought becomes a Full Idea.
602
+ //
603
+ // OPEN TO ANY BUILDER, INCLUDING THE AUTHOR. Cross-builder development is the
604
+ // entire point, so there is no author check here — the mirror image of R144,
605
+ // where the gate was the whole feature. The consent was already given and is
606
+ // already recorded (this row is a spark BECAUSE its author said so), and
607
+ // re-asking for it would make the queue unpickable. Self-development is
608
+ // likewise legal: an author may mark a thought and come back to it themselves.
609
+ //
610
+ // ONLY A SPARK CAN BE DEVELOPED, and "only once" is enforced by the DATABASE,
611
+ // not by this handler. developSpark's UPDATE carries `AND grade = 'spark'`, so
612
+ // two builders picking the same spark off the queue at the same instant cannot
613
+ // both succeed — the second matches zero rows. The pre-read below exists ONLY
614
+ // to tell a 404 apart from a 409 and to give a useful message; it is not the
615
+ // guard, and it deliberately does not try to be.
616
+ //
617
+ // WHAT THIS ROUTE DELIBERATELY DOES NOT DO: pay the stream-2 "Full Idea passed"
618
+ // credit. Capture and resubmit both fire it, but on those paths the recipient
619
+ // is unambiguously the author. Here there are two people, and choosing between
620
+ // them is R147's job (task 1002923 — "karma for a ratified Full Idea, scaled by
621
+ // completeness and SPLIT WITH THE SPARK AUTHOR"). Firing it here would not just
622
+ // pre-empt that decision, it would corrupt it: awardIdeaFullPassCredit's
623
+ // idempotency predicate is (builder_id, `idea.credit.full_pass:<id>`), so a
624
+ // payment to the DEVELOPER does not block a later payment to the AUTHOR — one
625
+ // idea would pay twice. The credit is idempotent per idea+builder, so R147 can
626
+ // still pay it at the right time, to the right people, in the right shares.
627
+ //
628
+ // The board window IS opened, because it is recipient-agnostic (it takes an
629
+ // idea and a score, not a builder) and without it a developed Full Idea could
630
+ // never reach ratification — the handoff would dead-end one step from done.
631
+ // Fail-open like every other caller of that port.
632
+ //
633
+ // rank: any-builder — development is the pickup, and the author's mark is the
634
+ // consent. Deliberately NOT pinned in route-rank-check: there is no gate here
635
+ // to protect, and pinning it would only assert that an open route stays open.
636
+ router.post('/inbox/:id/develop', api.requireBuilder, api.asyncHandler('POST /inbox/:id/develop', async (req, res) => {
637
+ const id = parseId(req, res, { code: 'bad_id', positiveInt: true });
638
+ if (id === null) return;
639
+ if (validateOrRespond(req, res, {
640
+ field_values: { required: true, type: 'object' },
641
+ template_key: { type: 'string', maxLength: 64 },
642
+ })) return;
643
+
644
+ const idea = await inbox.getIdea(id);
645
+ if (!idea) return res.fail('idea_not_found', 404);
646
+ if (idea.grade !== 'spark') {
647
+ return res.fail('not_a_spark', 409, {
648
+ grade: idea.grade,
649
+ ...(idea.developed_by ? { developed_by: idea.developed_by } : {}),
650
+ reason: idea.grade === 'full'
651
+ ? 'this idea has already been developed into a Full Idea'
652
+ : 'only a Big Idea Spark can be developed — its author has to mark it first, which is what makes developing it fair',
653
+ });
654
+ }
655
+ // A SPARK IS NOT NECESSARILY STILL DEVELOPABLE. Triage writes
656
+ // idea_inbox.status and never touches ideas_grades, so an idea a triager
657
+ // discarded, merged or promoted keeps grade='spark' forever. Developing one
658
+ // would resurrect content the triage flow already decided was dead — and
659
+ // worse, open a fresh board ratification window for it. The R144 mark and the
660
+ // R145 queue both require status='open'; this is the same rule on the write.
661
+ // (developSpark's predicate carries it too, for the mid-request race.)
662
+ if (idea.status !== 'open') {
663
+ return res.fail('idea_not_open', 409, {
664
+ status: idea.status,
665
+ reason: 'this idea is no longer open, so it is not awaiting development',
666
+ });
667
+ }
668
+
669
+ // Same validation the capture and resubmit paths use, against the same
670
+ // template declaration — a developed Full Idea is a Full Idea, and must not
671
+ // be held to a looser standard just because someone else wrote it.
672
+ const templateKey = req.body.template_key ?? idea.template_key ?? undefined;
673
+ let v;
674
+ try {
675
+ v = validateFieldValues(req.body.field_values, templateKey ? { templateKey } : undefined);
676
+ } catch (validationErr) {
677
+ api.logger('ideas').error(`POST /inbox/:id/develop validation threw: ${validationErr && validationErr.message}`);
678
+ return res.fail('invalid_field_values', 400, { errors: [{ field: null, reason: 'unvalidatable' }] });
679
+ }
680
+ if (!v.ok) return res.fail('invalid_field_values', 400, { errors: v.errors, template_key: templateKey ?? null });
681
+
682
+ const row = await inbox.developSpark(id, {
683
+ developedBy: req.builder.id,
684
+ ...(templateKey ? { templateKey } : {}),
685
+ fieldValues: req.body.field_values,
686
+ completenessScore: completenessScore(req.body.field_values, templateKey ? { templateKey } : undefined),
687
+ });
688
+ // Zero rows means the guard fired: somebody else developed this spark between
689
+ // the read above and this write. Their work stands; this caller is told so
690
+ // rather than silently overwriting it.
691
+ if (!row) {
692
+ // The guard fired between the read above and the write. THREE causes now,
693
+ // and guessing would misinform: another developer got there first, or a
694
+ // triager closed the idea. One extra read on a rare path buys an honest
695
+ // answer instead of a plausible one.
696
+ const now = await inbox.getIdea(id);
697
+ if (now && now.status !== 'open') {
698
+ return res.fail('idea_not_open', 409, {
699
+ status: now.status,
700
+ reason: 'a triager closed this idea while you were developing it',
701
+ });
702
+ }
703
+ return res.fail('spark_already_developed', 409, {
704
+ ...(now && now.developed_by ? { developed_by: now.developed_by } : {}),
705
+ reason: 'another builder developed this spark first — their version stands',
706
+ });
707
+ }
708
+
709
+ const out = {
710
+ ...idea,
711
+ grade: row.grade,
712
+ template_key: row.template_key,
713
+ field_values: row.field_values,
714
+ completeness_score: row.completeness_score,
715
+ developed_by: row.developed_by,
716
+ developed_at: row.developed_at,
717
+ };
718
+
719
+ let board_window;
720
+ try {
721
+ const government = api.resolveOptional && api.resolveOptional('government');
722
+ if (government && government.openBoardWindowForIdea) {
723
+ const w = await government.openBoardWindowForIdea({ ideaId: id, completenessScore: row.completeness_score });
724
+ if (w && w.opened && w.item) board_window = { item_id: w.item.id, closes_at: w.item.closes_at };
725
+ else if (w && w.reason === 'below_bar') {
726
+ // The same honest feedback the resubmit path gives: which section is
727
+ // thinnest, computed from what was just written. Derived, never stored.
728
+ const flag = weakestSectionPayload(req.body.field_values, templateKey ? { templateKey } : undefined);
729
+ if (flag) out.rejection = flag;
730
+ }
731
+ }
732
+ } catch (boardErr) {
733
+ // Fail-open, like every other caller of this port: the development landed
734
+ // and must not be rolled back because the board could not be told.
735
+ api.logger('ideas').error(`develop: board-window open failed (development intact, window skipped): ${boardErr && boardErr.message}`);
736
+ }
737
+
738
+ return res.json({
739
+ idea: out,
740
+ developed: true,
741
+ // Named explicitly so a caller never has to compare two ids to find out,
742
+ // and so the R147 split reads its two parties off one response.
743
+ author_id: idea.captured_by ?? null,
744
+ developer_id: row.developed_by,
745
+ self_developed: idea.captured_by != null && String(idea.captured_by) === String(row.developed_by),
746
+ ...(board_window ? { board_window } : {}),
747
+ });
748
+ }, { errorCode: 'idea_develop_failed' }));
749
+
596
750
  // Metic+ (#360 / ADR 0018): idea triage (promote/discard/merge) shapes the
597
751
  // backlog. Criterion C2 assigns triage to Metic, so this widened from
598
752
  // 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.296",
3
+ "version": "1.19.297",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.296",
9
+ "version": "1.19.297",
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.296",
3
+ "version": "1.19.297",
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",
@@ -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,10 @@ 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)
1022
1024
  // ---------------------------------------------------------------------------
1023
- const CORE_VERSION = '1.19.296'; // CI auto-patch carrier (ADR 0161); see changelog above
1025
+ const CORE_VERSION = '1.19.297'; // CI auto-patch carrier (ADR 0161); see changelog above
1024
1026
 
1025
1027
  // A namespaced logger so a module's log lines are attributable + consistent.
1026
1028
  // Usage: const log = api.logger('dev-box'); log.info('mounted');