@bongos/core 1.25.13 → 1.25.15

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 (59) hide show
  1. package/.bongos-core.json +118 -53
  2. package/bin/bongos.js +1 -0
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +20 -0
  5. package/clients/bongos-client/index.cjs +20 -0
  6. package/clients/bongos-client/index.d.ts +26 -2
  7. package/clients/bongos-client/index.mjs +20 -0
  8. package/docs/adr/0328-a-hosted-projects-authority-is-its-membership-row-not-its-owner-column.md +1 -1
  9. package/docs/adr/0407-a-project-grant-is-made-by-the-owner-not-witnessed-at-sign-in.md +124 -0
  10. package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +60 -0
  11. package/docs/adr/README.md +2 -0
  12. package/docs/api/openapi.json +550 -2
  13. package/docs/api-reference.md +14 -4
  14. package/docs/copy-inventory.md +28 -34
  15. package/docs/copy-registry.json +41 -125
  16. package/docs/file-map.md +2 -1
  17. package/docs/module-api-changelog.md +4 -0
  18. package/docs/page-inventory.json +1 -0
  19. package/docs/page-readings.json +3 -3
  20. package/modules/front-door-guide/db.js +13 -5
  21. package/modules/front-door-guide/guide.js +8 -2
  22. package/modules/front-door-guide/migrations/front_door_guide_003_starting_point.sql +12 -0
  23. package/modules/front-door-guide/module.json +2 -2
  24. package/modules/front-door-guide/plan.js +15 -6
  25. package/modules/front-door-guide/routes/front-door-guide-plan.js +3 -2
  26. package/modules/front-door-guide/routes/front-door-guide.js +17 -4
  27. package/modules/front-door-guide/starting-point.js +55 -0
  28. package/modules/hall-ui/public/genesis-home.js +29 -2
  29. package/modules/hall-ui/public/goals-page.js +3 -29
  30. package/modules/hall-ui/public/index.html +2 -1
  31. package/modules/hall-ui/public/task-lib.js +45 -0
  32. package/modules/hall-ui/public/work.js +3 -47
  33. package/modules/lifecycle/kickoff-checklist.js +1 -1
  34. package/modules/lifecycle/kickoff-seed.js +9 -5
  35. package/modules/onboarding/genesis-home.js +1 -1
  36. package/modules/onboarding/genesis-work.js +364 -0
  37. package/modules/onboarding/migrations/onboarding_002_genesis_work.sql +76 -0
  38. package/modules/onboarding/routes/genesis-work.js +85 -0
  39. package/modules/onboarding/routes/onboarding.js +3 -0
  40. package/package-lock.json +306 -3
  41. package/package.json +4 -2
  42. package/release-notes.json +24 -0
  43. package/scripts/bongos/genesis.js +178 -0
  44. package/scripts/bongos/package-core.js +4 -2
  45. package/scripts/bongos/run-unit-tests.js +3 -0
  46. package/scripts/gds/genesis.js +7 -0
  47. package/src/bongos/module-scope-map.js +1 -0
  48. package/src/module-api.js +1 -1
  49. package/tests/cutover_codemod.mjs +1 -1
  50. package/tests/front_door_guide_db.mjs +6 -1
  51. package/tests/front_door_guide_existing.mjs +238 -0
  52. package/tests/genesis_home.mjs +80 -2
  53. package/tests/genesis_work.mjs +255 -0
  54. package/tests/genesis_work_db.mjs +73 -0
  55. package/tests/genesis_work_routes.mjs +130 -0
  56. package/tests/package_core.mjs +11 -7
  57. package/tests/pruned_lockfile_integrity.mjs +4 -2
  58. package/tests/status_page_no_dead_uptime.mjs +1 -1
  59. package/tests/task_lib.mjs +46 -0
@@ -25,6 +25,7 @@ const db = require('../modules/front-door-guide/db.js');
25
25
  const sql = (n) => readFileSync(new URL(`../modules/front-door-guide/migrations/${n}`, import.meta.url), 'utf8');
26
26
  const M1 = sql('front_door_guide_001_conversations.sql');
27
27
  const M2 = sql('front_door_guide_002_plan_and_account.sql');
28
+ const M3 = sql('front_door_guide_003_starting_point.sql');
28
29
 
29
30
  const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
