@bongos/core 1.20.47 → 1.20.49

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 (73) hide show
  1. package/.bongos-core.json +108 -73
  2. package/docs/copy-inventory.md +200 -184
  3. package/docs/copy-registry.json +364 -220
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/page-inventory.json +11 -4
  6. package/docs/page-readings.json +411 -386
  7. package/modules/hall-ui/public/atlas.html +1 -1
  8. package/modules/hall-ui/public/blockers.html +1 -1
  9. package/modules/hall-ui/public/board-room.html +1 -1
  10. package/modules/hall-ui/public/collab.html +1 -1
  11. package/modules/hall-ui/public/commands.html +1 -1
  12. package/modules/hall-ui/public/copy-desk.html +1 -1
  13. package/modules/hall-ui/public/criterion-uat-ui.js +280 -0
  14. package/modules/hall-ui/public/deploy.html +1 -1
  15. package/modules/hall-ui/public/diagrams.html +1 -1
  16. package/modules/hall-ui/public/drachmae.html +1 -1
  17. package/modules/hall-ui/public/fleet.html +1 -1
  18. package/modules/hall-ui/public/gate.html +1 -1
  19. package/modules/hall-ui/public/goal-map.html +5 -3
  20. package/modules/hall-ui/public/goals-page.js +23 -44
  21. package/modules/hall-ui/public/goals-render.js +24 -15
  22. package/modules/hall-ui/public/goals.css +45 -0
  23. package/modules/hall-ui/public/goals.html +6 -3
  24. package/modules/hall-ui/public/goals.states.json +38 -0
  25. package/modules/hall-ui/public/government.html +1 -1
  26. package/modules/hall-ui/public/hall-render.js +3 -0
  27. package/modules/hall-ui/public/idea.html +1 -1
  28. package/modules/hall-ui/public/ideas.html +1 -1
  29. package/modules/hall-ui/public/index.html +1 -1
  30. package/modules/hall-ui/public/modules.html +1 -1
  31. package/modules/hall-ui/public/primer.html +1 -1
  32. package/modules/hall-ui/public/profile.html +1 -1
  33. package/modules/hall-ui/public/project-settings.html +1 -1
  34. package/modules/hall-ui/public/ranks.html +1 -1
  35. package/modules/hall-ui/public/roadmap.html +1 -1
  36. package/modules/hall-ui/public/roster.html +1 -1
  37. package/modules/hall-ui/public/sessions.html +1 -1
  38. package/modules/hall-ui/public/settings.html +1 -1
  39. package/modules/hall-ui/public/shell.js +82 -1
  40. package/modules/hall-ui/public/sky-data.js +9 -1
  41. package/modules/hall-ui/public/sky-interaction.js +1 -1
  42. package/modules/hall-ui/public/sky-panel.js +1 -1
  43. package/modules/hall-ui/public/studio.html +1 -1
  44. package/modules/hall-ui/public/style.css +36 -0
  45. package/modules/hall-ui/public/task.html +1 -1
  46. package/modules/hall-ui/public/thinking.html +6 -4
  47. package/modules/hall-ui/public/tweak-editor.html +1 -1
  48. package/modules/hall-ui/public/watch.html +1 -1
  49. package/modules/hall-ui/public/work-founding.states.json +39 -0
  50. package/modules/hall-ui/public/work.html +1 -1
  51. package/modules/hall-ui/records/founding-mode.md +31 -0
  52. package/modules/lifecycle/done-when.js +3 -1
  53. package/modules/lifecycle/sky.js +23 -8
  54. package/modules/onboarding/founding-mode.js +185 -0
  55. package/modules/onboarding/port.js +7 -0
  56. package/modules/ui-design/kit/fixtures/me-founding.json +24 -0
  57. package/modules/ui-design/kit/serve.js +3 -1
  58. package/package-lock.json +2 -2
  59. package/package.json +1 -1
  60. package/release-notes.json +12 -0
  61. package/scripts/hall-preview/server.js +2 -0
  62. package/src/bongos/routes/me.js +18 -1
  63. package/src/module-api.js +1 -1
  64. package/tests/claim_action.mjs +6 -1
  65. package/tests/criterion_uat_ui.mjs +113 -0
  66. package/tests/founding_mode.mjs +294 -0
  67. package/tests/goal_criterion_confirm_ui.mjs +188 -91
  68. package/tests/goal_map_page.mjs +2 -1
  69. package/tests/hall_sky.mjs +16 -0
  70. package/tests/hall_tweak_editor.mjs +1 -1
  71. package/tests/helpers.mjs +3 -1
  72. package/tests/nav_permission_atoms.mjs +1 -1
  73. package/tests/sky_read.mjs +7 -1
