@bongos/core 1.19.365 → 1.19.367

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 (34) hide show
  1. package/.bongos-core.json +93 -28
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +9 -1
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/adr/0192-a-category-orients-and-authorises-nothing.md +97 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +117 -1
  10. package/docs/api-reference.md +11 -3
  11. package/docs/file-map.md +13 -1
  12. package/migrations/core_226_work_categories.sql +100 -0
  13. package/modules/builder-settings/event-sound-prefs.js +14 -4
  14. package/modules/dev-box/app/src/vendor/bongos-client.cjs +8 -0
  15. package/modules/hall-ui/public/alert.wav +0 -0
  16. package/modules/hall-ui/public/fanfare.wav +0 -0
  17. package/modules/hall-ui/public/ship.wav +0 -0
  18. package/modules/hall-ui/public/triumph.wav +0 -0
  19. package/modules/lifecycle/db-goals.js +5 -5
  20. package/modules/lifecycle/goal-category.js +273 -0
  21. package/modules/lifecycle/routes/goals.js +38 -2
  22. package/package-lock.json +2 -2
  23. package/package.json +1 -1
  24. package/scripts/gds/category-advisory-guard.js +153 -0
  25. package/scripts/gds/fitness.js +6 -1
  26. package/scripts/gds/gen-sounds.js +176 -0
  27. package/scripts/gds/ship-io.js +16 -2
  28. package/scripts/gds/turn-end-sound.js +74 -0
  29. package/src/bongos/route-rank-check.js +11 -0
  30. package/src/module-api.js +5 -1
  31. package/tests/category_advisory_guard.mjs +135 -0
  32. package/tests/goal_category.mjs +199 -0
  33. package/tests/goal_tier.mjs +7 -5
  34. package/tests/sound_player.mjs +345 -0
