@bongos/core 1.19.815 → 1.19.816

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 CHANGED
@@ -2,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.815",
6
- "core_contract": "1.19.815",
7
- "source_commit": "49e12ef5bf0e0f642be16e2d07f3a8e4126088a3",
5
+ "core_version": "1.19.816",
6
+ "core_contract": "1.19.816",
7
+ "source_commit": "aa1a7485bebfc27a8b19c0acbff29287f1212828",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-17T07:58:43.512Z",
9
+ "built_at": "2026-09-17T08:14:24.758Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 489,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 2730,
20
- "tree_sha256": "8f4f7be2444b6e33f3772cc07af105a80fdd6a416ab122c91973dcb9600c0cf4",
20
+ "tree_sha256": "78e804a2a5bda8a926f2f33af27c70ca3a6e0b4b5b3aa9a5b3bd8c66dd5cec24",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2542,7 +2542,7 @@
2542
2542
  {
2543
2543
  "path": "docs/module-api-changelog.md",
2544
2544
  "mode": "0000644",
2545
- "sha256": "0f668e27938e73ba4769f2c5a1ee40205ffb350b25ad5c09bc7d660e2bb90b1d"
2545
+ "sha256": "c86194915e38b871c476151bdf00719d190411407942cfc8dd9f0a7734630ab0"
2546
2546
  },
