Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/status-all-flag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@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": [ <status>, ... ], "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`.
39 changes: 32 additions & 7 deletions docs-lab/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -866,25 +866,30 @@ 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:
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:

```
✖ Error: Missing required option --change. Available changes:
```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 <name>` and exits 0 even without either flag. With `--all --json`, the same empty state is `{ "changes": [], "message": "No active changes.", "root": ... }`.

**Options**

| Flag | Effect |
|---|---|
| `--change <id>` | The change to report on, by folder name. |
| `--all` | Report every active change, sorted by name. Can't be combined with `--change`. |
| `--schema <name>` | Override the schema auto-detected from `openspec/config.yaml`. An unknown name is an error. |
| `--json` | Print a structured report instead of text. |
| `--store <id>` | Use a registered store as the OpenSpec root instead of the current project. |
Expand Down Expand Up @@ -946,10 +951,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. This example trims the per-change status fields shown above:

```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

Expand Down
2 changes: 2 additions & 0 deletions docs/agent-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": { "<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.

Expand Down
10 changes: 9 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -640,6 +641,7 @@ program
.command('status')
.description('Display artifact completion status for a change')
.option('--change <id>', 'Change name to show status for')
.option('--all', 'Show status for all active changes')
.option('--schema <name>', 'Schema override (auto-detected from config.yaml)')
.option('--json', 'Output as JSON')
.option('--store <id>', STORE_OPTION_DESCRIPTION)
Expand All @@ -648,7 +650,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);
}
});
Expand Down
2 changes: 1 addition & 1 deletion src/commands/workflow/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
105 changes: 93 additions & 12 deletions src/commands/workflow/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -33,6 +35,7 @@ import {

export interface StatusOptions {
change?: string;
all?: boolean;
schema?: string;
store?: string;
storePath?: string;
Expand All @@ -43,10 +46,30 @@ 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<string, unknown> = {
changes: [],
root: null,
};

export async function statusCommand(options: StatusOptions): Promise<void> {
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;
}
Expand All @@ -59,9 +82,26 @@ export async function statusCommand(options: StatusOptions): Promise<void> {
const rootOutput = toRootOutput(root);
const newChangeHint = withStoreFlag(root, 'openspec new change <name>');

// 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();
Expand All @@ -78,10 +118,58 @@ export async function statusCommand(options: StatusOptions): Promise<void> {
console.log(`No active changes. Create one with: ${newChangeHint}`);
return;
}
// Changes exist but --change not provided

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();
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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

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}`));
}
});
// 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;
}
return;
}

// 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 ')}`
);
}

Expand All @@ -98,14 +186,7 @@ export async function statusCommand(options: StatusOptions): Promise<void> {
}

// 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();

Expand Down
4 changes: 4 additions & 0 deletions src/core/completions/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading