@kjerneverk/riotplan-catalyst 1.0.0-dev.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 (31) hide show
  1. package/.nvmrc +1 -0
  2. package/README.md +327 -0
  3. package/eslint.config.mjs +38 -0
  4. package/package.json +54 -0
  5. package/src/loader/catalyst-loader.ts +261 -0
  6. package/src/loader/plan-manifest.ts +228 -0
  7. package/src/merger/facet-merger.ts +225 -0
  8. package/src/riotplan-catalyst.ts +59 -0
  9. package/src/schema/schemas.ts +143 -0
  10. package/src/types.ts +140 -0
  11. package/tests/catalyst-loader.test.ts +243 -0
  12. package/tests/facet-merger.test.ts +311 -0
  13. package/tests/fixtures/complete-catalyst/catalyst.yml +11 -0
  14. package/tests/fixtures/complete-catalyst/constraints/documentation.md +7 -0
  15. package/tests/fixtures/complete-catalyst/constraints/testing.md +5 -0
  16. package/tests/fixtures/complete-catalyst/domain-knowledge/overview.md +11 -0
  17. package/tests/fixtures/complete-catalyst/output-templates/press-release.md +16 -0
  18. package/tests/fixtures/complete-catalyst/process-guidance/lifecycle.md +13 -0
  19. package/tests/fixtures/complete-catalyst/questions/exploration.md +10 -0
  20. package/tests/fixtures/complete-catalyst/questions/shaping.md +5 -0
  21. package/tests/fixtures/complete-catalyst/validation-rules/checklist.md +11 -0
  22. package/tests/fixtures/invalid-catalyst/questions/some-questions.md +3 -0
  23. package/tests/fixtures/partial-catalyst/catalyst.yml +7 -0
  24. package/tests/fixtures/partial-catalyst/constraints/general.md +4 -0
  25. package/tests/fixtures/partial-catalyst/questions/basics.md +4 -0
  26. package/tests/plan-manifest.test.ts +315 -0
  27. package/tests/schema.test.ts +308 -0
  28. package/tests/setup.ts +1 -0
  29. package/tsconfig.json +22 -0
  30. package/vite.config.ts +43 -0
  31. package/vitest.config.ts +28 -0
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Plan manifest read/write
3
+ * @packageDocumentation
4
+ */
5
+
6
+ import { readFile, writeFile } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
9
+ import { PlanManifestSchema, type PlanManifestOutput } from '@/schema/schemas';
10
+
11
+ /**
12
+ * Plan manifest stored in plan.yaml
13
+ *
14
+ * This is the metadata file for a plan that records:
15
+ * - The plan's identity (ID and title)
16
+ * - Which catalysts are associated with the plan
17
+ * - When the plan was created
18
+ * - Arbitrary metadata for extensibility
19
+ */
20
+ export interface PlanManifest extends PlanManifestOutput {}
21
+
22
+ const MANIFEST_FILENAME = 'plan.yaml';
23
+
24
+ /**
25
+ * Read a plan manifest from plan.yaml
26
+ *
27
+ * Returns null if the manifest file doesn't exist (graceful handling
28
+ * for backward compatibility with plans created before catalyst support).
29
+ * Throws if the file exists but contains invalid data.
30
+ *
31
+ * @param planDirectory - Absolute path to plan directory
32
+ * @returns Parsed and validated manifest, or null if not present
33
+ * @throws Error if manifest exists but is invalid
34
+ *
35
+ * @example
36
+ * ```typescript
37
+ * const manifest = await readPlanManifest('./my-plan');
38
+ * if (manifest) {
39
+ * console.log(`Plan: ${manifest.title}`);
40
+ * console.log(`Uses catalysts: ${manifest.catalysts?.join(', ')}`);
41
+ * }
42
+ * ```
43
+ */
44
+ export async function readPlanManifest(planDirectory: string): Promise<PlanManifest | null> {
45
+ const manifestPath = join(planDirectory, MANIFEST_FILENAME);
46
+
47
+ try {
48
+ const content = await readFile(manifestPath, 'utf-8');
49
+ const parsed = parseYaml(content);
50
+
51
+ // Validate with Zod schema
52
+ const result = PlanManifestSchema.safeParse(parsed);
53
+
54
+ if (!result.success) {
55
+ const errors = result.error.issues.map(issue =>
56
+ `${issue.path.join('.')}: ${issue.message}`
57
+ ).join('; ');
58
+ throw new Error(`Invalid plan manifest: ${errors}`);
59
+ }
60
+
61
+ return result.data;
62
+ } catch (error) {
63
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
64
+ // File doesn't exist - return null for backward compatibility
65
+ return null;
66
+ }
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Write a plan manifest to plan.yaml
73
+ *
74
+ * Creates or overwrites the plan.yaml file with the provided manifest.
75
+ * Automatically timestamps the manifest if `created` is not provided.
76
+ *
77
+ * @param planDirectory - Absolute path to plan directory
78
+ * @param manifest - Manifest to write
79
+ * @throws Error if manifest is invalid or write fails
80
+ *
81
+ * @example
82
+ * ```typescript
83
+ * const manifest: PlanManifest = {
84
+ * id: 'add-user-auth',
85
+ * title: 'Add User Authentication',
86
+ * catalysts: ['@kjerneverk/catalyst-nodejs'],
87
+ * created: new Date().toISOString(),
88
+ * };
89
+ * await writePlanManifest('./my-plan', manifest);
90
+ * ```
91
+ */
92
+ export async function writePlanManifest(
93
+ planDirectory: string,
94
+ manifest: PlanManifest
95
+ ): Promise<void> {
96
+ // Validate manifest
97
+ const result = PlanManifestSchema.safeParse(manifest);
98
+
99
+ if (!result.success) {
100
+ const errors = result.error.issues.map(issue =>
101
+ `${issue.path.join('.')}: ${issue.message}`
102
+ ).join('; ');
103
+ throw new Error(`Invalid plan manifest: ${errors}`);
104
+ }
105
+
106
+ // Ensure created timestamp exists
107
+ const manifestToWrite: PlanManifest = {
108
+ ...result.data,
109
+ created: result.data.created || new Date().toISOString(),
110
+ };
111
+
112
+ // Serialize to YAML
113
+ const yaml = stringifyYaml(manifestToWrite, {
114
+ indent: 2,
115
+ lineWidth: 120,
116
+ });
117
+
118
+ // Write to file
119
+ const manifestPath = join(planDirectory, MANIFEST_FILENAME);
120
+ await writeFile(manifestPath, yaml, 'utf-8');
121
+ }
122
+
123
+ /**
124
+ * Update specific fields in a plan manifest
125
+ *
126
+ * Reads the existing manifest (or creates a new one if it doesn't exist),
127
+ * merges the provided updates, and writes it back.
128
+ *
129
+ * @param planDirectory - Absolute path to plan directory
130
+ * @param updates - Partial manifest to merge (only specified fields are changed)
131
+ * @throws Error if updates are invalid or operation fails
132
+ *
133
+ * @example
134
+ * ```typescript
135
+ * // Add a catalyst to an existing plan
136
+ * await updatePlanManifest('./my-plan', {
137
+ * catalysts: ['@kjerneverk/catalyst-nodejs', '@kjerneverk/catalyst-react'],
138
+ * });
139
+ * ```
140
+ */
141
+ export async function updatePlanManifest(
142
+ planDirectory: string,
143
+ updates: Partial<PlanManifest>
144
+ ): Promise<void> {
145
+ // Read existing manifest
146
+ let existing = await readPlanManifest(planDirectory);
147
+
148
+ // If no existing manifest, start with a minimal one
149
+ if (!existing) {
150
+ // Updates must contain at least id and title
151
+ if (!updates.id || !updates.title) {
152
+ throw new Error('Cannot create manifest without id and title');
153
+ }
154
+ existing = {
155
+ id: '',
156
+ title: '',
157
+ };
158
+ }
159
+
160
+ // Merge updates
161
+ const merged: PlanManifest = {
162
+ ...existing,
163
+ ...updates,
164
+ };
165
+
166
+ // Write back
167
+ await writePlanManifest(planDirectory, merged);
168
+ }
169
+
170
+ /**
171
+ * Add a catalyst to a plan's manifest
172
+ *
173
+ * Convenience function that adds a catalyst ID to the catalysts array
174
+ * without affecting other fields.
175
+ *
176
+ * @param planDirectory - Absolute path to plan directory
177
+ * @param catalystId - Catalyst ID to add
178
+ *
179
+ * @example
180
+ * ```typescript
181
+ * await addCatalystToManifest('./my-plan', '@kjerneverk/catalyst-nodejs');
182
+ * ```
183
+ */
184
+ export async function addCatalystToManifest(
185
+ planDirectory: string,
186
+ catalystId: string
187
+ ): Promise<void> {
188
+ const manifest = await readPlanManifest(planDirectory);
189
+ const currentCatalysts = manifest?.catalysts ?? [];
190
+
191
+ // Only add if not already present
192
+ if (!currentCatalysts.includes(catalystId)) {
193
+ currentCatalysts.push(catalystId);
194
+ }
195
+
196
+ await updatePlanManifest(planDirectory, {
197
+ catalysts: currentCatalysts,
198
+ });
199
+ }
200
+
201
+ /**
202
+ * Remove a catalyst from a plan's manifest
203
+ *
204
+ * Convenience function that removes a catalyst ID from the catalysts array.
205
+ *
206
+ * @param planDirectory - Absolute path to plan directory
207
+ * @param catalystId - Catalyst ID to remove
208
+ *
209
+ * @example
210
+ * ```typescript
211
+ * await removeCatalystFromManifest('./my-plan', '@kjerneverk/catalyst-old');
212
+ * ```
213
+ */
214
+ export async function removeCatalystFromManifest(
215
+ planDirectory: string,
216
+ catalystId: string
217
+ ): Promise<void> {
218
+ const manifest = await readPlanManifest(planDirectory);
219
+ const currentCatalysts = manifest?.catalysts ?? [];
220
+
221
+ const filtered = currentCatalysts.filter(id => id !== catalystId);
222
+
223
+ if (filtered.length !== currentCatalysts.length) {
224
+ await updatePlanManifest(planDirectory, {
225
+ catalysts: filtered.length > 0 ? filtered : undefined,
226
+ });
227
+ }
228
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Facet merging for multiple catalysts
3
+ * @packageDocumentation
4
+ */
5
+
6
+ import type { Catalyst } from '@/types';
7
+ import type { FacetType } from '@/schema/schemas';
8
+ import { FACET_TYPES } from '@/schema/schemas';
9
+
10
+ /**
11
+ * A single piece of content with source attribution
12
+ */
13
+ export interface AttributedContent {
14
+ content: string;
15
+ sourceId: string;
16
+ filename?: string;
17
+ }
18
+
19
+ /**
20
+ * Facets merged from multiple catalysts with source attribution
21
+ */
22
+ export interface MergedFacets {
23
+ questions?: AttributedContent[];
24
+ constraints?: AttributedContent[];
25
+ outputTemplates?: AttributedContent[];
26
+ domainKnowledge?: AttributedContent[];
27
+ processGuidance?: AttributedContent[];
28
+ validationRules?: AttributedContent[];
29
+ }
30
+
31
+ /**
32
+ * Result of merging multiple catalysts
33
+ */
34
+ export interface MergedCatalyst {
35
+ /** Ordered list of catalyst IDs that were merged */
36
+ catalystIds: string[];
37
+ /** Merged facets with source attribution */
38
+ facets: MergedFacets;
39
+ /** Metadata about each catalyst's contribution */
40
+ contributions: Map<string, {
41
+ facetTypes: FacetType[];
42
+ contentCount: number;
43
+ }>;
44
+ }
45
+
46
+ /**
47
+ * Merge multiple catalysts in order
48
+ *
49
+ * Catalysts are merged in the order provided, with later catalysts
50
+ * layering on top of earlier ones. Content is concatenated (no conflict
51
+ * resolution in v1), and each piece of content retains its source catalyst ID.
52
+ *
53
+ * @param catalysts - Ordered array of catalysts to merge (can be empty)
54
+ * @returns Merged catalyst with source attribution
55
+ *
56
+ * @example
57
+ * ```typescript
58
+ * const catalyst1 = await loadCatalyst('./base-catalyst');
59
+ * const catalyst2 = await loadCatalyst('./nodejs-catalyst');
60
+ * const merged = mergeCatalysts([catalyst1, catalyst2]);
61
+ * ```
62
+ */
63
+ export function mergeCatalysts(catalysts: Catalyst[]): MergedCatalyst {
64
+ const mergedFacets: MergedFacets = {};
65
+ const catalystIds = catalysts.map(c => c.manifest.id);
66
+ const contributions = new Map<string, {
67
+ facetTypes: FacetType[];
68
+ contentCount: number;
69
+ }>();
70
+
71
+ // Process each facet type
72
+ for (const facetType of FACET_TYPES) {
73
+ const mergedContent: AttributedContent[] = [];
74
+
75
+ // Iterate through catalysts in order
76
+ for (const catalyst of catalysts) {
77
+ const facetContent = catalyst.facets[facetType];
78
+
79
+ if (facetContent && facetContent.length > 0) {
80
+ // Add each file's content with source attribution
81
+ for (const file of facetContent) {
82
+ mergedContent.push({
83
+ content: file.content,
84
+ sourceId: catalyst.manifest.id,
85
+ filename: file.filename,
86
+ });
87
+ }
88
+
89
+ // Track contribution
90
+ const contrib = contributions.get(catalyst.manifest.id) || {
91
+ facetTypes: [],
92
+ contentCount: 0,
93
+ };
94
+ if (!contrib.facetTypes.includes(facetType)) {
95
+ contrib.facetTypes.push(facetType);
96
+ }
97
+ contrib.contentCount += facetContent.length;
98
+ contributions.set(catalyst.manifest.id, contrib);
99
+ }
100
+ }
101
+
102
+ // Only set facet type if there's content
103
+ if (mergedContent.length > 0) {
104
+ mergedFacets[facetType] = mergedContent;
105
+ }
106
+ }
107
+
108
+ return {
109
+ catalystIds,
110
+ facets: mergedFacets,
111
+ contributions,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Format a facet type name for display
117
+ */
118
+ function formatFacetName(facetType: FacetType): string {
119
+ const names: Record<FacetType, string> = {
120
+ questions: 'Questions',
121
+ constraints: 'Constraints',
122
+ outputTemplates: 'Output Templates',
123
+ domainKnowledge: 'Domain Knowledge',
124
+ processGuidance: 'Process Guidance',
125
+ validationRules: 'Validation Rules',
126
+ };
127
+ return names[facetType];
128
+ }
129
+
130
+ /**
131
+ * Render merged facet content into a prompt-ready string
132
+ *
133
+ * Groups content by catalyst source and formats it for use in AI prompts.
134
+ * Each source is labeled and content is separated for readability.
135
+ *
136
+ * @param merged - Merged catalyst
137
+ * @param facetType - Facet type to render
138
+ * @returns Formatted string ready for prompt injection, or empty string if no content
139
+ *
140
+ * @example
141
+ * ```typescript
142
+ * const merged = mergeCatalysts([catalyst1, catalyst2]);
143
+ * const constraints = renderFacet(merged, 'constraints');
144
+ * // Returns:
145
+ * // From @kjerneverk/base-catalyst:
146
+ * // [content from first catalyst]
147
+ * // From @kjerneverk/nodejs-catalyst:
148
+ * // [content from second catalyst]
149
+ * ```
150
+ */
151
+ export function renderFacet(
152
+ merged: MergedCatalyst,
153
+ facetType: FacetType
154
+ ): string {
155
+ const content = merged.facets[facetType];
156
+
157
+ if (!content || content.length === 0) {
158
+ return '';
159
+ }
160
+
161
+ const lines: string[] = [];
162
+ let currentSource = '';
163
+
164
+ for (const item of content) {
165
+ // Add source header when it changes
166
+ if (item.sourceId !== currentSource) {
167
+ if (lines.length > 0) {
168
+ lines.push(''); // Blank line between sources
169
+ }
170
+ lines.push(`From ${item.sourceId}:`);
171
+ currentSource = item.sourceId;
172
+ }
173
+
174
+ // Add content
175
+ lines.push(item.content);
176
+ }
177
+
178
+ return lines.join('\n');
179
+ }
180
+
181
+ /**
182
+ * Render all facets from a merged catalyst
183
+ *
184
+ * Returns an object with each facet type mapped to its rendered string.
185
+ * Empty strings for facets with no content.
186
+ *
187
+ * @param merged - Merged catalyst
188
+ * @returns Object with all facets rendered
189
+ */
190
+ export function renderAllFacets(merged: MergedCatalyst): Record<FacetType, string> {
191
+ const rendered: Partial<Record<FacetType, string>> = {};
192
+
193
+ for (const facetType of FACET_TYPES) {
194
+ rendered[facetType] = renderFacet(merged, facetType);
195
+ }
196
+
197
+ return rendered as Record<FacetType, string>;
198
+ }
199
+
200
+ /**
201
+ * Generate a summary of merged catalysts
202
+ *
203
+ * Returns human-readable text about what was merged and what each
204
+ * catalyst contributed.
205
+ *
206
+ * @param merged - Merged catalyst
207
+ * @returns Summary string
208
+ */
209
+ export function summarizeMerge(merged: MergedCatalyst): string {
210
+ if (merged.catalystIds.length === 0) {
211
+ return 'No catalysts merged';
212
+ }
213
+
214
+ const lines: string[] = [];
215
+ lines.push(`Merged ${merged.catalystIds.length} catalyst(s):`);
216
+ lines.push('');
217
+
218
+ for (const [catalystId, contrib] of merged.contributions) {
219
+ lines.push(`- ${catalystId}:`);
220
+ lines.push(` - Facets: ${contrib.facetTypes.map(t => formatFacetName(t)).join(', ')}`);
221
+ lines.push(` - Content items: ${contrib.contentCount}`);
222
+ }
223
+
224
+ return lines.join('\n');
225
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @packageDocumentation
3
+ * Catalyst system for RiotPlan - composable, layerable guidance packages for plan creation
4
+ */
5
+
6
+ // Type exports
7
+ export type {
8
+ Catalyst,
9
+ CatalystManifest,
10
+ CatalystFacets,
11
+ FacetContent,
12
+ CatalystLoadResult,
13
+ CatalystLoadOptions,
14
+ FacetDirectoryMap,
15
+ PlanManifest,
16
+ } from './types';
17
+
18
+ // Schema exports
19
+ export {
20
+ CatalystManifestSchema,
21
+ PlanManifestSchema,
22
+ FacetsDeclarationSchema,
23
+ FACET_DIRECTORIES,
24
+ FACET_TYPES,
25
+ } from '@/schema/schemas';
26
+
27
+ export type {
28
+ FacetType,
29
+ CatalystManifestInput,
30
+ CatalystManifestOutput,
31
+ PlanManifestInput,
32
+ PlanManifestOutput,
33
+ FacetsDeclaration,
34
+ } from '@/schema/schemas';
35
+
36
+ // Loader exports
37
+ export {
38
+ loadCatalyst,
39
+ loadCatalystSafe,
40
+ resolveCatalysts,
41
+ } from '@/loader/catalyst-loader';
42
+
43
+ // Merger exports
44
+ export {
45
+ mergeCatalysts,
46
+ renderFacet,
47
+ renderAllFacets,
48
+ summarizeMerge,
49
+ } from '@/merger/facet-merger';
50
+ export type { MergedCatalyst, AttributedContent, MergedFacets } from '@/merger/facet-merger';
51
+
52
+ // Plan manifest exports
53
+ export {
54
+ readPlanManifest,
55
+ writePlanManifest,
56
+ updatePlanManifest,
57
+ addCatalystToManifest,
58
+ removeCatalystFromManifest,
59
+ } from '@/loader/plan-manifest';
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Zod schemas for catalyst.yml and plan.yaml manifests
3
+ * @packageDocumentation
4
+ */
5
+
6
+ import { z } from 'zod';
7
+
8
+ /**
9
+ * Facet directory names as they appear on disk
10
+ */
11
+ export const FACET_DIRECTORIES = {
12
+ questions: 'questions',
13
+ constraints: 'constraints',
14
+ outputTemplates: 'output-templates',
15
+ domainKnowledge: 'domain-knowledge',
16
+ processGuidance: 'process-guidance',
17
+ validationRules: 'validation-rules',
18
+ } as const;
19
+
20
+ /**
21
+ * All valid facet types
22
+ */
23
+ export const FACET_TYPES = [
24
+ 'questions',
25
+ 'constraints',
26
+ 'outputTemplates',
27
+ 'domainKnowledge',
28
+ 'processGuidance',
29
+ 'validationRules',
30
+ ] as const;
31
+
32
+ export type FacetType = typeof FACET_TYPES[number];
33
+
34
+ /**
35
+ * Schema for the facets declaration in catalyst.yml
36
+ * Each facet can be declared as present (true) or explicitly absent (false)
37
+ */
38
+ export const FacetsDeclarationSchema = z.object({
39
+ questions: z.boolean().optional().describe('Whether this catalyst provides guiding questions'),
40
+ constraints: z.boolean().optional().describe('Whether this catalyst provides constraints/rules'),
41
+ outputTemplates: z.boolean().optional().describe('Whether this catalyst provides output templates'),
42
+ domainKnowledge: z.boolean().optional().describe('Whether this catalyst provides domain knowledge'),
43
+ processGuidance: z.boolean().optional().describe('Whether this catalyst provides process guidance'),
44
+ validationRules: z.boolean().optional().describe('Whether this catalyst provides validation rules'),
45
+ });
46
+
47
+ /**
48
+ * Semver pattern for version validation
49
+ * Matches: 1.0.0, 1.0.0-dev.0, 1.0.0-alpha.1, etc.
50
+ */
51
+ const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[\w.]+)?$/;
52
+
53
+ /**
54
+ * NPM package name pattern
55
+ * Matches: @scope/name, name, @scope/name-with-dashes
56
+ */
57
+ const NPM_PACKAGE_PATTERN = /^(@[\w-]+\/)?[\w-]+$/;
58
+
59
+ /**
60
+ * Schema for catalyst.yml manifest file
61
+ *
62
+ * The manifest defines the catalyst's identity and declares which facets it provides.
63
+ * Facets are optional - a catalyst can provide any subset of the six facet types.
64
+ */
65
+ export const CatalystManifestSchema = z.object({
66
+ /**
67
+ * Catalyst identifier - should match NPM package name
68
+ * @example "@kjerneverk/catalyst-nodejs"
69
+ */
70
+ id: z.string()
71
+ .regex(NPM_PACKAGE_PATTERN, 'ID must be a valid NPM package name (e.g., @scope/name or name)')
72
+ .describe('Catalyst identifier (NPM package name)'),
73
+
74
+ /** Human-readable name for display */
75
+ name: z.string()
76
+ .min(1, 'Name cannot be empty')
77
+ .describe('Human-readable name'),
78
+
79
+ /** Description of what this catalyst provides */
80
+ description: z.string()
81
+ .min(1, 'Description cannot be empty')
82
+ .describe('What this catalyst provides'),
83
+
84
+ /**
85
+ * Semver version string
86
+ * @example "1.0.0" or "1.0.0-dev.0"
87
+ */
88
+ version: z.string()
89
+ .regex(SEMVER_PATTERN, 'Version must be valid semver (e.g., 1.0.0 or 1.0.0-dev.0)')
90
+ .describe('Semver version'),
91
+
92
+ /**
93
+ * Declaration of which facets this catalyst provides
94
+ * If omitted, facets are auto-detected from directory structure
95
+ */
96
+ facets: FacetsDeclarationSchema.optional()
97
+ .describe('Declaration of which facets this catalyst provides'),
98
+ });
99
+
100
+ /**
101
+ * Schema for plan.yaml manifest file
102
+ *
103
+ * The plan manifest gives each plan an identity and records which catalysts
104
+ * are associated with it.
105
+ */
106
+ export const PlanManifestSchema = z.object({
107
+ /** Plan identifier (typically kebab-case) */
108
+ id: z.string()
109
+ .min(1, 'ID cannot be empty')
110
+ .describe('Plan identifier'),
111
+
112
+ /** Human-readable title */
113
+ title: z.string()
114
+ .min(1, 'Title cannot be empty')
115
+ .describe('Human-readable title'),
116
+
117
+ /**
118
+ * Ordered list of catalyst IDs associated with this plan
119
+ * Catalysts are applied in order (first = base, last = top layer)
120
+ */
121
+ catalysts: z.array(z.string())
122
+ .optional()
123
+ .describe('Ordered list of catalyst IDs'),
124
+
125
+ /** ISO timestamp of when the plan was created */
126
+ created: z.string()
127
+ .optional()
128
+ .describe('ISO timestamp of creation'),
129
+
130
+ /** Extensible metadata for future use */
131
+ metadata: z.record(z.string())
132
+ .optional()
133
+ .describe('Extensible metadata'),
134
+ });
135
+
136
+ /**
137
+ * Inferred TypeScript types from Zod schemas
138
+ */
139
+ export type CatalystManifestInput = z.input<typeof CatalystManifestSchema>;
140
+ export type CatalystManifestOutput = z.output<typeof CatalystManifestSchema>;
141
+ export type PlanManifestInput = z.input<typeof PlanManifestSchema>;
142
+ export type PlanManifestOutput = z.output<typeof PlanManifestSchema>;
143
+ export type FacetsDeclaration = z.infer<typeof FacetsDeclarationSchema>;