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
117 changes: 117 additions & 0 deletions openspec/changes/project-local-schemas/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
## Context

OpenSpec currently resolves schemas from two locations:
1. User override: `~/.local/share/openspec/schemas/<name>/`
2. Package built-in: `<npm-package>/schemas/<name>/`

This change adds a third, highest-priority level: project-local schemas at `./openspec/schemas/<name>/`.

The resolver functions in `src/core/artifact-graph/resolver.ts` currently don't take a `projectRoot` parameter because user and package paths are absolute. To support project-local schemas, we need to pass project root context into the resolver.

## Goals / Non-Goals

**Goals:**
- Enable version-controlled custom workflow schemas
- Allow teams to share schemas via git without per-machine setup
- Maintain backward compatibility with existing resolver API
- Integrate with `config.yaml`'s `schema` field (from project-config change)

**Non-Goals:**
- Schema inheritance or `extends` keyword
- Template-level overrides (partial forks)
- Schema management CLI commands (`openspec schema copy/which/diff/reset`)
- Validation that project-local schema names don't conflict with built-ins (shadowing is intentional)

## Decisions

### Decision 1: Add optional `projectRoot` parameter to resolver functions

**Choice:** Add optional `projectRoot?: string` parameter to resolver functions rather than using `process.cwd()` internally.

**Alternatives considered:**
- Use `process.cwd()` internally: Simpler API but implicit, harder to test, doesn't match existing codebase patterns
- Create separate project-aware functions: No breaking changes but awkward API, callers must compose

**Rationale:** The codebase already follows a pattern where CLI commands get project root via `process.cwd()` and pass it down to functions that need it. Adding an optional parameter maintains backward compatibility while enabling explicit, testable behavior.

**Affected functions:**
```typescript
getSchemaDir(name: string, projectRoot?: string): string | null
listSchemas(projectRoot?: string): string[]
listSchemasWithInfo(projectRoot?: string): SchemaInfo[]
resolveSchema(name: string, projectRoot?: string): SchemaYaml
```

### Decision 2: Resolution order is project → user → package

**Choice:** Project-local schemas have highest priority, then user overrides, then package built-ins.

**Rationale:**
- Project-local should win because it represents team intent (version controlled, shared)
- User overrides still useful for personal experimentation without affecting team
- Package built-ins are the fallback defaults

```
1. ./openspec/schemas/<name>/ # Project-local (highest)
2. ~/.local/share/openspec/schemas/<name>/ # User override
3. <npm-package>/schemas/<name>/ # Package built-in (lowest)
```

### Decision 3: Add `getProjectSchemasDir()` helper function

**Choice:** Create a dedicated function to get the project schemas directory path.

```typescript
function getProjectSchemasDir(projectRoot: string): string {
return path.join(projectRoot, 'openspec', 'schemas');
}
```

**Rationale:** Matches existing pattern with `getPackageSchemasDir()` and `getUserSchemasDir()`. Keeps path logic centralized.

### Decision 4: Extend `SchemaInfo.source` to include `'project'`

**Choice:** Update the source type from `'package' | 'user'` to `'project' | 'user' | 'package'`.

**Rationale:** Consumers need to distinguish project-local schemas for display purposes (e.g., `schemasCommand` output).

### Decision 5: No special handling for schema name conflicts

**Choice:** If a project-local schema has the same name as a built-in (e.g., `spec-driven`), the project-local version wins. No warning, no error.

**Rationale:** This is intentional shadowing. Teams may want to customize a built-in schema while keeping the same name for familiarity.

## Risks / Trade-offs

### Risk: Confusion when project schema shadows built-in
A team could create `openspec/schemas/spec-driven/` that shadows the built-in, causing confusion when someone expects default behavior.

**Mitigation:** The `openspec schemas` command shows the source of each schema. Users can see `spec-driven (project)` vs `spec-driven (package)`.

### Risk: Missing projectRoot parameter
If callers forget to pass `projectRoot`, project-local schemas won't be found.

