@bongos/core 1.21.4 → 1.21.6

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 (126) hide show
  1. package/.bongos-core.json +233 -108
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +3 -1
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +1 -0
  8. package/docs/adr/0362-the-governor-docket-is-collected-from-contributed-sources.md +34 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +37 -2
  11. package/docs/api-reference.md +4 -3
  12. package/docs/copy-inventory.md +541 -658
  13. package/docs/copy-registry.json +1205 -2340
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/onboarding/diagrams/03-drachmae-karma.mmd +1 -1
  16. package/docs/onboarding/diagrams/assertions.json +1 -1
  17. package/docs/page-inventory.json +47 -28
  18. package/docs/page-readings.json +1128 -1339
  19. package/modules/autonomy/config-idle.js +192 -0
  20. package/modules/autonomy/governor-docket.js +101 -0
  21. package/modules/autonomy/routes/autonomy.js +27 -0
  22. package/modules/autonomy/runner-health.js +22 -1
  23. package/modules/discord/board-broadcast.js +3 -1
  24. package/modules/discord/craft-broadcast.js +107 -0
  25. package/modules/discord/routes/discord.js +10 -0
  26. package/modules/discord/ship-broadcast.js +30 -2
  27. package/modules/economy/reward.js +3 -0
  28. package/modules/government/board.js +20 -5
  29. package/modules/government/docket-vocab.js +82 -0
  30. package/modules/government/docket.js +458 -0
  31. package/modules/government/routes/government.js +34 -0
  32. package/modules/hall-ui/public/city-draw.js +438 -0
  33. package/modules/hall-ui/public/city.css +110 -0
  34. package/modules/hall-ui/public/city.html +71 -0
  35. package/modules/hall-ui/public/city.js +177 -0
  36. package/modules/hall-ui/public/city.states.json +30 -0
  37. package/modules/hall-ui/public/gate.js +11 -4
  38. package/modules/hall-ui/public/genesis-home.js +9 -10
  39. package/modules/hall-ui/public/profile.css +4 -0
  40. package/modules/hall-ui/public/profile.js +25 -1
  41. package/modules/hall-ui/public/settings-autobongos.js +19 -1
  42. package/modules/hall-ui/public/settings.css +26 -0
  43. package/modules/hall-ui/public/settings.html +8 -0
  44. package/modules/hall-ui/public/settings.js +43 -3
  45. package/modules/ideas/governor-docket.js +93 -0
  46. package/modules/ideas/routes/blockers.js +5 -0
  47. package/modules/lifecycle/governor-docket.js +192 -0
  48. package/modules/lifecycle/routes/gate-approvals.js +78 -33
  49. package/modules/lifecycle/routes/lifecycle.js +3 -0
  50. package/modules/onboarding/routes/access-requests.js +68 -27
  51. package/modules/platform-identity/craft-rollup.js +72 -0
  52. package/modules/platform-identity/migrations/platform_identity_029_activity_crafts.sql +23 -0
  53. package/modules/platform-identity/platform-identity.js +12 -6
  54. package/modules/platform-identity/routes/sso.js +4 -0
  55. package/modules/platform-identity/tests/platform-identity.mjs +1 -1
  56. package/modules/provisioning/core-upgrade.js +34 -7
  57. package/modules/provisioning/module.json +2 -1
  58. package/modules/provisioning/seams.js +2 -0
  59. package/modules/public-landing/public/assets/cosmos.css +6 -50
  60. package/modules/public-landing/public/projects.html +1170 -1659
  61. package/modules/public-landing/public/projects.probes.json +18 -18
  62. package/modules/public-landing/public/projects.states.json +24 -31
  63. package/modules/security/routes/reports.js +39 -0
  64. package/package-lock.json +2 -2
  65. package/package.json +1 -1
  66. package/release-notes.json +72 -0
  67. package/scripts/gds/autobongos-grade-cap.js +187 -0
  68. package/scripts/gds/autobongos-loop.js +4 -0
  69. package/scripts/gds/autobongos-run.js +154 -13
  70. package/scripts/gds/autobongos-verify.js +211 -14
  71. package/scripts/gds/provision-core-upgrade.js +14 -2
  72. package/scripts/gds/provision-repo.js +77 -13
  73. package/scripts/gds/ship-flow.js +5 -0
  74. package/scripts/gds/ship-preflight-steps.js +2 -1
  75. package/scripts/gds/ship.js +10 -0
  76. package/scripts/gds/update-sweep.js +24 -8
  77. package/scripts/gds/upgrade-outcome.js +1 -0
  78. package/src/bongos/auth-admission.js +95 -13
  79. package/src/bongos/db-kernel.js +1 -1
  80. package/src/bongos/module-rechecks.js +77 -0
  81. package/src/bongos/route-rank-check.js +3 -0
  82. package/src/bongos/routes/core-update.js +68 -2
  83. package/src/bongos/routes/modules.js +17 -0
  84. package/src/bongos/serve-internal.js +14 -0
  85. package/src/bongos/software-update.js +3 -2
  86. package/src/module-api.js +1 -1
  87. package/tests/activity_rollup_order_db.mjs +3 -1
  88. package/tests/activity_snapshots_db.mjs +2 -0
  89. package/tests/autobongos_grade_cap.mjs +125 -0
  90. package/tests/autobongos_loop.mjs +230 -1
  91. package/tests/autobongos_verify.mjs +239 -3
  92. package/tests/autonomy_config_idle.mjs +272 -0
  93. package/tests/core_update_banner.mjs +29 -0
  94. package/tests/core_upgrade_door.mjs +31 -1
  95. package/tests/core_upgrade_runner.mjs +20 -0
  96. package/tests/discord_craft_broadcast.mjs +162 -0
  97. package/tests/genesis_home.mjs +1 -1
  98. package/tests/government_routes.mjs +3 -1
  99. package/tests/governor_docket.mjs +552 -0
  100. package/tests/hall_audit.mjs +10 -1
  101. package/tests/hall_city.mjs +369 -0
  102. package/tests/hall_page_gate_map.mjs +5 -0
  103. package/tests/hub_craft_rollup.mjs +350 -0
  104. package/tests/onboard.mjs +10 -4
  105. package/tests/platform_boot.mjs +1 -1
  106. package/tests/profile_rollup_consent.mjs +2 -0
  107. package/tests/profile_route.mjs +16 -2
  108. package/tests/projects_hub.mjs +196 -89
  109. package/tests/projects_hub_app_status.mjs +3 -1
  110. package/tests/projects_hub_app_step.mjs +72 -48
  111. package/tests/projects_hub_dns_ready.mjs +10 -2
  112. package/tests/projects_hub_look.mjs +32 -22
  113. package/tests/projects_hub_module_picker.mjs +112 -62
  114. package/tests/projects_hub_pre_uat.mjs +48 -41
  115. package/tests/provision.mjs +78 -0
  116. package/tests/public_landing_projects.mjs +12 -6
  117. package/tests/settings_main_role.mjs +190 -0
  118. package/tests/software_update.mjs +56 -1
  119. package/tests/update_subscription_engine.mjs +55 -3
  120. package/tests/wizard_demo.mjs +37 -11
  121. package/tests/wizard_draft_resume.mjs +34 -22
  122. package/tests/wizard_front_door.mjs +173 -103
  123. package/tests/wizard_intent_resume.mjs +28 -25
  124. package/tests/wizard_physics.mjs +94 -76
  125. package/tests/wizard_physics_more.mjs +428 -277
  126. package/tests/wizard_six_screens.mjs +126 -0
