@bongos/core 1.19.299 → 1.19.301

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.
@@ -26,6 +26,15 @@
26
26
  // here would be a second copy of a DIFFERENT rule — the exact mistake this file
27
27
  // is organised to avoid — so the gauge reports completeness and stops there.
28
28
  //
29
+ // TWO MODES, ONE FORM (task 1002930 / R154). Opened plainly, the composer FILES a
30
+ // new Full Idea. Opened as ?develop=<id> from the spark queue below, it DEVELOPS
31
+ // an existing spark: the spark's own words are shown above the fields, its title
32
+ // becomes a read-only fact rather than an editable input (developing an idea must
33
+ // not quietly rename it out from under its author), and filing calls
34
+ // POST /inbox/:id/develop instead of POST /inbox. The five fields, the meter, the
35
+ // validation and the draft behaviour are identical — which is the point of reusing
36
+ // this surface rather than building a second one that drifts from it.
37
+ //
29
38
  // DRAFTS ARE LOCAL. "A partial draft is not lost on navigation" is a done-when,
30
39
  // and a half-written thought is not something to put in the database: it has no
31
40
  // title yet, no grade, and nothing downstream should be able to see it. It lives
@@ -38,9 +47,17 @@
38
47
  const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
39
48
  const { escapeHtml, $, toast } = window.OTB;
40
49
 
41
- // One key, this page only. Versioned by template key at save time so a project
42
- // that changes its template (R156) cannot restore a draft shaped for the old one.
43
- const DRAFT_KEY = 'hall-full-idea-draft';
50
+ // One key per THING BEING WRITTEN. A new idea and a spark being developed are
51
+ // different drafts, and sharing one key would let a half-written new idea
52
+ // overwrite someone's development work (or restore itself into it). Versioned by
53
+ // template key at save time too, so a project that changes its template (R156)
54
+ // cannot restore a draft shaped for the old one.
55
+ const developId = () => {
56
+ const m = /[?&]develop=(\d+)\b/.exec(location.search);
57
+ return m ? m[1] : null;
58
+ };
59
+ const DEVELOP_ID = developId();
60
+ const DRAFT_KEY = DEVELOP_ID ? `hall-full-idea-draft:develop:${DEVELOP_ID}` : 'hall-full-idea-draft';
44
61
  const SCORE_DEBOUNCE_MS = 300;
45
62
  const DRAFT_DEBOUNCE_MS = 500;
46
63
 
@@ -187,7 +204,9 @@
187
204
  // problem, not a capture error). Matching that exactly means the button never
188
205
  // promises an outcome the server will refuse, and never blocks one it accepts.
189
206
  const missingRequired = template.fields.some((f) => f.required && !fieldEl(f.key).value.trim());
190
- $('#fi-submit').disabled = filing || missingRequired || !titleEl().value.trim();
207
+ // In develop mode the title is the existing idea's and is not editable, so it
208
+ // cannot be a reason to keep the button off.
209
+ $('#fi-submit').disabled = filing || missingRequired || (!DEVELOP_ID && !titleEl().value.trim());
191
210
  }
192
211
 
193
212
  async function scoreNow() {
@@ -209,7 +228,7 @@
209
228
  // round-trip: it depends only on what is typed.
210
229
  if (lastScore) renderScore(lastScore);
211
230
  else $('#fi-submit').disabled = filing
212
- || !titleEl().value.trim()
231
+ || (!DEVELOP_ID && !titleEl().value.trim())
213
232
  || template.fields.some((f) => f.required && !fieldEl(f.key).value.trim());
214
233
  }
215
234
 
@@ -251,14 +270,22 @@
251
270
  showErr('');
252
271
  $('#fi-submit').disabled = true;
253
272
 
254
- const r = await api.inbox.postInbox({
255
- body: {
256
- title: titleEl().value.trim(),
257
- grade: 'full',
258
- template_key: template.key,
259
- field_values: collect(),
260
- },
261
- });
273
+ // The one behavioural fork between the two modes. Developing is an act on an
274
+ // EXISTING idea, so it carries no title and no grade: the row already has
275
+ // both, and R146 preserves them along with its author.
276
+ const r = DEVELOP_ID
277
+ ? await api.inbox.postInboxIdDevelop({
278
+ id: DEVELOP_ID,
279
+ body: { field_values: collect(), template_key: template.key },
280
+ })
281
+ : await api.inbox.postInbox({
282
+ body: {
283
+ title: titleEl().value.trim(),
284
+ grade: 'full',
285
+ template_key: template.key,
286
+ field_values: collect(),
287
+ },
288
+ });
262
289
 
263
290
  filing = false;