30
31
  try {
@@ -48,7 +49,7 @@ async function cleanup() {
48
49
  const call = (over = {}) => ({ visitorHash: V, model: 'claude-opus-5-5', inputTokens: 800, outputTokens: 200, cacheReadTokens: 0, cacheWriteTokens: 0, costMicro: 9000, stopReason: 'end_turn', ...over });
49
50
 
50
51
  try {
51
- for (const m of [M1, M2, M1, M2]) await x(m); // applies, then re-applies as a no-op
52
+ for (const m of [M1, M2, M3, M1, M2, M3]) await x(m); // applies, then re-applies as a no-op
52
53
  await cleanup();
53
54
  await insertTestBuilders(pool, [{ id: ALICE, login: 't1005273-alice' }, { id: BOB, login: 't1005273-bob' }]);
54
55
 
@@ -99,6 +100,10 @@ try {
99
100
  assert.equal(await db.siteSpendMicro('day', x), before, 'the budgets still count the deleted chat\'s money');
100
101
  assert.ok(before >= dayBefore + 9000 + 20000 + 1 + 5 + 7000);
101
102
 
103
+ // The starting point (task 1005279): set, read back, and nothing else accepted.
104
+ assert.equal((await db.setStartingPoint(conv.id, 'existing', x)).starting_point, 'existing');
105
+ await assert.rejects(x(`UPDATE front_door_guide_conversations SET starting_point = 'repo' WHERE id = $1`, [conv.id]));
106
+
102
107
  // Refusals the schema owns.
103
108
  await assert.rejects(x(`INSERT INTO front_door_guide_calls (visitor_hash, model, cost_micro, kind) VALUES ($1, 'm', 1, 'other')`, [V]));
104
109
  await assert.rejects(x(`INSERT INTO front_door_guide_calls (visitor_hash, model, cost_micro) VALUES ($1, 'm', -1)`, [V]));
@@ -0,0 +1,238 @@
1
+ // tests/front_door_guide_existing.mjs — the guide's branch for a visitor who already has
2
+ // something (task 1005279): the guide asks early whether they are starting fresh, asks an
3
+ // existing-project visitor what they have and what they want next, and plans from their
4
+ // DESCRIPTION. The owner's hard rule: nothing they have made is read on the platform's AI
5
+ // bill — no upload, no fetch, no link reading — so the tests pin the absence too: the
6
+ // requests carry text only and no tools, no outbound request is made besides the (mocked)
7
+ // model call, and the guide's routes have no upload path.
8
+ //
9
+ // The real routers over an in-memory db and a mocked Claude client: no key, no network,
10
+ // no spend.
11
+
12
+ import { test } from 'node:test';
13
+ import { strict as assert } from 'node:assert';
14
+ import { createRequire } from 'node:module';
15
+ import http from 'node:http';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const db = require('../modules/front-door-guide/db.js');
19
+ const claude = require('../modules/front-door-guide/claude.js');
20
+ const SP = require('../modules/front-door-guide/starting-point.js');
21
+ require('../modules/provisioning/seams.js').registerProvisioningSeams();
22
+
23
+ const ENV = 'BONGOS_FRONT_DOOR_GUIDE_';
24
+
25
+ // ---- in-memory db --------------------------------------------------------------------
26
+
27
+ const convs = new Map();
28
+ let calls = [];
29
+ let seq = 0;
30
+ const copy = (c) => c && JSON.parse(JSON.stringify(c));
31
+ Object.assign(db, {
32
+ createConversation: async (tokenHash, visitorHash) => {
33
+ const c = { id: String(++seq), token_hash: tokenHash, visitor_hash: visitorHash, messages: [], visitor_messages: 0, spend_micro: 0, status: 'open', end_reason: null, created_at: new Date(Date.now() - 10_000).toISOString(), plan: null, builder_id: null, starting_point: null };
34
+ convs.set(c.id, c);
35
+ return copy(c);
36
+ },
37
+ setStartingPoint: async (id, sp) => { const c = convs.get(String(id)); c.starting_point = sp; return copy(c); },
38
+ getConversation: async (id, tokenHash) => { const c = convs.get(String(id)); return c && c.token_hash === tokenHash ? copy(c) : null; },
39
+ siteSpendMicro: async () => calls.reduce((s, r) => s + r.cost_micro, 0),
40
+ visitorUsage: async (v) => {
41
+ const mine = calls.filter((r) => r.visitor_hash === v);
42
+ return { messages: mine.filter((r) => r.kind === 'reply').length, spendMicro: mine.reduce((s, r) => s + r.cost_micro, 0) };
43
+ },
44
+ recordTurn: async (id, { messages, status, endReason }, call) => {
45
+ calls.push({ visitor_hash: call.visitorHash, cost_micro: call.costMicro, kind: 'reply' });
46
+ const c = convs.get(String(id));
47
+ Object.assign(c, { messages, status, end_reason: endReason, visitor_messages: c.visitor_messages + 1 });
48
+ return copy(c);
49
+ },
50
+ endConversation: async (id, reason) => Object.assign(convs.get(String(id)), { status: 'ended', end_reason: reason }),
51
+ savePlan: async (id, plan, call) => {
52
+ calls.push({ visitor_hash: call.visitorHash, cost_micro: call.costMicro, kind: 'plan' });
53
+ const c = convs.get(String(id));
54
+ Object.assign(c, { plan: c.plan || plan, status: 'ended', end_reason: 'planned' });
55
+ return copy(c);
56
+ },
57
+ recordPlanSpend: async () => {},
58
+ });
59
+
60
+ // ---- the mocked model, and a trap for any other outbound request ----------------------------
61
+
62
+ let requests = [];
63
+ let planText = null;
64
+ claude.createClient = () => ({ messages: { create: async (req) => {
65
+ requests.push(req);
66
+ if (req.output_config && req.output_config.format) {
67
+ return { model: 'claude-opus-5-5', stop_reason: 'end_turn', usage: { input_tokens: 1500, output_tokens: 500 }, content: [{ type: 'text', text: planText }] };
68
+ }
69
+ return { model: 'claude-opus-5-5', stop_reason: 'end_turn', usage: { input_tokens: 700, output_tokens: 150 }, content: [{ type: 'text', text: 'What would you like to build next?' }] };
70
+ } } });
71
+
72
+ let outbound = [];
73
+ const realFetch = globalThis.fetch;
74
+ globalThis.fetch = async (url) => { outbound.push(String(url)); throw new Error('no outbound requests in this test'); };
75
+
76
+ // ---- the real routers over http -----------------------------------------------------------
77
+
78
+ const express = require('express');
79
+ const app = express();
80
+ app.set('trust proxy', true);
81
+ app.use(express.json({ limit: '64kb' }));
82
+ const chatRouter = require('../modules/front-door-guide/routes/front-door-guide.js')();
83
+ const planRouter = require('../modules/front-door-guide/routes/front-door-guide-plan.js')();
84
+ app.use('/api/bongos', chatRouter);
85
+ app.use('/api/bongos', planRouter);
86
+ const server = app.listen(0);
87
+ await new Promise((r) => server.once('listening', r));
88
+ const PORT = server.address().port;
89
+ test.after(() => { server.close(); globalThis.fetch = realFetch; });
90
+
91
+ let ipSeq = 0;
92
+ function call(method, path, body) {
93
+ return new Promise((resolve, reject) => {
94
+ const data = body === undefined ? null : JSON.stringify(body);
95
+ const req = http.request({ host: '127.0.0.1', port: PORT, method, path: `/api/bongos${path}`,
96
+ headers: { 'content-type': 'application/json', 'user-agent': 'Mozilla/5.0 (test browser)', 'x-forwarded-for': `10.7.0.${++ipSeq}` } }, (res) => {
97
+ let buf = '';
98
+ res.on('data', (c) => { buf += c; });
99
+ res.on('end', () => resolve({ status: res.statusCode, body: buf ? JSON.parse(buf) : null }));
100
+ });
101
+ req.on('error', reject);
102
+ if (data) req.write(data);
103
+ req.end();
104
+ });
105
+ }
106
+
107
+ function reset() {
108
+ for (const k of Object.keys(process.env)) if (k.startsWith(ENV)) delete process.env[k];
109
+ Object.assign(process.env, { [`${ENV}ANTHROPIC_API_KEY`]: 'sk-ant-TEST-ONLY-0123456789abcdef', [`${ENV}MIN_FIRST_MESSAGE_MS`]: '1', [`${ENV}STARTS_PER_HOUR`]: '1000', [`${ENV}MESSAGES_PER_MINUTE`]: '1000' });
110
+ convs.clear(); calls = []; requests = []; outbound = []; planText = null;
111
+ }
112
+
113
+ // Every request the platform sent its AI: text only, no tools, nothing attached.
114
+ function assertTextOnly(reqs) {
115
+ for (const r of reqs) {
116
+ assert.equal(r.tools, undefined, 'no tools: the model cannot fetch or read anything');
117
+ assert.equal(r.mcp_servers, undefined);
118
+ assert.equal(r.container, undefined);
119
+ assert.equal(typeof r.system, 'string');
120
+ for (const m of r.messages) assert.equal(typeof m.content, 'string', 'no file, image or document blocks');
121
+ }
122
+ assert.deepEqual(outbound, [], 'no other outbound request was made');
123
+ }
124
+
125
+ // ---- asking early -------------------------------------------------------------------------
126
+
127
+ test('without a starting point, the greeting asks it, with two answers to offer', async () => {
128
+ reset();
129
+ const st = await call('POST', '/front-door-guide/conversations', {});
130
+ assert.equal(st.status, 201);
131
+ assert.equal(st.body.greeting, SP.GREETINGS.unknown);
132
+ assert.match(st.body.greeting, /already have something/);
133
+ assert.deepEqual(st.body.starting_point_options, [{ value: 'fresh', label: 'Starting fresh' }, { value: 'existing', label: 'I already have something' }]);
134
+ assert.equal(st.body.conversation.starting_point, null);
135
+
136
+ const conv = st.body.conversation;
137
+ await call('POST', `/front-door-guide/conversations/${conv.id}/messages`, { token: conv.token, text: 'I want to make a recipe app.' });
138
+ assert.match(requests[0].system, /do not yet know whether this visitor is starting fresh/);
139
+ assert.match(requests[0].system, /cannot see, open or read any file, repository/);
140
+ });
141
+
142
+ test('a starting point can be given at the start, or with a message; anything else is refused', async () => {
143
+ reset();
144
+ const fresh = await call('POST', '/front-door-guide/conversations', { starting_point: 'fresh' });
145
+ assert.equal(fresh.body.greeting, SP.GREETINGS.fresh);
146
+ assert.equal(fresh.body.conversation.starting_point, 'fresh');
147
+
148
+ const bad = await call('POST', '/front-door-guide/conversations', { starting_point: 'github' });
149
+ assert.equal(bad.status, 400);
150
+
151
+ const st = await call('POST', '/front-door-guide/conversations', {});
152
+ const conv = st.body.conversation;
153
+ const r = await call('POST', `/front-door-guide/conversations/${conv.id}/messages`, { token: conv.token, text: 'I already have a half-built shop.', starting_point: 'existing' });
154
+ assert.equal(r.body.mode, 'chat');
155
+ assert.equal(r.body.conversation.starting_point, 'existing');
156
+ assert.match(requests[0].system, /This visitor already has something/, 'the reply already follows the answer');
157
+ assert.equal((await call('POST', `/front-door-guide/conversations/${conv.id}/messages`, { token: conv.token, text: 'x', starting_point: 'both' })).status, 400);
158
+ });
159
+
160
+ // ---- the existing-project branch -----------------------------------------------------------
161
+
162
+ test('an existing project: the guide asks what they have and what is next, and plans from their words alone', async () => {
163
+ reset();
164
+ const st = await call('POST', '/front-door-guide/conversations', { starting_point: 'existing' });
165
+ assert.equal(st.body.greeting, SP.GREETINGS.existing);
166
+ assert.match(st.body.greeting, /cannot open files or links/);
167
+ const conv = st.body.conversation;
168
+ const say = (text) => call('POST', `/front-door-guide/conversations/${conv.id}/messages`, { token: conv.token, text });
169
+
170
+ await say('I have a Node app for booking yoga classes, about half done, on github.com/someone/yoga-app.');
171
+ await say('Next I want online payments and a teacher dashboard.');
172
+ const system = requests[0].system;
173
+ assert.match(system, /ask what they have \(code, documents, artwork or something else, and how far along it is\) and what they want to build next/);
174
+ assert.match(system, /Never ask them to paste code, upload files or share links/);
175
+ assert.match(system, /If they paste a link, say you cannot open links/);
176
+
177
+ // The plan: the visitor's answer decides the starting point, even if the model said otherwise.
178
+ planText = JSON.stringify({
179
+ summary: 'Finish a yoga booking app and add payments and a teacher dashboard.',
180
+ starting_point: 'fresh',
181
+ what_you_have: ['A Node booking app, about half done', 'Code on GitHub'],
182
+ project_type: 'business', how_it_works: ['You decide; AI builders do small tasks.'],
183
+ modules: [], steps: [{ title: 'Sign up', detail: '' }, { title: 'Bring in your existing work', detail: 'Your own AI looks through it.' }],
184
+ needs: [{ item: 'A payments account', detail: '' }], open_questions: [],
185
+ });
186
+ const plan = await call('POST', `/front-door-guide/conversations/${conv.id}/plan`, { token: conv.token });
187
+ assert.equal(plan.body.mode, 'plan');
188
+ assert.equal(plan.body.plan.starting_point, 'existing');
189
+ assert.deepEqual(plan.body.plan.what_you_have, ['A Node booking app, about half done', 'Code on GitHub']);
190
+
191
+ const planReq = requests.find((r) => r.output_config.format);
192
+ const props = planReq.output_config.format.schema.properties;
193
+ assert.deepEqual(props.starting_point.enum, ['fresh', 'existing']);
194
+ assert.equal(props.what_you_have.type, 'array');
195
+ assert.match(planReq.system, /"existing" \(the visitor said so\)/);
196
+ assert.match(planReq.system, /Only what they described: you have seen none of it/);
197
+
198
+ // Nothing they have made reached the platform's AI: text only, no tools, no fetch of the link.
199
+ assertTextOnly(requests);
200
+ const sent = requests.flatMap((r) => r.messages.map((m) => m.content)).join('\n');
201
+ assert.ok(sent.includes('github.com/someone/yoga-app'), 'the link is only their words');
202
+ assert.equal(outbound.length, 0, 'and it was never opened');
203
+ });
204
+
205
+ test('a fresh project plans with nothing listed as already made', async () => {
206
+ reset();
207
+ const st = await call('POST', '/front-door-guide/conversations', { starting_point: 'fresh' });
208
+ const conv = st.body.conversation;
209
+ await call('POST', `/front-door-guide/conversations/${conv.id}/messages`, { token: conv.token, text: 'A garden planner.' });
210
+ planText = JSON.stringify({ summary: 'A garden planner.', starting_point: 'existing', what_you_have: [], project_type: 'not-sure', how_it_works: [], modules: [], steps: [], needs: [], open_questions: [] });
211
+ const plan = await call('POST', `/front-door-guide/conversations/${conv.id}/plan`, { token: conv.token });
212
+ assert.equal(plan.body.plan.starting_point, 'fresh');
213
+ assert.deepEqual(plan.body.plan.what_you_have, []);
214
+ assert.match(requests[0].system, /starting fresh, from an idea/);
215
+ assertTextOnly(requests);
216
+ });
217
+
218
+ // ---- no path for reading their work ------------------------------------------------------
219
+
220
+ test('the guide has no upload, fetch or link-reading path: these are all its routes', () => {
221
+ const routes = (r) => r.stack.filter((l) => l.route).map((l) => `${Object.keys(l.route.methods)[0].toUpperCase()} ${l.route.path}`);
222
+ assert.deepEqual([...routes(chatRouter), ...routes(planRouter)].sort(), [
223
+ 'GET /front-door-guide/conversations/:id',
224
+ 'GET /front-door-guide/mine',
225
+ 'GET /front-door-guide/status',
226
+ 'POST /front-door-guide/conversations',
227
+ 'POST /front-door-guide/conversations/:id/attach',
228
+ 'POST /front-door-guide/conversations/:id/messages',
229
+ 'POST /front-door-guide/conversations/:id/plan',
230
+ ]);
231
+ });
232
+
233
+ test('every instruction version carries the no-reading rule', () => {
234
+ for (const sp of [null, 'fresh', 'existing']) {
235
+ assert.match(SP.instructionsFor(sp), /You cannot see, open or read any file, repository, code, document, image or link/);
236
+ }
237
+ assert.equal(SP.greetingFor('nonsense'), SP.GREETINGS.unknown);
238
+ });
@@ -73,6 +73,23 @@ test('the board read groups genesis tasks by the stage their ref names', async (
73
73
  assert.deepEqual(out.scope.map((t) => t.id), ['1', '2']);
74
74
  });
75
75
 
76
+ test('the board read carries each genesis task\'s description, for its Copy prompt (task 1005109)', async () => {
77
+ let seen = '';
78
+ const pool = { query: async (sql) => {
79
+ seen = sql;
80
+ return { rows: [
81
+ { id: 7, title: 'Write the scope', status: 'ready', description: 'Answer the five scope questions.', source_ref: 'genesis:scope' },
82
+ { id: 8, title: 'Old row', status: 'ready', description: null, source_ref: 'genesis:charter' },
83
+ ] };
84
+ } };
85
+ const out = await seed.genesisStageTasks({ pool });
86
+ assert.match(seen, /\bdescription\b/, 'the SELECT reads the description');
87
+ assert.equal(out.scope[0].description, 'Answer the five scope questions.');
88
+ assert.equal(out.charter[0].description, '', 'a null description is an empty string, never "null"');
89
+ const g = gh.shapeGenesis({ mode: { stage: 'scope', stage_number: 1, stage_count: 5, day: 1 }, plan: gh.parsePlan({}).plan, tasks: out });
90
+ assert.equal(g.stages[0].tasks[0].description, 'Answer the five scope questions.', 'the genesis view passes it through');
91
+ });
92
+
76
93
  // ── the plan ─────────────────────────────────────────────────────────────────
77
94
 
78
95
  test('an empty plan is the recommended defaults: the solo fast-track is one click', () => {
@@ -361,25 +378,60 @@ test('every genesis write is the project owner\'s; the read is any signed-in bui
361
378
 
362
379
  const CARD = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'genesis-home.js'), 'utf8');
363
380
 
381
+ const TASK_LIB = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'task-lib.js'), 'utf8');
382
+
364
383
  function bootCard({ founding, data = { genesis: null } }) {
365
384
  const calls = [];
385
+ const copied = [];
386
+ const toasts = [];
366
387
  let widget = null;
367
388
  let observer = null;
368
389
  const attrs = founding ? { 'data-founding': 'on' } : {};
369
390
  const sandbox = {
370
391
  window: {
371
- OTB: { escapeHtml: (s) => String(s), statusPill: (s) => `<span>${s}</span>`, contributeHallWidget: (w) => { widget = w; } },
392
+ OTB: { escapeHtml: (s) => String(s), statusPill: (s) => `<span>${s}</span>`, contributeHallWidget: (w) => { widget = w; }, toast: (m) => toasts.push(m) },
372
393
  BongosClient: { createClient: () => ({ request: async (m, p) => { calls.push(`${m} ${p}`); return { ok: true, status: 200, data }; } }) },
373
394
  },
395
+ navigator: { clipboard: { writeText: async (t) => { copied.push(t); } } },
396
+ location: { hostname: 'builders.example.test', origin: 'https://builders.example.test' },
374
397
  document: { documentElement: { getAttribute: (k) => attrs[k] || null } },
375
398
  MutationObserver: class { constructor(fn) { observer = fn; } observe() {} disconnect() {} },
399
+ setTimeout: () => 0,
376
400
  Promise,
377
401
  };
378
402
  vm.createContext(sandbox);
403
+ // The hall's real prompt builder, loaded as index.html loads it (before the card).
404
+ vm.runInContext(TASK_LIB, sandbox, { filename: 'task-lib.js' });
379
405
  vm.runInContext(CARD, sandbox, { filename: 'genesis-home.js' });
380
406
  const host = { hidden: true, innerHTML: '', addEventListener() {}, querySelector: () => null, querySelectorAll: () => [] };
381
407
  widget.mount(host);
382
- return { widget, host, calls, attrs, fire: () => observer && observer() };
408
+ return { widget, host, calls, copied, toasts, attrs, fire: () => observer && observer() };
409
+ }
410
+
411
+ // Re-mount a booted card so its click handler is captured; returns a click(btn) driver.
412
+ async function mountClickable(b) {
413
+ let onClick = null;
414
+ b.host.addEventListener = (type, fn) => { if (type === 'click') onClick = fn; };
415
+ b.host.contains = () => true;
416
+ b.widget.mount(b.host);
417
+ await new Promise((r) => setImmediate(r));
418
+ await new Promise((r) => setImmediate(r));
419
+ return async (btn) => {
420
+ onClick({ target: { closest: () => btn } });
421
+ for (let i = 0; i < 4; i++) await new Promise((r) => setImmediate(r));
422
+ };
423
+ }
424
+
425
+ // A fake button carrying one data attribute, as the click handler reads it.
426
+ function fakeButton(attr, value) {
427
+ const set = {};
428
+ return {
429
+ set,
430
+ hasAttribute: (k) => k === attr,
431
+ getAttribute: (k) => (k === attr ? value : null),
432
+ setAttribute: (k, v) => { set[k] = v; },
433
+ querySelector: () => null,
434
+ };
383
435
  }
384
436
 
385
437
  test('the card registers in the Home banner, ahead of the welcome, hidden by default', () => {
@@ -459,6 +511,32 @@ test('the card lists every stage vote waiting on the viewer, and votes yes on al
459
511
  ], 'one yes per stage, earliest first');
460
512
  });
461
513
 
514
+ test('each open stage task has Copy prompt, and it copies the task\'s own words (task 1005109)', async () => {
515
+ const genesis = gh.shapeGenesis({
516
+ mode: { stage: 'scope', stage_number: 1, stage_count: 5, day: 1 },
517
+ plan: gh.parsePlan({}).plan,
518
+ tasks: { scope: [
519
+ { id: '201', title: 'Gather what already exists', status: 'shipped', description: 'Link the documents.' },
520
+ { id: '202', title: 'Write the scope', status: 'ready', description: 'Answer the five scope questions in your own words.' },
521
+ ] },
522
+ });
523
+ const b = bootCard({ founding: true, data: { genesis, viewer_id: '1' } });
524
+ const click = await mountClickable(b);
525
+ const html = b.host.innerHTML;
526
+ assert.match(html, /data-genesis-copy="202"/, 'the open task offers Copy prompt');
527
+ assert.doesNotMatch(html, /data-genesis-copy="201"/, 'a shipped task offers nothing');
528
+ assert.doesNotMatch(html, /task__claim|>Claim</, 'Copy prompt only: no Claim button (owner, 2026-11-07)');
529
+ const btn = fakeButton('data-genesis-copy', '202');
530
+ await click(btn);
531
+ assert.equal(b.copied.length, 1, 'one click, one copy');
532
+ const text = b.copied[0];
533
+ assert.doesNotMatch(text, /\(no description\)/, 'the copied prompt is never the empty fallback');
534
+ assert.match(text, /Answer the five scope questions in your own words\./, 'it carries the task\'s description');
535
+ assert.match(text, /Bongos task 202 \(Write the scope\)/, 'built by the hall\'s one prompt builder');
536
+ assert.equal(btn.set['data-copied'], '1');
537
+ assert.match(b.toasts.join(' '), /copied/i);
538
+ });
539
+
462
540
  test('the hall carries the five life plates byte-identical to the world\'s', () => {
463
541
  const world = path.join(ROOT, 'modules', 'public-landing', 'public', 'assets', 'world');
464
542
  const hall = path.join(ROOT, 'modules', 'hall-ui', 'public', 'genesis');
@@ -0,0 +1,255 @@
1
+ // tests/genesis_work.mjs — the founder's own work on a genesis stage (task 1005116)
2
+ //
3
+ // The DB-free half. It drives modules/onboarding/genesis-work.js against an in-memory
4
+ // store with the same four-method contract as the Postgres one, so the rules run
5
+ // everywhere: three doors onto ONE record (switching loses nothing), guided is the
6
+ // default, an answer is stored as an answer, "not sure yet" / skipped / empty are three
7
+ // different things, un-answering goes back to empty, a revision keeps its history, a
8
+ // carried-over answer never overwrites the founder, and the read path a planning
9
+ // session uses says what the founder said. Plus the assisted door's brief and the CLI's
10
+ // argument parsing.
11
+ //
12
+ // The SAME contract runs against Postgres in tests/genesis_work_db.mjs (integration lane),
13
+ // which imports `contract` from here. The tests below run only when this file is the
14
+ // entry point.
15
+ //
16
+ // Run: node tests/genesis_work.mjs
17
+
18
+ import { strict as assert } from 'node:assert';
19
+ import { createRequire } from 'node:module';
20
+ import url from 'node:url';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const gw = require('../modules/onboarding/genesis-work.js');
24
+ const { parseAnswer, formatStage } = require('../scripts/bongos/genesis.js');
25
+
26
+ // An in-memory store honouring the contract pgStore implements.
27
+ export function memStore() {
28
+ const work = new Map();
29
+ const answers = new Map();
30
+ const history = [];
31
+ let clock = 0;
32
+ const tick = () => new Date(1.7e12 + ++clock * 1000);
33
+ return {
34
+ work, answers, history,
35
+ async loadAll() { return { work: [...work.values()], answers: [...answers.values()] }; },
36
+ async upsertWork(stage, patch, by) {
37
+ const cur = work.get(stage) || { stage, door: 'guided', page_md: null, link_url: null };
38
+ work.set(stage, { ...cur, ...patch, updated_by: by, updated_at: tick() });
39
+ },
40
+ async writeAnswer(stage, q, v, by, { ifEmpty = false } = {}) {
41
+ const k = `${stage}|${q}`;
42
+ const cur = answers.get(k);
43
+ if (cur && ifEmpty) return { changed: false };
44
+ if (cur && cur.state === v.state && cur.answer_md === v.answer_md && cur.origin === v.origin) return { changed: false };
45
+ answers.set(k, { stage, question_id: q, ...v, updated_by: by, updated_at: tick() });
46
+ history.push({ stage, question_id: q, state: v.state, answer_md: v.answer_md, origin: v.origin, changed_by: by, changed_at: tick() });
47
+ return { changed: true };
48
+ },
49
+ async clearAnswer(stage, q, by) {
50
+ const k = `${stage}|${q}`;
51
+ if (!answers.has(k)) return { cleared: false };
52
+ answers.delete(k);
53
+ history.push({ stage, question_id: q, state: null, answer_md: null, origin: null, changed_by: by, changed_at: tick() });
54
+ return { cleared: true };
55
+ },
56
+ async history(stage, q) {
57
+ return history.filter((h) => h.stage === stage && h.question_id === q).reverse();
58
+ },
59
+ };
60
+ }
61
+
62
+ const qOf = (r, id) => r.work.questions.find((q) => q.id === id);
63
+
64
+ // The contract both stores must satisfy. `deps` carries the store; `A` is a builder id.
65
+ export async function contract(test, label, deps, A) {
66
+ await test(`${label}: an untouched stage reads guided, every question empty`, async () => {
67
+ const r = await gw.readStage('strategize', deps);
68
+ assert.equal(r.ok, true);
69
+ assert.equal(r.work.door, 'guided', 'guided is the default door');
70
+ assert.equal(r.work.adopted, true);
71
+ assert.deepEqual(r.work.counts, { total: 4, answered: 0, not_sure: 0, skipped: 0, empty: 4 });
72
+ assert.ok(r.work.questions.every((q) => q.answer === null));
73
+ });
74
+
75
+ await test(`${label}: an answer is stored as an answer, with its words and origin`, async () => {
76
+ const r = await gw.setAnswer('strategize', 'big_pieces', { text: ' Ledger first, then the importer, then the view. ' }, A, deps);
77
+ assert.equal(r.changed, true);
78
+ const a = qOf(r, 'big_pieces').answer;
79
+ assert.equal(a.state, 'answered');
80
+ assert.equal(a.text, 'Ledger first, then the importer, then the view.');
81
+ assert.equal(a.origin, 'founder');
82
+ assert.equal(a.carried_over, false);
83
+ });
84
+
85
+ await test(`${label}: "not sure yet", skipped and empty are three different states`, async () => {
86
+ await gw.setAnswer('strategize', 'risks', { state: 'not_sure', text: 'depends on the bank API' }, A, deps);
87
+ await gw.setAnswer('strategize', 'try_first', { state: 'skipped' }, A, deps);
88
+ const r = await gw.readStage('strategize', deps);
89
+ assert.equal(qOf(r, 'risks').answer.state, 'not_sure');
90
+ assert.equal(qOf(r, 'risks').answer.text, 'depends on the bank API');
91
+ assert.equal(qOf(r, 'try_first').answer.state, 'skipped');
92
+ assert.equal(qOf(r, 'change_course').answer, null, 'never touched is empty, not skipped');
93
+ assert.deepEqual(r.work.counts, { total: 4, answered: 1, not_sure: 1, skipped: 1, empty: 1 });
94
+ });
95
+
96
+ await test(`${label}: revising replaces the answer and keeps what it used to say`, async () => {
97
+ const r = await gw.setAnswer('strategize', 'risks', { text: 'The bank API may not allow read-only access.' }, A, deps);
98
+ assert.equal(qOf(r, 'risks').answer.state, 'answered');
99
+ const h = await gw.answerHistory('strategize', 'risks', deps);
100
+ assert.equal(h.ok, true);
101
+ assert.deepEqual(h.history.map((x) => x.state), ['answered', 'not_sure'], 'newest first');
102
+ assert.equal(h.history[1].text, 'depends on the bank API');
103
+ });
104
+
105
+ await test(`${label}: a write that changes nothing writes nothing`, async () => {
106
+ const r = await gw.setAnswer('strategize', 'risks', { text: 'The bank API may not allow read-only access.' }, A, deps);
107
+ assert.equal(r.changed, false);
108
+ assert.equal((await gw.answerHistory('strategize', 'risks', deps)).history.length, 2);
109
+ });
110
+
111
+ await test(`${label}: un-answering goes back to EMPTY (not skipped) and is recorded`, async () => {
112
+ const r = await gw.clearAnswer('strategize', 'try_first', A, deps);
113
+ assert.equal(r.cleared, true);
114
+ assert.equal(qOf(r, 'try_first').answer, null);
115
+ assert.equal(r.work.counts.skipped, 0);
116
+ assert.equal(r.work.counts.empty, 2);
117
+ const h = await gw.answerHistory('strategize', 'try_first', deps);
118
+ assert.equal(h.history[0].state, 'cleared');
119
+ const again = await gw.clearAnswer('strategize', 'try_first', A, deps);
120
+ assert.equal(again.cleared, false, 'clearing an empty question is a no-op');
121
+ });
122
+
123
+ await test(`${label}: switching doors keeps the answers, the page and the link`, async () => {
124
+ await gw.setDoor('strategize', 'open', A, deps);
125
+ await gw.setPage('strategize', 'We bet on the ledger. Everything else waits.', A, deps);
126
+ await gw.setLink('strategize', 'https://example.com/strategy', A, deps);
127
+ let r = await gw.setDoor('strategize', 'assisted', A, deps);
128
+ assert.equal(r.work.door, 'assisted');
129
+ r = await gw.setDoor('strategize', 'guided', A, deps);
130
+ assert.equal(r.work.page, 'We bet on the ledger. Everything else waits.');
131
+ assert.equal(r.work.link, 'https://example.com/strategy');
132
+ assert.equal(r.work.counts.answered, 2, 'answers survive every switch');
133
+ });
134
+
135
+ await test(`${label}: a carried-over answer fills only what is empty, and the founder's edit makes it theirs`, async () => {
136
+ const kept = await gw.setAnswer('strategize', 'big_pieces', { text: 'from the pre-sign-up chat', origin: 'carried_over' }, null, deps, { ifEmpty: true });
137
+ assert.equal(kept.changed, false);
138
+ assert.equal(qOf(kept, 'big_pieces').answer.origin, 'founder', 'never overwrites the founder');
139
+ const filled = await gw.setAnswer('strategize', 'change_course', { text: 'If nobody connects a bank in a month.', origin: 'carried_over' }, null, deps, { ifEmpty: true });
140
+ assert.equal(qOf(filled, 'change_course').answer.carried_over, true);
141
+ const mine = await gw.setAnswer('strategize', 'change_course', { text: 'If nobody connects a bank in two months.' }, A, deps);
142
+ assert.equal(qOf(mine, 'change_course').answer.origin, 'founder');
143
+ });
144
+
145
+ await test(`${label}: the read path a planning session uses says what the founder said`, async () => {
146
+ const { markdown, stages } = await gw.readContext(deps);
147
+ assert.equal(stages.length, 5);
148
+ assert.match(markdown, /^# What the founder has said about this project/);
149
+ assert.match(markdown, /## Strategize/);
150
+ assert.match(markdown, /Ledger first, then the importer, then the view\./);
151
+ assert.match(markdown, /We bet on the ledger/);
152
+ assert.match(markdown, /https:\/\/example\.com\/strategy/);
153
+ assert.doesNotMatch(markdown, /## Scope/, 'a stage with nothing in it is left out');
154
+ });
155
+ }
156
+
157
+ if (import.meta.url === url.pathToFileURL(process.argv[1]).href) {
158
+ let passed = 0;
159
+ let failed = 0;
160
+ const test = async (name, fn) => {
161
+ try { await fn(); passed++; console.log(` ok ${name}`); }
162
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
163
+ };
164
+
165
+ await contract(test, 'memory', { store: memStore() }, 7);
166
+
167
+ await test('refusals: unknown stage / question, bad state / origin / door / link, empty answer', async () => {
168
+ const deps = { store: memStore() };
169
+ assert.equal((await gw.readStage('decide', deps)).reason, 'unknown_stage');
170
+ assert.equal((await gw.setAnswer('strategize', 'nope', { text: 'x' }, 1, deps)).reason, 'unknown_question');
171
+ assert.equal((await gw.setAnswer('strategize', 'risks', { state: 'maybe' }, 1, deps)).reason, 'bad_state');
172
+ assert.equal((await gw.setAnswer('strategize', 'risks', { text: 'x', origin: 'model' }, 1, deps)).reason, 'bad_origin');
173
+ assert.equal((await gw.setAnswer('strategize', 'risks', { text: ' ' }, 1, deps)).reason, 'empty_answer');
174
+ assert.equal((await gw.setAnswer('strategize', 'risks', { text: 'x'.repeat(4001) }, 1, deps)).reason, 'answer_too_long');
175
+ assert.equal((await gw.setDoor('strategize', 'autopilot', 1, deps)).reason, 'bad_door');
176
+ assert.equal((await gw.setLink('strategize', 'javascript:alert(1)', 1, deps)).reason, 'bad_link');
177
+ assert.equal((await gw.clearAnswer('strategize', 'nope', 1, deps)).reason, 'unknown_question');
178
+ assert.equal(deps.store.answers.size, 0, 'no refusal wrote anything');
179
+ });
180
+
181
+ await test('"not sure yet" needs no words; a skip may carry a note', async () => {
182
+ const deps = { store: memStore() };
183
+ const r = await gw.setAnswer('strategize', 'risks', { state: 'not_sure' }, 1, deps);
184
+ assert.equal(qOf(r, 'risks').answer.state, 'not_sure');
185
+ assert.equal(qOf(r, 'risks').answer.text, null);
186
+ });
187
+
188
+ await test('a stage that has not adopted questions still takes a page and a link', async () => {
189
+ const deps = { store: memStore() };
190
+ const r = await gw.setPage('charter', 'Monarchy to start.', 1, deps);
191
+ assert.equal(r.work.adopted, false);
192
+ assert.equal(r.work.page, 'Monarchy to start.');
193
+ assert.equal((await gw.setAnswer('charter', 'anything', { text: 'x' }, 1, deps)).reason, 'unknown_question');
194
+ });
195
+
196
+ await test('an empty project gives the planning read path nothing to print', async () => {
197
+ assert.equal((await gw.readContext({ store: memStore() })).markdown, '');
198
+ });
199
+
200
+ await test('the read path labels not-sure, skipped, assisted and carried-over answers', async () => {
201
+ const deps = { store: memStore() };
202
+ await gw.setAnswer('strategize', 'risks', { state: 'not_sure', text: 'waiting on the bank' }, 1, deps);
203
+ await gw.setAnswer('strategize', 'try_first', { state: 'skipped' }, 1, deps);
204
+ await gw.setAnswer('strategize', 'big_pieces', { text: 'Ledger, then view.', origin: 'assisted' }, 1, deps);
205
+ await gw.setAnswer('strategize', 'change_course', { text: 'No users.', origin: 'carried_over' }, null, deps);
206
+ const md = (await gw.readContext(deps)).markdown;
207
+ assert.match(md, /Not sure yet — waiting on the bank/);
208
+ assert.match(md, /Skipped by the founder/);
209
+ assert.match(md, /worked out with an AI session, accepted by the founder/);
210
+ assert.match(md, /carried over from before sign-up, not yet confirmed/);
211
+ });
212
+
213
+ await test('the assisted door works the stage\'s own questions and leaves the founder the author', async () => {
214
+ const p = gw.assistPrompt('strategize');
215
+ assert.match(p, /I am the author; you are my assistant/);
216
+ for (const q of gw.QUESTIONS.strategize) assert.ok(p.includes(q.ask), `asks: ${q.ask}`);
217
+ assert.match(p, /Push back/);
218
+ assert.match(p, /Not sure yet/i);
219
+ assert.match(p, /npx bongos genesis answer strategize <question-id> "<my words>" --origin assisted/);
220
+ assert.equal(gw.assistPrompt('decide'), null);
221
+ });
222
+
223
+ await test('question ids are unique and fit the stored shape', async () => {
224
+ for (const [stage, qs] of Object.entries(gw.QUESTIONS)) {
225
+ assert.ok(gw.STAGES.includes(stage));
226
+ const ids = qs.map((q) => q.id);
227
+ assert.equal(new Set(ids).size, ids.length);
228
+ for (const id of ids) assert.match(id, /^[a-z][a-z0-9_]{1,39}$/);
229
+ }
230
+ });
231
+
232
+ await test('CLI: answer arguments become the right body', async () => {
233
+ assert.deepEqual(parseAnswer(['Ledger', 'first']).body, { state: 'answered', text: 'Ledger first' });
234
+ assert.deepEqual(parseAnswer(['--not-sure']).body, { state: 'not_sure' });
235
+ assert.deepEqual(parseAnswer(['--not-sure', 'bank API']).body, { state: 'not_sure', text: 'bank API' });
236
+ assert.deepEqual(parseAnswer(['--skip']).body, { state: 'skipped' });
237
+ assert.deepEqual(parseAnswer(['Ledger', '--origin', 'assisted']).body, { state: 'answered', text: 'Ledger', origin: 'assisted' });
238
+ assert.ok(parseAnswer([]).error);
239
+ assert.ok(parseAnswer(['--skip', '--not-sure']).error);
240
+ });
241
+
242
+ await test('CLI: a stage prints each answer with its state and origin', async () => {
243
+ const deps = { store: memStore() };
244
+ await gw.setAnswer('strategize', 'risks', { state: 'not_sure' }, 1, deps);
245
+ const r = await gw.setAnswer('strategize', 'big_pieces', { text: 'Ledger', origin: 'assisted' }, 1, deps);
246
+ const out = formatStage(r.work);
247
+ assert.match(out, /Strategize — working guided · 1 answered, 1 not sure, 0 skipped, 2 empty/);
248
+ assert.match(out, /\[not sure yet\]/);
249
+ assert.match(out, /Ledger \(with AI help\)/);
250
+ assert.match(out, /\(empty\)/);
251
+ });
252
+
253
+ console.log(`\ngenesis_work: ${passed} passed, ${failed} failed`);
254
+ if (failed) process.exit(1);
255
+ }