diff --git a/.changeset/status-all-flag.md b/.changeset/status-all-flag.md new file mode 100644 index 0000000000..cfd8f8b0cc --- /dev/null +++ b/.changeset/status-all-flag.md @@ -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": [ , ... ], "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..de940770a4 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,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 ` 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 ` | 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 +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 diff --git a/docs/agent-contract.md b/docs/agent-contract.md index 9338891f43..30fcfacccf 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": [], "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/src/cli/index.ts b/src/cli/index.ts index d1bb282998..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, @@ -640,6 +641,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 +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); } }); 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 32f5950716..2f18394137 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,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 = { + changes: [], + root: null, +}; + 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 +82,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,10 +118,58 @@ export async function statusCommand(options: StatusOptions): Promise { 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 { } // 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 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..65ad056cf8 --- /dev/null +++ b/test/commands/status-all.test.ts @@ -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 { + 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 { + 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'); + }); + }); +}); 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 }