@bongos/core 1.19.816 → 1.19.818

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 (37) hide show
  1. package/.bongos-core.json +56 -41
  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/adr/0298-the-reader-lens-is-advisory-and-waits-on-a-voice-guide.md +121 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +178 -3
  11. package/docs/api-reference.md +5 -2
  12. package/docs/copy-inventory.md +41 -38
  13. package/docs/copy-registry.json +67 -40
  14. package/docs/module-api-changelog.md +4 -0
  15. package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
  16. package/modules/government/catalog.js +1 -1
  17. package/modules/hall-ui/public/settings-specialities.js +56 -2
  18. package/modules/hall-ui/public/settings.html +1 -0
  19. package/modules/lifecycle/cascade.js +18 -5
  20. package/modules/lifecycle/routes/task-route-helpers.js +50 -3
  21. package/modules/lifecycle/tests/cascade.mjs +11 -1
  22. package/modules/specialities/db.js +41 -2
  23. package/modules/specialities/migrations/specialities_002_offered.sql +57 -0
  24. package/modules/specialities/routes/specialities.js +64 -0
  25. package/modules/specialities/specialities.js +30 -1
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/scripts/gds/category-advisory-guard.js +49 -8
  29. package/scripts/gds/linkify-refs.js +9 -1
  30. package/scripts/gds/run-unit-tests.js +8 -1
  31. package/scripts/gds/stale-worktrees.js +50 -6
  32. package/src/module-api.js +1 -1
  33. package/tests/government_require_permission.mjs +90 -14
  34. package/tests/linkify_refs.mjs +17 -0
  35. package/tests/migration_allocation.mjs +70 -0
  36. package/tests/speciality_offered.mjs +212 -0
  37. package/tests/stale_worktrees.mjs +54 -0
