@bongos/core 1.19.963 → 1.19.964

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/.bongos-core.json +64 -34
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +2 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0326-an-upgrade-may-add-a-deny-rule-to-a-file-the-instance-owns.md +5 -0
  8. package/docs/adr/README.md +1 -1
  9. package/docs/api/openapi.json +47 -2
  10. package/docs/api-reference.md +3 -2
  11. package/docs/copy-inventory.md +134 -128
  12. package/docs/copy-registry.json +212 -158
  13. package/docs/module-api-changelog.md +2 -0
  14. package/modules/hall-ui/public/goal-forest-lib.js +229 -0
  15. package/modules/hall-ui/public/goal-forest-render.js +151 -0
  16. package/modules/hall-ui/public/goals-page.js +6 -0
  17. package/modules/hall-ui/public/goals-render.js +9 -1
  18. package/modules/hall-ui/public/goals.css +26 -0
  19. package/modules/hall-ui/public/goals.html +19 -0
  20. package/modules/lifecycle/goal-forest.js +135 -0
  21. package/modules/lifecycle/routes/goals.js +8 -8
  22. package/package-lock.json +2 -2
  23. package/package.json +1 -1
  24. package/scripts/gds/claude-materialize.js +29 -6
  25. package/scripts/hall-preview/server.js +1 -0
  26. package/src/module-api.js +1 -1
  27. package/tests/claude_materialize.mjs +48 -4
  28. package/tests/goal_category_ui.mjs +2 -0
  29. package/tests/goal_criterion_confirm_ui.mjs +2 -0
  30. package/tests/goal_forest.mjs +160 -0
  31. package/tests/goal_forest_lib.mjs +153 -0
  32. package/tests/goal_forest_page.mjs +196 -0
  33. package/tests/goal_invite_consent.mjs +1 -1
  34. package/tests/helpers.mjs +7 -3
@@ -2400,5 +2400,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2400
2400
  landed since 1.19.961 with no explicit bump. run 35761137402. (task 1002620)
2401
2401
  1.19.963 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2402
2402
  landed since 1.19.962 with no explicit bump. run 35770645163. (task 1002620)
2403
+ 1.19.964 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2404
+ landed since 1.19.963 with no explicit bump. run 35787848112. (task 1002620)
2403
2405
  ---------------------------------------------------------------------------