**Mitigation:**
- Make the change incrementally, updating call sites that need project-local support
- Existing behavior (user + package only) is preserved when `projectRoot` is undefined

### Trade-off: Optional parameter vs required
Making `projectRoot` optional maintains backward compatibility but means some code paths may silently skip project-local resolution.

**Accepted:** Backward compatibility is more important. The main entry points (CLI commands) will always pass `projectRoot`.

## Implementation Approach

1. **Update `resolver.ts`:**
- Add `getProjectSchemasDir(projectRoot: string)` function
- Update `getSchemaDir()` to check project-local first when `projectRoot` provided
- Update `listSchemas()` to include project schemas when `projectRoot` provided
- Update `listSchemasWithInfo()` to return `source: 'project'` for project schemas
- Update `SchemaInfo` type to include `'project'` in source union

2. **Update `artifact-workflow.ts`:**
- Update `schemasCommand` to pass `projectRoot` and display source labels

3. **Update call sites:**
- Any existing code that needs project-local resolution should pass `projectRoot`
- `config.yaml` schema resolution already has access to `projectRoot`
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
## ADDED Requirements

### Requirement: Project-local schema resolution

The system SHALL resolve schemas from the project-local directory (`./openspec/schemas/<name>/`) with highest priority when a `projectRoot` is provided.

#### Scenario: Project-local schema takes precedence over user override
- **WHEN** a schema named "my-workflow" exists at `./openspec/schemas/my-workflow/schema.yaml`
- **AND** a schema named "my-workflow" exists at `~/.local/share/openspec/schemas/my-workflow/schema.yaml`
- **AND** `getSchemaDir("my-workflow", projectRoot)` is called
- **THEN** the system SHALL return the project-local path

#### Scenario: Project-local schema takes precedence over package built-in
- **WHEN** a schema named "spec-driven" exists at `./openspec/schemas/spec-driven/schema.yaml`
- **AND** "spec-driven" is a package built-in schema
- **AND** `getSchemaDir("spec-driven", projectRoot)` is called
- **THEN** the system SHALL return the project-local path

#### Scenario: Falls back to user override when no project-local schema
- **WHEN** no schema named "my-workflow" exists at `./openspec/schemas/my-workflow/`
- **AND** a schema named "my-workflow" exists at `~/.local/share/openspec/schemas/my-workflow/schema.yaml`
- **AND** `getSchemaDir("my-workflow", projectRoot)` is called
- **THEN** the system SHALL return the user override path

#### Scenario: Falls back to package built-in when no project-local or user schema
- **WHEN** no schema named "spec-driven" exists at `./openspec/schemas/spec-driven/`
- **AND** no schema named "spec-driven" exists at `~/.local/share/openspec/schemas/spec-driven/`
- **AND** "spec-driven" is a package built-in schema
- **AND** `getSchemaDir("spec-driven", projectRoot)` is called
- **THEN** the system SHALL return the package built-in path

#### Scenario: Backward compatibility when projectRoot not provided
- **WHEN** `getSchemaDir("my-workflow")` is called without a `projectRoot` parameter
- **THEN** the system SHALL only check user override and package built-in locations
- **AND** the system SHALL NOT check project-local location

### Requirement: Project schemas directory helper

The system SHALL provide a `getProjectSchemasDir(projectRoot)` function that returns the project-local schemas directory path.

#### Scenario: Returns correct path
- **WHEN** `getProjectSchemasDir("/path/to/project")` is called
- **THEN** the system SHALL return `/path/to/project/openspec/schemas`

### Requirement: List schemas includes project-local

The system SHALL include project-local schemas when listing available schemas if `projectRoot` is provided.

#### Scenario: Project-local schemas appear in list
- **WHEN** a schema named "team-flow" exists at `./openspec/schemas/team-flow/schema.yaml`
- **AND** `listSchemas(projectRoot)` is called
- **THEN** the returned list SHALL include "team-flow"

#### Scenario: Project-local schema shadows same-named user schema in list
- **WHEN** a schema named "custom" exists at both project-local and user override locations
- **AND** `listSchemas(projectRoot)` is called
- **THEN** the returned list SHALL include "custom" exactly once

