diff --git a/openspec/changes/add-instruction-loader/tasks.md b/openspec/changes/add-instruction-loader/tasks.md deleted file mode 100644 index 5184a9c6..00000000 --- a/openspec/changes/add-instruction-loader/tasks.md +++ /dev/null @@ -1,34 +0,0 @@ -## 1. Template Loading - -- [ ] 1.1 Create `src/core/artifact-graph/template.ts` -- [ ] 1.2 Implement `loadTemplate(schemaName, templatePath)` using schema directory structure -- [ ] 1.3 Add tests for template loading from schema directory -- [ ] 1.4 Add tests for error when template not found - -## 2. Change Context - -- [ ] 2.1 Create `src/core/artifact-graph/context.ts` -- [ ] 2.2 Define `ChangeContext` interface -- [ ] 2.3 Implement `loadChangeContext()` function -- [ ] 2.4 Add tests for context loading with existing change -- [ ] 2.5 Add tests for context loading with missing change directory - -## 3. Instruction Enrichment - -- [ ] 3.1 Create `src/core/artifact-graph/instructions.ts` -- [ ] 3.2 Implement `getInstructions()` with header injection -- [ ] 3.3 Add dependency status formatting (done/missing) -- [ ] 3.4 Add next steps calculation -- [ ] 3.5 Add tests for enrichment output - -## 4. Status Formatting - -- [ ] 4.1 Implement `formatStatus()` function in instructions.ts -- [ ] 4.2 Format as markdown table with status and output path -- [ ] 4.3 Show blocked dependencies -- [ ] 4.4 Add tests for status formatting - -## 5. Integration - -- [ ] 5.1 Export new functions from `src/core/artifact-graph/index.ts` -- [ ] 5.2 Ensure all tests pass diff --git a/openspec/changes/add-instruction-loader/design.md b/openspec/changes/archive/2025-12-28-add-instruction-loader/design.md similarity index 100% rename from openspec/changes/add-instruction-loader/design.md rename to openspec/changes/archive/2025-12-28-add-instruction-loader/design.md diff --git a/openspec/changes/add-instruction-loader/proposal.md b/openspec/changes/archive/2025-12-28-add-instruction-loader/proposal.md similarity index 100% rename from openspec/changes/add-instruction-loader/proposal.md rename to openspec/changes/archive/2025-12-28-add-instruction-loader/proposal.md diff --git a/openspec/changes/add-instruction-loader/specs/instruction-loader/spec.md b/openspec/changes/archive/2025-12-28-add-instruction-loader/specs/instruction-loader/spec.md similarity index 76% rename from openspec/changes/add-instruction-loader/specs/instruction-loader/spec.md rename to openspec/changes/archive/2025-12-28-add-instruction-loader/specs/instruction-loader/spec.md index 261a1ee1..7dbe61d5 100644 --- a/openspec/changes/add-instruction-loader/specs/instruction-loader/spec.md +++ b/openspec/changes/archive/2025-12-28-add-instruction-loader/specs/instruction-loader/spec.md @@ -1,13 +1,18 @@ +# instruction-loader Specification + +## Purpose +Load templates from schema directories and enrich them with change-specific context for guiding artifact creation. + ## ADDED Requirements ### Requirement: Template Loading The system SHALL load templates from schema directories. -#### Scenario: Template loaded from schema directory +#### Scenario: Load template from schema directory - **WHEN** `loadTemplate(schemaName, templatePath)` is called - **THEN** the system loads the template from `schemas//templates/` -#### Scenario: Template not found +#### Scenario: Template file not found - **WHEN** a template file does not exist in the schema's templates directory - **THEN** the system throws an error with the template path @@ -22,44 +27,44 @@ The system SHALL load change context combining graph and completion state. - **WHEN** `loadChangeContext(projectRoot, changeName, schemaName)` is called - **THEN** the system uses the specified schema instead of default -#### Scenario: Load context for missing change +#### Scenario: Load context for non-existent change directory - **WHEN** `loadChangeContext` is called for a non-existent change directory - **THEN** the system returns context with empty completed set -### Requirement: Instruction Enrichment +### Requirement: Template Enrichment The system SHALL enrich templates with change-specific context. -#### Scenario: Header with change info +#### Scenario: Include artifact metadata - **WHEN** instructions are generated for an artifact - **THEN** the output includes change name, artifact ID, schema name, and output path -#### Scenario: Dependency status shown +#### Scenario: Include dependency status - **WHEN** an artifact has dependencies - **THEN** the output shows each dependency with completion status (done/missing) -#### Scenario: Next steps shown +#### Scenario: Include unlocked artifacts - **WHEN** instructions are generated - **THEN** the output includes which artifacts become available after this one -#### Scenario: Root artifact dependencies +#### Scenario: Root artifact indicator - **WHEN** an artifact has no dependencies - **THEN** the dependency section indicates this is a root artifact ### Requirement: Status Formatting The system SHALL format change status as readable output. -#### Scenario: Format complete change +#### Scenario: All artifacts completed - **WHEN** all artifacts are completed - **THEN** status shows all artifacts as "done" -#### Scenario: Format partial change +#### Scenario: Mixed completion status - **WHEN** some artifacts are completed - **THEN** status shows completed as "done", ready as "ready", blocked as "blocked" -#### Scenario: Show blocked dependencies +#### Scenario: Blocked artifact details - **WHEN** an artifact is blocked - **THEN** status shows which dependencies are missing -#### Scenario: Show output paths +#### Scenario: Include output paths - **WHEN** status is formatted - **THEN** each artifact shows its output path pattern diff --git a/openspec/changes/archive/2025-12-28-add-instruction-loader/tasks.md b/openspec/changes/archive/2025-12-28-add-instruction-loader/tasks.md new file mode 100644 index 00000000..a4dbc3ac --- /dev/null +++ b/openspec/changes/archive/2025-12-28-add-instruction-loader/tasks.md @@ -0,0 +1,13 @@ +# Tasks + +## Implementation Tasks + +- [x] Create `instruction-loader` spec in `openspec/specs/instruction-loader/spec.md` +- [x] Implement `loadTemplate` function to load templates from schema directories +- [x] Implement `loadChangeContext` function to combine graph and completion state +- [x] Implement `generateInstructions` function to enrich templates with change context +- [x] Implement `formatChangeStatus` function for readable status output +- [x] Export new functions from `src/core/artifact-graph/index.ts` +- [x] Add comprehensive tests in `test/core/artifact-graph/instruction-loader.test.ts` +- [x] Verify build passes +- [x] Verify all tests pass diff --git a/openspec/specs/instruction-loader/spec.md b/openspec/specs/instruction-loader/spec.md new file mode 100644 index 00000000..d2a473ec --- /dev/null +++ b/openspec/specs/instruction-loader/spec.md @@ -0,0 +1,70 @@ +# instruction-loader Specification + +## Purpose +The instruction-loader loads instruction templates from schema directories, validates and enriches them with metadata and parameters (such as change context and dependency status), and exposes them for use by downstream services including template retrieval, parameter substitution, and enrichment. + +## Requirements +### Requirement: Template Loading +The system SHALL load templates from schema directories. + +#### Scenario: Load template from schema directory +- **WHEN** `loadTemplate(schemaName, templatePath)` is called +- **THEN** the system loads the template from `schemas//templates/` + +#### Scenario: Template file not found +- **WHEN** a template file does not exist in the schema's templates directory +- **THEN** the system throws an error with the template path + +### Requirement: Change Context Loading +The system SHALL load change context combining graph and completion state. + +#### Scenario: Load context for existing change +- **WHEN** `loadChangeContext(projectRoot, changeName)` is called for an existing change +- **THEN** the system returns a context with graph, completed set, schema name, and change info + +#### Scenario: Load context with custom schema +- **WHEN** `loadChangeContext(projectRoot, changeName, schemaName)` is called +- **THEN** the system uses the specified schema instead of default + +#### Scenario: Load context for non-existent change directory +- **WHEN** `loadChangeContext` is called for a non-existent change directory +- **THEN** the system returns context with empty completed set + +### Requirement: Template Enrichment +The system SHALL enrich templates with change-specific context. + +#### Scenario: Include artifact metadata +- **WHEN** instructions are generated for an artifact +- **THEN** the output includes change name, artifact ID, schema name, and output path + +#### Scenario: Include dependency status +- **WHEN** an artifact has dependencies +- **THEN** the output shows each dependency with completion status (done/missing) + +#### Scenario: Include unlocked artifacts +- **WHEN** instructions are generated +- **THEN** the output includes which artifacts become available after this one + +#### Scenario: Root artifact indicator +- **WHEN** an artifact has no dependencies +- **THEN** the dependency section indicates this is a root artifact + +### Requirement: Status Formatting +The system SHALL format change status as readable output. + +#### Scenario: All artifacts completed +- **WHEN** all artifacts are completed +- **THEN** status shows all artifacts as "done" + +#### Scenario: Mixed completion status +- **WHEN** some artifacts are completed +- **THEN** status shows completed as "done", ready as "ready", blocked as "blocked" + +#### Scenario: Blocked artifact details +- **WHEN** an artifact is blocked +- **THEN** status shows which dependencies are missing + +#### Scenario: Include output paths +- **WHEN** status is formatted +- **THEN** each artifact shows its output path pattern + diff --git a/src/core/artifact-graph/index.ts b/src/core/artifact-graph/index.ts index 36fc0411..f88c3269 100644 --- a/src/core/artifact-graph/index.ts +++ b/src/core/artifact-graph/index.ts @@ -26,3 +26,17 @@ export { getUserSchemasDir, SchemaLoadError, } from './resolver.js'; + +// Instruction loading +export { + loadTemplate, + loadChangeContext, + generateInstructions, + formatChangeStatus, + TemplateLoadError, + type ChangeContext, + type ArtifactInstructions, + type DependencyStatus, + type ArtifactStatus, + type ChangeStatus, +} from './instruction-loader.js'; diff --git a/src/core/artifact-graph/instruction-loader.ts b/src/core/artifact-graph/instruction-loader.ts new file mode 100644 index 00000000..08cdb2de --- /dev/null +++ b/src/core/artifact-graph/instruction-loader.ts @@ -0,0 +1,269 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { getSchemaDir, resolveSchema } from './resolver.js'; +import { ArtifactGraph } from './graph.js'; +import { detectCompleted } from './state.js'; +import type { Artifact, CompletedSet } from './types.js'; + +/** + * Error thrown when loading a template fails. + */ +export class TemplateLoadError extends Error { + constructor( + message: string, + public readonly templatePath: string + ) { + super(message); + this.name = 'TemplateLoadError'; + } +} + +/** + * Change context containing graph, completion state, and metadata. + */ +export interface ChangeContext { + /** The artifact dependency graph */ + graph: ArtifactGraph; + /** Set of completed artifact IDs */ + completed: CompletedSet; + /** Schema name being used */ + schemaName: string; + /** Change name */ + changeName: string; + /** Path to the change directory */ + changeDir: string; +} + +/** + * Enriched instructions for creating an artifact. + */ +export interface ArtifactInstructions { + /** Change name */ + changeName: string; + /** Artifact ID */ + artifactId: string; + /** Schema name */ + schemaName: string; + /** Output path pattern (e.g., "proposal.md") */ + outputPath: string; + /** Artifact description */ + description: string; + /** Template content */ + template: string; + /** Dependencies with completion status */ + dependencies: DependencyStatus[]; + /** Artifacts that become available after completing this one */ + unlocks: string[]; +} + +/** + * Dependency status information. + */ +export interface DependencyStatus { + /** Artifact ID */ + id: string; + /** Whether the dependency is completed */ + done: boolean; +} + +/** + * Status of a single artifact in the workflow. + */ +export interface ArtifactStatus { + /** Artifact ID */ + id: string; + /** Output path pattern */ + outputPath: string; + /** Status: done, ready, or blocked */ + status: 'done' | 'ready' | 'blocked'; + /** Missing dependencies (only for blocked) */ + missingDeps?: string[]; +} + +/** + * Formatted change status. + */ +export interface ChangeStatus { + /** Change name */ + changeName: string; + /** Schema name */ + schemaName: string; + /** Whether all artifacts are complete */ + isComplete: boolean; + /** Status of each artifact */ + artifacts: ArtifactStatus[]; +} + +/** + * Loads a template from a schema's templates directory. + * + * @param schemaName - Schema name (e.g., "spec-driven") + * @param templatePath - Relative path within the templates directory (e.g., "proposal.md") + * @returns The template content + * @throws TemplateLoadError if the template cannot be loaded + */ +export function loadTemplate(schemaName: string, templatePath: string): string { + const schemaDir = getSchemaDir(schemaName); + if (!schemaDir) { + throw new TemplateLoadError( + `Schema '${schemaName}' not found`, + templatePath + ); + } + + const fullPath = path.join(schemaDir, 'templates', templatePath); + + if (!fs.existsSync(fullPath)) { + throw new TemplateLoadError( + `Template not found: ${fullPath}`, + fullPath + ); + } + + try { + return fs.readFileSync(fullPath, 'utf-8'); + } catch (err) { + const ioError = err instanceof Error ? err : new Error(String(err)); + throw new TemplateLoadError( + `Failed to read template: ${ioError.message}`, + fullPath + ); + } +} + +/** + * Loads change context combining graph and completion state. + * + * @param projectRoot - Project root directory + * @param changeName - Change name + * @param schemaName - Optional schema name (defaults to "spec-driven") + * @returns Change context with graph, completed set, and metadata + */ +export function loadChangeContext( + projectRoot: string, + changeName: string, + schemaName: string = 'spec-driven' +): ChangeContext { + const schema = resolveSchema(schemaName); + const graph = ArtifactGraph.fromSchema(schema); + const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName); + const completed = detectCompleted(graph, changeDir); + + return { + graph, + completed, + schemaName, + changeName, + changeDir, + }; +} + +/** + * Generates enriched instructions for creating an artifact. + * + * @param context - Change context + * @param artifactId - Artifact ID to generate instructions for + * @returns Enriched artifact instructions + * @throws Error if artifact not found + */ +export function generateInstructions( + context: ChangeContext, + artifactId: string +): ArtifactInstructions { + const artifact = context.graph.getArtifact(artifactId); + if (!artifact) { + throw new Error(`Artifact '${artifactId}' not found in schema '${context.schemaName}'`); + } + + const template = loadTemplate(context.schemaName, artifact.template); + const dependencies = getDependencyStatus(artifact, context.completed); + const unlocks = getUnlockedArtifacts(context.graph, artifactId); + + return { + changeName: context.changeName, + artifactId: artifact.id, + schemaName: context.schemaName, + outputPath: artifact.generates, + description: artifact.description, + template, + dependencies, + unlocks, + }; +} + +/** + * Gets dependency status for an artifact. + */ +function getDependencyStatus( + artifact: Artifact, + completed: CompletedSet +): DependencyStatus[] { + return artifact.requires.map(id => ({ + id, + done: completed.has(id), + })); +} + +/** + * Gets artifacts that become available after completing the given artifact. + */ +function getUnlockedArtifacts(graph: ArtifactGraph, artifactId: string): string[] { + const unlocks: string[] = []; + + for (const artifact of graph.getAllArtifacts()) { + if (artifact.requires.includes(artifactId)) { + unlocks.push(artifact.id); + } + } + + return unlocks.sort(); +} + +/** + * Formats the status of all artifacts in a change. + * + * @param context - Change context + * @returns Formatted change status + */ +export function formatChangeStatus(context: ChangeContext): ChangeStatus { + const artifacts = context.graph.getAllArtifacts(); + const ready = new Set(context.graph.getNextArtifacts(context.completed)); + const blocked = context.graph.getBlocked(context.completed); + + const artifactStatuses: ArtifactStatus[] = artifacts.map(artifact => { + if (context.completed.has(artifact.id)) { + return { + id: artifact.id, + outputPath: artifact.generates, + status: 'done' as const, + }; + } + + if (ready.has(artifact.id)) { + return { + id: artifact.id, + outputPath: artifact.generates, + status: 'ready' as const, + }; + } + + return { + id: artifact.id, + outputPath: artifact.generates, + status: 'blocked' as const, + missingDeps: blocked[artifact.id] ?? [], + }; + }); + + // Sort by build order for consistent output + const buildOrder = context.graph.getBuildOrder(); + const orderMap = new Map(buildOrder.map((id, idx) => [id, idx])); + artifactStatuses.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0)); + + return { + changeName: context.changeName, + schemaName: context.schemaName, + isComplete: context.graph.isComplete(context.completed), + artifacts: artifactStatuses, + }; +} diff --git a/test/core/artifact-graph/instruction-loader.test.ts b/test/core/artifact-graph/instruction-loader.test.ts new file mode 100644 index 00000000..77ec1b47 --- /dev/null +++ b/test/core/artifact-graph/instruction-loader.test.ts @@ -0,0 +1,264 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + loadTemplate, + loadChangeContext, + generateInstructions, + formatChangeStatus, + TemplateLoadError, +} from '../../../src/core/artifact-graph/instruction-loader.js'; + +describe('instruction-loader', () => { + describe('loadTemplate', () => { + it('should load template from schema directory', () => { + // Uses built-in spec-driven schema + const template = loadTemplate('spec-driven', 'proposal.md'); + + expect(template).toContain('## Why'); + expect(template).toContain('## What Changes'); + }); + + it('should throw TemplateLoadError for non-existent template', () => { + expect(() => loadTemplate('spec-driven', 'nonexistent.md')).toThrow( + TemplateLoadError + ); + }); + + it('should throw TemplateLoadError for non-existent schema', () => { + expect(() => loadTemplate('nonexistent-schema', 'proposal.md')).toThrow( + TemplateLoadError + ); + }); + + it('should include template path in error', () => { + try { + loadTemplate('spec-driven', 'nonexistent.md'); + expect.fail('Should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(TemplateLoadError); + expect((err as TemplateLoadError).templatePath).toContain('nonexistent.md'); + } + }); + }); + + describe('loadChangeContext', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should load context with default schema', () => { + const context = loadChangeContext(tempDir, 'my-change'); + + expect(context.schemaName).toBe('spec-driven'); + expect(context.changeName).toBe('my-change'); + expect(context.graph.getName()).toBe('spec-driven'); + expect(context.completed.size).toBe(0); + }); + + it('should load context with custom schema', () => { + const context = loadChangeContext(tempDir, 'my-change', 'tdd'); + + expect(context.schemaName).toBe('tdd'); + expect(context.graph.getName()).toBe('tdd'); + }); + + it('should detect completed artifacts', () => { + // Create change directory with proposal.md + const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change'); + fs.mkdirSync(changeDir, { recursive: true }); + fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal'); + + const context = loadChangeContext(tempDir, 'my-change'); + + expect(context.completed.has('proposal')).toBe(true); + }); + + it('should return empty completed set for non-existent change directory', () => { + const context = loadChangeContext(tempDir, 'nonexistent-change'); + + expect(context.completed.size).toBe(0); + }); + }); + + describe('generateInstructions', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should include artifact metadata', () => { + const context = loadChangeContext(tempDir, 'my-change'); + const instructions = generateInstructions(context, 'proposal'); + + expect(instructions.changeName).toBe('my-change'); + expect(instructions.artifactId).toBe('proposal'); + expect(instructions.schemaName).toBe('spec-driven'); + expect(instructions.outputPath).toBe('proposal.md'); + }); + + it('should include template content', () => { + const context = loadChangeContext(tempDir, 'my-change'); + const instructions = generateInstructions(context, 'proposal'); + + expect(instructions.template).toContain('## Why'); + }); + + it('should show dependencies with completion status', () => { + const context = loadChangeContext(tempDir, 'my-change'); + const instructions = generateInstructions(context, 'specs'); + + expect(instructions.dependencies).toHaveLength(1); + expect(instructions.dependencies[0].id).toBe('proposal'); + expect(instructions.dependencies[0].done).toBe(false); + }); + + it('should mark completed dependencies as done', () => { + // Create proposal + const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change'); + fs.mkdirSync(changeDir, { recursive: true }); + fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal'); + + const context = loadChangeContext(tempDir, 'my-change'); + const instructions = generateInstructions(context, 'specs'); + + expect(instructions.dependencies[0].done).toBe(true); + }); + + it('should list artifacts unlocked by this one', () => { + const context = loadChangeContext(tempDir, 'my-change'); + const instructions = generateInstructions(context, 'proposal'); + + // proposal unlocks specs and design + expect(instructions.unlocks).toContain('specs'); + expect(instructions.unlocks).toContain('design'); + }); + + it('should have empty dependencies for root artifact', () => { + const context = loadChangeContext(tempDir, 'my-change'); + const instructions = generateInstructions(context, 'proposal'); + + expect(instructions.dependencies).toHaveLength(0); + }); + + it('should throw for non-existent artifact', () => { + const context = loadChangeContext(tempDir, 'my-change'); + + expect(() => generateInstructions(context, 'nonexistent')).toThrow( + "Artifact 'nonexistent' not found" + ); + }); + }); + + describe('formatChangeStatus', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should show all artifacts as ready/blocked when nothing completed', () => { + const context = loadChangeContext(tempDir, 'my-change'); + const status = formatChangeStatus(context); + + expect(status.changeName).toBe('my-change'); + expect(status.schemaName).toBe('spec-driven'); + expect(status.isComplete).toBe(false); + + // proposal has no deps, should be ready + const proposal = status.artifacts.find(a => a.id === 'proposal'); + expect(proposal?.status).toBe('ready'); + + // specs depends on proposal, should be blocked + const specs = status.artifacts.find(a => a.id === 'specs'); + expect(specs?.status).toBe('blocked'); + expect(specs?.missingDeps).toContain('proposal'); + }); + + it('should show completed artifacts as done', () => { + const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change'); + fs.mkdirSync(changeDir, { recursive: true }); + fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal'); + + const context = loadChangeContext(tempDir, 'my-change'); + const status = formatChangeStatus(context); + + const proposal = status.artifacts.find(a => a.id === 'proposal'); + expect(proposal?.status).toBe('done'); + + // specs should now be ready + const specs = status.artifacts.find(a => a.id === 'specs'); + expect(specs?.status).toBe('ready'); + }); + + it('should include output paths for each artifact', () => { + const context = loadChangeContext(tempDir, 'my-change'); + const status = formatChangeStatus(context); + + const proposal = status.artifacts.find(a => a.id === 'proposal'); + expect(proposal?.outputPath).toBe('proposal.md'); + + const specs = status.artifacts.find(a => a.id === 'specs'); + expect(specs?.outputPath).toBe('specs/*.md'); + }); + + it('should report isComplete true when all done', () => { + const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change'); + fs.mkdirSync(changeDir, { recursive: true }); + fs.mkdirSync(path.join(changeDir, 'specs'), { recursive: true }); + + // Create all required files for spec-driven schema + fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Proposal'); + fs.writeFileSync(path.join(changeDir, 'specs', 'test.md'), '# Spec'); + fs.writeFileSync(path.join(changeDir, 'design.md'), '# Design'); + fs.writeFileSync(path.join(changeDir, 'tasks.md'), '# Tasks'); + + const context = loadChangeContext(tempDir, 'my-change'); + const status = formatChangeStatus(context); + + expect(status.isComplete).toBe(true); + expect(status.artifacts.every(a => a.status === 'done')).toBe(true); + }); + + it('should show blocked artifacts with missing dependencies', () => { + const context = loadChangeContext(tempDir, 'my-change'); + const status = formatChangeStatus(context); + + // tasks requires specs and design + const tasks = status.artifacts.find(a => a.id === 'tasks'); + expect(tasks?.status).toBe('blocked'); + expect(tasks?.missingDeps).toContain('specs'); + expect(tasks?.missingDeps).toContain('design'); + }); + + it('should sort artifacts in build order', () => { + const context = loadChangeContext(tempDir, 'my-change'); + const status = formatChangeStatus(context); + + const ids = status.artifacts.map(a => a.id); + const proposalIdx = ids.indexOf('proposal'); + const specsIdx = ids.indexOf('specs'); + const tasksIdx = ids.indexOf('tasks'); + + // proposal must come before specs, specs before tasks + expect(proposalIdx).toBeLessThan(specsIdx); + expect(specsIdx).toBeLessThan(tasksIdx); + }); + }); +});