@bongos/core 1.25.15 → 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 (66) hide show
  1. package/.bongos-core.json +128 -58
  2. package/.claude/skills/goal-create/SKILL.md +1 -0
  3. package/.claude/skills/planning-session/SKILL.md +5 -4
  4. package/bin/bongos.js +1 -0
  5. package/clients/bongos-client/README.md +1 -1
  6. package/clients/bongos-client/bongos-client.global.js +14 -0
  7. package/clients/bongos-client/index.cjs +14 -0
  8. package/clients/bongos-client/index.d.ts +22 -2
  9. package/clients/bongos-client/index.mjs +14 -0
  10. package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +8 -0
  11. package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +1 -0
  12. package/docs/api/openapi.json +415 -3
  13. package/docs/api-reference.md +13 -2
  14. package/docs/copy-inventory.md +120 -109
  15. package/docs/copy-registry.json +275 -165
  16. package/docs/module-api-changelog.md +4 -2
  17. package/docs/page-inventory.json +2 -0
  18. package/docs/page-readings.json +107 -107
  19. package/docs/project-context.template.md +1 -0
  20. package/modules/government/decision-rules.js +1 -0
  21. package/modules/hall-ui/public/dom-utils.js +5 -0
  22. package/modules/hall-ui/public/genesis-home.js +23 -8
  23. package/modules/hall-ui/public/setup-gate.js +1 -1
  24. package/modules/hall-ui/public/style.css +21 -1
  25. package/modules/lifecycle/first-read-db.js +152 -0
  26. package/modules/lifecycle/first-read.js +265 -0
  27. package/modules/lifecycle/kickoff-checklist.js +29 -6
  28. package/modules/lifecycle/kickoff-seed.js +27 -4
  29. package/modules/lifecycle/migrations/lifecycle_022_repo_maps.sql +44 -0
  30. package/modules/lifecycle/module.json +2 -1
  31. package/modules/lifecycle/routes/first-read.js +79 -0
  32. package/modules/onboarding/genesis-home.js +11 -2
  33. package/modules/onboarding/genesis-work.js +28 -5
  34. package/modules/provisioning/pollers/app-liveness.js +23 -1
  35. package/modules/provisioning/render-sandbox.js +113 -0
  36. package/modules/provisioning/render-standup.js +3 -1
  37. package/modules/provisioning/routes/provisioning.js +4 -1
  38. package/modules/provisioning/routes/render-standup.js +49 -1
  39. package/modules/public-landing/public/assets/guide-scene.css +245 -0
  40. package/modules/public-landing/public/assets/guide-scene.js +566 -0
  41. package/modules/public-landing/public/index.html +62 -2
  42. package/modules/public-landing/public/projects.html +101 -4
  43. package/package-lock.json +2 -2
  44. package/package.json +1 -1
  45. package/release-notes.json +23 -1
  46. package/scripts/bongos/first-read.js +116 -0
  47. package/scripts/bongos/genesis.js +2 -0
  48. package/scripts/bongos/government-walk.js +4 -4
  49. package/scripts/bongos/provision-render-sandbox.js +171 -0
  50. package/scripts/bongos/provision-render.js +37 -2
  51. package/scripts/bongos/provision-teardown.js +1 -1
  52. package/scripts/bongos/render-api.js +33 -3
  53. package/scripts/bongos/render-payload.js +39 -1
  54. package/scripts/gds/first-read.js +7 -0
  55. package/scripts/gds/provision-render-sandbox.js +6 -0
  56. package/src/module-api.js +1 -1
  57. package/tests/first_read.mjs +303 -0
  58. package/tests/genesis_home.mjs +70 -2
  59. package/tests/genesis_work.mjs +49 -0
  60. package/tests/government_act_alone_buildings.mjs +2 -0
  61. package/tests/landing_guide_scene.mjs +504 -0
  62. package/tests/provisioning_app_status.mjs +3 -2
  63. package/tests/provisioning_render_route.mjs +1 -1
  64. package/tests/provisioning_teardown_intent.mjs +50 -0
  65. package/tests/render_api.mjs +5 -2
  66. package/tests/render_sandbox.mjs +447 -0
@@ -94,6 +94,7 @@ const AREAS = Object.freeze([
94
94
  'POST /goals/reorder',
95
95
  'POST /work-categories',
96
96
  'PUT /versions/focus',
97
+ 'POST /first-read/maps/:id/accept', // task 1005280: accepting a repository map files goals
97
98
  ]) }),
