@bongos/core 1.19.946 → 1.19.948

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.
@@ -0,0 +1,209 @@
1
+ // goal-map.js — the goal map page (task 1003479, goal 1000089; ADR 0227 §6 item 3
2
+ // and its §10.4 amendment). One read, GET /goals/graph, three surfaces from it:
3
+ // 1. the map — the goals tied to another goal by a dependency, drawn in layers;
4
+ // 2. the stand-alone list — the goals that have no edge, which §10.4 says belong
5
+ // in a list and not as dots on a canvas that has nothing to say about them;
6
+ // 3. the table — EVERY goal, with both directions of each dependency by name.
7
+ //
8
+ // THE TABLE IS THE PAGE AND THE MAP IS THE ENHANCEMENT (ADR 0227 §5). Every fact
9
+ // the picture carries is a cell in the table, on screen by default and not behind
10
+ // a "view as table" toggle. The picture is therefore a `role="img"` figure with a
11
+ // caption that says where the same facts are, and its nodes are NOT links or
12
+ // focus stops — a keyboard or screen-reader reader gets each goal, once, from the
13
+ // table, rather than thirty-six hidden interactive elements from a drawing.
14
+ //
15
+ // THERE IS NO CONTROL that changes what is drawn (ADR 0227 §7): no "show every
16
+ // task", no "densify". The only input is the aggregate the endpoint returns,
17
+ // which carries no task identity, so views A and D are unreachable by
18
+ // construction and not by a flag that happens to be off.
19
+ //
20
+ // The layout is goal-map-lib.js (pure, run by tests/goal_map_lib.mjs). This file
21
+ // draws what it returns, with createElementNS and textContent — the atlas's
22
+ // idiom — so a goal title (builder-authored text) never becomes markup.
23
+ (() => {
24
+ 'use strict';
25
+
26
+ const API = '/api/bongos';
27
+ const SVGNS = 'http://www.w3.org/2000/svg';
28
+ const kit = window.OTBKit;
29
+ const lib = window.OTBGoalMapLib;
30
+ const { escapeHtml } = window.OTB;
31
+ const $ = (id) => document.getElementById(id);
32
+
33
+ const hallBase = () => (location.hostname.startsWith('builders.') ? '' : '/builders');
34
+ const goalHref = (id) => `${hallBase()}/goals#/goal/${encodeURIComponent(id)}`;
35
+
36
+ function svgEl(tag, attrs, parent) {
37
+ const el = document.createElementNS(SVGNS, tag);
38
+ Object.keys(attrs || {}).forEach((k) => el.setAttribute(k, attrs[k]));
39
+ if (parent) parent.appendChild(el);
40
+ return el;
41
+ }
42
+ function text(parent, cls, x, y, content) {
43
+ const t = svgEl('text', { class: cls, x, y }, parent);
44
+ t.textContent = content;
45
+ return t;
46
+ }
47
+
48
+ // ---- the map -----------------------------------------------------------------
49
+
50
+ function drawGraph(a, lay, cats) {
51
+ const svg = svgEl('svg', {
52
+ class: 'gm-svg', viewBox: `0 0 ${lay.width} ${lay.height}`, width: lay.width, height: lay.height,
53
+ role: 'img', 'aria-labelledby': 'gm-cap', focusable: 'false',
54
+ });
55
+ const defs = svgEl('defs', {}, svg);
56
+ const marker = svgEl('marker', { id: 'gm-arrow', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: 'auto' }, defs);
57
+ svgEl('path', { class: 'gm-arrowhead', d: 'M0 0 L10 5 L0 10 z' }, marker);
58
+
59
+ const edges = svgEl('g', { class: 'gm-edges' }, svg);
60
+ lay.edges.forEach((e) => {
61
+ const path = svgEl('path', { class: 'gm-edge', d: e.d, 'stroke-width': e.width, 'marker-end': 'url(#gm-arrow)' }, edges);
62
+ const dependent = lib.displayTitle(a.byId[e.from]);
63
+ const dependency = lib.displayTitle(a.byId[e.to]);
64
+ svgEl('title', {}, path).textContent = `${dependent} waits on ${dependency} — ${lib.plural(e.count, 'task')}`;
65
+ });
66
+
67
+ // The node's WEIGHT (ADR 0227 §6 item 3: "node weight = task count"), as a
68
+ // fraction of the busiest goal ACTUALLY DRAWN — a scale that means something
69
+ // on this map, not on the whole corpus most of which never appears here.
70
+ const maxTotal = lay.nodes.reduce((m, p) => Math.max(m, a.byId[p.id].task_total || 0), 0);
71
+
72
+ const nodes = svgEl('g', { class: 'gm-nodes' }, svg);
73
+ lay.nodes.forEach((p) => {
74
+ const n = a.byId[p.id];
75
+ const g = svgEl('g', { class: `gm-node gm-c${lib.colourOf(n, cats)}`, transform: `translate(${p.x} ${p.y})` }, nodes);
76
+ const cat = n.category ? n.category.label : 'No category';
77
+ svgEl('title', {}, g).textContent = `${lib.displayTitle(n)} — ${cat} — ${n.task_done} of ${n.task_total} tasks done`
78
+ + ` — waits on ${lib.plural(n.depends_on || 0, 'goal')}, needed by ${lib.plural(n.depended_on_by || 0, 'goal')}`;
79
+ svgEl('rect', { class: 'gm-card', width: p.w, height: p.h, rx: 8 }, g);
80
+ svgEl('rect', { class: 'gm-stripe', width: 6, height: p.h, rx: 3 }, g);
81
+ text(g, 'gm-name', 16, 22, lib.clip(lib.displayTitle(n), lib.TITLE_CHARS));
82
+ text(g, 'gm-cat', 16, 38, lib.clip(cat, lib.SUB_CHARS));
83
+ text(g, 'gm-tasks', 16, 53, `${n.task_done} of ${lib.plural(n.task_total, 'task')} done`);
84
+ const trackW = p.w - 32;
85
+ svgEl('rect', { class: 'gm-weight-track', x: 16, y: 60, width: trackW, height: 3, rx: 1.5 }, g);
86
+ const frac = lib.weightOf(n.task_total, maxTotal);
87
+ if (frac > 0) svgEl('rect', { class: 'gm-weight-fill', x: 16, y: 60, width: Math.max(3, trackW * frac), height: 3, rx: 1.5 }, g);
88
+ });
89
+ return svg;
90
+ }
91
+
92
+ function renderGraph(a, cats, described) {
93
+ const host = $('gm-graph');
94
+ if (!a.connected) {
95
+ host.innerHTML = `<p class="scroll__lede">${escapeHtml(described.graph || described.summary)}</p>`;
96
+ return;
97
+ }
98
+ host.innerHTML = `
99
+ <figure class="gm-figure">
100
+ <figcaption id="gm-cap" class="scroll__lede">
101
+ An arrow runs from the goal that has to ship first to the goal that waits on it, and a thicker line means more
102
+ tasks are waiting. Every goal in this map, and every dependency, is also in the table below.
103
+ </figcaption>
104
+ <div class="gm-scroll" id="gm-scroll" tabindex="0" role="region" aria-label="Goal map — scrolls sideways inside this box"></div>
105
+ </figure>`;
106
+ $('gm-scroll').appendChild(drawGraph(a, lib.layout(a), cats));
107
+ }
108
+
109
+ function renderLegend(cats) {
110
+ const host = $('gm-legend');
111
+ if (!cats.list.length && !cats.uncategorised) { host.hidden = true; return; }
112
+ const swatch = (cls) => `<span class="gm-swatch ${cls}" aria-hidden="true"></span>`;
113
+ host.innerHTML = cats.list.map((c) => `<li>${swatch(`gm-c${c.colour}`)}${escapeHtml(c.label)} <span class="gm-n">${c.count}</span></li>`).join('')
114
+ + (cats.uncategorised ? `<li>${swatch('gm-cnone')}No category <span class="gm-n">${cats.uncategorised}</span></li>` : '');
115
+ host.hidden = false;
116
+ }
117
+
118
+ // ---- the stand-alone goals ------------------------------------------------------
119
+
120
+ function renderAlone(a) {
121
+ const section = $('gm-alone-scroll');
122
+ if (!a.isolated.length || !a.connected) { section.hidden = true; return; }
123
+ $('gm-alone-lede').textContent = `${lib.plural(a.isolated.length, 'goal')} ${a.isolated.length === 1 ? 'has' : 'have'} no task that waits on another goal’s work, `
124
+ + 'and none that another goal waits on, so there is nothing to draw between them and another goal. They are listed here instead of drawn as dots.';
125
+ $('gm-alone').innerHTML = a.isolated.map((id) => {
126
+ const n = a.byId[id];
127
+ const label = lib.displayTitle(n);
128
+ const facts = (n.category ? `${escapeHtml(n.category.label)} · ` : '') + lib.plural(n.task_total, 'task');
129
+ const inner = `<span>${escapeHtml(label)}</span><span class="gm-n">${facts}</span>`;
130
+ return n.title
131
+ ? `<li><a class="fact-chip gm-chip" href="${goalHref(id)}">${inner}</a></li>`
132
+ : `<li><span class="fact-chip gm-chip">${inner}</span></li>`;
133
+ }).join('');
134
+ section.hidden = false;
135
+ }
136
+
137
+ // ---- the table (primary) ----------------------------------------------------------
138
+
139
+ const depList = (list) => (list.length
140
+ ? `<ul class="gm-deps">${list.map((x) => `<li>${escapeHtml(x.title)} <span class="gm-n">· ${lib.plural(x.count, 'task')}</span></li>`).join('')}</ul>`
141
+ : '<span class="gm-none">—</span>');
142
+
143
+ function rowHtml(r) {
144
+ const title = r.isPrivate
145
+ ? `<span class="gm-private">${escapeHtml(r.title)}</span>`
146
+ : `<a class="lb-name" href="${goalHref(r.id)}">${escapeHtml(r.title)}</a>`;
147
+ return `<tr>
148
+ <td>${title}</td>
149
+ <td>${r.category ? escapeHtml(r.category) : '<span class="gm-none">—</span>'}</td>
150
+ <td class="ledger__num">${r.done} of ${r.total}</td>
151
+ <td>${depList(r.waits)}</td>
152
+ <td>${depList(r.needed)}</td>
153
+ <td>${escapeHtml(r.clusterLabel)}</td>
154
+ <td>${escapeHtml(r.version)}</td>
155
+ </tr>`;
156
+ }
157
+
158
+ function renderTable(a) {
159
+ const section = $('gm-table-scroll');
160
+ const rows = lib.tableRows(a);
161
+ section.hidden = false;
162
+ const pager = kit.makePager('gm', 'hall-goal-map-per-page', { defaultPerPage: 100 });
163
+ const ledger = kit.makeLedger({
164
+ mount: $('gm-table'),
165
+ columns: ['Goal', 'Category', { label: 'Tasks done', class: 'ledger__num' }, 'Waits on', 'Needed by', 'Cluster', 'Version'],
166
+ rowHtml,
167
+ emptyMsg: 'No goal carries any work yet.',
168
+ pager,
169
+ });
170
+ ledger.render(rows);
171
+ const table = $('gm-table').querySelector('table');
172
+ if (table) table.setAttribute('aria-label', 'Every goal, with what it waits on and what waits on it');
173
+ }
174
+
175
+ // ---- boot ---------------------------------------------------------------------------
176
+
177
+ function notice(msg) {
178
+ $('gm-sub').textContent = msg;
179
+ $('gm-graph').innerHTML = `<p class="note">${escapeHtml(msg)}</p>`;
180
+ }
181
+
182
+ async function boot() {
183
+ let payload;
184
+ try {
185
+ const res = await fetch(`${API}/goals/graph`, { credentials: 'same-origin', headers: { Accept: 'application/json' } });
186
+ if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
187
+ if (!res.ok) throw new Error(`graph → ${res.status}`);
188
+ payload = await res.json();
189
+ } catch (err) {
190
+ console.error('[goal-map] load failed', err);
191
+ notice('Couldn’t load the goal map right now.');
192
+ return;
193
+ }
194
+ const a = lib.analyse(payload);
195
+ const cats = lib.categories(a);
196
+ const described = lib.describe(a);
197
+ $('gm-sub').textContent = described.summary;
198
+ if (!a.order.length) {
199
+ $('gm-graph').innerHTML = kit.emptyStateHtml(described.summary);
200
+ return;
201
+ }
202
+ renderGraph(a, cats, described);
203
+ renderLegend(cats);
204
+ renderAlone(a);
205
+ renderTable(a);
206
+ }
207
+
208
+ boot();
209
+ })();
@@ -0,0 +1,14 @@
1
+ {
2
+ "_": "THE GOAL MAP (task 1003479, goal 1000089; ADR 0227 §6 item 3) through modules/ui-design/kit/render.js. The page reads ONE authenticated aggregate, GET /goals/graph, so against the kit's own stub it renders its signed-out / failed-read state — a state the floors must hold in and is rendered here deliberately. To look at the populated map (the layered picture, the stand-alone chips, the table), run it against the hall-preview harness, whose goals__graph.json fixture carries one large cluster, a triple, pairs, a mutual pair (a loop) and a private goal beside the stand-alone majority:\n\n node scripts/hall-preview/server.js --port 4620 --fixture-me\n node modules/ui-design/kit/render.js --states modules/hall-ui/public/goal-map.states.json --base-in http://localhost:4620 --base-out http://localhost:4620",
3
+ "page": "/builders/goal-map",
4
+ "surface": "hall-ui",
5
+ "stub": { "prefix": "/builders/" },
6
+ "modeQuery": false,
7
+ "ignoreRequests": ["/api/(gds|bongos)/"],
8
+ "ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
9
+ "states": {
10
+ "map": { "auth": true, "actions": [["wait", 1200]], "expect": { "visible": ["#gm-graph"] } },
11
+ "out": { "auth": false, "actions": [["wait", 600]] }
12
+ },
13
+ "reducedMotion": { "auth": true, "actions": [["wait", 1200]] }
14
+ }
@@ -42,6 +42,9 @@
42
42
  <div class="page-head page-head--board">
