@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.
@@ -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. 315 endpoints across 52 route files.
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` (26)
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
- 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.366",
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.366",
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.366",
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",