Skip to content

Commit 1786684

Browse files
authored
feat: add per-change schema metadata (.openspec.yaml) (#443)
This feature enables workflow schema auto-detection for changes: - Add ChangeMetadataSchema Zod schema to types.ts - Create change-metadata.ts with writeChangeMetadata(), readChangeMetadata() - Update createChange() to accept optional schema param and write metadata - Modify loadChangeContext() to auto-detect schema from .openspec.yaml - Add --schema option to openspec new change command - Update status/instructions commands to auto-detect schema from metadata Schema resolution order: 1. Explicit --schema flag (if provided) 2. Schema from .openspec.yaml in change directory 3. Default 'spec-driven'
1 parent 51fb10d commit 1786684

File tree

10 files changed

+586
-40
lines changed

10 files changed

+586
-40
lines changed
Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,29 @@
11
## 1. Zod Schema and Types
22

3-
- [ ] 1.1 Add `ChangeMetadataSchema` Zod schema to `src/core/artifact-graph/types.ts`
4-
- [ ] 1.2 Export `ChangeMetadata` type inferred from schema
3+
- [x] 1.1 Add `ChangeMetadataSchema` Zod schema to `src/core/artifact-graph/types.ts`
4+
- [x] 1.2 Export `ChangeMetadata` type inferred from schema
55

66
## 2. Core Metadata Functions
77

8-
- [ ] 2.1 Create `src/utils/change-metadata.ts` with `writeChangeMetadata()` function
9-
- [ ] 2.2 Add `readChangeMetadata()` function with Zod validation
10-
- [ ] 2.3 Update `createChange()` to accept optional `schema` param and write metadata
8+
- [x] 2.1 Create `src/utils/change-metadata.ts` with `writeChangeMetadata()` function
9+
- [x] 2.2 Add `readChangeMetadata()` function with Zod validation
10+
- [x] 2.3 Update `createChange()` to accept optional `schema` param and write metadata
1111

1212
## 3. Auto-Detection in Instruction Loader
1313

14-
- [ ] 3.1 Modify `loadChangeContext()` to read schema from `.openspec.yaml`
15-
- [ ] 3.2 Make `schemaName` parameter optional (fall back to metadata, then default)
14+
- [x] 3.1 Modify `loadChangeContext()` to read schema from `.openspec.yaml`
15+
- [x] 3.2 Make `schemaName` parameter optional (fall back to metadata, then default)
1616

1717
## 4. CLI Updates
1818

19-
- [ ] 4.1 Add `--schema <name>` option to `openspec new change` command
20-
- [ ] 4.2 Verify existing commands (`status`, `instructions`) work with auto-detection
19+
- [x] 4.1 Add `--schema <name>` option to `openspec new change` command
20+
- [x] 4.2 Verify existing commands (`status`, `instructions`) work with auto-detection
2121

2222
## 5. Tests
2323

24-
- [ ] 5.1 Test `ChangeMetadataSchema` validates correctly (valid/invalid cases)
25-
- [ ] 5.2 Test `writeChangeMetadata()` creates valid YAML
26-
- [ ] 5.3 Test `readChangeMetadata()` parses and validates schema
27-
- [ ] 5.4 Test `loadChangeContext()` auto-detects schema from metadata
28-
- [ ] 5.5 Test fallback to default when no metadata exists
29-
- [ ] 5.6 Test `--schema` flag overrides metadata
24+
- [x] 5.1 Test `ChangeMetadataSchema` validates correctly (valid/invalid cases)
25+
- [x] 5.2 Test `writeChangeMetadata()` creates valid YAML
26+
- [x] 5.3 Test `readChangeMetadata()` parses and validates schema
27+
- [x] 5.4 Test `loadChangeContext()` auto-detects schema from metadata
28+
- [x] 5.5 Test fallback to default when no metadata exists
29+
- [x] 5.6 Test `--schema` flag overrides metadata

src/commands/artifact-workflow.ts

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -187,9 +187,14 @@ async function statusCommand(options: StatusOptions): Promise<void> {
187187
try {
188188
const projectRoot = process.cwd();
189189
const changeName = await validateChangeExists(options.change, projectRoot);
190-
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
191190

192-
const context = loadChangeContext(projectRoot, changeName, schemaName);
191+
// Validate schema if explicitly provided
192+
if (options.schema) {
193+
validateSchemaExists(options.schema);
194+
}
195+
196+
// loadChangeContext will auto-detect schema from metadata if not provided
197+
const context = loadChangeContext(projectRoot, changeName, options.schema);
193198
const status = formatChangeStatus(context);
194199

195200
spinner.stop();
@@ -252,26 +257,30 @@ async function instructionsCommand(
252257
try {
253258
const projectRoot = process.cwd();
254259
const changeName = await validateChangeExists(options.change, projectRoot);
255-
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
260+
261+
// Validate schema if explicitly provided
262+
if (options.schema) {
263+
validateSchemaExists(options.schema);
264+
}
265+
266+
// loadChangeContext will auto-detect schema from metadata if not provided
267+
const context = loadChangeContext(projectRoot, changeName, options.schema);
256268

257269
if (!artifactId) {
258270
spinner.stop();
259-
const schema = resolveSchema(schemaName);
260-
const graph = ArtifactGraph.fromSchema(schema);
261-
const validIds = graph.getAllArtifacts().map((a) => a.id);
271+
const validIds = context.graph.getAllArtifacts().map((a) => a.id);
262272
throw new Error(
263273
`Missing required argument <artifact>. Valid artifacts:\n ${validIds.join('\n ')}`
264274
);
265275
}
266276

267-
const context = loadChangeContext(projectRoot, changeName, schemaName);
268277
const artifact = context.graph.getArtifact(artifactId);
269278

270279
if (!artifact) {
271280
spinner.stop();
272281
const validIds = context.graph.getAllArtifacts().map((a) => a.id);
273282
throw new Error(
274-
`Artifact '${artifactId}' not found in schema '${schemaName}'. Valid artifacts:\n ${validIds.join('\n ')}`
283+
`Artifact '${artifactId}' not found in schema '${context.schemaName}'. Valid artifacts:\n ${validIds.join('\n ')}`
275284
);
276285
}
277286

@@ -424,8 +433,9 @@ function parseTasksFile(content: string): TaskItem[] {
424433
async function generateApplyInstructions(
425434
projectRoot: string,
426435
changeName: string,
427-
schemaName: string
436+
schemaName?: string
428437
): Promise<ApplyInstructions> {
438+
// loadChangeContext will auto-detect schema from metadata if not provided
429439
const context = loadChangeContext(projectRoot, changeName, schemaName);
430440
const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);
431441

@@ -505,9 +515,14 @@ async function applyInstructionsCommand(options: ApplyInstructionsOptions): Prom
505515
try {
506516
const projectRoot = process.cwd();
507517
const changeName = await validateChangeExists(options.change, projectRoot);
508-
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
509518

510-
const instructions = await generateApplyInstructions(projectRoot, changeName, schemaName);
519+
// Validate schema if explicitly provided
520+
if (options.schema) {
521+
validateSchemaExists(options.schema);
522+
}
523+
524+
// generateApplyInstructions uses loadChangeContext which auto-detects schema
525+
const instructions = await generateApplyInstructions(projectRoot, changeName, options.schema);
511526

512527
spinner.stop();
513528

@@ -640,6 +655,7 @@ async function templatesCommand(options: TemplatesOptions): Promise<void> {
640655

641656
interface NewChangeOptions {
642657
description?: string;
658+
schema?: string;
643659
}
644660

645661
async function newChangeCommand(name: string | undefined, options: NewChangeOptions): Promise<void> {
@@ -652,11 +668,17 @@ async function newChangeCommand(name: string | undefined, options: NewChangeOpti
652668
throw new Error(validation.error);
653669
}
654670

655-
const spinner = ora(`Creating change '${name}'...`).start();
671+
// Validate schema if provided
672+
if (options.schema) {
673+
validateSchemaExists(options.schema);
674+
}
675+
676+
const schemaDisplay = options.schema ? ` with schema '${options.schema}'` : '';
677+
const spinner = ora(`Creating change '${name}'${schemaDisplay}...`).start();
656678

657679
try {
658680
const projectRoot = process.cwd();
659-
await createChange(projectRoot, name);
681+
await createChange(projectRoot, name, { schema: options.schema });
660682

661683
// If description provided, create README.md with description
662684
if (options.description) {
@@ -666,7 +688,8 @@ async function newChangeCommand(name: string | undefined, options: NewChangeOpti
666688
await fs.writeFile(readmePath, `# ${name}\n\n${options.description}\n`, 'utf-8');
667689
}
668690

669-
spinner.succeed(`Created change '${name}' at openspec/changes/${name}/`);
691+
const schemaUsed = options.schema ?? DEFAULT_SCHEMA;
692+
spinner.succeed(`Created change '${name}' at openspec/changes/${name}/ (schema: ${schemaUsed})`);
670693
} catch (error) {
671694
spinner.fail(`Failed to create change '${name}'`);
672695
throw error;
@@ -811,7 +834,7 @@ export function registerArtifactWorkflowCommands(program: Command): void {
811834
.command('status')
812835
.description('[Experimental] Display artifact completion status for a change')
813836
.option('--change <id>', 'Change name to show status for')
814-
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
837+
.option('--schema <name>', 'Schema override (auto-detected from .openspec.yaml)')
815838
.option('--json', 'Output as JSON')
816839
.action(async (options: StatusOptions) => {
817840
try {
@@ -828,7 +851,7 @@ export function registerArtifactWorkflowCommands(program: Command): void {
828851
.command('instructions [artifact]')
829852
.description('[Experimental] Output enriched instructions for creating an artifact or applying tasks')
830853
.option('--change <id>', 'Change name')
831-
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
854+
.option('--schema <name>', 'Schema override (auto-detected from .openspec.yaml)')
832855
.option('--json', 'Output as JSON')
833856
.action(async (artifactId: string | undefined, options: InstructionsOptions) => {
834857
try {
@@ -868,6 +891,7 @@ export function registerArtifactWorkflowCommands(program: Command): void {
868891
.command('change <name>')
869892
.description('[Experimental] Create a new change directory')
870893
.option('--description <text>', 'Description to add to README.md')
894+
.option('--schema <name>', `Workflow schema to use (default: ${DEFAULT_SCHEMA})`)
871895
.action(async (name: string, options: NewChangeOptions) => {
872896
try {
873897
await newChangeCommand(name, options);

src/core/artifact-graph/instruction-loader.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as path from 'node:path';
33
import { getSchemaDir, resolveSchema } from './resolver.js';
44
import { ArtifactGraph } from './graph.js';
55
import { detectCompleted } from './state.js';
6+
import { resolveSchemaForChange } from '../../utils/change-metadata.js';
67
import type { Artifact, CompletedSet } from './types.js';
78

89
/**
@@ -142,25 +143,34 @@ export function loadTemplate(schemaName: string, templatePath: string): string {
142143
/**
143144
* Loads change context combining graph and completion state.
144145
*
146+
* Schema resolution order:
147+
* 1. Explicit schemaName parameter (if provided)
148+
* 2. Schema from .openspec.yaml metadata (if exists in change directory)
149+
* 3. Default 'spec-driven'
150+
*
145151
* @param projectRoot - Project root directory
146152
* @param changeName - Change name
147-
* @param schemaName - Optional schema name (defaults to "spec-driven")
153+
* @param schemaName - Optional schema name override. If not provided, auto-detected from metadata.
148154
* @returns Change context with graph, completed set, and metadata
149155
*/
150156
export function loadChangeContext(
151157
projectRoot: string,
152158
changeName: string,
153-
schemaName: string = 'spec-driven'
159+
schemaName?: string
154160
): ChangeContext {
155-
const schema = resolveSchema(schemaName);
156-
const graph = ArtifactGraph.fromSchema(schema);
157161
const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);
162+
163+
// Resolve schema: explicit > metadata > default
164+
const resolvedSchemaName = resolveSchemaForChange(changeDir, schemaName);
165+
166+
const schema = resolveSchema(resolvedSchemaName);
167+
const graph = ArtifactGraph.fromSchema(schema);
158168
const completed = detectCompleted(graph, changeDir);
159169

160170
return {
161171
graph,
162172
completed,
163-
schemaName,
173+
schemaName: resolvedSchemaName,
164174
changeName,
165175
changeDir,
166176
};

src/core/artifact-graph/types.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,24 @@ export const SchemaYamlSchema = z.object({
2222
export type Artifact = z.infer<typeof ArtifactSchema>;
2323
export type SchemaYaml = z.infer<typeof SchemaYamlSchema>;
2424

25+
// Per-change metadata schema
26+
// Note: schema field is validated at parse time against available schemas
27+
// using a lazy import to avoid circular dependencies
28+
export const ChangeMetadataSchema = z.object({
29+
// Required: which workflow schema this change uses
30+
schema: z.string().min(1, { message: 'schema is required' }),
31+
32+
// Optional: creation timestamp (ISO date string)
33+
created: z
34+
.string()
35+
.regex(/^\d{4}-\d{2}-\d{2}$/, {
36+
message: 'created must be YYYY-MM-DD format',
37+
})
38+
.optional(),
39+
});
40+
41+
export type ChangeMetadata = z.infer<typeof ChangeMetadataSchema>;
42+
2543
// Runtime state types (not Zod - internal only)
2644

2745
// Slice 1: Simple completion tracking via filesystem

0 commit comments

Comments
 (0)