@bongos/core 1.25.15 → 1.25.17

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 (66) hide show
  1. package/.bongos-core.json +128 -58
  2. package/.claude/skills/goal-create/SKILL.md +1 -0
  3. package/.claude/skills/planning-session/SKILL.md +5 -4
  4. package/bin/bongos.js +1 -0
  5. package/clients/bongos-client/README.md +1 -1
  6. package/clients/bongos-client/bongos-client.global.js +14 -0
  7. package/clients/bongos-client/index.cjs +14 -0
  8. package/clients/bongos-client/index.d.ts +22 -2
  9. package/clients/bongos-client/index.mjs +14 -0
  10. package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +8 -0
  11. package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +1 -0
  12. package/docs/api/openapi.json +415 -3
  13. package/docs/api-reference.md +13 -2
  14. package/docs/copy-inventory.md +120 -109
  15. package/docs/copy-registry.json +275 -165
  16. package/docs/module-api-changelog.md +4 -2
  17. package/docs/page-inventory.json +2 -0
  18. package/docs/page-readings.json +107 -107
  19. package/docs/project-context.template.md +1 -0
  20. package/modules/government/decision-rules.js +1 -0
  21. package/modules/hall-ui/public/dom-utils.js +5 -0
  22. package/modules/hall-ui/public/genesis-home.js +23 -8
  23. package/modules/hall-ui/public/setup-gate.js +1 -1
  24. package/modules/hall-ui/public/style.css +21 -1
  25. package/modules/lifecycle/first-read-db.js +152 -0
  26. package/modules/lifecycle/first-read.js +265 -0
  27. package/modules/lifecycle/kickoff-checklist.js +29 -6
  28. package/modules/lifecycle/kickoff-seed.js +27 -4
  29. package/modules/lifecycle/migrations/lifecycle_022_repo_maps.sql +44 -0
  30. package/modules/lifecycle/module.json +2 -1
  31. package/modules/lifecycle/routes/first-read.js +79 -0
  32. package/modules/onboarding/genesis-home.js +11 -2
  33. package/modules/onboarding/genesis-work.js +28 -5
  34. package/modules/provisioning/pollers/app-liveness.js +23 -1
  35. package/modules/provisioning/render-sandbox.js +113 -0
  36. package/modules/provisioning/render-standup.js +3 -1
  37. package/modules/provisioning/routes/provisioning.js +4 -1
  38. package/modules/provisioning/routes/render-standup.js +49 -1
  39. package/modules/public-landing/public/assets/guide-scene.css +245 -0
  40. package/modules/public-landing/public/assets/guide-scene.js +566 -0
  41. package/modules/public-landing/public/index.html +62 -2
  42. package/modules/public-landing/public/projects.html +101 -4
  43. package/package-lock.json +2 -2
  44. package/package.json +1 -1
  45. package/release-notes.json +23 -1
  46. package/scripts/bongos/first-read.js +116 -0
  47. package/scripts/bongos/genesis.js +2 -0
  48. package/scripts/bongos/government-walk.js +4 -4
  49. package/scripts/bongos/provision-render-sandbox.js +171 -0
  50. package/scripts/bongos/provision-render.js +37 -2
  51. package/scripts/bongos/provision-teardown.js +1 -1
  52. package/scripts/bongos/render-api.js +33 -3
  53. package/scripts/bongos/render-payload.js +39 -1
  54. package/scripts/gds/first-read.js +7 -0
  55. package/scripts/gds/provision-render-sandbox.js +6 -0
  56. package/src/module-api.js +1 -1
  57. package/tests/first_read.mjs +303 -0
  58. package/tests/genesis_home.mjs +70 -2
  59. package/tests/genesis_work.mjs +49 -0
  60. package/tests/government_act_alone_buildings.mjs +2 -0
  61. package/tests/landing_guide_scene.mjs +504 -0
  62. package/tests/provisioning_app_status.mjs +3 -2
  63. package/tests/provisioning_render_route.mjs +1 -1
  64. package/tests/provisioning_teardown_intent.mjs +50 -0
  65. package/tests/render_api.mjs +5 -2
  66. package/tests/render_sandbox.mjs +447 -0
