-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Description
Add a specsPath option to openspec/config.yaml that allows projects to configure where their specs live, instead of the hardcoded openspec/specs. Includes cross-platform path normalization and {{specsPath}} placeholder support in schema and skill templates.
Motivation
Specs are project contracts, not OpenSpec artifacts. They should be independent of the tool used to create or manage them. Currently, the path openspec/specs is hardcoded throughout the codebase (CLI commands, schema templates, skill templates), making it impossible to store specs in a location of the project's choosing.
A project might want specs at docs/specs, contracts/, or any other path that fits its structure. OpenSpec should support this without coupling specs to its own directory layout.
Proposal
Configuration
New optional field in openspec/config.yaml:
schema: spec-driven
specsPath: docs/specs # relative to projectRoot, default: openspec/specs- Path is relative to projectRoot (where
openspec/lives) - Accepts both
/and\separators (cross-platform), with/as documented convention - Defaults to
openspec/specswhen omitted (backward compatible, no breaking changes)
Path resolution
A centralized utility resolves the configured path into three representations:
interface SpecsPaths {
absolute: string; // OS-native full path for file I/O
relativePosix: string; // relative to projectRoot, always '/' — for prompts and LLM instructions
relative: string; // relative to projectRoot, OS-native separators — for console output
}| Representation | Linux/Mac | Windows | Used for |
|---|---|---|---|
absolute |
/home/user/project/docs/specs |
C:\Users\user\project\docs\specs |
File I/O (fs.readFile, etc.) |
relativePosix |
docs/specs |
docs/specs |
Prompts, LLM instructions |
relative |
docs/specs |
docs\specs |
Console messages |
Resolution normalizes any input (splits on both / and \, reconstructs per target format).
Placeholder replacement in templates
Schema templates and skill templates currently hardcode openspec/specs:
# schema.yaml (before)
instruction: |
Check `openspec/specs/` for existing spec names.These become placeholders:
# schema.yaml (after)
instruction: |
Check `{{specsPath}}/` for existing spec names.Replacement happens at two points:
- Schema/instruction templates: The instruction loader replaces
{{specsPath}}when generating instructions at runtime - Skill templates:
openspec updatereplaces{{specsPath}}via the existingtransformInstructionscallback when generating skill files (.claude/skills/,.claude/commands/, etc.)
Both use the same replacement mechanism — an extensible Map<string, string> of placeholders. This means:
- Same
{{specsPath}}syntax everywhere (schema templates, skill templates, proposal templates) - AI agents editing templates see a consistent pattern, no TypeScript interpolation to confuse them
- The map can be extended with new placeholders in the future without changing the mechanism
Important: Changing specsPath in config requires running openspec update to regenerate skill files with the new path.
Custom schemas
The {{specsPath}} placeholder works for all schemas — built-in, project-local (openspec/schemas/), and user-level (~/.local/share/openspec/schemas/). The instruction loader replaces placeholders regardless of schema origin.
- Built-in schemas (e.g.,
spec-driven): Updated to use{{specsPath}}as part of this change - Custom schemas: Authors can use
{{specsPath}}in their instructions and templates — the instruction loader handles replacement automatically - Existing custom schemas that hardcode
openspec/specswill continue to work but won't respectspecsPathconfiguration — authors should migrate to{{specsPath}}
Scope
- Affected: Main specs path only. Delta specs inside changes (
openspec/changes/<name>/specs/) remain unchanged — they are internal to OpenSpec's workflow. - CLI commands affected:
spec.ts,archive.ts,specs-apply.ts,list.ts,item-discovery.ts,validate.ts,init.ts,view.ts,validator.ts - Templates affected:
schema.yaml,templates/proposal.md,skill-templates.ts - Documentation:
docs/customization.mdneeds a section forspecsPath— usage, defaults, cross-platform path conventions,openspec updaterequirement, and guidance for custom schema authors to use{{specsPath}}instead of hardcoding paths - No breaking changes: Default behavior is identical to current
Implementation sketch
Config schema (project-config.ts)
export const ProjectConfigSchema = z.object({
schema: z.string().min(1),
specsPath: z.string().optional()
.describe('Path to specs directory, relative to projectRoot. Default: openspec/specs'),
context: z.string().optional(),
rules: z.record(z.string(), z.array(z.string())).optional(),
});Path resolution utility (new)
function resolveSpecsPaths(projectRoot: string, specsPath?: string): SpecsPaths {
const raw = specsPath ?? path.join('openspec', 'specs');
const segments = raw.split(/[/\\]/);
return {
absolute: path.resolve(projectRoot, ...segments),
relativePosix: segments.join('/'),
relative: path.join(...segments),
};
}CLI commands (e.g., archive.ts)
// Before
const mainSpecsDir = path.join(targetPath, 'openspec', 'specs');
// After
const specsPaths = resolveSpecsPaths(targetPath, projectConfig?.specsPath);
const mainSpecsDir = specsPaths.absolute;Schema templates (schema.yaml)
# Before
instruction: |
Check `openspec/specs/` for existing spec names.
Modified capabilities: use the existing spec folder name from openspec/specs/<capability>/
# After
instruction: |
Check `{{specsPath}}/` for existing spec names.
Modified capabilities: use the existing spec folder name from {{specsPath}}/<capability>/Proposal template (templates/proposal.md)
<!-- Before -->
Use existing spec names from openspec/specs/.
<!-- After -->
Use existing spec names from {{specsPath}}/.Skill templates (skill-templates.ts)
Same {{specsPath}} placeholder — replaced at openspec update time, not at runtime.
// Before (hardcoded)
b. **Read the main spec** at \`openspec/specs/<capability>/spec.md\` (may not exist yet)
// After (placeholder, replaced when openspec update generates the files)
b. **Read the main spec** at \`{{specsPath}}/<capability>/spec.md\` (may not exist yet)Instruction loader — placeholder engine
Replaces {{specsPath}} in schema templates at runtime when generating instructions:
const placeholders = new Map<string, string>([
['specsPath', specsPaths.relativePosix],
]);
let instruction = artifact.instruction;
for (const [key, value] of placeholders) {
instruction = instruction.replaceAll(`{{${key}}}`, value);
}Update command — composes with existing transformInstructions
Replaces {{specsPath}} in skill templates at openspec update time. Composes with any existing transformer (e.g., OpenCode's transformToHyphenCommands) via the existing transformInstructions callback in generateSkillContent():
const transformer = (text: string) => {
let result = replacePlaceholders(text);
if (tool.value === 'opencode') result = transformToHyphenCommands(result);
return result;
};
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);Related to #581