@bongos/core 1.25.16 → 1.25.17

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 (45) hide show
  1. package/.bongos-core.json +95 -40
  2. package/bin/bongos.js +1 -0
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +14 -0
  5. package/clients/bongos-client/index.cjs +14 -0
  6. package/clients/bongos-client/index.d.ts +22 -2
  7. package/clients/bongos-client/index.mjs +14 -0
  8. package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +8 -0
  9. package/docs/api/openapi.json +415 -3
  10. package/docs/api-reference.md +13 -2
  11. package/docs/module-api-changelog.md +2 -0
  12. package/docs/page-readings.json +1 -1
  13. package/modules/government/decision-rules.js +1 -0
  14. package/modules/lifecycle/first-read-db.js +152 -0
  15. package/modules/lifecycle/first-read.js +265 -0
  16. package/modules/lifecycle/kickoff-checklist.js +12 -3
  17. package/modules/lifecycle/migrations/lifecycle_022_repo_maps.sql +44 -0
  18. package/modules/lifecycle/module.json +2 -1
  19. package/modules/lifecycle/routes/first-read.js +79 -0
  20. package/modules/provisioning/pollers/app-liveness.js +23 -1
  21. package/modules/provisioning/render-sandbox.js +113 -0
  22. package/modules/provisioning/render-standup.js +3 -1
  23. package/modules/provisioning/routes/provisioning.js +4 -1
  24. package/modules/provisioning/routes/render-standup.js +49 -1
  25. package/modules/public-landing/public/projects.html +101 -4
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/release-notes.json +10 -0
  29. package/scripts/bongos/first-read.js +116 -0
  30. package/scripts/bongos/government-walk.js +4 -4
  31. package/scripts/bongos/provision-render-sandbox.js +171 -0
  32. package/scripts/bongos/provision-render.js +37 -2
  33. package/scripts/bongos/provision-teardown.js +1 -1
  34. package/scripts/bongos/render-api.js +33 -3
  35. package/scripts/bongos/render-payload.js +39 -1
  36. package/scripts/gds/first-read.js +7 -0
  37. package/scripts/gds/provision-render-sandbox.js +6 -0
  38. package/src/module-api.js +1 -1
  39. package/tests/first_read.mjs +303 -0
  40. package/tests/government_act_alone_buildings.mjs +2 -0
  41. package/tests/provisioning_app_status.mjs +3 -2
  42. package/tests/provisioning_render_route.mjs +1 -1
  43. package/tests/provisioning_teardown_intent.mjs +50 -0
  44. package/tests/render_api.mjs +5 -2
  45. package/tests/render_sandbox.mjs +447 -0
