@bongos/core 1.19.288 → 1.19.290

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.
@@ -3,9 +3,9 @@
3
3
  "generator": "scripts/gds/copy-inventory.js",
4
4
  "goal": 1000074,
5
5
  "counts": {
6
- "strings": 1337,
7
- "unique_ids": 1186,
8
- "certain": 1337,
6
+ "strings": 1339,
7
+ "unique_ids": 1188,
8
+ "certain": 1339,
9
9
  "heuristic": 0,
10
10
  "duplicate_ids": 121
11
11
  },
@@ -13,7 +13,7 @@
13
13
  "builders-hall": {
14
14
  "at": "modules/hall-ui/public",
15
15
  "files": 61,
16
- "strings": 1053
16
+ "strings": 1055
17
17
  },
18
18
  "status-dashboard": {
19
19
  "at": "modules/status-ui/public",
@@ -3481,7 +3481,7 @@
3481
3481
  "surface": "builders-hall",
3482
3482
  "text": "Create task",
3483
3483
  "file": "modules/hall-ui/public/task-detail.js",
3484
- "line": 300,
3484
+ "line": 354,
3485
3485
  "origin": "js-markup",
3486
3486
  "confidence": "certain"
3487
3487
  },
@@ -3823,7 +3823,7 @@
3823
3823
  "surface": "builders-hall",
3824
3824
  "text": "Estimate (min)",
3825
3825
  "file": "modules/hall-ui/public/task-detail.js",
3826
- "line": 295,
3826
+ "line": 349,
3827
3827
  "origin": "js-markup",
3828
3828
  "confidence": "certain"
3829
3829
  },
@@ -4030,7 +4030,7 @@
4030
4030
  "surface": "builders-hall",
4031
4031
  "text": "Fold this idea into #",
4032
4032
  "file": "modules/hall-ui/public/task-detail.js",
4033
- "line": 369,
4033
+ "line": 444,
4034
4034
  "origin": "js-markup",
4035
4035
  "confidence": "certain"
4036
4036
  },
@@ -4957,7 +4957,7 @@
4957
4957
  "surface": "builders-hall",
4958
4958
  "text": "Loading versions…",
4959
4959
  "file": "modules/hall-ui/public/task-detail.js",
4960
- "line": 258,
4960
+ "line": 307,
4961
4961
  "origin": "js-markup",
4962
4962
  "confidence": "certain"
4963
4963
  },
@@ -5623,7 +5623,7 @@
5623
5623
  "surface": "builders-hall",
5624
5624
  "text": "Note (optional)",
5625
5625
  "file": "modules/hall-ui/public/task-detail.js",
5626
- "line": 372,
5626
+ "line": 447,
5627
5627
  "origin": "js-markup",
5628
5628
  "confidence": "certain"
5629
5629
  },
@@ -6325,7 +6325,7 @@
6325
6325
  "surface": "builders-hall",
6326
6326
  "text": "Reason (optional)",
6327
6327
  "file": "modules/hall-ui/public/task-detail.js",
6328
- "line": 423,
6328
+ "line": 498,
6329
6329
  "origin": "js-markup",
6330
6330
  "confidence": "certain"
6331
6331
  },
@@ -9313,7 +9313,7 @@
9313
9313
  "surface": "builders-hall",
9314
9314
  "text": "idea number",
9315
9315
  "file": "modules/hall-ui/public/task-detail.js",
9316
- "line": 370,
9316
+ "line": 445,
9317
9317
  "origin": "js-markup",
9318
9318
  "confidence": "certain"
9319
9319
  },
@@ -9425,6 +9425,15 @@
9425
9425
  "origin": "js-markup",
9426
9426
  "confidence": "certain"
9427
9427
  },
9428
+ {
9429
+ "id": "2ce7738b1eb0",
9430
+ "surface": "builders-hall",
9431
+ "text": "loading goals…",
9432
+ "file": "modules/hall-ui/public/task-detail.js",
9433
+ "line": 371,
9434
+ "origin": "js-markup",
9435
+ "confidence": "certain"
9436
+ },
9428
9437
  {
9429
9438
  "id": "8c22f0ee5edb",
9430
9439
  "surface": "builders-hall",
@@ -10024,7 +10033,7 @@
10024
10033
  "surface": "builders-hall",
10025
10034
  "text": "why these are the same",
10026
10035
  "file": "modules/hall-ui/public/task-detail.js",
10027
- "line": 373,
10036
+ "line": 448,
10028
10037
  "origin": "js-markup",
10029
10038
  "confidence": "certain"
10030
10039
  },
