@bongos/core 1.19.591 → 1.19.593

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 (42) hide show
  1. package/.bongos-core.json +72 -42
  2. package/.claude/skills/planning-session/SKILL.md +2 -2
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +3 -1
  5. package/clients/bongos-client/index.cjs +3 -1
  6. package/clients/bongos-client/index.d.ts +6 -2
  7. package/clients/bongos-client/index.mjs +3 -1
  8. package/docs/adr/0157-archon-is-rank-and-identity-only.md +2 -0
  9. package/docs/api/openapi.json +103 -7
  10. package/docs/api-reference.md +4 -3
  11. package/docs/copy-inventory.md +135 -116
  12. package/docs/copy-registry.json +319 -138
  13. package/docs/module-api-changelog.md +4 -0
  14. package/migrations/core_233_versions_one_planning_idx.sql +47 -0
  15. package/modules/dev-box/app/src/vendor/bongos-client.cjs +3 -1
  16. package/modules/government/catalog.js +20 -11
  17. package/modules/government/migrations/government_012_version_create_archon.sql +45 -0
  18. package/modules/hall-ui/public/goals-render.js +182 -6
  19. package/modules/hall-ui/public/goals.css +51 -0
  20. package/modules/hall-ui/public/roadmap.css +5 -0
  21. package/modules/hall-ui/public/roadmap.js +25 -0
  22. package/modules/hall-ui/public/task-detail.js +22 -11
  23. package/modules/lifecycle/db-goals.js +34 -2
  24. package/modules/lifecycle/db-versions.js +195 -3
  25. package/modules/lifecycle/db.js +4 -1
  26. package/modules/lifecycle/routes/goals.js +15 -1
  27. package/modules/lifecycle/routes/version-route-authz.js +171 -1
  28. package/modules/lifecycle/routes/versions.js +130 -10
  29. package/package-lock.json +2 -2
  30. package/package.json +1 -1
  31. package/scripts/gds/triage.js +60 -26
  32. package/src/module-api.js +1 -1
  33. package/tests/goal_archive_hall.mjs +198 -0
  34. package/tests/government_require_permission.mjs +7 -3
  35. package/tests/government_seed.mjs +101 -9
  36. package/tests/one_planning_version.mjs +25 -8
  37. package/tests/promote_goal_id.mjs +85 -30
  38. package/tests/publish_reconciler.mjs +35 -5
  39. package/tests/task_detail_ui.mjs +36 -9
  40. package/tests/version_build_slot.mjs +157 -0
  41. package/tests/version_close_route.mjs +261 -0
  42. package/tests/version_override_visibility.mjs +176 -0
@@ -23,7 +23,7 @@ const auth = api;
23
23
  const db = require('../db');
24
24
  const versionClose = require('../version-close');
25
25
  const doneWhen = require('../done-when');
26
- const { authorizeVersionCreate } = require('./version-route-authz.js');
26
+ const { authorizeVersionCreate, authorizeVersionBuild, planVersionClose, rollForwardNeedsPlanningVersion } = require('./version-route-authz.js');
27
27
  const { asyncHandler, validateOrRespond } = api;
28
28
 
29
29
  // versions.status CHECK (migration 003). A new version starts 'planning' or
