* fix(apply): warn when a change is ready to implement with no specs Apply gates on the schema's `apply.requires` (tasks) alone, so a change whose tasks file was written ahead of its specs read as ready even though it had no delta specs at all — the state `openspec validate` rejects. Apply was the one surface that green-lit a change every other surface flags, which is how agents end up implementing before the specs exist. Report it as a warning, in the text output and in `--json`, naming both ways out: write the specs, or declare `skip_specs: true`. Blocking would be a policy change; naming the gap is not. Changes that have specs, declare `skip_specs`, or are still blocked on their own required artifacts are unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(apply): name the metadata file from its shared constant Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(apply): cover custom schemas in the no-specs warning A schema with no spec-producing artifact must stay quiet, and one whose spec artifact is not called `specs` must still warn - the rule keys off the output path, not the artifact id. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(apply): stop asserting an absolute temp path on Windows os.tmpdir() hands back the short form (C:\Users\RUNNER~1) while the CLI resolves the long one, so the assertion pinned a path that never matched on windows-pwsh. Assert the change-relative tail instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(apply): name the whole chain a blocked change still needs Apply blocks on the schema's `apply.requires` alone, so its message stopped at the first hop: a change holding only a proposal was told "Missing artifacts: tasks" while the specs `tasks` depends on were missing too. Taken literally that is an instruction to write the tracking file straight from the proposal and skip everything between — the failure reported in #834 and #869. Walk `requires` and report the whole set, in build order, as `missingPrerequisites` (text and `--json`). What apply blocks on is unchanged, and the wording leaves conditional artifacts to the schema rather than demanding them. The remedies these messages give are now CLI commands rather than the `openspec-continue-change` skill: `continue` is not in CORE_WORKFLOWS, so on the default profile the old advice named a skill that is never installed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(apply): name the schema's own spec artifact in the warning alfred-openspec on #1783: collectApplyWarnings() discovers spec-producing artifacts by output path, so it correctly fires for a schema whose artifact id is `contracts`, but the remediation text then hardcoded `openspec instructions specs`. That names an artifact such a schema does not declare, so the advertised custom-schema support dead-ended at the exact step meant to resolve the warning. The command now derives its target from specArtifacts: the artifact's own id when the schema declares one spec-producing artifact, and `<artifact-id>` as a placeholder when it declares several, since there is no single right answer there and a guess would read as an instruction. The renamed-artifact test now asserts the command names `contracts` and rejects the hardcoded `specs` spelling, and a new test pins the two-artifact placeholder. Verified both fail against the hardcoded string. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(changeset): bump apply warnings to minor This adds `missingPrerequisites` and `warnings` to the documented `instructions apply --json` contract in docs/agent-contract.md. New fields are backward compatible, but they are new capability an agent can consume, which is a minor under semver rather than a patch. Taking the conservative direction deliberately: shipping new API surface as a patch is the violation, since a consumer pinned to a patch range would receive it without opting in. A minor costs nothing if the fields turn out to be uninteresting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
16 KiB
OpenSpec Agent Contract
Machine-readable surfaces of the openspec CLI, verified against src/ (capstone audit, 2026-06-11). Every shape below is documented from the emitting code.
1. General conventions
- One JSON document per invocation. In
--jsonmode, stdout carries exactly one JSON document (2-space pretty-printed). Human prose, spinners, and the store banner go to stderr. - Store banner. In human mode, a store-selected root prints
Using OpenSpec root: <id> (<path>)to stderr. Never printed in JSON mode. - Key casing is surface-dependent (see Known inconsistencies): store/doctor/context payloads use
snake_case; workflow payloads (status,instructions,new change,validate,list) usecamelCase, except the embeddedrootobject, which always usesstore_id. - Optional keys are omitted, not null, in most payloads (e.g.
root.store_id,member.path). Exceptions that use explicitnullare called out per shape (store doctorgit.*, failure payloads).
2. The diagnostic envelope
One envelope shape is shared by every machine-readable diagnostic (StoreDiagnostic):
{
"severity": "error" | "warning" | "info",
"code": "snake_case_string",
"message": "human sentence",
"target": "dotted.surface (optional)",
"fix": "one actionable sentence/command (optional)"
}
Diagnostics appear in two positions: status arrays (status: StoreDiagnostic[] at top level or per entry) for health findings, and thrown errors converted to a single-element status array on command failure.
3. Root selection and RootOutput
All root-resolving commands (list, show, validate, status, instructions, instructions apply, instructions archive, new change, archive, doctor, context, schemas) resolve one OpenSpec root with one precedence:
--store <id>→ the registered store's root (source: "store").- Otherwise, nearest ancestor with
openspec/: planning shape →source: "nearest"(astore:pointer is ignored with a stderr warning); config-only dir with a validstore:pointer → that store,source: "declared". - No nearest root + global
defaultStoreset (openspec config set defaultStore <id>) → that store,source: "global_default"; a stale id fails with the underlying store error and afixnamingopenspec config unset defaultStore. - No nearest root, no default + registered stores exist → error
no_root_with_registered_stores. - No root, no default, no stores: commands may treat the cwd as
source: "implicit";doctor,context,list, and bulkvalidateinstead fail withno_openspec_root.listpreserves the implicit fallback for legacy projects withopenspec/project.md.
Successful JSON payloads normally embed the root; successful schemas --json
deliberately remains the compatibility bare array documented in §4.13:
"root": { "path": "/abs/path", "source": "store" | "declared" | "global_default" | "nearest" | "implicit", "store_id": "id (only when store-selected)" }
Root-failure contract: in JSON mode a resolution failure prints { ...commandNullShape, "status": [diagnostic] } on stdout and exits 1.
4. Command JSON shapes
4.1 list --json
{ "changes": [ { "name", "completedTasks", "totalTasks", "lastModified", "status": "no-tasks"|"complete"|"in-progress" } ], "root": RootOutput } — note the per-change status is a string enum here. --specs: { "specs": [ { "id", "requirementCount" } ], "root" }.
4.2 show <item> --json
Change: { "id", "title", "deltaCount", "deltas": [...], "root" }. Spec: { "id", "title", "overview", "requirementCount", "requirements": [...], "metadata": { "version", "format", "sourcePath"? }, "root" }.
4.3 validate --json
{ "items": [ { "id", "type": "change"|"spec", "valid", "issues": [ { "level", "path", "message", "line"?, "column"? } ], "durationMs" } ], "summary": { "totals": {items,passed,failed}, "byType": {...} }, "version": "1.0", "root" }. Exit 1 when any item fails.
4.4 status --json
{ "changeName", "schemaName", "planningHome"?: { "kind", "root", "changesDir", "defaultSchema" }, "changeRoot", "artifactPaths": { "<id>": {outputPath, resolvedOutputPath, existingOutputPaths} }, "nextSteps": ["..."], "actionContext": { "mode": "repo-local", "sourceOfTruth": "repo", "planningArtifacts", "linkedContext", "allowedEditRoots", "requiresAffectedAreaSelection", "constraints" }, "isPlanningComplete", "isComplete", "applyRequires", "artifacts": [ {id, outputPath, status: "done"|"skipped"|"ready"|"blocked", requires, missingDeps?} ], "root" }. isPlanningComplete means every non-skipped planning artifact exists; skipped artifacts count as satisfied without being created. It does not mean implementation tasks are complete. isComplete is retained as a compatibility alias with the same value. Each artifact's requires is its direct dependency ids (present for every status, so the transitive required set is computable even when the artifact is done); missingDeps appears only when blocked. The artifacts array is in dependency order, with the schema's artifacts: declaration order breaking ties between artifacts that become ready at the same time (never alphabetical), so the first ready entry is the artifact to write next; missingDeps uses that same order. "skipped" marks an artifact whose generates path is under specs/ in a change whose .openspec.yaml declares skip_specs: true; it satisfies dependencies but must not be created. No active changes: { "changes": [], "message", "root" }, exit 0.
--all (batch, mutually exclusive with --change — combining them is an error with the { "changes": [], "root": null, "status": [d] } null-shape): { "changes": [ <per-change status object, no per-change root>, ... ], "root" }, sorted by change name. A change that fails to load contributes { "changeName", "status": [d] } in place; the sweep continues, preserves the complete envelope, and exits 1 in both text and JSON modes. An invalid --schema fails the whole invocation with the null-shape, even when no changes exist.
4.5 instructions <artifact> --json
{ "changeName", "artifactId", "schemaName", "changeDir", "planningHome"?, "outputPath", "resolvedOutputPath", "existingOutputPaths", "description", "instruction"?, "context"?, "rules"?, "references"?: ReferenceIndexEntry[], "skipped"?, "warning"?, "template", "dependencies": [{id,done,path,description,skipped?}], "unlocks", "root" }. unlocks lists the artifacts this one makes ready, in the schema's declaration order (the same order status recommends them). "skipped": true (with "warning") appears when the change declares skip_specs: true and this artifact is skipped — do not create its files. A dependency entry with skipped: true is satisfied without files — do not try to read its paths.
ReferenceIndexEntry: { "store_id", "root"?, "specs"?: [{id,summary}], "fetch"?, "status": [] } — resolved entries carry root/specs/fetch; unresolved carry store_id + warning status. Index capped at 50KB (reference_index_truncated).
4.6 instructions apply --json
{ "changeName", "changeDir", "schemaName", "contextFiles": { "<artifactId>": ["/abs", ...] }, "progress": {total,complete,remaining}, "tasks": [{id,description,done}], "state": "blocked"|"all_done"|"ready", "missingArtifacts"?, "missingPrerequisites"?, "warnings"?, "instruction", "references"?, "context"?, "operationGuidance"?, "root" }. missingArtifacts is what apply blocks on (the schema's apply.requires); missingPrerequisites is everything still to build before apply can run, in build order - the transitive closure of those requires, so it can be the longer list. warnings lists non-blocking problems with the change itself - today, a change that is ready to implement with no delta specs and no skip_specs: true, the state openspec validate rejects. Both optional root fields (context, operationGuidance) are read from the selected root on every invocation. context is a required prompt-level input whose relevant project facts, conventions, and constraints must be applied; operationGuidance is advisory input whose entries are followed only when applicable and compatible with the built-in workflow. Both remain separate from state, tasks, progress, context files, and the built-in instruction.
4.7 instructions archive --json
{ "changeName", "context"?, "operationGuidance"?, "root" }. Requires a valid --change in the resolved repo/store root and uses the same required-context/advisory-guidance semantics as apply. This is a read-only runtime-input surface: it does not return the static archive workflow, inspect or merge delta specs, write main specs, or move the change.
4.8 new change <name> --json
Success: { "change": { "id", "path", "metadataPath", "schema" }, "root" }. Failure: { "change": null, "status": [d] }, exit 1.
4.9 archive <name> --json
Success: { "archive": { "change", "archivedAs": "YYYY-MM-DD-name", "path", "specsUpdated", "totals"?, "warnings"? }, "root" }. Failure: { "archive": null, "root"?, "status": [d] }, exit 1. specsUpdated is true only when at least one spec file was written or retired (a capability whose last requirement the change removed has its spec deleted, which requires retire_capabilities: true in the change's .openspec.yaml; every retirement is named in warnings, with a pasteable Git recovery command only when the spec lived in the caller's checkout); an already-synced change archives with all-zero totals and the skips listed in warnings. JSON mode is strictly non-interactive: every prompt point becomes an archive_* code.
4.10 doctor --json
{ "root": { "path", "source", "store_id"?, "healthy", "status": [] }, "store": { "id", "metadata": {present,valid,remote?}, "origin_url"?, "drift"?: {ahead,behind}, "status": [] } | null, "references": [...], "status": [] }. drift (present only for a git-backed store checkout that has an upstream tracking ref) is ahead/behind counts against the last-fetched upstream, not the live remote. Health findings of any severity exit 0. Failure payload: { "root": null, "store": null, "references": [], "status": [d] }, exit 1.
4.11 context --json
{ "root": { "path", "source", "store_id"?, "role": "openspec_root" }, "members": [ { "role": "referenced_store", "id", "path"?, "remote"?, "fetch"?, "status": [] } ], "status": [] }. AVAILABLE = path present AND status empty. --code-workspace <path> writes {folders:[{name,path}]} (available referenced stores only, ref: prefixes); in JSON mode the write runs before printing so stdout holds exactly one document even on write failure. Failure: { "root": null, "members": [], "status": [d] }, exit 1.
4.12 store ... --json
setup/register: { "store": {id, root, metadata_path?}, "registry": {path, registered, already_registered}, "git": {is_repository, initialized, committed}, "created_files": [], "status": [] }. unregister/remove: { "store", "registry": {path, removed}, "files": {deleted, deleted_path, left_on_disk}, "status": [] }. list: { "stores": [{id, root}], "status": [] }. doctor: { "stores": [ { id, root, metadata_path?, openspec_root: {...healthy, status}, metadata: {present, valid, id?, remote}, git: {is_repository, has_commits, has_uncommitted_changes, has_remote, origin_url}, status } ], "status": [] } (null = unknown/not probed). Health findings exit 0; failures exit 1 with the matching null-shape. Prompt cancellation exits 130.
4.13 schemas --json / templates --json
schemas: success remains a bare array [ {name, description, artifacts, source} ]; it resolves the canonical root-selection precedence and accepts --store <id>. Root-selection failure: { "schemas": [], "root": null, "status": [d] }, exit 1. templates: keyed object { "<artifactId>": {path, source} }, still cwd-based with no root/status keys.
5. Exit-code contract
| Situation | Exit | Stdout |
|---|---|---|
| Success, incl. health findings (doctor/context/store doctor) | 0 | the payload |
Command failure in --json mode |
1 | one JSON document with status: [d] and the command's null-shape |
validate with failing items |
1 | full report |
Prompt cancellation (store group, human mode) |
130 | stderr only |
6. Diagnostic code catalog
Resolution
no_openspec_root, no_root_with_registered_stores, no_registered_stores, unknown_store, store_identity_mismatch, unhealthy_store_root, store_path_not_supported, invalid_store_pointer, initiative_option_removed, areas_option_removed; pass-through: invalid_store_id, invalid_store_registry, invalid_store_metadata.
OpenSpec-root health (error, no fix)
openspec_store_root_missing, openspec_store_root_not_directory, openspec_root_missing, openspec_root_not_directory, openspec_config_missing, openspec_config_not_file, openspec_specs_not_directory, openspec_changes_not_directory, openspec_archive_not_directory. During the stores beta, openspec/specs/, openspec/changes/, and openspec/changes/archive/ may be absent in a healthy root; they are only health errors when present but not directories.
Store registry/identity/state
invalid_store_id, invalid_store_registry, invalid_store_metadata, store_registry_busy, store_not_found, no_store_registry, store_registry_changed, store_metadata_missing, store_metadata_id_mismatch, store_metadata_invalid, store_id_conflict, store_path_conflict, store_already_registered (info).
Store setup/register/remove
store_setup_id_required, store_setup_path_required, store_setup_path_not_directory, store_setup_inside_git_repo, store_setup_non_empty_directory, store_setup_cancelled, store_path_required, store_path_missing, store_path_not_directory, store_root_pointer_declared, store_register_root_unhealthy, store_register_identity_confirmation_required, store_register_cancelled, store_remote_empty, store_remote_requires_hand_edit, store_remove_confirmation_required, store_remove_cancelled, store_remove_path_not_directory, store_remove_metadata_missing, store_root_missing (warning in remove, error in doctor), store_root_not_directory.
Store git
store_git_init_failed, store_git_identity_missing, store_git_commit_failed, store_git_no_commits (warning), store_clone_fragile_directories (warning), store_remote_divergence (info, doctor), store_checkout_drift (info, doctor).
References (warning)
reference_invalid_id, reference_registry_unreadable, reference_unresolved, reference_root_unhealthy, reference_index_truncated.
Relationships (warning; doctor; context keeps only the registry one)
relationship_registry_unreadable, root_pointer_ignored, root_pointer_invalid, pointer_declarations_inert.
Archive (JSON mode)
archive_change_name_required, archive_change_not_found, archive_change_symlink, archive_validation_failed, archive_confirmation_required, archive_tasks_incomplete, archive_spec_update_failed, archive_spec_validation_failed, archive_target_exists, archive_error.
Context writes
context_file_exists, context_output_dir_missing.
Fallbacks
doctor_failed, context_failed, store_error, change_error, archive_error.
Known inconsistencies
Recorded by the capstone audit; published-key renames are product decisions deferred past this release:
InFixed in the capstone gauntlet round:--jsonmode, several failure paths printed stderr only with no JSON document.show/validateunknown and ambiguous items emit{status:[{code: unknown_item | ambiguous_item, ...}]}; thrown errors instatus/instructions/list/show/validateroute through the JSON-aware failure helper (the command's null-shape +status);store <unknown subcommand> --jsonemits{status:[{code: unknown_store_subcommand}]};listcarries its{changes|specs: [], root: null}null-shape on resolution failures.store_root_missingis emitted with two severities (warning in remove, error in store doctor) — context-dependent, documented above.- snake_case (store family) vs camelCase (workflow family) key casing;
root.store_idis snake_case everywhere. - Four parallel envelope type declarations exist in src; archive diagnostics never carry
target. list --jsonreuses thestatuskey as a string enum per change.- Only
validateoutput carries aversionfield. templatesignores root selection (cwd-based, no--store).- Deprecated noun forms (
change/specsubcommands) emit unenveloped payloads withoutroot/status.