@bongos/core 1.19.496 → 1.19.498

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/docs/file-map.md CHANGED
@@ -185,7 +185,7 @@
185
185
  │ ├── box.js ← (#598/ADR 0031) per-builder dev box lifecycle CLI: provision·park·wake·deprovision·sweep-idle·sweep-dormant·status (dry-run default, --apply to act; rank-gated)
186
186
  │ ├── smoke-box.sh ← (#598) hermetic box-lifecycle smoke (no DB/DO/network): runs tests/boxes.mjs + tests/cf_tunnel.mjs (#771 named tunnel) + route-rank + module-load
187
187
  │ ├── recall.js ← `/recall` CLI: two-call progressive-disclosure search (summary → `--expand <id>`) over Bongos retrieval layer (ADR 0060); `node scripts/gds/recall.js <query>`
188
- │ ├── capture.js ← file an idea_inbox row from the CLI (the ideas.capture kernel port); backs the "capture a tangent" flow across skills. Task 1002927 / R151 taught it the GRADES: `--full --fields-file f.json` files a Full Idea, `--spark` is capture-then-mark (two requests — the route refuses a pre-marked capture), and after a plain quick capture it OFFERS to develop further. That offer is TTY-gated (`--no-prompt` too): capture.js is run by agents with no terminal and reads its body from stdin, so an unguarded prompt would hang every non-interactive caller
188
+ │ ├── capture.js ← file an idea_inbox row from the CLI (the ideas.capture kernel port); backs the "capture a tangent" flow across skills. Task 1002927 / R151 taught it the GRADES: `--full --fields-file f.json` files a Full Idea, `--spark` is capture-then-mark (two requests — the route refuses a pre-marked capture), and after a plain quick capture it OFFERS to develop further. That offer is TTY-gated (`--no-prompt` too): capture.js is run by agents with no terminal and reads its body from stdin, so an unguarded prompt would hang every non-interactive caller. Task 1003070 / R09 (goal 1000071) taught it capture-time ROUTING: `--goal N` sends goal_id, `--goal none` opts out explicitly, and with neither it defaults to the caller's active claim's goal, else prints a one-line R07 suggester hint
189
189
  │ ├── spark.js ← (tasks 1002920 / R144 + 1002921 / R145 + 1002922 / R146) the whole spark handoff from the terminal: mark one of your OWN quick ideas as a Big Idea Spark (`--undo` withdraws it), `--list` is the SPARK QUEUE, `--template` prints the five Full Idea fields, `--develop <id> --fields-file <json>` is the PICKUP (any builder; author + developer both recorded). Author-only server-side for the mark (it is the consent); its own command because sparking acts on an EXISTING idea, which is why capture.js refuses grade='spark'
190
190
  │ ├── stale-worktrees.js ← (task 1003365) the per-claim worktrees whose branch already landed on origin/main or is gone — pure git facts, no API; `--line` is the SessionStart probe, `/worktree-clean` walks the list through worktree.js remove
191
191
  │ ├── worktree-claim-guard.js ← (task 1002579) the PURE decision behind worktree-per-claim, enforced at claim time: claiming in the MAIN CHECKOUT is refused (escape hatch `--here` → loud warning), a linked worktree passes, an unclassifiable tree fails open. Stops the ship-time "the main checkout is on <branch>, not main" strand at its cause
@@ -1429,5 +1429,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1429
1429
  landed since 1.19.494 with no explicit bump. run 33766103175. (task 1002620)
1430
1430
  1.19.496 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1431
1431
  landed since 1.19.495 with no explicit bump. run 33766363834. (task 1002620)
1432
+ 1.19.497 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1433
+ landed since 1.19.496 with no explicit bump. run 33767382661. (task 1002620)
1434
+ 1.19.498 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1435
+ landed since 1.19.497 with no explicit bump. run 33768753719. (task 1002620)
1432
1436
  ---------------------------------------------------------------------------
1433
1437
  ```
@@ -20,6 +20,25 @@
20
20
  //
21
21
  // Requires (go-live): the Message Content PRIVILEGED intent (to read message
22
22
  // text) + DISCORD_IDEAS_CHANNEL_ID. Inert until both are set.
23
+ //
24
+ // ROUTING HINTS, NEVER ROUTING (task 1003068, BV1.R12; goal 1000071 criterion
25
+ // C5, deliberately not C1/C3). Unlike #bugs (discord-bugs.js, R11), this vector
26
+ // never routes: a Discord author is outside the trust boundary (ADR 0016,
27
+ // ADR 0032), so their message may SUGGEST a goal but must never BIND one. The
28
+ // suggester (modules/lifecycle/goal-suggest.js, resolved through the same
29
+ // `lifecycle` port #bugs already consumes) supplies a candidate goal; its id
30
+ // rides onto the row as the advisory `suggested_goal_id` hint (core_222) and its
31
+ // `version_id` as `suggested_version` — both read-only guesses for /idea-triage
32
+ // (R13), never `goalId`, which is what would make captureIdea actually route.
33
+ //
34
+ // suggested_version DEFAULTS even without a goal match: when the classifier
35
+ // finds no confident goal, `suggested_version` falls back to whichever open
36
+ // version most recently started `status='building'` — the version a filer is
37
+ // overwhelmingly likely to mean when they post right now, absent any other
38
+ // signal. A matched goal's OWN version always wins over the default (the two
39
+ // travel together elsewhere in this goal — a routed capture binds to the
40
+ // goal's version, never a caller-supplied one), so the fallback only fires
41
+ // when there is truly nothing more specific to say.
23
42
 
24
43
  // captureIdea is no longer a doorway export (retired at BV1.R78 / ADR 0093 §5).
25
44
  // We resolve the `ideas.capture` kernel PORT the ideas module registers — the
@@ -31,6 +50,7 @@ const auth = require('./auth');
31
50
  const attachments = require('./bug-attachments');
32
51
  const { createMessageAggregator } = require('./discord-message-buffer');
33
52
  const { LIMITS } = api;
53
+ const log = api.logger('discord');
34
54
 
35
55
  // Auto-pairing window for #ideas (task #979) — same default as #bugs. Env
36
56
  // DISCORD_IDEAS_PAIR_WINDOW_MS overrides.
@@ -68,10 +88,37 @@ const realDeps = {
68
88
  // Lazy: resolve the `ideas.capture` port at CALL time, so the ideas module's
69
89
  // registration (at its route-factory mount) need not precede this module's load.
70
90
  captureIdea: (...a) => api.resolve('ideas.capture')(...a),
91
+ // resolveOptional, not resolve: a vanilla instance without the lifecycle
92
+ // module has no goals to suggest, and that is a reason to skip the hint —
93
+ // never a reason to lose the idea (the discord-bugs.js precedent).
94
+ suggestGoalForCapture: async (...a) => {
95
+ const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
96
+ if (!lifecycle || !lifecycle.suggestGoalForCapture) return null;
97
+ return lifecycle.suggestGoalForCapture(...a);
98
+ },
99
+ // Same resolveOptional posture — a vanilla instance without lifecycle has no
100
+ // versions to default to, which is a reason to skip the default, never the idea.
101
+ listVersions: async () => {
102
+ const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
103
+ if (!lifecycle || !lifecycle.listVersions) return [];
104
+ return lifecycle.listVersions();
105
+ },
71
106
  insertAuditLog: db.insertAuditLog,
72
107
  persistImages: attachments.persistImages,
73
108
  };
74
109
 
110
+ // mostRecentBuildingVersionId — PURE: the id of whichever `status='building'`
111
+ // version most recently started, or null. The suggested_version fallback for a
112
+ // filing whose classifier found no confident goal (see header). `started_at` is
113
+ // a timestamp string/Date; String() comparison is wrong for it, so this compares
114
+ // parsed dates explicitly rather than relying on default sort.
115
+ function mostRecentBuildingVersionId(versions) {
116
+ const building = (Array.isArray(versions) ? versions : []).filter((v) => v && v.status === 'building');
117
+ if (building.length === 0) return null;
118
+ building.sort((a, b) => new Date(b.started_at) - new Date(a.started_at));
119
+ return building[0].id;
120
+ }
121
+
75
122
  // File one inbound message as an idea. NEVER throws (the caller is a gateway
76
123
  // event handler). Returns a small status object for logging/tests.
77
124
  async function recordInboundIdea({ discordUserId, content, imageUrls = [], messageId }, deps = realDeps) {
@@ -114,12 +161,34 @@ async function recordInboundIdea({ discordUserId, content, imageUrls = [], messa
114
161
  }
115
162
  bodyMd = bodyMd.slice(0, LIMITS.DESCRIPTION);
116
163
 
164
+ // HINTS ONLY (see header). Neither lookup throws into the filing — a
165
+ // suggester/version-list outage must not cost the idea, only the hints.
166
+ let hint = null;
167
+ try {
168
+ hint = await deps.suggestGoalForCapture({ title: derived.title, body: derived.bodyMd });
169
+ } catch (e) {
170
+ log.warn({ err: e && e.message }, 'idea goal suggestion failed — filing without a goal hint');
171
+ }
172
+ let suggestedVersion = hint ? hint.version_id : null;
173
+ if (!suggestedVersion) {
174
+ try {
175
+ suggestedVersion = mostRecentBuildingVersionId(await deps.listVersions());
176
+ } catch (e) {
177
+ log.warn({ err: e && e.message }, 'default-version lookup failed — filing without a version hint');
178
+ }
179
+ }
180
+
117
181
  const idea = await deps.captureIdea({
118
182
  title: derived.title,
119
183
  bodyMd,
120
184
  capturedBy: builder ? builder.id : null,
121
185
  // kind left null → inbox.captureIdea auto-classifies. We never pass
122
186
  // 'criterion-proposal' (the rank-gated kind) from this path.
187
+ // suggestedGoalId/suggestedVersion are ADVISORY-ONLY columns — this call
188
+ // deliberately never names the routing parameter (see header + F12 in
189
+ // tests/filing_abuse_matrix.mjs, which greps this exact call for it).
190
+ suggestedGoalId: hint ? hint.id : null,
191
+ suggestedVersion,
123
192
  });
124
193
 
125
194
  await auditDiscordBot(deps, {
@@ -144,4 +213,7 @@ function createIdeaAggregator({ onFlush, windowMs = pairWindowMs(), timers = glo
144
213
  return createMessageAggregator({ onFlush, windowMs, timers });
145
214
  }
146
215
 
147
- module.exports = { deriveIdea, recordInboundIdea, createIdeaAggregator, pairWindowMs, realDeps };
216
+ module.exports = {
217
+ deriveIdea, recordInboundIdea, createIdeaAggregator, pairWindowMs, realDeps,
218
+ mostRecentBuildingVersionId,
219
+ };
@@ -74,6 +74,11 @@
74
74
  .fi-err { margin: var(--sp-sm) 0 0; max-width: var(--measure); font-size: 14px; line-height: 1.5; color: var(--danger); }
75
75
  .fi-err[data-visible="0"] { display: none; }
76
76
 
77
+ /* The quick-filing form's outcome line (task 1003069 / R08) — a routed capture
78
+ names the task it landed in, so its link takes the world's 24px row recipe. */
79
+ .fi-result { margin: var(--sp-sm) 0 0; max-width: var(--measure); font-size: 14px; line-height: 1.5; color: var(--ink-soft); }
80
+ .fi-result a { display: inline-block; min-height: 24px; padding: 3px 0; line-height: 18px; box-sizing: border-box; color: inherit; }
81
+
77
82
  .fi-actions { display: flex; flex-wrap: wrap; gap: 10px 12px; align-items: center; margin-top: var(--sp-md); }
78
83
  .fi-draft { font-size: 12.5px; color: var(--ink-faint); }
79
84
 
@@ -47,6 +47,60 @@
47
47
  <div id="fi-queue"><p class="fi-note">Loading…</p></div>
48
48
  </section>
49
49
 
50
+ <!-- Quick filing (task 1003069 / R08, goal 1000071 "Idea routing") —
51
+ the hall's first bug/idea report, separate from the Full Idea
52
+ composer below: title + kind + optional body + a goal picker fed
53
+ by the R07 suggester. A routed capture must never carry
54
+ grade='full' (routing.js NEVER_ROUTE_GRADE), so this cannot share
55
+ a submit with the form below it. Hidden while developing a spark
56
+ (?develop=<id>) — that surface always turns someone else's
57
+ existing thought into a Full Idea, never a fresh quick report. -->
58
+ <section class="scroll" id="fq-compose-scroll" aria-labelledby="fq-compose-h">
59
+ <h2 id="fq-compose-h" class="scroll__h">File a bug or idea</h2>
60
+ <p class="scroll__lede">
61
+ A quick report — title, what's going on, and where it belongs. Pick a goal
62
+ and it lands there as a task the moment you file; leave it on "no goal" and
63
+ it waits in the inbox for triage.
64
+ </p>
65
+
66
+ <form id="fq-form" novalidate>
67
+ <label class="fi-label" for="fq-title">Title</label>
68
+ <input type="text" id="fq-title" class="fi-input" maxlength="200" autocomplete="off"
69
+ placeholder="what's wrong, or what you'd like to see">
70
+
71
+ <label class="fi-label" for="fq-kind">Kind</label>
72
+ <select id="fq-kind" class="fi-input">
73
+ <option value="bug" selected>Bug</option>
74
+ <option value="feature">Feature</option>
75
+ <option value="infra">Infra</option>
76
+ <option value="refactor">Refactor</option>
77
+ <option value="spike">Spike</option>
78
+ <option value="learning-capture">Learning capture</option>
79
+ <option value="cleanup">Cleanup</option>
80
+ <option value="decision">Decision</option>
81
+ <option value="blocker-resolution">Blocker resolution</option>
82
+ <option value="unclassified">Unclassified</option>
83
+ </select>
84
+
85
+ <label class="fi-label" for="fq-body">Details <span class="fi-hint">(optional)</span></label>
86
+ <textarea id="fq-body" class="fi-text" rows="4"
87
+ placeholder="repro steps, context, anything that helps…"></textarea>
88
+
89
+ <label class="fi-label" for="fq-goal">
90
+ Goal
91
+ <span class="fi-hint">Suggested as you type; leave it on "no goal" and it waits for triage.</span>
92
+ </label>
93
+ <select id="fq-goal" class="fi-input"></select>
94
+
95
+ <p class="fi-err" id="fq-err" role="alert"></p>
96
+ <p class="fi-result" id="fq-result" role="status" aria-live="polite"></p>
97
+
98
+ <div class="fi-actions">
99
+ <button type="submit" class="btn" id="fq-submit">File it</button>
100
+ </div>
101
+ </form>
102
+ </section>
103
+
50
104
  <!-- The composer. The idea detail view lives on its own page (R153). -->
51
105
  <section class="scroll" id="fi-compose-scroll" aria-labelledby="fi-compose-h">
52
106
  <h2 id="fi-compose-h" class="scroll__h">Write a Full Idea</h2>
@@ -121,6 +175,9 @@
121
175
  <script src="/builders/shell.js?v=2026-07-02-shell1"></script>
122
176
  <script src="/builders/palette.js" defer></script>
123
177
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
178
+ <!-- task 1003069 (R08) — the quick-filing form's routed result links a task
179
+ through window.OTBTaskDetail.href(); guarded if this ever loads without it. -->
180
+ <script src="/builders/task-detail.js?v=2026-07-03-taskdetail2" defer></script>
124
181
  <script src="/builders/ideas.js?v=2026-08-22-r154"></script>
125
182
  </body>
126
183
  </html>
@@ -75,6 +75,20 @@
75
75
  let draftTimer = null;
76
76
  let filing = false;
77
77
 
78
+ // QUICK FILING (task 1003069 / R08, goal 1000071 "Idea routing"). A second,
79
+ // independent capture path on this page: title + kind + optional body + a
80
+ // goal picker fed by the R07 suggester (GET /goals/suggestions). It lives
81
+ // beside the Full Idea form above rather than inside it — a routed capture
82
+ // must never carry grade='full' (routing.js NEVER_ROUTE_GRADE), so the two
83
+ // cannot share one submit. Quick-mode only: hidden while developing a spark.
84
+ const QUICK_SUGGEST_DEBOUNCE_MS = 300;
85
+ let buildingVersionIds = []; // fetched once — the suggester is scoped per version
86
+ let allOpenGoals = []; // the picker's "browse everything" half
87
+ let allGoalsById = new Map(); // for naming the goal a routed capture landed in
88
+ let goalTouchedByUser = false; // once the builder picks, stop clobbering it with fresh suggestions
89
+ let quickSuggestTimer = null;
90
+ let quickFiling = false;
91
+
78
92
  // ---------------------------------------------------------------------------
79
93
  // draft persistence
80
94
  // ---------------------------------------------------------------------------
@@ -347,6 +361,155 @@
347
361
  toast('Draft discarded.');
348
362
  }
349
363
 
364
+ // ---------------------------------------------------------------------------
365
+ // quick filing — kind + goal picker (task 1003069 / R08)
366
+ // ---------------------------------------------------------------------------
367
+
368
+ const fqTitleEl = () => $('#fq-title');
369
+ const fqKindEl = () => $('#fq-kind');
370
+ const fqBodyEl = () => $('#fq-body');
371
+ const fqGoalEl = () => $('#fq-goal');
372
+
373
+ // The picker's options: "no goal" first (the default — a filer never has to
374
+ // know the goal map, C5), then whatever the suggester found, then every open
375
+ // goal it didn't. Suggested ids are excluded from the second list rather than
376
+ // shown twice.
377
+ function renderGoalOptions(suggested) {
378
+ const sel = fqGoalEl();
379
+ const suggestedIds = new Set((suggested || []).map((g) => String(g.id)));
380
+ const rest = allOpenGoals.filter((g) => !suggestedIds.has(String(g.id)));
381
+ const optionHtml = (g, why) => `<option value="${escapeHtml(String(g.id))}">${escapeHtml(g.title || `goal ${g.id}`)}${why ? ` — matched: ${escapeHtml(why)}` : ''}</option>`;
382
+ let html = `<option value="" selected>No goal (homeless)</option>`;
383
+ if (suggested && suggested.length) {
384
+ html += `<optgroup label="Suggested">${suggested.map((g) => optionHtml(g, (g.matched_terms || []).join(', '))).join('')}</optgroup>`;
385
+ }
386
+ html += `<optgroup label="All open goals">${rest.map((g) => optionHtml(g)).join('')}</optgroup>`;
387
+ sel.innerHTML = html;
388
+ }
389
+
390
+ // The suggester is ranked per version (GET /goals/suggestions requires one),
391
+ // so a filer whose instance has more than one version building gets both
392
+ // consulted and merged by score. An outage — or no building version at all —
393
+ // degrades to "no suggestions"; the homeless option and the full goal list
394
+ // are still there either way (goal-suggest.js's own fail-open contract).
395
+ async function refreshGoalSuggestions() {
396
+ if (goalTouchedByUser) return; // never yank a manual pick out from under someone
397
+ const title = fqTitleEl().value.trim();
398
+ if (!title || !buildingVersionIds.length) { renderGoalOptions([]); return; }
399
+ const kind = fqKindEl().value;
400
+ try {
401
+ const results = await Promise.all(buildingVersionIds.map((versionId) => api.goals.getGoalsSuggestions({
402
+ query: { version: versionId, title, kind, limit: 5 },
403
+ })));
404
+ const byId = new Map();
405
+ for (const r of results) {
406
+ if (!r.ok || !r.data || !Array.isArray(r.data.suggestions)) continue;
407
+ for (const g of r.data.suggestions) {
408
+ const prev = byId.get(String(g.id));
409
+ if (!prev || g.score > prev.score) byId.set(String(g.id), g);
410
+ }
411
+ }
412
+ const merged = Array.from(byId.values()).sort((a, b) => b.score - a.score).slice(0, 5);
413
+ renderGoalOptions(merged);
414
+ } catch (_) {
415
+ renderGoalOptions([]);
416
+ }
417
+ }
418
+
419
+ function onQuickTitleEdit() {
420
+ clearTimeout(quickSuggestTimer);
421
+ quickSuggestTimer = setTimeout(refreshGoalSuggestions, QUICK_SUGGEST_DEBOUNCE_MS);
422
+ }
423
+
424
+ function showQuickErr(msg) {
425
+ $('#fq-err').textContent = msg || '';
426
+ }
427
+
428
+ // The server names why a routed capture was refused (task 1003066's landing
429
+ // gate) or names the refusal code when it doesn't — read it, never re-derive
430
+ // the landing matrix here (routing.js stays the one place that decides).
431
+ function explainQuickFiling(status, data) {
432
+ const err = data && data.error;
433
+ const code = err && (typeof err === 'object' ? err.code : err);
434
+ const details = (err && typeof err === 'object' && err.details) || {};
435
+ if (details.reason) return `Not filed — ${details.reason}`;
436
+ if (code) return `Not filed — ${code}.`;
437
+ if (status === 401 || status === 403) return 'Not filed — your session may have expired. Reload and try again.';
438
+ return `Not filed (${status}). Nothing was saved — try again.`;
439
+ }
440
+
441
+ async function fileQuick(ev) {
442
+ ev.preventDefault();
443
+ if (quickFiling) return;
444
+ const title = fqTitleEl().value.trim();
445
+ showQuickErr('');
446
+ $('#fq-result').innerHTML = '';
447
+ if (!title) { showQuickErr('Not filed — a title is required.'); return; }
448
+ quickFiling = true;
449
+ $('#fq-submit').disabled = true;
450
+
451
+ const goalId = fqGoalEl().value;
452
+ const body = { title, body_md: fqBodyEl().value, kind: fqKindEl().value };
453
+ if (goalId) body.goal_id = Number(goalId);
454
+
455
+ const r = await api.inbox.postInbox({ body });
456
+ quickFiling = false;
457
+ $('#fq-submit').disabled = false;
458
+
459
+ if (!r.ok) {
460
+ showQuickErr(explainQuickFiling(r.status, r.data));
461
+ return;
462
+ }
463
+
464
+ // The routing verdict travels on idea.routing; a routed capture also
465
+ // carries the task it became at the top level (routed_task) — see
466
+ // routes/inbox.js. Not routed (no goal picked, or the matrix declined
467
+ // one) is exactly today's inbox behaviour.
468
+ const routedTask = r.data && r.data.routed_task;
469
+ const routing = r.data && r.data.idea && r.data.idea.routing;
470
+ if (routedTask && routedTask.id && routing && routing.routed) {
471
+ const href = window.OTBTaskDetail && typeof window.OTBTaskDetail.href === 'function'
472
+ ? window.OTBTaskDetail.href(routedTask.id)
473
+ : `/task/${encodeURIComponent(routedTask.id)}`;
474
+ const goal = allGoalsById.get(String(routing.goal_id));
475
+ const goalName = goal ? goal.title : `goal ${routing.goal_id}`;
476
+ $('#fq-result').innerHTML = `Filed as <a href="${href}">task ${escapeHtml(String(routedTask.id))}</a> under ${escapeHtml(goalName)}.`;
477
+ } else {
478
+ $('#fq-result').textContent = 'Filed to the inbox.';
479
+ }
480
+
481
+ fqTitleEl().value = '';
482
+ fqBodyEl().value = '';
483
+ fqKindEl().value = 'bug';
484
+ goalTouchedByUser = false;
485
+ renderGoalOptions([]);
486
+ }
487
+
488
+ async function bootQuickComposer() {
489
+ fqKindEl().value = 'bug';
490
+ renderGoalOptions([]);
491
+ fqTitleEl().addEventListener('input', onQuickTitleEdit);
492
+ fqKindEl().addEventListener('change', onQuickTitleEdit);
493
+ fqGoalEl().addEventListener('change', () => { goalTouchedByUser = true; });
494
+ $('#fq-form').addEventListener('submit', fileQuick);
495
+
496
+ try {
497
+ const [versionsRes, goalsRes] = await Promise.all([
498
+ api.versions.getVersions({}),
499
+ api.goals.getGoals({ query: { status: 'open' } }),
500
+ ]);
501
+ buildingVersionIds = (versionsRes.ok && versionsRes.data && Array.isArray(versionsRes.data.versions))
502
+ ? versionsRes.data.versions.filter((v) => v.status === 'building').map((v) => v.id)
503
+ : [];
504
+ allOpenGoals = (goalsRes.ok && goalsRes.data && Array.isArray(goalsRes.data.goals)) ? goalsRes.data.goals : [];
505
+ allGoalsById = new Map(allOpenGoals.map((g) => [String(g.id), g]));
506
+ renderGoalOptions([]);
507
+ } catch (_) {
508
+ // The picker degrades to "homeless only" — filing itself never depends
509
+ // on this read succeeding.
510
+ }
511
+ }
512
+
350
513
  // ---------------------------------------------------------------------------
351
514
  // boot
352
515
  // ---------------------------------------------------------------------------
@@ -433,6 +596,16 @@
433
596
  // A surface that does not depend on the failure must not be hostage to it.
434
597
  loadQueue();
435
598
 
599
+ // Quick filing (R08) is a THIRD independent surface on this page, offered
600
+ // only outside develop mode — developing a spark always produces a Full
601
+ // Idea, never a fresh quick report (see the section's own header comment).
602
+ if (DEVELOP_ID) {
603
+ const scroll = $('#fq-compose-scroll');
604
+ if (scroll) scroll.hidden = true;
605
+ } else {
606
+ bootQuickComposer();
607
+ }
608
+
436
609
  const r = await api.inbox.getInboxTemplate({});
437
610
  if (!r.ok || !r.data || !r.data.template) {
438
611
  $('#fi-sub').textContent = 'Could not load the idea template. Reload, or file from the terminal with capture.js.';
@@ -66,6 +66,24 @@
66
66
  return data;
67
67
  }
68
68
 
69
+ // task 1003063 (R10): mutating call for the move-to-goal affordance. Same
70
+ // posture as postJSON — resolves to the parsed 2xx body, throws an Error
71
+ // carrying { status, data } on a non-2xx so the caller can read the server's
72
+ // own refusal sentence (ADR 0016 enforces; this only surfaces it).
73
+ async function patchJSON(path, body) {
74
+ const res = await api.request('PATCH', path, { body: body || {} });
75
+ if (res.status === 401) { toAuth(); throw new Error('redirecting to auth'); }
76
+ let data = null;
77
+ try { data = res.data; } catch (_) { /* empty / non-JSON body */ }
78
+ if (!res.ok) {
79
+ const err = new Error(`${path} → ${res.status}`);
80
+ err.status = res.status;
81
+ err.data = data || {};
82
+ throw err;
83
+ }
84
+ return data;
85
+ }
86
+
69
87
  // Map the server's typed lifecycle-error to a builder-readable line. Codes are
70
88
  // exactly those POST /tasks/:id/ship + /confirm can return. The server is the
71
89
  // authority (ADR 0016); the UI only surfaces the refusal.
@@ -430,11 +448,17 @@
430
448
  }
431
449
 
432
450
  function renderTask(t, lastClaim, viewer, goal, extras) {
433
- const { grade = null, sessionLogs = null, claims = null } = extras || {};
451
+ const { grade = null, sessionLogs = null, claims = null, goalsList = null } = extras || {};
434
452
  document.title = `#${t.id} — ${t.title || 'Task'} — ${document.title.split(' — ').pop()}`;
435
453
  const sub = $('#task-sub');
436
454
  if (sub) sub.textContent = `Task ${t.id}`;
437
455
 
456
+ // Metic+ is the floor for both the lifecycle supervisor controls below and
457
+ // the move-to-goal affordance (task 1003063 / R10) — hoisted so the two
458
+ // gates read the same rank once, rather than risking drift between copies.
459
+ const rank = (viewer && viewer.rank) ? String(viewer.rank).toLowerCase() : '';
460
+ const isSupervisor = rank === 'metic' || rank === 'archon';
461
+
438
462
  const parts = [];
439
463
  parts.push(`<p class="rec-kind">Task ${statusPill(t.status)}</p>`);
440
464
  parts.push(`<h1 class="rec-title" id="task-h"><span class="rec-title__id">${escapeHtml(String(t.id))}</span>${escapeHtml(t.title || 'Untitled task')}</h1>`);
@@ -473,6 +497,28 @@
473
497
  parts.push(`<p class="rec-goal">Goal <a href="/goals#/goal/${encodeURIComponent(t.goal_id)}">${escapeHtml(String(t.goal_id))}${goalName}</a>${catChip}</p>`);
474
498
  }
475
499
 
500
+ // task 1003063 (R10): the reassignment affordance — so a wrong routing pick
501
+ // is one PATCH away, never a re-file. Metic+ only (the server's own floor on
502
+ // PATCH /tasks/:id, ADR 0090); goalsList is fetched by load() scoped to this
503
+ // task's own version, because the server refuses a cross-version move
504
+ // (goal_version_mismatch, 409) and there is no point offering one.
505
+ if (isSupervisor && t.version_id) {
506
+ const openGoals = (Array.isArray(goalsList) ? goalsList : []).filter((g) => g.status !== 'archived');
507
+ const goalOpts = openGoals.length
508
+ ? openGoals.map((g) => `<option value="${escapeHtml(String(g.id))}"${String(g.id) === String(t.goal_id) ? ' selected' : ''}>${escapeHtml(String(g.id))} — ${escapeHtml(g.title || '')}</option>`).join('')
509
+ : '<option value="">No open goals in this version</option>';
510
+ parts.push(`
511
+ <div class="td-form" id="move-goal-form">
512
+ <label class="td-field td-field--wide"><span>Move to goal</span>
513
+ <select id="move-goal-select">${goalOpts}</select>
514
+ </label>
515
+ <div class="td-actions">
516
+ <button type="button" class="td-btn" id="move-goal-btn">Move</button>
517
+ </div>
518
+ </div>
519
+ `);
520
+ }
521
+
476
522
  const creditVal = window.OTBBranding?.taskCreditValue?.(t) ?? t.credits_reward;
477
523
  parts.push(figureBand([
478
524
  [window.OTBBranding?.taskCreditLabel?.(t) ?? curLabel(), creditVal],
@@ -550,8 +596,6 @@
550
596
  // toast. `confirmed → shipped` is the any-builder-supervisor / own-claim ship;
551
597
  // `completed → confirmed` is the Archon-only grader-bypass confirm (others see
552
598
  // a read-only note — the grader/Archon still has to act).
553
- const rank = (viewer && viewer.rank) ? String(viewer.rank).toLowerCase() : '';
554
- const isSupervisor = rank === 'metic' || rank === 'archon';
555
599
  const ownsLastClaim = !!(viewer && lastClaim && lastClaim.github_login &&
556
600
  lastClaim.github_login === viewer.github_login);
557
601
  let lifecycle = '';
@@ -610,6 +654,33 @@
610
654
  runLifecycle(confirmBtn, `${API}/tasks/${encodeURIComponent(t.id)}/confirm`, body, `#${t.id} confirmed.`);
611
655
  });
612
656
  }
657
+
658
+ // task 1003063 (R10): PATCH goal_id, then a full reload so the goal line, the
659
+ // picker's preselected option and everything else on the record re-render
660
+ // against the server's own state — no local patchwork of the DOM.
661
+ const moveGoalBtn = $('#move-goal-btn');
662
+ if (moveGoalBtn) {
663
+ moveGoalBtn.addEventListener('click', async () => {
664
+ const sel = $('#move-goal-select');
665
+ const goalId = sel ? Number(sel.value) : NaN;
666
+ if (!Number.isInteger(goalId) || goalId <= 0) { toast('Choose a goal first.'); return; }
667
+ const orig = moveGoalBtn.textContent;
668
+ moveGoalBtn.disabled = true;
669
+ moveGoalBtn.textContent = '…';
670
+ try {
671
+ await patchJSON(`${API}/tasks/${encodeURIComponent(t.id)}`, { goal_id: goalId });
672
+ toast(`#${t.id} moved to goal ${goalId}.`);
673
+ await load();
674
+ } catch (err) {
675
+ if (err && err.message === 'redirecting to auth') return;
676
+ // The server's own sentence, verbatim (a rank_forbidden 403 or a
677
+ // goal_version_mismatch 409 both say exactly what went wrong).
678
+ toast(errMessage(err && err.data) || `Could not move that task (${(err && err.status) || '?'}). Try again.`);
679
+ moveGoalBtn.disabled = false;
680
+ moveGoalBtn.textContent = orig;
681
+ }
682
+ });
683
+ }
613
684
  }