@@ -0,0 +1,273 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/goal-category.js — the human CATEGORY on a goal, and the
4
+ // project's category SET (task 1003275, goal 1000072, criterion
5
+ // legibility-categories).
6
+ //
7
+ // WHY THIS EXISTS. A person scanning the goal board cannot tell at a glance which
8
+ // goals are about money, which are about the walls, and which are about the hall.
9
+ // Each goal's own words say so, but only if you read all 38 of them. A category is
10
+ // the one word that answers it. The owner named the set; migration
11
+ // core_226_work_categories seeds it and holds the schema reasoning.
12
+ //
13
+ // THE RULE THAT OUTRANKS THE FEATURE — A CATEGORY IS ADVISORY AND MUST STAY THAT
14
+ // WAY. It orients a human and steers Claude; it authorises NOTHING. No claim gate,
15
+ // no route guard, no rank check, no claimable-feed filter and no dependency rule
16
+ // may read it. The reason is not tidiness: the system already HAS a real hierarchy
17
+ // (Project → Version → Goal → Criteria → Task, ADR 0086) with real enforcement —
18
+ // the scope_modules wall, per-task rank floors, goal membership — and a second
19
+ // thing that LOOKS like a hierarchy gets mistaken for one, after which work is
20
+ // refused because of a label nobody meant as a permission. Enforced by
21
+ // `scripts/gds/category-advisory-guard.js` (a fitness check), because a comment is
22
+ // not a guardrail. See ADR 0192.
23
+ //
24
+ // Two consequences that look like bugs and are not:
25
+ // - Uncategorised is LEGAL. A goal with no category is a prompt to a human, never
26
+ // a rejected write. The criterion is explicit: "where it cannot, the human is
27
+ // prompted to create a new category — that failure is a designed prompt, not
28
+ // an error."
29
+ // - A task has no category column. It INHERITS its goal's through tasks.goal_id.
30
+ // Storing a copy would create two answers to one question.
31
+ //
32
+ // Lives here rather than in routes/goals.js or db-goals.js because both are over
33
+ // the fitness oversize threshold and the ratchet is shrink-only — the
34
+ // goal-edits.js / goal-authz.js precedent (a cohesive slice with its own SQL and
35
+ // its handlers as factories, so a test can drive them without a database).
36
+
37
+ const api = require('../../src/module-api');
38
+ const { pool } = api;
39
+
40
+ // Shared column list so every category read/return shapes a row identically
41
+ // (the GOAL_COLS precedent in db-goals.js).
42
+ const CATEGORY_COLS = 'id, slug, label, sort_order, active, created_by, created_at, updated_at';
43
+
44
+ // A slug is the stable machine key a client filter and a skill's inference hold
45
+ // onto, so it is deliberately narrow: lowercase, alnum, single dashes, no edges.
46
+ // Derived from the label when the caller does not supply one, because the human
47
+ // creating a category at the moment Claude could not place a goal should be typing
48
+ // "Developer Relations", not inventing a key.
49
+ function slugify(text) {
50
+ return String(text == null ? '' : text)
51
+ .toLowerCase()
52
+ .replace(/[^a-z0-9]+/g, '-')
53
+ .replace(/^-+|-+$/g, '')
54
+ .slice(0, 48);
55
+ }
56
+
57
+ function isValidSlug(slug) {
58
+ const s = String(slug == null ? '' : slug);
59
+ if (s.length < 2 || s.length > 48) return false;
60
+ if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(s)) return false;
61
+ return !s.includes('--');
62
+ }
63
+
64
+ // -------------------------------------------------------------------------
65
+ // Reads
66
+ // -------------------------------------------------------------------------
67
+
68
+ // The project's category set, in the order a human should see it. Retired
69
+ // categories are excluded by default but readable with includeInactive, because a
70
+ // goal still carrying one must render correctly — that is why retiring is
71
+ // `active = false` and not a DELETE.
72
+ async function listCategories({ includeInactive = false } = {}, deps = {}) {
73
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
74
+ const { rows } = await activePool.query(
75
+ `SELECT ${CATEGORY_COLS}
76
+ FROM work_categories
77
+ WHERE ($1::boolean OR active)
78
+ ORDER BY sort_order, label, id`,
79
+ [includeInactive]
80
+ );
81
+ return rows;
82
+ }
83
+
84
+ async function getCategory(id, deps = {}) {
85
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
86
+ const { rows } = await activePool.query(
87
+ `SELECT ${CATEGORY_COLS} FROM work_categories WHERE id = $1`,
88
+ [id]
89
+ );
90
+ return rows[0] ?? null;
91
+ }
92
+
93
+ // -------------------------------------------------------------------------
94
+ // Writes
95
+ // -------------------------------------------------------------------------
96
+
97
+ // Create a category. `slug` collides → returns the EXISTING row rather than
98
+ // throwing: two people prompted to create "Developer Relations" in the same hour
99
+ // must converge on one category, and a 409 here would strand whichever goal lost
100
+ // the race with no label and no obvious next step.
101
+ async function createCategory({ slug, label, createdBy = null }, deps = {}) {
102
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
103
+ const { rows } = await activePool.query(
104
+ `WITH next_order AS (
105
+ SELECT COALESCE(MAX(sort_order) + 10, 10) AS n FROM work_categories
106
+ ), ins AS (
107
+ INSERT INTO work_categories (slug, label, sort_order, created_by)
108
+ SELECT $1, $2, next_order.n, $3 FROM next_order
109
+ ON CONFLICT (slug) DO NOTHING
110
+ RETURNING ${CATEGORY_COLS}
111
+ )
112
+ SELECT ${CATEGORY_COLS}, true AS created FROM ins
113
+ UNION ALL
114
+ SELECT ${CATEGORY_COLS}, false AS created FROM work_categories
115
+ WHERE slug = $1 AND NOT EXISTS (SELECT 1 FROM ins)`,
116
+ [slug, label, createdBy]
117
+ );
118
+ return rows[0] ?? null;
119
+ }
120
+
121
+ // Set (or clear, with null) a goal's category. Returns the goal row, or null when
122
+ // the goal is gone. No authorisation happens here — the route applies the SAME
123
+ // owner|manager|Archon gate the prose edit uses; a category never widens who may
124
+ // write to a goal, and it must never narrow it either.
125
+ async function setGoalCategory({ goalId, categoryId }, deps = {}) {
126
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
127
+ const { rows } = await activePool.query(
128
+ `UPDATE goals
129
+ SET category_id = $2, updated_at = now()
130
+ WHERE id = $1
131
+ RETURNING id, version_id, title, subtitle, category_id, status, created_by, updated_at`,
132
+ [goalId, categoryId]
133
+ );
134
+ return rows[0] ?? null;
135
+ }
136
+
137
+ // -------------------------------------------------------------------------
138
+ // Decoration
139
+ // -------------------------------------------------------------------------
140
+
141
+ // Attach the readable category onto a goal row for the wire: `category` is
142
+ // {id, slug, label} or null. A map keyed by id is passed in so listing 38 goals
143
+ // costs ONE category query, not 38 joins — and so the goal SELECTs in db-goals.js
144
+ // stay untouched (GOAL_COLS is used in INSERT ... RETURNING, where a join cannot
145
+ // go). Pure, so the shape is pinned by a test.
146
+ function decorateGoalCategory(goal, byId) {
147
+ if (!goal) return goal;
148
+ const id = goal.category_id;
149
+ const row = id == null ? null : (byId instanceof Map ? byId.get(String(id)) : (byId || {})[String(id)]);
150
+ return {
151
+ ...goal,
152
+ category: row ? { id: String(row.id), slug: row.slug, label: row.label } : null,
153
+ };
154
+ }
155
+
156
+ // Map of id → row, for decorateGoalCategory. Inactive categories are INCLUDED:
157
+ // a goal carrying a retired one must still render its label rather than silently
158
+ // reading as uncategorised.
159
+ async function categoryIndex(deps = {}) {
160
+ const rows = await listCategories({ includeInactive: true }, deps);
161
+ return new Map(rows.map((r) => [String(r.id), r]));
162
+ }
163
+
164
+ // -------------------------------------------------------------------------
165
+ // Handlers (factories — collaborators injected, so a test needs no database)
166
+ // -------------------------------------------------------------------------
167
+
168
+ // GET /work-categories — the set. Any signed-in builder: it is a vocabulary, and
169
+ // a client that cannot read it hard-codes the labels instead, which is how the
170
+ // "present in every builders hall on any project" requirement rots.
171
+ function categoriesListHandler({ list = listCategories, log } = {}) {
172
+ return async function getWorkCategories(req, res) {
173
+ try {
174
+ const includeInactive = String(req.query?.include_inactive || '') === '1';
175
+ const categories = await list({ includeInactive });
176
+ return res.json({ categories });
177
+ } catch (err) {
178
+ log?.error({ err: err && err.message }, 'GET /work-categories failed');
179
+ return res.fail('category_list_failed', { status: 500, message: 'internal error' });
180
+ }
181
+ };
182
+ }
183
+
184
+ // POST /work-categories — the endpoint the "Claude could not place this goal"
185
+ // prompt writes INTO. Gated at the same rank as authoring a goal: extending the
186
+ // project's vocabulary is a planning act, not an administrative one.
187
+ function categoryCreateHandler({ create = createCategory, validateOrRespond, LIMITS, log } = {}) {
188
+ return async function postWorkCategory(req, res) {
189
+ if (validateOrRespond(req, res, {
190
+ label: { required: true, type: 'string', minLength: 1, maxLength: LIMITS.TITLE },
191
+ slug: { type: 'string', maxLength: 48 },
192
+ })) return;
193
+ const label = String(req.body.label).trim();
194
+ if (!label) return res.fail('label_required', 400);
195
+ const slug = slugify(req.body.slug || label);
196
+ if (!isValidSlug(slug)) {
197
+ return res.fail('bad_slug', 400, {
198
+ message: 'A category slug is lowercase letters, digits and single dashes (2–48 chars). '
199
+ + 'Send a `slug` explicitly if the label does not reduce to one.',
200
+ });
201
+ }
202
+ try {
203
+ const row = await create({ slug, label, createdBy: req.builder.id });
204
+ if (!row) return res.fail('category_create_failed', { status: 500, message: 'internal error' });
205
+ const created = row.created !== false;
206
+ delete row.created;
207
+ return res.status(created ? 201 : 200).json({ ok: true, category: row, created });
208
+ } catch (err) {
209
+ log?.error({ err: err && err.message }, 'POST /work-categories failed');
210
+ return res.fail('category_create_failed', { status: 500, message: 'internal error' });
211
+ }
212
+ };
213
+ }
214
+
215
+ // PATCH /goals/:id/category — body { category_id } (null clears it).
216
+ //
217
+ // A separate route from PATCH /goals/:id on purpose: that one is the PROSE edit
218
+ // (task 1003102), which carries a write-once posture and an audit delta of the
219
+ // before/after text. A category is a label, not prose — re-labelling is cheap and
220
+ // reversible, and folding it into the prose handler would either subject a label
221
+ // to the prose rules or loosen the prose rules to suit a label.
222
+ function goalCategoryHandler({ db, authorizeGoalManagement, validateOrRespond, parseId, set = setGoalCategory, read = getCategory, index = categoryIndex, log } = {}) {
223
+ return async function patchGoalCategory(req, res) {
224
+ const id = parseId(req, res, { code: 'bad_goal_id' });
225
+ if (id === null) return;
226
+ if (!Object.prototype.hasOwnProperty.call(req.body || {}, 'category_id')) {
227
+ return res.fail('category_id_required', 400, {
228
+ message: 'Send { category_id: <id> } to set the goal\'s category, or { category_id: null } to clear it.',
229
+ });
230
+ }
231
+ const raw = req.body.category_id;
232
+ const categoryId = raw == null ? null : Number(raw);
233
+ if (categoryId !== null && (!Number.isInteger(categoryId) || categoryId <= 0)) {
234
+ return res.fail('bad_category_id', 400);
235
+ }
236
+ try {
237
+ const goal = await db.getGoal(id);
238
+ const actorMembership = goal
239
+ ? await db.getGoalMember({ goalId: id, builderId: req.builder.id })
240
+ : null;
241
+ const decision = authorizeGoalManagement({
242
+ goal, actorId: req.builder.id, actorRank: req.builder.rank, actorMembership,
243
+ });
244
+ if (!decision.ok) return res.status(decision.status).json(decision.body);
245
+ if (categoryId !== null) {
246
+ const category = await read(categoryId);
247
+ if (!category) return res.fail('category_not_found', 404);
248
+ }
249
+ const updated = await set({ goalId: id, categoryId });
250
+ if (!updated) return res.fail('goal_not_found', 404);
251
+ const byId = await index();
252
+ return res.json({ ok: true, goal: decorateGoalCategory(updated, byId) });
253
+ } catch (err) {
254
+ log?.error({ err: err && err.message }, 'PATCH /goals/:id/category failed');
255
+ return res.fail('goal_category_update_failed', { status: 500, message: 'internal error' });
256
+ }
257
+ };
258
+ }
259
+
260
+ module.exports = {
261
+ CATEGORY_COLS,
262
+ slugify,
263
+ isValidSlug,
264
+ listCategories,
265
+ getCategory,
266
+ createCategory,
267
+ setGoalCategory,
268
+ decorateGoalCategory,
269
+ categoryIndex,
270
+ categoriesListHandler,
271
+ categoryCreateHandler,
272
+ goalCategoryHandler,
273
+ };
@@ -28,6 +28,7 @@ const db = require('../db');
28
28
  const hierarchy = require('../hierarchy');
