@bongos/core 1.21.132 → 1.21.133

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,197 @@
1
+ // goal-author.js — the Goals page's "author a goal / add a done-when criterion"
2
+ // panel (task 1003463, goal 1000089; ADR 0366 D1.2/D1.3 + D3). The hall could
3
+ // SATISFY a criterion and never AUTHOR one, and had no POST /goals call at all.
4
+ //
5
+ // Two forms, nothing else:
6
+ // · GOAL — a name, a one-line subtitle, an optional longer description, the
7
+ // version, an optional category. NO scope field: the goal is created with
8
+ // spec_task:true, so it arrives with a "Spec goal N" task that gates its other
9
+ // work, and the scope is set when that task is done. Before submit, the
10
+ // suggester's matching open goals are shown so reuse is a click.
11
+ // · CRITERION — the outcome in a sentence; the slug is derived from it.
12
+ //
13
+ // The authority walls stay on the SERVER and this file lets them speak: it sends
14
+ // what was typed and renders the refusal. It never hides the version a Metic
15
+ // "cannot" use or the goal a non-member "cannot" add to — pre-filtering would turn
16
+ // a clear refusal into a form that quietly lacks the option (ADR 0086, ADR 0154).
17
+ //
18
+ // Steps that are interviews — planning a version, inferring scope, drafting
19
+ // criteria or seed tasks, judging reuse, carrying forward — are deliberately
20
+ // absent (ADR 0366 D2). The pure half (bodies, slug, refusal copy) is
21
+ // goal-author-lib.js; this file is the DOM and the two calls.
22
+ (function () {
23
+ 'use strict';
24
+ const lib = window.OTBGoalAuthor;
25
+ const { escapeHtml } = window.OTB;
26
+
27
+ // mount({ root, api, API, toast, getGoals }) — `api` is the page's typed client
28
+ // (api.request(method, path, { body }) → { status, ok, data }), `getGoals` the
29
+ // page's loaded goal rows (so the criterion form lists the goals already on the
30
+ // board without a second fetch).
31
+ function mount(opts) {
32
+ const { root, api, API, toast, getGoals } = opts;
33
+ if (!root || !lib) return;
34
+ let loaded = false;
35
+ let timer = null;
36
+ let seq = 0;
37
+
38
+ root.innerHTML = `
39
+ <button type="button" class="btn goal-author__open" id="ga-open" aria-expanded="false" aria-controls="ga-panel">New goal</button>
40
+ <section class="scroll goal-author" id="ga-panel" aria-labelledby="ga-h" hidden>
41
+ <h2 class="scroll__h" id="ga-h">Author a goal</h2>
42
+ <form id="ga-goal" class="goal-author__form" novalidate>
43
+ <label class="goal-author__field">Name
44
+ <input id="ga-title" type="text" maxlength="${lib.TITLE_MAX}" autocomplete="off" required>
45
+ </label>
46
+ <label class="goal-author__field">What it is for <span class="goal-author__hint">One line, under the name</span>
47
+ <input id="ga-subtitle" type="text" maxlength="${lib.TITLE_MAX}" autocomplete="off">
48
+ </label>
49
+ <label class="goal-author__field">Description <span class="goal-author__hint">optional</span>
50
+ <textarea id="ga-description" rows="3"></textarea>
51
+ </label>
52
+ <div class="goal-author__row">
53
+ <label class="goal-author__field">Version
54
+ <select id="ga-version" required><option value="">Loading…</option></select>
55
+ </label>
56
+ <label class="goal-author__field">Category <span class="goal-author__hint">optional</span>
57
+ <select id="ga-category"><option value="">— none —</option></select>
58
+ </label>
59
+ </div>
60
+ <div id="ga-suggest" class="goal-author__suggest" aria-live="polite" hidden></div>
61
+ <p class="goal-author__note">Scope is not asked for here: the goal gets a “Spec goal” task, and the scope is set when that task is done.</p>
62
+ <p class="goal-author__err" id="ga-goal-err" role="alert" hidden></p>
63
+ <button type="submit" class="btn btn--primary" id="ga-goal-go">Create goal</button>
64
+ </form>
65
+
66
+ <h2 class="scroll__h goal-author__h2" id="ga-crit-h">Add a done-when criterion</h2>
67
+ <form id="ga-crit" class="goal-author__form" novalidate aria-labelledby="ga-crit-h">
68
+ <label class="goal-author__field">Goal
69
+ <select id="ga-crit-goal" required><option value="">— pick a goal —</option></select>
70
+ </label>
71
+ <label class="goal-author__field">What will be true when it is done
72
+ <textarea id="ga-crit-text" rows="2"></textarea>
73
+ </label>
74
+ <p class="goal-author__note" id="ga-crit-slug" aria-live="polite"></p>
75
+ <p class="goal-author__err" id="ga-crit-err" role="alert" hidden></p>
76
+ <button type="submit" class="btn btn--primary" id="ga-crit-go">Add criterion</button>
77
+ </form>
78
+ </section>`;
79
+
80
+ const $q = (id) => root.querySelector(`#${id}`);
81
+ const panel = $q('ga-panel');
82
+ const openBtn = $q('ga-open');
83
+
84
+ const showErr = (id, text) => { const el = $q(id); el.textContent = text || ''; el.hidden = !text; };
85
+
86
+ async function get(path) {
87
+ try { return await api.request('GET', path, {}); } catch (_) { return { ok: false, status: 0, data: {} }; }
88
+ }
89
+
90
+ function fillGoalChoices() {
91
+ const sel = $q('ga-crit-goal');
92
+ const keep = sel.value;
93
+ const goals = (getGoals() || []).filter((g) => g && g.status === 'open');
94
+ sel.innerHTML = '<option value="">— pick a goal —</option>' + goals.map((g) =>
95
+ `<option value="${escapeHtml(String(g.id))}" data-version="${escapeHtml(String(g.version_id || ''))}">${escapeHtml(`${g.version_id || ''} · ${g.title}`)}</option>`).join('');
96
+ sel.value = keep;
97
+ }
98
+
99
+ async function loadChoices() {
100
+ if (loaded) return;
101
+ const [v, c] = await Promise.all([get(`${API}/versions`), get(`${API}/work-categories`)]);
102
+ if (!v.ok) { showErr('ga-goal-err', 'Could not load the versions. Close and reopen this panel to try again.'); return; }
103
+ loaded = true;
104
+ const versions = lib.openVersions(v.data && v.data.versions);
105
+ const vsel = $q('ga-version');
106
+ vsel.innerHTML = '<option value="">— pick a version —</option>' + versions.map((x) =>
107
+ `<option value="${escapeHtml(x.id)}">${escapeHtml(x.label)}</option>`).join('');
108
+ const cats = (c.data && c.data.categories) || [];
109
+ $q('ga-category').innerHTML = '<option value="">— none —</option>' + cats.map((x) =>
110
+ `<option value="${escapeHtml(String(x.id))}">${escapeHtml(x.label)}</option>`).join('');
111
+ }
112
+
113
+ // Reuse is a click: the suggester's open goals for this draft, linked to their
114
+ // detail view. Best-effort — a failed or empty read just hides the strip.
115
+ async function suggest() {
116
+ const box = $q('ga-suggest');
117
+ const version = $q('ga-version').value;
118
+ const title = $q('ga-title').value.trim();
119
+ const mine = ++seq;
120
+ if (!version || !title) { box.hidden = true; return; }
121
+ const r = await get(`${API}/goals/suggestions?version=${encodeURIComponent(version)}&title=${encodeURIComponent(title)}&kind=feature&limit=5`);
122
+ if (mine !== seq) return;
123
+ const rows = (r.ok && r.data && Array.isArray(r.data.suggestions)) ? r.data.suggestions : [];
124
+ if (!rows.length) { box.hidden = true; box.innerHTML = ''; return; }
125
+ box.innerHTML = '<p class="goal-author__hint">These open goals look related — use one instead?</p><ul>' + rows.map((g) =>
126
+ `<li><a href="#/goal/${encodeURIComponent(String(g.id))}">${escapeHtml(g.title || `Goal ${g.id}`)}</a></li>`).join('') + '</ul>';
127
+ box.hidden = false;
128
+ }
129
+ const suggestSoon = () => { clearTimeout(timer); timer = setTimeout(suggest, 350); };
130
+
131
+ function setOpen(open) {
132
+ panel.hidden = !open;
133
+ openBtn.setAttribute('aria-expanded', String(open));
134
+ if (open) { loadChoices(); fillGoalChoices(); $q('ga-title').focus(); }
135
+ }
136
+ openBtn.addEventListener('click', () => setOpen(panel.hidden));
137
+
138
+ $q('ga-title').addEventListener('input', suggestSoon);
139
+ $q('ga-version').addEventListener('change', suggestSoon);
140
+
141
+ $q('ga-goal').addEventListener('submit', async (e) => {
142
+ e.preventDefault();
143
+ showErr('ga-goal-err', '');
144
+ const fields = {
145
+ version_id: $q('ga-version').value, title: $q('ga-title').value,
146
+ subtitle: $q('ga-subtitle').value, description: $q('ga-description').value,
147
+ category_id: $q('ga-category').value,
148
+ };
149
+ const problem = lib.goalFormProblem(fields);
150
+ if (problem) return showErr('ga-goal-err', problem);
151
+ const btn = $q('ga-goal-go');
152
+ btn.disabled = true;
153
+ try {
154
+ const res = await api.request('POST', `${API}/goals`, { body: lib.buildGoalBody(fields) });
155
+ if (!res.ok) return showErr('ga-goal-err', lib.refusalText(res.status, res.data));
156
+ const goal = (res.data && res.data.goal) || {};
157
+ // The board derives everything from one boot read, so reload onto the new
158
+ // goal (a toast would be destroyed on the same tick).
159
+ if (goal.id != null) window.location.hash = `#/goal/${goal.id}`;
160
+ window.location.reload();
161
+ } catch (_) {
162
+ showErr('ga-goal-err', 'Could not reach the server. Try again in a moment.');
163
+ } finally { btn.disabled = false; }
164
+ });
165
+
166
+ const slugNote = () => {
167
+ const slug = lib.criterionSlug($q('ga-crit-text').value);
168
+ $q('ga-crit-slug').textContent = slug ? `Filed as: ${slug}` : '';
169
+ };
170
+ $q('ga-crit-text').addEventListener('input', slugNote);
171
+
172
+ $q('ga-crit').addEventListener('submit', async (e) => {
173
+ e.preventDefault();
174
+ showErr('ga-crit-err', '');
175
+ const sel = $q('ga-crit-goal');
176
+ const opt = sel.options[sel.selectedIndex];
177
+ const fields = {
178
+ goal_id: sel.value, version_id: opt ? opt.getAttribute('data-version') : '',
179
+ outcome: $q('ga-crit-text').value,
180
+ };
181
+ const problem = lib.criterionFormProblem(fields);
182
+ if (problem) return showErr('ga-crit-err', problem);
183
+ const req = lib.buildCriterionRequest(fields);
184
+ const btn = $q('ga-crit-go');
185
+ btn.disabled = true;
186
+ try {
187
+ const res = await api.request('POST', `${API}${req.path}`, { body: req.body });
188
+ if (!res.ok) return showErr('ga-crit-err', lib.refusalText(res.status, res.data));
189
+ window.location.reload();
190
+ } catch (_) {
191
+ showErr('ga-crit-err', 'Could not reach the server. Try again in a moment.');
192
+ } finally { btn.disabled = false; }
193
+ });
194
+ }
195
+
196
+ window.OTBGoalAuthorUI = { mount };
197
+ })();
@@ -842,6 +842,9 @@
842
842
  }
