@bongos/core 1.20.82 → 1.21.1

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 (76) hide show
  1. package/.bongos-core.json +154 -64
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +7 -0
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0051-full-session-transcript-corpus.md +3 -1
  8. package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +3 -3
  9. package/docs/adr/README.md +1 -1
  10. package/docs/api/openapi.json +153 -4
  11. package/docs/api-reference.md +5 -3
  12. package/docs/architecture.md +3 -0
  13. package/docs/copy-inventory.md +10 -10
  14. package/docs/copy-registry.json +12 -12
  15. package/docs/design/project-startup-direction.md +63 -0
  16. package/docs/file-map.md +2 -0
  17. package/docs/module-api-changelog.md +8 -0
  18. package/docs/page-inventory.json +8 -3
  19. package/docs/page-readings.json +382 -373
  20. package/docs/recipes/private-npm-distribution.md +1 -1
  21. package/migrations/core_269_session_transcripts.sql +35 -0
  22. package/modules/hall-ui/public/genesis-home.js +17 -3
  23. package/modules/hall-ui/public/index-genesis-demo.states.json +13 -0
  24. package/modules/lifecycle/done-when.js +82 -42
  25. package/modules/lifecycle/workflow-dispatch.js +21 -11
  26. package/modules/npm-release/release.js +10 -1
  27. package/modules/onboarding/founding-birth.js +116 -0
  28. package/modules/onboarding/genesis-home.js +72 -5
  29. package/modules/onboarding/port.js +9 -2
  30. package/modules/onboarding/routes/onboarding.js +8 -1
  31. package/modules/provisioning/birth.js +64 -0
  32. package/modules/provisioning/dns-resolves.js +54 -0
  33. package/modules/provisioning/migrations/provisioning_036_dns_resolved.sql +30 -0
  34. package/modules/provisioning/pollers/liveness-sweep.js +5 -0
  35. package/modules/provisioning/provisioning.js +7 -6
  36. package/modules/provisioning/routes/provisioning.js +6 -2
  37. package/modules/provisioning/tests/liveness.mjs +48 -0
  38. package/modules/public-landing/public/projects-dns-pending.states.json +85 -0
  39. package/modules/public-landing/public/projects.html +40 -15
  40. package/modules/sessions/db.js +58 -0
  41. package/modules/sessions/routes/sessions.js +97 -0
  42. package/modules/ui-design/kit/fixtures/founding-genesis-demo.json +176 -0
  43. package/modules/ui-design/kit/fixtures/me-founding-demo.json +27 -0
  44. package/modules/ui-design/kit/fixtures/provisioning-instances-demo.json +3 -0
  45. package/modules/ui-design/kit/fixtures/provisioning-instances-dns-pending.json +49 -0
  46. package/modules/ui-design/kit/fixtures/provisioning-instances.json +3 -0
  47. package/package-lock.json +2 -2
  48. package/package.json +1 -1
  49. package/release-notes.json +41 -0
  50. package/scripts/gds/provision.js +77 -53
  51. package/scripts/gds/session-digest.js +11 -3
  52. package/scripts/gds/session-transcript-build.js +155 -0
  53. package/scripts/gds/session-transcript-upload.js +29 -0
  54. package/scripts/gds/ship-session-upload.js +6 -1
  55. package/scripts/gds/upgrade-commit-identity.js +32 -0
  56. package/scripts/gds/upgrade.js +1 -1
  57. package/src/bongos/routes.js +3 -0
  58. package/src/branding.js +9 -0
  59. package/src/module-api.js +1 -1
  60. package/tests/auto_satisfy_criteria.mjs +20 -0
  61. package/tests/founding_birth.mjs +200 -0
  62. package/tests/founding_mode.mjs +3 -1
  63. package/tests/genesis_home.mjs +71 -2
  64. package/tests/goal_closure_invariants.mjs +68 -0
  65. package/tests/hub_map_orb_states.mjs +5 -1
  66. package/tests/npm_release_release.mjs +37 -2
  67. package/tests/projects_hub.mjs +5 -3
  68. package/tests/projects_hub_dns_ready.mjs +153 -0
  69. package/tests/projects_hub_pre_uat.mjs +9 -5
  70. package/tests/provision_dns_first.mjs +171 -0
  71. package/tests/provision_settings_apply.mjs +6 -1
  72. package/tests/provisioning_settings_env.mjs +49 -0
  73. package/tests/session_transcript.mjs +273 -0
  74. package/tests/ui_design_kit.mjs +1 -1
  75. package/tests/upgrade_commit_identity.mjs +89 -0
  76. package/tests/wizard_demo.mjs +29 -3
@@ -57,7 +57,7 @@ npm publish ./clients/bongos-client # real (publishConfig makes it
57
57
 
58
58
  **Publishing is automatic on merge** as of 2026-08-08: the `publish` workflow rides behind the required `unit` check and waits for the `integration` lane's verdict on the commit it releases (task 1004290), auto-bumps a patch when nobody bumped, builds through the fail-closed no-leak gate, publishes, and tags — see the workflow header (`.github/workflows/publish.yml`) and ADR 0161. It ships inert until the owner arms it: repo variable `PUBLISH_ON_MERGE=1`, a **trusted publisher** registered on npmjs.com for `@bongos/core` naming this repo + `publish.yml`, and the secret `RELEASE_PUSH_TOKEN` (contents:write, for the bump commit). There is deliberately **no** `NPM_TOKEN` — the lane authenticates by OIDC, and the workflow sets neither `NPM_TOKEN` nor `NODE_AUTH_TOKEN` because either one would send npm down the legacy token path and skip the OIDC exchange entirely. A **minor/major bump stays a human in-task edit**. The update subscription ([ADR 0136](../adr/0136-update-channel-subscription-policy.md)) consumes whatever is published, on its next sweep.
59
59
 
