@bongos/core 1.20.46 → 1.20.48

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 (68) hide show
  1. package/.bongos-core.json +103 -68
  2. package/docs/copy-inventory.md +230 -225
  3. package/docs/copy-registry.json +371 -286
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/page-inventory.json +7 -4
  6. package/docs/page-readings.json +248 -249
  7. package/modules/hall-ui/public/atlas.html +1 -1
  8. package/modules/hall-ui/public/blockers.html +1 -1
  9. package/modules/hall-ui/public/board-room.html +1 -1
  10. package/modules/hall-ui/public/bulk-toolbar.js +291 -0
  11. package/modules/hall-ui/public/collab.html +1 -1
  12. package/modules/hall-ui/public/commands.html +1 -1
  13. package/modules/hall-ui/public/copy-desk.html +1 -1
  14. package/modules/hall-ui/public/deploy.html +1 -1
  15. package/modules/hall-ui/public/diagrams.html +1 -1
  16. package/modules/hall-ui/public/drachmae.html +1 -1
  17. package/modules/hall-ui/public/fleet.html +1 -1
  18. package/modules/hall-ui/public/gate.html +1 -1
  19. package/modules/hall-ui/public/goals-page.js +29 -44
  20. package/modules/hall-ui/public/goals-render.js +33 -21
  21. package/modules/hall-ui/public/goals.html +13 -17
  22. package/modules/hall-ui/public/government.html +1 -1
  23. package/modules/hall-ui/public/hall-render.js +3 -0
  24. package/modules/hall-ui/public/idea.html +1 -1
  25. package/modules/hall-ui/public/ideas.html +1 -1
  26. package/modules/hall-ui/public/index.html +1 -1
  27. package/modules/hall-ui/public/modules.html +1 -1
  28. package/modules/hall-ui/public/primer.html +1 -1
  29. package/modules/hall-ui/public/profile.html +1 -1
  30. package/modules/hall-ui/public/project-settings.html +1 -1
  31. package/modules/hall-ui/public/ranks.html +1 -1
  32. package/modules/hall-ui/public/roadmap.html +1 -1
  33. package/modules/hall-ui/public/roster.html +1 -1
  34. package/modules/hall-ui/public/sessions.html +1 -1
  35. package/modules/hall-ui/public/settings.html +1 -1
  36. package/modules/hall-ui/public/shell.js +82 -1
  37. package/modules/hall-ui/public/studio.html +1 -1
  38. package/modules/hall-ui/public/style.css +110 -3
  39. package/modules/hall-ui/public/task.html +1 -1
  40. package/modules/hall-ui/public/thinking.html +1 -1
  41. package/modules/hall-ui/public/tweak-editor.html +1 -1
  42. package/modules/hall-ui/public/watch.html +1 -1
  43. package/modules/hall-ui/public/work-founding.states.json +39 -0
  44. package/modules/hall-ui/public/work.css +4 -66
  45. package/modules/hall-ui/public/work.html +3 -2
  46. package/modules/hall-ui/public/work.js +17 -236
  47. package/modules/hall-ui/records/founding-mode.md +31 -0
  48. package/modules/onboarding/founding-mode.js +185 -0
  49. package/modules/onboarding/port.js +7 -0
  50. package/modules/ui-design/kit/fixtures/me-founding.json +24 -0
  51. package/modules/ui-design/kit/serve.js +3 -1
  52. package/package-lock.json +2 -2
  53. package/package.json +1 -1
  54. package/release-notes.json +12 -0
  55. package/src/bongos/routes/me.js +18 -1
  56. package/src/module-api.js +1 -1
  57. package/tests/bulk_toolbar.mjs +202 -0
  58. package/tests/claim_action.mjs +13 -4
  59. package/tests/founding_mode.mjs +294 -0
  60. package/tests/goal_category_ui.mjs +1 -0
  61. package/tests/goal_criterion_confirm_ui.mjs +2 -0
  62. package/tests/goal_forest_page.mjs +1 -0
  63. package/tests/goal_invite_consent.mjs +1 -1
  64. package/tests/goal_mine_lens.mjs +1 -0
  65. package/tests/hall_board_world.mjs +1 -1
  66. package/tests/hall_mine_lens.mjs +1 -0
  67. package/tests/hall_tweak_editor.mjs +1 -1
  68. package/tests/nav_permission_atoms.mjs +1 -1
@@ -14,7 +14,9 @@
14
14
  verification state, which DESIGN.md §Reuse names as the one sanctioned
15
15
  exception to the Fifteen Rule.
16
16
 
17
- goals.html LOADS THIS SHEET TOO — a change here lands on both pages. */
17
+ goals.html NO LONGER LOADS THIS SHEET (task 1003141): what the goal board's
18
+ cards borrowed from it — .claim-count-line, .wip__release, .wip__select — now
19
+ lives in style.css, so this sheet is the board's alone. */
18
20
 
