genesis-compiler 1.0.0 → 1.1.0

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.
Files changed (70) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/README.md +423 -0
  3. package/bin/genesis.js +15 -0
  4. package/docs/assurance-model.md +26 -0
  5. package/docs/prompt-integration.md +92 -0
  6. package/docs/stack-components.md +269 -0
  7. package/package.json +56 -7
  8. package/plugins/genesis/.codex-plugin/plugin.json +19 -0
  9. package/plugins/genesis/hooks.json +18 -0
  10. package/prompts/blueprint.txt +9 -0
  11. package/prompts/describe.txt +17 -0
  12. package/prompts/deslop.txt +14 -0
  13. package/prompts/program.txt +12 -0
  14. package/prompts/reconcile.txt +12 -0
  15. package/prompts/review.txt +12 -0
  16. package/prompts/work.txt +21 -0
  17. package/skills/genesis-deslop/SKILL.md +36 -0
  18. package/skills/genesis-deslop/agents/openai.yaml +4 -0
  19. package/skills/genesis-program/SKILL.md +66 -0
  20. package/skills/genesis-program/agents/openai.yaml +4 -0
  21. package/skills/genesis-project/SKILL.md +53 -0
  22. package/skills/genesis-project/agents/openai.yaml +4 -0
  23. package/src/cli.js +276 -0
  24. package/src/index/agent-skills.js +425 -0
  25. package/src/index/assets.js +18 -0
  26. package/src/index/blueprint.js +38 -0
  27. package/src/index/check.js +102 -0
  28. package/src/index/code-index.js +283 -0
  29. package/src/index/code-indexers/ast-grep.js +414 -0
  30. package/src/index/codex-hooks.js +367 -0
  31. package/src/index/codex-plugin.js +73 -0
  32. package/src/index/context.js +137 -0
  33. package/src/index/errors.js +26 -0
  34. package/src/index/git.js +26 -0
  35. package/src/index/init.js +48 -0
  36. package/src/index/launch.js +34 -0
  37. package/src/index/paths.js +10 -0
  38. package/src/index/process.js +78 -0
  39. package/src/index/program.js +181 -0
  40. package/src/index/project-files.js +24 -0
  41. package/src/index/project-state.js +87 -0
  42. package/src/index/prompt.js +239 -0
  43. package/src/index/stack-catalog.js +72 -0
  44. package/src/index/stack-command.js +65 -0
  45. package/src/index/stack-composition.js +38 -0
  46. package/src/index/stack-launch.js +428 -0
  47. package/src/index/stack-piece.js +277 -0
  48. package/src/index/stack-preflight.js +25 -0
  49. package/src/index/stack-process.js +25 -0
  50. package/src/index/stack-workspace-setup.js +117 -0
  51. package/src/index/stack.js +272 -0
  52. package/src/index/utils.js +85 -0
  53. package/src/index/verification.js +77 -0
  54. package/src/index/workspace-setup.js +30 -0
  55. package/src/index.js +97 -0
  56. package/stacks/pieces/cpp.md +22 -0
  57. package/stacks/pieces/csharp.md +22 -0
  58. package/stacks/pieces/go.md +22 -0
  59. package/stacks/pieces/java.md +22 -0
  60. package/stacks/pieces/jskit-mysql.md +37 -0
  61. package/stacks/pieces/jskit.md +66 -0
  62. package/stacks/pieces/kotlin.md +22 -0
  63. package/stacks/pieces/mysql.md +18 -0
  64. package/stacks/pieces/nodejs.md +25 -0
  65. package/stacks/pieces/php.md +23 -0
  66. package/stacks/pieces/python.md +23 -0
  67. package/stacks/pieces/ruby.md +22 -0
  68. package/stacks/pieces/rust.md +22 -0
  69. package/stacks/pieces/shell.md +23 -0
  70. package/stacks/pieces/vue.md +19 -0