60
- **Candidates and Release** ([ADR 0361](../adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md), task 1004298). With the repo variable `PUBLISH_AS_CANDIDATE=1`, each merge publishes under the `next` label: the platform hall runs it, and no other project is offered it. **Release** on /deploy runs `publish.yml`'s `release` job (`workflow_dispatch`, input `release_version`), which moves `latest` to that version over the same OIDC trusted publisher. It needs **Allow npm dist-tag** ticked on the `publish.yml` trusted-publisher entry, and the server's GitHub App needs **Actions: write** to start the job. From a terminal the same job runs as `gh workflow run publish.yml -f release_version=<x.y.z>`. The arming order is in the ADR.
60
+ **Candidates and Release** ([ADR 0361](../adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md), task 1004298). With the repo variable `PUBLISH_AS_CANDIDATE=1`, each merge publishes under the `next` label: the platform hall runs it, and no other project is offered it. **Release** on /deploy runs `publish.yml`'s `release` job (`workflow_dispatch`, input `release_version`), which moves `latest` to that version over the same OIDC trusted publisher. It needs **Allow npm dist-tag** ticked on the `publish.yml` trusted-publisher entry, and the server's GitHub credential needs **Actions: write** to start the job: the `GITHUB_PUSH_TOKEN` access token (what cloudbongos.com uses), or the GitHub App where one is configured. From a terminal the same job runs as `gh workflow run publish.yml -f release_version=<x.y.z>`. The arming order is in the ADR.
61
61
 
62
62
  The steps below remain as the **manual fallback** (lane disarmed, npm outage, or a deliberate out-of-band release):
63
63
 
@@ -0,0 +1,35 @@
1
+ -- core_269_session_transcripts.sql — the FULL, secret-scrubbed transcript of every
2
+ -- session (ADR 0051, task 1001004).
3
+ --
4
+ -- WHY A SEPARATE TABLE and not a widened session_records.detail / a new jsonb
5
+ -- column on it: session_records is the BFG's hot, searched, paged table
6
+ -- (searchSessions, summarizeSessions, /mine all SELECT from it, and its rows are
7
+ -- KB by design — ADR 0027). A transcript is MB. Putting it on the same row would
8
+ -- make every list query drag (or have to remember to exclude) a fat jsonb, and
9
+ -- would make a TOASTed-column slip on one query a performance incident. One row
10
+ -- per session in each table, joined on session_id, keeps the evaluator's read
11
+ -- path exactly as cheap as it is today. ADR 0051 §1 allowed either shape
12
+ -- ("new transcript jsonb column, or a widened detail"); this is the third
13
+ -- option that satisfies its intent without the cost.
14
+ --
15
+ -- The content is scrubbed ON THE BUILDER'S BOX before upload (modules/security/
16
+ -- secret-scrub.js, the task-1003 scrubber); this table never holds raw text.
17
+ --
18
+ -- builder_id is denormalised from session_records ON PURPOSE: the read route's
19
+ -- ownership check (own transcript vs Archon-only cross-builder) must not depend
20
+ -- on a join succeeding, and the upload's ownership guard needs it at write time.
21
+ --
22
+ -- Retention: keep forever (ADR 0051 §4, Lars 2026-06-13). Storage-cost work is a
23
+ -- deliberate future task, not designed in here.
24
+
25
+ CREATE TABLE IF NOT EXISTS session_transcripts (
26
+ session_id text PRIMARY KEY, -- Claude Code session id, == session_records.session_id
27
+ builder_id bigint NOT NULL REFERENCES builders(id),
28
+ turn_count integer NOT NULL DEFAULT 0,
29
+ byte_size integer NOT NULL DEFAULT 0, -- serialized size of `turns`, for corpus accounting
30
+ truncated boolean NOT NULL DEFAULT false, -- true when the uploader hit its own size ceiling
31
+ turns jsonb NOT NULL DEFAULT '[]'::jsonb, -- per-turn scrubbed content (prompts, assistant text, tool calls + results)
32
+ uploaded_at timestamptz NOT NULL DEFAULT now()
33
+ );
34
+
35
+ CREATE INDEX IF NOT EXISTS idx_session_transcripts_builder ON session_transcripts (builder_id);
@@ -79,7 +79,7 @@
79
79
  var now = g.stages.find(function (s) { return s.state === 'now'; });
80
80
  var sub = g.deciding
81
81
  ? 'Deciding the plan' + (g.day ? ' · day ' + g.day : '')
82
- : 'Stage ' + g.stage_number + ' of ' + g.stage_count + ' · ' + (now ? now.label.toLowerCase() : g.stage) + (g.day ? ' · day ' + g.day : '');
82
+ : 'Stage ' + g.stage_number + ' of ' + g.stage_count + ' · ' + (now ? now.label.toLowerCase() : g.stage) + (g.demo ? ' · demo' : g.day ? ' · day ' + g.day : '');
83
83
  return '<div class="genesis__head"><h2 id="genesis-h" class="scroll__h">Genesis</h2>'
84
84
  + '<p class="genesis__sub">' + esc(sub) + '</p></div>' + stepperHtml(g);
85
85
  }
@@ -157,7 +157,10 @@
157
157
  function stagesHtml(g) {
158
158
  return '<ol class="genesis__stages">' + g.stages.map(function (s) {
159
159
  var wakes = (s.wakes || []).map(function (id) { return ROOM[id] || id; });
160
- var due = s.state === 'now' && s.due ? ' · due ' + new Date(s.due).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '';
160
+ // A demo's stage is minutes long (task 1004506): say how long, and due at a time.
161
+ var due = s.minutes
162
+ ? ' · ' + s.minutes + ' min' + (s.state === 'now' && s.due ? ', due ' + new Date(s.due).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) : '')
163
+ : s.state === 'now' && s.due ? ' · due ' + new Date(s.due).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '';
161
164
  return '<li class="genesis__stage is-' + s.state + '">'
162
165
  + '<span class="genesis__stage-n">0' + s.number + '</span>'
163
166
  + '<span class="genesis__stage-t"><b>' + esc(s.label) + '</b>'
@@ -225,12 +228,23 @@
225
228
  return document.documentElement.getAttribute('data-founding') === 'on';
226
229
  }
227
230
 
231
+ // A demo's genesis is expedited (task 1004506): its plan was settled from the demo's
232
+ // own picks before anyone saw it, so it never shows the decide form, only this line.
233
+ function demoHtml(g) {
234
+ var d = g.demo;
235
+ if (!d) return '';
236
+ var total = g.stages.reduce(function (n, s) { return n + (s.minutes || 0); }, 0);
237
+ return '<p class="scroll__lede">A demo’s genesis is short: about ' + esc(String(total)) + ' minutes, then the rest of your time is for building. '
238
+ + 'The plan is already set from your demo: Monarchy / BDFL, ' + esc(String(d.people)) + (d.people === 1 ? ' person, ' : ' people, ')
239
+ + esc(String(d.hours_each)) + (d.hours_each === 1 ? ' hour' : ' hours') + ' each. Finish each stage’s task, then approve it to move on.</p>';
240
+ }
241
+
228
242
  function render(host, state) {
229
243
  var g = state.genesis;
230
244
  if (!g) { host.hidden = true; return; }
231
245
  host.innerHTML = headHtml(g) + (g.deciding
232
246
  ? decideHtml(g, state.gov)
233
- : lifeHtml(g) + '<div class="genesis__grid">' + stagesHtml(g) + nowHtml(g, state.viewer) + '</div>');
247
+ : demoHtml(g) + lifeHtml(g) + '<div class="genesis__grid">' + stagesHtml(g) + nowHtml(g, state.viewer) + '</div>');
234
248
  host.hidden = false;
235
249
  }