98
99
  Object.freeze({ key: 'automation', name: 'Automation', gate: 'autonomy.fence.manage',
99
100
  today: 'whoever may switch unattended building on and off', acts: Object.freeze([
@@ -529,6 +529,10 @@
529
529
  } catch (err) { return null; }
530
530
  }
531
531
 
532
+ // The hall's one completion tick (24x24, stroked, currentColor): the setup flow's step
533
+ // marks and the Genesis checklist both draw it, so a done thing looks done the same way.
534
+ const CHECK_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>';
535
+
532
536
  const OTB = {
533
537
  escapeHtml, sleep, $, fmtNum, num, fmtInt, fmtCompact, fmtDate, rankLabel, toast, moduleOn, disciplines,
534
538
  plainMessage, toastKind, TOAST_MS, // task 1005086 — one toast, a duration per kind, no server address
@@ -542,6 +546,7 @@
542
546
  splitCriterion,
543
547
  categoryChip, // task 1003276 — shared by the Goals page, the Tasks board and the task page
544
548
  hubOrigin, // task 1003485 — the hub account page's origin, for Settings and the profile nudge
549
+ CHECK_SVG, // task 1005110 — the one tick, setup-gate.js and genesis-home.js
545
550
  };
546
551
  if (typeof window !== 'undefined') window.OTB = OTB;
547
552
  // Node: the hall libs that also load under require() (hall-kit.js, task-lib.js, …)
@@ -190,9 +190,10 @@
190
190
 
191
191
  // task 1005109: Copy prompt on each task still to do, so the founder pastes it into
192
192
  // Claude without leaving Genesis. The Tasks board's own button (work.js), and only that
193
- // one verb: the owner declined a Claim button here. A finished task offers nothing.
193
+ // one verb: the owner declined a Claim button here. Only an OPEN task offers it: a done
194
+ // one has nothing left, and a locked one cannot be claimed yet (task 1005110).
194
195
  function copyButtonHtml(t) {
195
- if (t.finished) return '';
196
+ if (t.state !== 'open') return '';
196
197
  return '<button class="task__copy" type="button" data-genesis-copy="' + esc(t.id) + '"'
197
198
  + ' title="Copy the task prompt to your clipboard" aria-label="Copy prompt for task ' + esc(t.id) + '">'
198
199
  + '<span class="task__copy-icon" aria-hidden="true">📋</span></button>';
@@ -212,16 +213,30 @@
212
213
  return lib.copyTaskPrompt(t, btn, toast);
213
214
  }
214
215
 
216
+ // task 1005110: one checklist row in one of three states, as the server decided them
217
+ // (shapeGenesis taskState): done (a filled circle with the tick), open (an empty
218
+ // circle, its status and Copy prompt) or locked (a dashed circle and what it waits on,
219
+ // no Copy prompt: the claim would be refused).
220
+ function taskRowHtml(t) {
221
+ var state = t.state === 'done' || t.state === 'locked' ? t.state : 'open';
222
+ var check = window.OTB.CHECK_SVG || '';
223
+ var mark = '<span class="genesis__mark" aria-hidden="true">' + (state === 'done' ? check : '') + '</span>';
224
+ var said = { done: 'Done', open: 'To do', locked: 'Locked' }[state];
225
+ var after = (t.waits_on || []).map(function (w) { return w.title; }).filter(Boolean);
226
+ var tail = state === 'locked'
227
+ ? '<span class="genesis__task-wait">' + esc(after.length ? 'after ' + after.join(', ') : 'after an earlier task') + '</span>'
228
+ : window.OTB.statusPill(t.status, { small: true, extraClass: 'genesis__task-st' }) + copyButtonHtml(t);
229
+ return '<li class="genesis__task is-' + state + '">' + mark
230
+ + '<span class="sr-only">' + said + ': </span>'
231
+ + '<a class="genesis__task-t" href="/task/' + encodeURIComponent(t.id) + '">' + esc(t.title) + '</a>'
232
+ + tail + '</li>';
233
+ }
234
+
215
235
  function nowHtml(g, viewerId) {
216
236
  var s = g.stages.find(function (x) { return x.state === 'now'; });
217
237
  if (!s) return '';
218
238
  var tasks = s.tasks.length
219
- ? '<ul class="genesis__tasks">' + s.tasks.map(function (t) {
220
- return '<li class="genesis__task' + (t.finished ? ' is-finished' : '') + '">'
221
- + '<a class="genesis__task-t" href="/task/' + encodeURIComponent(t.id) + '">' + esc(t.title) + '</a>'
222
- + window.OTB.statusPill(t.status, { small: true, extraClass: 'genesis__task-st' })
223
- + copyButtonHtml(t) + '</li>';
224
- }).join('') + '</ul>'
239
+ ? '<ul class="genesis__tasks">' + s.tasks.map(taskRowHtml).join('') + '</ul>'
225
240
  : '<p class="genesis__note">No tasks for this stage yet.</p>';
226
241
  return '<section class="genesis__now" aria-labelledby="genesis-now-h">'
227
242
  + '<h3 id="genesis-now-h" class="genesis__now-h">This stage’s work · ' + esc(s.label) + '</h3>'
@@ -20,7 +20,7 @@
20
20
 
21
21
  function esc(s) { return window.OTB.escapeHtml(s); }
22
22
 
23
- var CHECK = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>';
23
+ var CHECK = window.OTB.CHECK_SVG; // the hall's one tick (dom-utils.js), shared with the Genesis checklist
24
24
 
25
25
  function stepDetail(step) {
26
26
  if (step.id === 'cli_setup') {
@@ -1054,7 +1054,27 @@ html[data-theme="dark"] .gplate__light { display: none; }
1054
1054
  .genesis__stage.is-later { opacity: 0.6; }
1055
1055
  .genesis__now-h { margin: 0 0 4px; font-size: 15px; font-weight: 500; }
1056
1056
  .genesis__task-t { flex: 1; min-width: 0; padding: 3px 0; }
1057
- .genesis__task.is-finished .genesis__task-t { color: var(--ink-faint); text-decoration: line-through; }
1057
+ /* task 1005110: the stage's work reads as a checklist. Done is a filled circle with the
1058
+ hall's tick (the setup flow's mark), open an empty circle, locked a dashed one with
1059
+ what it waits on. Never struck through: a finished task was earned, not cancelled. */
1060
+ .genesis__mark {
1061
+ flex: none;
1062
+ display: inline-flex;
1063
+ align-items: center;
1064
+ justify-content: center;
1065
+ width: 24px;
1066
+ height: 24px;
1067
+ border-radius: var(--ds-radius-pill); /* round on purpose: the owner asked for a check filling a circle */
1068
+ border: 1.5px solid var(--rule);
1069
+ color: var(--ok);
1070
+ }
1071
+ .genesis__mark svg { width: 14px; height: 14px; }
1072
+ .genesis__task.is-done .genesis__mark { border-color: transparent; background: var(--ok-weak); }
1073
+ .genesis__task.is-done .genesis__task-t { color: var(--ink-soft); }
1074
+ .genesis__task.is-open .genesis__mark { border-color: var(--accent); }
1075
+ .genesis__task.is-locked .genesis__mark { border-style: dashed; }
1076
+ .genesis__task.is-locked .genesis__task-t { color: var(--ink-faint); }
1077
+ .genesis__task-wait { flex: none; max-width: 40%; font-size: var(--ds-text-xs); color: var(--ink-soft); text-align: right; }
1058
1078
  .genesis__alive { display: grid; justify-items: center; text-align: center; gap: 10px; padding: 12px 0; }
1059
1079
  .genesis__alive-plates { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }
1060
1080
  .genesis__alive-h { margin: 6px 0 0; font-size: 28px; font-weight: 400; }
@@ -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
@@ -144,7 +147,7 @@ function kickoffChecklist(spec = {}, { provisioned = false, firstSignIn = false
144
147
  const GENESIS_STEPS = Object.freeze({
145
148
  scope: [
146
149
  { title: 'Write the project scope: what we are making, for whom, and what is out of scope',
147
- description: 'Genesis stage 1 (scope). One page the whole team can read: the thing being made, who it is for, what done looks like for the first version, and what is deliberately left out. When it is written, Goals and the Roadmap wake.' },
150
+ 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
151
  ],
149
152
  strategize: [
150
153
  { title: 'Write out the strategy, or link the one you already have',
@@ -163,14 +166,30 @@ 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
- // genesisChecklist({ existing }) → { stageKey: [{ title, description, kind, discipline, ref }] }
180
+ // GENESIS_AFTER — "this step follows that one", by ref (task 1005110). A step listed
181
+ // here is filed with a real dependency edge on the step it names, so it cannot be
182
+ // claimed (DEPS_NOT_SHIPPED) and the genesis home draws it locked until that one
183
+ // ships. Only where the order is real: a stage with no entry here is all open at once.
184
+ // Today that is brownfield scope alone — the scope is written FROM what was gathered.
185
+ const GENESIS_AFTER = Object.freeze({
186
+ 'genesis:scope': 'genesis:scope:existing',
187
+ });
188
+
189
+ // genesisChecklist({ existing }) → { stageKey: [{ title, description, kind, discipline, ref, after }] }
172
190
  // in genesis order. `ref` is the stable per-task key the seeder dedups on, so filing
173
- // twice files nothing new. Pure — no I/O.
191
+ // twice files nothing new. `after` is the ref of the step this one waits for, set only
192
+ // when that step is in the same list (GENESIS_AFTER), else null. Pure — no I/O.
174
193
  function genesisChecklist({ existing = false } = {}) {
175
194
  const out = {};
176
195
  for (const [stage, steps] of Object.entries(GENESIS_STEPS)) {
@@ -181,6 +200,10 @@ function genesisChecklist({ existing = false } = {}) {
181
200
  ...t, kind: 'decision', discipline: 'artist', ref: t === GENESIS_EXISTING_FIRST ? 'genesis:scope:existing' : `genesis:${stage}`,
182
201
  }));
183
202
  }
203
+ for (const list of Object.values(out)) {
204
+ const refs = new Set(list.map((t) => t.ref));
205
+ for (const t of list) t.after = refs.has(GENESIS_AFTER[t.ref]) ? GENESIS_AFTER[t.ref] : null;
206
+ }
184
207
  return out;
185
208
  }
186
209