genesis-compiler 1.2.5 → 1.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +45 -19
  2. package/docs/prompt-integration.md +1 -1
  3. package/docs/stack-components.md +19 -4
  4. package/package.json +2 -4
  5. package/plugins/genesis/.codex-plugin/plugin.json +1 -1
  6. package/prompts/adopt.txt +6 -0
  7. package/src/cli.js +38 -10
  8. package/src/index/agent-skills.js +11 -3
  9. package/src/index/check.js +30 -2
  10. package/src/index/code-index.js +7 -2
  11. package/src/index/context.js +2 -2
  12. package/src/index/contracts.js +7 -0
  13. package/src/index/deployment.js +4 -2
  14. package/src/index/environment-files.js +8 -2
  15. package/src/index/init.js +2 -2
  16. package/src/index/launch.js +4 -1
  17. package/src/index/process.js +119 -5
  18. package/src/index/prompt.js +24 -16
  19. package/src/index/stack-catalog.js +111 -26
  20. package/src/index/stack.js +60 -8
  21. package/src/index/verification.js +15 -1
  22. package/src/index/workspace-setup.js +12 -2
  23. package/src/index.js +29 -17
  24. package/stacks/pieces/cpp.md +0 -34
  25. package/stacks/pieces/csharp.md +0 -22
  26. package/stacks/pieces/go.md +0 -22
  27. package/stacks/pieces/java.md +0 -22
  28. package/stacks/pieces/jskit-mysql.md +0 -56
  29. package/stacks/pieces/jskit-postgresql.md +0 -56
  30. package/stacks/pieces/jskit.md +0 -114
  31. package/stacks/pieces/kotlin.md +0 -22
  32. package/stacks/pieces/mysql.md +0 -29
  33. package/stacks/pieces/nodejs.md +0 -36
  34. package/stacks/pieces/php.md +0 -23
  35. package/stacks/pieces/postgresql.md +0 -30
  36. package/stacks/pieces/python.md +0 -23
  37. package/stacks/pieces/ruby.md +0 -22
  38. package/stacks/pieces/rust.md +0 -22
  39. package/stacks/pieces/shell.md +0 -23
  40. package/stacks/pieces/vue.md +0 -28
@@ -1,18 +1,120 @@
1
- import { execFile } from 'node:child_process';
1
+ import { spawn } from 'node:child_process';
2
2
 
3
3
  import { GenesisError } from './errors.js';
4
4
 
5
5
  const MAX_DIAGNOSTIC_OUTPUT = 16_384;
6
+ const DEFAULT_TERMINATION_GRACE_MS = 1_000;
6
7
 
