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.
- package/README.md +46 -16
- package/package.json +2 -2
- package/plugins/genesis/.codex-plugin/plugin.json +1 -1
- package/plugins/opencode/project-guidance.js +6 -5
- package/profiles/engineering/baseline.md +8 -2
- package/prompts/deslop.txt +3 -2
- package/prompts/review.txt +12 -5
- package/skills/genesis-deslop/SKILL.md +10 -2
- package/skills/genesis-project/SKILL.md +11 -8
- package/skills/genesis-project/references/sanity-checks.md +352 -0
- package/src/cli-commands.js +193 -0
- package/src/cli.js +18 -190
- package/src/index/agent-skills.js +26 -5
- package/src/index/environment-files.js +3 -2
- package/src/index/git.js +17 -1
- package/src/index/host-context.js +70 -0
- package/src/index/process.js +1 -0
- package/src/index/program.js +7 -2
- package/src/index/project-files.js +2 -2
- package/src/index/project-format.js +3 -4
- package/src/index/project-inspection.js +33 -11
- package/src/index/session-context.js +6 -67
- package/src/index/stack-catalog.js +29 -1
- package/src/index/stack-section-inspection.js +2 -16
- package/src/index/template-source.js +45 -10
|
@@ -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
|
-
|
|
54
|
+
async function listFiles(projectRoot, options) {
|
|
54
55
|
// Exclude dependency paths in Git before collecting and sorting its output.
|
|
55
|
-
const
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
68
|
+
listFiles(projectRoot, ['--cached', '--others', '--exclude-standard']),
|
|
69
|
+
listFiles(projectRoot, ['--deleted']),
|
|
70
|
+
kindOnly ? [] : listIgnored(),
|
|
60
71
|
]);
|
|
61
|
-
|
|
62
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
5
|
+
import { asDiagnostic } from './errors.js';
|
|
6
6
|
import { gitContext } from './git.js';
|
|
7
|
-
import {
|
|
7
|
+
import { inspectProjectAtRoot } from './project-inspection.js';
|
|
8
8
|
import { readStack } from './stack.js';
|
|
9
9
|
import { listStackCatalogPieces } from './stack-catalog.js';
|
|
10
|
-
import {
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
const
|
|
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
|
-
|
|
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 || !
|
|
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
|
-
|
|
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 }); }
|