@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
@@ -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
@@ -45,8 +45,50 @@ const VALID_FITS = new Set(['autonomous', 'parallel']);
45
45
  // whole-token, case-insensitive. The former scripts/gds/capture.js mirror was
46
46
  // deleted with the same task: captured ideas have no touches[] and the server
47
47
  // re-detects on create, so the CLI copy earned its keep no longer.
48
+ // task 1003910: "schema" is QUALIFIED, not bare.
49
+ //
50
+ // WHAT ACTUALLY WENT WRONG. The bug was filed as "the non-DB compound strip does
51
+ // not cover --json-schema". It does (the `json` arm below has always been there),
52
+ // and the live false positive it cites — task 1003902, flagged though it alters no
53
+ // table — was not triggered by the flag at all. It was triggered by a LATER,
54
+ // unqualified back-reference in the same paragraph: "`result` is EMPTY when a
55
+ // schema is in play". A bare noun, with no compound around it for any strip to
56
+ // catch.
57
+ //
58
+ // WHY THE SHAPE CHANGED RATHER THAN THE LIST. A blocklist of non-database
59
+ // compounds cannot be finished: every new way of saying "schema" about something
60
+ // that is not a table is another false positive, and each one costs a reserved
61
+ // migration number (task 1002614 burned 213), floors the task at Metic (ADR 0270)
62
+ // and halts an unattended chain. Enumerating `json-schema`, `schema.org` and
63
+ // `--output-schema` would have bought three phrasings and left the one in the bug
64
+ // report still flagging. So "schema" now has to be ATTACHED to a database noun or
65
+ // to a change verb to count — an allowlist, which fails the safe way: an
66
+ // unrecognised phrasing no longer flags, instead of flagging on sight.
67
+ //
68
+ // The other three keywords are untouched: `migration(s)`, `alter table` and
69
+ // `create table` are already unambiguous, and they are what catches genuine
70
+ // schema work whose prose never says the word "schema".
71
+ //
72
+ // A missed detection stays correctable by PATCH, exactly as a false one was.
73
+ const DB_SCHEMA_RE = [
74
+ // a database noun in front: "DB schema", "table-schema", "column schema"
75
+ /\b(?:db|database|table|column|postgres|pg|sql|index)[-\s]+schemas?\b/.source,
76
+ // a change noun behind: "schema change", "schema migration", "schema bump"
77
+ /\bschemas?[-\s]+(?:migrations?|changes?|bumps?|updates?|diffs?|drift)\b/.source,
78
+ // a change verb in front: "change the schema", "alter the table schema"
79
+ // The noun slot before "schema" excludes the API/transport nouns the old
80
+ // blocklist covered: a change verb is not evidence on its own, and "update the
81
+ // response schema" is the task 1002648 false-positive class wearing a verb.
82
+ // Anything else ("the task schema", "the tasks schema") still counts.
83
+ // The noun slot before "schema" excludes the API/transport nouns the old
84
+ // blocklist covered: a change verb is not evidence on its own, and "update the
85
+ // response schema" is the task 1002648 false-positive class wearing a verb.
86
+ // Anything else ("the task schema", "the tasks schema") still counts.
87
+ /\b(?:change|changing|alter|altering|update|updating|modify|modifying|extend|extending)\s+(?:the\s+)?(?:(?!(?:request|response|body|validation|patch|post|put|delete|json|openapi|api|error|output|input|result|payload|event)\b)\w+\s+)?schemas?\b/.source,
88
+ ].join('|');
89
+
48
90
  const MIGRATION_KEYWORD_RE =
49
- /\bmigrations?\b|\bschema\b|\balter\s+table\b|\bcreate\s+table\b/i;
91
+ new RegExp([/\bmigrations?\b/.source, DB_SCHEMA_RE, /\balter\s+table\b/.source, /\bcreate\s+table\b/.source].join('|'), 'i');
50
92
 
51
93
  // task 1002648 (idea 1000642): compounds where the keyword is qualified by a
52
94
  // NON-database noun are refactor/API language, not schema work, and are blanked
@@ -59,8 +101,13 @@ const NON_DB_COMPOUND_RE = new RegExp(
59
101
  [
60
102
  /\b(?:gate|route|code)s?[-\s]+migrations?\b/.source,
61
103
  /\bmigrations?\s+of\s+(?:gate|route|code)s?\b/.source,
62
- /\b(?:request|response|body|validation|patch|post|put|delete|json|openapi|api|error)[-\s]+schemas?\b/.source,
63
- /:\d+[-\s]+schemas?\b/.source,
104
+ // The API-schema arms this list used to carry (`request schema`, `:751
105
+ // schema`, `json schema`) are GONE, not lost: they existed only to subtract
106
+ // from a bare-"schema" rule that no longer exists. "the PATCH schema declares
107
+ // 11 keys" has no database noun and no change verb, so it never reaches a
108
+ // strip — and neither does the phrasing they missed. Keeping them would leave
109
+ // two mechanisms answering the same question, which is how the list grew a
110
+ // gap in the first place.
64
111
  ].join('|'),
65
112
  'gi'
66
113
  );