#### Scenario: Backward compatibility for listSchemas
- **WHEN** `listSchemas()` is called without a `projectRoot` parameter
- **THEN** the system SHALL only include user override and package built-in schemas

### Requirement: Schema info includes project source

The system SHALL indicate `source: 'project'` for project-local schemas in `listSchemasWithInfo()` results.

#### Scenario: Project-local schema shows project source
- **WHEN** a schema named "team-flow" exists at `./openspec/schemas/team-flow/schema.yaml`
- **AND** `listSchemasWithInfo(projectRoot)` is called
- **THEN** the schema info for "team-flow" SHALL have `source: 'project'`

#### Scenario: User override schema shows user source
- **WHEN** a schema named "my-custom" exists only at `~/.local/share/openspec/schemas/my-custom/`
- **AND** `listSchemasWithInfo(projectRoot)` is called
- **THEN** the schema info for "my-custom" SHALL have `source: 'user'`

#### Scenario: Package built-in schema shows package source
- **WHEN** "spec-driven" exists only as a package built-in
- **AND** `listSchemasWithInfo(projectRoot)` is called
- **THEN** the schema info for "spec-driven" SHALL have `source: 'package'`

### Requirement: Schemas command shows source

The `openspec schemas` command SHALL display the source of each schema.

#### Scenario: Display format includes source
- **WHEN** user runs `openspec schemas`
- **THEN** the output SHALL show each schema with its source label (project, user, or package)
28 changes: 28 additions & 0 deletions openspec/changes/project-local-schemas/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
## 1. Update Resolver Types and Helpers

- [x] 1.1 Update `SchemaInfo.source` type to include `'project'` in `src/core/artifact-graph/resolver.ts`
- [x] 1.2 Add `getProjectSchemasDir(projectRoot: string): string` function

## 2. Update Schema Resolution Functions

- [x] 2.1 Update `getSchemaDir(name, projectRoot?)` to check project-local first when projectRoot provided
- [x] 2.2 Update `resolveSchema(name, projectRoot?)` to pass projectRoot to getSchemaDir
- [x] 2.3 Update `listSchemas(projectRoot?)` to include project-local schemas
- [x] 2.4 Update `listSchemasWithInfo(projectRoot?)` to include project schemas with `source: 'project'`

## 3. Update CLI Commands

- [x] 3.1 Update `schemasCommand` to pass projectRoot and display source labels in output

## 4. Update Call Sites

- [x] 4.1 Review and update call sites that need project-local schema support to pass projectRoot

## 5. Testing