843
843
 
844
844
  async function boot() {
845
+ // task 1003463 — authoring a goal / criterion. Optional-called: a stale cached
846
+ // page without goal-author.js loses the button, never the board.
847
+ window.OTBGoalAuthorUI?.mount({ root: $('#goal-author-mount'), api, API, toast, getGoals: () => state.goals });
845
848
  // Kit shimmer placeholders while the goal list loads (was one "…" div).
846
849
  const grid = $('#goal-grid');
847
850
  if (grid) {
@@ -563,3 +563,16 @@
563
563
  .gf-brief__n { flex-shrink: 0; font-size: 12px; color: var(--ink-faint); }
564
564
  .gf-brief__conflicts { margin: 10px 0 0; font-size: 13px; color: var(--ink-soft); }
565
565
  .gf-brief__conflicts--live { color: var(--warn); font-weight: 600; }
566
+
567
+ /* task 1003463 — the author-a-goal / add-a-criterion panel (goal-author.js). */
568
+ .goal-author { margin-top: 12px; }
569
+ .goal-author__form { display: grid; gap: 12px; max-width: 640px; }
570
+ .goal-author__h2 { margin-top: 20px; }
571
+ .goal-author__field { display: grid; gap: 4px; }
572
+ .goal-author__field input, .goal-author__field select, .goal-author__field textarea { width: 100%; box-sizing: border-box; }
573
+ .goal-author__row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; }
574
+ .goal-author__hint, .goal-author__note { color: var(--ink-soft); font-size: 12.5px; margin: 0; }
575
+ .goal-author__suggest ul { margin: 4px 0 0; padding-left: 18px; }
576
+ .goal-author__suggest li a { display: inline-block; min-height: 24px; line-height: 24px; }
577
+ .goal-author__err { color: var(--danger); margin: 0; }
578
+ .goal-author__open { margin-top: 8px; }
@@ -37,6 +37,8 @@
37
37
  <!-- task 1003479: the goal map has no nav slot of its own (the sky's rule —
38
38
  a map that is reachable but not load-bearing), so this is its door. -->
39
39
  <p class="page-head__sub"><a href="/builders/goal-map">See which goals wait on each other →</a></p>
40
+ <!-- task 1003463: author a goal / a done-when criterion (goal-author.js). -->
41
+ <div id="goal-author-mount"></div>
40
42
  </div>
41
43
 
42
44
  <main id="hall-main">
@@ -260,6 +262,8 @@
260
262
  <!-- task 1004402: the criterion UAT pills, panel and sign-off (ADR 0351). Loaded
261
263
  BEFORE goals-render.js, which reads window.OTBCriterionUat at factory time. -->
262
264
  <script src="/builders/criterion-uat-ui.js?v=2026-09-30-uat2"></script>
265
+ <script src="/builders/goal-author-lib.js"></script>
266
+ <script src="/builders/goal-author.js"></script>
263
267
  <script src="/builders/goals-render.js?v=2026-09-30-uat2"></script>
264
268
  <script src="/builders/goals-page.js?v=2026-09-30-uat2"></script>
265
269
  </body>
@@ -14,7 +14,8 @@
14
14
  "/api/(gds|bongos)/tasks",
15
15
  "/api/(gds|bongos)/versions",
16
16
  "/api/(gds|bongos)/inbox/",
17
- "/api/(gds|bongos)/work-categories"
17
+ "/api/(gds|bongos)/work-categories",
18
+ "/api/(gds|bongos)/goals/suggestions"
18
19
  ],