7
- function executeFile(command, args, options) {
8
+ function processTerminationError(code, message) {
9
+ const error = new Error(message);
10
+ error.code = code;
11
+ return error;
12
+ }
13
+
14
+ function signalProcessTree(child, signal) {
15
+ if (!Number.isSafeInteger(child?.pid) || child.pid < 1) return;
16
+ try {
17
+ if (process.platform === 'win32') child.kill(signal);
18
+ else process.kill(-child.pid, signal);
19
+ } catch (error) {
20
+ if (error?.code !== 'ESRCH') throw error;
21
+ }
22
+ }
23
+
24
+ function executeFile(command, args, {
25
+ maxBuffer,
26
+ signal,
27
+ terminationGraceMs = DEFAULT_TERMINATION_GRACE_MS,
28
+ timeoutMs,
29
+ ...options
30
+ }) {
8
31
  return new Promise((resolve, reject) => {
9
- const child = execFile(command, args, options, (error, stdout, stderr) => {
10
- if (error) {
32
+ const stdoutChunks = [];
33
+ const stderrChunks = [];
34
+ let stdoutBytes = 0;
35
+ let stderrBytes = 0;
36
+ let forcedError = null;
37
+ let killTimer = null;
38
+ let spawnError = null;
39
+ let timeoutTimer = null;
40
+ let settled = false;
41
+ const cleanup = () => {
42
+ if (timeoutTimer) clearTimeout(timeoutTimer);
43
+ if (killTimer) clearTimeout(killTimer);
44
+ signal?.removeEventListener('abort', abort);
45
+ };
46
+ const child = spawn(command, args, {
47
+ ...options,
48
+ stdio: ['pipe', 'pipe', 'pipe'],
49
+ });
50
+ const terminate = (error) => {
51
+ if (settled || forcedError) return;
52
+ forcedError = error;
53
+ signalProcessTree(child, 'SIGTERM');
54
+ killTimer = setTimeout(() => {
55
+ if (!settled) signalProcessTree(child, 'SIGKILL');
56
+ }, terminationGraceMs);
57
+ killTimer.unref?.();
58
+ };
59
+ const collect = (chunks, chunk, stream) => {
60
+ chunks.push(chunk);
61
+ if (stream === 'stdout') stdoutBytes += chunk.length;
62
+ else stderrBytes += chunk.length;
63
+ if (Number.isFinite(maxBuffer) && Math.max(stdoutBytes, stderrBytes) > maxBuffer) {
64
+ terminate(processTerminationError(
65
+ 'GENESIS_PROCESS_OUTPUT_LIMIT',
66
+ `${command} exceeded its diagnostic output limit.`,
67
+ ));
68
+ }
69
+ };
70
+ child.stdout.on('data', (chunk) => collect(stdoutChunks, chunk, 'stdout'));
71
+ child.stderr.on('data', (chunk) => collect(stderrChunks, chunk, 'stderr'));
72
+ child.once('error', (error) => {
73
+ spawnError = error;
74
+ });
75
+ child.once('close', (status, processSignal) => {
76
+ settled = true;
77
+ cleanup();
78
+ const stdout = Buffer.concat(stdoutChunks);
79
+ const stderr = Buffer.concat(stderrChunks);
80
+ if (forcedError) {
81
+ forcedError.stdout = stdout;
82
+ forcedError.stderr = stderr;
83
+ forcedError.signal = processSignal || null;
84
+ reject(forcedError);
85
+ return;
86
+ }
87
+ if (spawnError) {
88
+ spawnError.stdout = stdout;
89
+ spawnError.stderr = stderr;
90
+ spawnError.signal = processSignal || null;
91
+ reject(spawnError);
92
+ return;
93
+ }
94
+ if (status !== 0) {
95
+ const error = new Error(`${command} exited with status ${status ?? 'unknown'}.`);
96
+ error.code = status;
97
+ error.signal = processSignal || null;
98
+ error.stdout = stdout;
99
+ error.stderr = stderr;
11
100
  reject(error);
12
101
  return;
13
102
  }
14
103
  resolve({ stdout, stderr });
15
104
  });
105
+ const abort = () => terminate(processTerminationError(
106
+ 'GENESIS_PROCESS_ABORTED',
107
+ `${command} was cancelled.`,
108
+ ));
109
+ if (signal?.aborted) abort();
110
+ else signal?.addEventListener('abort', abort, { once: true });
111
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
112
+ timeoutTimer = setTimeout(() => terminate(processTerminationError(
113
+ 'GENESIS_PROCESS_TIMEOUT',
114
+ `${command} timed out after ${timeoutMs} ms.`,
115
+ )), timeoutMs);
116
+ timeoutTimer.unref?.();
117
+ }
16
118
  child.stdin?.end();
17
119
  });
18
120
  }
@@ -39,21 +141,33 @@ export async function runProcess(command, args, {
39
141
  env = process.env,
40
142
  maxBytes = 32 * 1024 * 1024,
41
143
  code = 'PROCESS_EXEC_FAILED',
144
+ signal,
145
+ terminationGraceMs,
146
+ timeoutMs,
42
147
  } = {}) {
43
148
  try {
44
149
  const { stdout, stderr } = await executeFile(command, args, {
45
150
  cwd,
151
+ detached: process.platform !== 'win32',
46
152
  env,
47
153
  encoding: 'buffer',
48
154
  maxBuffer: maxBytes,
49
155
  shell: false,
156
+ signal,
157
+ terminationGraceMs,
158
+ timeoutMs,
50
159
  windowsHide: true,
51
160
  });
52
161
  return { status: 0, signal: null, stdout, stderr };
53
162
  } catch (error) {
54
163
  const stdout = Buffer.isBuffer(error.stdout) ? error.stdout : Buffer.from(error.stdout || '');
55
164
  const stderr = Buffer.isBuffer(error.stderr) ? error.stderr : Buffer.from(error.stderr || '');
56
- throw new GenesisError(code, `${command} failed: ${error.message}`, {
165
+ const diagnosticCode = error.code === 'GENESIS_PROCESS_ABORTED'
166
+ ? 'PROCESS_ABORTED'
167
+ : error.code === 'GENESIS_PROCESS_TIMEOUT'
168
+ ? 'PROCESS_TIMEOUT'
169
+ : code;
170
+ throw new GenesisError(diagnosticCode, `${command} failed: ${error.message}`, {
57
171
  command,
58
172
  args,
59
173
  status: typeof error.code === 'number' ? error.code : null,
@@ -3,7 +3,7 @@ import { inspectProjectSkills, renderAgentSkillCatalog } from './agent-skills.js
3
3
  import { buildProjectIndex, MACHINE_CITY_PATH, PROGRAM_CITY_PATH } from './code-index.js';
4
4
  import { BLUEPRINT_SKELETON_SOURCE, readBlueprint } from './blueprint.js';
5
5
  import { readStack, stackPromptContext } from './stack.js';
6
- import { listBuiltinStackPieces } from './stack-catalog.js';
6
+ import { listStackCatalogPieces } from './stack-catalog.js';
7
7
  import { GenesisError } from './errors.js';
8
8
  import { gitContext } from './git.js';
9
9
  import { inspectProgram } from './program.js';
@@ -120,12 +120,13 @@ function renderPrompt({
120
120
  ].join('\n');
121
121
  }
122
122
 
123
- async function generateAdoptionPrompt({ environment, instructions, program, request, root }) {
123
+ async function generateAdoptionPrompt({ environment, instructions, program, request, root, stackPackages }) {
124
124
  const blueprint = await readBlueprint(root, { required: true });
125
- const [stack, index, catalog] = await Promise.all([
126
- readStack(root),
127
- buildProjectIndex({ projectRoot: root, write: false }),
128
- listBuiltinStackPieces(),
125
+ const stack = await readStack(root, { stackPackages });
126
+ const availableStackPackages = [...new Set([...stack.stackPackages, ...stackPackages])];
127
+ const [index, catalog] = await Promise.all([
128
+ buildProjectIndex({ projectRoot: root, stackPackages, write: false }),
129
+ listStackCatalogPieces({ projectRoot: root, stackPackages: availableStackPackages }),
129
130
  ]);
130
131
  const projectSkills = await inspectProjectSkills({ projectRoot: root, stack });
131
132
  const missing = missingStackResources({
@@ -165,14 +166,14 @@ async function generateAdoptionPrompt({ environment, instructions, program, requ
165
166
  };
166
167
  }
167
168
 
168
- async function generateExplanationPrompt({ instructions, program, request, root, task }) {
169
+ async function generateExplanationPrompt({ instructions, program, request, root, stackPackages, task }) {
169
170
  const blueprint = await readBlueprint(root, {
170
171
  required: true,
171
172
  requireDescription: task === 'program',
172
173
  });
173
174
  const [stack, index] = await Promise.all([
174
- readStack(root),
175
- buildProjectIndex({ projectRoot: root, write: false }),
175
+ readStack(root, { stackPackages }),
176
+ buildProjectIndex({ projectRoot: root, stackPackages, write: false }),
176
177
  ]);
177
178
  const projectSkills = await inspectProjectSkills({ projectRoot: root, stack });
178
179
  const blueprintContext = task === 'describe'
@@ -204,7 +205,7 @@ async function generateExplanationPrompt({ instructions, program, request, root,
204
205
  };
205
206
  }
206
207
 
207
- async function generateStartPrompt({ instructions, program, request, root }) {
208
+ async function generateStartPrompt({ instructions, program, request, root, stackPackages }) {
208
209
  let blueprint;
209
210
  try {
210
211
  blueprint = await readBlueprint(root, { required: true });
@@ -231,10 +232,11 @@ async function generateStartPrompt({ instructions, program, request, root }) {
231
232
  verificationCommands: [],
232
233
  };
233
234
  }
234
- const [stack, index, catalog] = await Promise.all([
235
- readStack(root),
236
- buildProjectIndex({ projectRoot: root, write: false }),
237
- listBuiltinStackPieces(),
235
+ const stack = await readStack(root, { stackPackages });
236
+ const availableStackPackages = [...new Set([...stack.stackPackages, ...stackPackages])];
237
+ const [index, catalog] = await Promise.all([
238
+ buildProjectIndex({ projectRoot: root, stackPackages, write: false }),
239
+ listStackCatalogPieces({ projectRoot: root, stackPackages: availableStackPackages }),
238
240
  ]);
239
241
  const projectSkills = await inspectProjectSkills({ projectRoot: root, stack });
240
242
  const newProject = stack.components.length === 0 && index.fileCount === 0;
@@ -273,6 +275,7 @@ export async function generateProjectPrompt({
273
275
  environment = process.env,
274
276
  projectRoot,
275
277
  request = '',
278
+ stackPackages = [],
276
279
  task = 'work',
277
280
  } = {}) {
278
281
  if (!TASKS.has(task)) {
@@ -314,6 +317,7 @@ export async function generateProjectPrompt({
314
317
  program,
315
318
  request: userRequest,
316
319
  root,
320
+ stackPackages,
317
321
  });
318
322
  }
319
323
  if (task === 'adopt') {
@@ -323,12 +327,13 @@ export async function generateProjectPrompt({
323
327
  program,
324
328
  request: userRequest,
325
329
  root,
330
+ stackPackages,
326
331
  });
327
332
  }
328
333
  let stack;
329
334
  if (task === 'work') {
330
335
  try {
331
- stack = await readStack(root);
336
+ stack = await readStack(root, { stackPackages });
332
337
  } catch (error) {
333
338
  if (error?.code !== 'STACK_REQUIRED') throw error;
334
339
  return generateStartPrompt({
@@ -336,6 +341,7 @@ export async function generateProjectPrompt({
336
341
  program,
337
342
  request: userRequest,
338
343
  root,
344
+ stackPackages,
339
345
  });
340
346
  }
341
347
  if (stack.components.length === 0) {
@@ -344,6 +350,7 @@ export async function generateProjectPrompt({
344
350
  program,
345
351
  request: userRequest,
346
352
  root,
353
+ stackPackages,
347
354
  });
348
355
  }
349
356
  }
@@ -353,12 +360,13 @@ export async function generateProjectPrompt({
353
360
  program,
354
361
  request: userRequest,
355
362
  root,
363
+ stackPackages,
356
364
  task,
357
365
  });
358
366
  }
359
367
 
360
368
  const blueprint = await readBlueprint(root, { required: true, requireDescription: true });
361
- stack ||= await readStack(root);
369
+ stack ||= await readStack(root, { stackPackages });
362
370
  const [index, projectSkills] = await Promise.all([
363
371
  buildProjectIndex({ projectRoot: root, write: false }),
364
372
  inspectProjectSkills({ projectRoot: root, stack }),
@@ -1,6 +1,6 @@
1
+ import { createRequire } from 'node:module';
1
2
  import { readdir, readFile } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
4
 
5
5
  import { GenesisError } from './errors.js';
6
6
  import {
@@ -8,54 +8,138 @@ import {
8
8
  parseStackPieceSource,
9
9
  } from './stack-piece.js';
10
10
 
11
- const catalogRoot = fileURLToPath(new URL('../../stacks/pieces/', import.meta.url));
12
- function builtinStackPiecePath(id) {
13
- return path.join(catalogRoot, `${id}.md`);
11
+ const require = createRequire(import.meta.url);
12
+ const PACKAGE_NAME = /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/u;
13
+
14
+ export function normalizeStackPackageName(value) {
15
+ const name = String(value ?? '').trim();
16
+ if (!PACKAGE_NAME.test(name)) {
17
+ throw new GenesisError(
18
+ 'STACK_PACKAGE_INVALID',
19
+ 'Stack package names must be ordinary npm package names.',
20
+ { package: value },
21
+ );
22
+ }
23
+ return name;
24
+ }
25
+
26
+ async function packageDirectory(packageName, projectRoot) {
27
+ const roots = [
28
+ ...(projectRoot ? createRequire(path.join(projectRoot, 'package.json')).resolve.paths(packageName) || [] : []),
29
+ ...(require.resolve.paths(packageName) || []),
30
+ ];
31
+ for (const modulesRoot of [...new Set(roots)]) {
32
+ const manifest = path.join(modulesRoot, packageName, 'package.json');
33
+ try {
34
+ const value = JSON.parse(await readFile(manifest, 'utf8'));
35
+ if (value?.name === packageName) return { directory: path.dirname(manifest), manifest: value };
36
+ } catch (error) {
37
+ if (!['ENOENT', 'ENOTDIR'].includes(error?.code)) {
38
+ throw new GenesisError(
39
+ 'STACK_PACKAGE_INVALID',
40
+ `Cannot read Stack package ${packageName}: ${error.message}.`,
41
+ { package: packageName },
42
+ );
43
+ }
44
+ }
45
+ }
46
+ throw new GenesisError(
47
+ 'STACK_PACKAGE_UNAVAILABLE',
48
+ `Selected Stack package is unavailable: ${packageName}. Install it beside Genesis or in the project.`,
49
+ { package: packageName },
50
+ );
14
51
  }
15
52
 
16
- export async function readBuiltinStackCatalog() {
53
+ async function readPieceDirectory(
54
+ directory,
55
+ sourcePrefix,
56
+ stackPackage = null,
57
+ stackPackageRoot = null,
58
+ ) {
17
59
  let entries;
18
60
  try {
19
- entries = await readdir(catalogRoot, { withFileTypes: true });
61
+ entries = await readdir(directory, { withFileTypes: true });
20
62
  } catch (error) {
21
- if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
63
+ if (['ENOENT', 'ENOTDIR'].includes(error?.code)) {
22
64
  throw new GenesisError(
23
65
  'STACK_CATALOG_INVALID',
24
- 'The installed Genesis package is missing its built-in stack catalog.',
25
- { catalogRoot },
66
+ `Stack catalog is missing its pieces directory: ${sourcePrefix}.`,
67
+ { directory, package: stackPackage },
26
68
  );
27
69
  }
28
70
  throw error;
29
71
  }
30
-
31
72
  const files = entries
32
73
  .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
33
74
  .map((entry) => entry.name)
34
75
  .sort();
35
- const pieces = await Promise.all(files.map(async (file) => {
76
+ if (files.length === 0) {
77
+ throw new GenesisError(
78
+ 'STACK_CATALOG_INVALID',
79
+ `Stack catalog contains no pieces: ${sourcePrefix}.`,
80
+ { directory, package: stackPackage },
81
+ );
82
+ }
83
+ return Promise.all(files.map(async (file) => {
36
84
  const id = normalizeStackPieceId(file.slice(0, -3));
37
- const source = await readFile(builtinStackPiecePath(id), 'utf8');
38
- return parseStackPieceSource(source, {
39
- expectedId: id,
40
- path: `builtin:${id}`,
41
- });
85
+ const source = await readFile(path.join(directory, file), 'utf8');
86
+ const piece = parseStackPieceSource(source, { expectedId: id, path: `${sourcePrefix}:${id}` });
87
+ return {
88
+ ...piece,
89
+ skill: piece.skill === null ? null : { ...piece.skill, resolveFrom: stackPackageRoot },
90
+ stackPackage,
91
+ };
42
92
  }));
93
+ }
94
+
95
+ async function readExternalStackPieces(packageName, projectRoot) {
96
+ const resolved = await packageDirectory(packageName, projectRoot);
97
+ const relative = resolved.manifest?.genesis?.stackPieces;
98
+ if (
99
+ typeof relative !== 'string'
100
+ || !relative
101
+ || relative.includes('\\')
102
+ || path.isAbsolute(relative)
103
+ || relative.split('/').some((part) => !part || ['.', '..'].includes(part))
104
+ ) {
105
+ throw new GenesisError(
106
+ 'STACK_PACKAGE_INVALID',
107
+ `Stack package ${packageName} must declare one safe genesis.stackPieces directory.`,
108
+ { package: packageName },
109
+ );
110
+ }
111
+ return readPieceDirectory(
112
+ path.join(resolved.directory, relative),
113
+ `npm:${packageName}`,
114
+ packageName,
115
+ resolved.directory,
116
+ );
117
+ }
118
+
119
+ export async function readStackCatalog({ projectRoot, stackPackages = [] } = {}) {
43
120
  const catalog = new Map();
44
- for (const piece of pieces) {
45
- if (catalog.has(piece.id)) {
46
- throw new GenesisError(
47
- 'STACK_CATALOG_INVALID',
48
- `The built-in stack catalog contains duplicate piece ${piece.id}.`,
49
- { piece: piece.id },
50
- );
121
+ const packages = [...new Set(stackPackages.map(normalizeStackPackageName))].sort();
122
+ for (const packageName of packages) {
123
+ for (const piece of await readExternalStackPieces(packageName, projectRoot)) {
124
+ const previous = catalog.get(piece.id);
125
+ if (previous) {
126
+ throw new GenesisError(
127
+ 'STACK_CATALOG_COLLISION',
128
+ `Stack package ${packageName} collides with installed piece ${piece.id}.`,
129
+ {
130
+ piece: piece.id,
131
+ packages: [previous.stackPackage || 'genesis-compiler', packageName],
132
+ },
133
+ );
134
+ }
135
+ catalog.set(piece.id, piece);
51
136
  }
52
- catalog.set(piece.id, piece);
53
137
  }
54
138
  return catalog;
55
139
  }
56
140
 
57
- export async function listBuiltinStackPieces() {
58
- return [...(await readBuiltinStackCatalog()).values()]
141
+ export async function listStackCatalogPieces(options = {}) {
142
+ return [...(await readStackCatalog(options)).values()]
59
143
  .sort((left, right) => left.id.localeCompare(right.id))
60
144
  .map((piece) => ({
61
145
  id: piece.id,
@@ -71,6 +155,7 @@ export async function listBuiltinStackPieces() {
71
155
  launchTargets: piece.launchTargets.map(({ id }) => id),
72
156
  workspaceSetup: piece.workspaceSetupSteps,
73
157
  skill: piece.skill === null ? null : piece.skill.path,
158
+ stackPackage: piece.stackPackage,
74
159
  resources: piece.resources.map(({ id }) => id),
75
160
  }));
76
161
  }
@@ -3,7 +3,10 @@ import path from 'node:path';
3
3
 
4
4
  import { syncProjectSkills } from './agent-skills.js';
5
5
  import { GenesisError } from './errors.js';
6
- import { readBuiltinStackCatalog } from './stack-catalog.js';
6
+ import {
7
+ normalizeStackPackageName,
8
+ readStackCatalog,
9
+ } from './stack-catalog.js';
7
10
  import { parseStackCommandLines } from './stack-command.js';
8
11
  import { composeStackCityPresentation } from './stack-city-presentation.js';
9
12
  import { composeStackDeployment, parseStackDeploymentLines } from './stack-deployment.js';
@@ -31,8 +34,10 @@ import { STACK_PATH } from './paths.js';
31
34
  import { sha256, stableJson, writeFileAtomic } from './utils.js';
32
35
 
33
36
  const COMPONENT_LINE = /^- `([a-z0-9]+(?:-[a-z0-9]+)*)`$/u;
37
+ const STACK_PACKAGE_LINE = /^- `((?:@[a-z0-9._-]+\/)?[a-z0-9._-]+)`$/u;
34
38
  export const EMPTY_STACK_SOURCE = '# Stack\n\n## Components\n';
35
39
  const STACK_SECTIONS = new Set([
40
+ 'Stack packages',
36
41
  'Components',
37
42
  'Resources',
38
43
  'Environment defaults',
@@ -86,6 +91,27 @@ function componentIds(sections) {
86
91
  return ids;
87
92
  }
88
93
 
94
+ function stackPackageNames(sections) {
95
+ const lines = (sections.get('Stack packages') || []).filter((line) => line.trim());
96
+ const names = lines.map((line) => {
97
+ const match = line.trim().match(STACK_PACKAGE_LINE);
98
+ if (!match) {
99
+ throw new GenesisError(
100
+ 'STACK_INVALID',
101
+ 'Each Stack package must be exactly one bullet containing its backticked npm package name.',
102
+ { path: STACK_PATH, observed: line },
103
+ );
104
+ }
105
+ return normalizeStackPackageName(match[1]);
106
+ });
107
+ if (new Set(names).size !== names.length) {
108
+ throw new GenesisError('STACK_INVALID', 'Stack contains a duplicate Stack package.', {
109
+ path: STACK_PATH,
110
+ });
111
+ }
112
+ return names;
113
+ }
114
+
89
115
  function knownPieces(resolution) {
90
116
  if (resolution.unknown.length > 0) {
91
117
  throw new GenesisError(
@@ -112,10 +138,14 @@ function renderStack({
112
138
  environmentFileLines = null,
113
139
  launchLines = null,
114
140
  resourceLines = null,
141
+ stackPackages = [],
115
142
  workspaceSetupLines = null,
116
143
  }) {
117
144
  return [
118
145
  '# Stack',
146
+ ...(stackPackages.length > 0
147
+ ? ['', '## Stack packages', ...stackPackages.map((name) => `- \`${name}\``)]
148
+ : []),
119
149
  '',
120
150
  '## Components',
121
151
  ...componentIdsValue.map((id) => `- \`${id}\``),
@@ -140,7 +170,7 @@ function renderStack({
140
170
  ].join('\n');
141
171
  }
142
172
 
143
- export async function addStackPieces({ pieces, projectRoot }) {
173
+ export async function addStackPieces({ pieces, projectRoot, stackPackages = [] }) {
144
174
  if (!Array.isArray(pieces) || pieces.length === 0) {
145
175
  throw new GenesisError('STACK_PIECE_REQUIRED', 'genesis stack add requires at least one component.');
146
176
  }
@@ -158,11 +188,21 @@ export async function addStackPieces({ pieces, projectRoot }) {
158
188
  }
159
189
  const sections = parseSections(source);
160
190
  const existing = componentIds(sections);
161
- const catalog = await readBuiltinStackCatalog();
162
- const selected = (await Promise.all(
191
+ const declaredPackages = stackPackageNames(sections);
192
+ const availablePackages = [...new Set([
193
+ ...declaredPackages,
194
+ ...stackPackages.map(normalizeStackPackageName),
195
+ ])].sort();
196
+ const catalog = await readStackCatalog({ projectRoot, stackPackages: availablePackages });
197
+ const selectedPieces = await Promise.all(
163
198
  knownPieces(resolveStackPieces({ catalog, existing, requested }))
164
199
  .map((piece) => customizePiece(projectRoot, piece)),
165
- )).map(({ id }) => id);
200
+ );
201
+ const selected = selectedPieces.map(({ id }) => id);
202
+ const selectedPackages = [...new Set([
203
+ ...declaredPackages,
204
+ ...selectedPieces.map(({ stackPackage }) => stackPackage).filter(Boolean),
205
+ ])].sort();
166
206
  const commandLines = parseStackCommandLines(sections.get('Commands') || [], { path: STACK_PATH })
167
207
  .map(({ label, argv }) => `- Verify \`${label}\`: ${argv.map((value) => `\`${value}\``).join(' ')}`);
168
208
  const resourceLines = sections.has('Resources') ? sections.get('Resources') : null;
@@ -203,11 +243,15 @@ export async function addStackPieces({ pieces, projectRoot }) {
203
243
  environmentFileLines,
204
244
  launchLines,
205
245
  resourceLines,
246
+ stackPackages: selectedPackages,
206
247
  workspaceSetupLines,
207
248
  });
208
249
  const stackChanged = rendered !== source;
209
250
  if (stackChanged) await writeFileAtomic(location, rendered);
210
- const skills = await syncProjectSkills({ projectRoot, stack: await readStack(projectRoot) });
251
+ const skills = await syncProjectSkills({
252
+ projectRoot,
253
+ stack: await readStack(projectRoot, { stackPackages: availablePackages }),
254
+ });
211
255
  const changedFiles = [
212
256
  ...(stackChanged ? [STACK_PATH] : []),
213
257
  ...skills.changedFiles,
@@ -262,7 +306,7 @@ async function customizePiece(projectRoot, piece) {
262
306
  }));
263
307
  }
264
308
 
265
- export async function readStack(projectRoot) {
309
+ export async function readStack(projectRoot, { stackPackages = [] } = {}) {
266
310
  const location = path.join(projectRoot, STACK_PATH);
267
311
  let source;
268
312
  try {
@@ -277,7 +321,12 @@ export async function readStack(projectRoot) {
277
321
  throw error;
278
322
  }
279
323
  const sections = parseSections(source);
280
- const catalog = await readBuiltinStackCatalog();
324
+ const declaredPackages = stackPackageNames(sections);
325
+ const availablePackages = [...new Set([
326
+ ...declaredPackages,
327
+ ...stackPackages.map(normalizeStackPackageName),
328
+ ])].sort();
329
+ const catalog = await readStackCatalog({ projectRoot, stackPackages: availablePackages });
281
330
  const components = await Promise.all(knownPieces(resolveStackPieces({
282
331
  catalog,
283
332
  requested: componentIds(sections),
@@ -325,6 +374,7 @@ export async function readStack(projectRoot) {
325
374
  return {
326
375
  path: STACK_PATH,
327
376
  identityHash: sha256(stableJson({
377
+ stackPackages: declaredPackages,
328
378
  components: components.map(({ id }) => id),
329
379
  cityExclusions: cityPresentation.exclusions,
330
380
  cityRegions: cityPresentation.regions,
@@ -337,6 +387,7 @@ export async function readStack(projectRoot) {
337
387
  workspaceSetup,
338
388
  })),
339
389
  components,
390
+ stackPackages: declaredPackages,
340
391
  cityExclusions: cityPresentation.exclusions,
341
392
  cityRegions: cityPresentation.regions,
342
393
  commands,
@@ -354,6 +405,7 @@ export async function readStack(projectRoot) {
354
405
 
355
406
  export function stackPromptContext(stack) {
356
407
  return {
408
+ stackPackages: stack.stackPackages,
357
409
  components: stack.components.map((piece) => ({
358
410
  id: piece.id,
359
411
  description: piece.description,