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/specs when 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 update replaces {{specsPath}} via the existing transformInstructions callback 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/specs will continue to work but won't respect specsPath configuration — authors should migrate to {{specsPath}}
Legacy path handling
For backward compatibility, the instruction loader automatically replaces hardcoded openspec/specs with the configured specsPath value. When this replacement occurs in a custom schema, a warning is shown:
- Which file contains the hardcoded path
- Suggestion to migrate to
{{specsPath}}
- "Run
openspec update to regenerate skill files"
This keeps existing custom schemas working while encouraging migration to the placeholder syntax.
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.md needs a section for specsPath — usage, defaults, cross-platform path conventions, openspec update requirement, 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);
Add a
specsPathoption toopenspec/config.yamlthat allows projects to configure where their specs live, instead of the hardcodedopenspec/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/specsis 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:openspec/lives)/and\separators (cross-platform), with/as documented conventionopenspec/specswhen omitted (backward compatible, no breaking changes)Path resolution
A centralized utility resolves the configured path into three representations:
absolute/home/user/project/docs/specsC:\Users\user\project\docs\specsfs.readFile, etc.)relativePosixdocs/specsdocs/specsrelativedocs/specsdocs\specsResolution normalizes any input (splits on both
/and\, reconstructs per target format).Placeholder replacement in templates
Schema templates and skill templates currently hardcode
openspec/specs:These become placeholders:
Replacement happens at two points:
{{specsPath}}when generating instructions at runtimeopenspec 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:{{specsPath}}syntax everywhere (schema templates, skill templates, proposal templates)Important: Changing
specsPathin config requires runningopenspec updateto 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.spec-driven): Updated to use{{specsPath}}as part of this change{{specsPath}}in their instructions and templates — the instruction loader handles replacement automaticallyopenspec/specswill continue to work but won't respectspecsPathconfiguration — authors should migrate to{{specsPath}}Legacy path handling
For backward compatibility, the instruction loader automatically replaces hardcoded
openspec/specswith the configuredspecsPathvalue. When this replacement occurs in a custom schema, a warning is shown:{{specsPath}}openspec updateto regenerate skill files"This keeps existing custom schemas working while encouraging migration to the placeholder syntax.
Scope
openspec/changes/<name>/specs/) remain unchanged — they are internal to OpenSpec's workflow.spec.ts,archive.ts,specs-apply.ts,list.ts,item-discovery.ts,validate.ts,init.ts,view.ts,validator.tsschema.yaml,templates/proposal.md,skill-templates.tsdocs/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 pathsImplementation sketch
Config schema (
project-config.ts)Path resolution utility (new)
CLI commands (e.g.,
archive.ts)Schema templates (
schema.yaml)Proposal template (
templates/proposal.md)Skill templates (
skill-templates.ts)Same
{{specsPath}}placeholder — replaced atopenspec updatetime, not at runtime.Instruction loader — placeholder engine
Replaces
{{specsPath}}in schema templates at runtime when generating instructions:Update command — composes with existing
transformInstructionsReplaces
{{specsPath}}in skill templates atopenspec updatetime. Composes with any existing transformer (e.g., OpenCode'stransformToHyphenCommands) via the existingtransformInstructionscallback ingenerateSkillContent():