feat(view): display workflow status in active changes (#807)

* feat(view): display workflow status in active changes

Active changes now show workflow artifact status below each entry,
indicating schema name and completion state of each artifact (done✓,
ready→, blocked). Powered by loadChangeContext and formatChangeStatus.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* chore(release): track dashboard workflow status

* fix(view): neutralize controls in workflow output

* docs(view): move workflow status docs to docs-lab

* docs(view): preserve store in status guidance

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Clay Good <hi@claygood.com>
This commit is contained in:
Million
2026-09-29 18:25:57 +00:00
committed by GitHub
co-authored by Claude Opus 4.6 Clay Good
parent 070de01dfa
commit c21d897261
6 changed files with 311 additions and 33 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"@fission-ai/openspec": minor
---
### New Features
- **Dashboard workflow status**: `openspec view` now shows each active change's schema and which artifacts are done, ready, blocked, or skipped. Task progress remains visible if a workflow cannot be loaded. Thanks to @Million-mo for the original contribution in #807.
+15
View File
@@ -661,6 +661,21 @@ Use openspec list --changes or openspec list --specs for detailed views
A `Task Progress` summary line appears when any change has tasks underway.
Each active change also shows its schema and artifact states below its task progress bar:
```text
└─ [spec-driven] proposal✓ specs→ design→ tasks✓
```
| Marker | Artifact state |
|---|---|
| `✓` | Its output exists. An existing tasks artifact is done even when its checklist is unfinished. |
| `→` | It is ready to create. |
| No marker | It is blocked by a missing dependency. |
| `(skipped)` | The change skips it. |
If a workflow cannot be loaded, view prints a warning and keeps that change's task progress visible. Run `openspec status --change <name>` to inspect the workflow separately. After `openspec view --store <id>`, pass the same `--store <id>` to status.
**Exit codes**
- `0`: dashboard printed.
+29 -1
View File
@@ -45,6 +45,35 @@ The dashboard SHALL show active changes with visual progress indicators.
- **AND** treat missing progress values as 0% for ordering
- **AND** break ties by change identifier in ascending alphabetical order to keep output deterministic
### Requirement: Active Change Workflow Status
The dashboard SHALL show each active change's schema and artifact states beneath its task progress, using the same workflow resolution as `openspec status`. Workflow status SHALL NOT change task progress, change categories, or sorting.
#### Scenario: Workflow states
- **WHEN** an active change's workflow can be loaded
- **THEN** show the schema name and artifacts in dependency order
- **AND** mark existing artifact outputs with `✓`, ready artifacts with `→`, blocked artifacts with no symbol, and skipped artifacts with `(skipped)`
- **AND** treat an existing tasks artifact as done even when its implementation checklist is unfinished
#### Scenario: Store-local workflow
- **WHEN** the dashboard targets a store through `--store` or a project store pointer
- **THEN** resolve workflow schemas and artifact files from that store
- **AND** use the store's default schema for changes without a schema in their metadata
#### Scenario: Invalid workflow
- **WHEN** an active change's metadata or schema cannot be loaded
- **THEN** print a warning identifying the change and the error
- **AND** omit only that change's workflow status while retaining its task progress and rendering other changes
#### Scenario: Terminal controls in workflow text
- **WHEN** schema names, artifact identifiers, or workflow errors contain terminal control characters
- **THEN** replace those characters with inert text in the dashboard output
- **AND** preserve the underlying identifiers and workflow states
### Requirement: Completed Changes Display
The dashboard SHALL list completed changes in a separate section, only showing changes with ALL tasks completed.
@@ -126,4 +155,3 @@ The dashboard SHALL display changes without tasks in a separate "Draft" section.
- **WHEN** multiple draft changes exist
- **THEN** system sorts them alphabetically by name
+39 -3
View File
@@ -4,6 +4,7 @@ import chalk from 'chalk';
import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
import { MarkdownParser } from './parsers/markdown-parser.js';
import { discoverSpecFiles } from '../utils/spec-discovery.js';
import { loadChangeContext, formatChangeStatus, type ChangeStatus } from './artifact-graph/index.js';
export class ViewCommand {
async execute(targetPath: string = '.'): Promise<void> {
@@ -51,6 +52,10 @@ export class ViewCommand {
console.log(
` ${chalk.yellow('◉')} ${chalk.bold(change.name.padEnd(maxNameLength))} ${progressBar} ${chalk.dim(`${percentage}%`)}`
);
if (change.workflowStatus) {
const { schemaName, artifacts } = change.workflowStatus;
console.log(` ${chalk.dim(`└─ [${this.sanitizeWorkflowText(schemaName)}]`)} ${this.formatWorkflowArtifacts(artifacts)}`);
}
});
}
@@ -85,17 +90,18 @@ export class ViewCommand {
private async getChangesData(openspecDir: string): Promise<{
draft: Array<{ name: string }>;
active: Array<{ name: string; progress: { total: number; completed: number } }>;
active: Array<{ name: string; progress: { total: number; completed: number }; workflowStatus?: ChangeStatus }>;
completed: Array<{ name: string }>;
}> {
const changesDir = path.join(openspecDir, 'changes');
const projectRoot = path.dirname(openspecDir);
if (!fs.existsSync(changesDir)) {
return { draft: [], active: [], completed: [] };
}
const draft: Array<{ name: string }> = [];
const active: Array<{ name: string; progress: { total: number; completed: number } }> = [];
const active: Array<{ name: string; progress: { total: number; completed: number }; workflowStatus?: ChangeStatus }> = [];
const completed: Array<{ name: string }> = [];
const entries = fs.readdirSync(changesDir, { withFileTypes: true });
@@ -112,7 +118,16 @@ export class ViewCommand {
completed.push({ name: entry.name });
} else {
// Has tasks but not all complete
active.push({ name: entry.name, progress });
let workflowStatus: ChangeStatus | undefined;
try {
workflowStatus = formatChangeStatus(loadChangeContext(projectRoot, entry.name));
} catch (error) {
// Preserve task progress even when this change's workflow cannot be loaded.
console.warn(chalk.yellow(this.sanitizeWorkflowText(
`Could not load workflow status for "${entry.name}": ${error instanceof Error ? error.message : String(error)}`
)));
}
active.push({ name: entry.name, progress, workflowStatus });
}
}
}
@@ -202,6 +217,27 @@ export class ViewCommand {
}
}
private sanitizeWorkflowText(value: string): string {
// Metadata may contain terminal controls; mask them before adding our own colors.
return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, '?');
}
private formatWorkflowArtifacts(artifacts: ChangeStatus['artifacts']): string {
return artifacts.map((artifact) => {
const id = this.sanitizeWorkflowText(artifact.id);
switch (artifact.status) {
case 'done':
return `${id}${chalk.green('✓')}`;
case 'ready':
return `${id}${chalk.cyan('→')}`;
case 'skipped':
return chalk.dim(`${id} (skipped)`);
case 'blocked':
return chalk.dim(id);
}
}).join(' ');
}
private createProgressBar(completed: number, total: number, width: number = 20): string {
if (total === 0) return chalk.dim('─'.repeat(width));
+95 -25
View File
@@ -13,8 +13,38 @@ import { cleanupTempPath } from '../helpers/temp-cleanup.js';
*/
const STORE_ID = 'view-store';
const SCHEMA_NAME = 'store-billing';
const TIMEOUT_MS = 60_000;
const STORE_SCHEMA = `name: ${SCHEMA_NAME}
version: 1
description: Billing workflow owned by the registered store
artifacts:
- id: proposal
generates: planning/proposal.md
description: Billing change proposal
template: proposal.md
requires: []
- id: specs
generates: specs/**/*.md
description: Billing behavior
template: spec.md
requires: [proposal]
- id: design
generates: planning/design.md
description: Billing implementation design
template: design.md
requires: [specs]
- id: tasks
generates: execution/tasks.md
description: Implementation checklist
template: tasks.md
requires: [design]
apply:
requires: [tasks]
tracks: execution/tasks.md
`;
let base: string;
let storeRoot: string;
let pointerProject: string;
@@ -60,10 +90,40 @@ beforeAll(async () => {
await fs.mkdir(specDir, { recursive: true });
await fs.writeFile(path.join(specDir, 'spec.md'), SPEC);
const schemaDir = path.join(storeRoot, 'openspec', 'schemas', SCHEMA_NAME);
await fs.mkdir(path.join(schemaDir, 'templates'), { recursive: true });
await fs.writeFile(path.join(schemaDir, 'schema.yaml'), STORE_SCHEMA);
for (const template of ['proposal.md', 'spec.md', 'design.md', 'tasks.md']) {
await fs.writeFile(path.join(schemaDir, 'templates', template), '# Billing\n');
}
await fs.writeFile(
path.join(storeRoot, 'openspec', 'config.yaml'),
`schema: ${SCHEMA_NAME}\n`
);
for (const changeName of ['billing-update', 'billing-refactor']) {
const changeDir = path.join(storeRoot, 'openspec', 'changes', changeName);
await fs.mkdir(path.join(changeDir, 'planning'), { recursive: true });
await fs.mkdir(path.join(changeDir, 'execution'), { recursive: true });
await fs.writeFile(path.join(changeDir, 'planning', 'proposal.md'), '# Update billing\n');
// A written tasks artifact is done even while its checklist is unfinished.
await fs.writeFile(
path.join(changeDir, 'execution', 'tasks.md'),
'- [x] Audit billing\n- [ ] Implement billing change\n'
);
if (changeName === 'billing-refactor') {
await fs.writeFile(
path.join(changeDir, '.openspec.yaml'),
`schema: ${SCHEMA_NAME}\nskip_specs: true\n`
);
}
// billing-update deliberately inherits the store's schema from config.yaml.
}
await fs.mkdir(path.join(pointerProject, 'openspec'), { recursive: true });
await fs.writeFile(
path.join(pointerProject, 'openspec', 'config.yaml'),
`store: ${STORE_ID}\n`
`store: ${STORE_ID}\nschema: spec-driven\n`
);
}, TIMEOUT_MS);
@@ -72,36 +132,46 @@ afterAll(async () => {
});
describe('openspec view root resolution', () => {
it(
'follows a store pointer declared in openspec/config.yaml',
async () => {
const result = await runCLI(['view'], {
cwd: pointerProject,
env,
timeoutMs: TIMEOUT_MS,
});
it.each([
{ route: 'a declared store pointer', directory: 'project', storeArgs: [] },
{ route: 'explicit --store', directory: 'outside', storeArgs: ['--store', STORE_ID] },
])(
'resolves workflow schemas and artifacts from the store via $route',
async ({ directory, storeArgs }) => {
const cwd = path.join(base, directory);
await fs.mkdir(cwd, { recursive: true });
const options = { cwd, env, timeoutMs: TIMEOUT_MS };
const result = await runCLI(['view', ...storeArgs], options);
expect(result.exitCode, result.stderr).toBe(0);
expect(result.stdout).toContain('1 specs, 1 requirements');
expect(result.stdout).toContain('billing');
},
TIMEOUT_MS
);
expect(result.stdout).toContain('Active Changes: 2 in progress');
expect(result.stdout).toContain('Task Progress: 2/4 (50% complete)');
const lines = result.stdout.split(/\r?\n/);
it(
'targets a registered store when --store is passed',
async () => {
const outside = path.join(base, 'outside');
await fs.mkdir(outside, { recursive: true });
for (const changeName of ['billing-update', 'billing-refactor']) {
const skipped = changeName === 'billing-refactor';
const statusResult = await runCLI(
['status', '--change', changeName, '--json', ...storeArgs],
options
);
expect(statusResult.exitCode, statusResult.stderr).toBe(0);
const status = JSON.parse(statusResult.stdout);
expect(status.schemaName).toBe(SCHEMA_NAME);
expect(status.artifacts).toMatchObject([
{ id: 'proposal', status: 'done' },
{ id: 'specs', status: skipped ? 'skipped' : 'ready' },
{ id: 'design', status: skipped ? 'ready' : 'blocked' },
{ id: 'tasks', status: 'done' },
]);
const result = await runCLI(['view', '--store', STORE_ID], {
cwd: outside,
env,
timeoutMs: TIMEOUT_MS,
});
expect(result.exitCode, result.stderr).toBe(0);
expect(result.stdout).toContain('1 specs, 1 requirements');
const changeLine = lines.findIndex((line) => line.includes(`◉ ${changeName}`));
expect(changeLine).toBeGreaterThanOrEqual(0);
expect.soft(lines[changeLine + 1]).toBe(
` └─ [${SCHEMA_NAME}] proposal✓ ${skipped ? 'specs (skipped) design→' : 'specs→ design'} tasks✓`
);
}
},
TIMEOUT_MS
);
+126 -4
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
@@ -13,6 +13,8 @@ describe('ViewCommand', () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-view-test-'));
vi.stubEnv('XDG_DATA_HOME', path.join(tempDir, 'data'));
vi.stubEnv('XDG_CONFIG_HOME', path.join(tempDir, 'config'));
originalLog = console.log;
console.log = (...args: any[]) => {
@@ -24,9 +26,123 @@ describe('ViewCommand', () => {
afterEach(async () => {
console.log = originalLog;
vi.restoreAllMocks();
vi.unstubAllEnvs();
await fs.rm(tempDir, { recursive: true, force: true });
});
it('shows workflow states for legacy active changes without changing task progress', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
for (const name of ['planning', 'implementing']) {
await fs.mkdir(path.join(changesDir, name), { recursive: true });
await fs.writeFile(path.join(changesDir, name, 'tasks.md'), '- [x] Done\n- [ ] Pending\n');
}
await fs.writeFile(path.join(changesDir, 'implementing', 'proposal.md'), '# Proposal\n');
await new ViewCommand().execute(tempDir);
const lines = logOutput.map(stripAnsi);
const planningIndex = lines.findIndex(line => line.includes('◉') && line.includes('planning'));
const implementingIndex = lines.findIndex(line => line.includes('◉') && line.includes('implementing'));
expect(lines[planningIndex + 1]).toBe(' └─ [spec-driven] proposal→ specs design tasks✓');
expect(lines[implementingIndex + 1]).toBe(' └─ [spec-driven] proposal✓ specs→ design→ tasks✓');
expect(lines[planningIndex]).toContain('50%');
expect(lines[implementingIndex]).toContain('50%');
expect(lines.join('\n')).toContain('Task Progress: 2/4 (50% complete)');
});
it('distinguishes skipped specs from completed and blocked artifacts', async () => {
const changeDir = path.join(tempDir, 'openspec', 'changes', 'refactor');
await fs.mkdir(changeDir, { recursive: true });
await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\nskip_specs: true\n');
await fs.writeFile(path.join(changeDir, 'proposal.md'), '# Proposal\n');
await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [ ] Refactor\n');
await new ViewCommand().execute(tempDir);
expect(logOutput.map(stripAnsi)).toContain(
' └─ [spec-driven] proposal✓ specs (skipped) design→ tasks✓'
);
});
it.each([
['malformed YAML', 'schema: [', 'Invalid YAML'],
['invalid metadata', 'schema: 123\n', 'Invalid metadata'],
['unknown schema', 'schema: missing-workflow\n', "Unknown schema 'missing-workflow'"],
])('warns about %s without hiding changes or breaking the dashboard', async (_name, metadata, error) => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
for (const name of ['broken', 'healthy']) {
await fs.mkdir(path.join(changesDir, name), { recursive: true });
await fs.writeFile(path.join(changesDir, name, 'tasks.md'), '- [ ] Pending\n');
}
await fs.writeFile(path.join(changesDir, 'broken', '.openspec.yaml'), metadata);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
await new ViewCommand().execute(tempDir);
const lines = logOutput.map(stripAnsi);
expect(lines.filter(line => line.includes('◉'))).toHaveLength(2);
expect(lines.filter(line => line.includes('└─'))).toEqual([
' └─ [spec-driven] proposal→ specs design tasks✓',
]);
expect(warn).toHaveBeenCalledTimes(1);
expect(stripAnsi(String(warn.mock.calls[0][0]))).toContain('Could not load workflow status for "broken":');
expect(stripAnsi(String(warn.mock.calls[0][0]))).toContain(error);
expect(lines.join('\n')).toContain('Task Progress: 0/2 (0% complete)');
});
it('renders terminal controls in workflow errors as inert text', async () => {
const changeDir = path.join(tempDir, 'openspec', 'changes', 'broken');
await fs.mkdir(changeDir, { recursive: true });
await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [ ] Pending\n');
await fs.writeFile(
path.join(changeDir, '.openspec.yaml'),
JSON.stringify({ schema: 'missing\u001b[2J\u009bH\r\nFORGED' })
);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
await new ViewCommand().execute(tempDir);
expect(warn).toHaveBeenCalledTimes(1);
expect(stripAnsi(String(warn.mock.calls[0][0]))).toContain("Unknown schema 'missing?[2J?H??FORGED'");
expect(logOutput.map(stripAnsi).join('\n')).toContain('Task Progress: 0/1 (0% complete)');
});
it('renders terminal controls in every artifact state without changing workflow semantics', async () => {
const openspecDir = path.join(tempDir, 'openspec');
const schemaDir = path.join(openspecDir, 'schemas', 'custom');
const changeDir = path.join(openspecDir, 'changes', 'display-controls');
const ready = '設計\u001b[2J';
const blocked = 'blocked\u009bH';
const skipped = 'specs\r\n';
const done = 'tasks\b\u0007';
const artifact = (id: string, generates: string, requires: string[] = []) => ({
id, generates, requires, description: 'Test artifact', template: 'template.md',
});
await fs.mkdir(schemaDir, { recursive: true });
await fs.mkdir(changeDir, { recursive: true });
await fs.writeFile(path.join(schemaDir, 'schema.yaml'), JSON.stringify({
name: 'custom',
version: 1,
artifacts: [
artifact(ready, 'design.md'),
artifact(blocked, 'review.md', [ready]),
artifact(skipped, 'specs/**/*.md'),
artifact(done, 'tasks.md'),
],
apply: { requires: [done], tracks: 'tasks.md' },
}));
await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: custom\nskip_specs: true\n');
await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [ ] Pending\n');
await new ViewCommand().execute(tempDir);
expect(logOutput.map(stripAnsi)).toContain(
' └─ [custom] 設計?[2J→ blocked?H specs?? (skipped) tasks??✓'
);
expect(logOutput.map(stripAnsi).join('\n')).toContain('Task Progress: 0/1 (0% complete)');
});
it('shows changes with no tasks in Draft section, not Completed', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(changesDir, { recursive: true });
@@ -75,6 +191,7 @@ describe('ViewCommand', () => {
expect(completedNames).toContain('completed-change');
expect(completedNames).not.toContain('empty-change');
expect(completedNames).not.toContain('no-tasks-change');
expect(output).not.toContain('└─');
});
it('sorts active changes by completion percentage ascending with deterministic tie-breakers', async () => {
@@ -125,7 +242,7 @@ describe('ViewCommand', () => {
]);
});
it('classifies a nested glob-tasks change as Active, not Draft (#1202)', async () => {
it.each(['metadata', 'project config'])('uses a nested glob-tasks workflow from %s (#1202)', async (schemaSource) => {
const openspecDir = path.join(tempDir, 'openspec');
const changesDir = path.join(openspecDir, 'changes');
await fs.mkdir(changesDir, { recursive: true });
@@ -159,7 +276,12 @@ describe('ViewCommand', () => {
const changeDir = path.join(changesDir, 'nested-change');
await fs.mkdir(path.join(changeDir, 'backend'), { recursive: true });
await fs.mkdir(path.join(changeDir, 'frontend'), { recursive: true });
await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: glob-tasks\n');
if (schemaSource === 'metadata') {
await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: glob-tasks\n');
await fs.writeFile(path.join(openspecDir, 'config.yaml'), 'schema: spec-driven\n');
} else {
await fs.writeFile(path.join(openspecDir, 'config.yaml'), 'schema: glob-tasks\n');
}
await fs.writeFile(path.join(changeDir, 'backend', 'tasks.md'), '- [x] 1.1 a\n- [x] 1.2 b\n');
await fs.writeFile(path.join(changeDir, 'frontend', 'tasks.md'), '- [x] 2.1 a\n- [ ] 2.2 b\n- [ ] 2.3 c\n');
@@ -172,6 +294,7 @@ describe('ViewCommand', () => {
const draftLines = logOutput.map(stripAnsi).filter(line => line.includes('○'));
expect(draftLines.some(line => line.includes('nested-change'))).toBe(false);
expect(output).toContain('60%');
expect(output).toContain('└─ [glob-tasks] proposal→ tasks✓');
});
it('keeps a change with unfinished sub-tasks in Active, not Completed (#1485)', async () => {
@@ -241,4 +364,3 @@ describe('ViewCommand', () => {
expect(activeLines.find(l => l.includes(longName))).toContain(longName);
});
});