genesis-compiler 1.2.2 → 1.2.3

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
@@ -116,8 +116,13 @@ Genesis never overwrites an unmanaged skill and preserves locally modified
116
116
  managed skills. Run `genesis init` after manually editing `genesis/stack.md`,
117
117
  or use `genesis stack add`, to synchronize selected skills.
118
118
 
119
- Hosts that create dependency-empty workspaces can call the public
120
- `inspectWorkspaceSetup()` API. A concrete Stack piece may declare one exact,
119
+ Run `genesis prepare` to execute the selected Stack's exact workspace recipe
120
+ directly with the caller's environment and installed toolchain. It executes
121
+ argument arrays without a shell, honors declared project-relative working
122
+ directories and conditional paths, stops on the first failure, and never
123
+ returns environment values. Hosts that need their own runtime, permission, or
124
+ process policy can instead call the public `inspectWorkspaceSetup()` API and
125
+ execute the same normalized recipe themselves. A concrete Stack piece may declare one exact,
121
126
  ordered `## Workspace setup` recipe with labels, argument arrays expressed as
122
127
  backticked tokens, abstract runtimes, and optional project-relative working
123
128
  directories and readiness paths. A recipe whose declared project marker does
@@ -127,8 +132,9 @@ piece waits for `package.json` and then declares `npm install`; generic
127
132
  `genesis/stack.md` replaces all component defaults, including with
128
133
  `- Nothing.`. Without that override, exactly one component recipe is used;
129
134
  multiple component recipes are reported as ambiguous and never merged.
130
- Genesis does not run the recipe. Stack resources such as database variables do
131
- not gate dependency installation.
135
+ The standalone command does not provision declared resources: callers provide
136
+ their own database, credentials, compilers, and other infrastructure. Stack
137
+ resources such as database variables do not gate dependency installation.
132
138
 
133
139
  Hosts that manage project environments can call `inspectEnvironment()`.
134
140
  Concrete Stack integrations may declare public, non-secret Environment
@@ -24,3 +24,18 @@ semantically current, or that passing checks prove the whole product correct.
24
24
 
25
25
  Git remains the review and recovery boundary. The host running the generated
26
26
  prompt owns the agent and its interaction.
27
+
28
+ ## Filesystem ownership
29
+
30
+ Genesis does not copy package-file ownership or broad source modes into a
31
+ project. It creates ordinary project files with mode `0666`, directories and
32
+ executable skill files with mode `0777`, and lets the invoking process umask
33
+ and the destination directory's inherited ACL establish the effective mode and
34
+ group. Atomic replacements are created beside their destination so they inherit
35
+ the same filesystem policy before becoming visible. Private hook continuation
36
+ state remains explicitly `0600`.
37
+
38
+ This keeps ordinary single-user projects conventional while allowing a managed
39
+ host to require shared-group writes and no access for other users. The host is
40
+ responsible for establishing and verifying that umask, setgid directory, ACL,
41
+ and cross-identity contract before Genesis or an agent starts.
@@ -24,6 +24,17 @@ What this component contributes.
24
24
 
25
25
  - `javascript`
26
26
 
27
+ ## City regions
28
+
29
+ - Ignore `**/test/**`
30
+ - Ignore `**/tests/**`
31
+ - Ignore `**/__tests__/**`
32
+ - Ignore `**/*.test.*`
33
+ - Ignore `**/*.spec.*`
34
+ - Match `packages` as `Packages`: `packages/**`
35
+ - Match `source` as `Source`: `src/**`
36
+ - Fallback `everything-else` as `Everything else`
37
+
27
38
  ## Resources
28
39
 
