Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/status-all-flag.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/agent-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": { "<id>": {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": [ <per-change status object, no per-change root>, ... ], "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 <artifact> --json`
`{ "changeName", "artifactId", "schemaName", "changeDir", "planningHome"?, "outputPath", "resolvedOutputPath", "existingOutputPaths", "description", "instruction"?, "context"?, "rules"?, "references"?: ReferenceIndexEntry[], "template", "dependencies": [{id,done,path,description}], "unlocks", "root" }`.

Expand Down
8 changes: 7 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <item>` | 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 |
Expand Down Expand Up @@ -650,6 +650,7 @@ openspec status [options]
| Option | Description |
|--------|-------------|
| `--change <id>` | Change name (prompts if omitted) |
| `--all` | Show status for all active changes (mutually exclusive with `--change`) |
| `--schema <name>` | Schema override (auto-detected from change's config) |
| `--json` | Output as JSON |

Expand All @@ -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": [ <status>, ... ], "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 `✗ <name>: <message>` and the command exits 1 (like `validate --all`).

**Output (text):**

```
Expand Down
10 changes: 9 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -488,6 +489,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 @@ -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);
}
});
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 { instructionsCommand, applyInstructionsCommand } from './instructions.js';
Expand Down
94 changes: 84 additions & 10 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,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<string, unknown> = { changes: [] };

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 +79,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,6 +115,50 @@ export async function statusCommand(options: StatusOptions): Promise<void> {
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(
Expand All @@ -98,14 +179,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 @@ -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',
Expand Down
Loading