@@ -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',
@@ -0,0 +1,212 @@
1
+ // tests/speciality_offered.mjs — a project puts its name behind a speciality, and
2
+ // a builder decides whether to take it (task 1003972).
3
+ //
4
+ // THE ONE RULE EVERYTHING HERE PROTECTS: it OFFERS, it does not ADOPT. A
5
+ // speciality's contract_md is injected into every session where the adopter holds
6
+ // a claim in that craft, and ADR 0296's consent work exists because that text is
7
+ // another builder's text arriving in this builder's session. Auto-adopting a
8
+ // project's presets would hand every new builder a standing instruction they never
9
+ // read — the same failure, arriving by a different door.
10
+ //
11
+ // So the negative cases are the load-bearing ones, as usual: nothing here may
12
+ // return a speciality the viewer cannot adopt, and nothing may adopt on their
13
+ // behalf.
14
+ import assert from 'node:assert/strict';
15
+ import test from 'node:test';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { createRequire } from 'node:module';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
+ const S = require(path.join(ROOT, 'modules', 'specialities', 'specialities.js'));
24
+ const db = require(path.join(ROOT, 'modules', 'specialities', 'db.js'));
25
+
26
+ const ME = '1000025';
27
+ const spec = (over = {}) => ({
28
+ id: '1', name: 'Economics', discipline: 'ideator',
29
+ owner_kind: 'builder', owner_builder_id: '1000099',
30
+ visibility: 'public', offered: true, ...over,
31
+ });
32
+ const viewer = { builderId: ME, projectIds: ['p1'] };
33
+
34
+ // ---------- the rule ----------
35
+ test('an offered, adoptable speciality is suggested', () => {
36
+ assert.deepEqual(S.offeredTo([spec()], viewer, []).map((s) => s.id), ['1']);
37
+ });
38
+
39
+ test('a speciality nobody offered is NOT a suggestion, however visible', () => {
40
+ assert.deepEqual(S.offeredTo([spec({ offered: false })], viewer, []), []);
41
+ });
42
+
43
+ test('an offered speciality the viewer may not adopt is NOT suggested', () => {
44
+ // The whole point of running canAdopt here rather than trusting the query: a
45
+ // private speciality belonging to someone else must never be recommended, and a
46
+ // widened SQL filter must not be able to change that.
47
+ const theirs = spec({ visibility: 'private', owner_builder_id: '1000099' });
48
+ assert.deepEqual(S.offeredTo([theirs], viewer, []), []);
49
+ });
50
+
51
+ test('a builder-owned speciality set to "project" is not suggested to others', () => {
52
+ // It resolves to no project, so canAdopt treats it as owner-only. The offer
53
+ // flag must not smuggle it past that.
54
+ assert.deepEqual(S.offeredTo([spec({ visibility: 'project' })], viewer, []), []);
55
+ });
56
+
57
+ test('a speciality you already hold is not suggested back to you', () => {
58
+ const adoptions = [{ id: '1', discipline: 'ideator', active: true }];
59
+ assert.deepEqual(S.offeredTo([spec()], viewer, adoptions), []);
60
+ });
61
+
62
+ test('an inactive past adoption still suppresses the suggestion', () => {
63
+ // You have had it; re-suggesting something you dropped is nagging, not helping.
64
+ const adoptions = [{ id: '1', discipline: 'ideator', active: false }];
65
+ assert.deepEqual(S.offeredTo([spec()], viewer, adoptions), []);
66
+ });
67
+
68
+ test('a craft you already have an ACTIVE speciality in yields no suggestion', () => {
69
+ // One active speciality per craft is a DB constraint (the partial unique index),
70
+ // so offering a second in the same craft is offering something that would be
71
+ // refused. Surfacing it as neither is the honest answer.
72
+ const adoptions = [{ id: '77', discipline: 'ideator', active: true }];
73
+ assert.deepEqual(S.offeredTo([spec()], viewer, adoptions), []);
74
+ });
75
+
76
+ test('a different craft is still suggested while one craft is taken', () => {
77
+ const adoptions = [{ id: '77', discipline: 'artist', active: true }];
78
+ assert.deepEqual(S.offeredTo([spec()], viewer, adoptions).map((s) => s.id), ['1']);
79
+ });
80
+
81
+ test('offeredTo never adopts, and never mutates what it was given', () => {
82
+ const rows = [spec()];
83
+ const before = JSON.stringify(rows);
84
+ const adoptions = [];
85
+ S.offeredTo(rows, viewer, adoptions);
86
+ assert.equal(JSON.stringify(rows), before, 'the catalogue is not rewritten');
87
+ assert.deepEqual(adoptions, [], 'nothing was adopted on the builder\'s behalf');
88
+ });
89
+
90
+ test('missing inputs yield no suggestions rather than throwing', () => {
91
+ assert.deepEqual(S.offeredTo(null, viewer), []);
92
+ assert.deepEqual(S.offeredTo([spec()], null), []);
93
+ assert.deepEqual(S.offeredTo([spec()], viewer, null).map((s) => s.id), ['1']);
94
+ });
95
+
96
+ // ---------- the queries ----------
97
+ function fake(results = []) {
98
+ const calls = [];
99
+ const queue = [...results];
100
+ const exec = async (text, params) => {
101
+ calls.push({ sql: text.replace(/\s+/g, ' ').trim(), params });
102
+ return queue.shift() || { rows: [], rowCount: 0 };
103
+ };
104
+ exec.calls = calls;
105
+ return exec;
106
+ }
107
+
108
+ test('offering records WHO offered it and when', () => {
109
+ // A recommendation carries someone's name by definition — the surface says who
110
+ // suggested it, so a builder accepting a standing instruction can see whose
111
+ // judgement they are taking.
112
+ const q = fake([{ rows: [{ id: '1' }], rowCount: 1 }]);
113
+ return db.setOffered('1', { offered: true, builderId: ME }, q).then(() => {
114
+ assert.match(q.calls[0].sql, /offered = \$2/);
115
+ assert.match(q.calls[0].sql, /offered_by = CASE WHEN \$2 THEN \$3::bigint ELSE NULL END/);
116
+ assert.match(q.calls[0].sql, /offered_at = CASE WHEN \$2 THEN now\(\) ELSE NULL END/);
117
+ assert.deepEqual(q.calls[0].params, ['1', true, ME]);
118
+ });
119
+ });
120
+
121
+ test('withdrawing an offer clears the witness rather than leaving a stale name', async () => {
122
+ const q = fake([{ rows: [{ id: '1' }], rowCount: 1 }]);
123
+ await db.setOffered('1', { offered: false, builderId: null }, q);
124
+ assert.deepEqual(q.calls[0].params, ['1', false, null]);
125
+ });
126
+
127
+ test('listOffered filters on the flag AND on visibility, never the flag alone', async () => {
128
+ const q = fake([{ rows: [], rowCount: 0 }]);
129
+ await db.listOffered({ builderId: ME, projectIds: ['p1'] }, q);
130
+ const sql = q.calls[0].sql;
131
+ assert.match(sql, /WHERE offered/);
132
+ assert.match(sql, /visibility = 'public'/);
133
+ assert.match(sql, /owner_builder_id = \$1/);
134
+ });
135
+
136
+ // ---------- the wiring, which fails loudly only if someone checks ----------
137
+ const src = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
138
+
139
+ test('GET /specialities/offered is registered BEFORE /specialities/:id', () => {
140
+ // Express matches in registration order. Registered after, the :id route
141
+ // captures "offered" as an id — which is exactly what happened when this was
142
+ // first written, under a comment claiming the opposite.
143
+ const routes = src('modules/specialities/routes/specialities.js');
144
+ const offered = routes.indexOf("router.get('/specialities/offered'");
145
+ const byId = routes.indexOf("router.get('/specialities/:id'");
146
+ assert.ok(offered > 0 && byId > 0, 'both routes exist');
147
+ assert.ok(offered < byId, 'the literal path must be registered before the parameterised one');
148
+ });
149
+
150
+ test('offering is gated on project.curate, not on ownership', () => {
151
+ // Editing your own speciality is yours; recommending one to everybody is a
152
+ // claim on their attention and their sessions. Different act, different gate.
153
+ const routes = src('modules/specialities/routes/specialities.js');
154
+ for (const verb of ['offer', 'unoffer']) {
155
+ const at = routes.indexOf(`router.post('/specialities/:id/${verb}'`);
156
+ assert.ok(at > 0, `${verb} route exists`);
157
+ const decl = routes.slice(at, at + 220);
158
+ assert.match(decl, /requirePermission\('project\.curate'\)/, `${verb} must carry the curation gate`);
159
+ }
160
+ });
161
+
162
+ test('EVERY :id route checks visibleTo — including the curation ones', () => {
163
+ // The finding this test exists for: offer/unoffer read a speciality by id and
164
+ // skipped visibleTo, which every sibling performed. project.curate says "you
165
+ // may curate", not "you may curate THIS row" — so a curator could offer a
166
+ // project-owned speciality belonging to a project they are not in, and the
167
+ // 403-vs-404 shape confirmed its existence besides.
168
+ //
169
+ // DERIVED from the source rather than listed, so a route added later is covered
170
+ // without anyone remembering to extend this test.
171
+ const routes = src('modules/specialities/routes/specialities.js');
172
+ const decls = [...routes.matchAll(/router\.(get|post|patch|delete)\('(\/specialities\/:id[^']*)'/g)];
173
+ assert.ok(decls.length >= 6, `expected the :id routes to be found, saw ${decls.length}`);
174
+
175
+ let checked = 0;
176
+ for (const m of decls) {
177
+ const start = m.index;
178
+ // The handler body runs to the next router. registration, or to the end.
179
+ const nextAt = routes.indexOf('router.', start + 10);
180
+ const body = routes.slice(start, nextAt > 0 ? nextAt : routes.length);
181
+
182
+ // The rule applies to routes that READ the row. POST /:id/abandon does not:
183
+ // it deactivates the CALLER'S OWN adoption via db.abandon(req.builder.id, id),
184
+ // so the row is scoped by the session rather than by the path — a speciality
185
+ // you cannot see is one you never adopted, and the query matches nothing.
186
+ if (!/db\.getSpeciality\(/.test(body)) {
187
+ assert.match(body, /req\.builder\.id/,
188
+ `${m[1].toUpperCase()} ${m[2]} neither reads the speciality nor scopes to the caller — it is scoped to nobody`);
189
+ continue;
190
+ }
191
+ checked++;
192
+ assert.match(body, /visibleTo\(s, req\.builder\)/,
193
+ `${m[1].toUpperCase()} ${m[2]} reads a speciality by id without asking whether the caller may see it`);
194
+ }
195
+ // Not vacuous: if a refactor stopped every route reading the row, the loop above
196
+ // would pass by skipping everything.
197
+ assert.ok(checked >= 5, `expected several row-reading routes to be checked, saw ${checked}`);
198
+ });
199
+
200
+ test('the DB refuses a private speciality being offered', () => {
201
+ const sql = src('modules/specialities/migrations/specialities_002_offered.sql');
202
+ assert.match(sql, /CHECK \(offered = FALSE OR visibility <> 'private'\)/);
203
+ });
204
+
205
+ test('nothing in the offer path adopts', () => {
206
+ // The guarantee stated at the top of this file, asserted against the source so
207
+ // a future convenience cannot quietly add it.
208
+ const routes = src('modules/specialities/routes/specialities.js');
209
+ const at = routes.indexOf("router.get('/specialities/offered'");
210
+ const handler = routes.slice(at, routes.indexOf('}));', at));
211
+ assert.doesNotMatch(handler, /db\.adopt\(/, 'the suggestions read must never adopt');
212
+ });
@@ -2,6 +2,7 @@
2
2
  // facts (task 1003365): no repo, no API, just the porcelain text + two predicates.
3
3
  import { test } from 'node:test';
4
4
  import assert from 'node:assert/strict';
5
+ import { readFileSync } from 'node:fs';
5
6
  import { parseWorktreeList, classifyWorktrees, classifyHusks, lineFor } from '../scripts/gds/stale-worktrees.js';
6
7
 
7
8
  const PORCELAIN = [
@@ -120,3 +121,56 @@ test('--line reports both leaks in one line when both exist', () => {
120
121
  assert.match(line, /Stale worktrees: 1 /);
121
122
  assert.match(line, /Worktree husks: 1 /);
122
123
  });
124
+
125
+ // ---- task 1003912: an UNSTARTED claim worktree is live, not stale ----------
126
+ //
127
+ // `worktree.js add` branches off origin/main and commits nothing, so until the
128
+ // builder's first commit the branch IS origin/main — and therefore trivially an
129
+ // ancestor of it. The classifier called that "merged into main", and
130
+ // /worktree-clean removes every name on the stale list, so a cleanup run inside
131
+ // that window would take a live claim's tree. Observed live 2026-09-13 (task
132
+ // 1002594). `worktree.js remove` refusing a DIRTY tree does not cover this: a
133
+ // worktree with no commits yet is precisely the one that is still clean.
134
+
135
+ const MAIN_WT = { path: 'C:/repo', branch: 'main', isMain: true };
136
+ const CLAIM_WT = { path: 'C:/repo/.claude/worktrees/task-1002594', branch: 'task-1002594', isMain: false };
137
+
138
+ test('a branch sitting exactly at origin/main is LIVE, not stale (task 1003912)', () => {
139
+ const r = classifyWorktrees([MAIN_WT, CLAIM_WT], {
140
+ branchExists: () => true,
141
+ isMerged: () => true, // it genuinely IS an ancestor — that is the trap
142
+ isUnstarted: (b) => b === 'task-1002594',
143
+ });
144
+ assert.deepEqual(r.stale, [], 'a claim worktree with no commits must never be offered for deletion');
145
+ assert.equal(r.live.length, 1);
146
+ assert.match(r.live[0].why, /unstarted/, 'and the report should say why it is being spared');
147
+ });
148
+
149
+ test('unstarted is checked BEFORE merged — every unstarted branch is also "merged"', () => {
150
+ // If the order flipped the guard would be unreachable, and every other case here
151
+ // would still be green.
152
+ const r = classifyWorktrees([MAIN_WT, CLAIM_WT], {
153
+ branchExists: () => true, isMerged: () => true, isUnstarted: () => true,
154
+ });
155
+ assert.equal(r.stale.length, 0);
156
+ });
157
+
158
+ test('a branch that really did land is still stale — the guard narrows nothing else', () => {
159
+ const r = classifyWorktrees([MAIN_WT, CLAIM_WT], {
160
+ branchExists: () => true, isMerged: () => true, isUnstarted: () => false,
161
+ });
162
+ assert.equal(r.stale.length, 1, 'work that landed must still be cleaned up');
163
+ assert.equal(r.stale[0].why, 'merged into main');
164
+ });
165
+
166
+ test('a caller supplying no isUnstarted still classifies (older callers keep working)', () => {
167
+ const r = classifyWorktrees([MAIN_WT, CLAIM_WT], { branchExists: () => true, isMerged: () => true });
168
+ assert.equal(r.stale.length, 1);
169
+ });
170
+
171
+ test('SKILL.md no longer promises the current claim is safe by construction (task 1003912)', () => {
172
+ const md = readFileSync(new URL('../.claude/skills/worktree-clean/SKILL.md', import.meta.url), 'utf8');
173
+ assert.ok(!/never stale by construction/.test(md),
174
+ 'that sentence is what would stop an agent double-checking a live claim it was about to delete');
175
+ assert.match(md, /ask before removing it/, 'and it must say what to do instead');
176
+ });