2404
2406
  ```
@@ -0,0 +1,229 @@
1
+ // goal-forest-lib.js — the per-goal dependency forest's model and layout, pure
2
+ // (task 1003480, goal 1000089, ADR 0227 §6 item 4 and its §10.5 amendment).
3
+ //
4
+ // NOT A ZOOM OF A GLOBAL MAP (ADR 0227 §1). This draws the tasks INSIDE one
5
+ // already-open goal that depend on each other, at full resolution — there is
6
+ // no aggregation to do, because a per-goal forest is small by measurement
7
+ // (§10.5: largest 127 tasks, median 9). goal-map-lib.js earns its clustering
8
+ // and stand-alone-list machinery because ~half of ~70 GOALS have no edge; here
9
+ // every task this file is ever handed already has one (the server omits the
10
+ // rest, §3's own finding), so there is no isolated set to split out and no
11
+ // shelf-packing needed for many small independent clusters — one layered
12
+ // picture is the whole page.
13
+ //
14
+ // THE LAYERING IS THE SAME IDEA as the goal map's: a task sits one column
15
+ // right of the furthest task it depends on, deterministic (no random seed),
16
+ // so two disjoint chains in one goal share the picture without overlapping —
17
+ // they simply occupy different rows within whichever columns their own depths
18
+ // land on.
19
+ //
20
+ // THE TABLE IS THE PAGE, THE PICTURE IS THE ENHANCEMENT (ADR 0227 §5) — every
21
+ // fact here is also a tableRows() row, and the page ships the table by default.
22
+ //
23
+ // Pure — no window, no DOM, no fetch — and Node-require-safe (the
24
+ // goal-map-lib.js / goals-lib.js precedent) so tests/goal_forest_lib.mjs runs
25
+ // the real layout instead of agreeing with a copy of it.
26
+ (function (root, factory) {
27
+ var mod = factory();
28
+ if (typeof module !== 'undefined' && module.exports) module.exports = mod;
29
+ else root.OTBGoalForestLib = mod;
30
+ }(typeof self !== 'undefined' ? self : this, function () {
31
+ 'use strict';
32
+
33
+ var NODE_W = 168;
34
+ var NODE_H = 52;
35
+ var GAP_X = 52;
36
+ var GAP_Y = 14;
37
+ var PAD = 16;
38
+ var TITLE_CHARS = 24;
39
+
40
+ function num(id) { return Number(id); }
41
+ function byNumericId(a, b) { return num(a) - num(b); }
42
+ function clip(text, max) {
43
+ var t = String(text == null ? '' : text);
44
+ return t.length > max ? t.slice(0, max - 1).replace(/\s+$/, '') + '…' : t;
45
+ }
46
+ function plural(n, one, many) { return n + ' ' + (n === 1 ? one : (many || one + 's')); }
47
+
48
+ // analyse — nodes by id, who each task waits on and who needs it, and the
49
+ // components (for the table's grouping and the summary sentence only — the
50
+ // layout itself does not need them, unlike the goal map's shelf-packing).
51
+ function analyse(payload) {
52
+ var rawNodes = (payload && Array.isArray(payload.nodes)) ? payload.nodes : [];
53
+ var rawEdges = (payload && Array.isArray(payload.edges)) ? payload.edges : [];
54
+ var byId = {};
55
+ var order = [];
56
+ rawNodes.forEach(function (n) {
57
+ var id = String(n.task_id);
58
+ if (byId[id]) return;
59
+ byId[id] = Object.assign({}, n, { id: id });
60
+ order.push(id);
61
+ });
62
+
63
+ var waits = {};
64
+ var needed = {};
65
+ var edges = [];
66
+ var seen = {};
67
+ rawEdges.forEach(function (e) {
68
+ var from = String(e.from);
69
+ var to = String(e.to);
70
+ if (from === to || !byId[from] || !byId[to] || seen[from + '>' + to]) return;
71
+ seen[from + '>' + to] = true;
72
+ edges.push({ from: from, to: to });
73
+ (waits[from] = waits[from] || []).push(to);
74
+ (needed[to] = needed[to] || []).push(from);
75
+ });
76
+ Object.keys(waits).forEach(function (k) { waits[k].sort(byNumericId); });
77
+ Object.keys(needed).forEach(function (k) { needed[k].sort(byNumericId); });
78
+
79
+ var parent = {};
80
+ function find(x) { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; }
81
+ function union(a, b) { var ra = find(a); var rb = find(b); if (ra !== rb) parent[rb] = ra; }
82
+ order.forEach(function (id) { parent[id] = id; });
83
+ edges.forEach(function (e) { union(e.from, e.to); });
84
+ var groups = {};
85
+ order.forEach(function (id) { (groups[find(id)] = groups[find(id)] || []).push(id); });
86
+ var chains = Object.keys(groups).map(function (k) { return groups[k].sort(byNumericId); });
87
+ chains.sort(function (a, b) { return (b.length - a.length) || byNumericId(a[0], b[0]); });
88
+ var chainOf = {};
89
+ chains.forEach(function (ids, i) { ids.forEach(function (id) { chainOf[id] = { index: i + 1, size: ids.length }; }); });
90
+
91
+ return { order: order, byId: byId, edges: edges, waits: waits, needed: needed, chains: chains, chainOf: chainOf };
92
+ }
93
+
94
+ // A task sits one column right of the furthest task it depends on. A cycle
95
+ // (illegal by DB constraint for task deps, but this file trusts nothing it
96
+ // did not check itself) cannot advance past it, matching goal-map-lib.js.
97
+ function layerOf(ids, waits) {
98
+ var depth = {};
99
+ var state = {};
100
+ function visit(id) {
101
+ if (state[id] === 2) return depth[id];
102
+ if (state[id] === 1) return -1;
103
+ state[id] = 1;
104
+ var d = 0;
105
+ (waits[id] || []).forEach(function (dep) {
106
+ var dd = visit(dep);
107
+ if (dd >= 0) d = Math.max(d, dd + 1);
108
+ });
109
+ state[id] = 2;
110
+ depth[id] = d;
111
+ return d;
112
+ }
113
+ ids.forEach(visit);
114
+ return depth;
115
+ }
116
+
117
+ function orderColumns(columns, neighbours) {
118
+ var pos = {};
119
+ columns.forEach(function (col) { col.forEach(function (id, i) { pos[id] = i; }); });
120
+ var layerOfId = {};
121
+ columns.forEach(function (col, li) { col.forEach(function (id) { layerOfId[id] = li; }); });
122
+ for (var sweep = 0; sweep < 4; sweep++) {
123
+ var down = sweep % 2 === 0;
124
+ var from = down ? 1 : columns.length - 2;
125
+ var to = down ? columns.length : -1;
126
+ for (var li = from; li !== to; li += down ? 1 : -1) {
127
+ var col = columns[li];
128
+ var key = {};
129
+ col.forEach(function (id) {
130
+ var ns = (neighbours[id] || []).filter(function (n) { return down ? layerOfId[n] < li : layerOfId[n] > li; });
131
+ key[id] = ns.length ? ns.reduce(function (s, n) { return s + pos[n]; }, 0) / ns.length : pos[id];
132
+ });
133
+ col.sort(function (a, b) { return (key[a] - key[b]) || byNumericId(a, b); });
134
+ col.forEach(function (id, i) { pos[id] = i; });
135
+ }
136
+ }
137
+ }
138
+
139
+ function edgePath(dep, dependent) {
140
+ var x1 = dep.x + NODE_W;
141
+ var y1 = dep.y + NODE_H / 2;
142
+ var x2 = dependent.x;
143
+ var y2 = dependent.y + NODE_H / 2;
144
+ var dx = x2 > x1 ? (x2 - x1) / 2 : 44;
145
+ return 'M' + x1 + ' ' + y1 + ' C' + (x1 + dx) + ' ' + y1 + ' ' + (x2 - dx) + ' ' + y2 + ' ' + x2 + ' ' + y2;
146
+ }
147
+
148
+ // layout — ONE layered picture over every node `analyse` returned (they all
149
+ // carry an edge by construction — the server never hands this file a task
150
+ // with none). Returns the svg's own size; the page scrolls it inside its own
151
+ // box and NEVER lets the page body scroll sideways (ADR 0227 §5.2).
152
+ function layout(a) {
153
+ var depth = layerOf(a.order, a.waits);
154
+ var neighbours = {};
155
+ a.order.forEach(function (id) {
156
+ neighbours[id] = (a.waits[id] || []).concat(a.needed[id] || []);
157
+ });
158
+ var columns = [];
159
+ a.order.forEach(function (id) { (columns[depth[id]] = columns[depth[id]] || []).push(id); });
160
+ columns = columns.map(function (c) { return (c || []).sort(byNumericId); });
161
+ orderColumns(columns, neighbours);
162
+
163
+ var rows = columns.reduce(function (m, c) { return Math.max(m, c.length); }, 1);
164
+ var width = PAD * 2 + columns.length * NODE_W + Math.max(0, columns.length - 1) * GAP_X;
165
+ var height = PAD * 2 + rows * NODE_H + Math.max(0, rows - 1) * GAP_Y;
166
+ var abs = {};
167
+ var nodes = [];
168
+ columns.forEach(function (col, li) {
169
+ var colH = col.length * NODE_H + (col.length - 1) * GAP_Y;
170
+ var top = PAD + Math.round((height - PAD * 2 - colH) / 2);
171
+ col.forEach(function (id, i) {
172
+ var p = { x: PAD + li * (NODE_W + GAP_X), y: top + i * (NODE_H + GAP_Y) };
173
+ abs[id] = p;
174
+ nodes.push({ id: id, x: p.x, y: p.y, w: NODE_W, h: NODE_H });
175
+ });
176
+ });
177
+
178
+ var edges = a.edges.map(function (e) {
179
+ return { from: e.from, to: e.to, d: edgePath(abs[e.to], abs[e.from]) };
180
+ });
181
+ return { width: width, height: height, nodes: nodes, edges: edges, nodeW: NODE_W, nodeH: NODE_H };
182
+ }
183
+
184
+ var PRIORITY_LABEL = { 1: 'P1', 2: 'P2', 3: 'P3', 4: 'P4', 5: 'P5' };
185
+ function priorityLabel(p) { return p != null && PRIORITY_LABEL[p] ? PRIORITY_LABEL[p] : '—'; }
186
+
187
+ // tableRows — the primary surface. One row per task, both directions of its
188
+ // dependency by name.
189
+ function tableRows(a) {
190
+ function named(ids) {
191
+ return (ids || []).map(function (id) { return { id: id, title: a.byId[id] ? a.byId[id].title : ('task ' + id) }; });
192
+ }
193
+ var rows = a.order.map(function (id) {
194
+ var n = a.byId[id];
195
+ var ch = a.chainOf[id];
196
+ return {
197
+ id: id,
198
+ title: n.title || ('Task ' + id),
199
+ status: n.status || '',
200
+ priority: priorityLabel(n.priority),
201
+ waits: named(a.waits[id]),
202
+ needed: named(a.needed[id]),
203
+ chain: ch ? ch.index : 0,
204
+ chainSize: ch ? ch.size : 1,
205
+ chainLabel: ch && ch.size > 1 ? 'Chain ' + ch.index + ' · ' + plural(ch.size, 'task') : 'Its own pair',
206
+ };
207
+ });
208
+ rows.sort(function (x, y) { return (x.chain - y.chain) || byNumericId(x.id, y.id); });
209
+ return rows;
210
+ }
211
+
212
+ // describe — states the shape out loud, in numbers only. No `|| a.chains.length`
213
+ // fallback: every id in a.order arrived with at least one surviving edge (the
214
+ // server's own contract, restated at the top of this file), so a chain of size
215
+ // 1 cannot occur here — a fallback that could say so would be lying about data
216
+ // this file is never actually handed.
217
+ function describe(a) {
218
+ var n = a.order.length;
219
+ if (!n) return 'No task in this goal depends on another task in this goal.';
220
+ var multi = a.chains.filter(function (c) { return c.length > 1; }).length;
221
+ return plural(n, 'task') + ' in this goal ' + (n === 1 ? 'has' : 'have') + ' a dependency on another task in this goal, '
222
+ + 'in ' + plural(multi, 'chain') + '.';
223
+ }
224
+
225
+ return {
226
+ analyse: analyse, layout: layout, tableRows: tableRows, describe: describe,
227
+ clip: clip, plural: plural, priorityLabel: priorityLabel, TITLE_CHARS: TITLE_CHARS,
228
+ };
229
+ }));
@@ -0,0 +1,151 @@
1
+ // goal-forest-render.js — draws the per-goal task dependency forest on the
2
+ // Goals page (task 1003480, goal 1000089, ADR 0227 §6 item 4 and its §10.5
3
+ // amendment). Carved out of goals-render.js on arrival: that file is at the
4
+ // 1,500-line fitness ratchet with "neither half has much room" (its own
5
+ // header), so a new self-contained feature is its own file — the same
6
+ // separation goal-map.js already keeps from goals-render.js, one file down.
7
+ //
8
+ // NOT a band column, unlike Completion/Members/Conflicts on the same detail
9
+ // page: a node-link picture needs real width, and this is bounded small BY
10
+ // DESIGN (ADR 0227 §3's own finding — the tasks a forest omits are exactly
11
+ // the ones with nothing to connect them to), so there is no cap-and-"view
12
+ // all" split here. What renders is always the whole forest returned by GET
13
+ // /goals/:id/dependency-forest. The model + layout are goal-forest-lib.js
14
+ // (pure); this file only draws what it returns, with createElementNS +
15
+ // textContent — the atlas / goal-map.js idiom — so a task title never
16
+ // becomes markup.
17
+ //
18
+ // A factory, the goals-render.js precedent, because `state` keeps changing
19
+ // after load: `createGoalForestRender(ctx)` takes the SAME `{ API, state,
20
+ // sameId, getJSON }` slice of ctx goals-page.js already builds, and returns
21
+ // `{ loadDependencyForest, renderDependencyForest }` for goals-render.js to
22
+ // call from renderDetail() and for tests/goal_forest_page.mjs to call directly.
23
+ (function () {
24
+ 'use strict';
25
+
26
+ const SVGNS = 'http://www.w3.org/2000/svg';
27
+
28
+ window.OTBGoalForestRender = function createGoalForestRender(ctx) {
29
+ const { API, state, sameId, getJSON } = ctx;
30
+ const { escapeHtml, $ } = window.OTB;
31
+ const lib = window.OTBGoalForestLib;
32
+
33
+ function setCount(sel, n) {
34
+ const el = $(sel);
35
+ if (!el) return;
36
+ el.hidden = false;
37
+ el.textContent = String(n);
38
+ }
39
+
40
+ function svgEl(tag, attrs, parent) {
41
+ const el = document.createElementNS(SVGNS, tag);
42
+ Object.keys(attrs || {}).forEach((k) => el.setAttribute(k, attrs[k]));
43
+ if (parent) parent.appendChild(el);
44
+ return el;
45
+ }
46
+ function text(parent, cls, x, y, content) {
47
+ const t = svgEl('text', { class: cls, x, y }, parent);
48
+ t.textContent = content;
49
+ return t;
50
+ }
51
+ function drawForest(a, lay) {
52
+ const svg = svgEl('svg', {
53
+ class: 'gf-svg', viewBox: `0 0 ${lay.width} ${lay.height}`, width: lay.width, height: lay.height,
54
+ role: 'img', 'aria-labelledby': 'goal-deps-h', focusable: 'false',
55
+ });
56
+ const defs = svgEl('defs', {}, svg);
57
+ const marker = svgEl('marker', { id: 'gf-arrow', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: 'auto' }, defs);
58
+ svgEl('path', { class: 'gf-arrowhead', d: 'M0 0 L10 5 L0 10 z' }, marker);
59
+
60
+ const edges = svgEl('g', { class: 'gf-edges' }, svg);
61
+ lay.edges.forEach((e) => {
62
+ const path = svgEl('path', { class: 'gf-edge', d: e.d, 'marker-end': 'url(#gf-arrow)' }, edges);
63
+ const dependent = a.byId[e.from];
64
+ const dependency = a.byId[e.to];
65
+ svgEl('title', {}, path).textContent = `#${e.from} ${dependent ? dependent.title : ''} waits on #${e.to} ${dependency ? dependency.title : ''}`;
66
+ });
67
+
68
+ const nodes = svgEl('g', { class: 'gf-nodes' }, svg);
69
+ lay.nodes.forEach((p) => {
70
+ const n = a.byId[p.id];
71
+ const g = svgEl('g', { class: 'gf-node', transform: `translate(${p.x} ${p.y})` }, nodes);
72
+ svgEl('title', {}, g).textContent = `#${p.id} ${n.title || ''} — ${n.status || ''} — waits on ${n.depends_on || 0}, needed by ${n.depended_on_by || 0}`;
73
+ svgEl('rect', { class: 'gf-card', width: p.w, height: p.h, rx: 8 }, g);
74
+ text(g, 'gf-name', 16, 21, `#${p.id} ${lib.clip(n.title || '', lib.TITLE_CHARS)}`);
75
+ text(g, 'gf-sub', 16, 37, `${lib.priorityLabel(n.priority)} · ${n.status || ''}`);
76
+ });
77
+ return svg;
78
+ }
79
+
80
+ function renderDependencyForest() {
81
+ const lede = $('#goal-deps-lede');
82
+ const body = $('#goal-deps-body');
83
+ if (!lede || !body) return;
84
+ if (state.forestError) {
85
+ lede.textContent = 'Couldn’t load this goal’s task dependencies right now.';
86
+ body.innerHTML = '';
87
+ const countPill = $('#goal-deps-count');
88
+ if (countPill) countPill.hidden = true;
89
+ return;
90
+ }
91
+ if (!state.forest) {
92
+ lede.textContent = 'Reading…';
93
+ body.innerHTML = '';
94
+ return;
95
+ }
96
+ const a = lib.analyse(state.forest);
97
+ const countPill = $('#goal-deps-count');
98
+ if (!a.order.length) {
99
+ if (countPill) countPill.hidden = true;
100
+ lede.textContent = 'No task in this goal depends on another task in this goal.';
101
+ body.innerHTML = '';
102
+ return;
103
+ }
104
+ setCount('#goal-deps-count', a.order.length);
105
+ lede.innerHTML = `${escapeHtml(lib.describe(a))} An arrow runs from the task that has to ship first to the task that waits on it. `
106
+ + 'Every task here, and every dependency, is also in the table below.';
107
+ const rows = lib.tableRows(a);
108
+ const taskHref = (id) => `#/task/${encodeURIComponent(id)}`;
109
+ const depList = (list) => (list.length
110
+ ? `<ul class="gf-deps">${list.map((x) => `<li><a href="${taskHref(x.id)}">#${escapeHtml(x.id)} ${escapeHtml(x.title)}</a></li>`).join('')}</ul>`
111
+ : '<span class="gf-none">—</span>');
112
+ const rowHtml = (r) => `<tr>
113
+ <td><a class="lb-name" href="${taskHref(r.id)}">#${escapeHtml(r.id)} ${escapeHtml(r.title)}</a></td>
114
+ <td>${escapeHtml(r.status)}</td>
115
+ <td>${escapeHtml(r.priority)}</td>
116
+ <td>${depList(r.waits)}</td>
117
+ <td>${depList(r.needed)}</td>
118
+ <td>${escapeHtml(r.chainLabel)}</td>
119
+ </tr>`;
120
+ body.innerHTML = `
121
+ <figure class="gf-figure">
122
+ <div class="gf-scroll" tabindex="0" role="region" aria-label="Task dependency forest — scrolls sideways inside this box"></div>
123
+ </figure>
124
+ <table class="ledger gf-table">
125
+ <thead><tr><th>Task</th><th>Status</th><th>Priority</th><th>Waits on</th><th>Needed by</th><th>Chain</th></tr></thead>
126
+ <tbody>${rows.map(rowHtml).join('')}</tbody>
127
+ </table>`;
128
+ body.querySelector('.gf-scroll').appendChild(drawForest(a, lib.layout(a)));
129
+ }
130
+
131
+ async function loadDependencyForest(goalId) {
132
+ state.forest = null;
133
+ state.forestError = false;
134
+ renderDependencyForest();
135
+ try {
136
+ const data = await getJSON(`${API}/goals/${encodeURIComponent(goalId)}/dependency-forest`);
137
+ if (!sameId(state.current, goalId)) return;
138
+ state.forest = data;
139
+ renderDependencyForest();
140
+ } catch (err) {
141
+ if (!sameId(state.current, goalId)) return;
142
+ if (err && err.message === 'redirecting to auth') return;
143
+ console.error('[goals] dependency forest load failed', err);
144
+ state.forestError = true;
145
+ renderDependencyForest();
146
+ }
147
+ }
148
+
149
+ return { loadDependencyForest, renderDependencyForest };
150
+ };
151
+ })();
@@ -86,6 +86,12 @@
86
86
  // active" sort costs no extra request. A goal with no tasks is absent here
87
87
  // and falls back to its own goals.updated_at (see goalActivityMs).
88
88
  activity: new Map(),
89
+ // task 1003480 — the open goal's per-task dependency forest (GET
90
+ // /goals/:id/dependency-forest), the ADR 0227 §6 item 4 drill-down. null
91
+ // while loading; forestError distinguishes "still loading" from "the read
92
+ // failed" so the section can say which rather than looking permanently empty.
93
+ forest: null,
94
+ forestError: false,
89
95
  };
