genesis-compiler 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -120,7 +120,9 @@ Hosts that create dependency-empty workspaces can call the public
120
120
  `inspectWorkspaceSetup()` API. A concrete Stack piece may declare one exact,
121
121
  ordered `## Workspace setup` recipe with labels, argument arrays expressed as
122
122
  backticked tokens, abstract runtimes, and optional project-relative working
123
- directories. The built-in `jskit` piece declares `npm install`; generic
123
+ directories and readiness paths. A recipe whose declared project marker does
124
+ not exist remains unconfigured until a later inspection. The built-in `jskit`
125
+ piece waits for `package.json` and then declares `npm install`; generic
124
126
  `nodejs` and `php` deliberately declare no installer. One project section in
125
127
  `genesis/stack.md` replaces all component defaults, including with
126
128
  `- Nothing.`. Without that override, exactly one component recipe is used;
@@ -128,6 +130,14 @@ multiple component recipes are reported as ambiguous and never merged.
128
130
  Genesis does not run the recipe. Stack resources such as database variables do
129
131
  not gate dependency installation.
130
132
 
133
+ Hosts that manage project environments can call `inspectEnvironment()`.
134
+ An optional readable `## Environment files` section declares safe
135
+ project-relative dotenv projections such as `.env`. A project declaration
136
+ replaces component defaults, including with `- Nothing.`; otherwise component
137
+ paths compose. Genesis reports resource declarations, value-free missing-input
138
+ diagnostics, and paths. It never returns values, writes files, stores secrets,
139
+ or decides which resolved values a host should materialize.
140
+
131
141
  Hosts that provide previews can call the public `inspectLaunch()` API. An
132
142
  optional readable `## Launch` section in `genesis/stack.md` declares labeled
133
143
  targets, exact backticked argument tokens, relative working directories,
@@ -247,6 +257,7 @@ genesis prompt "Add filtering by note title"
247
257
 
248
258
  ```bash
249
259
  genesis prompt # implementation work
260
+ genesis prompt --task start # open a new or existing project conversation
250
261
  genesis prompt "Add filtering by note title"
251
262
  genesis prompt --task deslop # explicit cleanup
252
263
  genesis prompt --task deslop "Only review billing"
@@ -256,8 +267,12 @@ genesis prompt --task describe # create/refresh Blueprint a
256
267
  genesis prompt --task review # compare intent, code, and explanation
257
268
  ```
258
269
 
259
- The six task types are deliberately explicit:
270
+ The seven task types are deliberately explicit:
260
271
 
272
+ - `start` opens the project conversation. For a new project it asks what the
273
+ app is about, presents relevant choices from the live Stack catalog, and
274
+ waits for the person to select technology. For an existing project it asks
275
+ what the person wants to understand or change.
261
276
  - `work` implements the Blueprint and optional request using current code,
262
277
  Program, selected Stack context, and progressively loaded Agent Skills.
263
278
  - `deslop` requests cleanup explicitly. Codex projects also receive a bounded
