From 05efd62bff38db7ca3da615a2c3e3ef9f8712aa2 Mon Sep 17 00:00:00 2001 From: Tabish Bidiwale Date: Tue, 13 Jan 2026 21:06:57 -0800 Subject: [PATCH] feat(config): add project-level configuration via openspec/config.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds openspec/config.yaml support for project-level customization without forking schemas. Teams can now: - Set a default schema (used when --schema flag not provided) - Inject project context into all artifact instructions - Add per-artifact rules (e.g., proposal rules, specs rules) Key changes: - New `src/core/project-config.ts` with Zod schema and resilient parsing - New `src/core/config-prompts.ts` for interactive config creation - Updated schema resolution order: CLI → change metadata → config → default - Updated instruction generation to inject and XML sections - Integrated config creation prompts into `artifact-experimental-setup` Schema resolution precedence: 1. --schema CLI flag (explicit override) 2. .openspec.yaml in change directory (change-specific) 3. openspec/config.yaml schema field (project default) 4. "spec-driven" (hardcoded fallback) --- .../changes/project-config/.openspec.yaml | 2 + openspec/changes/project-config/design.md | 665 +++++++++++++++ openspec/changes/project-config/proposal.md | 774 ++++++++++++++++++ .../specs/config-loading/spec.md | 119 +++ .../specs/context-injection/spec.md | 51 ++ .../specs/rules-injection/spec.md | 99 +++ .../specs/schema-resolution/spec.md | 83 ++ openspec/changes/project-config/tasks.md | 72 ++ .../project-local-schemas/.openspec.yaml | 2 + .../changes/project-local-schemas/proposal.md | 167 ++++ src/commands/artifact-workflow.ts | 115 ++- src/core/artifact-graph/instruction-loader.ts | 66 +- src/core/config-prompts.ts | 196 +++++ src/core/project-config.ts | 254 ++++++ src/utils/change-metadata.ts | 20 +- src/utils/change-utils.ts | 19 +- .../artifact-graph/instruction-loader.test.ts | 315 ++++++- test/core/project-config.test.ts | 588 +++++++++++++ test/utils/change-metadata.test.ts | 77 ++ 19 files changed, 3674 insertions(+), 10 deletions(-) create mode 100644 openspec/changes/project-config/.openspec.yaml create mode 100644 openspec/changes/project-config/design.md create mode 100644 openspec/changes/project-config/proposal.md create mode 100644 openspec/changes/project-config/specs/config-loading/spec.md create mode 100644 openspec/changes/project-config/specs/context-injection/spec.md create mode 100644 openspec/changes/project-config/specs/rules-injection/spec.md create mode 100644 openspec/changes/project-config/specs/schema-resolution/spec.md create mode 100644 openspec/changes/project-config/tasks.md create mode 100644 openspec/changes/project-local-schemas/.openspec.yaml create mode 100644 openspec/changes/project-local-schemas/proposal.md create mode 100644 src/core/config-prompts.ts create mode 100644 src/core/project-config.ts create mode 100644 test/core/project-config.test.ts diff --git a/openspec/changes/project-config/.openspec.yaml b/openspec/changes/project-config/.openspec.yaml new file mode 100644 index 000000000..800912e1d --- /dev/null +++ b/openspec/changes/project-config/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: "2025-01-13" diff --git a/openspec/changes/project-config/design.md b/openspec/changes/project-config/design.md new file mode 100644 index 000000000..45e4a075d --- /dev/null +++ b/openspec/changes/project-config/design.md @@ -0,0 +1,665 @@ +# Design: Project Config + +## Context + +OpenSpec currently has a fixed schema resolution order: +1. `--schema` CLI flag +2. `.openspec.yaml` in change directory +3. Hardcoded default: `"spec-driven"` + +This forces users who want project-level customization to fork entire schemas, even for simple additions like injecting tech stack context or adding artifact-specific rules. + +The proposal introduces `openspec/config.yaml` as a lightweight customization layer that sits between preset schemas and full forking. It allows teams to: +- Set a default schema +- Inject project context into all artifacts +- Add per-artifact rules + +**Constraints:** +- Must not break existing changes that lack config +- Must maintain clean separation between "configure" (this) and "fork" (project-local-schemas) +- Config is project-level only (no global/user-level config) + +**Key stakeholders:** +- OpenSpec users who need light customization without forking +- Teams sharing workflow conventions via committed config + +## Goals / Non-Goals + +**Goals:** +- Load and parse `openspec/config.yaml` using Zod schema +- Use config's `schema` field as default in schema resolution +- Inject `context` into all artifact instructions +- Inject `rules` into matching artifact instructions only +- Gracefully handle missing or invalid config (fallback to defaults) + +**Non-Goals:** +- Structural changes to schemas (`skip`, `add`, inheritance) - those belong in fork path +- File references for context (`context: ./file.md`) - start with strings +- Global user-level config (XDG dirs, etc.) +- Config management commands (`openspec config init`) - manual creation for now +- Migration from old setups (no existing config to migrate from) + +## Decisions + +### 1. Config File Format: YAML vs JSON + +**Decision:** Use YAML (`.yaml` extension, support `.yml` alias) + +**Rationale:** +- YAML supports multi-line strings naturally (`context: |`) +- More readable for documentation-heavy content +- Consistent with `.openspec.yaml` used in changes +- Easy to parse with existing `yaml` library + +**Alternatives considered:** +- JSON: More strict, but poor multi-line string UX +- TOML: Less familiar to most users + +### 2. Config Location: Project Root vs openspec/ Directory + +**Decision:** `./openspec/config.yaml` (inside openspec directory) + +**Rationale:** +- Co-located with `openspec/schemas/` (project-local-schemas) +- Keeps project root clean +- Natural namespace for OpenSpec configuration +- Mirrors structure used by other tools (e.g., `.github/`) + +**Alternatives considered:** +- `./openspec.config.yaml` in root: Pollutes root, less clear ownership +- XDG config directories: Out of scope, no global config yet + +### 3. Context Injection: XML Tags vs Markdown Sections + +**Decision:** Use XML-style tags `` and `` + +**Rationale:** +- Clear delimiters that don't conflict with Markdown +- Agents can easily parse structure +- Matches existing patterns in the codebase for special sections + +**Example:** +```xml + +Tech stack: TypeScript, React + + + +- Include rollback plan + + + +``` + +**Alternatives considered:** +- Markdown headers: Conflicts with template content +- Comments: Less visible to agents + +### 4. Schema Resolution: Insert Position + +**Decision:** Config's `schema` field goes between change metadata and hardcoded default + +**New resolution order:** +1. `--schema` CLI flag (explicit override) +2. `.openspec.yaml` in change directory (change-specific binding) +3. **`openspec/config.yaml` schema field** (NEW - project default) +4. `"spec-driven"` (hardcoded fallback) + +**Rationale:** +- Preserves CLI and change-level overrides (most specific wins) +- Makes config act as a "project default" +- Backwards compatible (no existing configs to conflict with) + +### 5. Rules Validation: Strict vs Permissive + +**Decision:** Warn on unknown artifact IDs, don't error + +**Rationale:** +- Future-proof: If schema adds new artifacts, old configs don't break +- Dev experience: Typos show warnings, but don't halt workflow +- User can fix incrementally + +**Example:** +```yaml +rules: + proposal: [...] + testplan: [...] # Schema doesn't have this artifact → WARN, not ERROR +``` + +### 6. Error Handling: Config Parse Failures + +**Decision:** Log warning and fall back to defaults (don't halt commands) + +**Rationale:** +- Syntax errors in config shouldn't break all of OpenSpec +- User can fix config incrementally +- Commands remain usable during config development + +**Warning message:** +``` +⚠️ Failed to parse openspec/config.yaml: [error details] + Falling back to default schema (spec-driven) +``` + +## Implementation Plan + +### Phase 1: Core Types and Loading + +**File: `src/core/project-config.ts` (NEW)** + +```typescript +import { z } from 'zod'; +import { readFileSync, existsSync } from 'fs'; +import { parse as parseYaml } from 'yaml'; +import { findProjectRoot } from '../utils/path-utils'; + +/** + * Zod schema for project configuration. + * + * Purpose: + * 1. Documentation - clearly defines the config file structure + * 2. Type safety - TypeScript infers ProjectConfig type from schema + * 3. Runtime validation - uses safeParse() for resilient field-by-field validation + * + * Why Zod over manual validation: + * - Helps understand OpenSpec's data interfaces at a glance + * - Single source of truth for type and validation + * - Consistent with other OpenSpec schemas + */ +export const ProjectConfigSchema = z.object({ + schema: z.string().min(1).describe('The workflow schema to use (e.g., "spec-driven", "tdd")'), + context: z.string().optional().describe('Project context injected into all artifact instructions'), + rules: z.record( + z.string(), + z.array(z.string()) + ).optional().describe('Per-artifact rules, keyed by artifact ID'), +}); + +export type ProjectConfig = z.infer; + +const MAX_CONTEXT_SIZE = 50 * 1024; // 50KB hard limit + +/** + * Read and parse openspec/config.yaml from project root. + * Uses resilient parsing - validates each field independently using Zod safeParse. + * Returns null if file doesn't exist. + * Returns partial config if some fields are invalid (with warnings). + */ +export function readProjectConfig(): ProjectConfig | null { + const projectRoot = findProjectRoot(); + + // Try both .yaml and .yml, prefer .yaml + let configPath = path.join(projectRoot, 'openspec', 'config.yaml'); + if (!existsSync(configPath)) { + configPath = path.join(projectRoot, 'openspec', 'config.yml'); + if (!existsSync(configPath)) { + return null; // No config is OK + } + } + + try { + const content = readFileSync(configPath, 'utf-8'); + const raw = parseYaml(content); + + if (!raw || typeof raw !== 'object') { + console.warn(`⚠️ openspec/config.yaml is not a valid YAML object`); + return null; + } + + const config: Partial = {}; + + // Parse schema field using Zod + const schemaField = z.string().min(1); + const schemaResult = schemaField.safeParse(raw.schema); + if (schemaResult.success) { + config.schema = schemaResult.data; + } else if (raw.schema !== undefined) { + console.warn(`⚠️ Invalid 'schema' field in config (must be non-empty string)`); + } + + // Parse context field with size limit + if (raw.context !== undefined) { + const contextField = z.string(); + const contextResult = contextField.safeParse(raw.context); + + if (contextResult.success) { + const contextSize = Buffer.byteLength(contextResult.data, 'utf-8'); + if (contextSize > MAX_CONTEXT_SIZE) { + console.warn( + `⚠️ Context too large (${(contextSize / 1024).toFixed(1)}KB, limit: ${MAX_CONTEXT_SIZE / 1024}KB)` + ); + console.warn(` Ignoring context field`); + } else { + config.context = contextResult.data; + } + } else { + console.warn(`⚠️ Invalid 'context' field in config (must be string)`); + } + } + + // Parse rules field using Zod + if (raw.rules !== undefined) { + const rulesField = z.record(z.string(), z.array(z.string())); + + // First check if it's an object structure + if (typeof raw.rules === 'object' && !Array.isArray(raw.rules)) { + const parsedRules: Record = {}; + let hasValidRules = false; + + for (const [artifactId, rules] of Object.entries(raw.rules)) { + const rulesArrayResult = z.array(z.string()).safeParse(rules); + + if (rulesArrayResult.success) { + // Filter out empty strings + const validRules = rulesArrayResult.data.filter(r => r.length > 0); + if (validRules.length > 0) { + parsedRules[artifactId] = validRules; + hasValidRules = true; + } + if (validRules.length < rulesArrayResult.data.length) { + console.warn( + `⚠️ Some rules for '${artifactId}' are empty strings, ignoring them` + ); + } + } else { + console.warn( + `⚠️ Rules for '${artifactId}' must be an array of strings, ignoring this artifact's rules` + ); + } + } + + if (hasValidRules) { + config.rules = parsedRules; + } + } else { + console.warn(`⚠️ Invalid 'rules' field in config (must be object)`); + } + } + + // Return partial config even if some fields failed + return Object.keys(config).length > 0 ? (config as ProjectConfig) : null; + + } catch (error) { + console.warn(`⚠️ Failed to parse openspec/config.yaml:`, error); + return null; + } +} + +/** + * Validate artifact IDs in rules against a schema's artifacts. + * Called during instruction loading (when schema is known). + * Returns warnings for unknown artifact IDs. + */ +export function validateConfigRules( + rules: Record, + validArtifactIds: Set, + schemaName: string +): string[] { + const warnings: string[] = []; + + for (const artifactId of Object.keys(rules)) { + if (!validArtifactIds.has(artifactId)) { + const validIds = Array.from(validArtifactIds).sort().join(', '); + warnings.push( + `Unknown artifact ID in rules: "${artifactId}". ` + + `Valid IDs for schema "${schemaName}": ${validIds}` + ); + } + } + + return warnings; +} + +/** + * Suggest valid schema names when user provides invalid schema. + * Uses fuzzy matching to find similar names. + */ +export function suggestSchemas( + invalidSchemaName: string, + availableSchemas: { name: string; isBuiltIn: boolean }[] +): string { + // Simple fuzzy match: Levenshtein distance + function levenshtein(a: string, b: string): number { + const matrix: number[][] = []; + for (let i = 0; i <= b.length; i++) { + matrix[i] = [i]; + } + for (let j = 0; j <= a.length; j++) { + matrix[0][j] = j; + } + for (let i = 1; i <= b.length; i++) { + for (let j = 1; j <= a.length; j++) { + if (b.charAt(i - 1) === a.charAt(j - 1)) { + matrix[i][j] = matrix[i - 1][j - 1]; + } else { + matrix[i][j] = Math.min( + matrix[i - 1][j - 1] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j] + 1 + ); + } + } + } + return matrix[b.length][a.length]; + } + + // Find closest matches (distance <= 3) + const suggestions = availableSchemas + .map(s => ({ ...s, distance: levenshtein(invalidSchemaName, s.name) })) + .filter(s => s.distance <= 3) + .sort((a, b) => a.distance - b.distance) + .slice(0, 3); + + const builtIn = availableSchemas.filter(s => s.isBuiltIn).map(s => s.name); + const projectLocal = availableSchemas.filter(s => !s.isBuiltIn).map(s => s.name); + + let message = `❌ Schema '${invalidSchemaName}' not found in openspec/config.yaml\n\n`; + + if (suggestions.length > 0) { + message += `Did you mean one of these?\n`; + suggestions.forEach(s => { + const type = s.isBuiltIn ? 'built-in' : 'project-local'; + message += ` - ${s.name} (${type})\n`; + }); + message += '\n'; + } + + message += `Available schemas:\n`; + if (builtIn.length > 0) { + message += ` Built-in: ${builtIn.join(', ')}\n`; + } + if (projectLocal.length > 0) { + message += ` Project-local: ${projectLocal.join(', ')}\n`; + } else { + message += ` Project-local: (none found)\n`; + } + + message += `\nFix: Edit openspec/config.yaml and change 'schema: ${invalidSchemaName}' to a valid schema name`; + + return message; +} +``` + +### Phase 2: Schema Resolution + +**File: `src/utils/change-metadata.ts`** + +Update `resolveSchemaForChange()` to check config: + +```typescript +export function resolveSchemaForChange( + changeName: string, + cliSchema?: string +): string { + // 1. CLI flag wins + if (cliSchema) { + return cliSchema; + } + + // 2. Change metadata (.openspec.yaml) + const metadata = readChangeMetadata(changeName); + if (metadata?.schema) { + return metadata.schema; + } + + // 3. Project config (NEW) + const projectConfig = readProjectConfig(); + if (projectConfig?.schema) { + return projectConfig.schema; + } + + // 4. Hardcoded default + return 'spec-driven'; +} +``` + +**File: `src/utils/change-utils.ts`** + +Update `createNewChange()` to use config schema: + +```typescript +export function createNewChange( + changeName: string, + schema?: string +): void { + // Use schema from config if not specified + const resolvedSchema = schema ?? readProjectConfig()?.schema ?? 'spec-driven'; + + // ... rest of change creation logic +} +``` + +### Phase 3: Instruction Injection and Validation + +**File: `src/core/artifact-graph/instruction-loader.ts`** + +Update `loadInstructions()` to inject context, rules, and validate artifact IDs: + +```typescript +// Session-level cache for validation warnings (avoid repeating same warnings) +const shownWarnings = new Set(); + +export function loadInstructions( + changeName: string, + artifactId: string +): InstructionOutput { + const projectConfig = readProjectConfig(); + + // Load base instructions from schema + const baseInstructions = loadSchemaInstructions(changeName, artifactId); + const schema = getSchemaForChange(changeName); // Assumes we have schema loaded + + // Validate rules artifact IDs (only once per session) + if (projectConfig?.rules) { + const validArtifactIds = new Set(schema.artifacts.map(a => a.id)); + const warnings = validateConfigRules( + projectConfig.rules, + validArtifactIds, + schema.name + ); + + // Show each unique warning only once per session + for (const warning of warnings) { + if (!shownWarnings.has(warning)) { + console.warn(`⚠️ ${warning}`); + shownWarnings.add(warning); + } + } + } + + // Build enriched instruction with XML sections + let enrichedInstruction = ''; + + // Add context (all artifacts) + if (projectConfig?.context) { + enrichedInstruction += `\n${projectConfig.context}\n\n\n`; + } + + // Add rules (only for matching artifact) + const rulesForArtifact = projectConfig?.rules?.[artifactId]; + if (rulesForArtifact && rulesForArtifact.length > 0) { + enrichedInstruction += `\n`; + for (const rule of rulesForArtifact) { + enrichedInstruction += `- ${rule}\n`; + } + enrichedInstruction += `\n\n`; + } + + // Add original template + enrichedInstruction += ``; + + return { + ...baseInstructions, + instruction: enrichedInstruction, + }; +} +``` + +**Note on validation timing:** Rules are validated lazily during instruction loading (not at config load time) because: +1. Schema isn't known at config load time (circular dependency) +2. Warnings shown when user actually uses the feature (better UX) +3. Validation warnings cached per session to avoid spam + +### Phase 4: Performance and Caching + +**Why config is read multiple times:** + +```typescript +// Example: "openspec instructions proposal --change my-feature" + +// 1. Schema resolution (to know which schema to use) +resolveSchemaForChange('my-feature') + → readProjectConfig() // Read #1 + +// 2. Instruction loading (to inject context and rules) +loadInstructions('my-feature', 'proposal') + → readProjectConfig() // Read #2 + +// Result: Config read twice per command +// More complex commands may read 3-5 times +``` + +**Performance Strategy:** + +V1 approach: No caching, read config fresh each time +- Simpler implementation +- No cache invalidation complexity +- Acceptable if config reads are fast enough + +**Benchmark targets:** +- Typical config (1KB context, 5 artifact rules): **< 10ms** per read (imperceptible even 5x) +- Large config (50KB context limit): **< 50ms** per read (acceptable for rare case) + +**If benchmarks fail:** Add simple caching: + +```typescript +// Simple in-memory cache with no invalidation +let cachedConfig: { mtime: number; config: ProjectConfig | null } | null = null; + +export function readProjectConfig(): ProjectConfig | null { + const projectRoot = findProjectRoot(); + const configPath = path.join(projectRoot, 'openspec', 'config.yaml'); + + if (!existsSync(configPath)) { + return null; + } + + const stats = statSync(configPath); + const mtime = stats.mtimeMs; + + // Return cached config if file hasn't changed + if (cachedConfig && cachedConfig.mtime === mtime) { + return cachedConfig.config; + } + + // Read and parse config + const config = parseConfigFile(configPath); // Extracted logic + + // Cache result + cachedConfig = { mtime, config }; + return config; +} +``` + +**Performance testing task:** Add to Phase 6 (Testing) +- Measure typical config read time (1KB context) +- Measure large config read time (50KB context limit) +- Measure repeated reads within single command +- Document results, add caching only if needed + +## Data Flow + +``` +┌──────────────────────────────────────────────────────────────┐ +│ │ +│ User runs: openspec instructions proposal --change foo │ +│ │ +└────────────────────────────┬─────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ resolveSchemaForChange("foo") │ +│ │ +│ 1. Check CLI flag ✗ │ +│ 2. Check .openspec.yaml ✗ │ +│ 3. Check openspec/config.yaml ✓ → "spec-driven" │ +│ │ +└────────────────────────────┬─────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ loadInstructions("foo", "proposal") │ +│ │ +│ 1. Load spec-driven/artifacts/proposal.yaml │ +│ 2. Read openspec/config.yaml │ +│ 3. Build enriched instruction: │ +│ - ... │ +│ - ... (if rules.proposal exists) │ +│ - │ +│ │ +└────────────────────────────┬─────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ Return InstructionOutput with enriched content │ +│ │ +│ Agent sees project context + rules + schema template │ +│ │ +└──────────────────────────────────────────────────────────────┘ +``` + +## Risks / Trade-offs + +**[Risk]** Config typos silently ignored (e.g., wrong artifact ID in rules) +→ **Mitigation:** Validate and warn on unknown artifact IDs during config load. Don't error to allow forward compatibility. + +**[Risk]** Context grows too large, pollutes all artifact instructions +→ **Mitigation:** Document recommended size (< 500 chars). If this becomes an issue, add per-artifact context override later. + +**[Risk]** YAML parsing errors break OpenSpec commands +→ **Mitigation:** Catch parse errors, log warning, fall back to defaults. Commands remain functional. + +**[Risk]** Config cached incorrectly across commands +→ **Mitigation:** Read config fresh on each `readProjectConfig()` call. No caching layer for v1 (simplicity over perf). + +**[Trade-off]** Context is injected into ALL artifacts +→ **Benefit:** Consistent project knowledge across workflow +→ **Cost:** Can't scope context to specific artifacts (yet) +→ **Future:** Add `context: { global: "...", proposal: "..." }` if needed + +**[Trade-off]** Rules use artifact IDs, not human names +→ **Benefit:** Stable identifiers (IDs don't change) +→ **Cost:** User needs to know artifact IDs from schema +→ **Mitigation:** Document common artifact IDs, show in `openspec status` output + +## Migration Plan + +**No migration needed** - this is a new feature with no existing state. + +**Rollout steps:** +1. Deploy with config loading behind feature flag (optional, for safety) +2. Test with internal project (this repo) +3. Document in README with examples +4. Remove feature flag if used + +**Rollback strategy:** +- Config is additive only (doesn't break existing changes) +- If bugs found, config parsing can be disabled with env var +- Users can delete config file to restore old behavior + +## Open Questions + +**Q: Should context support file references (`context: ./CONTEXT.md`)?** +**A (deferred):** Start with string-only. Add file reference later if users request it. Keeps v1 simple. + +**Q: Should we support `.yml` alias in addition to `.yaml`?** +**A:** Yes, check both extensions. Prefer `.yaml` in docs, but accept `.yml` for users who prefer it. + +**Q: What if config's schema field references a non-existent schema?** +**A:** Schema resolution will fail downstream. Show error when trying to load schema, suggest valid schema names. + +**Q: Should rules be validated against the resolved schema's artifact IDs?** +**A:** Yes, validate and warn, but don't halt. This allows forward compatibility if schema evolves. diff --git a/openspec/changes/project-config/proposal.md b/openspec/changes/project-config/proposal.md new file mode 100644 index 000000000..bd54915cd --- /dev/null +++ b/openspec/changes/project-config/proposal.md @@ -0,0 +1,774 @@ +# Project Config + +## Summary + +Add `openspec/config.yaml` support for project-level configuration. This enables teams to customize OpenSpec behavior without forking schemas, by providing context and rules that are injected into artifact generation. + +## Motivation + +Currently, customizing OpenSpec requires forking entire schemas: +- Must copy all files even to add one rule +- Lose updates when openspec upgrades +- High friction for simple customizations + +Most users don't need different workflow structure. They need to: +- Provide project context (tech stack, conventions, constraints) +- Add rules for specific artifacts (requirements, formatting preferences) + +## Design Decisions + +### Two-Path Model + +OpenSpec customization follows two distinct paths: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ │ +│ CONFIGURE (this change) FORK (project-local-schemas) │ +│ ───────────────────── ──────────────────────────── │ +│ │ +│ Use a preset schema Define your own schema │ +│ + add context from scratch │ +│ + add rules │ +│ │ +│ openspec/config.yaml openspec/schemas/my-flow/ │ +│ │ +│ ✓ Simple ✓ Full control │ +│ ✓ Get updates ✗ You maintain everything │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Config Schema + +```yaml +# openspec/config.yaml + +# Required: which workflow schema to use +schema: spec-driven + +# Optional: project context injected into all artifact prompts +context: | + Tech stack: TypeScript, React, Node.js, PostgreSQL + API style: RESTful, documented in docs/api-conventions.md + Testing: Jest + React Testing Library + We value backwards compatibility for all public APIs + +# Optional: per-artifact rules (additive) +rules: + proposal: + - Include rollback plan + - Identify affected teams and notify in #platform-changes + specs: + - Use Given/When/Then format + - Reference existing patterns before inventing new ones + tasks: + - Each task should be completable in < 2 hours + - Include acceptance criteria +``` + +### What's NOT in Config + +The following were explicitly excluded to keep the model simple: + +| Feature | Decision | Rationale | +|---------|----------|-----------| +| `skip: [artifact]` | Not supported | Structural changes belong in fork path | +| `add: [{...}]` | Not supported | Structural changes belong in fork path | +| `extends: base` | Not supported | No inheritance, fork is full copy | +| `context: ./file.md` | Not supported (yet) | Start with string, add file reference later if needed | + +### Field Definitions + +#### `schema` (required) + +Which workflow schema to use. Can be: +- Built-in name: `spec-driven`, `tdd` +- Project-local schema name: `my-workflow` (requires project-local-schemas change) + +This becomes the default schema for: +- New changes created without `--schema` flag +- Commands run on changes without `.openspec.yaml` metadata + +#### `context` (optional) + +A string containing project context. Injected into ALL artifact prompts. + +Use cases: +- Tech stack description +- Link to conventions/style guides +- Team constraints or preferences +- Domain-specific context + +#### `rules` (optional) + +Per-artifact rules, keyed by artifact ID. Additive to schema's built-in guidance. + +```yaml +rules: + : + - Rule 1 + - Rule 2 +``` + +Rules are injected into the specific artifact's prompt, not all prompts. + +### Injection Format + +When generating instructions for an artifact: + +```xml + +Tech stack: TypeScript, React, Node.js, PostgreSQL +API style: RESTful, documented in docs/api-conventions.md +... + + + +- Include rollback plan +- Identify affected teams and notify in #platform-changes + + + +``` + +Context appears for all artifacts. Rules only appear for the matching artifact. + +### Config Creation Strategy + +**Why integrate with `artifact-experimental-setup`?** + +This feature targets **experimental workflow users**. The decision to create config during experimental setup (rather than providing standalone commands) is intentional: + +**Rationale:** +1. **Single entry point** - Users setting up experimental features are already in "configuration mode" +2. **Contextual timing** - Natural to configure project defaults when setting up workflow +3. **Avoids premature API surface** - No standalone `openspec config init` until feature graduates +4. **Experimental scope** - Keeps config as experimental feature, not stable API +5. **Progressive disclosure** - Users can skip and create manually later if needed + +**Evolution path:** + +``` +Today (Experimental): + openspec artifact-experimental-setup + → prompts for config creation + → creates .claude/skills/ + → creates openspec/config.yaml + +Future (When graduating): + openspec init + → prompts for config creation + → creates openspec/ directory + → creates openspec/config.yaml + + + standalone commands: + openspec config init + openspec config validate + openspec config set +``` + +**Why optional?** + +Config is **additive**, not required: +- OpenSpec works without config (uses defaults) +- Users can skip during setup and add manually later +- Teams can start simple and add config when they feel friction +- No config file in git = no problem, everyone gets defaults + +**Design principle:** The system never *requires* config, but makes it easy to create when users want customization. + +## Scope + +### In Scope + +**Core Config System:** +- Define `ProjectConfig` type with Zod schema +- Add `readProjectConfig()` function with graceful error handling +- Update instruction generation to inject context (all artifacts) +- Update instruction generation to inject rules (per-artifact) +- Update schema resolution to use config's `schema` field as default +- Update `openspec new change` to use config's schema as default + +**Config Creation (Experimental Setup):** +- Extend `artifact-experimental-setup` command to optionally create config +- Interactive prompts for schema selection (with description of each schema) +- Interactive prompts for project context (optional multi-line input) +- Interactive prompts for per-artifact rules (optional) +- Validate config immediately after creation +- Show clear "skip" option for users who want to create config manually later +- Display created config location and usage examples + +### Out of Scope + +- `skip` / `add` for structural changes (use fork path for structural changes) +- File reference for context (`context: ./CONTEXT.md`) - start with string, add later if needed +- Global user-level config (XDG directories, etc.) +- Integration with standard `openspec init` (will add when experimental graduates) +- Standalone `openspec config init` command (may add in future change) +- `openspec config validate` command (may add in future change) +- Config editing/updating commands (users edit YAML directly) + +## User Experience + +### Setting Up Config (Experimental Workflow) + +When users set up the experimental workflow, they're prompted to optionally create config: + +```bash +$ openspec artifact-experimental-setup + +Setting up experimental artifact workflow... + +✓ Created .claude/skills/openspec-explore/SKILL.md +✓ Created .claude/skills/openspec-new-change/SKILL.md +✓ Created .claude/skills/openspec-continue-change/SKILL.md +✓ Created .claude/skills/openspec-apply-change/SKILL.md +✓ Created .claude/skills/openspec-ff-change/SKILL.md +✓ Created .claude/skills/openspec-sync-specs/SKILL.md +✓ Created .claude/skills/openspec-archive-change/SKILL.md + +✓ Created .claude/commands/opsx/explore.md +✓ Created .claude/commands/opsx/new.md +✓ Created .claude/commands/opsx/continue.md +✓ Created .claude/commands/opsx/apply.md +✓ Created .claude/commands/opsx/ff.md +✓ Created .claude/commands/opsx/sync.md +✓ Created .claude/commands/opsx/archive.md + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +📋 Project Configuration (Optional) + +Configure project defaults for OpenSpec workflows. + +? Create openspec/config.yaml? (Y/n) Y + +? Default schema for new changes? + ❯ spec-driven (proposal → specs → design → tasks) + tdd (spec → tests → implementation → docs) + +? Add project context? (optional) + Context is shown to AI when creating artifacts. + Examples: tech stack, conventions, style guides, domain knowledge + + Press Enter to skip, or type/paste context: + │ Tech stack: TypeScript, React, Node.js, PostgreSQL + │ API style: RESTful, documented in docs/api-conventions.md + │ Testing: Jest + React Testing Library + │ We value backwards compatibility for all public APIs + │ + [Press Enter when done] + +? Add per-artifact rules? (optional) (Y/n) Y + + Which artifacts should have custom rules? + [Space to select, Enter when done] + ◯ proposal + ◉ specs + ◯ design + ◯ tasks + +? Rules for specs artifact: + Enter rules one per line, press Enter on empty line to finish: + │ Use Given/When/Then format for scenarios + │ Reference existing patterns before inventing new ones + │ + [Empty line to finish] + +✓ Created openspec/config.yaml + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +🎉 Setup Complete! + +📖 Config created at: openspec/config.yaml + • Default schema: spec-driven + • Project context: Added (4 lines) + • Rules: 1 artifact configured + +Usage: + • New changes automatically use 'spec-driven' schema + • Context injected into all artifact instructions + • Rules applied to matching artifacts + +To share with team: + git add openspec/config.yaml .claude/ + git commit -m "Setup OpenSpec experimental workflow with project config" + +[Rest of experimental setup output...] +``` + +**Key UX decisions:** + +1. **Prompted during setup** - Natural place since users are already configuring experimental features +2. **Optional at every step** - Clear skip options, no forced configuration +3. **Guided prompts** - Schema descriptions, example context, artifact selection +4. **Immediate validation** - Config is validated after creation, errors shown immediately +5. **Clear output** - Shows exactly what was created and how it affects workflow + +### Setting Up Config (Manual Creation) + +Users can also create config manually (or skip during setup and add later): + +```bash +# Create config file manually +cat > openspec/config.yaml << 'EOF' +schema: spec-driven + +context: | + Tech stack: TypeScript, React, Node.js + We follow REST conventions documented in docs/api.md + All changes require backwards compatibility consideration + +rules: + proposal: + - Must include rollback plan + - Must identify affected teams + specs: + - Use Given/When/Then format +EOF +``` + +### Effect on Workflow + +Once config is created, it affects the experimental workflow in three ways: + +**1. Default Schema Selection** + +```bash +# Before config: must specify schema +/opsx:new my-feature --schema spec-driven + +# After config (with schema: spec-driven): schema is automatic +/opsx:new my-feature +# Automatically uses spec-driven from config + +# Override still works +/opsx:new my-feature --schema tdd +# Uses tdd, ignoring config +``` + +**2. Context Injection (All Artifacts)** + +```bash +# Get instructions for any artifact +openspec instructions proposal --change my-feature + +# Output now includes project context: + +Tech stack: TypeScript, React, Node.js, PostgreSQL +API style: RESTful, documented in docs/api-conventions.md +Testing: Jest + React Testing Library +We value backwards compatibility for all public APIs + + + +``` + +Context appears in instructions for **all artifacts** (proposal, specs, design, tasks). + +**3. Rules Injection (Per-Artifact)** + +```bash +# Get instructions for artifact with rules configured +openspec instructions specs --change my-feature + +# Output includes artifact-specific rules: + +[Project context] + + + +- Use Given/When/Then format for scenarios +- Reference existing patterns before inventing new ones + + + +``` + +Rules only appear for the **specific artifact** they're configured for. + +**Artifacts without rules** (e.g., design, tasks) don't get a `` section: + +```bash +openspec instructions design --change my-feature +# Output: then