@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.
- package/.bongos-core.json +39 -24
- package/.claude/skills/idea-triage/SKILL.md +32 -2
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -2
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +35 -22
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +15 -13
- package/docs/copy-registry.json +32 -14
- package/modules/hall-ui/public/task-detail.js +75 -0
- package/modules/lifecycle/goal-suggest.js +171 -0
- package/modules/lifecycle/routes/goals.js +9 -19
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/triage.js +98 -7
- package/src/bongos/route-rank-check.js +17 -2
- package/src/module-api.js +5 -1
- package/tests/goal_suggest.mjs +195 -0
- package/tests/promote_goal_id.mjs +316 -0
package/docs/copy-registry.json
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
"generator": "scripts/gds/copy-inventory.js",
|
|
4
4
|
"goal": 1000074,
|
|
5
5
|
"counts": {
|
|
6
|
-
"strings":
|
|
7
|
-
"unique_ids":
|
|
8
|
-
"certain":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/scripts/gds/triage.js
CHANGED
|
@@ -151,11 +151,80 @@ async function patchIdea(id, patch) {
|
|
|
151
151
|
return r.data?.idea;
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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:
|
|
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,
|