@@ -17,7 +17,8 @@ const exec = (executor) => executor || ((text, params) => pool.query(text, param
17
17
 
18
18
  const COLUMNS = `id, slug, name, discipline, summary, contract_md,
19
19
  owner_kind, owner_builder_id, owner_project_id,
20
- visibility, sellable, created_by, created_at, updated_at`;
20
+ visibility, sellable, offered, offered_by, offered_at,
21
+ created_by, created_at, updated_at`;
21
22
 
22
23
  async function createSpeciality(row, executor) {
23
24
  const q = exec(executor);
@@ -225,8 +226,46 @@ async function taskDiscipline(taskId, executor) {
225
226
  return (r.rows[0] && r.rows[0].discipline) || null;
226
227
  }
227
228
 
229
+ // Flip the recommendation. `offered_by` and `offered_at` are written together
230
+ // with it so a suggestion always carries whose judgement it is — a builder
231
+ // deciding whether to accept a standing instruction can see who stood behind it.
232
+ // Clearing an offer clears the witness too rather than leaving a stale name on a
233
+ // recommendation nobody is making any more.
234
+ async function setOffered(id, { offered, builderId }, executor) {
235
+ const q = exec(executor);
236
+ const r = await q(
237
+ `UPDATE specialities_catalog
238
+ SET offered = $2,
239
+ offered_by = CASE WHEN $2 THEN $3::bigint ELSE NULL END,
240
+ offered_at = CASE WHEN $2 THEN now() ELSE NULL END,
241
+ updated_at = now()
242
+ WHERE id = $1
243
+ RETURNING ${COLUMNS}, offered, offered_by, offered_at`,
244
+ [id, !!offered, builderId || null],
245
+ );
246
+ return r.rows[0] || null;
247
+ }
248
+
249
+ // Everything on offer that this viewer could see at all. The RULE that decides
250
+ // what is actually suggested is specialities.offeredTo — this is the cheap
251
+ // prefilter, exactly as listVisibleTo is for the catalogue.
252
+ async function listOffered({ builderId, projectIds = [], discipline = null }, executor) {
253
+ const q = exec(executor);
254
+ const params = [builderId, projectIds.length ? projectIds : [null]];
255
+ let sql = `SELECT ${COLUMNS}, offered, offered_by, offered_at
256
+ FROM specialities_catalog
257
+ WHERE offered
258
+ AND (visibility = 'public'
259
+ OR (owner_kind = 'builder' AND owner_builder_id = $1)
260
+ OR (owner_kind = 'project' AND visibility = 'project' AND owner_project_id = ANY($2)))`;
261
+ if (discipline) { params.push(discipline); sql += ` AND discipline = $${params.length}`; }
262
+ sql += ' ORDER BY discipline ASC, name ASC';
263
+ const r = await q(sql, params);
264
+ return r.rows;
265
+ }
266
+
228
267
  module.exports = {
229
- UPDATABLE, taskDiscipline,
268
+ UPDATABLE, taskDiscipline, setOffered, listOffered,
230
269
  createSpeciality, getSpeciality, listVisibleTo, updateSpeciality,
231
270
  addDocument, listDocuments, getDocument, deleteDocument,
232
271
  linkLearning, countTraining,
@@ -0,0 +1,57 @@
1
+ -- specialities_002_offered.sql — a project OFFERS a speciality to its builders
2
+ -- (task 1003972; the micro-roles residue of task 1003856, built on ADR 0296).
3
+ --
4
+ -- WHAT THIS IS. Task 1003855 let a builder or a project OWN a speciality and set
5
+ -- who may adopt it. What it could not express is a recommendation: "this is the
6
+ -- set we suggest you start from." That is what `offered` adds, and it is one
7
+ -- column rather than a preset table because a preset IS a speciality — the list
8
+ -- is just the specialities someone chose to put their name behind.
9
+ --
10
+ -- IT OFFERS. IT DOES NOT ADOPT. This is the whole design decision and it is not a
11
+ -- convenience call. A speciality's `contract_md` is injected into every session
12
+ -- where the adopter holds a claim in that craft, and ADR 0296's consent work
13
+ -- (task 1003855's second grade) exists because that text is ANOTHER BUILDER'S
14
+ -- text arriving in this builder's session. Auto-adopting a project's presets on
15
+ -- join would hand every new builder a standing instruction they never read, which
16
+ -- is precisely the failure that grade caught — so an offer is a suggestion a
17
+ -- person accepts, every time, and there is no column here that could make it
18
+ -- otherwise.
19
+ --
20
+ -- WHY NO MEMBERSHIP LOOKUP IS NEEDED FOR THE COMMON CASE. "Project" in the
21
+ -- platform-identity sense is a HUB concept (ADR 0141 §3): a `client_id` in the
22
+ -- hub's cross-project directory, keyed by `github_id`. A normal instance is not a
23
+ -- hub and has no such rows — it simply IS the project. So an instance-level
24
+ -- recommendation is a speciality at `visibility='public'` (already "anyone on
25
+ -- this instance may adopt it") flagged `offered` — no membership resolution, no
26
+ -- optional seam, nothing to be absent. The federated case rides the same column
27
+ -- once the `project.membership` port exists: a project-owned speciality at
28
+ -- `visibility='project'` and `offered` is a suggestion to that project's members.
29
+
30
+ ALTER TABLE specialities_catalog
31
+ ADD COLUMN IF NOT EXISTS offered BOOLEAN NOT NULL DEFAULT FALSE;
32
+
33
+ -- You cannot recommend something nobody may adopt. Same shape as the `sellable`
34
+ -- constraint beside it, and for the same reason: a private-but-offered row is
35
+ -- incoherent rather than merely useless, and the DB is where that is cheapest to
36
+ -- refuse. Named for what it protects.
37
+ ALTER TABLE specialities_catalog
38
+ DROP CONSTRAINT IF EXISTS specialities_catalog_offered_needs_visibility;
39
+ ALTER TABLE specialities_catalog
40
+ ADD CONSTRAINT specialities_catalog_offered_needs_visibility
41
+ CHECK (offered = FALSE OR visibility <> 'private');
42
+
43
+ -- The suggestions read: "what does this instance/project recommend to me". Partial
44
+ -- because the offered set is by nature small and the unoffered majority is never
45
+ -- scanned by it.
46
+ CREATE INDEX IF NOT EXISTS specialities_catalog_offered_idx
47
+ ON specialities_catalog (discipline) WHERE offered;
48
+
49
+ -- WHO OFFERED IT, and when. Recorded because a recommendation carries someone's
50
+ -- name by definition — the settings surface says who suggested it, so a builder
51
+ -- deciding whether to accept a standing instruction can see whose judgement they
52
+ -- are taking. Nullable: a row offered before this column existed has no witness,
53
+ -- and inventing one would be worse than admitting it.
54
+ ALTER TABLE specialities_catalog
55
+ ADD COLUMN IF NOT EXISTS offered_by BIGINT REFERENCES builders(id) ON DELETE SET NULL;
56
+ ALTER TABLE specialities_catalog
57
+ ADD COLUMN IF NOT EXISTS offered_at TIMESTAMPTZ;
@@ -246,6 +246,22 @@ module.exports = function buildSpecialitiesRouter() {
246
246
  }
247
247
  }));
248
248
 
249
+ // GET /specialities/offered — the suggestions for THIS builder. Mounted before
250
+ // '/specialities/:id' would otherwise capture 'offered' as an id.
251
+ router.get('/specialities/offered', api.requireBuilder, asyncHandler('GET /specialities/offered', async (req, res) => {
252
+ const builder = req.builder;
253
+ const projectIds = await viewerProjectIds(builder);
254
+ const [rows, adoptions] = await Promise.all([
255
+ db.listOffered({ builderId: builder.id, projectIds, discipline: req.query.discipline || null }),
256
+ db.listAdoptions(builder.id),
257
+ ]);
258
+ // The SQL is the prefilter; offeredTo is the rule — it drops what this
259
+ // builder already holds and what would collide with a craft they have already
260
+ // chosen for. Nothing here adopts anything.
261
+ const suggestions = S.offeredTo(rows, { builderId: builder.id, projectIds }, adoptions);
262
+ res.json({ suggestions });
263
+ }));
264
+
249
265
  router.get('/specialities/:id', api.requireBuilder, asyncHandler('GET /specialities/:id', async (req, res) => {
250
266
  const id = parseId(req, res, { code: 'bad_id' });
251
267
  if (id === null) return;
@@ -371,6 +387,54 @@ module.exports = function buildSpecialitiesRouter() {
371
387
  res.json({ ok: true, was_active: dropped });
372
388
  }));
373
389
 
390
+ // ── offering: a project puts its name behind a speciality ─────────────────
391
+ //
392
+ // OFFERING IS CURATION, NOT OWNERSHIP, so it is gated differently from every
393
+ // other write here. Editing your own speciality is yours; recommending one to
394
+ // everybody on the instance is a claim on their attention and their sessions,
395
+ // which is the same act `project.curate` already governs for featuring a
396
+ // project (metic floor). An owner may therefore edit a speciality they cannot
397
+ // offer, and a curator may offer one they do not own — which is correct: the
398
+ // two decisions are about different things.
399
+ router.post('/specialities/:id/offer', api.requireBuilder, api.requirePermission('project.curate'),
400
+ asyncHandler('POST /specialities/:id/offer', async (req, res) => {
401
+ const id = parseId(req, res, { code: 'bad_id' });
402
+ if (id === null) return;
403
+ const s = await db.getSpeciality(id);
404
+ // visibleTo, like every sibling route. project.curate says "you may
405
+ // curate", NOT "you may curate this row" — without this a curator could
406
+ // offer a project-owned speciality belonging to a project they are not in,
407
+ // and the 403-vs-404 shape would confirm its existence besides.
408
+ if (!s || !(await visibleTo(s, req.builder))) return res.fail('not_found', 404);
409
+ // The DB refuses this too; refusing here as well turns a constraint
410
+ // violation into a sentence that says what to do about it.
411
+ if (s.visibility === 'private') {
412
+ return res.fail('offer_needs_visibility', {
413
+ status: 400,
414
+ message: 'A private speciality cannot be offered — nobody it is offered to could adopt it. Share it first.',
415
+ });
416
+ }
417
+ res.json({ speciality: await db.setOffered(id, { offered: true, builderId: req.builder.id }) });
418
+ }));
419
+
420
+ router.post('/specialities/:id/unoffer', api.requireBuilder, api.requirePermission('project.curate'),
421
+ asyncHandler('POST /specialities/:id/unoffer', async (req, res) => {
422
+ const id = parseId(req, res, { code: 'bad_id' });
423
+ if (id === null) return;
424
+ const s = await db.getSpeciality(id);
425
+ if (!s || !(await visibleTo(s, req.builder))) return res.fail('not_found', 404);
426
+ // Withdrawing a recommendation does NOT un-adopt anybody. A builder who
427
+ // accepted it chose it; taking that away silently would be the auto-adopt
428
+ // mistake in reverse.
429
+ //
430
+ // The actor is passed even though the row stores it only while an offer
431
+ // stands (setOffered nulls the witness on withdrawal, because a
432
+ // recommendation nobody is making has nobody behind it). WHO withdrew it is
433
+ // not lost: every authenticated POST through this router is recorded by
434
+ // src/bongos/middleware/audit.js with the builder, the body and the status.
435
+ res.json({ speciality: await db.setOffered(id, { offered: false, builderId: req.builder.id }) });
436
+ }));
437
+
374
438
  router.get('/me/specialities', api.requireBuilder, asyncHandler('GET /me/specialities', async (req, res) => {
375
439
  res.json({ adoptions: await db.listAdoptions(req.builder.id) });
376
440
  }));
@@ -195,4 +195,33 @@ function describeForApi(active, { documentCount = 0, learningCount = 0, selfAuth
195
195
  return { inject: contract.trim().length > 0, contract, speciality_id: active.id, slug: active.slug };
196
196
  }
197
197
 
198
- module.exports = { VISIBILITIES, LIMITS, slugify, validateDraft, canAdopt, describeForApi };
198
+ // WHAT A BUILDER IS OFFERED. The suggestions half of task 1003972: a speciality
199
+ // someone with project.curate has put the project's name behind.
200
+ //
201
+ // IT OFFERS, IT DOES NOT ADOPT — and this function is where that is enforced in
202
+ // the read, not merely intended. It returns suggestions; nothing here adopts, and
203
+ // the caller has no adoption path that does not go through the builder's own
204
+ // explicit POST. A speciality's contract is injected into every session in its
205
+ // craft, so handing someone a standing instruction they never accepted is the
206
+ // exact failure ADR 0296's consent work exists to prevent.
207
+ //
208
+ // Three filters, in the order that makes each one cheap:
209
+ // 1. offered — somebody recommended it
210
+ // 2. canAdopt — and this viewer may actually take it (the SAME rule the
211
+ // catalogue uses; never a second, looser copy)
212
+ // 3. not already — a speciality you already hold is not a suggestion, and one
213
+ // in a craft you already have an ACTIVE speciality for is a
214
+ // conflict rather than an offer (the adoption index allows one
215
+ // active per craft), so it is surfaced as neither.
216
+ function offeredTo(specialities, viewer, adoptions = []) {
217
+ const held = new Set((adoptions || []).map((a) => String(a.id ?? a.speciality_id)));
218
+ const activeCrafts = new Set((adoptions || []).filter((a) => a.active).map((a) => a.discipline));
219
+ return (specialities || []).filter((s) => {
220
+ if (!s || !s.offered) return false;
221
+ if (!canAdopt(s, viewer)) return false;
222
+ if (held.has(String(s.id))) return false;
223
+ return !activeCrafts.has(s.discipline);
224
+ });
225
+ }
226
+
227
+ module.exports = { VISIBILITIES, LIMITS, slugify, validateDraft, canAdopt, describeForApi, offeredTo };
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.817",
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.817",
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.817",
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
@@ -71,15 +71,56 @@ const FEATURE_ANCHORS = [
71
71
  // table name needs no boundary — nothing else is called that.
72
72
  const CATEGORY_RE = /\bcategor(y|ies|ised|ized|isation|ization)\b|\bcategory_id\b|work_categories/i;
73
73
 
74
- // Strip // line comments and /* */ blocks so a citation of the rule is legal and
75
- // only executable text is judged. Crude on purpose: it errs toward keeping text
76
- // (a `//` inside a string literal turns the rest of that line into a "comment"),
77
- // and erring toward keeping text would make the check LOUDER, not quieter — which
78
- // is the safe direction for a guard.
74
+ // Strip // line comments and /* */ blocks so a citation of a rule is legal and only
75
+ // executable text is judged.
76
+ //
77
+ // String-aware, and that is the whole point rather than a refinement. The direction
78
+ // a stripper errs in is a safety property: keeping a comment only re-raises a false
79
+ // alarm, which is LOUD and gets fixed, while dropping real code makes a guard report
80
+ // clean over a live violation, which is indistinguishable from a tree that is
81
+ // actually clean. So the scanner must never remove text it is not sure about.
82
+ //
83
+ // The previous regex pair claimed to err that way and did the opposite. `//` inside
84
+ // an ordinary string literal — a protocol-relative URL, a path fragment — matched the
85
+ // line-comment rule and erased the rest of the line, real code included:
86
+ //
87
+ // router.post('//legacy', requirePermission('task.abandon.own'), handler);
88
+ //
89
+ // stripped to `router.post('`, so the guard scanning for that very call found nothing
90
+ // (task 1003832; the equivalent hole in the ownership-scope scan in
91
+ // tests/government_require_permission.mjs). Tracking quotes fixes it at the root, for
92
+ // both callers, and makes the erring-toward-keeping promise above actually true.
93
+ //
94
+ // Unterminated constructs deliberately keep their text rather than swallowing to EOF.
95
+ // A regex literal is not tracked: an unescaped `//` cannot appear in one (it would be
96
+ // an empty regex), so the only way in is a `/`-delimited literal containing an escaped
97
+ // pair, which reads as escaped here too.
79
98
  function stripComments(src) {
80
- return src
81
- .replace(/\/\*[\s\S]*?\*\//g, ' ')
82
- .replace(/(^|[^:])\/\/[^\n]*/g, '$1');
99
+ let out = '';
100
+ let quote = null; // ' " or ` — inside a string literal, nothing is a comment
101
+ for (let i = 0; i < src.length;) {
102
+ const c = src[i];
103
+ const d = src[i + 1];
104
+ if (quote) {
105
+ if (c === '\\') { out += c + (d ?? ''); i += 2; continue; }
106
+ if (c === quote) quote = null;
107
+ out += c; i += 1; continue;
108
+ }
109
+ if (c === '"' || c === "'" || c === '`') { quote = c; out += c; i += 1; continue; }
110
+ if (c === '/' && d === '/') {
111
+ while (i < src.length && src[i] !== '\n') i += 1; // stop AT the newline, never past it
112
+ continue;
113
+ }
114
+ if (c === '/' && d === '*') {
115
+ const end = src.indexOf('*/', i + 2);
116
+ if (end === -1) { out += src.slice(i); break; } // unterminated: keep it
117
+ out += ' ';
118
+ i = end + 2;
119
+ continue;
120
+ }
121
+ out += c; i += 1;
122
+ }
123
+ return out;
83
124
  }
84
125
 
85
126
  function readFromDisk(rel) {