@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.
- package/.bongos-core.json +93 -28
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +9 -1
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0192-a-category-orients-and-authorises-nothing.md +97 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +117 -1
- package/docs/api-reference.md +11 -3
- package/docs/file-map.md +13 -1
- package/migrations/core_226_work_categories.sql +100 -0
- package/modules/builder-settings/event-sound-prefs.js +14 -4
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +8 -0
- package/modules/hall-ui/public/alert.wav +0 -0
- package/modules/hall-ui/public/fanfare.wav +0 -0
- package/modules/hall-ui/public/ship.wav +0 -0
- package/modules/hall-ui/public/triumph.wav +0 -0
- package/modules/lifecycle/db-goals.js +5 -5
- package/modules/lifecycle/goal-category.js +273 -0
- package/modules/lifecycle/routes/goals.js +38 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/category-advisory-guard.js +153 -0
- package/scripts/gds/fitness.js +6 -1
- package/scripts/gds/gen-sounds.js +176 -0
- package/scripts/gds/ship-io.js +16 -2
- package/scripts/gds/turn-end-sound.js +74 -0
- package/src/bongos/route-rank-check.js +11 -0
- package/src/module-api.js +5 -1
- package/tests/category_advisory_guard.mjs +135 -0
- package/tests/goal_category.mjs +199 -0
- package/tests/goal_tier.mjs +7 -5
- 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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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 };
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -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,
|
|
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.
|