@@ -0,0 +1,31 @@
1
+ # Project founding mode — the band and the asleep rooms, worn by the existing hall (task 1004422)
2
+
3
+ **BV2.PS03 (goal 1000121).**
4
+ - Design of record: [`docs/design/project-startup-direction.md`](../../../docs/design/project-startup-direction.md), part 2 of the genesis table.
5
+ - Spec: [`docs/specs/<redacted>.md`](../../../docs/specs/<redacted>.md).
6
+
7
+ ## The rule this record exists to keep
8
+
9
+ The owner's blend rule: **genesis is a MODE the existing hall wears, never a second hall.** Founding mode follows the social-mode precedent exactly: one attribute on the document, `html[data-founding="on"]`, read from `/me`. It adds two things in this task:
10
+
11
+ - **the founding band**, a strip created as the first child of `.app-body`. It sits above the sticky topbar, which is kept as it is.
12
+ - **the asleep state on today's nav items**: dimmed, with an "at <stage>" or "when alive" tag that reads in the open rail.
13
+
14
+ Nothing else changes: no new page, no new route, no new nav item. **A project that is not founding makes `applyFounding` return before it touches a single node**, and `tests/founding_mode.mjs` proves that with a proxy that records every DOM call. Switching the mode off REMOVES the band and every class and tag, so the hall is restored exactly; it does not merely hide them. It is cosmetic: an asleep room still opens, and nothing is enforced.
15
+
16
+ ## Where the answer comes from
17
+
18
+ - `modules/onboarding/founding-mode.js` holds three `project_settings` keys (`founding.active`, `founding.stage`, `founding.started_at`) and computes which rooms sleep.
19
+ - `asleep` is keyed by `shell.js` NAV ids, so the client never re-derives it. A test holds the two maps together.
20
+ - It **fails closed to OFF**: an absent, unreadable or unknown stage reads as not founding. A hall wrongly stuck asleep would be the hijack; a hall wrongly awake only loses a banner.
21
+ - `/me.founding_mode` carries the answer. Both `applyAccess` callers pass it (`shell.js` `probeMe` and `hall-render.js`).
22
+
23
+ ## Rendered, and the phone rule
24
+
25
+ work-founding.states.json renders an ordinary hall page with a founding /me (kit fixture me-founding.json, the real shaper's output). The kit's /me honours stub.fixtures for this. At 390 and 320 the band wraps, the sentence steps aside and the stage and link stay visible, because the first render measured the band widening the page to 483px.
26
+
27
+ ## What is deliberately not here
28
+
29
+ - The genesis home, the stage tasks and the "what's this?" destination belong to PS08 (task 1004423). The band links home until then.
30
+ - The founding mark belongs to PS10 (task 1004424).
31
+ - Starting founding on a new project and moving its stage is `setFoundingMode`, on the onboarding port, and PS08 drives it. Nothing in this task writes it.
@@ -221,7 +221,9 @@ async function criterionProgress(versionId) {
221
221
  // about coverage. It uses idx_tasks_version_status (version_id leading).
222
222
  const [{ rows }, { rows: unattr }, goalRows] = await Promise.all([
223
223
  pool.query(
224
- `WITH ranked AS (
224
+ // MATERIALIZED (task 1004402): the per-criterion UAT subqueries run once per
225
+ // criterion here, never once per (criterion, task) row after the join.
226
+ `WITH ranked AS MATERIALIZED (
225
227
  SELECT d.id, d.version_id, d.criterion_id, d.criterion_md, d.satisfied, d.goal_id,
226
228
  ROW_NUMBER() OVER (ORDER BY d.sort_order, d.criterion_id) AS cnum,
227
229
  ${uatReadColumnsSql('d')}
@@ -42,7 +42,7 @@ const api = require('../../src/module-api');
42
42
  const { pool } = api;
43
43
  const goalGraph = require('./goal-graph');
44
44
  const goalCategory = require('./goal-category');
45
- const { isPendingReview } = require('./done-when');
45
+ const { isPendingReview, uatState, uatReadColumnsSql } = require('./done-when');
46
46
  const { TERMINAL_STATUSES } = require('./task-status');
47
47
 
48
48
  const SKY_CACHE_TTL_MS = 60 * 1000;
@@ -67,15 +67,24 @@ async function skyRows({ versionId = null, viewerId = null, viewerIsArchon = fal
67
67
  activePool.query(
68
68
  // The criterion→task join, read once. Flat rows on purpose (see the header):
69
69
  // the fold happens in shapeSky so a test can compare it with the rollup's.
70
- `SELECT c.id::text AS id, c.goal_id::text AS goal_id, c.criterion_id, c.criterion_md,
71
- c.satisfied, c.sort_order,
70
+ // task 1004402: each criterion's UAT columns (ADR 0351) are read ONCE per
71
+ // criterion, in a MATERIALIZED step before the task join fans the rows out;
72
+ // spliced into the flat join they re-ran once per linked task, on the one
73
+ // read that is the whole project's sky.
74
+ `WITH crit AS MATERIALIZED (
75
+ SELECT c.id, c.goal_id, c.criterion_id, c.criterion_md, c.satisfied, c.sort_order,
76
+ ${uatReadColumnsSql('c')}
77
+ FROM done_when_criteria c
78
+ JOIN goals g ON g.id = c.goal_id
79
+ WHERE c.goal_id IS NOT NULL
80
+ AND ($1::text IS NULL OR g.version_id = $1)
81
+ )
82
+ SELECT c.id::text AS id, c.goal_id::text AS goal_id, c.criterion_id, c.criterion_md,
83
+ c.satisfied, c.sort_order, c.uat_kind, c.backend_only,
72
84
  t.id::text AS task_id, t.status AS task_status
73
- FROM done_when_criteria c
74
- JOIN goals g ON g.id = c.goal_id
85
+ FROM crit c
75
86
  LEFT JOIN task_criteria tc ON tc.criterion_id = c.id
76
87
  LEFT JOIN tasks t ON t.id = tc.task_id
77
- WHERE c.goal_id IS NOT NULL
78
- AND ($1::text IS NULL OR g.version_id = $1)
79
88
  ORDER BY c.goal_id, c.sort_order, c.criterion_id, t.id`,
80
89
  [version]
81
90
  ),
@@ -111,21 +120,27 @@ function foldCriteria(rows = []) {
111
120
  criterion_id: row.criterion_id,
112
121
  criterion_md: row.criterion_md,
113
122
  satisfied: row.satisfied === true,
123
+ backend_only: row.backend_only === true,
114
124
  task_ids: [],
115
125
  _unshipped: 0,
126
+ _shipped: 0,
127
+ _uatKind: row.uat_kind ?? null,
116
128
  };
117
129
  byId.set(c.id, c);
118
130
  }
119
131
  if (row.task_id != null) {
120
132
  c.task_ids.push(String(row.task_id));
121
133
  if (!TERMINAL_STATUSES.includes(row.task_status)) c._unshipped += 1;
134
+ if (row.task_status === 'shipped') c._shipped += 1;
122
135
  }
123
136
  }
124
137
  return [...byId.values()].map((c) => {
125
- const { _unshipped, ...rest } = c;
138
+ const { _unshipped, _shipped, _uatKind, ...rest } = c;
126
139
  return {
127
140
  ...rest,
128
141
  pending_review: isPendingReview({ satisfied: c.satisfied, linked: c.task_ids.length, unshipped: _unshipped }),
142
+ // task 1004402: the same derivation the goal page and /status use.
143
+ uat_state: uatState({ satisfied: c.satisfied, linked: c.task_ids.length, unshipped: _unshipped, shipped: _shipped, latestKind: _uatKind }),
129
144
  };
130
145
  });
131
146
  }
@@ -0,0 +1,185 @@
1
+ 'use strict';
2
+
3
+ // modules/onboarding/founding-mode.js — PROJECT FOUNDING MODE: is this project still
4
+ // being founded, and which stage of genesis is it on? (task 1004422 / BV2.PS03, goal
5
+ // 1000121; design of record docs/design/project-startup-direction.md.)
6
+ //
7
+ // THE BLEND RULE THIS FILE SERVES (owner, 2026-09-30). Genesis is a MODE the existing
8
+ // builders hall wears, never a second hall. So this file answers ONE question for the
9
+ // hall shell — founding or not, and at which stage — and the shell stamps the answer on
10
+ // the document the way it stamps social mode (`html[data-social]`). Nothing here renders
11
+ // anything, and a project that is not founding gets `null`, which the shell reads as OFF:
12
+ // the hall then renders exactly as it does today.
13
+ //
14
+ // WHY THE ONBOARDING MODULE. Founding is the FOUNDER-side counterpart of the joiner
15
+ // state machine this module already owns (onboarding-state.js graduates a newcomer; this
16
+ // graduates a project). It lives in project_settings (migration core_231) through the
17
+ // doorway's one accessor, like mingle.enabled, rather than a table of its own — one row
18
+ // per project is the whole state, and "do not fork a second settings store" is that
19
+ // accessor's own rule.
20
+ //
21
+ // FAIL POSTURE: CLOSED, i.e. OFF. An unreadable, absent, or malformed state is NOT
22
+ // founding. The failure this guards against is the owner's no-hijack condition: a hall
23
+ // wrongly stuck in founding mode would dim rooms that are really open. A hall wrongly
24
+ // NOT in founding mode only loses a banner. So every doubtful read answers null.
25
+
26
+ // The genesis stages, in order. 'decide' is the pre-stage the founder plans in; the
27
+ // five after it are the stages proper (spec C4). The order IS the data: a room wakes
28
+ // when the stage that wakes it has been left behind.
29
+ const STAGES = Object.freeze(['decide', 'scope', 'strategize', 'charter', 'legalize', 'legislation']);
30
+
31
+ // Which hall rooms each finished stage wakes (the direction doc's "How rooms wake"
32
+ // table). Keyed by the shell's NAV item ids, so the client applies it without a second
33
+ // mapping of its own — a surface that re-derived this could disagree with the server.
34
+ // 'community' wakes only when the planet is ALIVE, which is when founding mode switches
35
+ // off entirely, so during founding it is always asleep.
36
+ const WAKES = Object.freeze({
37
+ scope: Object.freeze(['roadmap', 'goals', 'goal-map']),
38
+ strategize: Object.freeze(['work']),
39
+ charter: Object.freeze(['board-room']),
40
+ legalize: Object.freeze(['economy']),
41
+ legislation: Object.freeze(['ranks']),
42
+ });
43
+ const WAKES_WHEN_ALIVE = Object.freeze(['collab', 'roster']);
44
+
45
+ // The project_settings keys. Namespaced 'founding.' like 'mingle.' and 'attention.'.
46
+ const KEYS = Object.freeze({
47
+ ACTIVE: 'founding.active',
48
+ STAGE: 'founding.stage',
49
+ STARTED_AT: 'founding.started_at',
50
+ });
51
+
52
+ // The only 'on' spelling accepted, mirroring mingle's consent-gate parse: an explicit,
53
+ // recognised yes. Anything else is off.
54
+ function parseActive(raw) {
55
+ if (raw == null) return false;
56
+ return ['true', '1', 'on', 'yes'].includes(String(raw).trim().toLowerCase());
57
+ }
58
+
59
+ // asleepRooms(stage) — PURE. For a known stage, { navId: wakingStage } for every room
60
+ // still asleep. A room is awake once its waking stage is BEHIND the current one.
61
+ function asleepRooms(stage) {
62
+ const at = STAGES.indexOf(stage);
63
+ if (at < 0) return null;
64
+ const asleep = {};
65
+ for (const [wakingStage, ids] of Object.entries(WAKES)) {
66
+ if (STAGES.indexOf(wakingStage) >= at) for (const id of ids) asleep[id] = wakingStage;
67
+ }
68
+ for (const id of WAKES_WHEN_ALIVE) asleep[id] = 'alive';
69
+ return asleep;
70
+ }
71
+
72
+ // dayOf(startedAt, now) — PURE. Day 1 is the founding day. null when unreadable, so a
73
+ // bad timestamp drops the day from the band rather than printing "day NaN".
74
+ function dayOf(startedAt, now = new Date()) {
75
+ if (!startedAt) return null;
76
+ const t = new Date(startedAt).getTime();
77
+ if (!Number.isFinite(t)) return null;
78
+ const days = Math.floor((now.getTime() - t) / 86400000) + 1;
79
+ return days >= 1 ? days : 1;
80
+ }
81
+
82
+ // shapeFoundingMode(rows, now) — PURE. rows = { active, stage, startedAt } raw strings
83
+ // (or nulls). Returns the /me payload, or null for "not founding" in any form.
84
+ function shapeFoundingMode(rows, now = new Date()) {
85
+ if (!rows || !parseActive(rows.active)) return null;
86
+ const stage = rows.stage == null ? 'decide' : String(rows.stage).trim().toLowerCase();
87
+ const asleep = asleepRooms(stage);
88
+ if (!asleep) return null; // an unknown stage is not a stage — fail closed (OFF)
89
+ const index = STAGES.indexOf(stage);
90
+ return {
91
+ active: true,
92
+ stage,
93
+ // 0 while deciding, then 1..5 through the stages proper.
94
+ stage_number: index,
95
+ stage_count: STAGES.length - 1,
96
+ day: dayOf(rows.startedAt, now),
97
+ asleep,
98
+ // Who closing the current stage is waiting on (spec D7) — display names, at most
99
+ // five, from the government module's genesis-stage sitting (task 1004429). null
100
+ // when the stage is not sitting, nobody is left to vote, or the read is missing.
101
+ waiting_on: Array.isArray(rows.waitingOn) && rows.waitingOn.length
102
+ ? rows.waitingOn.slice(0, 5).map((n) => String(n))
103
+ : null,
104
+ };
105
+ }
106
+
107
+ // foundingModeFor(deps) — the /me read. Resolves the three rows through the doorway's
108
+ // project-settings accessor (injectable for tests). Never throws: any read failure is
109
+ // "not founding".
110
+ // Three primary-key reads in parallel: the doorway's project-settings accessor is
111
+ // single-key by design (one accessor, one table), and a founding project is a short
112
+ // window of a project's life, so a batched read is not worth a second accessor.
113
+ async function foundingModeFor(deps = {}) {
114
+ const get = deps.getSetting || require('../../src/module-api').projectSettings.get;
115
+ let shaped;
116
+ try {
117
+ const [active, stage, startedAt] = await Promise.all([
118
+ get(KEYS.ACTIVE), get(KEYS.STAGE), get(KEYS.STARTED_AT),
119
+ ]);
120
+ shaped = shapeFoundingMode({
121
+ active: active && active.value,
122
+ stage: stage && stage.value,
123
+ startedAt: startedAt && startedAt.value,
124
+ }, deps.now || new Date());
125
+ } catch (_) {
126
+ return null;
127
+ }
128
+ if (!shaped) return null;
129
+ // The waiting-on names come from the government module's genesis-stage read
130
+ // (genesisStageStatus on its port, task 1004429: one entry per stage, each with
131
+ // a state and, while sitting, the members still to vote under the item's own
132
+ // snapshot). Optional and fail-open: a missing port or a failed read leaves the
133
+ // band without the names, never without the mode.
134
+ try {
135
+ const waitingOnFor = deps.waitingOnFor || waitingOnFromGovernment(
136
+ deps.government !== undefined ? deps.government : require('../../src/module-api').resolveOptional('government'),
137
+ );
138
+ if (waitingOnFor) {
139
+ const names = await waitingOnFor(shaped.stage);
140
+ if (Array.isArray(names) && names.length) shaped.waiting_on = names.slice(0, 5).map((n) => String(n));
141
+ }
142
+ } catch (_) { /* fail open: the band shows the stage without the names */ }
143
+ return shaped;
144
+ }
145
+
146
+ // waitingOnFromGovernment(gov) — a stage -> [display name] reader over the government
147
+ // port's genesisStageStatus(), or null when the port does not offer it. Only a stage
148
+ // that is SITTING waits on anyone; not_put, passed and returned wait on nobody.
149
+ function waitingOnFromGovernment(gov) {
150
+ if (!gov || typeof gov.genesisStageStatus !== 'function') return null;
151
+ return async (stage) => {
152
+ const status = await gov.genesisStageStatus();
153
+ const row = status && Array.isArray(status.stages) ? status.stages.find((s) => s.key === stage) : null;
154
+ if (!row || row.state !== 'sitting' || !Array.isArray(row.waiting_on)) return [];
155
+ return row.waiting_on.map((m) => m.display_name || m.github_login).filter(Boolean);
156
+ };
157
+ }
158
+
159
+ // setFoundingMode({ active, stage }, builderId, deps) — the write the genesis home
160
+ // (task 1004423) drives: start founding on a new project, move the stage, and switch
161
+ // founding off when the planet comes alive. Refuses an unknown stage rather than storing
162
+ // a value the read would then treat as OFF. Starting stamps started_at once.
163
+ async function setFoundingMode({ active, stage } = {}, builderId = null, deps = {}) {
164
+ const settings = require('../../src/module-api').projectSettings;
165
+ const get = deps.getSetting || settings.get;
166
+ const set = deps.setSetting || settings.set;
167
+ if (stage != null && !STAGES.includes(stage)) {
168
+ const err = new Error(`unknown founding stage '${stage}'`);
169
+ err.code = 'bad_founding_stage';
170
+ throw err;
171
+ }
172
+ if (active === true) {
173
+ const started = await get(KEYS.STARTED_AT);
174
+ if (!started || !started.value) await set(KEYS.STARTED_AT, (deps.now || new Date()).toISOString(), builderId);
175
+ }
176
+ if (typeof active === 'boolean') await set(KEYS.ACTIVE, active ? 'true' : 'false', builderId);
177
+ if (stage != null) await set(KEYS.STAGE, stage, builderId);
178
+ return foundingModeFor({ getSetting: get, now: deps.now });
179
+ }
180
+
181
+ module.exports = {
182
+ STAGES, WAKES, WAKES_WHEN_ALIVE, KEYS,
183
+ parseActive, asleepRooms, dayOf, shapeFoundingMode, waitingOnFromGovernment,
184
+ foundingModeFor, setFoundingMode,
185
+ };
@@ -40,6 +40,7 @@
40
40
 
41
41
  const onboardingState = require('./onboarding-state');
42
42
  const newcomerRestock = require('./newcomer-restock');
43
+ const foundingMode = require('./founding-mode');
43
44
 
44
45
  module.exports = {
45
46
  // --- the newcomer onboarding state machine (onboarding-state.js) ---------
@@ -53,6 +54,12 @@ module.exports = {
53
54
  markStage: (...a) => onboardingState.markStage(...a),
54
55
  broadcastGraduation: (...a) => onboardingState.broadcastGraduation(...a),
55
56
 
57
+ // --- project founding mode (founding-mode.js, task 1004422) ---------------
58
+ // me.js reads it for the hall shell (null = not founding = the hall as today);
59
+ // the genesis home (task 1004423) drives the write.
60
+ foundingModeFor: (...a) => foundingMode.foundingModeFor(...a),
61
+ setFoundingMode: (...a) => foundingMode.setFoundingMode(...a),
62
+
56
63
  // --- the evergreen newcomer-chore restock (newcomer-restock.js) ----------
57
64
  // restock the just-claimed chore's shelf (claims.js / tasks.js); the Archon
58
65
  // backstop tops every chore up (tasks.js). SOURCE_TAG is the marker the claim
@@ -0,0 +1,24 @@
1
+ {
2
+ "builder": {
3
+ "id": "1",
4
+ "github_login": "builder",
5
+ "display_name": "A Builder",
6
+ "avatar_url": "/__kit/avatar.svg",
7
+ "rank": "archon"
8
+ },
9
+ "founding_mode": {
10
+ "active": true,
11
+ "stage": "strategize",
12
+ "stage_number": 2,
13
+ "stage_count": 5,
14
+ "day": 9,
15
+ "asleep": {
16
+ "work": "strategize",
17
+ "board-room": "charter",
18
+ "economy": "legalize",
19
+ "ranks": "legislation",
20
+ "collab": "alive",
21
+ "roster": "alive"
22
+ }
23
+ }
24
+ }
@@ -277,7 +277,9 @@ function fileUnder(base, rel) {
277
277
  // is absolutised to this origin per request.
278
278
  const AVATAR_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><circle cx="32" cy="32" r="32" fill="#888"/><circle cx="32" cy="26" r="11" fill="#ddd"/><path d="M12 56c3-12 11-17 20-17s17 5 20 17" fill="#ddd"/></svg>';
279
279
  function me(origin) {
280
- const m = JSON.parse(fs.readFileSync(path.join(FIXTURES, 'me.json'), 'utf8'));
280
+ // stub.fixtures may re-point the identity too (task 1004422: the founding-mode render) —
281
+ // the same ALIAS every other fixture honours, so a variant states file needs no stub code.
282
+ const m = JSON.parse(fs.readFileSync(path.join(FIXTURES, (ALIAS.me || 'me') + '.json'), 'utf8'));
281
283
  if (m.builder && typeof m.builder.avatar_url === 'string' && m.builder.avatar_url.startsWith('/')) m.builder.avatar_url = origin + m.builder.avatar_url;
282
284
  return m;
283
285
  }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.47",
3
+ "version": "1.20.49",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.47",
9
+ "version": "1.20.49",
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.20.47",
3
+ "version": "1.20.49",
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",
@@ -8395,5 +8395,17 @@
8395
8395
  "id": "1003141",
8396
8396
  "text": "The Goals page now has the same bulk actions as the Tasks board: claim several tasks, release your own, or copy a prompt to work them in parallel or in order. Both pages now share one toolbar, so they stay in step."
8397
8397
  }
8398
+ ],
8399
+ "1.20.48": [
8400
+ {
8401
+ "id": "1004422",
8402
+ "text": "While a new project is being founded, its builders hall now shows a clear \"project founding mode\" band at the top and dims the rooms that open later. A hall that isn't being founded stays exactly as it was."
8403
+ }
8404
+ ],
8405
+ "1.20.49": [
8406
+ {
8407
+ "id": "1004402",
8408
+ "text": "A goal's page now shows where each checklist item really stands: Awaiting UAT, Satisfied, or Closed before UAT, with a Backend-only tag where there is nothing to record. The old Confirm button is gone. Instead, an item whose w"
8409
+ }
8398
8410
  ]
8399
8411
  }
@@ -105,6 +105,8 @@ const FIXTURE_FALLBACKS = [
105
105
  [/^goals\/\d+\/dependency-forest$/, 'goals__dependency-forest'],
106
106
  [/^goals\/\d+\/requests$/, 'goals__requests'],
107
107
  [/^goals\/\d+$/, 'goals__detail'],
108
+ // a criterion's UAT panel (task 1004402): one Awaiting UAT reading serves any id.
109
+ [/^done-when\/\d+\/uat$/, 'done-when__uat'],
108
110
  [/^tasks\/\d+$/, 'tasks__detail'],
109
111
  // the #/idea/N + #/blocker/N deep-link overlay (task-detail.js) — one record
110
112
  // fixture serves any id so the overlay seam assertion renders real content.
@@ -187,7 +187,7 @@ module.exports = function buildMeRouter() {
187
187
  recentGrades, builderAvg, onboardingRow, shipCount, recentKarma, karma30d, discordLink, cliAuthed,
188
188
  behaviorPrefs, geminiKeyRow, needsRebaseTasks, foundingBuilder, helpRequestCount,
189
189
  taskRecommendationCount, boardVotesPending, socialMode, mingleIntroductionCount,
190
- navPermissions,
190
+ navPermissions, foundingMode,
191
191
  ] = await Promise.all([
192
192
  db.getBuilderById(req.builder.id),
193
193
  lc().getActiveClaimsForBuilder(req.builder.id),
@@ -308,6 +308,16 @@ module.exports = function buildMeRouter() {
308
308
  // Same fan-out slot as the reads above, but it fails CLOSED rather than
309
309
  // open — null hides the link. See navPermissionsFor.
310
310
  navPermissionsFor(req.builder.id),
311
+ // task 1004422 (BV2.PS03): PROJECT FOUNDING MODE — is this project still being
312
+ // founded, and at which genesis stage. Same fan-out slot and posture as social
313
+ // mode: null (not founding, or unreadable) is OFF, and the hall then renders
314
+ // exactly as it does today, which is the owner's no-hijack condition.
315
+ (async () => {
316
+ try {
317
+ if (!onboarding || !onboarding.foundingModeFor) return null;
318
+ return await onboarding.foundingModeFor();
319
+ } catch (_) { return null; }
320
+ })(),
311
321
  ]);
312
322
  // V3.R84 (#302), criterion C3: the builder's month-to-date spend vs their
313
323
  // monthly budget. Surfaced here so the CLI (session-start ping) and the hall
@@ -378,6 +388,13 @@ module.exports = function buildMeRouter() {
378
388
  // when lifecycle is disabled or the read failed, and the shell treats null
379
389
  // as OFF — a mode that cannot be read is not on.
380
390
  social: socialMode,
391
+ // task 1004422 (BV2.PS03, goal 1000121): PROJECT FOUNDING MODE. Genesis is a
392
+ // mode the EXISTING hall wears (the blend rule, docs/design/project-startup-
393
+ // direction.md), so it rides /me exactly like social: the shell stamps
394
+ // html[data-founding], draws the founding band and dims the rooms still asleep.
395
+ // Shape: { active, stage, stage_number, stage_count, day, asleep: {navId: stage} },
396
+ // or null — and null is OFF, the hall as today.
397
+ founding_mode: foundingMode,
381
398
  // task 1003744: { '<atom>': boolean } for each permission key a hall nav
382
399
  // item may gate on (NAV_PERMISSION_ATOMS above), so an item can declare the
383
400
  // SAME atom its page is gated on instead of borrowing the nearest rank
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.20.47'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.49'; // 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');
@@ -401,8 +401,13 @@ await test('the cache-bust moved on every page whose script changed', () => {
401
401
  ['goals.html', ['goals-render.js', 'goals-page.js']],
402
402
  ]) {
403
403
  const html = read('modules', 'hall-ui', 'public', page);
404
+ // The invariant is "busted AT OR AFTER the claim-action change", not the
405
+ // literal stamp: a later change to the same script bumps it again (task
406
+ // 1004402 did), and a pin on the string would fail that correct bump.
404
407
  for (const s of scripts) {
405
- assert.match(html, new RegExp(s.replace('.', '\\.') + '\\?v=2026-09-30-bulktoolbar'), `${page}: ${s} not busted`);
408
+ const m = new RegExp(s.replace('.', '\\.') + '\\?v=(\\d{4}-\\d{2}-\\d{2})').exec(html);
409
+ assert.ok(m, `${page}: ${s} carries no dated cache-bust`);
410
+ assert.ok(m[1] >= '2026-09-26', `${page}: ${s} not busted since the claim-action change (${m[1]})`);
406
411
  }
407
412
  }
408
413
  });
@@ -0,0 +1,113 @@
1
+ // tests/criterion_uat_ui.mjs — the hall's criterion UAT helpers (task 1004402,
2
+ // ADR 0351), DB-free and DOM-free. The page-level wiring is driven for real in
3
+ // tests/goal_criterion_confirm_ui.mjs; this file pins the vocabulary and the
4
+ // panel's variations.
5
+ //
6
+ // 1. The labels equal the server's (modules/lifecycle/criterion-uat.js
7
+ // stateLabel), so the hall, /status and /goal-uat say the same thing. The
8
+ // browser cannot require server code, so this is the lockstep.
9
+ // 2. The backend-only switch appears only where it can work: an OPEN criterion,
10
+ // in the full view, for a reviewer, on a server that knows the rule.
11
+ // 3. The panel asks for what the server will require and nothing else: the
12
+ // attestation only where the site cannot read its own deploy; no file field
13
+ // on a backend-only criterion; no form for a viewer who cannot sign.
14
+ // 4. A stale sign-off is said to no longer count, never shown as the UAT.
15
+ //
16
+ // Run: node tests/criterion_uat_ui.mjs
17
+
18
+ import { strict as assert } from 'node:assert';
19
+ import { createRequire } from 'node:module';
20
+ import { makeRunner } from './helpers.mjs';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const ui = require('../modules/hall-ui/public/criterion-uat-ui.js');
24
+ const server = require('../modules/lifecycle/criterion-uat.js');
25
+ const { test, summary } = makeRunner();
26
+
27
+ await test('LABELS: every server state has the same words in the hall', () => {
28
+ for (const state of Object.values(server.UAT_STATES)) {
29
+ assert.equal(ui.STATE_LABELS[state], server.stateLabel(state), state);
30
+ }
31
+ assert.equal(Object.keys(ui.STATE_LABELS).length, Object.values(server.UAT_STATES).length, 'no hall-only state');
32
+ });
33
+
34
+ await test('STATE: an older server with no uat_state reads plainly and never as a UAT state', () => {
35
+ assert.equal(ui.stateOf({ satisfied: true }), 'legacy_satisfied');
36
+ assert.equal(ui.stateOf({ satisfied: false }), 'open');
37
+ assert.equal(ui.badgesHtml({ satisfied: true }), '');
38
+ assert.equal(ui.actionHtml({ id: 1, satisfied: true }, { canReview: true, full: true }), '');
39
+ });
40
+
41
+ await test('BACKEND-ONLY SWITCH: only on an OPEN criterion, in the full view, for a reviewer', () => {
42
+ const open = { id: 9, uat_state: 'open', satisfied: false };
43
+ assert.match(ui.actionHtml(open, { canReview: true, full: true }), /data-uat-backend="9" data-on="1"/);
44
+ assert.match(ui.actionHtml({ ...open, backend_only: true }, { canReview: true, full: true }), /data-on="0"[^>]*>Not backend-only/);
45
+ assert.equal(ui.actionHtml(open, { canReview: true, full: false }), '', 'not in the capped band: it is an authoring act');
46
+ assert.equal(ui.actionHtml(open, { canReview: false, full: true }), '');
47
+ assert.doesNotMatch(ui.actionHtml({ id: 9, uat_state: 'awaiting_uat' }, { canReview: true, full: true }), /data-uat-backend/,
48
+ 'once awaiting, the server refuses it, so the page does not offer it');
49
+ });
50
+
51
+ const VIEW = (over = {}) => ({
52
+ criterion: { id: 42 },
53
+ uat_state: 'awaiting_uat',
54
+ backend_only: false,
55
+ checks: { code: true, live: { available: false }, uat: null, stale_uat: null },
56
+ ...over,
57
+ });
58
+
59
+ await test('PANEL: a site that cannot read its deploy asks for the attestation; one that can, does not', () => {
60
+ const blind = ui.panelHtml(VIEW(), { apiBase: '/api/bongos', canReview: true });
61
+ assert.match(blind, /data-uat-attest="42"/);
62
+ assert.match(blind, /data-uat-file="42"/);
63
+ const seeing = ui.panelHtml(VIEW({ checks: { code: true, live: { available: true, live: true }, uat: null } }), { apiBase: '/api/bongos', canReview: true });
64
+ assert.doesNotMatch(seeing, /data-uat-attest/);
65
+ assert.match(seeing, /Running on the live site\./);
66
+ });
67
+
68
+ await test('PANEL: not live yet names the tasks, in plain words', () => {
69
+ const html = ui.panelHtml(VIEW({ checks: { code: true, live: { available: true, live: false, not_live: [{ task_id: '12' }] }, uat: null } }), { apiBase: '', canReview: true });
70
+ assert.match(html, /not on the live site yet \(#12\)/);
71
+ });
72
+
73
+ await test('PANEL: a backend-only criterion has no file field and asks how it was checked', () => {
74
+ const html = ui.panelHtml(VIEW({ backend_only: true }), { apiBase: '', canReview: true });
75
+ assert.doesNotMatch(html, /data-uat-file/);
76
+ assert.match(html, /How you checked it/);
77
+ assert.match(html, />Sign off</);
78
+ });
79
+
80
+ await test('PANEL: a viewer who cannot sign sees the checks and no form', () => {
81
+ const html = ui.panelHtml(VIEW(), { apiBase: '', canReview: false });
82
+ assert.match(html, /uat-checks/);
83
+ assert.doesNotMatch(html, /data-uat-form/);
84
+ });
85
+
86
+ await test('PANEL: a stale sign-off is said to no longer count, and its recording still plays', () => {
87
+ const stale = { kind: 'uat', signed_off_by_login: 'ana', created_at: '2026-09-20T10:00:00Z', recording_file: 'uat-42-0123456789abcdef.mp4' };
88
+ const html = ui.panelHtml(VIEW({ checks: { code: true, live: { available: false }, uat: null, stale_uat: stale } }), { apiBase: '/api/bongos', canReview: true });
89
+ assert.match(html, /no longer counts: more work shipped after it/);
90
+ assert.match(html, /<video class="uat-video" controls preload="metadata" src="\/api\/bongos\/criterion-uats\/recordings\/uat-42-0123456789abcdef\.mp4">/);
91
+ });
92
+
93
+ await test('PANEL: a closed criterion shows who signed it and when, and offers no form', () => {
94
+ const html = ui.panelHtml(VIEW({ uat_state: 'satisfied_uat', checks: { code: true, live: null, uat: { kind: 'uat', signed_off_by_login: 'ana', created_at: '2026-09-28T10:00:00Z', note: 'made a rank' } } }), { apiBase: '', canReview: true });
95
+ assert.match(html, /Tried on the live site by ana/);
96
+ assert.match(html, /made a rank/);
97
+ assert.doesNotMatch(html, /data-uat-form/);
98
+ });
99
+
100
+ await test('ESCAPING: a note or a login can never become markup', () => {
101
+ const html = ui.panelHtml(VIEW({ uat_state: 'satisfied_uat', checks: { code: true, uat: { kind: 'uat', signed_off_by_login: '<b>x</b>', created_at: '2026-09-28', note: '"><script>alert(1)</script>' } } }), { apiBase: '', canReview: false });
102
+ assert.doesNotMatch(html, /<script>|<b>x<\/b>/);
103
+ });
104
+
105
+ await test('REFUSALS: the server\'s sentence wins; the fallbacks are plain words', () => {
106
+ assert.equal(ui.refusalText({ error: { code: 'work_not_live', message: 'Some of this work is not deployed yet.' } }, 409), 'Some of this work is not deployed yet.');
107
+ assert.match(ui.refusalText({}, 413), /100 MB cap/);
108
+ assert.match(ui.refusalText(null, 500), /did not go through \(500\)/);
109
+ assert.equal(ui.checkFile({ type: 'video/webm', size: 5 }), null);
110
+ assert.equal(ui.checkFile(null), 'Choose the recording first.');
111
+ });
112
+
113
+ summary();