19
21
  /* `.page-head--board` MOVED to style.css (task 1003305) when the ideas composer
20
22
  became its third consumer: a modifier on the shell's own .page-head is shell
@@ -35,17 +37,6 @@
35
37
  .wip-builder { color: var(--ink); font-weight: 500; }
36
38
  .wip-builder small { color: var(--ink-soft); font-weight: 400; }
37
39
 
38
- /* Filtered-count line under the filter bar — the label voice, with the number
39
- itself stepping up to ink so the count reads before the words. */
40
- .claim-count-line {
41
- margin: 0 0 12px;
42
- font-family: var(--label-face);
43
- font-size: var(--label-size);
44
- letter-spacing: var(--label-space);
45
- text-transform: var(--label-case);
46
- color: var(--ink-faint);
47
- }
48
-
49
40
  .backlog-count {
50
41
  font-family: var(--font-mono);
51
42
  font-size: 11px;
@@ -67,27 +58,6 @@
67
58
 
68
59
  /* In-flight table: release action on the viewer's own claims. */
69
60
  .wip__action { text-align: right; white-space: nowrap; }
70
- .wip__release {
71
- display: inline-flex;
72
- align-items: center;
73
- justify-content: center;
74
- min-height: 30px;
75
- padding: 0 14px;
76
- background: none;
77
- border: var(--line) solid var(--rule);
78
- color: var(--ink-soft);
79
- border-radius: var(--radius-pill);
80
- font-size: 13px;
81
- text-transform: lowercase;
82
- cursor: pointer;
83
- transition: background 150ms, color 150ms, border-color 150ms;
84
- }
85
- .wip__release:hover:not(:disabled) {
86
- background: var(--danger-weak);
87
- color: var(--danger);
88
- border-color: color-mix(in srgb, var(--danger) 40%, transparent);
89
- }
90
- .wip__release:disabled { opacity: 0.55; cursor: default; }
91
61
  .wip__mine-none { color: var(--ink-faint); }
92
62
  .wip__row--mine { background: var(--accent-tint); }
93
63
 
@@ -121,7 +91,7 @@
121
91
  .history__when { color: var(--ink-soft); font-size: 12.5px; white-space: nowrap; font-variant-numeric: tabular-nums; }
122
92
 
123
93
 
124
- /* ---- task 1759: multi-select checkbox (the toolbar itself is in style.css) ----
94
+ /* ---- task 1759: multi-select (the toolbar AND the checkbox are in style.css) ----
125
95
  Re-cut for the 24px floor and the world (task 1003304). The floating dark
126
96
  pill is gone: it painted an INVERTED surface (background: var(--ink)) with
127
97
  three rgba(255,255,255,…) literals on top, which was correct only for as long
@@ -130,38 +100,6 @@
130
100
  content column instead, inset by the rail, so it never has to invert. */
131
101
 
132
102
  .wip__select-cell { width: 1%; text-align: center; }
133
- .wip__select {
134
- appearance: none;
135
- -webkit-appearance: none;
136
- position: relative;
137
- width: 24px;
138
- height: 24px;
139
- margin: 0;
140
- cursor: pointer;
141
- }
142
- .wip__select::before {
143
- content: "";
144
- position: absolute;
145
- left: 5px;
146
- top: 5px;
147
- width: 14px;
148
- height: 14px;
149
- border: var(--line) solid var(--rule);
150
- border-radius: 3px;
151
- }
152
- .wip__select:checked::before { background: var(--accent); border-color: var(--accent); }
153
- .wip__select:checked::after {
154
- content: "";
155
- position: absolute;
156
- left: 9px;
157
- top: 8px;
158
- width: 4px;
159
- height: 8px;
160
- border: solid var(--accent-on);
161
- border-width: 0 1.5px 1.5px 0;
162
- transform: rotate(42deg);
163
- }
164
-
165
103
  /* ---- "Only mine" lens (the board's tab-wide filter; goal board's To-do bar
166
104
  borrows the same class) --------------------------------------------------
167
105
  Deliberately QUIETER than the filter bar below it: a checkbox in the label
@@ -171,7 +171,7 @@
171
171
 
172
172
  <!-- The app shell (sidebar + top bar + footer). Dependency-free; runs at parse
173
173
  time so the chrome exists before the deferred page scripts execute. -->
174
- <script src="/builders/shell.js?v=2026-09-30-commands"></script>
174
+ <script src="/builders/shell.js?v=2026-09-30-founding-commands"></script>
175
175
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
176
176
  <script src="/builders/palette.js" defer></script>
177
177
  <!-- shared frontend toolkit (window.OTB) — loaded FIRST so work.js can pull from it. -->
@@ -189,7 +189,8 @@
189
189
  <script src="/builders/work-summary.js" defer></script>
190
190
  <script src="/builders/task-detail.js?v=2026-07-03-taskdetail" defer></script>
191
191
  <script src="/builders/brand-apply.js?v=2026-06-19-r56" defer></script>
192
- <script src="/builders/work.js?v=2026-09-26-claimaction1" defer></script>
192
+ <script src="/builders/bulk-toolbar.js" defer></script>
193
+ <script src="/builders/work.js?v=2026-09-30-bulktoolbar" defer></script>
193
194
  <script src="/builders/copy-on-click.js?v=2026-06-01-copy-1" defer></script>
194
195
  </body>
195
196
  </html>
@@ -187,9 +187,9 @@
187
187
  // The hall's ONE claim/release action (task 1003458). Everything this board
188
188
  // used to hand-roll four times — the POST, the refusal sentence, the
189
189
  // stale-view refresh, the all-or-nothing batch report — lives in
190
- // claim-action.js now; doClaim/doRelease/doBulkClaim/doBulkRelease below keep
191
- // only what is genuinely this page: button text, the re-entrancy guard, the
192
- // confirm() wording and the selection. friendlyBatchReason is injected rather
190
+ // claim-action.js now; doClaim/doRelease below keep only what is
191
+ // genuinely this page: button text and the re-entrancy guard (the bulk
192
+ // toolbar is bulk-toolbar.js, built from this same action). friendlyBatchReason is injected rather
193
193
  // than imported there, so claim-action.js stays dependency-free.
194
194
  // refreshLive is a hoisted declaration; toast and API are already bound above.
195
195
  const claimAction = makeClaimAction({
@@ -291,18 +291,6 @@
291
291
  // String(claim_id) -> task_id for the viewer's OWN in-flight claims (release
292
292
  // is only ever offered on those rows to begin with — see renderInFlight).
293
293
  const selection = { claim: new Set(), release: new Map() };
294
- // Last server dry-run (POST /claims/batch/validate) result, keyed by the
295
- // sorted selected-id set so an unrelated re-render doesn't refire it.
296
- const validateCache = { key: '', parallelSafe: null, byId: new Map() };
297
- let validateDebounce = null;
298
- // Per-task dependency edges for the "can these be sequenced?" cycle check —
299
- // fetched lazily via GET /tasks/:id (task-detail.js's call) since the
300
- // claimable feed doesn't carry dependencies[] per row. Stable for a ready
301
- // task within one page session, so caching by id is safe.
302
- const depsCache = new Map(); // task_id -> [dependency task_id, ...]
303
- const sequentialCache = { key: '', order: null };
304
- let sequentialDebounce = null;
305
-
306
294
  // task 1756: two tasks can't run in parallel if their touches[] overlap — exact
307
295
  // path match or a directory-prefix either way (mirrors the server's touchesOverlap
308
296
  // / the goals-page conflict estimate). Empty touches never conflict.
@@ -700,207 +688,18 @@
700
688
  }
701
689
  }
702
690
 
703
- function selectedClaimIds() { return [...selection.claim].map(Number); }
704
- function selectionKey(ids) { return ids.slice().sort((a, b) => a - b).join(','); }
705
-
706
- async function scheduleValidate(key, ids) {
707
- if (validateDebounce) clearTimeout(validateDebounce);
708
- validateDebounce = setTimeout(async () => {
709
- // The dry run lives in claim-action.js with the rest of the claim path; it
710
- // is best-effort there, so a failure lands as parallelSafe:false with an
711
- // empty map rather than a throw — Parallel stays disabled with a generic
712
- // title instead of copying a prompt for tasks nobody verified. The
713
- // debounce, the cache key and the re-render stay here: page furniture.
714
- const out = await claimAction.validateMany(ids);
715
- validateCache.key = key;
716
- validateCache.parallelSafe = out.parallelSafe;
717
- validateCache.byId = out.byId;
718
- renderBulkToolbar();
719
- }, 350);
720
- }
721
-
722
- async function fetchDeps(taskId) {
723
- if (depsCache.has(taskId)) return depsCache.get(taskId);
724
- let deps = [];
725
- try {
726
- const d = await getJSON(`${API}/tasks/${encodeURIComponent(taskId)}`, { softAuth: true });
727
- if (d && d.task && Array.isArray(d.task.dependencies)) {
728
- deps = d.task.dependencies.map((x) => Number(x.id)).filter((x) => Number.isFinite(x));
729
- }
730
- } catch (_) { /* treat as no known dependencies */ }
731
- depsCache.set(taskId, deps);
732
- return deps;
733
- }
734
-
735
- // Kahn's algorithm restricted to edges BETWEEN the selected ids (an
736
- // out-of-selection dependency doesn't block sequencing the selection
737
- // itself). Returns the topological order, or null if the restricted graph
738
- // has a cycle (can't be sequenced).
739
- function topoOrder(ids, edgesByTask) {
740
- const remaining = new Set(ids);
741
- const order = [];
742
- let progressed = true;
743
- while (remaining.size && progressed) {
744
- progressed = false;
745
- for (const id of Array.from(remaining)) {
746
- const deps = (edgesByTask.get(id) || []).filter((d) => remaining.has(d));
747
- if (deps.length === 0) {
748
- order.push(id);
749
- remaining.delete(id);
750
- progressed = true;
751
- }
752
- }
753
- }
754
- return remaining.size ? null : order;
755
- }
756
-
757
- function scheduleSequentialCheck(key, ids) {
758
- if (sequentialDebounce) clearTimeout(sequentialDebounce);
759
- sequentialDebounce = setTimeout(async () => {
760
- const edgesByTask = new Map();
761
- await Promise.all(ids.map(async (id) => { edgesByTask.set(id, await fetchDeps(id)); }));
762
- sequentialCache.key = key;
763
- sequentialCache.order = topoOrder(ids, edgesByTask);
764
- renderBulkToolbar();
765
- }, 350);
766
- }
767
-
768
- function renderBulkToolbar() {
769
- const bar = $('#bulk-toolbar');
770
- if (!bar) return;
771
- if (selectionTotal() === 0) { bar.hidden = true; return; }
772
- bar.hidden = false;
773
-
774
- const countEl = $('#bulk-count');
775
- if (countEl) {
776
- const parts = [];
777
- if (selection.claim.size) parts.push(`${selection.claim.size} to claim`);
778
- if (selection.release.size) parts.push(`${selection.release.size} to release`);
779
- countEl.textContent = parts.join(' · ');
780
- }
781
-
782
- const claimBtn = $('#bulk-claim-btn');
783
- if (claimBtn) {
784
- claimBtn.disabled = selection.claim.size === 0;
785
- claimBtn.textContent = selection.claim.size > 1 ? `Bulk claim (${selection.claim.size})` : 'Bulk claim';
786
- }
787
- const releaseBtn = $('#bulk-release-btn');
788
- if (releaseBtn) {
789
- releaseBtn.disabled = selection.release.size === 0;
790
- releaseBtn.textContent = selection.release.size > 1 ? `Bulk release (${selection.release.size})` : 'Bulk release';
791
- }
792
-
793
- const parallelBtn = $('#bulk-parallel-btn');
794
- if (parallelBtn) {
795
- if (selection.claim.size < 2) {
796
- parallelBtn.disabled = true;
797
- parallelBtn.title = 'Select at least 2 waiting tasks to copy a parallel-session prompt.';
798
- } else {
799
- const ids = selectedClaimIds();
800
- const key = selectionKey(ids);
801
- if (validateCache.key === key && validateCache.parallelSafe != null) {
802
- parallelBtn.disabled = !validateCache.parallelSafe;
803
- if (!validateCache.parallelSafe) {
804
- const bad = [...validateCache.byId.values()].filter((r) => !r.valid);
805
- const detail = bad.map((r) => `#${r.task_id}: ${(r.reasons || []).map(friendlyBatchReason).join(', ')}`).join(' · ');
806
- parallelBtn.title = detail || 'Selected tasks conflict and cannot run in parallel.';
807
- } else {
808
- parallelBtn.title = '';
809
- }
810
- } else {
811
- parallelBtn.disabled = true;
812
- parallelBtn.title = 'Checking for conflicts…';
813
- scheduleValidate(key, ids);
814
- }
815
- }
816
- }
817
-
818
- const sequentialBtn = $('#bulk-sequential-btn');
819
- if (sequentialBtn) {
820
- if (selection.claim.size < 2) {
821
- sequentialBtn.disabled = true;
822
- sequentialBtn.title = 'Select at least 2 waiting tasks to copy a sequential prompt.';
823
- } else {
824
- const ids = selectedClaimIds();
825
- const key = selectionKey(ids);
826
- if (sequentialCache.key === key) {
827
- sequentialBtn.disabled = sequentialCache.order == null;
828
- sequentialBtn.title = sequentialCache.order == null
829
- ? 'These tasks depend on each other in a cycle and cannot be ordered.'
830
- : '';
831
- } else {
832
- sequentialBtn.disabled = true;
833
- sequentialBtn.title = 'Checking dependency order…';
834
- scheduleSequentialCheck(key, ids);
835
- }
836
- }
837
- }
838
- }
839
-
840
- function clearSelection() {
841
- selection.claim.clear();
842
- selection.release.clear();
843
- renderClaimable();
844
- renderInFlight();
845
- renderBulkToolbar();
846
- }
847
-
848
- function buildBulkParallelPrompt(tasks) {
849
- const hallBase = location.hostname.startsWith('builders.') ? location.origin : `${location.origin}/builders`;
850
- const lines = [
851
- `I want to work on ${tasks.length} Bongos tasks IN PARALLEL — open ${tasks.length} separate Claude Code sessions, each in its own worktree, one per task below, so they can run at the same time without touching each other's files.`,
852
- '',
853
- ];
854
- tasks.forEach((t, i) => {
855
- lines.push(`Session ${i + 1} — task ${t.id} (${t.title}) — ${hallBase}#/task/${t.id}`);
856
- lines.push(`Claim it with /builder-claim ${t.id}, follow its acceptance criteria, stay within touches[], ship via /builder-ship when done.`);
857
- lines.push('');
858
- });
859
- return lines.join('\n').trim();
860
- }
861
-
862
- function buildBulkSequentialPrompt(tasks) {
863
- const hallBase = location.hostname.startsWith('builders.') ? location.origin : `${location.origin}/builders`;
864
- const lines = [
865
- `I want to work on ${tasks.length} Bongos tasks IN ORDER, one after another in this same session — claim, build, and ship each before moving to the next.`,
866
- '',
867
- ];
868
- tasks.forEach((t, i) => {
869
- lines.push(`${i + 1}. Task ${t.id} (${t.title}) — ${hallBase}#/task/${t.id}`);
870
- });
871
- lines.push('');
872
- lines.push('For each: /builder-claim <id>, follow its acceptance criteria, stay within touches[], /builder-ship <id> before starting the next.');
873
- return lines.join('\n');
874
- }
875
-
876
- async function doBulkClaim() {
877
- const ids = selectedClaimIds();
878
- if (!ids.length) return;
879
- const btn = $('#bulk-claim-btn');
880
- if (btn) { btn.disabled = true; btn.textContent = 'Claiming…'; }
881
- try {
882
- const out = await claimAction.claimMany(ids);
883
- if (out.ok) selection.claim.clear();
884
- } finally {
885
- renderBulkToolbar();
886
- }
887
- }
888
-
889
- async function doBulkRelease() {
890
- const entries = [...selection.release.entries()]; // [claimId, taskId]
891
- if (!entries.length) return;
892
- const label = entries.length === 1 ? `#${entries[0][1]}` : `${entries.length} tasks`;
893
- const pronoun = entries.length === 1 ? 'It returns' : 'They return';
894
- if (!window.confirm(`Release ${label}? ${pronoun} to the queue and no ${curLabel()} are earned.`)) return;
895
- const btn = $('#bulk-release-btn');
896
- if (btn) { btn.disabled = true; btn.textContent = 'Releasing…'; }
897
- try {
898
- const out = await claimAction.releaseMany(entries);
899
- if (out.ok) selection.release.clear();
900
- } finally {
901
- renderBulkToolbar();
902
- }
903
- }
691
+ // The toolbar itself (render, bulk claim/release, the two prompts) is the
692
+ // hall's shared one — bulk-toolbar.js. This page keeps the selection and how
693
+ // Clear repaints its own cards.
694
+ const bulk = window.OTBBulkToolbar.makeBulkToolbar({
695
+ ids: { bar: 'bulk-toolbar', count: 'bulk-count', claim: 'bulk-claim-btn', release: 'bulk-release-btn', parallel: 'bulk-parallel-btn', sequential: 'bulk-sequential-btn', clear: 'bulk-clear-btn' },
696
+ selection,
697
+ findTask: (id) => taskState.all.find((t) => Number(t.id) === id),
698
+ claimAction, getJSON, API, copyText, toast, curLabel, describeReason: friendlyBatchReason,
699
+ onClear: () => { renderClaimable(); renderInFlight(); },
700
+ });
701
+ function renderBulkToolbar() { bulk.render(); }
702
+ function clearSelection() { bulk.clearSelection(); }
904
703
 
905
704
  async function doClaim(taskId, btn) {
906
705
  const key = `claim:${taskId}`;
@@ -1373,26 +1172,8 @@
1373
1172
  doRelease(rel.dataset.claimId, rel.dataset.taskId, rel);
1374
1173
  });
1375
1174
 
1376
- // task 1759: the bottom bulk-action toolbar.
1377
- $('#bulk-claim-btn')?.addEventListener('click', doBulkClaim);
1378
- $('#bulk-release-btn')?.addEventListener('click', doBulkRelease);
1379
- $('#bulk-clear-btn')?.addEventListener('click', clearSelection);
1380
- $('#bulk-parallel-btn')?.addEventListener('click', async () => {
1381
- const ids = selectedClaimIds();
1382
- const tasks = ids.map((id) => taskState.all.find((t) => Number(t.id) === id)).filter(Boolean);
1383
- if (tasks.length < 2) return;
1384
- const ok = await copyText(buildBulkParallelPrompt(tasks));
1385
- toast(ok ? `Parallel-session prompt for ${tasks.length} tasks copied.` : 'Could not copy to the clipboard.');
1386
- });
1387
- $('#bulk-sequential-btn')?.addEventListener('click', async () => {
1388
- const ids = selectedClaimIds();
1389
- const key = selectionKey(ids);
1390
- if (sequentialCache.key !== key || sequentialCache.order == null) return;
1391
- const tasks = sequentialCache.order.map((id) => taskState.all.find((t) => Number(t.id) === id)).filter(Boolean);
1392
- if (tasks.length < 2) return;
1393
- const ok = await copyText(buildBulkSequentialPrompt(tasks));
1394
- toast(ok ? `Sequential prompt for ${tasks.length} tasks copied.` : 'Could not copy to the clipboard.');
1395
- });
1175
+ // task 1759: the bottom bulk-action toolbar (shared: bulk-toolbar.js).
1176
+ bulk.wire();
1396
1177
  }