29
40
  ```json genesis-resource
@@ -239,6 +250,25 @@ may contribute indexers, and duplicates are collapsed. Index output is
239
250
  navigation data, not proof that every dynamic callable or framework edge was
240
251
  discovered.
241
252
 
253
+ `City regions` is an optional complete presentation contract for Machine City.
254
+ Leading `Ignore` entries are canonical project-relative glob patterns using
255
+ only `*` within a path segment and `**` across segments. Genesis excludes their
256
+ matching files before it emits Machine City buildings or functions. This keeps
257
+ test/spec source out of the product City without requiring a host-side filter.
258
+ Each subsequent ordered `Match` entry declares one canonical project-relative
259
+ `path/**` prefix, stable id, and human title. Exactly one final `Fallback` entry
260
+ owns every remaining indexed path. Ignore patterns, ids, and prefixes must be
261
+ unique, prefixes must not overlap, and no more than one selected component may
262
+ declare the contract. Genesis resolves every indexed file and structural
263
+ district to a region and a campus while generating
264
+ `.genesis/machine-city.json`; a City renderer consumes those emitted facts and
265
+ must not reclassify or filter paths. Under a matched prefix, the first directory
266
+ is a campus, while files directly below the prefix belong to the prefix campus.
267
+ Under the fallback, each top-level directory is a campus and project-root files
268
+ belong to the project-root campus. The built-in JSKIT piece excludes exact
269
+ `test`, `tests`, and `__tests__` path segments plus `*.test.*` and `*.spec.*`
270
+ files, then declares `Packages`, `Source`, and `Everything else`.
271
+
242
272
  Do not create a Genesis-owned generic skill merely because a component is
243
273
  named `nodejs`, `php`, or another common technology. Official, user, and host
244
274
  skills with those names must coexist normally. Use `## Skill` only for an
@@ -337,6 +367,20 @@ blocked result.
337
367
  The optional environment object is accepted for a consistent host inspection
338
368
  boundary, but Workspace setup v1 neither reads nor returns its values.
339
369
 
370
+ Standalone users may execute the same normalized recipe with their own
371
+ environment and installed toolchain:
372
+
373
+ ```bash
374
+ genesis prepare
375
+ ```
376
+
377
+ `genesis prepare` runs each declared argv directly without a shell, in order,
378
+ and stops at the first failure. It honors project-relative workdirs and
379
+ conditional paths. It does not provision databases, compilers, credentials,
380
+ runtimes, or other resources. Managed hosts remain free to consume
381
+ `inspectWorkspaceSetup()` and enforce their own execution, identity, runtime,
382
+ and permission policies.
383
+
340
384
  Preview hosts read the normalized Launch contract independently:
341
385
 
342
386
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genesis-compiler",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
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": {
@@ -56,7 +56,7 @@
56
56
  "@ast-grep/lang-ruby": "^0.0.7",
57
57
  "@ast-grep/lang-rust": "^0.0.7",
58
58
  "@ast-grep/napi": "^0.45.1",
59
- "@jskit-ai/agent-docs": "0.1.131",
59
+ "@jskit-ai/agent-docs": "0.1.132",
60
60
  "yaml": "^2.9.0"
61
61
  }
62
62
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genesis",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
4
4
  "description": "Makes Codex aware of optional Genesis adoption for existing projects.",
5
5
  "author": {
6
6
  "name": "Mobily Enterprises"
package/src/cli.js CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  initialize,
12
12
  installCodex,
13
13
  listStackPieces,
14
+ prepareWorkspace,
14
15
  verify,
15
16
  } from './index.js';
16
17
  import {
@@ -30,6 +31,7 @@ const USAGE = `Usage:
30
31
  genesis stack add <piece...>
31
32
  genesis context <path...>
32
33
  genesis index [function-or-path...]
34
+ genesis prepare
33
35
  genesis prompt [request...]
34
36
  genesis prompt --task <start|work|deslop|program|blueprint|describe|review> [request...]
35
37
  genesis verify
@@ -46,7 +48,7 @@ prompt to the agent you already use. Review all edits through the ordinary Git
46
48
  diff, then run genesis verify for the Stack's concrete checks.
47
49
  `;
48
50
 
49
- const COMMANDS = new Set(['adopt', 'check', 'codex', 'context', 'hook', 'index', 'init', 'prompt', 'stack', 'verify']);
51
+ const COMMANDS = new Set(['adopt', 'check', 'codex', 'context', 'hook', 'index', 'init', 'prepare', 'prompt', 'stack', 'verify']);
50
52
 
51
53
  function parseCommand(argv) {
52
54
  if (argv.length === 0 || argv.includes('--help') || argv.includes('-h') || argv[0] === 'help') {
@@ -165,6 +167,19 @@ function writeResult(command, result) {
165
167
  line(process.stdout, `index: ${result.status}`);
166
168
  return;
167
169
  }
170
+ if (command === 'prepare') {
171
+ if (result.summary) line(process.stdout, result.summary);
172
+ namedItems('Commands', result.commands?.map(({ label, argv }) => `${label}: ${argv.join(' ')}`));
173
+ for (const item of result.diagnostics || []) {
174
+ line(process.stdout, `${item.code}: ${item.message}`);
175
+ for (const field of ['stdout', 'stderr']) {
176
+ const output = item.details?.[field]?.trim();
177
+ if (output) line(process.stdout, output);
178
+ }
179
+ }
180
+ line(process.stdout, `prepare: ${result.status}`);
181
+ return;
182
+ }
168
183
  if (command === 'hook') {
169
184
  if (['discover', 'session'].includes(result.kind) && result.output) line(process.stdout, result.output);
170
185
  else if (result.kind === 'stop') line(process.stdout, JSON.stringify(result.output));
@@ -226,6 +241,14 @@ async function execute({ command, operands, options }) {
226
241
  }
227
242
  if (command === 'context') return getContext({ paths: operands, projectRoot });
228
243
  if (command === 'index') return indexCodebase({ projectRoot, queries: operands });
244
+ if (command === 'prepare') {
245
+ return prepareWorkspace({
246
+ projectRoot,
247
+ onEvent: (event) => {
248
+ if (!options.json) line(process.stderr, event.message);
249
+ },
250
+ });
251
+ }
229
252
  if (command === 'hook') {
230
253
  if (operands[0] === 'discover') {
231
254
  return { kind: 'discover', ...await codexAdoptionRecommendation({ projectRoot }) };
@@ -1,13 +1,13 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { createRequire } from 'node:module';
3
3
  import {
4
- cp,
5
4
  lstat,
6
5
  mkdir,
7
6
  readFile,
8
7
  readdir,
9
8
  rename,
10
9
  rm,
10
+ writeFile,
11
11
  } from 'node:fs/promises';
12
12
  import path from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
@@ -92,7 +92,7 @@ async function skillTree(directory, relative = '') {
92
92
  files.push({
93
93
  path: child,
94
94
  hash: sha256(content),
95
- mode: (info.mode & 0o111) === 0 ? 0o100644 : 0o100755,
95
+ mode: (info.mode & 0o100) === 0 ? 0o100644 : 0o100755,
96
96
  });
97
97
  }
98
98
  return files;
@@ -251,7 +251,7 @@ async function replaceSkill(source, target) {
251
251
  await mkdir(path.dirname(target), { recursive: true });
252
252
  let replaced = false;
253
253
  try {
254
- await cp(source, temporary, { recursive: true, errorOnExist: true });
254
+ await copySkillTree(source, temporary);
255
255
  try {
256
256
  await rename(target, backup);
257
257
  replaced = true;
@@ -271,6 +271,23 @@ async function replaceSkill(source, target) {
271
271
  }
272
272
  }
273
273
 
274
+ async function copySkillTree(source, target) {
275
+ const info = await lstat(source);
276
+ if (info.isSymbolicLink() || (!info.isDirectory() && !info.isFile())) {
277
+ invalidSkill(`Agent Skill contains a non-ordinary entry: ${source}.`);
278
+ }
279
+ if (info.isDirectory()) {
280
+ await mkdir(target, { mode: 0o777 });
281
+ const entries = await readdir(source, { withFileTypes: true });
282
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
283
+ await copySkillTree(path.join(source, entry.name), path.join(target, entry.name));
284
+ }
285
+ return;
286
+ }
287
+ const mode = (info.mode & 0o100) === 0 ? 0o666 : 0o777;
288
+ await writeFile(target, await readFile(source), { flag: 'wx', mode });
289
+ }
290
+
274
291
  function targetFiles(skill) {
275
292
  return skill.files.map(({ path: file }) => `${SKILLS_ROOT}/${skill.name}/${file}`);
276
293
  }
@@ -304,6 +321,10 @@ export async function syncProjectSkills({ projectRoot, stack } = {}) {
304
321
  continue;
305
322
  }
306
323
  if (installed.hash !== managed.hash) {
324
+ if (installed.hash === skill.hash) {
325
+ manifest.value.skills[skill.name] = { hash: skill.hash, source: skill.source };
326
+ continue;
327
+ }
307
328
  diagnostics.push({
308
329
  code: 'AGENT_SKILL_CUSTOMIZED',
309
330
  message: `Preserved locally modified Agent Skill ${skill.name}.`,
@@ -388,7 +409,7 @@ export async function inspectProjectSkills({ projectRoot, stack } = {}) {
388
409
  message: `Project Agent Skill ${expected.name} is externally managed; Genesis preserved it.`,
389
410
  details: { name: expected.name, path: `${SKILLS_ROOT}/${expected.name}/SKILL.md` },
390
411
  });
391
- } else if (installed.hash !== managed.hash) {
412
+ } else if (installed.hash !== managed.hash && installed.hash !== expected.hash) {
392
413
  customized = true;
393
414
  diagnostics.push({
394
415
  code: 'AGENT_SKILL_CUSTOMIZED',
@@ -0,0 +1,83 @@
1
+ import path from 'node:path';
2
+
3
+ function presentationRegionForPath(filePath, regions) {
4
+ return regions.find((region) => (
5
+ !region.fallback
6
+ && (filePath === region.pathPrefix || filePath.startsWith(`${region.pathPrefix}/`))
7
+ )) || regions.find(({ fallback }) => fallback) || null;
8
+ }
9
+
10
+ function parentDirectory(filePath) {
11
+ const parent = path.posix.dirname(filePath);
12
+ return parent === '.' ? '' : parent;
13
+ }
14
+
15
+ function presentationCampusPath(filePath, region, { file = false } = {}) {
16
+ const ownershipPath = file ? parentDirectory(filePath) : filePath;
17
+ if (!region || region.fallback) {
18
+ return ownershipPath.split('/').filter(Boolean)[0] || '';
19
+ }
20
+ if (ownershipPath === region.pathPrefix) return region.pathPrefix;
21
+ const relative = ownershipPath.slice(region.pathPrefix.length + 1);
22
+ const child = relative.split('/').filter(Boolean)[0];
23
+ return child ? `${region.pathPrefix}/${child}` : region.pathPrefix;
24
+ }
25
+
26
+ function campusId(regionId, campusPath) {
27
+ return `presentation-campus:${regionId}:${campusPath || '.'}`;
28
+ }
29
+
30
+ /** Resolves Stack-declared City regions and campus roots into Machine City facts. */
31
+ export function buildCityPresentation({ districts = [], files = [], regions = [] } = {}) {
32
+ if (regions.length === 0) {
33
+ return {
34
+ campuses: [],
35
+ placementForPath: () => ({ presentationCampusId: null, presentationRegionId: null }),
36
+ regions: [],
37
+ };
38
+ }
39
+ const districtsByPath = new Map(districts.map((district) => [district.path, district]));
40
+ const placements = new Map();
41
+ const mutableCampuses = new Map();
42
+ const mutableRegions = new Map(regions.map((region) => [region.id, {
43
+ ...region,
44
+ buildingCount: 0,
45
+ }]));
46
+
47
+ function resolvePlacement(filePath = '', { file = false } = {}) {
48
+ if (placements.has(filePath)) return placements.get(filePath);
49
+ const region = presentationRegionForPath(filePath, regions);
50
+ const campusPath = presentationCampusPath(filePath, region, { file });
51
+ const placement = {
52
+ presentationCampusId: campusId(region.id, campusPath),
53
+ presentationRegionId: region.id,
54
+ };
55
+ placements.set(filePath, placement);
56
+ if (!mutableCampuses.has(placement.presentationCampusId)) {
57
+ mutableCampuses.set(placement.presentationCampusId, {
58
+ id: placement.presentationCampusId,
59
+ regionId: region.id,
60
+ path: campusPath,
61
+ title: campusPath ? path.posix.basename(campusPath) : 'Project root',
62
+ districtId: districtsByPath.get(campusPath)?.id || null,
63
+ buildingCount: 0,
64
+ });
65
+ }
66
+ return placement;
67
+ }
68
+
69
+ for (const file of files) {
70
+ const placement = resolvePlacement(file.path, { file: true });
71
+ mutableRegions.get(placement.presentationRegionId).buildingCount += 1;
72
+ mutableCampuses.get(placement.presentationCampusId).buildingCount += 1;
73
+ }
74
+ for (const district of districts) resolvePlacement(district.path);
75
+
76
+ return {
77
+ campuses: [...mutableCampuses.values()]
78
+ .filter(({ buildingCount }) => buildingCount > 0)
79
+ .sort((left, right) => left.id.localeCompare(right.id)),
80
+ placementForPath: (filePath = '') => placements.get(filePath) || resolvePlacement(filePath),
81
+ regions: [...mutableRegions.values()],
82
+ };
83
+ }
@@ -3,6 +3,8 @@ import path from 'node:path';
3
3
 