90
96
 
91
97
  async function getJSON(path) {
@@ -29,6 +29,7 @@
29
29
  const { DONE_STATUSES, CUT_STATUSES, isTodoStatus, ring, memberName,
30
30
  readinessOf, EMPTY_ROLLUP, goalRollLine, matchesTodoSearch,
31
31
  msOf, taskActivityMs, subtitleAttr, subtitleLine, applyGoalProse } = window.OTBGoalsLib;
32
+ const forestRender = window.OTBGoalForestRender(ctx);
32
33
 
33
34
  const CONFLICT_CAP = 3;
34
35
  // task 1828 — the band's Completion / Members / Conflicts columns each cap
@@ -622,6 +623,12 @@
622
623
  const back = $('#goal-conflicts-back');
623
624
  if (back && state.current != null) back.setAttribute('href', `#/goal/${state.current}`);
624
625
  }
626
+ // task 1003480 — the per-goal task dependency forest is drawn by its own
627
+ // module now (goal-forest-render.js, carved out to keep this file under the
628
+ // ratchet); `forestRender` below is that module's factory, given the same
629
+ // ctx slice it needs.
630
+ const { loadDependencyForest, renderDependencyForest } = forestRender;
631
+
625
632
  const isPending = (c) => !c.satisfied && !!c.pending_review;
