@bongos/core 1.19.814 → 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.
@@ -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
@@ -44,12 +44,30 @@ const cascade = require('../cascade');
44
44
  // leave it. Nothing here can affect the ship: the emitters fire post-commit and
45
45
  // already wrap the emit, and `runCascadesForShip` resolves rather than throws.
46
46
  let _cascadeListenerOff = null;
47
+ let _ideaCascadeListenerOff = null;
47
48
 
48
49
  function registerSeams() {
49
50
  if (!api.hasProvider('lifecycle')) {
50
51
  api.registerProvider('lifecycle', lifecycle);
51
52
  }
52
53
  if (_cascadeListenerOff) _cascadeListenerOff();
54
+ // Torn down with its sibling: the factory runs again every time a test rebuilds
55
+ // the router, and a leaked subscription would fire this cascade twice.
56
+ if (_ideaCascadeListenerOff) _ideaCascadeListenerOff();
57
+ // task 1003856: the second cross-role cascade. Same module, same runner
58
+ // guarantees, different trigger — an idea reaching Full rather than a ship.
59
+ _ideaCascadeListenerOff = api.on('idea.graded.full', (graded) => cascade.runCascadesForIdea(graded, {
60
+ // No getIdea: the ideas module owns that row and sends it whole. Injecting a
61
+ // reader here would mean lifecycle querying another module's table.
62
+ findTaskBySourceRef: (q) => db.findTaskBySourceRef(q),
63
+ createTask: (spec) => db.createTask(spec),
64
+ // INVOKED, like its sibling below. api.logger is a FACTORY (api.logger(ns)),
65
+ // so passing it bare left every log?.info?.() call resolving to undefined and
66
+ // silently doing nothing — the optional chaining that makes the runner safe is
67
+ // also what hid this.
68
+ log: api.logger ? api.logger('lifecycle') : null,
69
+ }));
70
+
53
71
  _cascadeListenerOff = api.on('task.shipped', (shipped) => cascade.runCascadesForShip(shipped, {
54
72
  getTask: (id) => db.getTask(id),
55
73
  findTaskBySourceRef: (ref) => db.findTaskBySourceRef(ref),
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.814",
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.814",
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.814",
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.814'; // 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');
@@ -11,6 +11,9 @@
11
11
  //
12
12
  // Run: node tests/agents_validate.mjs
13
13
 
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
14
17
  import { strict as assert } from 'node:assert';
15
18
  import { createRequire } from 'node:module';
16
19
 
@@ -73,9 +76,37 @@ await test('a failed validation returns NO value — nothing downstream can inse
73
76
 
74
77
  // --- THE EVENT CATALOG — the fail-closed rule -------------------------------
75
78
 
76
- await test('EVENT_CATALOG is exactly the v1 wired set, and is frozen', () => {
77
- assert.deepEqual(EVENT_CATALOG, ['task.shipped']);
79
+ await test('EVENT_CATALOG is frozen, and every entry is REALLY dispatched', () => {
78
80
  assert.equal(Object.isFrozen(EVENT_CATALOG), true, 'a caller must not be able to widen the catalog at runtime');
81
+ assert.ok(EVENT_CATALOG.includes('task.shipped'), 'the original wired event stays wired');
82
+
83
+ // The catalog's own comment is the rule: widening it "must land together with
84
+ // something that actually dispatches the new event". Pinned as that rule rather
85
+ // than as a frozen list, because a list cannot tell a legitimate widening from
86
+ // one that ships an entry nothing emits — and the second produces an agent that
87
+ // looks armed and never fires, which is the failure the catalog exists to stop.
88
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
89
+ const files = [];
90
+ const walk = (dir) => {
91
+ let ents = [];
92
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
93
+ for (const e of ents) {
94
+ const full = path.join(dir, e.name);
95
+ if (e.isDirectory()) { if (e.name !== 'node_modules') walk(full); continue; }
96
+ if (e.name.endsWith('.js')) files.push(full);
97
+ }
98
+ };
99
+ for (const d of ['src', 'modules']) walk(path.join(ROOT, d));
100
+ const tree = files.map((f) => fs.readFileSync(f, 'utf8')).join('\n');
101
+
102
+ for (const event of EVENT_CATALOG) {
103
+ // Any quote style, emit or emitAsync. A plain substring scan rather than a
104
+ // built regex: the thing being asserted is "somebody emits this string".
105
+ const dispatched = ['emit(', 'emitAsync('].some((call) =>
106
+ [String.fromCharCode(39), '"', String.fromCharCode(96)].some((q) => tree.includes(call + q + event + q)));
107
+ assert.ok(dispatched,
108
+ `"${event}" is in the catalog but nothing in src/ or modules/ emits it — an agent armed on it would never fire`);
109
+ }
79
110
  });
80
111
 
81
112
  await test('an UNWIRED event name is REFUSED, never silently accepted', () => {
@@ -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();