@bongos/core 1.19.774 → 1.19.775

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.
package/.bongos-core.json CHANGED
@@ -2,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.774",
6
- "core_contract": "1.19.774",
7
- "source_commit": "6d6560877cde735a21effbfbe166a9b6410dc6b5",
5
+ "core_version": "1.19.775",
6
+ "core_contract": "1.19.775",
7
+ "source_commit": "02a65922112e4360b309af219912dfbaae134134",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-16T18:13:25.558Z",
9
+ "built_at": "2026-09-16T18:20:46.880Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 486,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 2662,
20
- "tree_sha256": "9a1c294ec7e5b0ea58e0ae98f7a672078969f80834a4f92aa064246296c3ebda",
20
+ "tree_sha256": "98ff660bb87b9156a0e2535a91c5edf19a13b27736ca5c2a95982ce6e64f0ba4",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -422,7 +422,7 @@
422
422
  {
423
423
  "path": "config/modules.neutral.json",
424
424
  "mode": "0000644",
425
- "sha256": "ea124b7c53207fc2c361f91c34dabf0e0d827db19956c0152c4e14c5b4fd29f6"
425
+ "sha256": "9bdd8eb1fbfb9053ed0fb921c0f669dcdaad2fe8b2629ce4cc9457dca995afbe"
426
426
  },
