@bongos/core 1.21.70 → 1.21.72

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 (68) hide show
  1. package/.bongos-core.json +123 -63
  2. package/.claude/skills/goal-create/SKILL.md +21 -3
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +2 -0
  5. package/clients/bongos-client/index.cjs +2 -0
  6. package/clients/bongos-client/index.d.ts +5 -3
  7. package/clients/bongos-client/index.mjs +2 -0
  8. package/docs/adr/0368-red-and-blue-teams-practice-on-a-range-never-on-live.md +99 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +46 -5
  11. package/docs/api-reference.md +4 -3
  12. package/docs/copy-inventory.md +388 -377
  13. package/docs/copy-registry.json +567 -448
  14. package/docs/file-map.md +1 -1
  15. package/docs/module-api-changelog.md +4 -0
  16. package/docs/page-readings.json +912 -931
  17. package/docs/recipes/core-release-pipeline.md +6 -0
  18. package/docs/recipes/upgrading-the-core.md +2 -0
  19. package/modules/hall-ui/public/settings-software-update.js +77 -2
  20. package/modules/hall-ui/public/settings.html +1 -1
  21. package/modules/lifecycle/db-tasks.js +12 -0
  22. package/modules/lifecycle/goal-spec-task.js +129 -0
  23. package/modules/lifecycle/routes/goals.js +7 -9
  24. package/modules/lifecycle/routes/task-write-routes.js +11 -1
  25. package/modules/lifecycle/upkeep-home.js +55 -0
  26. package/modules/public-landing/public/projects-hall-live.states.json +7 -1
  27. package/modules/public-landing/public/projects.html +581 -0
  28. package/modules/public-landing/public/projects.probes.json +2 -2
  29. package/modules/public-landing/public/projects.states.json +1 -1
  30. package/package-lock.json +2 -2
  31. package/package.json +1 -1
  32. package/release-notes.json +43 -0
  33. package/scripts/gds/claude-materialize.js +125 -31
  34. package/scripts/gds/claude-md-template.js +47 -10
  35. package/scripts/gds/cli-lib.js +4 -4
  36. package/scripts/gds/feedback-send.js +3 -3
  37. package/scripts/gds/paste-token.js +1 -1
  38. package/scripts/gds/provision-net.js +1 -8
  39. package/scripts/gds/provision-units.js +28 -2
  40. package/scripts/gds/reauth.js +1 -1
  41. package/scripts/gds/run-unit-tests.js +3 -0
  42. package/scripts/gds/setup.js +1 -1
  43. package/scripts/gds/start.js +4 -4
  44. package/scripts/gds/surface.js +15 -3
  45. package/scripts/gds/task.js +32 -5
  46. package/scripts/gds/upgrade-operator.js +73 -0
  47. package/scripts/gds/upgrade.js +7 -2
  48. package/scripts/hall-preview/server.js +3 -0
  49. package/src/bongos/routes/core-update.js +98 -4
  50. package/src/bongos/routes/feedback.js +11 -1
  51. package/src/bongos/upgrade-request.js +158 -0
  52. package/src/module-api.js +1 -1
  53. package/tests/claude_materialize.mjs +195 -18
  54. package/tests/cli_allowlist_permissions.mjs +6 -2
  55. package/tests/cli_surface.mjs +2 -2
  56. package/tests/fixtures/page-ledger.mjs +5 -0
  57. package/tests/goal_admission_override.mjs +1 -1
  58. package/tests/goal_category.mjs +1 -1
  59. package/tests/goal_create_spec_entry.mjs +61 -0
  60. package/tests/goal_spec_task.mjs +197 -0
  61. package/tests/goal_spec_task_db.mjs +95 -0
  62. package/tests/projects_hub_cloud_hop.mjs +303 -0
  63. package/tests/provision_settings_apply.mjs +25 -1
  64. package/tests/software_update.mjs +18 -1
  65. package/tests/start_env_guard.mjs +2 -2
  66. package/tests/upgrade_operator.mjs +105 -0
  67. package/tests/upgrade_request.mjs +348 -0
  68. package/tests/upkeep_home.mjs +156 -0
@@ -6,6 +6,12 @@
6
6
  >