264
291
  if (!r.ok) {
@@ -275,17 +302,28 @@
275
302
  // grade write did not, and says so. Repeat that here instead of claiming a
276
303
  // Full Idea was stored when the response says otherwise.
277
304
  const landedFull = idea && idea.grade === 'full';
278
- toast(landedFull
279
- ? `Filed as a Full Idea${score == null ? '' : ` — ${score}/100`}.`
280
- : 'Filed, but stored as a quick idea — the grade could not be written.');
305
+ if (DEVELOP_ID) {
306
+ // The split is the developer's business: they earned a share of it and the
307
+ // author kept theirs, and saying so is how the handoff stops feeling like
308
+ // giving work away (R147 / ADR 0185).
309
+ const credited = r.data && r.data.idea && r.data.idea.ideator_credit;
310
+ toast(`Developed${score == null ? '' : ` — ${score}/100`}${credited ? `. ${credited} credits split with its author.` : '.'}`);
311
+ } else {
312
+ toast(landedFull
313
+ ? `Filed as a Full Idea${score == null ? '' : ` — ${score}/100`}.`
314
+ : 'Filed, but stored as a quick idea — the grade could not be written.');
315
+ }
281
316
 
282
- titleEl().value = '';
317
+ if (!DEVELOP_ID) titleEl().value = '';
283
318
  for (const f of template.fields) fieldEl(f.key).value = '';
284
319
  lastScore = null;
285
320
  renderScore(null);
286
321
  note('');
287
322
  if (idea && idea.id) {
288
- $('#fi-sub').innerHTML = `Filed <a href="/#/idea/${encodeURIComponent(idea.id)}">idea ${escapeHtml(String(idea.id))}</a>. Write another below.`;
323
+ // The detail PAGE (task 1002929 / R153), not the #/idea/N overlay: the
324
+ // thing just filed has five prose answers, which is what the page is for.
325
+ const base = location.hostname.startsWith('builders.') ? '' : '/builders';
326
+ $('#fi-sub').innerHTML = `Filed <a href="${base}/idea/${encodeURIComponent(idea.id)}">idea ${escapeHtml(String(idea.id))}</a>. Write another below.`;
289
327
  }
290
328
  }
291
329
 
@@ -305,6 +343,79 @@
305
343
  // boot
306
344
  // ---------------------------------------------------------------------------
307
345
 