19
20
  "ignoreConsole": [
20
21
  "/api/(gds|bongos)/",
@@ -184,6 +185,38 @@
184
185
  "#goal-completion-full .uat-form"
185
186
  ]
186
187
  }
188
+ },
189
+ "author": {
190
+ "auth": true,
191
+ "actions": [
192
+ [
193
+ "wait",
194
+ 600
195
+ ],
196
+ [
197
+ "click",
198
+ "#ga-open"
199
+ ],
200
+ [
201
+ "wait",
202
+ 300
203
+ ]
204
+ ],
205
+ "expect": {
206
+ "visible": [
207
+ "#ga-panel",
208
+ "#ga-title",
209
+ "#ga-version",
210
+ "#ga-crit-goal"
211
+ ],
212
+ "attr": [
213
+ [
214
+ "#ga-open",
215
+ "aria-expanded",
216
+ "true"
217
+ ]
218
+ ]
219
+ }
187
220
  }
188
221
  },
189
222
  "reducedMotion": {
@@ -0,0 +1,10 @@
1
+ # Authoring a goal and a done-when criterion — on the Goals page (task 1003463)
2
+
3
+ **The Goals page can AUTHOR now, not only satisfy (task 1003463, goal 1000089; ADR 0366 D1.2/D1.3/D2/D3).** A "New goal" button under the page head opens one panel with two forms. Pure half `goal-author-lib.js` (`window.OTBGoalAuthor`), DOM half `goal-author.js` (`window.OTBGoalAuthorUI`, mounted from `goals-page.js` behind an optional call so a stale cached page loses the button, never the board).
4
+
5
+ - **Goal form = name + subtitle + description + version + category, and NO scope field.** It always sends `spec_task: true` (task 1004585): the goal arrives with a "Spec goal N" task that gates its other work and the scope is set when that task is done. The name and subtitle are separate boxes — the goal board's convention (goal 1000072) is that a title does not carry its own explanation.
6
+ - **Reuse is a click.** As the name is typed (350ms debounce, stale answers dropped) the suggester's open goals for that version (`GET /goals/suggestions`) are linked to their detail view above the submit button. Best-effort: a failed read hides the strip.
7
+ - **Criterion form = outcome text; the slug is derived from it** and shown ("Filed as: …"). A slug shaped like a position (C1…) is prefixed, because the server refuses those. The goal's own version rides in the path (`POST /versions/:id/done-when`).
8
+ - **The two authority walls are the server's and the form does not soften them.** It offers every open version and every open goal, sends what was typed, and prints the server's own refusal sentence (`refusalText` only fills in a bare code). Pre-filtering "versions a Metic cannot use" or "goals you do not manage" would turn a clear refusal into a form that quietly lacks the option — the reason ADR 0154's mask was removed in task 1002614. `tests/goal_author_ui.mjs` drives the form's own request through the real goals and done-when routers: a Metic's protected-scope goal is still 403 `archon_required_for_protected_scope`, a non-member's criterion is still 403 `goal_not_yours`, a manager's succeeds.
9
+ - **Deliberately absent (ADR 0366 D2, interview-shaped):** version planning, scope inference, drafting criteria or seed tasks, reuse judgement, carry-forward, and the Archon admission into a building version. The test asserts the UI source names none of `scope_modules`, `succeeds_goal_id`, `admit_reason`.
10
+ - A successful create reloads the page (to `#/goal/N` for a goal) rather than patching `state.goals` by hand: the board's rollups, rings and filters all derive from one boot read.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.132",
3
+ "version": "1.21.133",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.132",
9
+ "version": "1.21.133",
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.21.132",
3
+ "version": "1.21.133",
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",
@@ -9794,5 +9794,11 @@
9794
9794
  "id": "1004743",
9795
9795
  "text": "A project whose GitHub sign-in already works now gets that step ticked on its setup checklist automatically, within about five minutes."
9796
9796
  }
