Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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.
147 changes: 91 additions & 56 deletions src/commands/artifact-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,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 @@ -427,8 +423,33 @@ 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 checking if the directory exists.
*/
function artifactOutputExists(changeDir: string, generates: string): boolean {
const fullPath = path.join(changeDir, generates);

// If it's a glob pattern (contains ** or *), check if the parent directory exists
if (generates.includes('*')) {
// Extract the directory part before the glob pattern
const parts = generates.split('/');
const dirParts: string[] = [];
for (const part of parts) {
if (part.includes('*')) break;
dirParts.push(part);
}
const dirPath = path.join(changeDir, ...dirParts);
return fs.existsSync(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 @@ -439,67 +460,80 @@ 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)) {
// Use glob pattern for specs-like artifacts, full path for others
if (artifact.generates.includes('*')) {
contextFiles[artifact.id] = path.join(changeDir, artifact.generates);
} else {
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);
if (tracksFile) {
const tracksPath = path.join(changeDir, tracksFile);
if (fs.existsSync(tracksPath)) {
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 && 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) {
} else if (tracksFile && total === 0) {
const tracksFilename = path.basename(tracksFile);
state = 'blocked';
instruction = 'The tasks.md file exists but contains no tasks.\nAdd tasks to tasks.md or regenerate it with openspec-continue-change.';
instruction = `The ${tracksFilename} file exists but contains no tasks.\nAdd tasks to ${tracksFilename} 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 @@ -539,9 +573,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 @@ -553,26 +588,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, { message: '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