@@ -375,6 +390,7 @@ import {
375
390
  getContext,
376
391
  indexCodebase,
377
392
  initialize,
393
+ inspectEnvironment,
378
394
  inspectLaunch,
379
395
  inspectWorkspaceSetup,
380
396
  installCodex,
@@ -394,7 +410,11 @@ detailed Machine City and simple Program City projections.
394
410
  `inspectWorkspaceSetup()` returns one normalized, shell-free workspace
395
411
  preparation recipe—or an explicit unconfigured or ambiguous result—for a host
396
412
  to execute with its own pinned runtimes, caches, process isolation, and
397
- credentials.
413
+ credentials. A configured recipe may remain unconfigured while it waits for a
414
+ technology-declared project marker.
415
+ `inspectEnvironment()` returns normalized Stack resource declarations,
416
+ value-free missing-input diagnostics, and dotenv projection paths without
417
+ returning any environment value.
398
418
  `inspectLaunch()` returns a validated, normalized Stack launch declaration for
399
419
  a host to execute with its own runtime, port, process, readiness, preview
400
420
  identity, secret, and browser policy.
@@ -15,7 +15,13 @@ const work = await generatePrompt({
15
15
  await currentAgent.send(work.prompt);
16
16
  ```
17
17
 
18
- Tasks are `work`, `deslop`, `program`, `blueprint`, `describe`, and `review`.
18
+ Tasks are `start`, `work`, `deslop`, `program`, `blueprint`, `describe`, and `review`.
19
+ `start` is the host-independent first conversation: it classifies an initialized
20
+ project from current Genesis facts, asks what a new app is about, exposes the
21
+ live Stack catalog for an explicit user choice, or asks what the person wants
22
+ to do with an existing project. For an existing uninitialized codebase it owns
23
+ the adoption recommendation as part of the same portable prompt. A host such as Vibe64 sends this prompt on the
24
+ first chat turn; it does not recreate the questions or Stack catalog.
19
25
  `describe` creates or refreshes the complete Blueprint and useful Program in one
20
26
  agent task. The host owns the agent and its interaction. Genesis owns only
21
27
  transparent prompt construction and deterministic project operations.
@@ -43,6 +43,10 @@ What this component contributes.
43
43
  - Concise supplemental guidance used during implementation, explanation,
44
44
  review, and cleanup.
45
45
 
46
+ ## Environment files
47
+
48
+ - Dotenv `.env`
49
+
46
50
  ## Workspace setup
47
51
 
48
52
  - Prepare `Install dependencies` with `example-runtime`: `example-install` `--locked`
@@ -99,13 +103,17 @@ through `genesis verify`.
99
103
  fresh workspace. Each entry uses this readable, shell-free form:
100
104
 
101
105
  ```markdown
102
- - Prepare `label` with `runtime` `another-runtime` in `optional/workdir`: `command` `argument`
106
+ - Prepare `label` with `runtime` `another-runtime` in `optional/workdir` when `optional/marker` exists: `command` `argument`
103
107
  ```
104
108
 
105
109
  Every command and argument is a separate backticked value and is returned as
106
110
  an `argv` array. Runtime names are opaque technology ids. Omitted `in` means
107
111
  the project root; otherwise the working directory must be canonical and
108
- project-relative. `- Nothing.` is valid only as the entire section and lets a
112
+ project-relative. The optional `when` path is also canonical and
113
+ project-relative. Until it exists, inspection reports the recipe as
114
+ `unconfigured`; this lets a host safely re-inspect after an agent creates a
115
+ technology substrate without knowing that technology's filenames. `- Nothing.`
116
+ is valid only as the entire section and lets a
109
117
  project explicitly disable component setup. Genesis validates and reports the
110
118
  recipe but never chooses a runtime, runs a command, installs a dependency, or
111
119
  infers a package manager. Generic language pieces such as `nodejs` and `php`
@@ -123,6 +131,15 @@ Precedence is intentionally small and deterministic:
123
131
  General Stack resources do not gate Workspace setup. In particular, missing
124
132
  database variables do not prevent an otherwise independent dependency install.
125
133
 
134
+ `Environment files` optionally declares project-relative dotenv projections a
135
+ host may materialize from its own resolved project environment. Genesis
136
+ validates and returns only the format and path; it never reads environment
137
+ values, writes a file, stores a secret, or chooses which variables belong in a
138
+ projection. A project `## Environment files` section in `genesis/stack.md`
139
+ replaces component declarations, including with `- Nothing.`. Without a
140
+ project section, component declarations compose and identical paths collapse.
141
+ The declaration is useful independently of Launch and Workspace setup.
142
+
126
143
  `Launch` is an optional readable declarative recipe for hosts that need to
127
144
  offer a preview without guessing how a concrete technology starts. Repeat a
128
145
  ``### Target `id`: label`` block to declare several targets. Default, Workdir,
@@ -211,25 +228,40 @@ Replacement component description.
211
228
 
212
229
  Each field is optional. `Override` replaces only that installed field; `Add`
213
230
  then follows the installed or overridden value. These files cannot alter
214
- dependencies, conflicts, Agent Skill source, indexers, resources, commands,
215
- Workspace setup, or Launch declarations.
231
+ dependencies, conflicts, Agent Skill source, indexers, resources, environment
232
+ files, commands, Workspace setup, or Launch declarations.
216
233
  `genesis stack add` preserves the files and composes their effective prose after
217
234
  dependency resolution.
218
235
 
219
236
  A project may select component ids in `genesis/stack.md`, replace component
220
- Workspace setup defaults with one project recipe, replace component Verify
221
- defaults with its own `## Commands`, and replace every component Launch target
222
- with one project `## Launch` contract. With no project Launch section, selected
223
- component targets compose; duplicate ids or multiple defaults are rejected
224
- rather than guessed. `genesis stack add` preserves existing project Workspace
225
- setup and Launch sections. Adding another language, framework, database, or
226
- integration is therefore an isolated Stack/indexer contribution rather than a
227
- prompt or controller special case.
237
+ environment-file declarations, replace component Workspace setup defaults
238
+ with one project recipe, replace component Verify defaults with its own
239
+ `## Commands`, and replace every component Launch target with one project
240
+ `## Launch` contract. With no project Launch section, selected component
241
+ targets compose; duplicate ids or multiple defaults are rejected rather than
242
+ guessed. `genesis stack add` preserves existing project Environment files,
243
+ Workspace setup, and Launch sections. Adding another language, framework,
244
+ database, or integration is therefore an isolated Stack/indexer contribution
245
+ rather than a prompt or controller special case.
228
246
 
229
247
  Description, Guidance, Agent Skill, and Deslop prose are instructions, not
230
248
  verification evidence. Changes to them do not stale an exact successful
231
- verification record; selected component ids, resources, Workspace setup,
232
- launch targets, and verification commands remain part of the Stack identity.
249
+ verification record; selected component ids, resources, environment files,
250
+ Workspace setup, launch targets, and verification commands remain part of the
251
+ Stack identity.
252
+
253
+ Hosts inspect normalized environment requirements independently:
254
+
255
+ ```js
256
+ import { inspectEnvironment } from 'genesis-compiler';
257
+
258
+ const projectEnvironment = await inspectEnvironment({ environment, projectRoot });
259
+ ```
260
+
261
+ The result has `status` (`ready`, `missing-inputs`, or `unconfigured`),
262
+ `stackHash`, selected `components`, declared `resources`, value-free
263
+ `diagnostics`, and normalized `files`. Each file contains `format`, `path`, and
264
+ `source`. Environment values never cross this API boundary.
233
265
 
234
266
  Fresh-workspace hosts read setup from the package root:
235
267
 
@@ -242,8 +274,11 @@ const setup = await inspectWorkspaceSetup({ projectRoot, environment });
242
274
  The setup result has `status` (`ready`, `blocked`, or `unconfigured`),
243
275
  `stackHash`, an exact ready-recipe `recipeHash`, selected `components`, its `source`, distinct
244
276
  `runtimeRequirements`, normalized ordered `steps`, and `diagnostics`. Each step
245
- contains `label`, `argv`, `runtimeRequirements`, and `workdir`. `blocked` means
246
- component recipes conflict, not that an unrelated Stack resource is absent.
277
+ contains `label`, `argv`, `runtimeRequirements`, `workdir`, and an optional
278
+ `readyWhen` project path. `blocked` means component recipes conflict, not that
279
+ an unrelated Stack resource is absent. An absent `readyWhen` marker produces
280
+ the unconfigured diagnostic `STACK_WORKSPACE_SETUP_WAITING`, never a failed or
281
+ blocked result.
247
282
  The optional environment object is accepted for a consistent host inspection
248
283
  boundary, but Workspace setup v1 neither reads nor returns its values.
249
284
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genesis-compiler",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "An agent-independent prompt, multi-language code-index, cleanup, and verification companion with optional Codex hooks.",
6
6
  "repository": {
@@ -33,6 +33,7 @@
33
33
  "prompts/program.txt",
34
34
  "prompts/reconcile.txt",
35
35
  "prompts/review.txt",
36
+ "prompts/start.txt",
36
37
  "prompts/work.txt",
37
38
  "plugins/genesis",
38
39
  "skills",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genesis",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Makes Codex aware of optional Genesis adoption for existing projects.",
5
5
  "author": {
6
6
  "name": "Mobily Enterprises"
@@ -0,0 +1,30 @@
1
+ Read and follow `.agents/skills/genesis-project/SKILL.md` completely.
2
+
3
+ This is the project-opening conversation. Do not begin implementation until
4
+ the product direction and any technology choice needed for the first useful
5
+ step are clear.
6
+
7
+ When `projectKind` is `new`, first ask what the app is about. Ask up to three
8
+ concise, high-impact questions at a time. Use `availableStackPieces` to present
9
+ only the relevant choices, explain them in product language, and let the user
10
+ decide. Never silently select a technology. After an explicit choice, use
11
+ `genesis stack add <piece...>`; do not edit `genesis/stack.md` by hand. Establish
12
+ the non-technical Blueprint from the user's answers before implementation.
13
+ If the product direction becomes clear while Stack is still empty, do not end
14
+ the turn after only writing or summarizing the Blueprint: present the relevant
15
+ Stack choices in that same turn and ask the user to select one explicitly.
16
+
17
+ When `projectKind` is `existing`, ask what the user wants to understand or
18
+ change. Use the current Blueprint, Stack, Program, and code index when present.
19
+ If the existing implementation is not yet explained, recommend the Genesis
20
+ description/adoption path instead of pretending its intent is known.
21
+
22
+ When `projectKind` is `existing-uninitialized`, ask what the user wants to
23
+ understand or change and strongly recommend `genesis adopt` before substantial
24
+ work. Explain that adoption preserves the implementation and prepares the
25
+ Blueprint and Program prompt. Do not run it without the user's approval. If the
26
+ user declines, continue normally and do not repeat the recommendation.
27
+
28
+ If the request already contains an unambiguous product description and an
29
+ explicit Stack choice, you may apply that choice and establish the Blueprint.
30
+ Otherwise this is a conversational turn: ask, listen, and preserve the source.
package/prompts/work.txt CHANGED
@@ -19,3 +19,10 @@ constructing a parallel fallback.
19
19
 
20
20
  Run focused checks when useful. Summarize files changed, checks actually run,
21
21
  and anything still requiring attention.
22
+
23
+ If no Stack components are selected, continue the project-opening
24
+ conversation instead of guessing a technology. Use `genesis stack list` for
25
+ the current catalog, present relevant choices, wait for an explicit decision,
26
+ and add the selection with `genesis stack add <piece...>` before relying on
27
+ technology-specific setup or launch behavior. Do not end after only updating
28
+ or summarizing the Blueprint while the Stack remains empty.
package/src/cli.js CHANGED
@@ -31,7 +31,7 @@ const USAGE = `Usage:
31
31
  genesis context <path...>
32
32
  genesis index [function-or-path...]
33
33
  genesis prompt [request...]
34
- genesis prompt --task <work|deslop|program|blueprint|describe|review> [request...]
34
+ genesis prompt --task <start|work|deslop|program|blueprint|describe|review> [request...]
35
35
  genesis verify
36
36
  genesis check
37
37
 
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
 
3
3
  const root = new URL('../../', import.meta.url);
4
4
  const assets = {
5
+ start: new URL('prompts/start.txt', root),
5
6
  work: new URL('prompts/work.txt', root),
6
7
  describe: new URL('prompts/describe.txt', root),
7
8
  reconcile: new URL('prompts/reconcile.txt', root),
@@ -0,0 +1,19 @@
1
+ import { gitContext } from './git.js';
2
+ import { readStack } from './stack.js';
3
+ import { missingStackResources } from './stack-preflight.js';
4
+
5
+ /** Inspect Stack environment requirements without returning or writing values. */
6
+ export async function inspectProjectEnvironment({ environment = process.env, projectRoot } = {}) {
7
+ const root = (await gitContext(projectRoot)).repositoryRoot;
8
+ const stack = await readStack(root);
9
+ const diagnostics = missingStackResources({ environment, resources: stack.resources });
10
+ const configured = stack.environmentFiles.length > 0 || stack.resources.length > 0;
11
+ return {
12
+ status: !configured ? 'unconfigured' : diagnostics.length > 0 ? 'missing-inputs' : 'ready',
13
+ stackHash: stack.identityHash,
14
+ components: stack.components.map(({ id }) => id),
15
+ files: stack.environmentFiles,
16
+ resources: stack.resources,
17
+ diagnostics,
18
+ };
19
+ }
@@ -1,10 +1,21 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { promisify } from 'node:util';
3
2
 
4
3
  import { GenesisError } from './errors.js';
5
4
 
6
5
  const MAX_DIAGNOSTIC_OUTPUT = 16_384;
7
- const executeFile = promisify(execFile);
6
+
7
+ function executeFile(command, args, options) {
8
+ return new Promise((resolve, reject) => {
9
+ const child = execFile(command, args, options, (error, stdout, stderr) => {
10
+ if (error) {
11
+ reject(error);
12
+ return;
13
+ }
14
+ resolve({ stdout, stderr });
15
+ });
16
+ child.stdin?.end();
17
+ });
18
+ }
8
19
 
9
20
  function boundedDiagnosticOutput(buffer) {
10
21
  const source = buffer.toString('utf8');
@@ -3,15 +3,19 @@ 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
7
  import { GenesisError } from './errors.js';
7
8
  import { gitContext } from './git.js';
8
9
  import { inspectProgram } from './program.js';
9
10
  import { inspectVerification } from './project-state.js';
10
11
  import { missingStackResources } from './stack-preflight.js';
11
12
  import { stableJson } from './utils.js';
13
+ import { gitVisibleFileStates } from './project-files.js';
14
+ import { isProjectContentPath } from './paths.js';
12
15
 
13
- const TASKS = new Set(['work', 'deslop', 'program', 'blueprint', 'describe', 'review']);
16
+ const TASKS = new Set(['start', 'work', 'deslop', 'program', 'blueprint', 'describe', 'review']);
14
17
  const DEFAULT_REQUEST = {
18
+ start: 'Start a conversation about this project.',
15
19
  work: 'Implement the product intent expressed by the current Blueprint.',
16
20
  deslop: 'Simplify the current Git-visible work without making unrelated changes.',
17
21
  program: 'Refresh the complete useful Program for the code that exists now.',
@@ -19,6 +23,15 @@ const DEFAULT_REQUEST = {
19
23
  review: 'Review the complete useful relationship between Blueprint, code, Program, and tests.',
20
24
  };
21
25
 
26
+ function stackCatalogContext(pieces = []) {
27
+ return pieces.map(({ id, description, requires, conflicts }) => ({
28
+ id,
29
+ description,
30
+ requires,
31
+ conflicts,
32
+ }));
33
+ }
34
+
22
35
  function requestText(value, task) {
23
36
  const request = String(value ?? '').trim();
24
37
  if (Buffer.byteLength(request, 'utf8') > 64 * 1024) {
@@ -142,6 +155,71 @@ async function generateExplanationPrompt({ instructions, program, request, root,
142
155
  };
143
156
  }
144
157
 
158
+ async function generateStartPrompt({ instructions, program, request, root }) {
159
+ let blueprint;
160
+ try {
161
+ blueprint = await readBlueprint(root, { required: true });
162
+ } catch (error) {
163
+ if (error?.code !== 'BLUEPRINT_REQUIRED') throw error;
164
+ const files = await gitVisibleFileStates(root, { includePath: isProjectContentPath });
165
+ const existing = [...files.values()].some((state) => state.exists);
166
+ if (!existing) throw error;
167
+ return {
168
+ status: 'ready',
169
+ task: 'start',
170
+ prompt: renderPrompt({
171
+ instructions,
172
+ request,
173
+ context: {
174
+ task: 'start',
175
+ projectRoot: root,
176
+ projectKind: 'existing-uninitialized',
177
+ genesis: { initialized: false },
178
+ program: programContext(program),
179
+ },
180
+ }),
181
+ warnings: program.diagnostic ? [program.diagnostic] : [],
182
+ verificationCommands: [],
183
+ };
184
+ }
185
+ const [stack, index, catalog] = await Promise.all([
186
+ readStack(root),
187
+ buildProjectIndex({ projectRoot: root, write: false }),
188
+ listBuiltinStackPieces(),
189
+ ]);
190
+ const projectSkills = await inspectProjectSkills({ projectRoot: root, stack });
191
+ const newProject = stack.components.length === 0 && index.fileCount === 0;
192
+ return {
193
+ status: 'ready',
194
+ task: 'start',
195
+ prompt: renderPrompt({
196
+ instructions,
197
+ request,
198
+ context: {
199
+ task: 'start',
200
+ projectRoot: root,
201
+ projectKind: newProject ? 'new' : 'existing',
202
+ blueprint: {
203
+ path: blueprint.path,
204
+ description: blueprint.description,
205
+ },
206
+ stack: stackPromptContext(stack),
207
+ availableStackPieces: stackCatalogContext(catalog),
208
+ program: programContext(program),
209
+ codeIndex: codeIndexContext(index),
210
+ },
211
+ guidance: stack.guidance,
212
+ skills: renderAgentSkillCatalog(projectSkills.skills),
213
+ }),
214
+ warnings: [
215
+ ...(program.diagnostic ? [program.diagnostic] : []),
216
+ ...projectSkills.diagnostics,
217
+ ...index.diagnostics,
218
+ ],
219
+ verificationCommands: [],
220
+ };
221
+ }
222
+
145
223
  export async function generateProjectPrompt({
146
224
  environment = process.env,
147
225
  projectRoot,
@@ -181,6 +259,36 @@ export async function generateProjectPrompt({
181
259
  }
182
260
 
183
261
  const program = await observeProgram(root);
262
+ if (task === 'start') {
263
+ return generateStartPrompt({
264
+ instructions,
265
+ program,
266
+ request: userRequest,
267
+ root,
268
+ });
269
+ }
270
+ let stack;
271
+ if (task === 'work') {
272
+ try {
273
+ stack = await readStack(root);
274
+ } catch (error) {
275
+ if (error?.code !== 'STACK_REQUIRED') throw error;
276
+ return generateStartPrompt({
277
+ instructions: await readInstalledAsset('start'),
278
+ program,
279
+ request: userRequest,
280
+ root,
281
+ });
282
+ }
283
+ if (stack.components.length === 0) {
284
+ return generateStartPrompt({
285
+ instructions: await readInstalledAsset('start'),
286
+ program,
287
+ request: userRequest,
288
+ root,
289
+ });
290
+ }
291
+ }
184
292
  if (['describe', 'program'].includes(task)) {
185
293
  return generateExplanationPrompt({
186
294
  instructions,
@@ -192,7 +300,7 @@ export async function generateProjectPrompt({
192
300
  }
193
301
 
194
302
  const blueprint = await readBlueprint(root, { required: true, requireDescription: true });
195
- const stack = await readStack(root);
303
+ stack ||= await readStack(root);
196
304
  const [index, projectSkills] = await Promise.all([
197
305
  buildProjectIndex({ projectRoot: root, write: false }),
198
306
  inspectProjectSkills({ projectRoot: root, stack }),
@@ -0,0 +1,83 @@
1
+ import { GenesisError } from './errors.js';
2
+ import { isCanonicalProjectWorkdir } from './stack-process.js';
3
+
4
+ const DOTENV_LINE = /^- Dotenv `([^`\r\n]+)`[ \t]*$/u;
5
+
6
+ function invalid(environmentPath, message, line, details = {}) {
7
+ throw new GenesisError('STACK_ENVIRONMENT_FILES_INVALID', message, {
8
+ path: environmentPath,
9
+ ...(line === undefined ? {} : { line }),
10
+ ...details,
11
+ });
12
+ }
13
+
14
+ function environmentFilePath(value, environmentPath, line) {
15
+ if (value === '.' || !isCanonicalProjectWorkdir(value)) {
16
+ invalid(
17
+ environmentPath,
18
+ 'Environment file paths must be canonical project-relative file paths.',
19
+ line,
20
+ { observed: value },
21
+ );
22
+ }
23
+ return value;
24
+ }
25
+
26
+ export function parseStackEnvironmentFileLines(lines, {
27
+ path: environmentPath = 'stack environment files',
28
+ } = {}) {
29
+ if (lines === undefined) return null;
30
+ const entries = lines.map((line) => line.trim()).filter(Boolean);
31
+ if (entries.length === 1 && entries[0] === '- Nothing.') return [];
32
+ const files = [];
33
+ for (let index = 0; index < lines.length; index += 1) {
34
+ const source = lines[index].trim();
35
+ if (!source) continue;
36
+ const entry = source.match(DOTENV_LINE);
37
+ if (!entry) {
38
+ invalid(
39
+ environmentPath,
40
+ 'Every Environment files entry must use `- Dotenv `project-relative-path``.',
41
+ index + 1,
42
+ { observed: source },
43
+ );
44
+ }
45
+ files.push({
46
+ format: 'dotenv',
47
+ path: environmentFilePath(entry[1], environmentPath, index + 1),
48
+ });
49
+ }
50
+ if (files.length === 0) {
51
+ invalid(environmentPath, '## Environment files needs at least one Dotenv entry.');
52
+ }
53
+ const paths = files.map((file) => file.path);
54
+ if (new Set(paths).size !== paths.length) {
55
+ invalid(environmentPath, '## Environment files contains a duplicate path.');
56
+ }
57
+ return files;
58
+ }
59
+
60
+ export function composeStackEnvironmentFiles(components, projectFiles) {
61
+ if (projectFiles !== null) {
62
+ return projectFiles.map((file) => ({ ...file, source: 'project' }));
63
+ }
64
+ const files = new Map();
65
+ for (const component of components) {
66
+ for (const file of component.environmentFiles) {
67
+ const existing = files.get(file.path);
68
+ if (existing && existing.format !== file.format) {
69
+ invalid(
70
+ `component:${component.id}`,
71
+ `Stack components declare incompatible environment file formats for ${file.path}.`,
72
+ );
73
+ }
74
+ if (!existing) {
75
+ files.set(file.path, {
76
+ ...file,
77
+ source: `component:${component.id}`,
78
+ });
79
+ }
80
+ }
81
+ }
82
+ return [...files.values()].sort((left, right) => left.path.localeCompare(right.path));
83
+ }
@@ -1,5 +1,6 @@
1
1
  import { GenesisError } from './errors.js';
2
2
  import { parseStackCommandLines } from './stack-command.js';
3
+ import { parseStackEnvironmentFileLines } from './stack-environment-files.js';
3
4
  import { parseStackLaunchLines } from './stack-launch.js';
4
5
  import { parseStackWorkspaceSetupLines } from './stack-workspace-setup.js';
5
6
  import { normalizeSource } from './utils.js';
@@ -215,6 +216,10 @@ export function parseStackPieceSource(value, {
215
216
  const deslop = textSection(all, 'Deslop');
216
217
  if (!description) invalid(piecePath, 'Stack piece needs Description.');
217
218
  const commands = parseStackCommandLines(all.get('Commands') || [], { path: piecePath });
219
+ const environmentFiles = parseStackEnvironmentFileLines(
220
+ all.has('Environment files') ? all.get('Environment files') : undefined,
221
+ { path: piecePath },
222
+ );
218
223
  const launch = parseStackLaunchLines(all.has('Launch') ? all.get('Launch') : undefined, {
219
224
  path: piecePath,
220
225
  });
@@ -232,6 +237,7 @@ export function parseStackPieceSource(value, {
232
237
  skill: skill(all, piecePath),
233
238
  resources: resources(all, piecePath),
234
239
  commands: commands.map(({ line: _line, ...command }) => command),
240
+ environmentFiles: environmentFiles || [],
235
241
  launchTargets: launch?.targets || [],
236
242
  workspaceSetupSteps: workspaceSetup || [],
237
243
  deslop,
@@ -7,6 +7,7 @@ import {
7
7
 
8
8
  const PREPARE_LINE = /^- Prepare `([^`\r\n]+)` with (.+?):[ \t]+(.+)$/u;
9
9
  const RUNTIME_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
10
+ const READY_WHEN_SUFFIX = /^(.*?)[ \t]+when `([^`\r\n]+)` exists$/u;
10
11
  const WORKDIR_SUFFIX = /^(.*?)[ \t]+in `([^`\r\n]+)`$/u;
11
12
 
12
13
  function invalid(setupPath, message, line, details = {}) {
@@ -58,7 +59,7 @@ export function parseStackWorkspaceSetupLines(lines, {
58
59
  if (!entry) {
59
60
  invalid(
60
61
  setupPath,
61
- 'Every Workspace setup entry must use `- Prepare `label` with `runtime` [in `workdir`]: `command` `argument`...`.',
62
+ 'Every Workspace setup entry must use `- Prepare `label` with `runtime` [in `workdir`] [when `path` exists]: `command` `argument`...`.',
62
63
  line,
63
64
  { observed: source },
64
65
  );
@@ -67,8 +68,18 @@ export function parseStackWorkspaceSetupLines(lines, {
67
68
  if (!label || /[\0\r\n]/u.test(label)) {
68
69
  invalid(setupPath, 'Workspace setup labels must be non-empty single lines.', line);
69
70
  }
70
- const workdirEntry = entry[2].match(WORKDIR_SUFFIX);
71
- const runtimeSource = workdirEntry ? workdirEntry[1] : entry[2];
71
+ const readyEntry = entry[2].match(READY_WHEN_SUFFIX);
72
+ const setupSource = readyEntry ? readyEntry[1] : entry[2];
73
+ const readyWhen = readyEntry ? readyEntry[2] : '';
74
+ if (readyWhen && (readyWhen === '.' || !isCanonicalProjectWorkdir(readyWhen))) {
75
+ invalid(
76
+ setupPath,
77
+ 'Workspace setup readiness paths must be canonical project-relative paths.',
78
+ line,
79
+ );
80
+ }
81
+ const workdirEntry = setupSource.match(WORKDIR_SUFFIX);
82
+ const runtimeSource = workdirEntry ? workdirEntry[1] : setupSource;
72
83
  const workdir = workdirEntry ? workdirEntry[2] : '.';
73
84
  if (!isCanonicalProjectWorkdir(workdir)) {
74
85
  invalid(setupPath, 'Workspace setup workdir must be a canonical project-relative directory.', line);
@@ -86,6 +97,7 @@ export function parseStackWorkspaceSetupLines(lines, {
86
97
  argv,
87
98
  runtimeRequirements: parseRuntimeRequirements(runtimeSource, setupPath, line),
88
99
  workdir,
100
+ ...(readyWhen ? { readyWhen } : {}),
89
101
  });
90
102
  }
91
103
  if (steps.length === 0) {
@@ -6,6 +6,10 @@ import { GenesisError } from './errors.js';
6
6
  import { readBuiltinStackCatalog } from './stack-catalog.js';
7
7
  import { parseStackCommandLines } from './stack-command.js';
8
8
  import { resolveStackPieces } from './stack-composition.js';
9
+ import {
10
+ composeStackEnvironmentFiles,
11
+ parseStackEnvironmentFileLines,
12
+ } from './stack-environment-files.js';
9
13
  import { composeStackLaunchTargets, parseStackLaunchLines } from './stack-launch.js';
10
14
  import {
11
15
  composeStackWorkspaceSetup,
@@ -21,7 +25,13 @@ import { sha256, stableJson, writeFileAtomic } from './utils.js';
21
25
 
22
26
  const COMPONENT_LINE = /^- `([a-z0-9]+(?:-[a-z0-9]+)*)`$/u;
23
27
  export const EMPTY_STACK_SOURCE = '# Stack\n\n## Components\n';
24
- const STACK_SECTIONS = new Set(['Components', 'Workspace setup', 'Commands', 'Launch']);
28
+ const STACK_SECTIONS = new Set([
29
+ 'Components',
30
+ 'Environment files',
31
+ 'Workspace setup',
32
+ 'Commands',
33
+ 'Launch',
34
+ ]);
25
35
  const STACK_CUSTOMIZATION_ROOT = 'genesis/stack';
26
36
 
27
37
  function parseSections(source) {
@@ -87,6 +97,7 @@ function withoutOuterBlankLines(lines = []) {
87
97
  function renderStack({
88
98
  commandLines = [],
89
99
  componentIds: componentIdsValue,
100
+ environmentFileLines = null,
90
101
  launchLines = null,
91
102
  workspaceSetupLines = null,
92
103
  }) {
@@ -95,6 +106,9 @@ function renderStack({
95
106
  '',
96
107
  '## Components',
97
108
  ...componentIdsValue.map((id) => `- \`${id}\``),
109
+ ...(environmentFileLines === null
110
+ ? []
111
+ : ['', '## Environment files', '', ...withoutOuterBlankLines(environmentFileLines)]),
98
112
  ...(workspaceSetupLines === null
99
113
  ? []
100
114
  : ['', '## Workspace setup', '', ...withoutOuterBlankLines(workspaceSetupLines)]),
@@ -129,6 +143,13 @@ export async function addStackPieces({ pieces, projectRoot }) {
129
143
  )).map(({ id }) => id);
130
144
  const commandLines = parseStackCommandLines(sections.get('Commands') || [], { path: STACK_PATH })
131
145
  .map(({ label, argv }) => `- Verify \`${label}\`: ${argv.map((value) => `\`${value}\``).join(' ')}`);
146
+ const environmentFileLines = sections.has('Environment files')
147
+ ? sections.get('Environment files')
148
+ : null;
149
+ parseStackEnvironmentFileLines(
150
+ environmentFileLines === null ? undefined : environmentFileLines,
151
+ { path: STACK_PATH },
152
+ );
132
153
  const launchLines = sections.has('Launch') ? sections.get('Launch') : null;
133
154
  parseStackLaunchLines(launchLines === null ? undefined : launchLines, { path: STACK_PATH });
134
155
  const workspaceSetupLines = sections.has('Workspace setup')
@@ -141,6 +162,7 @@ export async function addStackPieces({ pieces, projectRoot }) {
141
162
  const rendered = renderStack({
142
163
  commandLines,
143
164
  componentIds: selected,
165
+ environmentFileLines,
144
166
  launchLines,
145
167
  workspaceSetupLines,
146
168
  });
@@ -228,6 +250,11 @@ export async function readStack(projectRoot) {
228
250
  const componentCommands = components.flatMap((piece) => piece.commands);
229
251
  const commands = distinctCommands(projectCommands.length > 0 ? projectCommands : componentCommands);
230
252
  const resources = resourceDeclarations(components);
253
+ const projectEnvironmentFiles = parseStackEnvironmentFileLines(
254
+ sections.has('Environment files') ? sections.get('Environment files') : undefined,
255
+ { path: STACK_PATH },
256
+ );
257
+ const environmentFiles = composeStackEnvironmentFiles(components, projectEnvironmentFiles);
231
258
  const projectLaunch = parseStackLaunchLines(
232
259
  sections.has('Launch') ? sections.get('Launch') : undefined,
233
260
  { path: STACK_PATH },
@@ -243,12 +270,14 @@ export async function readStack(projectRoot) {
243
270
  identityHash: sha256(stableJson({
244
271
  components: components.map(({ id }) => id),
245
272
  commands: commands.map(({ label, argv }) => ({ label, argv })),
273
+ environmentFiles,
246
274
  launchTargets,
247
275
  resources,
248
276
  workspaceSetup,
249
277
  })),
250
278
  components,
251
279
  commands,
280
+ environmentFiles,
252
281
  launchTargets,
253
282
  workspaceSetup,
254
283
  resources,
@@ -266,6 +295,7 @@ export function stackPromptContext(stack) {
266
295
  requires: piece.requires,
267
296
  })),
268
297
  verifyCommands: stack.commands.map(({ label, argv }) => ({ label, argv })),
298
+ environmentFiles: stack.environmentFiles,
269
299
  launchTargets: stack.launchTargets,
270
300
  workspaceSetup: stack.workspaceSetup,
271
301
  };
@@ -1,3 +1,6 @@
1
+ import { access } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
1
4
  import { gitContext } from './git.js';
2
5
  import { readStack } from './stack.js';
3
6
  import { sha256, stableJson, uniqueSorted } from './utils.js';
@@ -8,10 +11,32 @@ export async function inspectProjectWorkspaceSetup({
8
11
  } = {}) {
9
12
  const root = (await gitContext(projectRoot)).repositoryRoot;
10
13
  const stack = await readStack(root);
11
- const diagnostics = stack.workspaceSetup.diagnostics;
14
+ const diagnostics = [...stack.workspaceSetup.diagnostics];
15
+ if (diagnostics.length === 0) {
16
+ const waitingFor = [];
17
+ for (const step of stack.workspaceSetup.steps) {
18
+ if (!step.readyWhen) continue;
19
+ try {
20
+ await access(path.join(root, step.readyWhen));
21
+ } catch (error) {
22
+ if (!['ENOENT', 'ENOTDIR'].includes(error?.code)) throw error;
23
+ waitingFor.push(step.readyWhen);
24
+ }
25
+ }
26
+ if (waitingFor.length > 0) {
27
+ const paths = uniqueSorted(waitingFor);
28
+ diagnostics.push({
29
+ code: 'STACK_WORKSPACE_SETUP_WAITING',
30
+ message: `Workspace setup is waiting for project ${paths.length === 1 ? 'path' : 'paths'}: ${paths.join(', ')}.`,
31
+ details: { paths },
32
+ });
33
+ }
34
+ }
35
+ const waiting = diagnostics.some(({ code }) => code === 'STACK_WORKSPACE_SETUP_WAITING');
36
+ const blocked = diagnostics.some(({ code }) => code !== 'STACK_WORKSPACE_SETUP_WAITING');
12
37
  let status = 'unconfigured';
13
- if (diagnostics.length > 0) status = 'blocked';
14
- else if (stack.workspaceSetup.steps.length > 0) status = 'ready';
38
+ if (blocked) status = 'blocked';
39
+ else if (!waiting && stack.workspaceSetup.steps.length > 0) status = 'ready';
15
40
  const recipeHash = status === 'ready'
16
41
  ? sha256(stableJson({ version: 1, steps: stack.workspaceSetup.steps }))
17
42
  : '';
package/src/index.js CHANGED
@@ -6,6 +6,7 @@ import { contextForProjectPaths } from './index/context.js';
6
6
  import { generateProjectPrompt } from './index/prompt.js';
7
7
  import { initializeProject } from './index/init.js';
8
8
  import { installCodexPlugin } from './index/codex-plugin.js';
9
+ import { inspectProjectEnvironment } from './index/environment-files.js';
9
10
  import { inspectProjectLaunch } from './index/launch.js';
10
11
  import { listBuiltinStackPieces } from './index/stack-catalog.js';
11
12
  import { addStackPieces } from './index/stack.js';
@@ -72,6 +73,10 @@ export function inspectLaunch(options) {
72
73
  return inspectProjectLaunch(options);
73
74
  }
74
75
 
76
+ export function inspectEnvironment(options) {
77
+ return inspectProjectEnvironment(options);
78
+ }
79
+
75
80
  export function inspectWorkspaceSetup(options) {
76
81
  return inspectProjectWorkspaceSetup(options);
77
82
  }
@@ -29,7 +29,11 @@ generated files, and runtime composition.
29
29
 
30
30
  ## Workspace setup
31
31
 
32
- - Prepare `Install dependencies` with `nodejs`: `npm` `install`
32
+ - Prepare `Install dependencies` with `nodejs` when `package.json` exists: `npm` `install`
33
+
34
+ ## Environment files
35
+
36
+ - Dotenv `.env`
33
37
 
34
38
  ## Launch
35
39