@bongos/core 1.19.298 → 1.19.300

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.
@@ -0,0 +1,338 @@
1
+ // modules/hall-ui/public/ideas.js — the hall "Ideas" tab (/builders/ideas).
2
+ // Task 1002928 / BV1.R152 of goal 1000062 (three grades of idea).
3
+ //
4
+ // WHY THE HALL AT ALL (the owner's principle, recorded on the goal): anything
5
+ // fundamentally simple, that does not need the Claude terminal, should be doable
6
+ // inside the builders hall. Writing down a thought is exactly that — so a Full
7
+ // Idea can be authored and filed here without a terminal, which is this task's
8
+ // first done-when.
9
+ //
10
+ // THE ONE RULE THIS FILE OBEYS: IT CONTAINS NO RULES.
11
+ // Not the five questions, not their character floors, not the scoring
12
+ // arithmetic. The done-when says the meter must reflect the REAL scorer, "not a
13
+ // second copy of the rules", and the cheapest way to violate that is not a
14
+ // re-implemented formula — it is five hand-written <textarea> elements, which
15
+ // quietly fork the template one step before any scoring happens. So:
16
+ //
17
+ // the FORM is rendered from GET /inbox/template (the R141 declaration)
18
+ // the METER is filled from POST /inbox/score (the R142 scorer itself)
19
+ //
20
+ // Both are pure server calls with no DB behind them, which is what makes a
21
+ // debounced call per keystroke reasonable.
22
+ //
23
+ // WHAT THE METER DELIBERATELY DOES NOT SAY: whether this will PASS. The pass bar
24
+ // lives in the economy module with the credit it gates (and the board applies its
25
+ // own), and the ideas scorer is documented as bar-AGNOSTIC. Printing a threshold
26
+ // here would be a second copy of a DIFFERENT rule — the exact mistake this file
27
+ // is organised to avoid — so the gauge reports completeness and stops there.
28
+ //
29
+ // DRAFTS ARE LOCAL. "A partial draft is not lost on navigation" is a done-when,
30
+ // and a half-written thought is not something to put in the database: it has no
31
+ // title yet, no grade, and nothing downstream should be able to see it. It lives
32
+ // in localStorage under one key, is restored on load, and is cleared only when
33
+ // the idea is actually filed.
34
+
35
+ (() => {
36
+ 'use strict';
37
+
38
+ const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
39
+ const { escapeHtml, $, toast } = window.OTB;
40
+
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';
44
+ const SCORE_DEBOUNCE_MS = 300;
45
+ const DRAFT_DEBOUNCE_MS = 500;
46
+
47
+ let template = null; // { key, label, fields: [{key,label,required,substance_min}] }
48
+ let lastScore = null;
49
+ let scoreTimer = null;
50
+ let draftTimer = null;
51
+ let filing = false;
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // draft persistence
55
+ // ---------------------------------------------------------------------------
56
+
57
+ // Every localStorage access is wrapped: a private window, cleared site data or
58
+ // a browser set to block storage all THROW on read, and losing the draft
59
+ // feature must never take the composer down with it (the shell.js NAV_COLLAPSE
60
+ // precedent, which guards the same way).
61
+ function readDraft() {
62
+ try {
63
+ const raw = window.localStorage.getItem(DRAFT_KEY);
64
+ if (!raw) return null;
65
+ const d = JSON.parse(raw);
66
+ return (d && typeof d === 'object') ? d : null;
67
+ } catch (_) { return null; }
68
+ }
69
+
70
+ function writeDraft() {
71
+ if (!template) return;
72
+ try {
73
+ window.localStorage.setItem(DRAFT_KEY, JSON.stringify({
74
+ template_key: template.key,
75
+ title: titleEl().value,
76
+ field_values: collect(),
77
+ at: new Date().toISOString(),
78
+ }));
79
+ note('Draft saved in this browser.');
80
+ } catch (_) {
81
+ // Storage refused. Say so once rather than pretending it saved — a builder
82
+ // who believes a draft is safe and navigates away loses the thought.
83
+ note('This browser is not storing drafts — keep the tab open.');
84
+ }
85
+ }
86
+
87
+ function clearDraft() {
88
+ try { window.localStorage.removeItem(DRAFT_KEY); } catch (_) { /* nothing to clear */ }
89
+ }
90
+
91
+ function note(msg) {
92
+ const el = $('#fi-draft');
93
+ if (el) el.textContent = msg;
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // the form, rendered from the template declaration
98
+ // ---------------------------------------------------------------------------
99
+
100
+ const titleEl = () => $('#fi-title');
101
+ const fieldEl = (key) => document.getElementById(`fi-f-${key}`);
102
+
103
+ function renderForm(draft) {
104
+ const host = $('#fi-fields');
105
+ host.innerHTML = template.fields.map((f) => `
106
+ <div class="fi-field" data-key="${escapeHtml(f.key)}">
107
+ <label class="fi-label" for="fi-f-${escapeHtml(f.key)}">
108
+ ${escapeHtml(f.label)}${f.required ? '' : ' <span class="fi-hint">(optional)</span>'}
109
+ </label>
110
+ <textarea id="fi-f-${escapeHtml(f.key)}" class="fi-text" rows="4"
111
+ data-key="${escapeHtml(f.key)}"
112
+ placeholder="…"></textarea>
113
+ <p class="fi-fieldnote" id="fi-n-${escapeHtml(f.key)}"></p>
114
+ </div>
115
+ `).join('');
116
+
117
+ // Restore BEFORE wiring listeners, so restoring does not itself fire a save.
118
+ if (draft && draft.field_values) {
119
+ for (const f of template.fields) {
120
+ const v = draft.field_values[f.key];
121
+ if (typeof v === 'string') fieldEl(f.key).value = v;
122
+ }
123
+ }
124
+ if (draft && typeof draft.title === 'string') titleEl().value = draft.title;
125
+
126
+ for (const f of template.fields) {
127
+ fieldEl(f.key).addEventListener('input', onEdit);
128
+ }
129
+ titleEl().addEventListener('input', onEdit);
130
+
131
+ $('#fi-meter').hidden = false;
132
+ renderMeterSkeleton();
133
+ }
134
+
135
+ function collect() {
136
+ const out = {};
137
+ for (const f of template.fields) out[f.key] = fieldEl(f.key).value;
138
+ return out;
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // the meter — every number in it came from the server
143
+ // ---------------------------------------------------------------------------
144
+
145
+ function renderMeterSkeleton() {
146
+ $('#fi-meter-list').innerHTML = template.fields.map((f) => `
147
+ <li class="fi-mrow" id="fi-m-${escapeHtml(f.key)}">
148
+ <span class="fi-mrow__l">${escapeHtml(f.label)}</span>
149
+ <span class="fi-mrow__bar"><span class="fi-mrow__fill" style="width:0%"></span></span>
150
+ </li>
151
+ `).join('');
152
+ }
153
+
154
+ function renderScore(payload) {
155
+ lastScore = payload;
156
+ const score = payload && Number.isInteger(payload.score) ? payload.score : null;
157
+ const fill = $('#fi-gauge-fill');
158
+ const num = $('#fi-gauge-n');
159
+ fill.style.width = `${score == null ? 0 : score}%`;
160
+ // Three bands, and they describe the TEXT rather than a verdict: this number
161
+ // is not a pass mark (see the header note), so the words must not imply one.
162
+ fill.dataset.band = score == null ? 'none' : (score >= 80 ? 'full' : score >= 40 ? 'part' : 'thin');
163
+ num.textContent = score == null ? '—' : `${score}/100`;
164
+
165
+ const byKey = new Map((payload && payload.fields ? payload.fields : []).map((f) => [f.key, f]));
166
+ for (const f of template.fields) {
167
+ const row = document.getElementById(`fi-m-${f.key}`);
168
+ const s = byKey.get(f.key);
169
+ const pct = s ? Math.round(s.ratio * 100) : 0;
170
+ if (row) row.querySelector('.fi-mrow__fill').style.width = `${pct}%`;
171
+ // Per-field prose under the textarea: how much more text this answer wants
172
+ // before it stops costing the total. `floor` and `substance` are the
173
+ // server's numbers — this only subtracts them.
174
+ const n = document.getElementById(`fi-n-${f.key}`);
175
+ if (!n) continue;
176
+ if (!s || s.substance === 0) { n.textContent = ''; continue; }
177
+ const missing = Math.max(0, s.floor - s.substance);
178
+ n.textContent = missing > 0
179
+ ? `about ${missing} more characters before this answer counts in full`
180
+ : 'counts in full';
181
+ n.dataset.ok = missing > 0 ? '0' : '1';
182
+ }
183
+
184
+ // The file button turns on when every REQUIRED field has something in it —
185
+ // capture validation's actual rule (R143 refuses a missing required field and
186
+ // does NOT enforce the substance floor, because a thin answer is a scoring
187
+ // problem, not a capture error). Matching that exactly means the button never
188
+ // promises an outcome the server will refuse, and never blocks one it accepts.
189
+ const missingRequired = template.fields.some((f) => f.required && !fieldEl(f.key).value.trim());
190
+ $('#fi-submit').disabled = filing || missingRequired || !titleEl().value.trim();
191
+ }
192
+
193
+ async function scoreNow() {
194
+ const body = { field_values: collect() };
195
+ if (template.key) body.template_key = template.key;
196
+ const r = await api.inbox.postInboxScore({ body });
197
+ // A scoring hiccup must not stop someone writing. The gauge simply stops
198
+ // updating; the composer keeps working and filing still validates server-side.
199
+ if (!r.ok) { note('Could not reach the scorer just now — you can still file.'); return; }
200
+ renderScore(r.data);
201
+ }
202
+
203
+ function onEdit() {
204
+ clearTimeout(scoreTimer);
205
+ clearTimeout(draftTimer);
206
+ scoreTimer = setTimeout(() => { scoreNow(); }, SCORE_DEBOUNCE_MS);
207
+ draftTimer = setTimeout(writeDraft, DRAFT_DEBOUNCE_MS);
208
+ // Re-evaluate the button on the keystroke rather than waiting for the score
209
+ // round-trip: it depends only on what is typed.
210
+ if (lastScore) renderScore(lastScore);
211
+ else $('#fi-submit').disabled = filing
212
+ || !titleEl().value.trim()
213
+ || template.fields.some((f) => f.required && !fieldEl(f.key).value.trim());
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // filing
218
+ // ---------------------------------------------------------------------------
219
+
220
+ function showErr(msg) {
221
+ const el = $('#fi-err');
222
+ el.textContent = msg || '';
223
+ el.dataset.visible = msg ? '1' : '0';
224
+ }
225
+
226
+ // The server names the field it refused (R143's done-when). Render that rather
227
+ // than a generic failure, and point at the field so it can be found.
228
+ function explainFiling(status, data) {
229
+ const err = data && data.error;
230
+ const code = err && typeof err === 'object' ? err.code : err;
231
+ const details = (err && typeof err === 'object' && err.details) || {};
232
+ if (code === 'invalid_field_values') {
233
+ const errors = Array.isArray(details) ? details : (details.errors || []);
234
+ const labelOf = (k) => {
235
+ const f = template.fields.find((x) => x.key === k);
236
+ return f ? f.label : (k || 'the idea');
237
+ };
238
+ return errors.length
239
+ ? `Not filed — ${errors.map((e) => `${labelOf(e.field)}: ${String(e.reason || '').replace(/_/g, ' ')}`).join('; ')}`
240
+ : 'Not filed — the five answers were refused.';
241
+ }
242
+ if (code === 'validation_failed') return 'Not filed — the title is required and must be under 200 characters.';
243
+ if (status === 401 || status === 403) return 'Not filed — your session may have expired. Reload and try again.';
244
+ return `Not filed (${status}). Nothing was saved; your draft is still here.`;
245
+ }
246
+
247
+ async function file(ev) {
248
+ ev.preventDefault();
249
+ if (filing) return;
250
+ filing = true;
251
+ showErr('');
252
+ $('#fi-submit').disabled = true;
253
+
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
+ });
262
+
263
+ filing = false;
264
+ if (!r.ok) {
265
+ showErr(explainFiling(r.status, r.data));
266
+ renderScore(lastScore);
267
+ return;
268
+ }
269
+
270
+ // Filed. NOW the draft goes — not before, so a failed POST never costs the text.
271
+ clearDraft();
272
+ const idea = r.data && r.data.idea;
273
+ const score = idea && Number.isInteger(idea.completeness_score) ? idea.completeness_score : null;
274
+ // The capture path is fail-open on grading (R143): the idea lands even if the
275
+ // grade write did not, and says so. Repeat that here instead of claiming a
276
+ // Full Idea was stored when the response says otherwise.
277
+ 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.');
281
+
282
+ titleEl().value = '';
283
+ for (const f of template.fields) fieldEl(f.key).value = '';
284
+ lastScore = null;
285
+ renderScore(null);
286
+ note('');
287
+ if (idea && idea.id) {
288
+ // The detail PAGE (task 1002929 / R153), not the #/idea/N overlay: the
289
+ // thing just filed has five prose answers, which is what the page is for.
290
+ const base = location.hostname.startsWith('builders.') ? '' : '/builders';
291
+ $('#fi-sub').innerHTML = `Filed <a href="${base}/idea/${encodeURIComponent(idea.id)}">idea ${escapeHtml(String(idea.id))}</a>. Write another below.`;
292
+ }
293
+ }
294
+
295
+ function discard() {
296
+ if (!template) return;
297
+ clearDraft();
298
+ titleEl().value = '';
299
+ for (const f of template.fields) fieldEl(f.key).value = '';
300
+ lastScore = null;
301
+ renderScore(null);
302
+ showErr('');
303
+ note('Draft discarded.');
304
+ toast('Draft discarded.');
305
+ }
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // boot
309
+ // ---------------------------------------------------------------------------
310
+
311
+ async function main() {
312
+ const r = await api.inbox.getInboxTemplate({});
313
+ if (!r.ok || !r.data || !r.data.template) {
314
+ $('#fi-sub').textContent = 'Could not load the idea template. Reload, or file from the terminal with capture.js.';
315
+ return;
316
+ }
317
+ template = r.data.template;
318
+
319
+ const draft = readDraft();
320
+ // A draft written against a different template is not restorable — the keys
321
+ // may not exist any more. Drop it rather than half-restoring it.
322
+ const usable = draft && (!draft.template_key || draft.template_key === template.key) ? draft : null;
323
+
324
+ $('#fi-sub').textContent = 'Five questions, and a score that counts how much of each you actually answered.';
325
+ renderForm(usable);
326
+ $('#fi-form').addEventListener('submit', file);
327
+ $('#fi-clear').addEventListener('click', discard);
328
+
329
+ if (usable) {
330
+ note('Draft restored.');
331
+ scoreNow();
332
+ } else {
333
+ renderScore(null);
334
+ }
335
+ }
336
+
337
+ main();
338
+ })();
@@ -130,6 +130,14 @@
130
130
  // craft rather than a rank, so hiding this behind one would hide the
131
131
  // artist's own queue from the artist. The module gate drops the tab on an
132
132
  // instance that disables copy-desk.
133
+ // task 1002928 (R152 of goal 1000062): the Full Idea composer. In the FIRST
134
+ // group — this is the project's own work-intake, beside Goals and Tasks,
135
+ // not one of "your numbers". gate 'signed' and NOT a craft or rank gate, on
136
+ // the goal's own settled decision: there is no permission gate on Full
137
+ // Ideas, completeness scoring and triage do the filtering. Hiding the page
138
+ // behind the ideator craft would hide the ideator's own surface from
139
+ // everyone else who has a thought, which is most of the value.
140
+ { id: 'ideas', label: 'Ideas', icon: 'goals', href: '/ideas', gate: 'signed' },
133
141
  { id: 'copy-desk', label: 'Copy desk', icon: 'docs', href: '/copy-desk', gate: 'signed', module: 'copy-desk' },
134
142
  ] },