@@ -0,0 +1,152 @@
1
+ // modules/lifecycle/first-read-db.js — the store behind the first read-through (task
2
+ // 1005280): keep a submitted repository map, show it for review with what is already
3
+ // filed, and file the owner's accepted items as DRAFT goals and tasks.
4
+ //
5
+ // The rules live in first-read.js (pure); this file only runs them against Postgres.
6
+ // Two properties an edit must keep:
7
+ // - SUBMIT FILES NOTHING. A map is stored `pending`; only acceptMap creates goals or
8
+ // tasks, and only for the refs the owner named (or 'all').
9
+ // - A RE-RUN NEVER DUPLICATES. Every filed ref is a row in lifecycle_repo_map_filed
10
+ // (PRIMARY KEY), read under an advisory lock inside the accept transaction, so two
11
+ // accepts — or a second map naming the same item — file it once.
12
+ // Nothing here calls an AI: the map arrives already written by the owner's own session.
13
+
14
+ 'use strict';
15
+
16
+ const api = require('../../src/module-api');
17
+ const db = require('./db');
18
+ const fr = require('./first-read');
19
+ const { FIRST_VERSION_SQL } = require('./kickoff-seed');
20
+
21
+ const TASK_SOURCE = 'repo-map';
22
+ const ACCEPT_LOCK = "pg_advisory_xact_lock(hashtext('first-read/accept'))";
23
+
24
+ const poolOf = (deps) => deps.pool || api.pool;
25
+
26
+ // submitMap({ raw, builderId }) → { ok: true, map_id, items } | { ok: false, errors }
27
+ // Validates, then stores the NORMALISED map (unknown sections kept). Files nothing.
28
+ async function submitMap({ raw, builderId = null }, deps = {}) {
29
+ const v = fr.validateRepoMap(raw);
30
+ if (!v.ok) return v;
31
+ const { rows: [row] } = await poolOf(deps).query(
32
+ `INSERT INTO lifecycle_repo_maps (schema, map, submitted_by) VALUES ($1, $2::jsonb, $3) RETURNING id, status, submitted_at`,
33
+ [fr.SCHEMA, JSON.stringify(v.map), builderId],
34
+ );
35
+ return { ok: true, map_id: String(row.id), status: row.status, items: v.items };
36
+ }
37
+
38
+ // filedRefs(refs) → Map(ref → { kind, entity_id }) for the refs already filed.
39
+ async function filedRefs(refs, exec) {
40
+ if (!refs.length) return new Map();
41
+ const { rows } = await exec.query(
42
+ 'SELECT ref, kind, entity_id FROM lifecycle_repo_map_filed WHERE ref = ANY($1::text[])', [refs],
43
+ );
44
+ return new Map(rows.map((r) => [r.ref, { kind: r.kind, entity_id: String(r.entity_id) }]));
45
+ }
46
+
47
+ // reviewMap(id | 'latest') → { map_id, status, summary, items: [{ ...item, filed }], extra } | null
48
+ // What the owner reads before deciding: every item with whether it is already in Bongos.
49
+ async function reviewMap(id, deps = {}) {
50
+ const pool = poolOf(deps);
51
+ const { rows: [row] } = id === 'latest'
52
+ ? await pool.query('SELECT id, status, map, submitted_at FROM lifecycle_repo_maps ORDER BY submitted_at DESC, id DESC LIMIT 1')
53
+ : await pool.query('SELECT id, status, map, submitted_at FROM lifecycle_repo_maps WHERE id = $1', [id]);
54
+ if (!row) return null;
55
+ const v = fr.validateRepoMap(row.map);
56
+ const items = v.ok ? v.items : [];
57
+ const filed = await filedRefs(items.map((i) => i.ref), pool);
58
+ const extra = {};
59
+ for (const [k, val] of Object.entries(row.map || {})) if (!['schema', 'summary', 'goals', 'next'].includes(k)) extra[k] = val;
60
+ return {
61
+ map_id: String(row.id), status: row.status, submitted_at: row.submitted_at,
62
+ summary: (row.map && row.map.summary) || '',
63
+ items: items.map((i) => ({ ...i, filed: filed.has(i.ref) ? filed.get(i.ref) : null })),
64
+ extra,
65
+ };
66
+ }
67
+
68
+ // acceptMap({ mapId, select, builderId }) → { ok, filed: { goals, tasks }, skipped } | { ok: false, code, status, ... }
69
+ // ONE transaction: lock the map, lock the accept path, plan against what is already
70
+ // filed, create the drafts, remember their refs, mark the map accepted. A refusal files
71
+ // nothing. Goals are created open; tasks at `backlog` (drafts nobody can claim yet).
72
+ async function acceptMap({ mapId, select, builderId = null }, deps = {}) {
73
+ if (select !== 'all' && (!Array.isArray(select) || select.length === 0)) {
74
+ return { ok: false, code: 'nothing_selected', status: 400 };
75
+ }
76
+ const createGoal = deps.createGoal || ((row, d) => db.createGoal(row, d));
77
+ const createTask = deps.createTask || ((row, d) => db.createTask(row, d));
78
+ const client = await poolOf(deps).connect();
79
+ try {
80
+ await client.query('BEGIN');
81
+ const { rows: [row] } = await client.query('SELECT id, status, map FROM lifecycle_repo_maps WHERE id = $1 FOR UPDATE', [mapId]);
82
+ if (!row) { await client.query('ROLLBACK'); return { ok: false, code: 'not_found', status: 404 }; }
83
+ if (row.status === 'rejected') { await client.query('ROLLBACK'); return { ok: false, code: 'map_rejected', status: 409 }; }
84
+ await client.query(`SELECT ${ACCEPT_LOCK}`);
85
+ const v = fr.validateRepoMap(row.map);
86
+ if (!v.ok) { await client.query('ROLLBACK'); return { ok: false, code: 'invalid_map', status: 409, errors: v.errors }; }
87
+ const goalRefs = [...new Set([...v.items.map((i) => i.ref), ...v.items.map((i) => i.goal_ref).filter(Boolean)])];
88
+ const already = await filedRefs(goalRefs, client);
89
+ const plan = fr.planAcceptance({ items: v.items, mapId: String(row.id), select, filedRefs: new Set(already.keys()) });
90
+ if (plan.unknown.length) { await client.query('ROLLBACK'); return { ok: false, code: 'unknown_items', status: 400, unknown: plan.unknown }; }
91
+
92
+ let versionId = null;
93
+ if (plan.goals.length || plan.tasks.length) {
94
+ const { rows: versions } = await client.query(FIRST_VERSION_SQL);
95
+ if (!versions.length) { await client.query('ROLLBACK'); return { ok: false, code: 'no_version', status: 409 }; }
96
+ versionId = versions[0].id;
97
+ }
98
+ // The filed refs are written in ONE statement after the creates, not one per item.
99
+ const remembered = [];
100
+ const remember = (ref, kind, id) => { remembered.push([ref, kind, String(id)]); };
101
+ const goalIds = new Map([...already].filter(([, f]) => f.kind === 'goal').map(([ref, f]) => [ref, f.entity_id]));
102
+ const filed = { goals: [], tasks: [] };
103
+ for (const g of plan.goals) {
104
+ const goal = await createGoal({ versionId, title: g.title, description: g.description, createdBy: builderId }, { client });
105
+ goalIds.set(g.ref, String(goal.id));
106
+ remember(g.ref, 'goal', goal.id);
107
+ filed.goals.push({ ref: g.ref, id: String(goal.id), title: g.title });
108
+ }
109
+ for (const t of plan.tasks) {
110
+ const task = await createTask({
111
+ versionId, title: t.title, description: t.description, status: 'backlog', kind: t.kind,
112
+ discipline: 'artisan', source: TASK_SOURCE, sourceRef: t.ref, goalId: goalIds.get(t.goalRef), createdBy: builderId,
113
+ }, { client });
114
+ remember(t.ref, 'task', task.id);
115
+ filed.tasks.push({ ref: t.ref, id: String(task.id), title: t.title, goal_id: goalIds.get(t.goalRef) });
116
+ }
117
+ if (remembered.length) {
118
+ await client.query(
119
+ `INSERT INTO lifecycle_repo_map_filed (ref, kind, entity_id, map_id, filed_by)
120
+ SELECT r, k, e, $4, $5 FROM unnest($1::text[], $2::text[], $3::bigint[]) AS u(r, k, e)`,
121
+ [remembered.map((x) => x[0]), remembered.map((x) => x[1]), remembered.map((x) => x[2]), row.id, builderId],
122
+ );
123
+ }
124
+ await client.query(
125
+ `UPDATE lifecycle_repo_maps SET status = 'accepted', reviewed_by = $2, reviewed_at = now() WHERE id = $1`,
126
+ [row.id, builderId],
127
+ );
128
+ await client.query('COMMIT');
129
+ return { ok: true, map_id: String(row.id), filed, skipped: plan.skipped };
130
+ } catch (e) {
131
+ await client.query('ROLLBACK').catch(() => {});
132
+ throw e;
133
+ } finally {
134
+ client.release();
135
+ }
136
+ }
137
+
138
+ // rejectMap({ mapId, builderId }) → { ok } | { ok: false, code, status }. Files nothing.
139
+ // Only a pending map can be rejected: an accepted one already has drafts the owner said yes to.
140
+ async function rejectMap({ mapId, builderId = null }, deps = {}) {
141
+ const { rows } = await poolOf(deps).query(
142
+ `UPDATE lifecycle_repo_maps SET status = 'rejected', reviewed_by = $2, reviewed_at = now()
143
+ WHERE id = $1 AND status = 'pending' RETURNING id`,
144
+ [mapId, builderId],
145
+ );
146
+ if (rows.length) return { ok: true, map_id: String(rows[0].id) };
147
+ const { rows: [row] } = await poolOf(deps).query('SELECT status FROM lifecycle_repo_maps WHERE id = $1', [mapId]);
148
+ if (!row) return { ok: false, code: 'not_found', status: 404 };
149
+ return { ok: false, code: 'not_pending', status: 409, current: row.status };
150
+ }
151
+
152
+ module.exports = { TASK_SOURCE, submitMap, reviewMap, acceptMap, rejectMap };
@@ -0,0 +1,265 @@
1
+ // modules/lifecycle/first-read.js — the first run-through of a project that arrives with
2
+ // code: the prompt the owner's OWN AI session runs, the repository map it writes back,
3
+ // the map's validation, and the plan for filing the owner's accepted items (task 1005280,
4
+ // goal 1000106 "Project creation").
5
+ //
6
+ // THE RULE THIS SERVES. The platform never reads a person's repository on its own AI
7
+ // bill. So Bongos does not read it at all: it hands the owner a prompt, their own AI
8
+ // session reads the repository on their own subscription, on their own computer, and
9
+ // sends back a MAP — goals already in progress, the tasks under each, and what needs
10
+ // doing next given errors or mishaps found in the code, each with a short reason and
11
+ // where it was seen. Nothing here calls an AI, and the map's length caps keep it a
12
+ // summary, never a copy of the code.
13
+ //
14
+ // NOTHING IS FILED WITHOUT THE OWNER'S YES. A submitted map waits as `pending`. Only an
15
+ // explicit accept (all, or named items) files anything, and only as drafts: goals open
16
+ // with their tasks in `backlog`, so nothing is claimable until someone promotes it.
17
+ //
18
+ // RE-RUNS DO NOT DUPLICATE. Each item's `ref` is derived from its title (never its
19
+ // position), and every filed ref is remembered (lifecycle_repo_map_filed), so a second
20
+ // map that names the same goal or task again plans to file nothing for it.
21
+ //
22
+ // ROOM TO GROW. Unknown top-level keys are kept, not refused — task 1005281 adds a
23
+ // `recommended_modules` section the same way, with no change to this version's shape.
24
+ //
25
+ // Pure — no I/O. The DB half is first-read-db.js; the HTTP surface routes/first-read.js;
26
+ // the CLI scripts/bongos/first-read.js; the Genesis step that hands out the prompt is
27
+ // GENESIS_EXISTING_FIRST in kickoff-checklist.js.
28
+
29
+ 'use strict';
30
+
31
+ const SCHEMA = 'bongos.repo-map/1';
32
+ const MAP_FILE = 'bongos-repo-map.json';
33
+
34
+ // Caps. Generous for a real project, small enough that a map cannot carry the code.
35
+ const LIMITS = Object.freeze({
36
+ SUMMARY: 1200,
37
+ TITLE: 160,
38
+ WHY: 500,
39
+ SEEN_AT: 10,
40
+ SEEN_AT_CHARS: 300,
41
+ GOALS: 20,
42
+ TASKS_PER_GOAL: 30,
43
+ NEXT: 50,
44
+ BYTES: 60000,
45
+ });
46
+ const TASK_STATES = ['done', 'in_progress', 'not_started'];
47
+ const NEXT_KINDS = ['error', 'mishap', 'gap'];
48
+ // Top-level keys this version reads. Anything else is kept as-is for a later reader.
49
+ const KNOWN_KEYS = ['schema', 'summary', 'goals', 'next'];
50
+ const UNSAFE_KEYS = ['__proto__', 'constructor', 'prototype'];
51
+ // The goal a `next` item lands under when it names no goal of the map.
52
+ const NEXT_GOAL = Object.freeze({
53
+ ref: 'goal:fix-what-the-first-read-found',
54
+ title: 'Fix what the first read-through found',
55
+ why: 'Errors, mishaps and gaps the first read-through of the repository found, not tied to one goal.',
56
+ });
57
+
58
+ // slug(text) → a stable lower-case key: the same title always gives the same ref.
59
+ function slug(text) {
60
+ return String(text || '').toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '')
61
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
62
+ }
63
+
64
+ // The refs, in one place, so the plan and the review agree.
65
+ const goalRef = (title) => `goal:${slug(title)}`;
66
+ const taskRef = (goalTitle, title) => `task:${slug(goalTitle)}:${slug(title)}`;
67
+ const nextRef = (title) => `next:${slug(title)}`;
68
+
69
+ // ── the prompt ───────────────────────────────────────────────────────────────
70
+
71
+ // An example map in the defined shape — printed in the prompt so the session copies it.
72
+ function exampleMap() {
73
+ return {
74
+ schema: SCHEMA,
75
+ summary: 'A booking site for a dog-walking business. Sign-up and the booking calendar work; payments are half built.',
76
+ goals: [{
77
+ title: 'Take payments for bookings',
78
+ why: 'A payments folder and a half-finished checkout page exist, and the last ten commits are about it.',
79
+ seen_at: ['src/payments/', 'src/pages/checkout.js'],
80
+ tasks: [
81
+ { title: 'Finish the checkout page', status: 'in_progress', why: 'The page renders but the pay button does nothing.', seen_at: ['src/pages/checkout.js:88'] },
82
+ { title: 'Send a receipt by email', status: 'not_started', why: 'A TODO says receipts are still to do.', seen_at: ['src/payments/charge.js:12'] },
83
+ ],
84
+ }],
85
+ next: [
86
+ { title: 'Fix the failing booking test', kind: 'error', goal: '', why: 'The test command fails: the calendar test expects Monday-first weeks.', seen_at: ['tests/calendar.test.js:40'] },
87
+ ],
88
+ };
89
+ }
90
+
91
+ // firstReadPrompt({ cli }) → the full text the owner hands their own AI session. It is
92
+ // also the Genesis step's description, so the hall's copy-prompt carries all of it.
93
+ function firstReadPrompt({ cli = 'npx bongos' } = {}) {
94
+ return [
95
+ 'First read-through of a project that already exists. You are the owner\'s own AI session, working on their computer, on their subscription. Bongos itself never reads this repository.',
96
+ '',
97
+ '1. Read the repository here yourself: the README and any docs or notes, the to-do comments, the recent git history and open branches, and the code. If the project has a test or build command, run it once to see what fails. Do not upload, paste or send any source code anywhere.',
98
+ '2. Write what you found as a map, in a file named ' + MAP_FILE + ' at the top of the repository (do not commit it):',
99
+ ' - "goals": the goals already in progress, each with the tasks under it (status done, in_progress or not_started).',
100
+ ' - "next": what needs doing next because of errors or mishaps you found in the code (kind error, mishap or gap). Name the goal it belongs to in "goal", or leave it empty.',
101
+ ' - Every goal, task and next item has a short "why" (one or two sentences, in plain words) and "seen_at" (where you saw it: file paths, with a line number when it helps). Reasons and places only, never copied code.',
102
+ ' Use exactly this shape:',
103
+ JSON.stringify(exampleMap(), null, 2).split('\n').map((l) => ' ' + l).join('\n'),
104
+ `3. Check the file: ${cli} first-read check ${MAP_FILE} — fix anything it lists and check again.`,
105
+ `4. Send it to Bongos: ${cli} first-read submit ${MAP_FILE} — this files nothing yet; it prints the map's number and every item's ref.`,
106
+ '5. Walk the owner through the map in plain words, goal by goal. Ask which items to accept: all of them, some of them, or none.',
107
+ `6. Only after the owner says yes, accept exactly what they chose: ${cli} first-read accept <map number> --all, or --items <ref>,<ref>. If they want none of it: ${cli} first-read reject <map number>. Never accept on your own. Accepted items become draft goals and tasks; nothing can be worked on until someone promotes it.`,
108
+ 'Running the read-through again later is safe: anything already accepted is not filed twice.',
109
+ ].join('\n');
110
+ }
111
+
112
+ // ── validation ───────────────────────────────────────────────────────────────
113
+
114
+ function str(v) { return typeof v === 'string' ? v.trim() : ''; }
115
+
116
+ // Check a text field; push an error and return '' when it is missing or too long.
117
+ function text(errors, where, v, max, { required = true } = {}) {
118
+ const s = str(v);
119
+ if (v != null && typeof v !== 'string') { errors.push(`${where} must be text`); return ''; }
120
+ if (!s) { if (required) errors.push(`${where} is missing`); return ''; }
121
+ if (s.length > max) { errors.push(`${where} is longer than ${max} characters — keep it short`); return ''; }
122
+ return s;
123
+ }
124
+
125
+ function seenAt(errors, where, v) {
126
+ if (!Array.isArray(v) || v.length === 0) { errors.push(`${where}.seen_at needs at least one place (a file path)`); return []; }
127
+ if (v.length > LIMITS.SEEN_AT) { errors.push(`${where}.seen_at lists more than ${LIMITS.SEEN_AT} places`); return []; }
128
+ const out = [];
129
+ v.forEach((p, i) => { const s = text(errors, `${where}.seen_at[${i}]`, p, LIMITS.SEEN_AT_CHARS); if (s) out.push(s); });
130
+ return out;
131
+ }
132
+
133
+ // validateRepoMap(raw) → { ok: true, map, items } | { ok: false, errors: [string] }
134
+ // `map` is the normalised map (unknown top-level keys kept verbatim); `items` is the flat
135
+ // review list: [{ ref, type: 'goal'|'task'|'next', title, why, seen_at, status?, kind?,
136
+ // goal_ref }]. Every error names the field it is about, so a session can fix and retry.
137
+ function validateRepoMap(raw) {
138
+ const errors = [];
139
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, errors: ['the map must be a JSON object'] };
140
+ if (raw.schema !== SCHEMA) errors.push(`schema must be "${SCHEMA}"`);
141
+ // The server takes request bodies up to 64 KB; a map that size is a summary no longer.
142
+ if (JSON.stringify(raw).length > LIMITS.BYTES) errors.push(`the map is larger than ${LIMITS.BYTES} characters — shorten the reasons or list fewer items`);
143
+ const summary = text(errors, 'summary', raw.summary, LIMITS.SUMMARY);
144
+ const goalsIn = raw.goals == null ? [] : raw.goals;
145
+ const nextIn = raw.next == null ? [] : raw.next;
146
+ if (!Array.isArray(goalsIn)) errors.push('goals must be a list');
147
+ if (!Array.isArray(nextIn)) errors.push('next must be a list');
148
+ if (errors.length) return { ok: false, errors };
149
+ if (goalsIn.length > LIMITS.GOALS) errors.push(`goals lists more than ${LIMITS.GOALS}`);
150
+ if (nextIn.length > LIMITS.NEXT) errors.push(`next lists more than ${LIMITS.NEXT}`);
151
+ if (!goalsIn.length && !nextIn.length) errors.push('the map has no goals and nothing next — list at least one');
152
+
153
+ const items = [];
154
+ const seen = new Set();
155
+ const claimRef = (ref, where) => {
156
+ if (ref.endsWith(':') || ref.endsWith('-') || /:$|::/.test(ref)) { errors.push(`${where}.title needs letters or numbers`); return false; }
157
+ if (seen.has(ref)) { errors.push(`${where}.title repeats another item's title — make it distinct`); return false; }
158
+ seen.add(ref); return true;
159
+ };
160
+ const goals = [];
161
+ goalsIn.slice(0, LIMITS.GOALS).forEach((g, gi) => {
162
+ const w = `goals[${gi}]`;
163
+ if (!g || typeof g !== 'object' || Array.isArray(g)) { errors.push(`${w} must be an object`); return; }
164
+ const title = text(errors, `${w}.title`, g.title, LIMITS.TITLE);
165
+ const why = text(errors, `${w}.why`, g.why, LIMITS.WHY);
166
+ const where = seenAt(errors, w, g.seen_at);
167
+ const tasksIn = g.tasks == null ? [] : g.tasks;
168
+ if (!Array.isArray(tasksIn)) { errors.push(`${w}.tasks must be a list`); return; }
169
+ if (tasksIn.length > LIMITS.TASKS_PER_GOAL) errors.push(`${w}.tasks lists more than ${LIMITS.TASKS_PER_GOAL}`);
170
+ const ref = title ? goalRef(title) : null;
171
+ if (ref && claimRef(ref, w)) items.push({ ref, type: 'goal', title, why, seen_at: where, goal_ref: null });
172
+ const tasks = [];
173
+ tasksIn.slice(0, LIMITS.TASKS_PER_GOAL).forEach((t, ti) => {
174
+ const tw = `${w}.tasks[${ti}]`;
175
+ if (!t || typeof t !== 'object' || Array.isArray(t)) { errors.push(`${tw} must be an object`); return; }
176
+ const tt = text(errors, `${tw}.title`, t.title, LIMITS.TITLE);
177
+ const twhy = text(errors, `${tw}.why`, t.why, LIMITS.WHY);
178
+ const tat = seenAt(errors, tw, t.seen_at);
179
+ const status = t.status == null || t.status === '' ? 'not_started' : t.status;
180
+ if (!TASK_STATES.includes(status)) errors.push(`${tw}.status must be one of ${TASK_STATES.join(', ')}`);
181
+ const tref = title && tt ? taskRef(title, tt) : null;
182
+ if (tref && claimRef(tref, tw)) items.push({ ref: tref, type: 'task', title: tt, why: twhy, seen_at: tat, status, goal_ref: ref });
183
+ tasks.push({ title: tt, status, why: twhy, seen_at: tat });
184
+ });
185
+ goals.push({ title, why, seen_at: where, tasks });
186
+ });
187
+ const goalTitles = new Map(goals.map((g) => [slug(g.title), g.title]));
188
+ const next = [];
189
+ nextIn.slice(0, LIMITS.NEXT).forEach((n, ni) => {
190
+ const w = `next[${ni}]`;
191
+ if (!n || typeof n !== 'object' || Array.isArray(n)) { errors.push(`${w} must be an object`); return; }
192
+ const title = text(errors, `${w}.title`, n.title, LIMITS.TITLE);
193
+ const why = text(errors, `${w}.why`, n.why, LIMITS.WHY);
194
+ const where = seenAt(errors, w, n.seen_at);
195
+ const kind = n.kind == null || n.kind === '' ? 'error' : n.kind;
196
+ if (!NEXT_KINDS.includes(kind)) errors.push(`${w}.kind must be one of ${NEXT_KINDS.join(', ')}`);
197
+ const goal = text(errors, `${w}.goal`, n.goal, LIMITS.TITLE, { required: false });
198
+ if (goal && !goalTitles.has(slug(goal))) errors.push(`${w}.goal names "${goal}", which is not a goal in this map — use a goal's exact title or leave it empty`);
199
+ const ref = title ? nextRef(title) : null;
200
+ const gref = goal && goalTitles.has(slug(goal)) ? goalRef(goal) : NEXT_GOAL.ref;
201
+ if (ref && claimRef(ref, w)) items.push({ ref, type: 'next', title, why, seen_at: where, kind, goal_ref: gref });
202
+ next.push({ title, kind, goal, why, seen_at: where });
203
+ });
204
+ if (errors.length) return { ok: false, errors };
205
+
206
+ const map = { schema: SCHEMA, summary, goals, next };
207
+ // Forward-compatible: a later section (task 1005281's recommended_modules) rides along.
208
+ // Prototype-shaped keys are never copied (CWE-1321): a later section is plain data.
209
+ for (const [k, v] of Object.entries(raw)) if (!KNOWN_KEYS.includes(k) && !UNSAFE_KEYS.includes(k)) map[k] = v;
210
+ return { ok: true, map, items };
211
+ }
212
+
213
+ // ── acceptance ───────────────────────────────────────────────────────────────
214
+
215
+ function describe(item, mapId) {
216
+ const lines = [item.why, '', `Seen at: ${item.seen_at.join(', ')}`];
217
+ if (item.status && item.status !== 'not_started') lines.push(`Status when read: ${item.status.replace('_', ' ')}`);
218
+ lines.push('', `From the first read-through of the repository (map ${mapId}, ${item.ref}). A draft: promote it when it is right.`);
219
+ return lines.join('\n');
220
+ }
221
+
222
+ // planAcceptance({ items, mapId, select, filedRefs }) → { goals, tasks, skipped, unknown }
223
+ // select 'all' | [ref] — what the owner said yes to. Nothing else is planned.
224
+ // filedRefs Set of refs already filed by an earlier accept (any map) — never filed again.
225
+ // A chosen task or next item brings its goal along (a task needs a home); a goal that is
226
+ // already filed is reused by ref. A task whose status is `done` is history, not work:
227
+ // it is shown in review but never filed.
228
+ function planAcceptance({ items, mapId, select, filedRefs = new Set() }) {
229
+ const byRef = new Map(items.map((i) => [i.ref, i]));
230
+ const wanted = select === 'all' ? items.map((i) => i.ref) : [...new Set(Array.isArray(select) ? select : [])];
231
+ const unknown = wanted.filter((r) => !byRef.has(r));
232
+ const goals = new Map();
233
+ const tasks = [];
234
+ const skipped = [];
235
+ const needGoal = (ref) => {
236
+ if (!ref || filedRefs.has(ref) || goals.has(ref)) return;
237
+ if (ref === NEXT_GOAL.ref) {
238
+ goals.set(ref, { ref, title: NEXT_GOAL.title, description: NEXT_GOAL.why });
239
+ return;
240
+ }
241
+ const g = byRef.get(ref);
242
+ if (g) goals.set(ref, { ref, title: g.title, description: describe(g, mapId) });
243
+ };
244
+ for (const ref of wanted) {
245
+ const it = byRef.get(ref);
246
+ if (!it) continue;
247
+ if (filedRefs.has(ref)) { skipped.push({ ref, reason: 'already_filed' }); continue; }
248
+ if (it.type === 'goal') { needGoal(ref); continue; }
249
+ if (it.type === 'task' && it.status === 'done') { skipped.push({ ref, reason: 'already_done' }); continue; }
250
+ needGoal(it.goal_ref);
251
+ tasks.push({
252
+ ref,
253
+ goalRef: it.goal_ref,
254
+ title: it.title,
255
+ description: describe(it, mapId),
256
+ kind: it.type === 'next' && it.kind !== 'gap' ? 'bug' : 'feature',
257
+ });
258
+ }
259
+ return { goals: [...goals.values()], tasks, skipped, unknown };
260
+ }
261
+
262
+ module.exports = {
263
+ SCHEMA, MAP_FILE, LIMITS, NEXT_GOAL,
264
+ exampleMap, firstReadPrompt, validateRepoMap, planAcceptance,
265
+ };
@@ -9,7 +9,8 @@
9
9
  // filers read the same steps and the copy cannot drift. It lives with the lifecycle