@@ -0,0 +1,190 @@
1
+ // tests/settings_main_role.mjs — the Settings main-role picker (task 1004541 /
2
+ // WA6.RP12, goal 1000095, criterion wa6-roles-read-true-in-public).
3
+ //
4
+ // The owner's rule (2026-09-30): the builder CHOOSES their main role. The server
5
+ // side existed (builders.main_discipline, PATCH /me/disciplines main_discipline,
6
+ // task 1004433) with nothing in the hall to make the choice. This EXECUTES the
7
+ // real modules/hall-ui/public/settings.js in a minimal DOM stub against a canned
8
+ // /me and a recording PATCH, and pins the control's contract:
9
+ //
10
+ // - it seeds from GET /me's main_discipline (and /me now carries it:
11
+ // db-kernel getBuilderById selects the column);
12
+ // - the options are "Let my work decide" plus exactly the roles ticked above,
13
+ // and ticking/unticking a role adds/removes its option;
14
+ // - unticking the main role hands the choice back to "Let my work decide";
15
+ // - Save sends main_discipline in the same PATCH — the pick, or null to clear —
16
+ // and re-seeds from the server's echo.
17
+ //
18
+ // Pure vm + file-read (no DB/network) — runs in the DB-free unit gate.
19
+ import { strict as assert } from 'node:assert';
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import vm from 'node:vm';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/settings.js'), 'utf8');
27
+ const HTML = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/settings.html'), 'utf8');
28
+ const KERNEL = fs.readFileSync(path.join(ROOT, 'src/bongos/db-kernel.js'), 'utf8');
29
+
30
+ let passed = 0, failed = 0;
31
+ async function t(name, fn) {
32
+ try { await fn(); passed++; console.log(` ok ${name}`); }
33
+ catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
34
+ }
35
+
36
+ const DISCIPLINES = ['engineer', 'artist', 'ideator', 'ui'];
37
+
38
+ // A permissive element: everything settings.js touches at boot, with listeners
39
+ // recorded so a test can fire them.
40
+ function makeEl() {
41
+ const listeners = {};
42
+ return {
43
+ hidden: false, textContent: '', value: '', disabled: false, href: '', className: '',
44
+ dataset: {}, style: {}, children: [], _html: '',
45
+ set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
46
+ addEventListener(type, fn) { (listeners[type] || (listeners[type] = [])).push(fn); },
47
+ fire(type, ev) { for (const fn of listeners[type] || []) fn(ev); },
48
+ appendChild(c) { this.children.push(c); return c; },
49
+ append() {}, prepend() {}, remove() {}, setAttribute() {}, removeAttribute() {}, getAttribute() { return null; },
50
+ querySelector() { return null; }, querySelectorAll() { return []; }, closest() { return null; },
51
+ classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
52
+ focus() {},
53
+ };
54
+ }
55
+
56
+ async function boot({ builder }) {
57
+ const els = new Map();
58
+ const $ = (sel) => { if (!els.has(sel)) els.set(sel, makeEl()); return els.get(sel); };
59
+ const patches = [];
60
+ const api = {
61
+ async request(method, url, opts = {}) {
62
+ if (method === 'GET' && url === '/api/bongos/me') return { status: 200, ok: true, data: { builder } };
63
+ if (method === 'PATCH' && url === '/api/bongos/me/disciplines') {
64
+ const body = opts.body;
65
+ patches.push(body);
66
+ const prefs = DISCIPLINES.filter((d) => body.preferred_disciplines.includes(d));
67
+ const main = body.main_discipline === undefined ? builder.main_discipline : body.main_discipline;
68
+ return { status: 200, ok: true, data: { builder: { ...builder, preferred_disciplines: prefs, main_discipline: main } } };
69
+ }
70
+ return { status: 404, ok: false, data: null };
71
+ },
72
+ };
73
+ const escapeHtml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
74
+ const document = {
75
+ getElementById: (id) => $(`#${id}`),
76
+ createElement: () => makeEl(),
77
+ dispatchEvent() {}, addEventListener() {},
78
+ querySelector: () => null, querySelectorAll: () => [],
79
+ };
80
+ const window = {
81
+ __BRANDING__: {}, __MODULES__: { enabled: {} },
82
+ BongosClient: { createClient: () => api },
83
+ OTB: {
84
+ $, escapeHtml, toast() {},
85
+ disciplines: () => DISCIPLINES,
86
+ roleLabel: (d) => (d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
87
+ hubOrigin: () => null,
88
+ settingsPanels: () => [],
89
+ },
90
+ OTBKit: { emptyStateHtml: (m) => m, makeSectionRail: () => ({ refresh() {}, activate() {} }) },
91
+ location: { hash: '', pathname: '/settings', search: '', assign() {} },
92
+ scrollTo() {}, confirm: () => false, addEventListener() {},
93
+ };
94
+ const sandbox = {
95
+ window, document, console: { log() {}, warn() {}, error() {}, info() {} },
96
+ history: { replaceState() {} }, URLSearchParams, setTimeout, clearTimeout,
97
+ };
98
+ sandbox.globalThis = sandbox;
99
+ vm.createContext(sandbox);
100
+ vm.runInContext(SRC, sandbox, { filename: 'settings.js' });
101
+ for (let i = 0; i < 12; i++) await new Promise((r) => setImmediate(r));
102
+
103
+ // The control as a test sees it.
104
+ const options = () => [...$('#craft-main-opts').innerHTML.matchAll(/value="([^"]*)"(\s*checked)?>([^<]*)<\/label>/g)]
105
+ .map((m) => ({ value: m[1], checked: !!m[2], label: m[3] }));
106
+ const checked = () => (options().find((o) => o.checked) || {}).value;
107
+ const toggleRole = (d, on) => {
108
+ const chip = { dataset: {} };
109
+ const box = { value: d, checked: on, closest: (sel) => (sel === '.craft-pref' ? box : sel === '.craft-chip' ? chip : null) };
110
+ $('#craft-chips').fire('change', { target: box });
111
+ };
112
+ const pickMain = (v) => {
113
+ const pick = { value: v, closest: (sel) => (sel === '.craft-main-pick' ? pick : null) };
114
+ $('#craft-main-opts').fire('change', { target: pick });
115
+ };
116
+ const save = async () => {
117
+ $('#craft-save').fire('click', {});
118
+ for (let i = 0; i < 6; i++) await new Promise((r) => setImmediate(r));
119
+ };
120
+ return { $, options, checked, toggleRole, pickMain, save, patches };
121
+ }
122
+
123
+ const B = (over = {}) => ({ id: 4, github_login: 'b4', display_name: 'Builder 4', preferred_disciplines: ['engineer', 'ideator'], main_discipline: null, ...over });
124
+
125
+ console.log('\nthe main-role picker:');
126
+
127
+ await t('the markup carries the Main role group inside Preferred roles', () => {
128
+ const craft = HTML.slice(HTML.indexOf('id="craft-scroll"'), HTML.indexOf('id="craft-save"'));
129
+ assert.match(craft, /<fieldset class="craft-main" id="craft-main">/);
130
+ assert.match(craft, /<legend class="craft-main__h">Main role<\/legend>/);
131
+ assert.match(craft, /id="craft-main-opts"/);
132
+ assert.match(craft, /leads your profile and your leaderboard entry/, 'it says what a main role does');
133
+ });
134
+
135
+ await t('GET /me carries the saved main role (getBuilderById selects the column)', () => {
136
+ const sel = KERNEL.slice(KERNEL.indexOf('async function getBuilderById'), KERNEL.indexOf('FROM builders WHERE id = $1'));
137
+ assert.match(sel, /\bmain_discipline\b/);
138
+ });
139
+
140
+ await t('options are "Let my work decide" plus exactly the ticked roles; nothing saved → by work', async () => {
141
+ const p = await boot({ builder: B() });
142
+ assert.deepEqual(p.options().map((o) => o.value), ['', 'engineer', 'ideator']);
143
+ assert.equal(p.options()[0].label, 'Let my work decide');
144
+ assert.deepEqual(p.options().map((o) => o.label).slice(1), ['Engineer', 'Ideator']);
145
+ assert.equal(p.checked(), '', 'no saved main role → "Let my work decide" is chosen');
146
+ });
147
+
148
+ await t('a saved main role is the one chosen on load', async () => {
149
+ const p = await boot({ builder: B({ main_discipline: 'ideator' }) });
150
+ assert.equal(p.checked(), 'ideator');
151
+ });
152
+
153
+ await t('ticking a role adds its option; unticking the MAIN role hands the choice back to your work', async () => {
154
+ const p = await boot({ builder: B({ main_discipline: 'ideator' }) });
155
+ p.toggleRole('artist', true);
156
+ assert.deepEqual(p.options().map((o) => o.value), ['', 'engineer', 'artist', 'ideator'], 'in the instance\'s role order');
157
+ assert.equal(p.checked(), 'ideator', 'adding a role keeps the pick');
158
+ p.toggleRole('ideator', false);
159
+ assert.deepEqual(p.options().map((o) => o.value), ['', 'engineer', 'artist']);
160
+ assert.equal(p.checked(), '', 'a role you no longer have cannot lead');
161
+ });
162
+
163
+ await t('Save sends the pick in the same PATCH, and the echo re-seeds it', async () => {
164
+ const p = await boot({ builder: B() });
165
+ p.pickMain('ideator');
166
+ await p.save();
167
+ assert.equal(p.patches.length, 1);
168
+ assert.deepEqual(JSON.parse(JSON.stringify(p.patches[0])), { preferred_disciplines: ['engineer', 'ideator'], main_discipline: 'ideator' });
169
+ assert.equal(p.checked(), 'ideator', 'the server\'s echo is what the control shows after saving');
170
+ assert.match(p.$('#craft-status').textContent, /Main role: Ideator\./);
171
+ });
172
+
173
+ await t('"Let my work decide" saves null, which clears a saved main role', async () => {
174
+ const p = await boot({ builder: B({ main_discipline: 'engineer' }) });
175
+ p.pickMain('');
176
+ await p.save();
177
+ assert.equal(p.patches[0].main_discipline, null);
178
+ assert.equal(p.checked(), '');
179
+ assert.doesNotMatch(p.$('#craft-status').textContent, /Main role/);
180
+ });
181
+
182
+ await t('unticking the main role, then saving, sends null — never a role outside the set', async () => {
183
+ const p = await boot({ builder: B({ main_discipline: 'ideator' }) });
184
+ p.toggleRole('ideator', false);
185
+ await p.save();
186
+ assert.deepEqual(JSON.parse(JSON.stringify(p.patches[0])), { preferred_disciplines: ['engineer'], main_discipline: null });
187
+ });
188
+
189
+ console.log(`\n${passed} passed, ${failed} failed`);
190
+ if (failed) process.exit(1);
@@ -194,7 +194,7 @@ const STUB_UPDATE = { reading: 'ok', running: '1.19.10', offered: '1.19.13' };
194
194
  test('the platform\'s hall: the Update button goes to its own door', async () => {
195
195
  const r = await drive({ softwareUpdate: async () => STUB_UPDATE, canUpdate: async () => true, doorInputs: () => ({ provisioning: true, idp: null }) });
196
196
  assert.equal(r.status, 200);
197
- assert.deepEqual(r.body.update, STUB_UPDATE);
197
+ assert.deepEqual(r.body.update, { ...STUB_UPDATE, channel_source: 'default' }, 'the reader\'s answer, plus which rule it used');
198
198
  assert.deepEqual(r.body.door, { kind: 'local', url: '/deploy' });
199
199
  assert.equal(r.body.can_update, true);
200
200
  });
@@ -447,3 +447,58 @@ test('the platform, which runs the door, keeps /deploy behind its own page gate'
447
447
  assert.equal(r.status, 302, 'a visitor with no session meets the page gate');
448
448
  assert.match(String(r.location), /auth|sign/i, 'and is sent to sign in, as before');
449
449
  });
450
+
451
+ // ── the project's OWN update rule reaches the offer (task 1004535) ────────────
452
+ //
453
+ // The live defect, 2026-10-02: cloudbongos's rule on /deploy is `minor` and the sweep
454
+ // follows it, but GET /software-update answered `"channel": "patch"` — the platform
455
+ // default — so a step-up release would read "up to date" here while the sweep installed it.
456
+
457
+ test('the route hands the reader the project\'s stored update rule (task 1004535)', async () => {
458
+ let seen;
459
+ const r = await drive({ softwareUpdate: async (a) => { seen = a; return STUB_UPDATE; }, selfChannel: async () => 'minor',
460
+ canUpdate: async () => true, doorInputs: () => ({ provisioning: true, idp: null }) });
461
+ assert.equal(r.status, 200);
462
+ assert.equal(seen.channel, 'minor', 'the rule set on /deploy, not the platform default');
463
+ assert.equal(r.body.update.channel_source, 'deploy', 'and the answer says which rule it used');
464
+ });
465
+
466
+ test('no stored rule, or a failed read of it, falls back to the default — never a 500 (task 1004535)', async () => {
467
+ for (const selfChannel of [async () => null, async () => { throw new Error('db down'); }]) {
468
+ let seen;
469
+ const r = await drive({ softwareUpdate: async (a) => { seen = a; return STUB_UPDATE; }, selfChannel,
470
+ canUpdate: async () => true, doorInputs: () => ({ provisioning: false, idp: null }) });
471
+ assert.equal(r.status, 200);
472
+ assert.equal(seen.channel, null, 'null → the reader applies DEFAULT_CHANNEL');
473
+ assert.equal(r.body.update.channel_source, 'default');
474
+ }
475
+ });
476
+
477
+ test('the REAL default reads the provisioning.selfUpdateChannel port — and with no provider, the default (task 1004535)', async () => {
478
+ // No selfChannel injected: the shipped storedSelfChannel → seams.resolveOptional path runs.
479
+ const seams = require('../src/module-seams.js');
480
+ const stub = { softwareUpdate: async (a) => ({ reading: 'ok', channel: a.channel }), canUpdate: async () => true, doorInputs: () => ({ provisioning: false, idp: null }) };
481
+ try {
482
+ seams._reset();
483
+ let r = await drive(stub);
484
+ assert.equal(r.status, 200);
485
+ assert.equal(r.body.update.channel, null, 'module off: no provider, so no rule is claimed');
486
+ assert.equal(r.body.update.channel_source, 'default');
487
+ seams.registerProvider('provisioning.selfUpdateChannel', async () => 'minor');
488
+ r = await drive(stub);
489
+ assert.equal(r.body.update.channel, 'minor', 'module on: the port the provisioning module registers is the one read');
490
+ assert.equal(r.body.update.channel_source, 'deploy');
491
+ } finally {
492
+ seams._reset();
493
+ }
494
+ });
495
+
496
+ test('[source] the port the route reads is the port the provisioning module registers and declares (task 1004535)', () => {
497
+ const port = 'provisioning.selfUpdateChannel';
498
+ const route = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/core-update.js'), 'utf8');
499
+ const reg = fs.readFileSync(path.join(ROOT, 'modules/provisioning/seams.js'), 'utf8');
500
+ const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/provisioning/module.json'), 'utf8'));
501
+ assert.ok(route.includes(`'${port}'`), 'the route asks for it');
502
+ assert.ok(reg.includes(`'${port}'`), 'the module registers it');
503
+ assert.ok(manifest.provides.includes(port), 'and declares it');
504
+ });
@@ -500,7 +500,7 @@ test('both commitPin successes report committed:true', () => {
500
500
 
501
501
  // A resolveFrom/load pair standing in for the target instance's core, so the probe is
502
502
  // exercised without a second core on disk.
503
- function fakeCore({ api, willRewrite, upgradeThrows = false, materializeThrows = false } = {}) {
503
+ function fakeCore({ api, willRewrite, upgradeThrows = false, materializeThrows = false, own = () => null } = {}) {
504
504
  const seen = [];
505
505
  const resolveFrom = (dir, rel) => {
506
506
  if (rel === 'scripts/gds/upgrade.js' && api === null) return null;
@@ -515,7 +515,7 @@ function fakeCore({ api, willRewrite, upgradeThrows = false, materializeThrows =
515
515
  if (materializeThrows) throw new Error('materialize blew up');
516
516
  return { materializeWillRewrite: willRewrite === undefined ? undefined : (args) => { seen.push(args); return willRewrite; } };
517
517
  };
518
- return { resolveFrom, load, seen };
518
+ return { resolveFrom, load, seen, own };
519
519
  }
520
520
 
521
521
  test('laneGeneratedPaths unions the API artifacts with what materialize WOULD rewrite', () => {
@@ -545,7 +545,8 @@ test('laneGeneratedPaths is FAIL-SOFT in every direction — an empty set, never
545
545
  // The direction matters. An instance this cannot interrogate keeps the OLD, stricter guard
546
546
  // (still wedged, and visibly so), rather than gaining a looser one that commits a stranger's work.
547
547
  assert.deepEqual(S.laneGeneratedPaths('/srv/demo', fakeCore({ api: null, willRewrite: null })), []);
548
- assert.deepEqual(S.laneGeneratedPaths('/srv/demo', fakeCore({ api: {}, willRewrite: undefined })), []);
548
+ // A core that predates the preview, and no .claude/skills to walk under it: nothing to fall back on.
549
+ assert.deepEqual(S.laneGeneratedPaths('/srv/demo', fakeCore({ api: {}, willRewrite: undefined, own: () => () => ['CLAUDE.md'] })), []);
549
550
  assert.deepEqual(S.laneGeneratedPaths('/srv/demo', fakeCore({ upgradeThrows: true, materializeThrows: true })), []);
550
551
  // A core that exports API_ARTIFACT_FILES but whose materialize throws still contributes the
551
552
  // half it could answer for.
@@ -555,6 +556,57 @@ test('laneGeneratedPaths is FAIL-SOFT in every direction — an empty set, never
555
556
  );
556
557
  });
557
558
 
559
+ // ---------------------------------------------------------------------------
560
+ // task 1004519 — a core too old to preview its own rewrite. hoodify-finance took one bump to
561
+ // 1.19.871 (before materializeWillRewrite, 1.19.933), was left with 33 materialized skills
562
+ // uncommitted, and halted on them every sweep because its core answered nothing for them.
563
+ // ---------------------------------------------------------------------------
564
+
565
+ function oldCoreOnDisk(skills) {
566
+ const dir = mkdtempSync(path.join(tmpdir(), 'sweep-oldcore-'));
567
+ const core = path.join(dir, 'node_modules', '@bongos', 'core');
568
+ for (const name of skills) {
569
+ mkdirSync(path.join(core, '.claude', 'skills', name), { recursive: true });
570
+ writeFileSync(path.join(core, '.claude', 'skills', name, 'SKILL.md'), `# ${name}\n`);
571
+ }
572
+ // An instance-owned CLAUDE.md, so the starter is not "would write" here.
573
+ writeFileSync(path.join(dir, 'CLAUDE.md'), '# instance\n');
574
+ return dir;
575
+ }
576
+
577
+ test('laneGeneratedPaths falls back to the sweep\'s own preview when the instance core predates it', () => {
578
+ const dir = oldCoreOnDisk(['ask-for-help', 'blocker-review']);
579
+ // The instance's core resolves claude-materialize.js but exports no materializeWillRewrite.
580
+ const core = fakeCore({ api: { g: ['docs/api/openapi.json'] }, willRewrite: undefined, own: () => require('../scripts/gds/claude-materialize.js').materializeWillRewrite });
581
+ const got = S.laneGeneratedPaths(dir, core);
582
+ for (const f of ['.claude/skills/ask-for-help/SKILL.md', '.claude/skills/blocker-review/SKILL.md', 'docs/api/openapi.json']) {
583
+ assert.ok(got.includes(f), `${f} is the lane's own output and must be classified so`);
584
+ }
585
+ assert.ok(!got.includes('CLAUDE.md'), 'an instance-owned CLAUDE.md is never the lane\'s');
586
+ });
587
+
588
+ test('real git: the hoodify-finance wedge clears — old-core skills sort as generated, a hand-written skill still halts', () => {
589
+ const dir = oldCoreOnDisk(['ask-for-help']);
590
+ const g = (...a) => spawnSync('git', ['-C', dir, ...a], { encoding: 'utf8' });
591
+ writeFileSync(path.join(dir, '.gitignore'), 'node_modules/\n');
592
+ mkdirSync(path.join(dir, '.claude', 'skills', 'ask-for-help'), { recursive: true });
593
+ mkdirSync(path.join(dir, '.claude', 'skills', 'my-own'), { recursive: true });
594
+ writeFileSync(path.join(dir, '.claude', 'skills', 'ask-for-help', 'SKILL.md'), 'old\n');
595
+ writeFileSync(path.join(dir, '.claude', 'skills', 'my-own', 'SKILL.md'), 'mine\n');
596
+ g('init', '-q'); g('add', '-A'); g('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'init');
597
+ const core = fakeCore({ api: {}, willRewrite: undefined, own: () => require('../scripts/gds/claude-materialize.js').materializeWillRewrite });
598
+ // The bump rewrote the materialized skill and left it uncommitted.
599
+ writeFileSync(path.join(dir, '.claude', 'skills', 'ask-for-help', 'SKILL.md'), 'new\n');
600
+ let pre = S.pinState(dir, spawnSync, S.laneGeneratedPaths(dir, core));
601
+ assert.deepEqual(pre.other, []);
602
+ assert.deepEqual(S.healDecision(pre, 'push').action, 'commit');
603
+ // An owner's own skill is not in the installed core's .claude/, so it is still a human's work.
604
+ writeFileSync(path.join(dir, '.claude', 'skills', 'my-own', 'SKILL.md'), 'edited\n');
605
+ pre = S.pinState(dir, spawnSync, S.laneGeneratedPaths(dir, core));
606
+ assert.deepEqual(pre.other.map((e) => e.file), ['.claude/skills/my-own/SKILL.md']);
607
+ assert.deepEqual(S.healDecision(pre, 'push'), { action: 'halt' });
608
+ });
609
+
558
610
  test('the core still exports the two things the probe reads', () => {
559
611
  // laneGeneratedPaths reads them BY NAME off a module it resolves at runtime, so a rename in the
560
612
  // core degrades it silently to the empty set — the pre-fix wedge, back again and invisible.
@@ -238,51 +238,77 @@ function bootHub() {
238
238
  const b = new El(`step-${k}${by}`); b.attrs['data-demo-step'] = k; b.attrs['data-by'] = by; return b;
239
239
  });
240
240
  const shown = [];
241
- const state = { demo: null, description: '', teamShape: null };
242
- const sandbox = { $, state, saveDraft() {}, show: (n) => shown.push(n), root: { querySelectorAll: () => steppers } };
241
+ // the doors wait for the fork's tick (task 1004504), so the founder here has given it
242
+ const state = { step: 1, demo: null, description: '', teamShape: null, termsAck: true, confirmed: { team: true } };
243
+ const sandbox = { $, state, saveDraft() {}, show: (n) => shown.push(n), eyebrowFor: () => 'Demo', root: { querySelectorAll: () => steppers } };
243
244
  vm.createContext(sandbox);
244
245
  vm.runInContext(SLICE, sandbox);
245
246
  const click = (id) => $(id).listeners.click.call($(id));
246
247
  return { $, state, shown, steppers, click, sandbox };
247
248
  }
248
249
 
249
- test('the door opens the setup, the steppers stay in their limits, and the first read follows them', () => {
250
+ test('the door opens ONE setup screen, the steppers stay in their limits, and the first read follows them', () => {
250
251
  const h = bootHub();
252
+ h.state.termsAck = false;
253
+ h.click('wizDemo');
254
+ assert.equal(h.$('wizDemo').attrs['aria-expanded'], undefined, 'an unticked box opens nothing (task 1004504)');
255
+ assert.equal(h.$('forkIntro').hidden, false, 'the doors stay');
256
+ h.state.termsAck = true;
251
257
  h.click('wizDemo');
252
258
  assert.equal(h.$('forkIntro').hidden, true);
253
259
  assert.equal(h.$('demoSetup').hidden, false);
254
- assert.equal(h.$('demoRing').hidden, false, 'the time-box ring on the planet');
255
260
  assert.equal(h.$('wizDemo').attrs['aria-expanded'], 'true');
261
+ assert.equal(h.$('wizBack').hidden, false, 'the one Back walks back to the doors');
256
262
  for (let i = 0; i < 20; i += 1) h.steppers[0].listeners.click.call(h.steppers[0]);
257
263
  assert.equal(h.$('demoPeople').textContent, '12', 'people stop at twelve');
258
264
  for (let i = 0; i < 20; i += 1) h.steppers[3].listeners.click.call(h.steppers[3]);
259
265
  assert.equal(h.$('demoHours').textContent, '1', 'hours stop at one');
260
266
  assert.equal(h.$('demoFitSum').textContent, '12 people × 1 hour = 12 builder-hours.');
261
267
  assert.equal(h.$('demoFitVerdict').textContent, demo.fitCheck(12, 1).sentence);
262
- h.click('demoBack1');
268
+ h.sandbox.demoShow('intro');
263
269
  assert.equal(h.$('forkIntro').hidden, false, 'Back returns to the two doors');
264
270
  assert.equal(h.$('wizDemo').attrs['aria-expanded'], 'false');
271
+ assert.equal(h.$('wizBack').hidden, true);
272
+ assert.match(HUB, /if \(state\.step === 1 && !\$\('demoSetup'\)\.hidden\) \{ demoShow\('intro'\); \$\('wizDemo'\)\.focus\(\); return; \}/, 'which is what #wizBack does from the demo screen');
273
+ assert.doesNotMatch(HUB, /id="demoReady"|id="demoRing"|id="demoNext1"|id="demoBack1"|id="demoBack2"|id="demoStageCap"/, 'the second demo screen, its ring and its own Back buttons are gone');
265
274
  });
266
275
 
267
- test('Name my demo planet carries the demo into the ordinary wizard: the idea, the team, the invites', () => {
276
+ test('the demo screen\'s Continue carries the demo into the ordinary wizard: the idea, the team, the invites', () => {
268
277
  const h = bootHub();
269
278
  h.click('wizDemo');
279
+ assert.equal(h.$('demoInvites').hidden, false, 'the invites sit on the same screen');
270
280
  h.$('demoIdea').value = 'A shared reading list.';
271
281
  h.$('demoIdea').listeners.input.call(h.$('demoIdea'));
272
- h.click('demoNext1');
273
- assert.equal(h.$('demoReady').hidden, false);
274
282
  h.$('demoInvite1').value = '@avery';
275
283
  h.$('demoInvite2').value = 'not a handle!';
276
284
  h.click('demoStart');
277
285
  assert.deepEqual(JSON.parse(JSON.stringify(h.state.demo)), { people: 3, hours_each: 4, idea: 'A shared reading list.', invites: ['avery'] });
278
286
  assert.equal(h.state.description, 'A shared reading list.', 'the idea is the description');
287
+ assert.equal(h.$('f-desc').value, 'A shared reading list.', 'and the sentence field shows it');
279
288
  assert.equal(h.state.teamShape, 'small-team');
280
- assert.deepEqual(h.shown, [4], 'on to the name step, past planet physics (task 1004418)');
289
+ assert.deepEqual(JSON.parse(JSON.stringify(h.state.confirmed)), {}, 'the demo\'s own rows are asked again on the review');
290
+ assert.deepEqual(h.shown, [2], 'on to "What are you making?" (task 1004504)');
291
+ assert.match(HUB, /<input type="text" id="demoIdea" maxlength="600"/, 'the idea is one line now');
292
+ assert.match(HUB, /<button type="button" class="btn" id="demoStart">Continue<\/button>/);
293
+ });
294
+
295
+ test('a demo skips the quick checks and sends only its team shape', () => {
296
+ assert.match(HUB, /if \(next === 3 && state\.demo\) \{ physDefaults\(\); show\(4, true\); return; \}/, 'the What screen goes straight to the review for a demo');
297
+ const fn = (HUB.match(/function createDetail\(\) \{[\s\S]*?\n {4}\}/) || [null])[0];
298
+ assert.ok(fn, 'createDetail() was found');
299
+ const box = { physDetail: () => ({ framing: 'hybrid', reward_intents: ['cash'] }), state: {} };
300
+ vm.createContext(box);
301
+ vm.runInContext(fn, box);
302
+ box.state = { demo: { people: 3, hours_each: 4 }, teamShape: 'small-team' };
303
+ assert.deepEqual(JSON.parse(JSON.stringify(box.createDetail())), { team_shape: 'small-team' }, 'the server applies the demo\'s picked answers');
304
+ box.state = { demo: null, teamShape: 'small-team' };
305
+ assert.deepEqual(JSON.parse(JSON.stringify(box.createDetail())), { framing: 'hybrid', reward_intents: ['cash'], team_shape: 'small-team' }, 'a real project sends its answers');
306
+ assert.match(HUB, /if \(state\.demo && \(key === 'team' \|\| key === 'rewards' \|\| key === 'run'\)\) return true;/, 'the demo\'s own rows are fixed on the review');
281
307
  });
282
308
 
283
- test('the create sends the demo, the review names it, and the project page asks the question', () => {
309
+ test('the create sends the demo, the recap names it, and the project page asks the question', () => {
284
310
  assert.match(HUB, /demo: state\.demo \? \{ people: state\.demo\.people, hours_each: state\.demo\.hours_each \} : undefined,/);
285
- assert.match(HUB, /if \(state\.demo\) \{\n\s*rows\.unshift\(\['Demo',/);
311
+ assert.match(HUB, /if \(state\.demo\) bits\.push\('demo'\);/, 'the look screen\'s recap says it is a demo');
286
312
  assert.match(HUB, /<p class="demoKeepH">Keep your planet\?<\/p>/);
287
313
  assert.match(HUB, /data-demo-settle="carried">Carry it over</);
288
314
  assert.match(HUB, /data-demo-settle="kept">Keep it as a demo</);
@@ -24,7 +24,7 @@
24
24
  // NOT covered here, deliberately: the team shape. The first draft of this task claimed the
25
25
  // OAuth hop dropped it; that was wrong — teamShape, projType and modules all survive a
26
26
  // reload (verified live). What looked like a drop was the documented "clicking the chosen
27
- // pill again UNSAYS it" toggle being re-clicked by hand.
27
+ // pill again UNSAYS it" toggle being re-clicked by hand (a toggle task 1004504 retired; slice 3).
28
28
  //
29
29
  // Run: node --test --test-reporter=tap tests/wizard_draft_resume.mjs
30
30
 
@@ -70,29 +70,33 @@ function runStep({ authKnown, draftStep }) {
70
70
  }
71
71
 
72
72
  test('pre-auth: the placeholder renders step 1 but the draft KEEPS the real step', () => {
73
- const { rendered, persisted } = runStep({ authKnown: false, draftStep: 5 });
74
- assert.deepEqual(rendered, [1], 'still renders step 1 (the terms, since task 1004417) before identity settles');
75
- assert.equal(persisted.step, 5, 'the draft must not be overwritten with the placeholder');
73
+ // step 4 is the review since task 1004504 — where the code row's repo access is granted
74
+ const { rendered, persisted } = runStep({ authKnown: false, draftStep: 4 });
75
+ assert.deepEqual(rendered, [1], 'still renders step 1 (the fork, since task 1004504) before identity settles');
76
+ assert.equal(persisted.step, 4, 'the draft must not be overwritten with the placeholder');
76
77
  });
77
78
 
78
79
  test('pre-auth: the in-memory step is restored too, so onAuth() can re-enter to it', () => {
79
80
  // onAuth() calls enter() again and reads state.step from MEMORY, not from storage — so
80
81
  // restoring only the stored copy would fix nothing.
81
- const { state } = runStep({ authKnown: false, draftStep: 6 });
82
- assert.equal(state.step, 6);
82
+ for (const step of [2, 3, 4, 5]) {
83
+ const { state } = runStep({ authKnown: false, draftStep: step });
84
+ assert.equal(state.step, step, `step ${step} comes back`);
85
+ }
83
86
  });
84
87
 
85
88
  test('post-auth: the real step is rendered', () => {
86
- const { rendered, persisted } = runStep({ authKnown: true, draftStep: 5 });
87
- assert.deepEqual(rendered, [5]);
88
- assert.equal(persisted.step, 5);
89
+ const { rendered, persisted } = runStep({ authKnown: true, draftStep: 4 });
90
+ assert.deepEqual(rendered, [4]);
91
+ assert.equal(persisted.step, 4);
89
92
  });
90
93
 
91
94
  test('a draft already at step 1 is unchanged, and an out-of-range step is not resurrected', () => {
92
95
  assert.equal(runStep({ authKnown: false, draftStep: 1 }).persisted.step, 1);
93
- // 12 is the done panel (11 before task 1004421's Look step, 9 before task 1004417's front door, 8 before task 1004351), reached by its own branch
94
- // above this slice; 0 is the gate. Neither is a resumable wizard position, so the placeholder is allowed to stand.
95
- for (const bad of [0, 13, 99]) {
96
+ // 6 is the done panel since task 1004504 (12 before it, 11 before task 1004421's Look step, 9 before task
97
+ // 1004417's front door, 8 before task 1004351), reached by its own branch above this slice; 0 is the gate.
98
+ // Neither is a resumable wizard position, so the placeholder is allowed to stand.
99
+ for (const bad of [0, 6, 13, 99]) {
96
100
  assert.equal(runStep({ authKnown: false, draftStep: bad }).persisted.step, 1, `step ${bad} is not resumed`);
97
101
  }
98
102
  });
@@ -153,9 +157,11 @@ test('while the connect offer is up, the way back is not', () => {
153
157
  // ── slice 3 · why this task's original team-shape claim was wrong ─────────────────
154
158
  // The bug was filed claiming a GitHub round-trip drops the team shape. It does not: a
155
159
  // planted draft reads teamShape back intact after a reload (verified live on 1.19.580).
156
- // The real explanation is this handler — the pill is an UNSAY toggle, so re-clicking the
157
- // already-chosen shape clears it. During the R24 walk that second click was mine. Pinning
158
- // it here so the claim rests on the handler's actual behaviour rather than on prose.
160
+ // The explanation then was this handler — the pill was an UNSAY toggle, so re-clicking the
161
+ // already-chosen shape cleared it. Since task 1004504 the pills live on the review's Team
162
+ // size row, where a team always has a shape ("solo" follows a team of one), so the pill is
163
+ // a plain pick: a second click keeps the answer, and nothing in the handler can drop it.
164
+ // Pinned here so the claim rests on the handler's actual behaviour rather than on prose.
159
165
  // Anchored on addEventListener: the same selector also appears inside paintTeam(), which
160
166
  // only writes aria-pressed. Matching that one instead yields a slice that will not parse.
161
167
  const TEAM_RE = /root\.querySelectorAll\('#teamCards \.tpill'\)\.forEach\(function \(b\) \{\s*b\.addEventListener[\s\S]*?\n {4}\}\);/;
@@ -166,17 +172,20 @@ test('the team-pill slice is still findable', () => {
166
172
  assert.match(teamSlice, /state\.teamShape/, 'the handler owns the team-shape answer');
167
173
  });
168
174
 
169
- test('the team pill UNSAYS on a second click — the answer is not dropped, it is toggled off', () => {
175
+ test('the team pill is a plain pick on the Team size row — a second click keeps the answer (task 1004504)', () => {
170
176
  const state = { teamShape: null };
171
177
  const handlers = [];
178
+ const unconfirmed = [];
179
+ let saves = 0, paints = 0;
172
180
  const pill = (team) => ({
173
181
  getAttribute: (a) => (a === 'data-team' ? team : null),
174
182
  addEventListener: (_e, fn) => handlers.push({ team, fn }),
175
183
  });
176
184
  const sandbox = {
177
185
  state,
178
- root: { querySelectorAll: () => [pill('solo'), pill('small-team'), pill('community')] },
179
- paintTeam() {}, paintDetail() {}, saveDraft() {},
186
+ root: { querySelectorAll: () => [pill('small-team'), pill('community')] },
187
+ paintTeam() {}, saveDraft() { saves += 1; }, paintSetup() { paints += 1; },
188
+ unconfirm(keys) { unconfirmed.push(...keys); },
180
189
  };
181
190
  sandbox.globalThis = sandbox;
182
191
  vm.createContext(sandbox);
@@ -186,9 +195,12 @@ test('the team pill UNSAYS on a second click — the answer is not dropped, it i
186
195
  click('small-team');
187
196
  assert.equal(state.teamShape, 'small-team', 'first click answers');
188
197
  click('small-team');
189
- assert.equal(state.teamShape, null, 'the SAME pill again unsays it — this is the mis-read bug');
190
- click('small-team');
191
- assert.equal(state.teamShape, 'small-team', 'and a third click answers again');
198
+ assert.equal(state.teamShape, 'small-team', 'the SAME pill again keeps it — a team always has a shape');
192
199
  click('community');
193
- assert.equal(state.teamShape, 'community', 'a DIFFERENT pill replaces rather than clears');
200
+ assert.equal(state.teamShape, 'community', 'a DIFFERENT pill replaces it');
201
+ assert.equal(saves, 3, 'every pick is saved into the draft, so the GitHub round-trip keeps it');
202
+ assert.deepEqual([...new Set(unconfirmed)], ['extras'], 'the suggested extras follow the team, so the review asks about them again');
203
+ assert.equal(paints, 3, 'and the review repaints');
204
+ assert.deepEqual([...HUB.matchAll(/data-team="([a-z-]+)"/g)].map((m) => m[1]), ['small-team', 'community'],
205
+ 'the row offers who can join a team; one person is the "One person" pill, not a team shape');
194
206
  });