@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.
- package/.bongos-core.json +50 -40
- package/.claude/skills/worktree-clean/SKILL.md +3 -2
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +9 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +178 -3
- package/docs/api-reference.md +5 -2
- package/docs/copy-inventory.md +41 -38
- package/docs/copy-registry.json +67 -40
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
- package/modules/government/catalog.js +1 -1
- package/modules/hall-ui/public/settings-specialities.js +56 -2
- package/modules/hall-ui/public/settings.html +1 -0
- package/modules/lifecycle/db-goals.js +36 -0
- package/modules/lifecycle/db.js +2 -0
- package/modules/lifecycle/goal-suggest.js +75 -9
- package/modules/lifecycle/routes/task-route-helpers.js +50 -3
- package/modules/specialities/db.js +41 -2
- package/modules/specialities/migrations/specialities_002_offered.sql +57 -0
- package/modules/specialities/routes/specialities.js +64 -0
- package/modules/specialities/specialities.js +30 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/capture.js +43 -8
- package/scripts/gds/category-advisory-guard.js +49 -8
- package/scripts/gds/stale-worktrees.js +50 -6
- package/src/module-api.js +1 -1
- package/tests/capture_goal_routing.mjs +65 -1
- package/tests/goal_suggest.mjs +148 -1
- package/tests/government_require_permission.mjs +90 -14
- package/tests/migration_allocation.mjs +70 -0
- package/tests/speciality_offered.mjs +212 -0
- 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
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
63
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/scripts/gds/capture.js
CHANGED
|
@@ -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
|
|
54
|
-
//
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
// and
|
|
78
|
-
// is
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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) {
|