43
43
  <h1 class="page-head__h">Goals</h1>
44
44
  <p class="page-head__sub" id="goals-sub">Loading goals…</p>
45
+ <!-- task 1003479: the goal map has no nav slot of its own (the sky's rule —
46
+ a map that is reachable but not load-bearing), so this is its door. -->
47
+ <p class="page-head__sub"><a href="/builders/goal-map">See which goals wait on each other →</a></p>
45
48
  </div>
46
49
 
47
50
  <main id="hall-main">
@@ -0,0 +1,176 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/goal-graph.js — the goal graph's data: one node per goal, one
4
+ // edge per pair of goals whose tasks depend on each other (task 1003479, goal
5
+ // 1000089, ADR 0227 §6 item 3 and its §10.4 amendment).
6
+ //
7
+ // WHY THIS IS AN AGGREGATE AND NOT A TASK LIST. ADR 0227 refused a whole-corpus
8
+ // task map: 3,253 tasks are confetti, and the readable thing is the ~70-goal
9
+ // graph. So the reduction happens in Postgres — task counts per goal (the same
10
+ // buckets goal-rollup.js uses, so the two boards cannot show different numbers)
11
+ // and a COUNT of task-level dependencies that cross a goal boundary — and the
12
+ // payload is a few kilobytes however many tasks exist. Nothing in this file ever
13
+ // returns a task id or a task title.
14
+ //
15
+ // THE EDGE DIRECTION IS "WAITS ON". An edge {from, to, count} says `count` tasks
16
+ // in goal `from` depend on tasks in goal `to`. Only edges between two DIFFERENT
17
+ // goals are kept: a dependency inside one goal is that goal's own business and
18
+ // belongs to the per-goal forest (task 1003480), not to the map of goals.
19
+ //
20
+ // VISIBILITY (ADR 0112's leak rule). A private goal's prose is the secret, not
21
+ // its existence or its counts, so a viewer who is neither a member nor an Archon
22
+ // still gets the node — its task counts and its edges — but `title` and
23
+ // `category` come back null. The client draws that as "a private goal" rather
24
+ // than dropping it, because dropping a node would silently turn its neighbours
25
+ // into isolated dots and misstate the very structure this view exists to show.
26
+ //
27
+ // A CATEGORY IS A LABEL HERE AND NOTHING MORE (ADR 0192): it is decorated onto
28
+ // the node for the reader and no query, filter or edge below reads it.
29
+
30
+ const api = require('../../src/module-api');
31
+ const { pool } = api;
32
+ const goalCategory = require('./goal-category');
33
+ const { DONE_STATUSES, CUT_STATUSES } = require('./task-status');
34
+
35
+ // goalGraphRows — the two raw reads. Returns { goals, edges }: `goals` one row per
36
+ // goal that carries at least one task, `edges` one row per ordered pair of goals.
37
+ // deps.pool honours the test seam (NODE_ENV==='test' only), like every helper here.
38
+ async function goalGraphRows({ versionId = null, viewerId = null, viewerIsArchon = false } = {}, deps = {}) {
39
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
40
+ // A repeated ?version= parses as an array; binding that against a ::text
41
+ // parameter is a driver-level 500, not a clean refusal, so it is flattened to
42
+ // its first value before it ever reaches the query (GET /goals has the same
43
+ // gap; fixing it there is a separate task).
44
+ const version = Array.isArray(versionId) ? (versionId[0] ?? null) : versionId;
45
+ const [goals, edges] = await Promise.all([
46
+ activePool.query(
47
+ // The outer JOIN tasks already requires at least one task per goal, so
48
+ // there is no separate EXISTS to filter goals with none.
49
+ `SELECT g.id::text AS goal_id,
50
+ g.title, g.status, g.version_id, g.category_id,
51
+ (g.visibility IS DISTINCT FROM 'private'
52
+ OR $2::boolean
53
+ OR bool_or(gm.builder_id IS NOT NULL)) AS visible,
54
+ COUNT(*) FILTER (WHERE t.status <> ALL($4::text[]))::int AS task_total,
55
+ COUNT(*) FILTER (WHERE t.status = ANY($5::text[]))::int AS task_done
56
+ FROM goals g
57
+ JOIN tasks t ON t.goal_id = g.id
58
+ LEFT JOIN goal_members gm ON gm.goal_id = g.id AND gm.builder_id = $3::bigint
59
+ WHERE ($1::text IS NULL OR g.version_id = $1)
60
+ GROUP BY g.id, g.title, g.status, g.version_id, g.category_id
61
+ ORDER BY g.version_id, g.sort_order, g.id`,
62
+ [version, viewerIsArchon === true, viewerId, CUT_STATUSES, DONE_STATUSES]
63
+ ),
64
+ activePool.query(
65
+ `SELECT ft.goal_id::text AS from_goal_id,
66
+ tt.goal_id::text AS to_goal_id,
67
+ COUNT(*)::int AS count
68
+ FROM dependencies d
69
+ JOIN tasks ft ON d.from_kind = 'task' AND ft.id = d.from_id
70
+ JOIN tasks tt ON d.to_kind = 'task' AND tt.id = d.to_id
71
+ JOIN goals fg ON fg.id = ft.goal_id
72
+ JOIN goals tg ON tg.id = tt.goal_id
73
+ WHERE ft.goal_id <> tt.goal_id
74
+ AND ($1::text IS NULL OR (fg.version_id = $1 AND tg.version_id = $1))
75
+ GROUP BY ft.goal_id, tt.goal_id
76
+ ORDER BY ft.goal_id, tt.goal_id`,
77
+ [version]
78
+ ),
79
+ ]);
80
+ return { goals: goals.rows, edges: edges.rows };
81
+ }
82
+
83
+ // shapeGraph — the wire shape. Pure, so a test pins it with no database.
84
+ //
85
+ // nodes: [{ goal_id, title, status, version_id, category, task_total, task_done,
86
+ // depends_on, depended_on_by }]
87
+ // edges: [{ from, to, count }] — `from` waits on `to`
88
+ // counts: { goals, connected, isolated, edges }
89
+ //
90
+ // `depends_on` / `depended_on_by` are DISTINCT GOAL counts, not task counts: the
91
+ // table this feeds says "waits on 3 goals", and a reader adding up task edges to
92
+ // find out how many goals are involved would get the wrong number.
93
+ //
94
+ // An edge whose endpoint is not among the nodes is DROPPED, not drawn to nowhere.
95
+ // That happens legitimately when a version filter scopes the goals but a stale
96
+ // row lingers, and an edge with a missing end is a line pointing at nothing.
97
+ function shapeGraph({ goals = [], edges = [] } = {}, categoryById = new Map()) {
98
+ const known = new Set(goals.map((g) => String(g.goal_id)));
99
+ const kept = [];
100
+ const seen = new Set();
101
+ for (const e of edges) {
102
+ const from = String(e.from_goal_id);
103
+ const to = String(e.to_goal_id);
104
+ if (from === to || !known.has(from) || !known.has(to)) continue;
105
+ const key = `${from}>${to}`;
106
+ if (seen.has(key)) continue;
107
+ seen.add(key);
108
+ kept.push({ from, to, count: Number(e.count) || 0 });
109
+ }
110
+
111
+ const waitsOn = new Map();
112
+ const blocks = new Map();
113
+ for (const e of kept) {
114
+ if (!waitsOn.has(e.from)) waitsOn.set(e.from, new Set());
115
+ waitsOn.get(e.from).add(e.to);
116
+ if (!blocks.has(e.to)) blocks.set(e.to, new Set());
117
+ blocks.get(e.to).add(e.from);
118
+ }
119
+
120
+ const nodes = goals.map((g) => {
121
+ const id = String(g.goal_id);
122
+ const visible = g.visible !== false;
123
+ const cat = visible && g.category_id != null ? categoryById.get(String(g.category_id)) : null;
124
+ return {
125
+ goal_id: id,
126
+ title: visible ? g.title : null,
127
+ status: g.status,
128
+ version_id: g.version_id,
129
+ category: cat ? { id: String(cat.id), slug: cat.slug, label: cat.label } : null,
130
+ task_total: Number(g.task_total) || 0,
131
+ task_done: Number(g.task_done) || 0,
132
+ depends_on: (waitsOn.get(id) || new Set()).size,
133
+ depended_on_by: (blocks.get(id) || new Set()).size,
134
+ };
135
+ });
136
+
137
+ const connected = nodes.filter((n) => n.depends_on + n.depended_on_by > 0).length;
138
+ return {
139
+ nodes,
140
+ edges: kept,
141
+ counts: { goals: nodes.length, connected, isolated: nodes.length - connected, edges: kept.length },
142
+ };
143
+ }
144
+
145
+ // goalGraph — read + shape. `categories` is injectable so the whole path runs in a
146
+ // test with a fake pool and no category table.
147
+ async function goalGraph(opts = {}, deps = {}) {
148
+ const [rows, byId] = await Promise.all([
149
+ goalGraphRows(opts, deps),
150
+ (deps.categoryIndex || goalCategory.categoryIndex)(deps),
151
+ ]);
152
+ return shapeGraph(rows, byId);
153
+ }
154
+
155
+ // goalGraphHandler — GET /goals/graph. A factory, so a test drives it with a stub
156
+ // read and no database (the goal-category.js handler precedent). Identity comes
157
+ // from the authenticated request, never the query: the viewer decides whether a
158
+ // private goal's title comes back, and a caller-supplied id would let anyone ask
159
+ // as somebody else.
160
+ function goalGraphHandler({ read = goalGraph, log = console } = {}) {
161
+ return async function getGoalGraph(req, res) {
162
+ try {
163
+ const graph = await read({
164
+ versionId: (req.query && req.query.version) || null,
165
+ viewerId: req.builder.id,
166
+ viewerIsArchon: req.builder.rank === 'archon',
167
+ });
168
+ return res.json(graph);
169
+ } catch (err) {
170
+ log.error('[gds] GET /goals/graph', err);
171
+ return res.fail('graph_failed', { status: 500, message: 'internal error' });
172
+ }
173
+ };
174
+ }
175
+
176
+ module.exports = { goalGraph, goalGraphRows, shapeGraph, goalGraphHandler };
@@ -30,6 +30,7 @@ const goalSuggest = require('../goal-suggest.js'); // task 1003062 — pre-creat
30
30
  const goalEdits = require('../goal-edits.js'); // task 1003102 — goal self-edit handlers
