genesis-compiler 1.7.5 → 1.7.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.
package/README.md CHANGED
@@ -946,7 +946,13 @@ detailed Machine City and simple Program City projections.
946
946
  public non-secret defaults, value-free missing-input diagnostics, and dotenv
947
947
  projection paths without returning any supplied environment value.
948
948
  `inspectStackSection()` returns one exact consumer-owned section without
949
- interpreting or executing its contents.
949
+ interpreting or executing its contents. Its default `rootMode: "repository"`
950
+ uses Git discovery. For an authorized source snapshot without Git metadata,
951
+ hosts may pass `rootMode: "directory"` and an explicit absolute `projectRoot`.
952
+ This reads that exact directory, including when it sits inside another Git
953
+ repository. It retains the same Stack validation and performs no writes or
954
+ commands. Missing/invalid roots fail; this is never an automatic fallback from
955
+ Git ownership or discovery errors.
950
956
 
951
957
  Hosts that already validate access to a shared worktree may wrap an operation
952
958
  in `withTrustedGitRepository(absoluteWorktreeRoot, () => inspectEnvironment({
@@ -177,6 +177,13 @@ The named consumer then owns all interpretation and action. This boundary works
177
177
  equally for web applications, libraries, command-line tools, native programs,
178
178
  firmware, desktop software, and future systems Genesis has never seen.
179
179
 
180
+ The library can inspect a source snapshot without Git metadata with
181
+ `inspectStackSection({ name, projectRoot: absoluteDirectory, rootMode: "directory" })`.
182
+ It uses the exact directory and the same Stack parser without repository
183
+ discovery. Default library and CLI inspection continue to use the enclosing
184
+ Git repository and its trust checks. Invalid modes or directory roots fail;
185
+ there is no automatic fallback.
186
+
180
187
  ## Selection and identity
181
188
 
182
189
  `genesis stack add` records selected packages and component dependency closure,
package/docs/templates.md CHANGED
@@ -23,8 +23,8 @@ components or authored project contracts when no catalogue technology matches.
23
23
  The exact bootstrap allowlist is `GENESIS_BOOTSTRAP_PATHS` in
24
24
  `src/index/project-inspection.js`: the four core Markdown documents and version,
25
25
  the two derived Cities, Genesis's Codex/OpenCode integration files, its managed
26
- skills manifest, and the SKILL.md/openai.yaml pair for each of the three Genesis
27
- workflow skills. Whole `.agents/`, `.codex/`, or `genesis/` directories are not
26
+ skills manifest, the relative Claude discovery link, and the SKILL.md/openai.yaml
27
+ pair for each of the three Genesis workflow skills. Whole `.agents/`, `.codex/`, or `genesis/` directories are not
28
28
  exempt. A README, `.gitignore`, application manifest, unfamiliar source file, or
29
29
  custom agent tool counts as existing content. Ignored files also count, except
30
30
  installed `node_modules` dependencies. Source recognition does not depend on a
@@ -56,8 +56,14 @@ Each branch is a complete, current Genesis project with actual application
56
56
  source, dependency lockfile, setup/run contracts and focused checks.
57
57
 
58
58
  Applying fetches the explicitly named branch once, resolves its commit and reads
59
- its ordinary Git blobs into a temporary project. Genesis rejects links,
59
+ its ordinary Git blobs into a temporary project. Genesis rejects arbitrary links,
60
60
  submodules, unsafe paths, dependencies and private dotenv files in the template.
61
+ The only link exception is the exact generated Claude skill discovery link
62
+ `.claude/skills/<name>` → `../../.agents/skills/<name>`. Incoming discovery links
63
+ are omitted and regenerated from the canonical skill directories by initialization.
64
+ Existing exact generated links are preserved and checked again before copying;
65
+ links with another target remain rejected. A bare destination receives the same
66
+ generated links without following links supplied by a template.
61
67
  It validates the prepared project before changing the destination. A project
62
68
  lock serializes competing imports; the source and authored bootstrap files are
63
69
  checked again immediately before the copy. An ordinary copy failure rolls back
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genesis-compiler",
3
- "version": "1.7.5",
3
+ "version": "1.7.7",
4
4
  "type": "module",
5
5
  "description": "An agent-independent prompt, multi-language code-index, cleanup, and verification companion with project agent guidance.",
6
6
  "repository": {
@@ -53,6 +53,11 @@ async function renderedSettings(projectRoot) {
53
53
 
54
54
  // Claude discovers .claude/skills; the canonical files remain in .agents/skills.
55
55
  // Only exact generated links are owned here. User-owned Claude skills win.
56
+ export function claudeSkillLinkTarget(relative) {
57
+ const name = /^\.claude\/skills\/([^/\\]+)$/u.exec(relative)?.[1];
58
+ return name && name !== '.' && name !== '..' ? `../../.agents/skills/${name}` : null;
59
+ }
60
+
56
61
  export async function claudeSkillLinks(root, write = false) {
57
62
  const [canonical, installed] = await Promise.all(['.agents/skills', '.claude/skills'].map(async (directory) => {
58
63
  try { return await readdir(path.join(root, directory), { withFileTypes: true }); }
@@ -65,7 +70,7 @@ export async function claudeSkillLinks(root, write = false) {
65
70
  for (const name of new Set([...names, ...existing.keys()])) {
66
71
  const relative = `.claude/skills/${name}`;
67
72
  const location = path.join(root, relative);
68
- const target = `../../.agents/skills/${name}`;
73
+ const target = claudeSkillLinkTarget(relative);
69
74
  const entry = existing.get(name);
70
75
  const owned = entry?.isSymbolicLink() && await readlink(location) === target;
71
76
  if (!names.has(name)) {
@@ -1,3 +1,6 @@
1
+ import { realpath, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
1
4
  import { GENESIS_CONTRACTS } from './contracts.js';
2
5
  import { gitContext } from './git.js';
3
6
  import { readStack } from './stack.js';
@@ -8,10 +11,22 @@ import { sha256, stableJson } from './utils.js';
8
11
  export async function inspectProjectStackSection({
9
12
  name,
10
13
  projectRoot,
14
+ rootMode = 'repository',
11
15
  stackPackages = [],
12
16
  } = {}) {
13
17
  const sectionName = normalizeStackSectionName(name);
14
- const root = (await gitContext(projectRoot)).repositoryRoot;
18
+ let root;
19
+ if (rootMode === 'repository') {
20
+ root = (await gitContext(projectRoot)).repositoryRoot;
21
+ } else if (rootMode === 'directory') {
22
+ if (typeof projectRoot !== 'string' || !path.isAbsolute(projectRoot)) {
23
+ throw new TypeError('Directory inspection requires an explicit absolute projectRoot.');
24
+ }
25
+ root = await realpath(projectRoot);
26
+ if (!(await stat(root)).isDirectory()) throw new TypeError('Directory inspection requires a directory.');
27
+ } else {
28
+ throw new TypeError('Stack section rootMode must be repository or directory.');
29
+ }
15
30
  const stack = await readStack(root, { stackPackages });
16
31
  const section = stack.extensions.find((candidate) => candidate.name === sectionName);
17
32
  const diagnostics = [...(section?.diagnostics || [])];
@@ -30,4 +45,3 @@ export async function inspectProjectStackSection({
30
45
  diagnostics,
31
46
  };
32
47
  }
33
-
@@ -1,4 +1,4 @@
1
- import { lstat, mkdir, mkdtemp, readFile, readdir, rm, rmdir, writeFile } from 'node:fs/promises';
1
+ import { lstat, mkdir, mkdtemp, readFile, readdir, readlink, rm, rmdir, symlink, writeFile } from 'node:fs/promises';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { parseBlueprintSource } from './blueprint.js';
@@ -13,6 +13,7 @@ import { listTemplates, resolveTemplate } from './template-catalog.js';
13
13
  import { readGitSnapshot } from './template-source.js';
14
14
  import { readStack } from './stack.js';
15
15
  import { writeFileAtomic } from './utils.js';
16
+ import { claudeSkillLinkTarget } from './claude-hooks.js';
16
17
 
17
18
  function sections(source) {
18
19
  const result = new Map();
@@ -43,8 +44,12 @@ async function existingFile(root, name) {
43
44
  const location = path.join(root, name);
44
45
  try {
45
46
  const state = await lstat(location);
47
+ if (state.isSymbolicLink()) {
48
+ const linkTarget = claudeSkillLinkTarget(name);
49
+ if (linkTarget && await readlink(location) === linkTarget) return { contents: Buffer.from(linkTarget), linkTarget };
50
+ }
46
51
  if (!state.isFile()) throw new GenesisError('TEMPLATE_DESTINATION_CONFLICT', `Template destination is not an ordinary file: ${name}.`);
47
- return await readFile(location);
52
+ return { contents: await readFile(location), linkTarget: null };
48
53
  } catch (error) {
49
54
  if (error.code === 'ENOENT') return null;
50
55
  throw error;
@@ -57,7 +62,7 @@ async function filesBelow(root, relative = '') {
57
62
  if (!relative && entry.name === '.git') continue;
58
63
  const name = relative ? `${relative}/${entry.name}` : entry.name;
59
64
  if (entry.isDirectory()) files.push(...await filesBelow(root, name));
60
- else if (entry.isFile()) files.push({ path: name, contents: await readFile(path.join(root, name)), mode: (await lstat(path.join(root, name))).mode & 0o111 ? 0o777 : 0o666 });
65
+ else if (entry.isFile() || entry.isSymbolicLink()) files.push({ path: name, ...await existingFile(root, name), mode: (await lstat(path.join(root, name))).mode & 0o111 ? 0o777 : 0o666 });
61
66
  else throw new GenesisError('TEMPLATE_TREE_INVALID', `Template contains a non-ordinary file: ${name}.`);
62
67
  }
63
68
  return files;
@@ -96,28 +101,34 @@ export async function applyTemplate({ projectRoot, templateId, stackPackages = [
96
101
  await mkdir(path.dirname(path.join(stagingRoot, file.path)), { recursive: true });
97
102
  await writeFile(path.join(stagingRoot, file.path), file.contents, { flag: 'wx', mode: file.mode });
98
103
  }
99
- const stackSource = await existingFile(stagingRoot, 'genesis/stack.md');
100
- if (!stackSource) throw new GenesisError('TEMPLATE_STACK_REQUIRED', 'A template must contain its complete genesis/stack.md.');
104
+ const stackFile = await existingFile(stagingRoot, 'genesis/stack.md');
105
+ if (!stackFile) throw new GenesisError('TEMPLATE_STACK_REQUIRED', 'A template must contain its complete genesis/stack.md.');
101
106
  const seedStack = await readStack(stagingRoot, { stackPackages });
102
107
  if (!seedStack.components.some(({ id }) => id === template.technology)) {
103
108
  throw new GenesisError('TEMPLATE_STACK_MISMATCH', 'The template Stack does not contain its advertised technology.');
104
109
  }
105
- for (const [name, contents] of preserved) {
106
- if (contents === null || name.startsWith('.genesis/')) continue;
110
+ for (const [name, previous] of preserved) {
111
+ if (previous === null || name.startsWith('.genesis/')) continue;
112
+ const { contents, linkTarget } = previous;
107
113
  if (name === 'genesis/blueprint.md' && !parseBlueprintSource(contents.toString('utf8')).description) continue;
108
114
  let value = contents;
109
- if (name === 'genesis/stack.md') value = mergeTemplateStack(stackSource.toString('utf8'), contents.toString('utf8'));
110
- await mkdir(path.dirname(path.join(stagingRoot, name)), { recursive: true });
111
- await writeFile(path.join(stagingRoot, name), value);
115
+ if (name === 'genesis/stack.md') value = mergeTemplateStack(stackFile.contents.toString('utf8'), contents.toString('utf8'));
116
+ const location = path.join(stagingRoot, name);
117
+ await mkdir(path.dirname(location), { recursive: true });
118
+ if (linkTarget) {
119
+ await rm(location, { force: true });
120
+ await symlink(linkTarget, location, 'dir');
121
+ } else await writeFile(location, value);
112
122
  }
113
123
  await initializeProject({ projectRoot: stagingRoot, stackPackages });
114
124
  await buildProjectIndex({ projectRoot: stagingRoot, stackPackages });
115
125
  const prepared = await inspectProject({ projectRoot: stagingRoot, stackPackages });
116
126
  if (prepared.state !== 'ready') throw new GenesisError('TEMPLATE_PROJECT_INVALID', 'The prepared template does not contain a usable Genesis project.', { inspection: prepared });
117
127
  await assertSeedable(root, stackPackages);
118
- for (const [name, contents] of preserved) {
128
+ for (const [name, previous] of preserved) {
119
129
  const current = await existingFile(root, name);
120
- if ((contents === null) !== (current === null) || contents && !contents.equals(current)) {
130
+ if ((previous === null) !== (current === null) || previous &&
131
+ (previous.linkTarget !== current.linkTarget || !previous.contents.equals(current.contents))) {
121
132
  throw new GenesisError('TEMPLATE_DESTINATION_CHANGED', 'Project context changed while the template was being prepared. Nothing was applied.');
122
133
  }
123
134
  }
@@ -126,16 +137,19 @@ export async function applyTemplate({ projectRoot, templateId, stackPackages = [
126
137
  try {
127
138
  for (const file of files) {
128
139
  const previous = await existingFile(root, file.path);
129
- if (previous && previous.equals(file.contents)) continue;
140
+ if (previous && previous.linkTarget === file.linkTarget && previous.contents.equals(file.contents)) continue;
130
141
  if (previous && !preserved.has(file.path)) throw new GenesisError('TEMPLATE_DESTINATION_CHANGED', `Existing project content was preserved: ${file.path}.`);
131
142
  await mkdir(path.dirname(path.join(root, file.path)), { recursive: true });
132
- if (previous) await writeFileAtomic(path.join(root, file.path), file.contents);
143
+ if (file.linkTarget || previous?.linkTarget) {
144
+ if (previous) throw new GenesisError('TEMPLATE_DESTINATION_CHANGED', `Existing project link was preserved: ${file.path}.`);
145
+ await symlink(file.linkTarget, path.join(root, file.path), 'dir');
146
+ } else if (previous) await writeFileAtomic(path.join(root, file.path), file.contents);
133
147
  else await writeFile(path.join(root, file.path), file.contents, { flag: 'wx', mode: file.mode });
134
148
  applied.push({ path: file.path, previous });
135
149
  }
136
150
  } catch (error) {
137
151
  for (const file of applied.reverse()) {
138
- if (file.previous) await writeFileAtomic(path.join(root, file.path), file.previous);
152
+ if (file.previous) await writeFileAtomic(path.join(root, file.path), file.previous.contents);
139
153
  else await rm(path.join(root, file.path));
140
154
  }
141
155
  throw error;
@@ -3,6 +3,7 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { GenesisError } from './errors.js';
5
5
  import { runGit, runGitText } from './process.js';
6
+ import { claudeSkillLinkTarget } from './claude-hooks.js';
6
7
 
7
8
  function validateRepository(repository) {
8
9
  if (typeof repository !== 'string' || !repository || repository.startsWith('-')) {
@@ -35,11 +36,19 @@ export async function readGitSnapshot({ repository, branch }) {
35
36
  const separator = item.indexOf('\t');
36
37
  const [mode, type, object] = item.slice(0, separator).split(' ');
37
38
  const name = item.slice(separator + 1);
38
- if (!['100644', '100755'].includes(mode) || type !== 'blob' || path.isAbsolute(name)
39
+ if (!['100644', '100755', '120000'].includes(mode) || type !== 'blob' || path.isAbsolute(name)
39
40
  || name.includes('\\') || name.split('/').some((part) => !part || ['.', '..', '.git'].includes(part.toLowerCase()))) {
40
41
  throw new GenesisError('TEMPLATE_TREE_INVALID', `Template contains an unsupported path or file: ${name}.`);
41
42
  }
42
43
  const blob = await runGit(temporaryRoot, ['cat-file', 'blob', object]);
44
+ if (mode === '120000') {
45
+ const target = claudeSkillLinkTarget(name);
46
+ if (!target || !blob.stdout.equals(Buffer.from(target))) {
47
+ throw new GenesisError('TEMPLATE_TREE_INVALID', `Template contains an unsupported link: ${name}.`);
48
+ }
49
+ // Regenerate these through initialization from the canonical skill files.
50
+ continue;
51
+ }
43
52
  bytes += blob.stdout.length;
44
53
  if (bytes > 64 * 1024 * 1024 || files.length >= 10_000) {
45
54
  throw new GenesisError('TEMPLATE_TREE_TOO_LARGE', 'Template exceeds the source size limit.');