@bongos/core 1.25.19 → 1.25.21

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 (131) hide show
  1. package/.bongos-core.json +237 -112
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +12 -0
  4. package/clients/bongos-client/index.cjs +12 -0
  5. package/clients/bongos-client/index.d.ts +26 -9
  6. package/clients/bongos-client/index.mjs +12 -0
  7. package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +10 -0
  8. package/docs/adr/0407-a-project-grant-is-made-by-the-owner-not-witnessed-at-sign-in.md +3 -1
  9. package/docs/api/openapi.json +444 -37
  10. package/docs/api-reference.md +11 -5
  11. package/docs/copy-inventory.md +223 -221
  12. package/docs/copy-registry.json +262 -244
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/page-readings.json +348 -347
  15. package/docs/project-context.template.md +1 -1
  16. package/modules/front-door-guide/handoff.js +120 -0
  17. package/modules/front-door-guide/module.json +1 -1
  18. package/modules/front-door-guide/routes/front-door-guide-plan.js +24 -0
  19. package/modules/government/board.js +8 -7
  20. package/modules/government/charter-advice.js +10 -3
  21. package/modules/government/charter.js +88 -4
  22. package/modules/government/migrations/government_039_vote_cast_via_filed.sql +22 -0
  23. package/modules/government/routes/government.js +12 -12
  24. package/modules/government/sole-member.js +75 -0
  25. package/modules/hall-ui/public/ai-studio.html +1 -1
  26. package/modules/hall-ui/public/atlas.html +1 -1
  27. package/modules/hall-ui/public/blockers.html +1 -1
  28. package/modules/hall-ui/public/board-room.html +1 -1
  29. package/modules/hall-ui/public/city.html +1 -1
  30. package/modules/hall-ui/public/collab.html +1 -1
  31. package/modules/hall-ui/public/commands.html +1 -1
  32. package/modules/hall-ui/public/copy-desk.html +1 -1
  33. package/modules/hall-ui/public/deploy.html +1 -1
  34. package/modules/hall-ui/public/genesis-home.js +35 -9
  35. package/modules/hall-ui/public/goal-map.html +1 -1
  36. package/modules/hall-ui/public/goals.html +1 -1
  37. package/modules/hall-ui/public/government.html +1 -1
  38. package/modules/hall-ui/public/home-city-draw.js +14 -1
  39. package/modules/hall-ui/public/idea.html +1 -1
  40. package/modules/hall-ui/public/ideas.html +1 -1
  41. package/modules/hall-ui/public/index.html +1 -1
  42. package/modules/hall-ui/public/market-watch-item.html +1 -1
  43. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  44. package/modules/hall-ui/public/market-watch.html +1 -1
  45. package/modules/hall-ui/public/modules.html +1 -1
  46. package/modules/hall-ui/public/money.html +1 -1
  47. package/modules/hall-ui/public/my-agents.html +1 -1
  48. package/modules/hall-ui/public/primer.html +1 -1
  49. package/modules/hall-ui/public/profile.html +1 -1
  50. package/modules/hall-ui/public/project-settings.html +1 -1
  51. package/modules/hall-ui/public/roster.html +1 -1
  52. package/modules/hall-ui/public/settings.html +1 -1
  53. package/modules/hall-ui/public/shell.js +32 -20
  54. package/modules/hall-ui/public/street.html +1 -1
  55. package/modules/hall-ui/public/studio.html +1 -1
  56. package/modules/hall-ui/public/style.css +9 -4
  57. package/modules/hall-ui/public/task.html +1 -1
  58. package/modules/hall-ui/public/thinking.html +1 -1
  59. package/modules/hall-ui/public/tweak-editor.html +1 -1
  60. package/modules/hall-ui/public/watch.html +1 -1
  61. package/modules/hall-ui/public/work.html +1 -1
  62. package/modules/lifecycle/home-city.js +17 -3
  63. package/modules/onboarding/founding-mode.js +45 -1
  64. package/modules/onboarding/genesis-home.js +16 -2
  65. package/modules/onboarding/guide-handoff.js +109 -0
  66. package/modules/onboarding/routes/genesis-work.js +9 -5
  67. package/modules/platform-identity/module.json +1 -1
  68. package/modules/platform-identity/project-standing.js +49 -0
  69. package/modules/platform-identity/routes/my-projects.js +8 -0
  70. package/modules/provisioning/grants.js +126 -0
  71. package/modules/provisioning/guide-handoff.js +65 -0
  72. package/modules/provisioning/migrations/provisioning_038_render_promote.sql +43 -0
  73. package/modules/provisioning/migrations/provisioning_039_project_grants.sql +40 -0
  74. package/modules/provisioning/module.json +2 -1
  75. package/modules/provisioning/project-refusal.js +107 -0
  76. package/modules/provisioning/provisioning.js +4 -4
  77. package/modules/provisioning/render-lookup.js +36 -1
  78. package/modules/provisioning/render-promote.js +80 -0
  79. package/modules/provisioning/route-helpers.js +70 -16
  80. package/modules/provisioning/routes/env-manifest.js +21 -5
  81. package/modules/provisioning/routes/grants.js +141 -0
  82. package/modules/provisioning/routes/provisioning.js +12 -9
  83. package/modules/provisioning/routes/render-standup.js +82 -3
  84. package/modules/public-landing/public/projects.html +178 -1
  85. package/package-lock.json +2 -2
  86. package/package.json +1 -1
  87. package/release-notes.json +28 -0
  88. package/scripts/bongos/genesis.js +11 -1
  89. package/scripts/bongos/government-walk-sole.js +41 -0
  90. package/scripts/bongos/government-walk.js +15 -16
  91. package/scripts/bongos/provision-render-common.js +48 -0
  92. package/scripts/bongos/provision-render-promote.js +93 -0
  93. package/scripts/bongos/provision-render-sandbox.js +14 -2
  94. package/scripts/bongos/provision-render.js +4 -36
  95. package/scripts/bongos/provision.js +1 -0
  96. package/scripts/bongos/render-api.js +17 -2
  97. package/scripts/gds/government-walk-sole.js +6 -0
  98. package/scripts/gds/provision-render-common.js +6 -0
  99. package/scripts/gds/provision-render-promote.js +6 -0
  100. package/src/bongos/founding-page-gate.js +67 -0
  101. package/src/bongos/serve-internal.js +3 -0
  102. package/src/branding.js +4 -0
  103. package/src/module-api.js +1 -1
  104. package/tests/fleet_cost_view.mjs +2 -0
  105. package/tests/founding_mode.mjs +84 -8
  106. package/tests/founding_page_gate.mjs +105 -0
  107. package/tests/front_door_guide_existing.mjs +1 -0
  108. package/tests/genesis_government.mjs +5 -1
  109. package/tests/genesis_home.mjs +41 -0
  110. package/tests/government_charter_advice.mjs +49 -0
  111. package/tests/government_charter_forms.mjs +14 -3
  112. package/tests/government_seed.mjs +1 -0
  113. package/tests/government_sole_member.mjs +153 -0
  114. package/tests/guide_handoff.mjs +322 -0
  115. package/tests/home_city.mjs +52 -0
  116. package/tests/nav_permission_atoms.mjs +1 -1
  117. package/tests/project_catalog_routes.mjs +2 -0
  118. package/tests/provision_settings_apply.mjs +2 -1
  119. package/tests/provisioning_env_manifest.mjs +4 -0
  120. package/tests/provisioning_env_manifest_edit_delete.mjs +2 -0
  121. package/tests/provisioning_project_grants.mjs +458 -0
  122. package/tests/provisioning_project_owner_gate.mjs +3 -1
  123. package/tests/provisioning_project_refusal.mjs +316 -0
  124. package/tests/provisioning_render_route.mjs +1 -1
  125. package/tests/provisioning_restart.mjs +2 -0
  126. package/tests/provisioning_settings.mjs +2 -0
  127. package/tests/provisioning_settings_apply.mjs +1 -1
  128. package/tests/provisioning_settings_env.mjs +2 -2
  129. package/tests/provisioning_teardown_intent.mjs +2 -2
  130. package/tests/render_promote.mjs +309 -0
  131. package/tests/render_sandbox.mjs +9 -4