@@ -128,15 +128,11 @@ module.exports = function buildVersionsRouter() {
128
128
  // sibling of R03's authorizeGoalCreate — the route gathers the facts and
129
129
  // renders the verdict, it does not carry the rule.
130
130
  //
131
- // RACE, ACCEPTED AND UNMITIGATED: this is check-then-insert, and no database
132
- // constraint backs it two concurrent POST /versions with status=planning
133
- // could both pass. Unlike the id collision above, which Postgres catches on
134
- // the primary key, nothing catches this one. The real fix is a partial unique
135
- // index (`ON versions (status) WHERE status = 'planning'`), which is a
136
- // migration this task did not reserve a number for; task 1003606 (R19) adds
137
- // the second-BUILDING-version rule of the same shape and is the natural place
138
- // to add the index for both. Until then the exposure is bounded by the route
139
- // being Archon-gated and used a few times a year — small, not zero.
131
+ // THE RACE IS CLOSED (BV1.R19, task 1003606, migration core_233): this is
132
+ // still check-then-insert, but `versions_one_planning_idx` now backs it, so a
133
+ // race-loser gets a 23505 that the catch below translates into this same
134
+ // refusal. R04 shipped this check documenting that nothing caught it; that is
135
+ // no longer true for the planning slot.
140
136
  const versionDecision = authorizeVersionCreate({
141
137
  status,
142
138
  planning: status === 'planning' ? await db.versionsWithStatus('planning') : [],
@@ -144,6 +140,27 @@ module.exports = function buildVersionsRouter() {
144
140
  if (!versionDecision.ok) {
145
141
  return res.status(versionDecision.status).json(versionDecision.body);
146
142
  }
143
+
144
+ // BV1.R19 (task 1003606, goal 1000086, ADR 0250 D5, ADR 0263 §9): the other
145
+ // half of the same rule. Exactly one version builds at a time — with two,
146
+ // "the current version" has no meaning and R03's goal-set gate has two
147
+ // answers depending on which row `currentBuildingVersionId`'s LIMIT 1 picked.
148
+ //
149
+ // NOT BACKED BY AN INDEX YET, and that is a data problem rather than an
150
+ // oversight: BONGOS-V1 and CB-V1 are both `building` on the live instance
151
+ // today, so `versions_one_building_idx` cannot be created until task 1003614
152
+ // (R27) folds them. core_233's header records why shipping it early — either
153
+ // unconditionally, which wedges the deploy, or skipped-with-a-NOTICE, which
154
+ // marks the migration applied and never arms — is worse than shipping it with
155
+ // the cutover. Until then this check-then-insert carries the rule alone, with
156
+ // the same bounded exposure R04 accepted: an Archon-gated route used a few
157
+ // times a year.
158
+ const buildDecision = authorizeVersionBuild({
159
+ building: status === 'building' ? await db.versionsWithStatus('building') : [],
160
+ });
161
+ if (!buildDecision.ok) {
162
+ return res.status(buildDecision.status).json(buildDecision.body);
163
+ }
147
164
  // Normalize criteria: each { criterion_id, criterion_md } (+ optional sort_order).
148
165
  const rawCriteria = Array.isArray(body.criteria) ? body.criteria : [];
149
166
  const criteria = [];
@@ -176,6 +193,31 @@ module.exports = function buildVersionsRouter() {
176
193
  // returns, instead of letting asyncHandler surface a 500 (hacker dispatch,
177
194
  // task 1204).
178
195
  if (err && err.code === '23505') {
196
+ // TWO unique constraints can raise here now, and they mean different
197
+ // things (BV1.R19, migration core_233). `versions_one_planning_idx` is
198
+ // the race-loser of the one-planning-version rule — the pre-check above
199
+ // passed because the winner had not committed yet — and must render as
200
+ // that rule's refusal, not as "this id is taken", which would send the
201
+ // caller off to rename a version that was never the problem. Matched on
202
+ // the constraint name rather than the message text, which is localised.
203
+ if (String(err.constraint || '') === 'versions_one_planning_idx') {
204
+ // Re-read rather than reuse the pre-check's rows: the winner has
205
+ // committed by now, so this read is what names it.
206
+ //
207
+ // The refusal is still rendered BY THE GATE, not written here — this
208
+ // route must never carry a second copy of the rule's wording, which is
209
+ // what tests/one_planning_version.mjs pins. The index only raises when
210
+ // a planning row exists, so the re-read is non-empty in every real
211
+ // case; the fallback row covers the one interleaving where the winner
212
+ // was deleted between the raise and this read, and exists solely so the
213
+ // gate cannot be handed an empty list and return `ok` with no body.
214
+ const planningNow = await db.versionsWithStatus('planning');
215
+ const raced = authorizeVersionCreate({
216
+ status: 'planning',
217
+ planning: planningNow.length ? planningNow : [{ id: '(a concurrent create)', name: '' }],
218
+ });
219
+ return res.status(raced.status).json(raced.body);
220
+ }
179
221
  return res.fail('version_exists', { status: 409, message: `Version '${id}' already exists.` });
180
222
  }
181
223
  throw err;
@@ -183,5 +225,83 @@ module.exports = function buildVersionsRouter() {
183
225
  res.status(201).json({ version, criteria: created });
184
226
  }, { errorCode: 'create_version_failed' }));
185
227
 
228
+
229
+ // POST /versions/:id/close — the move a version has never had (BV1.R12, task
230
+ // 1003599, goal 1000086, ADR 0250 D5, ADR 0263 §5).
231
+ //
232
+ // The hierarchy had a bottom and no top: tasks close, criteria close, goals
233
+ // close, and a version only ever moved by hand-run SQL on the droplet —
234
+ // `scripts/gds/version-close.js` still PRINTS that the API cannot do this (task
235
+ // 1003610, R23, points it here). A thing with no closing move does not close,
236
+ // which is how two versions ended up building at once.
237
+ //
238
+ // Archon-only, behind the `version.close` permission that has existed at that
239
+ // floor since BV1.R105 and that, until now, NOTHING referenced.
240
+ //
241
+ // THE TWO-STEP IS R14's ARCHIVE, in spirit and in shape: call it bare and a
242
+ // version holding open goals refuses with the GOALS ATTACHED; re-submit with a
243
+ // total `dispositions` map and it closes. Agents already know that interaction,
244
+ // and a second idiom for the same thing is how two surfaces drift apart.
245
+ //
246
+ // Auto-close (task 1003603, R16) reaches `db.closeVersion` with an EMPTY plan
247
+ // and never touches this route: by construction it fires only when the last
248
+ // non-maintenance goal achieved, so nothing is left to disposition (ADR 0263 §3).
249
+ // rank: archon — the disposition that finalises a version's scope-truth.
250
+ router.post('/versions/:id/close', auth.requireBuilder, auth.requirePermission('version.close'), asyncHandler('POST /versions/:id/close', async (req, res) => {
251
+ // ADR 0118 / R12: declare the body, so a typo'd `disposition` (singular) is
252
+ // REJECTED rather than read as "none given" — which would take the refusal
253
+ // branch and tell a caller who sent a complete map that the version holds open
254
+ // goals. Contents are validated by planVersionClose, which names every
255
+ // malformed entry.
256
+ if (validateOrRespond(req, res, {
257
+ reason: { type: 'string', maxLength: 2000 },
258
+ dispositions: { type: 'object' },
259
+ })) return;
260
+ const versionId = String(req.params.id || '').trim();
261
+ if (!versionId) return res.fail('version_id_required', 400);
262
+
263
+ const version = await db.getVersion(versionId);
264
+ const reason = typeof req.body?.reason === 'string' ? req.body.reason.slice(0, 2000) : '';
265
+ // The maintenance goal is filtered out by the READ (ADR 0263 §7): it is exempt
266
+ // from the close count and always carries forward, so it is never the caller's
267
+ // decision and must not appear in a refusal asking them to make one.
268
+ const openGoals = version ? await db.openNonMaintenanceGoals(versionId) : [];
269
+
270
+ const plan = planVersionClose({ version, openGoals, dispositions: req.body?.dispositions ?? null, reason });
271
+ if (!plan.ok) return res.status(plan.status).json(plan.body);
272
+
273
+ // A roll_forward needs a planning version to roll INTO, and its absence is a
274
+ // REFUSAL rather than an auto-create: cutting the successor is a scope
275
+ // decision with its own criteria, and doing it implicitly inside a close is
276
+ // the "fake hotfix version" ADR 0250 §3 built the override counter to prevent.
277
+ const needsPlanning = rollForwardNeedsPlanningVersion({
278
+ plan: plan.plan,
279
+ planning: plan.plan.some((p) => p.verb === 'roll_forward') ? await db.versionsWithStatus('planning') : [],
280
+ });
281
+ if (needsPlanning) return res.status(needsPlanning.status).json(needsPlanning.body);
282
+
283
+ try {
284
+ const out = await db.closeVersion({ versionId, plan: plan.plan, reason });
285
+ return res.json({ ok: true, version: out.version, applied: out.applied });
286
+ } catch (err) {
287
+ if (err && err.code === 'VERSION_NOT_FOUND') return res.fail('version_not_found', 404);
288
+ if (err && err.code === 'VERSION_NOT_BUILDING') {
289
+ return res.fail('version_not_building', {
290
+ status: 409,
291
+ message: `Version '${versionId}' changed to '${err.versionStatus}' while the close was running — nothing was applied.`,
292
+ details: { status: err.versionStatus },
293
+ });
294
+ }
295
+ if (err && err.code === 'CLOSE_INCOMPLETE_AFTER_APPLY') {
296
+ return res.fail('version_holds_open_goals', {
297
+ status: 409,
298
+ message: `Version '${versionId}' still held ${err.remaining} open goal(s) after the dispositions were applied — nothing was changed. Re-read and retry.`,
299
+ details: { remaining: err.remaining },
300
+ });
301
+ }
302
+ throw err;
303
+ }
304
+ }, { errorCode: 'close_version_failed' }));
305
+
186
306
  return router;
187
307
  };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.591",
3
+ "version": "1.19.593",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.591",
9
+ "version": "1.19.593",
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.591",
3
+ "version": "1.19.593",
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",
@@ -220,15 +220,37 @@ async function resolveGoalDefault(idea, versionId, { client = cliClient } = {})
220
220
  // default — declining has to be said ('-' or 'none'), because silently dropping a
221
221
  // real default on a bare Enter is how work ends up in the general bucket by
222
222
  // accident. Declining is legal: the server then applies the catch-all itself.
223
+ // applyGoalAnswer — read the operator's goal answer at the triage prompt.
224
+ //
225
+ // BV1.R22 (task 1003609, goal 1000086): THE GENERAL BUCKET IS GONE. Every branch
226
+ // that used to resolve to `goalId: null` did so because a goal-less create fell
227
+ // into the version's catch-all "<version> — general" goal. R11 (task 1003598)
228
+ // deleted that fallback and made `goal_id` required, so `null` is no longer an
229
+ // opt-out — it is a `400 goal_id_required` from the server, arriving after the
230
+ // operator has typed a title, a description and a priority, and losing all of it.
231
+ //
232
+ // So the two escapes become refusals, and they refuse HERE, before the round trip:
233
+ // '-' / 'none' — was "file it in the general bucket"; there is no bucket.
234
+ // a junk answer — was silently downgraded to the bucket with a warning, which
235
+ // is the worse of the two: a typo'd goal id read as a decision.
236
+ //
237
+ // `ok: false` means re-ask. The caller loops rather than aborting the whole
238
+ // triage run, because the operator's other answers are still good.
223
239
  function applyGoalAnswer(defaultGoalId, rawAnswer) {
224
240
  const answer = String(rawAnswer == null ? '' : rawAnswer).trim();
225
- if (answer === '') return { goalId: defaultGoalId, warn: null };
226
- if (answer === '-' || answer.toLowerCase() === 'none') return { goalId: null, warn: null };
241
+ if (answer === '') {
242
+ return defaultGoalId != null
243
+ ? { ok: true, goalId: defaultGoalId, warn: null }
244
+ : { ok: false, goalId: null, warn: 'a goal is required — every task belongs to one. Enter a goal id (GET /goals?version=<id> lists them), or "s" to skip this idea.' };
245
+ }
246
+ if (answer === '-' || answer.toLowerCase() === 'none') {
247
+ return { ok: false, goalId: null, warn: 'the version\'s "general" catch-all no longer exists (task 1003598) — a task must name a real goal. Enter a goal id, or "s" to skip this idea.' };
248
+ }
227
249
  const parsed = Number(answer);
228
250
  if (!Number.isInteger(parsed) || parsed <= 0) {
229
- return { goalId: null, warn: "not a goal id falling back to the version's general bucket" };
251
+ return { ok: false, goalId: null, warn: `"${answer}" is not a goal id. Enter a number, or "s" to skip this idea.` };
230
252
  }
231
- return { goalId: parsed, warn: null };
253
+ return { ok: true, goalId: parsed, warn: null };
232
254
  }
233
255
 
234
256
  async function createTask({ versionId, title, description, priority, sourceRef, goalId = null }) {
@@ -239,13 +261,13 @@ async function createTask({ versionId, title, description, priority, sourceRef,
239
261
  // make, and the server classifier still runs. Corrected rather than deleted so
240
262
  // the next reader does not re-derive it.)
241
263
  //
242
- // task 1003071 (R13): goal_id is the point of this task. Omitting it does NOT
243
- // leave the task goal-lessdb.createTask (modules/lifecycle/db.js) defaults a
244
- // goal-less create into the version's catch-all "<version> general" goal. That
245
- // bucket is the actual defect: it carries no done-when criteria, so since ADR
246
- // 0183 made criteria close themselves off LINKED tasks, work parked there can
247
- // never advance anything, and it does not read as real goal work on the board.
248
- // Naming the goal here is what stops a triaged promotion falling into it.
264
+ // task 1003071 (R13) named the goal here; BV1.R22 (task 1003609) makes it
265
+ // MANDATORY. R11 (task 1003598) deleted the "<version> general" catch-all that
266
+ // a goal-less create used to fall into, so omitting goal_id is now a server
267
+ // `400 goal_id_required` rather than a quiet mis-filing. Reaching this function
268
+ // without one is a bug in the caller, and it throws rather than posting: the
269
+ // prompt above re-asks until it has a real id, so the only way here with null is
270
+ // a new call site that forgot which should fail at its author, not in prod.
249
271
  const body = {
250
272
  version_id: versionId,
251
273
  title,
@@ -255,7 +277,10 @@ async function createTask({ versionId, title, description, priority, sourceRef,
255
277
  source: 'idea_inbox',
256
278
  source_ref: sourceRef ?? null,
257
279
  };
258
- if (goalId != null) body.goal_id = Number(goalId);
280
+ if (goalId == null) {
281
+ throw new Error('createTask: goal_id is required — every task belongs to a goal (task 1003598 deleted the catch-all fallback).');
282
+ }
283
+ body.goal_id = Number(goalId);
259
284
  const api = await cliClient();
260
285
  const r = await api.tasks.postTasks({ body });
261
286
  if (!r.ok) {
@@ -612,21 +637,30 @@ async function main() {
612
637
  const title = titleAns || idea.title;
613
638
 
614
639
  // task 1003071 (R13): a triaged promotion NAMES its goal. The default
615
- // comes from the stored hint, else the R07 suggester; '-' opts out
616
- // explicitly, which falls through to the version's catch-all "general"
617
- // bucket now a choice someone typed rather than the silent default.
618
- // The EFFECTIVE title, not idea.title: the suggester matches on title
619
- // terms, and the operator may just have retitled this.
640
+ // comes from the stored hint, else the R07 suggester. The EFFECTIVE
641
+ // title, not idea.title: the suggester matches on title terms, and the
642
+ // operator may just have retitled this.
643
+ //
644
+ // BV1.R22 (task 1003609): the opt-out is gone, so this RE-ASKS instead of
645
+ // falling through. Skipping is still available and is now spelled 's' —
646
+ // it leaves the idea open for the next pass rather than filing it
647
+ // somewhere it does not belong, which is what the general bucket was.
620
648
  const gd = await resolveGoalDefault({ ...idea, title }, versionId);
621
649
  if (gd.source === 'suggester') console.log(` suggested goal: ${gd.label}`);
622
- if (gd.source === 'unavailable') console.log(' (goal suggester unreachable — enter a goal id, or "-" to accept the general bucket)');
623
- const goalPrompt = gd.goalId != null
624
- ? ` goal [${gd.goalId}] ("-" for the version's general bucket): `
625
- : ' goal (blank/"-" for the version\'s general bucket): ';
626
- const goalAns = (await prompt(rl, goalPrompt)).trim();
627
- const picked = applyGoalAnswer(gd.goalId, goalAns);
628
- if (picked.warn) console.log(` ! ${picked.warn}`);
629
- const goalId = picked.goalId;
650
+ if (gd.source === 'unavailable') console.log(' (goal suggester unreachable — enter a goal id yourself; GET /goals?version=<id> lists them)');
651
+ let goalId = null;
652
+ let skipIdea = false;
653
+ for (;;) {
654
+ const goalPrompt = gd.goalId != null
655
+ ? ` goal [${gd.goalId}] ("s" to skip this idea): `
656
+ : ' goal id ("s" to skip this idea): ';
657
+ const goalAns = (await prompt(rl, goalPrompt)).trim();
658
+ if (goalAns.toLowerCase() === 's') { skipIdea = true; break; }
659
+ const picked = applyGoalAnswer(gd.goalId, goalAns);
660
+ if (picked.ok) { goalId = picked.goalId; break; }
661
+ console.log(` ! ${picked.warn}`);
662
+ }
663
+ if (skipIdea) { console.log(' – skipped (still open in the inbox)'); summary.skipped++; continue; }
630
664
 
631
665
  try {
632
666
  const task = await createTask({
@@ -644,7 +678,7 @@ async function main() {
644
678
  });
645
679
  summary.promoted++;
646
680
  promotedIds.push({ ideaId: idea.id, taskId: task.id });
647
- console.log(` ✓ promoted #${idea.id} → task #${task.id} (${versionId}, P${priority}${goalId != null ? `, goal ${goalId}` : ', → general bucket'})`);
681
+ console.log(` ✓ promoted #${idea.id} → task #${task.id} (${versionId}, P${priority}, goal ${goalId})`);
648
682
  } catch (err) {
649
683
  console.error(` ! ${err.message}`);
650
684
  }
package/src/module-api.js CHANGED
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
55
55
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
56
56
  // the entry to that file. Look for a version's history there, not here.
57
57
  // ---------------------------------------------------------------------------
58
- const CORE_VERSION = '1.19.591'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
58
+ const CORE_VERSION = '1.19.593'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
59
59
 
60
60
  // A namespaced logger so a module's log lines are attributable + consistent.
61
61
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,198 @@
1
+ // tests/goal_archive_hall.mjs — the goal page tells the truth about what is
2
+ // holding a goal open, and walks the archive two-step without curl
3
+ // (BV1.R24, task 1003611, goal 1000086, ADR 0250 D2/D3).
4
+ //
5
+ // TWO THINGS THIS PAGE COULD NOT SAY BEFORE.
6
+ //
7
+ // 1. WHY A GOAL IS STILL OPEN. Until R08/R09 (tasks 1003595, 1003596) a goal
8
+ // closed on its criteria alone, so "N of M criteria satisfied" was the whole
9
+ // answer. ADR 0250 D2 added "AND no task in it is non-terminal" — and a task
10
+ // linked to NO criterion appears nowhere in that rollup. A builder could read
11
+ // 5-of-5 satisfied, watch the goal stay open, and find nothing on the page
12
+ // explaining it. That reads as a bug in the gate rather than the gate working.
13
+ //
14
+ // 2. HOW TO RETIRE ONE. R10 made archive refuse and return the work; R14 made it
15
+ // apply a disposition. Both were API-only, so the only way to retire a goal
16
+ // holding work was hand-written JSON — which is why the ADR 0264 goal cut
17
+ // needed a terminal.
18
+ //
19
+ // Static assertions over the shipped browser source, the promote_goal_id.mjs
20
+ // precedent: these are DOM strings a unit test cannot invoke, and the properties
21
+ // worth pinning are structural — which read feeds the panel, what the move-target
22
+ // filter excludes, and that the server's refusal is shown rather than paraphrased.
23
+ //
24
+ // Run: node tests/goal_archive_hall.mjs
25
+
26
+ import { strict as assert } from 'node:assert';
27
+ import { readFileSync } from 'node:fs';
28
+ import { makeRunner } from './helpers.mjs';
29
+
30
+ const { test, summary } = makeRunner();
31
+ const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
32
+ const RENDER = src('modules/hall-ui/public/goals-render.js');
33
+ const CSS = src('modules/hall-ui/public/goals.css');
34
+ const ROUTES = src('modules/lifecycle/routes/goals.js');
35
+
36
+ // ---- the server hands the page the same facts the refusal uses --------------
37
+
38
+ await test('GET /goals/:id carries the open tasks, from the SAME read the refusal returns', () => {
39
+ const detail = ROUTES.slice(ROUTES.indexOf("router.get('/goals/:id'"), ROUTES.indexOf("router.get('/goals/:id/suggest-criteria'"));
40
+ assert.match(detail, /db\.openTasksInGoal\(id\)/,
41
+ 'the page must not compute "what is in the way" a second way');
42
+ assert.match(detail, /open_tasks: openTasks/, 'and it must reach the client');
43
+ // If the page used a different read, it could show a builder a list the archive
44
+ // refusal then disagrees with — the exact confusion the two-step exists to end.
45
+ const archive = ROUTES.slice(ROUTES.indexOf("router.post('/goals/:id/archive'"));
46
+ assert.match(archive, /db\.openTasksInGoal\(id/, 'the refusal reads it too');
47
+ });
48
+
49
+ // ---- 1. why the goal is open ------------------------------------------------
50
+
51
+ await test('the completion band names the unfinished work, with each task status', () => {
52
+ const fn = RENDER.slice(RENDER.indexOf('function heldOpenHtml'), RENDER.indexOf('function completionSummaryHtml'));
53
+ assert.match(fn, /holding this goal open/, 'it has to say what the tasks are doing, not merely list them');
54
+ assert.match(fn, /held__status/,
55
+ 'the status is the surprising part — `completed` and `confirmed` still hold a goal open');
56
+ assert.match(fn, /#\/task\//, 'and each is reachable, so the next move is one click');
57
+ });
58
+
59
+ await test('it is SILENT when nothing is held, and on a closed goal', () => {
60
+ const fn = RENDER.slice(RENDER.indexOf('function heldOpenHtml'), RENDER.indexOf('function completionSummaryHtml'));
61
+ assert.match(fn, /if \(!openTasks \|\| !openTasks\.total \|\| g\.status !== 'open'\) return '';/,
62
+ 'an empty "0 tasks" row on the common goal is noise, and this band renders on every goal');
63
+ });
64
+
65
+ await test('the summary line stops claiming criteria are the only thing left', () => {
66
+ const fn = RENDER.slice(RENDER.indexOf('function completionSummaryHtml'), RENDER.indexOf('function critListHtml'));
67
+ assert.match(fn, /const held = openTasks && openTasks\.total \? openTasks\.total : 0;/);
68
+ assert.match(fn, /still will not close while work is unfinished/,
69
+ 'satisfied criteria plus an open goal is exactly the state that reads as a bug unexplained');
70
+ // The old sentence must survive for the case it is still true for.
71
+ assert.match(fn, /a criterion becomes confirmable once every task linked to it has shipped/);
72
+ });
73
+
74
+ await test('both the band and the full view are passed the open tasks', () => {
75
+ // A detail view that renders the summary WITHOUT them silently drops the
76
+ // explanation on the surface a builder opens to investigate.
77
+ const calls = RENDER.match(/completionSummaryHtml\(g, crit, sat, \d+, d\.criterionless_task_count, d\.open_tasks\)/g) || [];
78
+ assert.equal(calls.length, 2, 'the compact band and the full view');
79
+ });
80
+
81
+ // ---- 2. the archive two-step ------------------------------------------------
82
+
83
+ await test('the Archive control appears only for someone who can manage an OPEN goal', () => {
84
+ const fn = RENDER.slice(RENDER.indexOf('function renderActions'), RENDER.indexOf('function archiveRowHtml'));
85
+ assert.match(fn, /if \(canManage && g\.status === 'open'\) \{/,
86
+ 'a Xenos sees the state, not the control — the server enforces either way');
87
+ assert.match(fn, /id="goal-archive-btn"/);
88
+ });
89
+
90
+ await test('a goal holding NOTHING archives in one step, with an optional reason', () => {
91
+ const fn = RENDER.slice(RENDER.indexOf('async function openArchivePanel'), RENDER.indexOf('async function submitArchive'));
92
+ assert.match(fn, /held\.total === 0/, 'the common case must not become a two-step');
93
+ assert.match(fn, /Why \(optional\)/);
94
+ assert.match(fn, /reversible/, 'and say so — an archive that reads as permanent is one nobody uses');
95
+ });
96
+
97
+ await test('move targets exclude closed goals AND the goal being archived', () => {
98
+ const fn = RENDER.slice(RENDER.indexOf('async function openArchivePanel'), RENDER.indexOf('async function submitArchive'));
99
+ assert.match(fn, /o\.status === 'open' && !sameId\(o\.id, g\.id\)/,
100
+ 'a closed goal takes no new work, and moving INTO the goal being archived is the silent discard renamed');
101
+ });
102
+
103
+ await test('every task needs a decision before the panel will submit', () => {
104
+ const fn = RENDER.slice(RENDER.indexOf('async function submitArchive'));
105
+ assert.match(fn, /undecided\.push\(id\)/);
106
+ assert.match(fn, /a decision: \$\{undecided\.map/, 'and the refusal names WHICH');
107
+ assert.match(fn, /if \(!reason\) return say\(/, 'an abandon without a reason is the silent discard D3 forbids');
108
+ });
109
+
110
+ await test('the client check is a courtesy — the server refusal is shown VERBATIM', () => {
111
+ const fn = RENDER.slice(RENDER.indexOf('async function submitArchive'));
112
+ assert.match(fn, /say\(\(e && e\.message\)/,
113
+ 'the server knows things this panel does not — a task claimed since load, a target that just closed');
114
+ // A UI that believes it is the enforcement is how a second, drifting copy of a
115
+ // rule gets written — the panel's own header says so, above the flow.
116
+ const flow = RENDER.slice(RENDER.indexOf('// ---- BV1.R24: the archive two-step'));
117
+ assert.match(flow, /COURTESY, never the gate/);
118
+ });
119
+
120
+ await test('the disposition payload is exactly the shape the route validates', () => {
121
+ const fn = RENDER.slice(RENDER.indexOf('async function submitArchive'));
122
+ assert.match(fn, /\{ verb: 'abandon' \}/);
123
+ assert.match(fn, /\{ verb: 'move', goal_id: Number\(v\) \}/);
124
+ assert.match(fn, /body\.dispositions = dispositions;/);
125
+ // The route declares `reason` and `dispositions` and rejects anything else
126
+ // (ADR 0118), so an extra field here would 400 the whole call.
127
+ const sent = fn.slice(0, fn.indexOf('postJSON'));
128
+ assert.equal(/body\.(?!dispositions|reason)[a-z_]+ =/.test(sent), false, 'no field the route does not declare');
129
+ });
130
+
131
+ await test('archiving re-reads the goal rather than patching the page from the response', () => {
132
+ const fn = RENDER.slice(RENDER.indexOf('async function submitArchive'));
133
+ assert.match(fn, /loadDetail\(g\.id, \{ force: true \}\)/,
134
+ 'the archive moved tasks between goals — a local patch would leave the page lying about both');
135
+ });
136
+
137
+ await test('the panel uses the page\'s own tokens, not colours of its own', () => {
138
+ const block = CSS.slice(CSS.indexOf('.arch-panel {'));
139
+ assert.equal(/#[0-9a-fA-F]{6}/.test(block.slice(0, block.indexOf('.arch-panel__err'))), false,
140
+ 'a panel that announces itself is one people click by accident');
141
+ assert.match(CSS, /\.held-open \{/);
142
+ assert.match(CSS, /\.arch-row__verb \{/);
143
+ });
144
+
145
+ // ---- 3. it actually RENDERS ------------------------------------------------
146
+ //
147
+ // The assertions above prove the source contains the right strings. These run the
148
+ // two pure builders and check the MARKUP — grammar, escaping, the overflow line,
149
+ // and that no verb is preselected. A static regex passes just as happily on a
150
+ // function that throws.
151
+
152
+ const escapeHtmlFn = (x) => String(x).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
153
+ const fmtNumFn = (n) => String(n);
154
+ const lift = (name, end, args, vals) => new Function(...args, `${RENDER.slice(RENDER.indexOf(`function ${name}`), RENDER.indexOf(end))}; return ${name};`)(...vals);
155
+ const heldOpenHtml = lift('heldOpenHtml', 'function completionSummaryHtml', ['escapeHtml', 'fmtNum'], [escapeHtmlFn, fmtNumFn]);
156
+ const archiveRowHtml = lift('archiveRowHtml', 'async function openArchivePanel', ['escapeHtml'], [escapeHtmlFn]);
157
+ const OPEN = { status: 'open' };
158
+
159
+ await test('RENDERS: a goal holding nothing produces no band at all', () => {
160
+ assert.equal(heldOpenHtml(OPEN, { total: 0, tasks: [] }), '');
161
+ assert.equal(heldOpenHtml(OPEN, null), '');
162
+ });
163
+
164
+ await test('RENDERS: an archived goal shows no band even while holding work', () => {
165
+ assert.equal(heldOpenHtml({ status: 'archived' }, { total: 3, tasks: [{ id: 1, title: 'x', status: 'ready' }] }), '');
166
+ });
167
+
168
+ await test('RENDERS: held tasks come out linked, status-tagged and ESCAPED', () => {
169
+ const html = heldOpenHtml(OPEN, { total: 2, tasks: [
170
+ { id: 1001, title: 'The <b>unescaped</b> one', status: 'confirmed' },
171
+ { id: 1002, title: 'second', status: 'ready' },
172
+ ] });
173
+ assert.match(html, /2 unfinished tasks are holding this goal open/);
174
+ assert.match(html, /#\/task\/1001/);
175
+ assert.match(html, /confirmed/, 'the mid-ship status is the surprising part and must show');
176
+ assert.doesNotMatch(html, /<b>unescaped<\/b>/, 'a task title is user input on a page everyone reads');
177
+ assert.match(html, /&lt;b&gt;/);
178
+ });
179
+
180
+ await test('RENDERS: the grammar is singular for one', () => {
181
+ const one = heldOpenHtml(OPEN, { total: 1, tasks: [{ id: 5, title: 'solo', status: 'active' }] });
182
+ assert.match(one, /1 unfinished task is holding this goal open/);
183
+ });
184
+
185
+ await test('RENDERS: a long list is capped and says how many are hidden', () => {
186
+ const many = heldOpenHtml(OPEN, { total: 30, tasks: Array.from({ length: 8 }, (_, i) => ({ id: i, title: 't', status: 'ready' })) });
187
+ assert.match(many, /and 22 more/, 'a truncated list that does not say it is truncated is a wrong answer');
188
+ });
189
+
190
+ await test('RENDERS: an archive row preselects NOTHING and offers each open goal', () => {
191
+ const row = archiveRowHtml({ id: 77, title: 'row', status: 'blocked' }, [{ id: 42, title: 'survivor' }]);
192
+ assert.match(row, /value="" selected/, 'a preselected verb is a decision nobody made');
193
+ assert.match(row, /value="abandon"/);
194
+ assert.match(row, /move → #42 survivor/);
195
+ assert.match(row, /data-task="77"/, 'the row carries its id, which is how the map is collected');
196
+ });
197
+
198
+ summary();
@@ -231,6 +231,13 @@ test('over the four seeded ranks, requirePermission admits exactly who requireRa
231
231
  const REPLACED_ARCHON = [
232
232
  'dependency.cross_tier.manage', 'gate_approval.decide',
233
233
  'task.confirm.grade_bypass', 'access_request.review',
234
+ // BV1.R21 (task 1003608): `version.create` RETURNED here. ADR 0157 had lowered it
235
+ // to metic on "authoring delegates, disposition does not"; ADR 0250 §6 amends that
236
+ // for this one key, because D1 makes the version boundary the scope gate and
237
+ // cutting a version is then the way AROUND the gate rather than authoring. Moving
238
+ // it back is an owner decision, which is exactly what this hand-written oracle
239
+ // exists to require — a floor that moved without one still reds the build.
240
+ 'version.create',
234
241
  ];
235
242
  // ADR 0157 (owner decision, 2026-08-05) — "the Archon rank is responsible for rank and
236
243
  // identity only". Each of these replaced a requireRank('archon') gate in R104/R105 and
@@ -249,9 +256,6 @@ test('over the four seeded ranks, requirePermission admits exactly who requireRa
249
256
  'task.peer_votes.tally', 'override_request.decide', 'memory.read.any',
250
257
  'project.curate', 'provisioning.fleet.manage', 'security.report.adjudicate',
251
258
  'session.search',
252
- // Minted by R105 (POST /versions had no atom); ADR 0157 then lowered it to metic.
253
- // Its sibling `version.close` stayed archon — authoring delegates, disposition does not.
254
- 'version.create',
255
259
  // Minted BY ADR 0157, splitting the Gate page's READ off `gate_approval.decide` so a
256
260
  // Metic can open /gate while approving a held PR stays Archon.
257
261
  'gate_approval.read',