135
143
  // Your own numbers. task 2174 folded Achievements into Standing and this task
@@ -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
  );
@@ -6,8 +6,8 @@ const api = require('../../../src/module-api');
6
6
  const inbox = require('../inbox');
7
7
  // Full Idea capture (task 1002919 / BV1.R143): the template validator (R141)
8
8
  // and the deterministic scorer (R142) — both pure, both walk the declaration.
9
- const { validateFieldValues } = require('../templates');
10
- const { completenessScore, weakestSectionPayload } = require('../completeness');
9
+ const { validateFieldValues, templateFor, fieldOrder } = require('../templates');
10
+ const { completenessScore, weakestSectionPayload, scoreFieldValues } = require('../completeness');
11
11
  const { validateOrRespond, LIMITS, parseId } = api;
12
12
 
13
13
  const INBOX_STATUS_VALUES = ['open', 'promoted', 'discarded', 'merged'];
@@ -313,6 +313,94 @@ module.exports = function buildInboxRouter() {
313
313
  });
314
314
 
315
315
 
316
+
317
+ // -----------------------------------------------------------------------
318
+ // The two reads the hall's Full Idea composer is built from
319
+ // (task 1002928 / BV1.R152, goal 1000062).
320
+ //
321
+ // The done-when says the live meter must reflect THE REAL SCORER, "not a
322
+ // second copy of the rules". A browser cannot require() this module —
323
+ // completeness.js and templates.js are CJS and templates.js reaches inbox.js
324
+ // for DEFAULT_TEMPLATE_KEY, which reaches the pool — so the choice is between
325
+ // re-implementing the arithmetic in page JS and asking the server. These two
326
+ // routes are that ask, and they are why the composer contains no rules at all:
327
+ //
328
+ // GET /inbox/template → what the five fields ARE (so the FORM is rendered
329
+ // from the R141 declaration, not from five <textarea>
330
+ // elements hand-written into the HTML — a hard-coded
331
+ // form is the same "second copy" defect as a
332
+ // hard-coded scorer, one step earlier, and it would
333
+ // silently ignore a per-project template (R156)).
334
+ // POST /inbox/score → what it is WORTH right now (the R142 scorer itself,
335
+ // per-field, in template order).
336
+ //
337
+ // BOTH ARE PURE. No DB read, no write, no idea id: they describe and score a
338
+ // payload the caller already holds. That is what makes a debounced call on
339
+ // every keystroke reasonable, and why scoring carries no rank beyond sign-in —
340
+ // it tells you nothing you did not just type.
341
+ //
342
+ // DECLARED BEFORE `GET /inbox/:id`, and for GET /inbox/template that ordering
343
+ // is LOAD-BEARING: Express matches in declaration order, so with `:id` first
344
+ // the literal "/inbox/template" binds id='template' and parseId 400s it. Same
345
+ // trap as /inbox/sparks and /inbox/awaiting-nod above.
346
+ //
347
+ // rank: any-builder — filing a Full Idea is open to every role by the goal's
348
+ // own settled decision ("no permission gate on Full Ideas; completeness scoring
349
+ // and triage do the filtering"), so describing and scoring one must be too.
350
+ // Deliberately NOT pinned in route-rank-check: neither has a gate to protect.
351
+ router.get('/inbox/template', api.requireBuilder, api.asyncHandler('GET /inbox/template', async (req, res) => {
352
+ // templateFor deliberately does NOT default — it returns null for anything
353
+ // that is not a declared own key, which is what makes it safe against an
354
+ // attacker-supplied 'constructor'/'__proto__' (the R143 grader's finding). So
355
+ // the DEFAULT is supplied here, explicitly, from the one place that declares
356
+ // it. Leaving it undefined 404s the normal page load, which is exactly what
357
+ // the first run of tests/idea_hall_composer.mjs caught.
358
+ const key = typeof req.query.template_key === 'string' && req.query.template_key
359
+ ? req.query.template_key
360
+ : inbox.DEFAULT_TEMPLATE_KEY;
361
+ const template = templateFor(key);
362
+ if (!template) {
363
+ return res.fail('unknown_template', 404, { template_key: key });
364
+ }
365
+ // Projected rather than passed through: the declaration is frozen and the
366
+ // composer needs exactly these four things per field.
367
+ const byKey = new Map(template.fields.map((f) => [f.key, f]));
368
+ return res.json({
369
+ template: {
370
+ key: template.key,
371
+ label: template.label,
372
+ // ORDER from fieldOrder (which returns KEYS — the one legal source of
373
+ // display order, since jsonb does not preserve key order), DATA from the
374
+ // declaration itself. Spelled as a lookup rather than mapping over
375
+ // template.fields directly so the provenance of the order is visible at
376
+ // the call site and cannot drift into an Object.keys() somewhere.
377
+ fields: fieldOrder(template).map((key) => {
378
+ const f = byKey.get(key);
379
+ return {
380
+ key: f.key,
381
+ label: f.label,
382
+ required: !!f.required,
383
+ substance_min: f.substanceMin || 0,
384
+ };
385
+ }),
386
+ },
387
+ });
388
+ }, { errorCode: 'template_read_failed' }));
389
+
390
+ router.post('/inbox/score', api.requireBuilder, api.asyncHandler('POST /inbox/score', async (req, res) => {
391
+ if (validateOrRespond(req, res, {
392
+ field_values: { required: true, type: 'object' },
393
+ template_key: { type: 'string', maxLength: inbox.TEMPLATE_KEY_MAX },
394
+ })) return;
395
+ const templateKey = req.body.template_key || undefined;
396
+ // scoreFieldValues NEVER throws (R142's contract) and answers
397
+ // { score: null, fields: [] } for anything it cannot score — an unknown
398
+ // template, a non-object, a non-string field. A draft in progress is exactly
399
+ // that kind of half-formed input, so the composer wants the null rather than
400
+ // a 400: "not scorable yet" is a legal state on the way to a Full Idea.
401
+ const { score, fields } = scoreFieldValues(req.body.field_values, templateKey ? { templateKey } : undefined);
402
+ return res.json({ score, fields, template_key: templateKey || null });
403
+ }, { errorCode: 'score_failed' }));
316
404
  // GET /inbox/sparks — THE SPARK QUEUE (task 1002921 / BV1.R145, goal 1000062).
