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
36 changes: 18 additions & 18 deletions openspec/changes/make-apply-instructions-schema-aware/tasks.md
Original file line number Diff line number Diff line change
@@ -1,35 +1,35 @@
## Prerequisites

- [ ] 0.1 Implement `add-per-change-schema-metadata` first (to auto-detect schema)
- [x] 0.1 Implement `add-per-change-schema-metadata` first (to auto-detect schema)

## 1. Schema Format

- [ ] 1.1 Add `ApplyPhaseSchema` Zod schema to `src/core/artifact-graph/types.ts`
- [ ] 1.2 Update `SchemaYamlSchema` to include optional `apply` field
- [ ] 1.3 Export `ApplyPhase` type
- [x] 1.1 Add `ApplyPhaseSchema` Zod schema to `src/core/artifact-graph/types.ts`
- [x] 1.2 Update `SchemaYamlSchema` to include optional `apply` field
- [x] 1.3 Export `ApplyPhase` type

## 2. Update Existing Schemas

- [ ] 2.1 Add `apply` block to `schemas/spec-driven/schema.yaml`
- [ ] 2.2 Add `apply` block to `schemas/tdd/schema.yaml`
- [x] 2.1 Add `apply` block to `schemas/spec-driven/schema.yaml`
- [x] 2.2 Add `apply` block to `schemas/tdd/schema.yaml`

## 3. Refactor generateApplyInstructions

- [ ] 3.1 Load schema via `resolveSchema(schemaName)`
- [ ] 3.2 Read `apply.requires` to determine required artifacts
- [ ] 3.3 Check artifact existence dynamically (not hardcoded paths)
- [ ] 3.4 Use `apply.tracks` for progress tracking (or skip if null)
- [ ] 3.5 Use `apply.instruction` for the instruction text
- [ ] 3.6 Build `contextFiles` from all existing artifacts in schema
- [x] 3.1 Load schema via `resolveSchema(schemaName)`
- [x] 3.2 Read `apply.requires` to determine required artifacts
- [x] 3.3 Check artifact existence dynamically (not hardcoded paths)
- [x] 3.4 Use `apply.tracks` for progress tracking (or skip if null)
- [x] 3.5 Use `apply.instruction` for the instruction text
- [x] 3.6 Build `contextFiles` from all existing artifacts in schema

## 4. Handle Fallback

- [ ] 4.1 If schema has no `apply` block, require all artifacts to exist
- [ ] 4.2 Default instruction: "All artifacts complete. Proceed with implementation."
- [x] 4.1 If schema has no `apply` block, require all artifacts to exist
- [x] 4.2 Default instruction: "All artifacts complete. Proceed with implementation."

## 5. Tests

- [ ] 5.1 Test apply instructions with spec-driven schema
- [ ] 5.2 Test apply instructions with tdd schema
- [ ] 5.3 Test fallback when schema has no apply block
- [ ] 5.4 Test blocked state when required artifacts missing
- [x] 5.1 Test apply instructions with spec-driven schema
- [x] 5.2 Test apply instructions with tdd schema
- [x] 5.3 Test fallback when schema has no apply block
- [x] 5.4 Test blocked state when required artifacts missing
7 changes: 7 additions & 0 deletions schemas/spec-driven/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,10 @@ artifacts:
requires:
- specs
- design

apply:
requires: [tasks]
tracks: tasks.md
instruction: |
Read context files, work through pending tasks, mark complete as you go.
Pause if you hit blockers or need clarification.
7 changes: 7 additions & 0 deletions schemas/tdd/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -204,3 +204,10 @@ artifacts:
Reference the spec for requirements, implementation for details.
requires:
- implementation

apply:
requires: [tests]
tracks: null
instruction: |
Run tests to see failures. Implement minimal code to pass each test.
Refactor while keeping tests green.
191 changes: 134 additions & 57 deletions src/commands/artifact-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,8 @@ interface TaskItem {
interface ApplyInstructions {
changeName: string;
changeDir: string;
contextFiles: {
proposal?: string;
specs: string;
design?: string;
tasks: string;
};
schemaName: string;
contextFiles: Record<string, string>;
progress: {
total: number;
complete: number;
Expand Down Expand Up @@ -429,8 +425,72 @@ function parseTasksFile(content: string): TaskItem[] {
return tasks;
}

/**
* Checks if an artifact output exists in the change directory.
* Supports glob patterns (e.g., "specs/*.md") by verifying at least one matching file exists.
*/
function artifactOutputExists(changeDir: string, generates: string): boolean {
// Normalize the generates path to use platform-specific separators
const normalizedGenerates = generates.split('/').join(path.sep);
const fullPath = path.join(changeDir, normalizedGenerates);

// If it's a glob pattern (contains ** or *), check for matching files
if (generates.includes('*')) {
// Extract the directory part before the glob pattern
const parts = normalizedGenerates.split(path.sep);
const dirParts: string[] = [];
let patternPart = '';
for (const part of parts) {
if (part.includes('*')) {
patternPart = part;
break;
}
dirParts.push(part);
}
const dirPath = path.join(changeDir, ...dirParts);

// Check if directory exists
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
return false;
}

// Extract expected extension from pattern (e.g., "*.md" -> ".md")
const extMatch = patternPart.match(/\*(\.[a-zA-Z0-9]+)$/);
const expectedExt = extMatch ? extMatch[1] : null;

// Recursively check for matching files
const hasMatchingFiles = (dir: string): boolean => {
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
// For ** patterns, recurse into subdirectories
if (generates.includes('**') && hasMatchingFiles(path.join(dir, entry.name))) {
return true;
}
} else if (entry.isFile()) {
// Check if file matches expected extension (or any file if no extension specified)
if (!expectedExt || entry.name.endsWith(expectedExt)) {
return true;
}
}
}
} catch {
return false;
}
return false;
};

return hasMatchingFiles(dirPath);
}

