diff --git a/.changeset/status-all-flag.md b/.changeset/status-all-flag.md new file mode 100644 index 0000000000..ba37793c66 --- /dev/null +++ b/.changeset/status-all-flag.md @@ -0,0 +1,7 @@ +--- +"@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. diff --git a/docs/agent-contract.md b/docs/agent-contract.md index 9f64d66d36..127cdf280f 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -56,6 +56,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" }, "isComplete", "applyRequires", "artifacts": [ {id, outputPath, status: "done"|"ready"|"blocked", missingDeps?} ], "root" }`. 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. + ### 4.5 `instructions --json` `{ "changeName", "artifactId", "schemaName", "changeDir", "planningHome"?, "outputPath", "resolvedOutputPath", "existingOutputPaths", "description", "instruction"?, "context"?, "rules"?, "references"?: ReferenceIndexEntry[], "template", "dependencies": [{id,done,path,description}], "unlocks", "root" }`. diff --git a/docs/cli.md b/docs/cli.md index 8f9c03baed..c08576616c 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 | @@ -650,6 +650,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 | @@ -664,8 +665,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; 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/cli/index.ts b/src/cli/index.ts index 98505b02fe..cc6b2a6291 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -25,6 +25,7 @@ import { registerContextCommand } from '../commands/context.js'; import { registerWorksetCommand } from '../commands/workset.js'; import { statusCommand, + BATCH_STATUS_FAILURE_PAYLOAD, instructionsCommand, applyInstructionsCommand, templatesCommand, @@ -488,6 +489,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) @@ -496,7 +498,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 ? 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 232b2dbe34..c9d0eb0047 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 { instructionsCommand, applyInstructionsCommand } from './instructions.js'; diff --git a/src/commands/workflow/status.ts b/src/commands/workflow/status.ts index 4374744bf5..de2d349ced 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,10 +46,27 @@ 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[] }; + +// 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.'); + } + // 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, + failurePayload: options.all ? BATCH_STATUS_FAILURE_PAYLOAD : undefined, + }); if (!root) { return; } @@ -59,9 +79,26 @@ 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) { + // 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(); @@ -78,6 +115,50 @@ export async function statusCommand(options: StatusOptions): Promise { console.log(`No active changes. Create one with: ${newChangeHint}`); return; } + + if (options.all) { + // 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((a, b) => a.localeCompare(b))) { + try { + entries.push(loadStatus(changeName)); + } 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; + } + + let failed = false; + entries.forEach((entry, index) => { + if (index > 0) { + console.log(); + } + 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. + if (failed) { + process.exitCode = 1; + } + return; + } + // Changes exist but --change not provided spinner?.stop(); throw new Error( @@ -98,14 +179,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/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 76f2a28587..ad53846073 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -167,6 +167,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..7351d58c99 --- /dev/null +++ b/test/commands/status-all.test.ts @@ -0,0 +1,295 @@ +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 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'); + + 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'); + }); + + 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'); + }); + }); +});