@@ -0,0 +1,303 @@
1
+ // tests/first_read.mjs — THE FIRST READ-THROUGH of a project that already exists (task
2
+ // 1005280, goal 1000106).
3
+ //
4
+ // Done-when: an adopted project's Genesis step hands the owner a prompt for their OWN AI
5
+ // session; that session's map comes back in a defined, validated shape; the owner
6
+ // accepts it whole or item by item into DRAFT goals and tasks; a rejection files
7
+ // nothing; a re-run files nothing twice; the shape has room for task 1005281's
8
+ // recommended modules.
9
+ //
10
+ // Everything here RUNS: the real prompt and Genesis checklist, the real validator and
11
+ // acceptance planner, the real store over an in-memory fake pool, and the real router
12
+ // over a socket.
13
+
14
+ import { strict as assert } from 'node:assert';
15
+ import { test, after } from 'node:test';
16
+ import { createRequire } from 'node:module';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const fr = require('../modules/lifecycle/first-read.js');
20
+ const storeModule = require('../modules/lifecycle/first-read-db.js');
21
+ // A snapshot of the real store: the route section below re-points the module's exports
22
+ // at its own pool, and module-level code runs before any test does.
23
+ const store = { ...storeModule };
24
+ const { genesisChecklist } = require('../modules/lifecycle/kickoff-checklist.js');
25
+
26
+ const clone = (o) => JSON.parse(JSON.stringify(o));
27
+
28
+ // ── an in-memory pool that answers exactly the store's statements ─────────────
29
+
30
+ function fakePool() {
31
+ const s = { maps: [], filed: new Map(), goals: [], tasks: [], nextId: 1 };
32
+ const query = async (sql, p = []) => {
33
+ const q = sql.replace(/\s+/g, ' ').trim();
34
+ if (/^(BEGIN|COMMIT|ROLLBACK)$/.test(q) || /pg_advisory_xact_lock/.test(q)) return { rows: [] };
35
+ if (/^INSERT INTO lifecycle_repo_maps/.test(q)) {
36
+ const row = { id: String(s.nextId++), schema: p[0], map: JSON.parse(p[1]), status: 'pending', submitted_by: p[2], submitted_at: new Date() };
37
+ s.maps.push(row); return { rows: [row] };
38
+ }
39
+ if (/FROM lifecycle_repo_maps ORDER BY/.test(q)) return { rows: s.maps.slice(-1) };
40
+ if (/^SELECT .* FROM lifecycle_repo_maps WHERE id = \$1/.test(q)) return { rows: s.maps.filter((m) => m.id === String(p[0])) };
41
+ if (/^SELECT ref, kind, entity_id FROM lifecycle_repo_map_filed/.test(q)) {
42
+ return { rows: p[0].filter((r) => s.filed.has(r)).map((r) => ({ ref: r, ...s.filed.get(r) })) };
43
+ }
44
+ if (/^INSERT INTO lifecycle_repo_map_filed .* unnest/.test(q)) {
45
+ s.filedWrites = (s.filedWrites || 0) + 1;
46
+ p[0].forEach((ref, i) => {
47
+ if (s.filed.has(ref)) throw Object.assign(new Error('duplicate key'), { code: '23505' });
48
+ s.filed.set(ref, { kind: p[1][i], entity_id: p[2][i], map_id: p[3] });
49
+ });
50
+ return { rows: [] };
51
+ }
52
+ if (/^SELECT id FROM versions/.test(q)) return { rows: [{ id: 'V1' }] };
53
+ if (/^UPDATE lifecycle_repo_maps SET status = 'accepted'/.test(q)) { s.maps.find((m) => m.id === String(p[0])).status = 'accepted'; return { rows: [] }; }
54
+ if (/^UPDATE lifecycle_repo_maps SET status = 'rejected'/.test(q)) {
55
+ const m = s.maps.find((x) => x.id === String(p[0]) && x.status === 'pending');
56
+ if (!m) return { rows: [] };
57
+ m.status = 'rejected'; return { rows: [{ id: m.id }] };
58
+ }
59
+ throw new Error('unexpected SQL: ' + q);
60
+ };
61
+ const pool = { query, connect: async () => ({ query, release() {} }), state: s };
62
+ const deps = {
63
+ pool,
64
+ createGoal: async (row, d) => { assert.ok(d && d.client, 'goals are created inside the accept transaction'); const g = { id: String(100 + s.goals.length), ...row }; s.goals.push(g); return g; },
65
+ createTask: async (row, d) => { assert.ok(d && d.client, 'tasks are created inside the accept transaction'); const t = { id: String(500 + s.tasks.length), ...row }; s.tasks.push(t); return t; },
66
+ };
67
+ return { pool, deps, s };
68
+ }
69
+
70
+ // ── the prompt ───────────────────────────────────────────────────────────────
71
+
72
+ test('the prompt sends the owner\'s own session to read locally, map in the defined shape, and accept only with a yes', () => {
73
+ const p = fr.firstReadPrompt();
74
+ for (const must of [
75
+ 'own AI session', 'Bongos itself never reads this repository', 'Do not upload, paste or send any source code',
76
+ fr.MAP_FILE, fr.SCHEMA, '"goals"', '"next"', '"why"', '"seen_at"', 'errors or mishaps',
77
+ 'first-read check', 'first-read submit', 'first-read accept', 'first-read reject', 'Never accept on your own',
78
+ 'not filed twice',
79
+ ]) assert.ok(p.includes(must), `the prompt says: ${must}`);
80
+ // The example it prints is itself a valid map, so a session that copies it is on the shape.
81
+ assert.equal(fr.validateRepoMap(fr.exampleMap()).ok, true);
82
+ const printed = JSON.parse(p.slice(p.indexOf('{'), p.lastIndexOf('}') + 1));
83
+ assert.deepEqual(printed, fr.exampleMap());
84
+ });
85
+
86
+ test('an adopted project\'s Genesis step carries the prompt; a new project\'s does not', () => {
87
+ const existing = genesisChecklist({ existing: true }).scope;
88
+ assert.equal(existing[0].ref, 'genesis:scope:existing', 'the step keeps its ref, so a seeded project is not refiled');
89
+ assert.ok(existing[0].description.includes(fr.firstReadPrompt()), 'the hall copy-prompt hands over the whole prompt');
90
+ assert.ok(/own AI/.test(existing[0].title));
91
+ const fresh = genesisChecklist().scope;
92
+ assert.ok(fresh.every((t) => !t.description.includes(fr.SCHEMA)));
93
+ });
94
+
95
+ // ── validation ───────────────────────────────────────────────────────────────
96
+
97
+ test('a good map validates into a flat review list with stable refs', () => {
98
+ const v = fr.validateRepoMap(fr.exampleMap());
99
+ assert.equal(v.ok, true);
100
+ assert.deepEqual(v.items.map((i) => [i.type, i.ref]), [
101
+ ['goal', 'goal:take-payments-for-bookings'],
102
+ ['task', 'task:take-payments-for-bookings:finish-the-checkout-page'],
103
+ ['task', 'task:take-payments-for-bookings:send-a-receipt-by-email'],
104
+ ['next', 'next:fix-the-failing-booking-test'],
105
+ ]);
106
+ assert.ok(v.items.every((i) => i.why && i.seen_at.length));
107
+ assert.equal(v.items[3].goal_ref, fr.NEXT_GOAL.ref, 'a next item naming no goal lands under the fix-what-was-found goal');
108
+ });
109
+
110
+ test('a later section (recommended modules) rides along without breaking the shape', () => {
111
+ const raw = { ...fr.exampleMap(), recommended_modules: [{ key: 'payments', why: 'a checkout exists' }] };
112
+ const v = fr.validateRepoMap(raw);
113
+ assert.equal(v.ok, true);
114
+ assert.deepEqual(v.map.recommended_modules, raw.recommended_modules);
115
+ });
116
+
117
+ test('a prototype-shaped key in the map is never copied onto it', () => {
118
+ const raw = JSON.parse(`{"schema":"${fr.SCHEMA}","summary":"s","next":[{"title":"t","why":"w","seen_at":["a"]}],"__proto__":{"polluted":true},"constructor":{"x":1}}`);
119
+ const v = fr.validateRepoMap(raw);
120
+ assert.equal(v.ok, true);
121
+ assert.equal(v.map.polluted, undefined);
122
+ assert.equal(Object.getPrototypeOf(v.map), Object.prototype);
123
+ assert.ok(!Object.prototype.hasOwnProperty.call(v.map, 'constructor'));
124
+ assert.equal(({}).polluted, undefined);
125
+ });
126
+
127
+ test('malformed maps are refused, each error naming its field', () => {
128
+ const cases = [
129
+ [null, /JSON object/],
130
+ [[], /JSON object/],
131
+ [{ ...fr.exampleMap(), schema: 'other/1' }, /schema must be/],
132
+ [{ ...fr.exampleMap(), goals: 'lots' }, /goals must be a list/],
133
+ [{ schema: fr.SCHEMA, summary: 'x', goals: [], next: [] }, /no goals and nothing next/],
134
+ [(() => { const m = fr.exampleMap(); delete m.goals[0].why; return m; })(), /goals\[0\]\.why is missing/],
135
+ [(() => { const m = fr.exampleMap(); m.goals[0].tasks[0].seen_at = []; return m; })(), /goals\[0\]\.tasks\[0\]\.seen_at needs at least one place/],
136
+ [(() => { const m = fr.exampleMap(); m.goals[0].tasks[1].status = 'half'; return m; })(), /status must be one of/],
137
+ [(() => { const m = fr.exampleMap(); m.goals[0].tasks[1].title = m.goals[0].tasks[0].title; return m; })(), /repeats another item's title/],
138
+ [(() => { const m = fr.exampleMap(); m.next[0].goal = 'No such goal'; return m; })(), /not a goal in this map/],
139
+ [(() => { const m = fr.exampleMap(); m.next[0].kind = 'oops'; return m; })(), /kind must be one of/],
140
+ [(() => { const m = fr.exampleMap(); m.goals[0].why = 'x'.repeat(fr.LIMITS.WHY + 1); return m; })(), /longer than/],
141
+ [(() => { const m = fr.exampleMap(); m.summary = 'y'.repeat(fr.LIMITS.BYTES); return m; })(), /larger than/],
142
+ [(() => { const m = fr.exampleMap(); m.goals[0].title = '!!!'; return m; })(), /needs letters or numbers/],
143
+ ];
144
+ for (const [raw, re] of cases) {
145
+ const v = fr.validateRepoMap(raw);
146
+ assert.equal(v.ok, false, `refused: ${re}`);
147
+ assert.ok(v.errors.some((e) => re.test(e)), `${re} in ${JSON.stringify(v.errors)}`);
148
+ }
149
+ });
150
+
151
+ // ── the store: submit, accept, reject, re-run ────────────────────────────────
152
+
153
+ test('submitting files nothing: the map waits pending', async () => {
154
+ const { deps, s } = fakePool();
155
+ const out = await store.submitMap({ raw: fr.exampleMap(), builderId: '1' }, deps);
156
+ assert.equal(out.ok, true);
157
+ assert.equal(s.maps[0].status, 'pending');
158
+ assert.equal(s.goals.length + s.tasks.length, 0);
159
+ const bad = await store.submitMap({ raw: { schema: 'nope' } }, deps);
160
+ assert.equal(bad.ok, false);
161
+ assert.equal(s.maps.length, 1, 'a malformed map is not stored');
162
+ });
163
+
164
+ test('accepting the whole map files draft goals and backlog tasks, nothing claimable', async () => {
165
+ const { deps, s } = fakePool();
166
+ const { map_id } = await store.submitMap({ raw: fr.exampleMap(), builderId: '1' }, deps);
167
+ const out = await store.acceptMap({ mapId: map_id, select: 'all', builderId: '1' }, deps);
168
+ assert.equal(out.ok, true);
169
+ assert.deepEqual(s.goals.map((g) => g.title), ['Take payments for bookings', fr.NEXT_GOAL.title]);
170
+ assert.equal(s.tasks.length, 3);
171
+ for (const t of s.tasks) {
172
+ assert.equal(t.status, 'backlog');
173
+ assert.equal(t.source, store.TASK_SOURCE);
174
+ assert.ok(t.goalId, 'every task has a goal home');
175
+ assert.ok(t.description.includes('Seen at:'));
176
+ }
177
+ assert.equal(s.tasks.find((t) => t.title === 'Fix the failing booking test').kind, 'bug');
178
+ assert.equal(s.tasks.find((t) => t.title === 'Fix the failing booking test').goalId, s.goals[1].id);
179
+ assert.equal(s.maps[0].status, 'accepted');
180
+ assert.equal(s.filedWrites, 1, 'every filed ref is remembered in one statement');
181
+ assert.equal(s.filed.size, 5);
182
+ });
183
+
184
+ test('accepting item by item files only what the owner chose (and the goal it needs)', async () => {
185
+ const { deps, s } = fakePool();
186
+ const { map_id } = await store.submitMap({ raw: fr.exampleMap() }, deps);
187
+ const out = await store.acceptMap({ mapId: map_id, select: ['task:take-payments-for-bookings:send-a-receipt-by-email'] }, deps);
188
+ assert.equal(out.ok, true);
189
+ assert.deepEqual(s.goals.map((g) => g.title), ['Take payments for bookings']);
190
+ assert.deepEqual(s.tasks.map((t) => t.title), ['Send a receipt by email']);
191
+ const unknown = await store.acceptMap({ mapId: map_id, select: ['task:nope'] }, deps);
192
+ assert.equal(unknown.code, 'unknown_items');
193
+ const none = await store.acceptMap({ mapId: map_id, select: [] }, deps);
194
+ assert.equal(none.code, 'nothing_selected');
195
+ assert.equal(s.tasks.length, 1, 'a refused accept files nothing');
196
+ });
197
+
198
+ test('a task the map says is done is shown, never filed', async () => {
199
+ const { deps, s } = fakePool();
200
+ const raw = fr.exampleMap();
201
+ raw.goals[0].tasks[0].status = 'done';
202
+ const { map_id } = await store.submitMap({ raw }, deps);
203
+ const out = await store.acceptMap({ mapId: map_id, select: 'all' }, deps);
204
+ assert.ok(out.skipped.some((x) => x.reason === 'already_done'));
205
+ assert.ok(!s.tasks.some((t) => t.title === 'Finish the checkout page'));
206
+ });
207
+
208
+ test('rejecting files nothing, and a rejected map cannot then be accepted', async () => {
209
+ const { deps, s } = fakePool();
210
+ const { map_id } = await store.submitMap({ raw: fr.exampleMap() }, deps);
211
+ assert.equal((await store.rejectMap({ mapId: map_id, builderId: '1' }, deps)).ok, true);
212
+ assert.equal(s.maps[0].status, 'rejected');
213
+ const again = await store.acceptMap({ mapId: map_id, select: 'all' }, deps);
214
+ assert.equal(again.code, 'map_rejected');
215
+ assert.equal(s.goals.length + s.tasks.length, 0);
216
+ assert.equal((await store.rejectMap({ mapId: map_id }, deps)).code, 'not_pending');
217
+ assert.equal((await store.rejectMap({ mapId: '99' }, deps)).code, 'not_found');
218
+ });
219
+
220
+ test('a re-run does not duplicate: the same items are skipped, only new ones are filed', async () => {
221
+ const { deps, s } = fakePool();
222
+ const first = await store.submitMap({ raw: fr.exampleMap() }, deps);
223
+ await store.acceptMap({ mapId: first.map_id, select: 'all' }, deps);
224
+ const goalsBefore = s.goals.length;
225
+ const tasksBefore = s.tasks.length;
226
+ // Accepting the same map again files nothing.
227
+ const twice = await store.acceptMap({ mapId: first.map_id, select: 'all' }, deps);
228
+ assert.equal(twice.filed.goals.length + twice.filed.tasks.length, 0);
229
+ // A second read-through names the same items (reordered, retitled in case) plus one new task.
230
+ const raw = clone(fr.exampleMap());
231
+ raw.goals[0].title = 'TAKE PAYMENTS FOR BOOKINGS';
232
+ raw.goals[0].tasks.reverse();
233
+ raw.goals[0].tasks.push({ title: 'Refund a booking', status: 'not_started', why: 'No refund path exists.', seen_at: ['src/payments/'] });
234
+ const second = await store.submitMap({ raw }, deps);
235
+ const review = await store.reviewMap('latest', deps);
236
+ assert.equal(review.map_id, second.map_id);
237
+ assert.equal(review.items.filter((i) => i.filed).length, 4, 'the review marks what is already in Bongos');
238
+ const out = await store.acceptMap({ mapId: second.map_id, select: 'all' }, deps);
239
+ assert.equal(s.goals.length, goalsBefore, 'no goal filed twice');
240
+ assert.equal(s.tasks.length, tasksBefore + 1);
241
+ assert.deepEqual(out.filed.tasks.map((t) => t.title), ['Refund a booking']);
242
+ assert.equal(out.filed.tasks[0].goal_id, s.goals[0].id, 'the new task joins the goal filed by the first run');
243
+ assert.equal(out.skipped.filter((x) => x.reason === 'already_filed').length, 4);
244
+ });
245
+
246
+ // ── the routes, over a real socket ───────────────────────────────────────────
247
+
248
+ const express = require('express');
249
+ const api = require('../src/module-api.js');
250
+ const perms = [];
251
+ api.requireBuilder = (req, _res, next) => { req.builder = { id: '7', rank: 'archon' }; next(); };
252
+ api.requirePermission = (...p) => { perms.push(p.join(',')); return (_req, _res, next) => next(); };
253
+ api.requireActAlone = (area) => { perms.push('act-alone:' + area); return (_req, _res, next) => next(); };
254
+
255
+ const route = fakePool();
256
+ for (const fn of ['submitMap', 'reviewMap', 'acceptMap', 'rejectMap']) {
257
+ storeModule[fn] = (a) => store[fn](a, route.deps);
258
+ }
259
+ const app = express();
260
+ app.use(express.json());
261
+ app.use((req, res, next) => {
262
+ res.fail = (code, status, details) => res.status(status || 400).json({ error: code, ...(details || {}) });
263
+ next();
264
+ });
265
+ app.use('/api/bongos', require('../modules/lifecycle/routes/first-read.js')());
266
+ const server = app.listen(0);
267
+ await new Promise((r) => server.once('listening', r));
268
+ after(() => server.close());
269
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
270
+ const call = async (method, p, body) => {
271
+ const r = await fetch(base + p, { method, headers: { 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
272
+ return { status: r.status, body: await r.json().catch(() => null) };
273
+ };
274
+
275
+ test('the routes: filing is gated like creating a goal, and the flow runs end to end', async () => {
276
+ assert.ok(perms.includes('goal.create'));
277
+ assert.ok(perms.includes('act-alone:planning'));
278
+ const prompt = await call('GET', '/first-read/prompt');
279
+ assert.equal(prompt.status, 200);
280
+ assert.equal(prompt.body.prompt, fr.firstReadPrompt());
281
+
282
+ const bad = await call('POST', '/first-read/maps', { map: { schema: fr.SCHEMA, summary: 'x', goals: [{ title: 'A' }] } });
283
+ assert.equal(bad.status, 400);
284
+ assert.equal(bad.body.error, 'invalid_map');
285
+ assert.ok(bad.body.errors.length >= 2);
286
+
287
+ const ok = await call('POST', '/first-read/maps', { map: fr.exampleMap() });
288
+ assert.equal(ok.status, 201);
289
+ assert.equal(route.s.tasks.length, 0, 'submit files nothing');
290
+
291
+ const bogus = await call('POST', `/first-read/maps/${ok.body.map_id}/accept`, { items: ['x'], all: true });
292
+ assert.equal(bogus.status, 400);
293
+ const accepted = await call('POST', `/first-read/maps/${ok.body.map_id}/accept`, { items: ['goal:take-payments-for-bookings'] });
294
+ assert.equal(accepted.status, 200);
295
+ assert.equal(accepted.body.filed.goals.length, 1);
296
+ assert.equal(accepted.body.filed.tasks.length, 0);
297
+
298
+ const shown = await call('GET', '/first-read/maps/latest');
299
+ assert.equal(shown.status, 200);
300
+ assert.equal(shown.body.items.find((i) => i.ref === 'goal:take-payments-for-bookings').filed.kind, 'goal');
301
+ assert.equal((await call('GET', '/first-read/maps/999')).status, 404);
302
+ assert.equal((await call('POST', `/first-read/maps/${ok.body.map_id}/reject`)).status, 409, 'an accepted map is not then rejected');
303
+ });
@@ -46,17 +46,54 @@ test('genesis files a task per stage, keyed by a ref that names the step, not it
46
46
 
47
47
  test('the seeder files every genesis step once, under the catch-all, and never twice', async () => {
48
48
  const filed = [];
49
+ const edges = [];
49
50
  const deps = {
50
51
  pool: { query: async () => ({ rows: [{ id: 'V1' }] }) },
51
- createTask: async (row) => { filed.push(row); },
52
+ createTask: async (row) => { filed.push({ ...row, id: 100 + filed.length }); },
52
53
  findTaskBySourceRef: async ({ source, sourceRef }) => filed.find((r) => r.source === source && r.sourceRef === sourceRef) || null,
54
+ addTaskDependency: async (id, dep) => { edges.push([id, dep]); },
53
55
  };
54
56
  const first = await seed.seedGenesisStages({ builderId: 1 }, deps);
55
57
  assert.equal(first.seeded, 5);
56
58
  assert.ok(filed.every((r) => r.status === 'ready' && r.source === 'genesis' && r.allowCatchAll === true && r.versionId === 'V1'));
59
+ assert.deepEqual(edges, [], 'a new project: every stage has one step, nothing waits on anything');
57
60
  const again = await seed.seedGenesisStages({ builderId: 1, existing: true }, deps);
58
61
  assert.equal(again.seeded, 1, 'a second run files only the step that was not there (the brownfield one)');
59
62
  assert.equal(filed.length, 6);
63
+ const scope = filed.find((r) => r.sourceRef === 'genesis:scope');
64
+ const gather = filed.find((r) => r.sourceRef === 'genesis:scope:existing');
65
+ assert.deepEqual(edges, [[scope.id, gather.id]], 'writing the scope waits on gathering what exists (task 1005110)');
66
+ });
67
+
68
+ test('the checklist declares order only where it is real: brownfield scope (task 1005110)', () => {
69
+ const fresh = genesisChecklist({});
70
+ assert.ok(Object.values(fresh).flat().every((t) => t.after === null), 'a new project: all open');
71
+ const old = genesisChecklist({ existing: true });
72
+ assert.deepEqual(old.scope.map((t) => [t.ref, t.after]), [
73
+ ['genesis:scope:existing', null],
74
+ ['genesis:scope', 'genesis:scope:existing'],
75
+ ]);
76
+ for (const k of ['strategize', 'charter', 'legalize', 'legislation']) assert.equal(old[k][0].after, null);
77
+ });
78
+
79
+ test('the board read says what each task waits on, from the dependency edges (task 1005110)', async () => {
80
+ let seen = '';
81
+ const pool = { query: async (sql) => {
82
+ seen = sql;
83
+ return { rows: [
84
+ { id: 1, title: 'Gather', status: 'ready', description: '', source_ref: 'genesis:scope:existing', waits_on: [] },
85
+ { id: 2, title: 'Write', status: 'ready', description: '', source_ref: 'genesis:scope', waits_on: [{ id: 1, title: 'Gather' }] },
86
+ ] };
87
+ } };
88
+ const out = await seed.genesisStageTasks({ pool });
89
+ assert.match(seen, /FROM dependencies d/);
90
+ assert.match(seen, /x\.status <> 'shipped'/, 'the claim gate\'s own rule: only a shipped task stops blocking');
91
+ assert.deepEqual(out.scope[1].waits_on, [{ id: '1', title: 'Gather' }]);
92
+ const g = gh.shapeGenesis({ mode: { stage: 'scope', stage_number: 1, stage_count: 5, day: 1 }, plan: gh.parsePlan({}).plan, tasks: out });
93
+ assert.deepEqual(g.stages[0].tasks.map((t) => t.state), ['open', 'locked']);
94
+ const done = gh.shapeGenesis({ mode: { stage: 'scope', stage_number: 1, stage_count: 5, day: 1 }, plan: gh.parsePlan({}).plan,
95
+ tasks: { scope: [{ id: '1', title: 'Gather', status: 'shipped' }, { id: '2', title: 'Write', status: 'ready', waits_on: [] }] } });
96
+ assert.deepEqual(done.stages[0].tasks.map((t) => t.state), ['done', 'open'], 'once the first ships, the second opens');
60
97
  });
61
98
 
62
99
  test('the board read groups genesis tasks by the stage their ref names', async () => {
@@ -389,7 +426,7 @@ function bootCard({ founding, data = { genesis: null } }) {
389
426
  const attrs = founding ? { 'data-founding': 'on' } : {};
390
427
  const sandbox = {
391
428
  window: {
392
- OTB: { escapeHtml: (s) => String(s), statusPill: (s) => `<span>${s}</span>`, contributeHallWidget: (w) => { widget = w; }, toast: (m) => toasts.push(m) },
429
+ OTB: { escapeHtml: (s) => String(s), statusPill: (s) => `<span>${s}</span>`, contributeHallWidget: (w) => { widget = w; }, toast: (m) => toasts.push(m), CHECK_SVG: '<svg/>' },
393
430
  BongosClient: { createClient: () => ({ request: async (m, p) => { calls.push(`${m} ${p}`); return { ok: true, status: 200, data }; } }) },
394
431
  },
395
432
  navigator: { clipboard: { writeText: async (t) => { copied.push(t); } } },
@@ -537,6 +574,37 @@ test('each open stage task has Copy prompt, and it copies the task\'s own words
537
574
  assert.match(b.toasts.join(' '), /copied/i);
538
575
  });
539
576
 
577
+ test('the stage reads as a checklist: done, open and locked, and a locked row offers no prompt (task 1005110)', async () => {
578
+ const genesis = gh.shapeGenesis({
579
+ mode: { stage: 'scope', stage_number: 1, stage_count: 5, day: 1 },
580
+ plan: gh.parsePlan({}).plan,
581
+ tasks: { scope: [
582
+ { id: '301', title: 'Gather what already exists', status: 'ready', description: 'Link it.' },
583
+ { id: '302', title: 'Write the scope', status: 'ready', description: 'Write it.', waits_on: [{ id: '301', title: 'Gather what already exists' }] },
584
+ ], strategize: [{ id: '303', title: 'Strategy', status: 'shipped' }] },
585
+ });
586
+ const b = bootCard({ founding: true, data: { genesis, viewer_id: '1' } });
587
+ await mountClickable(b);
588
+ const html = b.host.innerHTML;
589
+ const row = (id) => html.split('<li ').find((x) => x.includes(`/task/${id}"`)) || '';
590
+ assert.match(row('301'), /class="genesis__task is-open"/);
591
+ assert.match(row('301'), /data-genesis-copy="301"/, 'the open row has Copy prompt');
592
+ assert.match(row('302'), /class="genesis__task is-locked"/);
593
+ assert.match(row('302'), /after Gather what already exists/, 'a locked row says what it waits on');
594
+ assert.doesNotMatch(row('302'), /data-genesis-copy/, 'a locked row offers no prompt for work it cannot claim');
595
+ assert.doesNotMatch(html, /is-finished|line-through/, 'nothing is drawn struck through');
596
+ // The "done" row: the earlier stage is not the one on screen, so check the shape directly.
597
+ const done = gh.shapeGenesis({ mode: { stage: 'scope', stage_number: 1, stage_count: 5, day: 1 }, plan: gh.parsePlan({}).plan,
598
+ tasks: { scope: [{ id: '304', title: 'Gathered', status: 'shipped' }, { id: '305', title: 'Next', status: 'ready' }] } });
599
+ const b2 = bootCard({ founding: true, data: { genesis: done, viewer_id: '1' } });
600
+ await mountClickable(b2);
601
+ const r304 = b2.host.innerHTML.split('<li ').find((x) => x.includes('/task/304"')) || '';
602
+ assert.match(r304, /class="genesis__task is-done"/);
603
+ assert.match(r304, /<span class="genesis__mark" aria-hidden="true"><svg/, 'done fills its circle with the hall\'s tick');
604
+ assert.match(r304, /class="sr-only">Done: /, 'and says so to a screen reader');
605
+ assert.doesNotMatch(r304, /data-genesis-copy/);
606
+ });
607
+
540
608
  test('the hall carries the five life plates byte-identical to the world\'s', () => {
541
609
  const world = path.join(ROOT, 'modules', 'public-landing', 'public', 'assets', 'world');
542
610
  const hall = path.join(ROOT, 'modules', 'hall-ui', 'public', 'genesis');
@@ -152,6 +152,41 @@ export async function contract(test, label, deps, A) {
152
152
  assert.match(markdown, /https:\/\/example\.com\/strategy/);
153
153
  assert.doesNotMatch(markdown, /## Scope/, 'a stage with nothing in it is left out');
154
154
  });
155
+
156
+ // ── the scope interview (task 1005114) ──
157
+ await test(`${label}: scope's interview is its guided door: six questions, all empty, guided first`, async () => {
158
+ const r = await gw.readStage('scope', deps);
159
+ assert.equal(r.work.door, 'guided');
160
+ assert.deepEqual(r.work.questions.map((q) => q.id),
161
+ ['what_creating', 'goal_impact', 'success_two_years', 'reach_who', 'work_with_who', 'others_wrong']);
162
+ assert.deepEqual([...new Set(r.work.questions.map((q) => q.group))], ['What and why', 'Who', 'The bet']);
163
+ assert.equal(r.work.counts.empty, 6);
164
+ });
165
+
166
+ await test(`${label}: scope stores answers, "not sure yet", a skip, an un-answer and a revision`, async () => {
167
+ await gw.setAnswer('scope', 'what_creating', { text: 'A live picture of each business you run.' }, A, deps);
168
+ await gw.setAnswer('scope', 'success_two_years', { state: 'not_sure', text: 'depends on whether others want it' }, A, deps);
169
+ await gw.setAnswer('scope', 'work_with_who', { state: 'skipped' }, A, deps);
170
+ await gw.setAnswer('scope', 'others_wrong', { text: 'They value a business from the outside.' }, A, deps);
171
+ await gw.clearAnswer('scope', 'work_with_who', A, deps);
172
+ let r = await gw.setAnswer('scope', 'what_creating', { text: 'A live picture of each business you run, and your worth across them.' }, A, deps);
173
+ assert.deepEqual(r.work.counts, { total: 6, answered: 2, not_sure: 1, skipped: 0, empty: 3 });
174
+ assert.equal(qOf(r, 'work_with_who').answer, null, 'un-answered is empty, not skipped');
175
+ const h = await gw.answerHistory('scope', 'what_creating', deps);
176
+ assert.equal(h.history.length, 2, 'the founder can see what the answer used to say');
177
+ assert.equal(h.history[1].text, 'A live picture of each business you run.');
178
+ r = await gw.setAnswer('scope', 'success_two_years', { text: 'Three other founders run their estate on it.' }, A, deps);
179
+ assert.equal(qOf(r, 'success_two_years').answer.state, 'answered', '"not sure yet" is easy to come back to');
180
+ });
181
+
182
+ await test(`${label}: a session planning a goal reads the scope answers, scope first`, async () => {
183
+ const { markdown } = await gw.readContext(deps);
184
+ assert.ok(markdown.indexOf('## Scope') < markdown.indexOf('## Strategize'), 'genesis order');
185
+ assert.match(markdown, /In a few sentences, what are you creating with this project\?\*\*\n A live picture of each business you run, and your worth across them\./);
186
+ assert.match(markdown, /Three other founders run their estate on it\./);
187
+ assert.match(markdown, /They value a business from the outside\./);
188
+ assert.doesNotMatch(markdown, /Who do you want working on it with you/, 'an empty question is not printed');
189
+ });
155
190
  }
156
191
 
157
192
  if (import.meta.url === url.pathToFileURL(process.argv[1]).href) {
@@ -220,6 +255,20 @@ if (import.meta.url === url.pathToFileURL(process.argv[1]).href) {
220
255
  assert.equal(gw.assistPrompt('decide'), null);
221
256
  });
222
257
 
258
+ await test('the assisted door for scope walks the same interview, ids included, and drafts only from the answers', async () => {
259
+ const p = gw.assistPrompt('scope');
260
+ for (const q of gw.QUESTIONS.scope) assert.ok(p.includes(`[${q.id}] ${q.ask}`), `asks: ${q.id}`);
261
+ assert.match(p, /draft page built only from my answers/);
262
+ });
263
+
264
+ await test('CLI: the scope interview prints under its group headings', async () => {
265
+ const deps = { store: memStore() };
266
+ const r = await gw.readStage('scope', deps);
267
+ const out = formatStage(r.work);
268
+ assert.ok(out.indexOf('— What and why —') < out.indexOf('— Who —'));
269
+ assert.ok(out.indexOf('— Who —') < out.indexOf('— The bet —'));
270
+ });
271
+
223
272
  await test('question ids are unique and fit the stored shape', async () => {
224
273
  for (const [stage, qs] of Object.entries(gw.QUESTIONS)) {
225
274
  assert.ok(gw.STAGES.includes(stage));
@@ -216,6 +216,7 @@ for (const rel of [
216
216
  'modules/lifecycle/routes/goals.js',
217
217
  'modules/lifecycle/routes/versions.js',
218
218
  'modules/lifecycle/routes/project-settings.js',
219
+ 'modules/lifecycle/routes/first-read.js',
219
220
  'modules/render-deploy/routes/act.js',
220
221
  'modules/npm-release/routes/release.js',
221
222
  'modules/provisioning/routes/core-upgrade.js',
@@ -262,6 +263,7 @@ const REQUESTS = {
262
263
  'POST /goals/reorder': ['POST', '/goals/reorder', {}],
263
264
  'POST /work-categories': ['POST', '/work-categories', {}],
264
265
  'PUT /versions/focus': ['PUT', '/versions/focus', { version_id: ' ' }],
266
+ 'POST /first-read/maps/:id/accept': ['POST', `/first-read/maps/${BAD}/accept`, { all: true }], // task 1005280
265
267
  'POST /autonomy/fence': ['POST', '/autonomy/fence', { enabled: 'yes' }],
266
268
  'POST /autonomy/fence/goals': ['POST', '/autonomy/fence/goals', { goal_id: 0 }],
267
269
  'DELETE /autonomy/fence/goals/:goalId': ['DELETE', `/autonomy/fence/goals/${BAD}`],