10
10
  // module because tasks are its domain and the kernel doorway must not grow a domain
11
11
  // import (ADR 0091 §1); init is a core CLI and, like abandon.js or audit-deps.js,
12
- // may require module code directly. Pure — imports nothing.
12
+ // may require module code directly. Pure — its one import, first-read.js (the
13
+ // first read-through prompt, task 1005280), is pure too.
13
14
  //
14
15
  // Each step becomes a real, claimable task so it is tracked, never silently skipped
15
16
  // (grounded in the onboarding/deploy surfaces; the task 1204 mapping pass). Per-module
@@ -17,6 +18,8 @@
17
18
 
18
19
  'use strict';
19
20
 
21
+ const { firstReadPrompt } = require('./first-read');
22
+
20
23
  // The one kickoff step the provisioning module AUTOMATES (ADR 0111 §3): standing up
21
24
  // production hosting/TLS/DNS/managed-DB. Kept as a named builder so init can either
22
25
  // FILE it (no provisioning requested, or the request couldn't be enqueued) or OMIT
@@ -163,9 +166,15 @@ const GENESIS_STEPS = Object.freeze({
163
166
  description: 'Genesis stage 5 (legislation). Who has a say, and the rule a decision passes by. When it is done, Ranks wake, and the project can come alive.' },
164
167
  ],