31
31
  const goalCategory = require('../goal-category.js'); // task 1003275 — the ADVISORY human category (gates nothing)
32
32
  const goalRollup = require('../goal-rollup.js'); // task 1003278 — per-goal rollups computed in SQL, not in the browser
33
+ const goalGraph = require('../goal-graph.js'); // task 1003479 — the goal graph: nodes + cross-goal edges, aggregated in SQL
33
34
  const { suggestCriteria } = require('../criterion-suggest');
34
35
  const { classifyTaskKind, VALID_KINDS, WRITABLE_DISCIPLINES, resolveWriteDiscipline, TASK_FIELD_BOUNDS } = require('../task-classifier');
35
36
  const { validateOrRespond, parseId, LIMITS } = api;
@@ -210,6 +211,9 @@ module.exports = function buildGoalsRouter() {
210
211
  }
211
212
  });
212
213
 
214
+ // rank: any-builder — GET /goals/graph, the goal board's map (task 1003479, ADR 0227); before /goals/:id.
215
+ router.get('/goals/graph', auth.requireBuilder, goalGraph.goalGraphHandler({ log }));
216
+
213
217
  // GET /goals?version=&status= — list goals (read).
214
218
  // rank: any-builder — viewing the goal board.
215
219
  router.get('/goals', auth.requireBuilder, async (req, res) => {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.946",
3
+ "version": "1.19.948",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.946",
9
+ "version": "1.19.948",
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.946",
3
+ "version": "1.19.948",
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",
@@ -374,14 +374,29 @@ function renderPackageJson(spec) {
374
374
  },
375
375
  files: ['index.mjs', 'index.cjs', 'index.d.ts', 'bongos-client.global.js', 'README.md'],
376
376
  license: 'AGPL-3.0-or-later',
377
+ // `private: true` is the ENFORCEMENT of the paragraph below (task 1004143). Task
378
+ // 1003742 removed the publishConfig, which stopped the repo CLAIMING this was a
379
+ // published package — but nothing stopped it BECOMING one again: `npm publish` in
380
+ // clients/bongos-client/ succeeded and would have recreated @bongos/client@0.0.2,
381
+ // the exact package the owner deleted from the registry on 2026-09-09. npm now
382
+ // refuses with EPRIVATE, so that removal cannot be silently undone by anyone who
383
+ // runs publish in the wrong directory.
384
+ //
385
+ // This is not the publish posture the note below calls fiction. That was declaring
386
+ // HOW it publishes (access: restricted) for something never published; this
387
+ // declares that it does not publish, which is the same fact the note states in
388
+ // prose. It does not affect `main`/`types`/`exports`, so the vendored copy and a
389
+ // workspace link resolve exactly as before.
390
+ private: true,
377
391
  // NO publishConfig — this client is NOT an npm package (task 1003742).
378
392
  //
379
393
  // It was published once, 2026-07-07 at 0.0.2, under ADR 0134's private-first plan, and never
380
394
  // updated while the API it is generated FROM moved 500+ core versions. It had zero consumers
381
395
  // the whole time: nothing across the core or any instance repo ever declared it as a
382
- // dependency — every consumer VENDORS a copy instead (modules/dev-box/app/src/vendor/, and
383
- // build-cli-package.js into @cloudbongos/cli, which could not depend on it anyway). The owner
384
- // removed it from the registry on 2026-09-09.
396
+ // dependency — every consumer VENDORS a copy instead. That was two places when this was
397
+ // written; task 1003891 deleted the Dev Box Electron app, so build-cli-package.js vendoring
398
+ // it into @cloudbongos/cli (which could not depend on it anyway) is now the only one. The
399
+ // owner removed it from the registry on 2026-09-09.
385
400
  //
386
401
  // The manifest itself stays: `main`/`types`/`exports` are what make the vendored copy and a
387
402
  // workspace link resolve. Declaring a publish posture is the part that was fiction.
@@ -1163,13 +1163,16 @@ function mountInternalSurfaces(app) {
1163
1163
  // because the page reads the VIEWER's own trail and nobody else's — the id it
1164
1164
  // asks for comes from GET /me, never from the URL, so a rank could protect
1165
1165
  // nothing here that the session does not already.
1166
+ // task 1003479 (goal 1000089): `goal-map` — the map of which goals wait on which,
1167
+ // reached from Goals. It reads GET /goals/graph, which is `requireBuilder`, so a
1168
+ // page gated any lower would show a signed-out visitor an empty shell.
1166
1169
  // task 1004105 (BV2.R26): `sky` — THE SKY, the ambient view over the same
1167
1170
  // trail the room lists — joins that tier for exactly the same reasons. It is
1168
1171
  // the SAME read (GET /inbox/by-builder/:id with the id from GET /me), drawn
1169
1172
  // as a map instead of a table, so a floor above sign-in would protect on one
1170
1173
  // page what it admits on the other.
1171
1174
  const SIGNEDIN_BUILDERS_PAGE_RE =
1172
- /^\/builders\/(?:work|settings|atlas|modules|goals|roadmap|copy-desk|studio|ideas|thinking|sky|collab|task|idea|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
1175
+ /^\/builders\/(?:work|settings|atlas|modules|goals|goal-map|roadmap|copy-desk|studio|ideas|thinking|sky|collab|task|idea|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
1173
1176
  // The diagram image assets under /builders/diagrams/ ride the same tier as the
1174
1177
  // page that embeds them.
1175
1178
  const SIGNEDIN_BUILDERS_PREFIX_RE = /^\/builders\/diagrams\//;
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.946'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.948'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -303,3 +303,40 @@ test('--check reads a CRLF working copy as fresh, and a real edit as stale (task
303
303
  fs.rmSync(root, { recursive: true, force: true });
304
304
  }
305
305
  });
306
+
307
+ // ── the generated manifest must refuse to become an npm package (task 1004143) ──
308
+ //
309
+ // @bongos/client was published once (2026-07-07, 0.0.2), gathered zero consumers —
310
+ // everyone vendors a copy — and the owner deleted it from the registry on
311
+ // 2026-09-09. Task 1003742 removed the publishConfig so the repo stopped CLAIMING
312
+ // it was published, but nothing stopped it BECOMING published again: `npm publish`
313
+ // in clients/bongos-client/ succeeded and would have recreated it.
314
+ //
315
+ // WHY THIS IS ASSERTED ON THE MANIFEST AND NOT BY RUNNING npm. `npm publish
316
+ // --dry-run` is NOT a test of this and passes with `private: true` set: npm's
317
+ // dry-run path returns in lib/commands/publish.js before it ever calls
318
+ // libnpmpublish, and libnpmpublish/lib/publish.js is where the real guard lives —
319
+ // `if (manifest.private) throw EPRIVATE` as its first statement, unconditional and
320
+ // ahead of any network call. So a real publish IS blocked; a dry-run simply never
321
+ // reaches the check. Asserting the field is the honest test at this layer, and it
322
+ // is the part this repo actually controls.
323
+ test('the generated client manifest is private, so it cannot be republished', () => {
324
+ const pkg = JSON.parse(fs.readFileSync(
325
+ path.join(ROOT, 'clients', 'bongos-client', 'package.json'), 'utf8'));
326
+ assert.equal(pkg.private, true,
327
+ 'clients/bongos-client/package.json lost `private: true` — npm publish in that directory '
328
+ + 'would recreate @bongos/client, the package the owner removed from the registry.');
329
+ // The manifest still has to RESOLVE: 1003742 kept these on purpose, because the
330
+ // vendored copy and a workspace link go through them. `private` must not cost that.
331
+ for (const field of ['main', 'types', 'exports']) {
332
+ assert.ok(pkg[field], `the manifest lost \`${field}\` — the vendored copy resolves through it`);
333
+ }
334
+ });
335
+
336
+ test('the GENERATOR emits it, not just the committed file', () => {
337
+ // Fixing only the output is a fix that the next regeneration undoes.
338
+ const src = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'gen-api-client.js'), 'utf8');
339
+ assert.match(src, /private:\s*true/,
340
+ 'gen-api-client.js no longer emits `private: true` — the next regeneration would '
341
+ + 'make clients/bongos-client publishable again.');
342
+ });