From 9fa328ad2fd605cb99aa5c252bf2d801a2e6e869 Mon Sep 17 00:00:00 2001 From: Michael Tanner <57499600+m-tanner@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:53:09 -0700 Subject: [PATCH 1/8] feat(status): add --all for batch status of every active change `openspec status --all --json` reports every active change in one process instead of one CLI spawn (~500ms module-load) per change, mirroring the existing `validate --all`. Emits a single `{ changes: [ChangeStatus, ...], root }` envelope sorted by change name; a change that fails to load contributes a per-change error entry instead of failing the sweep. `--all` and `--change` are mutually exclusive, honoring the --json null-shape on failure. Co-Authored-By: Claude Fable 5 --- .changeset/status-all-flag.md | 7 + docs/agent-contract.md | 2 + docs/cli.md | 6 + src/cli/index.ts | 9 +- src/commands/workflow/status.ts | 56 +++++++ src/core/completions/command-registry.ts | 4 + test/commands/status-all.test.ts | 177 +++++++++++++++++++++++ 7 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 .changeset/status-all-flag.md create mode 100644 test/commands/status-all.test.ts diff --git a/.changeset/status-all-flag.md b/.changeset/status-all-flag.md new file mode 100644 index 0000000000..ddac8f7686 --- /dev/null +++ b/.changeset/status-all-flag.md @@ -0,0 +1,7 @@ +--- +"@fission-ai/openspec": minor +--- + +### Features + +- **`status --all`** — report every active change in one process. `openspec status --all --json` emits a single `{ changes: [ChangeStatus, ...], root }` envelope sorted by change name; a change that fails to load contributes a per-change error entry (`{ changeName, status: [diagnostic] }`) instead of failing the sweep. Mutually exclusive with `--change`. Mirrors the existing `validate --all`. diff --git a/docs/agent-contract.md b/docs/agent-contract.md index 9338891f43..a222503e61 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -58,6 +58,8 @@ Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id ### 4.4 `status --json` `{ "changeName", "schemaName", "planningHome"?: { "kind", "root", "changesDir", "defaultSchema" }, "changeRoot", "artifactPaths": { "": {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": [], "status": [d] }` null-shape): `{ "changes": [ , ... ], "root" }`, sorted by change name, exit 0. A change that fails to load contributes `{ "changeName", "status": [d] }` in place; the sweep does not abort. + ### 4.5 `instructions --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. diff --git a/docs/cli.md b/docs/cli.md index 7c5a75291b..9a7b0d7e81 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -729,6 +729,7 @@ openspec status [options] | Option | Description | |--------|-------------| | `--change ` | Change name (prompts if omitted) | +| `--all` | Show status for all active changes (mutually exclusive with `--change`) | | `--schema ` | Schema override (auto-detected from change's config) | | `--json` | Output as JSON | @@ -743,8 +744,13 @@ openspec status --change add-dark-mode # JSON for agent use openspec status --change add-dark-mode --json + +# Every active change in one call (batch health-check) +openspec status --all --json ``` +With `--all --json` the output is one envelope, `{ "changes": [ , ... ], "root": ... }`, with the changes sorted by name. A change that fails to load contributes `{ "changeName", "status": [diagnostic] }` instead of aborting the sweep. + **Output (text):** ``` diff --git a/src/cli/index.ts b/src/cli/index.ts index d1bb282998..40f028badc 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -640,6 +640,7 @@ program .command('status') .description('Display artifact completion status for a change') .option('--change ', 'Change name to show status for') + .option('--all', 'Show status for all active changes') .option('--schema ', 'Schema override (auto-detected from config.yaml)') .option('--json', 'Output as JSON') .option('--store ', STORE_OPTION_DESCRIPTION) @@ -648,7 +649,13 @@ program try { await statusCommand(options); } catch (error) { - failWithError(error, { enabled: options.json, fallbackCode: 'change_error' }); + failWithError(error, { + enabled: options.json, + // The batch null-shape; the single-change failure shape is + // pre-existing contract and stays payload-free. + payload: options.all ? { changes: [] } : undefined, + fallbackCode: 'change_error', + }); process.exit(1); } }); diff --git a/src/commands/workflow/status.ts b/src/commands/workflow/status.ts index 32f5950716..685e65c2c8 100644 --- a/src/commands/workflow/status.ts +++ b/src/commands/workflow/status.ts @@ -19,6 +19,8 @@ import { formatChangeStatus, type ChangeStatus, } from '../../core/artifact-graph/index.js'; +import { asStatus } from '../shared-output.js'; +import type { StoreDiagnostic } from '../../core/store/errors.js'; import { validateChangeExists, validateSchemaExists, @@ -33,6 +35,7 @@ import { export interface StatusOptions { change?: string; + all?: boolean; schema?: string; store?: string; storePath?: string; @@ -43,7 +46,15 @@ export interface StatusOptions { // Command Implementation // ----------------------------------------------------------------------------- +// A batch entry is either a fully loaded status or, for a change that failed +// to load, the change name plus the diagnostic — the sweep never aborts. +type BatchStatusEntry = ChangeStatus | { changeName: string; status: StoreDiagnostic[] }; + export async function statusCommand(options: StatusOptions): Promise { + if (options.all && options.change) { + throw new Error('The --all and --change options are mutually exclusive.'); + } + // The root resolves (and the store banner prints) before the spinner starts // so the two do not fight over stderr. const root = await resolveRootForCommand(options, { json: options.json }); @@ -78,6 +89,51 @@ export async function statusCommand(options: StatusOptions): Promise { console.log(`No active changes. Create one with: ${newChangeHint}`); return; } + + if (options.all) { + if (options.schema) { + validateSchemaExists(options.schema, projectRoot); + } + + // readdir order is platform-dependent; sort for deterministic output + // (same reason validate's listChangeIds sorts). + const entries: BatchStatusEntry[] = []; + for (const changeName of [...available].sort()) { + try { + const context = loadChangeContext(projectRoot, changeName, options.schema, { + changeDir: getChangeDir(planningHome, changeName), + planningHome, + }); + entries.push( + formatChangeStatus(context, isStoreSelectedRoot(root) ? { storeId: root.storeId } : {}) + ); + } catch (error) { + // One malformed change must not blank the sweep; carry its + // diagnostic in place and keep going. + entries.push({ changeName, status: [asStatus(error, 'change_error')] }); + } + } + + spinner?.stop(); + + if (options.json) { + console.log(JSON.stringify({ changes: entries, root: rootOutput }, null, 2)); + return; + } + + entries.forEach((entry, index) => { + if (index > 0) { + console.log(); + } + if ('artifacts' in entry) { + printStatusText(entry); + } else { + console.log(chalk.red(`✗ ${entry.changeName}: ${entry.status[0]?.message}`)); + } + }); + return; + } + // Changes exist but --change not provided spinner?.stop(); throw new Error( diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 67cd8d8172..f402ba777f 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -190,6 +190,10 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Change name to show status for', takesValue: true, }, + { + name: 'all', + description: 'Show status for all active changes', + }, { name: 'schema', description: 'Schema override', diff --git a/test/commands/status-all.test.ts b/test/commands/status-all.test.ts new file mode 100644 index 0000000000..adc1c70c63 --- /dev/null +++ b/test/commands/status-all.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { runCLI } from '../helpers/run-cli.js'; + +describe('status --all', () => { + let tempDir: string; + let changesDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-status-all-')); + changesDir = path.join(tempDir, 'openspec', 'changes'); + await fs.mkdir(changesDir, { recursive: true }); + }); + + afterEach(async () => { + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + function getOutput(result: { stdout: string; stderr: string }): string { + return result.stdout + result.stderr; + } + + async function createTestChange( + changeName: string, + artifacts: ('design' | 'specs' | 'tasks')[] = [] + ): Promise { + const changeDir = path.join(changesDir, changeName); + await fs.mkdir(changeDir, { recursive: true }); + + // proposal.md marks the change as active + await fs.writeFile( + path.join(changeDir, 'proposal.md'), + '## Why\nMinimal proposal.\n\n## What Changes\n- **test:** Placeholder' + ); + + if (artifacts.includes('design')) { + await fs.writeFile(path.join(changeDir, 'design.md'), '# Design\n\nTechnical design.'); + } + + if (artifacts.includes('specs')) { + const specsDir = path.join(changeDir, 'specs'); + await fs.mkdir(specsDir, { recursive: true }); + await fs.writeFile(path.join(specsDir, 'test-spec.md'), '## Purpose\nTest spec.'); + } + + if (artifacts.includes('tasks')) { + await fs.writeFile(path.join(changeDir, 'tasks.md'), '## Tasks\n- [ ] Task 1'); + } + + return changeDir; + } + + it('reports every active change in alphabetical order', async () => { + // Created out of order to prove the output sort is not readdir order + await createTestChange('zebra-change', ['design']); + await createTestChange('alpha-change'); + await createTestChange('mid-change', ['design', 'specs', 'tasks']); + + const result = await runCLI(['status', '--all', '--json'], { cwd: tempDir }); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); + + const json = JSON.parse(result.stdout); + expect(json.changes.map((c: any) => c.changeName)).toEqual([ + 'alpha-change', + 'mid-change', + 'zebra-change', + ]); + }); + + it('emits the empty envelope when no changes exist', async () => { + const result = await runCLI(['status', '--all', '--json'], { cwd: tempDir }); + expect(result.exitCode).toBe(0); + + const json = JSON.parse(result.stdout); + expect(json.changes).toEqual([]); + expect(json.message).toBe('No active changes.'); + expect(json.root).toBeDefined(); + }); + + it('hoists root to the envelope and carries a full ChangeStatus per change', async () => { + await createTestChange('json-change', ['design']); + + const result = await runCLI(['status', '--all', '--json'], { cwd: tempDir }); + expect(result.exitCode).toBe(0); + + const json = JSON.parse(result.stdout); + expect(json.root).toBeDefined(); + expect(typeof json.root.path).toBe('string'); + expect(json.changes).toHaveLength(1); + + const entry = json.changes[0]; + // Full ChangeStatus shape, same as the single-change payload + expect(entry.changeName).toBe('json-change'); + expect(entry.schemaName).toBe('spec-driven'); + expect(entry.isComplete).toBe(false); + expect(Array.isArray(entry.artifacts)).toBe(true); + expect(entry.artifacts).toHaveLength(4); + expect(Array.isArray(entry.nextSteps)).toBe(true); + expect(entry.actionContext).toBeDefined(); + expect(entry.artifactPaths).toBeDefined(); + // root lives on the envelope only + expect(entry.root).toBeUndefined(); + + const designArtifact = entry.artifacts.find((a: any) => a.id === 'design'); + expect(designArtifact.status).toBe('done'); + }); + + it('rejects --all combined with --change', async () => { + await createTestChange('some-change'); + + const result = await runCLI(['status', '--all', '--change', 'some-change'], { + cwd: tempDir, + }); + expect(result.exitCode).toBe(1); + expect(getOutput(result)).toContain('mutually exclusive'); + }); + + it('honors the JSON null-shape when --all and --change are combined', async () => { + await createTestChange('some-change'); + + const result = await runCLI( + ['status', '--all', '--change', 'some-change', '--json'], + { cwd: tempDir } + ); + expect(result.exitCode).toBe(1); + + // Exactly one JSON document: null-shape plus status array + const json = JSON.parse(result.stdout); + expect(json.changes).toEqual([]); + expect(Array.isArray(json.status)).toBe(true); + expect(json.status[0].severity).toBe('error'); + expect(json.status[0].message).toContain('mutually exclusive'); + }); + + it('keeps sweeping when one change fails to load', async () => { + await createTestChange('good-change', ['design']); + const brokenDir = await createTestChange('broken-change'); + // An unknown schema in the metadata makes loadChangeContext throw + await fs.writeFile( + path.join(brokenDir, '.openspec.yaml'), + 'schema: no-such-schema\n' + ); + + const result = await runCLI(['status', '--all', '--json'], { cwd: tempDir }); + expect(result.exitCode).toBe(0); + + const json = JSON.parse(result.stdout); + expect(json.changes).toHaveLength(2); + + const broken = json.changes.find((c: any) => c.changeName === 'broken-change'); + expect(Array.isArray(broken.status)).toBe(true); + expect(broken.status[0].code).toBe('change_error'); + expect(broken.status[0].severity).toBe('error'); + expect(broken.artifacts).toBeUndefined(); + + const good = json.changes.find((c: any) => c.changeName === 'good-change'); + expect(good.schemaName).toBe('spec-driven'); + expect(good.artifacts).toHaveLength(4); + }); + + it('prints one text block per change with --all', async () => { + await createTestChange('first-change'); + await createTestChange('second-change', ['design']); + + const result = await runCLI(['status', '--all'], { cwd: tempDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Change: first-change'); + expect(result.stdout).toContain('Change: second-change'); + expect(result.stdout).toContain('1/4 artifacts complete'); + expect(result.stdout).toContain('2/4 artifacts complete'); + }); +}); From 13e8e7c28a84d4a9c0accccef3e2fd32dc5d73f6 Mon Sep 17 00:00:00 2001 From: Michael Tanner <57499600+m-tanner@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:43:14 -0700 Subject: [PATCH 2/8] fix(status): harden --all per adversarial review findings - Validate --schema before the no-changes early return so a bogus schema fails consistently whether or not any change exists. - Text mode now exits 1 when any change fails to load (mirrors validate --all); JSON mode still exits 0 with per-change diagnostics. - Add tests for the --all --schema interaction (unknown schema null-shape, override propagation, broken-metadata precedence) and text-mode failure rendering. - Changeset heading to "### New Features" per repo convention; add status --all to the agent quick-reference table in docs/cli.md. Co-Authored-By: Claude Fable 5 --- .changeset/status-all-flag.md | 4 +- docs/agent-contract.md | 2 +- docs/cli.md | 4 +- src/commands/workflow/status.ts | 18 ++++-- test/commands/status-all.test.ts | 105 +++++++++++++++++++++++++++++++ 5 files changed, 124 insertions(+), 9 deletions(-) diff --git a/.changeset/status-all-flag.md b/.changeset/status-all-flag.md index ddac8f7686..d7f7621e29 100644 --- a/.changeset/status-all-flag.md +++ b/.changeset/status-all-flag.md @@ -2,6 +2,6 @@ "@fission-ai/openspec": minor --- -### Features +### New Features -- **`status --all`** — report every active change in one process. `openspec status --all --json` emits a single `{ changes: [ChangeStatus, ...], root }` envelope sorted by change name; a change that fails to load contributes a per-change error entry (`{ changeName, status: [diagnostic] }`) instead of failing the sweep. Mutually exclusive with `--change`. Mirrors the existing `validate --all`. +- **`status --all`** — report every active change in one process. `openspec status --all --json` emits a single `{ changes: [ChangeStatus, ...], root }` envelope sorted by change name; a change that fails to load contributes a per-change error entry (`{ changeName, status: [diagnostic] }`) instead of failing the sweep (in text mode a failed change exits 1, like `validate --all`). Mutually exclusive with `--change`. Mirrors the existing `validate --all`. diff --git a/docs/agent-contract.md b/docs/agent-contract.md index a222503e61..2798406aee 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -58,7 +58,7 @@ Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id ### 4.4 `status --json` `{ "changeName", "schemaName", "planningHome"?: { "kind", "root", "changesDir", "defaultSchema" }, "changeRoot", "artifactPaths": { "": {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": [], "status": [d] }` null-shape): `{ "changes": [ , ... ], "root" }`, sorted by change name, exit 0. A change that fails to load contributes `{ "changeName", "status": [d] }` in place; the sweep does not abort. +`--all` (batch, mutually exclusive with `--change` — combining them is an error with the `{ "changes": [], "status": [d] }` null-shape): `{ "changes": [ , ... ], "root" }`, sorted by change name, exit 0. A change that fails to load contributes `{ "changeName", "status": [d] }` in place; the sweep does not abort. (Text mode differs: a failed change exits 1, like `validate --all`.) An invalid `--schema` fails the whole invocation with the null-shape, even when no changes exist. ### 4.5 `instructions --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. diff --git a/docs/cli.md b/docs/cli.md index 9a7b0d7e81..2f5e9864ba 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -47,7 +47,7 @@ These commands support `--json` output for programmatic use by AI agents and scr | `openspec list` | Browse changes/specs | `--json` for structured data | | `openspec show ` | Read content | `--json` for parsing | | `openspec validate` | Check for issues | `--all --json` for bulk validation | -| `openspec status` | See artifact progress | `--json` for structured status | +| `openspec status` | See artifact progress | `--all --json` for bulk status, `--json` for one change | | `openspec instructions` | Get next steps | `--json` for agent instructions | | `openspec templates` | Find template paths | `--json` for path resolution | | `openspec schemas` | List available schemas | `--json` for schema discovery; `--store ` to select a registered root | @@ -749,7 +749,7 @@ openspec status --change add-dark-mode --json openspec status --all --json ``` -With `--all --json` the output is one envelope, `{ "changes": [ , ... ], "root": ... }`, with the changes sorted by name. A change that fails to load contributes `{ "changeName", "status": [diagnostic] }` instead of aborting the sweep. +With `--all --json` the output is one envelope, `{ "changes": [ , ... ], "root": ... }`, with the changes sorted by name. A change that fails to load contributes `{ "changeName", "status": [diagnostic] }` instead of aborting the sweep; the JSON sweep itself exits 0. In text mode a failed change prints a one-line `✗ : ` and the command exits 1 (like `validate --all`). **Output (text):** diff --git a/src/commands/workflow/status.ts b/src/commands/workflow/status.ts index 685e65c2c8..57fbef3b95 100644 --- a/src/commands/workflow/status.ts +++ b/src/commands/workflow/status.ts @@ -73,6 +73,12 @@ export async function statusCommand(options: StatusOptions): Promise { // Handle no-changes case gracefully — status is informational, // so "no changes" is a valid state, not an error. if (!options.change) { + // Validate before the no-changes early return so a bogus --schema + // fails the same way whether or not any change exists yet. + if (options.all && options.schema) { + validateSchemaExists(options.schema, projectRoot); + } + const available = await getAvailableChanges(projectRoot, root.changesDir); if (available.length === 0) { spinner?.stop(); @@ -91,10 +97,6 @@ export async function statusCommand(options: StatusOptions): Promise { } if (options.all) { - if (options.schema) { - validateSchemaExists(options.schema, projectRoot); - } - // readdir order is platform-dependent; sort for deterministic output // (same reason validate's listChangeIds sorts). const entries: BatchStatusEntry[] = []; @@ -121,6 +123,7 @@ export async function statusCommand(options: StatusOptions): Promise { return; } + let failed = 0; entries.forEach((entry, index) => { if (index > 0) { console.log(); @@ -128,9 +131,16 @@ export async function statusCommand(options: StatusOptions): Promise { if ('artifacts' in entry) { printStatusText(entry); } else { + failed += 1; console.log(chalk.red(`✗ ${entry.changeName}: ${entry.status[0]?.message}`)); } }); + // Text mode signals load failures via the exit code (like + // validate --all); JSON mode instead exits 0 and carries the + // per-change diagnostics so the sweep result stays parseable. + if (failed > 0) { + process.exitCode = 1; + } return; } diff --git a/test/commands/status-all.test.ts b/test/commands/status-all.test.ts index adc1c70c63..db79b6c28f 100644 --- a/test/commands/status-all.test.ts +++ b/test/commands/status-all.test.ts @@ -174,4 +174,109 @@ describe('status --all', () => { expect(result.stdout).toContain('1/4 artifacts complete'); expect(result.stdout).toContain('2/4 artifacts complete'); }); + + it('exits 1 in text mode when a change fails to load, still printing the others', async () => { + await createTestChange('good-change', ['design']); + const brokenDir = await createTestChange('broken-change'); + await fs.writeFile( + path.join(brokenDir, '.openspec.yaml'), + 'schema: no-such-schema\n' + ); + + const result = await runCLI(['status', '--all'], { cwd: tempDir }); + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain('✗ broken-change:'); + expect(result.stdout).toContain('Change: good-change'); + expect(result.stdout).toContain('2/4 artifacts complete'); + }); + + describe('--schema interaction', () => { + /** Writes a minimal project-local schema so an override is distinguishable from the default. */ + async function createProjectSchema(schemaName: string): Promise { + const schemaDir = path.join(tempDir, 'openspec', 'schemas', schemaName); + await fs.mkdir(schemaDir, { recursive: true }); + await fs.writeFile( + path.join(schemaDir, 'schema.yaml'), + [ + `name: ${schemaName}`, + 'version: 1', + 'description: Minimal test schema', + 'artifacts:', + ' - id: proposal', + ' generates: proposal.md', + ' description: Proposal document', + ' template: proposal.md', + '', + ].join('\n') + ); + } + + it('fails with the null-shape when --schema names an unknown schema', async () => { + await createTestChange('some-change'); + + const result = await runCLI( + ['status', '--all', '--schema', 'no-such-schema', '--json'], + { cwd: tempDir } + ); + expect(result.exitCode).toBe(1); + + const json = JSON.parse(result.stdout); + expect(json.changes).toEqual([]); + expect(Array.isArray(json.status)).toBe(true); + expect(json.status[0].severity).toBe('error'); + expect(json.status[0].message).toContain("'no-such-schema' not found"); + }); + + it('rejects an unknown --schema even when no changes exist', async () => { + const result = await runCLI( + ['status', '--all', '--schema', 'no-such-schema', '--json'], + { cwd: tempDir } + ); + expect(result.exitCode).toBe(1); + + const json = JSON.parse(result.stdout); + expect(json.changes).toEqual([]); + expect(json.status[0].message).toContain("'no-such-schema' not found"); + }); + + it('applies a valid --schema override to every change', async () => { + await createProjectSchema('mini'); + await createTestChange('first-change'); + await createTestChange('second-change'); + + const result = await runCLI(['status', '--all', '--schema', 'mini', '--json'], { + cwd: tempDir, + }); + expect(result.exitCode).toBe(0); + + const json = JSON.parse(result.stdout); + expect(json.changes).toHaveLength(2); + for (const entry of json.changes) { + expect(entry.schemaName).toBe('mini'); + expect(entry.artifacts).toHaveLength(1); + } + }); + + it('does not rescue a change with broken metadata via an explicit --schema', async () => { + await createTestChange('good-change'); + const brokenDir = await createTestChange('broken-change'); + await fs.writeFile( + path.join(brokenDir, '.openspec.yaml'), + 'schema: no-such-schema\n' + ); + + const result = await runCLI( + ['status', '--all', '--schema', 'spec-driven', '--json'], + { cwd: tempDir } + ); + expect(result.exitCode).toBe(0); + + const json = JSON.parse(result.stdout); + const broken = json.changes.find((c: any) => c.changeName === 'broken-change'); + expect(broken.status[0].code).toBe('change_error'); + + const good = json.changes.find((c: any) => c.changeName === 'good-change'); + expect(good.schemaName).toBe('spec-driven'); + }); + }); }); From 06bb72263b4544eb8a9c54ea24bfd6a8b63b8ee3 Mon Sep 17 00:00:00 2001 From: Michael Tanner <57499600+m-tanner@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:02:17 -0700 Subject: [PATCH 3/8] fix(status): thread batch null-shape through root resolution, align sort with validate Code-review findings on --all: - Pass failurePayload: { changes: [] } to resolveRootForCommand so a root-selection failure under --all --json still emits the documented batch null-shape (siblings like list/doctor/context already do this). - Sort with localeCompare to match validate --all's ordering for mixed-case change names. - Extract a shared loadStatus helper so the batch and single-change payloads cannot drift apart. - Changeset no longer claims exact validate --all parity (JSON exit semantics deliberately differ). Co-Authored-By: Claude Fable 5 --- .changeset/status-all-flag.md | 2 +- src/commands/workflow/status.ts | 43 +++++++++++++++++--------------- test/commands/status-all.test.ts | 13 ++++++++++ 3 files changed, 37 insertions(+), 21 deletions(-) diff --git a/.changeset/status-all-flag.md b/.changeset/status-all-flag.md index d7f7621e29..ba37793c66 100644 --- a/.changeset/status-all-flag.md +++ b/.changeset/status-all-flag.md @@ -4,4 +4,4 @@ ### New Features -- **`status --all`** — report every active change in one process. `openspec status --all --json` emits a single `{ changes: [ChangeStatus, ...], root }` envelope sorted by change name; a change that fails to load contributes a per-change error entry (`{ changeName, status: [diagnostic] }`) instead of failing the sweep (in text mode a failed change exits 1, like `validate --all`). Mutually exclusive with `--change`. Mirrors the existing `validate --all`. +- **`status --all`** — report every active change in one process. `openspec status --all --json` emits a single `{ changes: [ChangeStatus, ...], root }` envelope sorted by change name; a change that fails to load contributes a per-change error entry (`{ changeName, status: [diagnostic] }`) instead of failing the sweep (in text mode a failed change exits 1, like `validate --all`). Mutually exclusive with `--change`. Modeled on the existing `validate --all`; note the JSON sweep exits 0 with per-change diagnostics, unlike validate's JSON mode. diff --git a/src/commands/workflow/status.ts b/src/commands/workflow/status.ts index 57fbef3b95..b8ece91968 100644 --- a/src/commands/workflow/status.ts +++ b/src/commands/workflow/status.ts @@ -56,8 +56,12 @@ export async function statusCommand(options: StatusOptions): Promise { } // The root resolves (and the store banner prints) before the spinner starts - // so the two do not fight over stderr. - const root = await resolveRootForCommand(options, { json: options.json }); + // so the two do not fight over stderr. The batch null-shape rides along so + // a root-selection failure under --all --json still carries `changes: []`. + const root = await resolveRootForCommand(options, { + json: options.json, + ...(options.all ? { failurePayload: { changes: [] } } : {}), + }); if (!root) { return; } @@ -70,6 +74,17 @@ export async function statusCommand(options: StatusOptions): Promise { const rootOutput = toRootOutput(root); const newChangeHint = withStoreFlag(root, 'openspec new change '); + // Single definition of "load one change's status" so the batch and + // single-change payloads can never drift apart. + const loadStatus = (changeName: string): ChangeStatus => + formatChangeStatus( + loadChangeContext(projectRoot, changeName, options.schema, { + changeDir: getChangeDir(planningHome, changeName), + planningHome, + }), + isStoreSelectedRoot(root) ? { storeId: root.storeId } : {} + ); + // Handle no-changes case gracefully — status is informational, // so "no changes" is a valid state, not an error. if (!options.change) { @@ -97,18 +112,13 @@ export async function statusCommand(options: StatusOptions): Promise { } if (options.all) { - // readdir order is platform-dependent; sort for deterministic output - // (same reason validate's listChangeIds sorts). + // readdir order is platform-dependent; sort for deterministic output, + // with the same comparator validate --all uses so the two batch + // commands order a given change set identically. const entries: BatchStatusEntry[] = []; - for (const changeName of [...available].sort()) { + for (const changeName of [...available].sort((a, b) => a.localeCompare(b))) { try { - const context = loadChangeContext(projectRoot, changeName, options.schema, { - changeDir: getChangeDir(planningHome, changeName), - planningHome, - }); - entries.push( - formatChangeStatus(context, isStoreSelectedRoot(root) ? { storeId: root.storeId } : {}) - ); + entries.push(loadStatus(changeName)); } catch (error) { // One malformed change must not blank the sweep; carry its // diagnostic in place and keep going. @@ -164,14 +174,7 @@ export async function statusCommand(options: StatusOptions): Promise { } // loadChangeContext will auto-detect schema from metadata if not provided - const context = loadChangeContext(projectRoot, changeName, options.schema, { - changeDir: getChangeDir(planningHome, changeName), - planningHome, - }); - const status = formatChangeStatus( - context, - isStoreSelectedRoot(root) ? { storeId: root.storeId } : {} - ); + const status = loadStatus(changeName); spinner?.stop(); diff --git a/test/commands/status-all.test.ts b/test/commands/status-all.test.ts index db79b6c28f..7351d58c99 100644 --- a/test/commands/status-all.test.ts +++ b/test/commands/status-all.test.ts @@ -120,6 +120,19 @@ describe('status --all', () => { expect(getOutput(result)).toContain('mutually exclusive'); }); + it('honors the JSON null-shape when root selection fails under --all', async () => { + const result = await runCLI(['status', '--all', '--json', '--store', 'no-such-store'], { + cwd: tempDir, + }); + expect(result.exitCode).toBe(1); + + // The failure document must still carry the batch null-shape. + const json = JSON.parse(result.stdout); + expect(json.changes).toEqual([]); + expect(Array.isArray(json.status)).toBe(true); + expect(json.status[0].severity).toBe('error'); + }); + it('honors the JSON null-shape when --all and --change are combined', async () => { await createTestChange('some-change'); From e933f45cff60cd578766d16cdc55ca3c86e95e72 Mon Sep 17 00:00:00 2001 From: Michael Tanner <57499600+m-tanner@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:10:03 -0700 Subject: [PATCH 4/8] refactor(status): give the --all failure null-shape a single owner Simplify pass on the --all diff: hoist the { changes: [] } batch null-shape into an exported BATCH_STATUS_FAILURE_PAYLOAD constant so the root-resolution and CLI-wrapper failure paths cannot drift, replace the conditional spread with the plain ternary the sibling call site already uses, drop a redundant array copy before sort, and narrow the text-mode failure counter to the boolean it actually is. Co-Authored-By: Claude Fable 5 --- src/cli/index.ts | 3 ++- src/commands/workflow/index.ts | 2 +- src/commands/workflow/status.ts | 15 ++++++++++----- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 40f028badc..aa629fda42 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -35,6 +35,7 @@ import { registerContextCommand } from '../commands/context.js'; import { registerWorksetCommand } from '../commands/workset.js'; import { statusCommand, + BATCH_STATUS_FAILURE_PAYLOAD, instructionsCommand, applyInstructionsCommand, archiveInstructionsCommand, @@ -653,7 +654,7 @@ program enabled: options.json, // The batch null-shape; the single-change failure shape is // pre-existing contract and stays payload-free. - payload: options.all ? { changes: [] } : undefined, + payload: options.all ? BATCH_STATUS_FAILURE_PAYLOAD : undefined, fallbackCode: 'change_error', }); process.exit(1); diff --git a/src/commands/workflow/index.ts b/src/commands/workflow/index.ts index 4c468760a3..8a95ded710 100644 --- a/src/commands/workflow/index.ts +++ b/src/commands/workflow/index.ts @@ -4,7 +4,7 @@ * Commands for the artifact-driven workflow: status, instructions, templates, schemas, new change. */ -export { statusCommand } from './status.js'; +export { statusCommand, BATCH_STATUS_FAILURE_PAYLOAD } from './status.js'; export type { StatusOptions } from './status.js'; export { diff --git a/src/commands/workflow/status.ts b/src/commands/workflow/status.ts index b8ece91968..365fe9a8b7 100644 --- a/src/commands/workflow/status.ts +++ b/src/commands/workflow/status.ts @@ -50,6 +50,11 @@ export interface StatusOptions { // to load, the change name plus the diagnostic — the sweep never aborts. type BatchStatusEntry = ChangeStatus | { changeName: string; status: StoreDiagnostic[] }; +// The --all --json failure null-shape. Root-selection failures (handled in +// resolveRootForCommand) and thrown errors (caught by the CLI wrapper) must +// emit the same shape, so both call sites reference this one constant. +export const BATCH_STATUS_FAILURE_PAYLOAD: Record = { changes: [] }; + export async function statusCommand(options: StatusOptions): Promise { if (options.all && options.change) { throw new Error('The --all and --change options are mutually exclusive.'); @@ -60,7 +65,7 @@ export async function statusCommand(options: StatusOptions): Promise { // a root-selection failure under --all --json still carries `changes: []`. const root = await resolveRootForCommand(options, { json: options.json, - ...(options.all ? { failurePayload: { changes: [] } } : {}), + failurePayload: options.all ? BATCH_STATUS_FAILURE_PAYLOAD : undefined, }); if (!root) { return; @@ -116,7 +121,7 @@ export async function statusCommand(options: StatusOptions): Promise { // with the same comparator validate --all uses so the two batch // commands order a given change set identically. const entries: BatchStatusEntry[] = []; - for (const changeName of [...available].sort((a, b) => a.localeCompare(b))) { + for (const changeName of available.sort((a, b) => a.localeCompare(b))) { try { entries.push(loadStatus(changeName)); } catch (error) { @@ -133,7 +138,7 @@ export async function statusCommand(options: StatusOptions): Promise { return; } - let failed = 0; + let failed = false; entries.forEach((entry, index) => { if (index > 0) { console.log(); @@ -141,14 +146,14 @@ export async function statusCommand(options: StatusOptions): Promise { if ('artifacts' in entry) { printStatusText(entry); } else { - failed += 1; + failed = true; console.log(chalk.red(`✗ ${entry.changeName}: ${entry.status[0]?.message}`)); } }); // Text mode signals load failures via the exit code (like // validate --all); JSON mode instead exits 0 and carries the // per-change diagnostics so the sweep result stays parseable. - if (failed > 0) { + if (failed) { process.exitCode = 1; } return; From 316a962e1aced144c84b6d60ac3b82aa7f7b9f61 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Thu, 20 Aug 2026 11:47:28 -0500 Subject: [PATCH 5/8] fix(status): point the missing-target error at --all, correct the docs `openspec status` with neither --change nor --all listed the available changes and named only --change, so the batch path was discoverable only from --help. The error now offers both. Also corrects two stale claims in the status section of docs/cli.md that the new row sits next to: the command never prompts for a change (it errors), and bare `openspec status` is not an interactive check. Co-Authored-By: Claude Opus 5 --- .changeset/status-all-flag.md | 4 +--- docs/cli.md | 4 ++-- src/commands/workflow/status.ts | 6 ++++-- test/commands/status-all.test.ts | 9 +++++++++ 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.changeset/status-all-flag.md b/.changeset/status-all-flag.md index ba37793c66..fde140e1d5 100644 --- a/.changeset/status-all-flag.md +++ b/.changeset/status-all-flag.md @@ -2,6 +2,4 @@ "@fission-ai/openspec": minor --- -### New Features - -- **`status --all`** — report every active change in one process. `openspec status --all --json` emits a single `{ changes: [ChangeStatus, ...], root }` envelope sorted by change name; a change that fails to load contributes a per-change error entry (`{ changeName, status: [diagnostic] }`) instead of failing the sweep (in text mode a failed change exits 1, like `validate --all`). Mutually exclusive with `--change`. Modeled on the existing `validate --all`; note the JSON sweep exits 0 with per-change diagnostics, unlike validate's JSON mode. +Add `openspec status --all`, which reports every active change in one process instead of one CLI spawn per change. `--all --json` emits a single `{ "changes": [ , ... ], "root" }` envelope sorted by change name; a change that fails to load contributes `{ "changeName", "status": [diagnostic] }` in place rather than aborting the sweep. In text mode a failed change exits 1, as `validate --all` does; in JSON mode the sweep exits 0 and carries the per-change diagnostics so the result stays parseable. Mutually exclusive with `--change`. diff --git a/docs/cli.md b/docs/cli.md index 2f5e9864ba..269f0cc136 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -728,7 +728,7 @@ openspec status [options] | Option | Description | |--------|-------------| -| `--change ` | Change name (prompts if omitted) | +| `--change ` | Change name (required unless `--all` is given) | | `--all` | Show status for all active changes (mutually exclusive with `--change`) | | `--schema ` | Schema override (auto-detected from change's config) | | `--json` | Output as JSON | @@ -736,7 +736,7 @@ openspec status [options] **Examples:** ```bash -# Interactive status check +# No target given: lists the active changes and asks for --change or --all openspec status # Status for specific change diff --git a/src/commands/workflow/status.ts b/src/commands/workflow/status.ts index 365fe9a8b7..bcc4da778b 100644 --- a/src/commands/workflow/status.ts +++ b/src/commands/workflow/status.ts @@ -159,10 +159,12 @@ export async function statusCommand(options: StatusOptions): Promise { return; } - // Changes exist but --change not provided + // Changes exist but neither --change nor --all provided. Name --all + // here too: it is the other way to answer this prompt, and a caller + // who wants every change should not have to find it in --help. spinner?.stop(); throw new Error( - `Missing required option --change. Available changes:\n ${available.join('\n ')}` + `Missing required option --change (or --all for every active change). Available changes:\n ${available.join('\n ')}` ); } diff --git a/test/commands/status-all.test.ts b/test/commands/status-all.test.ts index 7351d58c99..61a4b9ba36 100644 --- a/test/commands/status-all.test.ts +++ b/test/commands/status-all.test.ts @@ -120,6 +120,15 @@ describe('status --all', () => { expect(getOutput(result)).toContain('mutually exclusive'); }); + it('offers --all when neither --change nor --all is given', async () => { + await createTestChange('some-change'); + + const result = await runCLI(['status'], { cwd: tempDir }); + expect(result.exitCode).toBe(1); + expect(getOutput(result)).toContain('--all'); + expect(getOutput(result)).toContain('some-change'); + }); + it('honors the JSON null-shape when root selection fails under --all', async () => { const result = await runCLI(['status', '--all', '--json', '--store', 'no-such-store'], { cwd: tempDir, From 1716d5bd64af95352aa3dbc58d3191d5b29eb876 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Thu, 20 Aug 2026 11:50:48 -0500 Subject: [PATCH 6/8] test(status): assert --all carries store context like the single-change path The batch sweep resolves the root once and threads the store id into every entry. Nothing pinned that: a regression would have shown up only as a wrong path inside an agent's JSON. Assert the sweep's envelope root and per-change payload match `status --change` in a registered store. Co-Authored-By: Claude Opus 5 --- test/commands/store-root-selection.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/commands/store-root-selection.test.ts b/test/commands/store-root-selection.test.ts index 334c05830f..f2926fb89d 100644 --- a/test/commands/store-root-selection.test.ts +++ b/test/commands/store-root-selection.test.ts @@ -229,6 +229,20 @@ describe('store root selection for normal commands', () => { store_id: 'team-context', }); + // The batch sweep must select the same root and carry the same store + // context per change as the single-change path above. + const batch = await runCLI(['status', '--all', '--store', 'team-context', '--json'], { + cwd: appRepo, + env, + }); + expect(batch.exitCode).toBe(0); + const batchJson = parseJson(batch); + expect(batchJson.root).toEqual(statusJson.root); + expect(batchJson.changes.map((change: { changeName: string }) => change.changeName)).toEqual([ + 'store-change', + ]); + expect(batchJson.changes[0]).toEqual({ ...statusJson, root: undefined }); + const instructions = await runCLI( ['instructions', 'design', '--change', 'store-change', '--store', 'team-context', '--json'], { cwd: appRepo, env } From c9ed24d07096eee196477df13e6fee2bad27cafa Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 24 Aug 2026 10:15:23 -0500 Subject: [PATCH 7/8] fix(status): fail incomplete batch reports --- .changeset/status-all-flag.md | 2 +- docs-lab/reference/cli.md | 35 ++++++++++++++++++++++++++------ docs/agent-contract.md | 2 +- docs/cli.md | 12 +++-------- src/commands/workflow/status.ts | 17 ++++++++++------ test/commands/status-all.test.ts | 12 +++++++++-- 6 files changed, 55 insertions(+), 25 deletions(-) diff --git a/.changeset/status-all-flag.md b/.changeset/status-all-flag.md index fde140e1d5..cfd8f8b0cc 100644 --- a/.changeset/status-all-flag.md +++ b/.changeset/status-all-flag.md @@ -2,4 +2,4 @@ "@fission-ai/openspec": minor --- -Add `openspec status --all`, which reports every active change in one process instead of one CLI spawn per change. `--all --json` emits a single `{ "changes": [ , ... ], "root" }` envelope sorted by change name; a change that fails to load contributes `{ "changeName", "status": [diagnostic] }` in place rather than aborting the sweep. In text mode a failed change exits 1, as `validate --all` does; in JSON mode the sweep exits 0 and carries the per-change diagnostics so the result stays parseable. Mutually exclusive with `--change`. +Add `openspec status --all`, which reports every active change in one process instead of one CLI spawn per change. `--all --json` emits a single `{ "changes": [ , ... ], "root" }` envelope sorted by change name; a change that fails to load contributes `{ "changeName", "status": [diagnostic] }` in place rather than aborting the sweep. A partial failure exits 1 in both text and JSON modes while preserving the complete JSON envelope. Mutually exclusive with `--change`. diff --git a/docs-lab/reference/cli.md b/docs-lab/reference/cli.md index d8af89fc6a..fec32c6494 100644 --- a/docs-lab/reference/cli.md +++ b/docs-lab/reference/cli.md @@ -31,7 +31,7 @@ Your agent runs most of these during the workflow. | Command | What it does | |---|---| | [`openspec new`](#openspec-new) | Create a new change directory. | -| [`openspec status`](#openspec-status) | Artifact completion status for a change. | +| [`openspec status`](#openspec-status) | Artifact completion status for one or every active change. | | [`openspec instructions`](#openspec-instructions) | Instructions for creating an artifact, applying, or archiving. | | [`openspec templates`](#openspec-templates) | Resolved template paths for a schema's artifacts. | | [`openspec schemas`](#openspec-schemas) | List available workflow schemas. | @@ -866,17 +866,19 @@ With `--json`: ## openspec status -Reports artifact completion status for a change. +Reports artifact completion status for one change or every active change. ```bash openspec status --change add-rate-limit # checklist view openspec status --change add-rate-limit --json # structured report +openspec status --all # every active change +openspec status --all --json # one batch report ``` -`--change` is required. Without it, status exits 1 and lists the available changes, even when only one exists: +Use exactly one of `--change` or `--all`. Without either, status exits 1 and lists the available changes, even when only one exists: ``` -✖ Error: Missing required option --change. Available changes: +✖ Error: Missing required option --change (or --all for every active change). Available changes: add-rate-limit ``` @@ -885,6 +887,7 @@ openspec status --change add-rate-limit --json # structured report | Flag | Effect | |---|---| | `--change ` | The change to report on, by folder name. | +| `--all` | Report every active change, sorted by name. Can't be combined with `--change`. | | `--schema ` | Override the schema auto-detected from `openspec/config.yaml`. An unknown name is an error. | | `--json` | Print a structured report instead of text. | | `--store ` | Use a registered store as the OpenSpec root instead of the current project. | @@ -946,10 +949,30 @@ Progress: 2/4 artifacts complete } ``` +With `--all --json`, `changes` contains the same status object for each change, without a per-change `root`. The selected root appears once on the envelope: + +```json +{ + "changes": [ + { + "changeName": "add-rate-limit", + "schemaName": "spec-driven", + "artifacts": [] + } + ], + "root": { + "path": "/Users/you/projects/my-app", + "source": "nearest" + } +} +``` + +If one change can't load, the batch continues. Its entry contains `changeName` and a `status` diagnostic while the other entries remain available. The command exits 1, including in JSON mode, so CI doesn't accept an incomplete report as successful. JSON output remains one parseable document. + **Exit codes** -- `0`: status printed. -- `1`: `--change` missing, the change doesn't exist, or the schema override is unknown. +- `0`: every requested status printed; an empty `--all` report also exits 0. +- `1`: a requested change failed to load, `--change` or `--all` is missing, the two flags were combined, the change doesn't exist, or the schema override is unknown. ## openspec instructions diff --git a/docs/agent-contract.md b/docs/agent-contract.md index 2798406aee..30fcfacccf 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -58,7 +58,7 @@ Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id ### 4.4 `status --json` `{ "changeName", "schemaName", "planningHome"?: { "kind", "root", "changesDir", "defaultSchema" }, "changeRoot", "artifactPaths": { "": {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": [], "status": [d] }` null-shape): `{ "changes": [ , ... ], "root" }`, sorted by change name, exit 0. A change that fails to load contributes `{ "changeName", "status": [d] }` in place; the sweep does not abort. (Text mode differs: a failed change exits 1, like `validate --all`.) An invalid `--schema` fails the whole invocation with the null-shape, even when no changes exist. +`--all` (batch, mutually exclusive with `--change` — combining them is an error with the `{ "changes": [], "root": null, "status": [d] }` null-shape): `{ "changes": [ , ... ], "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 --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. diff --git a/docs/cli.md b/docs/cli.md index 269f0cc136..7c5a75291b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -47,7 +47,7 @@ These commands support `--json` output for programmatic use by AI agents and scr | `openspec list` | Browse changes/specs | `--json` for structured data | | `openspec show ` | Read content | `--json` for parsing | | `openspec validate` | Check for issues | `--all --json` for bulk validation | -| `openspec status` | See artifact progress | `--all --json` for bulk status, `--json` for one change | +| `openspec status` | See artifact progress | `--json` for structured status | | `openspec instructions` | Get next steps | `--json` for agent instructions | | `openspec templates` | Find template paths | `--json` for path resolution | | `openspec schemas` | List available schemas | `--json` for schema discovery; `--store ` to select a registered root | @@ -728,15 +728,14 @@ openspec status [options] | Option | Description | |--------|-------------| -| `--change ` | Change name (required unless `--all` is given) | -| `--all` | Show status for all active changes (mutually exclusive with `--change`) | +| `--change ` | Change name (prompts if omitted) | | `--schema ` | Schema override (auto-detected from change's config) | | `--json` | Output as JSON | **Examples:** ```bash -# No target given: lists the active changes and asks for --change or --all +# Interactive status check openspec status # Status for specific change @@ -744,13 +743,8 @@ openspec status --change add-dark-mode # JSON for agent use openspec status --change add-dark-mode --json - -# Every active change in one call (batch health-check) -openspec status --all --json ``` -With `--all --json` the output is one envelope, `{ "changes": [ , ... ], "root": ... }`, with the changes sorted by name. A change that fails to load contributes `{ "changeName", "status": [diagnostic] }` instead of aborting the sweep; the JSON sweep itself exits 0. In text mode a failed change prints a one-line `✗ : ` and the command exits 1 (like `validate --all`). - **Output (text):** ``` diff --git a/src/commands/workflow/status.ts b/src/commands/workflow/status.ts index bcc4da778b..2f18394137 100644 --- a/src/commands/workflow/status.ts +++ b/src/commands/workflow/status.ts @@ -53,7 +53,10 @@ type BatchStatusEntry = ChangeStatus | { changeName: string; status: StoreDiagno // The --all --json failure null-shape. Root-selection failures (handled in // resolveRootForCommand) and thrown errors (caught by the CLI wrapper) must // emit the same shape, so both call sites reference this one constant. -export const BATCH_STATUS_FAILURE_PAYLOAD: Record = { changes: [] }; +export const BATCH_STATUS_FAILURE_PAYLOAD: Record = { + changes: [], + root: null, +}; export async function statusCommand(options: StatusOptions): Promise { if (options.all && options.change) { @@ -132,13 +135,16 @@ export async function statusCommand(options: StatusOptions): Promise { } spinner?.stop(); + const failed = entries.some((entry) => !('artifacts' in entry)); if (options.json) { console.log(JSON.stringify({ changes: entries, root: rootOutput }, null, 2)); + if (failed) { + process.exitCode = 1; + } return; } - let failed = false; entries.forEach((entry, index) => { if (index > 0) { console.log(); @@ -146,13 +152,12 @@ export async function statusCommand(options: StatusOptions): Promise { if ('artifacts' in entry) { printStatusText(entry); } else { - failed = true; console.log(chalk.red(`✗ ${entry.changeName}: ${entry.status[0]?.message}`)); } }); - // Text mode signals load failures via the exit code (like - // validate --all); JSON mode instead exits 0 and carries the - // per-change diagnostics so the sweep result stays parseable. + // A partial load is still a failed command in both output modes; + // JSON callers can parse the complete envelope independently of + // the process exit code. if (failed) { process.exitCode = 1; } diff --git a/test/commands/status-all.test.ts b/test/commands/status-all.test.ts index 61a4b9ba36..65ad056cf8 100644 --- a/test/commands/status-all.test.ts +++ b/test/commands/status-all.test.ts @@ -138,6 +138,7 @@ describe('status --all', () => { // The failure document must still carry the batch null-shape. const json = JSON.parse(result.stdout); expect(json.changes).toEqual([]); + expect(json.root).toBeNull(); expect(Array.isArray(json.status)).toBe(true); expect(json.status[0].severity).toBe('error'); }); @@ -154,6 +155,7 @@ describe('status --all', () => { // Exactly one JSON document: null-shape plus status array const json = JSON.parse(result.stdout); expect(json.changes).toEqual([]); + expect(json.root).toBeNull(); expect(Array.isArray(json.status)).toBe(true); expect(json.status[0].severity).toBe('error'); expect(json.status[0].message).toContain('mutually exclusive'); @@ -169,10 +171,14 @@ describe('status --all', () => { ); const result = await runCLI(['status', '--all', '--json'], { cwd: tempDir }); - expect(result.exitCode).toBe(0); + expect(result.exitCode).toBe(1); const json = JSON.parse(result.stdout); expect(json.changes).toHaveLength(2); + expect(json.changes.map((c: any) => c.changeName)).toEqual([ + 'broken-change', + 'good-change', + ]); const broken = json.changes.find((c: any) => c.changeName === 'broken-change'); expect(Array.isArray(broken.status)).toBe(true); @@ -244,6 +250,7 @@ describe('status --all', () => { const json = JSON.parse(result.stdout); expect(json.changes).toEqual([]); + expect(json.root).toBeNull(); expect(Array.isArray(json.status)).toBe(true); expect(json.status[0].severity).toBe('error'); expect(json.status[0].message).toContain("'no-such-schema' not found"); @@ -258,6 +265,7 @@ describe('status --all', () => { const json = JSON.parse(result.stdout); expect(json.changes).toEqual([]); + expect(json.root).toBeNull(); expect(json.status[0].message).toContain("'no-such-schema' not found"); }); @@ -291,7 +299,7 @@ describe('status --all', () => { ['status', '--all', '--schema', 'spec-driven', '--json'], { cwd: tempDir } ); - expect(result.exitCode).toBe(0); + expect(result.exitCode).toBe(1); const json = JSON.parse(result.stdout); const broken = json.changes.find((c: any) => c.changeName === 'broken-change'); From fc8aa554325c87030d8be213160593ecf7dd4660 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 24 Aug 2026 10:30:06 -0500 Subject: [PATCH 8/8] docs(status): clarify empty and batch output --- docs-lab/reference/cli.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs-lab/reference/cli.md b/docs-lab/reference/cli.md index fec32c6494..de940770a4 100644 --- a/docs-lab/reference/cli.md +++ b/docs-lab/reference/cli.md @@ -875,13 +875,15 @@ openspec status --all # every active change openspec status --all --json # one batch report ``` -Use exactly one of `--change` or `--all`. Without either, status exits 1 and lists the available changes, even when only one exists: +When active changes exist, use exactly one of `--change` or `--all`. Without either, status exits 1 and lists the available changes, even when only one exists: -``` +```text ✖ Error: Missing required option --change (or --all for every active change). Available changes: add-rate-limit ``` +When the project has no active changes, status prints `No active changes. Create one with: openspec new change ` and exits 0 even without either flag. With `--all --json`, the same empty state is `{ "changes": [], "message": "No active changes.", "root": ... }`. + **Options** | Flag | Effect | @@ -949,7 +951,7 @@ Progress: 2/4 artifacts complete } ``` -With `--all --json`, `changes` contains the same status object for each change, without a per-change `root`. The selected root appears once on the envelope: +With `--all --json`, `changes` contains the same status object for each change, without a per-change `root`. The selected root appears once on the envelope. This example trims the per-change status fields shown above: ```json {