@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.
- package/.bongos-core.json +56 -41
- 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/adr/0298-the-reader-lens-is-advisory-and-waits-on-a-voice-guide.md +121 -0
- package/docs/adr/README.md +1 -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/cascade.js +18 -5
- package/modules/lifecycle/routes/task-route-helpers.js +50 -3
- package/modules/lifecycle/tests/cascade.mjs +11 -1
- 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/category-advisory-guard.js +49 -8
- package/scripts/gds/linkify-refs.js +9 -1
- package/scripts/gds/run-unit-tests.js +8 -1
- package/scripts/gds/stale-worktrees.js +50 -6
- package/src/module-api.js +1 -1
- package/tests/government_require_permission.mjs +90 -14
- package/tests/linkify_refs.mjs +17 -0
- package/tests/migration_allocation.mjs +70 -0
- package/tests/speciality_offered.mjs +212 -0
- package/tests/stale_worktrees.mjs +54 -0
|
@@ -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.818",
|
|
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.818",
|
|
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.818",
|
|
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",
|
|
@@ -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) {
|
|
@@ -115,6 +115,14 @@ const { API_BASE } = require('./cli-lib');
|
|
|
115
115
|
// the root CLAUDE.md/README.md plus all of docs/** (session-logs included). A
|
|
116
116
|
// "GENERATED FILE" marker means the bare refs reappear on the next regen, so
|
|
117
117
|
// the generator — not its output — is where those get fixed; skip them here.
|
|
118
|
+
// PATHS COME BACK POSIX-STYLE, on every platform (task 1003935). path.join gives
|
|
119
|
+
// OS-native separators, so on Windows this used to return 'docs\\adr\\0001-….md'.
|
|
120
|
+
// Three things read these paths and only one of them tolerates that: fs accepts
|
|
121
|
+
// either separator on Windows, but the --check report PRINTS them (so CI output
|
|
122
|
+
// differed by platform for identical content) and the test that pins recursion
|
|
123
|
+
// into docs/ compares against 'docs/' — which is how a permanently-red case hid
|
|
124
|
+
// for months in a file no lane ran. Normalising here, at the one boundary that
|
|
125
|
+
// produces them, is cheaper than teaching every consumer to be separator-aware.
|
|
118
126
|
function discoverDocs() {
|
|
119
127
|
const out = [];
|
|
120
128
|
const walk = (rel) => {
|
|
@@ -123,7 +131,7 @@ function discoverDocs() {
|
|
|
123
131
|
if (fs.statSync(abs).isDirectory()) {
|
|
124
132
|
for (const name of fs.readdirSync(abs).sort()) walk(path.join(rel, name));
|
|
125
133
|
} else if (rel.endsWith('.md')) {
|
|
126
|
-
out.push(rel);
|
|
134
|
+
out.push(rel.split(path.sep).join('/'));
|
|
127
135
|
}
|
|
128
136
|
};
|
|
129
137
|
for (const root of ['CLAUDE.md', 'README.md', 'docs']) walk(root);
|
|
@@ -131,7 +131,14 @@ const INTEGRATION = new Set([
|
|
|
131
131
|
// the --integration lane never saw them either, because it runs only this set. A
|
|
132
132
|
// test that cannot fail in either lane is a file that looks like a gate.
|
|
133
133
|
'handle_machinery', 'hub_profile_leak', 'profile_fields',
|
|
134
|
-
'linkify_refs'
|
|
134
|
+
// task 1003935: 'linkify_refs' was swept in with the three above without the
|
|
135
|
+
// no-DB check they were being fixed for. It touches no Postgres at all — pure
|
|
136
|
+
// string work plus a filesystem walk — so the DB-free lane skipped it and the
|
|
137
|
+
// --integration lane, which runs only this set, never had a reason to. It ran in
|
|
138
|
+
// NEITHER lane, which is the exact failure the comment above describes, and it
|
|
139
|
+
// is why one permanently-red Windows case sat unnoticed. It is now in the unit
|
|
140
|
+
// gate, where it is the only coverage linkifyText has.
|
|
141
|
+
'memory_isolation',
|
|
135
142
|
'memory_pull', 'needs_rebase', 'parallel-load', 'profile_rollup_consent',
|
|
136
143
|
// task 1003145: whether the PLANNER uses the migration 225 index for the recall
|
|
137
144
|
// freshness probe. The DB-free sibling (recall_corpus_health) pins the query
|
|
@@ -13,6 +13,28 @@
|
|
|
13
13
|
// deleted it). Both are pure git facts, so this runs in well under a second at
|
|
14
14
|
// SessionStart. The primary checkout is never listed.
|
|
15
15
|
//
|
|
16
|
+
// THE THIRD LEAK — UNSTARTED CLAIM TREES (task 1003912). "Ancestor of origin/main"
|
|
17
|
+
// is also true of a branch that is origin/main, which is exactly what
|
|
18
|
+
// `worktree.js add` leaves behind: it branches off origin/main and commits
|
|
19
|
+
// nothing. So between `worktree.js add` and the builder's FIRST COMMIT, a live
|
|
20
|
+
// claim's worktree was reported as "merged into main". Observed 2026-09-13: the
|
|
21
|
+
// session working task 1002594 saw its own tree listed as stale. The classifier
|
|
22
|
+
// had the git fact right and the intent wrong — nothing had landed, nothing had
|
|
23
|
+
// even started.
|
|
24
|
+
//
|
|
25
|
+
// That window is small and the consequence is not: /worktree-clean removes every
|
|
26
|
+
// name on the stale list, so a cleanup run by another session (or the SessionStart
|
|
27
|
+
// nag) inside it would take a live claim's tree. `worktree.js remove` refuses a
|
|
28
|
+
// dirty tree, which bounds the damage — but a claim worktree that is still empty
|
|
29
|
+
// is precisely the one that is NOT dirty, so the guard does not cover this case.
|
|
30
|
+
//
|
|
31
|
+
// The fix stays a pure git fact, keeping the "no API call" property this header is
|
|
32
|
+
// explicit about wanting: a branch whose TIP EQUALS origin/main is zero commits
|
|
33
|
+
// ahead, so it cannot have landed anything, and is reported live-but-unstarted.
|
|
34
|
+
// The alternative — cross-checking branch names against active claims — would put
|
|
35
|
+
// a network round trip in a SessionStart probe to learn something git already
|
|
36
|
+
// knows.
|
|
37
|
+
//
|
|
16
38
|
// THE SECOND LEAK — HUSKS (task 1002594). Under OneDrive, `git worktree remove`
|
|
17
39
|
// often errors 'Permission denied' / 'Device or resource busy' but still
|
|
18
40
|
// DEREGISTERS the worktree, so `worktree.js remove` treats deregistration as
|
|
@@ -65,7 +87,12 @@ function parseWorktreeList(porcelain) {
|
|
|
65
87
|
|
|
66
88
|
// Pure classifier so the decision is unit-testable without a repo: `isMerged(branch)`
|
|
67
89
|
// and `branchExists(branch)` are the two git facts it needs.
|
|
68
|
-
|
|
90
|
+
// `isUnstarted(branch)` is the task 1003912 guard: branch tip === origin/main tip.
|
|
91
|
+
// It is checked BEFORE isMerged because every unstarted branch is also trivially
|
|
92
|
+
// "merged" — that coincidence is the whole bug, and order is what separates them.
|
|
93
|
+
// Optional, so a caller that does not supply it gets the old behaviour rather than
|
|
94
|
+
// a crash.
|
|
95
|
+
function classifyWorktrees(worktrees, { isMerged, branchExists, isUnstarted = () => false }) {
|
|
69
96
|
const stale = [];
|
|
70
97
|
const live = [];
|
|
71
98
|
for (const wt of worktrees) {
|
|
@@ -73,6 +100,7 @@ function classifyWorktrees(worktrees, { isMerged, branchExists }) {
|
|
|
73
100
|
const name = path.basename(wt.path);
|
|
74
101
|
if (!wt.branch) { live.push({ name, path: wt.path, branch: null }); continue; } // detached: not ours to judge
|
|
75
102
|
if (!branchExists(wt.branch)) stale.push({ name, path: wt.path, branch: wt.branch, why: 'branch gone' });
|
|
103
|
+
else if (isUnstarted(wt.branch)) live.push({ name, path: wt.path, branch: wt.branch, why: 'unstarted (no commits yet)' });
|
|
76
104
|
else if (isMerged(wt.branch)) stale.push({ name, path: wt.path, branch: wt.branch, why: 'merged into main' });
|
|
77
105
|
else live.push({ name, path: wt.path, branch: wt.branch });
|
|
78
106
|
}
|
|
@@ -93,22 +121,38 @@ function classifyHusks({ dirNames, worktreePaths, worktreesRoot }) {
|
|
|
93
121
|
.map((name) => ({ name, path: path.join(worktreesRoot, name) }));
|
|
94
122
|
}
|
|
95
123
|
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
124
|
+
// FOUR git calls total, whatever the worktree count: the worktree list, every local
|
|
125
|
+
// branch WITH ITS TIP (one read, both facts — task 1003912), every branch already
|
|
126
|
+
// merged into origin/main, and origin/main's own tip. Per-worktree subprocesses
|
|
127
|
+
// would be N+1 over exactly the number this probe exists to shrink.
|
|
99
128
|
function refNames(out) {
|
|
100
129
|
return new Set(String(out).split('\n').map((s) => s.trim()).filter(Boolean));
|
|
101
130
|
}
|
|
102
131
|
|
|
103
132
|
function scan(cwd) {
|
|
104
133
|
const worktrees = parseWorktreeList(git(['worktree', 'list', '--porcelain'], cwd));
|
|
105
|
-
|
|
134
|
+
// ONE read of refs/heads/, carrying both facts it is asked for: which branches
|
|
135
|
+
// exist, and where each one points. The tip is what the task 1003912 guard needs
|
|
136
|
+
// (equal to origin/main's ⇒ zero commits ahead ⇒ nothing has landed from it,
|
|
137
|
+
// because nothing has been written in it) and asking for it separately would
|
|
138
|
+
// have spawned a second subprocess over the identical ref set — in the one file
|
|
139
|
+
// whose header makes a promise about its git-call count.
|
|
140
|
+
const tipOf = new Map();
|
|
141
|
+
for (const line of git(['for-each-ref', '--format=%(refname:short) %(objectname)', 'refs/heads/'], cwd).split('\n')) {
|
|
142
|
+
const [name, sha] = line.trim().split(/\s+/);
|
|
143
|
+
if (name && sha) tipOf.set(name, sha);
|
|
144
|
+
}
|
|
145
|
+
const existing = new Set(tipOf.keys());
|
|
106
146
|
let merged;
|
|
107
147
|
try { merged = refNames(git(['branch', '--merged', MAIN_REF, '--format=%(refname:short)'], cwd)); }
|
|
108
148
|
catch { merged = new Set(); } // no origin/main yet (fresh clone, no fetch) → nothing is provably stale
|
|
149
|
+
let mainTip = null;
|
|
150
|
+
try { mainTip = git(['rev-parse', MAIN_REF], cwd).trim(); }
|
|
151
|
+
catch { mainTip = null; } // same fail-open as `merged`: unknown ⇒ claim nothing
|
|
109
152
|
const r = classifyWorktrees(worktrees, {
|
|
110
153
|
branchExists: (b) => existing.has(b),
|
|
111
154
|
isMerged: (b) => merged.has(b),
|
|
155
|
+
isUnstarted: (b) => Boolean(mainTip) && tipOf.get(b) === mainTip,
|
|
112
156
|
});
|
|
113
157
|
// The main checkout is always the first porcelain entry, so the husk directory is
|
|
114
158
|
// derivable without a second git call.
|
|
@@ -157,7 +201,7 @@ function main() {
|
|
|
157
201
|
console.log(` Worktrees: ${r.live.length} live, ${r.stale.length} stale, ${husks.length} husks`);
|
|
158
202
|
for (const s of r.stale) console.log(` stale ${s.name} (${s.why})`);
|
|
159
203
|
for (const h of husks) console.log(` husk ${h.name} (on disk, not registered)`);
|
|
160
|
-
for (const l of r.live) console.log(` live ${l.name} [${l.branch || 'detached'}]`);
|
|
204
|
+
for (const l of r.live) console.log(` live ${l.name} [${l.branch || 'detached'}]${l.why ? ` (${l.why})` : ''}`);
|
|
161
205
|
if (r.stale.length) console.log(' Remove with: node scripts/gds/worktree.js remove <name> (or /worktree-clean)');
|
|
162
206
|
if (husks.length) console.log(' Sweep husks with: node scripts/gds/worktree.js prune-husks');
|
|
163
207
|
if (r.stale.length || husks.length) process.exit(1);
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.818'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -24,6 +24,10 @@ const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
|
|
|
24
24
|
const { attachFail } = require(path.join(ROOT, 'src', 'bongos', 'middleware', 'error-envelope.js'));
|
|
25
25
|
const { classify } = require(path.join(ROOT, 'src', 'bongos', 'api-errors.js'));
|
|
26
26
|
const catalog = require(path.join(ROOT, 'modules', 'government', 'catalog.js'));
|
|
27
|
+
// The same stripper the category guard scans with, rather than a second copy: both
|
|
28
|
+
// checks are answering "which text here is executable", and two answers in one tree
|
|
29
|
+
// is how one of them quietly stops agreeing with the other (task 1003832).
|
|
30
|
+
const { stripComments } = require(path.join(ROOT, 'scripts', 'gds', 'category-advisory-guard.js'));
|
|
27
31
|
|
|
28
32
|
// Minimal res double capturing status + body, with res.fail() installed exactly
|
|
29
33
|
// as the router installs it (mirrors tests/rank_gate.mjs).
|
|
@@ -479,29 +483,101 @@ test('a throwing isOwnershipScoped does not admit and does not crash the gate',
|
|
|
479
483
|
assert.equal(res._status, 403);
|
|
480
484
|
});
|
|
481
485
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
486
|
+
// The scan, with its file list and its reader INJECTED — the shape
|
|
487
|
+
// category-advisory-guard's scanGates() uses, and for the same reason. A scan run
|
|
488
|
+
// only against the live tree proves nothing about what it would REFUSE, because the
|
|
489
|
+
// tree is clean today by construction; and a scan that can only be run against the
|
|
490
|
+
// live tree cannot be shown to ignore a comment without dirtying a real file.
|
|
491
|
+
//
|
|
492
|
+
// Comments are stripped before the match because a gate is a CALL. This repo's guards
|
|
493
|
+
// routinely CITE the spelling they forbid — that is how the rule stays legible at the
|
|
494
|
+
// site it governs — and in task 1003194 exactly such a comment reds CI, on a file whose
|
|
495
|
+
// executable text was clean. Stripping makes a citation legal and judges only code.
|
|
496
|
+
function scanForOwnedGates(files, read, ownedKeys) {
|
|
497
|
+
// A gate is a call: requirePermission('<key>'), in any of its spellings. Compiled
|
|
498
|
+
// once per key rather than once per (file, key) pair.
|
|
499
|
+
const patterns = ownedKeys.map((key) => [
|
|
500
|
+
key,
|
|
501
|
+
new RegExp(`requirePermission\\s*\\([^)]*['"\`]${key.replace(/\./g, '\\.')}['"\`]`),
|
|
502
|
+
]);
|
|
488
503
|
const offenders = [];
|
|
504
|
+
for (const rel of files) {
|
|
505
|
+
const src = read(rel);
|
|
506
|
+
if (src == null) continue;
|
|
507
|
+
const code = stripComments(src);
|
|
508
|
+
for (const [key, re] of patterns) {
|
|
509
|
+
if (re.test(code)) offenders.push(`${rel} :: ${key}`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return offenders;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ROOT-relative .js paths under `dirs`, so the reader and the offender labels speak
|
|
516
|
+
// the same language the injected corpus below does.
|
|
517
|
+
function jsFilesUnder(dirs) {
|
|
518
|
+
const out = [];
|
|
489
519
|
const walk = (dir) => {
|
|
490
520
|
if (!fs.existsSync(dir)) return;
|
|
491
521
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
492
522
|
const full = path.join(dir, entry.name);
|
|
493
523
|
if (entry.isDirectory()) { if (entry.name !== 'node_modules') walk(full); continue; }
|
|
494
|
-
if (
|
|
495
|
-
const text = fs.readFileSync(full, 'utf8');
|
|
496
|
-
for (const key of owned) {
|
|
497
|
-
// A gate is a call: requirePermission('<key>'), in any of its spellings.
|
|
498
|
-
if (new RegExp(`requirePermission\\s*\\([^)]*['"\`]${key.replace(/\./g, '\\.')}['"\`]`).test(text)) {
|
|
499
|
-
offenders.push(`${path.relative(ROOT, full)} :: ${key}`);
|
|
500
|
-
}
|
|
501
|
-
}
|
|
524
|
+
if (entry.name.endsWith('.js')) out.push(path.relative(ROOT, full));
|
|
502
525
|
}
|
|
503
526
|
};
|
|
504
527
|
dirs.forEach(walk);
|
|
528
|
+
return out;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
test('the own-gate scan reads a citation as prose and a call as a gate', () => {
|
|
532
|
+
// The half the live scan below cannot assert about itself. A scanner breaks by
|
|
533
|
+
// quietly finding NOTHING — a stripper that over-matches blanks the source and every
|
|
534
|
+
// file passes, which looks exactly like a clean tree. So this pins BOTH directions in
|
|
535
|
+
// one assertion: the two citations must be ignored AND the two calls must still be
|
|
536
|
+
// caught. Dropping either half is what makes the guard a green light over nothing.
|
|
537
|
+
const KEY = 'task.abandon.own';
|
|
538
|
+
const corpus = {
|
|
539
|
+
'cited-in-a-line-comment.js':
|
|
540
|
+
`// never mount requirePermission('${KEY}') here — it would admit unconditionally\n`
|
|
541
|
+
+ "router.post('/a', requirePermission('task.abandon'), handler);\n",
|
|
542
|
+
'cited-in-a-block-comment.js':
|
|
543
|
+
`/*\n * requirePermission('${KEY}') cannot answer an ownership question.\n */\n`
|
|
544
|
+
+ "router.post('/b', requirePermission('idea.triage'), handler);\n",
|
|
545
|
+
'a-real-gate.js': `router.post('/c', requirePermission('${KEY}'), handler);\n`,
|
|
546
|
+
// The over-strip canary: a line comment must end at its newline. If stripping ate
|
|
547
|
+
// the rest of the file this gate would vanish and the scan would report clean.
|
|
548
|
+
'a-real-gate-below-a-comment.js':
|
|
549
|
+
`// this module is documented first\nrouter.post('/d', requirePermission('${KEY}'), handler);\n`,
|
|
550
|
+
// The blind spot that failed this task's first grade. A `//` inside an ordinary
|
|
551
|
+
// STRING is not a comment, and a stripper that thinks it is erases the rest of the
|
|
552
|
+
// line — here, the live gate sitting right after it. The guard then reports clean
|
|
553
|
+
// over a real violation, which reads exactly like a clean tree.
|
|
554
|
+
'a-real-gate-after-a-string-holding-a-slash-slash.js':
|
|
555
|
+
`router.post('//legacy', requirePermission('${KEY}'), handler);\n`,
|
|
556
|
+
'a-real-gate-after-a-string-holding-a-block-open.js':
|
|
557
|
+
`const note = '/* not a comment */';\nrouter.post('/e', requirePermission('${KEY}'), handler);\n`,
|
|
558
|
+
};
|
|
559
|
+
const found = scanForOwnedGates(Object.keys(corpus), (rel) => corpus[rel], [KEY]);
|
|
560
|
+
assert.deepEqual(found.sort(), [
|
|
561
|
+
`a-real-gate-after-a-string-holding-a-block-open.js :: ${KEY}`,
|
|
562
|
+
`a-real-gate-after-a-string-holding-a-slash-slash.js :: ${KEY}`,
|
|
563
|
+
`a-real-gate-below-a-comment.js :: ${KEY}`,
|
|
564
|
+
`a-real-gate.js :: ${KEY}`,
|
|
565
|
+
], 'a citation is documentation; a call is a gate');
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test('no route in the tree is gated on an ownership-scoped key today', () => {
|
|
569
|
+
// The enumeration the fix was written against, kept honest. A new gate mounted on
|
|
570
|
+
// an own-scoped atom would now 403 at runtime; this fails at BUILD time instead,
|
|
571
|
+
// naming the file, so nobody ships a route that can only ever deny.
|
|
572
|
+
const owned = catalog.permissionKeys().filter((k) => catalog.isOwnershipScoped(k));
|
|
573
|
+
assert.ok(owned.length > 0, 'no ownership-scoped atoms — the scan would pass vacuously');
|
|
574
|
+
const files = jsFilesUnder([path.join(ROOT, 'src', 'bongos', 'routes'), path.join(ROOT, 'modules')]);
|
|
575
|
+
// A mis-pointed root is the other way this passes while scanning nothing, and it
|
|
576
|
+
// reads identically to a clean tree. Pin the corpus so the roots must still resolve.
|
|
577
|
+
assert.ok(files.length > 100, `expected the route corpus; scanned only ${files.length} files`);
|
|
578
|
+
const offenders = scanForOwnedGates(files, (rel) => {
|
|
579
|
+
try { return fs.readFileSync(path.join(ROOT, rel), 'utf8'); } catch { return null; }
|
|
580
|
+
}, owned);
|
|
505
581
|
assert.deepEqual(offenders, [],
|
|
506
582
|
'requirePermission cannot answer an ownership-scoped key — the handler must call ' +
|
|
507
583
|
'authorizeOwned() with the row\'s owner instead');
|
package/tests/linkify_refs.mjs
CHANGED
|
@@ -290,6 +290,23 @@ t('a known idea link is never unwrapped by the past-mislink pass', () => {
|
|
|
290
290
|
assert.equal(runIds(s, ids), s);
|
|
291
291
|
});
|
|
292
292
|
|
|
293
|
+
// task 1003935: the separator contract, stated rather than assumed.
|
|
294
|
+
//
|
|
295
|
+
// discoverDocs builds with path.join, so on Windows it returned
|
|
296
|
+
// 'docs\\adr\\0001-….md' and the recursion assertion below — which compares against
|
|
297
|
+
// 'docs/' — was permanently red. Nobody saw it: this file was in run-unit-tests'
|
|
298
|
+
// INTEGRATION exclusion set while touching no database, so it ran in neither lane
|
|
299
|
+
// (fixed in the same task). The assertion was right and the walker was wrong to
|
|
300
|
+
// hand it OS-native paths, so the fix is here at the boundary; this test exists so
|
|
301
|
+
// the next reader does not have to rediscover which of the two was at fault.
|
|
302
|
+
t('discoverDocs returns POSIX-style paths on every platform (task 1003935)', () => {
|
|
303
|
+
const docs = discoverDocs();
|
|
304
|
+
const native = docs.filter((d) => d.includes('\\'));
|
|
305
|
+
assert.equal(native.length, 0,
|
|
306
|
+
`paths must not carry OS-native separators; got ${JSON.stringify(native.slice(0, 3))}`);
|
|
307
|
+
assert.ok(docs.some((d) => d.includes('/')), 'and nested paths must still be nested');
|
|
308
|
+
});
|
|
309
|
+
|
|
293
310
|
t('discoverDocs finds CLAUDE.md, recurses docs/ subdirs (only .md), skips the generated ideas file', () => {
|
|
294
311
|
const docs = discoverDocs();
|
|
295
312
|
assert.ok(docs.includes('CLAUDE.md'), 'includes CLAUDE.md');
|