@@ -10033,7 +10042,7 @@
10033
10042
  "surface": "builders-hall",
10034
10043
  "text": "why this isn’t worth doing",
10035
10044
  "file": "modules/hall-ui/public/task-detail.js",
10036
- "line": 424,
10045
+ "line": 499,
10037
10046
  "origin": "js-markup",
10038
10047
  "confidence": "certain"
10039
10048
  },
@@ -10478,6 +10487,15 @@
10478
10487
  "origin": "js-markup",
10479
10488
  "confidence": "certain"
10480
10489
  },
10490
+ {
10491
+ "id": "9ac518dfa95e",
10492
+ "surface": "builders-hall",
10493
+ "text": "— none (the version's general bucket) —",
10494
+ "file": "modules/hall-ui/public/task-detail.js",
10495
+ "line": 302,
10496
+ "origin": "js-markup",
10497
+ "confidence": "certain"
10498
+ },
10481
10499
  {
10482
10500
  "id": "da9962dc1729",
10483
10501
  "surface": "builders-hall",
@@ -10645,7 +10663,7 @@
10645
10663
  "surface": "builders-hall",
10646
10664
  "text": "→ no idea #",
10647
10665
  "file": "modules/hall-ui/public/task-detail.js",
10648
- "line": 391,
10666
+ "line": 466,
10649
10667
  "origin": "js-text-assign",
10650
10668
  "confidence": "certain"
10651
10669
  },
@@ -254,6 +254,55 @@
254
254
  + '</div>';
255
255
  }
256
256
 
257
+ // task 1003071 (R13) — the goal picker's options, same precedence as the CLI
258
+ // triage path: the STORED hint (idea.suggested_goal_id, written by
259
+ // inbox.captureIdea precisely so triage inherits the guess) wins; the R07
260
+ // suggester only fills in when there is no hint. Returns option HTML.
261
+ async function goalOptionsFor(versionId) {
262
+ // Number() so a junk stored value cannot reach the DOM as value="NaN" and
263
+ // then be POSTed as a goal id.
264
+ var raw = Number(idea.suggested_goal_id);
265
+ var hint = Number.isFinite(raw) && raw > 0 ? raw : null;
266
+ var suggestions = [];
267
+ // The suggester is a convenience, so an outage degrades to "no suggestions"
268
+ // and must not stop a triage verdict — the hint and the opt-out below are
269
+ // both still reachable without it.
270
+ try {
271
+ var q = '?version=' + encodeURIComponent(versionId)
272
+ + '&title=' + encodeURIComponent(idea.title || '')
273
+ + '&body=' + encodeURIComponent(String(idea.body_md || '').slice(0, 2000))
274
+ + '&limit=5';
275
+ // softAuth: a cosmetic suggestion list must not navigate the builder away
276
+ // from a half-filled promote form. The submit re-auths if the session died.
277
+ var res = await getJSON(API + '/goals/suggestions' + q, { softAuth: true });
278
+ suggestions = (res && res.suggestions) || [];
279
+ } catch (_) { suggestions = []; }
280
+ var opts = [];
281
+ var seen = {};
282
+ if (hint != null) {
283
+ seen[hint] = true;
284
+ opts.push('<option value="' + hint + '" selected>goal ' + hint + ' — carried from the filing</option>');
285
+ }
286
+ suggestions.forEach(function (g, i) {
287
+ if (seen[g.id]) return;
288
+ seen[g.id] = true;
289
+ var why = (g.matched_terms || []).join(', ');
290
+ // The score + matched terms are shown on purpose: token overlap can match
291
+ // an incidental word, so the operator judges the suggestion rather than
292
+ // trusting it (task 1003062's stated limit).
293
+ opts.push('<option value="' + esc(String(g.id)) + '"' + (hint == null && i === 0 ? ' selected' : '') + '>'
294
+ + esc(String(g.id)) + ' — ' + esc(g.title || '') + (why ? ' (matched: ' + esc(why) + ')' : '') + '</option>');
295
+ });
296
+ // Always offer the opt-out, and make it the fallback when nothing was found.
297
+ // Sending no goal_id does not create a goal-less task — db.createTask files
298
+ // it under the version's catch-all "general" goal — so the label names where
299
+ // it actually lands. That bucket carries no done-when criteria, which is why
300
+ // it should be a choice rather than the silent default it used to be.
301
+ opts.push('<option value=""' + (opts.length === 0 ? ' selected' : '')
302
+ + '>— none (the version\'s general bucket) —</option>');
303
+ return opts.join('');
304
+ }
305
+
257
306
  async function renderPromote() {
258
307
  box.innerHTML = '<p class="deeplink-card__note">Loading versions…</p>';
259
308
  // Both dead ends offer the way back through the same delegated [data-go]
@@ -283,6 +332,10 @@
283
332
  // CHECKed 1-5, so clamp rather than let an out-of-range value match no
284
333
  // option and leave the browser silently defaulting to P1.
285
334
  var pri = Math.min(5, Math.max(1, Number(idea.suggested_priority) || 3));
335
+ // Fetched for the PREFERRED version; the change listener below re-fetches,
336
+ // because goals belong to a version and showing another version's goals
337
+ // would offer a home the server would reject.
338
+ var goalOptions = await goalOptionsFor(preferred);
286
339
  var priOptions = [1, 2, 3, 4, 5].map(function (p) {
287
340
  return '<option value="' + p + '"' + (p === pri ? ' selected' : '') + '>P' + p + '</option>';
288
341
  }).join('');
@@ -291,6 +344,7 @@
291
344
  + '<label class="td-field td-field--wide"><span>Title</span>'
292
345
  + '<input type="text" name="title" maxlength="' + TITLE_MAX + '" required value="' + esc(idea.title || '') + '"></label>'
293
346
  + '<label class="td-field td-field--wide"><span>Version</span><select name="version">' + options + '</select></label>'
347
+ + '<label class="td-field td-field--wide"><span>Goal</span><select name="goal" data-goal-select>' + goalOptions + '</select></label>'
294
348
  + '<label class="td-field"><span>Priority</span><select name="priority">' + priOptions + '</select></label>'
295
349
  + '<label class="td-field"><span>Estimate (min)</span>'
296
350
  + '<input type="number" name="est" min="0" max="100000" step="5" placeholder="—"></label>'
@@ -304,6 +358,23 @@
304
358
  ev.preventDefault();
305
359
  submitPromote(ev.target);
306
360
  });