2547
2547
  {
2548
2548
  "path": "docs/modules-contract.md",
@@ -5557,7 +5557,7 @@
5557
5557
  {
5558
5558
  "path": "modules/lifecycle/db-goals.js",
5559
5559
  "mode": "0000644",
5560
- "sha256": "b4d1c8bd2254f93519a88302072c2f524a1acb4152382fb09bdc22b1ef95f0ed"
5560
+ "sha256": "1b229bd4bf18563dd772c0be46f320ae533e71a5c7ba32aaa4b523ac9f97a4dc"
5561
5561
  },
5562
5562
  {
5563
5563
  "path": "modules/lifecycle/db-grade.js",
@@ -5597,7 +5597,7 @@
5597
5597
  {
5598
5598
  "path": "modules/lifecycle/db.js",
5599
5599
  "mode": "0000644",
5600
- "sha256": "a568b425560d8d8f4523995442454233b69016a31934810c5d4b7d83baab0b09"
5600
+ "sha256": "30c8884948a8dee8213c55279c12f06e104c7d95ab3d7fad9dc67506ed1ac1ab"
5601
5601
  },
5602
5602
  {
5603
5603
  "path": "modules/lifecycle/dead-deps.js",
@@ -5657,7 +5657,7 @@
5657
5657
  {
5658
5658
  "path": "modules/lifecycle/goal-suggest.js",
5659
5659
  "mode": "0000644",
5660
- "sha256": "85117db645accd023f4f61735f9f22a8c5f6ef08a29c61db99fbade51d081147"
5660
+ "sha256": "cdbfa36b58dc26b7ce9d7a3d1f2812edd0ea050c2b27fe3837be6b6edf7d9ac5"
5661
5661
  },
5662
5662
  {
5663
5663
  "path": "modules/lifecycle/help-requests.js",
@@ -7792,12 +7792,12 @@
7792
7792
  {
7793
7793
  "path": "package-lock.json",
7794
7794
  "mode": "0000644",
7795
- "sha256": "48fba5427217aab544717eef126aa155f404dc6ba71ad21e2b2c4db586da9be7"
7795
+ "sha256": "aa6749d880220869386e00488652da7ae32a0116855dba519e6b943c47f7180c"
7796
7796
  },
7797
7797
  {
7798
7798
  "path": "package.json",
7799
7799
  "mode": "0000644",
7800
- "sha256": "abc68f18878f7339c9dba180f3b965c6b2cb253f91255b509ccb9fbd47c3b21b"
7800
+ "sha256": "966ab00e5b62d0280043e13e156ce400cd3afa3f7c5bc04f02eea79392fd26f3"
7801
7801
  },
7802
7802
  {
7803
7803
  "path": "public-docs/index.html",
@@ -8057,7 +8057,7 @@
8057
8057
  {
8058
8058
  "path": "scripts/gds/capture.js",
8059
8059
  "mode": "0000644",
8060
- "sha256": "6779ebc36d51762777cb55352489a05ca9974f29c49d874b40c7486be83496ff"
8060
+ "sha256": "7320809dacf61454d114d5c55c244e7eac3314f36302eab879950355540573f6"
8061
8061
  },
8062
8062
  {
8063
8063
  "path": "scripts/gds/category-advisory-guard.js",
@@ -9687,7 +9687,7 @@
9687
9687
  {
9688
9688
  "path": "src/module-api.js",
9689
9689
  "mode": "0000644",
9690
- "sha256": "15e7fcbcb8377bea7245c66e4784accaecd09d92bd9baf3ecda20aab73faadac"
9690
+ "sha256": "575b32eed8f62dad0302816f97e77604488891d13188bf5b690a867c37a962ac"
9691
9691
  },
9692
9692
  {
9693
9693
  "path": "src/module-loader/catalog.js",
@@ -10312,7 +10312,7 @@
10312
10312
  {
10313
10313
  "path": "tests/capture_goal_routing.mjs",
10314
10314
  "mode": "0000644",
10315
- "sha256": "30c86ef5ae46c698b621dbe90e15210d162cc88d8b33bd0889330ff8686a065e"
10315
+ "sha256": "2ddceb37d46eda5c3ab2a56b54c86f3cf55596479b5fe7f30646f45b1ac11ac2"
10316
10316
  },
10317
10317
  {
10318
10318
  "path": "tests/cascade-dispatch.mjs",
@@ -11102,7 +11102,7 @@
11102
11102
  {
11103
11103
  "path": "tests/goal_suggest.mjs",
11104
11104
  "mode": "0000644",
11105
- "sha256": "d61e2bd5b5a65239f1410011a5035b124bf8e20c41eac08018635ec8cd3a49c7"
11105
+ "sha256": "41cf7774b86acabe7621563430fc4784152dea05f6cd3136b00081eca876e74d"
11106
11106
  },
11107
11107
  {
11108
11108
  "path": "tests/goal_tier.mjs",
@@ -2104,5 +2104,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2104
2104
  landed since 1.19.813 with no explicit bump. run 35194334679. (task 1002620)
2105
2105
  1.19.815 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2106
2106
  landed since 1.19.814 with no explicit bump. run 35197139985. (task 1002620)
2107
+ 1.19.816 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2108
+ landed since 1.19.815 with no explicit bump. run 35198566737. (task 1002620)
2107
2109
  ---------------------------------------------------------------------------
2108
2110
  ```
@@ -832,6 +832,41 @@ async function listCriteriaForGoal(goalId, deps = {}) {
832
832
  }));
833
833
  }
834
834
 
835
+ // listCriterionTextForGoals — every goal's done-when criterion prose, keyed by
836
+ // goal id, in ONE query (task 1003859). The goal suggester ranks on this.
837
+ //
838
+ // WHY BULK. The suggester scores all open goals for a version at once — eleven
839
+ // of them on BONGOS-V2 — so per-goal reads would be eleven round trips on a
840
+ // path that runs at capture time, for prose that is a few hundred bytes a goal.
841
+ //
842
+ // NO INDEX ON goal_id, DELIBERATELY. done_when_criteria carries a handful of rows
843
+ // per goal and low hundreds in total — a scan of it is cheaper than the index it
844
+ // would need maintaining, and adding one would mint a migration (a reserved number
845
+ // and human review) to speed up a read that is already the smallest thing on this
846
+ // path. idx_done_when_version exists for the version-scoped read; revisit if the
847
+ // table ever grows a digit.
848
+ //
849
+ // WHY NOT listCriteriaForGoal. That read carries linked-task counts and the
850
+ // pending_review derivation, none of which a text ranker wants; this returns the
851
+ // criterion prose and nothing else.
852
+ async function listCriterionTextForGoals(goalIds, deps = {}) {
853
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
854
+ const ids = (goalIds || []).map((g) => String(g)).filter(Boolean);
855
+ if (ids.length === 0) return new Map();
856
+ const { rows } = await activePool.query(
857
+ `SELECT goal_id, criterion_md
858
+ FROM done_when_criteria
859
+ WHERE goal_id = ANY($1::bigint[])`,
860
+ [ids]
861
+ );
862
+ const byGoal = new Map();
863
+ for (const r of rows) {
864
+ const k = String(r.goal_id);
865
+ byGoal.set(k, (byGoal.get(k) ? byGoal.get(k) + ' ' : '') + String(r.criterion_md || ''));
866
+ }
867
+ return byGoal;
868
+ }
869
+
835
870
  // isGoalMember — is this builder a member of this goal? The membership predicate
836
871
  // behind the bounded-authority gates (BV1.R64 ask-to-widen; the R60 authoring
837
872
  // gate reuses the same idea). Returns a plain boolean.
@@ -1251,6 +1286,7 @@ module.exports = {
1251
1286
  isGoalMember,
1252
1287
  listActiveClaimsForTasks,
1253
1288
  listCriteriaForGoal,
1289
+ listCriterionTextForGoals,
1254
1290
  openTasksInGoal,
1255
1291
  listGoalMembers,
1256
1292
  listGoals,
@@ -88,6 +88,7 @@ const {
88
88
  isGoalMember,
89
89
  listActiveClaimsForTasks,
90
90
  listCriteriaForGoal,
91
+ listCriterionTextForGoals,
91
92
  archiveGoalWithDispositions,
92
93
  openTasksInGoal,
93
94
  listGoalMembers,
@@ -267,6 +268,7 @@ module.exports = {
267
268
  listInboxFor,
268
269
  listPendingRequestsForGoal,
269
270
  listCriteriaForGoal,
271
+ listCriterionTextForGoals,
270
272
  archiveGoalWithDispositions,
271
273
  openTasksInGoal,
272
274
  xenosClaimAllowed,
@@ -18,12 +18,25 @@
18
18
  // so there is one notion of "these two texts are about the same thing" in the
19
19
  // module, not two.
20
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`.
21
+ // WHAT A GOAL IS MATCHED ON: its title, its scope_modules, and its DONE-WHEN
22
+ // CRITERIA (task 1003859). Criteria were added because title-only ranking went
23
+ // dead the moment the goal board adopted ADR 0264's naming: all eleven open
24
+ // BONGOS-V2 goals are titled 'Working area N — <subject>', so a real subject word
25
+ // in a draft — 'release', 'grader', 'dependency' — had almost nothing to overlap,
26
+ // every goal scored under threshold, and the suggester returned [] for every idea
27
+ // walked at the 2026-09-12 triage. It also left suggested_goal_id NULL at capture
28
+ // time on every BONGOS-V2 filing, so triage inherited nothing either. The criteria
29
+ // carry the subject vocabulary the titles stopped carrying.
30
+ //
31
+ // Criterion terms are weighted BELOW title terms (see CRITERION_WEIGHT): a goal
32
+ // named for the subject is still the stronger claim, and criteria are longer text
33
+ // that would otherwise swamp it.
34
+ //
35
+ // Still deliberately NOT the goal DESCRIPTION — a goal description is where a whole
36
+ // planning spec lives (goal 1000062 carries an entire decision record in its), so
37
+ // it shares tokens with almost any draft and would flatten every score toward the
38
+ // same number. Modules are included because they are a strong, cheap signal: a
39
+ // draft about the hall really does belong with a goal scoped to `hall-ui`.
27
40
 
28
41
  const { tokenize } = require('./criterion-suggest.js');
29
42
  const { versionAcceptsNewWork } = require('./db-tasks.js');
@@ -38,10 +51,47 @@ const captureErr = (err) => api.logger('lifecycle').error({ err: err && err.mess
38
51
  // Matches suggestCriteria's default so the two suggesters feel the same.
39
52
  const SUGGEST_LIMIT = 5;
40
53
 
54
+ // A criterion term counts for less than the same term in the title. Criteria are
55
+ // several sentences per goal against a title's handful of words, so equal weight
56
+ // would let a goal that merely MENTIONS a subject outrank one NAMED for it.
57
+ const CRITERION_WEIGHT = 0.5;
58
+
59
+ // rankableCriteria — the criterion prose the ranker may read, for the goals it is
60
+ // about to score. Both callers go through this, so the two rules below have ONE
61
+ // home and cannot drift apart.
62
+ //
63
+ // RULE 1 — PRIVATE GOALS ARE EXCLUDED. This function's own header explains that
64
+ // GET /goals (and therefore this suggester) already lists private goals' TITLES to
65
+ // any signed-in builder, and that reconciling that is a decision about the goal
66
+ // board as a whole rather than something a suggester settles. Criteria are a
67
+ // different order of exposure: score + matched_terms make a ranker into a
68
+ // word-guessing oracle, so ranking on a private goal's done-when prose would let
69
+ // any builder probe the CONTENT of work they cannot see, one candidate word per
70
+ // request. So this addition stops at the visibility line the status quo already
71
+ // draws — a private goal keeps ranking exactly as it did before (title + modules),
72
+ // and nothing new is disclosed. Widening or narrowing the title-level status quo
73
+ // stays the separate decision it was.
74
+ //
75
+ // RULE 2 — BEST EFFORT. A criteria read that fails degrades to title-only ranking,
76
+ // which is what this path did before the criteria existed. A suggester is advisory
77
+ // everywhere it is called; taking down a bug report or 500ing a picker to protect
78
+ // a ranking refinement would be the wrong trade.
79
+ async function rankableCriteria(db, goals, onError) {
80
+ if (typeof db.listCriterionTextForGoals !== 'function') return null;
81
+ const rankable = (goals || []).filter((g) => g && g.visibility !== 'private');
82
+ if (rankable.length === 0) return null;
83
+ try {
84
+ return await db.listCriterionTextForGoals(rankable.map((g) => g.id));
85
+ } catch (err) { onError(err); return null; }
86
+ }
87
+
41
88
  // Rank open goals against a DRAFT capture. Pure — no DB, no writes; the caller
42
89
  // supplies the goal rows.
43
90
  //
44
91
  // goals — open goal rows { id, title, scope_modules }
92
+ // opts.criteriaByGoal — Map<goalId, criterion prose> (db.listCriterionTextForGoals).
93
+ // Optional: omitted, this ranks exactly as it did before — the pure
94
+ // function stays callable with no DB anywhere near it.
45
95
  // draft — { title, body } (body optional: a capture's prose, weighted lower)
46
96
  // opts — { versionId, limit, includeZero }
47
97
  //
@@ -54,7 +104,7 @@ const SUGGEST_LIMIT = 5;
54
104
  // offer "browse all" without this function pretending to a confidence it lacks.
55
105
  function suggestGoals(goals, draft = {}, opts = {}) {
56
106
  const { title = '', body = '' } = draft;
57
- const { versionId = null, limit = SUGGEST_LIMIT, includeZero = false } = opts;
107
+ const { versionId = null, limit = SUGGEST_LIMIT, includeZero = false, criteriaByGoal = null } = opts;
58
108
 
59
109
  // Weighted term map: body terms at 1, title terms override to 2 (a term in
60
110
  // both IS a title term — the stronger signal wins). suggestCriteria's rule.
@@ -73,10 +123,20 @@ function suggestGoals(goals, draft = {}, opts = {}) {
73
123
  // by the >=3-char filter, which is fine — 'hall' is the signal.
74
124
  ...tokenize((g.scope_modules || []).join(' ')),
75
125
  ]);
126
+ // Criterion terms that the title/modules already cover add nothing — they are
127
+ // scored at the full title weight above, and double-counting would reward a
128
+ // goal for repeating itself.
129
+ const criterionTerms = new Set();
130
+ if (criteriaByGoal) {
131
+ for (const t of tokenize(criteriaByGoal.get(String(g.id)) || '')) {
132
+ if (!goalTerms.has(t)) criterionTerms.add(t);
133
+ }
134
+ }
76
135
  let score = 0;
77
136
  const matched = [];
78
137
  for (const [term, w] of weights) {
79
138
  if (goalTerms.has(term)) { score += w; matched.push(term); }
139
+ else if (criterionTerms.has(term)) { score += w * CRITERION_WEIGHT; matched.push(term); }
80
140
  }
81
141
  return { ...g, score, matched_terms: matched.sort() };
82
142
  }).sort((a, b) => (b.score - a.score) || (Number(a.id) - Number(b.id)));
@@ -133,11 +193,16 @@ async function suggestGoalForCapture({ title = '', body = '' } = {}, deps = {})
133
193
  && g.title !== generalGoalTitle(g.version_id));
134
194
  if (homes.length === 0) return null;
135
195
 
196
+ // Criterion prose for the same goals — this is the capture-time half of task
197
+ // 1003859, and it is the half that matters most: suggested_goal_id is written
198
+ // HERE, and it was NULL on every BONGOS-V2 filing.
199
+ const criteriaByGoal = await rankableCriteria(db, homes, captureErr);
200
+
136
201
  // versionId is passed null: the catch-alls are already gone, and passing one
137
202
  // version's id here would only re-filter for that version. includeZero stays
138
203
  // false, so a filing that matches nothing gets NO goal rather than the
139
204
  // first one dressed up as a suggestion.
140
- const [best] = suggestGoals(homes, { title, body }, { versionId: null, limit: 1 });
205
+ const [best] = suggestGoals(homes, { title, body }, { versionId: null, limit: 1, criteriaByGoal });
141
206
  if (!best) return null;
142
207
 
143
208
  // suggestGoals normalises to { id, title, scope_modules, … } and drops
@@ -184,7 +249,8 @@ function goalSuggestHandler({ db, LIMITS }) {
184
249
  // was an oversight, not a decision.
185
250
  try {
186
251
  const open = await db.listGoals({ versionId, status: 'open' });
187
- const suggestions = suggestGoals(open, { title, body }, { versionId, limit });
252
+ const criteriaByGoal = await rankableCriteria(db, open || [], proseErr);
253
+ const suggestions = suggestGoals(open, { title, body }, { versionId, limit, criteriaByGoal });
188
254
  return res.json({
189
255
  version_id: versionId,
190
256
  // `kind` is echoed, NOT ranked on. Kind decides how a capture LANDS (the
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.815",
3
+ "version": "1.19.816",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.815",
9
+ "version": "1.19.816",
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.815",
3
+ "version": "1.19.816",
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",
@@ -50,8 +50,12 @@
50
50
  // the active-claim default below is skipped —
51
51
  // an opt-out that still guessed a goal would not
52
52
  // be an opt-out.
53
- // (neither) → default to the ACTIVE CLAIM's goal, if this
54
- // builder holds one and its task has a goal
53
+ // (neither) → default to the ACTIVE CLAIM's goal — but ONLY
54
+ // when the captured text and the claimed task
55
+ // share a subject word (task 1003859); an
56
+ // unrelated filing says so and stays homeless.
57
+ // Applies if this builder holds a claim and its
58
+ // task has a goal
55
59
  // ("capturing mid-work almost always concerns
56
60
  // the work" — the interview's own words). With
57
61
  // no default to offer, the R07 suggester
@@ -160,6 +164,29 @@ function sanitizeForTerminal(s) {
160
164
  return String(s == null ? '' : s).replace(/[\x00-\x1f\x7f]/g, '');
161
165
  }
162
166
 
167
+ // claimShareSubject — does the captured text have ANYTHING in common with the
168
+ // task I happen to be holding? (task 1003859)
169
+ //
170
+ // WHY THE DEFAULT NEEDED A GUARD. The active-claim default is a convenience for
171
+ // the ordinary case: you are working on X and you file something about X. But it
172
+ // fired unconditionally, so filing an UNRELATED bug mid-session minted it into
173
+ // the claim's goal — this very bug was filed from a session holding a security
174
+ // task and landed in Working area 10 (Security), which has nothing to do with it,
175
+ // and had to be moved by hand. ADR 0234's owner-interview decision is that a
176
+ // guess must not mint work into a goal, and an inherited goal with no subject in
177
+ // common is exactly a guess.
178
+ //
179
+ // Reuses criterion-suggest's tokenize — the same notion of 'these two texts are
180
+ // about the same thing' the goal suggester ranks with, so the guard and the
181
+ // suggester cannot disagree about what a subject word is.
182
+ const { tokenize } = require('../../modules/lifecycle/criterion-suggest.js');
183
+
184
+ function claimShareSubject(capturedText, claimTitle) {
185
+ const theirs = new Set(tokenize(claimTitle));
186
+ if (theirs.size === 0) return true; // nothing to disagree with — keep the old behaviour
187
+ return tokenize(capturedText).some((t) => theirs.has(t));
188
+ }
189
+
163
190
  // resolveGoalForCapture — the WHOLE of --goal resolution for one capture, in the
164
191
  // interview's own precedence: an explicit id wins outright; 'none' is a hard
165
192
  // opt-out (no default, no hint); otherwise the active-claim default runs, and
@@ -168,26 +195,34 @@ function sanitizeForTerminal(s) {
168
195
  // is testable without capturing stdout. Never throws (both collaborators above
169
196
  // already degrade to null on their own).
170
197
  async function resolveGoalForCapture({ goalArg, version, title, kind, client = cliClient } = {}) {
198
+ const claimLines = [];
171
199
  const parsed = parseGoalArg(goalArg);
172
200
  if (parsed.kind === 'id') return { goalId: parsed.id, lines: [] };
173
201
  if (parsed.kind === 'none') return { goalId: null, lines: [] };
174
202
 
175
203
  const defaulted = await resolveActiveClaimGoal({ client });
176
204
  if (defaulted) {
177
- return {
178
- goalId: defaulted.goalId,
179
- lines: [`↪ defaulting --goal to ${defaulted.goalId} from your active claim on task #${defaulted.taskId} ("${sanitizeForTerminal(defaulted.taskTitle)}") — override with --goal <id>, or --goal none to file homeless.`],
180
- };
205
+ // Only inherit when the two are actually about the same thing. When they are
206
+ // not, say so and fall through to the suggester — which ranks on what was
207
+ // WRITTEN rather than on what happens to be open — instead of filing silently
208
+ // into the wrong goal.
209
+ if (claimShareSubject(title, defaulted.taskTitle)) {
210
+ return {
211
+ goalId: defaulted.goalId,
212
+ lines: [`↪ defaulting --goal to ${defaulted.goalId} from your active claim on task #${defaulted.taskId} ("${sanitizeForTerminal(defaulted.taskTitle)}") — override with --goal <id>, or --goal none to file homeless.`],
213
+ };
214
+ }
215
+ claimLines.push(`↪ NOT defaulting to your active claim's goal: task #${defaulted.taskId} ("${sanitizeForTerminal(defaulted.taskTitle)}") shares no subject with what you filed — pass --goal ${defaulted.goalId} if it really belongs there.`);
181
216
  }
182
217
 
183
218
  const hint = await fetchGoalSuggestion({ client, version, title, kind });
184
219
  if (hint) {
185
220
  return {
186
221
  goalId: null,
187
- lines: [`💡 possible home: goal ${hint.id} "${sanitizeForTerminal(hint.title)}" (score ${hint.score}) — re-run with --goal ${hint.id} (or --goal none to stay homeless on purpose).`],
222
+ lines: [...claimLines, `💡 possible home: goal ${hint.id} "${sanitizeForTerminal(hint.title)}" (score ${hint.score}) — re-run with --goal ${hint.id} (or --goal none to stay homeless on purpose).`],
188
223
  };
189
224
  }
190
- return { goalId: null, lines: [] };
225
+ return { goalId: null, lines: claimLines };
191
226
  }
192
227
 
193
228
  // DEFAULT IS NO, and only an explicit yes counts. Extracted so the done-when
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.815'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.816'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -181,7 +181,11 @@ await test('no --goal: the active-claim default fires and names how to opt out',
181
181
  me: { ok: true, data: { active_claims: [{ task_id: 55, title: 'Fix the thing' }] } },
182
182
  task: { ok: true, data: { task: { id: 55, goal_id: 1000071, title: 'Fix the thing' } } },
183
183
  });
184
- const r = await capture.resolveGoalForCapture({ goalArg: null, version: 'BV1', title: 't', client });
184
+ // The captured text shares 'thing' with the claim — the ordinary case the
185
+ // default exists for. (It used to be the bare title 't'; since task 1003859 the
186
+ // default only fires on a shared subject word, so a placeholder no longer
187
+ // exercises this path.)
188
+ const r = await capture.resolveGoalForCapture({ goalArg: null, version: 'BV1', title: 'the thing is broken', client });
185
189
  assert.equal(r.goalId, 1000071);
186
190
  assert.equal(r.lines.length, 1);
187
191
  assert.match(r.lines[0], /1000071/);
@@ -239,4 +243,64 @@ await test('WIRING: a routing refusal is surfaced through the existing verbatim
239
243
  assert.match(CAPTURE_SRC, /console\.error\(formatCaptureFailure\(r\.status, r\.data\)\);/);
240
244
  });
241
245
 
246
+ // ---------------------------------------------------------------------------
247
+ // the active-claim default must not mint UNRELATED work into the claim's goal
248
+ // (task 1003859)
249
+ // ---------------------------------------------------------------------------
250
+ //
251
+ // The default fired unconditionally, so filing an unrelated bug mid-session
252
+ // routed it into whatever goal you happened to be holding — task 1003859 itself
253
+ // was filed from a security claim and landed in Working area 10, and had to be
254
+ // moved by hand. ADR 0234: a guess must not mint work into a goal.
255
+
256
+ await test('no --goal, claim shares NO subject with the filing: does not inherit, and says why', async () => {
257
+ const { client, calls } = stubClient({
258
+ me: { ok: true, data: { active_claims: [{ task_id: 55, title: 'Harden the OAuth callback against replay' }] } },
259
+ task: { ok: true, data: { task: { id: 55, goal_id: 1000094, title: 'Harden the OAuth callback against replay' } } },
260
+ suggestions: { ok: true, data: { suggestions: [] } },
261
+ });
262
+ const r = await capture.resolveGoalForCapture({
263
+ goalArg: null, version: 'BV1', title: 'The goal suggester returns zero suggestions for every idea', client,
264
+ });
265
+ assert.equal(r.goalId, null, 'an unrelated claim must not decide where this work lives');
266
+ assert.equal(r.lines.length, 1, 'staying homeless silently would be the same bug with no evidence');
267
+ assert.match(r.lines[0], /NOT defaulting/);
268
+ assert.match(r.lines[0], /1000094/, 'it must name the goal it declined, so --goal can override it');
269
+ assert.equal(calls.suggestions.length, 1, 'with no default, the suggester still gets its turn');
270
+ });
271
+
272
+ await test('a declined default still yields to a real suggestion, keeping both lines', async () => {
273
+ const { client } = stubClient({
274
+ me: { ok: true, data: { active_claims: [{ task_id: 55, title: 'Harden the OAuth callback against replay' }] } },
275
+ task: { ok: true, data: { task: { id: 55, goal_id: 1000094, title: 'Harden the OAuth callback against replay' } } },
276
+ suggestions: { ok: true, data: { suggestions: [{ id: 1000091, title: 'Working area 7', score: 3 }] } },
277
+ });
278
+ const r = await capture.resolveGoalForCapture({
279
+ goalArg: null, version: 'BV1', title: 'The goal suggester returns zero suggestions', client,
280
+ });
281
+ assert.equal(r.goalId, null);
282
+ assert.equal(r.lines.length, 2, 'the refusal and the hint are different facts — both are printed');
283
+ assert.match(r.lines[0], /NOT defaulting/);
284
+ assert.match(r.lines[1], /1000091/);
285
+ });
286
+
287
+ await test('--goal N still wins outright over the guard (it is not a veto)', async () => {
288
+ const { client, calls } = stubClient({
289
+ me: { ok: true, data: { active_claims: [{ task_id: 55, title: 'Something else entirely' }] } },
290
+ task: { ok: true, data: { task: { id: 55, goal_id: 1000094 } } },
291
+ });
292
+ const r = await capture.resolveGoalForCapture({ goalArg: '1000094', version: 'BV1', title: 'unrelated words here', client });
293
+ assert.deepEqual(r, { goalId: 1000094, lines: [] });
294
+ assert.equal(calls.me, 0);
295
+ });
296
+
297
+ await test('a claim title with no subject words at all still inherits — the guard only fires on real disagreement', async () => {
298
+ const { client } = stubClient({
299
+ me: { ok: true, data: { active_claims: [{ task_id: 55, title: 'a' }] } },
300
+ task: { ok: true, data: { task: { id: 55, goal_id: 1000071, title: 'a' } } },
301
+ });
302
+ const r = await capture.resolveGoalForCapture({ goalArg: null, version: 'BV1', title: 'anything at all', client });
303
+ assert.equal(r.goalId, 1000071, 'nothing to compare against is not evidence of a mismatch');
304
+ });
305
+
242
306
  summary();
@@ -199,13 +199,23 @@ await test('the handler clamps limit into a sane range', async () => {
199
199
  // confident answer and declines whenever "confident" is not available.
200
200
  // =========================================================================
201
201
 
202
- const fakeDb = ({ versions = [], goals = [], throws = null, calls = null } = {}) => ({
202
+ const fakeDb = ({ versions = [], goals = [], throws = null, calls = null, criteria = null } = {}) => ({
203
203
  listVersions: async () => { if (throws === 'versions') throw new Error('boom'); return versions; },
204
204
  listGoals: async (args) => {
205
205
  if (calls) calls.push(args);
206
206
  if (throws === 'goals') throw new Error('boom');
207
207
  return goals;
208
208
  },
209
+ // task 1003859: the criterion prose the ranker reads alongside the title.
210
+ // Omitted (criteria: null) it is absent from the fake ENTIRELY, which is also
211
+ // the pinned shape for a db that predates this read.
212
+ ...(criteria ? {
213
+ listCriterionTextForGoals: async (ids) => {
214
+ if (throws === 'criteria') throw new Error('boom');
215
+ if (calls) calls.push({ criteriaFor: ids.map(String) });
216
+ return new Map(Object.entries(criteria));
217
+ },
218
+ } : {}),
209
219
  });
210
220
 
211
221
  const BUILDING = { id: 'BONGOS-V1', status: 'building' };
@@ -298,4 +308,141 @@ await test('suggestGoalForCapture: the catch-all is never offered as a home', as
298
308
  assert.equal(got, null, 'a bucket is not a workspace — dropped for EVERY open version, not just one');
299
309
  });
300
310
 
311
+ // ---------------------------------------------------------------------------
312
+ // ranking on DONE-WHEN CRITERIA (task 1003859)
313
+ // ---------------------------------------------------------------------------
314
+ //
315
+ // The real failure: since ADR 0264 every open BONGOS-V2 goal is titled
316
+ // 'Working area N — <subject>'. A subject word in a draft had nothing to overlap
317
+ // in the title, every goal scored 0, and the suggester returned [] for every idea
318
+ // walked at the 2026-09-12 triage — which also left suggested_goal_id NULL on
319
+ // every filing. These reproduce that board rather than a convenient one.
320
+
321
+ const WORKING_AREAS = [
322
+ { id: 1000091, title: 'Working area 7', scope_modules: [], version_id: 'BONGOS-V2' },
323
+ { id: 1000094, title: 'Working area 10', scope_modules: [], version_id: 'BONGOS-V2' },
324
+ ];
325
+ const WA_CRITERIA = {
326
+ 1000091: 'A release is published and an instance can move its pin to it. The grader runs and its findings are readable.',
327
+ 1000094: 'Every authenticated route re-checks the caller rank. A credential never reaches a log.',
328
+ };
329
+
330
+ await test('the real board: title-only ranking scores every Working area goal ZERO', () => {
331
+ const got = suggestGoals(WORKING_AREAS, { title: 'the grader returns no findings' }, { versionId: 'BONGOS-V2', includeZero: true });
332
+ assert.deepEqual(got.map((g) => g.score), [0, 0],
333
+ 'this is the bug, pinned: the naming convention left the ranker nothing to match');
334
+ assert.deepEqual(suggestGoals(WORKING_AREAS, { title: 'the grader returns no findings' }, { versionId: 'BONGOS-V2' }), [],
335
+ 'and so the suggester returned nothing at all');
336
+ });
337
+
338
+ await test('with criteria, the same draft finds its goal, with the matched terms shown', () => {
339
+ const got = suggestGoals(
340
+ WORKING_AREAS,
341
+ { title: 'the grader returns no findings' },
342
+ { versionId: 'BONGOS-V2', criteriaByGoal: new Map(Object.entries(WA_CRITERIA)) }
343
+ );
344
+ assert.equal(got.length, 1, 'only the goal whose criteria talk about grading');
345
+ assert.equal(got[0].id, 1000091);
346
+ assert.ok(got[0].score > 0);
347
+ assert.ok(got[0].matched_terms.includes('grader'), `expected 'grader' among ${JSON.stringify(got[0].matched_terms)}`);
348
+ });
349
+
350
+ await test('a criterion match weighs LESS than the same word in the title', () => {
351
+ const byTitle = { id: 10, title: 'Grader health', scope_modules: [], version_id: 'BONGOS-V2' };
352
+ const byCriteria = { id: 11, title: 'Working area 7', scope_modules: [], version_id: 'BONGOS-V2' };
353
+ const got = suggestGoals(
354
+ [byCriteria, byTitle],
355
+ { title: 'grader' },
356
+ { versionId: 'BONGOS-V2', criteriaByGoal: new Map([['11', 'the grader runs nightly']]) }
357
+ );
358
+ assert.equal(got[0].id, 10, 'a goal NAMED for the subject still outranks one that merely mentions it');
359
+ assert.ok(got[0].score > got[1].score);
360
+ });
361
+
362
+ await test('a term in both title and criteria is counted ONCE, at the title weight', () => {
363
+ const g = { id: 12, title: 'Grader health', scope_modules: [], version_id: 'BONGOS-V2' };
364
+ const withCriteria = suggestGoals([g], { title: 'grader' }, { versionId: 'BONGOS-V2', criteriaByGoal: new Map([['12', 'grader grader grader']]) });
365
+ const without = suggestGoals([g], { title: 'grader' }, { versionId: 'BONGOS-V2' });
366
+ assert.equal(withCriteria[0].score, without[0].score, 'repeating itself must not inflate a goal');
367
+ });
368
+
369
+ await test('no criteriaByGoal: ranking is EXACTLY what it was before (the pure function stays DB-free)', () => {
370
+ const draft = { title: 'the hall sidebar collapses on mobile' };
371
+ const a = suggestGoals([HALL_GOAL, ECON_GOAL], draft, { versionId: 'BONGOS-V1' });
372
+ const b = suggestGoals([HALL_GOAL, ECON_GOAL], draft, { versionId: 'BONGOS-V1', criteriaByGoal: null });
373
+ assert.deepEqual(a, b);
374
+ assert.equal(a[0].id, 81);
375
+ });
376
+
377
+ await test('suggestGoalForCapture reads the criteria, so suggested_goal_id is populated on the real board', async () => {
378
+ const calls = [];
379
+ const got = await suggestGoalForCapture(
380
+ { title: 'the grader returns no findings', body: '' },
381
+ { db: fakeDb({ versions: [{ id: 'BONGOS-V2', status: 'building' }], goals: WORKING_AREAS, criteria: WA_CRITERIA, calls }) }
382
+ );
383
+ assert.ok(got, 'this returned NULL before task 1003859 — which is why suggested_goal_id was null on every filing');
384
+ assert.equal(got.id, 1000091);
385
+ assert.equal(got.version_id, 'BONGOS-V2');
386
+ assert.ok(calls.some((c) => c && c.criteriaFor), 'the criteria read must actually be issued');
387
+ });
388
+
389
+ await test('a criteria read that THROWS degrades to title-only ranking, never to a lost filing', async () => {
390
+ const got = await suggestGoalForCapture(
391
+ { title: 'the hall sidebar collapses on mobile', body: '' },
392
+ { db: fakeDb({ versions: [BUILDING], goals: [HALL_GOAL, ECON_GOAL], criteria: {}, throws: 'criteria' }) }
393
+ );
394
+ assert.ok(got, 'a bug report must survive a criteria read that fails');
395
+ assert.equal(got.id, 81, 'and still rank on what it can see');
396
+ });
397
+
398
+ await test('a db with NO listCriterionTextForGoals at all still works (older core, or a lean fake)', async () => {
399
+ const got = await suggestGoalForCapture(
400
+ { title: 'the hall sidebar collapses on mobile', body: '' },
401
+ { db: fakeDb({ versions: [BUILDING], goals: [HALL_GOAL, ECON_GOAL] }) }
402
+ );
403
+ assert.equal(got.id, 81);
404
+ });
405
+
406
+ // ---------------------------------------------------------------------------
407
+ // a PRIVATE goal's criteria are never ranked on (task 1003859, grade round 1)
408
+ // ---------------------------------------------------------------------------
409
+ //
410
+ // score + matched_terms make a ranker into a word-guessing oracle: one candidate
411
+ // word per request tells you whether that word appears in the ranked text. The
412
+ // suggester already exposes private goals' TITLES (GET /goals does too — a wider
413
+ // status quo this task does not settle), but their done-when prose is a different
414
+ // order of exposure, so the criteria read stops at the visibility line.
415
+
416
+ const PRIVATE_GOAL = { id: 900, title: 'Working area 12', scope_modules: [], version_id: 'BONGOS-V2', visibility: 'private' };
417
+ const PUBLIC_GOAL = { id: 901, title: 'Working area 13', scope_modules: [], version_id: 'BONGOS-V2', visibility: 'public' };
418
+
419
+ await test('the criteria of a PRIVATE goal are never fetched — not even to rank', async () => {
420
+ const asked = [];
421
+ const db = {
422
+ listGoals: async () => [PRIVATE_GOAL, PUBLIC_GOAL],
423
+ listCriterionTextForGoals: async (ids) => { asked.push(...ids.map(String)); return new Map(); },
424
+ };
425
+ const res = fakeRes();
426
+ await goalSuggestHandler({ db, LIMITS: { TITLE: 200 } })(
427
+ { query: { version: 'BONGOS-V2', title: 'embargo' } }, res
428
+ );
429
+ assert.deepEqual(asked, ['901'], 'only the public goal may contribute criterion text');
430
+ assert.ok(!asked.includes('900'), 'a private goal must not be probeable one word at a time');
431
+ });
432
+
433
+ await test('a private goal still ranks on its TITLE — this narrows nothing that already worked', () => {
434
+ const got = suggestGoals([PRIVATE_GOAL], { title: 'working area 12' }, { versionId: 'BONGOS-V2' });
435
+ assert.equal(got.length, 1, 'the title-level status quo is a separate decision, left exactly as it was');
436
+ });
437
+
438
+ await test('every goal private ⇒ no criteria read is issued at all', async () => {
439
+ let called = 0;
440
+ const db = {
441
+ listGoals: async () => [PRIVATE_GOAL],
442
+ listCriterionTextForGoals: async () => { called++; return new Map(); },
443
+ };
444
+ await goalSuggestHandler({ db, LIMITS: { TITLE: 200 } })({ query: { version: 'BONGOS-V2', title: 'x y z' } }, fakeRes());
445
+ assert.equal(called, 0, 'a query with nothing rankable must not be issued');
446
+ });
447
+
301
448
  summary();