29
29
  const goalSuggest = require('../goal-suggest.js'); // task 1003062 — pre-creation goal suggester
30
30
  const goalEdits = require('../goal-edits.js'); // task 1003102 — goal self-edit handlers
31
+ const goalCategory = require('../goal-category.js'); // task 1003275 — the ADVISORY human category (gates nothing)
31
32
  const { suggestCriteria } = require('../criterion-suggest');
32
33
  const { classifyTaskKind, VALID_KINDS, VALID_DISCIPLINES, TASK_FIELD_BOUNDS } = require('../task-classifier');
33
34
  const { validateOrRespond, parseId, LIMITS } = api;
@@ -77,6 +78,9 @@ module.exports = function buildGoalsRouter() {
77
78
  subtitle: { type: 'string', maxLength: LIMITS.TITLE }, description: { type: 'string', maxLength: LIMITS.DESCRIPTION },
78
79
  scope_modules: { type: 'array', maxItems: 64, itemsType: 'string' },
79
80
  succeeds_goal_id: { type: 'integer', min: 1 },
81
+ // task 1003275 — the human category. ADVISORY: absent is legal (it becomes a
82
+ // prompt, never a refusal), and no gate below reads it.
83
+ category_id: { type: 'integer', min: 1 },
80
84
  })) return;