626
633
  // criterionlessTaskCount: task 1003157's DONE WHEN item 2 — the count of this
627
634
  // goal's tasks linked to no criterion, rendered beside the criteria rollup so
@@ -901,6 +908,7 @@
901
908
  renderMembers();
902
909
 
903
910
  loadGoalTasks(g.id);
911
+ loadDependencyForest(g.id);
904
912
  // task 1953 — my own pending request (any viewer) + the goal's pending queue
905
913
  // (managers only); async, re-renders Actions + the pending panel on arrival.
906
914
  loadGoalMembership(g.id, canManage);
@@ -1461,6 +1469,6 @@
1461
1469
  return d;
1462
1470
  }
1463
1471
 
1464
- return { GOAL_SORTS, WORK_SORT_HEADERS, errCode, errDetails, friendlyClaimError, goalSel, goalSelTotal, loadDetail, loadDirectory, loadGoalTasks, pagers, reflectSortHeaders, renderDetail, renderGoalBulkToolbar, renderList, renderTodoCards, renderWork };
1472
+ return { GOAL_SORTS, WORK_SORT_HEADERS, errCode, errDetails, friendlyClaimError, goalSel, goalSelTotal, loadDependencyForest, loadDetail, loadDirectory, loadGoalTasks, pagers, reflectSortHeaders, renderDependencyForest, renderDetail, renderGoalBulkToolbar, renderList, renderTodoCards, renderWork };
1465
1473
  };