package/src/cli.js ADDED
@@ -0,0 +1,276 @@
1
+ import process from 'node:process';
2
+ import { parseArgs } from 'node:util';
3
+
4
+ import {
5
+ addStack,
6
+ adoptProject,
7
+ check,
8
+ generatePrompt,
9
+ getContext,
10
+ indexCodebase,
11
+ initialize,
12
+ installCodex,
13
+ listStackPieces,
14
+ verify,
15
+ } from './index.js';
16
+ import {
17
+ codexAdoptionRecommendation,
18
+ codexSessionContext,
19
+ completeCodexTurn,
20
+ discardCodexTurn,
21
+ recordCodexTurn,
22
+ } from './index/codex-hooks.js';
23
+ import { asDiagnostic, fail } from './index/errors.js';
24
+
25
+ const USAGE = `Usage:
26
+ genesis init
27
+ genesis adopt [product guidance...]
28
+ genesis codex install
29
+ genesis stack list
30
+ genesis stack add <piece...>
31
+ genesis context <path...>
32
+ genesis index [function-or-path...]
33
+ genesis prompt [request...]
34
+ genesis prompt --task <work|deslop|program|blueprint|describe|review> [request...]
35
+ genesis verify
36
+ genesis check
37
+
38
+ Options:
39
+ --project-root <path> Set the project root (default: current directory)
40
+ --task <task> Select the prompt task (default: work)
41
+ --json Emit one machine-readable result
42
+ -h, --help Show this help
43
+
44
+ Genesis generates prompts; it never starts or manages an AI agent. Give the
45
+ prompt to the agent you already use. Review all edits through the ordinary Git
46
+ diff, then run genesis verify for the Stack's concrete checks.
47
+ `;
48
+
49
+ const COMMANDS = new Set(['adopt', 'check', 'codex', 'context', 'hook', 'index', 'init', 'prompt', 'stack', 'verify']);
50
+
51
+ function parseCommand(argv) {
52
+ if (argv.length === 0 || argv.includes('--help') || argv.includes('-h') || argv[0] === 'help') {
53
+ return { command: 'help', operands: [], options: {} };
54
+ }
55
+ const [command, ...rest] = argv;
56
+ if (!COMMANDS.has(command)) fail('CLI_UNKNOWN_COMMAND', `Unknown command: ${command}`);
57
+ let parsed;
58
+ try {
59
+ parsed = parseArgs({
60
+ args: rest,
61
+ allowPositionals: true,
62
+ strict: true,
63
+ options: {
64
+ json: { type: 'boolean', default: false },
65
+ 'project-root': { type: 'string' },
66
+ task: { type: 'string' },
67
+ },
68
+ });
69
+ } catch (error) {
70
+ fail('CLI_ARGUMENT_INVALID', error.message);
71
+ }
72
+ const options = {
73
+ json: parsed.values.json,
74
+ projectRoot: parsed.values['project-root'],
75
+ task: parsed.values.task,
76
+ };
77
+ const operands = parsed.positionals;
78
+ if (options.task !== undefined && command !== 'prompt') {
79
+ fail('CLI_OPTION_NOT_APPLICABLE', `Option --task is not applicable to ${command}.`);
80
+ }
81
+ if (command === 'stack') {
82
+ if (!['list', 'add'].includes(operands[0])) {
83
+ fail('CLI_STACK_ACTION_REQUIRED', 'Command stack requires list or add.');
84
+ }
85
+ if (operands[0] === 'add' && operands.length < 2) {
86
+ fail('CLI_STACK_PIECE_REQUIRED', 'Command stack add requires at least one piece.');
87
+ }
88
+ if (operands[0] === 'list' && operands.length !== 1) {
89
+ fail('CLI_EXTRA_ARGUMENT', 'Command stack list accepts no extra arguments.');
90
+ }
91
+ } else if (command === 'codex') {
92
+ if (operands.length !== 1 || operands[0] !== 'install') {
93
+ fail('CLI_CODEX_ACTION_REQUIRED', 'Command codex requires exactly: install.');
94
+ }
95
+ } else if (command === 'context' && operands.length === 0) {
96
+ fail('CONTEXT_PATH_REQUIRED', 'Command context requires at least one project path.');
97
+ } else if (command === 'hook' && (operands.length !== 1 || !['begin', 'discover', 'end', 'session', 'stop'].includes(operands[0]))) {
98
+ fail('CLI_HOOK_ACTION_REQUIRED', 'Command hook requires exactly one of: discover, session, begin, stop, end.');
99
+ } else if (!['adopt', 'context', 'hook', 'index', 'prompt'].includes(command) && operands.length > 0) {
100
+ fail('CLI_EXTRA_ARGUMENT', `Command ${command} accepts no arguments.`);
101
+ }
102
+ return { command, operands, options };
103
+ }
104
+
105
+ function line(stream, value = '') {
106
+ stream.write(`${String(value).replace(/\n$/u, '')}\n`);
107
+ }
108
+
109
+ function namedItems(label, values) {
110
+ if (!Array.isArray(values) || values.length === 0) return;
111
+ line(process.stdout, `${label}:`);
112
+ for (const value of values) line(process.stdout, ` - ${value}`);
113
+ }
114
+
115
+ function writeCheck(result) {
116
+ line(process.stdout, `Blueprint: ${result.blueprint}`);
117
+ line(process.stdout, `Stack: ${result.stack}`);
118
+ line(process.stdout, `Agent Skills: ${result.skills}`);
119
+ line(process.stdout, `Program: ${result.program}`);
120
+ line(process.stdout, `Resource inputs: ${result.resources}`);
121
+ line(process.stdout, `Verification: ${result.verification}`);
122
+ namedItems('Program files', result.programFiles);
123
+ namedItems('Subsystems', result.subsystems);
124
+ for (const item of result.diagnostics) line(process.stdout, `${item.code}: ${item.message}`);
125
+ if (result.guidance) line(process.stdout, result.guidance);
126
+ line(process.stdout, `Check: ${result.status}`);
127
+ }
128
+
129
+ function writeResult(command, result) {
130
+ if (command === 'prompt') {
131
+ process.stdout.write(result.prompt.endsWith('\n') ? result.prompt : `${result.prompt}\n`);
132
+ return;
133
+ }
134
+ if (command === 'adopt') {
135
+ if (result.summary) line(process.stdout, result.summary);
136
+ namedItems('Changed files', result.changedFiles);
137
+ if (result.guidance) line(process.stdout, result.guidance);
138
+ line(process.stdout, '');
139
+ process.stdout.write(result.prompt.endsWith('\n') ? result.prompt : `${result.prompt}\n`);
140
+ return;
141
+ }
142
+ if (command === 'check') {
143
+ writeCheck(result);
144
+ return;
145
+ }
146
+ if (command === 'context') {
147
+ process.stdout.write(result.context.endsWith('\n') ? result.context : `${result.context}\n`);
148
+ return;
149
+ }
150
+ if (command === 'index') {
151
+ line(process.stdout, result.summary);
152
+ line(process.stdout, `Machine City: ${result.paths.machine}`);
153
+ line(process.stdout, `Program City: ${result.paths.program}`);
154
+ if (result.totalMatches > 0) {
155
+ line(process.stdout, 'Matching functions:');
156
+ for (const entry of result.matches) {
157
+ const signature = `${entry.qualifiedName}(${entry.parameters.join(', ')})`;
158
+ line(process.stdout, ` - ${entry.visibility} ${signature} — ${entry.path}:${entry.line}`);
159
+ }
160
+ if (result.totalMatches > result.matches.length) {
161
+ line(process.stdout, ` - ... and ${result.totalMatches - result.matches.length} more`);
162
+ }
163
+ }
164
+ for (const item of result.diagnostics || []) line(process.stdout, `${item.code}: ${item.message}`);
165
+ line(process.stdout, `index: ${result.status}`);
166
+ return;
167
+ }
168
+ if (command === 'hook') {
169
+ if (['discover', 'session'].includes(result.kind) && result.output) line(process.stdout, result.output);
170
+ else if (result.kind === 'stop') line(process.stdout, JSON.stringify(result.output));
171
+ return;
172
+ }
173
+ if (command === 'stack' && Array.isArray(result.pieces)) {
174
+ for (const piece of result.pieces) {
175
+ line(process.stdout, `${piece.id}: ${piece.description}`);
176
+ if (piece.requires.length > 0) line(process.stdout, ` requires: ${piece.requires.join(', ')}`);
177
+ if (piece.indexers.length > 0) line(process.stdout, ` indexers: ${piece.indexers.join(', ')}`);
178
+ }
179
+ return;
180
+ }
181
+ if (result.summary) line(process.stdout, result.summary);
182
+ namedItems('Changed files', result.changedFiles);
183
+ namedItems('Commands', result.commands?.map(({ label, argv }) => `${label}: ${argv.join(' ')}`));
184
+ for (const item of result.diagnostics || []) {
185
+ line(process.stdout, `${item.code}: ${item.message}`);
186
+ for (const field of ['stdout', 'stderr']) {
187
+ const output = item.details?.[field]?.trim();
188
+ if (output) line(process.stdout, output);
189
+ }
190
+ }
191
+ if (result.guidance) line(process.stdout, result.guidance);
192
+ line(process.stdout, `${command}: ${result.status}`);
193
+ }
194
+
195
+ async function hookInput() {
196
+ if (process.stdin.isTTY) fail('CODEX_HOOK_INPUT_INVALID', 'Codex hook input must be supplied on stdin.');
197
+ const chunks = [];
198
+ let bytes = 0;
199
+ for await (const chunk of process.stdin) {
200
+ bytes += chunk.length;
201
+ if (bytes > 1024 * 1024) fail('CODEX_HOOK_INPUT_INVALID', 'Codex hook input exceeds 1 MiB.');
202
+ chunks.push(chunk);
203
+ }
204
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch (error) {
205
+ fail('CODEX_HOOK_INPUT_INVALID', `Codex hook input is invalid JSON: ${error.message}.`);
206
+ }
207
+ }
208
+
209
+ async function execute({ command, operands, options }) {
210
+ const projectRoot = options.projectRoot || process.cwd();
211
+ if (command === 'init') return initialize({ projectRoot });
212
+ if (command === 'adopt') {
213
+ return adoptProject({ projectRoot, request: operands.join(' ') });
214
+ }
215
+ if (command === 'codex') return installCodex();
216
+ if (command === 'stack') {
217
+ if (operands[0] === 'list') return { status: 'ok', pieces: await listStackPieces() };
218
+ return addStack({ pieces: operands.slice(1), projectRoot });
219
+ }
220
+ if (command === 'prompt') {
221
+ return generatePrompt({
222
+ projectRoot,
223
+ task: options.task || 'work',
224
+ request: operands.join(' '),
225
+ });
226
+ }
227
+ if (command === 'context') return getContext({ paths: operands, projectRoot });
228
+ if (command === 'index') return indexCodebase({ projectRoot, queries: operands });
229
+ if (command === 'hook') {
230
+ if (operands[0] === 'discover') {
231
+ return { kind: 'discover', ...await codexAdoptionRecommendation({ projectRoot }) };
232
+ }
233
+ if (operands[0] === 'session') {
234
+ return { kind: 'session', ...await codexSessionContext({ projectRoot }) };
235
+ }
236
+ const input = await hookInput();
237
+ if (operands[0] === 'begin') {
238
+ return { kind: 'begin', ...await recordCodexTurn({ input, projectRoot }) };
239
+ }
240
+ if (operands[0] === 'end') {
241
+ return { kind: 'end', ...await discardCodexTurn({ input, projectRoot }) };
242
+ }
243
+ return { kind: 'stop', status: 'ready', output: await completeCodexTurn({ input, projectRoot }) };
244
+ }
245
+ if (command === 'verify') {
246
+ return verify({
247
+ projectRoot,
248
+ onEvent: (event) => {
249
+ if (!options.json) line(process.stderr, event.message);
250
+ },
251
+ });
252
+ }
253
+ if (command === 'check') return check({ projectRoot });
254
+ fail('CLI_UNKNOWN_COMMAND', `Unknown command: ${command}`);
255
+ }
256
+
257
+ export async function runCli(argv = process.argv.slice(2)) {
258
+ let options = {};
259
+ try {
260
+ const parsed = parseCommand(argv);
261
+ if (parsed.command === 'help') {
262
+ process.stdout.write(USAGE);
263
+ return 0;
264
+ }
265
+ options = parsed.options;
266
+ const result = await execute(parsed);
267
+ if (options.json) line(process.stdout, JSON.stringify(result));
268
+ else writeResult(parsed.command, result);
269
+ return ['blocked', 'failed', 'invalid'].includes(result?.status) ? 2 : 0;
270
+ } catch (error) {
271
+ const diagnostic = asDiagnostic(error);
272
+ if (options.json) line(process.stderr, JSON.stringify(diagnostic));
273
+ else line(process.stderr, `${diagnostic.code}: ${diagnostic.message}`);
274
+ return 1;
275
+ }
276
+ }
@@ -0,0 +1,425 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createRequire } from 'node:module';
3
+ import {
4
+ cp,
5
+ lstat,
6
+ mkdir,
7
+ readFile,
8
+ readdir,
9
+ rename,
10
+ rm,
11
+ } from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ import { parse as parseYaml } from 'yaml';
16
+
17
+ import { GenesisError } from './errors.js';
18
+ import { sha256, stableJson, writeFileAtomic } from './utils.js';
19
+
20
+ const packageRoot = fileURLToPath(new URL('../../', import.meta.url));
21
+ const require = createRequire(import.meta.url);
22
+ const SKILLS_ROOT = '.agents/skills';
23
+ const MANIFEST_PATH = `${SKILLS_ROOT}/.genesis-managed.json`;
24
+ const MANIFEST_VERSION = 1;
25
+ const HASH = /^sha256:[0-9a-f]{64}$/u;
26
+ const SKILL_FIELDS = new Set([
27
+ 'name',
28
+ 'description',
29
+ 'license',
30
+ 'compatibility',
31
+ 'metadata',
32
+ 'allowed-tools',
33
+ ]);
34
+
35
+ const CORE_SKILLS = [
36
+ 'genesis-project',
37
+ 'genesis-program',
38
+ 'genesis-deslop',
39
+ ].map((name) => ({
40
+ component: null,
41
+ package: null,
42
+ path: `skills/${name}`,
43
+ source: `genesis:skills/${name}`,
44
+ }));
45
+
46
+ function invalidSkill(message, details = {}) {
47
+ throw new GenesisError('AGENT_SKILL_INVALID', message, details);
48
+ }
49
+
50
+ function validSkillName(value) {
51
+ if (typeof value !== 'string') return false;
52
+ const name = value.normalize('NFKC');
53
+ return name.length > 0
54
+ && name.length <= 64
55
+ && name === name.toLowerCase()
56
+ && !name.startsWith('-')
57
+ && !name.endsWith('-')
58
+ && !name.includes('--')
59
+ && [...name].every((character) => character === '-' || /[\p{L}\p{N}]/u.test(character));
60
+ }
61
+
62
+ function frontmatter(source, location) {
63
+ const normalized = source.replace(/\r\n?/gu, '\n');
64
+ if (!normalized.startsWith('---\n')) invalidSkill(`Agent Skill is missing YAML frontmatter: ${location}.`);
65
+ const end = normalized.indexOf('\n---\n', 4);
66
+ if (end === -1) invalidSkill(`Agent Skill frontmatter is not closed: ${location}.`);
67
+ let metadata;
68
+ try {
69
+ metadata = parseYaml(normalized.slice(4, end));
70
+ } catch (error) {
71
+ invalidSkill(`Agent Skill frontmatter is invalid YAML: ${location}.`, { cause: error.message });
72
+ }
73
+ if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
74
+ invalidSkill(`Agent Skill frontmatter must be a mapping: ${location}.`);
75
+ }
76
+ return metadata;
77
+ }
78
+
79
+ async function skillTree(directory, relative = '') {
80
+ const entries = await readdir(path.join(directory, relative), { withFileTypes: true });
81
+ const files = [];
82
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
83
+ const child = relative ? `${relative}/${entry.name}` : entry.name;
84
+ if (entry.isSymbolicLink()) invalidSkill(`Agent Skill contains a symbolic link: ${child}.`);
85
+ if (entry.isDirectory()) {
86
+ files.push(...await skillTree(directory, child));
87
+ continue;
88
+ }
89
+ if (!entry.isFile()) invalidSkill(`Agent Skill contains a non-file entry: ${child}.`);
90
+ const location = path.join(directory, child);
91
+ const [content, info] = await Promise.all([readFile(location), lstat(location)]);
92
+ files.push({
93
+ path: child,
94
+ hash: sha256(content),
95
+ mode: (info.mode & 0o111) === 0 ? 0o100644 : 0o100755,
96
+ });
97
+ }
98
+ return files;
99
+ }
100
+
101
+ async function describeSkill(directory, source) {
102
+ let info;
103
+ try { info = await lstat(directory); } catch (error) {
104
+ if (['ENOENT', 'ENOTDIR'].includes(error?.code)) return null;
105
+ throw error;
106
+ }
107
+ if (!info.isDirectory() || info.isSymbolicLink()) {
108
+ invalidSkill(`Agent Skill must be an ordinary directory: ${directory}.`);
109
+ }
110
+ const skillFile = path.join(directory, 'SKILL.md');
111
+ let skillSource;
112
+ try { skillSource = await readFile(skillFile, 'utf8'); } catch (error) {
113
+ invalidSkill(`Agent Skill is missing SKILL.md: ${directory}.`, { cause: error.code || error.message });
114
+ }
115
+ const metadata = frontmatter(skillSource, skillFile);
116
+ const unexpected = Object.keys(metadata).filter((field) => !SKILL_FIELDS.has(field));
117
+ if (unexpected.length > 0) {
118
+ invalidSkill(`Agent Skill frontmatter contains unsupported fields: ${unexpected.sort().join(', ')}.`);
119
+ }
120
+ const { name } = metadata;
121
+ const description = typeof metadata.description === 'string' ? metadata.description.trim() : '';
122
+ if (
123
+ !validSkillName(name)
124
+ || name.normalize('NFKC') !== path.basename(directory).normalize('NFKC')
125
+ ) invalidSkill(`Agent Skill name must match its directory and use the standard lowercase format: ${directory}.`);
126
+ if (!description || description.length > 1024) {
127
+ invalidSkill(`Agent Skill description must contain 1-1024 characters: ${skillFile}.`);
128
+ }
129
+ if (
130
+ metadata.compatibility !== undefined
131
+ && (typeof metadata.compatibility !== 'string' || metadata.compatibility.length > 500)
132
+ ) invalidSkill(`Agent Skill compatibility must be a string of at most 500 characters: ${skillFile}.`);
133
+ if (metadata.license !== undefined && typeof metadata.license !== 'string') {
134
+ invalidSkill(`Agent Skill license must be a string: ${skillFile}.`);
135
+ }
136
+ if (metadata['allowed-tools'] !== undefined && typeof metadata['allowed-tools'] !== 'string') {
137
+ invalidSkill(`Agent Skill allowed-tools must be a string: ${skillFile}.`);
138
+ }
139
+ if (
140
+ metadata.metadata !== undefined
141
+ && (
142
+ !metadata.metadata
143
+ || typeof metadata.metadata !== 'object'
144
+ || Array.isArray(metadata.metadata)
145
+ || Object.values(metadata.metadata).some((value) => typeof value !== 'string')
146
+ )
147
+ ) invalidSkill(`Agent Skill metadata must map strings to strings: ${skillFile}.`);
148
+ const files = await skillTree(directory);
149
+ return {
150
+ name,
151
+ description,
152
+ directory,
153
+ source,
154
+ files,
155
+ hash: sha256(stableJson(files)),
156
+ };
157
+ }
158
+
159
+ async function packageDirectory(packageName) {
160
+ for (const modulesRoot of require.resolve.paths(packageName) || []) {
161
+ const manifest = path.join(modulesRoot, packageName, 'package.json');
162
+ try {
163
+ const value = JSON.parse(await readFile(manifest, 'utf8'));
164
+ if (value?.name === packageName) return path.dirname(manifest);
165
+ } catch (error) {
166
+ if (!['ENOENT', 'ENOTDIR'].includes(error?.code)) {
167
+ throw new GenesisError(
168
+ 'AGENT_SKILL_UNAVAILABLE',
169
+ `Cannot read selected Agent Skill package ${packageName}: ${error.message}.`,
170
+ { package: packageName },
171
+ );
172
+ }
173
+ }
174
+ }
175
+ throw new GenesisError(
176
+ 'AGENT_SKILL_UNAVAILABLE',
177
+ `Selected Agent Skill package is unavailable: ${packageName}.`,
178
+ { package: packageName },
179
+ );
180
+ }
181
+
182
+ async function resolveLocator(locator) {
183
+ const root = locator.package ? await packageDirectory(locator.package) : packageRoot;
184
+ const directory = path.join(root, locator.path);
185
+ const source = locator.source || (
186
+ locator.package
187
+ ? `npm:${locator.package}/${locator.path}`
188
+ : `genesis:${locator.path}`
189
+ );
190
+ const skill = await describeSkill(directory, source);
191
+ if (!skill) invalidSkill(`Selected Agent Skill is unavailable: ${directory}.`);
192
+ return { ...skill, component: locator.component };
193
+ }
194
+
195
+ /** Resolves the three Genesis workflow skills plus authoritative selected Stack skills. */
196
+ export async function projectSkillPlan(stack) {
197
+ const locators = [
198
+ ...CORE_SKILLS,
199
+ ...(stack?.components || []).flatMap((component) => (
200
+ component.skill ? [{ ...component.skill, component: component.id }] : []
201
+ )),
202
+ ];
203
+ const resolved = await Promise.all(locators.map(resolveLocator));
204
+ const skills = new Map();
205
+ for (const skill of resolved) {
206
+ const previous = skills.get(skill.name);
207
+ if (previous && previous.hash !== skill.hash) {
208
+ throw new GenesisError(
209
+ 'AGENT_SKILL_COLLISION',
210
+ `Selected Stack sources provide different Agent Skills named ${skill.name}.`,
211
+ { name: skill.name, sources: [previous.source, skill.source] },
212
+ );
213
+ }
214
+ if (!previous) skills.set(skill.name, skill);
215
+ }
216
+ return [...skills.values()].sort((left, right) => left.name.localeCompare(right.name));
217
+ }
218
+
219
+ async function readManifest(projectRoot) {
220
+ const location = path.join(projectRoot, MANIFEST_PATH);
221
+ let value;
222
+ try { value = JSON.parse(await readFile(location, 'utf8')); } catch (error) {
223
+ if (['ENOENT', 'ENOTDIR'].includes(error?.code)) {
224
+ return { location, value: { schemaVersion: MANIFEST_VERSION, skills: {} } };
225
+ }
226
+ throw new GenesisError('AGENT_SKILLS_MANIFEST_INVALID', `${MANIFEST_PATH} is invalid: ${error.message}.`);
227
+ }
228
+ const valid = value
229
+ && typeof value === 'object'
230
+ && !Array.isArray(value)
231
+ && value.schemaVersion === MANIFEST_VERSION
232
+ && value.skills
233
+ && typeof value.skills === 'object'
234
+ && !Array.isArray(value.skills)
235
+ && Object.entries(value.skills).every(([name, record]) => (
236
+ validSkillName(name)
237
+ && record
238
+ && typeof record === 'object'
239
+ && Object.keys(record).sort().join(',') === 'hash,source'
240
+ && HASH.test(record.hash)
241
+ && typeof record.source === 'string'
242
+ && record.source.length > 0
243
+ ));
244
+ if (!valid) throw new GenesisError('AGENT_SKILLS_MANIFEST_INVALID', `${MANIFEST_PATH} has an invalid shape.`);
245
+ return { location, value };
246
+ }
247
+
248
+ async function replaceSkill(source, target) {
249
+ const temporary = `${target}.${randomUUID()}.tmp`;
250
+ const backup = `${target}.${randomUUID()}.backup`;
251
+ await mkdir(path.dirname(target), { recursive: true });
252
+ let replaced = false;
253
+ try {
254
+ await cp(source, temporary, { recursive: true, errorOnExist: true });
255
+ try {
256
+ await rename(target, backup);
257
+ replaced = true;
258
+ } catch (error) {
259
+ if (!['ENOENT', 'ENOTDIR'].includes(error?.code)) throw error;
260
+ }
261
+ await rename(temporary, target);
262
+ } catch (error) {
263
+ if (replaced) {
264
+ await rm(target, { recursive: true, force: true }).catch(() => {});
265
+ await rename(backup, target).catch(() => {});
266
+ }
267
+ throw error;
268
+ } finally {
269
+ await rm(temporary, { recursive: true, force: true });
270
+ await rm(backup, { recursive: true, force: true });
271
+ }
272
+ }
273
+
274
+ function targetFiles(skill) {
275
+ return skill.files.map(({ path: file }) => `${SKILLS_ROOT}/${skill.name}/${file}`);
276
+ }
277
+
278
+ /** Installs only Genesis-owned or authoritative Stack-declared skills. */
279
+ export async function syncProjectSkills({ projectRoot, stack } = {}) {
280
+ const desired = await projectSkillPlan(stack);
281
+ const desiredNames = new Set(desired.map(({ name }) => name));
282
+ const manifest = await readManifest(projectRoot);
283
+ const changedFiles = [];
284
+ const diagnostics = [];
285
+
286
+ for (const skill of desired) {
287
+ const target = path.join(projectRoot, SKILLS_ROOT, skill.name);
288
+ const installed = await describeSkill(target, `project:${skill.name}`);
289
+ const managed = Object.hasOwn(manifest.value.skills, skill.name)
290
+ ? manifest.value.skills[skill.name]
291
+ : null;
292
+ if (!installed) {
293
+ await replaceSkill(skill.directory, target);
294
+ manifest.value.skills[skill.name] = { hash: skill.hash, source: skill.source };
295
+ changedFiles.push(...targetFiles(skill));
296
+ continue;
297
+ }
298
+ if (!managed) {
299
+ diagnostics.push({
300
+ code: 'AGENT_SKILL_EXTERNAL',
301
+ message: `Preserved existing project Agent Skill ${skill.name}; Genesis does not own it.`,
302
+ details: { name: skill.name, path: `${SKILLS_ROOT}/${skill.name}/SKILL.md` },
303
+ });
304
+ continue;
305
+ }
306
+ if (installed.hash !== managed.hash) {
307
+ diagnostics.push({
308
+ code: 'AGENT_SKILL_CUSTOMIZED',
309
+ message: `Preserved locally modified Agent Skill ${skill.name}.`,
310
+ details: { name: skill.name, path: `${SKILLS_ROOT}/${skill.name}/SKILL.md` },
311
+ });
312
+ continue;
313
+ }
314
+ if (installed.hash !== skill.hash || managed.source !== skill.source) {
315
+ await replaceSkill(skill.directory, target);
316
+ manifest.value.skills[skill.name] = { hash: skill.hash, source: skill.source };
317
+ changedFiles.push(...new Set([...targetFiles(installed), ...targetFiles(skill)]));
318
+ }
319
+ }
320
+
321
+ for (const [name, record] of Object.entries(manifest.value.skills)) {
322
+ if (desiredNames.has(name)) continue;
323
+ const target = path.join(projectRoot, SKILLS_ROOT, name);
324
+ const installed = await describeSkill(target, `project:${name}`);
325
+ if (installed?.hash === record.hash) {
326
+ await rm(target, { recursive: true, force: true });
327
+ changedFiles.push(...targetFiles(installed));
328
+ } else if (installed) {
329
+ diagnostics.push({
330
+ code: 'AGENT_SKILL_PRESERVED',
331
+ message: `Preserved modified deselected Agent Skill ${name} and released Genesis ownership.`,
332
+ details: { name, path: `${SKILLS_ROOT}/${name}/SKILL.md` },
333
+ });
334
+ }
335
+ delete manifest.value.skills[name];
336
+ }
337
+
338
+ const rendered = stableJson(manifest.value);
339
+ let previous = null;
340
+ try { previous = await readFile(manifest.location, 'utf8'); } catch (error) {
341
+ if (!['ENOENT', 'ENOTDIR'].includes(error?.code)) throw error;
342
+ }
343
+ if (rendered !== previous) {
344
+ await writeFileAtomic(manifest.location, rendered);
345
+ changedFiles.push(MANIFEST_PATH);
346
+ }
347
+ return {
348
+ status: changedFiles.length > 0 ? 'updated' : 'unchanged',
349
+ changedFiles: [...new Set(changedFiles)].sort(),
350
+ diagnostics,
351
+ skills: desired.map(({ name }) => name),
352
+ };
353
+ }
354
+
355
+ /** Inspects the exact project copies without changing them. */
356
+ export async function inspectProjectSkills({ projectRoot, stack } = {}) {
357
+ const desired = await projectSkillPlan(stack);
358
+ const manifest = await readManifest(projectRoot);
359
+ const diagnostics = [];
360
+ const skills = [];
361
+ let customized = false;
362
+ let invalid = false;
363
+ let missing = false;
364
+ for (const expected of desired) {
365
+ const target = path.join(projectRoot, SKILLS_ROOT, expected.name);
366
+ let installed;
367
+ try { installed = await describeSkill(target, `project:${expected.name}`); } catch (error) {
368
+ invalid = true;
369
+ diagnostics.push({ code: error.code || 'AGENT_SKILL_INVALID', message: error.message });
370
+ continue;
371
+ }
372
+ if (!installed) {
373
+ missing = true;
374
+ diagnostics.push({
375
+ code: 'AGENT_SKILL_MISSING',
376
+ message: `Project Agent Skill is missing: ${SKILLS_ROOT}/${expected.name}/SKILL.md.`,
377
+ details: { name: expected.name },
378
+ });
379
+ continue;
380
+ }
381
+ const managed = Object.hasOwn(manifest.value.skills, expected.name)
382
+ ? manifest.value.skills[expected.name]
383
+ : null;
384
+ if (!managed) {
385
+ customized = true;
386
+ diagnostics.push({
387
+ code: 'AGENT_SKILL_EXTERNAL',
388
+ message: `Project Agent Skill ${expected.name} is externally managed; Genesis preserved it.`,
389
+ details: { name: expected.name, path: `${SKILLS_ROOT}/${expected.name}/SKILL.md` },
390
+ });
391
+ } else if (installed.hash !== managed.hash) {
392
+ customized = true;
393
+ diagnostics.push({
394
+ code: 'AGENT_SKILL_CUSTOMIZED',
395
+ message: `Project Agent Skill ${expected.name} differs from its Genesis-managed source.`,
396
+ details: { name: expected.name, path: `${SKILLS_ROOT}/${expected.name}/SKILL.md` },
397
+ });
398
+ }
399
+ skills.push({
400
+ name: installed.name,
401
+ description: installed.description,
402
+ path: `${SKILLS_ROOT}/${installed.name}/SKILL.md`,
403
+ component: expected.component,
404
+ });
405
+ }
406
+ return {
407
+ status: invalid ? 'invalid' : missing ? 'missing' : customized ? 'customized' : 'current',
408
+ diagnostics,
409
+ skills,
410
+ };
411
+ }
412
+
413
+ export function renderAgentSkillCatalog(skills) {
414
+ if (!skills.length) return '';
415
+ return [
416
+ 'The following Agent Skills are available for progressive loading. Read a matching',
417
+ '`SKILL.md` completely before acting, and resolve its relative references from the',
418
+ 'skill directory. Load scripts, references, and assets only when needed.',
419
+ '',
420
+ ...skills.flatMap(({ name, description, path: skillPath }) => [
421
+ `- \`${name}\` — ${description}`,
422
+ ` Path: \`${skillPath}\``,
423
+ ]),
424
+ ].join('\n');
425
+ }