@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.
- package/.bongos-core.json +101 -41
- package/.claude/skills/goal-create/SKILL.md +1 -0
- package/.claude/skills/planning-session/SKILL.md +5 -4
- package/bin/bongos.js +1 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +20 -0
- package/clients/bongos-client/index.cjs +20 -0
- package/clients/bongos-client/index.d.ts +24 -0
- package/clients/bongos-client/index.mjs +20 -0
- package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +61 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +544 -2
- package/docs/api-reference.md +12 -2
- package/docs/copy-inventory.md +120 -109
- package/docs/copy-registry.json +275 -165
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +2 -0
- package/docs/page-readings.json +107 -107
- package/docs/project-context.template.md +1 -0
- package/modules/hall-ui/public/dom-utils.js +5 -0
- package/modules/hall-ui/public/genesis-home.js +23 -8
- package/modules/hall-ui/public/setup-gate.js +1 -1
- package/modules/hall-ui/public/style.css +21 -1
- package/modules/lifecycle/kickoff-checklist.js +18 -4
- package/modules/lifecycle/kickoff-seed.js +27 -4
- package/modules/onboarding/genesis-home.js +11 -2
- package/modules/onboarding/genesis-work.js +387 -0
- package/modules/onboarding/migrations/onboarding_002_genesis_work.sql +76 -0
- package/modules/onboarding/routes/genesis-work.js +85 -0
- package/modules/onboarding/routes/onboarding.js +3 -0
- package/modules/public-landing/public/assets/guide-scene.css +245 -0
- package/modules/public-landing/public/assets/guide-scene.js +566 -0
- package/modules/public-landing/public/index.html +62 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +18 -0
- package/scripts/bongos/genesis.js +180 -0
- package/scripts/bongos/run-unit-tests.js +3 -0
- package/scripts/gds/genesis.js +7 -0
- package/src/module-api.js +1 -1
- package/tests/genesis_home.mjs +70 -2
- package/tests/genesis_work.mjs +304 -0
- package/tests/genesis_work_db.mjs +73 -0
- package/tests/genesis_work_routes.mjs +130 -0
- 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.
|
|
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.
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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).
|
|
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
|
-
//
|
|
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.
|
|
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
|
|
116
|
-
// genesis task on the board, grouped by the stage its source_ref names. The genesis
|
|
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
|
|
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) => (
|
|
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
|
+
};
|