614
685
 
615
686
  // The two honest failures. Both keep the <h1> — the empty and error renders are
@@ -664,6 +735,18 @@
664
735
  goal = (g && g.goal) ? g.goal : null;
665
736
  } catch (_) { /* cosmetic — the goal link still resolves without it */ }
666
737
  }
738
+ // task 1003063 (R10): the move-to-goal picker's options, fetched only for
739
+ // the rank that can act on it and scoped to this task's own version (the
740
+ // server refuses a cross-version move). Best-effort: a failed read just
741
+ // means an empty picker, never a blocked page.
742
+ let goalsList = null;
743
+ const viewerRank = (viewer && viewer.rank) ? String(viewer.rank).toLowerCase() : '';
744
+ if ((viewerRank === 'metic' || viewerRank === 'archon') && d.task.version_id) {
745
+ try {
746
+ const gl = await getJSON(`${API}/goals?version=${encodeURIComponent(d.task.version_id)}`, { softAuth: true });
747
+ goalsList = Array.isArray(gl && gl.goals) ? gl.goals : [];
748
+ } catch (_) { /* cosmetic — the picker just has nothing to offer */ }
749
+ }
667
750
  // The repo bases a description's relative paths resolve onto (the primer's
668
751
  // and drachmae's pattern). Best-effort: without them a path passes through.
