@bongos/core 1.25.13 → 1.25.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +118 -53
- 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 +26 -2
- package/clients/bongos-client/index.mjs +20 -0
- package/docs/adr/0328-a-hosted-projects-authority-is-its-membership-row-not-its-owner-column.md +1 -1
- package/docs/adr/0407-a-project-grant-is-made-by-the-owner-not-witnessed-at-sign-in.md +124 -0
- package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +60 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +550 -2
- package/docs/api-reference.md +14 -4
- package/docs/copy-inventory.md +28 -34
- package/docs/copy-registry.json +41 -125
- package/docs/file-map.md +2 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +1 -0
- package/docs/page-readings.json +3 -3
- package/modules/front-door-guide/db.js +13 -5
- package/modules/front-door-guide/guide.js +8 -2
- package/modules/front-door-guide/migrations/front_door_guide_003_starting_point.sql +12 -0
- package/modules/front-door-guide/module.json +2 -2
- package/modules/front-door-guide/plan.js +15 -6
- package/modules/front-door-guide/routes/front-door-guide-plan.js +3 -2
- package/modules/front-door-guide/routes/front-door-guide.js +17 -4
- package/modules/front-door-guide/starting-point.js +55 -0
- package/modules/hall-ui/public/genesis-home.js +29 -2
- package/modules/hall-ui/public/goals-page.js +3 -29
- package/modules/hall-ui/public/index.html +2 -1
- package/modules/hall-ui/public/task-lib.js +45 -0
- package/modules/hall-ui/public/work.js +3 -47
- package/modules/lifecycle/kickoff-checklist.js +1 -1
- package/modules/lifecycle/kickoff-seed.js +9 -5
- package/modules/onboarding/genesis-home.js +1 -1
- package/modules/onboarding/genesis-work.js +364 -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/package-lock.json +306 -3
- package/package.json +4 -2
- package/release-notes.json +24 -0
- package/scripts/bongos/genesis.js +178 -0
- package/scripts/bongos/package-core.js +4 -2
- package/scripts/bongos/run-unit-tests.js +3 -0
- package/scripts/gds/genesis.js +7 -0
- package/src/bongos/module-scope-map.js +1 -0
- package/src/module-api.js +1 -1
- package/tests/cutover_codemod.mjs +1 -1
- package/tests/front_door_guide_db.mjs +6 -1
- package/tests/front_door_guide_existing.mjs +238 -0
- package/tests/genesis_home.mjs +80 -2
- package/tests/genesis_work.mjs +255 -0
- package/tests/genesis_work_db.mjs +73 -0
- package/tests/genesis_work_routes.mjs +130 -0
- package/tests/package_core.mjs +11 -7
- package/tests/pruned_lockfile_integrity.mjs +4 -2
- package/tests/status_page_no_dead_uptime.mjs +1 -1
- package/tests/task_lib.mjs +46 -0
|
@@ -641,54 +641,10 @@
|
|
|
641
641
|
async function copyPromptForTask(taskId, btn) {
|
|
642
642
|
const t = taskState.all.find((x) => String(x.id) === String(taskId));
|
|
643
643
|
if (!t) return;
|
|
644
|
-
|
|
645
|
-
const icon = btn.querySelector('.task__copy-icon');
|
|
646
|
-
const label = btn.querySelector('.task__copy-label');
|
|
647
|
-
try {
|
|
648
|
-
await navigator.clipboard.writeText(prompt);
|
|
649
|
-
btn.dataset.copied = '1';
|
|
650
|
-
if (icon) icon.textContent = '✓';
|
|
651
|
-
if (label) label.textContent = 'Copied';
|
|
652
|
-
setTimeout(() => {
|
|
653
|
-
btn.dataset.copied = '0';
|
|
654
|
-
if (icon) icon.textContent = '📋';
|
|
655
|
-
if (label) label.textContent = 'Copy prompt';
|
|
656
|
-
}, 1600);
|
|
657
|
-
toast(`Prompt for #${taskId} copied to your clipboard.`);
|
|
658
|
-
} catch (err) {
|
|
659
|
-
try {
|
|
660
|
-
const ta = document.createElement('textarea');
|
|
661
|
-
ta.value = prompt;
|
|
662
|
-
ta.style.position = 'fixed';
|
|
663
|
-
ta.style.opacity = '0';
|
|
664
|
-
document.body.appendChild(ta);
|
|
665
|
-
ta.select();
|
|
666
|
-
document.execCommand('copy');
|
|
667
|
-
ta.remove();
|
|
668
|
-
toast(`Prompt for #${taskId} copied.`);
|
|
669
|
-
} catch (_) {
|
|
670
|
-
toast('Could not copy to the clipboard. Try again.');
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
async function copyText(text) {
|
|
675
|
-
try { await navigator.clipboard.writeText(text); return true; }
|
|
676
|
-
catch (_) {
|
|
677
|
-
try {
|
|
678
|
-
const ta = document.createElement('textarea');
|
|
679
|
-
ta.value = text;
|
|
680
|
-
ta.style.position = 'fixed';
|
|
681
|
-
ta.style.opacity = '0';
|
|
682
|
-
document.body.appendChild(ta);
|
|
683
|
-
ta.select();
|
|
684
|
-
const ok = document.execCommand('copy');
|
|
685
|
-
ta.remove();
|
|
686
|
-
return ok;
|
|
687
|
-
} catch (_e) {
|
|
688
|
-
return false;
|
|
689
|
-
}
|
|
690
|
-
}
|
|
644
|
+
await window.OTBTaskLib.copyTaskPrompt(t, btn, toast);
|
|
691
645
|
}
|
|
646
|
+
// The hall's one clipboard write (task-lib.js), kept under this name for the ctx below.
|
|
647
|
+
const copyText = (text) => window.OTBTaskLib.copyText(text);
|
|
692
648
|
|
|
693
649
|
// ---- bulk selection (task 1759) -------------------------------------------
|
|
694
650
|
|
|
@@ -148,7 +148,7 @@ const GENESIS_STEPS = Object.freeze({
|
|
|
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',
|
|
@@ -112,20 +112,24 @@ async function seedGenesisStages({ builderId = null, existing = false } = {}, de
|
|
|
112
112
|
return { seeded, versionId };
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
// genesisStageTasks(deps) → { stageKey: [{ id, title, status }] } for every
|
|
116
|
-
// task on the board, grouped by the stage its source_ref names. The genesis home
|
|
117
|
-
// them, and a stage close hands their ids to the Board Room's finished-tasks gate.
|
|
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.
|
|
118
|
+
// The description rides along so each row's Copy prompt carries the task's own words
|
|
119
|
+
// (task 1005109); the read stays bounded, it is only the few seeded genesis tasks.
|
|
118
120
|
async function genesisStageTasks(deps = {}) {
|
|
119
121
|
const pool = deps.pool || api.pool;
|
|
120
122
|
const { rows } = await pool.query(
|
|
121
|
-
`SELECT id, title, status, source_ref FROM tasks WHERE source = $1 ORDER BY id ASC`,
|
|
123
|
+
`SELECT id, title, status, description, source_ref FROM tasks WHERE source = $1 ORDER BY id ASC`,
|
|
122
124
|
[GENESIS_SOURCE],
|
|
123
125
|
);
|
|
124
126
|
const out = {};
|
|
125
127
|
for (const r of rows) {
|
|
126
128
|
const stage = String(r.source_ref || '').split(':')[1];
|
|
127
129
|
if (!stage) continue;
|
|
128
|
-
(out[stage] = out[stage] || []).push({
|
|
130
|
+
(out[stage] = out[stage] || []).push({
|
|
131
|
+
id: String(r.id), title: r.title, status: r.status, description: r.description || '',
|
|
132
|
+
});
|
|
129
133
|
}
|
|
130
134
|
return out;
|
|
131
135
|
}
|
|
@@ -146,7 +146,7 @@ const FINISHED = Object.freeze(['shipped', 'abandoned']); // the Board Room's ow
|
|
|
146
146
|
// shapeGenesis({ mode, plan, tasks, board, stageAt }) — PURE. The genesis home's view.
|
|
147
147
|
// mode foundingModeFor's shape (non-null)
|
|
148
148
|
// plan the saved plan or null (deciding)
|
|
149
|
-
// tasks { stage: [{ id, title, status }] }
|
|
149
|
+
// tasks { stage: [{ id, title, status, description }] }
|
|
150
150
|
// board { stage: genesisStageStatus row } or {}
|
|
151
151
|
// stageAt { stage: ISO } — when each stage began, for its due date
|
|
152
152
|
function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
|
|
@@ -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
|
};
|