361
+ // Re-fetch the goal list when the version changes (task 1003071). Two quick
362
+ // changes race — the fetches can resolve out of order — so only the newest
363
+ // request is allowed to write, and only it re-enables the control.
364
+ var vsel = box.querySelector('select[name="version"]');
365
+ var gsel = box.querySelector('[data-goal-select]');
366
+ if (vsel && gsel) {
367
+ var goalFetch = 0;
368
+ vsel.addEventListener('change', async function () {
369
+ var mine = ++goalFetch;
370
+ gsel.disabled = true;
371
+ gsel.innerHTML = '<option value="">loading goals…</option>';
372
+ var html = await goalOptionsFor(vsel.value);
373
+ if (mine !== goalFetch) return;
374
+ gsel.innerHTML = html;
375
+ gsel.disabled = false;
376
+ });
377
+ }
307
378
  }
308
379
 
309
380
  async function submitPromote(form) {
@@ -325,6 +396,10 @@
325
396
  source: 'idea_inbox',
326
397
  source_ref: 'idea:' + idea.id,
327
398
  };
399
+ // task 1003071 (R13): the whole point — a triaged promotion NAMES its goal.
400
+ // Empty means the operator chose the version's general bucket, which the
401
+ // server applies itself when goal_id is absent.
402
+ if (form.goal && form.goal.value !== '') payload.goal_id = Number(form.goal.value);
328
403
  if (form.est.value !== '') payload.est_minutes = Number(form.est.value);
329
404
  if (form.credits.value !== '') payload.credits_reward = Number(form.credits.value);
330
405
  // 'criterion-proposal' is an idea-only kind; everything else is shared with