669
752
  try {
@@ -675,6 +758,7 @@
675
758
  grade: d.grade || null,
676
759
  sessionLogs: Array.isArray(d.session_logs) ? d.session_logs : null,
677
760
  claims: Array.isArray(d.claims) ? d.claims : null,
761
+ goalsList,
678
762
  });
679
763
  } catch (err) {
680
764
  if (err && err.code === AUTH_NEEDED) { toAuth(); return; }
@@ -240,6 +240,13 @@ async function captureIdea({
240
240
  // (a hall filing surface, a Discord classifier) can, and when it does the
241
241
  // goal's scope wall is applied to it verbatim. See ./routing.js.
242
242
  touches = null,
243
+ // task 1003068 (BV1.R12): a HINT-ONLY goal guess — NEVER fed to decideLanding,
244
+ // only recorded as the advisory suggested_goal_id column. `goalId` is a
245
+ // caller's routing DECISION; this is a machine guess with no such authority
246
+ // (the Discord #ideas classifier has no human behind it to have decided
247
+ // anything). Ignored when `goalId` is already set, so every existing caller
248
+ // is untouched.
249
+ suggestedGoalId = null,
243
250
  }, deps = {}) {
244
251
  if (!title || typeof title !== 'string') {
245
252
  throw { code: 'TITLE_REQUIRED' };
@@ -277,7 +284,7 @@ async function captureIdea({
277
284
  $7)
278
285
  RETURNING ${IDEA_COLS}`,
279
286
  [title, bodyMd, resolvedKind, suggestedVersion, suggestedPriority, capturedBy,
280
- goalId ?? null]
287
+ goalId ?? suggestedGoalId ?? null]
281
288
  );
282
289
  // A brand-new idea has no ideas_grades row, so it is quick by definition —
283
290
  // no join needed to know that. Grading it (R143's Full Idea capture) is a
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.496",
3
+ "version": "1.19.498",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.496",
9
+ "version": "1.19.498",
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.496",
3
+ "version": "1.19.498",
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",