diff --git a/.changeset/fuzzy-dagster-workspaces.md b/.changeset/fuzzy-dagster-workspaces.md new file mode 100644 index 0000000000..9624e6ccba --- /dev/null +++ b/.changeset/fuzzy-dagster-workspaces.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +Move beta workspace view state to `.openspec-workspace/view.yaml`, stop top-level `openspec update` from routing into workspace updates, and ignore foreign root `workspace.yaml` files so Dagster projects keep updating normally. diff --git a/docs/cli.md b/docs/cli.md index 9e85c5aa76..103dd7d4fe 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -306,7 +306,7 @@ openspec workspace update --workspace platform --tools none `workspace update` refreshes the generated workspace guidance block and local open surface. For agent skills, it reuses the stored workspace skill agent selection when `--tools` is omitted. Passing `--tools` replaces that stored selection. It refreshes only OpenSpec-managed workflow skill directories in the workspace root, removes deselected managed workflow skills, and leaves linked repos and folders untouched. -Running `openspec update` from inside a workspace redirects to `openspec workspace update`; run `openspec update` inside repo-local projects when you want repo-owned tool files updated. +Running `openspec update` from inside a workspace does not update workspace-local files. Use `openspec workspace update` when you want workspace-local guidance and skills refreshed, and run `openspec update` inside repo-local projects when you want repo-owned tool files updated. ### `openspec workspace open` diff --git a/docs/concepts.md b/docs/concepts.md index 2205d317c1..a04c65d812 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -71,7 +71,8 @@ A workspace has a different shape from a repo-local project: ```text getGlobalDataDir()/workspaces// -├── workspace.yaml # Private local view record +├── .openspec-workspace/ +│ └── view.yaml # Private local view record ├── AGENTS.md # Generated runtime guidance └── .code-workspace # Generated editor workspace file ``` @@ -85,12 +86,14 @@ repo-root/ └── changes/ ``` +Root-level `workspace.yaml` files are not OpenSpec workspace state. Workspace state is namespaced under `.openspec-workspace/`, so other tools can keep owning root-level files with the same name. + That distinction matters. The workspace folder is a local coordination surface for opening and inspecting linked repos or folders. Each repo's `openspec/` directory remains the home for repo-owned specs, repo-local changes, and implementation planning. Users do not need to run repo-local `openspec init` inside a workspace folder. Stable link names are how a workspace refers to repos and folders. The private workspace record keeps names such as `api`, `web`, or `checkout` and maps them to this runtime's local paths. ```yaml -# workspace.yaml +# .openspec-workspace/view.yaml version: 1 name: platform context: null @@ -99,7 +102,7 @@ links: web: /repos/web ``` -When a workspace opens an initiative, `context` records the selected context-store binding and initiative id. Registry-selected stores stay portable by id; path-selected stores intentionally preserve the runtime-local path because `workspace.yaml` is private local state. +When a workspace opens an initiative, `context` records the selected context-store binding and initiative id. Registry-selected stores stay portable by id; path-selected stores intentionally preserve the runtime-local path because `.openspec-workspace/view.yaml` is private local state. ```yaml context: @@ -133,7 +136,7 @@ getGlobalDataDir()/workspaces That means `$XDG_DATA_HOME/openspec/workspaces` when `XDG_DATA_HOME` is set, `~/.local/share/openspec/workspaces` on Unix-style fallback, and `%LOCALAPPDATA%\openspec\workspaces` on native Windows fallback. Native Windows shells, PowerShell, and WSL2 each keep the path strings for the runtime running OpenSpec. This foundation does not translate between `D:\repo`, `/mnt/d/repo`, and UNC WSL paths. -OpenSpec can still read older beta workspace roots as compatibility inputs, but managed workspaces now use the root `workspace.yaml` record above. The workspace folder remains authoritative for its own private local view. +Managed workspaces use the namespaced private view record above. The workspace folder remains authoritative for its own private local view. Workspace visibility is not change commitment. Set up a workspace when OpenSpec should know which repos or folders are relevant; create a change later when you are ready to plan a feature, fix, project, or other piece of work. diff --git a/docs/workspaces-beta/user-guide.md b/docs/workspaces-beta/user-guide.md index 29fbe12c15..e8cb505143 100644 --- a/docs/workspaces-beta/user-guide.md +++ b/docs/workspaces-beta/user-guide.md @@ -30,7 +30,7 @@ which local repos or folders to include. The opened editor view shows linked repos and folders first, initiative context when attached, and a small `OpenSpec workspace` folder last with `AGENTS.md`, -`workspace.yaml`, and the generated `.code-workspace` file. +`.openspec-workspace/view.yaml`, and the generated `.code-workspace` file. Use `openspec workspace open --initiative team-context/billing-launch --editor` when you want to skip the picker. Use `--agent codex-cli`, `--agent claude`, or diff --git a/openspec/specs/cli-update/spec.md b/openspec/specs/cli-update/spec.md index 6e848751ac..34e32c91f3 100644 --- a/openspec/specs/cli-update/spec.md +++ b/openspec/specs/cli-update/spec.md @@ -166,7 +166,7 @@ The archive slash command template SHALL support optional change ID arguments fo - **AND** wrap it in a clear structure like `\n $ARGUMENTS\n` to indicate the expected argument - **AND** include validation steps in the template body to check if the change ID is valid -### Requirement: Repo update redirects from workspace planning homes +### Requirement: Repo update stays separate from workspace planning homes The repo-local `openspec update` command SHALL not silently treat a workspace planning home as a repo-local OpenSpec project. #### Scenario: Running update from a workspace root @@ -186,6 +186,13 @@ The repo-local `openspec update` command SHALL not silently treat a workspace pl - **WHEN** the user runs `openspec update` - **THEN** OpenSpec SHALL preserve existing repo-local update behavior +#### Scenario: Updating a repo-local project nested below a workspace folder +- **GIVEN** the target path contains repo-local OpenSpec state +- **AND** an ancestor is an OpenSpec workspace root +- **WHEN** the user runs `openspec update ` +- **THEN** OpenSpec SHALL preserve repo-local update behavior for the target path +- **AND** it SHALL not run workspace update behavior + ## Edge Cases ### Error Handling diff --git a/openspec/specs/workspace-foundation/spec.md b/openspec/specs/workspace-foundation/spec.md index e6ef3658c8..513ae3aa15 100644 --- a/openspec/specs/workspace-foundation/spec.md +++ b/openspec/specs/workspace-foundation/spec.md @@ -30,7 +30,7 @@ OpenSpec SHALL use one kebab-case workspace name across workspace identity, mana #### Scenario: Using one workspace name - **WHEN** OpenSpec creates or records a managed workspace -- **THEN** the workspace name SHALL be stored in `.openspec-workspace/workspace.yaml` +- **THEN** the workspace name SHALL be stored in `.openspec-workspace/view.yaml` - **AND** the same name SHALL be used as the default managed workspace folder name - **AND** the same name SHALL be used as the local registry name @@ -78,7 +78,7 @@ OpenSpec SHALL keep shared workspace information separate from local machine pat #### Scenario: Keeping managed workspace view state local - **WHEN** OpenSpec creates a managed workspace -- **THEN** it SHALL write `workspace.yaml` in the workspace root as private local view state +- **THEN** it SHALL write `.openspec-workspace/view.yaml` as private local view state - **AND** the file SHALL preserve stable link names and local path values for the current machine ### Requirement: Standard Workspace Location @@ -130,7 +130,7 @@ OpenSpec SHALL keep a lightweight local registry of known workspaces on the curr #### Scenario: Keeping workspace folders authoritative - **WHEN** OpenSpec reads workspace details -- **THEN** each workspace folder's `.openspec-workspace/workspace.yaml` SHALL remain the source of truth for that workspace +- **THEN** each workspace folder's `.openspec-workspace/view.yaml` SHALL remain the source of truth for that workspace - **AND** the local registry SHALL act only as an index of known workspace locations #### Scenario: Finding workspaces from anywhere @@ -210,7 +210,7 @@ OpenSpec SHALL store a workspace's preferred opener in machine-local workspace s #### Scenario: Recording an interactive setup opener choice - **WHEN** an interactive user chooses a preferred opener during `openspec workspace setup` -- **THEN** OpenSpec SHALL record the opener in `.openspec-workspace/local.yaml` +- **THEN** OpenSpec SHALL record the opener in `.openspec-workspace/view.yaml` - **AND** the stored value SHALL use a structured `preferred_opener` object with `kind` and `id` #### Scenario: Recording a non-interactive setup opener choice diff --git a/src/cli/index.ts b/src/cli/index.ts index d06fdddc54..0c42f43cb4 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -4,7 +4,7 @@ import ora from 'ora'; import path from 'path'; import { fileURLToPath } from 'url'; import { promises as fs } from 'fs'; -import { AI_TOOLS } from '../core/config.js'; +import { AI_TOOLS, OPENSPEC_DIR_NAME } from '../core/config.js'; import { UpdateCommand } from '../core/update.js'; import { ListCommand } from '../core/list.js'; import { ArchiveCommand } from '../core/archive.js'; @@ -17,10 +17,7 @@ import { CompletionCommand } from '../commands/completion.js'; import { FeedbackCommand } from '../commands/feedback.js'; import { registerConfigCommand } from '../commands/config.js'; import { registerSchemaCommand } from '../commands/schema.js'; -import { - registerWorkspaceCommand, - runWorkspaceUpdateForRoot, -} from '../commands/workspace.js'; +import { registerWorkspaceCommand } from '../commands/workspace.js'; import { registerContextStoreCommand } from '../commands/context-store.js'; import { registerInitiativeCommand } from '../commands/initiative.js'; import { findWorkspaceRoot } from '../core/workspace/index.js'; @@ -100,6 +97,22 @@ program.hook('postAction', async () => { const availableToolIds = AI_TOOLS.filter((tool) => tool.skillsDir).map((tool) => tool.value); const toolsOptionDescription = `Configure AI tools non-interactively. Use "all", "none", or a comma-separated list of: ${availableToolIds.join(', ')}`; +async function hasRepoLocalOpenSpecProject(projectPath: string): Promise { + try { + const stats = await fs.stat(path.join(projectPath, OPENSPEC_DIR_NAME)); + return stats.isDirectory(); + } catch (error) { + const code = + typeof error === 'object' && error !== null && 'code' in error + ? (error as { code?: unknown }).code + : undefined; + if (code !== 'ENOENT' && code !== 'ENOTDIR') { + throw error; + } + return false; + } +} + program .command('init [path]') .description('Initialize OpenSpec in your project') @@ -170,13 +183,19 @@ program .action(async (targetPath = '.', options?: { force?: boolean }) => { try { const resolvedPath = path.resolve(targetPath); + const updateCommand = new UpdateCommand({ force: options?.force }); + if (await hasRepoLocalOpenSpecProject(resolvedPath)) { + await updateCommand.execute(resolvedPath); + return; + } + const workspaceRoot = await findWorkspaceRoot(resolvedPath); if (workspaceRoot) { - await runWorkspaceUpdateForRoot(workspaceRoot, { force: options?.force }); - return; + throw new Error( + 'OpenSpec workspace detected. Run `openspec workspace update` to refresh workspace-local guidance and skills.' + ); } - const updateCommand = new UpdateCommand({ force: options?.force }); await updateCommand.execute(resolvedPath); } catch (error) { console.log(); // Empty line for spacing diff --git a/src/commands/workspace.ts b/src/commands/workspace.ts index 5262f6efa2..1b957b8aef 100644 --- a/src/commands/workspace.ts +++ b/src/commands/workspace.ts @@ -27,10 +27,7 @@ import { updateWorkspaceLink, validateWorkspaceNameForSetup, } from './workspace/operations.js'; -import { - selectWorkspaceForCommand, - selectWorkspaceRootForCommand, -} from './workspace/selection.js'; +import { selectWorkspaceForCommand } from './workspace/selection.js'; import { launchWorkspaceOpenCommand, } from './workspace/open.js'; @@ -671,15 +668,6 @@ class WorkspaceCommand { } } - async updateRoot(workspaceRoot: string, options: WorkspaceUpdateOptions = {}): Promise { - try { - const selected = await selectWorkspaceRootForCommand(workspaceRoot); - await this.updateSelected(selected, options); - } catch (error) { - this.handleFailure(options.json, { workspace: null, workspace_skills: null, status: [] }, error); - } - } - private async updateSelected( selected: SelectedWorkspace, options: WorkspaceUpdateOptions @@ -796,14 +784,6 @@ export async function runWorkspaceUpdate( await workspaceCommand.update(positionalName, options); } -export async function runWorkspaceUpdateForRoot( - workspaceRoot: string, - options: WorkspaceUpdateOptions = {} -): Promise { - const workspaceCommand = new WorkspaceCommand(); - await workspaceCommand.updateRoot(workspaceRoot, options); -} - export function registerWorkspaceCommand(program: Command): void { registerWorkspaceCommandWith(program, new WorkspaceCommand()); } diff --git a/src/commands/workspace/context-status.ts b/src/commands/workspace/context-status.ts index 73e13ea5bc..6620b15e3d 100644 --- a/src/commands/workspace/context-status.ts +++ b/src/commands/workspace/context-status.ts @@ -46,7 +46,7 @@ export async function collectWorkspaceContextStatuses( target: 'workspace.context.store', fix: context.store.selector.kind === 'registry' ? 'openspec context-store doctor' - : `Check the path in workspace.yaml or run openspec initiative show ${initiativeId} ${selector}`, + : `Check the path in .openspec-workspace/view.yaml or run openspec initiative show ${initiativeId} ${selector}`, } ), ]; diff --git a/src/commands/workspace/open-view.ts b/src/commands/workspace/open-view.ts index 6286771780..4f2395d106 100644 --- a/src/commands/workspace/open-view.ts +++ b/src/commands/workspace/open-view.ts @@ -211,7 +211,7 @@ async function resolveStoredWorkspaceInitiative( target: 'workspace.context.store', fix: context.store.selector.kind === 'registry' ? 'openspec context-store doctor' - : 'Check the path in workspace.yaml.', + : 'Check the path in .openspec-workspace/view.yaml.', } ); } diff --git a/src/commands/workspace/operations.ts b/src/commands/workspace/operations.ts index c07167a035..8b6650e05e 100644 --- a/src/commands/workspace/operations.ts +++ b/src/commands/workspace/operations.ts @@ -205,7 +205,7 @@ function localStateInvalidStatus(error: unknown): WorkspaceStatus { `Machine-local paths could not be read: ${asErrorMessage(error)}`, { target: 'workspace.local_state', - fix: 'Repair workspace.yaml, then run openspec workspace relink for affected links.', + fix: 'Repair .openspec-workspace/view.yaml, then run openspec workspace relink for affected links.', } ); } @@ -433,7 +433,7 @@ export async function loadWorkspaceForDoctor( `Workspace state could not be read: ${asErrorMessage(error)}`, { target: 'workspace.root', - fix: 'Repair .openspec-workspace/workspace.yaml before using this workspace.', + fix: 'Repair .openspec-workspace/view.yaml before using this workspace.', } ), ], @@ -523,7 +523,7 @@ async function readWorkspaceViewForMutation(selected: SelectedWorkspace): Promis 'workspace_state_invalid', { target: 'workspace.state', - fix: 'Repair workspace.yaml before using this workspace.', + fix: 'Repair .openspec-workspace/view.yaml before using this workspace.', } ); } diff --git a/src/commands/workspace/selection.ts b/src/commands/workspace/selection.ts index 6c5b6bec8d..b6348cd874 100644 --- a/src/commands/workspace/selection.ts +++ b/src/commands/workspace/selection.ts @@ -78,26 +78,6 @@ export async function selectedWorkspaceFromRoot( }; } -export async function selectWorkspaceRootForCommand( - workspaceRoot: string -): Promise { - const entries = await listKnownWorkspaceEntries(); - const currentWorkspaceRoot = await findWorkspaceRoot(workspaceRoot); - - if (!currentWorkspaceRoot) { - throw new WorkspaceCliError( - `No OpenSpec workspace found at '${workspaceRoot}'.`, - 'workspace_not_found', - { - target: 'workspace.root', - fix: 'Pass a path inside an OpenSpec workspace.', - } - ); - } - - return selectedWorkspaceFromRoot(currentWorkspaceRoot, entries); -} - export async function selectWorkspaceForCommand( options: WorkspaceSelectionOptions, commandName: string, diff --git a/src/core/workspace/foundation.ts b/src/core/workspace/foundation.ts index a805399b09..80fcb50d61 100644 --- a/src/core/workspace/foundation.ts +++ b/src/core/workspace/foundation.ts @@ -9,7 +9,7 @@ import { import { FileSystemUtils } from '../../utils/file-system.js'; export const WORKSPACE_METADATA_DIR_NAME = '.openspec-workspace'; -export const WORKSPACE_VIEW_STATE_FILE_NAME = 'workspace.yaml'; +export const WORKSPACE_VIEW_STATE_FILE_NAME = 'view.yaml'; export const WORKSPACE_CHANGES_DIR_NAME = 'changes'; export const WORKSPACE_CODE_WORKSPACE_EXTENSION = '.code-workspace'; @@ -77,7 +77,7 @@ export function getWorkspaceMetadataDir(workspaceRoot: string): string { } export function getWorkspaceViewStatePath(workspaceRoot: string): string { - return joinWorkspacePath(workspaceRoot, WORKSPACE_VIEW_STATE_FILE_NAME); + return joinWorkspacePath(getWorkspaceMetadataDir(workspaceRoot), WORKSPACE_VIEW_STATE_FILE_NAME); } export function getWorkspaceChangesDir(workspaceRoot: string): string { diff --git a/src/core/workspace/legacy-state.ts b/src/core/workspace/legacy-state.ts index e0c91ef8ef..14ca74eeb4 100644 --- a/src/core/workspace/legacy-state.ts +++ b/src/core/workspace/legacy-state.ts @@ -3,7 +3,6 @@ import { z } from 'zod'; import { WORKSPACE_METADATA_DIR_NAME, - WORKSPACE_VIEW_STATE_FILE_NAME, getWorkspaceMetadataDir, parseWorkspaceViewState, validateWorkspaceLinkName, @@ -16,7 +15,7 @@ import { } from './foundation.js'; import { FileSystemUtils } from '../../utils/file-system.js'; -export const WORKSPACE_LEGACY_SHARED_STATE_FILE_NAME = WORKSPACE_VIEW_STATE_FILE_NAME; +export const WORKSPACE_LEGACY_SHARED_STATE_FILE_NAME = 'workspace.yaml'; export const WORKSPACE_LEGACY_LOCAL_STATE_FILE_NAME = 'local.yaml'; export const WORKSPACE_LEGACY_LOCAL_STATE_IGNORE_PATTERN = `${WORKSPACE_METADATA_DIR_NAME}/${WORKSPACE_LEGACY_LOCAL_STATE_FILE_NAME}`; diff --git a/src/core/workspace/open-surface.ts b/src/core/workspace/open-surface.ts index b77a79443b..2378d9d1f6 100644 --- a/src/core/workspace/open-surface.ts +++ b/src/core/workspace/open-surface.ts @@ -25,7 +25,7 @@ This directory is an OpenSpec workspace: a local working view over context store - Use repo-local OpenSpec changes for implementation plans owned by a repo or team. - Use linked repos and folders to inspect context, understand ownership, and make edits in the place that owns the work. - Keep workspace-local files focused on local paths, opener state, agent setup, and other machine-specific view state. -- Use OpenSpec workspace commands instead of hand-editing \`workspace.yaml\`. +- Use OpenSpec workspace commands instead of hand-editing \`.openspec-workspace/view.yaml\`. - If this workspace contains legacy or beta workspace-level planning files, treat them as compatibility context unless the user explicitly asks to use that beta flow.`; export interface WorkspaceOpenResolvedContext { diff --git a/src/core/workspace/state-io.ts b/src/core/workspace/state-io.ts index bb3b72be5e..c95d206fee 100644 --- a/src/core/workspace/state-io.ts +++ b/src/core/workspace/state-io.ts @@ -4,6 +4,7 @@ import * as path from 'node:path'; import { FileSystemUtils } from '../../utils/file-system.js'; import { getWorkspaceChangesDir, + getWorkspaceMetadataDir, getWorkspaceViewStatePath, parseWorkspaceViewState, serializeWorkspaceViewState, @@ -162,10 +163,10 @@ export async function writeWorkspaceViewState( workspaceRoot: string, state: WorkspaceViewState ): Promise { - await FileSystemUtils.writeFile( - getWorkspaceViewStatePath(workspaceRoot), - serializeWorkspaceViewState(state) - ); + const content = serializeWorkspaceViewState(state); + + await FileSystemUtils.createDirectory(getWorkspaceMetadataDir(workspaceRoot)); + await FileSystemUtils.writeFile(getWorkspaceViewStatePath(workspaceRoot), content); } export async function workspaceChangesDirExists(workspaceRoot: string): Promise { diff --git a/test/commands/workspace-initiative-open.test.ts b/test/commands/workspace-initiative-open.test.ts index fae2fca136..0070b59a6a 100644 --- a/test/commands/workspace-initiative-open.test.ts +++ b/test/commands/workspace-initiative-open.test.ts @@ -231,7 +231,7 @@ describe('workspace open initiative views', () => { }, }) ); - expect(fs.existsSync(path.join(workspaceRoot, '.openspec-workspace'))).toBe(false); + expect(fs.existsSync(path.join(workspaceRoot, '.openspec-workspace'))).toBe(true); expect(fs.existsSync(path.join(globalDataDir, 'workspaces', 'registry.yaml'))).toBe(false); expect(fs.readFileSync(path.join(workspaceRoot, 'AGENTS.md'), 'utf-8')).toContain( 'Initiative title: Billing Launch' diff --git a/test/commands/workspace.test.ts b/test/commands/workspace.test.ts index 7e3bffeab7..2e085d1c80 100644 --- a/test/commands/workspace.test.ts +++ b/test/commands/workspace.test.ts @@ -443,7 +443,7 @@ describe('workspace command', () => { ); }); - it('redirects openspec update from a workspace root to workspace update', async () => { + it('does not route openspec update through workspace update from a workspace root', async () => { const api = mkdir('repos/api'); const linkedEntriesBefore = fs.readdirSync(api).sort(); writeGlobalConfig({ @@ -453,6 +453,7 @@ describe('workspace command', () => { }); const setup = await setupWorkspace('update-redirect', [`api=${api}`], ['--tools', 'codex']); const workspaceRoot = setup.workspace.root; + const workspaceStateBefore = fs.readFileSync(getWorkspaceViewStatePath(workspaceRoot), 'utf-8'); expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'skills', 'openspec-apply-change', 'SKILL.md'))).toBe(true); expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'skills', 'openspec-propose', 'SKILL.md'))).toBe(false); @@ -465,17 +466,103 @@ describe('workspace command', () => { cwd: workspaceRoot, env, }); - expect(update.exitCode).toBe(0); - expect(update.stdout).toContain('Workspace update complete'); - expect(update.stdout).toContain('update-redirect'); + expect(update.exitCode).toBe(1); + expect(`${update.stdout}\n${update.stderr}`).toContain('Run `openspec workspace update`'); + expect(update.stdout).not.toContain('Workspace update complete'); expect(update.stdout).not.toContain('not in the managed local workspace views list'); - expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'skills', 'openspec-propose', 'SKILL.md'))).toBe(true); - expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'skills', 'openspec-sync-specs', 'SKILL.md'))).toBe(true); + expect(fs.readFileSync(getWorkspaceViewStatePath(workspaceRoot), 'utf-8')).toBe(workspaceStateBefore); + expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'skills', 'openspec-propose', 'SKILL.md'))).toBe(false); + expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'skills', 'openspec-sync-specs', 'SKILL.md'))).toBe(false); expect(fs.readdirSync(api).sort()).toEqual(linkedEntriesBefore); expect(fs.existsSync(path.join(api, '.codex'))).toBe(false); }); - it('updates the workspace passed to openspec update even when another workspace is known', async () => { + it('updates repo-local project targets nested under a workspace without touching workspace state', async () => { + const api = mkdir('repos/api'); + writeGlobalConfig({ + profile: 'custom', + delivery: 'commands', + workflows: ['apply'], + }); + const setup = await setupWorkspace('nested-update-target', [`api=${api}`], ['--tools', 'codex']); + const workspaceRoot = setup.workspace.root; + const workspaceStateBefore = fs.readFileSync(getWorkspaceViewStatePath(workspaceRoot), 'utf-8'); + const nestedRepo = path.join(workspaceRoot, 'repos', 'nested-api'); + fs.mkdirSync(path.join(nestedRepo, 'openspec'), { recursive: true }); + expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'skills', 'openspec-apply-change', 'SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'skills', 'openspec-propose', 'SKILL.md'))).toBe(false); + + writeGlobalConfig({ + profile: 'core', + delivery: 'commands', + }); + + const update = await runCLI(['update', nestedRepo], { + cwd: tempDir, + env, + }); + + expect(update.exitCode).toBe(0); + expect(update.stdout).toContain('No configured tools found'); + expect(`${update.stdout}\n${update.stderr}`).not.toContain('Run `openspec workspace update`'); + expect(update.stdout).not.toContain('Workspace update complete'); + expect(fs.readFileSync(getWorkspaceViewStatePath(workspaceRoot), 'utf-8')).toBe(workspaceStateBefore); + expect(fs.existsSync(path.join(workspaceRoot, '.codex', 'skills', 'openspec-propose', 'SKILL.md'))).toBe(false); + }); + + it('does not touch workspace state when updating repo-local projects with foreign workspace.yaml', async () => { + const existingApi = mkdir('repos/existing-api'); + writeGlobalConfig({ + profile: 'custom', + delivery: 'commands', + workflows: ['apply'], + }); + const existingWorkspace = await setupWorkspace('known-workspace', [`api=${existingApi}`], ['--tools', 'codex']); + const existingWorkspaceRoot = existingWorkspace.workspace.root; + const existingWorkspaceStateBefore = fs.readFileSync( + getWorkspaceViewStatePath(existingWorkspaceRoot), + 'utf-8' + ); + expect(fs.existsSync(path.join(existingWorkspaceRoot, '.codex', 'skills', 'openspec-apply-change', 'SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(existingWorkspaceRoot, '.codex', 'skills', 'openspec-propose', 'SKILL.md'))).toBe(false); + + writeGlobalConfig({ + profile: 'core', + delivery: 'commands', + }); + + const repoRoot = mkdir('repos/foreign-tool'); + fs.mkdirSync(path.join(repoRoot, 'openspec'), { recursive: true }); + const foreignWorkspaceYaml = `tool_workspace: + projects: + - name: example + path: ./service +`; + fs.writeFileSync(path.join(repoRoot, 'workspace.yaml'), foreignWorkspaceYaml); + + const update = await runCLI(['update'], { + cwd: repoRoot, + env, + }); + + expect(update.exitCode).toBe(0); + expect(update.stdout).not.toContain('Workspace update complete'); + expect(update.stderr).not.toContain('Invalid workspace state'); + expect(update.stdout).toContain('No configured tools found'); + expect(fs.readFileSync(getWorkspaceViewStatePath(existingWorkspaceRoot), 'utf-8')).toBe( + existingWorkspaceStateBefore + ); + expect(fs.existsSync(path.join(existingWorkspaceRoot, '.codex', 'skills', 'openspec-propose', 'SKILL.md'))).toBe(false); + expect(fs.readFileSync(path.join(repoRoot, 'workspace.yaml'), 'utf-8')).toBe( + foreignWorkspaceYaml + ); + expect(fs.existsSync(path.join(repoRoot, WORKSPACE_METADATA_DIR_NAME))).toBe(false); + expect(fs.existsSync(path.join(repoRoot, WORKSPACE_CHANGES_DIR_NAME))).toBe(false); + expect(fs.readdirSync(repoRoot).some((entry) => entry.endsWith('.code-workspace'))).toBe(false); + expect(fs.existsSync(getWorkspaceRegistryPath({ globalDataDir: path.join(dataHome, 'openspec') }))).toBe(false); + }); + + it('does not update a workspace passed to openspec update even when another workspace is known', async () => { const firstApi = mkdir('repos/first-api'); const secondApi = mkdir('repos/second-api'); writeGlobalConfig({ @@ -485,6 +572,8 @@ describe('workspace command', () => { }); const first = await setupWorkspace('target-first', [`api=${firstApi}`], ['--tools', 'codex']); const second = await setupWorkspace('target-second', [`api=${secondApi}`], ['--tools', 'codex']); + const firstWorkspaceStateBefore = fs.readFileSync(getWorkspaceViewStatePath(first.workspace.root), 'utf-8'); + const secondWorkspaceStateBefore = fs.readFileSync(getWorkspaceViewStatePath(second.workspace.root), 'utf-8'); writeGlobalConfig({ profile: 'core', @@ -496,11 +585,17 @@ describe('workspace command', () => { { cwd: tempDir, env } ); - expect(update.exitCode).toBe(0); - expect(update.stdout).toContain('Workspace update complete'); - expect(update.stdout).toContain('target-first'); + expect(update.exitCode).toBe(1); + expect(`${update.stdout}\n${update.stderr}`).toContain('Run `openspec workspace update`'); + expect(update.stdout).not.toContain('Workspace update complete'); expect(update.stdout).not.toContain('Multiple OpenSpec workspaces are known'); - expect(fs.existsSync(path.join(first.workspace.root, '.codex', 'skills', 'openspec-propose', 'SKILL.md'))).toBe(true); + expect(fs.readFileSync(getWorkspaceViewStatePath(first.workspace.root), 'utf-8')).toBe( + firstWorkspaceStateBefore + ); + expect(fs.readFileSync(getWorkspaceViewStatePath(second.workspace.root), 'utf-8')).toBe( + secondWorkspaceStateBefore + ); + expect(fs.existsSync(path.join(first.workspace.root, '.codex', 'skills', 'openspec-propose', 'SKILL.md'))).toBe(false); expect(fs.existsSync(path.join(second.workspace.root, '.codex', 'skills', 'openspec-propose', 'SKILL.md'))).toBe(false); }); diff --git a/test/core/planning-home.test.ts b/test/core/planning-home.test.ts index 57c0275169..ebc782312b 100644 --- a/test/core/planning-home.test.ts +++ b/test/core/planning-home.test.ts @@ -48,8 +48,8 @@ describe('planning home paths', () => { fs.mkdirSync(path.join(realWorkspaceRoot, '.openspec-workspace'), { recursive: true }); fs.writeFileSync( - path.join(realWorkspaceRoot, '.openspec-workspace', 'workspace.yaml'), - 'version: 1\nname: platform\nlinks: {}\n', + path.join(realWorkspaceRoot, '.openspec-workspace', 'view.yaml'), + 'version: 1\nname: platform\ncontext: null\nlinks: {}\n', 'utf-8' ); fs.symlinkSync( @@ -74,7 +74,7 @@ describe('planning home paths', () => { fs.mkdirSync(path.join(workspaceRoot, '.openspec-workspace'), { recursive: true }); fs.writeFileSync( - path.join(workspaceRoot, 'workspace.yaml'), + path.join(workspaceRoot, '.openspec-workspace', 'view.yaml'), 'version: 1\nname: bad/name\ncontext: null\nlinks: {}\n', 'utf-8' ); @@ -91,4 +91,30 @@ describe('planning home paths', () => { }) ).toThrow(/Workspace name/u); }); + + it('resolves repo-local projects with foreign workspace.yaml as repo planning homes', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-planning-home-')); + tempDirs.push(tempDir); + const repoRoot = path.join(tempDir, 'foreign-tool-repo'); + const changesDir = path.join(repoRoot, 'openspec', 'changes'); + + fs.mkdirSync(changesDir, { recursive: true }); + fs.writeFileSync( + path.join(repoRoot, 'workspace.yaml'), + `tool_workspace: + projects: + - name: example + path: ./service +`, + 'utf-8' + ); + + const planningHome = resolveCurrentPlanningHomeSync({ + startPath: changesDir, + allowImplicitRepoRoot: false, + }); + + expect(planningHome.kind).toBe('repo'); + expect(planningHome.root).toBe(fs.realpathSync.native(repoRoot)); + }); }); diff --git a/test/core/workspace/foundation.test.ts b/test/core/workspace/foundation.test.ts index 94e7476286..f57047732d 100644 --- a/test/core/workspace/foundation.test.ts +++ b/test/core/workspace/foundation.test.ts @@ -58,7 +58,7 @@ describe('workspace foundation', () => { function createWorkspaceRoot(name = 'platform'): string { const workspaceRoot = path.join(tempDir, name); - fs.mkdirSync(workspaceRoot, { recursive: true }); + fs.mkdirSync(getWorkspaceMetadataDir(workspaceRoot), { recursive: true }); fs.writeFileSync( getWorkspaceViewStatePath(workspaceRoot), `version: 1 @@ -83,7 +83,7 @@ links: {} describe('path helpers', () => { it('exposes the workspace constants', () => { expect(WORKSPACE_METADATA_DIR_NAME).toBe('.openspec-workspace'); - expect(WORKSPACE_VIEW_STATE_FILE_NAME).toBe('workspace.yaml'); + expect(WORKSPACE_VIEW_STATE_FILE_NAME).toBe('view.yaml'); expect(WORKSPACE_CHANGES_DIR_NAME).toBe('changes'); expect(MANAGED_WORKSPACES_DIR_NAME).toBe('workspaces'); expect(WORKSPACE_REGISTRY_FILE_NAME).toBe('registry.yaml'); @@ -96,7 +96,7 @@ links: {} path.join(workspaceRoot, '.openspec-workspace') ); expect(getWorkspaceViewStatePath(workspaceRoot)).toBe( - path.join(workspaceRoot, 'workspace.yaml') + path.join(workspaceRoot, '.openspec-workspace', 'view.yaml') ); expect(getWorkspaceChangesDir(workspaceRoot)).toBe(path.join(workspaceRoot, 'changes')); expect(getWorkspaceCodeWorkspaceFileName('platform')).toBe('platform.code-workspace'); @@ -109,7 +109,7 @@ links: {} const workspaceRoot = 'D:\\repos\\platform-workspace'; expect(getWorkspaceViewStatePath(workspaceRoot)).toBe( - 'D:\\repos\\platform-workspace\\workspace.yaml' + 'D:\\repos\\platform-workspace\\.openspec-workspace\\view.yaml' ); }); @@ -220,6 +220,56 @@ links: {} ); }); + it('ignores foreign root workspace.yaml files in repo-local projects', async () => { + const repoRoot = path.join(tempDir, 'foreign-tool-repo'); + const nestedDir = path.join(repoRoot, 'openspec', 'changes', 'add-feature'); + fs.mkdirSync(nestedDir, { recursive: true }); + fs.writeFileSync( + path.join(repoRoot, 'workspace.yaml'), + `tool_workspace: + projects: + - name: example + path: ./service +` + ); + + await expect(isWorkspaceRoot(repoRoot)).resolves.toBe(false); + await expect(findWorkspaceRoot(nestedDir)).resolves.toBe(null); + }); + + it('ignores unmarked root view state even when it is OpenSpec-shaped', async () => { + const workspaceRoot = path.join(tempDir, 'unmarked-beta-workspace'); + fs.mkdirSync(workspaceRoot, { recursive: true }); + fs.writeFileSync( + path.join(workspaceRoot, 'workspace.yaml'), + `version: 1 +name: unmarked-beta-workspace +context: null +links: {} +` + ); + + await expect(isWorkspaceRoot(workspaceRoot)).resolves.toBe(false); + await expect(findWorkspaceRoot(workspaceRoot)).resolves.toBe(null); + }); + + it('writes canonical view state inside the OpenSpec metadata directory', async () => { + const workspaceRoot = path.join(tempDir, 'written-workspace'); + + await writeWorkspaceViewState(workspaceRoot, { + version: 1, + name: 'written-workspace', + context: null, + links: {}, + }); + + expect(fs.existsSync(getWorkspaceMetadataDir(workspaceRoot))).toBe(true); + expect(fs.existsSync(getWorkspaceViewStatePath(workspaceRoot))).toBe(true); + expect(fs.existsSync(path.join(workspaceRoot, 'workspace.yaml'))).toBe(false); + await expect(isWorkspaceRoot(workspaceRoot)).resolves.toBe(true); + expectSameExistingPath(await findWorkspaceRoot(workspaceRoot), workspaceRoot); + }); + it('detects a workspace even when a linked path has no repo-local openspec state', async () => { const workspaceRoot = createWorkspaceRoot(); const linkedPath = path.join(workspaceRoot, 'external-folder'); diff --git a/test/core/workspace/legacy-state.test.ts b/test/core/workspace/legacy-state.test.ts index 0cd2e28d4f..3fa7a94379 100644 --- a/test/core/workspace/legacy-state.test.ts +++ b/test/core/workspace/legacy-state.test.ts @@ -39,7 +39,7 @@ describe('workspace legacy state compatibility', () => { function createWorkspaceRoot(name = 'platform'): string { const workspaceRoot = path.join(tempDir, name); - fs.mkdirSync(workspaceRoot, { recursive: true }); + fs.mkdirSync(path.dirname(getWorkspaceViewStatePath(workspaceRoot)), { recursive: true }); fs.writeFileSync( getWorkspaceViewStatePath(workspaceRoot), `version: 1 @@ -64,6 +64,9 @@ links: {} expect(getWorkspaceLegacyLocalStatePath(workspaceRoot)).toBe( path.join(workspaceRoot, '.openspec-workspace', 'local.yaml') ); + expect(getWorkspaceViewStatePath(workspaceRoot)).toBe( + path.join(workspaceRoot, '.openspec-workspace', 'view.yaml') + ); expect(getWorkspaceLegacyLocalStatePath('D:\\repos\\platform-workspace')).toBe( 'D:\\repos\\platform-workspace\\.openspec-workspace\\local.yaml' );