9797
+ ],
9798
+ "1.21.133": [
9799
+ {
9800
+ "id": "1003463",
9801
+ "text": "The Goals page can now create a goal and add a done-when criterion to it."
9802
+ }
9797
9803
  ]
9798
9804
  }
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.132'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.133'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,210 @@
1
+ // tests/goal_author_ui.mjs — authoring a goal and a done-when criterion from the
2
+ // hall (task 1003463; ADR 0366 D1.2/D1.3/D2/D3). Three layers:
3
+ // 1. the pure lib (goal-author-lib.js) in a vm — the request BODIES the form
4
+ // sends, the derived slug, and how a refusal is put into words;
5
+ // 2. the page wiring, read as source — the form carries no scope field and no
6
+ // interview-shaped step;
7
+ // 3. the two server walls the form must not soften, driven with the form's OWN
8
+ // request through the assembled routers: a Metic's goal on a protected module
9
+ // is still refused, and a non-member's criterion is still refused.
10
+ //
11
+ // Run: node --test tests/goal_author_ui.mjs
12
+
13
+ import assert from 'node:assert/strict';
14
+ import { test } from 'node:test';
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import vm from 'node:vm';
18
+ import { createRequire } from 'node:module';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ process.env.NODE_ENV = 'test';
22
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
+ const PUB = path.join(ROOT, 'modules/hall-ui/public');
24
+ const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8');
25
+
26
+ const win = {};
27
+ const sandbox = { window: win, String, Number, Array, Object, JSON, Math, Date, Set, Map };
28
+ vm.createContext(sandbox);
29
+ vm.runInContext(read('goal-author-lib.js'), sandbox, { filename: 'goal-author-lib.js' });
30
+ const lib = win.OTBGoalAuthor;
31
+ // Objects built inside the vm are another realm's; compare their JSON.
32
+ const plain = (v) => JSON.parse(JSON.stringify(v));
33
+
34
+ // ── 1. the pure half ─────────────────────────────────────────────────────────
35
+
36
+ test('the goal body carries spec_task:true and NO scope, successor or admission', () => {
37
+ const body = lib.buildGoalBody({
38
+ version_id: ' V9 ', title: ' Hall goal ', subtitle: ' what it is for ', description: '', category_id: '3',
39
+ scope_modules: ['kernel'], succeeds_goal_id: 5, admit_reason: 'because reasons',
40
+ });
41
+ assert.deepEqual(plain(body), { version_id: 'V9', title: 'Hall goal', spec_task: true, subtitle: 'what it is for', category_id: 3 });
42
+ for (const k of ['scope_modules', 'succeeds_goal_id', 'admit_reason']) assert.ok(!(k in body), `${k} must not be sendable from this form`);
43
+ });
44
+
45
+ test('an empty subtitle, description and category are omitted rather than sent blank', () => {
46
+ const body = lib.buildGoalBody({ version_id: 'V9', title: 'x', subtitle: ' ', description: '', category_id: '' });
47
+ assert.deepEqual(plain(body), { version_id: 'V9', title: 'x', spec_task: true });
48
+ });
49
+
50
+ test('the goal form asks only for what the server checks first', () => {
51
+ assert.match(lib.goalFormProblem({ version_id: '', title: 'x' }), /version/i);
52
+ assert.match(lib.goalFormProblem({ version_id: 'V9', title: ' ' }), /name/i);
53
+ assert.match(lib.goalFormProblem({ version_id: 'V9', title: 'x'.repeat(lib.TITLE_MAX + 1) }), /under/);
54
+ assert.equal(lib.goalFormProblem({ version_id: 'V9', title: 'x' }), null);
55
+ });
56
+
57
+ test('the criterion slug is derived from the outcome text and is never positional', () => {
58
+ assert.equal(lib.criterionSlug('A goal and a criterion can be authored from the hall!'), 'a-goal-and-a-criterion-can-be-authored-from-the');
59
+ assert.equal(lib.criterionSlug('C7'), 'criterion-c7', 'a Cn-shaped slug is refused by the server, so it is never produced');
60
+ assert.equal(lib.criterionSlug('!!!'), '');
61
+ assert.ok(lib.criterionSlug('word '.repeat(40)).length <= 48);
62
+ assert.ok(!lib.criterionSlug('café déjà vu').match(/[^a-z0-9-]/));
63
+ });
64
+
65
+ test('the criterion request is POST /versions/<goal\'s version>/done-when with the goal named', () => {
66
+ const r = lib.buildCriterionRequest({ goal_id: '12', version_id: 'V 9', outcome: ' Hall can author a goal ' });
67
+ assert.equal(r.path, '/versions/V%209/done-when');
68
+ assert.deepEqual(plain(r.body), { criterion_id: 'hall-can-author-a-goal', criterion_md: 'Hall can author a goal', goal_id: 12 });
69
+ assert.match(lib.criterionFormProblem({ goal_id: '', outcome: 'x' }), /goal/i);
70
+ assert.match(lib.criterionFormProblem({ goal_id: '3', outcome: ' ' }), /true/i);
71
+ assert.equal(lib.criterionFormProblem({ goal_id: '3', outcome: 'x' }), null);
72
+ });
73
+
74
+ test('a refusal keeps the server\'s own sentence and only fills in a bare code', () => {
75
+ assert.equal(lib.refusalText(403, { error: { code: 'x', message: 'Only an Archon.' } }), 'Only an Archon.');
76
+ assert.match(lib.refusalText(403, { error: 'goal_not_yours', message: 'Ask the owner.' }), /owner/);
77
+ assert.match(lib.refusalText(403, { error: { code: 'archon_required_for_protected_scope', message: 'archon_required_for_protected_scope' } }), /Archon/);
78
+ assert.match(lib.refusalText(409, { error: 'criterion_exists' }), /already exists/);
79
+ assert.match(lib.refusalText(500, null), /did not go through/);
80
+ });
81
+
82
+ test('versions are listed by openness only — the rank wall is the server\'s', () => {
83
+ const v = lib.openVersions([{ id: 'A', status: 'shipped' }, { id: 'B', status: 'building' }, { id: 'C', status: 'planning' }, null]);
84
+ assert.deepEqual(plain(v).map((x) => x.id), ['B', 'C']);
85
+ });
86
+
87
+ // ── 2. the page wiring ───────────────────────────────────────────────────────
88
+
89
+ test('the form has no scope field and none of the interview-shaped steps', () => {
90
+ const ui = read('goal-author.js');
91
+ assert.ok(!/scope_modules|ga-scope|succeeds_goal_id|admit_reason/.test(ui), 'no scope / carry-forward / admission control');
92
+ assert.ok(!/\/goals\/\$\{[^}]*\}\/scope/.test(ui), 'the scope is set by the spec task, not here');
93
+ assert.match(ui, /goals\/suggestions\?version=/, 'matching open goals are shown before submit');
94
+ assert.match(ui, /\/goals`, \{ body: lib\.buildGoalBody/, 'POST /goals goes through the pure body builder');
95
+ });
96
+
97
+ test('goals.html loads the lib before the UI, and goals-page.js mounts it behind a guard', () => {
98
+ const html = read('goals.html');
99
+ const at = (f) => html.indexOf(`src="/builders/${f}"`);
100
+ assert.ok(at('goal-author-lib.js') > 0 && at('goal-author-lib.js') < at('goal-author.js'));
101
+ assert.ok(at('goal-author.js') < at(html.match(/src="\/builders\/(goals-page\.js[^"]*)"/)[1]));
102
+ assert.match(html, /id="goal-author-mount"/);
103
+ assert.match(read('goals-page.js'), /window\.OTBGoalAuthorUI\?\.mount\(/);
104
+ });
105
+
106
+ // ── 3. the walls, driven with the form's own request ─────────────────────────
107
+
108
+ const r2 = createRequire(import.meta.url);
109
+
110
+ async function withRouter(routeFile, rank, setup, fn) {
111
+ const auth = r2(path.join(ROOT, 'src/bongos/auth.js'));
112
+ const api = r2(path.join(ROOT, 'src/module-api.js'));
113
+ const db = r2(path.join(ROOT, 'modules/lifecycle/db.js'));
114
+ const routePath = r2.resolve(path.join(ROOT, routeFile));
115
+ const saved = { rb: auth.requireBuilder, rp: auth.requirePermission, tx: api.withTx };
116
+ const savedDb = {};
117
+ auth.requireBuilder = (req, _res, next) => { req.builder = { id: 7, rank }; next(); };
118
+ auth.requirePermission = () => (_req, _res, next) => next();
119
+ api.withTx = async (f) => f({ query: async () => ({ rows: [] }) });
120
+ const calls = setup(db, (k) => { savedDb[k] = savedDb[k] || db[k]; });
121
+ delete r2.cache[routePath];
122
+ const express = r2('express');
123
+ const { attachFail } = r2(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
124
+ const app = express();
125
+ app.use(express.json());
126
+ app.use(attachFail);
127
+ app.use(r2(routePath)());
128
+ const server = app.listen(0);
129
+ await new Promise((res) => server.once('listening', res));
130
+ const post = async (p, body) => {
131
+ const res = await fetch(`http://127.0.0.1:${server.address().port}${p}`, {
132
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
133
+ });
134
+ return { status: res.status, data: await res.json().catch(() => ({})) };
135
+ };
136
+ try { await fn(post, calls); } finally {
137
+ server.close();
138
+ auth.requireBuilder = saved.rb; auth.requirePermission = saved.rp; api.withTx = saved.tx;
139
+ for (const [k, v] of Object.entries(savedDb)) db[k] = v;
140
+ delete r2.cache[routePath];
141
+ }
142
+ }
143
+
144
+ const goalSetup = (db, keep) => {
145
+ const calls = { createGoal: 0 };
146
+ for (const k of ['getVersion', 'createGoal', 'createTask']) keep(k);
147
+ db.getVersion = async (id) => ({ id, status: 'planning' });
148
+ db.createGoal = async (f) => { calls.createGoal += 1; return { id: 42, version_id: f.versionId, title: f.title }; };
149
+ db.createTask = async (f) => ({ id: 500, ...f });
150
+ return calls;
151
+ };
152
+
153
+ test('wall: the form\'s own goal request succeeds for a Metic and creates the spec task', async () => {
154
+ await withRouter('modules/lifecycle/routes/goals.js', 'metic', goalSetup, async (post, calls) => {
155
+ const body = lib.buildGoalBody({ version_id: 'V9', title: 'Hall goal', subtitle: 'for the hall' });
156
+ const res = await post('/goals', body);
157
+ assert.equal(res.status, 201, JSON.stringify(res.data));
158
+ assert.equal(res.data.spec_task.id, 500);
159
+ assert.equal(calls.createGoal, 1);
160
+ });
161
+ });
162
+
163
+ test('wall: a Metic still cannot create a goal scoped to a protected module', async () => {
164
+ await withRouter('modules/lifecycle/routes/goals.js', 'metic', goalSetup, async (post, calls) => {
165
+ // The hall form cannot send this field; the server must refuse it anyway.
166
+ const res = await post('/goals', { ...lib.buildGoalBody({ version_id: 'V9', title: 'Hall goal' }), scope_modules: ['kernel'] });
167
+ assert.equal(res.status, 403);
168
+ assert.equal(res.data.error && res.data.error.code, 'archon_required_for_protected_scope');
169
+ assert.equal(calls.createGoal, 0);
170
+ assert.match(lib.refusalText(res.status, res.data), /Archon/);
171
+ });
172
+ });
173
+
174
+ const criterionSetup = (membership) => (db, keep) => {
175
+ const calls = { createCriterion: 0 };
176
+ for (const k of ['getVersion', 'getGoal', 'getGoalMember', 'createCriterion']) keep(k);
177
+ db.getVersion = async (id) => ({ id, status: 'building' });
178
+ db.getGoal = async (id) => ({ id, version_id: 'V9', status: 'open', created_by: 99 });
179
+ db.getGoalMember = async () => membership;
180
+ db.createCriterion = async (f) => { calls.createCriterion += 1; return { criterion_id: f.criterionId }; };
181
+ return calls;
182
+ };
183
+
184
+ test('wall: a non-member Metic\'s criterion is refused by the server, with its reason', async () => {
185
+ await withRouter('modules/lifecycle/routes/done-when.js', 'metic', criterionSetup(null), async (post, calls) => {
186
+ const req = lib.buildCriterionRequest({ goal_id: '12', version_id: 'V9', outcome: 'Hall can author a goal' });
187
+ req.body.goal_id = 12;
188
+ const res = await post(req.path, req.body);
189
+ assert.equal(res.status, 403);
190
+ assert.equal(res.data.error, 'goal_not_yours');
191
+ assert.equal(calls.createCriterion, 0);
192
+ assert.match(lib.refusalText(res.status, res.data), /owner or a manager/);
193
+ });
194
+ });
195
+
196
+ test('wall: the goal\'s manager can add the same criterion through the same request', async () => {
197
+ await withRouter('modules/lifecycle/routes/done-when.js', 'metic', criterionSetup({ membership_kind: 'lead' }), async (post, calls) => {
198
+ const req = lib.buildCriterionRequest({ goal_id: '12', version_id: 'V9', outcome: 'Hall can author a goal' });
199
+ const res = await post(req.path, req.body);
200
+ assert.equal(res.status, 201, JSON.stringify(res.data));
201
+ assert.equal(calls.createCriterion, 1);
202
+ });
203
+ });
204
+
205
+ test('wall: both routes still sit behind their permission atoms', () => {
206
+ const g = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/routes/goals.js'), 'utf8');
207
+ const d = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/routes/done-when.js'), 'utf8');
208
+ assert.match(g, /router\.post\('\/goals', auth\.requireBuilder, auth\.requirePermission\('goal\.create'\)/);
209
+ assert.match(d, /router\.post\('\/versions\/:id\/done-when', auth\.requireBuilder, auth\.requirePermission\('criterion\.create'\)/);
210
+ });