@bongos/core 1.19.815 → 1.19.817

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.
Files changed (36) hide show
  1. package/.bongos-core.json +50 -40
  2. package/.claude/skills/worktree-clean/SKILL.md +3 -2
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +6 -0
  5. package/clients/bongos-client/index.cjs +6 -0
  6. package/clients/bongos-client/index.d.ts +9 -0
  7. package/clients/bongos-client/index.mjs +6 -0
  8. package/docs/api/openapi.json +178 -3
  9. package/docs/api-reference.md +5 -2
  10. package/docs/copy-inventory.md +41 -38
  11. package/docs/copy-registry.json +67 -40
  12. package/docs/module-api-changelog.md +4 -0
  13. package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
  14. package/modules/government/catalog.js +1 -1
  15. package/modules/hall-ui/public/settings-specialities.js +56 -2
  16. package/modules/hall-ui/public/settings.html +1 -0
  17. package/modules/lifecycle/db-goals.js +36 -0
  18. package/modules/lifecycle/db.js +2 -0
  19. package/modules/lifecycle/goal-suggest.js +75 -9
  20. package/modules/lifecycle/routes/task-route-helpers.js +50 -3
  21. package/modules/specialities/db.js +41 -2
  22. package/modules/specialities/migrations/specialities_002_offered.sql +57 -0
  23. package/modules/specialities/routes/specialities.js +64 -0
  24. package/modules/specialities/specialities.js +30 -1
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/scripts/gds/capture.js +43 -8
  28. package/scripts/gds/category-advisory-guard.js +49 -8
  29. package/scripts/gds/stale-worktrees.js +50 -6
  30. package/src/module-api.js +1 -1
  31. package/tests/capture_goal_routing.mjs +65 -1
  32. package/tests/goal_suggest.mjs +148 -1
  33. package/tests/government_require_permission.mjs +90 -14
  34. package/tests/migration_allocation.mjs +70 -0
  35. package/tests/speciality_offered.mjs +212 -0
  36. package/tests/stale_worktrees.mjs +54 -0
@@ -13,6 +13,28 @@
13
13
  // deleted it). Both are pure git facts, so this runs in well under a second at
14
14
  // SessionStart. The primary checkout is never listed.
15
15
  //
16
+ // THE THIRD LEAK — UNSTARTED CLAIM TREES (task 1003912). "Ancestor of origin/main"
17
+ // is also true of a branch that is origin/main, which is exactly what
18
+ // `worktree.js add` leaves behind: it branches off origin/main and commits
19
+ // nothing. So between `worktree.js add` and the builder's FIRST COMMIT, a live
20
+ // claim's worktree was reported as "merged into main". Observed 2026-09-13: the
21
+ // session working task 1002594 saw its own tree listed as stale. The classifier
22
+ // had the git fact right and the intent wrong — nothing had landed, nothing had
23
+ // even started.
24
+ //
25
+ // That window is small and the consequence is not: /worktree-clean removes every
26
+ // name on the stale list, so a cleanup run by another session (or the SessionStart
27
+ // nag) inside it would take a live claim's tree. `worktree.js remove` refuses a
28
+ // dirty tree, which bounds the damage — but a claim worktree that is still empty
29
+ // is precisely the one that is NOT dirty, so the guard does not cover this case.
30
+ //
31
+ // The fix stays a pure git fact, keeping the "no API call" property this header is
32
+ // explicit about wanting: a branch whose TIP EQUALS origin/main is zero commits
33
+ // ahead, so it cannot have landed anything, and is reported live-but-unstarted.
34
+ // The alternative — cross-checking branch names against active claims — would put
35
+ // a network round trip in a SessionStart probe to learn something git already
36
+ // knows.
37
+ //
16
38
  // THE SECOND LEAK — HUSKS (task 1002594). Under OneDrive, `git worktree remove`
17
39
  // often errors 'Permission denied' / 'Device or resource busy' but still
18
40
  // DEREGISTERS the worktree, so `worktree.js remove` treats deregistration as
