@bongos/core 1.25.14 → 1.25.16

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 (46) hide show
  1. package/.bongos-core.json +101 -41
  2. package/.claude/skills/goal-create/SKILL.md +1 -0
  3. package/.claude/skills/planning-session/SKILL.md +5 -4
  4. package/bin/bongos.js +1 -0
  5. package/clients/bongos-client/README.md +1 -1
  6. package/clients/bongos-client/bongos-client.global.js +20 -0
  7. package/clients/bongos-client/index.cjs +20 -0
  8. package/clients/bongos-client/index.d.ts +24 -0
  9. package/clients/bongos-client/index.mjs +20 -0
  10. package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +61 -0
  11. package/docs/adr/README.md +1 -0
  12. package/docs/api/openapi.json +544 -2
  13. package/docs/api-reference.md +12 -2
  14. package/docs/copy-inventory.md +120 -109
  15. package/docs/copy-registry.json +275 -165
  16. package/docs/file-map.md +1 -0
  17. package/docs/module-api-changelog.md +2 -0
  18. package/docs/page-inventory.json +2 -0
  19. package/docs/page-readings.json +107 -107
  20. package/docs/project-context.template.md +1 -0
  21. package/modules/hall-ui/public/dom-utils.js +5 -0
  22. package/modules/hall-ui/public/genesis-home.js +23 -8
  23. package/modules/hall-ui/public/setup-gate.js +1 -1
  24. package/modules/hall-ui/public/style.css +21 -1
  25. package/modules/lifecycle/kickoff-checklist.js +18 -4
  26. package/modules/lifecycle/kickoff-seed.js +27 -4
  27. package/modules/onboarding/genesis-home.js +11 -2
  28. package/modules/onboarding/genesis-work.js +387 -0
  29. package/modules/onboarding/migrations/onboarding_002_genesis_work.sql +76 -0
  30. package/modules/onboarding/routes/genesis-work.js +85 -0
  31. package/modules/onboarding/routes/onboarding.js +3 -0
  32. package/modules/public-landing/public/assets/guide-scene.css +245 -0
  33. package/modules/public-landing/public/assets/guide-scene.js +566 -0
  34. package/modules/public-landing/public/index.html +62 -2
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/release-notes.json +18 -0
  38. package/scripts/bongos/genesis.js +180 -0
  39. package/scripts/bongos/run-unit-tests.js +3 -0
  40. package/scripts/gds/genesis.js +7 -0
  41. package/src/module-api.js +1 -1
  42. package/tests/genesis_home.mjs +70 -2
  43. package/tests/genesis_work.mjs +304 -0
  44. package/tests/genesis_work_db.mjs +73 -0
  45. package/tests/genesis_work_routes.mjs +130 -0
  46. package/tests/landing_guide_scene.mjs +504 -0
@@ -529,6 +529,10 @@
529
529
  } catch (err) { return null; }
530
530
  }
531
531
 
532
+ // The hall's one completion tick (24x24, stroked, currentColor): the setup flow's step
533
+ // marks and the Genesis checklist both draw it, so a done thing looks done the same way.
534
+ const CHECK_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>';
535
+
532
536
  const OTB = {
533
537
  escapeHtml, sleep, $, fmtNum, num, fmtInt, fmtCompact, fmtDate, rankLabel, toast, moduleOn, disciplines,
534
538
  plainMessage, toastKind, TOAST_MS, // task 1005086 — one toast, a duration per kind, no server address
@@ -542,6 +546,7 @@
542
546
  splitCriterion,
543
547
  categoryChip, // task 1003276 — shared by the Goals page, the Tasks board and the task page
544
548
  hubOrigin, // task 1003485 — the hub account page's origin, for Settings and the profile nudge
549
+ CHECK_SVG, // task 1005110 — the one tick, setup-gate.js and genesis-home.js
545
550
  };
546
551
  if (typeof window !== 'undefined') window.OTB = OTB;
547
552
  // Node: the hall libs that also load under require() (hall-kit.js, task-lib.js, …)
@@ -190,9 +190,10 @@
190
190
 
191
191
  // task 1005109: Copy prompt on each task still to do, so the founder pastes it into
192
192
  // Claude without leaving Genesis. The Tasks board's own button (work.js), and only that
193
- // one verb: the owner declined a Claim button here. A finished task offers nothing.
193
+ // one verb: the owner declined a Claim button here. Only an OPEN task offers it: a done
194
+ // one has nothing left, and a locked one cannot be claimed yet (task 1005110).
194
195
  function copyButtonHtml(t) {
195
- if (t.finished) return '';
196
+ if (t.state !== 'open') return '';
196
197
  return '<button class="task__copy" type="button" data-genesis-copy="' + esc(t.id) + '"'
197
198
  + ' title="Copy the task prompt to your clipboard" aria-label="Copy prompt for task ' + esc(t.id) + '">'
198
199
  + '<span class="task__copy-icon" aria-hidden="true">📋</span></button>';
@@ -212,16 +213,30 @@
212
213
  return lib.copyTaskPrompt(t, btn, toast);
213
214
  }