return fs.existsSync(fullPath);
}

/**
* Generates apply instructions for implementing tasks from a change.
* Schema-aware: reads apply phase configuration from schema to determine
* required artifacts, tracking file, and instruction.
*/
async function generateApplyInstructions(
projectRoot: string,
Expand All @@ -441,67 +501,83 @@ async function generateApplyInstructions(
const context = loadChangeContext(projectRoot, changeName, schemaName);
const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);

// Check if required artifacts exist (tasks.md is the minimum requirement)
const tasksPath = path.join(changeDir, 'tasks.md');
const proposalPath = path.join(changeDir, 'proposal.md');
const designPath = path.join(changeDir, 'design.md');
const specsPath = path.join(changeDir, 'specs');
// Get the full schema to access the apply phase configuration
const schema = resolveSchema(context.schemaName);
const applyConfig = schema.apply;

const hasProposal = fs.existsSync(proposalPath);
const hasDesign = fs.existsSync(designPath);
const hasTasks = fs.existsSync(tasksPath);
const hasSpecs = fs.existsSync(specsPath);
// Determine required artifacts and tracking file from schema
// Fallback: if no apply block, require all artifacts
const requiredArtifactIds = applyConfig?.requires ?? schema.artifacts.map((a) => a.id);
const tracksFile = applyConfig?.tracks ?? null;
const schemaInstruction = applyConfig?.instruction ?? null;

// Determine state and missing artifacts
// Check which required artifacts are missing
const missingArtifacts: string[] = [];
if (!hasTasks) {
// Check what's missing to create tasks (design is optional)
if (!hasProposal) missingArtifacts.push('proposal');
if (!hasSpecs) missingArtifacts.push('specs');
if (missingArtifacts.length === 0) missingArtifacts.push('tasks');
for (const artifactId of requiredArtifactIds) {
const artifact = schema.artifacts.find((a) => a.id === artifactId);
if (artifact && !artifactOutputExists(changeDir, artifact.generates)) {
missingArtifacts.push(artifactId);
}
}

// Build context files object
const contextFiles: ApplyInstructions['contextFiles'] = {
specs: path.join(changeDir, 'specs/**/*.md'),
tasks: tasksPath,
};
if (hasProposal) contextFiles.proposal = proposalPath;
if (hasDesign) contextFiles.design = designPath;
// Build context files from all existing artifacts in schema
const contextFiles: Record<string, string> = {};
for (const artifact of schema.artifacts) {
if (artifactOutputExists(changeDir, artifact.generates)) {
contextFiles[artifact.id] = path.join(changeDir, artifact.generates);
}
}

// Parse tasks if file exists
// Parse tasks if tracking file exists
let tasks: TaskItem[] = [];
if (hasTasks) {
const tasksContent = await fs.promises.readFile(tasksPath, 'utf-8');
tasks = parseTasksFile(tasksContent);
let tracksFileExists = false;
if (tracksFile) {
const tracksPath = path.join(changeDir, tracksFile);
tracksFileExists = fs.existsSync(tracksPath);
if (tracksFileExists) {
const tasksContent = await fs.promises.readFile(tracksPath, 'utf-8');
tasks = parseTasksFile(tasksContent);
}
}

// Calculate progress
const total = tasks.length;
const complete = tasks.filter((t) => t.done).length;
const remaining = total - complete;

// Determine state
// Determine state and instruction
let state: ApplyInstructions['state'];
let instruction: string;

if (!hasTasks || missingArtifacts.length > 0) {
if (missingArtifacts.length > 0) {
state = 'blocked';
instruction = `Cannot apply this change yet. Missing artifacts: ${missingArtifacts.join(', ')}.\nUse the openspec-continue-change skill to create the missing artifacts first.`;
} else if (remaining === 0 && total > 0) {
} else if (tracksFile && !tracksFileExists) {
// Tracking file configured but doesn't exist yet
const tracksFilename = path.basename(tracksFile);
state = 'blocked';
instruction = `The ${tracksFilename} file is missing and must be created.\nUse openspec-continue-change to generate the tracking file.`;
} else if (tracksFile && tracksFileExists && total === 0) {
// Tracking file exists but contains no tasks
const tracksFilename = path.basename(tracksFile);
state = 'blocked';
instruction = `The ${tracksFilename} file exists but contains no tasks.\nAdd tasks to ${tracksFilename} or regenerate it with openspec-continue-change.`;
} else if (tracksFile && remaining === 0 && total > 0) {
state = 'all_done';
instruction = 'All tasks are complete! This change is ready to be archived.\nConsider running tests and reviewing the changes before archiving.';
} else if (total === 0) {
state = 'blocked';
instruction = 'The tasks.md file exists but contains no tasks.\nAdd tasks to tasks.md or regenerate it with openspec-continue-change.';
} else if (!tracksFile) {
// No tracking file (e.g., TDD schema) - ready to apply
state = 'ready';
instruction = schemaInstruction?.trim() ?? 'All required artifacts complete. Proceed with implementation.';
} else {
state = 'ready';
instruction = 'Read context files, work through pending tasks, mark complete as you go.\nPause if you hit blockers or need clarification.';
instruction = schemaInstruction?.trim() ?? 'Read context files, work through pending tasks, mark complete as you go.\nPause if you hit blockers or need clarification.';
}

return {
changeName,
changeDir,
schemaName: context.schemaName,
contextFiles,
progress: { total, complete, remaining },
tasks,
Expand Down Expand Up @@ -541,9 +617,10 @@ async function applyInstructionsCommand(options: ApplyInstructionsOptions): Prom
}

function printApplyInstructionsText(instructions: ApplyInstructions): void {
const { changeName, contextFiles, progress, tasks, state, missingArtifacts, instruction } = instructions;
const { changeName, schemaName, contextFiles, progress, tasks, state, missingArtifacts, instruction } = instructions;

console.log(`## Apply: ${changeName}`);
console.log(`Schema: ${schemaName}`);
console.log();

// Warning for blocked state
Expand All @@ -555,26 +632,26 @@ function printApplyInstructionsText(instructions: ApplyInstructions): void {
console.log();
}

// Context files
console.log('### Context Files');
if (contextFiles.proposal) {
console.log(`- proposal: ${contextFiles.proposal}`);
}
console.log(`- specs: ${contextFiles.specs}`);
if (contextFiles.design) {
console.log(`- design: ${contextFiles.design}`);
// Context files (dynamically from schema)
const contextFileEntries = Object.entries(contextFiles);
if (contextFileEntries.length > 0) {
console.log('### Context Files');
for (const [artifactId, filePath] of contextFileEntries) {
console.log(`- ${artifactId}: ${filePath}`);
}
console.log();
}
console.log(`- tasks: ${contextFiles.tasks}`);
console.log();

// Progress
console.log('### Progress');
if (state === 'all_done') {
console.log(`${progress.complete}/${progress.total} complete ✓`);
} else {
console.log(`${progress.complete}/${progress.total} complete`);
// Progress (only show if we have tracking)
if (progress.total > 0 || tasks.length > 0) {
console.log('### Progress');
if (state === 'all_done') {
console.log(`${progress.complete}/${progress.total} complete ✓`);
} else {
console.log(`${progress.complete}/${progress.total} complete`);
}
console.log();
}
console.log();

// Tasks
if (tasks.length > 0) {
Expand Down
13 changes: 13 additions & 0 deletions src/core/artifact-graph/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,29 @@ export const ArtifactSchema = z.object({
requires: z.array(z.string()).default([]),
});

// Apply phase configuration for schema-aware apply instructions
export const ApplyPhaseSchema = z.object({
// Artifact IDs that must exist before apply is available
requires: z.array(z.string()).min(1, { error: 'At least one required artifact' }),
// Path to file with checkboxes for progress (relative to change dir), or null if no tracking
tracks: z.string().nullable().optional(),
// Custom guidance for the apply phase
instruction: z.string().optional(),
});

// Full schema YAML structure
export const SchemaYamlSchema = z.object({
name: z.string().min(1, { error: 'Schema name is required' }),
version: z.number().int().positive({ error: 'Version must be a positive integer' }),
description: z.string().optional(),
artifacts: z.array(ArtifactSchema).min(1, { error: 'At least one artifact required' }),
// Optional apply phase configuration (for schema-aware apply instructions)
apply: ApplyPhaseSchema.optional(),
});

// Derived TypeScript types
export type Artifact = z.infer<typeof ArtifactSchema>;
export type ApplyPhase = z.infer<typeof ApplyPhaseSchema>;
export type SchemaYaml = z.infer<typeof SchemaYamlSchema>;

// Per-change metadata schema
Expand Down
Loading
Loading