81
85
 
82
86
  const scopeModules = req.body.scope_modules || [];
@@ -107,12 +111,19 @@ module.exports = function buildGoalsRouter() {
107
111
  return res.fail('predecessor_not_found', { status: 404, message: 'No such goal to carry forward.' });
108
112
  }
109
113
  }
114
+ // task 1003275: an unknown category is the caller's typo, not a new
115
+ // category — creating one is an explicit POST /work-categories.
116
+ const categoryId = req.body.category_id ?? null;
117
+ if (categoryId !== null && !(await goalCategory.getCategory(categoryId))) {
118
+ return res.fail('category_not_found', 404, { message: 'No such category. List them at GET /work-categories, or create one with POST /work-categories.' });
119
+ }
110
120
  const goal = await db.createGoal({
111
121
  versionId: req.body.version_id,
112
122
  title: req.body.title,
113
123
  subtitle: (req.body.subtitle || '').trim() || null, description: (req.body.description || '').trim() || null,
114
124
  scopeModules,
115
125
  createdBy: req.builder.id,
126
+ categoryId,
116
127
  });
117
128
  // Stamp the predecessor's succeeded_by pointer at the new goal (the lineage).
118
129
  if (succeedsId != null) {
@@ -134,7 +145,10 @@ module.exports = function buildGoalsRouter() {
134
145
  }
135
146
  try {
136
147
  const goals = await db.listGoals({ versionId: version || null, status: status || null });
137
- return res.json({ goals: goals.map(decorateGoalScope) });
148
+ // task 1003275: ONE category query decorates the whole board (never a join
149
+ // per goal). Purely a label for the reader — nothing downstream branches on it.
150
+ const byId = await goalCategory.categoryIndex();
151
+ return res.json({ goals: goals.map((g) => goalCategory.decorateGoalCategory(decorateGoalScope(g), byId)) });
138
152
  } catch (err) {
139
153
  log.error('[gds] GET /goals', err);
140
154
  return res.fail('list_failed', { status: 500, message: 'internal error' });
@@ -210,7 +224,8 @@ module.exports = function buildGoalsRouter() {
210
224
  db.listCriteriaForGoal(id),
211
225
  db.getGoalDependencies(id), // task 1761 — prerequisite goals + satisfied state
212
226
  ]);
213
- return res.json({ goal: decorateGoalScope(goal), members, criteria, dependencies });
227
+ const byId = await goalCategory.categoryIndex();
228
+ return res.json({ goal: goalCategory.decorateGoalCategory(decorateGoalScope(goal), byId), members, criteria, dependencies });
214
229
  } catch (err) {
215
230
  log.error('[gds] GET /goals/:id', err);
216
231
  return res.fail('fetch_failed', { status: 500, message: 'internal error' });
@@ -286,6 +301,27 @@ module.exports = function buildGoalsRouter() {
286
301
  // Pinned in route-rank-check.MODULE_EXPECTED_RANKS['lifecycle'].
287
302
  router.patch('/goals/:id/visibility', auth.requireBuilder, goalEdits.goalVisibilityHandler({ db, authorizeGoalManagement, validateOrRespond, parseId, VALID_VISIBILITIES }));
288
303
 
304
+ // ---- the ADVISORY human category (task 1003275, criterion legibility-categories) ----
305
+ // A category ORIENTS a person and steers Claude; it authorises nothing. Nothing
306
+ // in this file, in claim eligibility, or in the claimable feed may branch on it —
307
+ // scripts/gds/category-advisory-guard.js fails the build if that changes (ADR 0192).
308
+ //
309
+ // GET /work-categories — the project's set. rank: any-builder; it is a vocabulary,
310
+ // and a client that cannot read it hard-codes the labels instead.
311
+ router.get('/work-categories', auth.requireBuilder, goalCategory.categoriesListHandler({ log }));
312
+ // POST /work-categories — the endpoint the "Claude could not place this goal"
313
+ // prompt writes into. Same permission as authoring a goal: extending the project's
314
+ // vocabulary is a planning act. A duplicate slug returns the existing row (200),
315
+ // so two people prompted at once converge instead of one being stranded.
316
+ router.post('/work-categories', auth.requireBuilder, auth.requirePermission('goal.create'),
317
+ goalCategory.categoryCreateHandler({ validateOrRespond, LIMITS, log }));
318
+ // PATCH /goals/:id/category — body { category_id } (null clears it). Separate from
319
+ // PATCH /goals/:id because that route is the PROSE edit with its write-once posture
320
+ // and before/after audit delta; a label is neither.
321
+ // rank: any-builder (requireBuilder) + the in-handler owner|manager|Archon gate.
322
+ router.patch('/goals/:id/category', auth.requireBuilder,
323
+ goalCategory.goalCategoryHandler({ db, authorizeGoalManagement, validateOrRespond, parseId, log }));
324
+
289
325
  // POST /goals/:id/requests — a builder asks to join a (private or protected)
290
326
  // goal (task 1952 / ADR 0112). Refused on a public, non-protected goal (that's
291
327
  // open-join — just POST /goals/:id/join). Creates a pending kind='request' the
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.365",
3
+ "version": "1.19.367",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.365",
9
+ "version": "1.19.367",
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.365",
3
+ "version": "1.19.367",
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",
@@ -0,0 +1,153 @@
1
+ 'use strict';
2
+
3
+ // scripts/gds/category-advisory-guard.js — the fitness check that keeps a work
4
+ // CATEGORY advisory (task 1003275, goal 1000072, criterion legibility-categories;
5
+ // ADR 0192).
6
+ //
7
+ // WHY A CHECK AND NOT A COMMENT. The owner said it twice about this organisation:
8
+ // it "has no hierarchical purpose", and these categories have "no effect on the
9
+ // creation and agentic management of these elements". The risk is specific and
10
+ // named in the goal's own prose: the system ALREADY has a real hierarchy
11
+ // (Project → Version → Goal → Criteria → Task, ADR 0086) with real enforcement —
12
+ // the scope_modules wall, per-task rank floors, goal membership — so a SECOND
13
+ // thing that looks like a hierarchy "will be mistaken for one within a release".
14
+ // The mistake does not arrive as a decision; it arrives as one plausible line in
15
+ // a gate ("skip Legal goals in the feed"), and from then on work is refused
16
+ // because of a label nobody meant as a permission. A comment cannot refuse that
17
+ // line. This check can.
18
+ //
19
+ // WHAT IT ENFORCES, in two halves:
20
+ // 1. NO GATE READS A CATEGORY. Every file that decides authority, claim
21
+ // eligibility, feed membership or dependency satisfaction is scanned with
22
+ // comments stripped; any surviving mention of a category is a hard failure.
23
+ // Comments are stripped rather than flagged so a gate may CITE this rule (the
24
+ // checkVocabularyWall precedent) — what it may not do is branch on it.
25
+ // 2. THE FEATURE STILL EXISTS. A guard whose subject was renamed away passes
26
+ // silently and reads as proof (the ADR 0022 assurance-of-assurance finding
27
+ // that produced control-manifest.js). So the check also asserts the category
28
+ // surface is present; if the feature is gone, the guard must fail loudly
29
+ // rather than congratulate an empty repo.
30
+
31
+ const fs = require('node:fs');
32
+ const path = require('node:path');
33
+
34
+ const ROOT = path.resolve(__dirname, '..', '..');
35
+
36
+ // Every file that DECIDES something: who may act, what may be claimed, what the
37
+ // feed offers, when a dependency is satisfied. A category may not appear in any
38
+ // of them. Add to this list when a new decision surface lands — that is the
39
+ // maintenance cost, and it is the point.
40
+ const GATE_FILES = [
41
+ 'src/bongos/auth.js',
42
+ 'src/bongos/auth-admission.js',
43
+ 'src/bongos/permission-path-check.js',
44
+ 'src/bongos/goal-scope-check.js',
45
+ 'src/bongos/module-scope-map.js',
46
+ 'src/bongos/stealth-gate.js',
47
+ 'modules/lifecycle/claim-eligibility.js',
48
+ 'modules/lifecycle/claim-feed.js',
49
+ 'modules/lifecycle/goal-authz.js',
50
+ 'modules/lifecycle/routes/goal-route-authz.js',
51
+ 'modules/lifecycle/db-rank-authz.js',
52
+ 'modules/lifecycle/db-deps-criteria.js',
53
+ 'scripts/gds/gating.js',
54
+ ];
55
+
56
+ // DELIBERATELY NOT a gate: src/bongos/route-rank-check.js. It is the pin TABLE
57
+ // the rank fitness check reads, so it names every route including the category
58
+ // ones by design, and it decides nothing at runtime. Scanning it would force the
59
+ // pins out of the registry that exists to hold them.
60
+
61
+ // The category surface half 2 asserts still exists. Path + a token that must
62
+ // appear in it, so a rename or a gutting is caught rather than a bare `existsSync`.
63
+ const FEATURE_ANCHORS = [
64
+ { file: 'migrations/core_226_work_categories.sql', needle: 'CREATE TABLE IF NOT EXISTS work_categories' },
65
+ { file: 'modules/lifecycle/goal-category.js', needle: 'decorateGoalCategory' },
66
+ { file: 'modules/lifecycle/routes/goals.js', needle: "router.patch('/goals/:id/category'" },
67
+ ];
68
+
69
+ // Word-bounded on purpose: `subcategory_id` in some unrelated module is not this
70
+ // feature, and a guard that cries wolf gets suppressed rather than obeyed. The
71
+ // table name needs no boundary — nothing else is called that.
72
+ const CATEGORY_RE = /\bcategor(y|ies|ised|ized|isation|ization)\b|\bcategory_id\b|work_categories/i;
73
+
74
+ // Strip // line comments and /* */ blocks so a citation of the rule is legal and
75
+ // only executable text is judged. Crude on purpose: it errs toward keeping text
76
+ // (a `//` inside a string literal turns the rest of that line into a "comment"),
77
+ // and erring toward keeping text would make the check LOUDER, not quieter — which
78
+ // is the safe direction for a guard.
79
+ function stripComments(src) {
80
+ return src
81
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
82
+ .replace(/(^|[^:])\/\/[^\n]*/g, '$1');
83
+ }
84
+
85
+ function readFromDisk(rel) {
86
+ try { return fs.readFileSync(path.join(ROOT, rel), 'utf8'); } catch { return null; }
87
+ }
88
+
89
+ // The scan, with its file list and its reader INJECTED. A guard tested only
90
+ // against the live tree proves nothing about what it would refuse — the tree is
91
+ // clean today by construction. `read(rel)` returns the source or null.
92
+ function scanGates(files, read) {
93
+ const violations = [];
94
+ const warnings = [];
95
+ let scanned = 0;
96
+
97
+ for (const rel of files) {
98
+ const src = read(rel);
99
+ if (src == null) {
100
+ // A missing gate file is a warning, not a pass: the list has drifted from
101
+ // the repo and someone must re-point it.
102
+ warnings.push(`${rel} not found — the GATE_FILES list in scripts/gds/category-advisory-guard.js has drifted; re-point it at the file that replaced it.`);
103
+ continue;
104
+ }
105
+ scanned += 1;
106
+ const code = stripComments(src);
107
+ const hits = code.split('\n')
108
+ .map((line, i) => ({ line: line.trim(), n: i + 1 }))
109
+ .filter(({ line }) => CATEGORY_RE.test(line));
110
+ for (const { line, n } of hits) {
111
+ violations.push(
112
+ `${rel}:${n} reads a work CATEGORY inside a decision surface — \`${line.slice(0, 100)}\`. `
113
+ + 'A category is ADVISORY: it orients a human and steers Claude, and authorises nothing (task 1003275, ADR 0192). '
114
+ + 'The real hierarchy with real enforcement is ADR 0086 — gate on scope_modules, rank, membership or dependencies instead. '
115
+ + 'If this label genuinely must gate something, that is an owner decision and needs its own ADR before this check is relaxed.'
116
+ );
117
+ }
118
+ }
119
+
120
+ return { violations, warnings, scanned };
121
+ }
122
+
123
+ // Half 2: the feature this guard constrains must still be there, or the guard is
124
+ // vacuous and its green tick is a lie (the ADR 0022 assurance-of-assurance finding).
125
+ function scanAnchors(anchors, read) {
126
+ const violations = [];
127
+ for (const { file, needle } of anchors) {
128
+ const src = read(file);
129
+ if (src == null || !src.includes(needle)) {
130
+ violations.push(
131
+ `${file} no longer carries \`${needle}\` — the category feature this guard exists to constrain has moved or been removed, so the guard is now vacuous and would pass an empty repo. `
132
+ + 'Re-point FEATURE_ANCHORS in scripts/gds/category-advisory-guard.js, or delete the guard along with the feature.'
133
+ );
134
+ }
135
+ }
136
+
137
+ return violations;
138
+ }
139
+
140
+ function checkCategoryIsAdvisory({ files = GATE_FILES, anchors = FEATURE_ANCHORS, read = readFromDisk } = {}) {
141
+ const { violations: gateViolations, warnings, scanned } = scanGates(files, read);
142
+ const violations = gateViolations.concat(scanAnchors(anchors, read));
143
+ return {
144
+ name: 'a work category is ADVISORY — no gate reads it (task 1003275 / ADR 0192)',
145
+ ok: violations.length === 0,
146
+ hardFail: violations.length > 0,
147
+ violations,
148
+ warnings,
149
+ note: `${scanned} decision surface(s) scanned (authority, claim eligibility, claimable feed, dependency satisfaction) with comments stripped — a gate may CITE this rule, never branch on it. Plus ${anchors.length} feature anchor(s), so a renamed-away feature fails loudly instead of passing vacuously.`,
150
+ };
151
+ }
152
+
153
+ module.exports = { checkCategoryIsAdvisory, scanGates, scanAnchors, GATE_FILES, FEATURE_ANCHORS, stripComments };
@@ -1307,7 +1307,12 @@ const CHECKS = [
1307
1307
  require('./control-manifest.js').checkControlsExist, // Check 21 — task 1003131: goal-1000076 controls assert their own existence (the ADR 0022 assurance-of-assurance)
1308
1308
  require('./routine-schedule.js').checkRoutineCadencesScheduled, // Check 25 — task 1003209: a routine that declares a cadence must have a scheduler firing it AT that period (ADR 0188)
1309
1309
  require('./baseline-staleness.js').checkBaselineFloors, // Check 26 — task 1003210: a shrink-only baseline that never shrinks is unmanaged debt; a floor exempting one must say why
1310
- require('./client-baseurl-guard.js').checkClientBaseUrl, // Check 27 — task 1003263: a page must not combine baseUrl:'' with a generated namespaced call (that pairing 404s every request; three pages shipped it, two of them dead in prod)
1310
+ require('./client-baseurl-guard.js').checkClientBaseUrl,
1311
+ // Check 29 — task 1003275 / ADR 0189: a work category ORIENTS a human and steers
1312
+ // Claude; it authorises nothing. The owner said so twice, and the goal's own prose
1313
+ // predicts the failure: a second thing that looks like a hierarchy gets mistaken
1314
+ // for one within a release. A comment cannot refuse the line that does it.
1315
+ require('./category-advisory-guard.js').checkCategoryIsAdvisory, // Check 27 — task 1003263: a page must not combine baseUrl:'' with a generated namespaced call (that pairing 404s every request; three pages shipped it, two of them dead in prod)
1311
1316
  // Check 28 — task 1001654 / ADR 0095: does each source file open with a module
1312
1317
  // header saying what it does and why. ADVISORY ONLY (never hardFail) — a
1313
1318
  // doc-comment gate that blocks a ship buys filler prose, not understanding.