diff --git a/.changeset/view-current-work-only.md b/.changeset/view-current-work-only.md new file mode 100644 index 0000000000..044c96f81d --- /dev/null +++ b/.changeset/view-current-work-only.md @@ -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. diff --git a/docs-lab/reference/cli.md b/docs-lab/reference/cli.md index b935c12c70..c13c39e1dd 100644 --- a/docs-lab/reference/cli.md +++ b/docs-lab/reference/cli.md @@ -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** @@ -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 diff --git a/src/core/view.ts b/src/core/view.ts index f6ec9d7a3a..877bd9622c 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -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')); @@ -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 }); @@ -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> { @@ -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 = @@ -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); diff --git a/test/cli-e2e/view-store-resolution.test.ts b/test/cli-e2e/view-store-resolution.test.ts index 005807bfe8..85f59ebd7d 100644 --- a/test/cli-e2e/view-store-resolution.test.ts +++ b/test/cli-e2e/view-store-resolution.test.ts @@ -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']) { @@ -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( diff --git a/test/core/view.test.ts b/test/core/view.test.ts index cc24a62258..5b5f65ca32 100644 --- a/test/core/view.test.ts +++ b/test/core/view.test.ts @@ -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 () => {