427
427
  {
428
428
  "path": "docker-compose.yml",
@@ -2527,7 +2527,7 @@
2527
2527
  {
2528
2528
  "path": "docs/module-api-changelog.md",
2529
2529
  "mode": "0000644",
2530
- "sha256": "8580243194e524ec2b486bf25efba134ebde1c618ca73221c8cdb3b29dc11f9d"
2530
+ "sha256": "0868d9c27813842952237812a33990e846e6d8019be740290ee1ac49f3b19e0a"
2531
2531
  },
2532
2532
  {
2533
2533
  "path": "docs/modules-contract.md",
@@ -6372,7 +6372,7 @@
6372
6372
  {
6373
6373
  "path": "modules/provisioning/starter-bundles.js",
6374
6374
  "mode": "0000644",
6375
- "sha256": "78cdbb14f2dc911d78d1dd7e1ae73027734e94a3bbd8826b2f27e343a2daba1d"
6375
+ "sha256": "85f4b8e39801614457ed31f36220a0d2491c3d7dd8cd85ea04771d4ce77a79c2"
6376
6376
  },
6377
6377
  {
6378
6378
  "path": "modules/provisioning/tests/liveness.mjs",
@@ -7317,7 +7317,7 @@
7317
7317
  {
7318
7318
  "path": "modules/ui-design/kit/serve.js",
7319
7319
  "mode": "0000644",
7320
- "sha256": "0dd05fcbc98d62c99d59b7a0c258c4e7104cbfb64382f58fa003c62812be0515"
7320
+ "sha256": "b041a60e0babb4b9d409f6542f748471c86235b8015922bdc501a5c0b8a2625f"
7321
7321
  },
7322
7322
  {
7323
7323
  "path": "modules/ui-design/kit/tells.js",
@@ -7627,12 +7627,12 @@
7627
7627
  {
7628
7628
  "path": "package-lock.json",
7629
7629
  "mode": "0000644",
7630
- "sha256": "564ee1ef2714eb5d47be1bf7d996e0f0fb615837722a68534f9917de1cf3ebd1"
7630
+ "sha256": "6be6f24971120806165359b15deb4042a43de963b54a09c1c25108383a11197c"
7631
7631
  },
7632
7632
  {
7633
7633
  "path": "package.json",
7634
7634
  "mode": "0000644",
7635
- "sha256": "a6c0ebbfb2480954dad153c0105905a07553eb163a5e95c4ead4a7c64de8e58f"
7635
+ "sha256": "14e4c485f0795f8138ff062f0e1bf92740ddc2349117f8ef0d35570f4a9b672e"
7636
7636
  },
7637
7637
  {
7638
7638
  "path": "public-docs/index.html",
@@ -9492,7 +9492,7 @@
9492
9492
  {
9493
9493
  "path": "src/module-api.js",
9494
9494
  "mode": "0000644",
9495
- "sha256": "672c2b371fd586c6e143353c9482040124e18da76d0a64da29e5854fd0c1c08e"
9495
+ "sha256": "885acafa5a2527e8ede987295a14a562feaa93dc6c9bdc2e2284e6d5d98c2999"
9496
9496
  },
9497
9497
  {
9498
9498
  "path": "src/module-loader/catalog.js",
@@ -12132,7 +12132,7 @@
12132
12132
  {
12133
12133
  "path": "tests/project_type_editable.mjs",
12134
12134
  "mode": "0000644",
12135
- "sha256": "9d2432b2f7ad937415213e3cdbdb98aca5dd8e0c6eff5d73757ca0e873e89b8b"
12135
+ "sha256": "85ac90a891c8f6f19d7780dd1bae94964f770fbaa9620322c4bc3df16ce790d6"
12136
12136
  },
12137
12137
  {
12138
12138
  "path": "tests/project_visibility_db.mjs",
@@ -12242,7 +12242,7 @@
12242
12242
  {
12243
12243
  "path": "tests/provisioning_module_selection.mjs",
12244
12244
  "mode": "0000644",
12245
- "sha256": "92db65b8da5b849379bab03e70287239deab8b9a2b2e47b4574f12b8acac013e"
12245
+ "sha256": "dffa4b500d3afe770764e562236ee97b6d581ed3db301ee3f984c5caa6f6f35e"
12246
12246
  },
12247
12247
  {
12248
12248
  "path": "tests/provisioning_onboard_mode.mjs",
@@ -12257,7 +12257,7 @@
12257
12257
  {
12258
12258
  "path": "tests/provisioning_recommendations.mjs",
12259
12259
  "mode": "0000644",
12260
- "sha256": "0557876002c1f0efde5a8e7c87702fa57df3a9efb26ea93b077969bcc10e3637"
12260
+ "sha256": "587c6e640493939be17f6f293ceba8d22d125c79e43c070793b0325458132c71"
12261
12261
  },
12262
12262
  {
12263
12263
  "path": "tests/provisioning_restart.mjs",
@@ -12287,7 +12287,7 @@
12287
12287
  {
12288
12288
  "path": "tests/provisioning_starter_bundles.mjs",
12289
12289
  "mode": "0000644",
12290
- "sha256": "b63751ddf17d58ec3fdfdbd19c9f9a7569eed1ca9ff157a2a37f7034151eead7"
12290
+ "sha256": "5996c2dac6fbb427fba2eac98058ec9cea529fd2aa74d4b51e4085263af798ae"
12291
12291
  },
12292
12292
  {
12293
12293
  "path": "tests/provisioning_teardown_intent.mjs",
@@ -1,5 +1,5 @@
1
1
  {
2
- "_note": "Feature-module selection — the VANILLA (customer-0) default: every optional feature OFF. A fresh Cloud Bongos instance is the methodology core only (builders/tasks/claims/credits/grading/memory/recall) with the engineer discipline. To enable a module on an instance, copy this to config/modules.json and flip flags (or set <ENVPREFIX>_MODULE_<NAME>=1). Sibling of config/branding.neutral.json. Owned by R54 / task 1193 (src/modules.js, ADR 0062 §4). Known modules: game, art-pipeline, discord, dev-box.",
2
+ "_note": "Feature-module selection — the VANILLA (customer-0) default: every optional feature OFF. A fresh Cloud Bongos instance is the methodology core only (builders/tasks/claims/credits/grading/memory/recall) with the engineer discipline. To enable a module on an instance, copy this to config/modules.json and flip flags (or set <ENVPREFIX>_MODULE_<NAME>=1). Sibling of config/branding.neutral.json. Owned by R54 / task 1193 (src/modules.js, ADR 0062 §4). Known modules: game, art-pipeline, discord. RETIRED: dev-box (goal 1000091, task 1003886) — no longer offered in any starter bundle and not in the creation picker's catalog. The flag stays declared and false until modules/dev-box/ is deleted, so an instance carrying an older config/modules.json cannot silently re-enable it.",
3
3
  "modules": {
4
4
  "game": false,
5
5
  "art-pipeline": false,
@@ -2022,5 +2022,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2022
2022
  landed since 1.19.772 with no explicit bump. run 35118731311. (task 1002620)
2023
2023
  1.19.774 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2024
2024
  landed since 1.19.773 with no explicit bump. run 35132992149. (task 1002620)
2025
+ 1.19.775 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2026
+ landed since 1.19.774 with no explicit bump. run 35133750943. (task 1002620)
2025
2027
  ---------------------------------------------------------------------------
2026
2028
  ```
@@ -70,6 +70,21 @@ const ALWAYS_ON_CORE = Object.freeze([
70
70
  // refuse them by construction rather than by anyone remembering.
71
71
  const HUB_ONLY_MODULES = Object.freeze(['platform-identity', 'provisioning']);
72
72
 
73
+ // Modules that were once offered and are being WITHDRAWN. A retired key is still
74
+ // a module on disk — its manifest, routes and tables are deleted by later tasks —
75
+ // but it must stop being OFFERED the moment the withdrawal starts, or an owner
76
+ // creating a project today opts into something that is on its way out.
77
+ //
78
+ // WHY A LIST AND NOT JUST DELETING THE ROW. The integrity test below pins
79
+ // OPTIONAL_MODULES against the on-disk `default: false` manifests, so that a new
80
+ // opt-in module cannot ship without owner-facing copy. Dropping a row while its
81
+ // manifest is still on disk would trip exactly that guard. Naming the withdrawal
82
+ // keeps the guard honest in both directions: the catalog may omit a RETIRED
83
+ // module and may omit nothing else. The entry is deleted when the module is
84
+ // (goal 1000091, task 1003892) — at which point the manifest is gone and the
85
+ // plain drift check covers it again.
86
+ const RETIRED_MODULES = Object.freeze(['dev-box']);
87
+
73
88
  // The OPT-IN catalog as the creation picker renders it (BV1.R20, task 1002339):
74
89
  // every module an owner may actually choose, in the order the picker lists them,
75
90
  // with copy written for someone who has met none of them.
@@ -90,11 +105,6 @@ const OPTIONAL_MODULES = Object.freeze([
90
105
  label: 'Extra agents',
91
106
  summary: 'More AI agents alongside the ones every project gets, for reading, research and review.',
92
107
  }),
93
- Object.freeze({
94
- key: 'dev-box',
95
- label: 'Dev boxes',
96
- summary: 'A cloud workspace where a change can be tried out before anyone else sees it. Nothing is charged until someone asks for one.',
97
- }),
98
108
  Object.freeze({
99
109
  key: 'discord',
100
110
  label: 'Discord',
@@ -122,8 +132,10 @@ const OPTIONAL_MODULE_KEYS = Object.freeze(OPTIONAL_MODULES.map((m) => m.key));
122
132
  // stay there. Decision: ADR 0243 (BV1.R22, task 1002341).
123
133
  //
124
134
  // Deliberately tiny: ADR 0237 already made the per-type preset the opinion, and
125
- // today's opt-in catalog is three modules wide — so this table's job is to correct
126
- // a preset the owner's answer contradicts, not to re-derive it.
135
+ // today's opt-in catalog is two modules wide — so this table's job is to correct
136
+ // a preset the owner's answer contradicts, not to re-derive it. The two rules that
137
+ // added and removed `dev-box` by team shape went with the module (task 1003886):
138
+ // a rule about a module nobody can select adjusts nothing.
127
139
  //
128
140
  // A rule may only ever name an OPT-IN module. Adding or removing an always-on one
129
141
  // would be a toggle that toggles nothing (ADR 0237 §3's argument, and
@@ -136,20 +148,6 @@ const TEAM_SHAPE_RULES = Object.freeze([
136
148
  module: 'discord',
137
149
  reason: 'You said an open community is building this, so somewhere for it to gather is switched on.',
138
150
  }),
139
- Object.freeze({
140
- id: 'a-team-shares-one-environment',
141
- teamShape: 'small-team',
142
- effect: 'add',
143
- module: 'dev-box',
144
- reason: 'A small team gets one shared environment to build in, so nobody is debugging their own laptop.',
145
- }),
146
- Object.freeze({
147
- id: 'building-alone-needs-no-shared-environment',
148
- teamShape: 'solo',
149
- effect: 'remove',
150
- module: 'dev-box',
151
- reason: 'You are building this alone, so the shared cloud environment is off — your own machine is the setup.',
152
- }),
153
151
  ]);
154
152
 
155
153
  // The team shapes the rules above can fire on. Derived from the table rather than
@@ -167,16 +165,17 @@ const RULED_TEAM_SHAPES = Object.freeze([...new Set(TEAM_SHAPE_RULES.map((r) =>
167
165
  // here has not met any of these modules yet.
168
166
  //
169
167
  // The lists are SHORT on purpose. A preselect an owner has to audit is worse than
170
- // none, and today's opt-in catalog is genuinely small (`agents`, `dev-box`,
171
- // `discord`); a bundle grows as the catalog does, which is exactly why this is a
172
- // declaration and not a rule engine. Enabling `dev-box` opens the door to a cloud
173
- // dev environment — it provisions nothing and bills nothing until a builder asks
174
- // for a box — so preselecting it costs the owner nothing but a toggle.
168
+ // none, and today's opt-in catalog is genuinely small (`agents`, `discord`); a
169
+ // bundle grows as the catalog does, which is exactly why this is a declaration and
170
+ // not a rule engine. `dev-box` was preselected by the game and business bundles
171
+ // until it was retired (goal 1000091, task 1003886) — withdrawing it left both
172
+ // bundles one module wide, which is the floor working as intended, not a gap to
173
+ // backfill with a module an owner did not ask for.
175
174
  const STARTER_BUNDLES = Object.freeze({
176
175
  game: Object.freeze({
177
176
  label: 'Game starter',
178
- summary: 'Somewhere for players to gather, and a place to walk a change through before anyone plays it.',
179
- modules: Object.freeze(['dev-box', 'discord']),
177
+ summary: 'Somewhere for players to gather, hear what you are building, and send their ideas back.',
178
+ modules: Object.freeze(['discord']),
180
179
  }),
181
180
  research: Object.freeze({
182
181
  label: 'Research starter',
@@ -185,8 +184,8 @@ const STARTER_BUNDLES = Object.freeze({
185
184
  }),
186
185
  business: Object.freeze({
187
186
  label: 'Business starter',
188
- summary: 'A place to try a change before customers see it, and agents to look it over first.',
189
- modules: Object.freeze(['agents', 'dev-box']),
187
+ summary: 'Extra agents to look a change over before customers ever see it.',
188
+ modules: Object.freeze(['agents']),
190
189
  }),
191
190
  'non-profit': Object.freeze({
192
191
  label: 'Non-profit starter',
@@ -259,6 +258,8 @@ function adjustBundle(bundle, teamShape) {
259
258
  for (const rule of TEAM_SHAPE_RULES) {
260
259
  if (rule.teamShape !== teamShape) continue;
261
260
  if (rule.effect === 'add' && !held.has(rule.module)) held.add(rule.module);
261
+ // `remove` is declared but named by no live rule since dev-box was retired
262
+ // (task 1003886). Kept for the next rule that needs it, not exercised today.
262
263
  else if (rule.effect === 'remove' && held.has(rule.module)) held.delete(rule.module);
263
264
  else continue;
264
265
  adjustments.push({ rule: rule.id, effect: rule.effect, module: rule.module, reason: rule.reason });
@@ -374,6 +375,7 @@ function effectiveModules(row) {
374
375
  module.exports = {
375
376
  ALWAYS_ON_CORE,
376
377
  HUB_ONLY_MODULES,
378
+ RETIRED_MODULES,
377
379
  OPTIONAL_MODULES,
378
380
  STARTER_BUNDLES,
379
381
  TEAM_SHAPE_RULES,
@@ -121,15 +121,14 @@ const STARTER_BUNDLES_FIXTURE = {
121
121
  'security', 'sessions', 'status-ui', 'ui-design',
122
122
  ],
123
123
  bundles: [
124
- { type: 'game', bundle: ['dev-box', 'discord'] },
124
+ { type: 'game', bundle: ['discord'] },
125
125
  { type: 'research', bundle: ['agents'] },
126
- { type: 'business', bundle: ['agents', 'dev-box'] },
126
+ { type: 'business', bundle: ['agents'] },
127
127
  { type: 'non-profit', bundle: ['discord'] },
128
128
  { type: 'not-sure', bundle: [] },
129
129
  ],
130
130
  optional: [
131
131
  { key: 'agents', label: 'Extra agents', summary: 'More AI agents alongside the ones every project gets, for reading, research and review.' },
132
- { key: 'dev-box', label: 'Dev boxes', summary: 'A cloud workspace where a change can be tried out before anyone else sees it. Nothing is charged until someone asks for one.' },
133
132
  { key: 'discord', label: 'Discord', summary: 'A Discord server that carries your build news, and lets people send ideas and bug reports back.' },
134
133
  ],
135
134
  };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.774",
3
+ "version": "1.19.775",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.774",
9
+ "version": "1.19.775",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.774",
3
+ "version": "1.19.775",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.774'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.775'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -98,7 +98,7 @@ test('updateInstanceDetail can change the type and something else in one stateme
98
98
  test('every SET clause binds a PLACEHOLDER — a bare index is not valid SQL', async () => {
99
99
  const db = recordingDb({ id: 41 });
100
100
  await realUpdateInstanceDetail(db, 41, {
101
- description: 'Hello.', detail: { team_shape: 'solo' }, modules: ['dev-box'], type: 'business',
101
+ description: 'Hello.', detail: { team_shape: 'solo' }, modules: ['agents'], type: 'business',
102
102
  });
103
103
  const { text, params } = db.calls[0];
104
104
  const sets = text.slice(text.indexOf('SET '), text.indexOf(' WHERE '));
@@ -114,10 +114,10 @@ test('every SET clause binds a PLACEHOLDER — a bare index is not valid SQL', a
114
114
 
115
115
  test('the modules branch still binds its JSON, not its own parameter index', async () => {
116
116
  const db = recordingDb({ id: 41 });
117
- await realUpdateInstanceDetail(db, 41, { modules: ['dev-box', 'discord'] });
117
+ await realUpdateInstanceDetail(db, 41, { modules: ['agents', 'discord'] });
118
118
  const { text, params } = db.calls[0];
119
119
  assert.match(text, /modules = \$\d+::jsonb/, 'the placeholder carries the cast, the index does not replace it');
120
- assert.ok(params.some((p) => typeof p === 'string' && p.includes('dev-box')),
120
+ assert.ok(params.some((p) => typeof p === 'string' && p.includes('agents')),
121
121
  'the serialized selection reaches the statement');
122
122
  });
123
123
 
@@ -313,7 +313,7 @@ function runKind({ inst, fetchImpl, catalogMods } = {}) {
313
313
  return { sandbox, els, calls, band: () => el('mKindPick').innerHTML, msg: () => el('mKindMsg') };
314
314
  }
315
315
 
316
- const INST = (over = {}) => ({ id: 18, type: 'game', modules: { selected: ['dev-box'], source: 'bundle' }, ...over });
316
+ const INST = (over = {}) => ({ id: 18, type: 'game', modules: { selected: ['agents'], source: 'bundle' }, ...over });
317
317
 
318
318
  test('the band paints every kind from TYPE_META, with the declared one pressed', () => {
319
319
  const h = runKind({ inst: INST() });
@@ -353,7 +353,7 @@ function okFetch(body) {
353
353
  test('a pick sends the kind on the own-scoped detail channel and repaints from the PLATFORM\'S answer', async () => {
354
354
  const h = runKind({
355
355
  inst: INST({ type: 'game' }),
356
- fetchImpl: okFetch({ type: 'research', modules: { selected: ['dev-box'], source: 'picked' } }),
356
+ fetchImpl: okFetch({ type: 'research', modules: { selected: ['agents'], source: 'picked' } }),
357
357
  });
358
358
  h.sandbox.doKindPick('research');
359
359
  await new Promise((r) => setImmediate(r));
@@ -369,7 +369,7 @@ test('when the extras followed the kind, the band above is repainted AND the own
369
369
  // the type's starter bundle (ADR 0240 §2) — so changing the kind moves the extras.
370
370
  // Leaving that band painted from the old kind would be a lie on the same page.
371
371
  const h = runKind({
372
- inst: INST({ type: 'game', modules: { selected: ['dev-box'], source: 'bundle' } }),
372
+ inst: INST({ type: 'game', modules: { selected: ['agents'], source: 'bundle' } }),
373
373
  fetchImpl: okFetch({ type: 'research', modules: { selected: [], source: 'bundle' } }),
374
374
  });
375
375
  h.sandbox.doKindPick('research');
@@ -380,8 +380,8 @@ test('when the extras followed the kind, the band above is repainted AND the own
380
380
 
381
381
  test('a set the owner picked for themselves never moves, and the receipt does not claim it did', async () => {
382
382
  const h = runKind({
383
- inst: INST({ type: 'game', modules: { selected: ['dev-box'], source: 'picked' } }),
384
- fetchImpl: okFetch({ type: 'business', modules: { selected: ['dev-box'], source: 'picked' } }),
383
+ inst: INST({ type: 'game', modules: { selected: ['agents'], source: 'picked' } }),
384
+ fetchImpl: okFetch({ type: 'business', modules: { selected: ['agents'], source: 'picked' } }),
385
385
  });
386
386
  h.sandbox.doKindPick('business');
387
387
  await new Promise((r) => setImmediate(r));
@@ -235,12 +235,12 @@ await ta('POST /instances: the picker\'s ids ride the create request through to
235
235
 
236
236
  // Act — the body the wizard files
237
237
  const res = await post('/provisioning/instances', {
238
- slug: 'mercury', hosting_shape: 'standalone', type: 'game', modules: ['dev-box', 'discord'],
238
+ slug: 'mercury', hosting_shape: 'standalone', type: 'game', modules: ['agents', 'discord'],
239
239
  });
240
240
 
241
241
  // Assert
242
242
  assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
243
- assert.deepEqual(lastCreateArgs && lastCreateArgs.modules, ['dev-box', 'discord']);
243
+ assert.deepEqual(lastCreateArgs && lastCreateArgs.modules, ['agents', 'discord']);
244
244
  });
245
245
 
246
246
  await ta('POST /instances: an omitted modules key is still a valid create — skipping is supported, not an error', async () => {
@@ -324,17 +324,17 @@ await ta('PATCH /detail: the reply carries modules, resolved from the row the UP
324
324
  detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: {} };
325
325
  lastUpdatedRow = null;
326
326
 
327
- // Act — the owner says they are building it alone
328
- const res = await patch('/provisioning/instances/41/detail', { detail: { team_shape: 'solo' } });
327
+ // Act — the owner says an open community is building it
328
+ const res = await patch('/provisioning/instances/41/detail', { detail: { team_shape: 'community' } });
329
329
 
330
330
  // Assert — the field is there, and it is the POST-patch resolution: a route
331
- // that answered off `inst` instead of `updated` would still say 'dev-box'.
331
+ // that answered off `inst` instead of `updated` would still omit 'discord'.
332
332
  assert.equal(res.status, 200, res.raw);
333
333
  assert.ok(lastUpdatedRow, 'the route ran the update');
334
334
  assert.deepEqual(res.body.modules, effectiveModules(lastUpdatedRow));
335
- assert.deepEqual(res.body.modules.selected, ['discord'], 'solo drops the shared dev box out of the game bundle');
335
+ assert.deepEqual(res.body.modules.selected, ['discord'], 'community keeps the gathering place in the game bundle');
336
336
  assert.equal(res.body.modules.source, 'bundle');
337
- assert.ok(res.body.modules.adjustments.some((a) => a.module === 'dev-box'), 'and the change carries its reason');
337
+ assert.deepEqual(res.body.modules.adjustments, [], 'the game bundle already carried it, so nothing is claimed');
338
338
  detailRow = null; lastUpdatedRow = null;
339
339
  });
340
340
 
@@ -387,14 +387,14 @@ await ta('PATCH /detail: an owner who skipped the picker can add one module late
387
387
  await ta('PATCH /detail: a module can be REMOVED later too — the list replaces, it never merges', async () => {
388
388
  // The half a merging write would make impossible, which is why
389
389
  // updateInstanceDetail replaces `modules` and merges `detail`.
390
- detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: ['dev-box', 'discord'], detail: {} };
390
+ detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: ['agents', 'discord'], detail: {} };
391
391
  lastUpdatedRow = null;
392
392
 
393
393
  const res = await patch('/provisioning/instances/41/detail', { modules: ['discord'] });
394
394
 
395
395
  assert.equal(res.status, 200, res.raw);
396
396
  assert.deepEqual(lastUpdatedRow.modules, ['discord']);
397
- assert.deepEqual(res.body.modules.selected, ['discord'], 'dev-box is gone, not still merged in');
397
+ assert.deepEqual(res.body.modules.selected, ['discord'], 'agents is gone, not still merged in');
398
398
  assert.equal(res.body.modules.source, 'chosen');
399
399
  detailRow = null; lastUpdatedRow = null;
400
400
  });
@@ -517,17 +517,17 @@ await ta('PATCH /detail: another builder’s project is a 404, not a module chan
517
517
  // — not merely "close to one".
518
518
 
519
519
  await ta('PATCH /detail: answer then CLEAR — the row reads back exactly as one that was never asked', async () => {
520
- // Arrange — a game project that skipped the picker, so its modules resolve from
520
+ // Arrange — a research project that skipped the picker, so its modules resolve from
521
521
  // the bundle and the interview answer is the only thing that can move them.
522
- const virgin = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: {} };
522
+ const virgin = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'research', modules: null, detail: {} };
523
523
  const neverAnswered = effectiveModules(virgin);
524
524
  assert.equal(neverAnswered.source, 'bundle', 'the fixture really is an unanswered project');
525
525
 
526
526
  detailRow = { ...virgin };
527
527
  lastUpdatedRow = null;
528
- const answered = await patch('/provisioning/instances/41/detail', { detail: { team_shape: 'solo' } });
528
+ const answered = await patch('/provisioning/instances/41/detail', { detail: { team_shape: 'community' } });
529
529
  assert.equal(answered.status, 200, answered.raw);
530
- assert.deepEqual(answered.body.detail, { team_shape: 'solo' });
530
+ assert.deepEqual(answered.body.detail, { team_shape: 'community' });
531
531
  assert.notDeepEqual(answered.body.modules.selected, neverAnswered.selected,
532
532
  'the fixture is only meaningful if the answer really moved the bundle');
533
533
 
@@ -543,7 +543,7 @@ await ta('PATCH /detail: answer then CLEAR — the row reads back exactly as one
543
543
  assert.deepEqual(cleared.body.detail, {}, 'and the read surface says the interview is unanswered');
544
544
  assert.deepEqual(cleared.body.modules, neverAnswered, 'source, preset and adjustments are the never-answered ones');
545
545
  assert.equal(cleared.body.modules.source, 'bundle');
546
- assert.deepEqual(cleared.body.modules.selected, bundleForProjectType('game').bundle, 'the UNADJUSTED preset');
546
+ assert.deepEqual(cleared.body.modules.selected, bundleForProjectType('research').bundle, 'the UNADJUSTED preset');
547
547
  assert.deepEqual(cleared.body.modules.adjustments, [], 'no rule fired, so nothing claims to have');
548
548
  assert.deepEqual(cleared.body.publish, answered.body.publish, 'clearing an answer is not a publish change');
549
549
  detailRow = null; lastUpdatedRow = null;
@@ -101,42 +101,34 @@ test('an open community adds somewhere to gather to a bundle that lacks one', ()
101
101
  assert.ok(recs.bundle.adjustments[0].reason.length > 0, 'an adjustment always says why');
102
102
  });
103
103
 
104
- test('a small team adds the shared build environment', () => {
105
- // Arrange
106
- const answers = { type: 'research', team_shape: 'small-team' };
107
-
108
- // Act
109
- const recs = recommendationsForDetails(answers);
110
-
111
- // Assert
112
- assert.deepEqual(recs.bundle.bundle, ['agents', 'dev-box']);
113
- assert.deepEqual(recs.bundle.adjustments.map((a) => a.module), ['dev-box']);
114
- });
115
-
116
- test('a solo builder drops the shared build environment from the bundle', () => {
117
- // Arrange — the business bundle is `agents` + `dev-box`, so the removal bites.
118
- const answers = { type: 'business', team_shape: 'solo' };
104
+ test('a community adds the gathering place to a bundle that lacks it', () => {
105
+ // The surviving bundle rule's ADD path. The two rules that added and removed
106
+ // `dev-box` by team shape went with the module (task 1003886), which leaves
107
+ // `effect: 'remove'` declared in adjustBundle but named by no live rule - it is
108
+ // kept for the next rule that needs it, not because anything exercises it today.
109
+ // Arrange - the research bundle is `agents` alone, so the addition bites.
110
+ const answers = { type: 'research', team_shape: 'community' };
119
111
 
120
112
  // Act
121
113
  const recs = recommendationsForDetails(answers);
122
114
 
123
115
  // Assert
124
- assert.deepEqual(recs.bundle.bundle, ['agents']);
125
- assert.equal(recs.bundle.adjustments.length, 1);
126
- assert.equal(recs.bundle.adjustments[0].effect, 'remove');
127
- assert.equal(recs.bundle.adjustments[0].module, 'dev-box');
116
+ assert.deepEqual(recs.bundle.bundle, ['agents', 'discord']);
117
+ assert.deepEqual(recs.bundle.adjustments.map((a) => a.module), ['discord']);
118
+ assert.equal(recs.bundle.adjustments[0].effect, 'add');
128
119
  });
129
120
 
130
121
  test('a rule does not fire for a team shape it does not name', () => {
131
- // Arrange — `solo` removes dev-box; `community` must leave it exactly alone.
132
- const answers = { type: 'business', team_shape: 'community' };
122
+ // Arrange - `community` adds discord; `solo` names no rule, so the business
123
+ // bundle must come back exactly as declared.
124
+ const answers = { type: 'business', team_shape: 'solo' };
133
125
 
134
126
  // Act
135
127
  const recs = recommendationsForDetails(answers);
136
128
 
137
129
  // Assert
138
- assert.ok(recs.bundle.bundle.includes('dev-box'), 'the solo removal did not leak onto another shape');
139
- assert.deepEqual(recs.bundle.adjustments.map((a) => a.rule), ['a-community-needs-somewhere-to-gather']);
130
+ assert.deepEqual(recs.bundle.bundle, ['agents'], 'the community addition did not leak onto another shape');
131
+ assert.deepEqual(recs.bundle.adjustments, [], 'and nothing is claimed');
140
132
  });
141
133
 
142
134
  test('a rule whose module the bundle already carries records no adjustment', () => {
@@ -304,8 +296,8 @@ test('a caller may mutate the result without corrupting the declaration', () =>
304
296
 
305
297
  // Assert
306
298
  const second = recommendationsForDetails({ type: 'game', team_shape: 'small-team' });
307
- assert.deepEqual(second.bundle.bundle, ['dev-box', 'discord']);
308
- assert.deepEqual(second.bundle.modules, [...ALWAYS_ON_CORE, 'dev-box', 'discord']);
299
+ assert.deepEqual(second.bundle.bundle, ['discord']);
300
+ assert.deepEqual(second.bundle.modules, [...ALWAYS_ON_CORE, 'discord']);
309
301
  assert.equal(second.invites.seeking.limit, 3);
310
302
  });
311
303
 
@@ -37,6 +37,7 @@ import {
37
37
  OPTIONAL_MODULES,
38
38
  STARTER_BUNDLES,
39
39
  HUB_ONLY_MODULES,
40
+ RETIRED_MODULES,
40
41
  RULED_TEAM_SHAPES,
41
42
  bundleForProjectType,
42
43
  allStarterBundles,
@@ -335,6 +336,9 @@ test('the opt-in catalog declared for the picker IS the on-disk opt-in set', ()
335
336
  const onDisk = manifestsOnDisk();
336
337
  const optInOnDisk = [...onDisk.values()]
337
338
  .filter((m) => m.default !== true && !HUB_ONLY_MODULES.includes(m.key))
339
+ // A RETIRED module is still on disk while its removal lands, and must already
340
+ // be gone from the catalog — so it is the one permitted absence (task 1003886).
341
+ .filter((m) => !RETIRED_MODULES.includes(m.key))
338
342
  .map((m) => m.key)
339
343
  .sort();
340
344
 
@@ -346,6 +350,24 @@ test('the opt-in catalog declared for the picker IS the on-disk opt-in set', ()
346
350
  'the picker\'s catalog has drifted from the manifests — a module was added, removed, or flipped its default');
347
351
  });
348
352
 
353
+ test('a RETIRED module is withdrawn everywhere an owner could still reach it', () => {
354
+ // The retirement is only real if it holds at every offer point at once. A key
355
+ // left in any one of them is still a module a new project can end up with,
356
+ // which is exactly what the withdrawal exists to stop (task 1003886).
357
+ for (const key of RETIRED_MODULES) {
358
+ assert.ok(!OPTIONAL_MODULES.some((m) => m.key === key),
359
+ `retired module "${key}" is still a row the creation picker offers`);
360
+ for (const [type, bundle] of Object.entries(STARTER_BUNDLES)) {
361
+ assert.ok(!bundle.modules.includes(key),
362
+ `retired module "${key}" is still preselected by the "${type}" bundle`);
363
+ }
364
+ // Posting it directly is a real answer that resolves to nothing, not a
365
+ // non-answer: the key is DROPPED, exactly as an unknown one would be.
366
+ assert.deepEqual(normalizeModuleSelection([key]), [],
367
+ `retired module "${key}" is still accepted when an owner posts it directly`);
368
+ }
369
+ });
370
+
349
371
  test('every opt-in module carries owner-facing copy, and no module jargon rides along', () => {
350
372
  for (const row of OPTIONAL_MODULES) {
351
373
  assert.ok(row.label && row.label.length <= 40, `"${row.key}" needs a short label`);
@@ -411,24 +433,24 @@ test('effectiveModules: an answered picker wins over the bundle, including "none
411
433
  });
412
434
 
413
435
  test('effectiveModules: a skipped picker after an ANSWERED interview yields the TAILORED bundle', () => {
414
- // BV1.R22 / ADR 0243 — the property ADR 0240 §2 leans the skip on ("the
436
+ // BV1.R22 / ADR 0243 — the property ADR 0240 leans the skip on ("the
415
437
  // preselect and the fallback are the same declaration"), now that the panel
416
438
  // preselects the team-shape-adjusted set. Without this the picker would show a
417
- // solo owner one thing and the project would quietly get another.
418
- const eff = effectiveModules({ type: 'game', modules: null, detail: { team_shape: 'solo' } });
439
+ // community owner one thing and the project would quietly get another.
440
+ const eff = effectiveModules({ type: 'research', modules: null, detail: { team_shape: 'community' } });
419
441
 
420
- assert.deepEqual(eff.selected, ['discord'], 'the shared environment the rule removes is not silently restored');
442
+ assert.deepEqual(eff.selected, ['agents', 'discord'], 'the gathering place the rule adds is really there');
421
443
  assert.equal(eff.source, 'bundle', 'and it is still an UNANSWERED set, not a stored one');
422
- assert.deepEqual(eff.adjustments.map((a) => a.module), ['dev-box']);
423
- assert.match(eff.adjustments[0].reason, /building this alone/, 'carrying the reason the panel showed');
444
+ assert.deepEqual(eff.adjustments.map((a) => a.module), ['discord']);
445
+ assert.match(eff.adjustments[0].reason, /open community/, 'carrying the reason the panel showed');
424
446
  });
425
447
 
426
448
  test("effectiveModules: the owner's own answer is never re-adjusted by a rule", () => {
427
449
  // A rule corrects a PRESELECT the owner did not make. Once they have toggled,
428
450
  // re-applying it would overrule them with an answer they had already refused.
429
- const eff = effectiveModules({ type: 'game', modules: ['dev-box'], detail: { team_shape: 'solo' } });
451
+ const eff = effectiveModules({ type: 'research', modules: [], detail: { team_shape: 'community' } });
430
452
 
431
- assert.deepEqual(eff.selected, ['dev-box'], 'solo removes dev-box, and the owner put it back');
453
+ assert.deepEqual(eff.selected, [], 'community adds discord, and the owner switched everything off');
432
454
  assert.equal(eff.source, 'chosen');
433
455
  assert.deepEqual(eff.adjustments, [], 'and no reason is handed back for a choice they made themselves');
434
456
  });
@@ -439,7 +461,7 @@ test('effectiveModules: a detail that says nothing usable adjusts nothing', () =
439
461
  // importing it.
440
462
  for (const detail of [undefined, null, {}, { team_shape: null }, { team_shape: 'a-huge-corporation' }, 'not-an-object', []]) {
441
463
  const eff = effectiveModules({ type: 'game', modules: null, detail });
442
- assert.deepEqual(eff.selected, ['dev-box', 'discord'], `detail=${JSON.stringify(detail)} falls back to the type's bundle`);
464
+ assert.deepEqual(eff.selected, ['discord'], `detail=${JSON.stringify(detail)} falls back to the type's bundle`);
443
465
  assert.deepEqual(eff.adjustments, []);
444
466
  }
445
467
  });