@@ -4,7 +4,7 @@
4
4
  >
5
5
  > **Relationship to the rest of the project:**
6
6
  > - [`CLAUDE.md`](../CLAUDE.md) is the **portable methodology core** Cloud Bongos ships — working rules, session protocol, claim→ship lifecycle, the [ADR 0061](adr/0061-context-layer-decomposition.md) content charter, the nested-`CLAUDE.md` pattern. Instance-agnostic; you do not author it.
7
- > - **The founder's own answers come first.** What the project is, who it is for, what success looks like and the bet it makes are asked in the genesis scope interview and stored as answers in Bongos, not here. `node scripts/bongos/genesis.js context` (or `npx bongos genesis context`) prints them. Where this file and those answers disagree, the answers are the founder's latest word ([ADR 0408](adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md), task 1005114).
7
+ > - **The founder's own answers come first.** What the project is, who it is for, what success looks like and the bet it makes are asked in the genesis scope interview and stored as answers in Bongos, not here. `node scripts/bongos/genesis.js context` (or `npx bongos genesis context`) prints them. Where this file and those answers disagree, the answers are the founder's latest word ([ADR 0408](adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md), task 1005114). A founder who planned the project with the start-a-project guide before signing up also has a hand-off file (where the project started, the plan, the recommended modules and the first steps); the same command prints it first (task 1005274).
8
8
  > - **This file is the *prose* identity** — lore, vision, the why. The **machine-readable** identity (exact strings code reads: product/world/company names, domains, cookie/OAuth origins, currency label, palette, repo binding) lives in the branding contract [`config/branding.json`](../config/branding.json) ([`docs/branding-contract.md`](branding-contract.md)). **Where a name must be exact for code, the branding contract wins** — never re-hardcode a contract-owned string here.
9
9
 
10
10
  <!--
