Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions openspec/changes/add-per-change-schema-metadata/tasks.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,29 @@
## 1. Zod Schema and Types

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

## 2. Core Metadata Functions

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

## 3. Auto-Detection in Instruction Loader

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

## 4. CLI Updates

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

## 5. Tests

- [ ] 5.1 Test `ChangeMetadataSchema` validates correctly (valid/invalid cases)
- [ ] 5.2 Test `writeChangeMetadata()` creates valid YAML
- [ ] 5.3 Test `readChangeMetadata()` parses and validates schema
- [ ] 5.4 Test `loadChangeContext()` auto-detects schema from metadata
- [ ] 5.5 Test fallback to default when no metadata exists
- [ ] 5.6 Test `--schema` flag overrides metadata
- [x] 5.1 Test `ChangeMetadataSchema` validates correctly (valid/invalid cases)
- [x] 5.2 Test `writeChangeMetadata()` creates valid YAML
- [x] 5.3 Test `readChangeMetadata()` parses and validates schema
- [x] 5.4 Test `loadChangeContext()` auto-detects schema from metadata
- [x] 5.5 Test fallback to default when no metadata exists
- [x] 5.6 Test `--schema` flag overrides metadata
56 changes: 40 additions & 16 deletions src/commands/artifact-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,9 +187,14 @@ async function statusCommand(options: StatusOptions): Promise<void> {
try {
const projectRoot = process.cwd();
const changeName = await validateChangeExists(options.change, projectRoot);
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);

const context = loadChangeContext(projectRoot, changeName, schemaName);
// Validate schema if explicitly provided
if (options.schema) {
validateSchemaExists(options.schema);
}

// loadChangeContext will auto-detect schema from metadata if not provided
const context = loadChangeContext(projectRoot, changeName, options.schema);
const status = formatChangeStatus(context);

spinner.stop();
Expand Down Expand Up @@ -252,26 +257,30 @@ async function instructionsCommand(
try {
const projectRoot = process.cwd();
const changeName = await validateChangeExists(options.change, projectRoot);
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);

// Validate schema if explicitly provided
if (options.schema) {
validateSchemaExists(options.schema);
}

// loadChangeContext will auto-detect schema from metadata if not provided
const context = loadChangeContext(projectRoot, changeName, options.schema);

if (!artifactId) {
spinner.stop();
const schema = resolveSchema(schemaName);
const graph = ArtifactGraph.fromSchema(schema);
const validIds = graph.getAllArtifacts().map((a) => a.id);
const validIds = context.graph.getAllArtifacts().map((a) => a.id);
throw new Error(
`Missing required argument <artifact>. Valid artifacts:\n ${validIds.join('\n ')}`
);
}

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

if (!artifact) {
spinner.stop();
const validIds = context.graph.getAllArtifacts().map((a) => a.id);
throw new Error(
`Artifact '${artifactId}' not found in schema '${schemaName}'. Valid artifacts:\n ${validIds.join('\n ')}`
`Artifact '${artifactId}' not found in schema '${context.schemaName}'. Valid artifacts:\n ${validIds.join('\n ')}`
);
}

Expand Down Expand Up @@ -424,8 +433,9 @@ function parseTasksFile(content: string): TaskItem[] {
async function generateApplyInstructions(
projectRoot: string,
changeName: string,
schemaName: string
schemaName?: string
): Promise<ApplyInstructions> {
// loadChangeContext will auto-detect schema from metadata if not provided
const context = loadChangeContext(projectRoot, changeName, schemaName);
const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);

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

const instructions = await generateApplyInstructions(projectRoot, changeName, schemaName);
// Validate schema if explicitly provided
if (options.schema) {
validateSchemaExists(options.schema);
}

// generateApplyInstructions uses loadChangeContext which auto-detects schema
const instructions = await generateApplyInstructions(projectRoot, changeName, options.schema);

spinner.stop();

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

interface NewChangeOptions {
description?: string;
schema?: string;
}

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

const spinner = ora(`Creating change '${name}'...`).start();
// Validate schema if provided
if (options.schema) {
validateSchemaExists(options.schema);
}

const schemaDisplay = options.schema ? ` with schema '${options.schema}'` : '';
const spinner = ora(`Creating change '${name}'${schemaDisplay}...`).start();

try {
const projectRoot = process.cwd();
await createChange(projectRoot, name);
await createChange(projectRoot, name, { schema: options.schema });

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

spinner.succeed(`Created change '${name}' at openspec/changes/${name}/`);
const schemaUsed = options.schema ?? DEFAULT_SCHEMA;
spinner.succeed(`Created change '${name}' at openspec/changes/${name}/ (schema: ${schemaUsed})`);
} catch (error) {
spinner.fail(`Failed to create change '${name}'`);
throw error;
Expand Down Expand Up @@ -811,7 +834,7 @@ export function registerArtifactWorkflowCommands(program: Command): void {
.command('status')
.description('[Experimental] Display artifact completion status for a change')
.option('--change <id>', 'Change name to show status for')
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
.option('--schema <name>', 'Schema override (auto-detected from .openspec.yaml)')
.option('--json', 'Output as JSON')
.action(async (options: StatusOptions) => {
try {
Expand All @@ -828,7 +851,7 @@ export function registerArtifactWorkflowCommands(program: Command): void {
.command('instructions [artifact]')
.description('[Experimental] Output enriched instructions for creating an artifact or applying tasks')
.option('--change <id>', 'Change name')
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
.option('--schema <name>', 'Schema override (auto-detected from .openspec.yaml)')
.option('--json', 'Output as JSON')
.action(async (artifactId: string | undefined, options: InstructionsOptions) => {
try {
Expand Down Expand Up @@ -868,6 +891,7 @@ export function registerArtifactWorkflowCommands(program: Command): void {
.command('change <name>')
.description('[Experimental] Create a new change directory')
.option('--description <text>', 'Description to add to README.md')
.option('--schema <name>', `Workflow schema to use (default: ${DEFAULT_SCHEMA})`)
.action(async (name: string, options: NewChangeOptions) => {
try {
await newChangeCommand(name, options);
Expand Down
20 changes: 15 additions & 5 deletions src/core/artifact-graph/instruction-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as path from 'node:path';
import { getSchemaDir, resolveSchema } from './resolver.js';
import { ArtifactGraph } from './graph.js';
import { detectCompleted } from './state.js';
import { resolveSchemaForChange } from '../../utils/change-metadata.js';
import type { Artifact, CompletedSet } from './types.js';

/**
Expand Down Expand Up @@ -142,25 +143,34 @@ export function loadTemplate(schemaName: string, templatePath: string): string {
/**
* Loads change context combining graph and completion state.
*
* Schema resolution order:
* 1. Explicit schemaName parameter (if provided)
* 2. Schema from .openspec.yaml metadata (if exists in change directory)
* 3. Default 'spec-driven'
*
* @param projectRoot - Project root directory
* @param changeName - Change name
* @param schemaName - Optional schema name (defaults to "spec-driven")
* @param schemaName - Optional schema name override. If not provided, auto-detected from metadata.
* @returns Change context with graph, completed set, and metadata
*/
export function loadChangeContext(
projectRoot: string,
changeName: string,
schemaName: string = 'spec-driven'
schemaName?: string
): ChangeContext {
const schema = resolveSchema(schemaName);
const graph = ArtifactGraph.fromSchema(schema);
const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);

// Resolve schema: explicit > metadata > default
const resolvedSchemaName = resolveSchemaForChange(changeDir, schemaName);

const schema = resolveSchema(resolvedSchemaName);
const graph = ArtifactGraph.fromSchema(schema);
const completed = detectCompleted(graph, changeDir);

return {
graph,
completed,
schemaName,
schemaName: resolvedSchemaName,
changeName,
changeDir,
};
Expand Down
18 changes: 18 additions & 0 deletions src/core/artifact-graph/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,24 @@ export const SchemaYamlSchema = z.object({
export type Artifact = z.infer<typeof ArtifactSchema>;
export type SchemaYaml = z.infer<typeof SchemaYamlSchema>;

// Per-change metadata schema
// Note: schema field is validated at parse time against available schemas
// using a lazy import to avoid circular dependencies
export const ChangeMetadataSchema = z.object({
// Required: which workflow schema this change uses
schema: z.string().min(1, { message: 'schema is required' }),

// Optional: creation timestamp (ISO date string)
created: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, {
message: 'created must be YYYY-MM-DD format',
})
.optional(),
});

export type ChangeMetadata = z.infer<typeof ChangeMetadataSchema>;

// Runtime state types (not Zod - internal only)

// Slice 1: Simple completion tracking via filesystem
Expand Down
Loading
Loading