genesis-compiler 1.10.10 → 1.10.12

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.
@@ -34,6 +34,7 @@ export const GENESIS_BOOTSTRAP_PATHS = Object.freeze([
34
34
  `.agents/skills/${name}/SKILL.md`,
35
35
  `.agents/skills/${name}/agents/openai.yaml`,
36
36
  ]),
37
+ '.agents/skills/genesis-project/references/sanity-checks.md',
37
38
  ]);
38
39
  const bootstrapPaths = new Set(GENESIS_BOOTSTRAP_PATHS);
39
40
  // Genesis creates .gitignore only to ignore its Cities; any other content is the project's.
@@ -50,22 +51,39 @@ async function isBootstrapGitignore(projectRoot, paths) {
50
51
  }
51
52
  }
52
53
 
53
- export async function inspectProjectContent(projectRoot) {
54
+ async function listFiles(projectRoot, options) {
54
55
  // Exclude dependency paths in Git before collecting and sorting its output.
55
- const sourcePaths = ['--', '.', ':(exclude,glob)**/node_modules/**'];
56
+ const { stdout } = await runGit(projectRoot, ['ls-files', '-z', ...options, '--', '.', ':(exclude,glob)**/node_modules/**']);
57
+ return stdout.toString('utf8').split('\0').filter(Boolean);
58
+ }
59
+
60
+ /**
61
+ * With kindOnly, only `kind` is returned. Listing ignored files walks ignored dependency trees such as node_modules
62
+ * despite the exclusion, which dominates the inspection of an installed application, so it is skipped when Git's
63
+ * visible files already include project content: ignored files can add existing content, never remove it.
64
+ */
65
+ export async function inspectProjectContent(projectRoot, { kindOnly = false } = {}) {
66
+ const listIgnored = () => listFiles(projectRoot, ['--others', '--ignored', '--exclude-standard']);
56
67
  const [visible, deleted, ignored] = await Promise.all([
57
- runGit(projectRoot, ['ls-files', '-z', '--cached', '--others', '--exclude-standard', ...sourcePaths]),
58
- runGit(projectRoot, ['ls-files', '-z', '--deleted', ...sourcePaths]),
59
- runGit(projectRoot, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', ...sourcePaths]),
68
+ listFiles(projectRoot, ['--cached', '--others', '--exclude-standard']),
69
+ listFiles(projectRoot, ['--deleted']),
70
+ kindOnly ? [] : listIgnored(),
60
71
  ]);
61
- const absent = new Set(deleted.stdout.toString('utf8').split('\0').filter(Boolean));
62
- const paths = [...new Set([visible, ignored].flatMap(({ stdout }) => stdout.toString('utf8').split('\0').filter(Boolean)))]
72
+ if (!kindOnly) return classifyContent(projectRoot, visible, deleted, ignored);
73
+ const { kind } = await classifyContent(projectRoot, visible, deleted, []);
74
+ if (kind === 'existing') return { kind };
75
+ return { kind: (await classifyContent(projectRoot, visible, deleted, await listIgnored())).kind };
76
+ }
77
+
78
+ async function classifyContent(projectRoot, visible, deleted, ignored) {
79
+ const absent = new Set(deleted);
80
+ const paths = [...new Set([...visible, ...ignored])]
63
81
  .filter((file) => !absent.has(file) && file !== '.git' && !file.startsWith('.git/'))
64
82
  .sort();
65
83
  // OpenCode installs its plugin dependency beside the generated guidance plugin.
66
84
  // Recognize only its exact ignored scaffolding, never an authored config tree.
67
85
  const opencodePaths = ['.opencode/.gitignore', '.opencode/package.json', '.opencode/package-lock.json'];
68
- const ignoredPaths = new Set(ignored.stdout.toString('utf8').split('\0').filter(Boolean));
86
+ const ignoredPaths = new Set(ignored);
69
87
  let opencodeBootstrap = [];
70
88
  if (opencodePaths.every((file) => ignoredPaths.has(file))) {
71
89
  try {
@@ -99,15 +117,19 @@ export async function inspectProjectContent(projectRoot) {
99
117
 
100
118
  /** Read-only opening inspection: no index generation, verification, or application execution. */
101
119
  export async function inspectProject({ projectRoot, stackPackages = [] } = {}) {
102
- const root = (await gitContext(projectRoot)).repositoryRoot;
120
+ return inspectProjectAtRoot((await gitContext(projectRoot)).repositoryRoot, { stackPackages });
121
+ }
122
+
123
+ /** The same inspection of a resolved repository root; without `count`, content reports only its kind. */
124
+ export async function inspectProjectAtRoot(root, { stackPackages = [], count = true } = {}) {
103
125
  const [content, format] = await Promise.all([
104
- inspectProjectContent(root),
126
+ inspectProjectContent(root, { kindOnly: !count }),
105
127
  inspectProjectFormatAtRoot(root),
106
128
  ]);
107
129
  const result = {
108
130
  contract: GENESIS_CONTRACTS.projectInspection,
109
131
  state: 'attention',
110
- content: { kind: content.kind, count: content.existingPaths.length },
132
+ content: count ? { kind: content.kind, count: content.existingPaths.length } : { kind: content.kind },
111
133
  projectFormat: format,
112
134
  templateEligible: false,
113
135
  stackComponents: [],
@@ -2,17 +2,12 @@ import { readCollaboration } from './collaboration.js';
2
2
  import { inspectProjectSkills, renderAgentSkillCatalog } from './agent-skills.js';
3
3
  import { GENESIS_CONTRACTS } from './contracts.js';
4
4
  import { readEngineering, readEngineeringBaseline } from './engineering.js';
5
- import { asDiagnostic, GenesisError } from './errors.js';
5
+ import { asDiagnostic } from './errors.js';
6
6
  import { gitContext } from './git.js';
7
- import { inspectProject } from './project-inspection.js';
7
+ import { inspectProjectAtRoot } from './project-inspection.js';
8
8
  import { readStack } from './stack.js';
9
9
  import { listStackCatalogPieces } from './stack-catalog.js';
10
- import { normalizeSource, sha256, stableJson } from './utils.js';
11
-
12
- const HOST_CONTEXT_MAX_BYTES = Object.freeze({
13
- session: 24 * 1024,
14
- turn: 512,
15
- });
10
+ import { composedContext, hostContextContribution } from './host-context.js';
16
11
 
17
12
  async function optionalStack(projectRoot, stackPackages) {
18
13
  try { return await readStack(projectRoot, { stackPackages }); } catch { return null; }
@@ -47,53 +42,6 @@ async function optionalCollaboration(projectRoot) {
47
42
  }
48
43
  }
49
44
 
50
- async function hostContextContribution({ hostDriver, hostDriverInput, scope }) {
51
- if (hostDriver === undefined || hostDriver === null) {
52
- if (hostDriverInput !== undefined) {
53
- throw new GenesisError(
54
- 'HOST_CONTEXT_INVALID',
55
- 'Host context input requires an explicitly registered host driver.',
56
- );
57
- }
58
- return '';
59
- }
60
- if (typeof hostDriver !== 'function') {
61
- throw new GenesisError('HOST_CONTEXT_INVALID', 'The registered host driver must be a function.');
62
- }
63
- if (
64
- !hostDriverInput
65
- || typeof hostDriverInput !== 'object'
66
- || Array.isArray(hostDriverInput)
67
- || hostDriverInput.scope !== scope
68
- ) {
69
- throw new GenesisError(
70
- 'HOST_CONTEXT_INVALID',
71
- `The ${scope} context lane requires host input with scope \`${scope}\`.`,
72
- );
73
- }
74
- const value = await hostDriver(hostDriverInput);
75
- if (typeof value !== 'string') {
76
- throw new GenesisError('HOST_CONTEXT_INVALID', 'The registered host driver must return plain text.');
77
- }
78
- const output = normalizeSource(value).trim();
79
- if (Buffer.byteLength(output, 'utf8') > HOST_CONTEXT_MAX_BYTES[scope]) {
80
- throw new GenesisError(
81
- 'HOST_CONTEXT_INVALID',
82
- `The registered host driver's ${scope} contribution exceeds ${HOST_CONTEXT_MAX_BYTES[scope]} bytes.`,
83
- );
84
- }
85
- return output;
86
- }
87
-
88
- function composedContext(contract, output) {
89
- return {
90
- contract,
91
- status: 'ready',
92
- output,
93
- identity: sha256(stableJson({ contract, output })),
94
- };
95
- }
96
-
97
45
  async function optionalStackComponentIds(projectRoot, stack, stackPackages) {
98
46
  try {
99
47
  const packages = [...new Set([...(stack?.stackPackages || []), ...stackPackages])];
@@ -116,7 +64,8 @@ export async function projectSessionContext({
116
64
  optionalCollaboration(root),
117
65
  optionalEngineering(root),
118
66
  hostContextContribution({ hostDriver, hostDriverInput, scope: 'session' }),
119
- inspectProject({ projectRoot: root, stackPackages }),
67
+ // The opening state needs only the content kind, not a count of every ignored file.
68
+ inspectProjectAtRoot(root, { stackPackages, count: false }),
120
69
  ]);
121
70
  const availableComponents = await optionalStackComponentIds(root, stack, stackPackages);
122
71
  const skills = stack && inspection.projectFormat.status === 'current'
@@ -172,14 +121,4 @@ export async function projectSessionContext({
172
121
  return { ...composedContext(GENESIS_CONTRACTS.sessionContext, output), warnings: skills.diagnostics };
173
122
  }
174
123
 
175
- export async function projectTurnContext({ hostDriver, hostDriverInput } = {}) {
176
- const output = await hostContextContribution({
177
- hostDriver,
178
- hostDriverInput,
179
- scope: 'turn',
180
- });
181
- return {
182
- ...composedContext(GENESIS_CONTRACTS.turnContext, output),
183
- trust: 'untrusted',
184
- };
185
- }
124
+ export { projectTurnContext } from './host-context.js';
@@ -7,11 +7,39 @@ import {
7
7
  normalizeStackPieceId,
8
8
  parseStackPieceSource,
9
9
  } from './stack-piece.js';
10
+ import { sha256 } from './utils.js';
10
11
 
11
12
  const require = createRequire(import.meta.url);
12
13
  const PACKAGE_NAME = /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/u;
13
14
  const FIRST_PARTY_STACK_PACKAGE = 'genesis-stack';
14
15
 
16
+ // Parsed catalogue pieces by source text. One operation reads the catalogue several times (the Stack, then the code
17
+ // index, context and prompts each read it again), and every read parsed every piece. A parse depends only on the text,
18
+ // the expected id and the path its errors name, so each distinct piece is parsed once per process; the result is
19
+ // frozen so no reader can change what another reads. Every read still reads each file, so an edited piece is parsed
20
+ // again, and a piece that fails to parse is never kept.
21
+ const MAX_PARSED_PIECES = 512;
22
+ const parsedPieces = new Map();
23
+
24
+ function deepFreeze(value) {
25
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
26
+ Object.freeze(value);
27
+ for (const child of Object.values(value)) deepFreeze(child);
28
+ }
29
+ return value;
30
+ }
31
+
32
+ function parsedStackPiece(source, expectedId, piecePath) {
33
+ const key = `${sha256(source)}\0${expectedId}\0${piecePath}`;
34
+ let piece = parsedPieces.get(key);
35
+ if (!piece) {
36
+ piece = deepFreeze(parseStackPieceSource(source, { expectedId, path: piecePath }));
37
+ if (parsedPieces.size >= MAX_PARSED_PIECES) parsedPieces.delete(parsedPieces.keys().next().value);
38
+ parsedPieces.set(key, piece);
39
+ }
40
+ return piece;
41
+ }
42
+
15
43
  export function normalizeStackPackageName(value) {
16
44
  const name = String(value ?? '').trim();
17
45
  if (!PACKAGE_NAME.test(name)) {
@@ -94,7 +122,7 @@ async function readPieceDirectory(
94
122
  return Promise.all(files.map(async (file) => {
95
123
  const id = normalizeStackPieceId(file.slice(0, -3));
96
124
  const source = await readFile(path.join(directory, file), 'utf8');
97
- const piece = parseStackPieceSource(source, { expectedId: id, path: `${sourcePrefix}:${id}` });
125
+ const piece = parsedStackPiece(source, id, `${sourcePrefix}:${id}`);
98
126
  return {
99
127
  ...piece,
100
128
  skill: piece.skill === null ? null : { ...piece.skill, resolveFrom: stackPackageRoot },
@@ -1,8 +1,5 @@
1
- import { realpath, stat } from 'node:fs/promises';
2
- import path from 'node:path';
3
-
4
1
  import { GENESIS_CONTRACTS } from './contracts.js';
5
- import { gitContext } from './git.js';
2
+ import { inspectionRoot } from './git.js';
6
3
  import { readStack } from './stack.js';
7
4
  import { normalizeStackSectionName } from './stack-section.js';
8
5
  import { sha256, stableJson } from './utils.js';
@@ -29,18 +26,7 @@ export async function inspectProjectStackSections({
29
26
  if (new Set(sectionNames).size !== sectionNames.length) {
30
27
  throw new TypeError('Stack section inspection requires distinct names.');
31
28
  }
32
- let root;
33
- if (rootMode === 'repository') {
34
- root = (await gitContext(projectRoot)).repositoryRoot;
35
- } else if (rootMode === 'directory') {
36
- if (typeof projectRoot !== 'string' || !path.isAbsolute(projectRoot)) {
37
- throw new TypeError('Directory inspection requires an explicit absolute projectRoot.');
38
- }
39
- root = await realpath(projectRoot);
40
- if (!(await stat(root)).isDirectory()) throw new TypeError('Directory inspection requires a directory.');
41
- } else {
42
- throw new TypeError('Stack section rootMode must be repository or directory.');
43
- }
29
+ const root = await inspectionRoot({ projectRoot, rootMode, subject: 'Stack section' });
44
30
  const stack = await readStack(root, { stackPackages });
45
31
  return sectionNames.map((sectionName) => {
46
32
  const section = stack.extensions.find((candidate) => candidate.name === sectionName);
@@ -17,6 +17,29 @@ function validateRepository(repository) {
17
17
  }
18
18
  }
19
19
 
20
+ /** Read every listed blob through one `git cat-file --batch` process, in listing order. */
21
+ async function readBlobs(root, entries) {
22
+ if (entries.length === 0) return [];
23
+ const { stdout } = await runGit(root, ['cat-file', '--batch'], {
24
+ input: `${entries.map(({ object }) => object).join('\n')}\n`,
25
+ // Each blob is preceded by "<object> blob <size>\n" and followed by "\n".
26
+ maxBytes: entries.reduce((total, { size }) => total + size + 128, 0),
27
+ });
28
+ const blobs = [];
29
+ let offset = 0;
30
+ for (const { object, size } of entries) {
31
+ const headerEnd = stdout.indexOf(0x0a, offset);
32
+ const header = headerEnd === -1 ? '' : stdout.subarray(offset, headerEnd).toString('utf8');
33
+ const start = headerEnd + 1;
34
+ if (header !== `${object} blob ${size}` || stdout.length < start + size + 1) {
35
+ throw new GenesisError('TEMPLATE_SOURCE_INVALID', `Git did not return template blob ${object}.`);
36
+ }
37
+ blobs.push(stdout.subarray(start, start + size));
38
+ offset = start + size + 1;
39
+ }
40
+ return blobs;
41
+ }
42
+
20
43
  /**
21
44
  * Fetch a branch once and read ordinary blobs; never execute or check out remote hooks/filters.
22
45
  * The scratch repository is created below scratchRoot (the system temporary directory by default).
@@ -32,31 +55,43 @@ export async function readGitSnapshot({ repository, branch, scratchRoot = os.tmp
32
55
  await runGit(temporaryRoot, ['check-ref-format', `refs/heads/${branch}`]);
33
56
  await runGit(temporaryRoot, ['fetch', '--quiet', '--depth=1', '--no-tags', '--', repository, `refs/heads/${branch}`], { timeoutMs: 120_000 });
34
57
  const revision = await runGitText(temporaryRoot, ['rev-parse', 'FETCH_HEAD^{commit}']);
35
- const tree = await runGit(temporaryRoot, ['ls-tree', '-rz', '--full-tree', revision]);
36
- const files = [];
58
+ // The long listing carries each blob's size, so the limits hold before any content is read.
59
+ const tree = await runGit(temporaryRoot, ['ls-tree', '-rlz', '--full-tree', revision]);
60
+ const entries = [];
37
61
  let bytes = 0;
62
+ let fileCount = 0;
38
63
  for (const item of tree.stdout.toString('utf8').split('\0').filter(Boolean)) {
39
64
  const separator = item.indexOf('\t');
40
- const [mode, type, object] = item.slice(0, separator).split(' ');
65
+ const [mode, type, object, size] = item.slice(0, separator).split(/ +/u);
41
66
  const name = item.slice(separator + 1);
42
67
  if (!['100644', '100755', '120000'].includes(mode) || type !== 'blob' || path.isAbsolute(name)
43
68
  || name.includes('\\') || name.split('/').some((part) => !part || ['.', '..', '.git'].includes(part.toLowerCase()))) {
44
69
  throw new GenesisError('TEMPLATE_TREE_INVALID', `Template contains an unsupported path or file: ${name}.`);
45
70
  }
46
- const blob = await runGit(temporaryRoot, ['cat-file', 'blob', object]);
71
+ if (mode === '120000') {
72
+ // Only short Claude skill links are accepted; never read a large one.
73
+ if (Number(size) > 1024) throw new GenesisError('TEMPLATE_TREE_INVALID', `Template contains an unsupported link: ${name}.`);
74
+ } else {
75
+ bytes += Number(size);
76
+ fileCount += 1;
77
+ if (bytes > 64 * 1024 * 1024 || fileCount > 10_000) {
78
+ throw new GenesisError('TEMPLATE_TREE_TOO_LARGE', 'Template exceeds the source size limit.');
79
+ }
80
+ }
81
+ entries.push({ mode, object, name, size: Number(size) });
82
+ }
83
+ const blobs = await readBlobs(temporaryRoot, entries);
84
+ const files = [];
85
+ for (const [index, { mode, name }] of entries.entries()) {
47
86
  if (mode === '120000') {
48
87
  const target = claudeSkillLinkTarget(name);
49
- if (!target || !blob.stdout.equals(Buffer.from(target))) {
88
+ if (!target || !blobs[index].equals(Buffer.from(target))) {
50
89
  throw new GenesisError('TEMPLATE_TREE_INVALID', `Template contains an unsupported link: ${name}.`);
51
90
  }
52
91
  // Regenerate these through initialization from the canonical skill files.
53
92
  continue;
54
93
  }
55
- bytes += blob.stdout.length;
56
- if (bytes > 64 * 1024 * 1024 || files.length >= 10_000) {
57
- throw new GenesisError('TEMPLATE_TREE_TOO_LARGE', 'Template exceeds the source size limit.');
58
- }
59
- files.push({ path: name, contents: blob.stdout, mode: mode === '100755' ? 0o777 : 0o666 });
94
+ files.push({ path: name, contents: blobs[index], mode: mode === '100755' ? 0o777 : 0o666 });
60
95
  }
61
96
  return { repository, branch, revision, files };
62
97
  } finally { await rm(temporaryRoot, { recursive: true, force: true }); }