@@ -0,0 +1,120 @@
1
+ 'use strict';
2
+
3
+ // modules/front-door-guide/handoff.js — THE GUIDE'S HAND-OFF (task 1005274, goal 1000106):
4
+ // what a founder told the start-a-project guide, carried forward so nothing is asked twice.
5
+ // PURE: a saved Plan in, words out. Nothing here calls an AI; it moves saved answers.
6
+ //
7
+ // Three readers, one source (the Plan the guide saved, plan.js):
8
+ // * the creation page's pre-fill reads the Plan itself (GET /front-door-guide/mine);
9
+ // * scopeAnswers(plan) → the Genesis scope interview's carried-over answers;
10
+ // * handoffMarkdown(plan) → the hand-off file: what the project is, the plan, the
11
+ // recommended modules and the first steps, written for the founder's own AI.
12
+ //
13
+ // THE SCOPE MAP IS EXPLICIT, and a question the plan does not answer stays EMPTY. The
14
+ // plan's fields are what the guide asked; the scope interview's six questions
15
+ // (modules/onboarding/genesis-work.js QUESTIONS.scope) are the founder's own. Only one
16
+ // field says the same thing as a scope question. Every other question maps to null on
17
+ // purpose: inventing "who it is for" or "the bet" from a chat that never asked would put
18
+ // words in the founder's mouth, which is the one thing genesis exists to prevent.
19
+
20
+ const SCOPE_MAP = Object.freeze({
21
+ what_creating: 'summary', // "what are you creating" ← the guide's summary of what they said
22
+ goal_impact: null, // the guide never asks why or for what impact
23
+ success_two_years: null, // nor what success looks like
24
+ reach_who: null, // the summary may mention who, but not as an answer to this
25
+ work_with_who: null, // nor who they want working with them
26
+ others_wrong: null, // nor the bet
27
+ });
28
+
29
+ const ANSWER_MAX = 4000; // genesis-work.js LIMITS.answer
30
+ const MARKDOWN_MAX = 12000; // the hand-off rides an env line to the new project; keep it bounded
31
+ const PROJECT_TYPE_WORDS = Object.freeze({
32
+ game: 'A game', research: 'Research', business: 'A business', 'non-profit': 'A non-profit', 'not-sure': 'Not decided yet',
33
+ });
34
+
35
+ const str = (v) => (typeof v === 'string' ? v.trim() : '');
36
+ const list = (v) => (Array.isArray(v) ? v : []);
37
+
38
+ // scopeAnswers(plan) → { question_id: text } for the questions the plan answers, and only
39
+ // those. An empty object for no plan.
40
+ function scopeAnswers(plan) {
41
+ const out = {};
42
+ if (!plan || typeof plan !== 'object') return out;
43
+ for (const [question, field] of Object.entries(SCOPE_MAP)) {
44
+ if (!field) continue;
45
+ const words = str(plan[field]).slice(0, ANSWER_MAX);
46
+ if (words) out[question] = words;
47
+ }
48
+ return out;
49
+ }
50
+
51
+ // handoffMarkdown(plan, { name }) → the hand-off file, or '' for no usable plan. Written
52
+ // so an AI session that reads ONLY this file can say what the project is for and what to
53
+ // do next, and knows these are carried-over words the founder has not yet confirmed.
54
+ function handoffMarkdown(plan, { name = '' } = {}) {
55
+ if (!plan || typeof plan !== 'object' || !str(plan.summary)) return '';
56
+ const title = str(name);
57
+ const lines = [
58
+ `# ${title ? `${title}: ` : ''}where this project started`,
59
+ '',
60
+ 'Before signing up, the founder talked with the start-a-project guide. This is what came out of that conversation, carried over so nobody asks them the same things again.',
61
+ '',
62
+ 'If you are an AI session working on this project: read this first. It is a starting point, not the founder\'s final word. Where the founder has since answered the Genesis questions (`npx bongos genesis context`), their answers win. Confirm anything below with them before relying on it, and never ask them again what is already written here.',
63
+ '',
64
+ '## What the project is',
65
+ '',
66
+ str(plan.summary),
67
+ '',
68
+ ];
69
+ const type = PROJECT_TYPE_WORDS[plan.project_type];
70
+ if (type) lines.push(`**Kind of project:** ${type}`, '');
71
+ if (plan.starting_point === 'existing') {
72
+ lines.push('**Starting point:** they already have something.', '');
73
+ const have = list(plan.what_you_have).map(str).filter(Boolean);
74
+ if (have.length) lines.push('What they said they already have:', '', ...have.map((h) => `- ${h}`), '');
75
+ } else if (plan.starting_point === 'fresh') {
76
+ lines.push('**Starting point:** starting fresh.', '');
77
+ }
78
+ const how = list(plan.how_it_works).map(str).filter(Boolean);
79
+ if (how.length) lines.push('## The plan', '', ...how.map((h) => `- ${h}`), '');
80
+ const modules = list(plan.modules).filter((m) => m && str(m.key));
81
+ if (modules.length) {
82
+ lines.push('## Recommended modules', '');
83
+ for (const m of modules) {
84
+ const why = str(m.why);
85
+ lines.push(`- **${str(m.label) || str(m.key)}** (\`${str(m.key)}\`)${str(m.summary) ? `: ${str(m.summary)}` : ''}${why ? ` Why: ${why}` : ''}`);
86
+ }
87
+ lines.push('');
88
+ }
89
+ const steps = list(plan.steps).filter((s) => s && str(s.title));
90
+ if (steps.length) {
91
+ lines.push('## First steps', '');
92
+ steps.forEach((s, i) => lines.push(`${i + 1}. **${str(s.title)}**${str(s.detail) ? ` ${str(s.detail)}` : ''}`));
93
+ lines.push('');
94
+ }
95
+ const needs = list(plan.needs).filter((n) => n && str(n.item));
96
+ if (needs.length) {
97
+ lines.push('## What they will need', '');
98
+ for (const n of needs) lines.push(`- **${str(n.item)}**${str(n.detail) ? `: ${str(n.detail)}` : ''}`);
99
+ lines.push('');
100
+ }
101
+ const open = list(plan.open_questions).map(str).filter(Boolean);
102
+ if (open.length) lines.push('## Still open', '', ...open.map((q) => `- ${q}`), '');
103
+ lines.push('## Your next step', '',
104
+ steps.length
105
+ ? `Start with step 1, "${str(steps[0].title)}". Then open the Genesis scope questions with the founder (\`npx bongos genesis show scope\`): only "what are you creating" was carried over, and the rest are theirs to answer.`
106
+ : 'Open the Genesis scope questions with the founder (`npx bongos genesis show scope`): only "what are you creating" was carried over, and the rest are theirs to answer.',
107
+ '');
108
+ const md = lines.join('\n');
109
+ return md.length > MARKDOWN_MAX ? `${md.slice(0, MARKDOWN_MAX - 40).trimEnd()}\n\n(The rest was cut for length.)\n` : md;
110
+ }
111
+
112
+ // handoffFor(plan, opts) → { scope, markdown } | null — what the provisioning port hands the
113
+ // create route to stamp on a new project.
114
+ function handoffFor(plan, opts = {}) {
115
+ const markdown = handoffMarkdown(plan, opts);
116
+ if (!markdown) return null;
117
+ return { scope: scopeAnswers(plan), markdown };
118
+ }
119
+
120
+ module.exports = { SCOPE_MAP, MARKDOWN_MAX, scopeAnswers, handoffMarkdown, handoffFor };
@@ -25,7 +25,7 @@
25
25
  "retention-sweep"
26
26
  ]
27
27
  },
28
- "provides": [],
28
+ "provides": ["front-door-guide.handoff"],
29
29
  "consumes": [],
30
30
  "dependencies": { "@anthropic-ai/sdk": "^0.131.0" }
31
31
  }
@@ -5,6 +5,7 @@
5
5
  // POST /front-door-guide/conversations/:id/plan write (or re-read) the plan { token, website? }
6
6
  // POST /front-door-guide/conversations/:id/attach put the chat on my account { token }
7
7
  // GET /front-door-guide/mine my newest chat and its plan
8
+ // GET /front-door-guide/mine/handoff.md my plan as the hand-off file, a download (?name=)
8
9
  //
9
10
  // The plan is ONE structured-output call per chat (a JSON schema, never parsed prose),
10
11
  // asked once the visitor has said something; a second request re-reads the stored plan
@@ -29,6 +30,7 @@ const claude = require('../claude');
29
30
  const { readSettings } = require('../settings');
30
31
  const L = require('../limits');
31
32
  const C = require('../context');
33
+ const H = require('../handoff');
32
34
 
33
35
  const log = api.logger('front-door-guide');
34
36
  const ID_RE = /^\d{1,18}$/;
@@ -62,8 +64,19 @@ function mineView(conv, settings) {
62
64
  };
63
65
  }
64
66
 