- [x] 5.1 Add unit tests for `getProjectSchemasDir()`
- [x] 5.2 Add unit tests for project-local schema resolution priority
- [x] 5.3 Add unit tests for backward compatibility (no projectRoot = user + package only)
- [x] 5.4 Add unit tests for `listSchemas()` including project schemas
- [x] 5.5 Add unit tests for `listSchemasWithInfo()` with `source: 'project'`
- [x] 5.6 Add integration test with temp project containing local schema
65 changes: 44 additions & 21 deletions src/commands/artifact-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,11 +159,14 @@ async function validateChangeExists(

/**
* Validates that a schema exists and returns available schemas if not.
*
* @param schemaName - The schema name to validate
* @param projectRoot - Optional project root for project-local schema resolution
*/
function validateSchemaExists(schemaName: string): string {
const schemaDir = getSchemaDir(schemaName);
function validateSchemaExists(schemaName: string, projectRoot?: string): string {
const schemaDir = getSchemaDir(schemaName, projectRoot);
if (!schemaDir) {
const availableSchemas = listSchemas();
const availableSchemas = listSchemas(projectRoot);
throw new Error(
`Schema '${schemaName}' not found. Available schemas:\n ${availableSchemas.join('\n ')}`
);
Expand All @@ -190,7 +193,7 @@ async function statusCommand(options: StatusOptions): Promise<void> {

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

// loadChangeContext will auto-detect schema from metadata if not provided
Expand Down Expand Up @@ -260,7 +263,7 @@ async function instructionsCommand(

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

// loadChangeContext will auto-detect schema from metadata if not provided
Expand Down Expand Up @@ -598,7 +601,7 @@ async function applyInstructionsCommand(options: ApplyInstructionsOptions): Prom

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

// generateApplyInstructions uses loadChangeContext which auto-detects schema
Expand Down Expand Up @@ -682,27 +685,40 @@ interface TemplatesOptions {
interface TemplateInfo {
artifactId: string;
templatePath: string;
source: 'user' | 'package';
source: 'project' | 'user' | 'package';
}

async function templatesCommand(options: TemplatesOptions): Promise<void> {
const spinner = ora('Loading templates...').start();

try {
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
const schema = resolveSchema(schemaName);
const projectRoot = process.cwd();
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA, projectRoot);
const schema = resolveSchema(schemaName, projectRoot);
const graph = ArtifactGraph.fromSchema(schema);
const schemaDir = getSchemaDir(schemaName)!;

// Determine if this is a user override or package built-in
const { getUserSchemasDir } = await import('../core/artifact-graph/resolver.js');
const schemaDir = getSchemaDir(schemaName, projectRoot)!;

// Determine the source (project, user, or package)
const {
getUserSchemasDir,
getProjectSchemasDir,
} = await import('../core/artifact-graph/resolver.js');
const projectSchemasDir = getProjectSchemasDir(projectRoot);
const userSchemasDir = getUserSchemasDir();
const isUserOverride = schemaDir.startsWith(userSchemasDir);

let source: 'project' | 'user' | 'package';
if (schemaDir.startsWith(projectSchemasDir)) {
source = 'project';
} else if (schemaDir.startsWith(userSchemasDir)) {
source = 'user';
} else {
source = 'package';
}

const templates: TemplateInfo[] = graph.getAllArtifacts().map((artifact) => ({
artifactId: artifact.id,
templatePath: path.join(schemaDir, 'templates', artifact.template),
source: isUserOverride ? 'user' : 'package',
source,
}));

spinner.stop();
Expand All @@ -717,7 +733,7 @@ async function templatesCommand(options: TemplatesOptions): Promise<void> {
}

console.log(`Schema: ${schemaName}`);
console.log(`Source: ${isUserOverride ? 'user override' : 'package built-in'}`);
console.log(`Source: ${source}`);
console.log();

for (const t of templates) {
Expand Down Expand Up @@ -749,16 +765,17 @@ async function newChangeCommand(name: string | undefined, options: NewChangeOpti
throw new Error(validation.error);
}

const projectRoot = process.cwd();

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

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

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

// If description provided, create README.md with description
Expand Down Expand Up @@ -926,7 +943,7 @@ ${template.content}
} else {
// Prompt for config creation
try {
const configResult = await promptForConfig();
const configResult = await promptForConfig(projectRoot);

if (configResult.createConfig && configResult.schema) {
// Build config object
Expand Down Expand Up @@ -1052,7 +1069,8 @@ interface SchemasOptions {
}

async function schemasCommand(options: SchemasOptions): Promise<void> {
const schemas = listSchemasWithInfo();
const projectRoot = process.cwd();
const schemas = listSchemasWithInfo(projectRoot);

if (options.json) {
console.log(JSON.stringify(schemas, null, 2));
Expand All @@ -1063,7 +1081,12 @@ async function schemasCommand(options: SchemasOptions): Promise<void> {
console.log();

for (const schema of schemas) {
const sourceLabel = schema.source === 'user' ? chalk.dim(' (user override)') : '';
let sourceLabel = '';
if (schema.source === 'project') {
sourceLabel = chalk.cyan(' (project)');
} else if (schema.source === 'user') {
sourceLabel = chalk.dim(' (user override)');
}
console.log(` ${chalk.bold(schema.name)}${sourceLabel}`);
console.log(` ${schema.description}`);
console.log(` Artifacts: ${schema.artifacts.join(' → ')}`);
Expand Down
Loading
Loading