feat(status): add --all for batch status of every active change (#1301)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix(status): fail incomplete batch reports

* docs(status): clarify empty and batch output

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Clay Good <hi@claygood.com>
This commit is contained in:
Tanner
2026-08-26 19:25:39 +00:00
committed by GitHub
co-authored by Claude Opus 5 Clay Good
parent c0c50f9a4c
commit a7353aea9a
9 changed files with 472 additions and 21 deletions
+5
View File
@@ -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`.
+32 -7
View File
@@ -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,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. |
@@ -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
+2
View File
@@ -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.
+9 -1
View File
@@ -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,
@@ -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)
@@ -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);
}
});
+1 -1
View File
@@ -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 {
+93 -12
View File
@@ -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,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;
}
@@ -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();
@@ -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;
}
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 ')}`
);
}
@@ -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();
+4
View File
@@ -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',
+312
View File
@@ -0,0 +1,312 @@
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<string> {
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('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,
});
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(json.root).toBeNull();
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(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');
});
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(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);
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<void> {
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(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");
});
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.root).toBeNull();
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(1);
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');
});
});
});
@@ -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 }