7
7
  > A change that has landed on `main` is running on **zero** instances. It reaches users only after someone bumps `CORE_VERSION`, cuts an artifact, and moves an instance's pin. Those are three separate acts by design ([ADR 0137](../adr/0137-upstream-publish-policy-manual-owner-gated.md)) — and every layer around them reports success in the meantime.
8
8
 
9
+ > ## Working on a hosted project? Stop here — nothing in this file is yours to run.
10
+ >
11
+ > If the project you are building is run for you by an operator (it lives on their server; `bongos doctor` says *"Deploy mode: laptop — the operator lands ships"*; `bongos upgrade` says *"This project is upgraded by its operator"*), **the operator upgrades it**. Every command below runs on that server, which you cannot reach. The supported path is to ask: open **Settings → Software update** in the project and use the button there, or from a terminal run `bongos feedback "Please upgrade this project to the latest version"`.
12
+ >
13
+ > **For an assistant:** do not turn this recipe into steps for that builder — no ssh, no "log in as the account that owns the directory", and never a guessed database, service or health-check name. A placeholder you cannot fill in is a sign you are on the wrong path (task 1004596).
14
+
9
15
  ## Why this file exists
10
16
 
11
17
  Between 2026-07-24 and 2026-08-03, **93 work commits (171 counting merges) — including the entire governance permission system — sat on `main` unreleased for ten days** while the live platform served the previous version. Nothing was broken. `/builder-ship` printed success, the publish-reconciler flipped `confirmed → shipped`, credits paid out. Every signal a builder could see said "delivered"; nothing compared live against `main` (blocker 1000097, task [1002521](https://example.com/builders#/task/1002521)).
@@ -4,6 +4,8 @@
4
4
  >
5
5
  > The R86 update-consumption channel ([ADR 0100](../adr/0100-two-repo-instance-model.md) §2, [ADR 0103](../adr/0103-core-first-extraction-cloud-bongos-trunk.md) §4, task [#1690](https://example.com/builders#/task/1690)). How a **two-repo consumer instance** — one that installs `@bongos/core` as a vendored-tarball dependency — pulls the core from version N → N+1. Tooling: [`scripts/gds/upgrade.js`](../../scripts/gds/upgrade.js) (`bongos upgrade`). First proven live on emersonian-circles 1.15.0 → 1.16.0 (task [#1976](https://example.com/builders#/task/1976)).
6
6
 
7
+ > **Not for a hosted project's builders.** Everything here runs on the server where the project lives. On a project its operator runs, `bongos upgrade` from your own computer says so and stops; ask for the upgrade through **Settings → Software update** or `bongos feedback "…"` instead — never write server steps or guess its names (task 1004596; [the release pipeline](core-release-pipeline.md) has the detail).
8
+
7
9
  ## What the channel does
8
10
 
9
11
  `bongos upgrade --to <version> --from <tgz>` runs, from a consumer instance root:
@@ -65,6 +65,49 @@
65
65
  </div>`;
66
66
  }
67
67
 
68
+ // "Ask for this upgrade" (task 1004595). A project the hub's operator runs cannot take an
69
+ // update itself, so instead of a dead end the page offers one press that reaches the people
70
+ // who run its server, and then says that it was asked. The server decides whether it is
71
+ // offered (`ask.available`) and remembers the request (`ask.requested`).
72
+ function longDate(iso) {
73
+ const d = new Date(iso);
74
+ if (!Number.isFinite(d.getTime())) return null;
75
+ try { return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }); } catch (e) { return d.toISOString().slice(0, 10); }
76
+ }
77
+
78
+ // Asked, and still waiting: for this very version, or recently enough that asking again
79
+ // would only be refused.
80
+ function askPending(ask, offered) {
81
+ const r = ask && ask.requested;
82
+ if (!r) return false;
83
+ if (r.version === offered) return true;
84
+ const again = Date.parse(ask.ask_again_after);
85
+ return Number.isFinite(again) && Date.now() < again;
86
+ }
87
+
88
+ function askHtml(ask, offered, { lead }) {
89
+ const team = ask.hub_host ? `the team at ${escapeHtml(ask.hub_host)}` : 'the team that runs this project\'s server';
90
+ if (askPending(ask, offered)) {
91
+ const r = ask.requested;
92
+ const when = longDate(r.requested_at);
93
+ return `<div class="su-door su-ask">
94
+ <p class="panel-status panel-status--ok" role="status">Upgrade requested${when ? ` on ${escapeHtml(when)}` : ''}${r.by ? ` by @${escapeHtml(r.by)}` : ''}, for version ${mono(r.version)}. ${team.charAt(0).toUpperCase() + team.slice(1)} has your request. This page shows the new version once it is installed.</p>
95
+ </div>`;
96
+ }
97
+ const last = ask.requested && longDate(ask.requested.requested_at);
98
+ const before = last ? ` You last asked on ${escapeHtml(last)}, for ${mono(ask.requested.version)}.` : '';
99
+ const intro = lead
100
+ ? `<p class="panel-hint">This project runs on a server that ${team} looks after, so they install its updates. Ask them for this one and they are told which version you want.${before}</p>`
101
+ : `<p class="panel-hint">If that page does not let you in, ask ${team} to install it for you.${before}</p>`;
102
+ return `<div class="su-door su-ask">
103
+ ${intro}
104
+ <div class="row-end">
105
+ <button type="button" class="pbtn ${lead ? 'pbtn--save' : 'pbtn--quiet'}" id="su-ask">Ask for this upgrade</button>
106
+ <span class="panel-hint" id="su-ask-status" role="status"></span>
107
+ </div>
108
+ </div>`;
109
+ }
110
+
68
111
  // Where the Update button goes, and what it says it will do. Only a plain same-origin path
69
112
  // or an http(s) URL is ever put in an href; anything else draws no button.
70
113
  function doorHtml(data, offered) {
@@ -72,7 +115,9 @@
72
115
  if (!data.can_update) {
73
116
  return '<p class="panel-hint su-door">Only the people allowed to upgrade this project see an Update button here.</p>';
74
117
  }
118
+ const ask = data.ask || {};
75
119
  if (door.kind === 'cli') {
120
+ if (ask.available) return askHtml(ask, offered, { lead: true });
76
121
  return '<p class="panel-hint su-door">This project is upgraded on the server that runs it: <code>bongos upgrade</code> there takes this version, with the same health check and rollback.</p>';
77
122
  }
78
123
  const url = String(door.url || '');
@@ -85,7 +130,7 @@
85
130
  return `<div class="row-end su-door">
86
131
  <a class="pbtn pbtn--save" id="su-update" href="${escapeHtml(url)}">Update to ${escapeHtml(offered)}${sameOrigin ? '' : ' ↗'}</a>
87
132
  <span class="panel-hint">Opens this project's upgrade${escapeHtml(where)}: you see what would move, confirm it, and it installs, checks itself, and rolls back if the project does not come back healthy.</span>
88
- </div>`;
133
+ </div>${door.kind === 'hub' && ask.available ? askHtml(ask, offered, { lead: false }) : ''}`;
89
134
  }
90
135
 
91
136
  // One entry: its text, and — only in the hall whose ledger the ids belong to, which is
@@ -142,7 +187,8 @@
142
187
  html += row('Newest for this project', `${mono(u.running)} — this project is up to date.`, '<span class="su-pill su-pill--ok">Up to date</span>');
143
188
  } else {
144
189
  const when = ago(u.offered_published_at);
145
- html += row('Newest for this project', `${mono(u.offered)}${when ? ` — published ${escapeHtml(when)}` : ''}`, '<span class="su-pill">Update ready</span>');
190
+ const pending = data.can_update && data.ask && data.ask.available && askPending(data.ask, u.offered);
191
+ html += row('Newest for this project', `${mono(u.offered)}${when ? ` — published ${escapeHtml(when)}` : ''}`, `<span class="su-pill">${pending ? 'Requested' : 'Update ready'}</span>`);
146
192
  html += doorHtml(data, u.offered);
147
193
  html += whatsNewHtml(u.whats_new, (data.door || {}).kind === 'local');
148
194
  }
@@ -154,6 +200,9 @@
154
200
  body.innerHTML = html;
155
201
  if (note) note.textContent = notes.join(' ');
156
202
 
203
+ const askBtn = byId('su-ask');
204
+ if (askBtn) askBtn.addEventListener('click', () => askForUpgrade(data, askBtn));
205
+
157
206
  const more = byId('su-more');
158
207
  if (more) {
159
208
  more.addEventListener('click', () => {
@@ -163,6 +212,32 @@
163
212
  }
164
213
  }
165
214
 
215
+ // One press: the server sends the request and remembers it; the panel redraws from its
216
+ // answer. "Asked recently" carries the stored request, so it redraws as pending too.
217
+ async function askForUpgrade(data, btn) {
218
+ const status = byId('su-ask-status');
219
+ btn.disabled = true;
220
+ btn.textContent = 'Asking…';
221
+ if (status) status.textContent = '';
222
+ try {
223
+ const res = await api.request('POST', `${API}/software-update/request`, { body: {} });
224
+ const err = (res.data && res.data.error) || {};
225
+ const request = (res.data && res.data.request) || (err.details && err.details.request);
226
+ if ((res.ok || res.status === 429) && request) {
227
+ const again = new Date(Date.parse(request.requested_at) + 24 * 3600 * 1000).toISOString();
228
+ render({ ...data, ask: { ...data.ask, requested: request, ask_again_after: again } });
229
+ return;
230
+ }
231
+ // Every refusal the server sends carries a sentence; a bare code is only ever a
232
+ // proxy or a crash, so the status stands in for it.
233
+ throw new Error(err.message && err.message !== err.code ? err.message : `HTTP ${res.status}`);
234
+ } catch (e) {
235
+ btn.disabled = false;
236
+ btn.textContent = 'Ask for this upgrade';
237
+ if (status) status.textContent = `Could not send the request: ${String(e.message || 'unknown error').replace(/\.$/, '')}.`;
238
+ }
239
+ }
240
+
166
241
  async function load() {
167
242
  if (asked) return;
168
243
  asked = true;
@@ -461,7 +461,7 @@
461
461
  settings.js is against the 1500-line ratchet. -->
462
462
  <script src="/builders/settings-render.js?v=2026-09-16-render-levels"></script>
463
463
  <!-- task 1004296: Settings → Software update. Own file on the same precedent. -->
464
- <script src="/builders/settings-software-update.js?v=2026-09-26-update"></script>
464
+ <script src="/builders/settings-software-update.js?v=2026-10-06-ask"></script>
465
465
  <!-- task 1004501: Settings → Your Autobongos runner. Own file on the same precedent. -->
466
466
  <script src="/builders/settings-autobongos.js?v=2026-10-01-autobongos"></script>
467
467
  </body>
@@ -20,6 +20,7 @@ const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
20
20
  const { executorFor } = require('./db-executor.js');
21
21
  const { isReservedCriterionSlug } = require('./criterion-ref.js');
22
22
  const { resolveWriteDiscipline, WRITABLE_DISCIPLINES } = require('./task-classifier.js');
23
+ const { gateOnSpecTask } = require('./goal-spec-task.js');
23
24
 
24
25
  // -------------------------------------------------------------------------
25
26
  // Tasks
@@ -293,6 +294,10 @@ async function createTask({
293
294
  `[reward-gate] AUTO-ASSIGN task ${rows[0].id} credits_reward -> ${creditsRewardEffective} (cap ${REWARD_SUGGESTION_CAP}) [creation]`
294
295
  );
295
296
  }
297
+ // task 1004585 / ADR 0366 D3: a task born into a goal whose spec task is still
298
+ // open waits on it. Here, at the lowest create layer, so every vector inherits it;
299
+ // inside the caller's transaction when there is one, like the INSERT above.
300
+ if (rows[0]) await gateOnSpecTask(rows[0], deps.client || pool);
296
301
  return rows[0];
297
302
  }
298
303
 
@@ -417,6 +422,13 @@ async function updateTaskGoal(id, goalId) {
417
422
  `UPDATE tasks SET goal_id = $1, updated_at = now() WHERE id = $2 RETURNING *`,
418
423
  [goalId, id]
419
424
  );
425
+ // task 1004585: a task MOVED into a goal waits on its open spec task too. Through
426
+ // addTaskDependency (lazy — it lives beside this file), because a moved task may
427
+ // already have dependents and the edge must pass the cycle check.
428
+ if (rows[0]) {
429
+ const { addTaskDependency } = require('./db-deps-criteria.js');
430
+ await gateOnSpecTask(rows[0], pool, { addTaskDependency });
431
+ }
420
432
  return rows[0] ?? null;
421
433
  }
422
434
 
@@ -0,0 +1,129 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/goal-spec-task.js — a goal made in the hall carries its own
4
+ // spec task, and the goal's other work waits behind it (task 1004585, ADR 0366 D3).
5
+ //
6
+ // WHY. The hall's goal form asks only what a person knows (a name, a description,
7
+ // a version, a category). The rest of a goal — its scope wall, its criteria, its
8
+ // first tasks — is inference, which /goal-create does in a session (ADR 0366 D2).
9
+ // So a hall-made goal arrives with a task that says exactly that, and nothing else
10
+ // in the goal can be claimed until it ships: an unspecced goal is visible as a
11
+ // task on the board, not a silence.
12
+ //
13
+ // HOW, with no new permission and no migration:
14
+ // - The spec task is an ordinary task, recognised by (source, source_ref) =
15
+ // ('goal-spec', 'goal-<id>-spec'). That pair is also what makes creating it
16
+ // idempotent (findTaskBySourceRef).
17
+ // - "Waits behind it" is an ordinary task→task dependency, so the claim gate's
18
+ // existing DEPS_NOT_SHIPPED refusal and the auto-promotion on ship do the rest.
19
+ // A goal-level edge would be simpler, but every task in the goal inherits it
20
+ // (unsatisfiedDeps), the spec task included, which would block it on itself.
21
+ // - The edge is added at the two places a task ENTERS a goal: db-tasks.createTask
22
+ // (every create vector passes through it) and db-tasks.updateTaskGoal (a move).
23
+
24
+ const SPEC_SOURCE = 'goal-spec';
25
+
26
+ function specSourceRef(goalId) {
27
+ return `goal-${goalId}-spec`;
28
+ }
29
+
30
+ // The spec task's fields, for db.createTask. `ready` so it can be claimed at once;
31
+ // a spike, because its output is a decision (the scope and the criteria) rather
32
+ // than code; ideator, the craft whose pack covers "the scopes of work ideas become".
33
+ function specTaskFields(goal, { createdBy = null } = {}) {
34
+ const id = goal.id;
35
+ return {
36
+ versionId: goal.version_id,
37
+ goalId: id,
38
+ title: `Spec goal ${id}: its scope, criteria and first tasks`,
39
+ description: [
40
+ `Goal ${id} ("${goal.title}") was created from the hall, which asks only for a name and a description. This task settles the rest, and every other task in the goal waits on it (ADR 0366 D3).`,
41
+ '',
42
+ 'Settle, in a session (run /goal-create against this goal):',
43
+ `1. The scope wall: which code areas the goal's tasks may touch. Set it with POST /goals/${id}/scope (the goal's owner or a manager; a protected area needs an Archon).`,
44
+ `2. Two to four done-when criteria, with POST /versions/${goal.version_id}/done-when under the goal (ADR 0154: the goal's owner or a manager).`,
45
+ '3. The first tasks, with their dependencies.',
46
+ '',
47
+ 'If you do not own or manage this goal, draft the scope and criteria and hand them to its owner rather than skipping them.',
48
+ '',
49
+ 'DONE WHEN: the goal has its scope (or a written reason it needs none), its criteria, and at least one claimable task.',
50
+ ].join('\n'),
51
+ status: 'ready',
52
+ kind: 'spike',
53
+ discipline: 'ideator',
54
+ source: SPEC_SOURCE,
55
+ sourceRef: specSourceRef(id),
56
+ createdBy,
57
+ };
58
+ }
59
+
60
+ // The goal's spec task if it is still open, else null. An abandoned spec task
61
+ // gates nothing new (the owner decided the goal needs no spec); a shipped one is done.
62
+ async function openSpecTaskId(goalId, client) {
63
+ if (goalId == null) return null;
64
+ const { rows } = await client.query(
65
+ `SELECT id FROM tasks
66
+ WHERE goal_id = $1 AND source = $2 AND source_ref = $3
67
+ AND status NOT IN ('shipped', 'abandoned')
68
+ ORDER BY id ASC
69
+ LIMIT 1`,
70
+ [goalId, SPEC_SOURCE, specSourceRef(goalId)]
71
+ );
72
+ return rows[0] ? Number(rows[0].id) : null;
73
+ }
74
+
75
+ // Make a task that just entered a goal wait on that goal's open spec task.
76
+ // Returns the spec task id it now waits on, or null. A task that IS the spec task
77
+ // is left alone. Safe without a cycle check only for a brand-new task (nothing can
78
+ // depend on it yet) — a moved task goes through addTaskDependency, which checks.
79
+ async function gateOnSpecTask(task, client, { addTaskDependency = null } = {}) {
80
+ if (!task || task.goal_id == null) return null;
81
+ if (task.source === SPEC_SOURCE && task.source_ref === specSourceRef(task.goal_id)) return null;
82
+ const specId = await openSpecTaskId(task.goal_id, client);
83
+ if (specId == null || specId === Number(task.id)) return null;
84
+ if (addTaskDependency) {
85
+ try {
86
+ await addTaskDependency(task.id, specId);
87
+ } catch (err) {
88
+ // A cycle means the spec task already waits on this task; gating it would
89
+ // deadlock both, so the move stands without the edge.
90
+ if (err && err.code === 'CYCLE') return null;
91
+ throw err;
92
+ }
93
+ } else {
94
+ await client.query(
95
+ `INSERT INTO dependencies (from_kind, from_id, to_kind, to_id)
96
+ VALUES ('task', $1, 'task', $2)
97
+ ON CONFLICT DO NOTHING`,
98
+ [task.id, specId]
99
+ );
100
+ }
101
+ return specId;
102
+ }
103
+
104
+ // Give a goal its spec task, safely re-runnable: if the goal already has an OPEN
105
+ // spec task, that one is returned and nothing is written, so a second run (a retry,
106
+ // or a future "re-offer the spec" path) never files a duplicate. Run it inside the
107
+ // caller's transaction so the read and the insert see the same snapshot.
108
+ async function ensureSpecTask({ db, goal, client, createdBy = null }) {
109
+ const existing = await openSpecTaskId(goal.id, client);
110
+ if (existing != null) return { specTask: { id: existing }, created: false };
111
+ const specTask = await db.createTask(specTaskFields(goal, { createdBy }), { client });
112
+ return { specTask, created: true };
113
+ }
114
+
115
+ // POST /goals's create step. Without the opt-in it is exactly db.createGoal. With it,
116
+ // the goal and its spec task land in ONE transaction: a goal without its spec task
117
+ // would be ungated, which is the state D3 exists to end.
118
+ async function createGoalWithSpec({ db, withTx, goalFields, withSpecTask = false }) {
119
+ if (!withSpecTask) return { goal: await db.createGoal(goalFields), specTask: null };
120
+ let goal;
121
+ let specTask = null;
122
+ await withTx(async (client) => {
123
+ goal = await db.createGoal(goalFields, { client });
124
+ ({ specTask } = await ensureSpecTask({ db, goal, client, createdBy: goalFields.createdBy }));
125
+ });
126
+ return { goal, specTask };
127
+ }
128
+
129
+ module.exports = { SPEC_SOURCE, specSourceRef, specTaskFields, openSpecTaskId, gateOnSpecTask, ensureSpecTask, createGoalWithSpec };
@@ -29,6 +29,7 @@ const hierarchy = require('../hierarchy');
29
29
  const goalSuggest = require('../goal-suggest.js'); // task 1003062 — pre-creation goal suggester
30
30
  const goalEdits = require('../goal-edits.js'); // task 1003102 — goal self-edit handlers
31
31
  const goalCategory = require('../goal-category.js'); // task 1003275 — the ADVISORY human category (gates nothing)
32
+ const goalSpecTask = require('../goal-spec-task.js'); // task 1004585 — a hall-made goal carries its own spec task (ADR 0366 D3)
32
33
  const goalRollup = require('../goal-rollup.js'); // task 1003278 — per-goal rollups computed in SQL, not in the browser
33
34
  const goalGraph = require('../goal-graph.js'); // task 1003479 — the goal graph: nodes + cross-goal edges, aggregated in SQL
34
35
  const goalForest = require('../goal-forest.js'); // task 1003480 — per-goal dependency forest
@@ -97,6 +98,7 @@ module.exports = function buildGoalsRouter() {
97
98
  // requested without stating a cause. minLength 8 because 'ok' / '.' is not
98
99
  // a reason, and the row is the only place the why survives.
99
100
  admit_reason: { type: 'string', minLength: 8, maxLength: 500 },
101
+ spec_task: { type: 'boolean' }, // task 1004585 / ADR 0366 D3 — the hall form's opt-in (goal-spec-task.js)
100
102
  })) return;
