@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,364 @@
1
+ 'use strict';
2
+
3
+ // modules/onboarding/genesis-work.js — THE FOUNDER'S OWN WORK ON A GENESIS STAGE: one
4
+ // stored record per stage that three ways of working all read and write (task 1005116,
5
+ // goal 1000106; owner ruling 2026-11-07).
6
+ //
7
+ // WHY. A genesis stage is a decision only the founder can make. It used to have one
8
+ // route through it: an AI session drafted the stage's answer, and the project's strategy
9
+ // (or scope) became the model's. The owner's rule: Bongos may structure the thinking
10
+ // and may help with the words, but THE FOUNDER IS THE AUTHOR. So every stage offers
11
+ // three DOORS onto the same work, and the founder picks and switches freely:
12
+ //
13
+ // guided the platform asks the stage's questions one at a time; the founder
14
+ // answers every one. The DEFAULT, offered first: a blank page is how a
15
+ // stage comes back thin.
16
+ // open a blank page and the stage's done-when line. The questions still show
17
+ // beside it, as the shape of a good answer, never as fields to fill.
18
+ // assisted the founder's own AI session helps (assistPrompt): it works the SAME
19
+ // questions, pushes back and offers options, and records only what the
20
+ // founder decides.
21
+ //
22
+ // Plus the fourth way that already existed: link the document you already have.
23
+ //
24
+ // ONE ARTIFACT, NOT THREE FLOWS. The door is only how the founder is working right now.
25
+ // The answers, the free page and the link are one record per stage, kept whatever the
26
+ // door, so switching loses nothing: start guided, finish on the page, ask the session
27
+ // to poke holes in it.
28
+ //
29
+ // AN ANSWER IS STORED AS AN ANSWER, never as prose inside a task description, so every
30
+ // later goal and task can read it (contextMarkdown, `GET /founding/context`). Each has
31
+ // a STATE, and the three are deliberately distinct from one another and from "empty":
32
+ // answered the founder's words
33
+ // not_sure "not sure yet" — an answer, not a gap (an optional note says why)
34
+ // skipped the founder chose to pass on it
35
+ // (no row) never touched
36
+ // Un-answering deletes the row, back to empty. Each answer carries its ORIGIN —
37
+ // `founder` (typed by them), `assisted` (worked out with their session and accepted by
38
+ // them) or `carried_over` (brought in from before sign-up, task 1005274; shown as
39
+ // editable, and any edit by the founder makes it theirs). Every write and every
40
+ // un-answer appends a history row, so the founder can see what an answer used to say.
41
+ //
42
+ // WHICH STAGES. A stage adopts the pattern by declaring its questions in QUESTIONS.
43
+ // Strategize does here; scope's guided door is its project interview (task 1005114);
44
+ // charter, legalize and legislation adopt it when they are next touched. A stage with
45
+ // no questions still takes a free page and a link.
46
+ //
47
+ // THE BOUNDARY. Pure apart from the store. The store is injected (tests) or the
48
+ // Postgres one below, over the doorway's pool; tables in
49
+ // migrations/onboarding_002_genesis_work.sql.
50
+
51
+ const fm = require('./founding-mode');
52
+
53
+ const STAGES = Object.freeze(fm.STAGES.slice(1)); // scope … legislation
54
+ const DOORS = Object.freeze(['guided', 'open', 'assisted']);
55
+ const DEFAULT_DOOR = 'guided';
56
+ const STATES = Object.freeze(['answered', 'not_sure', 'skipped']);
57
+ const ORIGINS = Object.freeze(['founder', 'assisted', 'carried_over']);
58
+ const LIMITS = Object.freeze({ answer: 4000, page: 20000, link: 500 });
59
+
60
+ // Each adopted stage's questions, in the order the guided door asks them. `id` is
61
+ // stable: a stored answer names it, so never rename or reuse one. `hint` is the shape
62
+ // of a good answer, shown under the question in every door.
63
+ const QUESTIONS = Object.freeze({
64
+ strategize: Object.freeze([
65
+ Object.freeze({ id: 'big_pieces', ask: 'What are the big pieces, and in what order do they get built?',
66
+ hint: 'Three to six pieces is plenty. Say why that order: what has to exist before what.' }),
67
+ Object.freeze({ id: 'risks', ask: 'What are the biggest risks, the things that could sink this?',
68
+ hint: 'Name what you are least sure of: a technical unknown, whether anyone wants it, time, money.' }),
69
+ Object.freeze({ id: 'try_first', ask: 'What gets tried first, and why that?',
70
+ hint: 'Usually the cheapest test of the biggest risk.' }),
71
+ Object.freeze({ id: 'change_course', ask: 'What would make you change course?',
72
+ hint: 'The signal that would tell you this plan is wrong, so you notice it when it comes.' }),
73
+ ]),
74
+ });
75
+
76
+ const isStage = (s) => STAGES.includes(s);
77
+ const questionsFor = (stage) => QUESTIONS[stage] || [];
78
+ const questionFor = (stage, id) => questionsFor(stage).find((q) => q.id === id) || null;
79
+ const text = (v) => (v == null ? '' : String(v)).trim();
80
+
81
+ // ── pure: checking a write ────────────────────────────────────────────────────
82
+
83
+ // checkAnswer(stage, questionId, body) → { ok, value } or { ok:false, reason }.
84
+ // body: { state, text?, origin? }. `answered` needs words; `not_sure` and `skipped`
85
+ // may carry a short note. The origin defaults to `founder`.
86
+ function checkAnswer(stage, questionId, body = {}) {
87
+ if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
88
+ if (!questionFor(stage, questionId)) return { ok: false, reason: 'unknown_question' };
89
+ const b = body && typeof body === 'object' ? body : {};
90
+ const state = b.state == null ? 'answered' : String(b.state);
91
+ if (!STATES.includes(state)) return { ok: false, reason: 'bad_state' };
92
+ const origin = b.origin == null ? 'founder' : String(b.origin);
93
+ if (!ORIGINS.includes(origin)) return { ok: false, reason: 'bad_origin' };
94
+ const words = text(b.text);
95
+ if (words.length > LIMITS.answer) return { ok: false, reason: 'answer_too_long' };
96
+ if (state === 'answered' && !words) return { ok: false, reason: 'empty_answer' };
97
+ return { ok: true, value: { state, answer_md: words || null, origin } };
98
+ }
99
+
100
+ // checkLink(url) → { ok, value } — an http(s) address, or '' / null to remove it.
101
+ function checkLink(url) {
102
+ const u = text(url);
103
+ if (!u) return { ok: true, value: null };
104
+ if (u.length > LIMITS.link) return { ok: false, reason: 'link_too_long' };
105
+ if (!/^https?:\/\/[^\s]+$/i.test(u)) return { ok: false, reason: 'bad_link' };
106
+ return { ok: true, value: u };
107
+ }
108
+
109
+ // ── pure: the view ────────────────────────────────────────────────────────────
110
+
111
+ const iso = (v) => (v ? new Date(v).toISOString() : null);
112
+
113
+ // shapeStage(stage, work, answers) → one stage's record as every door sees it.
114
+ // work the stage row ({ door, page_md, link_url, updated_at }) or null
115
+ // answers the stage's answer rows
116
+ function shapeStage(stage, work, answers = []) {
117
+ const byId = new Map(answers.filter((a) => a.stage === stage).map((a) => [a.question_id, a]));
118
+ const questions = questionsFor(stage).map((q) => {
119
+ const a = byId.get(q.id);
120
+ return {
121
+ id: q.id, ask: q.ask, hint: q.hint,
122
+ answer: a ? {
123
+ state: a.state, text: a.answer_md || null, origin: a.origin,
124
+ // A carried-over answer is shown as such until the founder makes it theirs.
125
+ carried_over: a.origin === 'carried_over',
126
+ updated_at: iso(a.updated_at),
127
+ } : null,
128
+ };
129
+ });
130
+ const count = (st) => questions.filter((q) => q.answer && q.answer.state === st).length;
131
+ return {
132
+ stage,
133
+ adopted: questionsFor(stage).length > 0,
134
+ door: (work && DOORS.includes(work.door)) ? work.door : DEFAULT_DOOR,
135
+ doors: DOORS,
136
+ questions,
137
+ page: work && work.page_md ? work.page_md : null,
138
+ link: work && work.link_url ? work.link_url : null,
139
+ counts: {
140
+ total: questions.length,
141
+ answered: count('answered'),
142
+ not_sure: count('not_sure'),
143
+ skipped: count('skipped'),
144
+ empty: questions.filter((q) => !q.answer).length,
145
+ },
146
+ updated_at: iso(work && work.updated_at),
147
+ };
148
+ }
149
+
150
+ // shapeAll({ work, answers }) → every stage, in genesis order.
151
+ function shapeAll({ work = [], answers = [] } = {}) {
152
+ const byStage = new Map(work.map((w) => [w.stage, w]));
153
+ return STAGES.map((s) => shapeStage(s, byStage.get(s) || null, answers));
154
+ }
155
+
156
+ const STATE_WORDS = { not_sure: 'Not sure yet', skipped: 'Skipped by the founder' };
157
+ const ORIGIN_WORDS = { assisted: ' (worked out with an AI session, accepted by the founder)', carried_over: ' (carried over from before sign-up, not yet confirmed by the founder)' };
158
+
159
+ // contextMarkdown(stages) → THE READ PATH for an AI session planning a goal or task:
160
+ // what the founder has said about the project, stage by stage, as plain markdown. Only
161
+ // stages with something in them appear; an empty project returns an empty string so a
162
+ // caller can say nothing rather than print an empty heading.
163
+ function contextMarkdown(stages) {
164
+ const out = [];
165
+ for (const s of stages || []) {
166
+ const said = s.questions.filter((q) => q.answer);
167
+ if (!said.length && !s.page && !s.link) continue;
168
+ out.push(`## ${s.stage[0].toUpperCase()}${s.stage.slice(1)}`);
169
+ for (const q of said) {
170
+ const a = q.answer;
171
+ const body = a.state === 'answered' ? a.text : `${STATE_WORDS[a.state]}${a.text ? ` — ${a.text}` : ''}`;
172
+ out.push(`- **${q.ask}**${ORIGIN_WORDS[a.origin] || ''}\n ${String(body).replace(/\n/g, '\n ')}`);
173
+ }
174
+ if (s.page) out.push('', '**In the founder\'s own words:**', '', s.page);
175
+ if (s.link) out.push('', `**The founder's existing document:** ${s.link}`);
176
+ out.push('');
177
+ }
178
+ if (!out.length) return '';
179
+ return ['# What the founder has said about this project', '',
180
+ 'Written by the founder during genesis. Treat it as the project\'s own direction: plan inside it, and say so when a goal or task would go against it. "Not sure yet" is an honest answer, not a gap to fill in for them.', '',
181
+ ...out].join('\n').trimEnd() + '\n';
182
+ }
183
+
184
+ // assistPrompt(stage) → the brief the founder hands their own AI session for the
185
+ // assisted door. It works the stage's own questions (never a shape of its own),
186
+ // argues rather than authors, and records only what the founder decides.
187
+ function assistPrompt(stage) {
188
+ if (!isStage(stage)) return null;
189
+ const qs = questionsFor(stage);
190
+ const lines = [
191
+ `Help me work through the ${stage} stage of my project's genesis. I am the author; you are my assistant.`,
192
+ '',
193
+ 'How to help:',
194
+ '- Work these questions, one at a time, in this order. Do not invent a different structure.',
195
+ '- Ask what I am assuming. Push back when an answer is vague or I seem too sure.',
196
+ '- Offer two or three options when I am stuck, never one confident answer.',
197
+ '- "Not sure yet" is a fine answer. Record it as that rather than filling the gap for me.',
198
+ '- Record an answer only after I say it is mine, in my words (you may tidy the wording; read it back first).',
199
+ '',
200
+ ];
201
+ if (qs.length) {
202
+ lines.push('The questions:');
203
+ qs.forEach((q, i) => lines.push(`${i + 1}. ${q.ask} (${q.hint})`));
204
+ lines.push('');
205
+ }
206
+ lines.push('To see what I have already said: npx bongos genesis show ' + stage);
207
+ if (qs.length) {
208
+ lines.push(`To record an answer I accepted: npx bongos genesis answer ${stage} <question-id> "<my words>" --origin assisted`);
209
+ lines.push(`To record "not sure yet": npx bongos genesis answer ${stage} <question-id> --not-sure ["why"]`);
210
+ }
211
+ lines.push(`To save a page I wrote: npx bongos genesis page ${stage} --file <path>`);
212
+ return lines.join('\n');
213
+ }
214
+
215
+ // ── the store (Postgres) ──────────────────────────────────────────────────────
216
+
217
+ function pgStore(pool) {
218
+ const db = () => pool || require('../../src/module-api').pool;
219
+ return {
220
+ async loadAll() {
221
+ const [w, a] = await Promise.all([
222
+ db().query('SELECT stage, door, page_md, link_url, updated_at FROM onboarding_genesis_work'),
223
+ db().query('SELECT stage, question_id, state, answer_md, origin, updated_at FROM onboarding_genesis_answers'),
224
+ ]);
225
+ return { work: w.rows, answers: a.rows };
226
+ },
227
+ // patch: any of { door, page_md, link_url }; an absent key keeps what is stored.
228
+ async upsertWork(stage, patch, by) {
229
+ const has = (k) => Object.prototype.hasOwnProperty.call(patch, k);
230
+ await db().query(
231
+ `INSERT INTO onboarding_genesis_work (stage, door, page_md, link_url, updated_by)
232
+ VALUES ($1, COALESCE($2, '${DEFAULT_DOOR}'), $4, $6, $8)
233
+ ON CONFLICT (stage) DO UPDATE SET
234
+ door = CASE WHEN $3 THEN EXCLUDED.door ELSE onboarding_genesis_work.door END,
235
+ page_md = CASE WHEN $5 THEN EXCLUDED.page_md ELSE onboarding_genesis_work.page_md END,
236
+ link_url = CASE WHEN $7 THEN EXCLUDED.link_url ELSE onboarding_genesis_work.link_url END,
237
+ updated_by = EXCLUDED.updated_by, updated_at = now()`,
238
+ [stage, has('door') ? patch.door : null, has('door'), has('page_md') ? patch.page_md : null,
239
+ has('page_md'), has('link_url') ? patch.link_url : null, has('link_url'), by == null ? null : by],
240
+ );
241
+ },
242
+ // One statement: the answer and its history row land together, and a write that
243
+ // changes nothing writes neither. ifEmpty never touches an answer already there.
244
+ async writeAnswer(stage, questionId, v, by, { ifEmpty = false } = {}) {
245
+ const { rows } = await db().query(
246
+ `WITH up AS (
247
+ INSERT INTO onboarding_genesis_answers (stage, question_id, state, answer_md, origin, updated_by)
248
+ VALUES ($1, $2, $3, $4, $5, $6)
249
+ ON CONFLICT (stage, question_id) DO ${ifEmpty ? 'NOTHING' : `UPDATE SET
250
+ state = EXCLUDED.state, answer_md = EXCLUDED.answer_md, origin = EXCLUDED.origin,
251
+ updated_by = EXCLUDED.updated_by, updated_at = now()
252
+ WHERE (onboarding_genesis_answers.state, onboarding_genesis_answers.answer_md, onboarding_genesis_answers.origin)
253
+ IS DISTINCT FROM (EXCLUDED.state, EXCLUDED.answer_md, EXCLUDED.origin)`}
254
+ RETURNING stage, question_id, state, answer_md, origin, updated_by)
255
+ INSERT INTO onboarding_genesis_answer_history (stage, question_id, state, answer_md, origin, changed_by)
256
+ SELECT stage, question_id, state, answer_md, origin, updated_by FROM up
257
+ RETURNING id`,
258
+ [stage, questionId, v.state, v.answer_md, v.origin, by == null ? null : by],
259
+ );
260
+ return { changed: rows.length > 0 };
261
+ },
262
+ async clearAnswer(stage, questionId, by) {
263
+ const { rows } = await db().query(
264
+ `WITH del AS (
265
+ DELETE FROM onboarding_genesis_answers WHERE stage = $1 AND question_id = $2
266
+ RETURNING stage, question_id)
267
+ INSERT INTO onboarding_genesis_answer_history (stage, question_id, state, answer_md, origin, changed_by)
268
+ SELECT stage, question_id, NULL, NULL, NULL, $3 FROM del
269
+ RETURNING id`,
270
+ [stage, questionId, by == null ? null : by],
271
+ );
272
+ return { cleared: rows.length > 0 };
273
+ },
274
+ async history(stage, questionId) {
275
+ const { rows } = await db().query(
276
+ `SELECT state, answer_md, origin, changed_by, changed_at FROM onboarding_genesis_answer_history
277
+ WHERE stage = $1 AND question_id = $2 ORDER BY changed_at DESC, id DESC LIMIT 50`,
278
+ [stage, questionId],
279
+ );
280
+ return rows;
281
+ },
282
+ };
283
+ }
284
+
285
+ // ── the operations the routes call ────────────────────────────────────────────
286
+
287
+ const storeOf = (deps = {}) => deps.store || pgStore(deps.pool);
288
+
289
+ async function readAll(deps = {}) {
290
+ return shapeAll(await storeOf(deps).loadAll());
291
+ }
292
+
293
+ async function readStage(stage, deps = {}) {
294
+ if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
295
+ const all = await readAll(deps);
296
+ return { ok: true, work: all.find((s) => s.stage === stage) };
297
+ }
298
+
299
+ async function setDoor(stage, door, by, deps = {}) {
300
+ if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
301
+ if (!DOORS.includes(door)) return { ok: false, reason: 'bad_door' };
302
+ await storeOf(deps).upsertWork(stage, { door }, by);
303
+ return readStage(stage, deps);
304
+ }
305
+
306
+ async function setPage(stage, page, by, deps = {}) {
307
+ if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
308
+ const words = text(page);
309
+ if (words.length > LIMITS.page) return { ok: false, reason: 'page_too_long' };
310
+ await storeOf(deps).upsertWork(stage, { page_md: words || null }, by);
311
+ return readStage(stage, deps);
312
+ }
313
+
314
+ async function setLink(stage, url, by, deps = {}) {
315
+ if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
316
+ const c = checkLink(url);
317
+ if (!c.ok) return c;
318
+ await storeOf(deps).upsertWork(stage, { link_url: c.value }, by);
319
+ return readStage(stage, deps);
320
+ }
321
+
322
+ // setAnswer — store or revise one answer. opts.ifEmpty (for carrying answers over,
323
+ // task 1005274) writes only where the founder has said nothing yet.
324
+ async function setAnswer(stage, questionId, body, by, deps = {}, opts = {}) {
325
+ const c = checkAnswer(stage, questionId, body);
326
+ if (!c.ok) return c;
327
+ const { changed } = await storeOf(deps).writeAnswer(stage, questionId, c.value, by, opts);
328
+ const r = await readStage(stage, deps);
329
+ return { ...r, changed };
330
+ }
331
+
332
+ // clearAnswer — un-answer: the question goes back to empty (not to "skipped").
333
+ async function clearAnswer(stage, questionId, by, deps = {}) {
334
+ if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
335
+ if (!questionFor(stage, questionId)) return { ok: false, reason: 'unknown_question' };
336
+ const { cleared } = await storeOf(deps).clearAnswer(stage, questionId, by);
337
+ const r = await readStage(stage, deps);
338
+ return { ...r, cleared };
339
+ }
340
+
341
+ async function answerHistory(stage, questionId, deps = {}) {
342
+ if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
343
+ if (!questionFor(stage, questionId)) return { ok: false, reason: 'unknown_question' };
344
+ const rows = await storeOf(deps).history(stage, questionId);
345
+ return {
346
+ ok: true,
347
+ history: rows.map((r) => ({
348
+ state: r.state || 'cleared', text: r.answer_md || null, origin: r.origin || null,
349
+ changed_by: r.changed_by == null ? null : String(r.changed_by), changed_at: iso(r.changed_at),
350
+ })),
351
+ };
352
+ }
353
+
354
+ async function readContext(deps = {}) {
355
+ const stages = await readAll(deps);
356
+ return { markdown: contextMarkdown(stages), stages };
357
+ }
358
+
359
+ // Only what a caller uses is exported; the checks and the view shaping stay private
360
+ // and are exercised through the operations (tests/genesis_work.mjs).
361
+ module.exports = {
362
+ STAGES, LIMITS, QUESTIONS, assistPrompt,
363
+ pgStore, readAll, readStage, setDoor, setPage, setLink, setAnswer, clearAnswer, answerHistory, readContext,
364
+ };
@@ -0,0 +1,76 @@
1
+ -- onboarding_002_genesis_work.sql — the founder's own work on each genesis stage
2
+ -- (task 1005116; read modules/onboarding/genesis-work.js for the why).
3
+ --
4
+ -- Every genesis stage offers three ways of working (guided · open · assisted) onto ONE
5
+ -- record per stage, so switching loses nothing:
6
+ -- onboarding_genesis_work one row per stage: the door the founder is using
7
+ -- now, their free page, and a link to a document
8
+ -- they already have
9
+ -- onboarding_genesis_answers one row per answered question. The state tells
10
+ -- "answered", "not sure yet" and "skipped" apart;
11
+ -- no row means never touched (empty). The origin
12
+ -- says whose words: the founder's, worked out with
13
+ -- their AI session, or carried over from before
14
+ -- sign-up (task 1005274).
15
+ -- onboarding_genesis_answer_history append-only: every write and un-answer, so a
16
+ -- revised answer keeps what it used to say. A NULL
17
+ -- state is an un-answer.
18
+ --
19
+ -- The questions themselves are code (genesis-work.js QUESTIONS); the API refuses an id
20
+ -- a stage does not name, so question_id is only shape-checked here.
21
+ --
22
+ -- ADDITIVE, module-owned (ADR 0083 §Decision #5). Idempotent: safe to re-run.
23
+
24
+ BEGIN;
25
+
26
+ CREATE TABLE IF NOT EXISTS onboarding_genesis_work (
27
+ stage text PRIMARY KEY,
28
+ door text NOT NULL DEFAULT 'guided',
29
+ page_md text,
30
+ link_url text,
31
+ updated_by bigint REFERENCES builders(id) ON DELETE SET NULL,
32
+ updated_at timestamptz NOT NULL DEFAULT now(),
33
+ CONSTRAINT onboarding_genesis_work_stage_chk CHECK (stage IN ('scope', 'strategize', 'charter', 'legalize', 'legislation')),
34
+ CONSTRAINT onboarding_genesis_work_door_chk CHECK (door IN ('guided', 'open', 'assisted')),
35
+ CONSTRAINT onboarding_genesis_work_page_chk CHECK (page_md IS NULL OR length(page_md) <= 20000),
36
+ CONSTRAINT onboarding_genesis_work_link_chk CHECK (link_url IS NULL OR (length(link_url) <= 500 AND link_url ~* '^https?://'))
37
+ );
38
+
39
+ CREATE TABLE IF NOT EXISTS onboarding_genesis_answers (
40
+ stage text NOT NULL,
41
+ question_id text NOT NULL,
42
+ state text NOT NULL,
43
+ answer_md text,
44
+ origin text NOT NULL DEFAULT 'founder',
45
+ updated_by bigint REFERENCES builders(id) ON DELETE SET NULL,
46
+ created_at timestamptz NOT NULL DEFAULT now(),
47
+ updated_at timestamptz NOT NULL DEFAULT now(),
48
+ PRIMARY KEY (stage, question_id),
49
+ CONSTRAINT onboarding_genesis_answers_stage_chk CHECK (stage IN ('scope', 'strategize', 'charter', 'legalize', 'legislation')),
50
+ CONSTRAINT onboarding_genesis_answers_qid_chk CHECK (question_id ~ '^[a-z][a-z0-9_]{1,39}$'),
51
+ CONSTRAINT onboarding_genesis_answers_state_chk CHECK (state IN ('answered', 'not_sure', 'skipped')),
52
+ CONSTRAINT onboarding_genesis_answers_origin_chk CHECK (origin IN ('founder', 'assisted', 'carried_over')),
53
+ -- "answered" always has words; the other two may carry a short note.
54
+ CONSTRAINT onboarding_genesis_answers_words_chk CHECK (state <> 'answered' OR length(btrim(coalesce(answer_md, ''))) > 0),
55
+ CONSTRAINT onboarding_genesis_answers_len_chk CHECK (answer_md IS NULL OR length(answer_md) <= 4000)
56
+ );
57
+
58
+ CREATE TABLE IF NOT EXISTS onboarding_genesis_answer_history (
59
+ id bigserial PRIMARY KEY,
60
+ stage text NOT NULL,
61
+ question_id text NOT NULL,
62
+ state text,
63
+ answer_md text,
64
+ origin text,
65
+ changed_by bigint REFERENCES builders(id) ON DELETE SET NULL,
66
+ changed_at timestamptz NOT NULL DEFAULT now(),
67
+ CONSTRAINT onboarding_genesis_answer_history_state_chk CHECK (state IS NULL OR state IN ('answered', 'not_sure', 'skipped'))
68
+ );
69
+
70
+ CREATE INDEX IF NOT EXISTS onboarding_genesis_answer_history_q_idx
71
+ ON onboarding_genesis_answer_history (stage, question_id, changed_at DESC);
72
+
73
+ INSERT INTO schema_migrations (version) VALUES ('onboarding_002_genesis_work')
74
+ ON CONFLICT (version) DO NOTHING;
75
+
76
+ COMMIT;
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ // modules/onboarding/routes/genesis-work.js — the HTTP surface of the founder's own work
4
+ // on each genesis stage (task 1005116; the why is in ../genesis-work.js). Mounted by the
5
+ // onboarding route factory (./onboarding.js), on the same /api/bongos router.
6
+ //
7
+ // Who may do what: any signed-in builder READS (the whole team, and every AI session
8
+ // planning a goal or task, reads what the founder said). Every WRITE is the project
9
+ // owner's, behind the same `project.settings.manage` atom as the rest of genesis — the
10
+ // founder is the author. Writes are allowed whether or not founding mode is on, so an
11
+ // answer can be revised long after the project came alive.
12
+
13
+ const api = require('../../../src/module-api');
14
+ const work = require('../genesis-work');
15
+
16
+ const REFUSALS = {
17
+ unknown_stage: 404, unknown_question: 404, bad_door: 400, bad_state: 400, bad_origin: 400,
18
+ empty_answer: 400, answer_too_long: 400, page_too_long: 400, link_too_long: 400, bad_link: 400,
19
+ };
20
+
21
+ // `deps` is for tests only (an in-memory store); production passes none.
22
+ module.exports = function mountGenesisWorkRoutes(router, deps = {}) {
23
+ const owns = api.requirePermission('project.settings.manage');
24
+ const handle = (fn) => async (req, res) => {
25
+ try {
26
+ const r = await fn(req);
27
+ if (r && r.ok === false) {
28
+ return res.status(REFUSALS[r.reason] || 409).json({ error: { code: r.reason, message: r.reason } });
29
+ }
30
+ res.set('Cache-Control', 'no-store');
31
+ res.json(r);
32
+ } catch (err) {
33
+ api.logger('onboarding').error('[onboarding] genesis work', err);
34
+ res.status(500).json({ error: { code: 'genesis_work_failed', message: 'internal error' } });
35
+ }
36
+ };
37
+ const stage = (req) => String(req.params.stage);
38
+ const q = (req) => String(req.params.question);
39
+
40
+ // The read path for every door and for AI sessions planning a goal or task.
41
+ router.get('/founding/work', api.requireBuilder, handle(async () => ({ stages: await work.readAll(deps) })));
42
+ router.get('/founding/context', api.requireBuilder, handle(() => work.readContext(deps)));
43
+ router.get('/founding/work/:stage', api.requireBuilder, handle((req) => work.readStage(stage(req), deps)));
44
+ router.get('/founding/work/:stage/assist', api.requireBuilder, handle(async (req) => {
45
+ const prompt = work.assistPrompt(stage(req));
46
+ return prompt ? { prompt } : { ok: false, reason: 'unknown_stage' };
47
+ }));
48
+ router.get('/founding/work/:stage/answers/:question/history', api.requireBuilder,
49
+ handle((req) => work.answerHistory(stage(req), q(req), deps)));
50
+
51
+ // The founder's writes.
52
+ router.put('/founding/work/:stage/door', api.requireBuilder, owns,
53
+ (req, res, next) => {
54
+ if (api.validateOrRespond(req, res, { door: { type: 'string', required: true, maxLength: 16 } })) return;
55
+ next();
56
+ },
57
+ handle((req) => work.setDoor(stage(req), req.body.door, req.builder.id, deps)));
58
+ router.put('/founding/work/:stage/page', api.requireBuilder, owns,
59
+ (req, res, next) => {
60
+ if (api.validateOrRespond(req, res, { page: { type: 'string', maxLength: work.LIMITS.page } })) return;
61
+ next();
62
+ },
63
+ handle((req) => work.setPage(stage(req), req.body.page, req.builder.id, deps)));
64
+ router.put('/founding/work/:stage/link', api.requireBuilder, owns,
65
+ (req, res, next) => {
66
+ if (api.validateOrRespond(req, res, { url: { type: 'string', maxLength: work.LIMITS.link } })) return;
67
+ next();
68
+ },
69
+ handle((req) => work.setLink(stage(req), req.body.url, req.builder.id, deps)));
70
+ router.put('/founding/work/:stage/answers/:question', api.requireBuilder, owns,
71
+ (req, res, next) => {
72
+ if (api.validateOrRespond(req, res, {
73
+ state: { type: 'string', maxLength: 16 },
74
+ text: { type: 'string', maxLength: work.LIMITS.answer },
75
+ origin: { type: 'string', maxLength: 16 },
76
+ if_empty: { type: 'boolean' },
77
+ })) return;
78
+ next();
79
+ },
80
+ handle((req) => work.setAnswer(stage(req), q(req), req.body || {}, req.builder.id, deps,
81
+ { ifEmpty: req.body && req.body.if_empty === true })));
82
+ // Un-answer: back to empty, which is not the same as skipped.
83
+ router.delete('/founding/work/:stage/answers/:question', api.requireBuilder, owns,
84
+ handle((req) => work.clearAnswer(stage(req), q(req), req.builder.id, deps)));
85
+ };
@@ -38,6 +38,7 @@ const foundingBuilders = require('../founding-builders');
38
38
  const genesis = require('../genesis-home');
39
39
  const foundingBirth = require('../founding-birth');
40
40
  const howtoState = require('../howto-state');
41
+ const mountGenesisWorkRoutes = require('./genesis-work');
41
42
 
42
43
  // Register the `onboarding` kernel PORT. See the header + port.js for the full
43
44
  // consumer list and the graceful-degradation contract.
@@ -65,6 +66,8 @@ module.exports = function buildOnboardingRouter() {
65
66
  res.json({ builders });
66
67
  });
67
68
  mountGenesisRoutes(router);
69
+ // The founder's own work on each stage: three doors onto one record (task 1005116).
70
+ mountGenesisWorkRoutes(router);
68
71
  mountHowtoRoutes(router);
69
72
  return router;
70
73
  };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.25.14",
3
+ "version": "1.25.15",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.25.14",
9
+ "version": "1.25.15",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.25.14",
3
+ "version": "1.25.15",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -11806,5 +11806,11 @@
11806
11806
  "id": "1005279",
11807
11807
  "text": "The project guide now asks whether you are starting fresh or already have something, and for an existing project plans from your own description, without ever reading your files or code."
11808
11808
  }
11809
+ ],
11810
+ "1.25.15": [
11811
+ {
11812
+ "id": "1005116",
11813
+ "text": "Founders now write their own strategy. Each setup stage can be done three ways: answer guided questions, write it freely, or get help from your own AI — and you can switch at any time without losing anything. Your answers are"
11814
+ }
11809
11815
  ]
11810
11816
  }