@bongos/core 1.19.366 → 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 +55 -25
- 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/migrations/core_226_work_categories.sql +100 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +8 -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/src/bongos/route-rank-check.js +11 -0
- package/src/module-api.js +3 -1
- package/tests/category_advisory_guard.mjs +135 -0
- package/tests/goal_category.mjs +199 -0
- package/tests/goal_tier.mjs +7 -5
package/docs/api-reference.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Bongos API reference
|
|
4
4
|
|
|
5
|
-
> **Generated from the live route files** — the route file is authoritative.
|
|
5
|
+
> **Generated from the live route files** — the route file is authoritative. 318 endpoints across 52 route files.
|
|
6
6
|
> Machine-readable spec: [`docs/api/openapi.json`](api/openapi.json) (OpenAPI 3.1). Rendered docs site: **`/docs`** (e.g. `cloudbongos.com/docs`).
|
|
7
7
|
|
|
8
8
|
Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>.md)): `public` < `any-builder` < `metic+archon` < `archon`.
|
|
@@ -205,15 +205,16 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
205
205
|
| GET | `/api/bongos/github/repos` | `any-builder` | — | acts only on the caller's OWN GitHub identity (the token they granted). |
|
|
206
206
|
| POST | `/api/bongos/github/repos` | `any-builder` | `name` | creates a repo in the caller's OWN account. |
|
|
207
207
|
|
|
208
|
-
## `goals` (
|
|
208
|
+
## `goals` (27)
|
|
209
209
|
|
|
210
210
|
| Method | Path | Rank | Body | Description |
|
|
211
211
|
|---|---|---|---|---|
|
|
212
212
|
| GET | `/api/bongos/goals` | `any-builder` | — | GET /goals?version=&status= — list goals (read). |
|
|
213
|
-
| POST | `/api/bongos/goals` | `metic+archon` | `version_id`, `title`, `subtitle`, `description`, `scope_modules`, `succeeds_goal_id` | POST /goals — create a goal. |
|
|
213
|
+
| POST | `/api/bongos/goals` | `metic+archon` | `version_id`, `title`, `subtitle`, `description`, `scope_modules`, `succeeds_goal_id`, `category_id` | POST /goals — create a goal. |
|
|
214
214
|
| GET | `/api/bongos/goals/:id` | `any-builder` | — | GET /goals/:id — one goal with its members + linked criteria (read). |
|
|
215
215
|
| PATCH | `/api/bongos/goals/:id` | `any-builder` | — | PATCH /goals/:id — correct a goal's own title/subtitle/description (tasks 1003102, 1003224). |
|
|
216
216
|
| POST | `/api/bongos/goals/:id/archive` | `metic+archon` | — | POST /goals/:id/archive — retire a goal (open\|achieved → archived). |
|
|
217
|
+
| PATCH | `/api/bongos/goals/:id/category` | `any-builder` | — | PATCH /goals/:id/category — body { category_id } (null clears it). |
|
|
217
218
|
| GET | `/api/bongos/goals/:id/conflicts` | `any-builder` | — | GET /goals/:id/conflicts — the server-side task-conflict view (task 1736). |
|
|
218
219
|
| POST | `/api/bongos/goals/:id/conflicts/resolve` | `any-builder` | `action`, `note`, `touches`, `first_task_id`, `then_task_id`, `task_id` | POST /goals/:id/conflicts/resolve — the owner/manager resolution actions for a detected conflict (task 1736; authority model ADR 0106): s… |
|
|
219
220
|
| POST | `/api/bongos/goals/:id/invitations` | `any-builder` | `note`, `builder_id` | POST /goals/:id/invitations { builder_id, note? } — an owner\|manager\|Archon invites a builder (task 1952). |
|
|
@@ -577,6 +578,13 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
577
578
|
| GET | `/api/bongos/versions/close-suggestions` | `public` | — | Close-flow gate suggestions for every version still 'building'. |
|
|
578
579
|
| GET | `/api/bongos/versions/progress` | `public` | — | read-only progress aggregate; mirrors /public/progress. |
|
|
579
580
|
|
|
581
|
+
## `work-categories` (2)
|
|
582
|
+
|
|
583
|
+
| Method | Path | Rank | Body | Description |
|
|
584
|
+
|---|---|---|---|---|
|
|
585
|
+
| GET | `/api/bongos/work-categories` | `any-builder` | — | ---- the ADVISORY human category (task 1003275, criterion legibility-categories) ---- A category ORIENTS a person and steers Claude; it a… |
|
|
586
|
+
| POST | `/api/bongos/work-categories` | `metic+archon` | — | POST /work-categories — the endpoint the "Claude could not place this goal" prompt writes into. |
|
|
587
|
+
|
|
580
588
|
---
|
|
581
589
|
|
|
582
590
|
> 8 write endpoint(s) take a JSON body with no declared validator schema — their fields are not yet machine-documented. Adding a `validateOrRespond` schema (`src/bongos/routes/_helpers.js`) surfaces them here automatically.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
-- core_226_work_categories.sql — a human CATEGORY for the work, that gates
|
|
2
|
+
-- nothing (task 1003275, goal 1000072, criterion legibility-categories).
|
|
3
|
+
--
|
|
4
|
+
-- WHAT THIS IS FOR. The project is legible to Claude and illegible to a person.
|
|
5
|
+
-- A human scanning 38 open goals cannot tell at a glance which are about money,
|
|
6
|
+
-- which are about the walls, and which are about the hall — the goal's own words
|
|
7
|
+
-- carry that, but only if you read all of them. A category is one word that
|
|
8
|
+
-- answers it, and the owner named the set: Builders Hall, Context Management,
|
|
9
|
+
-- Government (Decision Making), Modules, Security, Economics, Legal, and
|
|
10
|
+
-- User/ABI Experience.
|
|
11
|
+
--
|
|
12
|
+
-- THE CONSTRAINT THAT MATTERS MORE THAN THE FEATURE — THIS IS ADVISORY ONLY.
|
|
13
|
+
-- The owner said it twice: this organisation "has no hierarchical purpose" and
|
|
14
|
+
-- these categories have "no effect on the creation and agentic management of
|
|
15
|
+
-- these elements". The system ALREADY has a real hierarchy (Project → Version →
|
|
16
|
+
-- Goal → Criteria → Task, ADR 0086) with real enforcement — the scope_modules
|
|
17
|
+
-- wall, per-task rank floors, goal membership. A second thing that LOOKS like a
|
|
18
|
+
-- hierarchy will be mistaken for one within a release, and then something will
|
|
19
|
+
-- be refused because of a label. So: no claim gate, no route guard, no rank
|
|
20
|
+
-- check, no claimable-feed filter and no dependency rule may read a category.
|
|
21
|
+
-- That is enforced in code by the fitness check
|
|
22
|
+
-- `scripts/gds/category-advisory-guard.js`, not by this comment (ADR 0192).
|
|
23
|
+
--
|
|
24
|
+
-- WHY A TABLE AND NOT AN ENUM OR A text COLUMN. The criterion requires that when
|
|
25
|
+
-- Claude cannot fit a goal into the set, "the human is prompted to create a new
|
|
26
|
+
-- category — that failure is a designed prompt, not an error". A category set
|
|
27
|
+
-- that a person extends at runtime cannot be a CHECK constraint or a pg enum
|
|
28
|
+
-- without a migration per addition, and it must not be a free-text column or the
|
|
29
|
+
-- set is not a set (two spellings of "Security" and the label has stopped
|
|
30
|
+
-- orienting anyone).
|
|
31
|
+
--
|
|
32
|
+
-- WHY A TASK HAS NO COLUMN HERE. The criterion says "every goal (and task, BY
|
|
33
|
+
-- INHERITANCE)". A task's category is its goal's, read through tasks.goal_id —
|
|
34
|
+
-- storing a copy creates two answers to one question and no way to know which is
|
|
35
|
+
-- right. Inheritance is literal: there is nothing to keep in sync.
|
|
36
|
+
--
|
|
37
|
+
-- WHY CORE AND NOT modules/lifecycle/migrations/. `goals` was created by
|
|
38
|
+
-- migrations/160_goal_tier.sql and is a CORE table; the lifecycle module owns the
|
|
39
|
+
-- QUERIES against it, not the table (modules/lifecycle/CLAUDE.md: "None owned").
|
|
40
|
+
-- Fitness check 11 refuses an `ALTER TABLE goals` from a module migration however
|
|
41
|
+
-- lifecycle-ish it feels (ADR 0083 §Decision #5) — the same trap core_224 hit.
|
|
42
|
+
--
|
|
43
|
+
-- Additive, replay-safe, forward-safe: nullable with no default (NULL = never
|
|
44
|
+
-- categorised, which is a legal state and a prompt, never a rejection), and every
|
|
45
|
+
-- existing reader SELECTs an explicit column list so old code on the new schema
|
|
46
|
+
-- neither sees nor needs it (tests/migrations_forward_safe.mjs).
|
|
47
|
+
|
|
48
|
+
BEGIN;
|
|
49
|
+
|
|
50
|
+
-- 1. The set itself. `slug` is the stable machine key (a client filter, a skill's
|
|
51
|
+
-- inference); `label` is what a person reads and may be re-worded without
|
|
52
|
+
-- breaking anything that referenced it. created_by NULL = shipped with the
|
|
53
|
+
-- project (the eight below); a real id = a human created it at the moment
|
|
54
|
+
-- Claude could not place a goal. A migration must never hard-code a builder
|
|
55
|
+
-- id, so the seeded rows are system-attributed (learning 1238).
|
|
56
|
+
-- `active` retires a category without deleting it — a goal already carrying it
|
|
57
|
+
-- keeps reading correctly, which a DELETE would break.
|
|
58
|
+
CREATE TABLE IF NOT EXISTS work_categories (
|
|
59
|
+
id bigserial PRIMARY KEY,
|
|
60
|
+
slug text NOT NULL UNIQUE,
|
|
61
|
+
label text NOT NULL,
|
|
62
|
+
sort_order integer NOT NULL DEFAULT 0,
|
|
63
|
+
active boolean NOT NULL DEFAULT true,
|
|
64
|
+
created_by bigint REFERENCES builders(id), -- NULL = shipped with the project
|
|
65
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
66
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
COMMENT ON TABLE work_categories IS
|
|
70
|
+
'The human category set a project owns (task 1003275). ADVISORY ONLY: no gate, route guard, rank check, feed filter or dependency rule may read it — enforced by scripts/gds/category-advisory-guard.js. The real hierarchy is ADR 0086.';
|
|
71
|
+
|
|
72
|
+
-- 2. The owner's default set. ON CONFLICT DO NOTHING so a replay is a no-op and
|
|
73
|
+
-- a project that has re-worded a label does not have it reverted.
|
|
74
|
+
INSERT INTO work_categories (slug, label, sort_order) VALUES
|
|
75
|
+
('builders-hall', 'Builders Hall', 10),
|
|
76
|
+
('context-management', 'Context Management', 20),
|
|
77
|
+
('government', 'Government (Decision Making)', 30),
|
|
78
|
+
('modules', 'Modules', 40),
|
|
79
|
+
('security', 'Security', 50),
|
|
80
|
+
('economics', 'Economics', 60),
|
|
81
|
+
('legal', 'Legal', 70),
|
|
82
|
+
('user-experience', 'User/ABI Experience', 80)
|
|
83
|
+
ON CONFLICT (slug) DO NOTHING;
|
|
84
|
+
|
|
85
|
+
-- 3. The goal's label. ON DELETE SET NULL rather than RESTRICT: retiring a
|
|
86
|
+
-- category is `active = false`, but if one is ever genuinely deleted an
|
|
87
|
+
-- advisory label must not be able to block it — a goal falling back to
|
|
88
|
+
-- uncategorised is exactly the designed prompt.
|
|
89
|
+
ALTER TABLE goals
|
|
90
|
+
ADD COLUMN IF NOT EXISTS category_id bigint REFERENCES work_categories(id) ON DELETE SET NULL;
|
|
91
|
+
|
|
92
|
+
COMMENT ON COLUMN goals.category_id IS
|
|
93
|
+
'The goal''s human category (task 1003275). ADVISORY ONLY — orients a person and steers Claude, authorises nothing. NULL = not yet categorised, a legal state that prompts rather than refuses. A task inherits this through tasks.goal_id and stores no copy.';
|
|
94
|
+
|
|
95
|
+
CREATE INDEX IF NOT EXISTS idx_goals_category ON goals (category_id);
|
|
96
|
+
|
|
97
|
+
COMMIT;
|
|
98
|
+
|
|
99
|
+
INSERT INTO schema_migrations (version) VALUES ('core_226_work_categories')
|
|
100
|
+
ON CONFLICT (version) DO NOTHING;
|
|
@@ -322,6 +322,8 @@ function createClient(opts = {}) {
|
|
|
322
322
|
patchGoalsId: (args) => request("PATCH", "/goals/{id}", { hasBody: true }, args),
|
|
323
323
|
// POST /goals/{id}/archive — rank: metic+archon — POST /goals/:id/archive
|
|
324
324
|
postGoalsIdArchive: (args) => request("POST", "/goals/{id}/archive", { hasBody: true }, args),
|
|
325
|
+
// PATCH /goals/{id}/category — rank: any-builder — PATCH /goals/:id/category
|
|
326
|
+
patchGoalsIdCategory: (args) => request("PATCH", "/goals/{id}/category", { hasBody: true }, args),
|
|
325
327
|
// GET /goals/{id}/conflicts — rank: any-builder — GET /goals/:id/conflicts
|
|
326
328
|
getGoalsIdConflicts: (args) => request("GET", "/goals/{id}/conflicts", { hasBody: false }, args),
|
|
327
329
|
// POST /goals/{id}/conflicts/resolve — rank: any-builder — POST /goals/:id/conflicts/resolve
|
|
@@ -815,6 +817,12 @@ function createClient(opts = {}) {
|
|
|
815
817
|
// GET /versions/progress — rank: public — GET /versions/progress
|
|
816
818
|
getVersionsProgress: (args) => request("GET", "/versions/progress", { hasBody: false }, args),
|
|
817
819
|
},
|
|
820
|
+
"workCategories": {
|
|
821
|
+
// GET /work-categories — rank: any-builder — GET /work-categories
|
|
822
|
+
getWorkCategories: (args) => request("GET", "/work-categories", { hasBody: false }, args),
|
|
823
|
+
// POST /work-categories — rank: metic+archon — POST /work-categories
|
|
824
|
+
postWorkCategories: (args) => request("POST", "/work-categories", { hasBody: true }, args),
|
|
825
|
+
},
|
|
818
826
|
};
|
|
819
827
|
}
|
|
820
828
|
|
|
@@ -32,7 +32,7 @@ const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
|
|
|
32
32
|
// Shared column list so every goal read/return shapes a row identically.
|
|
33
33
|
|
|
34
34
|
const GOAL_COLS =
|
|
35
|
-
'id, version_id, title, subtitle, description, scope_modules, status, visibility, created_by, succeeded_by_goal_id, sort_order, created_at, updated_at';
|
|
35
|
+
'id, version_id, title, subtitle, description, scope_modules, status, visibility, created_by, succeeded_by_goal_id, sort_order, category_id, created_at, updated_at';
|
|
36
36
|
|
|
37
37
|
// Shared column list for a goal_membership_requests row (task 1951 / ADR 0112) —
|
|
38
38
|
// the invite / join-request queue behind private goals. Every helper below shapes
|
|
@@ -48,7 +48,7 @@ const MEMBERSHIP_REQUEST_COLS =
|
|
|
48
48
|
// goals) inserts no member — those goals have no owner; Archons act as one.
|
|
49
49
|
|
|
50
50
|
async function createGoal(
|
|
51
|
-
{ versionId, title, subtitle = null, description = null, scopeModules = [], status = 'open', createdBy = null },
|
|
51
|
+
{ versionId, title, subtitle = null, description = null, scopeModules = [], status = 'open', createdBy = null, categoryId = null },
|
|
52
52
|
deps = {}
|
|
53
53
|
) {
|
|
54
54
|
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
@@ -56,15 +56,15 @@ async function createGoal(
|
|
|
56
56
|
`WITH next_order AS (
|
|
57
57
|
SELECT COALESCE(MAX(sort_order) + 1, 0) AS n FROM goals WHERE version_id = $1
|
|
58
58
|
), g AS (
|
|
59
|
-
INSERT INTO goals (version_id, title, subtitle, description, scope_modules, status, created_by, sort_order)
|
|
60
|
-
SELECT $1, $2, $7, $3, $4, $5, $6, next_order.n FROM next_order
|
|
59
|
+
INSERT INTO goals (version_id, title, subtitle, description, scope_modules, status, created_by, sort_order, category_id)
|
|
60
|
+
SELECT $1, $2, $7, $3, $4, $5, $6, next_order.n, $8 FROM next_order
|
|
61
61
|
RETURNING ${GOAL_COLS}
|
|
62
62
|
), first_lead AS (
|
|
63
63
|
INSERT INTO goal_members (goal_id, builder_id, membership_kind)
|
|
64
64
|
SELECT g.id, $6::bigint, 'lead' FROM g WHERE $6::bigint IS NOT NULL
|
|
65
65
|
)
|
|
66
66
|
SELECT * FROM g`,
|
|
67
|
-
[versionId, title, description, scopeModules, status, createdBy, subtitle]
|
|
67
|
+
[versionId, title, description, scopeModules, status, createdBy, subtitle, categoryId]
|
|
68
68
|
);
|
|
69
69
|
return rows[0];
|
|
70
70
|
}
|
|
@@ -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",
|