317
405
  //
318
406
  // Big Idea Sparks awaiting development: the ideator craft's pickup surface,
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.298",
3
+ "version": "1.19.300",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.298",
9
+ "version": "1.19.300",
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.298",
3
+ "version": "1.19.300",
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) {
@@ -927,6 +933,11 @@ function mountInternalSurfaces(app) {
927
933
  // flag is open to every rank by design (see the nav comment in shell.js), so
928
934
  // the page's only real gate is sign-in — the writes behind it self-gate, and
929
935
  // closing someone else's flag is checked server-side at metic-or-author.
936
+ // task 1002928: `ideas` (the R152 Full Idea composer) joins the same tier for
937
+ // the same reason — goal 1000062's settled decision is that there is NO
938
+ // permission gate on Full Ideas; scoring and triage do the filtering. Its two
939
+ // reads (GET /inbox/template, POST /inbox/score) are pure and any-builder, and
940
+ // the file itself goes through POST /inbox, which carries its own rules.
930
941
  //
931
942
  // NB the assignment below must stay `const NAME_RE = /…/;` with NOTHING between
932
943
  // the `=` and the literal: tests/hall_page_gate_map.mjs reconstructs the whole
@@ -934,7 +945,7 @@ function mountInternalSurfaces(app) {
934
945
  // the regex out of its map — which reads as "every page on this tier is
935
946
  // ungated". It fails loudly by design; keep prose above the const.
936
947
  const SIGNEDIN_BUILDERS_PAGE_RE =
937
- /^\/builders\/(?:work|pair|settings|atlas|modules|goals|roadmap|copy-desk|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
948
+ /^\/builders\/(?:work|pair|settings|atlas|modules|goals|roadmap|copy-desk|ideas|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
938
949
  // The diagram image assets under /builders/diagrams/ ride the same tier as the
939
950
  // page that embeds them.
940
951
  const SIGNEDIN_BUILDERS_PREFIX_RE = /^\/builders\/diagrams\//;
@@ -951,7 +962,7 @@ function mountInternalSurfaces(app) {
951
962
  // behind the proxy's path normalization.
952
963
  const norm = path.posix.normalize(req.path);
953
964
  if (SIGNEDIN_BUILDERS_PAGE_RE.test(norm) || SIGNEDIN_BUILDERS_PREFIX_RE.test(norm)
954
- || TASK_DETAIL_PAGE_RE.test(norm)) {
965
+ || TASK_DETAIL_PAGE_RE.test(norm) || IDEA_DETAIL_PAGE_RE.test(norm)) {
955
966
  return gdsAuth.requireBuilderPage(req, res, next);
956
967
  }
957
968
  next();
package/src/module-api.js CHANGED
@@ -1023,8 +1023,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1023
1023
  // landed since 1.19.296 with no explicit bump. run 32592502621. (task 1002620)
1024
1024
  // 1.19.298 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1025
1025
  // landed since 1.19.297 with no explicit bump. run 32597859474. (task 1002620)
1026
+ // 1.19.299 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
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)
1026
1030
  // ---------------------------------------------------------------------------
1027
- const CORE_VERSION = '1.19.298'; // CI auto-patch carrier (ADR 0161); see changelog above
1031
+ const CORE_VERSION = '1.19.300'; // CI auto-patch carrier (ADR 0161); see changelog above
1028
1032
 
1029
1033
  // A namespaced logger so a module's log lines are attributable + consistent.
1030
1034
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -137,7 +137,12 @@ test('sidebar groups read by audience, in order', () => {
137
137
  // whose subject is a person rather than the product. Deliberate addition to
138
138
  // this oracle: the group is pinned exactly so a new tab in the hall's most
139
139
  // valuable nav real estate is a reviewed decision, not a drift.
140
- assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'work', 'copy-desk'],
140
+ // task 1002928 (R152 of goal 1000062): 'ideas' — the Full Idea composer — joins
141
+ // the same group, placed BEFORE copy-desk so the run reads as the four project
142
+ // pages followed by the two CRAFT surfaces side by side: the ideator's queue and
143
+ // the artist's. Same reasoning as copy-desk for why it is here rather than Docs
144
+ // (a place you act, not reference material) or You (its subject is the project).
145
+ assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'work', 'ideas', 'copy-desk'],
141
146
  'the unlabeled group is the project itself (task 1002425 order)');
142
147
  assert.deepEqual(nav[1].items.map((i) => i.id), ['standing', 'profile'], 'You = your own numbers');
143
148
  // People (task 1002577): the /scouting directory graduated into the hall.