@@ -0,0 +1,171 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/goal-suggest.js — "which goal should this work live in?",
4
+ // answered BEFORE anything is created (task 1003062 / R07 of goal 1000071).
5
+ //
6
+ // THE GAP. `buildGoalAdvisory` (goal-advisory.js) already answers this — but only
7
+ // AFTER a task exists, as a hint attached to the 201. Every consumer R07 feeds
8
+ // needs it BEFORE: the hall filing surface offering a goal picker (R08), the CLI
9
+ // asking at capture time (R09), and the Discord suggestion (R11) all have a draft
10
+ // title and no row yet. So the candidate logic is lifted into a pure read that
11
+ // creates nothing.
12
+ //
13
+ // WHY THIS RANKS, rather than returning "the first five open goals". Unranked
14
+ // candidates are useless here: BONGOS-V1 carries dozens of open goals, so an
15
+ // arbitrary five is noise dressed as a suggestion. Ranking is what makes it a
16
+ // suggester. It reuses `tokenize` from criterion-suggest.js — a sibling, already
17
+ // covering the weighting idea (title terms 2, body terms 1) and already tested —
18
+ // so there is one notion of "these two texts are about the same thing" in the
19
+ // module, not two.
20
+ //
21
+ // WHAT A GOAL IS MATCHED ON: its title and its scope_modules. Deliberately NOT
22
+ // its description — a goal description is where a whole planning spec lives (goal
23
+ // 1000062 carries an entire decision record in its), so it shares tokens with
24
+ // almost any draft and would flatten every score toward the same number. Modules
25
+ // are included because they are a strong, cheap signal: a draft about the hall
26
+ // really does belong with a goal scoped to `hall-ui`.
27
+
28
+ const { tokenize } = require('./criterion-suggest.js');
29
+ const { candidateGoals } = require('./goal-advisory.js');
30
+ const api = require('../../src/module-api');
31
+ // Through the published logger seam, not a raw console call (est-advisory.js precedent).
32
+ const proseErr = (err) => api.logger('lifecycle').error({ err: err && err.message }, 'GET /goals/suggestions failed');
33
+
34
+ // Matches suggestCriteria's default so the two suggesters feel the same.
35
+ const SUGGEST_LIMIT = 5;
36
+
37
+ // Rank open goals against a DRAFT capture. Pure — no DB, no writes; the caller
38
+ // supplies the goal rows.
39
+ //
40
+ // goals — open goal rows { id, title, scope_modules }
41
+ // draft — { title, body } (body optional: a capture's prose, weighted lower)
42
+ // opts — { versionId, limit, includeZero }
43
+ //
44
+ // Returns [{ id, title, scope_modules, score, matched_terms }] best-first, ties
45
+ // broken by ascending id so output is stable for a picker.
46
+ //
47
+ // `includeZero` defaults FALSE, following suggestCriteria: a draft that matches
48
+ // nothing returns [] — "no confident suggestion" — rather than five arbitrary
49
+ // goals. The route reports the open-goal count alongside so a caller can still
50
+ // offer "browse all" without this function pretending to a confidence it lacks.
51
+ function suggestGoals(goals, draft = {}, opts = {}) {
52
+ const { title = '', body = '' } = draft;
53
+ const { versionId = null, limit = SUGGEST_LIMIT, includeZero = false } = opts;
54
+
55
+ // Weighted term map: body terms at 1, title terms override to 2 (a term in
56
+ // both IS a title term — the stronger signal wins). suggestCriteria's rule.
57
+ const weights = new Map();
58
+ for (const t of tokenize(body)) weights.set(t, 1);
59
+ for (const t of tokenize(title)) weights.set(t, 2);
60
+
61
+ // candidateGoals drops the "<version> — general" catch-all and normalises the
62
+ // row shape. Reused rather than re-filtered so "which goals may be suggested"
63
+ // has one definition — the catch-all is never a suggestion, it is the fallback
64
+ // when there is no home, which is a different question (R03 owns that).
65
+ const ranked = candidateGoals(goals, versionId).map((g) => {
66
+ const goalTerms = new Set([
67
+ ...tokenize(g.title),
68
+ // A module key like `hall-ui` tokenizes to ['hall','ui']; 'ui' is dropped
69
+ // by the >=3-char filter, which is fine — 'hall' is the signal.
70
+ ...tokenize((g.scope_modules || []).join(' ')),
71
+ ]);
72
+ let score = 0;
73
+ const matched = [];
74
+ for (const [term, w] of weights) {
75
+ if (goalTerms.has(term)) { score += w; matched.push(term); }
76
+ }
77
+ return { ...g, score, matched_terms: matched.sort() };
78
+ }).sort((a, b) => (b.score - a.score) || (Number(a.id) - Number(b.id)));
79
+
80
+ const kept = includeZero ? ranked : ranked.filter((r) => r.score > 0);
81
+ return kept.slice(0, Math.max(0, limit));
82
+ }
83
+
84
+
85
+ // goalSuggestHandler — GET /goals/suggestions. Handler lives here, not inline in
86
+ // routes/goals.js, because that file is over the fitness oversize threshold and
87
+ // the ratchet is shrink-only; collaborators are injected so this module still
88
+ // requires nothing but its siblings.
89
+ //
90
+ // Creates NOTHING — that is the point of R07. A caller with a draft title gets
91
+ // candidates and decides; POST /inbox (R01) is what actually routes.
92
+ //
93
+ // VISIBILITY, stated rather than silently decided: this returns open goals
94
+ // without filtering on `visibility`, which MATCHES the existing GET /goals — that
95
+ // route already lists private goals' titles to any signed-in builder
96
+ // (db.listGoals has no visibility predicate). So this neither widens nor narrows
97
+ // the status quo. It is worth knowing that the status quo is wider than the
98
+ // private-goal leak rule the CLAIM refusal follows (goal-authz
99
+ // goalMembershipClaimRefusal withholds a private goal's title); reconciling the
100
+ // two is a real decision about the goal board as a whole, not something a
101
+ // suggester should settle on its own.
102
+ function goalSuggestHandler({ db, LIMITS }) {
103
+ return async function getGoalSuggestions(req, res) {
104
+ const versionId = String(req.query.version || '').trim();
105
+ if (!versionId) return res.fail('version_required', 400, { message: 'Pass ?version=<version_id> — suggestions are scoped to one version.' });
106
+ const title = String(req.query.title || '').slice(0, (LIMITS && LIMITS.TITLE) || 200);
107
+ const body = String(req.query.body || '').slice(0, 4000);
108
+ if (!title && !body) {
109
+ return res.fail('draft_text_required', 400, { message: 'Pass ?title= (and optionally ?body=) — there is nothing to rank against otherwise.' });
110
+ }
111
+ const rawLimit = Number(req.query.limit);
112
+ const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(1, Math.trunc(rawLimit)), 20) : SUGGEST_LIMIT;
113
+ // Wrapped like criteriaSuggestHandler below: without it a db error escapes to
114
+ // Express's default handler, which can put internal detail on a JSON API
115
+ // response. The sibling in this same file already did this; not matching it
116
+ // was an oversight, not a decision.
117
+ try {
118
+ const open = await db.listGoals({ versionId, status: 'open' });
119
+ const suggestions = suggestGoals(open, { title, body }, { versionId, limit });
120
+ return res.json({
121
+ version_id: versionId,
122
+ // `kind` is echoed, NOT ranked on. Kind decides how a capture LANDS (the
123
+ // R05 landing matrix: a bug hard-lands, a feature soft-lands); it does not
124
+ // indicate WHICH goal the work belongs to. Saying so here stops a caller
125
+ // reading the echo as a ranking input.
126
+ kind: req.query.kind ? String(req.query.kind).slice(0, 64) : null,
127
+ suggestions,
128
+ // So a caller can offer "browse all" honestly when nothing scored: an empty
129
+ // `suggestions` means "no confident match", never "this version has no goals".
130
+ open_goal_count: (open || []).length,
131
+ created: null,
132
+ });
133
+ } catch (err) {
134
+ proseErr(err);
135
+ return res.fail('suggest_failed', { status: 500, message: 'internal error' });
136
+ }
137
+ };
138
+ }
139
+
140
+ // criteriaSuggestHandler — GET /goals/:id/suggest-criteria (task 2165), moved
141
+ // here in task 1003062. Same reason as its sibling above: routes/goals.js is over
142
+ // the fitness oversize threshold and the ratchet is shrink-only, so adding the
143
+ // goal suggester there had to be PAID FOR — and paying with a cohesion
144
+ // improvement beats shaving comments. It belongs beside the other suggester
145
+ // anyway: both answer "what should this draft attach to", one at goal level and
146
+ // one at criterion level. Behaviour byte-identical; only the registration line
147
+ // stays in the route file so route-rank-check still classifies it.
148
+ function criteriaSuggestHandler({ db, suggestCriteria, parseId }) {
149
+ return async function getSuggestCriteria(req, res) {
150
+ const id = parseId(req, res, { code: 'bad_goal_id' });
151
+ if (id === null) return;
152
+ try {
153
+ const goal = await db.getGoal(id);
154
+ if (!goal) return res.fail('goal_not_found', 404);
155
+ const criteria = await db.listCriteriaForGoal(id);
156
+ const limit = Math.min(10, Math.max(1, Number(req.query.limit) || 3));
157
+ const suggestions = suggestCriteria(
158
+ criteria,
159
+ { title: req.query.title, description: req.query.description },
160
+ { limit }
161
+ );
162
+ return res.json({ goal_id: goal.id, suggestions });
163
+ } catch (err) {
164
+ // Same seam as its sibling above — one error-handling pattern per file.
165
+ api.logger('lifecycle').error({ err: err && err.message }, 'GET /goals/:id/suggest-criteria failed');
166
+ return res.fail('fetch_failed', { status: 500, message: 'internal error' });
167
+ }
168
+ };
169
+ }
170
+
171
+ module.exports = { suggestGoals, goalSuggestHandler, criteriaSuggestHandler, SUGGEST_LIMIT };
@@ -26,6 +26,7 @@ const api = require('../../../src/module-api');
26
26
  const auth = api;