1466
1474
  })();
@@ -483,3 +483,29 @@
483
483
  .arch-panel__foot { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
484
484
  .arch-panel__foot input { flex: 1; min-width: 200px; }
485
485
  .arch-panel__err { margin-top: 8px; font-size: 12.5px; color: var(--danger); }
486
+
487
+ /* task 1003480 — the per-goal task dependency forest (ADR 0227 §6 item 4). The
488
+ TABLE is the section (below); this is only the node-link figure, drawn with
489
+ createElementNS (goal-map.css's sibling — a plain card + edge, no category
490
+ colour channel here, since a task carries a priority, not a category).
491
+ The figure scrolls INSIDE its own box and names BOTH overflow axes (task
492
+ 1004130) so the page itself never scrolls sideways. */
493
+ .gf-figure { margin: 0 0 14px; }
494
+ .gf-scroll {
495
+ overflow-x: auto;
496
+ overflow-y: hidden;
497
+ max-width: 100%;
498
+ border: var(--line) solid var(--rule-soft);
499
+ border-radius: var(--radius);
500
+ background: color-mix(in srgb, var(--ink) 3%, var(--bg-card));
501
+ }
502
+ .gf-scroll:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
503
+ .gf-svg { display: block; }
504
+ .gf-edge { fill: none; stroke: var(--ink-faint); stroke-width: 1.5; opacity: 0.85; }
505
+ .gf-arrowhead { fill: var(--ink-faint); }
506
+ .gf-card { fill: var(--bg-card); stroke: var(--rule); stroke-width: 1; }
507
+ .gf-name { font: 600 12.5px var(--font-body); fill: var(--ink); }
508
+ .gf-sub { font: 500 11px var(--font-body); fill: var(--ink-faint); }
509
+ .gf-table .gf-deps { margin: 0; padding: 0; list-style: none; min-width: 12rem; }
510
+ .gf-table .gf-deps li { font-size: 12.5px; line-height: 1.4; }
511
+ .gf-none { color: var(--ink-faint); }
@@ -130,6 +130,18 @@
130
130
  </div>
131
131
  </div>
132
132
 
133
+ <!-- task 1003480 (ADR 0227 §6 item 4 + §10.5): the per-goal task
134
+ dependency forest. Its own full-width section, not a band
135
+ column — a node-link picture needs real width, and (unlike
136
+ Completion/Members/Conflicts) this is bounded small BY DESIGN
137
+ (ADR 0227 §3's own finding), so there is no "view all" split:
138
+ what renders here is the whole forest, every time. -->
139
+ <div class="scroll" style="margin-top:20px" id="goal-deps-scroll">
140
+ <h2 class="scroll__h" id="goal-deps-h">Task dependencies<span class="count-pill" id="goal-deps-count" hidden></span></h2>
141
+ <p class="scroll__lede" id="goal-deps-lede">Reading…</p>
142
+ <div id="goal-deps-body"></div>
143
+ </div>
144
+
133
145
  <div class="scroll" style="margin-top:20px">
134
146
  <div class="goal-work__head">
135
147
  <h2 class="scroll__h">Tasks</h2>
@@ -234,6 +246,13 @@
234
246
  <script src="/builders/task-detail.js?v=2026-07-03-taskdetail"></script>
235
247
  <script src="/builders/brand-apply.js?v=2026-06-27-r75"></script>
236
248
  <script src="/builders/goals-lib.js?v=2026-08-21-goalslib1"></script>
249
+ <!-- task 1003480: the per-goal dependency forest's pure model + layout —
250
+ goal-map-lib.js's sibling, loaded before the renderer that draws it. -->
251
+ <script src="/builders/goal-forest-lib.js"></script>
252
+ <!-- task 1003480: the forest's own drawing module — goal-map.js's sibling,
253
+ carved out of goals-render.js to keep that file under the size ratchet.
254
+ Loaded before goals-render.js, which calls window.OTBGoalForestRender(ctx). -->
255
+ <script src="/builders/goal-forest-render.js"></script>
237
256
  <!-- task 1003206: the page RENDERERS, the second half of the carve goals-lib.js
238
257
  began — goals-lib took the pure helpers, this takes the view painters.
239
258
  Loaded BEFORE goals-page.js, which calls window.OTBGoalsRender(ctx). -->
@@ -0,0 +1,135 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/goal-forest.js — the per-goal dependency forest: the DRILL-
4
+ // DOWN behind GET /goals/:id/dependency-forest (task 1003480, goal 1000089,
5
+ // ADR 0227 §6 item 4 and its §10.5 amendment).
6
+ //
7
+ // NOT A ZOOM OF THE GOAL MAP (ADR 0227 §1). goal-graph.js draws goal-to-goal
8
+ // edges, aggregated; this draws the TASK-to-task edges INSIDE one goal, at full
9
+ // resolution — queried only when that one goal is opened, never as a slice of a
10
+ // global read. A dependency whose two tasks sit in DIFFERENT goals belongs to
11
+ // the goal map instead (goal-graph.js's own header says exactly the reverse of
12
+ // this filter), so the two views partition the corpus rather than overlapping.
13
+ //
14
+ // SCOPED, NOT SAMPLED. ADR 0227 §3 measured this as covering 3–40% of the
15
+ // corpus and never 100%, and §10.5 confirmed it against the real database
16
+ // (23.61% of tasks carry an edge) — that is the finding, not a gap. The tasks
17
+ // left out are exactly the ones with nothing to connect them to, so this file
18
+ // returns ONLY the tasks that have an edge within this goal; a goal whose tasks
19
+ // carry none returns an empty forest and the page says so plainly.
20
+ //
21
+ // A private goal's own gate has already run by the time this is asked for — a
22
+ // forest is requested from an already-open goal detail page, never cold — so
23
+ // this file re-derives no visibility of its own.
24
+
25
+ const api = require('../../src/module-api');
26
+ const { pool } = api;
27
+
28
+ // goalForestRows — the two raw reads. `edges`: one row per task-level dependency
29
+ // whose BOTH endpoints sit in `goalId` (the ADR 0227 §10.5 measurement's own
30
+ // filter — see scripts/gds/spike-task-map.js's per-goal grouping). `tasks`: the
31
+ // facts for exactly the task ids that appear in at least one such edge — never
32
+ // the goal's whole task list, which the page already holds from GET
33
+ // /tasks?goal_id=.
34
+ async function goalForestRows(goalId, deps = {}) {
35
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
36
+ const edges = await activePool.query(
37
+ `SELECT ft.id::text AS from_task_id,
38
+ tt.id::text AS to_task_id
39
+ FROM dependencies d
40
+ JOIN tasks ft ON d.from_kind = 'task' AND ft.id = d.from_id
41
+ JOIN tasks tt ON d.to_kind = 'task' AND tt.id = d.to_id
42
+ WHERE ft.goal_id = $1 AND tt.goal_id = $1
43
+ ORDER BY ft.id, tt.id`,
44
+ [goalId]
45
+ );
46
+ const ids = new Set();
47
+ for (const r of edges.rows) { ids.add(r.from_task_id); ids.add(r.to_task_id); }
48
+ if (!ids.size) return { edges: [], tasks: [] };
49
+ const tasks = await activePool.query(
50
+ `SELECT id::text, title, status, priority
51
+ FROM tasks
52
+ WHERE id = ANY($1::bigint[])
53
+ ORDER BY id`,
54
+ [[...ids].map(Number)]
55
+ );
56
+ return { edges: edges.rows, tasks: tasks.rows };
57
+ }
58
+
59
+ // shapeForest — the wire shape. Pure, so a test pins it with no database.
60
+ //
61
+ // nodes: [{ task_id, title, status, priority, depends_on, depended_on_by }]
62
+ // edges: [{ from, to }] — `from` waits on `to`
63
+ // counts: { tasks, edges }
64
+ //
65
+ // An edge whose endpoint task is missing from `tasks` (a stale row racing a
66
+ // delete) is dropped, not drawn to nowhere — the same rule goal-graph.js's
67
+ // shapeGraph applies for the same reason.
68
+ function shapeForest({ edges = [], tasks = [] } = {}) {
69
+ const known = new Set(tasks.map((t) => String(t.id)));
70
+ const kept = [];
71
+ const seen = new Set();
72
+ for (const e of edges) {
73
+ const from = String(e.from_task_id);
74
+ const to = String(e.to_task_id);
75
+ if (from === to || !known.has(from) || !known.has(to)) continue;
76
+ const key = `${from}>${to}`;
77
+ if (seen.has(key)) continue;
78
+ seen.add(key);
79
+ kept.push({ from, to });
80
+ }
81
+
82
+ const waitsOn = new Map();
83
+ const blocks = new Map();
84
+ for (const e of kept) {
85
+ if (!waitsOn.has(e.from)) waitsOn.set(e.from, new Set());
86
+ waitsOn.get(e.from).add(e.to);
87
+ if (!blocks.has(e.to)) blocks.set(e.to, new Set());
88
+ blocks.get(e.to).add(e.from);
89
+ }
90
+
91
+ // Only tasks touched by a KEPT edge become nodes — a task whose only edge was
92
+ // dropped above (stale endpoint) must not appear as a lone, edge-less node.
93
+ const touched = new Set();
94
+ kept.forEach((e) => { touched.add(e.from); touched.add(e.to); });
95
+ const nodes = tasks.filter((t) => touched.has(String(t.id))).map((t) => {
96
+ const id = String(t.id);
97
+ return {
98
+ task_id: id,
99
+ title: t.title,
100
+ status: t.status,
101
+ priority: t.priority != null ? Number(t.priority) : null,
102
+ depends_on: (waitsOn.get(id) || new Set()).size,
103
+ depended_on_by: (blocks.get(id) || new Set()).size,
104
+ };
105
+ });
106
+
107
+ return { nodes, edges: kept, counts: { tasks: nodes.length, edges: kept.length } };
108
+ }
109
+
110
+ // goalForest — read + shape.
111
+ async function goalForest(goalId, deps = {}) {
112
+ const rows = await goalForestRows(goalId, deps);
113
+ return shapeForest(rows);
114
+ }
115
+
116
+ // goalForestHandler — GET /goals/:id/dependency-forest. A factory (the
117
+ // goal-category.js / goal-graph.js precedent) so a test drives it with a stub
118
+ // read and no database.
119
+ function goalForestHandler({ getGoal, read = goalForest, parseId, log = console } = {}) {
120
+ return async function getGoalForest(req, res) {
121
+ const id = parseId(req, res, { code: 'bad_goal_id' });
122
+ if (id === null) return;
123
+ try {
124
+ const goal = await getGoal(id);
125
+ if (!goal) return res.fail('goal_not_found', 404);
126
+ const forest = await read(id);
127
+ return res.json({ ok: true, goal_id: Number(id), ...forest });
128
+ } catch (err) {
129
+ log.error('[gds] GET /goals/:id/dependency-forest', err);
130
+ return res.fail('forest_failed', { status: 500, message: 'internal error' });
131
+ }
132
+ };
133
+ }
134
+
135
+ module.exports = { goalForest, goalForestRows, shapeForest, goalForestHandler };