@bongos/core 1.25.14 → 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.
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env node
2
+ // scripts/bongos/genesis.js — the founder's genesis answers from a terminal (task 1005116)
3
+ //
4
+ // WHY. Each genesis stage is the founder's to write, through one of three doors onto one
5
+ // stored record (modules/onboarding/genesis-work.js): guided questions, an open page, or
6
+ // their own AI session helping. This is how an AI session works a stage WITH the founder
7
+ // — it reads what they have said, asks the stage's own questions, and records only the
8
+ // answers the founder accepts — and how any session planning a goal or task reads what
9
+ // the founder said (`context`). Every write goes to the server as the person signed in;
10
+ // only the project owner may write, so a session can never author a stage on its own.
11
+ //
12
+ // Usage:
13
+ // npx bongos genesis show [stage] every stage, or one, with its answers
14
+ // npx bongos genesis context what the founder said, as markdown (for planning sessions)
15
+ // npx bongos genesis assist <stage> the brief for the founder's AI session (the assisted door)
16
+ // npx bongos genesis answer <stage> <question> "<words>" [--origin founder|assisted]
17
+ // npx bongos genesis answer <stage> <question> --not-sure ["why"]
18
+ // npx bongos genesis answer <stage> <question> --skip ["why"]
19
+ // npx bongos genesis clear <stage> <question> un-answer: back to empty (not skipped)
20
+ // npx bongos genesis history <stage> <question> what an answer used to say
21
+ // npx bongos genesis door <stage> guided|open|assisted which way the founder is working now
22
+ // npx bongos genesis page <stage> "<words>" | --file <path> | --remove
23
+ // npx bongos genesis link <stage> <url> | --remove the document the founder already has
24
+ //
25
+ // Also `node scripts/bongos/genesis.js …`.
26
+ 'use strict';
27
+
28
+ const fs = require('node:fs');
29
+ const { apiCall, requireSession, errCode, cliExit } = require('./cli-lib');
30
+
31
+ const USAGE = 'usage: npx bongos genesis show [stage] | context | assist <stage> | answer <stage> <question> "<words>" [--origin founder|assisted] | answer <stage> <question> --not-sure|--skip ["why"] | clear <stage> <question> | history <stage> <question> | door <stage> guided|open|assisted | page <stage> "<words>"|--file <path>|--remove | link <stage> <url>|--remove';
32
+
33
+ const STATE_LABEL = { answered: '', not_sure: '[not sure yet] ', skipped: '[skipped] ' };
34
+ const ORIGIN_LABEL = { assisted: ' (with AI help)', carried_over: ' (carried over — confirm or edit)' };
35
+
36
+ // Pure: one stage as the founder reads it.
37
+ function formatStage(s) {
38
+ const out = [`${s.stage[0].toUpperCase()}${s.stage.slice(1)} — working ${s.door}` +
39
+ (s.adopted ? ` · ${s.counts.answered} answered, ${s.counts.not_sure} not sure, ${s.counts.skipped} skipped, ${s.counts.empty} empty` : '')];
40
+ for (const q of s.questions) {
41
+ out.push(` ${q.id}: ${q.ask}`);
42
+ if (!q.answer) { out.push(' (empty)'); continue; }
43
+ const a = q.answer;
44
+ const words = a.text ? a.text.replace(/\n/g, '\n ') : '';
45
+ out.push(` ${STATE_LABEL[a.state] || ''}${words}${ORIGIN_LABEL[a.origin] || ''}`.trimEnd());
46
+ }
47
+ if (s.page) out.push(' Page:', ` ${s.page.replace(/\n/g, '\n ')}`);
48
+ if (s.link) out.push(` Link: ${s.link}`);
49
+ return out.join('\n');
50
+ }
51
+
52
+ // Pure: parse `answer` arguments into the PUT body, or { error }.
53
+ function parseAnswer(rest) {
54
+ const args = [...rest];
55
+ const body = {};
56
+ const take = (flag) => {
57
+ const i = args.indexOf(flag);
58
+ if (i < 0) return undefined;
59
+ const next = args[i + 1];
60
+ const val = next != null && !String(next).startsWith('--') ? next : '';
61
+ args.splice(i, val === '' && (next == null || String(next).startsWith('--')) ? 1 : 2);
62
+ return val;
63
+ };
64
+ const origin = take('--origin');
65
+ const notSure = take('--not-sure');
66
+ const skip = take('--skip');
67
+ if (notSure !== undefined && skip !== undefined) return { error: 'choose one of --not-sure or --skip' };
68
+ if (origin !== undefined) body.origin = origin;
69
+ if (notSure !== undefined) { body.state = 'not_sure'; if (notSure) body.text = notSure; }
70
+ else if (skip !== undefined) { body.state = 'skipped'; if (skip) body.text = skip; }
71
+ else {
72
+ const words = args.join(' ').trim();
73
+ if (!words) return { error: 'give the answer in quotes, or --not-sure / --skip' };
74
+ body.state = 'answered';
75
+ body.text = words;
76
+ }
77
+ return { body };
78
+ }
79
+
80
+ async function call(method, path, body, what) {
81
+ const r = await apiCall(method, `/api/bongos${path}`, body);
82
+ if (!r.ok) console.error(`Could not ${what} (${r.status}${errCode(r.data) ? `: ${errCode(r.data)}` : ''}).`);
83
+ return r.ok ? r.data : null;
84
+ }
85
+
86
+ const enc = encodeURIComponent;
87
+
88
+ async function main(argv) {
89
+ const [cmd, stage, ...rest] = argv;
90
+ if (!cmd || cmd === '--help' || cmd === '-h') { console.log(USAGE); return cmd ? 0 : 2; }
91
+ await requireSession();
92
+ if (cmd === 'show') {
93
+ if (stage) {
94
+ const d = await call('GET', `/founding/work/${enc(stage)}`, null, 'read the stage');
95
+ if (!d) return 1;
96
+ console.log(formatStage(d.work));
97
+ return 0;
98
+ }
99
+ const d = await call('GET', '/founding/work', null, 'read genesis');
100
+ if (!d) return 1;
101
+ console.log(d.stages.map(formatStage).join('\n\n'));
102
+ return 0;
103
+ }
104
+ if (cmd === 'context') {
105
+ const d = await call('GET', '/founding/context', null, 'read what the founder said');
106
+ if (!d) return 1;
107
+ console.log(d.markdown || 'The founder has not written anything in genesis yet.');
108
+ return 0;
109
+ }
110
+ if (!stage) { console.error(USAGE); return 2; }
111
+ if (cmd === 'assist') {
112
+ const d = await call('GET', `/founding/work/${enc(stage)}/assist`, null, 'build the brief');
113
+ if (!d) return 1;
114
+ console.log(d.prompt);
115
+ return 0;
116
+ }
117
+ if (cmd === 'answer') {
118
+ const [question, ...words] = rest;
119
+ if (!question) { console.error(USAGE); return 2; }
120
+ const p = parseAnswer(words);
121
+ if (p.error) { console.error(p.error); return 2; }
122
+ const d = await call('PUT', `/founding/work/${enc(stage)}/answers/${enc(question)}`, p.body, 'save the answer');
123
+ if (!d) return 1;
124
+ console.log(d.changed === false ? 'Unchanged — that is already the answer.' : 'Saved.');
125
+ console.log(formatStage(d.work));
126
+ return 0;
127
+ }
128
+ if (cmd === 'clear' || cmd === 'history') {
129
+ const [question] = rest;
130
+ if (!question) { console.error(USAGE); return 2; }
131
+ if (cmd === 'clear') {
132
+ const d = await call('DELETE', `/founding/work/${enc(stage)}/answers/${enc(question)}`, null, 'clear the answer');
133
+ if (!d) return 1;
134
+ console.log(d.cleared ? 'Cleared — the question is empty again.' : 'It was already empty.');
135
+ return 0;
136
+ }
137
+ const d = await call('GET', `/founding/work/${enc(stage)}/answers/${enc(question)}/history`, null, 'read the history');
138
+ if (!d) return 1;
139
+ if (!d.history.length) console.log('No answer has ever been saved here.');
140
+ for (const h of d.history) console.log(`${h.changed_at} ${h.state}${h.text ? `: ${h.text}` : ''}`);
141
+ return 0;
142
+ }
143
+ if (cmd === 'door') {
144
+ const d = await call('PUT', `/founding/work/${enc(stage)}/door`, { door: String(rest[0] || '') }, 'switch the door');
145
+ if (!d) return 1;
146
+ console.log(`Now working ${d.work.door}. Nothing already written was lost.`);
147
+ return 0;
148
+ }
149
+ if (cmd === 'page') {
150
+ let page;
151
+ if (rest[0] === '--remove') page = '';
152
+ else if (rest[0] === '--file') {
153
+ if (!rest[1]) { console.error('give the file to read: --file <path>'); return 2; }
154
+ page = fs.readFileSync(rest[1], 'utf8');
155
+ } else page = rest.join(' ');
156
+ const d = await call('PUT', `/founding/work/${enc(stage)}/page`, { page }, 'save the page');
157
+ if (!d) return 1;
158
+ console.log(d.work.page ? 'Page saved.' : 'Page removed.');
159
+ return 0;
160
+ }
161
+ if (cmd === 'link') {
162
+ const url = rest[0] === '--remove' ? '' : String(rest[0] || '');
163
+ const d = await call('PUT', `/founding/work/${enc(stage)}/link`, { url }, 'save the link');
164
+ if (!d) return 1;
165
+ console.log(d.work.link ? `Linked: ${d.work.link}` : 'Link removed.');
166
+ return 0;
167
+ }
168
+ console.error(USAGE);
169
+ return 2;
170
+ }
171
+
172
+ if (require.main === module) {
173
+ main(process.argv.slice(2))
174
+ .then((code) => cliExit(code))
175
+ .catch((err) => { console.error('fatal:', err && err.message ? err.message : err); cliExit(1); });
176
+ }
177
+
178
+ module.exports = { main, formatStage, parseAnswer };
@@ -375,6 +375,9 @@ const INTEGRATION = new Set([
375
375
  // the second proves the search index never widens a caller past their rank or into
376
376
  // another builder memory. Each skipped in CI before, so none had ever been gated.
377
377
  'page_notes_db', 'howto_state_db', 'search_isolation_db',
378
+ // task 1005116: the genesis stage-work store on the real schema (the DB-free half,
379
+ // genesis_work, stays in the unit lane).
380
+ 'genesis_work_db',
378
381
  ]);