27
27
  const db = require('../db');
28
28
  const hierarchy = require('../hierarchy');
29
+ const goalSuggest = require('../goal-suggest.js'); // task 1003062 — pre-creation goal suggester
29
30
  const goalEdits = require('../goal-edits.js'); // task 1003102 — goal self-edit handlers
30
31
  const { suggestCriteria } = require('../criterion-suggest');
31
32
  const { classifyTaskKind, VALID_KINDS, VALID_DISCIPLINES, TASK_FIELD_BOUNDS } = require('../task-classifier');
@@ -535,6 +536,13 @@ module.exports = function buildGoalsRouter() {
535
536
  }
536
537
  });
537
538
 
539
+ // GET /goals/suggestions — "which goal should this draft live in?", answered
540
+ // before anything is created (task 1003062 / R07). Handler + the ranking and
541
+ // visibility rationale: ../goal-suggest.js. Mounted BEFORE GET /goals/:id so
542
+ // 'suggestions' is never captured as an id.
543
+ // rank: any-builder — a read of open goal titles, same surface as GET /goals.
544
+ router.get('/goals/suggestions', auth.requireBuilder, goalSuggest.goalSuggestHandler({ db, LIMITS }));
545
+
538
546
  // GET /goals/inbox — the caller's private-goals inbox (task 1952 / ADR 0112):
