@bongos/core 1.19.527 → 1.19.529

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.
@@ -1491,5 +1491,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1491
1491
  landed since 1.19.525 with no explicit bump. run 33824700579. (task 1002620)
1492
1492
  1.19.527 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1493
1493
  landed since 1.19.526 with no explicit bump. run 33827382028. (task 1002620)
1494
+ 1.19.528 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1495
+ landed since 1.19.527 with no explicit bump. run 33829339717. (task 1002620)
1496
+ 1.19.529 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1497
+ landed since 1.19.528 with no explicit bump. run 33877197857. (task 1002620)
1494
1498
  ---------------------------------------------------------------------------
1495
1499
  ```
@@ -23,6 +23,25 @@ function runSubagent({ prompt, opts = {} }) {
23
23
  // isn't named `claude` on PATH — covers any OS, not just Windows.
24
24
  const bin = opts.claudeBin ?? process.env.OTB_CLAUDE_BIN ?? process.env.CLAUDE_BIN ?? 'claude';
25
25
 
26
+ // THE PERMISSION MODE IS ROOT-DEPENDENT (task 1003574). `bypassPermissions` maps
27
+ // to --dangerously-skip-permissions, and the CLI REFUSES that when euid is 0:
28
+ // "--dangerously-skip-permissions cannot be used with root/sudo privileges"
29
+ // It exits non-zero before doing any work, so under root EVERY worker returns
30
+ // SUBAGENT_NONZERO_EXIT and the whole panel dies. A root container is an ordinary
31
+ // CI/sandbox shape, not an exotic one — this was hit on a real ship (task 1003280).
32
+ //
33
+ // `acceptEdits` is the fallback because it is the closest mode the CLI accepts as
34
+ // root, and it grants this worker NOTHING extra: the tool list is `Read` alone, so
35
+ // there are no edits to accept. Verified as root that a worker under acceptEdits
36
+ // still reads files inside its cwd (which is all a grader worker ever does) while
37
+ // a path outside the project is refused without hanging — in -p there is no prompt
38
+ // to answer, so a denial returns rather than blocking.
39
+ //
40
+ // Non-root keeps bypassPermissions exactly as before: this narrows the mode only
41
+ // where the wider one cannot run at all.
42
+ const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
43
+ const permissionMode = isRoot ? 'acceptEdits' : 'bypassPermissions';
44
+
26
45
  const args = [
27
46
  '-p',
28
47
  '--print',
@@ -30,7 +49,7 @@ function runSubagent({ prompt, opts = {} }) {
30
49
  '--output-format', 'json',
31
50
  '--tools', 'Read',
32
51
  '--disable-slash-commands',
33
- '--permission-mode', 'bypassPermissions',
52
+ '--permission-mode', permissionMode,
34
53
  ];
35
54
 
36
55
  const startedAt = Date.now();
@@ -183,6 +183,11 @@ function aggregatePanel(workerResults, meta = {}) {
183
183
  // If the panel were somehow advisory-only, fall back to gating on everyone so
184
184
  // a lone Narc still counts (defensive — the default panel always has 3+
185
185
  // gating workers).
186
+ // A pseudo-worker produced by a deterministic check rather than an LLM subagent
187
+ // (route-rank, permission-path — both stamp model:'deterministic'). It cannot be
188
+ // taken out by a grader outage, so it is not evidence either way about whether the
189
+ // panel ran. Task 1003574.
190
+ const isDeterministic = (w) => w && w.model === 'deterministic';
186
191
  const nonAdvisory = workerResults.filter((w) => !isAdvisory(w));
187
192
  const gating = nonAdvisory.length ? nonAdvisory : workerResults;
188
193
  const gatingFail = gating.filter((w) => w.verdict === 'fail' && !isMissingSignal(w)).length;
@@ -202,7 +207,25 @@ function aggregatePanel(workerResults, meta = {}) {
202
207
  // is an infra/outage condition, NOT a 0/10 quality rejection. Distinguished
203
208
  // from the partial-error case (some gating workers errored, others reported)
204
209
  // so callers (ship.js) can present "grader unavailable" instead of a FAIL.
205
- const gatingErrorAll = gating.length > 0 && gating.every((w) => isMissingSignal(w));
210
+ //
211
+ // TASK 1003574 — WHY THIS COUNTS ONLY THE SUBAGENT WORKERS. When #943 was written
212
+ // every gating worker was an LLM subagent, so "all of them are missing signal" and
213
+ // "the grader could not run" were the same sentence. They stopped being the same
214
+ // the moment the DETERMINISTIC pseudo-workers (route-rank, permission-path) were
215
+ // appended to workerResults: those never spawn a subprocess, so an outage cannot
216
+ // touch them, and a passing route-rank made `gating.every(isMissingSignal)`
217
+ // permanently false. The effect was that a TOTAL subagent outage could no longer
218
+ // reach this branch at all — it fell through to securityLensErrored and was
219
+ // reported as `FAIL · 0/10` with zero issues and every rubric axis null, which is
220
+ // indistinguishable from a real rejection and routes the builder to a re-grade
221
+ // that cannot clear it. Observed end-to-end on task 1003280.
222
+ //
223
+ // Excluding the deterministic checks here does NOT weaken the gate: a
224
+ // deterministic worker that FAILS is a real finding and is caught by `gatingFail`,
225
+ // which is the first branch and takes precedence over this one. All this says is
226
+ // that a deterministic check PASSING is not evidence the grader ran.
227
+ const subagentGating = gating.filter((w) => !isDeterministic(w));
228
+ const gatingErrorAll = subagentGating.length > 0 && subagentGating.every((w) => isMissingSignal(w));
206
229
  // Advisory-worker outcomes — never a hard block. Uses isAdvisory so a Narc
207
230
  // that DID raise a blocker is treated as gating (in `nonAdvisory`), not here.
208
231
  const advisoryFail = workerResults.some((w) => isAdvisory(w) && w.verdict === 'fail' && !isMissingSignal(w));
@@ -77,10 +77,85 @@
77
77
  // a throw is caught and treated as "no match", because a broken
78
78
  // predicate must not stop the other rules.
79
79
  // title(task) / body(task) the generated task's title and description.
80
- // kind optional task kind; defaults to 'chore'.
80
+ // kind optional task kind; must be one the classifier knows
81
+ // (task-classifier.VALID_KINDS). Defaults to 'unclassified' —
82
+ // which is right for generated work anyway, because a request the
83
+ // board has not triaged has, precisely, no classification yet.
81
84
  //
82
- // Production rules live here. It is empty by design — see the header.
83
- const CASCADES = Object.freeze([]);
85
+ // ---------------------------------------------------------------------------
86
+ // The first production rule (R04 of goal 1000074, task 1003115).
87
+ // ---------------------------------------------------------------------------
88
+ //
89
+ // THE ARTIST REVIEW. "Every shipped work with copy or visuals becomes a Review
90
+ // task for an artist, in two steps" (the criterion artist-review-task-on-ship).
91
+ // This is the rule that makes that true, and it is the whole of R04's build: the
92
+ // mechanism (ADR 0242) and its four safety properties already exist and are
93
+ // tested; R04 writes the one declaration.
94
+ //
95
+ // TWO GUARDS THAT KEEP IT FROM MISFIRING, both pinned in the tests:
96
+ // • It excludes `source === CASCADE_SOURCE`. Without that, the artist review
97
+ // task it generates — discipline 'artist', so it matches its own predicate —
98
+ // would on ship generate a review OF the review, and so on. The exclusion
99
+ // makes the generation a single hop, never a chain.
100
+ // • "Copy or visuals" is a NARROW, explicit predicate (hasCopyOrVisualWork),
101
+ // not "every ship". A cascade that fired on everything would bury the artist,
102
+ // and the criterion's "dismissable rather than nagging" would be a lie.
103
+ //
104
+ // It does NOT block anything (ADR 0241 draws the line: an artist may hold a
105
+ // DEPLOY, decided per project, never a builder's ship; that gate is a separate
106
+ // build). The generated task is a `backlog` request that triage may dismiss.
107
+
108
+ // The modules whose shipped work puts user-facing copy or visuals in front of a
109
+ // person. Kept as an explicit set rather than inferred, because "is this
110
+ // copy/visual work" is a judgement the rule should state out loud and a test
111
+ // should be able to pin — an inferred predicate is the kind that quietly widens.
112
+ const COPY_VISUAL_MODULES = Object.freeze([
113
+ 'copy-desk', // the copy registry + proposals (goal 1000074's own spine)
114
+ 'hall-ui', // the builders' hall — the most-seen surface
115
+ 'art-pipeline', // generated visual assets
116
+ 'status-ui', // the public status dashboard
117
+ 'ui-design', // the design-system surfaces (ADR 0081)
118
+ ]);
119
+
120
+ // Does this shipped task's work put copy or visuals in front of a person? Pure
121
+ // and total — the runner treats a throw as "no match", but this must not throw.
122
+ function hasCopyOrVisualWork(task) {
123
+ if (!task) return false;
124
+ if (task.source === 'copy-proposal') return true; // a copy change that landed via the copy desk (ADR 0233)
125
+ if (task.discipline === 'artist') return true; // work an artist did is, by definition, on their surface
126
+ if (task.module_key && COPY_VISUAL_MODULES.includes(task.module_key)) return true;
127
+ return false;
128
+ }
129
+
130
+ // The two-step artist review body. STEP 1 coaches the grader (the artist trains
131
+ // the grading system, not only the artefact); STEP 2 remakes the copy/visuals
132
+ // with both routes explicitly offered — R04's brief, verbatim in intent.
133
+ function artistReviewBody(task) {
134
+ return [
135
+ `This is an ARTIST REVIEW, generated automatically because task ${task.id} put copy or visuals in front of a person. It is a REQUEST, not a gate — it blocks nothing that shipped, and triage may dismiss it.`,
136
+ '',
137
+ `STEP 1 — REVIEW THE GRADER, not only the work. Read how the grader judged task ${task.id}'s copy and visuals, and coach it: where was it right, and where did it miss what an artist would catch — slop, an off-voice line, a claim that does not land, a visual that reads as generated? The artist trains the grading system, not just the artefact, so this step is the one that compounds.`,
138
+ '',
139
+ 'STEP 2 — REMAKE OR TWEAK the copy/visuals if they need it. Your choice of route, both offered on purpose: OUTSIDE the AI chat (edit the surface directly and land it through the copy desk or the normal pipeline), or WITH it (draft in chat, then land). Either way it lands as a claimed, graded, shipped task like everything else — there is no live CMS (goal 1000074\'s hard rule).',
140
+ '',
141
+ 'If the copy and visuals are fine and the grader judged them well, DISMISS this in triage. That is a real, expected outcome — not a failure.',
142
+ ].join('\n');
143
+ }
144
+
145
+ // Production rules. The artist review is the first (R04); add another by
146
+ // appending an entry, never by touching the runner (ADR 0242).
147
+ const CASCADES = Object.freeze([
148
+ Object.freeze({
149
+ key: 'artist-review-on-ship',
150
+ discipline: 'artist',
151
+ kind: 'unclassified',
152
+ // The anti-recursion guard (source !== CASCADE_SOURCE) is FIRST so a
153
+ // cascade-generated task is rejected before any other test runs.
154
+ when: (t) => t.source !== CASCADE_SOURCE && hasCopyOrVisualWork(t),
155
+ title: (t) => `Artist review: the copy and visuals that shipped in task ${t.id}`,
156
+ body: artistReviewBody,
157
+ }),
158
+ ]);
84
159
 