67
+ // The `front-door-guide.handoff` port (task 1005274): the signed-in builder's newest attached
68
+ // plan as { scope, markdown }, or null. The hub's create route stamps it on a new project
69
+ // (modules/provisioning/guide-handoff.js), which is how the guide's answers reach a project
70
+ // whose database this process cannot touch. Read-only.
71
+ async function handoffForBuilder(builderId, { name = '' } = {}) {
72
+ if (builderId == null) return null;
73
+ const conv = await db.latestForBuilder(builderId);
74
+ return conv && conv.plan ? H.handoffFor(conv.plan, { name }) : null;
75
+ }
76
+
65
77
  module.exports = function buildFrontDoorGuidePlanRouter() {
66
78
  const router = express.Router();
79
+ if (!api.hasProvider('front-door-guide.handoff')) api.registerProvider('front-door-guide.handoff', handoffForBuilder);
67
80
  let current = readSettings();
68
81
  const fresh = (req, res, next) => { current = readSettings(); next(); };
69
82
  const planLimit = L.makeLimiter({ windowMs: 60_000, max: () => current.messagesPerMinute, scope: 'front-door-guide-plan' });
@@ -141,5 +154,16 @@ module.exports = function buildFrontDoorGuidePlanRouter() {
141
154
  res.json({ ok: true, conversation: conv ? mineView(conv, current) : null });
142
155
  }));
143
156
 