539
547
  // { invitations: invites addressed to me (accept/decline); joinRequests: pending
540
548
  // join-requests on goals I own or manage (approve/deny); myPendingRequests: my own
@@ -578,25 +586,7 @@ module.exports = function buildGoalsRouter() {
578
586
  // task, so a create flow can SUGGEST which done-when criterion to link (the
579
587
  // author confirms/overrides — nothing is auto-linked). Read-only + advisory;
580
588
  // rank: any-builder (same as GET /goals/:id).
581
- router.get('/goals/:id/suggest-criteria', auth.requireBuilder, async (req, res) => {
582
- const id = parseId(req, res, { code: 'bad_goal_id' });
583
- if (id === null) return;
584
- try {
585
- const goal = await db.getGoal(id);
586
- if (!goal) return res.fail('goal_not_found', 404);
587
- const criteria = await db.listCriteriaForGoal(id);
588
- const limit = Math.min(10, Math.max(1, Number(req.query.limit) || 3));
589
- const suggestions = suggestCriteria(
590
- criteria,
591
- { title: req.query.title, description: req.query.description },
592
- { limit }
593
- );
594
- return res.json({ goal_id: goal.id, suggestions });
595
- } catch (err) {
596
- console.error('[gds] GET /goals/:id/suggest-criteria', err);
597
- return res.fail('fetch_failed', { status: 500, message: 'internal error' });
598
- }
599
- });
589
+ router.get('/goals/:id/suggest-criteria', auth.requireBuilder, goalSuggest.criteriaSuggestHandler({ db, suggestCriteria, parseId }));
600
590
 
601
591
  // POST /goals/:id/join — join an open goal.
602
592
  // rank: any-builder — open join for a NORMAL goal. A PROTECTED-scope goal is
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.288",
3
+ "version": "1.19.290",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.288",
9
+ "version": "1.19.290",
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.288",
3
+ "version": "1.19.290",
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",
@@ -151,11 +151,80 @@ async function patchIdea(id, patch) {
151
151
  return r.data?.idea;
152
152
  }
153
153
 
154
- async function createTask({ versionId, title, description, priority, sourceRef }) {
155
- // NOTE: POST /api/gds/tasks doesn't currently accept 'kind' — tasks default
156
- // to 'unclassified' and the server-side classifier (B1) runs separately.
157
- // The idea's auto-classified kind is therefore informational only at this
158
- // step; the task description preserves the body for re-classification.
154
+ // resolveGoalDefault — what goal should a triaged promotion land in? (task
155
+ // 1003071 / R13.)
156
+ //
157
+ // PRECEDENCE IS DELIBERATE:
158
+ // 1. `idea.suggested_goal_id` — the STORED hint. inbox.captureIdea writes it
159
+ // even when the matrix declined to route, precisely so "/idea-triage
160
+ // inherits the guess instead of re-deriving it" (core_222, its own comment).
161
+ // A human or a filing surface already named this goal; a heuristic must not
162
+ // overrule that.
163
+ // 2. the R07 suggester (GET /goals/suggestions) — only when there is no hint.
164
+ // Best-scoring candidate, and its score/matched_terms are shown so the
165
+ // operator can see WHY before accepting.
166
+ // Returns { goalId, label, source } — goalId null when neither yields anything.
167
+ // The caller prints `source` as provenance, because "the suggester guessed this"
168
+ // and "the filing already named this" deserve different trust. Never throws: a
169
+ // suggester outage degrades to "no default", it does not block a triage verdict.
170
+ //
171
+ // `client` is injectable so the precedence can be driven end-to-end in a test
172
+ // rather than pattern-matched in the source. (Task 1003102 shipped a handler
173
+ // whose collaborators were not injectable; its tests read the source, passed,
174
+ // and the route 500'd on every call. Not repeating that.)
175
+ async function resolveGoalDefault(idea, versionId, { client = cliClient } = {}) {
176
+ // Guarded: a junk stored value must not become goalId NaN and then a goal_id
177
+ // the server rejects with 400 on every retry.
178
+ const hint = Number(idea.suggested_goal_id);
179
+ if (Number.isInteger(hint) && hint > 0) {
180
+ return { goalId: hint, label: `goal ${hint}`, source: 'hint' };
181
+ }
182
+ try {
183
+ const api = await client();
184
+ // The GENERATED typed method (regenerated when R07 landed), not the generic
185
+ // request() escape hatch — so a path or param rename reds the client build.
186
+ const r = await api.goals.getGoalsSuggestions({
187
+ query: { version: versionId, title: idea.title || '', body: (idea.body_md || '').slice(0, 2000), limit: 3 },
188
+ });
189
+ const top = r.ok && r.data && Array.isArray(r.data.suggestions) ? r.data.suggestions[0] : null;
190
+ if (!top) return { goalId: null, label: null, source: 'none' };
191
+ const why = (top.matched_terms || []).join('/');
192
+ return { goalId: Number(top.id), label: `${top.id} ${trim(top.title, 44)} (score ${top.score}${why ? ' · ' + why : ''})`, source: 'suggester' };
193
+ } catch (_) {
194
+ return { goalId: null, label: null, source: 'unavailable' };
195
+ }
196
+ }
197
+
198
+ // What the operator's answer to the goal prompt means. Blank ACCEPTS the shown
199
+ // default — declining has to be said ('-' or 'none'), because silently dropping a
200
+ // real default on a bare Enter is how work ends up in the general bucket by
201
+ // accident. Declining is legal: the server then applies the catch-all itself.
202
+ function applyGoalAnswer(defaultGoalId, rawAnswer) {
203
+ const answer = String(rawAnswer == null ? '' : rawAnswer).trim();
204
+ if (answer === '') return { goalId: defaultGoalId, warn: null };
205
+ if (answer === '-' || answer.toLowerCase() === 'none') return { goalId: null, warn: null };
206
+ const parsed = Number(answer);
207
+ if (!Number.isInteger(parsed) || parsed <= 0) {
208
+ return { goalId: null, warn: "not a goal id — falling back to the version's general bucket" };
209
+ }
210
+ return { goalId: parsed, warn: null };
211
+ }
212
+
213
+ async function createTask({ versionId, title, description, priority, sourceRef, goalId = null }) {
214
+ // (The old note here said POST /api/gds/tasks does not accept 'kind'. That is
215
+ // no longer true — routes/tasks.js takes it, and the hall promote path already
216
+ // sends it. Left un-sent from THIS path deliberately: changing what kind a
217
+ // triaged promotion lands with is a behaviour change task R13 was not asked to
218
+ // make, and the server classifier still runs. Corrected rather than deleted so
219
+ // the next reader does not re-derive it.)
220
+ //
221
+ // task 1003071 (R13): goal_id is the point of this task. Omitting it does NOT
222
+ // leave the task goal-less — db.createTask (modules/lifecycle/db.js) defaults a
223
+ // goal-less create into the version's catch-all "<version> — general" goal. That
224
+ // bucket is the actual defect: it carries no done-when criteria, so since ADR
225
+ // 0183 made criteria close themselves off LINKED tasks, work parked there can
226
+ // never advance anything, and it does not read as real goal work on the board.
227
+ // Naming the goal here is what stops a triaged promotion falling into it.
159
228
  const body = {
160
229
  version_id: versionId,
161
230
  title,
@@ -165,6 +234,7 @@ async function createTask({ versionId, title, description, priority, sourceRef }
165
234
  source: 'idea_inbox',
166
235
  source_ref: sourceRef ?? null,
167
236
  };
237
+ if (goalId != null) body.goal_id = Number(goalId);
168
238
  const api = await cliClient();
169
239
  const r = await api.tasks.postTasks({ body });
170
240
  if (!r.ok) {
@@ -296,7 +366,7 @@ async function main() {
296
366
  }
297
367
 
298
368
  if (ans === 'p' || ans === 'promote') {
299
- // Promote: ask for version + priority (defaulted from idea).
369
+ // Promote: version, priority, title, goal — each defaulted from the idea.
300
370
  const defaultVersion = idea.suggested_version || 'PMS-V2';
301
371
  const versionAns = (await prompt(rl, ` version [${defaultVersion}]: `)).trim();
302
372
  const versionId = versionAns || defaultVersion;
@@ -308,6 +378,23 @@ async function main() {
308
378
  const titleAns = (await prompt(rl, ` title [${trim(idea.title, 60)}]: `)).trim();
309
379
  const title = titleAns || idea.title;
310
380
 
381
+ // task 1003071 (R13): a triaged promotion NAMES its goal. The default
382
+ // comes from the stored hint, else the R07 suggester; '-' opts out
383
+ // explicitly, which falls through to the version's catch-all "general"
384
+ // bucket — now a choice someone typed rather than the silent default.
385
+ // The EFFECTIVE title, not idea.title: the suggester matches on title
386
+ // terms, and the operator may just have retitled this.
387
+ const gd = await resolveGoalDefault({ ...idea, title }, versionId);
388
+ if (gd.source === 'suggester') console.log(` suggested goal: ${gd.label}`);
389
+ if (gd.source === 'unavailable') console.log(' (goal suggester unreachable — enter a goal id, or "-" to accept the general bucket)');
390
+ const goalPrompt = gd.goalId != null
391
+ ? ` goal [${gd.goalId}] ("-" for the version's general bucket): `
392
+ : ' goal (blank/"-" for the version\'s general bucket): ';
393
+ const goalAns = (await prompt(rl, goalPrompt)).trim();
394
+ const picked = applyGoalAnswer(gd.goalId, goalAns);
395
+ if (picked.warn) console.log(` ! ${picked.warn}`);
396
+ const goalId = picked.goalId;
397
+
311
398
  try {
312
399
  const task = await createTask({
313
400
  versionId,
@@ -315,6 +402,7 @@ async function main() {
315
402
  description: idea.body_md || '',
316
403
  priority: Number.isFinite(priority) ? priority : null,
317
404
  sourceRef: `idea:${idea.id}`,
405
+ goalId,
318
406
  });
319
407
  await patchIdea(idea.id, {
320
408
  status: 'promoted',
@@ -323,7 +411,7 @@ async function main() {
323
411
  });
324
412
  summary.promoted++;
325
413
  promotedIds.push({ ideaId: idea.id, taskId: task.id });
326
- console.log(` ✓ promoted #${idea.id} → task #${task.id} (${versionId}, P${priority})`);
414
+ console.log(` ✓ promoted #${idea.id} → task #${task.id} (${versionId}, P${priority}${goalId != null ? `, goal ${goalId}` : ', → general bucket'})`);
327
415
  } catch (err) {
328
416
  console.error(` ! ${err.message}`);
329
417
  }
@@ -360,6 +448,9 @@ if (require.main === module) {
360
448
  }
361
449
 
362
450
  module.exports = {
451
+ resolveGoalDefault,
452
+ applyGoalAnswer,
453
+ createTask,
363
454
  resolveDevelopmentPath,
364
455
  parseDevelopment,
365
456
  loadDevelopment,