85
160
  // The (source, source_ref) pair the generated task is keyed on. `source` is a
86
161
  // constant so every cascade-generated row is findable as a class; the ref pins
@@ -171,7 +246,11 @@ function cascadeTaskSpec(rule, task) {
171
246
  title: String(rule.title(task) || '').trim(),
172
247
  description: `${preamble}\n\n${body}`,
173
248
  discipline: rule.discipline,
174
- kind: rule.kind || 'chore',
249
+ // 'unclassified', not 'chore' (task 1003115): the cascade calls db.createTask
250
+ // directly, bypassing the route's kind validation, and 'chore' is not in
251
+ // task-classifier.VALID_KINDS — it would have written an invalid kind. A
252
+ // generated request the board has not triaged is genuinely unclassified.
253
+ kind: rule.kind || 'unclassified',
175
254
  status: 'backlog',
176
255
  source: CASCADE_SOURCE,
177
256
  sourceRef: cascadeSourceRef(rule.key, task.id),
@@ -229,6 +308,9 @@ async function runCascadesForShip(shipped, deps = {}) {
229
308
  module.exports = {
230
309
  CASCADES,
231
310
  CASCADE_SOURCE,
311
+ COPY_VISUAL_MODULES,
312
+ hasCopyOrVisualWork,
313
+ artistReviewBody,
232
314
  cascadeSourceRef,
233
315
  assertRuleShape,
234
316
  validateCascades,
@@ -185,12 +185,69 @@ await test('the production table is valid, and a duplicate key is refused', () =
185
185
  assert.ok(shape.some((p) => /body/.test(p)));
186
186
  });
187
187
 
188
- await test('the production table ships EMPTY, deliberately — R04 writes the first rule', () => {
189
- // If this fails because a rule was added, that is fine: replace the assertion
190
- // with one that pins the new rule's key and discipline. It exists so the
191
- // emptiness is a DECISION on the record (see cascade.js's header) rather than
192
- // something nobody noticed.
193
- assert.equal(cascade.CASCADES.length, 0);
188
+ // --- the first production rule: the artist review (R04, task 1003115) --------
189
+
190
+ const shippedCopy = (over = {}) => ({
191
+ id: '7001', version_id: 'BONGOS-V1', goal_id: '1000074',
192
+ title: 'Reword the hall empty states', discipline: 'engineer',
193
+ kind: 'feature', status: 'shipped', module_key: 'hall-ui', source: 'manual',
194
+ ...over,
195
+ });
196
+
197
+ await test('the artist review is the one production rule, keyed and disciplined', () => {
198
+ assert.equal(cascade.CASCADES.length, 1);
199
+ const r = cascade.CASCADES[0];
200
+ assert.equal(r.key, 'artist-review-on-ship');
201
+ assert.equal(r.discipline, 'artist', 'the review is FOR the artist, whatever role shipped the work');
202
+ assert.equal(r.kind, 'unclassified', 'a valid kind — not the invalid "chore" the spec used to default to');
203
+ assert.deepEqual(cascade.validateCascades(), [], 'and the shipped table is well-formed');
204
+ });
205
+
206
+ await test('a copy/visual ship generates the two-step artist review', () => {
207
+ const { matched } = cascade.cascadesFor(shippedCopy());
208
+ assert.equal(matched.length, 1);
209
+ const spec = matched[0].spec;
210
+ assert.equal(spec.discipline, 'artist');
211
+ assert.equal(spec.kind, 'unclassified');
212
+ assert.equal(spec.status, 'backlog', 'a request that triage may dismiss — not claimable, not nagging');
213
+ assert.match(spec.title, /7001/);
214
+ // Both steps, and both routes of step 2, are in the body.
215
+ assert.match(spec.description, /STEP 1 — REVIEW THE GRADER/);
216
+ assert.match(spec.description, /STEP 2 — REMAKE OR TWEAK/);
217
+ assert.match(spec.description, /OUTSIDE the AI chat/);
218
+ assert.match(spec.description, /WITH it/);
219
+ assert.match(spec.description, /DISMISS this in triage/);
220
+ assert.match(spec.description, /blocks nothing that shipped/, 'it says out loud that it is not a gate');
221
+ });
222
+
223
+ await test('"copy or visuals" is a NARROW predicate — three ways in, and a default of no', () => {
224
+ const H = cascade.hasCopyOrVisualWork;
225
+ assert.equal(H(shippedCopy({ module_key: 'copy-desk' })), true);
226
+ assert.equal(H(shippedCopy({ module_key: 'art-pipeline' })), true);
227
+ assert.equal(H(shippedCopy({ module_key: null, source: 'copy-proposal' })), true, 'a landed copy proposal');
228
+ assert.equal(H(shippedCopy({ module_key: null, source: 'manual', discipline: 'artist' })), true, 'work an artist did');
229
+ // The default is NO — a backend/infra ship is not an artist review.
230
+ assert.equal(H(shippedCopy({ module_key: 'lifecycle', discipline: 'engineer', source: 'manual' })), false);
231
+ assert.equal(H(shippedCopy({ module_key: null, discipline: 'engineer', source: 'manual' })), false);
232
+ assert.equal(H(null), false);
233
+ });
234
+
235
+ await test('THE ANTI-RECURSION GUARD: an artist review shipping does NOT generate a review of itself', () => {
236
+ // The generated review is discipline:'artist' + source:'cascade'. Its own
237
+ // predicate (discipline artist) would match — so without the source guard it
238
+ // would cascade forever. This is the test that proves it is a single hop.
239
+ const reviewTaskThatShipped = shippedCopy({
240
+ discipline: 'artist', source: cascade.CASCADE_SOURCE, module_key: null, id: '7100',
241
+ });
242
+ const { matched } = cascade.cascadesFor(reviewTaskThatShipped);
243
+ assert.equal(matched.length, 0, 'a cascade-generated task never triggers another cascade');
244
+ });
245
+
246
+ await test('the review inherits the shipped task goal and version, not goal 1000074 by fiat', () => {
247
+ // A copy ship in some other goal gets its review filed next to that work.
248
+ const { matched } = cascade.cascadesFor(shippedCopy({ goal_id: '1000046', version_id: 'BONGOS-V1' }));
249
+ assert.equal(matched[0].spec.goalId, 1000046);
250
+ assert.equal(matched[0].spec.versionId, 'BONGOS-V1');
194
251
  });
195
252
 
196
253
  console.log(`\n${passed} passed, ${failed} failed`);
@@ -39,42 +39,20 @@
39
39
 
40
40
  'use strict';
41
41
 
42
- const { bundleForProjectType, ALWAYS_ON_CORE } = require('./starter-bundles');
42
+ const { bundleForAnswers, TEAM_SHAPE_RULES, ALWAYS_ON_CORE } = require('./starter-bundles');
43
43
  const { PROJECT_DETAIL_VOCAB } = require('./provisioning');
44
44
 
45
- // How the team shape adjusts the type's starter bundle, in application order.
46
- // Each entry is one condition (`teamShape`) and one effect on ONE opt-in module,
47
- // carrying the sentence a picker shows when it fires. Deliberately tiny: ADR 0237
48
- // already made the per-type preset the opinion, and today's opt-in catalog is
49
- // three modules wide (`agents`, `dev-box`, `discord`) — so this table's job is to
50
- // correct a preset the owner's answer contradicts, not to re-derive it.
45
+ // `TEAM_SHAPE_RULES` MOVED to starter-bundles.js (BV1.R22, task 1002341; ADR 0243)
46
+ // and is re-exported here so this file's public surface is unchanged. It went to
47
+ // the bundle layer because a bundle adjustment is not advice about the bundle —
48
+ // it IS the bundle once the owner has answered, and `effectiveModules` is the
49
+ // function ADR 0240 §2 hangs the whole skip contract on. A rule the creation
50
+ // picker applies but that fallback does not is two lists kept in agreement by
51
+ // hand, which is the failure ADR 0240 §2 was written to make impossible.
51
52
  //
52
- // A rule may only ever name an OPT-IN module. Adding or removing an always-on one
53
- // would be a toggle that toggles nothing (ADR 0237 §3's argument, and
54
- // `tests/provisioning_recommendations.mjs` fails the build over it).
55
- const TEAM_SHAPE_RULES = Object.freeze([
56
- Object.freeze({
57
- id: 'a-community-needs-somewhere-to-gather',
58
- teamShape: 'community',
59
- effect: 'add',
60
- module: 'discord',
61
- reason: 'You said an open community is building this, so somewhere for it to gather is switched on.',
62
- }),
63
- Object.freeze({
64
- id: 'a-team-shares-one-environment',
65
- teamShape: 'small-team',
66
- effect: 'add',
67
- module: 'dev-box',
68
- reason: 'A small team gets one shared environment to build in, so nobody is debugging their own laptop.',
69
- }),
70
- Object.freeze({
71
- id: 'building-alone-needs-no-shared-environment',
72
- teamShape: 'solo',
73
- effect: 'remove',
74
- module: 'dev-box',
75
- reason: 'You are building this alone, so the shared cloud environment is off — your own machine is the setup.',
76
- }),
77
- ]);
53
+ // What stays HERE is the advice: a governance preset that is only ever previewed,
54
+ // and an invite SEEK nothing in this module may run. Neither is a thing a project
55
+ // silently ends up with, so neither belongs in the read-time resolution.
78
56
 
79
57
  // The governance presets, by key. A preset is a NAME plus the one sentence that
80
58
  // says what living under it feels like — never a set of roles or permissions,
@@ -167,25 +145,6 @@ function normalizeTeamShape(value) {
167
145
  return PROJECT_DETAIL_VOCAB.team_shape.includes(value) ? value : null;
168
146
  }
169
147
 
170
- // Apply the team-shape rules to one type's opt-in bundle. Returns the adjusted
171
- // list plus the adjustments that ACTUALLY changed it — a rule whose module the
172
- // bundle already carries (or already lacks) records nothing, because a stated
173
- // reason for a change that did not happen is a lie the owner cannot check.
174
- // Never touches the always-on core. PURE.
175
- function adjustBundle(bundle, teamShape) {
176
- const modules = [...bundle];
177
- const adjustments = [];
178
- for (const rule of TEAM_SHAPE_RULES) {
179
- if (rule.teamShape !== teamShape) continue;
180
- const at = modules.indexOf(rule.module);
181
- if (rule.effect === 'add' && at === -1) modules.push(rule.module);
182
- else if (rule.effect === 'remove' && at !== -1) modules.splice(at, 1);
183
- else continue;
184
- adjustments.push({ rule: rule.id, effect: rule.effect, module: rule.module, reason: rule.reason });
185
- }
186
- return { modules, adjustments };
187
- }
188
-
189
148
  // The whole recommendation for one set of creation answers:
190
149
  //
191
150
  // {
@@ -211,8 +170,10 @@ function adjustBundle(bundle, teamShape) {
211
170
  function recommendationsForDetails(answers) {
212
171
  const given = answers && typeof answers === 'object' && !Array.isArray(answers) ? answers : {};
213
172
  const teamShape = normalizeTeamShape(given.team_shape);
214
- const preset = bundleForProjectType(given.type);
215
- const { modules, adjustments } = adjustBundle(preset.bundle, teamShape);
173
+ // The SAME resolver the picker preselects from and effectiveModules falls back
174
+ // to (ADR 0243) — so the recommendation, the panel and the project a skipped
175
+ // picker ends up with are one function's answer, not three that agree.
176
+ const preset = bundleForAnswers(given.type, teamShape);
216
177
 
217
178
  const gov = GOVERNANCE_RULES.find((r) => r.teamShape === teamShape) || GOVERNANCE_DEFAULT;
218
179
  const invite = INVITE_RULES.find((r) => r.teamShape === teamShape) || INVITE_DEFAULT;
@@ -226,9 +187,9 @@ function recommendationsForDetails(answers) {
226
187
  label: preset.label,
227
188
  summary: preset.summary,
228
189
  core: [...ALWAYS_ON_CORE],
229
- bundle: modules,
230
- modules: [...ALWAYS_ON_CORE, ...modules],
231
- adjustments,
190
+ bundle: preset.bundle,
191
+ modules: preset.modules,
192
+ adjustments: preset.adjustments,
232
193
  },
233
194
  governance: {
234
195
  preset: gov.preset,
@@ -36,7 +36,7 @@ const auth = api;
36
36
  const { pool, validateOrRespond, parseId } = api;
37
37
  const provisioning = require('../provisioning');
38
38
  const { onboardPlan, stepsForShape } = require('../onboard-plan');
39
- const { ALWAYS_ON_CORE, OPTIONAL_MODULES, allStarterBundles, optionalModules } = require('../starter-bundles');
39
+ const { ALWAYS_ON_CORE, OPTIONAL_MODULES, starterBundleAnswerTable, optionalModules, effectiveModules } = require('../starter-bundles');
40
40
  const { recommendationsForDetails } = require('../recommendations');
41
41
  const { makeAskRateLimiter, clientIp } = require('../rate-limit');
42
42
  // task 1003208: structured logging (pino via the doorway) — was console.*.
@@ -840,6 +840,8 @@ module.exports = function provisioningRoutes() {
840
840
  // project is now on the map, and one who cleared it learns it just left.
841
841
  // Re-deriving it from a follow-up GET would let the two answers disagree.
842
842
  publish: publishVerdict(updated),
843
+ // `team_shape` MOVES a bundle-sourced project's modules — effect with cause (ADR 0243 §5).
844
+ modules: effectiveModules(updated),
843
845
  });
844
846
  } catch (err) {
845
847
  log.error('[provisioning] PATCH /provisioning/instances/:id/detail', err);
@@ -1337,8 +1339,10 @@ module.exports = function provisioningRoutes() {
1337
1339
  // row for. The alternatives were both worse: `GET /modules` needs a session
1338
1340
  // (this wizard is read signed-out) and `GET /public/modules` serves booleans,
1339
1341
  // not copy. Additive — `core` and `bundles` keep their shape.
1342
+ // `byTeamShape` re-resolves each bundle for every answer the About step can give,
1343
+ // riding THIS payload rather than a second read (task 1002341; ADR 0243 §4).
1340
1344
  router.get('/provisioning/starter-bundles', (req, res) => {
1341
- res.json({ core: [...ALWAYS_ON_CORE], bundles: allStarterBundles(), optional: optionalModules() });
1345
+ res.json({ core: [...ALWAYS_ON_CORE], bundles: starterBundleAnswerTable(), optional: optionalModules() });
1342
1346
  });
1343
1347
 
1344
1348
  // GET /provisioning/recommendations?type=<project type>&team_shape=<interview answer>
@@ -106,6 +106,60 @@ const OPTIONAL_MODULES = Object.freeze([
106
106
  // so the declaration above stays the single place a choice is written down.
107
107
  const OPTIONAL_MODULE_KEYS = Object.freeze(OPTIONAL_MODULES.map((m) => m.key));
108
108
 
109
+ // How the interview's team-shape answer ADJUSTS the type's starter bundle, in
110
+ // application order. Each entry is one condition (`teamShape`) and one effect on
111
+ // ONE opt-in module, carrying the sentence the picker shows when it fires.
112
+ //
113
+ // WHY THE TABLE LIVES HERE and not in recommendations.js beside the governance
114
+ // and invite tables it shipped with (task 1002336, ADR 0239). A bundle adjustment
115
+ // is not advice about the bundle — it IS the bundle, once the owner has answered.
116
+ // `effectiveModules` below is the function ADR 0240 §2 leans the whole skip
117
+ // contract on ("the preselect and the fallback are the same declaration — one
118
+ // function, called twice"), and a rule the picker applies but that function does
119
+ // not is exactly two lists kept in agreement by hand. So the rules sit at the
120
+ // bundle layer, `effectiveModules` applies them, and recommendations.js re-exports
121
+ // them unchanged — its governance preview and invite seam are genuinely advice and
122
+ // stay there. Decision: ADR 0243 (BV1.R22, task 1002341).
123
+ //
124
+ // 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.
127
+ //
128
+ // A rule may only ever name an OPT-IN module. Adding or removing an always-on one
129
+ // would be a toggle that toggles nothing (ADR 0237 §3's argument, and
130
+ // `tests/provisioning_recommendations.mjs` fails the build over it).
131
+ const TEAM_SHAPE_RULES = Object.freeze([
132
+ Object.freeze({
133
+ id: 'a-community-needs-somewhere-to-gather',
134
+ teamShape: 'community',
135
+ effect: 'add',
136
+ module: 'discord',
137
+ reason: 'You said an open community is building this, so somewhere for it to gather is switched on.',
138
+ }),
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
+ ]);
154
+
155
+ // The team shapes the rules above can fire on. Derived from the table rather than
156
+ // imported from provisioning.js's PROJECT_DETAIL_VOCAB, because importing it would
157
+ // point this file UP at the module that requires it. It costs nothing to derive:
158
+ // an off-vocabulary shape matches no rule and adjusts nothing, which is already
159
+ // the right answer — `tests/provisioning_recommendations.mjs` pins the two lists
160
+ // against each other so a fourth interview answer cannot arrive unhandled.
161
+ const RULED_TEAM_SHAPES = Object.freeze([...new Set(TEAM_SHAPE_RULES.map((r) => r.teamShape))]);
162
+
109
163
  // The curated presets, keyed by the declared project type (the closed vocabulary
110
164
  // owner-decided 2026-08-13 and mirrored in provisioning.PROJECT_TYPES + the
111
165
  // provisioning_007 CHECK). `label` and `summary` are the owner-facing copy the
@@ -192,6 +246,69 @@ function optionalModules() {
192
246
  return OPTIONAL_MODULES.map((m) => ({ ...m }));
193
247
  }
194
248
 
249
+ // Apply the team-shape rules to one type's opt-in bundle. Returns the adjusted
250
+ // list plus the adjustments that ACTUALLY changed it — a rule whose module the
251
+ // bundle already carries (or already lacks) records nothing, because a stated
252
+ // reason for a change that did not happen is a lie the owner cannot check.
253
+ // A null/unknown/off-vocabulary shape fires no rule and adjusts nothing, which is
254
+ // the same answer `normalizeProjectDetail` gives such a value on the way in.
255
+ // Never touches the always-on core. PURE.
256
+ function adjustBundle(bundle, teamShape) {
257
+ const held = new Set(bundle);
258
+ const adjustments = [];
259
+ for (const rule of TEAM_SHAPE_RULES) {
260
+ if (rule.teamShape !== teamShape) continue;
261
+ if (rule.effect === 'add' && !held.has(rule.module)) held.add(rule.module);
262
+ else if (rule.effect === 'remove' && held.has(rule.module)) held.delete(rule.module);
263
+ else continue;
264
+ adjustments.push({ rule: rule.id, effect: rule.effect, module: rule.module, reason: rule.reason });
265
+ }
266
+ // CATALOG order, not append order — `normalizeModuleSelection` stores a chosen
267
+ // set this way and the picker re-derives its toggles this way, so an adjusted
268
+ // bundle that appended would make the same set of modules read as a different
269
+ // list depending on whether the owner answered the picker or skipped it.
270
+ return { modules: OPTIONAL_MODULE_KEYS.filter((k) => held.has(k)), adjustments };
271
+ }
272
+
273
+ // What the creation flow preselects for one set of ANSWERS — `bundleForProjectType`
274
+ // with the team-shape rules applied, in the same `{ type, label, summary, core,
275
+ // bundle, modules }` shape plus the `adjustments` delta that got it there.
276
+ //
277
+ // This is the single resolver the picker paints from and `effectiveModules` falls
278
+ // back to, so an owner who answers the interview and then skips the picker gets
279
+ // the set the panel was showing them (ADR 0240 §2's property, extended to the
280
+ // interview's answer — BV1.R22, ADR 0243). PURE; arrays are fresh.
281
+ function bundleForAnswers(type, teamShape) {
282
+ const preset = bundleForProjectType(type);
283
+ const { modules, adjustments } = adjustBundle(preset.bundle, teamShape);
284
+ return {
285
+ ...preset,
286
+ bundle: modules,
287
+ modules: [...ALWAYS_ON_CORE, ...modules],
288
+ adjustments,
289
+ };
290
+ }
291
+
292
+ // Every curated bundle resolved for every team shape the rules can fire on, plus
293
+ // the no-answer resolution — the whole preselect table in ONE read.
294
+ //
295
+ // The picker needs this shape rather than a per-answer query because ADR 0240 §4's
296
+ // rule is that what may be chosen and what is preselected have to arrive together
297
+ // or the panel can preselect a key it has no row for. The owner flips the type
298
+ // question and the team-shape question freely on two earlier panels; re-preselecting
299
+ // must not cost a round trip, and a round trip it did pay could land after they had
300
+ // moved on. PURE — five types by four answers is twenty small rows.
301
+ function starterBundleAnswerTable() {
302
+ return allStarterBundles().map((preset) => {
303
+ const byTeamShape = {};
304
+ for (const shape of RULED_TEAM_SHAPES) {
305
+ const { modules, adjustments } = adjustBundle(preset.bundle, shape);
306
+ byTeamShape[shape] = { bundle: modules, adjustments };
307
+ }
308
+ return { ...preset, byTeamShape };
309
+ });
310
+ }
311
+
195
312
  // A MODULE SELECTION as it may be STORED (BV1.R20, task 1002339): opt-in keys
196
313
  // only, deduped, in catalog order. Everything else is dropped — an always-on
197
314
  // module (it was never a choice), a hub-only one (the control plane's own), and
@@ -218,22 +335,39 @@ function normalizeModuleSelection(input) {
218
335
 
219
336
  // What a row's modules READ as: the always-on floor, plus whatever the owner
220
337
  // chose — or, when they never answered, the starter bundle for their declared
221
- // type. `source` says which of the two it is, so a surface can tell a chosen
222
- // empty set from an unanswered one without re-deriving the rule.
338
+ // type ADJUSTED by what they told the interview. `source` says which of the two
339
+ // it is, so a surface can tell a chosen empty set from an unanswered one without
340
+ // re-deriving the rule.
223
341
  //
224
342
  // This is what makes skipping the picker a supported default rather than a
225
343
  // project with nothing on it: the flow's preselect and the server's fallback are
226
344
  // the SAME declaration, so the owner gets exactly the set the panel showed them.
227
- // PURE — takes a row-shaped `{ type, modules }`, touches no database.
345
+ // The team-shape adjustment is INSIDE that fallback (BV1.R22, ADR 0243) for
346
+ // exactly that reason — the picker paints the adjusted set, so a picker skipped
347
+ // after an answered interview has to resolve to the adjusted set too, or the
348
+ // panel's promise stops being true the moment the interview is answered.
349
+ //
350
+ // Reads `detail.team_shape` straight off the row rather than through
351
+ // provisioning.js's `normalizeProjectDetail` — importing it would point this file
352
+ // up at the module that requires it, and it would buy nothing: an off-vocabulary
353
+ // shape fires no rule and adjusts nothing, which is the answer the normalizer
354
+ // would have produced anyway.
355
+ //
356
+ // PURE — takes a row-shaped `{ type, modules, detail }`, touches no database.
228
357
  function effectiveModules(row) {
229
358
  const chosen = normalizeModuleSelection(row && row.modules);
230
- const preset = bundleForProjectType(row && row.type);
359
+ const detail = row && row.detail && typeof row.detail === 'object' ? row.detail : null;
360
+ const preset = bundleForAnswers(row && row.type, detail ? detail.team_shape : null);
231
361
  const selected = chosen === null ? preset.bundle : chosen;
232
362
  return {
233
363
  core: [...ALWAYS_ON_CORE],
234
364
  selected,
235
365
  modules: [...ALWAYS_ON_CORE, ...selected],
236
366
  source: chosen === null ? 'bundle' : 'chosen',
367
+ // Why the unanswered set is what it is, in the owner's own words — empty when
368
+ // nothing adjusted it, and never populated for a `chosen` set, because an
369
+ // owner's own toggle needs no reason given back to them.
370
+ adjustments: chosen === null ? preset.adjustments : [],
237
371
  };
238
372
  }
239
373
 
@@ -242,9 +376,14 @@ module.exports = {
242
376
  HUB_ONLY_MODULES,
243
377
  OPTIONAL_MODULES,
244
378
  STARTER_BUNDLES,
379
+ TEAM_SHAPE_RULES,
380
+ RULED_TEAM_SHAPES,
245
381
  bundleForProjectType,
246
382
  allStarterBundles,
247
383
  optionalModules,
384
+ adjustBundle,
385
+ bundleForAnswers,
386
+ starterBundleAnswerTable,
248
387
  normalizeModuleSelection,
249
388
  effectiveModules,
250
389
  };