236
250
 
@@ -0,0 +1,13 @@
1
+ {
2
+ "_": "The genesis home of a DEMO, born into genesis (task 1004506): 3 people, 4 hours each. The plan was settled at birth from the demo's picks, so there is no decide form: one line names the plan, and each stage reads in minutes (scope in progress, due at a time). /me says founding at scope (kit fixture me-founding-demo.json) and /founding/genesis answers founding-genesis-demo.json, built from the real server shaper. A real project's genesis is index-genesis.states.json and index-genesis-decide.states.json; a hall that is not founding is index.states.json.",
3
+ "page": "/builders/",
4
+ "surface": "hall-ui",
5
+ "stub": { "prefix": "/builders/", "fixtures": { "me": "me-founding-demo", "founding-genesis": "founding-genesis-demo" } },
6
+ "modeQuery": false,
7
+ "ignoreRequests": ["/api/(gds|bongos)/me$", "/api/(gds|bongos)/me/", "/api/(gds|bongos)/tasks", "/api/(gds|bongos)/claims", "/api/(gds|bongos)/goals", "/api/(gds|bongos)/inbox", "/api/(gds|bongos)/live", "/api/(gds|bongos)/analytics/", "/api/(gds|bongos)/achievements", "/api/(gds|bongos)/versions", "/api/(gds|bongos)/leaderboard", "/api/(gds|bongos)/removal-proposals", "/api/(gds|bongos)/blockers"],
8
+ "_ignored": "removal-proposals and blockers are fixtures the kit stub does not carry; index.states.json's home render reports the same two 404s.",
9
+ "ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
10
+ "states": {
11
+ "demo": { "auth": true, "actions": [["wait", 800]], "expect": { "visible": ["#genesis-home", "#founding-band"] } }
12
+ }
13
+ }
@@ -518,42 +518,91 @@ async function autoSatisfyShippedCriteria(exec, { taskId = null, criterionId = n
518
518
  c.satisfied_by_task_id`,
519
519
  [scopedTaskId, scopedCriterionId]
520
520
  );
521
- if (criteria.length === 0) return { criteria: [], goals: [] };
521
+ // THE SHIP THAT FLIPS NO CRITERION STILL FINISHES ITS GOAL (task 1004513).
522
+ // Returning here unconditionally was a hole. When every criterion of a goal was
523
+ // satisfied BEFORE its last task shipped, the UPDATE above matches nothing — and
524
+ // the other writer (`POST /done-when/:id/satisfy` → achieveGoalIfComplete) had
525
+ // already run too early, while that task was still open, so it correctly held
526
+ // the goal open and then nobody ever re-asked. The goal sat `open` with every
527
+ // criterion satisfied and no work left, until a criterion was satisfied a second
528
+ // time purely to re-trigger the other door.
529
+ //
530
+ // So on the SCOPED ship path, re-check the shipping task's OWN goal against the
531
+ // same rule the cascade below applies. `exec` is load-bearing here for the same
532
+ // reason it is there: this task's flip to `shipped` is uncommitted, and on the
533
+ // module pool the task check would still read `confirmed` — non-terminal — and
534
+ // hold the goal open exactly as before.
535
+ //
536
+ // The UNSCOPED sweep keeps its cheap early return: it holds no task, so there is
537
+ // no candidate goal to name, and every goal it can close it closes through the
538
+ // cascade below.
539
+ if (criteria.length === 0) {
540
+ if (scopedTaskId == null) return { criteria: [], goals: [] };
541
+ const { rows: own } = await exec.query(
542
+ `SELECT goal_id FROM tasks WHERE id = $1::bigint AND goal_id IS NOT NULL`,
543
+ [scopedTaskId]
544
+ );
545
+ const goals = await achieveCompletedGoals(exec, own.map((r) => Number(r.goal_id)));
546
+ return { criteria: [], goals, versionsClosed: await autoCloseVersionsForGoals(exec, goals) };
547
+ }
522
548
 
523
549
  // Cascade. Deliberately re-derived from the table rather than from `criteria`:
524
550
  // a goal completes when ALL of its criteria are satisfied, and the ones that
525
- // closed in THIS call may not be all of them. Mirrors db.achieveGoalIfComplete
526
- // exactly — open goals only, >=1 criterion, zero unsatisfied, AND no unfinished
527
- // task (BV1.R09, task 1003596) — but on `exec`, so it sees the flips above
528
- // inside the same transaction.
529
- //
530
- // THE THIRD CLAUSE, and why it must run HERE and not only in db-goals.js. This
531
- // is the path that actually fires on every ship (`db-ship.js`) and every
532
- // reconciler tick (`publish-reconciler.js`); `achieveGoalIfComplete` covers the
533
- // manual `POST /done-when/:id/satisfy`. The two are a DELIBERATE duplicate —
534
- // collapsing them into one helper creates a require cycle (db-goals requires
535
- // done-when) — so the rule is written twice on purpose and both copies must
536
- // move together, or a goal closes correctly through one door and wrongly
537
- // through the other. ADR 0250 D2.
538
- //
539
- // `exec` is load-bearing for THIS clause specifically. The shipping task's flip
540
- // to `shipped` is uncommitted at this point in the ship transaction; on the
541
- // module pool the subquery would still see it as `confirmed` — non-terminal —
542
- // and refuse to close a goal whose last task just landed. Every goal would then
543
- // need a second, later trigger to close at all.
544
- //
545
- // Shipping is NOT blocked by any of this: the ship's own writes are untouched
546
- // and this UPDATE simply matches no row, leaving the goal open.
547
- //
548
- // The subquery is index-served: migration 228 added idx_tasks_goal ON tasks
549
- // (goal_id), so this is a small index scan per candidate goal, and the candidate
550
- // set is only the goals whose criteria just closed in this call. (Raised in
551
- // review off docs/architecture.md, whose tasks index list predated 228 and has
552
- // been corrected.)
551
+ // closed in THIS call may not be all of them. The statement itself is
552
+ // achieveCompletedGoals, below.
553
553
  const goalIds = [...new Set(criteria.map((c) => c.goal_id).filter((g) => g != null))].map(Number);
554
554
  // No goal achieved, so no version can have just become closable.
555
555
  if (goalIds.length === 0) return { criteria, goals: [], versionsClosed: [] };
556
- const { rows: goals } = await exec.query(
556
+ const goals = await achieveCompletedGoals(exec, goalIds);
557
+ // BV1.R16 (task 1003603, ADR 0263 §§3–4): a version closes ITSELF once its last
558
+ // non-maintenance goal achieves. On the caller's `exec`, so it sees the flips
559
+ // above that have not committed yet — a check on the pool would never close the
560
+ // version on the ship that actually finished it.
561
+ //
562
+ // `autoCloseVersionsForGoals` catches per version and never rethrows: a version
563
+ // close must not be able to fail a builder's ship. The accepted failure is a
564
+ // version left un-closed, which the reconciler's next unscoped sweep heals.
565
+ const versionsClosed = await autoCloseVersionsForGoals(exec, goals);
566
+ return { criteria, goals, versionsClosed };
567
+ }
568
+
569
+ // achieveCompletedGoals — the ONE statement that flips a goal open→achieved on
570
+ // the ship/sweep side. Written once because autoSatisfyShippedCriteria now enters
571
+ // it from two places — the criteria this call just closed, and (task 1004513) the
572
+ // shipping task's own goal when it closed none — and two copies of this rule
573
+ // would be two things to keep in step. Takes the caller's `exec` and the candidate
574
+ // goal ids; returns the rows that actually flipped (usually none).
575
+ //
576
+ // Mirrors db.achieveGoalIfComplete exactly — open goals only, >=1 criterion, zero
577
+ // unsatisfied, AND no unfinished task (BV1.R09, task 1003596).
578
+ //
579
+ // THE THIRD CLAUSE, and why it must run HERE and not only in db-goals.js. This is
580
+ // the path that actually fires on every ship (`db-ship.js`) and every reconciler
581
+ // tick (`publish-reconciler.js`); `achieveGoalIfComplete` covers the manual
582
+ // `POST /done-when/:id/satisfy`. The two are a DELIBERATE duplicate — collapsing
583
+ // them into one helper creates a require cycle (db-goals requires done-when) — so
584
+ // the rule is written twice on purpose and both copies must move together, or a
585
+ // goal closes correctly through one door and wrongly through the other. ADR 0250
586
+ // D2.
587
+ //
588
+ // `exec` is load-bearing for THIS clause specifically. The shipping task's flip
589
+ // to `shipped` is uncommitted at this point in the ship transaction; on the module
590
+ // pool the subquery would still see it as `confirmed` — non-terminal — and refuse
591
+ // to close a goal whose last task just landed. Every goal would then need a
592
+ // second, later trigger to close at all.
593
+ //
594
+ // Shipping is NOT blocked by any of this: the ship's own writes are untouched and
595
+ // this UPDATE simply matches no row, leaving the goal open.
596
+ //
597
+ // The subquery is index-served: migration 228 added idx_tasks_goal ON tasks
598
+ // (goal_id), so this is a small index scan per candidate goal, and the candidate
599
+ // set is only the goals this call could possibly have completed. (Raised in review
600
+ // off docs/architecture.md, whose tasks index list predated 228 and has been
601
+ // corrected.)
602
+ async function achieveCompletedGoals(exec, goalIds) {
603
+ const ids = (goalIds || []).filter((g) => g != null).map(Number);
604
+ if (ids.length === 0) return [];
605
+ const { rows } = await exec.query(
557
606
  `UPDATE goals g
558
607
  SET status = 'achieved', updated_at = now()
559
608
  WHERE g.id = ANY($1::bigint[])
@@ -564,21 +613,12 @@ async function autoSatisfyShippedCriteria(exec, { taskId = null, criterionId = n
564
613
  AND ${nonTerminalSql()}
565
614
  AND ${SMOKE_NOT_LIKE.replace(/title/, 't.title')})
566
615
  RETURNING g.id, g.title, g.version_id, g.status`,
567
- [goalIds]
616
+ [ids]
568
617
  );
569
618
  // Task 1003660: an achieved goal takes no new members — close its pending asks
570
619
  // on the same executor, so the cancel rides the ship transaction.
571
- await cancelPendingMembershipRequests(exec, goals.map((g) => g.id));
572
- // BV1.R16 (task 1003603, ADR 0263 §§3–4): a version closes ITSELF once its last
573
- // non-maintenance goal achieves. On the caller's `exec`, so it sees the flips
574
- // above that have not committed yet — a check on the pool would never close the
575
- // version on the ship that actually finished it.
576
- //
577
- // `autoCloseVersionsForGoals` catches per version and never rethrows: a version
578
- // close must not be able to fail a builder's ship. The accepted failure is a
579
- // version left un-closed, which the reconciler's next unscoped sweep heals.
580
- const versionsClosed = await autoCloseVersionsForGoals(exec, goals);
581
- return { criteria, goals, versionsClosed };
620
+ await cancelPendingMembershipRequests(exec, rows.map((g) => g.id));
621
+ return rows;
582
622
  }
583
623
 
584
624
  // describeAutoClose — the one-line summary of what a close actually closed, or
@@ -5,16 +5,21 @@
5
5
  // Kept beside github-push.js rather than in it: it shares that file's credential and headers,
6
6
  // and nothing else.
7
7
 
8
- const { resolveToken, ghHeaders, isSafeBranch } = require('./github-push');
8
+ const { getInstallationToken, ghHeaders, isSafeBranch } = require('./github-push');
9
9
  const { repoInfo: { loadRepoInfo } } = require('../../src/module-api');
10
10
 
11
11
  // dispatchWorkflow — start a GitHub Actions workflow on this repo (task 1004298, ADR 0361). The
12
12
  // Release button on /deploy uses it to run publish.yml's release job, so the npm credential never
13
- // leaves GitHub: the server holds only the GitHub App key it already holds, and the App needs
14
- // Actions: write for this one call. Returns { ok:true } on GitHub's 204, otherwise
15
- // { ok:false, code, status? } — UNCONFIGURED (no credential), NO_REPO_INFO, BAD_INPUT, REFUSED
16
- // (GitHub said no: 403 = the App lacks Actions: write, 404 = no such workflow or no access,
17
- // 422 = an input the workflow does not declare), UNREACHABLE. Never throws.
13
+ // leaves GitHub: the server uses only the GitHub credential it already holds, which needs
14
+ // Actions: write for this one call. Returns { ok:true, credential } on GitHub's 204, otherwise
15
+ // { ok:false, code, status?, credential? } — UNCONFIGURED (no credential), NO_REPO_INFO, BAD_INPUT,
16
+ // REFUSED (GitHub said no: 403 = the credential lacks Actions: write, 404 = no such workflow or no
17
+ // access, 422 = an input the workflow does not declare), UNREACHABLE. Never throws.
18
+ //
19
+ // `credential` says WHICH one was used — 'app' (a GitHub App installation token) or 'token' (the
20
+ // GITHUB_PUSH_TOKEN access token) — so a refusal can name the thing the owner must edit
21
+ // (task 1004518: cloudbongos.com uses the access token, and a message naming "the App" sent the
22
+ // owner to the wrong settings page). Resolved in resolveToken's order: App first, then the token.
18
23
  //
19
24
  // ONLY THE WORKFLOWS NAMED HERE can be started. The port lends the server's GitHub credential to
20
25
  // another module; an allowlist keeps a buggy or future borrower from running any other workflow in
@@ -22,8 +27,13 @@ const { repoInfo: { loadRepoInfo } } = require('../../src/module-api');
22
27
  const DISPATCHABLE_WORKFLOWS = Object.freeze(['publish.yml']);
23
28
  async function dispatchWorkflow({ workflow, ref = 'main', inputs = {} }, deps = {}) {
24
29
  if (!DISPATCHABLE_WORKFLOWS.includes(String(workflow || '')) || !isSafeBranch(ref)) return { ok: false, code: 'BAD_INPUT' };
25
- let token;
26
- try { token = await resolveToken(deps); } catch (_) { return { ok: false, code: 'UNCONFIGURED' }; }
30
+ let token = deps.token || null;
31
+ let credential = token ? (deps.credential || 'token') : null;
32
+ if (!token) {
33
+ try { token = await (deps.appToken || getInstallationToken)(deps); } catch (_) { return { ok: false, code: 'UNCONFIGURED', credential: 'app' }; }
34
+ if (token) credential = 'app';
35
+ }
36
+ if (!token && process.env.GITHUB_PUSH_TOKEN) { token = process.env.GITHUB_PUSH_TOKEN; credential = 'token'; }
27
37
  if (!token) return { ok: false, code: 'UNCONFIGURED' };
28
38
  const repoInfo = deps.repoInfo || loadRepoInfo();
29
39
  if (!repoInfo || repoInfo.error) return { ok: false, code: 'NO_REPO_INFO' };
@@ -35,10 +45,10 @@ async function dispatchWorkflow({ workflow, ref = 'main', inputs = {} }, deps =
35
45
  headers: { ...ghHeaders(token), 'Content-Type': 'application/json' },
36
46
  body: JSON.stringify({ ref, inputs }),
37
47
  });
38
- if (res.status === 204 || res.ok) return { ok: true };
39
- return { ok: false, code: 'REFUSED', status: res.status };
48
+ if (res.status === 204 || res.ok) return { ok: true, credential };
49
+ return { ok: false, code: 'REFUSED', status: res.status, credential };
40
50
  } catch (_) {
41
- return { ok: false, code: 'UNREACHABLE' };
51
+ return { ok: false, code: 'UNREACHABLE', credential };
42
52
  }
43
53
  }
44
54
 
@@ -95,6 +95,15 @@ async function readReleaseLedger(pool) {
95
95
  }
96
96
  }
97
97
 
98
+ // The credential a refusal names, in the words of the settings page the owner must open: GitHub
99
+ // lists an App under Developer settings → GitHub Apps, and an access token under Developer
100
+ // settings → Personal access tokens (task 1004518).
101
+ function credentialName(credential) {
102
+ if (credential === 'app') return "the server's GitHub App";
103
+ if (credential === 'token') return "the server's GitHub access token (GITHUB_PUSH_TOKEN, under Developer settings → Personal access tokens)";
104
+ return "the server's GitHub credential";
105
+ }
106
+
98
107
  /**
99
108
  * What the Release button does. Returns { status, body } for the route to send.
100
109
  *
@@ -118,7 +127,7 @@ async function requestRelease({ version, registry, dispatch } = {}) {
118
127
  }
119
128
  const why = !r ? 'no answer'
120
129
  : r.code === 'UNCONFIGURED' ? 'this server has no GitHub credential'
121
- : r.code === 'REFUSED' && r.status === 403 ? "GitHub refused: the server's GitHub App needs the Actions: write permission"
130
+ : r.code === 'REFUSED' && r.status === 403 ? `GitHub refused: ${credentialName(r.credential)} needs the Actions: write permission on this repository`
122
131
  : r.code === 'REFUSED' && r.status === 404 ? 'GitHub could not find publish.yml, or the App cannot see this repository'
123
132
  : r.code === 'REFUSED' ? `GitHub refused the request (${r.status})`
124
133
  : r.code === 'UNREACHABLE' ? 'GitHub could not be reached'
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ // modules/onboarding/founding-birth.js — A NEW PROJECT IS BORN INTO GENESIS (task
4
+ // 1004506 / BV2.PS18, goal 1000121; spec docs/specs/bongos-v2-project-startup.md step 5,
5
+ // D4, D8, D9).
6
+ //
7
+ // THE PROBLEM. POST /founding/start existed, but nothing called it, so a project made
8
+ // through the startup flow opened as an ordinary hall. The create request lands on the
9
+ // HUB, while founding mode lives in the NEW INSTANCE's own project_settings, and the hub
10
+ // holds no way into that database. So the hub only says so, and the instance acts:
11
+ //
12
+ // 1. The hub's create route stamps a reserved key on the new row
13
+ // (modules/provisioning/birth.js) — never on a row that already exists.
14
+ // 2. The runner writes it into the instance's web.env with the other settings
15
+ // companions: <PREFIX>_FOUNDING_BIRTH, plus <PREFIX>_DEMO_PEOPLE / _DEMO_HOURS
16
+ // when the row is a demo. src/branding.js maps them onto `founding.*`, which is
17
+ // server-only (clientBranding never publishes it).
18
+ // 3. Here, the instance reads that marker and, once, starts founding — and for a
19
+ // demo, also settles the expedited plan, so its decide stage needs no choices.
20
+ //
21
+ // WHEN IT RUNS: lazily, on the first founding read (GET /me through the port, and
22
+ // GET /founding/genesis), not at boot. The first /me is the hall's first paint after the
23
+ // founder's first sign-in, so the hall opens already in genesis; and by then the
24
+ // founder is seated and the kickoff seed has run, so the board the demo's stages are
25
+ // filed on exists. At boot it would race the module load order (the lifecycle port the
26
+ // plan files tasks through may not be registered yet) and a database that is still
27
+ // coming up.
28
+ //
29
+ // WHY IT CANNOT RACE OR TOUCH AN OLD PROJECT.
30
+ // * No marker, no run: a project created before this shipped has none (D8), and a
31
+ // marker-less read resolves without touching the database.
32
+ // * Once per process: one shared promise, so two concurrent first reads share one run.
33
+ // * Once for good: founding is started only when it was never started and never
34
+ // ended (founding.started_at / ended_at), so a restart, a later settings push that
35
+ // rewrites the env, or an owner who already started by hand changes nothing. A demo
36
+ // carried over keeps its state for the same reason: genesis happened once.
37
+ // * The demo's plan is saved only while the project is still deciding with no plan,
38
+ // and savePlan itself refuses outside 'decide'; the stage tasks are filed by a seeder
39
+ // that skips a step already filed.
40
+
41
+ const fm = require('./founding-mode');
42
+ const genesis = require('./genesis-home');
43
+
44
+ // The demo's limits and the bounds check are genesis-home.js's own, never retyped here.
45
+ const { DEMO_PEOPLE, DEMO_HOURS, intIn } = genesis;
46
+
47
+ // birthMarker(founding) — PURE. The resolved branding pack's `founding` block → whether
48
+ // this project was born through the startup flow, and its demo time box if a demo. A
49
+ // malformed demo value reads as no demo (the project still founds, with the normal plan).
50
+ function birthMarker(founding) {
51
+ const f = founding && typeof founding === 'object' ? founding : {};
52
+ const born = typeof f.birth === 'string' && Number.isFinite(new Date(f.birth).getTime());
53
+ if (!born) return { born: false, demo: null };
54
+ const people = Number(f.demoPeople);
55
+ const hours = Number(f.demoHours);
56
+ const demo = intIn(people, DEMO_PEOPLE[0], DEMO_PEOPLE[1]) && intIn(hours, DEMO_HOURS[0], DEMO_HOURS[1]) ? { people, hours_each: hours } : null;
57
+ return { born: true, demo };
58
+ }
59
+
60
+ function markerFrom(deps) {
61
+ if (deps.marker) return deps.marker;
62
+ let b;
63
+ try { b = require('../../src/module-api').branding(); } catch (_) { return { born: false, demo: null }; }
64
+ return birthMarker(b && b.founding);
65
+ }
66
+
67
+ // bornFounding(deps) — the guarded birth. Returns what it did:
68
+ // { ran: false, reason } nothing to do (no marker, already, alive)
69
+ // { ran: true, started, plan, demo } it started founding and/or saved the plan
70
+ // { ran: false, reason, retry: true } the demo plan could not be saved yet
71
+ async function bornFounding(deps = {}) {
72
+ const marker = markerFrom(deps);
73
+ if (!marker.born) return { ran: false, reason: 'no_marker' };
74
+ const get = deps.getSetting || require('../../src/module-api').projectSettings.get;
75
+ const [started, ended] = await Promise.all([get(fm.KEYS.STARTED_AT), get(fm.KEYS.ENDED_AT)]);
76
+ if (ended && ended.value) return { ran: false, reason: 'already_alive' };
77
+ let didStart = false;
78
+ if (!(started && started.value)) {
79
+ const r = await genesis.startFounding(null, deps);
80
+ if (!r.ok) return { ran: false, reason: r.reason };
81
+ didStart = true;
82
+ }
83
+ if (!marker.demo) return didStart ? { ran: true, started: true, plan: false, demo: false } : { ran: false, reason: 'already_started' };
84
+ // The demo's expedited plan: only while still deciding, and only if none is saved.
85
+ const mode = await fm.foundingModeFor(genesis.modeDepsFor({ ...deps, getSetting: get }));
86
+ const planRow = await get(genesis.PLAN_KEY);
87
+ if (!mode || mode.stage !== 'decide' || (planRow && planRow.value)) {
88
+ return didStart ? { ran: true, started: true, plan: false, demo: true } : { ran: false, reason: 'already_started' };
89
+ }
90
+ const saved = await genesis.savePlan(genesis.demoPlan(marker.demo), null, deps);
91
+ if (!saved.ok) return { ran: false, reason: saved.reason, retry: true };
92
+ return { ran: true, started: didStart, plan: true, demo: true };
93
+ }
94
+
95
+ // ensureBirth(deps) — the once-per-process door the reads call. Shares one run between
96
+ // concurrent callers; forgets a run that failed or asked to retry, so a later read tries
97
+ // again. Never throws: a birth hiccup must never fail the read that triggered it.
98
+ let once = null;
99
+ function ensureBirth(deps = {}) {
100
+ if (once) return once;
101
+ const p = bornFounding(deps).then((r) => {
102
+ if (r && r.retry) once = null;
103
+ return r;
104
+ }, (err) => {
105
+ once = null;
106
+ try { require('../../src/module-api').logger('onboarding').error('[onboarding] founding birth failed', err); } catch (_) { /* no logger: nothing to do */ }
107
+ return { ran: false, reason: 'error' };
108
+ });
109
+ once = p;
110
+ return p;
111
+ }
112
+
113
+ // Test seam: forget the per-process run.
114
+ function resetBirthForTests() { once = null; }
115
+
116
+ module.exports = { birthMarker, bornFounding, ensureBirth, resetBirthForTests };
@@ -75,7 +75,65 @@ function parsePlan(body = {}) {
75
75
  if (government !== null && !/^[a-z][a-z-]{1,39}$/.test(government)) return { ok: false, reason: 'bad_government' };
76
76
  const expected = b.expected_builders == null || b.expected_builders === '' ? null : Number(b.expected_builders);
77
77
  if (expected !== null && !intIn(expected, 1, 100000)) return { ok: false, reason: 'bad_expected_builders' };
78
- return { ok: true, plan: { days, passes, advisors, government, expected_builders: expected, existing: b.existing === true } };
78
+ const plan = { days, passes, advisors, government, expected_builders: expected, existing: b.existing === true };
79
+ // A DEMO's plan (task 1004506) also carries its time box and each stage's length in
80
+ // minutes. Only the birth path writes these (demoPlan below): the plan route's schema
81
+ // does not name them, so a client cannot send one.
82
+ if (b.demo != null) {
83
+ const d = b.demo;
84
+ if (!d || !intIn(d.people, DEMO_PEOPLE[0], DEMO_PEOPLE[1]) || !intIn(d.hours_each, DEMO_HOURS[0], DEMO_HOURS[1])) return { ok: false, reason: 'bad_demo' };
85
+ plan.demo = { people: d.people, hours_each: d.hours_each };
86
+ }
87
+ if (b.minutes != null) {
88
+ plan.minutes = {};
89
+ for (const s of PROPER) {
90
+ const m = b.minutes[s];
91
+ if (!intIn(m, 1, MAX_DEMO_MINUTES)) return { ok: false, reason: 'bad_minutes', stage: s };
92
+ plan.minutes[s] = m;
93
+ }
94
+ }
95
+ return { ok: true, plan };
96
+ }
97
+
98
+ // ── the demo's expedited genesis (task 1004506) ──────────────────────────────
99
+ // Owner ruling, 2026-10-01: a demo DOES go through genesis, but expedited, because the
100
+ // point of a demo is that the whole thing is fast. So a demo's plan is settled before
101
+ // the founder ever sees it, from what the demo already picked: Monarchy / BDFL (the
102
+ // founder's one yes closes each stage, spec D4's fast-track), one pass, no advisors,
103
+ // the demo's people as the headcount. Its stages are minutes, not days: genesis gets a
104
+ // fifth of the hours each person has (at least ten minutes, at most two hours), split
105
+ // by how much each stage asks, so a small team reaches "come alive" with most of the
106
+ // time box left for building.
107
+ const DEMO_PEOPLE = Object.freeze([1, 12]); // modules/provisioning/demo.js DEMO_LIMITS
108
+ const DEMO_HOURS = Object.freeze([1, 40]);
109
+ const MAX_DEMO_MINUTES = 240;
110
+ const DEMO_SHARE = 0.2;
111
+ const DEMO_GENESIS_MINUTES = Object.freeze([10, 120]);
112
+ const DEMO_WEIGHTS = Object.freeze({ scope: 3, strategize: 2, charter: 2, legalize: 1, legislation: 2 });
113
+
114
+ // demoMinutes(hoursEach) — PURE. Each stage's length in minutes, from the time box.
115
+ function demoMinutes(hoursEach) {
116
+ const [lo, hi] = DEMO_GENESIS_MINUTES;
117
+ const budget = Math.min(hi, Math.max(lo, Math.round(hoursEach * 60 * DEMO_SHARE)));
118
+ const total = Object.values(DEMO_WEIGHTS).reduce((a, b) => a + b, 0);
119
+ const out = {};
120
+ for (const s of PROPER) out[s] = Math.max(1, Math.round((budget * DEMO_WEIGHTS[s]) / total));
121
+ return out;
122
+ }
123
+
124
+ // demoPlan({ people, hours_each }) — PURE. The plan body a demo is born with; it goes
125
+ // through parsePlan like any other, so it is held to the same ranges.
126
+ function demoPlan({ people, hours_each: hoursEach }) {
127
+ return {
128
+ days: Object.fromEntries(PROPER.map((s) => [s, 1])),
129
+ minutes: demoMinutes(hoursEach),
130
+ passes: 1,
131
+ advisors: 'none',
132
+ government: 'monarchy',
133
+ expected_builders: people,
134
+ existing: false,
135
+ demo: { people, hours_each: hoursEach },
136
+ };
79
137
  }
80
138
 
81
139
  function readPlanValue(raw) {
@@ -99,8 +157,11 @@ function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
99
157
  const list = (tasks[key] || []).map((t) => ({ ...t, finished: FINISHED.includes(t.status) }));
100
158
  const b = board[key] || null;
101
159
  const days = plan && plan.days && plan.days[key] ? plan.days[key] : info.days;
160
+ // A demo's stage is minutes long (task 1004506); its due time is counted in them.
161
+ const minutes = plan && plan.minutes && plan.minutes[key] ? plan.minutes[key] : null;
102
162
  const began = stageAt[key] ? new Date(stageAt[key]) : null;
103
- const due = began && Number.isFinite(began.getTime()) ? new Date(began.getTime() + days * 86400000).toISOString() : null;
163
+ const span = minutes ? minutes * 60000 : days * 86400000;
164
+ const due = began && Number.isFinite(began.getTime()) ? new Date(began.getTime() + span).toISOString() : null;
104
165
  return {
105
166
  key,
106
167
  number: idx,
@@ -110,6 +171,7 @@ function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
110
171
  done_when: info.done_when,
111
172
  wakes: fm.WAKES[key] || [],
112
173
  days,
174
+ minutes,
113
175
  due,
114
176
  state: idx < at ? 'done' : idx === at ? 'now' : 'later',
115
177
  tasks: list,
@@ -131,6 +193,8 @@ function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
131
193
  day: mode.day,
132
194
  deciding: mode.stage === 'decide',
133
195
  plan: plan || null,
196
+ // The demo's time box when this genesis is a demo's (task 1004506), else null.
197
+ demo: plan && plan.demo ? plan.demo : null,
134
198
  stages,
135
199
  // The close door: every task of the current stage finished, and not already sitting.
136
200
  can_close: !!(now && now.total > 0 && now.finished === now.total && !sitting && !passed),
@@ -193,8 +257,9 @@ async function readGenesis(deps = {}) {
193
257
  return shapeGenesis({ mode, plan, tasks, board, stageAt });
194
258
  }
195
259
 
196
- // startFounding — a brand-new project enters genesis (the create flow's birth calls
197
- // this). Refused once the project has come alive: genesis happens once (spec D8).
260
+ // startFounding — a brand-new project enters genesis (founding-birth.js calls this once
261
+ // on a project born through the startup flow, task 1004506). Refused once the project
262
+ // has come alive: genesis happens once (spec D8).
198
263
  async function startFounding(builderId, deps = {}) {
199
264
  const s = seams(deps);
200
265
  const ended = await s.get(fm.KEYS.ENDED_AT);
@@ -269,6 +334,8 @@ async function onStagePassed(deps = {}) {
269
334
  }
270
335
 
271
336
  module.exports = {
272
- PLAN_KEY, STAGE_INFO, PROPER, parsePlan, shapeGenesis,
337
+ PLAN_KEY, STAGE_INFO, PROPER, DEMO_PEOPLE, DEMO_HOURS, intIn, parsePlan, shapeGenesis, demoMinutes, demoPlan,
273
338
  readGenesis, startFounding, savePlan, closeStage, comeAlive, onStagePassed,
339
+ // the founding-mode deps this file reads the mode with, for founding-birth.js (task 1004506)
340
+ modeDepsFor: (deps) => modeDeps(seams(deps)),
274
341
  };
@@ -41,6 +41,7 @@
41
41
  const onboardingState = require('./onboarding-state');
42
42
  const newcomerRestock = require('./newcomer-restock');
43
43
  const foundingMode = require('./founding-mode');
44
+ const foundingBirth = require('./founding-birth');
44
45
 
45
46
  module.exports = {
46
47
  // --- the newcomer onboarding state machine (onboarding-state.js) ---------
@@ -56,8 +57,14 @@ module.exports = {
56
57
 
57
58
  // --- project founding mode (founding-mode.js, task 1004422) ---------------
58
59
  // me.js reads it for the hall shell (null = not founding = the hall as today);
59
- // the genesis home (task 1004423) drives the write.
60
- foundingModeFor: (...a) => foundingMode.foundingModeFor(...a),
60
+ // the genesis home (task 1004423) drives the write. A project born through the
61
+ // startup flow enters genesis on this, its first read (founding-birth.js, task
62
+ // 1004506): once, guarded, and a no-op without the birth marker. A caller passing
63
+ // its own deps (a test) skips the birth.
64
+ foundingModeFor: async (...a) => {
65
+ if (!a.length) await foundingBirth.ensureBirth();
66
+ return foundingMode.foundingModeFor(...a);
67
+ },
61
68
  setFoundingMode: (...a) => foundingMode.setFoundingMode(...a),
62
69
 
63
70
  // --- the evergreen newcomer-chore restock (newcomer-restock.js) ----------
@@ -36,6 +36,7 @@ const onboardingPort = require('../port');
36
36
  const buildAccessRequestsRouter = require('./access-requests');
37
37
  const foundingBuilders = require('../founding-builders');
38
38
  const genesis = require('../genesis-home');
39
+ const foundingBirth = require('../founding-birth');
39
40
 
40
41
  // Register the `onboarding` kernel PORT. See the header + port.js for the full
41
42
  // consumer list and the graceful-degradation contract.
@@ -76,6 +77,7 @@ const GENESIS_REFUSALS = {
76
77
  not_at_legislation: 409, legislation_not_passed: 409, stage_tasks_unfinished: 409,
77
78
  no_stage_tasks: 409, unknown_stage: 400, no_board: 503,
78
79
  bad_days: 400, bad_passes: 400, bad_advisors: 400, bad_government: 400, bad_expected_builders: 400,
80
+ bad_demo: 400, bad_minutes: 400,
79
81
  };
80
82
  let genesisListening = false;
81
83
 
@@ -101,7 +103,12 @@ function mountGenesisRoutes(router) {
101
103
  res.status(500).json({ error: { code: 'genesis_failed', message: 'internal error' } });
102
104
  }
103
105
  };
104
- router.get('/founding/genesis', api.requireBuilder, handle(async (req) => ({ genesis: await genesis.readGenesis(), viewer_id: String(req.builder.id) })));
106
+ // A project born through the startup flow enters genesis on its first founding read
107
+ // (founding-birth.js, task 1004506) — here and on GET /me through the port.
108
+ router.get('/founding/genesis', api.requireBuilder, handle(async (req) => {
109
+ await foundingBirth.ensureBirth();
110
+ return { genesis: await genesis.readGenesis(), viewer_id: String(req.builder.id) };
111
+ }));
105
112
  router.post('/founding/start', api.requireBuilder, owns, handle((req) => genesis.startFounding(req.builder.id)));
106
113
  // The plan's ranges and vocabularies are genesis-home.js parsePlan's (one source);
107
114
  // this schema only names the fields so an unknown one is refused.