157
+ // rank: builder — my plan as the hand-off file (task 1005274), offered as a download on the
158
+ // creation page. 404 with no plan. The name is only the file's title; it is never stored.
159
+ router.get('/front-door-guide/mine/handoff.md', api.requireBuilder, api.asyncHandler('GET /front-door-guide/mine/handoff.md', async (req, res) => {
160
+ const name = typeof req.query.name === 'string' ? req.query.name.replace(/[\r\n#]/g, ' ').trim().slice(0, 80) : '';
161
+ const h = await handoffForBuilder(req.builder.id, { name });
162
+ if (!h) return res.status(404).json({ ok: false, error: 'no_plan' });
163
+ res.set('Cache-Control', 'no-store');
164
+ res.set('Content-Disposition', 'attachment; filename="PROJECT-HANDOFF.md"');
165
+ res.type('text/markdown; charset=utf-8').send(h.markdown);
166
+ }));
167
+
144
168
  return router;
145
169
  };
@@ -256,20 +256,20 @@ function validateVote({ direction, reasonMd, sectionKey } = {}) {
256
256
  // { ok: true, vote, fresh: false, self_cast } — idempotent re-cast
257
257
  // { ok: false, reason: 'item_not_found' | 'item_closed' | 'vote_window_expired'
258
258
  // | 'not_board_member' | 'bad_cast_via' | <validateVote reason> }
259
- // `castVia` (task 1005121, government_038) is internal — never read from a request: 'ballot', or 'stage_close' (genesis-close.js: a founder's confirm given by closing a genesis stage — genesis_stage + yes only, no vote karma).
259
+ // `castVia` (task 1005121, government_038) is internal — never read from a request: 'ballot', 'stage_close' (genesis-close.js: a founder's confirm given by closing a genesis stage — genesis_stage + yes only, no vote karma), or 'filed' (sole-member.js, task 1005130 / government_039: a board of one's confirm given by filing — constitutional_amendment + yes only, no vote karma).
260
260
  // 'anti_self_vote' is GONE as of task 1003273 — an author may cast; what they
261
261
  // cannot do is decide alone, which shouldCloseAfterVote and tallyOutcome enforce.
262
262
  // A self-cast is recorded and visible but earns no karma (karma_delta: 0).
263
263
  async function castVote({ itemId, voterId, direction, reasonMd = null, sectionKey = null, castVia = 'ballot' } = {}) {
264
264
  const id = Number(itemId);
265
265
  if (!Number.isFinite(id) || id <= 0) return { ok: false, reason: 'item_not_found' };
266
- if (castVia !== 'ballot' && castVia !== 'stage_close') return { ok: false, reason: 'bad_cast_via' };
266
+ if (!['ballot', 'stage_close', 'filed'].includes(castVia)) return { ok: false, reason: 'bad_cast_via' };
267
267
  const v = validateVote({ direction, reasonMd, sectionKey });
268
268
  if (!v.ok) return v;
269
269
 
270
270
  const item = await db.getBoardItemForVote(id);
271
271
  if (!item) return { ok: false, reason: 'item_not_found' };
272
- if (castVia === 'stage_close' && (item.subject_type !== 'genesis_stage' || v.direction !== 'yes')) return { ok: false, reason: 'bad_cast_via' };
272
+ if (castVia !== 'ballot' && (item.subject_type !== (castVia === 'filed' ? 'constitutional_amendment' : 'genesis_stage') || v.direction !== 'yes')) return { ok: false, reason: 'bad_cast_via' };
273
273
  if (item.closed_at) return { ok: false, reason: 'item_closed' };
274
274
  // The clock is checked here as well as by the R11 poller: a vote landing in
275
275
  // the gap between expiry and the sweep would be a vote after time. The
@@ -321,7 +321,7 @@ async function castVote({ itemId, voterId, direction, reasonMd = null, sectionKe
321
321
  }
322
322
  // Karma rides the FRESH insert only, and the whole route refused a closed or
323
323
  // expired item above, so every paid vote is by construction in-window.
324
- if (isSelfCast || castVia === 'stage_close') {
324
+ if (isSelfCast || castVia !== 'ballot') {
325
325
  return { ok: true, vote: inserted, fresh: true, karma_delta: 0, self_cast: isSelfCast };
326
326
  }
327
327
  await db.insertVoteKarma({ voterId, itemId: id, delta: VOTE_KARMA_DELTA });
@@ -906,9 +906,10 @@ async function proposeCharter({
906
906
  }
907
907
  const unchanged = ['membership', 'pass_rule', 'window_minutes', 'close_early_on_full_turnout']
908
908
  .every((k) => v.proposal[k] === live[k]);
909
- if (unchanged) return { ok: false, reason: 'no_change' };
910
-
909
+ // task 1005130: a choice that changes nothing SAYS so, and counts as done.
910
+ if (unchanged) return { ok: false, reason: 'no_change', in_force: charter.readCharter(live).name, complete: true, message: charter.alreadyLine(live) };
911
911
  const readsAs = charter.readCharter(v.proposal);
912
+ const next = `${readsAs.form === 'custom' ? 'These settings' : `Choosing ${readsAs.name}`} ${charter.whatHappensNext(live)}`;
912
913
  const out = await proposeAmendment({
913
914
  proposal: v.proposal,
914
915
  // The record says in words what was chosen, so the amendment history reads
@@ -917,7 +918,7 @@ async function proposeCharter({
917
918
  proposedBy,
918
919
  acknowledgeSelfRemoval,
919
920
  });
920
- return out.ok ? { ...out, reads_as: readsAs } : out;
921
+ return out.ok ? { ...out, reads_as: readsAs, what_happens_next: next } : out;
921
922
  }
922
923
 
923
924
  // ── who decides what (task 1004529, ADR 0363) ───────────────────────────────
@@ -67,12 +67,18 @@ function draftBylaws({ charter: rec, preset, prose = {} }) {
67
67
  // buildAdvice({ answers, prose, headcount, type, laws }) — PURE. `answers` holds only
68
68
  // the closed answers the founder actually gave (not "not sure yet", not skipped);
69
69
  // `laws` is db.listLaws() rows, so the preset view says which is in force now.
70
- function buildAdvice({ answers = {}, prose = {}, headcount = 0, type = null, laws = [] } = {}) {
70
+ function buildAdvice({ answers = {}, prose = {}, headcount = 0, type = null, laws = [], board = null } = {}) {
71
71
  const rec = recommendedCharter(headcount, type, answers);
72
72
  const preset = presets.recommendedPreset(answers);
73
73
  const view = presets.presetsView(laws);
74
+ // task 1005130: what is true NOW, before the founder chooses — the form in force,
75
+ // every form saying who decides and what closing takes, and what choosing the
76
+ // recommended one would do (nothing, when it is already in force).
77
+ const forms = board ? charter.charterView(board) : null;
74
78
  return {
75
- charter: rec,
79
+ in_force: forms ? forms.in_force : null,
80
+ forms: forms ? forms.forms : null,
81
+ charter: board ? { ...rec, on_choose: charter.choiceOutcome(board, rec.key) } : rec,
76
82
  // null when the founder's principles do not point at one — no preset is fine
77
83
  preset: preset ? { ...preset, locks: (view.presets.find((p) => p.key === preset.key) || {}).locks || [] } : null,
78
84
  preset_in_force: view.current,
@@ -91,13 +97,14 @@ function recommendedCharter(headcount, type, answers) {
91
97
  // otherwise the live count of active builders.
92
98
  async function charterAdvice({ answers, prose, headcount = null, type = null } = {}, deps = {}) {
93
99
  const db = deps.db || require('./db');
100
+ const board = deps.board !== undefined ? deps.board : require('./config').loadGovernmentConfig().board;
94
101
  const [count, laws] = await Promise.all([
95
102
  headcount != null && Number(headcount) > 0 ? Number(headcount) : db.countActiveBuilders(),
96
103
  db.listLaws(),
97
104
  ]);
98
105
  const p = {};
99
106
  for (const k of PROSE) if (prose && text(prose[k])) p[k] = text(prose[k]);
100
- return buildAdvice({ answers, prose: p, headcount: count, type, laws });
107
+ return buildAdvice({ answers, prose: p, headcount: count, type, laws, board });
101
108
  }
102
109
 
103
110
  module.exports = { buildAdvice, charterAdvice };
@@ -123,6 +123,41 @@ const FORMS = freezeAll([
123
123
  explanation: 'Self-governing teams coordinate under one shared pact. Coming later, for projects with many teams.' },
124
124
  ]);
125
125
 
126
+ // ── what a form asks of the founder day to day (task 1005130) ───────────────
127
+ // Owner, 2026-11-07: "I am the monarch" read as "nothing is asked of me", and the
128
+ // project still asked for a vote to close a stage. Monarchy means ONE yes decides,
129
+ // not NO yes is needed. So every form says, beside its name, who decides and what
130
+ // closing something takes — DERIVED from its franchise and pass rule, never written
131
+ // by hand, so it can never disagree with what the board actually runs.
132
+ const WHO_DECIDES = Object.freeze({
133
+ founder: 'You decide.',
134
+ trusted: 'You and the builders who have earned the trusted rank decide together.',
135
+ everyone: 'Every builder on the project decides.',
136
+ council: 'The council you appoint decides.',
137
+ custom: 'The members these settings seat decide.',
138
+ });
139
+ const TO_CLOSE = Object.freeze({
140
+ first_ratifier: 'Each decision takes one yes from one of them.',
141
+ consent: 'Each decision passes unless one of them objects, once they have all voted (or its clock runs out, if one is set).',
142
+ majority: 'Each decision needs a yes from more than half of them.',
143
+ unanimous: 'Each decision needs everyone who votes to agree, and at least one yes.',
144
+ });
145
+ // A board of one: every rule comes down to that one person's vote.
146
+ const TO_CLOSE_FOUNDER = Object.freeze({
147
+ first_ratifier: 'Each decision takes one yes from you.',
148
+ consent: 'Each decision takes your vote: it passes unless you object.',
149
+ majority: 'Each decision takes one yes from you.',
150
+ unanimous: 'Each decision takes one yes from you.',
151
+ });
152
+
153
+ // dutyOf(franchise, passRule) → { who_decides, to_close } in plain words.
154
+ function dutyOf(franchise, passRule) {
155
+ const who = WHO_DECIDES[franchise] || WHO_DECIDES.custom;
156
+ const close = (franchise === 'founder' ? TO_CLOSE_FOUNDER : TO_CLOSE)[passRule]
157
+ || 'This pass rule is not one this version knows; only its clock can close a decision.';
158
+ return { who_decides: who, to_close: close };
159
+ }
160
+
126
161
  const CUSTOM_EXPLANATION = 'These settings match no named form of government. They are still the real rules the board runs under.';
127
162
 
128
163
  const byKey = (list, key) => list.find((x) => x.key === key) || null;
@@ -152,8 +187,8 @@ function readCharter(board) {
152
187
  const passRule = byKey(PASS_RULE_OPTIONS, b.pass_rule) ? b.pass_rule : 'custom';
153
188
  const form = FORMS.find((f) => f.available && f.franchise === franchise && f.pass_rule === passRule);
154
189
  return Object.freeze(form
155
- ? { form: form.key, name: form.name, explanation: form.explanation, franchise, pass_rule: passRule }
156
- : { form: 'custom', name: 'Custom', explanation: CUSTOM_EXPLANATION, franchise, pass_rule: passRule });
190
+ ? { form: form.key, name: form.name, explanation: form.explanation, franchise, pass_rule: passRule, ...dutyOf(franchise, passRule) }
191
+ : { form: 'custom', name: 'Custom', explanation: CUSTOM_EXPLANATION, franchise, pass_rule: passRule, ...dutyOf(franchise, passRule) });
157
192
  }
158
193
 
159
194
  // Build the WHOLE constitution a charter choice makes, on top of the live one.
@@ -209,17 +244,62 @@ function buildCharterProposal(liveBoard, { form, franchise, councilRank, passRul
209
244
  // The whole vocabulary plus what the constitution in force reads as — the
210
245
  // payload the genesis charter and legislation stages render from.
211
246
  function charterView(liveBoard) {
247
+ const readsAs = readCharter(liveBoard);
212
248
  return {
213
- forms: FORMS,
249
+ // Every form, each saying what it asks day to day, and the one in force marked.
250
+ forms: FORMS.map((f) => (f.available
251
+ ? { ...f, ...dutyOf(f.franchise, f.pass_rule), current: f.key === readsAs.form }
252
+ : { ...f, current: false })),
253
+ in_force: inForce(readsAs),
214
254
  variables: VARIABLES.map((v) => (
215
255
  v.key === 'franchise' ? { ...v, options: FRANCHISE_OPTIONS }
216
256
  : v.key === 'pass_rule' ? { ...v, options: PASS_RULE_OPTIONS }
217
257
  : v.key === 'trajectory' ? { ...v, options: TRAJECTORIES }
218
258
  : v)),
219
- reads_as: readCharter(liveBoard),
259
+ reads_as: readsAs,
220
260
  };
221
261
  }
222
262
 
263
+ // inForce(readsAs) → what is true NOW, said before the founder chooses (task 1005130):
264
+ // every project opens as Monarchy / BDFL, and a founder should learn that from the
265
+ // platform rather than infer it from a choice that does nothing.
266
+ function inForce(readsAs) {
267
+ const opening = readsAs.form === 'monarchy'
268
+ ? ' Every project opens as one, so choosing Monarchy / BDFL changes nothing.'
269
+ : '';
270
+ return Object.freeze({
271
+ form: readsAs.form,
272
+ name: readsAs.name,
273
+ who_decides: readsAs.who_decides,
274
+ to_close: readsAs.to_close,
275
+ line: `This project runs as ${readsAs.name} now.${opening} ${readsAs.who_decides} ${readsAs.to_close}`,
276
+ });
277
+ }
278
+
279
+ // choiceOutcome(liveBoard, formKey) → what choosing a form would DO, in words: nothing
280
+ // (it is already in force, so this part of the stage is done), or an amendment
281
+ // decided under the government in force — and, when one yes carries it, whose.
282
+ function choiceOutcome(liveBoard, formKey) {
283
+ const now = readCharter(liveBoard);
284
+ const f = byKey(FORMS, formKey);
285
+ if (!f) return null;
286
+ if (now.form === f.key) return Object.freeze({ changes: false, complete: true, line: alreadyLine(liveBoard) });
287
+ return Object.freeze({ changes: true, complete: false, line: `Choosing ${f.name} ${whatHappensNext(liveBoard)}` });
288
+ }
289
+
290
+ // whatHappensNext(liveBoard) → the rest of the sentence "Choosing X …": a change is
291
+ // filed as an amendment and decided under the government in force, and who carries it.
292
+ function whatHappensNext(liveBoard) {
293
+ const now = readCharter(liveBoard);
294
+ const oneYes = now.pass_rule === 'first_ratifier';
295
+ const whose = now.franchise === 'founder' ? 'your own one yes applies it' : 'one yes from a member applies it';
296
+ return `is filed as a change to the rules and decided under the government in force (${now.name}). ${oneYes ? `Under it, ${whose}.` : now.to_close}`;
297
+ }
298
+ // alreadyLine(liveBoard) → "You are already a Monarchy / BDFL. Nothing to change…"
299
+ function alreadyLine(liveBoard) {
300
+ return `You are already a ${readCharter(liveBoard).name}. Nothing to change, and this choice is done.`;
301
+ }
302
+
223
303
  module.exports = {
224
304
  FORMS,
225
305
  VARIABLES,
@@ -227,6 +307,10 @@ module.exports = {
227
307
  PASS_RULE_OPTIONS,
228
308
  TRAJECTORIES,
229
309
  readCharter,
310
+ dutyOf,
311
+ choiceOutcome,
312
+ alreadyLine,
313
+ whatHappensNext,
230
314
  buildCharterProposal,
231
315
  charterView,
232
316
  };
@@ -0,0 +1,22 @@
1
+ -- government_039_vote_cast_via_filed.sql — a third way a vote is cast (task 1005130).
2
+ --
3
+ -- A board of ONE under first_ratifier: the board exists only to collect that one
4
+ -- person's yes, so asking them to vote on their own instruction is ceremony, not
5
+ -- governance (owner feedback 2026-11-07; the same ruling task 1005121 shipped for
6
+ -- closing a genesis stage). When that sole member FILES an amendment, their yes is
7
+ -- cast in the same call (sole-member.js) — and, as with a stage close, the record
8
+ -- says HOW it was cast rather than looking like a clicked ballot.
9
+ --
10
+ -- cast_via 'ballot' a vote cast in the Board Room (the vote route)
11
+ -- 'stage_close' the confirm given by closing a genesis stage
12
+ -- 'filed' the sole member's confirm given by filing an amendment;
13
+ -- only board.js writes it, only on a
14
+ -- constitutional_amendment item, only yes
15
+
16
+ BEGIN;
17
+
18
+ ALTER TABLE government_board_votes DROP CONSTRAINT IF EXISTS government_board_votes_cast_via_check;
19
+ ALTER TABLE government_board_votes ADD CONSTRAINT government_board_votes_cast_via_check
20
+ CHECK (cast_via IN ('ballot', 'stage_close', 'filed') AND (cast_via = 'ballot' OR direction = 'yes'));
21
+
22
+ COMMIT;
@@ -137,7 +137,7 @@ const governmentPort = {
137
137
  // an ordinary amendment under the constitution in force — for the genesis
138
138
  // home, which renders the stages from GET /government/constitution's
139
139
  // `charter` block. Opening a sitting decides nothing; the vote does.
140
- proposeCharter: board.proposeCharter,
140
+ proposeCharter: (a) => require('../sole-member').proposeCharter(a), // task 1005130: a board of one decides by filing (sole-member.js), here and on every amendment route
141
141
  charterAdvice: (input) => require('../charter-advice').charterAdvice(input), // task 1005119: advice only, never a write
142
142
  genesisStageStatus: board.genesisStageStatus,
143
143
  // task 1004529 — who decides what. The table, resolved, and the check a
@@ -972,7 +972,7 @@ module.exports = function buildGovernmentRouter() {
972
972
  });
973
973
  }
974
974
  try {
975
- const out = await board.proposeAmendment({
975
+ const out = await require('../sole-member').proposeAmendment({
976
976
  proposal: req.body.proposal,
977
977
  rationaleMd: req.body.rationale_md ?? null,
978
978
  proposedBy: req.builder.id,
@@ -1002,7 +1002,7 @@ module.exports = function buildGovernmentRouter() {
1002
1002
  if (wording && typeof wording.flagInvestmentWording === 'function' && typeof req.body.rationale_md === 'string') {
1003
1003
  wording.flagInvestmentWording(req.body.rationale_md, { where: 'board item', ref: out.item && out.item.id });
1004
1004
  }
1005
- res.status(201).json({ ok: true, amendment: out.amendment, item: out.item });
1005
+ res.status(201).json({ ok: true, amendment: out.amendment, item: out.item , decided: out.decided });
1006
1006
  } catch (err) {
1007
1007
  log.error('[gds] POST /government/board/items', err);
1008
1008
  res.fail('propose_failed', { status: 500, message: 'internal error' });
@@ -1033,7 +1033,7 @@ module.exports = function buildGovernmentRouter() {
1033
1033
  return res.fail('invalid_proposal', 400, { reason: 'bad_window_minutes', expected: 'null (no clock) or a whole number of minutes' });
1034
1034
  }
1035
1035
  try {
1036
- const out = await board.proposeCharter({
1036
+ const out = await require('../sole-member').proposeCharter({
1037
1037
  form: body.form ?? undefined,
1038
1038
  franchise: body.franchise ?? undefined,
1039
1039
  councilRank: body.council_rank ?? undefined,
@@ -1054,7 +1054,7 @@ module.exports = function buildGovernmentRouter() {
1054
1054
  });
1055
1055
  }
1056
1056
  if (out.reason === 'no_change') {
1057
- return res.fail('no_change', 409, { reason: 'the constitution in force already has these settings' });
1057
+ return res.fail('no_change', 409, { reason: 'the constitution in force already has these settings', in_force: out.in_force, complete: true, message: out.message });
1058
1058
  }
1059
1059
  return res.fail('invalid_charter', 400, {
1060
1060
  reason: out.reason,
@@ -1063,7 +1063,7 @@ module.exports = function buildGovernmentRouter() {
1063
1063
  ...(out.rank_key ? { rank_key: out.rank_key } : {}),
1064
1064
  });
1065
1065
  }
1066
- res.status(201).json({ ok: true, amendment: out.amendment, item: out.item, reads_as: out.reads_as });
1066
+ res.status(201).json({ ok: true, amendment: out.amendment, item: out.item, reads_as: out.reads_as, what_happens_next: out.what_happens_next , decided: out.decided });
1067
1067
  } catch (err) {
1068
1068
  log.error('[gds] POST /government/board/charter', err);
1069
1069
  res.fail('charter_failed', { status: 500, message: 'internal error' });
@@ -1090,7 +1090,7 @@ module.exports = function buildGovernmentRouter() {
1090
1090
  rationale_md: { type: 'string', maxLength: 5000 },
1091
1091
  })) return;
1092
1092
  try {
1093
- const out = await board.proposeDecisionRules({
1093
+ const out = await require('../sole-member').proposeDecisionRules({
1094
1094
  rules: req.body.rules,
1095
1095
  rationaleMd: req.body.rationale_md ?? null,
1096
1096
  proposedBy: req.builder.id,
@@ -1105,7 +1105,7 @@ module.exports = function buildGovernmentRouter() {
1105
1105
  ...(out.rank_key ? { rank_key: out.rank_key } : {}),
1106
1106
  });
1107
1107
  }
1108
- res.status(201).json({ ok: true, amendment: out.amendment, item: out.item, rules: out.rules });
1108
+ res.status(201).json({ ok: true, amendment: out.amendment, item: out.item, rules: out.rules , decided: out.decided });
1109
1109
  } catch (err) {
1110
1110
  log.error('[gds] POST /government/board/decision-rules', err);
1111
1111
  res.fail('propose_failed', { status: 500, message: 'internal error' });
@@ -1129,7 +1129,7 @@ module.exports = function buildGovernmentRouter() {
1129
1129
  rationale_md: { type: 'string', maxLength: 5000 },
1130
1130
  })) return;
1131
1131
  try {
1132
- const out = await board.proposeLaws({
1132
+ const out = await require('../sole-member').proposeLaws({
1133
1133
  enact: req.body.enact,
1134
1134
  repeal: req.body.repeal,
1135
1135
  rationaleMd: req.body.rationale_md ?? null,
@@ -1150,7 +1150,7 @@ module.exports = function buildGovernmentRouter() {
1150
1150
  ...(out.setting ? { setting: out.setting } : {}),
1151
1151
  });
1152
1152
  }
1153
- res.status(201).json({ ok: true, amendment: out.amendment, item: out.item, proposal: out.proposal });
1153
+ res.status(201).json({ ok: true, amendment: out.amendment, item: out.item, proposal: out.proposal , decided: out.decided });
1154
1154
  } catch (err) {
1155
1155
  log.error('[gds] POST /government/board/laws', err);
1156
1156
  res.fail('propose_failed', { status: 500, message: 'internal error' });
@@ -1200,14 +1200,14 @@ module.exports = function buildGovernmentRouter() {
1200
1200
  if (sw.reason === 'preset_in_force') return res.fail('preset_in_force', 409, { preset: req.body.preset });
1201
1201
  return res.fail('invalid_preset', 400, { reason: sw.reason, ...(sw.expected ? { expected: sw.expected } : {}) });
1202
1202
  }
1203
- const out = await board.proposeLaws({ ...sw.proposal, rationaleMd: sw.rationale, proposedBy: req.builder.id });
1203
+ const out = await require('../sole-member').proposeLaws({ ...sw.proposal, rationaleMd: sw.rationale, proposedBy: req.builder.id });
1204
1204
  if (!out.ok) {
1205
1205
  if (out.reason === 'already_in_force' || out.reason === 'setting_already_locked' || out.reason === 'not_in_force') {
1206
1206
  return res.fail(out.reason, 409, { ...(out.law_id ? { law_id: out.law_id } : {}), ...(out.setting ? { setting: out.setting } : {}) });
1207
1207
  }
1208
1208
  return res.fail('invalid_preset', 400, { reason: out.reason });
1209
1209
  }
1210
- res.status(201).json({ ok: true, preset: req.body.preset, amendment: out.amendment, item: out.item, proposal: out.proposal });
1210
+ res.status(201).json({ ok: true, preset: req.body.preset, amendment: out.amendment, item: out.item, proposal: out.proposal , decided: out.decided });
1211
1211
  } catch (err) {
1212
1212
  log.error('[gds] POST /government/board/presets', err);
1213
1213
  res.fail('propose_failed', { status: 500, message: 'internal error' });
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+ // modules/government/sole-member.js — a board of ONE decides by filing (task 1005130,
3
+ // part C; owner feedback 2026-11-07, the ruling task 1005121 shipped for closing a
4
+ // genesis stage, applied wherever it holds).
5
+ //
6
+ // WHY. Under monarchy the board is one person, and a sitting it opens exists only to
7
+ // collect that person's yes. Asking them to vote on their own instruction is a second
8
+ // step that decides nothing: "the government is in place after it is defined, but it
9
+ // still asked me to cast a vote". So when the SOLE member files an amendment, their
10
+ // yes is cast in the same call and the sitting closes.
11
+ //
12
+ // EXACTLY THREE CONDITIONS, read on the server from the item's own snapshot — the
13
+ // constitution in force at the moment of filing — never from the request:
14
+ // 1. the pass rule is first_ratifier (one yes decides);
15
+ // 2. the board that snapshot seats has exactly one member (live rows, uncached);
16
+ // 3. the person filing IS that member.
17
+ // Anything else — two or more members, any other rule, a caller who is not the sole
18
+ // member — returns the filing untouched, byte for byte what it was before. Under
19
+ // first_ratifier the first yes closes regardless of a window or early-close setting,
20
+ // so neither changes the outcome here.
21
+ //
22
+ // NOTHING IS SKIPPED. The filing ran its own permission gate, strict validation and
23
+ // self-removal guard before this is reached; the yes goes through board.castVote,
24
+ // which re-checks membership under the snapshot, the window and the clock; the close
25
+ // goes through board.maybeCloseAfterVote → closeItem, so a passed amendment is applied
26
+ // and recorded exactly as a clicked ballot's would be. The vote is that member's,
27
+ // attributed and timestamped, marked cast_via 'filed' (government_039) so the record
28
+ // tells it from a ballot — the stage-close precedent — and it earns no vote karma.
29
+ //
30
+ // FAIL-OPEN. The filing already stands; if the confirm or the close fails, the
31
+ // sitting is simply left open for an ordinary vote, as before this existed.
32
+ //
33
+ // Composed only from board.js's exports, looked up at call time (genesis-close.js's
34
+ // pattern), so a test's stubs on board's db bite exactly as they do for board itself.
35
+ const board = require('./board.js');
36
+ const { resolveBoardMembers } = require('./board-membership.js');
37
+ const log = require('../../src/module-api').logger('government');
38
+
39
+ // confirmIfSoleMember(out, filedBy) → the filing's answer, plus `decided` when the
40
+ // sole member's filing decided it: { closed, outcome, cast_via, constitution_not_applied? }.
41
+ async function confirmIfSoleMember(out, filedBy) {
42
+ if (!out || !out.ok || !out.item || filedBy == null || filedBy === '') return out;
43
+ const item = out.item;
44
+ const constitution = item.constitution || {};
45
+ if (constitution.pass_rule !== 'first_ratifier') return out;
46
+ try {
47
+ const members = await resolveBoardMembers(constitution);
48
+ if (members.length !== 1 || String(members[0].builder_id) !== String(filedBy)) return out;
49
+ const v = await board.castVote({ itemId: item.id, voterId: filedBy, direction: 'yes', castVia: 'filed' });
50
+ if (!v.ok || !v.fresh) return out;
51
+ const c = await board.maybeCloseAfterVote(item.id);
52
+ return {
53
+ ...out,
54
+ decided: {
55
+ closed: !!(c && c.closed),
56
+ outcome: (c && c.outcome) || null,
57
+ cast_via: 'filed',
58
+ ...(c && c.constitution_not_applied ? { constitution_not_applied: c.constitution_not_applied } : {}),
59
+ },
60
+ };
61
+ } catch (err) {
62
+ log.error('[government] sole-member confirm failed (the filing stands; the sitting is left open):', err);
63
+ return out;
64
+ }
65
+ }
66
+
67
+ // The amendment filers, each followed by the confirm. Same arguments, same answer,
68
+ // plus `decided` when a board of one decided it.
69
+ const wrap = (name) => async (args = {}) => confirmIfSoleMember(await board[name](args), args.proposedBy);
70
+ const proposeAmendment = wrap('proposeAmendment');
71
+ const proposeCharter = wrap('proposeCharter');
72
+ const proposeLaws = wrap('proposeLaws');
73
+ const proposeDecisionRules = wrap('proposeDecisionRules');
74
+
75
+ module.exports = { proposeAmendment, proposeCharter, proposeLaws, proposeDecisionRules };
@@ -92,7 +92,7 @@
92
92
  <script src="/builders/shell-game.js?v=2026-11-11-view2"></script>
93
93
  <script src="/builders/shell-trail.js?v=2026-11-05-numbers"></script>
94
94
  <script src="/builders/craft-mode.js?v=2026-11-10-visor"></script>
95
- <script src="/builders/shell.js?v=2026-11-11-view2"></script>
95
+ <script src="/builders/shell.js?v=2026-11-13-founding"></script>
96
96
  <script src="/builders/hall-dialog.js"></script>
97
97
  <script src="/builders/palette.js" defer></script>
98
98
  <script src="/builders/tweak-editor-lib.js?v=2026-09-28-tw23"></script>
@@ -93,7 +93,7 @@
93
93
  <script src="/builders/shell-game.js?v=2026-11-11-view2"></script>
94
94
  <script src="/builders/shell-trail.js?v=2026-11-05-numbers"></script>
95
95
  <script src="/builders/craft-mode.js?v=2026-11-10-visor"></script>
96
- <script src="/builders/shell.js?v=2026-11-11-view2"></script>
96
+ <script src="/builders/shell.js?v=2026-11-13-founding"></script>
97
97
  <script src="/builders/hall-dialog.js"></script>
98
98
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
99
99
  <script src="/builders/palette.js" defer></script>