1397
1178
 
1398
1179
  document.addEventListener('DOMContentLoaded', () => {
@@ -0,0 +1,31 @@
1
+ # Project founding mode — the band and the asleep rooms, worn by the existing hall (task 1004422)
2
+
3
+ **BV2.PS03 (goal 1000121).**
4
+ - Design of record: [`docs/design/project-startup-direction.md`](../../../docs/design/project-startup-direction.md), part 2 of the genesis table.
5
+ - Spec: [`docs/specs/<redacted>.md`](../../../docs/specs/<redacted>.md).
6
+
7
+ ## The rule this record exists to keep
8
+
9
+ The owner's blend rule: **genesis is a MODE the existing hall wears, never a second hall.** Founding mode follows the social-mode precedent exactly: one attribute on the document, `html[data-founding="on"]`, read from `/me`. It adds two things in this task:
10
+
11
+ - **the founding band**, a strip created as the first child of `.app-body`. It sits above the sticky topbar, which is kept as it is.
12
+ - **the asleep state on today's nav items**: dimmed, with an "at <stage>" or "when alive" tag that reads in the open rail.
13
+
14
+ Nothing else changes: no new page, no new route, no new nav item. **A project that is not founding makes `applyFounding` return before it touches a single node**, and `tests/founding_mode.mjs` proves that with a proxy that records every DOM call. Switching the mode off REMOVES the band and every class and tag, so the hall is restored exactly; it does not merely hide them. It is cosmetic: an asleep room still opens, and nothing is enforced.
15
+
16
+ ## Where the answer comes from
17
+
18
+ - `modules/onboarding/founding-mode.js` holds three `project_settings` keys (`founding.active`, `founding.stage`, `founding.started_at`) and computes which rooms sleep.
19
+ - `asleep` is keyed by `shell.js` NAV ids, so the client never re-derives it. A test holds the two maps together.
20
+ - It **fails closed to OFF**: an absent, unreadable or unknown stage reads as not founding. A hall wrongly stuck asleep would be the hijack; a hall wrongly awake only loses a banner.
21
+ - `/me.founding_mode` carries the answer. Both `applyAccess` callers pass it (`shell.js` `probeMe` and `hall-render.js`).
22
+
23
+ ## Rendered, and the phone rule
24
+
25
+ work-founding.states.json renders an ordinary hall page with a founding /me (kit fixture me-founding.json, the real shaper's output). The kit's /me honours stub.fixtures for this. At 390 and 320 the band wraps, the sentence steps aside and the stage and link stay visible, because the first render measured the band widening the page to 483px.
26
+
27
+ ## What is deliberately not here
28
+
29
+ - The genesis home, the stage tasks and the "what's this?" destination belong to PS08 (task 1004423). The band links home until then.
30
+ - The founding mark belongs to PS10 (task 1004424).
31
+ - Starting founding on a new project and moving its stage is `setFoundingMode`, on the onboarding port, and PS08 drives it. Nothing in this task writes it.
@@ -0,0 +1,185 @@
1
+ 'use strict';
2
+
3
+ // modules/onboarding/founding-mode.js — PROJECT FOUNDING MODE: is this project still
4
+ // being founded, and which stage of genesis is it on? (task 1004422 / BV2.PS03, goal
5
+ // 1000121; design of record docs/design/project-startup-direction.md.)
6
+ //
7
+ // THE BLEND RULE THIS FILE SERVES (owner, 2026-09-30). Genesis is a MODE the existing
8
+ // builders hall wears, never a second hall. So this file answers ONE question for the
9
+ // hall shell — founding or not, and at which stage — and the shell stamps the answer on
10
+ // the document the way it stamps social mode (`html[data-social]`). Nothing here renders
11
+ // anything, and a project that is not founding gets `null`, which the shell reads as OFF:
12
+ // the hall then renders exactly as it does today.
13
+ //
14
+ // WHY THE ONBOARDING MODULE. Founding is the FOUNDER-side counterpart of the joiner
15
+ // state machine this module already owns (onboarding-state.js graduates a newcomer; this
16
+ // graduates a project). It lives in project_settings (migration core_231) through the
17
+ // doorway's one accessor, like mingle.enabled, rather than a table of its own — one row
18
+ // per project is the whole state, and "do not fork a second settings store" is that
19
+ // accessor's own rule.
20
+ //
21
+ // FAIL POSTURE: CLOSED, i.e. OFF. An unreadable, absent, or malformed state is NOT
22
+ // founding. The failure this guards against is the owner's no-hijack condition: a hall
23
+ // wrongly stuck in founding mode would dim rooms that are really open. A hall wrongly
24
+ // NOT in founding mode only loses a banner. So every doubtful read answers null.
25
+
26
+ // The genesis stages, in order. 'decide' is the pre-stage the founder plans in; the
27
+ // five after it are the stages proper (spec C4). The order IS the data: a room wakes
28
+ // when the stage that wakes it has been left behind.
29
+ const STAGES = Object.freeze(['decide', 'scope', 'strategize', 'charter', 'legalize', 'legislation']);
30
+
31
+ // Which hall rooms each finished stage wakes (the direction doc's "How rooms wake"
32
+ // table). Keyed by the shell's NAV item ids, so the client applies it without a second
33
+ // mapping of its own — a surface that re-derived this could disagree with the server.
34
+ // 'community' wakes only when the planet is ALIVE, which is when founding mode switches
35
+ // off entirely, so during founding it is always asleep.
36
+ const WAKES = Object.freeze({
37
+ scope: Object.freeze(['roadmap', 'goals', 'goal-map']),
38
+ strategize: Object.freeze(['work']),
39
+ charter: Object.freeze(['board-room']),
40
+ legalize: Object.freeze(['economy']),
41
+ legislation: Object.freeze(['ranks']),
42
+ });
43
+ const WAKES_WHEN_ALIVE = Object.freeze(['collab', 'roster']);
44
+
45
+ // The project_settings keys. Namespaced 'founding.' like 'mingle.' and 'attention.'.
46
+ const KEYS = Object.freeze({
47
+ ACTIVE: 'founding.active',
48
+ STAGE: 'founding.stage',
49
+ STARTED_AT: 'founding.started_at',
50
+ });
51
+
52
+ // The only 'on' spelling accepted, mirroring mingle's consent-gate parse: an explicit,
53
+ // recognised yes. Anything else is off.
54
+ function parseActive(raw) {
55
+ if (raw == null) return false;
56
+ return ['true', '1', 'on', 'yes'].includes(String(raw).trim().toLowerCase());
57
+ }
58
+
59
+ // asleepRooms(stage) — PURE. For a known stage, { navId: wakingStage } for every room
60
+ // still asleep. A room is awake once its waking stage is BEHIND the current one.
61
+ function asleepRooms(stage) {
62
+ const at = STAGES.indexOf(stage);
63
+ if (at < 0) return null;
64
+ const asleep = {};
65
+ for (const [wakingStage, ids] of Object.entries(WAKES)) {
66
+ if (STAGES.indexOf(wakingStage) >= at) for (const id of ids) asleep[id] = wakingStage;
67
+ }
68
+ for (const id of WAKES_WHEN_ALIVE) asleep[id] = 'alive';
69
+ return asleep;
70
+ }
71
+
72
+ // dayOf(startedAt, now) — PURE. Day 1 is the founding day. null when unreadable, so a
73
+ // bad timestamp drops the day from the band rather than printing "day NaN".
74
+ function dayOf(startedAt, now = new Date()) {
75
+ if (!startedAt) return null;
76
+ const t = new Date(startedAt).getTime();
77
+ if (!Number.isFinite(t)) return null;
78
+ const days = Math.floor((now.getTime() - t) / 86400000) + 1;
79
+ return days >= 1 ? days : 1;
80
+ }
81
+
82
+ // shapeFoundingMode(rows, now) — PURE. rows = { active, stage, startedAt } raw strings
83
+ // (or nulls). Returns the /me payload, or null for "not founding" in any form.
84
+ function shapeFoundingMode(rows, now = new Date()) {
85
+ if (!rows || !parseActive(rows.active)) return null;
86
+ const stage = rows.stage == null ? 'decide' : String(rows.stage).trim().toLowerCase();
87
+ const asleep = asleepRooms(stage);
88
+ if (!asleep) return null; // an unknown stage is not a stage — fail closed (OFF)
89
+ const index = STAGES.indexOf(stage);
90
+ return {
91
+ active: true,
92
+ stage,
93
+ // 0 while deciding, then 1..5 through the stages proper.
94
+ stage_number: index,
95
+ stage_count: STAGES.length - 1,
96
+ day: dayOf(rows.startedAt, now),
97
+ asleep,
98
+ // Who closing the current stage is waiting on (spec D7) — display names, at most
99
+ // five, from the government module's genesis-stage sitting (task 1004429). null
100
+ // when the stage is not sitting, nobody is left to vote, or the read is missing.
101
+ waiting_on: Array.isArray(rows.waitingOn) && rows.waitingOn.length
102
+ ? rows.waitingOn.slice(0, 5).map((n) => String(n))
103
+ : null,
104
+ };
105
+ }
106
+
107
+ // foundingModeFor(deps) — the /me read. Resolves the three rows through the doorway's
108
+ // project-settings accessor (injectable for tests). Never throws: any read failure is
109
+ // "not founding".
110
+ // Three primary-key reads in parallel: the doorway's project-settings accessor is
111
+ // single-key by design (one accessor, one table), and a founding project is a short
112
+ // window of a project's life, so a batched read is not worth a second accessor.
113
+ async function foundingModeFor(deps = {}) {
114
+ const get = deps.getSetting || require('../../src/module-api').projectSettings.get;
115
+ let shaped;
116
+ try {
117
+ const [active, stage, startedAt] = await Promise.all([
118
+ get(KEYS.ACTIVE), get(KEYS.STAGE), get(KEYS.STARTED_AT),
119
+ ]);
120
+ shaped = shapeFoundingMode({
121
+ active: active && active.value,
122
+ stage: stage && stage.value,
123
+ startedAt: startedAt && startedAt.value,
124
+ }, deps.now || new Date());
125
+ } catch (_) {
126
+ return null;
127
+ }
128
+ if (!shaped) return null;
129
+ // The waiting-on names come from the government module's genesis-stage read
130
+ // (genesisStageStatus on its port, task 1004429: one entry per stage, each with
131
+ // a state and, while sitting, the members still to vote under the item's own
132
+ // snapshot). Optional and fail-open: a missing port or a failed read leaves the
133
+ // band without the names, never without the mode.
134
+ try {
135
+ const waitingOnFor = deps.waitingOnFor || waitingOnFromGovernment(
136
+ deps.government !== undefined ? deps.government : require('../../src/module-api').resolveOptional('government'),
137
+ );
138
+ if (waitingOnFor) {
139
+ const names = await waitingOnFor(shaped.stage);
140
+ if (Array.isArray(names) && names.length) shaped.waiting_on = names.slice(0, 5).map((n) => String(n));
141
+ }
142
+ } catch (_) { /* fail open: the band shows the stage without the names */ }
143
+ return shaped;
144
+ }
145
+
146
+ // waitingOnFromGovernment(gov) — a stage -> [display name] reader over the government
147
+ // port's genesisStageStatus(), or null when the port does not offer it. Only a stage
148
+ // that is SITTING waits on anyone; not_put, passed and returned wait on nobody.
149
+ function waitingOnFromGovernment(gov) {
150
+ if (!gov || typeof gov.genesisStageStatus !== 'function') return null;
151
+ return async (stage) => {
152
+ const status = await gov.genesisStageStatus();
153
+ const row = status && Array.isArray(status.stages) ? status.stages.find((s) => s.key === stage) : null;
154
+ if (!row || row.state !== 'sitting' || !Array.isArray(row.waiting_on)) return [];
155
+ return row.waiting_on.map((m) => m.display_name || m.github_login).filter(Boolean);
156
+ };
157
+ }
158
+
159
+ // setFoundingMode({ active, stage }, builderId, deps) — the write the genesis home
160
+ // (task 1004423) drives: start founding on a new project, move the stage, and switch
161
+ // founding off when the planet comes alive. Refuses an unknown stage rather than storing
162
+ // a value the read would then treat as OFF. Starting stamps started_at once.
163
+ async function setFoundingMode({ active, stage } = {}, builderId = null, deps = {}) {
164
+ const settings = require('../../src/module-api').projectSettings;
165
+ const get = deps.getSetting || settings.get;
166
+ const set = deps.setSetting || settings.set;
167
+ if (stage != null && !STAGES.includes(stage)) {
168
+ const err = new Error(`unknown founding stage '${stage}'`);
169
+ err.code = 'bad_founding_stage';
170
+ throw err;
171
+ }
172
+ if (active === true) {
173
+ const started = await get(KEYS.STARTED_AT);
174
+ if (!started || !started.value) await set(KEYS.STARTED_AT, (deps.now || new Date()).toISOString(), builderId);
175
+ }
176
+ if (typeof active === 'boolean') await set(KEYS.ACTIVE, active ? 'true' : 'false', builderId);
177
+ if (stage != null) await set(KEYS.STAGE, stage, builderId);
178
+ return foundingModeFor({ getSetting: get, now: deps.now });
179
+ }
180
+
181
+ module.exports = {
182
+ STAGES, WAKES, WAKES_WHEN_ALIVE, KEYS,
183
+ parseActive, asleepRooms, dayOf, shapeFoundingMode, waitingOnFromGovernment,
184
+ foundingModeFor, setFoundingMode,
185
+ };
@@ -40,6 +40,7 @@
40
40
 
41
41
  const onboardingState = require('./onboarding-state');
42
42
  const newcomerRestock = require('./newcomer-restock');
43
+ const foundingMode = require('./founding-mode');
43
44
 
44
45
  module.exports = {
45
46
  // --- the newcomer onboarding state machine (onboarding-state.js) ---------
@@ -53,6 +54,12 @@ module.exports = {
53
54
  markStage: (...a) => onboardingState.markStage(...a),
54
55
  broadcastGraduation: (...a) => onboardingState.broadcastGraduation(...a),
55
56
 
57
+ // --- project founding mode (founding-mode.js, task 1004422) ---------------
58
+ // 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),
61
+ setFoundingMode: (...a) => foundingMode.setFoundingMode(...a),
62
+
56
63
  // --- the evergreen newcomer-chore restock (newcomer-restock.js) ----------
57
64
  // restock the just-claimed chore's shelf (claims.js / tasks.js); the Archon
58
65
  // backstop tops every chore up (tasks.js). SOURCE_TAG is the marker the claim
@@ -0,0 +1,24 @@
1
+ {
2
+ "builder": {
3
+ "id": "1",
4
+ "github_login": "builder",
5
+ "display_name": "A Builder",
6
+ "avatar_url": "/__kit/avatar.svg",
7
+ "rank": "archon"
8
+ },
9
+ "founding_mode": {
10
+ "active": true,
11
+ "stage": "strategize",
12
+ "stage_number": 2,
13
+ "stage_count": 5,
14
+ "day": 9,
15
+ "asleep": {
16
+ "work": "strategize",
17
+ "board-room": "charter",
18
+ "economy": "legalize",
19
+ "ranks": "legislation",
20
+ "collab": "alive",
21
+ "roster": "alive"
22
+ }
23
+ }
24
+ }