346
+ // ---------------------------------------------------------------------------
347
+ // the spark queue (task 1002930 / R154)
348
+ // ---------------------------------------------------------------------------
349
+
350
+ const hallBase = () => (location.hostname.startsWith('builders.') ? '' : '/builders');
351
+
352
+ function renderQueue(sparks, count) {
353
+ const host = $('#fi-queue');
354
+ if (!sparks || sparks.length === 0) {
355
+ // An answer, not a blank space — and it says how one gets here, since the
356
+ // only way to fill this queue is for an author to offer something up.
357
+ host.innerHTML = '<p class="fi-queue-empty">Nothing is waiting. A spark appears here when its author offers a quick idea up for development.</p>';
358
+ return;
359
+ }
360
+ const total = Number.isInteger(count) ? count : sparks.length;
361
+ const more = total > sparks.length ? `<p class="fi-note">Showing ${sparks.length} of ${total}.</p>` : '';
362
+ host.innerHTML = `<div class="fi-queue">${sparks.map((sp) => {
363
+ const who = sp.filed_by_login ? `@${escapeHtml(sp.filed_by_login)}` : 'an unnamed builder';
364
+ const when = sp.captured_at ? ` · ${escapeHtml(String(sp.captured_at).slice(0, 10))}` : '';
365
+ return `
366
+ <div class="fi-spark">
367
+ <p class="fi-spark__title"><a href="${hallBase()}/idea/${encodeURIComponent(sp.id)}">${escapeHtml(sp.title || `Idea ${sp.id}`)}</a></p>
368
+ <span class="fi-spark__who">had by ${who}${when}</span>
369
+ <a class="fi-spark__go btn btn--ghost" href="?develop=${encodeURIComponent(sp.id)}">Develop this →</a>
370
+ </div>`;
371
+ }).join('')}</div>${more}`;
372
+ }
373
+
374
+ async function loadQueue() {
375
+ const r = await api.inbox.getInboxSparks({});
376
+ // The queue is secondary to the composer: an outage here must not stop
377
+ // someone writing, so it degrades to a line rather than an error state.
378
+ if (!r.ok || !r.data) {
379
+ $('#fi-queue').innerHTML = '<p class="fi-queue-empty">Could not load the queue just now.</p>';
380
+ return;
381
+ }
382
+ renderQueue(r.data.sparks, r.data.count);
383
+ }
384
+
385
+ // ---------------------------------------------------------------------------
386
+ // develop mode
387
+ // ---------------------------------------------------------------------------
388
+
389
+ // Show the spark's own words above the fields, and make the title a FACT rather
390
+ // than an input. Returns false when the idea cannot be developed, so boot can
391
+ // say why instead of presenting a form that will 409.
392
+ function enterDevelopMode(idea) {
393
+ if (idea.grade !== 'spark') {
394
+ $('#fi-sub').textContent = idea.grade === 'full'
395
+ ? `Idea ${idea.id} has already been developed into a Full Idea.`
396
+ : `Idea ${idea.id} is not a Big Idea Spark — only its author can offer it up for development.`;
397
+ $('#fi-form').hidden = true;
398
+ return false;
399
+ }
400
+ $('#fi-develop').hidden = false;
401
+ $('#fi-develop-title').textContent = idea.title || `Idea ${idea.id}`;
402
+ $('#fi-develop-body').textContent = String(idea.body_md || '').trim();
403
+ $('#fi-develop-who').textContent = idea.filed_by_login
404
+ ? `had by @${idea.filed_by_login}${idea.captured_at ? ` · ${String(idea.captured_at).slice(0, 10)}` : ''} — they keep the credit for the thought`
405
+ : 'they keep the credit for the thought';
406
+ // The title is the existing idea's. Shown, not editable: developing an idea
407
+ // must not quietly rename it out from under its author.
408
+ const t = titleEl();
409
+ t.value = idea.title || '';
410
+ t.readOnly = true;
411
+ t.tabIndex = -1;
412
+ $('#fi-title-label').hidden = true;
413
+ t.hidden = true;
414
+ $('#fi-submit').textContent = 'Develop this idea';
415
+ $('#fi-sub').textContent = 'Answer the five questions and this spark becomes a Full Idea.';
416
+ return true;
417
+ }
418
+
308
419
  async function main() {
309
420
  const r = await api.inbox.getInboxTemplate({});
310
421
  if (!r.ok || !r.data || !r.data.template) {
@@ -323,6 +434,22 @@
323
434
  $('#fi-form').addEventListener('submit', file);
324
435
  $('#fi-clear').addEventListener('click', discard);
325
436
 
437
+ // The queue loads on every visit, develop mode or not: having just picked one
438
+ // up, seeing what else is waiting is the natural next thing.
439
+ loadQueue();
440
+
441
+ if (DEVELOP_ID) {
442
+ const d = await api.inbox.getInboxId({ id: DEVELOP_ID });
443
+ if (!d.ok || !d.data || !d.data.idea) {
444
+ $('#fi-sub').textContent = `Could not load idea ${DEVELOP_ID}.`;
445
+ $('#fi-form').hidden = true;
446
+ return;
447
+ }
448
+ if (!enterDevelopMode(d.data.idea)) return;
449
+ // Re-run the button rule now that the title requirement is lifted.
450
+ onEdit();
451
+ }
452
+
326
453
  if (usable) {
327
454
  note('Draft restored.');
328
455
  scoreNow();
@@ -118,6 +118,19 @@
118
118
  }
119
119
  if (r.shipped_at) nodes.push(h('p', 'deeplink-card__note', 'Shipped ' + fmtRel(r.shipped_at)));
120
120
 
121
+ // task 1002929 (R153): an idea now has a real page, and this card cannot show
122
+ // a Full Idea's five prose answers legibly — so it stops pretending to and
123
+ // points at the page instead. The overlay KEEPS the triage panel below (~320
124
+ // lines of promote/merge/discard); this is the summary→detail relationship the
125
+ // Work board already has with /task/:id, not a replacement.
126
+ if (kind === 'idea') {
127
+ var fv = h('p', 'deeplink-card__note');
128
+ var fl = h('a', 'deeplink-card__link', r.grade === 'full' ? 'Read the full idea →' : 'Open this idea →');
129
+ fl.href = hallBase() + '/idea/' + encodeURIComponent(r.id);
130
+ fv.appendChild(fl);
131
+ nodes.push(fv);
132
+ }
133
+
121
134
  // task 759 → task 1002447: Metic+ idea triage, right in the overlay (no
122
135
  // /idea-triage skill and no LLM in the loop). 759 shipped Discard alone;
123
136
  // 1002447 adds the other two verbs so the whole verdict set is reachable
@@ -507,11 +507,24 @@ async function listOpenIdeas({ kind = null, limit = 50, viewerRank = null } = {}
507
507
  return rows;
508
508
  }
509
509
 
510
+ // The by-id read. It carries the two people's LOGINS as well as their ids
511
+ // (task 1002929 / R153): an idea's attribution is a claim about humans, and
512
+ // "captured_by: 41" is not attribution to anyone reading it. Named to match the
513
+ // R145 spark queue's `filed_by_login` so the two idea surfaces agree.
514
+ //
515
+ // Both joins are LEFT: a builder row can be missing (an FK that went
516
+ // ON DELETE SET NULL), and an idea nobody developed has no developer at all —
517
+ // which is the common case, not an error. A plain JOIN here would drop most of
518
+ // the inbox, the same trap the grade join documents above.
510
519
  async function getIdea(id) {
511
520
  const { rows } = await pool.query(
512
- `SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q}
521
+ `SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q},
522
+ ba.github_login AS filed_by_login,
523
+ bd.github_login AS developed_by_login
513
524
  FROM idea_inbox i
514
525
  ${IDEA_JOIN_GRADE}
526
+ LEFT JOIN builders ba ON ba.id = i.captured_by
527
+ LEFT JOIN builders bd ON bd.id = g.developed_by
515
528
  WHERE i.id = $1`,
516
529
  [id]
517
530
  );
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.299",
3
+ "version": "1.19.301",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.299",
9
+ "version": "1.19.301",
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.299",
3
+ "version": "1.19.301",
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",
@@ -881,9 +881,15 @@ function mountInternalSurfaces(app) {
881
881
  // needs its own two-segment match ahead of the single-segment resolution
882
882
  // below. Same gate tier as Work/Roadmap/Goals (SIGNEDIN_BUILDERS_PAGE_RE).
883
883
  const TASK_DETAIL_PAGE_RE = /^\/builders\/task\/(\d+)\/?$/;
884
+ // task 1002929 (R153 of goal 1000062): the idea-detail page is the SECOND
885
+ // two-segment hall page — /builders/idea/:id — for the same reason task 751 gave
886
+ // the task one: a record with real prose in it deserves a shareable URL and
887
+ // working back/forward, not a modal. Same gate tier as the task page.
888
+ const IDEA_DETAIL_PAGE_RE = /^\/builders\/idea\/(\d+)\/?$/;
884
889
  function buildersHtmlName(reqPath) {
885
890
  if (reqPath === '/builders' || reqPath === '/builders/') return 'index.html';
886
891
  if (TASK_DETAIL_PAGE_RE.test(reqPath)) return buildersHtml['task.html'] ? 'task.html' : null;
892
+ if (IDEA_DETAIL_PAGE_RE.test(reqPath)) return buildersHtml['idea.html'] ? 'idea.html' : null;
887
893
  // e.g. /builders/ranks or /builders/ranks.html → ranks.html
888
894
  const m = reqPath.match(BUILDERS_PAGE_RE);
889
895
  if (m) {
@@ -956,7 +962,7 @@ function mountInternalSurfaces(app) {
956
962
  // behind the proxy's path normalization.
957
963
  const norm = path.posix.normalize(req.path);
958
964
  if (SIGNEDIN_BUILDERS_PAGE_RE.test(norm) || SIGNEDIN_BUILDERS_PREFIX_RE.test(norm)
959
- || TASK_DETAIL_PAGE_RE.test(norm)) {
965
+ || TASK_DETAIL_PAGE_RE.test(norm) || IDEA_DETAIL_PAGE_RE.test(norm)) {
960
966
  return gdsAuth.requireBuilderPage(req, res, next);
961
967
  }
962
968
  next();
package/src/module-api.js CHANGED
@@ -1025,8 +1025,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1025
1025
  // landed since 1.19.297 with no explicit bump. run 32597859474. (task 1002620)
1026
1026
  // 1.19.299 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1027
1027
  // landed since 1.19.298 with no explicit bump. run 32600322268. (task 1002620)
1028
+ // 1.19.300 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1029
+ // landed since 1.19.299 with no explicit bump. run 32601521458. (task 1002620)
1030
+ // 1.19.301 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1031
+ // landed since 1.19.300 with no explicit bump. run 32602146266. (task 1002620)
1028
1032
  // ---------------------------------------------------------------------------
1029
- const CORE_VERSION = '1.19.299'; // CI auto-patch carrier (ADR 0161); see changelog above
1033
+ const CORE_VERSION = '1.19.301'; // CI auto-patch carrier (ADR 0161); see changelog above
1030
1034
 
1031
1035
  // A namespaced logger so a module's log lines are attributable + consistent.
1032
1036
  // Usage: const log = api.logger('dev-box'); log.info('mounted');