@@ -65,7 +87,12 @@ function parseWorktreeList(porcelain) {
65
87
 
66
88
  // Pure classifier so the decision is unit-testable without a repo: `isMerged(branch)`
67
89
  // and `branchExists(branch)` are the two git facts it needs.
68
- function classifyWorktrees(worktrees, { isMerged, branchExists }) {
90
+ // `isUnstarted(branch)` is the task 1003912 guard: branch tip === origin/main tip.
91
+ // It is checked BEFORE isMerged because every unstarted branch is also trivially
92
+ // "merged" — that coincidence is the whole bug, and order is what separates them.
93
+ // Optional, so a caller that does not supply it gets the old behaviour rather than
94
+ // a crash.
95
+ function classifyWorktrees(worktrees, { isMerged, branchExists, isUnstarted = () => false }) {
69
96
  const stale = [];
70
97
  const live = [];
71
98
  for (const wt of worktrees) {
@@ -73,6 +100,7 @@ function classifyWorktrees(worktrees, { isMerged, branchExists }) {
73
100
  const name = path.basename(wt.path);
74
101
  if (!wt.branch) { live.push({ name, path: wt.path, branch: null }); continue; } // detached: not ours to judge
75
102
  if (!branchExists(wt.branch)) stale.push({ name, path: wt.path, branch: wt.branch, why: 'branch gone' });
103
+ else if (isUnstarted(wt.branch)) live.push({ name, path: wt.path, branch: wt.branch, why: 'unstarted (no commits yet)' });
76
104
  else if (isMerged(wt.branch)) stale.push({ name, path: wt.path, branch: wt.branch, why: 'merged into main' });
77
105
  else live.push({ name, path: wt.path, branch: wt.branch });
78
106
  }
@@ -93,22 +121,38 @@ function classifyHusks({ dirNames, worktreePaths, worktreesRoot }) {
93
121
  .map((name) => ({ name, path: path.join(worktreesRoot, name) }));
94
122
  }
95
123
 
96
- // Three git calls total, whatever the worktree count: the list, every local branch,
97
- // every branch already merged into origin/main. Per-worktree subprocesses would be
98
- // N+1 over exactly the number this probe exists to shrink.
124
+ // FOUR git calls total, whatever the worktree count: the worktree list, every local
125
+ // branch WITH ITS TIP (one read, both facts — task 1003912), every branch already
126
+ // merged into origin/main, and origin/main's own tip. Per-worktree subprocesses
127
+ // would be N+1 over exactly the number this probe exists to shrink.
99
128
  function refNames(out) {
100
129
  return new Set(String(out).split('\n').map((s) => s.trim()).filter(Boolean));
101
130
  }
102
131
 
103
132
  function scan(cwd) {
104
133
  const worktrees = parseWorktreeList(git(['worktree', 'list', '--porcelain'], cwd));
105
- const existing = refNames(git(['for-each-ref', '--format=%(refname:short)', 'refs/heads/'], cwd));
134
+ // ONE read of refs/heads/, carrying both facts it is asked for: which branches
135
+ // exist, and where each one points. The tip is what the task 1003912 guard needs
136
+ // (equal to origin/main's ⇒ zero commits ahead ⇒ nothing has landed from it,
137
+ // because nothing has been written in it) and asking for it separately would
138
+ // have spawned a second subprocess over the identical ref set — in the one file
139
+ // whose header makes a promise about its git-call count.
140
+ const tipOf = new Map();
141
+ for (const line of git(['for-each-ref', '--format=%(refname:short) %(objectname)', 'refs/heads/'], cwd).split('\n')) {
142
+ const [name, sha] = line.trim().split(/\s+/);
143
+ if (name && sha) tipOf.set(name, sha);
144
+ }
145
+ const existing = new Set(tipOf.keys());
106
146
  let merged;
107
147
  try { merged = refNames(git(['branch', '--merged', MAIN_REF, '--format=%(refname:short)'], cwd)); }
108
148
  catch { merged = new Set(); } // no origin/main yet (fresh clone, no fetch) → nothing is provably stale
149
+ let mainTip = null;
150
+ try { mainTip = git(['rev-parse', MAIN_REF], cwd).trim(); }
151
+ catch { mainTip = null; } // same fail-open as `merged`: unknown ⇒ claim nothing
109
152
  const r = classifyWorktrees(worktrees, {
110
153
  branchExists: (b) => existing.has(b),
111
154
  isMerged: (b) => merged.has(b),
155
+ isUnstarted: (b) => Boolean(mainTip) && tipOf.get(b) === mainTip,
112
156
  });
113
157
  // The main checkout is always the first porcelain entry, so the husk directory is
114
158
  // derivable without a second git call.
@@ -157,7 +201,7 @@ function main() {
157
201
  console.log(` Worktrees: ${r.live.length} live, ${r.stale.length} stale, ${husks.length} husks`);
158
202
  for (const s of r.stale) console.log(` stale ${s.name} (${s.why})`);
159
203
  for (const h of husks) console.log(` husk ${h.name} (on disk, not registered)`);
160
- for (const l of r.live) console.log(` live ${l.name} [${l.branch || 'detached'}]`);
204
+ for (const l of r.live) console.log(` live ${l.name} [${l.branch || 'detached'}]${l.why ? ` (${l.why})` : ''}`);
161
205
  if (r.stale.length) console.log(' Remove with: node scripts/gds/worktree.js remove <name> (or /worktree-clean)');
162
206
  if (husks.length) console.log(' Sweep husks with: node scripts/gds/worktree.js prune-husks');
163
207
  if (r.stale.length || husks.length) process.exit(1);
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.817'; // 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();
@@ -24,6 +24,10 @@ const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
24
24
  const { attachFail } = require(path.join(ROOT, 'src', 'bongos', 'middleware', 'error-envelope.js'));
25
25
  const { classify } = require(path.join(ROOT, 'src', 'bongos', 'api-errors.js'));
26
26
  const catalog = require(path.join(ROOT, 'modules', 'government', 'catalog.js'));
27
+ // The same stripper the category guard scans with, rather than a second copy: both
28
+ // checks are answering "which text here is executable", and two answers in one tree
29
+ // is how one of them quietly stops agreeing with the other (task 1003832).
30
+ const { stripComments } = require(path.join(ROOT, 'scripts', 'gds', 'category-advisory-guard.js'));
27
31
 
28
32
  // Minimal res double capturing status + body, with res.fail() installed exactly
29
33
  // as the router installs it (mirrors tests/rank_gate.mjs).
@@ -479,29 +483,101 @@ test('a throwing isOwnershipScoped does not admit and does not crash the gate',
479
483
  assert.equal(res._status, 403);
480
484
  });
481
485
 
482
- test('no route in the tree is gated on an ownership-scoped key today', () => {
483
- // The enumeration the fix was written against, kept honest. A new gate mounted on
484
- // an own-scoped atom would now 403 at runtime; this fails at BUILD time instead,
485
- // naming the file, so nobody ships a route that can only ever deny.
486
- const owned = catalog.permissionKeys().filter((k) => catalog.isOwnershipScoped(k));
487
- const dirs = [path.join(ROOT, 'src', 'bongos', 'routes'), path.join(ROOT, 'modules')];
486
+ // The scan, with its file list and its reader INJECTED — the shape
487
+ // category-advisory-guard's scanGates() uses, and for the same reason. A scan run
488
+ // only against the live tree proves nothing about what it would REFUSE, because the
489
+ // tree is clean today by construction; and a scan that can only be run against the
490
+ // live tree cannot be shown to ignore a comment without dirtying a real file.
491
+ //
492
+ // Comments are stripped before the match because a gate is a CALL. This repo's guards
493
+ // routinely CITE the spelling they forbid — that is how the rule stays legible at the
494
+ // site it governs — and in task 1003194 exactly such a comment reds CI, on a file whose
495
+ // executable text was clean. Stripping makes a citation legal and judges only code.
496
+ function scanForOwnedGates(files, read, ownedKeys) {
497
+ // A gate is a call: requirePermission('<key>'), in any of its spellings. Compiled
498
+ // once per key rather than once per (file, key) pair.
499
+ const patterns = ownedKeys.map((key) => [
500
+ key,
501
+ new RegExp(`requirePermission\\s*\\([^)]*['"\`]${key.replace(/\./g, '\\.')}['"\`]`),
502
+ ]);
488
503
  const offenders = [];
504
+ for (const rel of files) {
505
+ const src = read(rel);
506
+ if (src == null) continue;
507
+ const code = stripComments(src);
508
+ for (const [key, re] of patterns) {
509
+ if (re.test(code)) offenders.push(`${rel} :: ${key}`);
510
+ }
511
+ }
512
+ return offenders;
513
+ }
514
+
515
+ // ROOT-relative .js paths under `dirs`, so the reader and the offender labels speak
516
+ // the same language the injected corpus below does.
517
+ function jsFilesUnder(dirs) {
518
+ const out = [];
489
519
  const walk = (dir) => {
490
520
  if (!fs.existsSync(dir)) return;
491
521
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
492
522
  const full = path.join(dir, entry.name);
493
523
  if (entry.isDirectory()) { if (entry.name !== 'node_modules') walk(full); continue; }
494
- if (!entry.name.endsWith('.js')) continue;
495
- const text = fs.readFileSync(full, 'utf8');
496
- for (const key of owned) {
497
- // A gate is a call: requirePermission('<key>'), in any of its spellings.
498
- if (new RegExp(`requirePermission\\s*\\([^)]*['"\`]${key.replace(/\./g, '\\.')}['"\`]`).test(text)) {
499
- offenders.push(`${path.relative(ROOT, full)} :: ${key}`);
500
- }
501
- }
524
+ if (entry.name.endsWith('.js')) out.push(path.relative(ROOT, full));
502
525
  }
503
526
  };
504
527
  dirs.forEach(walk);
528
+ return out;
529
+ }
530
+
531
+ test('the own-gate scan reads a citation as prose and a call as a gate', () => {
532
+ // The half the live scan below cannot assert about itself. A scanner breaks by
533
+ // quietly finding NOTHING — a stripper that over-matches blanks the source and every
534
+ // file passes, which looks exactly like a clean tree. So this pins BOTH directions in
535
+ // one assertion: the two citations must be ignored AND the two calls must still be
536
+ // caught. Dropping either half is what makes the guard a green light over nothing.
537
+ const KEY = 'task.abandon.own';
538
+ const corpus = {
539
+ 'cited-in-a-line-comment.js':
540
+ `// never mount requirePermission('${KEY}') here — it would admit unconditionally\n`
541
+ + "router.post('/a', requirePermission('task.abandon'), handler);\n",
542
+ 'cited-in-a-block-comment.js':
543
+ `/*\n * requirePermission('${KEY}') cannot answer an ownership question.\n */\n`
544
+ + "router.post('/b', requirePermission('idea.triage'), handler);\n",
545
+ 'a-real-gate.js': `router.post('/c', requirePermission('${KEY}'), handler);\n`,
546
+ // The over-strip canary: a line comment must end at its newline. If stripping ate
547
+ // the rest of the file this gate would vanish and the scan would report clean.
548
+ 'a-real-gate-below-a-comment.js':
549
+ `// this module is documented first\nrouter.post('/d', requirePermission('${KEY}'), handler);\n`,
550
+ // The blind spot that failed this task's first grade. A `//` inside an ordinary
551
+ // STRING is not a comment, and a stripper that thinks it is erases the rest of the
552
+ // line — here, the live gate sitting right after it. The guard then reports clean
553
+ // over a real violation, which reads exactly like a clean tree.
554
+ 'a-real-gate-after-a-string-holding-a-slash-slash.js':
555
+ `router.post('//legacy', requirePermission('${KEY}'), handler);\n`,
556
+ 'a-real-gate-after-a-string-holding-a-block-open.js':
557
+ `const note = '/* not a comment */';\nrouter.post('/e', requirePermission('${KEY}'), handler);\n`,
558
+ };
559
+ const found = scanForOwnedGates(Object.keys(corpus), (rel) => corpus[rel], [KEY]);
560
+ assert.deepEqual(found.sort(), [
561
+ `a-real-gate-after-a-string-holding-a-block-open.js :: ${KEY}`,
562
+ `a-real-gate-after-a-string-holding-a-slash-slash.js :: ${KEY}`,
563
+ `a-real-gate-below-a-comment.js :: ${KEY}`,
564
+ `a-real-gate.js :: ${KEY}`,
565
+ ], 'a citation is documentation; a call is a gate');
566
+ });
567
+
568
+ test('no route in the tree is gated on an ownership-scoped key today', () => {
569
+ // The enumeration the fix was written against, kept honest. A new gate mounted on
570
+ // an own-scoped atom would now 403 at runtime; this fails at BUILD time instead,
571
+ // naming the file, so nobody ships a route that can only ever deny.
572
+ const owned = catalog.permissionKeys().filter((k) => catalog.isOwnershipScoped(k));
573
+ assert.ok(owned.length > 0, 'no ownership-scoped atoms — the scan would pass vacuously');
574
+ const files = jsFilesUnder([path.join(ROOT, 'src', 'bongos', 'routes'), path.join(ROOT, 'modules')]);
575
+ // A mis-pointed root is the other way this passes while scanning nothing, and it
576
+ // reads identically to a clean tree. Pin the corpus so the roots must still resolve.
577
+ assert.ok(files.length > 100, `expected the route corpus; scanned only ${files.length} files`);
578
+ const offenders = scanForOwnedGates(files, (rel) => {
579
+ try { return fs.readFileSync(path.join(ROOT, rel), 'utf8'); } catch { return null; }
580
+ }, owned);
505
581
  assert.deepEqual(offenders, [],
506
582
  'requirePermission cannot answer an ownership-scoped key — the handler must call ' +
507
583
  'authorizeOwned() with the row\'s owner instead');
@@ -168,6 +168,76 @@ function shouldReserveMigrationNumber(task) {
168
168
  }
169
169
  });
170
170
 
171
+ // ---- task 1003910: "schema" must be QUALIFIED to flag ------------------
172
+ //
173
+ // The live false positive (task 1003902, flagged though it alters no table) was
174
+ // NOT caused by the `--json-schema` flag the bug report blamed — that compound
175
+ // was already stripped. It was a bare back-reference later in the same
176
+ // paragraph. The first case below is that exact sentence, so a regression
177
+ // reintroducing a bare-"schema" rule fails on the wording that actually shipped
178
+ // the bug rather than on a paraphrase of it.
179
+ await test('a BARE "schema" no longer flags — the live task 1003902 wording (task 1003910)', () => {
180
+ const falsePositives = [
181
+ '`result` is EMPTY when a schema is in play, which reads as a failure if you parse the wrong field',
182
+ 'Require a structured verdict via --json-schema',
183
+ 'a JSON Schema for the payload',
184
+ 'see the schema.org markup',
185
+ 'the --output-schema flag takes a path',
186
+ 'the worker returns a schema',
187
+ ];
188
+ for (const text of falsePositives) {
189
+ assert.equal(detectsMigration(text), false, `must NOT flag: ${JSON.stringify(text)}`);
190
+ }
191
+ });
192
+
193
+ await test('a DB noun or a change verb is what makes "schema" count (task 1003910)', () => {
194
+ const truePositives = [
195
+ 'DB schema change',
196
+ 'the database schema for tasks',
197
+ 'table-schema drift',
198
+ 'schema bump for the counter',
199
+ 'change the schema of tasks',
200
+ 'altering the task schema',
201
+ ];
202
+ for (const text of truePositives) {
203
+ assert.equal(detectsMigration(text), true, `must flag: ${JSON.stringify(text)}`);
204
+ }
205
+ });
206
+
207
+ // The direction of the failure is the point: an unrecognised phrasing must fall
208
+ // to NOT-flagged. A false positive reserves a migration number that is never
209
+ // used (task 1002614 burned 213), floors the task at Metic (ADR 0270) and halts
210
+ // an unattended chain; a missed one is a PATCH, the same correction a false one
211
+ // always needed.
212
+ await test('an unknown "schema" phrasing fails SAFE, not loud (task 1003910)', () => {
213
+ assert.equal(detectsMigration('the widget-schema thing nobody has named yet'), false);
214
+ assert.equal(detectsMigration('schema'), false, 'the bare noun alone decides nothing');
215
+ });
216
+
217
+ // A change VERB is not evidence on its own. The first cut of task 1003910's
218
+ // allowlist let any verb legitimise any noun, so 'update the response schema'
219
+ // flagged — the exact false-positive class task 1002648 fixed for the old
220
+ // blocklist, reintroduced by the mechanism that replaced it. The noun slot now
221
+ // excludes API/transport nouns, and these pin that it stays excluded.
222
+ await test('a change verb does NOT legitimise an API noun (task 1003910)', () => {
223
+ const falsePositives = [
224
+ 'update the response schema',
225
+ 'modify the API schema',
226
+ 'change the request schema',
227
+ 'updating the json schema',
228
+ 'extend the payload schema',
229
+ ];
230
+ for (const text of falsePositives) {
231
+ assert.equal(detectsMigration(text), false, `must NOT flag: ${JSON.stringify(text)}`);
232
+ }
233
+ });
234
+
235
+ await test('the same verbs still flag a DB-shaped or unqualified noun (task 1003910)', () => {
236
+ for (const text of ['change the schema of tasks', 'alter the table schema', 'altering the task schema', 'change the schema']) {
237
+ assert.equal(detectsMigration(text), true, `must flag: ${JSON.stringify(text)}`);
238
+ }
239
+ });
240
+
171
241
  await test('classifier still flags real schema work (regression)', () => {
172
242
  const truePositives = [
173
243
  'add a migration to backfill the marker',