4
4
  import { astGrepCodeIndexers } from './code-indexers/ast-grep.js';
5
5
  import { asDiagnostic } from './errors.js';
6
+ import { buildCityPresentation } from './city-presentation.js';
7
+ import { cityPathExcluded } from './stack-city-presentation.js';
6
8
  import { gitContext } from './git.js';
7
9
  import { isProjectContentPath } from './paths.js';
8
10
  import { inspectProgram } from './program.js';
@@ -128,8 +130,14 @@ function mergeIndexedFiles(contributions) {
128
130
  };
129
131
  }
130
132
 
131
- function machineCity({ components, contributions, diagnostics, indexers }) {
133
+ function machineCity({ cityRegions, components, contributions, diagnostics, indexers }) {
132
134
  const merged = mergeIndexedFiles(contributions);
135
+ const structuralDistricts = directoryRecords(merged.files.map(({ path: filePath }) => filePath));
136
+ const presentation = buildCityPresentation({
137
+ districts: structuralDistricts,
138
+ files: merged.files,
139
+ regions: cityRegions,
140
+ });
133
141
  const visibility = new Map(merged.functions.map((entry) => [entry.id, entry.visibility]));
134
142
  const codeHash = sha256(stableJson(merged.files.map(({ path: filePath, hash, mode }) => ({
135
143
  path: filePath,
@@ -141,13 +149,19 @@ function machineCity({ components, contributions, diagnostics, indexers }) {
141
149
  schemaVersion: 1,
142
150
  status: indexers.length === 0 ? 'unconfigured' : diagnostics.length > 0 ? 'completed-with-warning' : 'current',
143
151
  codeHash,
152
+ presentationRegions: presentation.regions,
153
+ presentationCampuses: presentation.campuses,
144
154
  stackComponents: components,
145
155
  indexers,
146
156
  diagnostics,
147
157
  functions: merged.functions,
148
- districts: directoryRecords(merged.files.map(({ path: filePath }) => filePath)),
158
+ districts: structuralDistricts.map((district) => ({
159
+ ...district,
160
+ ...presentation.placementForPath(district.path),
161
+ })),
149
162
  buildings: merged.files.map((file) => ({
150
163
  ...file,
164
+ ...presentation.placementForPath(file.path),
151
165
  districtId: directoryId(parentDirectory(file.path)),
152
166
  title: path.posix.basename(file.path),
153
167
  publicFunctionCount: file.functionIds.filter((id) => visibility.get(id) === 'public').length,
@@ -229,7 +243,13 @@ export async function buildProjectIndex({ projectRoot, queries = [], write = tru
229
243
  const stack = await readStack(root);
230
244
  const states = await gitVisibleFileStates(root, { includePath: isProjectContentPath });
231
245
  const files = [...states]
232
- .filter(([, state]) => state.exists && !state.symlink && !state.special && state.hash)
246
+ .filter(([filePath, state]) => (
247
+ state.exists
248
+ && !state.symlink
249
+ && !state.special
250
+ && state.hash
251
+ && !cityPathExcluded(filePath, stack.cityExclusions)
252
+ ))
233
253
  .map(([filePath, state]) => ({ path: filePath, hash: state.hash, mode: state.mode }));
234
254
  const indexers = [...new Set(stack.components.flatMap((component) => component.indexers || []))].sort();
235
255
  const contributions = [];
@@ -249,6 +269,7 @@ export async function buildProjectIndex({ projectRoot, queries = [], write = tru
249
269
  }
250
270
  }
251
271
  const machine = machineCity({
272
+ cityRegions: stack.cityRegions,
252
273
  components: stack.components.map(({ id }) => id),
253
274
  contributions,
254
275
  diagnostics,
package/src/index/init.js CHANGED
@@ -12,7 +12,7 @@ async function createIfMissing(projectRoot, relativePath, source) {
12
12
  const location = path.join(projectRoot, relativePath);
13
13
  await mkdir(path.dirname(location), { recursive: true });
14
14
  try {
15
- await writeFile(location, source, { flag: 'wx', mode: 0o644 });
15
+ await writeFile(location, source, { flag: 'wx', mode: 0o666 });
16
16
  return relativePath;
17
17
  } catch (error) {
18
18
  if (error?.code === 'EEXIST') return null;
@@ -63,6 +63,8 @@ export async function listBuiltinStackPieces() {
63
63
  guidance: piece.guidance,
64
64
  requires: piece.requires,
65
65
  conflicts: piece.conflicts,
66
+ cityExclusions: piece.cityExclusions,
67
+ cityRegions: piece.cityRegions,
66
68
  indexers: piece.indexers,
67
69
  environmentDefaults: piece.environmentDefaults,
68
70
  launchTargets: piece.launchTargets.map(({ id }) => id),
@@ -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
+ }
@@ -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';
@@ -220,6 +221,10 @@ export function parseStackPieceSource(value, {
220
221
  const deslop = textSection(all, 'Deslop');
221
222
  if (!description) invalid(piecePath, 'Stack piece needs Description.');
222
223
  const commands = parseStackCommandLines(all.get('Commands') || [], { path: piecePath });
224
+ const cityPresentation = parseStackCityPresentationLines(
225
+ all.has('City regions') ? all.get('City regions') : undefined,
226
+ { path: piecePath },
227
+ );
223
228
  const environmentDefaults = parseStackEnvironmentDefaultLines(
224
229
  all.has('Environment defaults') ? all.get('Environment defaults') : undefined,
225
230
  { path: piecePath },
@@ -249,6 +254,8 @@ export function parseStackPieceSource(value, {
249
254
  skill: skill(all, piecePath),
250
255
  resources: resources(all, piecePath),
251
256
  commands: commands.map(({ line: _line, ...command }) => command),
257
+ cityExclusions: cityPresentation.exclusions,
258
+ cityRegions: cityPresentation.regions,
252
259
  environmentDefaults,
253
260
  environmentFiles: environmentFiles || [],
254
261
  launchTargets: launch?.targets || [],
@@ -5,6 +5,7 @@ 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 {
@@ -264,6 +265,7 @@ export async function readStack(projectRoot) {
264
265
  ).map(({ line: _line, ...command }) => command);
265
266
  const componentCommands = components.flatMap((piece) => piece.commands);
266
267
  const commands = distinctCommands(projectCommands.length > 0 ? projectCommands : componentCommands);
268
+ const cityPresentation = composeStackCityPresentation(components);
267
269
  const resources = resourceDeclarations(components);
268
270
  const environmentDefaults = composeStackEnvironmentDefaults(components);
269
271
  const projectEnvironmentFiles = parseStackEnvironmentFileLines(
@@ -290,6 +292,8 @@ export async function readStack(projectRoot) {
290
292
  path: STACK_PATH,
291
293
  identityHash: sha256(stableJson({
292
294
  components: components.map(({ id }) => id),
295
+ cityExclusions: cityPresentation.exclusions,
296
+ cityRegions: cityPresentation.regions,
293
297
  commands: commands.map(({ label, argv }) => ({ label, argv })),
294
298
  environmentDefaults,
295
299
  environmentFiles,
@@ -299,6 +303,8 @@ export async function readStack(projectRoot) {
299
303
  workspaceSetup,
300
304
  })),
301
305
  components,
306
+ cityExclusions: cityPresentation.exclusions,
307
+ cityRegions: cityPresentation.regions,
302
308
  commands,
303
309
  environmentDefaults,
304
310
  environmentFiles,
@@ -319,6 +325,8 @@ export function stackPromptContext(stack) {
319
325
  ...(piece.guidance ? { guidance: piece.guidance } : {}),
320
326
  requires: piece.requires,
321
327
  })),
328
+ cityExclusions: stack.cityExclusions,
329
+ cityRegions: stack.cityRegions,
322
330
  verifyCommands: stack.commands.map(({ label, argv }) => ({ label, argv })),
323
331
  environmentDefaults: stack.environmentDefaults,
324
332
  environmentFiles: stack.environmentFiles,
@@ -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();
@@ -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
  }
@@ -36,6 +36,17 @@ application-owned composition.
36
36
 
37
37
  - Dotenv `.env`
38
38
 
39
+ ## City regions
40
+
41
+ - Ignore `**/test/**`
42
+ - Ignore `**/tests/**`
43
+ - Ignore `**/__tests__/**`
44
+ - Ignore `**/*.test.*`
45
+ - Ignore `**/*.spec.*`
46
+ - Match `packages` as `Packages`: `packages/**`
47
+ - Match `source` as `Source`: `src/**`
48
+ - Fallback `everything-else` as `Everything else`
49
+
39
50
  ## Launch
40
51
 
41
52
  ### Target `app`: Run app