genesis-compiler 1.3.3 → 1.4.1
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 +32 -13
- package/docs/templates.md +122 -0
- package/package.json +2 -1
- package/plugins/genesis/.codex-plugin/plugin.json +4 -2
- package/prompts/deslop.txt +10 -6
- package/prompts/start-existing-uninitialized.txt +14 -5
- package/prompts/start-new.txt +7 -0
- package/skills/genesis-deslop/SKILL.md +41 -20
- package/skills/genesis-deslop/agents/openai.yaml +2 -2
- package/skills/genesis-project/SKILL.md +5 -2
- package/src/cli.js +46 -5
- package/src/index/codex-hooks.js +4 -3
- package/src/index/collaboration.js +4 -0
- package/src/index/contracts.js +3 -0
- package/src/index/process.js +2 -0
- package/src/index/project-files.js +4 -9
- package/src/index/project-inspection.js +107 -0
- package/src/index/prompt.js +27 -1
- package/src/index/session-context.js +13 -2
- package/src/index/template-catalog.js +100 -0
- package/src/index/template-project.js +152 -0
- package/src/index/template-source.js +51 -0
- package/src/index.js +13 -2
package/src/index/codex-hooks.js
CHANGED
|
@@ -4,7 +4,8 @@ import path from 'node:path';
|
|
|
4
4
|
import { GenesisError } from './errors.js';
|
|
5
5
|
import { gitContext } from './git.js';
|
|
6
6
|
import { classifyProjectKind } from './project-files.js';
|
|
7
|
-
import {
|
|
7
|
+
import { writeFileAtomic } from './utils.js';
|
|
8
|
+
import { inspectProjectFormatAtRoot } from './project-format.js';
|
|
8
9
|
|
|
9
10
|
const HOOKS_PATH = '.codex/hooks.json';
|
|
10
11
|
const LEGACY_GENESIS_HOOKS_DESCRIPTION = 'Genesis project hooks.';
|
|
@@ -18,7 +19,7 @@ function hookCommand(action) {
|
|
|
18
19
|
const SESSION_HOOK = {
|
|
19
20
|
event: 'SessionStart',
|
|
20
21
|
group: {
|
|
21
|
-
matcher: '^(startup|clear|compact)$',
|
|
22
|
+
matcher: '^(startup|resume|clear|compact)$',
|
|
22
23
|
hooks: [{
|
|
23
24
|
type: 'command',
|
|
24
25
|
command: hookCommand('session'),
|
|
@@ -107,7 +108,7 @@ export async function installCodexHooks({ projectRoot } = {}) {
|
|
|
107
108
|
export async function codexAdoptionRecommendation({ projectRoot = process.cwd() } = {}) {
|
|
108
109
|
let root;
|
|
109
110
|
try { root = (await gitContext(projectRoot)).repositoryRoot; } catch { return { status: 'not-applicable', output: '' }; }
|
|
110
|
-
if ((await
|
|
111
|
+
if ((await inspectProjectFormatAtRoot(root)).status !== 'uninitialized') {
|
|
111
112
|
return { status: 'not-applicable', output: '' };
|
|
112
113
|
}
|
|
113
114
|
if (await classifyProjectKind({ projectRoot: root }) === 'new') {
|
|
@@ -198,6 +198,10 @@ function collaborationCatalog() {
|
|
|
198
198
|
|
|
199
199
|
function collaborationGuidance(value) {
|
|
200
200
|
return [
|
|
201
|
+
'Apply the configured tone, response length, assumed experience, and explanation style to every user-facing message, including progress updates and final responses.',
|
|
202
|
+
'Match technical depth and terminology to the configured experience level. Report concrete changes, findings, decisions, or blockers at that level of detail.',
|
|
203
|
+
'Do not announce that you are following project, UI, Stack, or Agent Skill guidance, or add unsolicited assurances about unrelated behavior staying unchanged. Explain process or scope when the user asks or it matters to a decision or blocker, using the configured style.',
|
|
204
|
+
'',
|
|
201
205
|
'## Tone',
|
|
202
206
|
'',
|
|
203
207
|
DIMENSIONS.tone[value.tone].guidance,
|
package/src/index/contracts.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export const GENESIS_CONTRACTS = Object.freeze({
|
|
2
|
+
projectInspection: 'genesis.project-inspection.v1',
|
|
3
|
+
templates: 'genesis.templates.v1',
|
|
4
|
+
templateApplication: 'genesis.template-application.v1',
|
|
2
5
|
collaboration: 'genesis.collaboration.v1',
|
|
3
6
|
derivedArtifacts: 'genesis.derived-artifacts.v1',
|
|
4
7
|
engineering: 'genesis.engineering.v1',
|
package/src/index/process.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
|
|
3
|
-
import { isProjectContentPath, OPENCODE_PLUGIN_PATH } from './paths.js';
|
|
4
3
|
import { runGit } from './process.js';
|
|
5
4
|
import { pathState } from './utils.js';
|
|
5
|
+
import { inspectProjectContent } from './project-inspection.js';
|
|
6
6
|
|
|
7
7
|
async function visiblePaths(projectRoot) {
|
|
8
8
|
const [visible, deleted] = await Promise.all([
|
|
@@ -25,12 +25,7 @@ export async function gitVisibleFileStates(projectRoot, { includePath = () => tr
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/** Classifies opening behavior from cheap Git-visible paths and selected Stack state. */
|
|
28
|
-
export async function classifyProjectKind({ projectRoot
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
isProjectContentPath(file)
|
|
32
|
-
&& file !== OPENCODE_PLUGIN_PATH
|
|
33
|
-
&& !file.split('/').includes('node_modules')
|
|
34
|
-
));
|
|
35
|
-
return existing ? 'existing' : 'new';
|
|
28
|
+
export async function classifyProjectKind({ projectRoot } = {}) {
|
|
29
|
+
const content = await inspectProjectContent(projectRoot);
|
|
30
|
+
return content.kind === 'existing' ? 'existing' : 'new';
|
|
36
31
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { readBlueprint } from './blueprint.js';
|
|
5
|
+
import { readCollaboration } from './collaboration.js';
|
|
6
|
+
import { GENESIS_CONTRACTS } from './contracts.js';
|
|
7
|
+
import { readEngineering } from './engineering.js';
|
|
8
|
+
import { asDiagnostic } from './errors.js';
|
|
9
|
+
import { gitContext } from './git.js';
|
|
10
|
+
import { inspectProjectFormatAtRoot, projectFormatDiagnostic } from './project-format.js';
|
|
11
|
+
import { inspectProgram } from './program.js';
|
|
12
|
+
import { runGit } from './process.js';
|
|
13
|
+
import { readStack } from './stack.js';
|
|
14
|
+
|
|
15
|
+
// These are initialization outputs, not whole directories that may contain user code.
|
|
16
|
+
export const GENESIS_BOOTSTRAP_PATHS = Object.freeze([
|
|
17
|
+
'genesis/version',
|
|
18
|
+
'genesis/blueprint.md',
|
|
19
|
+
'genesis/stack.md',
|
|
20
|
+
'genesis/collaboration.md',
|
|
21
|
+
'genesis/engineering.md',
|
|
22
|
+
'.genesis/machine-city.json',
|
|
23
|
+
'.genesis/program-city.json',
|
|
24
|
+
'.codex/hooks.json',
|
|
25
|
+
'.opencode/plugins/genesis-project-guidance.js',
|
|
26
|
+
'.agents/skills/.genesis-managed.json',
|
|
27
|
+
...['genesis-project', 'genesis-program', 'genesis-deslop'].flatMap((name) => [
|
|
28
|
+
`.agents/skills/${name}/SKILL.md`,
|
|
29
|
+
`.agents/skills/${name}/agents/openai.yaml`,
|
|
30
|
+
]),
|
|
31
|
+
]);
|
|
32
|
+
const bootstrapPaths = new Set(GENESIS_BOOTSTRAP_PATHS);
|
|
33
|
+
|
|
34
|
+
export async function inspectProjectContent(projectRoot) {
|
|
35
|
+
const [visible, deleted, ignored] = await Promise.all([
|
|
36
|
+
runGit(projectRoot, ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', '.']),
|
|
37
|
+
runGit(projectRoot, ['ls-files', '-z', '--deleted', '--', '.']),
|
|
38
|
+
runGit(projectRoot, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--', '.']),
|
|
39
|
+
]);
|
|
40
|
+
const absent = new Set(deleted.stdout.toString('utf8').split('\0').filter(Boolean));
|
|
41
|
+
const paths = [...new Set([visible, ignored].flatMap(({ stdout }) => stdout.toString('utf8').split('\0').filter(Boolean)))]
|
|
42
|
+
.filter((file) => !absent.has(file) && file !== '.git' && !file.startsWith('.git/'))
|
|
43
|
+
.sort();
|
|
44
|
+
const existingPaths = paths.filter((file) => !bootstrapPaths.has(file) && !file.split('/').includes('node_modules'));
|
|
45
|
+
return {
|
|
46
|
+
kind: existingPaths.length ? 'existing' : paths.length ? 'bootstrap' : 'empty',
|
|
47
|
+
existingPaths,
|
|
48
|
+
paths,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Read-only opening inspection: no index generation, verification, or application execution. */
|
|
53
|
+
export async function inspectProject({ projectRoot, stackPackages = [] } = {}) {
|
|
54
|
+
const root = (await gitContext(projectRoot)).repositoryRoot;
|
|
55
|
+
const [content, format] = await Promise.all([
|
|
56
|
+
inspectProjectContent(root),
|
|
57
|
+
inspectProjectFormatAtRoot(root),
|
|
58
|
+
]);
|
|
59
|
+
const result = {
|
|
60
|
+
contract: GENESIS_CONTRACTS.projectInspection,
|
|
61
|
+
state: 'attention',
|
|
62
|
+
content: { kind: content.kind, count: content.existingPaths.length },
|
|
63
|
+
projectFormat: format,
|
|
64
|
+
templateEligible: false,
|
|
65
|
+
stackComponents: [],
|
|
66
|
+
diagnostics: [],
|
|
67
|
+
nextAction: 'repair',
|
|
68
|
+
};
|
|
69
|
+
if (!['uninitialized', 'current'].includes(format.status)) {
|
|
70
|
+
result.nextAction = format.action;
|
|
71
|
+
result.diagnostics = [projectFormatDiagnostic(format)];
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
if (format.status === 'uninitialized') {
|
|
75
|
+
return { ...result,
|
|
76
|
+
state: content.kind === 'existing' ? 'adoption' : 'new',
|
|
77
|
+
templateEligible: content.kind !== 'existing',
|
|
78
|
+
nextAction: content.kind === 'existing' ? 'adopt' : 'init',
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let blueprintEmpty = false;
|
|
83
|
+
try {
|
|
84
|
+
const blueprint = await readBlueprint(root, { required: true });
|
|
85
|
+
blueprintEmpty = !blueprint.description;
|
|
86
|
+
} catch (error) { result.diagnostics.push(asDiagnostic(error)); }
|
|
87
|
+
let stack;
|
|
88
|
+
try {
|
|
89
|
+
// Missing Stack must not be hidden by readStack's optional default.
|
|
90
|
+
await readFile(path.join(root, 'genesis/stack.md'), 'utf8');
|
|
91
|
+
stack = await readStack(root, { stackPackages });
|
|
92
|
+
result.stackComponents = stack.components.map(({ id }) => id);
|
|
93
|
+
} catch (error) { result.diagnostics.push(asDiagnostic(error)); }
|
|
94
|
+
for (const read of [readCollaboration, readEngineering]) {
|
|
95
|
+
try { await read(root); } catch (error) { result.diagnostics.push(asDiagnostic(error)); }
|
|
96
|
+
}
|
|
97
|
+
try { await inspectProgram(root); } catch (error) { result.diagnostics.push(asDiagnostic(error)); }
|
|
98
|
+
if (result.diagnostics.length) return result;
|
|
99
|
+
if (content.kind !== 'existing') {
|
|
100
|
+
return { ...result, state: 'new', templateEligible: true, nextAction: 'create' };
|
|
101
|
+
}
|
|
102
|
+
const stackDescribed = stack.components.length > 0 || stack.projectContracts.some(({ lines }) => lines.some((line) => line.trim()));
|
|
103
|
+
if (blueprintEmpty || !stackDescribed) {
|
|
104
|
+
return { ...result, state: 'adoption', nextAction: 'adopt' };
|
|
105
|
+
}
|
|
106
|
+
return { ...result, state: 'ready', nextAction: 'work' };
|
|
107
|
+
}
|
package/src/index/prompt.js
CHANGED
|
@@ -20,13 +20,14 @@ import { withStackEnvironmentDefaults } from './stack-environment-defaults.js';
|
|
|
20
20
|
import { stableJson } from './utils.js';
|
|
21
21
|
import { classifyProjectKind } from './project-files.js';
|
|
22
22
|
import { inspectProjectFormatAtRoot } from './project-format.js';
|
|
23
|
+
import { inspectProject } from './project-inspection.js';
|
|
23
24
|
|
|
24
25
|
const TASKS = new Set(['start', 'adopt', 'work', 'deslop', 'program', 'blueprint', 'describe', 'review']);
|
|
25
26
|
const DEFAULT_REQUEST = {
|
|
26
27
|
start: 'Start a conversation about this project.',
|
|
27
28
|
adopt: 'Import the existing project into a truthful Genesis project contract.',
|
|
28
29
|
work: 'Implement the product intent expressed by the current Blueprint.',
|
|
29
|
-
deslop: 'Deslop the
|
|
30
|
+
deslop: 'Deslop your own changes for the current task.',
|
|
30
31
|
program: 'Refresh the complete useful Program for the code that exists now.',
|
|
31
32
|
describe: 'Create or refresh the complete Blueprint and useful Program for the codebase that exists now.',
|
|
32
33
|
review: 'Review the complete useful relationship between Blueprint, code, Program, and tests.',
|
|
@@ -284,6 +285,7 @@ async function generateExplanationPrompt({ instructions, program, request, root,
|
|
|
284
285
|
}
|
|
285
286
|
|
|
286
287
|
async function generateStartPrompt({
|
|
288
|
+
inspection,
|
|
287
289
|
hiddenStackPieces,
|
|
288
290
|
instructions,
|
|
289
291
|
program,
|
|
@@ -292,6 +294,20 @@ async function generateStartPrompt({
|
|
|
292
294
|
sessionPrompt,
|
|
293
295
|
stackPackages,
|
|
294
296
|
}) {
|
|
297
|
+
inspection ??= await inspectProject({ projectRoot: root, stackPackages });
|
|
298
|
+
if (inspection.state === 'adoption') {
|
|
299
|
+
return {
|
|
300
|
+
status: 'ready', task: 'start',
|
|
301
|
+
prompt: renderPrompt({
|
|
302
|
+
instructions: await startInstructions(instructions, 'existing-uninitialized'), request,
|
|
303
|
+
context: { task: 'start', projectRoot: root, projectKind: 'existing-uninitialized', inspection,
|
|
304
|
+
genesis: { initialized: inspection.projectFormat.status === 'current' }, ...sessionPrompt.context },
|
|
305
|
+
collaborationGuidance: sessionPrompt.collaborationGuidance,
|
|
306
|
+
engineeringGuidance: sessionPrompt.engineeringGuidance,
|
|
307
|
+
}),
|
|
308
|
+
warnings: inspection.diagnostics, verificationCommands: [],
|
|
309
|
+
};
|
|
310
|
+
}
|
|
295
311
|
let blueprint;
|
|
296
312
|
try {
|
|
297
313
|
blueprint = await readBlueprint(root, { required: true });
|
|
@@ -384,6 +400,15 @@ export async function generateProjectPrompt({
|
|
|
384
400
|
}
|
|
385
401
|
const root = (await gitContext(projectRoot)).repositoryRoot;
|
|
386
402
|
const userRequest = requestText(request, task);
|
|
403
|
+
const openingInspection = task === 'start' ? await inspectProject({ projectRoot: root, stackPackages }) : null;
|
|
404
|
+
if (openingInspection) {
|
|
405
|
+
const inspection = openingInspection;
|
|
406
|
+
if (inspection.state === 'attention') {
|
|
407
|
+
return { status: 'ready', task: 'start',
|
|
408
|
+
prompt: `This existing Genesis project needs attention. Preserve source and history; never offer a seed. Explain the reported issue and the exact next action. Do not run application verification on startup.\n\n${JSON.stringify(inspection, null, 2)}\n\nUSER REQUEST\n${userRequest}\n`,
|
|
409
|
+
warnings: inspection.diagnostics, verificationCommands: [] };
|
|
410
|
+
}
|
|
411
|
+
}
|
|
387
412
|
const [instructions, collaboration, engineering] = await Promise.all([
|
|
388
413
|
readInstalledAsset(task),
|
|
389
414
|
collaborationForPrompt(root),
|
|
@@ -426,6 +451,7 @@ export async function generateProjectPrompt({
|
|
|
426
451
|
const program = await observeProgram(root);
|
|
427
452
|
if (task === 'start') {
|
|
428
453
|
return generateStartPrompt({
|
|
454
|
+
inspection: openingInspection,
|
|
429
455
|
instructions,
|
|
430
456
|
hiddenStackPieces,
|
|
431
457
|
program,
|
|
@@ -3,6 +3,7 @@ import { GENESIS_CONTRACTS } from './contracts.js';
|
|
|
3
3
|
import { readEngineering, readEngineeringBaseline } from './engineering.js';
|
|
4
4
|
import { GenesisError } from './errors.js';
|
|
5
5
|
import { gitContext } from './git.js';
|
|
6
|
+
import { inspectProject } from './project-inspection.js';
|
|
6
7
|
import { readStack } from './stack.js';
|
|
7
8
|
import { listStackCatalogPieces } from './stack-catalog.js';
|
|
8
9
|
import { normalizeSource, sha256, stableJson } from './utils.js';
|
|
@@ -109,11 +110,12 @@ export async function projectSessionContext({
|
|
|
109
110
|
stackPackages = [],
|
|
110
111
|
} = {}) {
|
|
111
112
|
const root = (await gitContext(projectRoot)).repositoryRoot;
|
|
112
|
-
const [stack, collaboration, engineering, hostContext] = await Promise.all([
|
|
113
|
+
const [stack, collaboration, engineering, hostContext, inspection] = await Promise.all([
|
|
113
114
|
optionalStack(root, stackPackages),
|
|
114
115
|
optionalCollaboration(root),
|
|
115
116
|
optionalEngineering(root),
|
|
116
117
|
hostContextContribution({ hostDriver, hostDriverInput, scope: 'session' }),
|
|
118
|
+
inspectProject({ projectRoot: root, stackPackages }),
|
|
117
119
|
]);
|
|
118
120
|
const availableComponents = await optionalStackComponentIds(root, stack, stackPackages);
|
|
119
121
|
const selected = stack?.components.map(({ id }) => id) || [];
|
|
@@ -122,6 +124,15 @@ export async function projectSessionContext({
|
|
|
122
124
|
: 'unavailable; run the Genesis `check` operation';
|
|
123
125
|
const output = [
|
|
124
126
|
'This is a Genesis-enriched project.',
|
|
127
|
+
`Project opening state: ${inspection.state}.`,
|
|
128
|
+
...(inspection.state === 'new' ? [
|
|
129
|
+
'- This directory contains only bootstrap files. Offer a ready-made template (`genesis templates list`) or creation through conversation. Apply one explicitly selected template to this empty project; a selected Stack alone does not mean source exists.',
|
|
130
|
+
] : inspection.state === 'adoption' ? [
|
|
131
|
+
'- Existing project content needs description and configuration. Ask what this project does and what the user wants to run, using answers already supplied. Inspect its implementation to work backwards into Blueprint, Stack and Program. Do not offer seeds or replace existing source/history. Run `genesis adopt` and follow its prompt; initialized Genesis files already express the user’s decision to use Genesis.',
|
|
132
|
+
] : inspection.state === 'attention' ? [
|
|
133
|
+
`- Genesis needs attention: ${inspection.diagnostics.map(({ message }) => message).join(' ')} Next action: ${inspection.nextAction}. Preserve source and explain the specific issue before the relevant migration or repair. Do not offer seeds.`,
|
|
134
|
+
] : []),
|
|
135
|
+
'- Opening or resuming a session only reads project metadata and content paths. Do not run verification, tests, builds, dependency installation, or database preparation just because a session started. Run relevant checks when the requested work calls for them.',
|
|
125
136
|
'- Read `genesis/blueprint.md`, `genesis/collaboration.md`, `genesis/engineering.md`, and `genesis/stack.md` for product intent, collaboration approach, engineering approach, and selected technology.',
|
|
126
137
|
'- For a new project whose Blueprint does not yet establish product direction, do not research technology or create source until the user has made clear what is being built, who or what will use or invoke it, and the first observable useful outcome. Ask only unresolved high-impact questions. A reply confirms only what it explicitly answers; Stack confirmation is not product intent.',
|
|
127
138
|
'- Once product direction is clear, choose one smallest implementation path through selected technology guidance, or authoritative technology documentation when the catalog has no match, and read only what that path requires. Do not survey alternatives, clone whole technology repositories, inspect unrelated package internals, or delegate research unless one concrete failure requires one exact investigation.',
|
|
@@ -133,7 +144,7 @@ export async function projectSessionContext({
|
|
|
133
144
|
'- Program is fallible explanation; code, tests, and runtime behavior remain evidence.',
|
|
134
145
|
'- Keep Blueprint and affected Program explanations aligned with intentional observable product behavior in the same implementation turn. Private restructuring may need only source citations or no explanatory change.',
|
|
135
146
|
'- Before reporting completion, compare the requested observable behavior, required inputs and resources, declared project operations, and focused evidence with what actually exists. State anything not proven.',
|
|
136
|
-
'- Deslop only when explicitly requested.
|
|
147
|
+
'- Deslop only when explicitly requested. By default, clean up your own changes for the current task, committed or uncommitted; explicit commits or ranges take precedence. Preserve behavior, unrelated work, and existing staging; a dirty worktree is allowed. Selected Stack components may add technology-specific cleanup guidance.',
|
|
137
148
|
'- This guidance is loaded for a new session and refreshed after compaction. Continue the active request without restarting completed work.',
|
|
138
149
|
`Engineering profile: ${engineering.profile?.id || 'invalid; run the Genesis `check` operation'}.`,
|
|
139
150
|
`Selected Stack components: ${stackStatus}.`,
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { GenesisError } from './errors.js';
|
|
6
|
+
import { GENESIS_CONTRACTS } from './contracts.js';
|
|
7
|
+
import { readGitSnapshot } from './template-source.js';
|
|
8
|
+
import { readStack } from './stack.js';
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const NAME = /^[a-z][a-z0-9-]*$/u;
|
|
12
|
+
const ID = /^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/u;
|
|
13
|
+
|
|
14
|
+
function templateCatalogError(message) {
|
|
15
|
+
return new GenesisError('TEMPLATE_CATALOG_INVALID', message);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseTemplateCatalog(value, namespace) {
|
|
19
|
+
if (!NAME.test(namespace) || value?.schemaVersion !== 1 || !Array.isArray(value.templates)) {
|
|
20
|
+
throw templateCatalogError('A template catalogue requires a namespace, schemaVersion 1, and templates.');
|
|
21
|
+
}
|
|
22
|
+
const ids = new Set();
|
|
23
|
+
return value.templates.map((entry) => {
|
|
24
|
+
if (!ID.test(entry?.id) || ids.has(entry.id)
|
|
25
|
+
|| typeof entry.name !== 'string' || !entry.name.trim()
|
|
26
|
+
|| typeof entry.repository !== 'string' || !entry.repository
|
|
27
|
+
|| typeof entry.branch !== 'string' || !entry.branch
|
|
28
|
+
|| entry.technology !== entry.id.split('/')[0]) {
|
|
29
|
+
throw templateCatalogError(`Invalid or duplicate template in ${namespace}: ${entry?.id || '(missing id)'}.`);
|
|
30
|
+
}
|
|
31
|
+
ids.add(entry.id);
|
|
32
|
+
return {
|
|
33
|
+
id: `${namespace}:${entry.id}`,
|
|
34
|
+
namespace,
|
|
35
|
+
technology: entry.technology,
|
|
36
|
+
variant: entry.id.split('/')[1],
|
|
37
|
+
name: entry.name.trim(),
|
|
38
|
+
description: typeof entry.description === 'string' ? entry.description.trim() : '',
|
|
39
|
+
repository: entry.repository,
|
|
40
|
+
branch: entry.branch,
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function installedCatalogs(stackPackages, projectRoot) {
|
|
46
|
+
const sources = [];
|
|
47
|
+
for (const packageName of stackPackages) {
|
|
48
|
+
let manifestPath;
|
|
49
|
+
try {
|
|
50
|
+
manifestPath = require.resolve(`${packageName}/package.json`, { paths: [projectRoot, ...require.resolve.paths(packageName)] });
|
|
51
|
+
} catch { throw templateCatalogError(`Configured Stack package is not installed: ${packageName}.`); }
|
|
52
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
53
|
+
const declaration = manifest.genesis?.templates;
|
|
54
|
+
if (!declaration) continue;
|
|
55
|
+
if (!NAME.test(declaration.namespace) || declaration.path !== 'genesis.templates.json') {
|
|
56
|
+
throw templateCatalogError(`Invalid template declaration in ${packageName}.`);
|
|
57
|
+
}
|
|
58
|
+
sources.push({
|
|
59
|
+
namespace: declaration.namespace,
|
|
60
|
+
catalog: JSON.parse(await readFile(path.join(path.dirname(manifestPath), declaration.path), 'utf8')),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return sources;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Catalogues are explicit data sources. A Stack or Skill never implicitly installs a template. */
|
|
67
|
+
export async function listTemplates({ projectRoot = process.cwd(), stackPackages = [], templateSources = [] } = {}) {
|
|
68
|
+
let recorded = [];
|
|
69
|
+
try { recorded = (await readStack(projectRoot, { stackPackages })).stackPackages; } catch { /* Explicit sources also work before init. */ }
|
|
70
|
+
stackPackages = [...new Set([...recorded, ...stackPackages])];
|
|
71
|
+
const sources = [...await installedCatalogs(stackPackages, projectRoot), ...templateSources];
|
|
72
|
+
const namespaces = new Set();
|
|
73
|
+
const templates = [];
|
|
74
|
+
for (const source of sources) {
|
|
75
|
+
if (!NAME.test(source.namespace) || namespaces.has(source.namespace)) {
|
|
76
|
+
throw templateCatalogError(`Duplicate or invalid template source namespace: ${source.namespace}.`);
|
|
77
|
+
}
|
|
78
|
+
namespaces.add(source.namespace);
|
|
79
|
+
let catalog = source.catalog;
|
|
80
|
+
if (!catalog) {
|
|
81
|
+
const snapshot = await readGitSnapshot({ repository: source.repository, branch: source.branch || 'main' });
|
|
82
|
+
const file = snapshot.files.find(({ path: name }) => name === 'genesis.templates.json');
|
|
83
|
+
if (!file) throw templateCatalogError(`${source.namespace} does not contain genesis.templates.json.`);
|
|
84
|
+
try { catalog = JSON.parse(file.contents.toString('utf8')); }
|
|
85
|
+
catch { throw templateCatalogError(`${source.namespace}/genesis.templates.json is not valid JSON.`); }
|
|
86
|
+
}
|
|
87
|
+
templates.push(...parseTemplateCatalog(catalog, source.namespace));
|
|
88
|
+
}
|
|
89
|
+
return { contract: GENESIS_CONTRACTS.templates, templates };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function resolveTemplate(templates, id) {
|
|
93
|
+
const matches = templates.filter((entry) => entry.id === id || (!id.includes(':') && entry.id.split(':')[1] === id));
|
|
94
|
+
if (matches.length !== 1) {
|
|
95
|
+
throw new GenesisError(matches.length ? 'TEMPLATE_AMBIGUOUS' : 'TEMPLATE_NOT_FOUND',
|
|
96
|
+
matches.length ? `Choose a catalogue-qualified template: ${matches.map(({ id: name }) => name).join(', ')}.`
|
|
97
|
+
: `No configured template matches ${id}.`, { matches: matches.map(({ id: name }) => name) });
|
|
98
|
+
}
|
|
99
|
+
return matches[0];
|
|
100
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { lstat, mkdir, mkdtemp, readFile, readdir, rm, rmdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { parseBlueprintSource } from './blueprint.js';
|
|
5
|
+
import { buildProjectIndex } from './code-index.js';
|
|
6
|
+
import { GenesisError } from './errors.js';
|
|
7
|
+
import { GENESIS_CONTRACTS } from './contracts.js';
|
|
8
|
+
import { gitContext } from './git.js';
|
|
9
|
+
import { initializeProject } from './init.js';
|
|
10
|
+
import { GENESIS_BOOTSTRAP_PATHS, inspectProject } from './project-inspection.js';
|
|
11
|
+
import { runGit, runGitText } from './process.js';
|
|
12
|
+
import { listTemplates, resolveTemplate } from './template-catalog.js';
|
|
13
|
+
import { readGitSnapshot } from './template-source.js';
|
|
14
|
+
import { readStack } from './stack.js';
|
|
15
|
+
import { writeFileAtomic } from './utils.js';
|
|
16
|
+
|
|
17
|
+
function sections(source) {
|
|
18
|
+
const result = new Map();
|
|
19
|
+
let name = '';
|
|
20
|
+
let fence = false;
|
|
21
|
+
for (const line of source.split('\n')) {
|
|
22
|
+
if (/^```/u.test(line)) fence = !fence;
|
|
23
|
+
const heading = !fence && line.match(/^## (.+)$/u);
|
|
24
|
+
if (heading) { name = heading[1]; result.set(name, []); }
|
|
25
|
+
else if (name) result.get(name).push(line);
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Existing authored sections remain whole. The template contributes only missing sections.
|
|
31
|
+
function mergeTemplateStack(templateSource, existingSource) {
|
|
32
|
+
const template = sections(templateSource);
|
|
33
|
+
const existing = sections(existingSource);
|
|
34
|
+
for (const [name, lines] of existing) {
|
|
35
|
+
if (['Components', 'Stack packages'].includes(name)) {
|
|
36
|
+
template.set(name, [...new Set([...(template.get(name) || []), ...lines].map((line) => line.trim()).filter(Boolean))]);
|
|
37
|
+
} else template.set(name, lines);
|
|
38
|
+
}
|
|
39
|
+
return `# Stack\n\n${[...template].map(([name, lines]) => `## ${name}\n\n${lines.join('\n').trim()}`).join('\n\n')}\n`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function existingFile(root, name) {
|
|
43
|
+
const location = path.join(root, name);
|
|
44
|
+
try {
|
|
45
|
+
const state = await lstat(location);
|
|
46
|
+
if (!state.isFile()) throw new GenesisError('TEMPLATE_DESTINATION_CONFLICT', `Template destination is not an ordinary file: ${name}.`);
|
|
47
|
+
return await readFile(location);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error.code === 'ENOENT') return null;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function filesBelow(root, relative = '') {
|
|
55
|
+
const files = [];
|
|
56
|
+
for (const entry of await readdir(path.join(root, relative), { withFileTypes: true })) {
|
|
57
|
+
if (!relative && entry.name === '.git') continue;
|
|
58
|
+
const name = relative ? `${relative}/${entry.name}` : entry.name;
|
|
59
|
+
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 });
|
|
61
|
+
else throw new GenesisError('TEMPLATE_TREE_INVALID', `Template contains a non-ordinary file: ${name}.`);
|
|
62
|
+
}
|
|
63
|
+
return files;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function assertSeedable(root, stackPackages) {
|
|
67
|
+
const inspection = await inspectProject({ projectRoot: root, stackPackages });
|
|
68
|
+
if (!inspection.templateEligible) {
|
|
69
|
+
throw new GenesisError('TEMPLATE_PROJECT_NOT_EMPTY', 'This project contains existing content or Genesis needs attention. Its source was preserved.', { inspection });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Apply one complete source tree without changing Git history or running application commands. */
|
|
74
|
+
export async function applyTemplate({ projectRoot, templateId, stackPackages = [], templateSources = [] } = {}) {
|
|
75
|
+
const root = (await gitContext(projectRoot)).repositoryRoot;
|
|
76
|
+
const lockPath = path.resolve(root, await runGitText(root, ['rev-parse', '--git-path', 'genesis-template.lock']));
|
|
77
|
+
try { await mkdir(lockPath); }
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error.code === 'EEXIST') throw new GenesisError('TEMPLATE_PROJECT_BUSY', 'Another template operation holds this project lock.');
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
let stagingRoot;
|
|
83
|
+
try {
|
|
84
|
+
await assertSeedable(root, stackPackages);
|
|
85
|
+
const { templates } = await listTemplates({ projectRoot: root, stackPackages, templateSources });
|
|
86
|
+
const template = resolveTemplate(templates, String(templateId || ''));
|
|
87
|
+
const preserved = new Map();
|
|
88
|
+
for (const name of GENESIS_BOOTSTRAP_PATHS) preserved.set(name, await existingFile(root, name));
|
|
89
|
+
const snapshot = await readGitSnapshot(template);
|
|
90
|
+
stagingRoot = await mkdtemp(path.join(os.tmpdir(), 'genesis-template-project-'));
|
|
91
|
+
await runGit(stagingRoot, ['init', '--quiet']);
|
|
92
|
+
for (const file of snapshot.files) {
|
|
93
|
+
if (file.path.split('/').includes('node_modules') || file.path.split('/').some((part) => part === '.env' || part.startsWith('.env.') && part !== '.env.example')) {
|
|
94
|
+
throw new GenesisError('TEMPLATE_TREE_INVALID', `Template contains private environment or installed dependencies: ${file.path}.`);
|
|
95
|
+
}
|
|
96
|
+
await mkdir(path.dirname(path.join(stagingRoot, file.path)), { recursive: true });
|
|
97
|
+
await writeFile(path.join(stagingRoot, file.path), file.contents, { flag: 'wx', mode: file.mode });
|
|
98
|
+
}
|
|
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.');
|
|
101
|
+
const seedStack = await readStack(stagingRoot, { stackPackages });
|
|
102
|
+
if (!seedStack.components.some(({ id }) => id === template.technology)) {
|
|
103
|
+
throw new GenesisError('TEMPLATE_STACK_MISMATCH', 'The template Stack does not contain its advertised technology.');
|
|
104
|
+
}
|
|
105
|
+
for (const [name, contents] of preserved) {
|
|
106
|
+
if (contents === null || name.startsWith('.genesis/')) continue;
|
|
107
|
+
if (name === 'genesis/blueprint.md' && !parseBlueprintSource(contents.toString('utf8')).description) continue;
|
|
108
|
+
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);
|
|
112
|
+
}
|
|
113
|
+
await initializeProject({ projectRoot: stagingRoot, stackPackages });
|
|
114
|
+
await buildProjectIndex({ projectRoot: stagingRoot, stackPackages });
|
|
115
|
+
const prepared = await inspectProject({ projectRoot: stagingRoot, stackPackages });
|
|
116
|
+
if (prepared.state !== 'ready') throw new GenesisError('TEMPLATE_PROJECT_INVALID', 'The prepared template does not contain a usable Genesis project.', { inspection: prepared });
|
|
117
|
+
await assertSeedable(root, stackPackages);
|
|
118
|
+
for (const [name, contents] of preserved) {
|
|
119
|
+
const current = await existingFile(root, name);
|
|
120
|
+
if ((contents === null) !== (current === null) || contents && !contents.equals(current)) {
|
|
121
|
+
throw new GenesisError('TEMPLATE_DESTINATION_CHANGED', 'Project context changed while the template was being prepared. Nothing was applied.');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const files = await filesBelow(stagingRoot);
|
|
125
|
+
const applied = [];
|
|
126
|
+
try {
|
|
127
|
+
for (const file of files) {
|
|
128
|
+
const previous = await existingFile(root, file.path);
|
|
129
|
+
if (previous && previous.equals(file.contents)) continue;
|
|
130
|
+
if (previous && !preserved.has(file.path)) throw new GenesisError('TEMPLATE_DESTINATION_CHANGED', `Existing project content was preserved: ${file.path}.`);
|
|
131
|
+
await mkdir(path.dirname(path.join(root, file.path)), { recursive: true });
|
|
132
|
+
if (previous) await writeFileAtomic(path.join(root, file.path), file.contents);
|
|
133
|
+
else await writeFile(path.join(root, file.path), file.contents, { flag: 'wx', mode: file.mode });
|
|
134
|
+
applied.push({ path: file.path, previous });
|
|
135
|
+
}
|
|
136
|
+
} catch (error) {
|
|
137
|
+
for (const file of applied.reverse()) {
|
|
138
|
+
if (file.previous) await writeFileAtomic(path.join(root, file.path), file.previous);
|
|
139
|
+
else await rm(path.join(root, file.path));
|
|
140
|
+
}
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
return { contract: GENESIS_CONTRACTS.templateApplication, status: 'applied', template,
|
|
144
|
+
source: { repository: snapshot.repository, branch: snapshot.branch, revision: snapshot.revision },
|
|
145
|
+
changedFiles: applied.map(({ path: name }) => name).sort(),
|
|
146
|
+
guidance: 'The starting application is ready for its declared workspace setup. No application commands or Git commits were run.',
|
|
147
|
+
};
|
|
148
|
+
} finally {
|
|
149
|
+
if (stagingRoot) await rm(stagingRoot, { recursive: true, force: true });
|
|
150
|
+
await rmdir(lockPath);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { GenesisError } from './errors.js';
|
|
5
|
+
import { runGit, runGitText } from './process.js';
|
|
6
|
+
|
|
7
|
+
function validateRepository(repository) {
|
|
8
|
+
if (typeof repository !== 'string' || !repository || repository.startsWith('-')) {
|
|
9
|
+
throw new GenesisError('TEMPLATE_SOURCE_INVALID', 'A template repository is required.');
|
|
10
|
+
}
|
|
11
|
+
if (path.isAbsolute(repository)) return;
|
|
12
|
+
let url;
|
|
13
|
+
try { url = new URL(repository); } catch { /* diagnosed below */ }
|
|
14
|
+
if (!url || url.protocol !== 'https:' || url.username || url.password || url.hash || url.search) {
|
|
15
|
+
throw new GenesisError('TEMPLATE_SOURCE_INVALID', 'Template sources must be HTTPS repositories or explicit local absolute paths.');
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Fetch a branch once and read ordinary blobs; never execute or check out remote hooks/filters. */
|
|
20
|
+
export async function readGitSnapshot({ repository, branch }) {
|
|
21
|
+
validateRepository(repository);
|
|
22
|
+
if (typeof branch !== 'string' || !branch || branch.startsWith('-')) {
|
|
23
|
+
throw new GenesisError('TEMPLATE_SOURCE_INVALID', 'A template branch is required.');
|
|
24
|
+
}
|
|
25
|
+
const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), 'genesis-template-source-'));
|
|
26
|
+
try {
|
|
27
|
+
await runGit(temporaryRoot, ['init', '--bare', '--quiet']);
|
|
28
|
+
await runGit(temporaryRoot, ['check-ref-format', `refs/heads/${branch}`]);
|
|
29
|
+
await runGit(temporaryRoot, ['fetch', '--quiet', '--depth=1', '--no-tags', '--', repository, `refs/heads/${branch}`], { timeoutMs: 120_000 });
|
|
30
|
+
const revision = await runGitText(temporaryRoot, ['rev-parse', 'FETCH_HEAD^{commit}']);
|
|
31
|
+
const tree = await runGit(temporaryRoot, ['ls-tree', '-rz', '--full-tree', revision]);
|
|
32
|
+
const files = [];
|
|
33
|
+
let bytes = 0;
|
|
34
|
+
for (const item of tree.stdout.toString('utf8').split('\0').filter(Boolean)) {
|
|
35
|
+
const separator = item.indexOf('\t');
|
|
36
|
+
const [mode, type, object] = item.slice(0, separator).split(' ');
|
|
37
|
+
const name = item.slice(separator + 1);
|
|
38
|
+
if (!['100644', '100755'].includes(mode) || type !== 'blob' || path.isAbsolute(name)
|
|
39
|
+
|| name.includes('\\') || name.split('/').some((part) => !part || ['.', '..', '.git'].includes(part.toLowerCase()))) {
|
|
40
|
+
throw new GenesisError('TEMPLATE_TREE_INVALID', `Template contains an unsupported path or file: ${name}.`);
|
|
41
|
+
}
|
|
42
|
+
const blob = await runGit(temporaryRoot, ['cat-file', 'blob', object]);
|
|
43
|
+
bytes += blob.stdout.length;
|
|
44
|
+
if (bytes > 64 * 1024 * 1024 || files.length >= 10_000) {
|
|
45
|
+
throw new GenesisError('TEMPLATE_TREE_TOO_LARGE', 'Template exceeds the source size limit.');
|
|
46
|
+
}
|
|
47
|
+
files.push({ path: name, contents: blob.stdout, mode: mode === '100755' ? 0o777 : 0o666 });
|
|
48
|
+
}
|
|
49
|
+
return { repository, branch, revision, files };
|
|
50
|
+
} finally { await rm(temporaryRoot, { recursive: true, force: true }); }
|
|
51
|
+
}
|
package/src/index.js
CHANGED
|
@@ -22,9 +22,14 @@ import { inspectProjectStackSection } from './index/stack-section-inspection.js'
|
|
|
22
22
|
import { listStackCatalogPieces } from './index/stack-catalog.js';
|
|
23
23
|
import { addStackPieces, readStack } from './index/stack.js';
|
|
24
24
|
import { uniqueSorted } from './index/utils.js';
|
|
25
|
+
import { inspectProject } from './index/project-inspection.js';
|
|
25
26
|
import { verifyProject } from './index/verification.js';
|
|
26
27
|
import { projectSessionContext, projectTurnContext } from './index/session-context.js';
|
|
27
28
|
import { withTrustedGitRepository } from './index/process.js';
|
|
29
|
+
|
|
30
|
+
export { inspectProject };
|
|
31
|
+
export { listTemplates } from './index/template-catalog.js';
|
|
32
|
+
export { applyTemplate } from './index/template-project.js';
|
|
28
33
|
import {
|
|
29
34
|
HOST_CONTEXT_RESOLVER_DATA_ENV,
|
|
30
35
|
HOST_CONTEXT_RESOLVER_ENV,
|
|
@@ -59,8 +64,14 @@ async function initializeWithIndex(projectRoot, stackPackages = []) {
|
|
|
59
64
|
return withIndexResult(initialized, index);
|
|
60
65
|
}
|
|
61
66
|
|
|
62
|
-
export function initialize({ projectRoot = process.cwd(), stackPackages = [] } = {}) {
|
|
63
|
-
|
|
67
|
+
export async function initialize({ projectRoot = process.cwd(), stackPackages = [] } = {}) {
|
|
68
|
+
const initialized = await initializeWithIndex(projectRoot, stackPackages);
|
|
69
|
+
const inspection = await inspectProject({ projectRoot, stackPackages });
|
|
70
|
+
return { ...initialized, inspection, guidance: `${initialized.guidance}\n${inspection.state === 'adoption'
|
|
71
|
+
? 'Existing application detected. Initialization has not documented it: open your agent to describe the project and choose what to run. Do not seed this repository.'
|
|
72
|
+
: inspection.state === 'new'
|
|
73
|
+
? 'Ready for a new project. Open your agent or list ready-made starting points with genesis templates list.'
|
|
74
|
+
: 'Open your agent to continue. Session startup does not run application verification.'}` };
|
|
64
75
|
}
|
|
65
76
|
|
|
66
77
|
export async function migrate({ projectRoot = process.cwd(), stackPackages = [] } = {}) {
|