Configuration & customization
cospec draws a hard line between what it manages and what you own. There are exactly three tiers, and they're never mixed.
Tier 1: cospec-managed
openspec/schemas/**, the harness files under .claude/, .agents/skills/cospec-*/, .codex/, .github/ (Copilot's skills and prompts, and its two cloud-agent files once you opt in), and .opencode/, and the gate files are generated by cospec init and regenerated by cospec update. Every managed file carries provenance frontmatter naming the generating version and a content hash of its body — that's what lets cospec update tell an untouched file from one you edited.
Never hand-edit a managed file. If you need different behavior, use tier 2 or tier 3 below — a header comment on each managed file points back here.
Hand-editing a managed file doesn't get silently overwritten, but it
does get flagged and shunted aside on the next update — see reconciliation below. :::
Tier 2: openspec/config.yaml
Your own change and spec content, plus openspec/config.yaml, is never touched by cospec, except for two keys: githubCopilot.cloudAgent, which init writes when you pass a Copilot cloud flag or answer its question, and context, which cospec init --language writes when it creates the file. The config file gives you two levers, both delegated straight to the wrapped OpenSpec binary:
context— free-text project context injected into every generated instruction.rules— per-artifact guidance (keyed by artifact id:proposal,tasks,verification, and so on), injected natively by OpenSpec into the instructions it renders.
A rules key that is not an artifact id — a typo such as proposals for proposal — silently drops that whole rule list; OpenSpec only prints one stderr line while generating instructions. cospec doctor reports each such key as a config warning that names it, lists the known ids (every artifact id of the schemas OpenSpec resolves — project, user-global and package — as its own cospec schemas listing shows them; a schema OpenSpec rejects as invalid contributes none) and suggests the closest id. If that listing cannot be read, doctor says so in one config warning and flags no key. A rules that is absent or not a mapping is not reported.
For the full key reference and syntax, see OpenSpec's customization guide.
cospec init --language <language> writes the context for you. A new config.yaml gets upstream's three-line directive as its context:
context: |
Language: <language>
All artifacts must be written in <language>.
Keep OpenSpec structural headings and SHALL/MUST keywords in English.init refuses, writing nothing, when a config.yaml (or config.yml) already exists and its context does not already hold that directive. Before it refuses (or accepts), it prints openspec's own warnings about the config it read: a file that is not valid YAML or not a mapping, and each field that fails its check (schema, a context that is not a string or is over 50KB, rules, operations, references, store and githubCopilot), one line each on stderr. A config-only openspec/ that declares a store: is refused first, before --language's own check. A new file also carries commented examples of three more keys: operations: (advisory guidance for apply and archive), store: (the registered store that holds this repo's planning) and references: (other stores this project reads). They are comments until you uncomment them.
operations: advisory guidance for apply and archive
operations gives advice to the two workflows that gate and ship a change. Its keys are the operation ids, and there are exactly two: apply and archive. The two guidance keys are operations.apply.guidance and operations.archive.guidance. Each id maps to an object whose only field is guidance, a list of strings:
operations:
apply:
guidance:
- Run the migration tests before marking a task done.
archive:
guidance:
- Keep the archive summary to one line.Empty entries are dropped. Guidance is advisory and additive: it is prompt-level text the workflow weighs, never a check, and it never changes what apply or archive gates on or what their exit codes are. Where it reaches:
apply—cospec apply <slug> --jsoncarries it asapply.operationGuidanceon a clear gate. The human transcript prints it under### Operation Guidance (advisory)after the instruction.archive— the workflow reads it fromcospec instructions archive --change <slug> --json(asoperationGuidance), andcospec archiveprints it after its summary, or after a refusal in text mode. A successful--jsondocument carries it as a top-leveloperationGuidancekey.
When operations is malformed, the reader warns (on stderr, from apply and archive alike) and ignores the part it cannot use, and the rest of the file still applies. An unknown id (for example applies) is reported with the supported ids, apply and archive, and its guidance is ignored. An operations value that is not a mapping, an operation that is not a mapping, an unknown field inside one, or a guidance that is not a list of strings each produce one warning, with OpenSpec's wording. A project that configures no operations sees no change in output.
One extra vocabulary lever lives here too: verification.layers lets you extend the closed set of @<layer> tokens (@unit, @e2e, @manual, and so on) that verification rows can cite — see Verification for the key's shape and the full grammar.
Per-change metadata: skip_specs and retire_capabilities
A change's own .openspec.yaml (not the repo-wide config.yaml above) carries two boolean keys:
skip_specs: true— a persisted alternative to passing--skip-specson everycospec apply/cospec archivecall for a spec-bearing type that legitimately has no deltas this run. Precedence, from strongest to weakest: the CLI--skip-specsflag, then this marker, then the structural default (a spec-bearing type must show deltas). Declaring the marker while files actually exist underspecs/is a validate-time ERROR (deltas/skip-specs-conflict) — the marker makesspecsoptional, not forbidden.retire_capabilities: true— authorizes openspec 1.8.0+ to delete a capability's livingspec.mdwhen a change'sREMOVEDoperation takes its last requirement. Without the marker, the merge refuses outright, with the change and spec untouched — cospec reports it at validate time on a cospec-typed change (archive/rebuilt-spec-invalid) and relays openspec's refusal on a legacy one. With it, the spec is deleted only when deleting it loses nothing the merge cannot name: prose, a comment, a fence, a heading or a table outside## Purposeand the requirement blocks' own parts — above the requirements, inside or below a removed block, or in a trailing section — keeps openspec writing the empty spec and refusing it, as does a spec the change removed no requirement from; cospec reports both at validate time, quoting the blocking lines. Two footguns worth knowing: the marker is only honored when the whole.openspec.yamlis valid to openspec — acreatedinYYYY-MM-DDform, a non-emptygoal,affected_areasas a list of non-empty strings, aninitiativeof exactly a kebab-casestoreandid— and itsschema:is one openspec lists and loads (in a repo whoseschema:isn't installed,schema: unknown schema '<type>'). Otherwise the binary counts the change as unmarked and refuses the emptied spec, sayingThe marker present now cannot be honored (<reason>); cospec reports the same at validate time on a cospec-typed change (archive/rebuilt-spec-invalid, endingretire_capabilities is set but cannot be honored (<reason>)). And a retirement that does go through is reported bycospec archiveas aRetired:line (and aretired[]array in--json) — a living spec disappearing without the marker is treated as an invariant breach, never silently accepted.
A key present but set to a non-boolean value is a validate-time ERROR (meta/skip-specs-type, meta/retire-capabilities-type).
A store: key redirects cospec (and OpenSpec) to a registered OpenSpec store by default, so you don't have to pass --store <id> on every command — but only from an openspec/ that has no planning shape of its own (no specs/ or changes/ existing as a directory). Inside a real planning root the key is ignored instead, with a one-time warning naming it, since a real root is never redirected out from under itself. It's a root-resolution input rather than a cospec-managed or validated field like context and rules above — see Stores for the full resolution order and what it changes.
Documented limitation rules are keyed by artifact id repo-wide
— they cannot vary per conventional-commit type. A proposal rule applies to every feat and every chore proposal alike. If you genuinely need different rules per type, that's what schema forking (tier 3) is for. :::
The Copilot cloud agent key
githubCopilot.cloudAgent is the one key cospec writes into openspec/config.yaml (or config.yml, when only that exists):
githubCopilot:
cloudAgent: true- Who writes it.
cospec init, and only when you pass--copilot-cloudor--no-copilot-cloud, or answer its question.update,doctorandupdate --checkonly read it. - What it does.
truewrites the GitHub Copilot cloud-agent files (.github/workflows/copilot-setup-steps.ymland.github/agents/cospec.agent.md).falseremoves cospec's untouched copies, even with no flag and even when the files exist, so it wins over the files. When the key is absent, existing files stay current and nothing is created. - Shared with OpenSpec. The key is upstream's, and OpenSpec's own
initreads it too, so a repo that uses both tools has one decision. - Malformed values. A non-boolean
cloudAgentcounts as undecided, and cospec warns on stderr. Saving keeps your comments and other keys, and replaces agithubCopilotthat isn't a mapping. Aconfig.yamlthat doesn't parse is left untouched, with a warning.
The full decision order and what each outcome prints are in GitHub Copilot cloud agent.
Tier 3: schema forking
For changes bigger than config can express — a wholly different artifact set, or type-specific rules — fork a schema:
cospec schema fork <type> [name] # name defaults to <type>-custom
cospec schema init <name> # a schema with no cospec-type ancestorthen create a change against it:
cospec new <name> <slug>cospec schema fork/init are disciplined passthroughs to openspec schema fork/init — cospec adds exactly one guard on top: it refuses (exit 1, before ever spawning the wrapped binary) a destination name that collides with one of the eleven cospec types, since that would overwrite a canon-managed schema.yaml every other command reads. Any other destination name forks normally. cospec new <name> <slug> recognizes a resolved project-local schema the same way — it delegates to OpenSpec, skips the cospec-only schemaVersion stamp and typed artifact-plan output, and prints a "legacy schema — reduced cospec guarantees" note; a name that resolves to neither a cospec type nor a project-local schema still gets today's unknown-type error.
config.yaml can't reach what a fork can context/rules are
additive prose injected into an instruction — they cannot override which sections a template requires or replace an instruction's structure outright. If you need a genuinely different artifact set or template body, that's what forking is for; config alone can't get you there. :::
Once a fork exists, cospec's own read-only inspection commands work on it the same as on any of the eleven built-in schemas:
cospec schemaslists every resolvable schema — the eleven cospec types plus your fork — with its artifact chain.cospec schema which <change>reports which schema a change resolves to.cospec schema validate <name>validates a schema's own structure, including a forked one.cospec templates [--schema <name>]shows the resolved per-artifact template paths a schema composes to.
Forked schemas are legacy as far as the change lifecycle is concerned: cospec validate runs only structural checks against a legacy change and delegates the rest to openspec validate (which validates the fork against its own artifact graph), cospec apply skips the cospec blocker gate for it, and cospec doctor notes the reduced guarantees. What cospec does not relax for a legacy change: archive still runs the tasks gate, the scenario-preservation gate, the filesystem-move verification, and blocker-ledger fan-out — only the verification-ledger gate is scoped out, since a legacy schema has no cospec-typed verification artifact to gate on. So a fork trades the eleven built-in schemas' mechanically-required artifacts for OpenSpec-delegated structural validation of its own graph, while keeping every schema-agnostic cospec hard gate at full strength. cospec never generates, regenerates, or otherwise manages a forked schema itself — it's entirely yours from the moment you fork it. See the type and artifact matrix for what you're diverging from.
The eleven built-in types can't be weakened by a fork
cospec schema fork/init refuse outright before touching disk if the destination collides with one of the eleven cospec types — that guard is the only thing standing between a fork and overwriting a canon-managed schema.yaml. If you bypass cospec and run the native openspec schema fork/init directly against a reserved name, cospec's drift check (cospec doctor, cospec update --check) still catches the clobbered file as a hand-edit the next time either runs — the same as any other file modified outside the managed-file protocol — but that's a reactive backstop, not a substitute for going through cospec schema. :::
Machine-global: openspec config
The three tiers above are all repo-local. OpenSpec also keeps one machine-global config file, ~/.config/openspec/config.json, and cospec config <sub> wraps it. cospec reads four of its keys itself — profile, workflows and delivery (see Installed workflows and delivery), and defaultStore — and writes one, completionTipSeen (below). It adds no validation of its own, and relays upstream's key validation, value coercion, and prototype-pollution guard as OpenSpec answers, its remedies spelled cospec (Fix it with "cospec config edit", …, and profile <preset>'s Config updated. Run `cospec update` in your projects to apply.).
cospec config with no subcommand (--scope global or not) prints cospec config --help on stderr and exits 1, as OpenSpec prints its own config help there; under --json OpenSpec's refusal of --json at the config level (error: unknown option '--json') is relayed instead.
Installed workflows and delivery
profile, workflows and delivery choose which workflows cospec init and cospec update install, and which surface each one is written to. A key applies only when it is present in the file. OpenSpec's built-in default (profile: core, reported for an unset key) does not count, so a machine that never set profile keeps all twelve workflows.
| key | values | effect |
|---|---|---|
profile | core, custom | core installs propose, explore, apply, update, sync-specs and archive. custom installs the workflows list. Any other present value acts as core. |
workflows | a list of workflow ids | Read only with custom. Ids are spelled as upstream spells them (sync means sync-specs); unknown ids are dropped, and sync-specs is added before archive or bulk-archive when it is missing. |
delivery | skills, commands, both | Which surface each workflow is written to. Unset means both. Any other present value acts as both. |
cospec init --profile core|custom overrides the profile key for that one run. The workflows list still comes from the file. The flag is not saved: a later cospec update with no profile key writes all twelve workflows, and cospec doctor says so at INFO (installed-workflows) instead of calling the workflows the repo never had missing.
cospec update never removes an installed workflow. Its set is the profile's plus every cospec workflow the repo already has, so narrowing a profile leaves the dropped workflows in place, and cospec doctor names them. Delivery is the one exception, as the BREAKING notes below say. cospec doctor also reports the explicit profile and delivery it reads, as an INFO finding.
BREAKING Two keys that cospec used to ignore now take effect, so a
machine whose global config already sets them gets different output:
- An explicit
profile: coremakescospec initin a fresh repo install six workflows, where it installed twelve. An explicitprofile: custominstalls only itsworkflowslist. - An explicit
delivery: skillsordelivery: commandsmakescospec initwrite that one surface only.cospec updatethen removes the other surface's cospec files in a repo that has both. An absentdeliverykey, ordelivery: both, changes nothing. :::
completionTipSeen: runtime-managed
cospec writes one key here itself. After the completion tip has shown once on a terminal, cospec sets completionTipSeen: true and never shows the tip again. It is runtime-managed, not a setting you tune, and OpenSpec reads the same flag, so a user who has seen OpenSpec's tip never sees cospec's. The tip's rules are on Shell completion.
cospec config splits into two call classes:
- Piped (
path,list,get <key>,set <key> <value>,unset <key>,reset --all -y,profile <preset>) — a disciplined passthrough. Exit1is an ordinary negative result here (unset key, invalid key), not a wrapped-call violation. - Terminal handover (
edit,profilewith no preset,reset --allwithout-y) — upstream spawns$EDITORor runs an@inquirermenu, which cannot survive cospec's pipedstdin: 'ignore'spawn. cospec hands the terminal over instead: inherited stdio, the child's verbatim exit code (including130on prompt cancellation), and no--json— the same terminal-handover contractcospec workset openuses. Nothing OpenSpec prints on that terminal can be relayed, so before handing it over cospec refuses what OpenSpec would, in OpenSpec's order: the argv first (an unknown option, an excess argument, a short cluster such asreset --all -yz,--store-path), on stderr with exit1and nothing on stdout, even under--json; then--json(the envelope below).profilewith no preset tests for a TTY on stdout, as OpenSpec does: with none it runs piped, and OpenSpec's refusal is relayed —Interactive mode required. Use `cospec config profile core` or set config via environment/flags., exit1; on a TTY a read-only piped check runs first, and anything OpenSpec refuses before its menu — an unreadable global config (cospec config edit/cospec config reset --all),--scope project(Error: Project-local config is not yet implemented) — gets OpenSpec's refusal instead of the menu.reset --alltests for a TTY on stdin, which its confirm reads: with none it runs piped and cospec forwards its stdin to the confirm unmodified. An empty stdin (</dev/null) is cancelled —Reset cancelled., exit130, nothing reset; an answer that arrives after the prompt ((sleep 3; echo y) | …,yes | …) is taken —yresets,nprintsReset cancelled., exit0— as OpenSpec answers each. An answer already waiting on the pipe when the prompt is drawn (echo y | …,echo n | …) gets no single answer from OpenSpec: whether it takes one depends on timing, as its first-run telemetry work can delay the prompt past the answer (taken with telemetry at its default, cancelled with exit130underOPENSPEC_TELEMETRY=0). cospec's answer is fixed: it takes the waiting answer —echo y |resets,echo n |printsReset cancelled., exit0either way. Pass-yto reset from a script.edithas no non-interactive branch and always hands over.
A prompt you cancel — Ctrl-C or Ctrl-D at a handed-over prompt — or one given no input at all (an ended pipe) is cancelled as OpenSpec cancels it under Node: its cancellation line (Reset cancelled., Config profile cancelled.) and exit 130. OpenSpec runs under cospec's own runtime, so cospec runs it behind a small preload that delivers the exit notice OpenSpec's prompts listen for when their input closes (Ctrl-D, an ended pipe), and writes OpenSpec's printed lines through the output streams, as Node does, so an answer printed after a prompt has redrawn many times (yes | …) always arrives, and a line whose reader has already gone (… | head -0) is dropped without stopping OpenSpec, as Node drops it. Its error lines (config edit's editor failures, say) print uncoloured, as OpenSpec prints them under Node. cospec writes that file to its cache (${XDG_CACHE_HOME:-~/.cache}/cospec), or — when it cannot write there — to a directory of its own under the system temp directory, removed when cospec exits, so a read-only cache never stops a prompt.
--scope is a parent-level option (not --store — OpenSpec config is machine-global, so cospec config never resolves a root or threads root.storeArgs). --json exists on list only, matching upstream; the other subcommands still owe a --json caller exactly one JSON document, so cospec wraps their text output in its own version: 1 envelope:
| subcommand | --json shape |
|---|---|
list | upstream's own document, relayed verbatim |
path | { version: 1, command: 'config path', path } |
get | { version: 1, command: 'config get', key, value, found } (value/found are null/false when the key is unset) |
a refused path/get | { version: 1, command: 'config <sub>', key?, ok: false, message } — message is OpenSpec's reason (--scope project) |
set/unset/reset | { version: 1, command: 'config <sub>', ok, message } |
| a Class B subcommand | { version: 1, command: 'config <sub>', ok: false, message: '… is interactive and cannot emit JSON' }, exit 1 |
Beside an answer OpenSpec gave, its stderr is relayed as well — an unreadable global config's Warning: Invalid JSON in …, using defaults, say. A config list --json OpenSpec refuses before listing (--scope project) has no document to relay: its reason is relayed on stderr, exit 1, stdout empty.
The envelope is for an answer the subcommand gave. When OpenSpec refuses the argv itself — cospec config get foo --bogus --json, cospec config path --bogus --json, an unknown subcommand (cospec config bogus, cospec config -- --json) — its refusal (error: unknown option '--bogus', error: unknown command 'bogus') is relayed on stderr, exit 1, with nothing on stdout, as OpenSpec prints it before any output.
--json is a cospec global flag on every config subcommand, where OpenSpec declares it on list alone. So given an excess argument too (cospec config path extra --json, cospec config edit extra --json), cospec refuses the excess argument (error: too many arguments …) where OpenSpec names --json as the unknown option; both exit 1 before anything runs, with nothing on stdout.
cospec config --store <id> is refused outright (exit 1, before spawning the wrapped binary) rather than silently ignored — OpenSpec config has no store dimension, so a --store a user typed out of habit needs a named answer, not a no-op.
Precedence notes
After a successful mutation, cospec prints a stderr note (stderr, so a --json stdout stays exactly one document) wherever cospec's own behavior overrides or bypasses the key just written:
set telemetry.enabled— cospec forcesOPENSPEC_TELEMETRY=0on every wrapped call regardless of this key, so the setting affects bareopenspecruns only.profile <preset>/set profile|workflows|delivery— the key takes effect on the nextcospec initorcospec update, which regenerate the harness files (.claude/,.codex/,.opencode/) from cospec canon. Runcospec update, notopenspec update. The stderr note says so:note: cospec's harness files are generated from cospec canon — run 'cospec update', not 'openspec update'.
defaultStore is the other global key cospec itself reads, beside profile, workflows and delivery (as a fallback root during store resolution). Until this command, it had no way to set from cospec — see Stores for the full resolution order.
The managed-file protocol
Every cospec update recomposes each managed file and decides its fate by comparing the file's current hash to the content hash recorded in its own frontmatter (or, for YAML with no frontmatter, such as schema files and the Copilot setup workflow, a manifest at openspec/.cospec-manifest.json):
| Situation | Outcome |
|---|---|
| File doesn't exist yet | Created |
| File exists but isn't cospec's (no matching provenance) | Left alone; new content written to <file>.cospec-new |
| Unmodified since last generation, content unchanged | No-op |
| Unmodified since last generation, content changed | Rewritten in place |
| Modified by you since last generation | Left alone; new content written to <file>.cospec-new |
Every write is atomic. Files a newer cospec version no longer emits are deleted only if you never touched them — otherwise they're preserved and reported, same as any other conflict.
Reconciling .cospec-new
When your hand-edit collides with an update, cospec never clobbers your file — it writes the new managed content next to it as <file>.cospec-new. To reconcile:
- Diff
<file>against<file>.cospec-new. - Fold in whatever you want from the new version.
- Delete
<file>.cospec-new.
cospec doctor flags stale .cospec-new files so they don't linger unnoticed. If you'd rather just take the update and discard your edits, cospec update --force overwrites in place — it prints a diff summary first so you know what you're losing.
Running cospec update twice in a row with no intervening changes is a no-op: every file reports unchanged and your working tree stays clean.