101
103
 
102
104
  const scopeModules = req.body.scope_modules || [];
@@ -156,15 +158,11 @@ module.exports = function buildGoalsRouter() {
156
158
  const admission = versionDecision.overrideApplied
157
159
  ? { admittedBy: req.builder.id, reason: admitReason }
158
160
  : null;
159
- const goal = await db.createGoal({
160
- versionId: req.body.version_id,
161
- title: req.body.title,
161
+ const { goal, specTask } = await goalSpecTask.createGoalWithSpec({ db, withTx: api.withTx, withSpecTask: req.body.spec_task === true, goalFields: {
162
+ versionId: req.body.version_id, title: req.body.title,
162
163
  subtitle: (req.body.subtitle || '').trim() || null, description: (req.body.description || '').trim() || null,
163
- scopeModules,
164
- createdBy: req.builder.id,
165
- categoryId,
166
- admission,
167
- });
164
+ scopeModules, createdBy: req.builder.id, categoryId, admission,
165
+ } });
168
166
  // Stamp the predecessor's succeeded_by pointer at the new goal (the lineage).
169
167
  if (succeedsId != null) {
170
168
  await db.setGoalSuccessor({ goalId: succeedsId, successorId: goal.id });
@@ -182,7 +180,7 @@ module.exports = function buildGoalsRouter() {
182
180
  // waved into a building version, by whom and why — the caller should not
183
181
  // have to re-read the goal to find out whether their override took effect.
184
182
  const admitted = admission ? await db.getGoalAdmission(goal.id) : null;
185
- return res.status(201).json({ ok: true, goal, succeeds_goal_id: succeedsId, category_advisory, admitted });
183
+ return res.status(201).json({ ok: true, goal, succeeds_goal_id: succeedsId, category_advisory, admitted, spec_task: specTask });
186
184
  } catch (err) {
187
185
  log.error('[gds] POST /goals', err);
188
186
  return res.fail('goal_create_failed', { status: 500, message: 'internal error' });
@@ -29,6 +29,7 @@ const { validateOrRespond, LIMITS, parseId } = api;
29
29
  const { badKind, detectsMigration } = require('./task-route-helpers.js');
30
30
  const { describeCheckViolation } = require('../pg-constraint-fail.js');
31
31
  const { goalNotOpenRefusal } = require('./goal-route-authz.js');
32
+ const { resolveUpkeepHome, isUpkeepFiling, UPKEEP_HINT } = require('../upkeep-home.js'); // task 1004597
32
33
  // task 1003208: structured logging (pino via the doorway) — was console.*.
33
34
  const log = api.logger('lifecycle');
34
35
 
@@ -189,10 +190,18 @@ module.exports = function registerTaskWriteRoutes(router) {
189
190
  // nobody reads. This is the human/agent door, and it always has a goal to
190
191
  // name — /builder-start lists work by goal, and POST /goals/:id/tasks has
191
192
  // required one since BV1.R60.
193
+ // task 1004597: the one exception is PLATFORM UPKEEP (a core upgrade, Bongos
194
+ // maintenance), which fits no product goal. An explicit kind 'infra' with no
195
+ // goal lands in the version's maintenance goal — see upkeep-home.js.
196
+ const upkeepHome = await resolveUpkeepHome(body, { ensureMaintenanceGoal: db.ensureMaintenanceGoal, withTx: api.withTx });
197
+ if (upkeepHome) body.goal_id = Number(upkeepHome.id);
192
198
  if (body.goal_id == null) {
193
199
  return res.fail('goal_id_required', {
194
200
  status: 400,
195
- message: 'goal_id is required — every task belongs to a goal. List them at GET /goals?version=<id>, or author under one directly with POST /goals/:id/tasks.',
201
+ message: 'goal_id is required — every task belongs to a goal. List them at GET /goals?version=<id>, or author under one directly with POST /goals/:id/tasks. ' +
202
+ (isUpkeepFiling(body)
203
+ ? `Version '${body.version_id}' is not being built right now, so it has no maintenance goal for upkeep — file it against the version that is.`
204
+ : UPKEEP_HINT),
196
205
  });
197
206
  }
198
207
  if (body.goal_id != null) {
@@ -297,6 +306,7 @@ module.exports = function registerTaskWriteRoutes(router) {
297
306
  needs_migration: needsMigration,
298
307
  criteria,
299
308
  suggested_criteria,
309
+ ...(upkeepHome ? { goal_home: 'maintenance' } : {}), // task 1004597
300
310
  goal_advisory, dependency_advisory, est_advisory: await require('../est-advisory').buildEstAdvisory({ db, task }), // task 1003148
301
311
  });
302
312
  } catch (err) {
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/upkeep-home.js — WHERE PLATFORM UPKEEP GOES WHEN NO PRODUCT
4
+ // GOAL FITS (task 1004597).
5
+ //
6
+ // THE CONTRADICTION THIS CLOSES. The ledger rule says record a change before you
7
+ // make it; the goal rule (ADR 0250 D4, BV1.R11) says every task belongs to a
8
+ // goal. A project's goals are product goals ("Marketing", "Live Floor"), so a
9
+ // builder about to upgrade the Bongos core had nowhere honest to file it: POST
10
+ // /tasks refused `goal_id_required`, and the only ways forward were to misfile
11
+ // the upgrade under a product goal or skip the record. One session (mercury,
12
+ // 2026-10-06) chose the latter.
13
+ //
14
+ // THE ANSWER REUSES THE HOMELESS-BUG HOME. Every building version already has a
15
+ // find-or-create MAINTENANCE goal (ADR 0262, `ensureMaintenanceGoal`): open, no
16
+ // scope wall, exempt from the version-close count, carried forward on close.
17
+ // Platform upkeep is maintenance in exactly that sense, so a task filed with
18
+ // NO goal and kind 'infra' lands there.
19
+ //
20
+ // ONLY AN EXPLICIT kind COUNTS — never the classifier's guess. The defect
21
+ // BV1.R11 fixed was SILENT bucketing (53 tasks filed out of sight by a default).
22
+ // A caller who writes kind 'infra' has said out loud what the work is; a feature
23
+ // whose text merely reads like infra must still name its goal.
24
+ //
25
+ // RANK AND APPROVAL ARE UNTOUCHED. This only answers "which goal"; the route's
26
+ // own permission check (task.create, Metic+) and status rules run exactly as for
27
+ // any other goal.
28
+
29
+ const UPKEEP_KIND = 'infra';
30
+
31
+ // isUpkeepFiling — true when a create names no goal and explicitly says it is
32
+ // platform upkeep. PURE; exported so the rule has one spelling.
33
+ function isUpkeepFiling(body) {
34
+ return !!body && body.goal_id == null && body.kind === UPKEEP_KIND;
35
+ }
36
+
37
+ // resolveUpkeepHome — the maintenance goal row an upkeep filing lands in, or
38
+ // null when it is not an upkeep filing or the version cannot take work (not
39
+ // `building`). The find-or-create runs inside its own transaction so the
40
+ // advisory lock in ensureMaintenanceGoal actually serialises concurrent filers
41
+ // (outside a transaction it would release at the end of its own statement).
42
+ async function resolveUpkeepHome(body, deps) {
43
+ if (!isUpkeepFiling(body)) return null;
44
+ const { ensureMaintenanceGoal, withTx } = deps;
45
+ return withTx((client) => ensureMaintenanceGoal(body.version_id, { client }));
46
+ }
47
+
48
+ // The plain-words half of the goal_id_required refusal: what to do when the work
49
+ // is upkeep rather than product work. Shared by the route and the CLI so the two
50
+ // never drift.
51
+ const UPKEEP_HINT =
52
+ 'Platform upkeep — a Bongos upgrade or other maintenance — has a standing home: ' +
53
+ `file it with kind "${UPKEEP_KIND}" and no goal, and it goes into this version's maintenance goal.`;
54
+
55
+ module.exports = { UPKEEP_KIND, UPKEEP_HINT, isUpkeepFiling, resolveUpkeepHome };
@@ -158,7 +158,13 @@
158
158
  "visible": [
159
159
  "#panel-done",
160
160
  "#wizInvite",
161
- "#wizInviteWho"
161
+ "#wizInviteWho",
162
+ "#humanSteps li.cur",
163
+ "#stepPager",
164
+ "#stepNext"
165
+ ],
166
+ "hidden": [
167
+ "#hop"
162
168
  ],
163
169
  "text": [
164
170
  [