genesis-compiler 1.2.2 → 1.2.4

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.
@@ -0,0 +1,178 @@
1
+ import { GenesisError } from './errors.js';
2
+
3
+ const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
4
+ const IGNORE_LINE = /^- Ignore `([^`]+)`[ \t]*$/u;
5
+ const MATCH_LINE = /^- Match `([^`]+)` as `([^`]+)`: `([^`]+)\/\*\*`[ \t]*$/u;
6
+ const FALLBACK_LINE = /^- Fallback `([^`]+)` as `([^`]+)`[ \t]*$/u;
7
+ const REGEXP_SPECIAL = /[.+^${}()|[\]\\]/u;
8
+ const compiledGlobPatterns = new Map();
9
+
10
+ function invalid(piecePath, message) {
11
+ throw new GenesisError('STACK_PIECE_INVALID', message, { path: piecePath });
12
+ }
13
+
14
+ function normalizedPrefix(value, piecePath) {
15
+ const prefix = String(value || '').trim();
16
+ if (
17
+ !prefix
18
+ || prefix.startsWith('/')
19
+ || prefix.includes('\\')
20
+ || /[*?\[\]{}!]/u.test(prefix)
21
+ || prefix.split('/').some((segment) => !segment || ['.', '..'].includes(segment))
22
+ ) {
23
+ invalid(piecePath, '## City regions match paths must be canonical project-relative prefixes.');
24
+ }
25
+ return prefix;
26
+ }
27
+
28
+ function normalizedGlob(value, piecePath) {
29
+ const pattern = String(value || '').trim();
30
+ if (
31
+ !pattern
32
+ || pattern.startsWith('/')
33
+ || pattern.includes('\\')
34
+ || pattern.includes('***')
35
+ || /[?\[\]{}!]/u.test(pattern)
36
+ || pattern.split('/').some((segment) => !segment || ['.', '..'].includes(segment))
37
+ ) {
38
+ invalid(
39
+ piecePath,
40
+ '## City regions ignore patterns must be canonical project-relative globs using only * and ** wildcards.',
41
+ );
42
+ }
43
+ return pattern;
44
+ }
45
+
46
+ function regionIdentity(id, title, piecePath) {
47
+ if (!ID_PATTERN.test(id)) {
48
+ invalid(piecePath, '## City regions ids must use lowercase letters, digits, and single hyphens.');
49
+ }
50
+ if (!title.trim()) invalid(piecePath, '## City regions titles must not be empty.');
51
+ return { id, title: title.trim() };
52
+ }
53
+
54
+ function globExpression(pattern) {
55
+ if (compiledGlobPatterns.has(pattern)) return compiledGlobPatterns.get(pattern);
56
+ let expression = '^';
57
+ for (let index = 0; index < pattern.length; index += 1) {
58
+ const character = pattern[index];
59
+ if (character === '*' && pattern[index + 1] === '*') {
60
+ if (pattern[index + 2] === '/') {
61
+ expression += '(?:[^/]+/)*';
62
+ index += 2;
63
+ } else {
64
+ expression += '.*';
65
+ index += 1;
66
+ }
67
+ } else if (character === '*') {
68
+ expression += '[^/]*';
69
+ } else {
70
+ expression += REGEXP_SPECIAL.test(character) ? `\\${character}` : character;
71
+ }
72
+ }
73
+ const compiled = new RegExp(`${expression}$`, 'u');
74
+ compiledGlobPatterns.set(pattern, compiled);
75
+ return compiled;
76
+ }
77
+
78
+ export function cityPathExcluded(filePath, exclusions = []) {
79
+ return exclusions.some((pattern) => globExpression(pattern).test(filePath));
80
+ }
81
+
82
+ /**
83
+ * A City presentation declaration is one complete, ordered contract. Ignore
84
+ * globs remove non-product files before Machine City is emitted. Matching
85
+ * prefixes are explicit Stack facts; one final fallback owns every remaining
86
+ * path. Genesis resolves both region and campus placement, so renderers never
87
+ * classify or filter repository paths.
88
+ */
89
+ export function parseStackCityPresentationLines(lines, { path = 'stack piece' } = {}) {
90
+ if (lines === undefined) return { exclusions: [], regions: [] };
91
+ const content = lines.filter((line) => line.trim());
92
+ if (content.length === 0 || (content.length === 1 && content[0].trim() === '- Nothing.')) {
93
+ return { exclusions: [], regions: [] };
94
+ }
95
+ const exclusions = [];
96
+ const regions = [];
97
+ let regionDeclarationStarted = false;
98
+ content.forEach((line, index) => {
99
+ const ignore = line.match(IGNORE_LINE);
100
+ if (ignore) {
101
+ if (regionDeclarationStarted) {
102
+ invalid(path, '## City regions Ignore entries must precede Match and Fallback entries.');
103
+ }
104
+ exclusions.push(normalizedGlob(ignore[1], path));
105
+ return;
106
+ }
107
+ regionDeclarationStarted = true;
108
+ const match = line.match(MATCH_LINE);
109
+ if (match) {
110
+ const identity = regionIdentity(match[1], match[2], path);
111
+ regions.push({
112
+ ...identity,
113
+ fallback: false,
114
+ pathPrefix: normalizedPrefix(match[3], path),
115
+ });
116
+ return;
117
+ }
118
+ const fallback = line.match(FALLBACK_LINE);
119
+ if (fallback) {
120
+ if (index !== content.length - 1) {
121
+ invalid(path, '## City regions fallback must be the final declaration.');
122
+ }
123
+ regions.push({
124
+ ...regionIdentity(fallback[1], fallback[2], path),
125
+ fallback: true,
126
+ pathPrefix: null,
127
+ });
128
+ return;
129
+ }
130
+ invalid(
131
+ path,
132
+ '## City regions entries must use Ignore with a glob, Match with an id, title, and path/**, or one final Fallback.',
133
+ );
134
+ });
135
+ if (new Set(exclusions).size !== exclusions.length) {
136
+ invalid(path, '## City regions contains a duplicate ignore pattern.');
137
+ }
138
+ if (new Set(regions.map(({ id }) => id)).size !== regions.length) {
139
+ invalid(path, '## City regions contains a duplicate region id.');
140
+ }
141
+ const matches = regions.filter(({ fallback }) => !fallback);
142
+ if (new Set(matches.map(({ pathPrefix }) => pathPrefix)).size !== matches.length) {
143
+ invalid(path, '## City regions contains a duplicate path prefix.');
144
+ }
145
+ if (regions.filter(({ fallback }) => fallback).length !== 1) {
146
+ invalid(path, '## City regions requires exactly one final fallback.');
147
+ }
148
+ for (const [index, left] of matches.entries()) {
149
+ if (matches.slice(index + 1).some((right) => (
150
+ left.pathPrefix.startsWith(`${right.pathPrefix}/`)
151
+ || right.pathPrefix.startsWith(`${left.pathPrefix}/`)
152
+ ))) {
153
+ invalid(path, '## City regions match prefixes must not overlap.');
154
+ }
155
+ }
156
+ return { exclusions, regions };
157
+ }
158
+
159
+ export function composeStackCityPresentation(components = []) {
160
+ const owners = components.filter((component) => (
161
+ component.cityRegions.length > 0 || component.cityExclusions.length > 0
162
+ ));
163
+ if (owners.length === 0) return { exclusions: [], regions: [] };
164
+ if (owners.length > 1) {
165
+ throw new GenesisError(
166
+ 'STACK_CITY_PRESENTATION_AMBIGUOUS',
167
+ `Stack components ${owners.map(({ id }) => id).join(', ')} declare competing City presentation contracts.`,
168
+ { components: owners.map(({ id }) => id) },
169
+ );
170
+ }
171
+ return {
172
+ exclusions: [...owners[0].cityExclusions],
173
+ regions: owners[0].cityRegions.map((region) => ({
174
+ ...region,
175
+ component: owners[0].id,
176
+ })),
177
+ };
178
+ }
@@ -14,6 +14,8 @@ export function parseStackEnvironmentDefaultLines(lines, {
14
14
  path: defaultsPath = 'stack environment defaults',
15
15
  } = {}) {
16
16
  if (lines === undefined) return [];
17
+ const present = lines.map((line) => line.trim()).filter(Boolean);
18
+ if (present.length === 1 && present[0] === '- Nothing.') return [];
17
19
  const defaults = [];
18
20
  for (let index = 0; index < lines.length; index += 1) {
19
21
  const source = lines[index].trim();
@@ -7,9 +7,8 @@ import {
7
7
 
8
8
  const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
9
9
  const PLACEHOLDER = /\{([a-z][a-z0-9-]*)\}/gu;
10
- const PREVIEW_IDENTITY_COMMAND_PATH = /^\.vibe64\/bin\/[A-Za-z0-9][A-Za-z0-9._-]*$/u;
11
10
  const PREVIEW_IDENTITY_ENVIRONMENT_NAME = /^[A-Z_][A-Z0-9_]*$/u;
12
- const PREVIEW_IDENTITY_PROTOCOL = 'vibe64.preview-identity.command.v1';
11
+ const PREVIEW_IDENTITY_PROTOCOL = 'genesis.preview-identity.command.v1';
13
12
  const PREVIEW_IDENTITY_RESERVED_ENVIRONMENT_NAMES = new Set([
14
13
  'HOME',
15
14
  'LOGNAME',
@@ -17,8 +16,6 @@ const PREVIEW_IDENTITY_RESERVED_ENVIRONMENT_NAMES = new Set([
17
16
  'PATH',
18
17
  'TMPDIR',
19
18
  'USER',
20
- 'VIBE64_PREVIEW_IDENTITY_ENABLED',
21
- 'VIBE64_PREVIEW_IDENTITY_SECRET',
22
19
  ]);
23
20
  const PREVIEW_IDENTITY_TYPES = new Set(['email', 'login', 'user-id']);
24
21
  const PREVIEW_FIELDS = [
@@ -291,12 +288,14 @@ function normalizePreviewIdentity(draft, launchPath, targetId) {
291
288
  if (
292
289
  command.length > 64
293
290
  || command.some((entry) => !entry.trim() || entry.length > 4096 || /[\r\n]/u.test(entry))
294
- || !PREVIEW_IDENTITY_COMMAND_PATH.test(command[0])
291
+ || !command[0].includes('/')
292
+ || command[0].includes('\\')
293
+ || command[0].split('/').some((part) => !part || ['.', '..'].includes(part))
295
294
  || command.some((entry) => /\{(?:host|port)\}/u.test(entry))
296
295
  ) {
297
296
  invalid(
298
297
  launchPath,
299
- 'Preview identity command must use an app-owned executable under .vibe64/bin and contain at most 64 literal arguments.',
298
+ 'Preview identity command must use a committed app-owned project-relative executable and contain at most 64 literal arguments.',
300
299
  draft.line,
301
300
  );
302
301
  }
@@ -1,5 +1,6 @@
1
1
  import { GenesisError } from './errors.js';
2
2
  import { parseStackCommandLines } from './stack-command.js';
3
+ import { parseStackCityPresentationLines } from './stack-city-presentation.js';
3
4
  import { parseStackDeploymentLines } from './stack-deployment.js';
4
5
  import { parseStackEnvironmentDefaultLines } from './stack-environment-defaults.js';
5
6
  import { parseStackEnvironmentFileLines } from './stack-environment-files.js';
@@ -9,7 +10,7 @@ import { normalizeSource } from './utils.js';
9
10
 
10
11
  const STACK_PIECE_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
11
12
  const STACK_CUSTOMIZATION_TITLE = /^# Stack customization: ([a-z0-9]+(?:-[a-z0-9]+)*)\s*$/u;
12
- const STACK_CUSTOMIZATION_FIELDS = new Set(['Description', 'Guidance', 'Deslop']);
13
+ const STACK_CUSTOMIZATION_FIELDS = new Set(['Description', 'Guidance', 'Adoption', 'Deslop']);
13
14
 
14
15
  export function normalizeStackPieceId(value, {
15
16
  code = 'STACK_PIECE_INVALID',
@@ -92,12 +93,12 @@ function customizationSections(source, piecePath) {
92
93
  if (/^#{1,3}\s+/u.test(line)) invalid(piecePath, `Unknown customization heading: ${line.trim()}.`);
93
94
  if (field) content.push(line);
94
95
  else if (line.trim()) {
95
- invalid(piecePath, 'Customization content must be below Description, Guidance, or Deslop.');
96
+ invalid(piecePath, 'Customization content must be below Description, Guidance, Adoption, or Deslop.');
96
97
  }
97
98
  }
98
99
  finishField();
99
100
  if (Object.values(result).every((values) => Object.keys(values).length === 0)) {
100
- invalid(piecePath, 'Stack customization must add or override Description, Guidance, or Deslop.');
101
+ invalid(piecePath, 'Stack customization must add or override Description, Guidance, Adoption, or Deslop.');
101
102
  }
102
103
  return result;
103
104
  }
@@ -150,18 +151,22 @@ function skill(all, piecePath) {
150
151
  };
151
152
  }
152
153
 
153
- function resources(all, piecePath) {
154
- const source = textSection(all, 'Resources');
154
+ export function parseStackResourceLines(lines, {
155
+ path: resourcePath = 'stack resources',
156
+ } = {}) {
157
+ if (lines === undefined) return [];
158
+ const source = lines.join('\n').trim();
155
159
  if (!source) return [];
160
+ if (source === '- Nothing.') return [];
156
161
  const blocks = [...source.matchAll(/```json genesis-resource\s*\n([\s\S]*?)\n```/gu)];
157
162
  const outside = source.replace(/```json genesis-resource\s*\n[\s\S]*?\n```/gu, '').trim();
158
163
  if (outside || blocks.length === 0) {
159
- invalid(piecePath, '## Resources may contain only `json genesis-resource` fenced objects.');
164
+ invalid(resourcePath, '## Resources may contain only `json genesis-resource` fenced objects.');
160
165
  }
161
166
  const parsed = blocks.map((match) => {
162
167
  let resource;
163
168
  try { resource = JSON.parse(match[1]); } catch (error) {
164
- invalid(piecePath, `A Stack resource is invalid JSON: ${error.message}`);
169
+ invalid(resourcePath, `A Stack resource is invalid JSON: ${error.message}`);
165
170
  }
166
171
  if (
167
172
  !resource
@@ -172,7 +177,7 @@ function resources(all, piecePath) {
172
177
  || !Array.isArray(resource.environmentAlternatives)
173
178
  || resource.environmentAlternatives.length === 0
174
179
  ) {
175
- invalid(piecePath, 'A Stack resource needs an id, kind, and at least one environment alternative.');
180
+ invalid(resourcePath, 'A Stack resource needs an id, kind, and at least one environment alternative.');
176
181
  }
177
182
  const environmentAlternatives = resource.environmentAlternatives.map((alternative) => {
178
183
  const required = alternative?.required;
@@ -181,7 +186,7 @@ function resources(all, piecePath) {
181
186
  !Array.isArray(required)
182
187
  || !Array.isArray(allowEmpty)
183
188
  ) {
184
- invalid(piecePath, `Stack resource ${resource.id} has an invalid environment alternative.`);
189
+ invalid(resourcePath, `Stack resource ${resource.id} has an invalid environment alternative.`);
185
190
  }
186
191
  const names = [...required, ...allowEmpty];
187
192
  if (
@@ -190,17 +195,23 @@ function resources(all, piecePath) {
190
195
  || allowEmpty.some((name) => !required.includes(name))
191
196
  || new Set(required).size !== required.length
192
197
  || new Set(allowEmpty).size !== allowEmpty.length
193
- ) invalid(piecePath, `Stack resource ${resource.id} has an invalid environment alternative.`);
198
+ ) invalid(resourcePath, `Stack resource ${resource.id} has an invalid environment alternative.`);
194
199
  return { required: [...required], allowEmpty: [...allowEmpty] };
195
200
  });
196
201
  return { id: resource.id, kind: resource.kind, environmentAlternatives };
197
202
  });
198
203
  if (new Set(parsed.map(({ id }) => id)).size !== parsed.length) {
199
- invalid(piecePath, '## Resources contains a duplicate resource id.');
204
+ invalid(resourcePath, '## Resources contains a duplicate resource id.');
200
205
  }
201
206
  return parsed;
202
207
  }
203
208
 
209
+ function resources(all, piecePath) {
210
+ return parseStackResourceLines(all.has('Resources') ? all.get('Resources') : undefined, {
211
+ path: piecePath,
212
+ });
213
+ }
214
+
204
215
  /**
205
216
  * Stack pieces are guidance, not a second programming language. Genesis reads
206
217
  * only the few sections it uses and deliberately ignores explanatory sections.
@@ -217,9 +228,14 @@ export function parseStackPieceSource(value, {
217
228
  const all = sections(source);
218
229
  const description = textSection(all, 'Description');
219
230
  const guidance = textSection(all, 'Guidance');
231
+ const adoption = textSection(all, 'Adoption');
220
232
  const deslop = textSection(all, 'Deslop');
221
233
  if (!description) invalid(piecePath, 'Stack piece needs Description.');
222
234
  const commands = parseStackCommandLines(all.get('Commands') || [], { path: piecePath });
235
+ const cityPresentation = parseStackCityPresentationLines(
236
+ all.has('City regions') ? all.get('City regions') : undefined,
237
+ { path: piecePath },
238
+ );
223
239
  const environmentDefaults = parseStackEnvironmentDefaultLines(
224
240
  all.has('Environment defaults') ? all.get('Environment defaults') : undefined,
225
241
  { path: piecePath },
@@ -243,12 +259,15 @@ export function parseStackPieceSource(value, {
243
259
  id,
244
260
  description,
245
261
  guidance,
262
+ adoption,
246
263
  requires: idList(all, 'Requires', piecePath, { required: true }),
247
264
  conflicts: idList(all, 'Conflicts', piecePath),
248
265
  indexers: idList(all, 'Indexers', piecePath),
249
266
  skill: skill(all, piecePath),
250
267
  resources: resources(all, piecePath),
251
268
  commands: commands.map(({ line: _line, ...command }) => command),
269
+ cityExclusions: cityPresentation.exclusions,
270
+ cityRegions: cityPresentation.regions,
252
271
  environmentDefaults,
253
272
  environmentFiles: environmentFiles || [],
254
273
  launchTargets: launch?.targets || [],
@@ -292,6 +311,11 @@ export function applyStackPieceCustomization(piece, customization) {
292
311
  customization.override.guidance,
293
312
  customization.add.guidance,
294
313
  ),
314
+ adoption: customizedText(
315
+ piece.adoption,
316
+ customization.override.adoption,
317
+ customization.add.adoption,
318
+ ),
295
319
  deslop: customizedText(piece.deslop, customization.override.deslop, customization.add.deslop),
296
320
  };
297
321
  }
@@ -5,10 +5,12 @@ import { syncProjectSkills } from './agent-skills.js';
5
5
  import { GenesisError } from './errors.js';
6
6
  import { readBuiltinStackCatalog } from './stack-catalog.js';
7
7
  import { parseStackCommandLines } from './stack-command.js';
8
+ import { composeStackCityPresentation } from './stack-city-presentation.js';
8
9
  import { composeStackDeployment, parseStackDeploymentLines } from './stack-deployment.js';
9
10
  import { resolveStackPieces } from './stack-composition.js';
10
11
  import {
11
12
  composeStackEnvironmentDefaults,
13
+ parseStackEnvironmentDefaultLines,
12
14
  } from './stack-environment-defaults.js';
13
15
  import {
14
16
  composeStackEnvironmentFiles,
@@ -23,6 +25,7 @@ import {
23
25
  applyStackPieceCustomization,
24
26
  normalizeStackPieceId,
25
27
  parseStackPieceCustomizationSource,
28
+ parseStackResourceLines,
26
29
  } from './stack-piece.js';
27
30
  import { STACK_PATH } from './paths.js';
28
31
  import { sha256, stableJson, writeFileAtomic } from './utils.js';
@@ -31,6 +34,8 @@ const COMPONENT_LINE = /^- `([a-z0-9]+(?:-[a-z0-9]+)*)`$/u;
31
34
  export const EMPTY_STACK_SOURCE = '# Stack\n\n## Components\n';
32
35
  const STACK_SECTIONS = new Set([
33
36
  'Components',
37
+ 'Resources',
38
+ 'Environment defaults',
34
39
  'Environment files',
35
40
  'Workspace setup',
36
41
  'Commands',
@@ -103,8 +108,10 @@ function renderStack({
103
108
  commandLines = [],
104
109
  componentIds: componentIdsValue,
105
110
  deploymentLines = null,
111
+ environmentDefaultLines = null,
106
112
  environmentFileLines = null,
107
113
  launchLines = null,
114
+ resourceLines = null,
108
115
  workspaceSetupLines = null,
109
116
  }) {
110
117
  return [
@@ -112,6 +119,12 @@ function renderStack({
112
119
  '',
113
120
  '## Components',
114
121
  ...componentIdsValue.map((id) => `- \`${id}\``),
122
+ ...(resourceLines === null
123
+ ? []
124
+ : ['', '## Resources', '', ...withoutOuterBlankLines(resourceLines)]),
125
+ ...(environmentDefaultLines === null
126
+ ? []
127
+ : ['', '## Environment defaults', '', ...withoutOuterBlankLines(environmentDefaultLines)]),
115
128
  ...(environmentFileLines === null
116
129
  ? []
117
130
  : ['', '## Environment files', '', ...withoutOuterBlankLines(environmentFileLines)]),
@@ -152,6 +165,15 @@ export async function addStackPieces({ pieces, projectRoot }) {
152
165
  )).map(({ id }) => id);
153
166
  const commandLines = parseStackCommandLines(sections.get('Commands') || [], { path: STACK_PATH })
154
167
  .map(({ label, argv }) => `- Verify \`${label}\`: ${argv.map((value) => `\`${value}\``).join(' ')}`);
168
+ const resourceLines = sections.has('Resources') ? sections.get('Resources') : null;
169
+ parseStackResourceLines(resourceLines === null ? undefined : resourceLines, { path: STACK_PATH });
170
+ const environmentDefaultLines = sections.has('Environment defaults')
171
+ ? sections.get('Environment defaults')
172
+ : null;
173
+ parseStackEnvironmentDefaultLines(
174
+ environmentDefaultLines === null ? undefined : environmentDefaultLines,
175
+ { path: STACK_PATH },
176
+ );
155
177
  const environmentFileLines = sections.has('Environment files')
156
178
  ? sections.get('Environment files')
157
179
  : null;
@@ -177,8 +199,10 @@ export async function addStackPieces({ pieces, projectRoot }) {
177
199
  commandLines,
178
200
  componentIds: selected,
179
201
  deploymentLines,
202
+ environmentDefaultLines,
180
203
  environmentFileLines,
181
204
  launchLines,
205
+ resourceLines,
182
206
  workspaceSetupLines,
183
207
  });
184
208
  const stackChanged = rendered !== source;
@@ -264,8 +288,20 @@ export async function readStack(projectRoot) {
264
288
  ).map(({ line: _line, ...command }) => command);
265
289
  const componentCommands = components.flatMap((piece) => piece.commands);
266
290
  const commands = distinctCommands(projectCommands.length > 0 ? projectCommands : componentCommands);
267
- const resources = resourceDeclarations(components);
268
- const environmentDefaults = composeStackEnvironmentDefaults(components);
291
+ const cityPresentation = composeStackCityPresentation(components);
292
+ const projectResources = sections.has('Resources')
293
+ ? parseStackResourceLines(sections.get('Resources'), { path: STACK_PATH })
294
+ : null;
295
+ const resources = projectResources === null
296
+ ? resourceDeclarations(components)
297
+ : projectResources.map((resource) => ({ component: 'project', resource }));
298
+ const projectEnvironmentDefaults = parseStackEnvironmentDefaultLines(
299
+ sections.has('Environment defaults') ? sections.get('Environment defaults') : undefined,
300
+ { path: STACK_PATH },
301
+ );
302
+ const environmentDefaults = sections.has('Environment defaults')
303
+ ? projectEnvironmentDefaults.map((entry) => ({ ...entry, sources: ['project'] }))
304
+ : composeStackEnvironmentDefaults(components);
269
305
  const projectEnvironmentFiles = parseStackEnvironmentFileLines(
270
306
  sections.has('Environment files') ? sections.get('Environment files') : undefined,
271
307
  { path: STACK_PATH },
@@ -290,6 +326,8 @@ export async function readStack(projectRoot) {
290
326
  path: STACK_PATH,
291
327
  identityHash: sha256(stableJson({
292
328
  components: components.map(({ id }) => id),
329
+ cityExclusions: cityPresentation.exclusions,
330
+ cityRegions: cityPresentation.regions,
293
331
  commands: commands.map(({ label, argv }) => ({ label, argv })),
294
332
  environmentDefaults,
295
333
  environmentFiles,
@@ -299,6 +337,8 @@ export async function readStack(projectRoot) {
299
337
  workspaceSetup,
300
338
  })),
301
339
  components,
340
+ cityExclusions: cityPresentation.exclusions,
341
+ cityRegions: cityPresentation.regions,
302
342
  commands,
303
343
  environmentDefaults,
304
344
  environmentFiles,
@@ -307,6 +347,7 @@ export async function readStack(projectRoot) {
307
347
  workspaceSetup,
308
348
  resources,
309
349
  guidance: composedProse(components, 'guidance'),
350
+ adoption: composedProse(components, 'adoption'),
310
351
  deslop: composedProse(components, 'deslop'),
311
352
  };
312
353
  }
@@ -317,10 +358,14 @@ export function stackPromptContext(stack) {
317
358
  id: piece.id,
318
359
  description: piece.description,
319
360
  ...(piece.guidance ? { guidance: piece.guidance } : {}),
361
+ ...(piece.adoption ? { adoption: piece.adoption } : {}),
320
362
  requires: piece.requires,
321
363
  })),
364
+ cityExclusions: stack.cityExclusions,
365
+ cityRegions: stack.cityRegions,
322
366
  verifyCommands: stack.commands.map(({ label, argv }) => ({ label, argv })),
323
367
  environmentDefaults: stack.environmentDefaults,
368
+ resources: stack.resources,
324
369
  environmentFiles: stack.environmentFiles,
325
370
  launchTargets: stack.launchTargets,
326
371
  deployment: stack.deployment,
@@ -29,7 +29,7 @@ export function uniqueSorted(values = []) {
29
29
  return [...new Set(values)].sort();
30
30
  }
31
31
 
32
- export async function writeFileAtomic(filePath, source, { mode = 0o644 } = {}) {
32
+ export async function writeFileAtomic(filePath, source, { mode = 0o666 } = {}) {
33
33
  const temporary = `${filePath}.${randomUUID()}.tmp`;
34
34
  await mkdir(path.dirname(filePath), { recursive: true });
35
35
  try {
@@ -1,10 +1,17 @@
1
1
  import { access } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
 
4
+ import { asDiagnostic } from './errors.js';
4
5
  import { gitContext } from './git.js';
6
+ import { runProcess } from './process.js';
5
7
  import { readStack } from './stack.js';
8
+ import { withStackEnvironmentDefaults } from './stack-environment-defaults.js';
6
9
  import { sha256, stableJson, uniqueSorted } from './utils.js';
7
10
 
11
+ async function emit(onEvent, event) {
12
+ try { await onEvent?.(event); } catch { /* Progress observers do not control preparation. */ }
13
+ }
14
+
8
15
  export async function inspectWorkspaceSetupForStack({ projectRoot: root, stack }) {
9
16
  const diagnostics = [...stack.workspaceSetup.diagnostics];
10
17
  const applicableSteps = [];
@@ -68,3 +75,72 @@ export async function inspectProjectWorkspaceSetup({
68
75
  const stack = await readStack(root);
69
76
  return inspectWorkspaceSetupForStack({ projectRoot: root, stack });
70
77
  }
78
+
79
+ /** Execute the selected Stack's exact workspace recipe with the caller's environment. */
80
+ export async function prepareProjectWorkspace({
81
+ environment = process.env,
82
+ onEvent,
83
+ processRunner = runProcess,
84
+ projectRoot,
85
+ } = {}) {
86
+ const root = (await gitContext(projectRoot)).repositoryRoot;
87
+ const stack = await readStack(root);
88
+ const setup = await inspectWorkspaceSetupForStack({ projectRoot: root, stack });
89
+ if (setup.status !== 'ready') {
90
+ const summary = setup.diagnostics.map(({ message }) => message).join(' ')
91
+ || 'The selected Stack declares no applicable workspace preparation commands.';
92
+ return {
93
+ ...setup,
94
+ summary,
95
+ commands: [],
96
+ };
97
+ }
98
+
99
+ const resolvedEnvironment = withStackEnvironmentDefaults(
100
+ environment,
101
+ stack.environmentDefaults,
102
+ );
103
+ const commands = [];
104
+ try {
105
+ for (const step of setup.steps) {
106
+ await emit(onEvent, {
107
+ type: 'genesis.workspace-preparation',
108
+ code: 'WORKSPACE_PREPARATION_STARTED',
109
+ message: `Preparing: ${step.label}.`,
110
+ details: { label: step.label, argv: step.argv, workdir: step.workdir },
111
+ });
112
+ await processRunner(step.argv[0], step.argv.slice(1), {
113
+ cwd: path.resolve(root, step.workdir),
114
+ env: resolvedEnvironment,
115
+ maxBytes: 32 * 1024 * 1024,
116
+ code: 'WORKSPACE_PREPARATION_FAILED',
117
+ });
118
+ commands.push({
119
+ label: step.label,
120
+ argv: step.argv,
121
+ workdir: step.workdir,
122
+ });
123
+ await emit(onEvent, {
124
+ type: 'genesis.workspace-preparation',
125
+ code: 'WORKSPACE_PREPARATION_COMPLETED',
126
+ message: `Prepared: ${step.label}.`,
127
+ details: { label: step.label, argv: step.argv, workdir: step.workdir },
128
+ });
129
+ }
130
+ return {
131
+ ...setup,
132
+ status: 'passed',
133
+ summary: `Prepared the workspace with ${commands.length} command${commands.length === 1 ? '' : 's'}.`,
134
+ commands,
135
+ diagnostics: [],
136
+ };
137
+ } catch (error) {
138
+ return {
139
+ ...setup,
140
+ status: 'failed',
141
+ summary: error.message,
142
+ commands,
143
+ diagnostics: [asDiagnostic(error)],
144
+ };
145
+ }
146
+ }
package/src/index.js CHANGED
@@ -12,7 +12,10 @@ import { inspectProjectLaunch } from './index/launch.js';
12
12
  import { listBuiltinStackPieces } from './index/stack-catalog.js';
13
13
  import { addStackPieces } from './index/stack.js';
14
14
  import { verifyProject } from './index/verification.js';
15
- import { inspectProjectWorkspaceSetup } from './index/workspace-setup.js';
15
+ import {
16
+ inspectProjectWorkspaceSetup,
17
+ prepareProjectWorkspace,
18
+ } from './index/workspace-setup.js';
16
19
 
17
20
  function withIndexResult(result, index) {
18
21
  const changedFiles = [...new Set([...result.changedFiles, ...index.changedFiles])].sort();
@@ -41,7 +44,7 @@ export async function adoptProject({ projectRoot = process.cwd(), request = '' }
41
44
  const description = await generateProjectPrompt({
42
45
  projectRoot,
43
46
  request,
44
- task: 'describe',
47
+ task: 'adopt',
45
48
  });
46
49
  return {
47
50
  status: 'ready',
@@ -51,7 +54,7 @@ export async function adoptProject({ projectRoot = process.cwd(), request = '' }
51
54
  changedFiles: initialized.changedFiles,
52
55
  prompt: description.prompt,
53
56
  warnings: description.warnings,
54
- guidance: 'Follow the generated description prompt now. Genesis does not start another agent.',
57
+ guidance: 'Follow the generated adoption prompt now. Genesis does not start another agent.',
55
58
  };
56
59
  }
57
60
 
@@ -86,6 +89,10 @@ export function inspectWorkspaceSetup(options) {
86
89
  return inspectProjectWorkspaceSetup(options);
87
90
  }
88
91
 
92
+ export function prepareWorkspace(options) {
93
+ return prepareProjectWorkspace(options);
94
+ }
95
+
89
96
  export function generatePrompt(options) {
90
97
  return generateProjectPrompt(options);
91
98
  }
@@ -12,6 +12,18 @@ C and C++ translation-unit, header, callable, ownership, build, and test convent
12
12
 
13
13
  - `cpp`
14
14
 
15
+ ## Adoption
16
+
17
+ - Identify the actual build system and presets, compiler and language level,
18
+ package/runtime dependencies, generated-source steps, and supported build
19
+ configurations without substituting a preferred CMake or Meson layout.
20
+ - Record exact configure/build/test commands and the executable argv, workdir,
21
+ host/port handling, HTTP readiness predicate, and signal/shutdown contract for
22
+ every web-service target.
23
+ - Preserve public headers, ABI and platform assumptions. Treat databases,
24
+ queues, TLS material, and external service credentials as explicit Resources,
25
+ not implicit properties of the native binary.
26
+
15
27
  ## Deslop
16
28
 
17
29
  - Preserve public header boundaries, ABI expectations, build configuration,