165
168
  });
169
+ // task 1005280: the first read-through. The step's description IS the prompt the owner
170
+ // copies into their own AI session (the hall's copy-prompt hands over a task's
171
+ // description), so the repository is read on the owner's subscription, never the
172
+ // platform's, and comes back as a map the owner accepts or rejects (first-read.js).
166
173
  const GENESIS_EXISTING_FIRST = {
167
- title: 'Gather what already exists: link the documents, code and notes the project has',
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.',
174
+ title: 'Gather what already exists: have your own AI read the repository and map it',
175
+ description: 'Genesis stage 1 (scope), for a project that already exists. Before writing anything new, map what is already there, so the scope is written from it rather than beside it. ' +
176
+ 'Copy this task\'s prompt into your own AI session, opened in the project\'s folder on your computer; it reads the code there and sends back a map for you to accept.\n\n' +
177
+ firstReadPrompt(),
169
178
  };
170
179
 
171
180
  // GENESIS_AFTER — "this step follows that one", by ref (task 1005110). A step listed
@@ -0,0 +1,44 @@
1
+ -- lifecycle_022_repo_maps.sql — the first read-through's repository maps (task 1005280,
2
+ -- goal 1000106).
3
+ --
4
+ -- WHAT IT IS. A project that arrives with code gets a prompt for the owner's OWN AI
5
+ -- session; that session reads the repository on the owner's computer and subscription
6
+ -- and sends back a map (modules/lifecycle/first-read.js). A map waits here as `pending`
7
+ -- until the owner accepts it — whole or item by item — or rejects it. Nothing is filed
8
+ -- by the submit itself.
9
+ --
10
+ -- WHY A SECOND TABLE. Re-running the read-through must not file the same goal or task
11
+ -- twice, and goals carry no source_ref to dedup on. So every accepted item's stable
12
+ -- `ref` (derived from its title, never its position) is a row in
13
+ -- lifecycle_repo_map_filed, PRIMARY KEY on the ref: the next accept skips it, whichever
14
+ -- map names it again.
15
+ --
16
+ -- MODULE-OWNED and `lifecycle_`-prefixed (ADR 0083 §Decision #5).
17
+
18
+ CREATE TABLE IF NOT EXISTS lifecycle_repo_maps (
19
+ id bigserial PRIMARY KEY,
20
+ schema text NOT NULL,
21
+ map jsonb NOT NULL,
22
+ status text NOT NULL DEFAULT 'pending',
23
+ submitted_by bigint REFERENCES builders(id),
24
+ submitted_at timestamptz NOT NULL DEFAULT now(),
25
+ reviewed_by bigint REFERENCES builders(id),
26
+ reviewed_at timestamptz,
27
+
28
+ CONSTRAINT lifecycle_repo_maps_status_chk
29
+ CHECK (status IN ('pending', 'accepted', 'rejected'))
30
+ );
31
+
32
+ CREATE INDEX IF NOT EXISTS lifecycle_repo_maps_newest_idx
33
+ ON lifecycle_repo_maps (submitted_at DESC);
34
+
35
+ CREATE TABLE IF NOT EXISTS lifecycle_repo_map_filed (
36
+ ref text PRIMARY KEY,
37
+ kind text NOT NULL,
38
+ entity_id bigint NOT NULL,
39
+ map_id bigint NOT NULL REFERENCES lifecycle_repo_maps(id),
40
+ filed_by bigint REFERENCES builders(id),
41
+ filed_at timestamptz NOT NULL DEFAULT now(),
42
+
43
+ CONSTRAINT lifecycle_repo_map_filed_kind_chk CHECK (kind IN ('goal', 'task'))
44
+ );
@@ -38,7 +38,8 @@
38
38
  "home-city",
39
39
  "home-gallery",
40
40
  "task-votes",
41
- "notes"
41
+ "notes",
42
+ "first-read"
42
43
  ],