379
382
 
380
383
  // Excluded so this gate is trustworthy in a CLEAN checkout (the CI condition):
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/genesis.js — @bongos-forwarder (task 1004848). Generated by
3
+ // scripts/bongos/codemod-rename-scripts-gds.js; do not edit.
4
+ // The real script is scripts/bongos/genesis.js. This forwarder keeps callers that still name
5
+ // the old path working, and warns once so they get repointed. Task 1003706 removes it.
6
+ 'use strict';
7
+ require('../bongos/forwarder.js').forward(module, 'genesis.js');
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.25.14'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.25.15'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -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
+ }
@@ -0,0 +1,73 @@
1
+ // tests/genesis_work_db.mjs — the genesis stage-work contract against a REAL Postgres
2
+ // (task 1005116).
3
+ //
4
+ // tests/genesis_work.mjs runs the contract against an in-memory store in the DB-free
5
+ // unit lane. This runs the SAME contract (imported, not copied) through the real
6
+ // Postgres store (genesis-work.js pgStore) over the real migration, so the one-statement
7
+ // answer-plus-history writes, the no-op guard, the if-empty carry-over and the CHECK
8
+ // constraints are proven where they live. Integration lane (run-unit-tests.js).
9
+ //
10
+ // Needs a MIGRATED database (scripts/migrate.sh). SKIPS (exit 0) with no reachable
11
+ // Postgres, like every *_db test.
12
+ //
13
+ // Run: DATABASE_URL=postgres://... node tests/genesis_work_db.mjs
14
+
15
+ import { strict as assert } from 'node:assert';
16
+ import { createRequire } from 'node:module';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import url from 'node:url';
20
+ import { contract } from './genesis_work.mjs';
21
+ import { insertTestBuilders } from './helpers.mjs';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..');
25
+ const { Pool } = require('pg');
26
+ const gw = require('../modules/onboarding/genesis-work.js');
27
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
28
+
29
+ try {
30
+ await pool.query('SELECT 1');
31
+ } catch (e) {
32
+ console.log(`genesis_work_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}). Set DATABASE_URL to run.`);
33
+ await pool.end().catch(() => {});
34
+ process.exit(0);
35
+ }
36
+
37
+ let passed = 0;
38
+ let failed = 0;
39
+ async function test(name, fn) {
40
+ try { await fn(); passed++; console.log(` ok ${name}`); }
41
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
42
+ }
43
+
44
+ const MIGRATION = fs.readFileSync(path.join(ROOT, 'modules/onboarding/migrations/onboarding_002_genesis_work.sql'), 'utf8');
45
+ const wipe = () => pool.query('DELETE FROM onboarding_genesis_answers; DELETE FROM onboarding_genesis_answer_history; DELETE FROM onboarding_genesis_work;');
46
+
47
+ await test('postgres: the migration re-applies cleanly over the migrated schema (idempotent)', async () => {
48
+ await pool.query(MIGRATION);
49
+ await pool.query(MIGRATION);
50
+ });
51
+
52
+ const A = 910000201;
53
+ await insertTestBuilders(pool, [{ id: A, login: 'genesis-a' }]);
54
+ await wipe();
55
+ try {
56
+ await contract(test, 'postgres', { store: gw.pgStore(pool) }, A);
57
+ await test('postgres: the CHECKs refuse a wordless answer, a bad state, origin, door and link', async () => {
58
+ const bad = (sql, params) => assert.rejects(pool.query(sql, params), (e) => e.code === '23514');
59
+ await bad("INSERT INTO onboarding_genesis_answers (stage, question_id, state, answer_md) VALUES ('scope', 'what', 'answered', ' ')");
60
+ await bad("INSERT INTO onboarding_genesis_answers (stage, question_id, state) VALUES ('scope', 'what', 'maybe')");
61
+ await bad("INSERT INTO onboarding_genesis_answers (stage, question_id, state, origin) VALUES ('scope', 'what', 'skipped', 'model')");
62
+ await bad("INSERT INTO onboarding_genesis_answers (stage, question_id, state) VALUES ('decide', 'what', 'skipped')");
63
+ await bad("INSERT INTO onboarding_genesis_work (stage, door) VALUES ('charter', 'autopilot')");
64
+ await bad("INSERT INTO onboarding_genesis_work (stage, link_url) VALUES ('charter', 'javascript:alert(1)')");
65
+ });
66
+ } finally {
67
+ await wipe().catch(() => {});
68
+ await pool.query('DELETE FROM builders WHERE id = $1', [A]).catch(() => {});
69
+ await pool.end().catch(() => {});
70
+ }
71
+
72
+ console.log(`\ngenesis_work_db: ${passed} passed, ${failed} failed`);
73
+ if (failed) process.exit(1);