214
215
 
216
+ // task 1005110: one checklist row in one of three states, as the server decided them
217
+ // (shapeGenesis taskState): done (a filled circle with the tick), open (an empty
218
+ // circle, its status and Copy prompt) or locked (a dashed circle and what it waits on,
219
+ // no Copy prompt: the claim would be refused).
220
+ function taskRowHtml(t) {
221
+ var state = t.state === 'done' || t.state === 'locked' ? t.state : 'open';
222
+ var check = window.OTB.CHECK_SVG || '';
223
+ var mark = '<span class="genesis__mark" aria-hidden="true">' + (state === 'done' ? check : '') + '</span>';
224
+ var said = { done: 'Done', open: 'To do', locked: 'Locked' }[state];
225
+ var after = (t.waits_on || []).map(function (w) { return w.title; }).filter(Boolean);
226
+ var tail = state === 'locked'
227
+ ? '<span class="genesis__task-wait">' + esc(after.length ? 'after ' + after.join(', ') : 'after an earlier task') + '</span>'
228
+ : window.OTB.statusPill(t.status, { small: true, extraClass: 'genesis__task-st' }) + copyButtonHtml(t);
229
+ return '<li class="genesis__task is-' + state + '">' + mark
230
+ + '<span class="sr-only">' + said + ': </span>'
231
+ + '<a class="genesis__task-t" href="/task/' + encodeURIComponent(t.id) + '">' + esc(t.title) + '</a>'
232
+ + tail + '</li>';
233
+ }
234
+
215
235
  function nowHtml(g, viewerId) {
216
236
  var s = g.stages.find(function (x) { return x.state === 'now'; });
217
237
  if (!s) return '';
218
238
  var tasks = s.tasks.length
219
- ? '<ul class="genesis__tasks">' + s.tasks.map(function (t) {
220
- return '<li class="genesis__task' + (t.finished ? ' is-finished' : '') + '">'
221
- + '<a class="genesis__task-t" href="/task/' + encodeURIComponent(t.id) + '">' + esc(t.title) + '</a>'
222
- + window.OTB.statusPill(t.status, { small: true, extraClass: 'genesis__task-st' })
223
- + copyButtonHtml(t) + '</li>';
224
- }).join('') + '</ul>'
239
+ ? '<ul class="genesis__tasks">' + s.tasks.map(taskRowHtml).join('') + '</ul>'
225
240
  : '<p class="genesis__note">No tasks for this stage yet.</p>';
226
241
  return '<section class="genesis__now" aria-labelledby="genesis-now-h">'
227
242
  + '<h3 id="genesis-now-h" class="genesis__now-h">This stage’s work · ' + esc(s.label) + '</h3>'
@@ -20,7 +20,7 @@
20
20
 
21
21
  function esc(s) { return window.OTB.escapeHtml(s); }
22
22
 
23
- var CHECK = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>';
23
+ var CHECK = window.OTB.CHECK_SVG; // the hall's one tick (dom-utils.js), shared with the Genesis checklist
24
24
 
25
25
  function stepDetail(step) {
26
26
  if (step.id === 'cli_setup') {
@@ -1054,7 +1054,27 @@ html[data-theme="dark"] .gplate__light { display: none; }
1054
1054
  .genesis__stage.is-later { opacity: 0.6; }
1055
1055
  .genesis__now-h { margin: 0 0 4px; font-size: 15px; font-weight: 500; }
1056
1056
  .genesis__task-t { flex: 1; min-width: 0; padding: 3px 0; }
1057
- .genesis__task.is-finished .genesis__task-t { color: var(--ink-faint); text-decoration: line-through; }
1057
+ /* task 1005110: the stage's work reads as a checklist. Done is a filled circle with the
1058
+ hall's tick (the setup flow's mark), open an empty circle, locked a dashed one with
1059
+ what it waits on. Never struck through: a finished task was earned, not cancelled. */
1060
+ .genesis__mark {
1061
+ flex: none;
1062
+ display: inline-flex;
1063
+ align-items: center;
1064
+ justify-content: center;
1065
+ width: 24px;
1066
+ height: 24px;
1067
+ border-radius: var(--ds-radius-pill); /* round on purpose: the owner asked for a check filling a circle */
1068
+ border: 1.5px solid var(--rule);
1069
+ color: var(--ok);
1070
+ }
1071
+ .genesis__mark svg { width: 14px; height: 14px; }
1072
+ .genesis__task.is-done .genesis__mark { border-color: transparent; background: var(--ok-weak); }
1073
+ .genesis__task.is-done .genesis__task-t { color: var(--ink-soft); }
1074
+ .genesis__task.is-open .genesis__mark { border-color: var(--accent); }
1075
+ .genesis__task.is-locked .genesis__mark { border-style: dashed; }
1076
+ .genesis__task.is-locked .genesis__task-t { color: var(--ink-faint); }
1077
+ .genesis__task-wait { flex: none; max-width: 40%; font-size: var(--ds-text-xs); color: var(--ink-soft); text-align: right; }
1058
1078
  .genesis__alive { display: grid; justify-items: center; text-align: center; gap: 10px; padding: 12px 0; }
1059
1079
  .genesis__alive-plates { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }
1060
1080
  .genesis__alive-h { margin: 6px 0 0; font-size: 28px; font-weight: 400; }
@@ -144,11 +144,11 @@ function kickoffChecklist(spec = {}, { provisioned = false, firstSignIn = false
144
144
  const GENESIS_STEPS = Object.freeze({
145
145
  scope: [
146
146
  { title: 'Write the project scope: what we are making, for whom, and what is out of scope',
147
- description: 'Genesis stage 1 (scope). One page the whole team can read: the thing being made, who it is for, what done looks like for the first version, and what is deliberately left out. When it is written, Goals and the Roadmap wake.' },
147
+ description: 'Genesis stage 1 (scope). The project interview: what you are creating and why, who it is for and who you want building it with you, what success looks like in two years, and what everyone else in this area is getting wrong. Every future goal and task is planned from your answers, and "not sure yet" is a fine answer you can come back to. You are the author and you choose how to work: be guided through the questions, write it yourself with the questions beside you, or have your own AI session walk you through them (it asks and pushes back; you decide) — switch any time, nothing is lost. The one-page scope is then drafted from your answers. Run `npx bongos genesis show scope` to see the questions and your answers, or `npx bongos genesis assist scope` for the brief to give your AI session. When it is written, Goals and the Roadmap wake.' },
148
148
  ],
149
149
  strategize: [
150
150
  { title: 'Write out the strategy, or link the one you already have',
151
- description: 'Genesis stage 2 (strategize). How the project gets from here to its first version: the order of the big pieces, the risks, and what gets tried first. A strategy that already exists can be linked instead. When it is done, Tasks wake.' },
151
+ description: 'Genesis stage 2 (strategize). How the project gets from here to its first version: the order of the big pieces, the risks, and what gets tried first. You are the author, and you choose how to work: be guided through the questions, write it yourself on a blank page, or have your own AI session help (it asks the same questions and pushes back, and you decide) — switch any time, nothing is lost. A strategy that already exists can be linked instead. Run `npx bongos genesis show strategize` to see the questions and your answers, or `npx bongos genesis assist strategize` for the brief to give your AI session. When it is done, Tasks wake.' },
152
152
  ],
153
153
  charter: [
154
154
  { title: 'Choose the starting government and write the bylaws',
@@ -168,9 +168,19 @@ const GENESIS_EXISTING_FIRST = {
168
168
  description: 'Genesis stage 1 (scope), for a project that already exists. Before writing anything new, link what is already there, so the scope is written from it rather than beside it.',
169
169
  };
170
170
 
171
- // genesisChecklist({ existing }) → { stageKey: [{ title, description, kind, discipline, ref }] }
171
+ // GENESIS_AFTER — "this step follows that one", by ref (task 1005110). A step listed
172
+ // here is filed with a real dependency edge on the step it names, so it cannot be
173
+ // claimed (DEPS_NOT_SHIPPED) and the genesis home draws it locked until that one
174
+ // ships. Only where the order is real: a stage with no entry here is all open at once.
175
+ // Today that is brownfield scope alone — the scope is written FROM what was gathered.
176
+ const GENESIS_AFTER = Object.freeze({
177
+ 'genesis:scope': 'genesis:scope:existing',
178
+ });
179
+
180
+ // genesisChecklist({ existing }) → { stageKey: [{ title, description, kind, discipline, ref, after }] }
172
181
  // in genesis order. `ref` is the stable per-task key the seeder dedups on, so filing
173
- // twice files nothing new. Pure — no I/O.
182
+ // twice files nothing new. `after` is the ref of the step this one waits for, set only
183
+ // when that step is in the same list (GENESIS_AFTER), else null. Pure — no I/O.
174
184
  function genesisChecklist({ existing = false } = {}) {
175
185
  const out = {};
176
186
  for (const [stage, steps] of Object.entries(GENESIS_STEPS)) {
@@ -181,6 +191,10 @@ function genesisChecklist({ existing = false } = {}) {
181
191
  ...t, kind: 'decision', discipline: 'artist', ref: t === GENESIS_EXISTING_FIRST ? 'genesis:scope:existing' : `genesis:${stage}`,
182
192
  }));
183
193
  }
194
+ for (const list of Object.values(out)) {
195
+ const refs = new Set(list.map((t) => t.ref));
196
+ for (const t of list) t.after = refs.has(GENESIS_AFTER[t.ref]) ? GENESIS_AFTER[t.ref] : null;
197
+ }
184
198
  return out;
185
199
  }
186
200
 
@@ -85,6 +85,7 @@ async function seedGenesisStages({ builderId = null, existing = false } = {}, de
85
85
  const pool = deps.pool || api.pool;
86
86
  const insert = deps.createTask || ((row) => db.createTask(row));
87
87
  const find = deps.findTaskBySourceRef || ((q) => db.findTaskBySourceRef(q));
88
+ const addDep = deps.addTaskDependency || ((id, dep) => db.addTaskDependency(id, dep));
88
89
  const { rows: versions } = await pool.query(FIRST_VERSION_SQL);
89
90
  if (!versions.length) return { seeded: 0, skipped: 'no_version' };
90
91
  const versionId = versions[0].id;
@@ -109,18 +110,39 @@ async function seedGenesisStages({ builderId = null, existing = false } = {}, de
109
110
  seeded += 1;
110
111
  }
111
112
  }
113
+ // task 1005110: the order genesisChecklist declares becomes a real edge, so the claim
114
+ // gate refuses the later step (DEPS_NOT_SHIPPED) until the earlier one ships. Written
115
+ // for steps already filed too (ON CONFLICT DO NOTHING), so a re-run repairs a missing edge.
116
+ for (const steps of Object.values(genesisChecklist({ existing }))) {
117
+ for (const t of steps) {
118
+ if (!t.after) continue;
119
+ const [me, before] = await Promise.all([
120
+ find({ source: GENESIS_SOURCE, sourceRef: t.ref }),
121
+ find({ source: GENESIS_SOURCE, sourceRef: t.after }),
122
+ ]);
123
+ if (me && before) await addDep(me.id, before.id);
124
+ }
125
+ }
112
126
  return { seeded, versionId };
113
127
  }
114
128
 
115
- // genesisStageTasks(deps) → { stageKey: [{ id, title, status, description }] } for every
116
- // genesis task on the board, grouped by the stage its source_ref names. The genesis home
117
- // lists them, and a stage close hands their ids to the Board Room's finished-tasks gate.
129
+ // genesisStageTasks(deps) → { stageKey: [{ id, title, status, description, waits_on }] } for
130
+ // every genesis task on the board, grouped by the stage its source_ref names. The genesis
131
+ // home lists them, and a stage close hands their ids to the Board Room's finished-tasks gate.
132
+ // `waits_on` is [{ id, title }] of the tasks it depends on that have not shipped: the claim
133
+ // gate's own rule (unsatisfiedDeps, abandoned still blocks), so a row drawn locked is one
134
+ // POST /claims would refuse (task 1005110).
118
135
  // The description rides along so each row's Copy prompt carries the task's own words
119
136
  // (task 1005109); the read stays bounded, it is only the few seeded genesis tasks.
120
137
  async function genesisStageTasks(deps = {}) {
121
138
  const pool = deps.pool || api.pool;
122
139
  const { rows } = await pool.query(
123
- `SELECT id, title, status, description, source_ref FROM tasks WHERE source = $1 ORDER BY id ASC`,
140
+ `SELECT t.id, t.title, t.status, t.description, t.source_ref,
141
+ COALESCE((SELECT json_agg(json_build_object('id', x.id, 'title', x.title) ORDER BY x.id)
142
+ FROM dependencies d JOIN tasks x ON x.id = d.to_id
143
+ WHERE d.from_kind = 'task' AND d.from_id = t.id
144
+ AND d.to_kind = 'task' AND x.status <> 'shipped'), '[]'::json) AS waits_on
145
+ FROM tasks t WHERE t.source = $1 ORDER BY t.id ASC`,
124
146
  [GENESIS_SOURCE],
125
147
  );
126
148
  const out = {};
@@ -129,6 +151,7 @@ async function genesisStageTasks(deps = {}) {
129
151
  if (!stage) continue;
130
152
  (out[stage] = out[stage] || []).push({
131
153
  id: String(r.id), title: r.title, status: r.status, description: r.description || '',
154
+ waits_on: (Array.isArray(r.waits_on) ? r.waits_on : []).map((w) => ({ id: String(w.id), title: w.title })),
132
155
  });
133
156
  }
134
157
  return out;
@@ -143,10 +143,19 @@ function readPlanValue(raw) {
143
143
 
144
144
  const FINISHED = Object.freeze(['shipped', 'abandoned']); // the Board Room's own gate, mirrored for display
145
145
 
146
+ // taskState(t) — one stage task as the checklist draws it (task 1005110).
147
+ function taskState(t) {
148
+ const finished = FINISHED.includes(t.status);
149
+ const waits = finished ? [] : (Array.isArray(t.waits_on) ? t.waits_on : []);
150
+ return { ...t, finished, waits_on: waits, state: finished ? 'done' : waits.length ? 'locked' : 'open' };
151
+ }
152
+
146
153
  // shapeGenesis({ mode, plan, tasks, board, stageAt }) — PURE. The genesis home's view.
147
154
  // mode foundingModeFor's shape (non-null)
148
155
  // plan the saved plan or null (deciding)
149
- // tasks { stage: [{ id, title, status, description }] }
156
+ // tasks { stage: [{ id, title, status, description, waits_on }] }
157
+ // Each task comes back with `state`: 'done' (finished), 'locked' (waits on an unshipped
158
+ // task, named in `waits_on`) or 'open' — decided here, never re-derived in a browser.
150
159
  // board { stage: genesisStageStatus row } or {}
151
160
  // stageAt { stage: ISO } — when each stage began, for its due date
152
161
  function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
@@ -154,7 +163,7 @@ function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
154
163
  const stages = PROPER.map((key) => {
155
164
  const info = STAGE_INFO[key];
156
165
  const idx = fm.STAGES.indexOf(key);
157
- const list = (tasks[key] || []).map((t) => ({ ...t, finished: FINISHED.includes(t.status) }));
166
+ const list = (tasks[key] || []).map((t) => taskState(t));
158
167
  const b = board[key] || null;
159
168
  const allFinished = list.length > 0 && list.every((t) => t.finished);
160
169
  const onBoard = !!(b && (b.state === 'sitting' || b.state === 'passed'));
@@ -0,0 +1,387 @@
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 (task 1005116); 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. `group` (optional) is a
63
+ // heading the guided door shows above the questions that share it.
64
+ //
65
+ // SCOPE IS THE PROJECT INTERVIEW (task 1005114): the questions every later goal and task
66
+ // should be planned inside — what this is for, who it is for, and the bet — rather than
67
+ // V1's edges, which is what came back thin before. The owner's starter set, as written;
68
+ // the list is "not final" and the owner invited more. These are prose questions; the
69
+ // closed-vocabulary ones (team shape, geography, entity, rewards) are the hub's project
70
+ // detail and are not repeated here.
71
+ const QUESTIONS = Object.freeze({
72
+ scope: Object.freeze([
73
+ Object.freeze({ id: 'what_creating', group: 'What and why', ask: 'In a few sentences, what are you creating with this project?',
74
+ hint: 'Say it the way you would to a friend. What exists when this is done that does not exist now?' }),
75
+ Object.freeze({ id: 'goal_impact', group: 'What and why', ask: 'What is your goal with it? What impact do you want it to have?',
76
+ hint: 'Why this is worth doing at all, and for whom it changes something.' }),
77
+ Object.freeze({ id: 'success_two_years', group: 'What and why', ask: 'In two years, what does success look like?',
78
+ hint: 'Something you could point at: who is using it, what they do with it, what it has become.' }),
79
+ Object.freeze({ id: 'reach_who', group: 'Who', ask: 'Who do you want to reach with this project?',
80
+ hint: 'The people it is ultimately for, not only the first ones. Be as specific as you can.' }),
81
+ Object.freeze({ id: 'work_with_who', group: 'Who', ask: 'Who do you want working on it with you?',
82
+ hint: 'The kind of people you hope join: their skills, how they work, what they care about.' }),
83
+ Object.freeze({ id: 'others_wrong', group: 'The bet', ask: 'What is everyone else in this area getting wrong?',
84
+ hint: 'The thing you believe that others in this space do not. This is the bet the project makes.' }),
85
+ ]),
86
+ strategize: Object.freeze([
87
+ Object.freeze({ id: 'big_pieces', ask: 'What are the big pieces, and in what order do they get built?',
88
+ hint: 'Three to six pieces is plenty. Say why that order: what has to exist before what.' }),
89
+ Object.freeze({ id: 'risks', ask: 'What are the biggest risks, the things that could sink this?',
90
+ hint: 'Name what you are least sure of: a technical unknown, whether anyone wants it, time, money.' }),
91
+ Object.freeze({ id: 'try_first', ask: 'What gets tried first, and why that?',
92
+ hint: 'Usually the cheapest test of the biggest risk.' }),
93
+ Object.freeze({ id: 'change_course', ask: 'What would make you change course?',
94
+ hint: 'The signal that would tell you this plan is wrong, so you notice it when it comes.' }),
95
+ ]),
96
+ });
97
+
98
+ const isStage = (s) => STAGES.includes(s);
99
+ const questionsFor = (stage) => QUESTIONS[stage] || [];
100
+ const questionFor = (stage, id) => questionsFor(stage).find((q) => q.id === id) || null;
101
+ const text = (v) => (v == null ? '' : String(v)).trim();
102
+
103
+ // ── pure: checking a write ────────────────────────────────────────────────────
104
+
105
+ // checkAnswer(stage, questionId, body) → { ok, value } or { ok:false, reason }.
106
+ // body: { state, text?, origin? }. `answered` needs words; `not_sure` and `skipped`
107
+ // may carry a short note. The origin defaults to `founder`.
108
+ function checkAnswer(stage, questionId, body = {}) {
109
+ if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
110
+ if (!questionFor(stage, questionId)) return { ok: false, reason: 'unknown_question' };
111
+ const b = body && typeof body === 'object' ? body : {};
112
+ const state = b.state == null ? 'answered' : String(b.state);
113
+ if (!STATES.includes(state)) return { ok: false, reason: 'bad_state' };
114
+ const origin = b.origin == null ? 'founder' : String(b.origin);
115
+ if (!ORIGINS.includes(origin)) return { ok: false, reason: 'bad_origin' };
116
+ const words = text(b.text);
117
+ if (words.length > LIMITS.answer) return { ok: false, reason: 'answer_too_long' };
118
+ if (state === 'answered' && !words) return { ok: false, reason: 'empty_answer' };
119
+ return { ok: true, value: { state, answer_md: words || null, origin } };
120
+ }
121
+
122
+ // checkLink(url) → { ok, value } — an http(s) address, or '' / null to remove it.
123
+ function checkLink(url) {
124
+ const u = text(url);
125
+ if (!u) return { ok: true, value: null };
126
+ if (u.length > LIMITS.link) return { ok: false, reason: 'link_too_long' };
127
+ if (!/^https?:\/\/[^\s]+$/i.test(u)) return { ok: false, reason: 'bad_link' };
128
+ return { ok: true, value: u };
129
+ }
130
+
131
+ // ── pure: the view ────────────────────────────────────────────────────────────
132
+
133
+ const iso = (v) => (v ? new Date(v).toISOString() : null);
134
+
135
+ // shapeStage(stage, work, answers) → one stage's record as every door sees it.
136
+ // work the stage row ({ door, page_md, link_url, updated_at }) or null
137
+ // answers the stage's answer rows
138
+ function shapeStage(stage, work, answers = []) {
139
+ const byId = new Map(answers.filter((a) => a.stage === stage).map((a) => [a.question_id, a]));
140
+ const questions = questionsFor(stage).map((q) => {
141
+ const a = byId.get(q.id);
142
+ return {
143
+ id: q.id, ask: q.ask, hint: q.hint, group: q.group || null,
144
+ answer: a ? {
145
+ state: a.state, text: a.answer_md || null, origin: a.origin,
146
+ // A carried-over answer is shown as such until the founder makes it theirs.
147
+ carried_over: a.origin === 'carried_over',
148
+ updated_at: iso(a.updated_at),
149
+ } : null,
150
+ };
151
+ });
152
+ const count = (st) => questions.filter((q) => q.answer && q.answer.state === st).length;
153
+ return {
154
+ stage,
155
+ adopted: questionsFor(stage).length > 0,
156
+ door: (work && DOORS.includes(work.door)) ? work.door : DEFAULT_DOOR,
157
+ doors: DOORS,
158
+ questions,
159
+ page: work && work.page_md ? work.page_md : null,
160
+ link: work && work.link_url ? work.link_url : null,
161
+ counts: {
162
+ total: questions.length,
163
+ answered: count('answered'),
164
+ not_sure: count('not_sure'),
165
+ skipped: count('skipped'),
166
+ empty: questions.filter((q) => !q.answer).length,
167
+ },
168
+ updated_at: iso(work && work.updated_at),
169
+ };
170
+ }
171
+
172
+ // shapeAll({ work, answers }) → every stage, in genesis order.
173
+ function shapeAll({ work = [], answers = [] } = {}) {
174
+ const byStage = new Map(work.map((w) => [w.stage, w]));
175
+ return STAGES.map((s) => shapeStage(s, byStage.get(s) || null, answers));
176
+ }
177
+
178
+ const STATE_WORDS = { not_sure: 'Not sure yet', skipped: 'Skipped by the founder' };
179
+ 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)' };
180
+
181
+ // contextMarkdown(stages) → THE READ PATH for an AI session planning a goal or task:
182
+ // what the founder has said about the project, stage by stage, as plain markdown. Only
183
+ // stages with something in them appear; an empty project returns an empty string so a
184
+ // caller can say nothing rather than print an empty heading.
185
+ function contextMarkdown(stages) {
186
+ const out = [];
187
+ for (const s of stages || []) {
188
+ const said = s.questions.filter((q) => q.answer);
189
+ if (!said.length && !s.page && !s.link) continue;
190
+ out.push(`## ${s.stage[0].toUpperCase()}${s.stage.slice(1)}`);
191
+ for (const q of said) {
192
+ const a = q.answer;
193
+ const body = a.state === 'answered' ? a.text : `${STATE_WORDS[a.state]}${a.text ? ` — ${a.text}` : ''}`;
194
+ out.push(`- **${q.ask}**${ORIGIN_WORDS[a.origin] || ''}\n ${String(body).replace(/\n/g, '\n ')}`);
195
+ }
196
+ if (s.page) out.push('', '**In the founder\'s own words:**', '', s.page);
197
+ if (s.link) out.push('', `**The founder's existing document:** ${s.link}`);
198
+ out.push('');
199
+ }
200
+ if (!out.length) return '';
201
+ return ['# What the founder has said about this project', '',
202
+ '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.', '',
203
+ ...out].join('\n').trimEnd() + '\n';
204
+ }
205
+
206
+ // assistPrompt(stage) → the brief the founder hands their own AI session for the
207
+ // assisted door. It works the stage's own questions (never a shape of its own),
208
+ // argues rather than authors, and records only what the founder decides.
209
+ function assistPrompt(stage) {
210
+ if (!isStage(stage)) return null;
211
+ const qs = questionsFor(stage);
212
+ const lines = [
213
+ `Help me work through the ${stage} stage of my project's genesis. I am the author; you are my assistant.`,
214
+ '',
215
+ 'How to help:',
216
+ '- Work these questions, one at a time, in this order. Do not invent a different structure.',
217
+ '- Ask what I am assuming. Push back when an answer is vague or I seem too sure.',
218
+ '- Offer two or three options when I am stuck, never one confident answer.',
219
+ '- "Not sure yet" is a fine answer. Record it as that rather than filling the gap for me.',
220
+ '- Record an answer only after I say it is mine, in my words (you may tidy the wording; read it back first).',
221
+ '- Once I have answered, you may offer a draft page built only from my answers. I edit it; save it only when I say it is mine.',
222
+ '',
223
+ ];
224
+ if (qs.length) {
225
+ lines.push('The questions (the id in brackets is what you record the answer under):');
226
+ qs.forEach((q, i) => lines.push(`${i + 1}. [${q.id}] ${q.ask} (${q.hint})`));
227
+ lines.push('');
228
+ }
229
+ lines.push('To see what I have already said: npx bongos genesis show ' + stage);
230
+ if (qs.length) {
231
+ lines.push(`To record an answer I accepted: npx bongos genesis answer ${stage} <question-id> "<my words>" --origin assisted`);
232
+ lines.push(`To record "not sure yet": npx bongos genesis answer ${stage} <question-id> --not-sure ["why"]`);
233
+ }
234
+ lines.push(`To save a page I wrote: npx bongos genesis page ${stage} --file <path>`);
235
+ return lines.join('\n');
236
+ }
237
+
238
+ // ── the store (Postgres) ──────────────────────────────────────────────────────
239
+
240
+ function pgStore(pool) {
241
+ const db = () => pool || require('../../src/module-api').pool;
242
+ return {
243
+ async loadAll() {
244
+ const [w, a] = await Promise.all([
245
+ db().query('SELECT stage, door, page_md, link_url, updated_at FROM onboarding_genesis_work'),
246
+ db().query('SELECT stage, question_id, state, answer_md, origin, updated_at FROM onboarding_genesis_answers'),
247
+ ]);
248
+ return { work: w.rows, answers: a.rows };
249
+ },
250
+ // patch: any of { door, page_md, link_url }; an absent key keeps what is stored.
251
+ async upsertWork(stage, patch, by) {
252
+ const has = (k) => Object.prototype.hasOwnProperty.call(patch, k);
253
+ await db().query(
254
+ `INSERT INTO onboarding_genesis_work (stage, door, page_md, link_url, updated_by)
255
+ VALUES ($1, COALESCE($2, '${DEFAULT_DOOR}'), $4, $6, $8)
256
+ ON CONFLICT (stage) DO UPDATE SET
257
+ door = CASE WHEN $3 THEN EXCLUDED.door ELSE onboarding_genesis_work.door END,
258
+ page_md = CASE WHEN $5 THEN EXCLUDED.page_md ELSE onboarding_genesis_work.page_md END,
259
+ link_url = CASE WHEN $7 THEN EXCLUDED.link_url ELSE onboarding_genesis_work.link_url END,
260
+ updated_by = EXCLUDED.updated_by, updated_at = now()`,
261
+ [stage, has('door') ? patch.door : null, has('door'), has('page_md') ? patch.page_md : null,
262
+ has('page_md'), has('link_url') ? patch.link_url : null, has('link_url'), by == null ? null : by],
263
+ );
264
+ },
265
+ // One statement: the answer and its history row land together, and a write that
266
+ // changes nothing writes neither. ifEmpty never touches an answer already there.
267
+ async writeAnswer(stage, questionId, v, by, { ifEmpty = false } = {}) {
268
+ const { rows } = await db().query(
269
+ `WITH up AS (
270
+ INSERT INTO onboarding_genesis_answers (stage, question_id, state, answer_md, origin, updated_by)
271
+ VALUES ($1, $2, $3, $4, $5, $6)
272
+ ON CONFLICT (stage, question_id) DO ${ifEmpty ? 'NOTHING' : `UPDATE SET
273
+ state = EXCLUDED.state, answer_md = EXCLUDED.answer_md, origin = EXCLUDED.origin,
274
+ updated_by = EXCLUDED.updated_by, updated_at = now()
275
+ WHERE (onboarding_genesis_answers.state, onboarding_genesis_answers.answer_md, onboarding_genesis_answers.origin)
276
+ IS DISTINCT FROM (EXCLUDED.state, EXCLUDED.answer_md, EXCLUDED.origin)`}
277
+ RETURNING stage, question_id, state, answer_md, origin, updated_by)
278
+ INSERT INTO onboarding_genesis_answer_history (stage, question_id, state, answer_md, origin, changed_by)
279
+ SELECT stage, question_id, state, answer_md, origin, updated_by FROM up
280
+ RETURNING id`,
281
+ [stage, questionId, v.state, v.answer_md, v.origin, by == null ? null : by],
282
+ );
283
+ return { changed: rows.length > 0 };
284
+ },
285
+ async clearAnswer(stage, questionId, by) {
286
+ const { rows } = await db().query(
287
+ `WITH del AS (
288
+ DELETE FROM onboarding_genesis_answers WHERE stage = $1 AND question_id = $2
289
+ RETURNING stage, question_id)
290
+ INSERT INTO onboarding_genesis_answer_history (stage, question_id, state, answer_md, origin, changed_by)
291
+ SELECT stage, question_id, NULL, NULL, NULL, $3 FROM del
292
+ RETURNING id`,
293
+ [stage, questionId, by == null ? null : by],
294
+ );
295
+ return { cleared: rows.length > 0 };
296
+ },
297
+ async history(stage, questionId) {
298
+ const { rows } = await db().query(
299
+ `SELECT state, answer_md, origin, changed_by, changed_at FROM onboarding_genesis_answer_history
300
+ WHERE stage = $1 AND question_id = $2 ORDER BY changed_at DESC, id DESC LIMIT 50`,
301
+ [stage, questionId],
302
+ );
303
+ return rows;
304
+ },
305
+ };
306
+ }
307
+
308
+ // ── the operations the routes call ────────────────────────────────────────────
309
+
310
+ const storeOf = (deps = {}) => deps.store || pgStore(deps.pool);
311
+
312
+ async function readAll(deps = {}) {
313
+ return shapeAll(await storeOf(deps).loadAll());
314
+ }
315
+
316
+ async function readStage(stage, deps = {}) {
317
+ if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
318
+ const all = await readAll(deps);
319
+ return { ok: true, work: all.find((s) => s.stage === stage) };
320
+ }
321
+
322
+ async function setDoor(stage, door, by, deps = {}) {
323
+ if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
324
+ if (!DOORS.includes(door)) return { ok: false, reason: 'bad_door' };
325
+ await storeOf(deps).upsertWork(stage, { door }, by);
326
+ return readStage(stage, deps);
327
+ }
328
+
329
+ async function setPage(stage, page, by, deps = {}) {
330
+ if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
331
+ const words = text(page);
332
+ if (words.length > LIMITS.page) return { ok: false, reason: 'page_too_long' };
333
+ await storeOf(deps).upsertWork(stage, { page_md: words || null }, by);
334
+ return readStage(stage, deps);
335
+ }
336
+
337
+ async function setLink(stage, url, by, deps = {}) {
338
+ if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
339
+ const c = checkLink(url);
340
+ if (!c.ok) return c;
341
+ await storeOf(deps).upsertWork(stage, { link_url: c.value }, by);
342
+ return readStage(stage, deps);
343
+ }
344
+
345
+ // setAnswer — store or revise one answer. opts.ifEmpty (for carrying answers over,
346
+ // task 1005274) writes only where the founder has said nothing yet.
347
+ async function setAnswer(stage, questionId, body, by, deps = {}, opts = {}) {
348
+ const c = checkAnswer(stage, questionId, body);
349
+ if (!c.ok) return c;
350
+ const { changed } = await storeOf(deps).writeAnswer(stage, questionId, c.value, by, opts);
351
+ const r = await readStage(stage, deps);
352
+ return { ...r, changed };
353
+ }
354
+
355
+ // clearAnswer — un-answer: the question goes back to empty (not to "skipped").
356
+ async function clearAnswer(stage, questionId, by, deps = {}) {
357
+ if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
358
+ if (!questionFor(stage, questionId)) return { ok: false, reason: 'unknown_question' };
359
+ const { cleared } = await storeOf(deps).clearAnswer(stage, questionId, by);
360
+ const r = await readStage(stage, deps);
361
+ return { ...r, cleared };
362
+ }
363
+
364
+ async function answerHistory(stage, questionId, deps = {}) {
365
+ if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
366
+ if (!questionFor(stage, questionId)) return { ok: false, reason: 'unknown_question' };
367
+ const rows = await storeOf(deps).history(stage, questionId);
368
+ return {
369
+ ok: true,
370
+ history: rows.map((r) => ({
371
+ state: r.state || 'cleared', text: r.answer_md || null, origin: r.origin || null,
372
+ changed_by: r.changed_by == null ? null : String(r.changed_by), changed_at: iso(r.changed_at),
373
+ })),
374
+ };
375
+ }
376
+
377
+ async function readContext(deps = {}) {
378
+ const stages = await readAll(deps);
379
+ return { markdown: contextMarkdown(stages), stages };
380
+ }
381
+
382
+ // Only what a caller uses is exported; the checks and the view shaping stay private
383
+ // and are exercised through the operations (tests/genesis_work.mjs).
384
+ module.exports = {
385
+ STAGES, LIMITS, QUESTIONS, assistPrompt,
386
+ pgStore, readAll, readStage, setDoor, setPage, setLink, setAnswer, clearAnswer, answerHistory, readContext,
387
+ };