Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/view-current-work-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fission-ai/openspec": patch
---

`openspec view` no longer lists or counts archived changes. In projects with many archived changes, the list pushed active work off the screen. The dashboard shows current work again, and `openspec list --archived` still shows archived changes on request.
9 changes: 1 addition & 8 deletions docs-lab/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -643,9 +643,7 @@ Prints a one-screen dashboard of specs and changes.
openspec view # project summary in one screen
```

view prints the dashboard once and exits. It reads no keystrokes. Changes group by task progress: Draft (no tasks yet), Active (tasks underway, with a progress bar and percent), Completed (every task checked), and Archived. Specs list with requirement counts, largest first.

Archived changes appear by directory name in alphabetical order. They do not contribute to the Draft, Active, Completed, or Task Progress totals.
view prints the dashboard once and exits. It reads no keystrokes. Changes group by task progress: Draft (no tasks yet), Active (tasks underway, with a progress bar and percent), Completed (every task checked). Specs list with requirement counts, largest first.

**Options**

Expand All @@ -664,16 +662,11 @@ Summary:
● Draft Changes: 1
● Active Changes: 0 in progress
● Completed Changes: 0
● Archived Changes: 1

Draft Changes
────────────────────────────────────────────────────────────
○ add-rate-limit

Archived Changes
────────────────────────────────────────────────────────────
◦ 2026-08-10-add-login

Specifications
────────────────────────────────────────────────────────────
▪ api 1 requirement
Expand Down
32 changes: 3 additions & 29 deletions src/core/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,6 @@ export class ViewCommand {
});
}

// Display archived changes
if (changesData.archived.length > 0) {
console.log(chalk.bold.gray('\nArchived Changes'));
console.log('─'.repeat(60));
changesData.archived.forEach((change) => {
console.log(chalk.gray(` ◦ ${change.name}`));
});
}

// Display specifications
if (specsData.length > 0) {
console.log(chalk.bold.blue('\nSpecifications'));
Expand All @@ -101,32 +92,17 @@ export class ViewCommand {
draft: Array<{ name: string }>;
active: Array<{ name: string; progress: { total: number; completed: number }; workflowStatus?: ChangeStatus }>;
completed: Array<{ name: string }>;
archived: Array<{ name: string }>;
}> {
const changesDir = path.join(openspecDir, 'changes');
const projectRoot = path.dirname(openspecDir);

if (!fs.existsSync(changesDir)) {
return { draft: [], active: [], completed: [], archived: [] };
return { draft: [], active: [], completed: [] };
}

const draft: Array<{ name: string }> = [];
const active: Array<{ name: string; progress: { total: number; completed: number }; workflowStatus?: ChangeStatus }> = [];
const completed: Array<{ name: string }> = [];
let archived: Array<{ name: string }> = [];

try {
archived = fs.readdirSync(path.join(changesDir, 'archive'), { withFileTypes: true })
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
.map((entry) => ({ name: entry.name }));
} catch (error) {
// A missing archive, or an `archive` path that is a file, has no archived
// changes to show; neither should break the rest of the dashboard.
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
throw error;
}
}

const entries = fs.readdirSync(changesDir, { withFileTypes: true });

Expand Down Expand Up @@ -169,9 +145,8 @@ export class ViewCommand {
return a.name.localeCompare(b.name);
});
completed.sort((a, b) => a.name.localeCompare(b.name));
archived.sort((a, b) => a.name.localeCompare(b.name));

return { draft, active, completed, archived };
return { draft, active, completed };
}

private async getSpecsData(openspecDir: string): Promise<Array<{ name: string; requirementCount: number }>> {
Expand Down Expand Up @@ -200,7 +175,7 @@ export class ViewCommand {
}

private displaySummary(
changesData: { draft: any[]; active: any[]; completed: any[]; archived: any[] },
changesData: { draft: any[]; active: any[]; completed: any[] },
specsData: any[]
): void {
const totalChanges =
Expand Down Expand Up @@ -233,7 +208,6 @@ export class ViewCommand {
` ${chalk.yellow('●')} Active Changes: ${chalk.bold(changesData.active.length)} in progress`
);
console.log(` ${chalk.green('●')} Completed Changes: ${chalk.bold(changesData.completed.length)}`);
console.log(` ${chalk.gray('●')} Archived Changes: ${chalk.bold(changesData.archived.length)}`);

if (totalTasks > 0) {
const overallProgress = Math.round((completedTasks / totalTasks) * 100);
Expand Down
8 changes: 4 additions & 4 deletions test/cli-e2e/view-store-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,8 @@ describe('openspec view root resolution', () => {
expect(result.stdout).toContain('billing');
expect(result.stdout).toContain('Active Changes: 2 in progress');
expect(result.stdout).toContain('Task Progress: 2/4 (50% complete)');
expect(result.stdout).toContain('Archived Changes: 1');
expect(result.stdout).toContain('2026-08-27-store-history');
expect(result.stdout).not.toContain('Archived');
expect(result.stdout).not.toContain('2026-08-27-store-history');
const lines = result.stdout.split(/\r?\n/);

for (const changeName of ['billing-update', 'billing-refactor']) {
Expand Down Expand Up @@ -228,8 +228,8 @@ describe('openspec view root resolution', () => {
cwd: base, env: aliasEnv, timeoutMs: TIMEOUT_MS,
});
expect(viewed.exitCode, viewed.stderr).toBe(0);
expect(viewed.stdout).toContain('Archived Changes: 1');
expect(viewed.stdout).toContain('2026-08-27-store-history');
expect(viewed.stdout).toContain('Active Changes: 2 in progress');
expect(viewed.stdout).not.toContain('2026-08-27-store-history');
}, TIMEOUT_MS);

it(
Expand Down
99 changes: 10 additions & 89 deletions test/core/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,106 +313,27 @@ describe('ViewCommand', () => {
expect(completedLines.some(line => line.includes('subtask-change'))).toBe(false);
});

it('lists archived directories in name order without affecting current changes or task progress', async () => {
it('renders the same dashboard however many changes are archived (#2030)', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
const archiveDir = path.join(changesDir, 'archive');
await fs.mkdir(path.join(archiveDir, '2026-01-02-zebra'), { recursive: true });
await fs.writeFile(
path.join(archiveDir, '2026-01-02-zebra', 'tasks.md'),
'- [x] Done\n- [ ] Unfinished when archived\n- [ ] Another task\n'
);
// Archived directories need neither a proposal nor a tasks file.
await fs.mkdir(path.join(archiveDir, '2026-01-01-alpha'));
await fs.mkdir(path.join(archiveDir, '.hidden-change'));
await fs.writeFile(path.join(archiveDir, 'README.md'), 'Archive notes');
await fs.mkdir(path.join(changesDir, 'draft-change'));
await fs.mkdir(path.join(changesDir, 'draft-change'), { recursive: true });
await fs.mkdir(path.join(changesDir, 'active-change'));
await fs.writeFile(path.join(changesDir, 'active-change', 'tasks.md'), '- [x] Done\n- [ ] Pending\n');
await fs.mkdir(path.join(changesDir, 'completed-change'));
await fs.writeFile(path.join(changesDir, 'completed-change', 'tasks.md'), '- [x] Done\n');

await new ViewCommand().execute(tempDir);
const withoutHistory = logOutput.map(stripAnsi);

const lines = logOutput.map(stripAnsi);
const output = lines.join('\n');
expect(output).toContain('Archived Changes: 2');
expect(output).toContain('Draft Changes: 1');
expect(output).toContain('Active Changes: 1 in progress');
expect(output).toContain('Completed Changes: 1');
expect(output).toContain('Task Progress: 1/2 (50% complete)');
expect(output).not.toContain('.hidden-change');
expect(output).not.toContain('README.md');

const archiveHeading = lines.indexOf('\nArchived Changes');
expect(archiveHeading).toBeGreaterThan(lines.indexOf('\nCompleted Changes'));
const archivedLines = lines.filter(line => line.includes('2026-01-'));
expect(archivedLines).toHaveLength(2);
expect(archivedLines[0]).toContain('2026-01-01-alpha');
expect(archivedLines[1]).toContain('2026-01-02-zebra');
expect(archivedLines.every(line => lines.indexOf(line) > archiveHeading)).toBe(true);
});

it.each(['missing changes', 'missing archive', 'empty archive', 'hidden entries only'])(
'shows a zero archive count without an archived section for %s',
async (state) => {
const openspecDir = path.join(tempDir, 'openspec');
const changesDir = path.join(openspecDir, 'changes');
const archiveDir = path.join(changesDir, 'archive');
await fs.mkdir(openspecDir);
if (state !== 'missing changes') {
await fs.mkdir(changesDir);
}
if (state === 'empty archive' || state === 'hidden entries only') {
await fs.mkdir(archiveDir);
}
if (state === 'hidden entries only') {
await fs.mkdir(path.join(archiveDir, '.hidden-change'));
await fs.writeFile(path.join(archiveDir, 'README.md'), 'Archive notes');
}

await new ViewCommand().execute(tempDir);

const lines = logOutput.map(stripAnsi);
expect(lines.join('\n')).toContain('Archived Changes: 0');
expect(lines).not.toContain('\nArchived Changes');
expect(lines.join('\n')).not.toContain('Task Progress:');
// Archives grow for the life of a project; the dashboard must not grow with them.
for (let i = 1; i <= 200; i++) {
const archived = path.join(changesDir, 'archive', `2026-01-01-shipped-${i}`);
await fs.mkdir(archived, { recursive: true });
await fs.writeFile(path.join(archived, 'tasks.md'), '- [x] Done\n- [ ] Left unfinished\n');
}
);

it('still renders the dashboard when the archive path is a file', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(path.join(changesDir, 'active-change'), { recursive: true });
await fs.writeFile(path.join(changesDir, 'active-change', 'tasks.md'), '- [x] Done\n- [ ] Pending\n');
await fs.writeFile(path.join(changesDir, 'archive'), 'Not a directory');

logOutput = [];
await new ViewCommand().execute(tempDir);

const lines = logOutput.map(stripAnsi);
expect(lines.join('\n')).toContain('Active Changes: 1 in progress');
expect(lines.join('\n')).toContain('Archived Changes: 0');
expect(lines).not.toContain('\nArchived Changes');
});

it.skipIf(process.platform === 'win32')('surfaces unreadable archive directories', async ({ skip }) => {
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
await fs.mkdir(archiveDir, { recursive: true });
await fs.chmod(archiveDir, 0o000);

try {
// Root and some filesystems do not enforce permission bits.
let unreadable = false;
try {
await fs.readdir(archiveDir);
} catch {
unreadable = true;
}
if (!unreadable) skip();

await expect(new ViewCommand().execute(tempDir)).rejects.toMatchObject({ code: 'EACCES' });
expect(logOutput.map(stripAnsi).join('\n')).not.toContain('Archived Changes: 0');
} finally {
await fs.chmod(archiveDir, 0o755);
}
expect(logOutput.map(stripAnsi)).toEqual(withoutHistory);
});

it('aligns progress bars in Active Changes when a change name exceeds 30 characters (#1986)', async () => {
Expand Down
Loading