43
44
  "pollers": [
44
45
  "publish-reconciler"
@@ -0,0 +1,79 @@
1
+ // modules/lifecycle/routes/first-read.js — the HTTP surface of the first read-through
2
+ // (task 1005280, goal 1000106): hand out the prompt, take a repository map from the
3
+ // owner's own AI session, show it for review, and accept or reject it.
4
+ //
5
+ // No route here calls an AI, reads a repository, or files anything on submit. Filing is
6
+ // the accept route alone, gated like creating a goal (`goal.create` + the planning
7
+ // act-alone rule), because accepting a map IS creating goals and tasks. The rules are
8
+ // first-read.js; the store is first-read-db.js. Reaches core only through the doorway.
9
+
10
+ 'use strict';
11
+
12
+ const express = require('express');
13
+ const api = require('../../../src/module-api');
14
+ const fr = require('../first-read');
15
+ const store = require('../first-read-db');
16
+
17
+ const { asyncHandler, parseId, validateOrRespond } = api;
18
+
19
+ module.exports = function buildFirstReadRouter() {
20
+ const router = express.Router();
21
+
22
+ // rank: any-builder — the prompt and the shape are instructions, not project data.
23
+ router.get('/first-read/prompt', api.requireBuilder, (req, res) => {
24
+ res.json({ schema: fr.SCHEMA, file: fr.MAP_FILE, prompt: fr.firstReadPrompt(), example: fr.exampleMap(), limits: fr.LIMITS });
25
+ });
26
+
27
+ // rank: goal.create — a map is a planning proposal for this project. Stores it
28
+ // pending; files nothing. A malformed map is refused with every error named.
29
+ router.post('/first-read/maps', api.requireBuilder, api.requirePermission('goal.create'), asyncHandler('POST /first-read/maps', async (req, res) => {
30
+ // The envelope is checked here; the map inside it by first-read.js, which names every error.
31
+ if (validateOrRespond(req, res, { map: { required: true, type: 'object' } })) return undefined;
32
+ const out = await store.submitMap({ raw: req.body.map, builderId: req.builder.id });
33
+ if (!out.ok) return res.fail('invalid_map', 400, { errors: out.errors });
34
+ return res.status(201).json({
35
+ map_id: out.map_id, status: out.status,
36
+ items: out.items.map((i) => ({ ref: i.ref, type: i.type, title: i.title })),
37
+ next: 'Show the owner the map. File it only with their yes: POST /first-read/maps/' + out.map_id + '/accept { all: true } or { items: [ref] }.',
38
+ });
39
+ }, { errorCode: 'first_read_submit_failed' }));
40
+
41
+ // rank: goal.create — the review is for whoever may accept or reject it.
42
+ router.get('/first-read/maps/:id', api.requireBuilder, api.requirePermission('goal.create'), asyncHandler('GET /first-read/maps/:id', async (req, res) => {
43
+ const id = req.params.id === 'latest' ? 'latest' : parseId(req, res, { positiveInt: true });
44
+ if (id == null) return undefined;
45
+ const review = await store.reviewMap(id);
46
+ if (!review) return res.fail('not_found', 404);
47
+ return res.json(review);
48
+ }, { errorCode: 'first_read_review_failed' }));
49
+
50
+ // rank: goal.create + act-alone planning — the owner's yes. Files the chosen items as
51
+ // draft goals (open) and tasks (backlog); an item already filed is skipped.
52
+ router.post('/first-read/maps/:id/accept', api.requireBuilder, api.requirePermission('goal.create'), api.requireActAlone('planning'),
53
+ asyncHandler('POST /first-read/maps/:id/accept', async (req, res) => {
54
+ const mapId = parseId(req, res, { positiveInt: true });
55
+ if (mapId == null) return undefined;
56
+ if (validateOrRespond(req, res, {
57
+ all: { type: 'boolean' },
58
+ items: { type: 'array', itemsType: 'string', maxItems: 500 },
59
+ })) return undefined;
60
+ const { all, items } = req.body || {};
61
+ if ((all === true) === Array.isArray(items)) {
62
+ return res.fail('invalid_items', 400, { hint: 'send { all: true } or { items: [ref, …] } — the refs from the review' });
63
+ }
64
+ const out = await store.acceptMap({ mapId, select: all === true ? 'all' : items, builderId: req.builder.id });
65
+ if (!out.ok) { const { code, status, ...rest } = out; return res.fail(code, status, rest); }
66
+ return res.json(out);
67
+ }, { errorCode: 'first_read_accept_failed' }));
68
+
69
+ // rank: goal.create — the owner's no. Files nothing.
70
+ router.post('/first-read/maps/:id/reject', api.requireBuilder, api.requirePermission('goal.create'), asyncHandler('POST /first-read/maps/:id/reject', async (req, res) => {
71
+ const mapId = parseId(req, res, { positiveInt: true });
72
+ if (mapId == null) return undefined;
73
+ const out = await store.rejectMap({ mapId, builderId: req.builder.id });
74
+ if (!out.ok) { const { code, status, ...rest } = out; return res.fail(code, status, rest); }
75
+ return res.json(out);
76
+ }, { errorCode: 'first_read_reject_failed' }));
77
+
78
+ return router;
79
+ };
@@ -86,7 +86,29 @@ async function sweepOnce(deps = {}) {
86
86
  [r.id, h.state]);
87
87
  if (rowCount) { live++; log(`[app-liveness] ${r.slug}: its app answers at ${h.serviceUrl} — live`); }
88
88
  }
89
- return { checked: rows.length, live };
89
+ // The project's SANDBOX (ADR 0406, task 1005289) gets the same knock until its first
90
+ // answer, then never again: it sleeps on the free plan, and a knock would wake it.
91
+ const { rows: sbs } = await db.query(
92
+ `SELECT id, slug, app_host->'sandbox' AS sb FROM provisioning_instances
93
+ WHERE app_host->>'platform' = 'render' AND app_host->'sandbox'->>'state' = 'deploying'
94
+ AND status <> 'torn_down'
95
+ ORDER BY id LIMIT ${SWEEP_LIMIT}`);
96
+ // the same few-at-a-time knocking as the apps above, so one slow sandbox holds none up
97
+ const sbAnswers = [];
98
+ for (let i = 0; i < sbs.length; i += CONCURRENCY) {
99
+ const batch = sbs.slice(i, i + CONCURRENCY);
100
+ sbAnswers.push(...(await Promise.all(batch.map((r) => appAnswers((r.sb || {}).serviceUrl, { fetchImpl: deps.fetchImpl, timeoutMs: deps.timeoutMs })))));
101
+ }
102
+ for (const [i, r] of sbs.entries()) {
103
+ const sb = r.sb || {};
104
+ if (!sbAnswers[i]) continue;
105
+ const { rowCount } = await db.query(
106
+ `UPDATE provisioning_instances
107
+ SET app_host = jsonb_set(app_host, '{sandbox}', (app_host->'sandbox') || jsonb_build_object('state', 'live', 'liveAt', now()::text))
108
+ WHERE id = $1 AND app_host->'sandbox'->>'state' = 'deploying'`, [r.id]);
109
+ if (rowCount) { live++; log(`[app-liveness] ${r.slug}: its sandbox answers at ${sb.serviceUrl} — live`); }
110
+ }
111
+ return { checked: rows.length + sbs.length, live };
90
112
  }
91
113
 
92
114
  function start(deps = {}) {