diff --git a/docs/concepts.md b/docs/concepts.md index b929a588a..4e3f21ae2 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -49,6 +49,92 @@ OpenSpec organizes your work into two main areas: This separation is key. You can work on multiple changes in parallel without conflicts. You can review a change before it affects the main specs. And when you archive a change, its deltas merge cleanly into the source of truth. +## Coordination Workspaces + +Workspace support is in beta. The concepts below describe the direction and foundation currently being implemented; commands and workflows may change, and some workspace commands may not be available in the current stable release yet. + +Repo-local OpenSpec projects are the right default when one repo owns the planning, implementation, and archive flow. Some work spans several repos or folders. For that case, an OpenSpec coordination workspace is the durable planning home. + +The workspace mental model is: + +```text +workspace = where related cross-repo changes live +link = a stable name for a repo or folder the workspace can plan against +change = one feature, fix, project, or other planned piece of work +``` + +A workspace has a different shape from a repo-local project: + +```text +workspace-root/ +├── changes/ # Workspace-level planning +└── .openspec-workspace/ + ├── workspace.yaml # Shared workspace identity and link names + └── local.yaml # This machine's local paths +``` + +Repo-local OpenSpec state keeps the existing shape: + +```text +repo-root/ +└── openspec/ + ├── specs/ + └── changes/ +``` + +That distinction matters. The workspace root is a coordination surface for planning across 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 root. + +Stable link names are how workspace planning refers to repos and folders. The shared workspace state keeps names such as `api`, `web`, or `checkout`; each machine maps those names to its own local paths in `.openspec-workspace/local.yaml`. + +```yaml +# .openspec-workspace/workspace.yaml +version: 1 +name: platform +links: + api: {} + web: {} +``` + +```yaml +# .openspec-workspace/local.yaml +version: 1 +paths: + api: /repos/api + web: /repos/web +``` + +OpenSpec-created workspaces exclude `.openspec-workspace/local.yaml` from portable collaboration state by default. `.openspec-workspace/workspace.yaml` remains portable because it stores the workspace name and stable link names, not one user's absolute checkout paths. + +Linked paths can be full repos, folders inside a large monorepo, or other existing folders. They do not need repo-local `openspec/` state before they can participate in workspace planning. Later implementation, verify, or archive workflows may require more repo readiness, but planning visibility starts with the link. + +```text +multi-repo: + api -> /repos/api + web -> /repos/web + +large monorepo: + billing -> /repos/platform/services/billing + checkout -> /repos/platform/apps/checkout +``` + +Managed workspaces live under the standard OpenSpec data directory: + +```text +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 also keeps a machine-local registry at: + +```text +getGlobalDataDir()/workspaces/registry.yaml +``` + +The registry maps workspace names to workspace roots so later global commands can list or select known workspaces from anywhere. It is only an index. Each workspace folder remains authoritative for its own `.openspec-workspace/workspace.yaml` and `.openspec-workspace/local.yaml`, so stale registry entries can be reported and repaired without redefining the workspace itself. + +This foundation intentionally stops before the full workspace workflow. Creation, link and relink commands, agent launch, workspace proposal creation, repo-slice apply, verify, and archive behavior are later slices built on this storage and naming contract. + ## Specs Specs describe your system's behavior using structured requirements and scenarios. diff --git a/openspec/changes/workspace-create-and-register-repos/design.md b/openspec/changes/workspace-create-and-register-repos/design.md new file mode 100644 index 000000000..6b1e5a510 --- /dev/null +++ b/openspec/changes/workspace-create-and-register-repos/design.md @@ -0,0 +1,242 @@ +## Product Shape + +This slice is the first user-facing step after `workspace-foundation`. + +The user experience should be: + +```text +I set up a workspace. +I link the repos or folders it should know about. +I can list my workspaces later. +I can ask OpenSpec what is broken and how to fix it. +``` + +No change proposal is required yet. + +## Links + +A workspace link is a stable name plus a local path on the current machine. + +Examples: + +```text +api -> /repos/api +web -> /repos/web +checkout -> /repos/platform/apps/checkout +billing -> /repos/platform/services/billing +``` + +The path may point at a full repo or a folder inside a large monorepo. It may point at a repo or folder that has not adopted repo-local OpenSpec yet. + +The product language should say "repos or folders". It should avoid "working set", "code area", "entry", "alias", and "local overlay" in user-facing output. + +Link names are normally inferred from the folder basename: + +```text +/repos/api -> api +/repos/platform/apps/checkout -> checkout +``` + +If the inferred name conflicts, interactive flows should ask for a different name. Non-interactive flows should fail with a clear message. + +## Commands + +### `workspace setup` + +Guided onboarding: + +- create a workspace in the standard workspace location +- ask for a workspace name +- require at least one existing repo or folder path +- infer link names from folder names +- let the user add more repos or folders with a simple repeated prompt +- register the workspace in the local workspace registry +- run `workspace doctor` +- print the workspace root, planning path, linked repos/folders, and next useful commands + +This slice should not ask for preferred agent or open the workspace with an agent. Those belong to `workspace-open-agent-context`. + +Setup should support a non-interactive mode for automation: + +```bash +openspec workspace setup --no-interactive --name platform --link /path/to/api --link web=/path/to/web +``` + +In non-interactive mode, setup should fail cleanly unless the user provides a valid workspace name and at least one valid link. `--link` should accept either a path, which infers the name from the folder basename, or `name=path`. + +There is no public `workspace create` command in this slice. Setup is the creation flow. + +### `workspace list` + +Show known OpenSpec-managed workspaces from the local workspace registry. + +`workspace ls` should behave the same way. + +The output should answer what exists and what each workspace links to: + +```yaml +workspaces: + - name: platform + root: /.../openspec/workspaces/platform + links: + - name: api + path: /repos/api + - name: web + path: /repos/web + - name: checkout + root: /.../openspec/workspaces/checkout + links: + - name: app + path: /repos/platform/apps/checkout +``` + +List should keep deep validation for `workspace doctor`. It can still report obviously stale workspace registry entries if a registered workspace path no longer exists. + +### `workspace link [name] ` + +Record an existing repo or folder path for the selected workspace. + +Supported forms: + +```bash +openspec workspace link /path/to/api +openspec workspace link api-service /path/to/api +``` + +The one-argument form infers the link name from the folder basename. The two-argument form lets the user choose the link name. + +The path must exist. The command should accept: + +- full repo roots +- monorepo folders such as packages, services, and apps +- repos or folders without repo-local `openspec/` + +If the path has repo-local OpenSpec state, OpenSpec can report the repo specs path in doctor output. If it does not, OpenSpec should still allow workspace planning. + +`workspace link` only records the link. It must not create, copy, move, initialize, or edit files in the linked repo or folder. + +### `workspace relink ` + +Repair or change the local path for an existing link. + +This slice should keep relink focused on path repair. It should not include owner/handoff metadata; that language was too process-heavy in the POC and can be revisited later if users need contact or notes fields. + +### `workspace doctor` + +Explain the current workspace from the user's machine: + +- workspace root +- workspace planning path +- linked repos and folders +- whether each local path exists +- repo-local specs path when present +- missing local paths +- local names that are not in shared workspace state +- shared link names that are missing local paths +- stale local registry entries +- suggested fixes for each issue + +Doctor should report issues and suggested fixes. It should not repair anything automatically. + +Human output should be YAML-like with snake_case keys: + +```yaml +workspace: + name: platform + root: /.../openspec/workspaces/platform + planning_path: /.../openspec/workspaces/platform/changes + +links: + - name: api + path: /repos/api + path_status: exists + repo_specs_path: /repos/api/openspec/specs + + - name: web + path: /old/path/web + path_status: missing + repo_specs_path: null + issue: linked_path_missing + fix: openspec workspace relink web /path/to/web + +summary: + status: needs_attention + issues: 1 +``` + +JSON output can keep the same structure using JSON syntax. + +## Workspace Selection + +Workspace commands should work from anywhere. + +Commands that do not need one workspace: + +- `workspace setup` +- `workspace list` +- `workspace ls` + +Commands that need one workspace: + +- `workspace link` +- `workspace relink` +- `workspace doctor` + +If the current command needs one workspace and `--workspace ` is not provided: + +- use the current workspace when running from inside a workspace +- otherwise show an interactive picker when multiple known workspaces exist +- otherwise select the only known workspace +- otherwise explain that no workspaces exist and suggest `openspec workspace setup` + +In non-interactive mode, commands that need one workspace should fail when selection is ambiguous and suggest `--workspace `. + +## Machine-Local Files + +Workspace creation should make machine-local state safe by default. + +The workspace should ignore: + +```text +/.openspec-workspace/local.yaml +``` + +The local workspace registry should also be machine-local: + +```text +/workspaces/registry.yaml +``` + +Generated agent-open surfaces can be ignored by `workspace-open-agent-context` when that slice creates them. + +## JSON Output + +Interactive setup does not need JSON output as its primary contract. Non-interactive setup and direct commands should support JSON output for scripting: + +- `workspace setup --no-interactive --json` +- `workspace list --json` +- `workspace link --json` +- `workspace relink --json` +- `workspace doctor --json` + +## POC Adjustments + +Keep: + +- guided setup as the default first run +- direct list/link/check commands +- shared state separate from local paths +- clean non-interactive failure when required setup inputs are missing +- JSON output for non-interactive/direct commands + +Change: + +- do not expose public `workspace create` in the first release +- do not require repo-local OpenSpec state to link a repo or folder +- use `workspace link` instead of `workspace add-repo` +- use `workspace relink` instead of `workspace update-repo` +- do not save preferred agent during setup +- do not offer to open the workspace from setup +- require setup to link at least one existing repo or folder +- keep update behavior focused on path repair rather than owner/handoff metadata +- do not use "working set", "code area", "entry", "alias", or "local overlay" in human-facing output diff --git a/openspec/changes/workspace-create-and-register-repos/proposal.md b/openspec/changes/workspace-create-and-register-repos/proposal.md index f02863afa..30829b808 100644 --- a/openspec/changes/workspace-create-and-register-repos/proposal.md +++ b/openspec/changes/workspace-create-and-register-repos/proposal.md @@ -1,44 +1,99 @@ ## Why -Users start workspace work by collecting the repos involved in a product goal. They should not have to create a change before the system can see those repos. +Users start workspace work by creating a planning home and linking the repos or folders OpenSpec should know about. + +They should not have to create a change before OpenSpec can see the relevant repos, monorepo folders, packages, services, or apps. The product rule is: ```text -Repository visibility is not change commitment. +Workspace visibility is not change commitment. ``` -A registered repo is part of the workspace working set. A change is a later planning commitment. +A workspace is the durable planning home. A change is a feature, fix, project, or other planned piece of work inside that workspace. ## What Changes -Add the user-facing flow for creating a workspace and registering repos: +Add the first user-facing workspace setup flow: ```text -Create a workspace. -Add repos by stable aliases. -See which repos are available to the workspace. +Set up a workspace. +Link existing repos or folders. +List known workspaces and what they link to. +Check what OpenSpec can resolve and how to fix problems. ``` Expected user surface: ```bash -openspec workspace create my-workspace -openspec workspace add-repo openspec /path/to/openspec -openspec workspace add-repo landing /path/to/openspec-landing +openspec workspace setup +openspec workspace setup --no-interactive --name platform --link /path/to/api --link web=/path/to/web +openspec workspace list +openspec workspace ls +openspec workspace link /path/to/api +openspec workspace link api-service /path/to/api +openspec workspace relink api /new/path/to/api +openspec workspace doctor ``` -The system should store committed repo guidance separately from local checkout paths so a workspace can be shared without committing machine-specific state. +`workspace setup` is the creation path for users. It should ask for the workspace name first, create the workspace in the standard location, require at least one existing repo or folder path, infer link names from folder names, show the workspace path, and run a check at the end so the user knows what OpenSpec can see. + +`workspace setup --no-interactive` is the automation path. It should require enough flags to create a useful workspace, including a workspace name and at least one link. + +`workspace list` shows known OpenSpec-managed workspaces from the local workspace registry, including each workspace path and linked repos or folders. + +`workspace link` records an existing local repo or folder path for the selected workspace. It should support a simple form that infers the link name from the folder name and an explicit-name form for conflicts or clarity. Linking does not create, copy, move, initialize, or edit files in the linked repo or folder. + +`workspace relink` lets users repair or change the local path for an existing link without recreating the workspace. It should not introduce owner or handoff metadata in this slice. + +`workspace doctor` explains what the current machine can resolve: the workspace root, the workspace planning path, linked repos or folders, missing paths, stale local registry entries, repo-local specs paths when present, and suggested fixes. It reports issues but does not repair them automatically. + +Workspace commands should work globally. When a command needs one workspace and the user did not specify it, OpenSpec should use the local registry to show an interactive picker. In non-interactive mode, it should fail with a clear message and suggest `--workspace `. Planning dependency: - Depends on `workspace-foundation`. +## POC Findings + +Behavior to preserve: + +- `workspace setup` was the friendly onboarding path. +- `workspace list` made managed workspaces discoverable. +- A direct automation path is still useful, but it should live under `workspace setup --no-interactive`. +- Link repair is useful, but owner/handoff metadata should not carry forward in this slice. +- `workspace doctor` was the right place to answer "what does OpenSpec know about this workspace?" +- Shared workspace state and local paths were stored separately. +- Setup failed cleanly when non-interactive inputs were incomplete. +- Created workspaces ignored machine-local path state. + +Behavior to change: + +- The POC required registered repos to already contain repo-local `openspec/`. This should become an implementation-readiness signal, not a planning prerequisite. +- The POC used repo-only language. This slice should use "repos or folders" for user-facing text. +- The public command should be `workspace link`, not `workspace add-repo`. +- The repair command should be `workspace relink`, not `workspace update-repo`. +- Public `workspace create` should be removed for the first release. Setup should be the creation flow. +- The POC's `setup` flow stored preferred agent/open behavior. Agent launch preferences belong to `workspace-open-agent-context`, not this slice. +- Human output should avoid implementation terms such as working set, code area, entry, alias, or local overlay. +- `setup` should require at least one linked repo or folder so the created workspace is immediately useful. + +## Non-Goals + +- No public `openspec workspace create` command in this first release. +- No workspace-open agent launch behavior. +- No preferred-agent prompts or saved agent preference. +- No owner or handoff metadata fields. +- No workspace change creation or target selection. +- No apply, verify, archive, branch, or worktree behavior. +- No requirement that linked repos or folders have repo-local OpenSpec state. +- No automatic repair behavior in `workspace doctor`. + ## Capabilities ### New Capabilities -- `workspace-repo-registry`: Lets users create a workspace and register repos as the working set for future cross-repo planning. +- `workspace-links`: Lets users set up a workspace, link repos or folders, list known workspaces, and check workspace resolution before change creation. ### Modified Capabilities @@ -46,7 +101,11 @@ Planning dependency: ## Impact -- `openspec workspace create` -- `openspec workspace add-repo` -- Workspace metadata and local overlay files. -- Docs and generated agent guidance that explain registered repos as visibility, not implementation commitment. +- `openspec workspace setup` +- `openspec workspace list` +- `openspec workspace ls` +- `openspec workspace link` +- `openspec workspace relink` +- `openspec workspace doctor` +- Local workspace registry usage from `workspace-foundation`. +- Docs and generated guidance that explain linked repos/folders as planning context, not implementation commitment. diff --git a/openspec/changes/workspace-create-and-register-repos/specs/cli-artifact-workflow/spec.md b/openspec/changes/workspace-create-and-register-repos/specs/cli-artifact-workflow/spec.md new file mode 100644 index 000000000..e969d2bc3 --- /dev/null +++ b/openspec/changes/workspace-create-and-register-repos/specs/cli-artifact-workflow/spec.md @@ -0,0 +1,24 @@ +## ADDED Requirements + +### Requirement: Workspace Setup Commands +The CLI artifact workflow SHALL expose workspace setup commands before change creation. + +#### Scenario: Preparing workspace planning before a change +- **WHEN** a user needs to prepare workspace planning across repos or folders +- **THEN** the CLI SHALL provide commands to set up, list, link, relink, and doctor workspaces +- **AND** those commands SHALL not require an active workspace change + +#### Scenario: Listing workspaces with a short command +- **WHEN** a user wants a concise workspace list command +- **THEN** the CLI SHALL support `openspec workspace ls` +- **AND** it SHALL behave the same as `openspec workspace list` + +#### Scenario: Keeping setup separate from agent launch +- **WHEN** a user completes workspace setup +- **THEN** the setup workflow SHALL leave agent launch and workspace-open behavior to a later workflow +- **AND** setup SHALL not require a preferred agent choice + +#### Scenario: Avoiding public direct creation +- **WHEN** users create a workspace in the first workspace setup flow +- **THEN** the CLI SHALL use `openspec workspace setup` +- **AND** it SHALL not expose `openspec workspace create` as the public creation path diff --git a/openspec/changes/workspace-create-and-register-repos/specs/workspace-links/spec.md b/openspec/changes/workspace-create-and-register-repos/specs/workspace-links/spec.md new file mode 100644 index 000000000..e9507b9cf --- /dev/null +++ b/openspec/changes/workspace-create-and-register-repos/specs/workspace-links/spec.md @@ -0,0 +1,219 @@ +## ADDED Requirements + +### Requirement: Guided Workspace Setup +OpenSpec SHALL provide a guided setup flow for users starting workspace planning. + +#### Scenario: Creating a workspace through setup +- **WHEN** a user runs `openspec workspace setup` +- **THEN** OpenSpec SHALL guide the user through creating an OpenSpec workspace +- **AND** the workspace SHALL use the standard workspace location from the workspace foundation + +#### Scenario: Asking for the workspace name first +- **WHEN** interactive setup starts +- **THEN** OpenSpec SHALL ask for the workspace name before asking for repos or folders +- **AND** workspace names SHALL use lowercase letters, numbers, and hyphens + +#### Scenario: Linking a required first repo or folder +- **WHEN** setup asks for repos or folders +- **THEN** the user SHALL provide at least one existing repo or folder path +- **AND** setup SHALL not finish successfully until at least one path is linked + +#### Scenario: Inferring link names during setup +- **WHEN** the user provides a repo or folder path during setup +- **THEN** OpenSpec SHALL infer the link name from the folder basename +- **AND** it SHALL ask for a different name only when the inferred name conflicts + +#### Scenario: Adding multiple repos or folders during setup +- **WHEN** setup links a repo or folder +- **THEN** OpenSpec SHALL let the user add another repo or folder with a simple repeated prompt +- **AND** each linked path SHALL be recorded without editing the target repo or folder + +#### Scenario: Running setup with non-interactive inputs +- **WHEN** `openspec workspace setup --no-interactive` receives a workspace name and at least one valid link +- **THEN** OpenSpec SHALL create the workspace without prompts +- **AND** it SHALL support repeated `--link` values + +#### Scenario: Missing non-interactive setup inputs +- **WHEN** `openspec workspace setup --no-interactive` is missing a workspace name or link +- **THEN** OpenSpec SHALL fail with a clear message +- **AND** it SHALL explain which flags are required + +#### Scenario: Finishing setup +- **WHEN** setup finishes +- **THEN** OpenSpec SHALL show the workspace root, planning path, and linked repos or folders +- **AND** it SHALL check what the current machine can resolve + +#### Scenario: Registering created workspaces locally +- **WHEN** setup creates a workspace +- **THEN** OpenSpec SHALL record it in the local workspace registry +- **AND** the workspace folder SHALL remain the source of truth for workspace state + +#### Scenario: Reusing an existing workspace name during setup +- **GIVEN** a managed workspace already exists with the requested name +- **WHEN** a user runs setup with that workspace name +- **THEN** OpenSpec SHALL explain that the workspace already exists +- **AND** it SHALL not overwrite the existing workspace + +### Requirement: Workspace Discovery +OpenSpec SHALL let users see the OpenSpec-managed workspaces available on the current machine. + +#### Scenario: Listing workspaces +- **WHEN** a user runs `openspec workspace list` +- **THEN** OpenSpec SHALL list known managed workspaces +- **AND** each workspace SHALL include the workspace name, workspace path, and linked repos or folders + +#### Scenario: Using the short list command +- **WHEN** a user runs `openspec workspace ls` +- **THEN** OpenSpec SHALL behave the same as `openspec workspace list` + +#### Scenario: Listing when no workspaces exist +- **WHEN** a user runs `openspec workspace list` +- **AND** no managed workspaces exist +- **THEN** OpenSpec SHALL say that no workspaces were found +- **AND** it SHALL show the user how to create one + +#### Scenario: Listing stale registry entries +- **WHEN** the local registry contains a workspace path that no longer exists +- **THEN** `workspace list` SHALL report the stale workspace entry +- **AND** it SHALL avoid silently deleting registry state + +### Requirement: Global Workspace Commands +OpenSpec SHALL let workspace commands run from outside workspace directories. + +#### Scenario: Selecting a workspace by flag +- **WHEN** a command that needs one workspace receives `--workspace ` +- **THEN** OpenSpec SHALL use that workspace from the local registry +- **AND** it SHALL fail clearly if the workspace name is unknown + +#### Scenario: Using the current workspace +- **GIVEN** the command runs from a workspace root or subdirectory +- **WHEN** the command needs one workspace and no `--workspace` flag is provided +- **THEN** OpenSpec SHALL use the current workspace + +#### Scenario: Picking from multiple workspaces +- **GIVEN** multiple known workspaces exist +- **WHEN** an interactive command needs one workspace and none is specified +- **THEN** OpenSpec SHALL show a workspace picker +- **AND** the picker SHALL include workspace names and paths + +#### Scenario: Ambiguous non-interactive workspace selection +- **GIVEN** multiple known workspaces exist +- **WHEN** a non-interactive command needs one workspace and none is specified +- **THEN** OpenSpec SHALL fail with a clear message +- **AND** it SHALL suggest passing `--workspace ` + +#### Scenario: No known workspaces for a command that needs one +- **GIVEN** no known workspaces exist in the local registry +- **AND** the command is not running from a workspace root or subdirectory +- **WHEN** `workspace link`, `workspace relink`, `workspace doctor`, or another command that needs one workspace runs without `--workspace ` +- **THEN** OpenSpec SHALL fail without showing a picker regardless of interactive mode +- **AND** it SHALL print `No known OpenSpec workspaces. Run 'openspec workspace setup' first.` +- **AND** it SHALL explain that `--workspace ` can be used after at least one workspace is registered + +### Requirement: Workspace Links +OpenSpec SHALL let users link existing repos or folders to a workspace before creating a change. + +#### Scenario: Linking with an inferred name +- **WHEN** a user runs `openspec workspace link ` +- **THEN** OpenSpec SHALL infer the link name from the folder basename +- **AND** it SHALL store the local path as machine-local state + +#### Scenario: Linking with an explicit name +- **WHEN** a user runs `openspec workspace link ` +- **THEN** OpenSpec SHALL use the explicit link name for planning +- **AND** it SHALL store the local path as machine-local state + +#### Scenario: Requiring an existing path +- **WHEN** a user links a repo or folder path +- **THEN** the path SHALL exist on the current machine +- **AND** OpenSpec SHALL reject missing paths with a clear message + +#### Scenario: Linking a monorepo folder +- **WHEN** a user links a package, service, app, or directory inside a monorepo +- **THEN** OpenSpec SHALL store it as a workspace link +- **AND** it SHALL not require that folder to have its own repo-local `openspec/` directory + +#### Scenario: Linking without repo-local OpenSpec +- **WHEN** a user links a path that does not contain repo-local OpenSpec state +- **THEN** OpenSpec SHALL keep that repo or folder available for workspace planning +- **AND** it SHALL not treat missing repo-local OpenSpec state as a link failure + +#### Scenario: Link records only +- **WHEN** a user links a repo or folder +- **THEN** OpenSpec SHALL record workspace state and local path state +- **AND** it SHALL not create, copy, move, initialize, or edit files in the linked repo or folder + +#### Scenario: Reusing a link name +- **GIVEN** a workspace already has a link with a given name +- **WHEN** a user tries to link another path with the same name +- **THEN** OpenSpec SHALL explain that the link name is already in use +- **AND** it SHALL preserve the existing link unless the user explicitly relinks it + +### Requirement: Workspace Relinks +OpenSpec SHALL let users update existing link paths without recreating the workspace. + +#### Scenario: Updating a local path +- **GIVEN** a workspace has a link +- **WHEN** a user runs `openspec workspace relink ` +- **THEN** OpenSpec SHALL keep the stable link name +- **AND** it SHALL update the machine-local path for the current machine + +#### Scenario: Requiring an existing relink path +- **WHEN** a user relinks to a new path +- **THEN** the new path SHALL exist on the current machine +- **AND** OpenSpec SHALL reject missing paths with a clear message + +#### Scenario: Updating an unknown link +- **WHEN** a user tries to relink a link that does not exist +- **THEN** OpenSpec SHALL explain that the link name is unknown +- **AND** it SHALL preserve existing workspace state + +#### Scenario: Avoiding owner and handoff fields +- **WHEN** users link or relink repos or folders in this slice +- **THEN** OpenSpec SHALL not ask for owner or handoff metadata +- **AND** link maintenance SHALL focus on names and local paths + +### Requirement: Workspace Health Check +OpenSpec SHALL explain what the current machine can resolve for a workspace. + +#### Scenario: Checking a healthy workspace +- **WHEN** a user runs `openspec workspace doctor` +- **THEN** OpenSpec SHALL show the workspace root and workspace planning path +- **AND** it SHALL show linked repos or folders and which paths resolve on the current machine + +#### Scenario: Reporting repo-local specs paths +- **WHEN** a linked repo or folder resolves +- **THEN** doctor SHALL report `repo_specs_path` when repo-local `openspec/specs` exists +- **AND** it SHALL report `repo_specs_path: null` when repo-local specs are not present + +#### Scenario: Checking missing paths +- **WHEN** a link points to a path that is missing on the current machine +- **THEN** doctor SHALL identify the affected link name +- **AND** it SHALL include a suggested `workspace relink` fix + +#### Scenario: Checking shared and local state drift +- **WHEN** shared workspace state and machine-local path state do not agree +- **THEN** doctor SHALL explain which link names are affected +- **AND** it SHALL distinguish shared workspace links from local-only paths + +#### Scenario: Reporting without auto-repair +- **WHEN** doctor finds issues +- **THEN** it SHALL report all issues it can find +- **AND** it SHALL not automatically repair workspace state + +#### Scenario: Using YAML-like human output +- **WHEN** doctor prints human output +- **THEN** it SHALL use YAML-like structure with snake_case keys +- **AND** it SHALL include a summary status and issue count + +### Requirement: Scriptable Workspace Setup Commands +OpenSpec SHALL provide JSON output for direct workspace setup commands. + +#### Scenario: Requesting JSON output +- **WHEN** a user passes `--json` to direct workspace setup commands +- **THEN** OpenSpec SHALL print machine-readable output +- **AND** the output SHALL avoid extra human-readable text + +#### Scenario: Commands with JSON output +- **WHEN** users run `workspace setup --no-interactive`, `workspace list`, `workspace link`, `workspace relink`, or `workspace doctor` +- **THEN** each command SHALL support JSON output diff --git a/openspec/changes/workspace-create-and-register-repos/tasks.md b/openspec/changes/workspace-create-and-register-repos/tasks.md new file mode 100644 index 000000000..d9b87a290 --- /dev/null +++ b/openspec/changes/workspace-create-and-register-repos/tasks.md @@ -0,0 +1,104 @@ +## 1. POC Findings And Scope + +- [x] 1.1 Confirm `setup`, `list`, and `doctor` belong to this slice +- [x] 1.2 Capture that setup should not own preferred-agent or workspace-open behavior +- [x] 1.3 Capture that linked repos/folders and monorepo paths are allowed without repo-local OpenSpec state +- [x] 1.4 Capture decisions for JSON output, `ls`, `.gitignore`, non-interactive setup, required first link, and relink behavior +- [x] 1.5 Capture that public `workspace create` is out of scope for the first release +- [x] 1.6 Capture `link`/`relink` as the user-facing commands + +## 2. Workspace Setup + +- [ ] 2.1 Implement `openspec workspace setup` as the only public creation path +- [ ] 2.2 Prompt for workspace name first in interactive setup +- [ ] 2.3 Validate workspace names with lowercase letters, numbers, and hyphens +- [ ] 2.4 Require at least one existing repo or folder path during setup +- [ ] 2.5 Infer link names from folder basenames during setup +- [ ] 2.6 Let users add more repos/folders with a simple repeated prompt +- [ ] 2.7 Run `workspace doctor` after setup and show a readable summary +- [ ] 2.8 Print the workspace root, planning path, linked repos/folders, and next useful commands +- [ ] 2.9 Keep preferred-agent prompts and workspace opening out of this slice +- [ ] 2.10 Add `.gitignore` handling for machine-local workspace state +- [ ] 2.11 Register created workspaces in the local workspace registry +- [ ] 2.12 Add tests for native Windows/PowerShell and WSL2-compatible path construction where practical + +## 3. Non-Interactive Setup + +- [ ] 3.1 Add `workspace setup --no-interactive --name --link ` support +- [ ] 3.2 Support repeated `--link` values +- [ ] 3.3 Support `--link ` with inferred names +- [ ] 3.4 Support `--link =` with explicit names +- [ ] 3.5 Fail cleanly when non-interactive setup is missing a name or at least one link +- [ ] 3.6 Add `--json` output for non-interactive setup +- [ ] 3.7 Preserve the interactive setup UX when `--no-interactive` is not passed + +## 4. Workspace Listing + +- [ ] 4.1 Implement `openspec workspace list` +- [ ] 4.2 Add `workspace ls` as an alias for `workspace list` +- [ ] 4.3 List known OpenSpec-managed workspaces from the local workspace registry +- [ ] 4.4 Handle the no-workspaces case with a clear next step +- [ ] 4.5 Show each workspace path and linked repos/folders +- [ ] 4.6 Report stale registry entries without doing deep doctor validation +- [ ] 4.7 Add JSON output for scripts + +## 5. Workspace Selection + +- [ ] 5.1 Make workspace commands work from outside workspace directories +- [ ] 5.2 Add `--workspace ` to commands that need one workspace +- [ ] 5.3 Use the current workspace when running from inside a workspace +- [ ] 5.4 Show an interactive picker when multiple known workspaces exist and no workspace is specified +- [ ] 5.5 Select the only known workspace automatically when there is exactly one +- [ ] 5.6 Fail clearly in non-interactive mode when workspace selection is ambiguous +- [ ] 5.7 Use the local workspace registry for workspace lookup + +## 6. Workspace Links + +- [ ] 6.1 Implement `openspec workspace link ` with inferred link names +- [ ] 6.2 Implement `openspec workspace link ` with explicit link names +- [ ] 6.3 Accept full repo roots and monorepo package/service/app folder paths +- [ ] 6.4 Require linked paths to exist +- [ ] 6.5 Allow links without repo-local `openspec/` +- [ ] 6.6 Store stable link names in shared state and local paths in machine-local state +- [ ] 6.7 Detect duplicate link names with a clear error or interactive rename prompt +- [ ] 6.8 Preserve native Windows and WSL2-style paths as local path values +- [ ] 6.9 Ensure link only records state and does not edit the linked repo/folder +- [ ] 6.10 Add `--json` output for `workspace link` + +## 7. Workspace Relinks + +- [ ] 7.1 Implement `openspec workspace relink ` +- [ ] 7.2 Let users repair or change the local path for an existing link +- [ ] 7.3 Require relink paths to exist +- [ ] 7.4 Keep owner/handoff metadata out of this slice +- [ ] 7.5 Add `--json` output for `workspace relink` +- [ ] 7.6 Return a clear error for unknown link names + +## 8. Workspace Doctor + +- [ ] 8.1 Implement `openspec workspace doctor` +- [ ] 8.2 Show the workspace root and workspace planning path +- [ ] 8.3 Show linked repos/folders in YAML-like human output with snake_case keys +- [ ] 8.4 Report missing local paths, missing filesystem paths, local-only names, and stale registry entries +- [ ] 8.5 Report `repo_specs_path` when repo-local `openspec/specs` exists and `null` otherwise +- [ ] 8.6 Include suggested fixes for each issue +- [ ] 8.7 Avoid automatic repair behavior +- [ ] 8.8 Add JSON output for scripts + +## 9. Documentation And Guidance + +- [ ] 9.1 Document setup/list/link/relink/doctor in user-facing product language +- [ ] 9.2 Document linked repos/folders and large-monorepo folder links +- [ ] 9.3 Document that workspace visibility is not change commitment +- [ ] 9.4 Avoid "working set", "code area", "entry", "alias", and "local overlay" in human-facing docs +- [ ] 9.5 Document JSON output support for non-interactive/direct commands +- [ ] 9.6 Document global command behavior, workspace picker behavior, and `--workspace ` +- [ ] 9.7 Document that setup controls workspace storage and always shows the workspace path + +## 10. Verification + +- [ ] 10.1 Run `openspec validate workspace-create-and-register-repos --strict` +- [ ] 10.2 Run targeted command tests for workspace setup/list/link/relink/doctor +- [ ] 10.3 Run targeted tests for links without repo-local OpenSpec and monorepo folder links +- [ ] 10.4 Run targeted tests for JSON output, `ls`, `.gitignore`, non-interactive setup, and required first link +- [ ] 10.5 Run targeted tests for global command selection and local workspace registry behavior diff --git a/openspec/changes/workspace-foundation/design.md b/openspec/changes/workspace-foundation/design.md new file mode 100644 index 000000000..be148e8f2 --- /dev/null +++ b/openspec/changes/workspace-foundation/design.md @@ -0,0 +1,208 @@ +## Product Model + +An OpenSpec workspace is the durable planning home for work that spans multiple repos or folders. + +It should feel like this: + +```text +workspace = where related changes live +link = a named repo or folder the workspace can plan against +change = one feature, fix, project, or other planned piece of work +``` + +The foundation intentionally avoids the rest of the workflow. It only defines how OpenSpec recognizes a workspace, where managed workspaces live, how linked paths are represented, and how shared state differs from local state. + +A workspace is not a feature. It can hold many changes over time. The linked repos or folders provide planning context, while the code stays where it is. + +## Workspace Shape + +OpenSpec workspaces use this shape: + +```text +workspace-root/ + changes/ # workspace-level proposals, tasks, specs + .openspec-workspace/ + workspace.yaml # shared workspace information + local.yaml # this machine's paths and preferences +``` + +The user-facing planning surface is `changes/`. The identity file that makes the directory a workspace is `.openspec-workspace/workspace.yaml`. + +Repo-local projects keep the existing shape: + +```text +repo-root/ + openspec/ + specs/ + changes/ +``` + +That distinction lets a user or agent tell which surface they are working in: + +```text +coordination workspace -> shared cross-repo planning +repo-local project -> repo-owned specs and implementation planning +``` + +Users should not run repo-local `openspec init` inside the workspace root. A workspace is already an OpenSpec coordination surface; it is not a product repo adopting repo-local OpenSpec. + +## Workspace Names + +A workspace name is a simple folder-style identifier, not a display name. + +The name must be usable as a folder name in the current runtime. It must not be empty, must not be `.` or `..`, and must not contain path separators. + +OpenSpec should not maintain a cross-platform reserved-name list in this slice. Setup/create flows should let filesystem creation surface OS-specific invalid folder names, then report that failure clearly. + +The same workspace name is stored in `.openspec-workspace/workspace.yaml`, used as the default managed workspace folder name, and used as the local registry name. + +## Shared And Local State + +Workspace state follows a simple sharing rule: + +```text +share stable link names and planning +keep local checkout paths local +``` + +Expected shared state: + +```yaml +version: 1 +name: platform +links: + api: {} + web: {} +``` + +Expected local state: + +```yaml +version: 1 +paths: + api: /repos/api + web: /repos/web +``` + +Later slices can expand these shapes, but the product rule should stay stable: a shared workspace should not commit one user's absolute checkout paths. + +OpenSpec-created workspaces should include an ignore rule for `.openspec-workspace/local.yaml` so local checkout paths are not accidentally shared. `.openspec-workspace/workspace.yaml` remains the portable workspace identity and link-name state. + +## Workspace Location + +OpenSpec should create managed workspaces in one standard place: + +```text +getGlobalDataDir()/workspaces +``` + +That reuses existing OpenSpec data-directory behavior: + +- `$XDG_DATA_HOME/openspec/workspaces` when `XDG_DATA_HOME` is set +- `~/.local/share/openspec/workspaces` on Unix/macOS fallback +- `%LOCALAPPDATA%\openspec\workspaces` on native Windows fallback + +This slice intentionally does not define a workspace-specific environment-variable, command, or configuration override for managed workspace storage. Tests should rely on existing global data-directory controls and test helpers instead of a separate workspace-home override. + +This is deliberately quiet. The product should not ask most users where workspaces should live. + +OpenSpec should show the resolved workspace path after setup. Quiet defaults should avoid a prompt, not hide where planning files were created. + +## Local Workspace Registry + +OpenSpec should keep a lightweight local registry of known workspaces: + +```text +getGlobalDataDir()/workspaces/registry.yaml +``` + +Expected registry state: + +```yaml +version: 1 +workspaces: + platform: /Users/tabish/.local/share/openspec/workspaces/platform + checkout: /Users/tabish/.local/share/openspec/workspaces/checkout +``` + +The registry is a local index, not the source of truth. It exists so workspace commands can work from anywhere, show a picker when multiple workspaces exist, and list known workspaces without scanning arbitrary folders. + +Each workspace folder remains authoritative for its own `.openspec-workspace/workspace.yaml` and `.openspec-workspace/local.yaml`. If a registry entry points at a missing or invalid workspace, later check/list flows can report that and suggest a repair. + +## Windows And WSL2 + +Path behavior is runtime-local: + +- PowerShell/native Windows uses Windows paths and Windows data-directory fallback. +- WSL2 uses Linux paths and Linux/XDG fallback inside WSL. +- Local repo paths are stored as the user supplied them for the current runtime. + +Examples: + +```text +PowerShell: + default base -> %LOCALAPPDATA%\openspec\workspaces + +WSL2: + default base -> ~/.local/share/openspec/workspaces +``` + +This slice should not translate between `D:\repo`, `/mnt/d/repo`, and `\\wsl$` paths. Cross-runtime translation can be reconsidered later if an agent-launch workflow requires it. + +## Link Names + +A link name is the stable way to refer to a repo or folder inside workspace planning. + +The local path can vary by machine: + +```text +shared link name: landing +Tabish path: /Users/tabish/repos/landing +Windows path: D:\repos\landing +WSL2 path: /mnt/d/repos/landing +``` + +Later workflows should refer to `landing` in workspace planning, status, and apply context. The local path is only how the current machine finds that repo or folder. + +Link names are intentionally minimal: they must be non-empty, must not be `.` or `..`, must not contain path separators, and must be unique within the workspace. + +The owning repo or folder remains the home of canonical specs and implementation work. The workspace makes the cross-boundary plan legible; it does not take ownership away from the linked repos or folders. + +Link names are normally inferred from the folder basename in guided flows. Direct flows can allow an explicit name when the default would conflict or be unclear. + +## Linked Repos And Folders + +Workspace planning visibility should not require repo-local OpenSpec state. + +That matters for two common cases: + +- a repo has not adopted OpenSpec yet, but still needs to be considered in planning +- a large monorepo has folders such as packages, services, or apps that should be planned like separate areas, without each folder having its own `openspec/` + +Foundation should allow the link model to describe both: + +```text +multi-repo: + api -> /repos/api + web -> /repos/web + +large monorepo: + billing -> /repos/platform/services/billing + checkout -> /repos/platform/apps/checkout +``` + +Later apply/verify/archive workflows can decide what extra readiness is needed for implementation. Planning should be able to start before that. + +Linking only records the relationship between a workspace link name and a local path. It must not create, copy, move, initialize, or edit files inside the linked repo or folder. + +Repo-local spec availability is computed when needed. For example, `repo_specs_path` can be reported by a later doctor command when a linked path contains `openspec/specs`, but that path should not be treated as required workspace state. + +## Later Slices + +This foundation stops before user-facing workspace workflows: + +- `workspace-create-and-register-repos` owns setup, link, relink, list, and doctor behavior. +- `workspace-open-agent-context` owns agent launch context. +- `workspace-change-planning` owns workspace proposals and repo scope. +- `workspace-apply-repo-slice` owns implementation of one repo slice. +- `workspace-verify-and-archive` owns completion and archive behavior. diff --git a/openspec/changes/workspace-foundation/proposal.md b/openspec/changes/workspace-foundation/proposal.md index 540edec6e..ba788656e 100644 --- a/openspec/changes/workspace-foundation/proposal.md +++ b/openspec/changes/workspace-foundation/proposal.md @@ -1,46 +1,142 @@ ## Why -Users need a workspace to feel like a durable place for cross-repo planning, not like a special command mode that appears only after implementation work has started. +Users need a workspace to feel like the obvious home for planning across multiple repos or folders. -The foundation should establish the workspace mental model before any higher-level workflow depends on it: +They should be able to think: ```text -I have a multi-repo product goal. +I have repos or folders that are often planned together. I create an OpenSpec workspace. -That workspace has its own planning surface and local repo registry. +That workspace is where changes live. +My code stays where it is. +OpenSpec links the workspace to those local paths. ``` -The POC proved that workspace state is useful, but the reimplementation should make the core model boring, explicit, and easy for agents to explain. +A workspace is not a feature. It is the durable planning home. Individual features, fixes, and projects are changes inside the workspace. + +Users should not have to choose a storage location, create a change early, or understand internal workspace state before OpenSpec can orient itself. + +The POC proved that workspace state is useful. This reimplementation should turn that into a simple product model that users and agents can explain without special-case vocabulary. ## What Changes -Define the foundational workspace model: +This change defines the user-facing foundation for OpenSpec workspaces. + +An OpenSpec workspace has a recognizable planning home: + +```text +workspace-root/ + changes/ + .openspec-workspace/ +``` + +`changes/` is where workspace-level planning lives. `.openspec-workspace/` identifies the directory as an OpenSpec workspace and stores workspace state. + +OpenSpec-managed workspaces live in one standard location: + +```text +/workspaces/ +``` + +Users should not need to choose that location. OpenSpec still shows the workspace path after setup so users know where planning files live. This foundation slice does not provide a workspace-specific environment-variable or configuration override for managed workspace storage. + +OpenSpec also keeps a lightweight local registry of known workspaces on the current machine. The registry powers global commands, pickers, and listing, but each workspace folder remains the source of truth. + +Workspace state is split by user expectation: -- workspace root detection -- workspace metadata directory naming -- committed planning surface versus local-only machine state -- stable repo aliases as the durable identity for registered repos -- compatibility expectations between repo-local OpenSpec projects and coordination workspaces +- shared workspace information can move between machines +- local checkout paths stay local to each machine +- linked repos and folders are referred to by stable link names, not by absolute paths -This slice should settle whether the workspace metadata directory is `.openspec-workspace/` or another name before other changes build on the storage contract. +A linked path can be a full repo, a folder inside a monorepo, or another existing folder the workspace should plan against. A linked path does not need repo-local `openspec/` state before it can be included in workspace planning. Repo-local OpenSpec state may still matter later for implementation, verification, or archive workflows, but it is not a prerequisite for planning visibility. + +Native Windows/PowerShell and WSL2 are both supported. Each runtime uses its own path conventions. OpenSpec does not translate paths between Windows and WSL in this foundation slice. + +## Outcome + +After this change, later workspace features can rely on one clear product contract: + +- OpenSpec can tell when the user is inside a workspace. +- OpenSpec knows where to create managed workspaces by default. +- OpenSpec can keep a local registry of known workspaces. +- A workspace has one visible planning area: `changes/`. +- Workspace state is distinguishable from repo-local `openspec/` state. +- Shared workspace state does not force one user's local paths onto another user. +- Workspace planning can reference existing repos or folders by stable link names. +- Linked repos or folders do not need repo-local OpenSpec state for workspace planning. +- Multi-repo and large-monorepo work can use the same workspace planning model. +- Repo-owned specs and implementation remain owned by their repos or source areas. +- Windows, PowerShell, and WSL2 path behavior is predictable. + +This change does not deliver the full workspace workflow. It gives `workspace-create-and-register-repos` the foundation it needs to add the first user-facing commands. + +## POC Findings + +Behavior to preserve: + +- A workspace is a durable coordination home for cross-repo planning. +- The workspace has a visible `changes/` directory at its root. +- Linked repos and folders provide the context the workspace can plan against. +- Stable link names matter more than local checkout paths. +- Local machine paths should not become shared workspace state. +- Canonical specs and implementation still belong to the owning repos. + +Lessons to carry forward: + +- The POC's hidden `.openspec/` workspace metadata shape made workspace state too easy to confuse with repo-local OpenSpec state. +- Users should not need to run repo-local `openspec init` inside the workspace root. +- The POC's requirement that registered repos already have `openspec/` is too strict for planning. Repos and folders should be linkable before they adopt repo-local OpenSpec state. +- Repo or folder visibility should not depend on creating a change. +- Workspace setup should not imply repo-local implementation, branch, worktree, apply, verify, or archive behavior. +- `add-repo` is too narrow for the user-facing model. Linking an existing repo or folder is clearer. + +## Decisions + +- Workspace identity directory: `.openspec-workspace/`. +- Workspace identity file: `.openspec-workspace/workspace.yaml`. +- Workspace name: a valid folder name for the current OS, excluding empty names, `.`/`..`, and path separators. +- Workspace name usage: stored in `workspace.yaml`, used as the default managed workspace folder name, and used as the local registry name. +- Planning surface: top-level `changes/`. +- Local machine state: `.openspec-workspace/local.yaml`. +- Local machine state exclusion: OpenSpec-created workspaces exclude `.openspec-workspace/local.yaml` from portable collaboration state by default. +- Local workspace registry: `/workspaces/registry.yaml`. +- Default workspace base: `/workspaces/`. +- Platform behavior: native Windows and WSL2 each use the path conventions of the runtime running OpenSpec. +- Linked paths may be full repos, monorepo folders, or other existing folders. +- Link names: non-empty stable names, unique within a workspace, excluding `.`/`..` and path separators. +- Repo-local `openspec/` state is not required for workspace planning visibility. +- Linking records the relationship only; it does not create, copy, move, initialize, or edit files in the linked repo or folder. Planning dependency: - None. This is the first implementation slice. +## Non-Goals + +- No complete `openspec workspace setup`, `openspec workspace link`, or `openspec workspace relink` flow yet. +- No public `openspec workspace create` command in the first user-facing workspace flow. +- No user-facing command, environment variable, or configuration setting for changing the standard workspace location. +- No question that asks users where OpenSpec should store workspaces by default. +- No automatic Windows-to-WSL or WSL-to-Windows path translation. +- No workspace-open agent launch behavior. +- No workspace-level proposal creation. +- No repo-slice apply, verify, archive, branch, or worktree behavior. +- No copying workspace planning files into linked repos or folders as a side effect of creating, detecting, or linking a workspace. + ## Capabilities ### New Capabilities -- `workspace-foundation`: Defines the durable workspace root, metadata, and local-state model used by later workspace workflows. +- `workspace-foundation`: Defines the product foundation for OpenSpec workspaces. ### Modified Capabilities -- `openspec-conventions`: Adds conventions for distinguishing repo-local OpenSpec projects from coordination workspaces. +- `openspec-conventions`: Describes how coordination workspaces differ from repo-local OpenSpec projects. ## Impact -- Workspace root and metadata helpers. -- Workspace configuration parsing and validation. +- Workspace recognition and path behavior. +- Workspace state parsing. +- Local workspace registry parsing. - Documentation and agent guidance for the workspace mental model. -- No repo registration, agent launch, change planning, apply, verify, or archive behavior should depend on hidden assumptions outside this foundation. +- Later workspace slices should build on this contract instead of redefining workspace storage, identity, registry, or path behavior. diff --git a/openspec/changes/workspace-foundation/specs/openspec-conventions/spec.md b/openspec/changes/workspace-foundation/specs/openspec-conventions/spec.md new file mode 100644 index 000000000..2662fdc24 --- /dev/null +++ b/openspec/changes/workspace-foundation/specs/openspec-conventions/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: Workspace Product Language +OpenSpec conventions SHALL describe coordination workspaces in user-facing product terms. + +#### Scenario: Describing workspace structure +- **WHEN** OpenSpec documentation describes workspace support +- **THEN** it SHALL present a workspace as the planning home for work across linked repos or folders +- **AND** it SHALL describe `changes/` as the workspace planning area + +#### Scenario: Avoiding internal workspace vocabulary +- **WHEN** OpenSpec documentation explains what a workspace includes +- **THEN** it SHALL prefer plain product language such as "repos or folders" +- **AND** it SHALL avoid user-facing reliance on terms such as "working set", "code area", "entry", "alias", or "local overlay" + +#### Scenario: Distinguishing workspaces from changes +- **WHEN** OpenSpec documentation explains workspace planning +- **THEN** it SHALL describe a workspace as a durable planning home +- **AND** it SHALL describe individual features, fixes, and projects as changes inside the workspace + +#### Scenario: Distinguishing workspace and repo-local surfaces +- **WHEN** OpenSpec documentation compares workspace and repo-local flows +- **THEN** it SHALL explain that workspace planning lives in the workspace root +- **AND** it SHALL explain that repo-local specs and changes continue to live under each repo's `openspec/` directory + +#### Scenario: Sequencing the workspace roadmap +- **WHEN** workspace reimplementation work is split across multiple active changes +- **THEN** conventions SHALL allow those changes to remain flat siblings under `openspec/changes/` +- **AND** dependency order MAY be documented in proposal prose until formal change stacking metadata is available diff --git a/openspec/changes/workspace-foundation/specs/workspace-foundation/spec.md b/openspec/changes/workspace-foundation/specs/workspace-foundation/spec.md new file mode 100644 index 000000000..2a1373ce3 --- /dev/null +++ b/openspec/changes/workspace-foundation/specs/workspace-foundation/spec.md @@ -0,0 +1,199 @@ +## ADDED Requirements + +### Requirement: Recognizable Workspace Home +OpenSpec SHALL give users and agents a recognizable workspace home for cross-repo planning. + +#### Scenario: Planning across linked repos or folders +- **WHEN** a user creates an OpenSpec workspace for repos or folders they plan across +- **THEN** the workspace SHALL provide a durable planning home +- **AND** the workspace SHALL be able to hold multiple changes over time + +#### Scenario: Working from inside a workspace +- **GIVEN** a user runs OpenSpec from a workspace root or one of its subdirectories +- **WHEN** OpenSpec resolves the current workspace +- **THEN** it SHALL identify the workspace root +- **AND** it SHALL use the workspace root's `changes/` directory as the workspace planning area + +#### Scenario: Avoiding accidental workspace mode +- **GIVEN** a directory has `changes/` but is not an OpenSpec workspace +- **WHEN** OpenSpec resolves the current workspace +- **THEN** it SHALL avoid treating that directory as a workspace +- **AND** it SHALL enter workspace mode only when the workspace identity file is present + +### Requirement: Stable Workspace Name +OpenSpec SHALL use one folder-style workspace name across workspace identity, managed storage, and the local registry. + +#### Scenario: Using one workspace name +- **WHEN** OpenSpec creates or registers a managed workspace +- **THEN** the workspace name SHALL be stored in `.openspec-workspace/workspace.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 + +#### Scenario: Rejecting invalid folder-style names +- **WHEN** OpenSpec accepts a workspace name +- **THEN** it SHALL reject empty names, `.` or `..`, and names containing path separators +- **AND** setup or create flows SHALL report OS-level folder creation failures clearly + +### Requirement: Dedicated Workspace Identity +OpenSpec SHALL distinguish a coordination workspace from a repo-local OpenSpec project. + +#### Scenario: Reading workspace identity +- **WHEN** OpenSpec reads or writes workspace identity and workspace state +- **THEN** it SHALL use `.openspec-workspace/` + +#### Scenario: Preserving repo-local OpenSpec projects +- **GIVEN** a repo-local OpenSpec project uses `openspec/` +- **WHEN** that repo is linked to a workspace +- **THEN** OpenSpec SHALL continue treating `openspec/` as that repo's local OpenSpec directory +- **AND** workspace planning SHALL remain anchored in the workspace root + +#### Scenario: Avoiding repo-local initialization in the workspace root +- **WHEN** a user is working from an OpenSpec workspace root +- **THEN** OpenSpec SHALL treat that root as a workspace coordination surface +- **AND** users SHALL not need to initialize a repo-local `openspec/` project inside the workspace root + +### Requirement: Safe Workspace Sharing +OpenSpec SHALL keep shared workspace information separate from local machine paths. + +#### Scenario: Sharing workspace planning +- **WHEN** a workspace is shared with another user or machine +- **THEN** shared workspace information SHALL include portable workspace identity and stable link names +- **AND** it SHALL not require another user to reuse the original user's absolute checkout paths + +#### Scenario: Keeping checkout paths local +- **WHEN** OpenSpec stores local paths for a workspace +- **THEN** those paths SHALL be treated as local to the current machine and runtime +- **AND** another machine MAY map the same link names to different local paths + +#### Scenario: Preserving runtime-local paths +- **WHEN** OpenSpec reads or writes local workspace paths +- **THEN** it SHALL preserve path strings valid for the current runtime +- **AND** it SHALL support native Windows paths and WSL2/Linux paths as local state values + +#### Scenario: Excluding local state from portable collaboration +- **WHEN** OpenSpec creates a workspace +- **THEN** it SHALL exclude `.openspec-workspace/local.yaml` from portable collaboration state by default +- **AND** `.openspec-workspace/workspace.yaml` SHALL remain the portable workspace identity and link-name state + +### Requirement: Standard Workspace Location +OpenSpec SHALL use a standard location for OpenSpec-managed workspaces without asking most users to choose one. + +#### Scenario: Using the standard workspace location +- **WHEN** OpenSpec needs the location for OpenSpec-managed workspaces +- **THEN** it SHALL use `/workspaces` +- **AND** `` SHALL follow existing OpenSpec XDG and platform data directory behavior + +#### Scenario: Avoiding workspace-specific storage overrides +- **WHEN** OpenSpec resolves the location for OpenSpec-managed workspaces +- **THEN** it SHALL not use a workspace-specific environment variable, command, or configuration setting in this slice +- **AND** managed workspace storage SHALL remain under `/workspaces` + +#### Scenario: Running from native Windows +- **WHEN** OpenSpec runs from native Windows shells such as PowerShell +- **AND** `XDG_DATA_HOME` is not set +- **THEN** OpenSpec SHALL store managed workspaces under the Windows global data location +- **AND** paths SHALL follow native Windows path behavior + +#### Scenario: Running from WSL2 +- **WHEN** OpenSpec runs from WSL2 +- **THEN** OpenSpec SHALL store managed workspaces under the Linux/XDG data location inside WSL +- **AND** paths SHALL follow Linux path behavior inside WSL + +#### Scenario: Using the workspace location automatically +- **WHEN** OpenSpec creates or resolves OpenSpec-managed workspaces in later workflows +- **THEN** it SHALL use the resolved workspace location by default +- **AND** users SHALL be able to follow the normal workspace flow without choosing a storage location + +#### Scenario: Showing the workspace path +- **WHEN** OpenSpec creates a workspace in the standard workspace location +- **THEN** it SHALL report the workspace path to the user +- **AND** it SHALL not hide where planning files were created + +#### Scenario: Staying in the current runtime +- **WHEN** OpenSpec resolves workspace paths or local repo paths +- **THEN** it SHALL interpret paths for the runtime running OpenSpec +- **AND** Windows, UNC WSL, and WSL mount paths SHALL remain explicit user-provided paths + +### Requirement: Local Workspace Registry +OpenSpec SHALL keep a lightweight local registry of known workspaces on the current machine. + +#### Scenario: Recording known workspaces +- **WHEN** OpenSpec creates or learns about a managed workspace +- **THEN** it SHALL be able to record the workspace name and path in a local registry +- **AND** the registry SHALL be machine-local state + +#### 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 +- **AND** the local registry SHALL act only as an index of known workspace paths + +#### Scenario: Finding workspaces from anywhere +- **WHEN** a later workspace command runs outside a workspace directory +- **THEN** OpenSpec MAY use the local registry to find known workspaces +- **AND** commands that need one workspace MAY use the registry to support an interactive picker + +### Requirement: Stable Link Names +OpenSpec SHALL use stable link names to refer to repos and folders in workspace planning. + +#### Scenario: Referring to a repo or folder in workspace planning +- **WHEN** workspace state or later workspace planning artifacts refer to a linked repo or folder +- **THEN** they SHALL use the stable link name +- **AND** the same link name SHALL remain valid even when local checkout paths differ + +#### Scenario: Reusing link names across machines +- **WHEN** a workspace is used on another machine +- **THEN** link names SHALL remain stable +- **AND** local checkout paths MAY differ on that machine + +#### Scenario: Rejecting invalid link names +- **WHEN** OpenSpec accepts a workspace link name +- **THEN** it SHALL reject empty names, `.` or `..`, and names containing path separators +- **AND** link names SHALL be unique within the workspace + +### Requirement: Linked Repos And Folders +OpenSpec SHALL allow workspace planning to include linked repos and folders before they have repo-local OpenSpec state. + +#### Scenario: Planning with a repo that has not adopted OpenSpec +- **WHEN** a workspace links a repo path that does not yet contain repo-local `openspec/` +- **THEN** the repo SHALL still be available for workspace-level planning +- **AND** implementation readiness MAY be handled by a later workflow + +#### Scenario: Planning across monorepo folders +- **WHEN** planning spans multiple packages, services, apps, or directories inside one monorepo +- **THEN** the workspace SHALL be able to link those folders separately +- **AND** each folder SHALL not need its own repo-local `openspec/` directory to participate in workspace planning + +#### Scenario: Treating repos and folders consistently +- **WHEN** a workspace plan includes both separate repos and folders inside a monorepo +- **THEN** OpenSpec SHALL use the same planning model for both +- **AND** users SHALL not need to create different kinds of workspace plans for multi-repo and monorepo changes + +#### Scenario: Recording links without changing targets +- **WHEN** OpenSpec records a link between a workspace and a local repo or folder +- **THEN** it SHALL store the link in workspace state +- **AND** it SHALL not create, copy, move, initialize, or edit files inside the linked repo or folder + +### Requirement: Planning Before Implementation +OpenSpec SHALL treat workspace creation and detection as planning setup, not implementation. + +#### Scenario: Creating or detecting a workspace +- **WHEN** a workspace exists +- **THEN** OpenSpec SHALL treat it as a place for workspace-level planning +- **AND** repo implementation files SHALL remain unchanged until an explicit implementation workflow runs + +#### Scenario: Deferring repo implementation +- **WHEN** repo-local implementation, apply, verify, or archive behavior is needed +- **THEN** that behavior SHALL require an explicit later workspace workflow + +### Requirement: Repo Ownership Boundaries +OpenSpec SHALL keep repo ownership legible when planning happens in a workspace. + +#### Scenario: Planning across owned repos +- **WHEN** a workspace plan refers to behavior owned by a repo or source area +- **THEN** that owner SHALL remain the home for canonical specs and implementation work +- **AND** the workspace SHALL make the cross-boundary plan visible without taking ownership away from that owner + +#### Scenario: Drafting before ownership is clear +- **WHEN** cross-repo behavior is still being explored and ownership is not clear +- **THEN** the workspace MAY hold planning notes or draft behavior +- **AND** those drafts SHALL remain distinguishable from canonical repo-owned specs diff --git a/openspec/changes/workspace-foundation/tasks.md b/openspec/changes/workspace-foundation/tasks.md new file mode 100644 index 000000000..551289431 --- /dev/null +++ b/openspec/changes/workspace-foundation/tasks.md @@ -0,0 +1,56 @@ +## 1. POC Findings And Model Decisions + +- [x] 1.1 Capture the foundation POC findings in the proposal/design artifacts +- [x] 1.2 Settle `.openspec-workspace/` as the workspace metadata directory +- [x] 1.3 Define the minimal workspace root shape and root marker +- [x] 1.4 Define committed workspace state versus machine-local workspace state +- [x] 1.5 Capture that workspace setup is useful only after at least one repo or folder is linked +- [x] 1.6 Capture that repo-owned specs and implementation remain owned by repos +- [x] 1.7 Capture that planning can include repos or monorepo folders without repo-local OpenSpec state +- [x] 1.8 Capture that workspaces hold many changes and are not feature containers +- [x] 1.9 Capture `link`/`relink` as the user-facing model instead of `add-repo`/`update-repo` + +## 2. Foundation Helpers + +- [x] 2.1 Add workspace path constants and helpers for `.openspec-workspace/`, `workspace.yaml`, `local.yaml`, and root `changes/` +- [x] 2.2 Add workspace root detection from an arbitrary starting directory +- [x] 2.3 Add typed parsing and validation for minimal shared workspace state +- [x] 2.4 Add typed parsing and validation for minimal machine-local workspace state +- [x] 2.5 Ensure repo-local `openspec/` projects are not mistaken for coordination workspaces +- [x] 2.6 Add a standard workspace location resolver using `getGlobalDataDir()/workspaces` +- [x] 2.7 Ensure workspace path helpers use platform path APIs and avoid hardcoded POSIX separators +- [x] 2.8 Add local workspace registry path constants and helpers + +## 3. Metadata And Local State + +- [x] 3.1 Define the versioned shared-state shape with workspace name and stable link map +- [x] 3.2 Define the versioned local-state shape with stable link names mapped to local paths +- [x] 3.3 Ensure local-state files are treated as machine-local and OpenSpec-created workspaces exclude `.openspec-workspace/local.yaml` from portable collaboration state +- [x] 3.4 Add validation for invalid versions, invalid link names, malformed link maps, and malformed local path maps +- [x] 3.5 Preserve native Windows and WSL2 path strings when reading and writing local path state +- [x] 3.6 Define the versioned local registry shape with workspace names mapped to workspace roots +- [x] 3.7 Ensure the local registry is treated as a convenience index, not the workspace source of truth + +## 4. Documentation And Guidance + +- [x] 4.1 Document the coordination workspace mental model +- [x] 4.2 Document how `.openspec-workspace/` differs from repo-local `openspec/` +- [x] 4.3 Document stable link names as the way to refer to linked repos and folders +- [x] 4.4 Document which behavior is intentionally deferred to later workspace slices +- [x] 4.5 Document native Windows/PowerShell and WSL2 path behavior for managed workspace storage +- [x] 4.6 Document linked repos/folders without repo-local OpenSpec and large-monorepo planning behavior +- [x] 4.7 Document the local workspace registry and global command model + +## 5. Verification + +- [x] 5.1 Add unit tests for root detection and non-detection cases +- [x] 5.2 Add unit tests for shared-state and local-state parsing +- [x] 5.3 Add unit tests for standard workspace location resolution with XDG/Linux fallback and native Windows fallback +- [x] 5.4 Add unit tests that local-state parsing preserves native Windows and WSL2-style paths +- [x] 5.5 Add unit tests for repo-local compatibility boundaries +- [x] 5.6 Add tests or docs coverage that linked repos/folders do not require repo-local `openspec/` +- [x] 5.7 Add tests or docs coverage for monorepo folder links under the same workspace model +- [x] 5.8 Add tests for local registry parsing and stale registry entries +- [x] 5.9 Add tests or docs coverage for `.openspec-workspace/local.yaml` exclusion in OpenSpec-created workspaces +- [x] 5.10 Run `openspec validate workspace-foundation --strict` +- [x] 5.11 Run targeted test coverage for the new workspace foundation helpers diff --git a/openspec/changes/workspace-reimplementation-roadmap/POC_REFERENCE_GUIDE.md b/openspec/changes/workspace-reimplementation-roadmap/POC_REFERENCE_GUIDE.md index bf74950b2..10830b0de 100644 --- a/openspec/changes/workspace-reimplementation-roadmap/POC_REFERENCE_GUIDE.md +++ b/openspec/changes/workspace-reimplementation-roadmap/POC_REFERENCE_GUIDE.md @@ -118,7 +118,7 @@ Focus on: - workspace root shape - metadata directory naming - local versus committed state -- repo alias semantics +- stable workspace name semantics Read: @@ -140,8 +140,10 @@ Bring back: Focus on: - how a user creates a workspace -- how repo aliases are registered +- how repos or folders are linked - what `doctor` or equivalent status output should explain +- how POC `create`/`add-repo` behavior maps to the target `setup`/`link`/`relink`/`doctor` flow before change creation +- how planning-only repos and monorepo modules differ from implementation-ready repo-local OpenSpec projects Read: @@ -156,14 +158,14 @@ Bring back: - expected commands - expected files -- validation behavior for bad paths, duplicate aliases, and missing repos +- validation behavior for bad paths, duplicate workspace names, missing paths, planning-only links, and duplicate link names ### `workspace-open-agent-context` Focus on: - what context the agent receives -- how registered repos become visible +- how linked repos or folders become visible - how one-session agent selection should work - what should be stable guidance versus dynamic launch context diff --git a/openspec/changes/workspace-reimplementation-roadmap/README.md b/openspec/changes/workspace-reimplementation-roadmap/README.md index 0c25b56c1..aa8560c26 100644 --- a/openspec/changes/workspace-reimplementation-roadmap/README.md +++ b/openspec/changes/workspace-reimplementation-roadmap/README.md @@ -42,9 +42,9 @@ OpenSpec currently discovers active changes as immediate directories under `open `workspace-foundation` establishes the storage, root detection, and naming model. Every later slice should build on that model instead of redefining workspace metadata. -`workspace-create-and-register-repos` makes registered repos visible before a change exists. This preserves the product rule that repository visibility is not change commitment. +`workspace-create-and-register-repos` creates the workspace and makes linked repos or folders visible before a change exists. Linked items may be full repos, monorepo modules, or planning-only code areas. This preserves the product rule that workspace visibility is not change commitment. -`workspace-open-agent-context` gives the agent the workspace root, registered repos, active changes, and selected change scope. +`workspace-open-agent-context` gives the agent the workspace root, linked repos or folders, active changes, and selected change scope. `workspace-change-planning` creates the workspace-level planning commitment and identifies target repo slices. diff --git a/openspec/config.yaml b/openspec/config.yaml index ec9f5bca2..0b7ad5176 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -5,6 +5,12 @@ context: | Package manager: pnpm CLI framework: Commander.js + Product language: + - Write OpenSpec proposals and specs in user-facing product behavior language + - Requirements should describe the experience, observable behavior, and product contract + - Avoid implementation-negative SHALL statements when a positive user outcome can express the same rule + - Put internal mechanisms in design.md or tasks.md unless the mechanism is itself part of the user-facing contract + Cross-platform requirements: - This tool runs on macOS, Linux, AND Windows - Always use path.join() or path.resolve() for file paths - never hardcode slashes @@ -16,7 +22,8 @@ rules: specs: - Include scenarios for Windows path handling when dealing with file paths - Requirements involving paths must specify cross-platform behavior - - Be explicit about mechanisms, not just outcomes (say HOW, not just WHAT) + - Prefer user-facing product behavior and observable outcomes over internal implementation mechanics + - Include HOW details only when the mechanism is part of the product contract - If we generate artifacts, specify deletion/modification by explicit list lookup, not pattern matching tasks: - Add Windows CI verification as a task when changes involve file paths diff --git a/src/core/global-config.ts b/src/core/global-config.ts index 08b3e7462..1f213c7cb 100644 --- a/src/core/global-config.ts +++ b/src/core/global-config.ts @@ -63,27 +63,36 @@ export function getGlobalConfigDir(): string { * - Unix/macOS fallback: ~/.local/share/openspec/ * - Windows fallback: %LOCALAPPDATA%/openspec/ */ -export function getGlobalDataDir(): string { +export interface GlobalDataDirOptions { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + homedir?: string; +} + +export function getGlobalDataDir(options: GlobalDataDirOptions = {}): string { + const env = options.env ?? process.env; + // XDG_DATA_HOME takes precedence on all platforms when explicitly set - const xdgDataHome = process.env.XDG_DATA_HOME; + const xdgDataHome = env.XDG_DATA_HOME; if (xdgDataHome) { return path.join(xdgDataHome, GLOBAL_DATA_DIR_NAME); } - const platform = os.platform(); + const platform = options.platform ?? os.platform(); + const homedir = options.homedir ?? os.homedir(); if (platform === 'win32') { // Windows: use %LOCALAPPDATA% - const localAppData = process.env.LOCALAPPDATA; + const localAppData = env.LOCALAPPDATA; if (localAppData) { - return path.join(localAppData, GLOBAL_DATA_DIR_NAME); + return path.win32.join(localAppData, GLOBAL_DATA_DIR_NAME); } // Fallback for Windows if LOCALAPPDATA is not set - return path.join(os.homedir(), 'AppData', 'Local', GLOBAL_DATA_DIR_NAME); + return path.win32.join(homedir, 'AppData', 'Local', GLOBAL_DATA_DIR_NAME); } // Unix/macOS fallback: ~/.local/share - return path.join(os.homedir(), '.local', 'share', GLOBAL_DATA_DIR_NAME); + return path.join(homedir, '.local', 'share', GLOBAL_DATA_DIR_NAME); } /** diff --git a/src/core/index.ts b/src/core/index.ts index e8677090f..d9aa8afb8 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -3,10 +3,13 @@ export { GLOBAL_CONFIG_DIR_NAME, GLOBAL_CONFIG_FILE_NAME, GLOBAL_DATA_DIR_NAME, + type GlobalDataDirOptions, type GlobalConfig, getGlobalConfigDir, getGlobalConfigPath, getGlobalConfig, saveGlobalConfig, getGlobalDataDir -} from './global-config.js'; \ No newline at end of file +} from './global-config.js'; + +export * from './workspace/index.js'; diff --git a/src/core/workspace/foundation.ts b/src/core/workspace/foundation.ts new file mode 100644 index 000000000..6992ab2b4 --- /dev/null +++ b/src/core/workspace/foundation.ts @@ -0,0 +1,420 @@ +import * as nodeFs from 'node:fs'; +import * as path from 'node:path'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { z } from 'zod'; + +import { getGlobalDataDir } from '../global-config.js'; +import { FileSystemUtils } from '../../utils/file-system.js'; + +const fs = nodeFs.promises; + +export const WORKSPACE_METADATA_DIR_NAME = '.openspec-workspace'; +export const WORKSPACE_SHARED_STATE_FILE_NAME = 'workspace.yaml'; +export const WORKSPACE_LOCAL_STATE_FILE_NAME = 'local.yaml'; +export const WORKSPACE_CHANGES_DIR_NAME = 'changes'; +export const MANAGED_WORKSPACES_DIR_NAME = 'workspaces'; +export const WORKSPACE_REGISTRY_FILE_NAME = 'registry.yaml'; +export const WORKSPACE_LOCAL_STATE_IGNORE_PATTERN = `${WORKSPACE_METADATA_DIR_NAME}/${WORKSPACE_LOCAL_STATE_FILE_NAME}`; + +export interface WorkspaceSharedState { + version: 1; + name: string; + links: Record; +} + +export type WorkspaceLinkState = Record; + +export interface WorkspaceLocalState { + version: 1; + paths: Record; +} + +export interface WorkspaceRegistryState { + version: 1; + workspaces: Record; +} + +export interface WorkspaceRegistryEntry { + name: string; + workspaceRoot: string; +} + +export interface WorkspacePathOptions { + globalDataDir?: string; +} + +function joinWorkspacePath(basePath: string, ...segments: string[]): string { + return FileSystemUtils.joinPath(basePath, ...segments); +} + +export function getWorkspaceMetadataDir(workspaceRoot: string): string { + return joinWorkspacePath(workspaceRoot, WORKSPACE_METADATA_DIR_NAME); +} + +export function getWorkspaceSharedStatePath(workspaceRoot: string): string { + return joinWorkspacePath( + getWorkspaceMetadataDir(workspaceRoot), + WORKSPACE_SHARED_STATE_FILE_NAME + ); +} + +export function getWorkspaceLocalStatePath(workspaceRoot: string): string { + return joinWorkspacePath( + getWorkspaceMetadataDir(workspaceRoot), + WORKSPACE_LOCAL_STATE_FILE_NAME + ); +} + +export function getWorkspaceChangesDir(workspaceRoot: string): string { + return joinWorkspacePath(workspaceRoot, WORKSPACE_CHANGES_DIR_NAME); +} + +export function getManagedWorkspacesDir(options: WorkspacePathOptions = {}): string { + return joinWorkspacePath(options.globalDataDir ?? getGlobalDataDir(), MANAGED_WORKSPACES_DIR_NAME); +} + +export function getManagedWorkspaceRoot( + workspaceName: string, + options: WorkspacePathOptions = {} +): string { + validateWorkspaceName(workspaceName); + return joinWorkspacePath(getManagedWorkspacesDir(options), workspaceName); +} + +export function getWorkspaceRegistryPath(options: WorkspacePathOptions = {}): string { + return joinWorkspacePath(getManagedWorkspacesDir(options), WORKSPACE_REGISTRY_FILE_NAME); +} + +export function getWorkspacePortableIgnorePatterns(): string[] { + return [WORKSPACE_LOCAL_STATE_IGNORE_PATTERN]; +} + +function validateFolderStyleName(name: string, label: string): string { + if (name.length === 0) { + throw new Error(`${label} must not be empty`); + } + + if (name === '.' || name === '..') { + throw new Error(`${label} must not be '${name}'`); + } + + if (/[\\/]/u.test(name)) { + throw new Error(`${label} must not contain path separators`); + } + + return name; +} + +export function validateWorkspaceName(name: string): string { + return validateFolderStyleName(name, 'Workspace name'); +} + +export function validateWorkspaceLinkName(name: string): string { + return validateFolderStyleName(name, 'Workspace link name'); +} + +export function isValidWorkspaceName(name: string): boolean { + try { + validateWorkspaceName(name); + return true; + } catch { + return false; + } +} + +export function isValidWorkspaceLinkName(name: string): boolean { + try { + validateWorkspaceLinkName(name); + return true; + } catch { + return false; + } +} + +async function pathIsFile(filePath: string): Promise { + try { + return (await fs.stat(filePath)).isFile(); + } catch { + return false; + } +} + +async function pathIsDirectory(dirPath: string): Promise { + try { + return (await fs.stat(dirPath)).isDirectory(); + } catch { + return false; + } +} + +export async function isWorkspaceRoot(candidateRoot: string): Promise { + return pathIsFile(getWorkspaceSharedStatePath(candidateRoot)); +} + +async function getSearchStartDirectory(startPath: string): Promise { + const resolvedStart = path.resolve(startPath); + + try { + const stats = await fs.stat(resolvedStart); + return stats.isDirectory() ? resolvedStart : path.dirname(resolvedStart); + } catch { + return resolvedStart; + } +} + +export async function findWorkspaceRoot(startPath = process.cwd()): Promise { + let currentDir = await getSearchStartDirectory(startPath); + + while (true) { + if (await isWorkspaceRoot(currentDir)) { + return currentDir; + } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + return null; + } + + currentDir = parentDir; + } +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +const PlainObjectSchema = z.custom>(isPlainObject, { + message: 'must be an object', +}); + +const SharedStateSchema = z.object({ + version: z.literal(1), + name: z.string(), + links: z.record(z.string(), PlainObjectSchema), +}).strict(); + +const LocalStateSchema = z.object({ + version: z.literal(1), + paths: z.record(z.string(), z.string()), +}).strict(); + +const RegistryStateSchema = z.object({ + version: z.literal(1), + workspaces: z.record(z.string(), z.string()), +}).strict(); + +function formatZodIssues(error: z.ZodError): string { + return error.issues + .map((issue) => { + const location = issue.path.length > 0 ? issue.path.join('.') : 'root'; + return `${location}: ${issue.message}`; + }) + .join('; '); +} + +function parseYamlObject(content: string, label: string): unknown { + try { + return parseYaml(content); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid ${label}: ${message}`); + } +} + +function assertValidMapKeys( + keys: string[], + validator: (name: string) => string, + label: string +): void { + for (const key of keys) { + try { + validator(key); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid ${label} '${key}': ${message}`); + } + } +} + +export function parseWorkspaceSharedState(content: string): WorkspaceSharedState { + const raw = parseYamlObject(content, 'workspace shared state'); + const result = SharedStateSchema.safeParse(raw); + + if (!result.success) { + throw new Error(`Invalid workspace shared state: ${formatZodIssues(result.error)}`); + } + + validateWorkspaceName(result.data.name); + assertValidMapKeys( + Object.keys(result.data.links), + validateWorkspaceLinkName, + 'workspace link name' + ); + + return { + version: 1, + name: result.data.name, + links: result.data.links, + }; +} + +export function parseWorkspaceLocalState(content: string): WorkspaceLocalState { + const raw = parseYamlObject(content, 'workspace local state'); + const result = LocalStateSchema.safeParse(raw); + + if (!result.success) { + throw new Error(`Invalid workspace local state: ${formatZodIssues(result.error)}`); + } + + assertValidMapKeys( + Object.keys(result.data.paths), + validateWorkspaceLinkName, + 'workspace local path name' + ); + + return { + version: 1, + paths: result.data.paths, + }; +} + +export function parseWorkspaceRegistryState(content: string): WorkspaceRegistryState { + const raw = parseYamlObject(content, 'workspace registry state'); + const result = RegistryStateSchema.safeParse(raw); + + if (!result.success) { + throw new Error(`Invalid workspace registry state: ${formatZodIssues(result.error)}`); + } + + assertValidMapKeys( + Object.keys(result.data.workspaces), + validateWorkspaceName, + 'workspace registry name' + ); + + return { + version: 1, + workspaces: result.data.workspaces, + }; +} + +export function serializeWorkspaceSharedState(state: WorkspaceSharedState): string { + validateWorkspaceName(state.name); + assertValidMapKeys(Object.keys(state.links), validateWorkspaceLinkName, 'workspace link name'); + + for (const [linkName, linkState] of Object.entries(state.links)) { + if (!isPlainObject(linkState)) { + throw new Error(`Invalid workspace link '${linkName}': link state must be an object`); + } + } + + return stringifyYaml({ + version: 1, + name: state.name, + links: state.links, + }); +} + +export function serializeWorkspaceLocalState(state: WorkspaceLocalState): string { + assertValidMapKeys( + Object.keys(state.paths), + validateWorkspaceLinkName, + 'workspace local path name' + ); + + for (const [linkName, localPath] of Object.entries(state.paths)) { + if (typeof localPath !== 'string') { + throw new Error(`Invalid workspace local path '${linkName}': path must be a string`); + } + } + + return stringifyYaml({ + version: 1, + paths: state.paths, + }); +} + +export function serializeWorkspaceRegistryState(state: WorkspaceRegistryState): string { + assertValidMapKeys( + Object.keys(state.workspaces), + validateWorkspaceName, + 'workspace registry name' + ); + + for (const [workspaceName, workspaceRoot] of Object.entries(state.workspaces)) { + if (typeof workspaceRoot !== 'string') { + throw new Error(`Invalid workspace registry entry '${workspaceName}': path must be a string`); + } + } + + return stringifyYaml({ + version: 1, + workspaces: state.workspaces, + }); +} + +export function listWorkspaceRegistryEntries( + registry: WorkspaceRegistryState +): WorkspaceRegistryEntry[] { + return Object.entries(registry.workspaces) + .map(([name, workspaceRoot]) => ({ name, workspaceRoot })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +export async function readWorkspaceSharedState(workspaceRoot: string): Promise { + return parseWorkspaceSharedState( + await fs.readFile(getWorkspaceSharedStatePath(workspaceRoot), 'utf-8') + ); +} + +export async function readWorkspaceLocalState(workspaceRoot: string): Promise { + return parseWorkspaceLocalState( + await fs.readFile(getWorkspaceLocalStatePath(workspaceRoot), 'utf-8') + ); +} + +export async function writeWorkspaceSharedState( + workspaceRoot: string, + state: WorkspaceSharedState +): Promise { + await FileSystemUtils.writeFile( + getWorkspaceSharedStatePath(workspaceRoot), + serializeWorkspaceSharedState(state) + ); +} + +export async function writeWorkspaceLocalState( + workspaceRoot: string, + state: WorkspaceLocalState +): Promise { + await FileSystemUtils.writeFile( + getWorkspaceLocalStatePath(workspaceRoot), + serializeWorkspaceLocalState(state) + ); +} + +export async function readWorkspaceRegistryState( + options: WorkspacePathOptions = {} +): Promise { + const registryPath = getWorkspaceRegistryPath(options); + + if (!(await pathIsFile(registryPath))) { + return null; + } + + return parseWorkspaceRegistryState(await fs.readFile(registryPath, 'utf-8')); +} + +export async function writeWorkspaceRegistryState( + state: WorkspaceRegistryState, + options: WorkspacePathOptions = {} +): Promise { + await FileSystemUtils.writeFile( + getWorkspaceRegistryPath(options), + serializeWorkspaceRegistryState(state) + ); +} + +export async function workspaceChangesDirExists(workspaceRoot: string): Promise { + return pathIsDirectory(getWorkspaceChangesDir(workspaceRoot)); +} diff --git a/src/core/workspace/index.ts b/src/core/workspace/index.ts new file mode 100644 index 000000000..e114a7d67 --- /dev/null +++ b/src/core/workspace/index.ts @@ -0,0 +1 @@ +export * from './foundation.js'; diff --git a/test/core/workspace/foundation.test.ts b/test/core/workspace/foundation.test.ts new file mode 100644 index 000000000..d42b59717 --- /dev/null +++ b/test/core/workspace/foundation.test.ts @@ -0,0 +1,371 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { getGlobalDataDir } from '../../../src/core/global-config.js'; +import { + MANAGED_WORKSPACES_DIR_NAME, + WORKSPACE_CHANGES_DIR_NAME, + WORKSPACE_LOCAL_STATE_FILE_NAME, + WORKSPACE_LOCAL_STATE_IGNORE_PATTERN, + WORKSPACE_METADATA_DIR_NAME, + WORKSPACE_REGISTRY_FILE_NAME, + WORKSPACE_SHARED_STATE_FILE_NAME, + findWorkspaceRoot, + getManagedWorkspaceRoot, + getManagedWorkspacesDir, + getWorkspaceChangesDir, + getWorkspaceLocalStatePath, + getWorkspaceMetadataDir, + getWorkspacePortableIgnorePatterns, + getWorkspaceRegistryPath, + getWorkspaceSharedStatePath, + isValidWorkspaceLinkName, + isValidWorkspaceName, + isWorkspaceRoot, + listWorkspaceRegistryEntries, + parseWorkspaceLocalState, + parseWorkspaceRegistryState, + parseWorkspaceSharedState, + readWorkspaceLocalState, + readWorkspaceRegistryState, + readWorkspaceSharedState, + serializeWorkspaceLocalState, + workspaceChangesDirExists, + writeWorkspaceLocalState, + writeWorkspaceRegistryState, +} from '../../../src/core/workspace/index.js'; + +describe('workspace foundation', () => { + let tempDir: string; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-workspace-foundation-')); + originalEnv = { ...process.env }; + }); + + afterEach(() => { + process.env = originalEnv; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function createWorkspaceRoot(name = 'platform'): string { + const workspaceRoot = path.join(tempDir, name); + fs.mkdirSync(path.join(workspaceRoot, WORKSPACE_METADATA_DIR_NAME), { recursive: true }); + fs.mkdirSync(path.join(workspaceRoot, WORKSPACE_CHANGES_DIR_NAME), { recursive: true }); + fs.writeFileSync( + path.join(workspaceRoot, WORKSPACE_METADATA_DIR_NAME, WORKSPACE_SHARED_STATE_FILE_NAME), + `version: 1 +name: ${name} +links: {} +` + ); + fs.writeFileSync( + path.join(workspaceRoot, WORKSPACE_METADATA_DIR_NAME, WORKSPACE_LOCAL_STATE_FILE_NAME), + `version: 1 +paths: {} +` + ); + + return workspaceRoot; + } + + describe('path helpers', () => { + it('exposes the workspace constants', () => { + expect(WORKSPACE_METADATA_DIR_NAME).toBe('.openspec-workspace'); + expect(WORKSPACE_SHARED_STATE_FILE_NAME).toBe('workspace.yaml'); + expect(WORKSPACE_LOCAL_STATE_FILE_NAME).toBe('local.yaml'); + expect(WORKSPACE_CHANGES_DIR_NAME).toBe('changes'); + expect(MANAGED_WORKSPACES_DIR_NAME).toBe('workspaces'); + expect(WORKSPACE_REGISTRY_FILE_NAME).toBe('registry.yaml'); + }); + + it('returns workspace paths using platform-aware path helpers', () => { + const workspaceRoot = path.join(tempDir, 'platform'); + + expect(getWorkspaceMetadataDir(workspaceRoot)).toBe( + path.join(workspaceRoot, '.openspec-workspace') + ); + expect(getWorkspaceSharedStatePath(workspaceRoot)).toBe( + path.join(workspaceRoot, '.openspec-workspace', 'workspace.yaml') + ); + expect(getWorkspaceLocalStatePath(workspaceRoot)).toBe( + path.join(workspaceRoot, '.openspec-workspace', 'local.yaml') + ); + expect(getWorkspaceChangesDir(workspaceRoot)).toBe(path.join(workspaceRoot, 'changes')); + }); + + it('preserves Windows-style root strings when building workspace paths', () => { + const workspaceRoot = 'D:\\repos\\platform-workspace'; + + expect(getWorkspaceSharedStatePath(workspaceRoot)).toBe( + 'D:\\repos\\platform-workspace\\.openspec-workspace\\workspace.yaml' + ); + expect(getWorkspaceLocalStatePath(workspaceRoot)).toBe( + 'D:\\repos\\platform-workspace\\.openspec-workspace\\local.yaml' + ); + }); + + it('uses getGlobalDataDir for managed workspace and registry locations', () => { + process.env.XDG_DATA_HOME = tempDir; + + expect(getManagedWorkspacesDir()).toBe(path.join(tempDir, 'openspec', 'workspaces')); + expect(getManagedWorkspaceRoot('platform')).toBe( + path.join(tempDir, 'openspec', 'workspaces', 'platform') + ); + expect(getWorkspaceRegistryPath()).toBe( + path.join(tempDir, 'openspec', 'workspaces', 'registry.yaml') + ); + }); + + it('uses the Linux data-dir fallback under the managed workspaces directory', () => { + const dataDir = getGlobalDataDir({ + env: {}, + platform: 'linux', + homedir: '/home/tabish', + }); + + expect(getManagedWorkspacesDir({ globalDataDir: dataDir })).toBe( + '/home/tabish/.local/share/openspec/workspaces' + ); + }); + + it('uses the native Windows data-dir fallback under the managed workspaces directory', () => { + const dataDir = getGlobalDataDir({ + env: {}, + platform: 'win32', + homedir: 'C:\\Users\\Tabish', + }); + + expect(getManagedWorkspacesDir({ globalDataDir: dataDir })).toBe( + 'C:\\Users\\Tabish\\AppData\\Local\\openspec\\workspaces' + ); + }); + + it('exposes the portable collaboration ignore rule for local state', () => { + expect(WORKSPACE_LOCAL_STATE_IGNORE_PATTERN).toBe('.openspec-workspace/local.yaml'); + expect(getWorkspacePortableIgnorePatterns()).toEqual(['.openspec-workspace/local.yaml']); + }); + }); + + describe('name validation', () => { + it('accepts folder-style workspace and link names', () => { + expect(isValidWorkspaceName('platform')).toBe(true); + expect(isValidWorkspaceLinkName('billing')).toBe(true); + }); + + it('rejects empty names, dot names, and path separators', () => { + for (const invalidName of ['', '.', '..', 'bad/name', 'bad\\name']) { + expect(isValidWorkspaceName(invalidName)).toBe(false); + expect(isValidWorkspaceLinkName(invalidName)).toBe(false); + } + }); + }); + + describe('workspace root detection', () => { + it('detects a workspace root from the root and nested directories', async () => { + const workspaceRoot = createWorkspaceRoot(); + const nestedDir = path.join(workspaceRoot, 'changes', 'add-billing', 'specs'); + fs.mkdirSync(nestedDir, { recursive: true }); + + await expect(isWorkspaceRoot(workspaceRoot)).resolves.toBe(true); + await expect(findWorkspaceRoot(workspaceRoot)).resolves.toBe(workspaceRoot); + await expect(findWorkspaceRoot(nestedDir)).resolves.toBe(workspaceRoot); + await expect(workspaceChangesDirExists(workspaceRoot)).resolves.toBe(true); + }); + + it('does not enter workspace mode for directories that only contain changes', async () => { + const notWorkspace = path.join(tempDir, 'plain-changes-root'); + fs.mkdirSync(path.join(notWorkspace, 'changes'), { recursive: true }); + + await expect(isWorkspaceRoot(notWorkspace)).resolves.toBe(false); + await expect(findWorkspaceRoot(path.join(notWorkspace, 'changes'))).resolves.toBe(null); + }); + + it('does not mistake repo-local openspec projects for coordination workspaces', async () => { + const repoRoot = path.join(tempDir, 'repo'); + fs.mkdirSync(path.join(repoRoot, 'openspec', 'changes', 'add-feature'), { + recursive: true, + }); + fs.mkdirSync(path.join(repoRoot, 'openspec', 'specs'), { recursive: true }); + + await expect(findWorkspaceRoot(path.join(repoRoot, 'openspec', 'changes'))).resolves.toBe( + null + ); + }); + + 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'); + fs.mkdirSync(linkedPath, { recursive: true }); + + await expect(findWorkspaceRoot(linkedPath)).resolves.toBe(workspaceRoot); + }); + }); + + describe('state parsing', () => { + it('parses shared workspace state with stable link names', () => { + const state = parseWorkspaceSharedState(`version: 1 +name: platform +links: + api: {} + web: + note: planning only +`); + + expect(state).toEqual({ + version: 1, + name: 'platform', + links: { + api: {}, + web: { note: 'planning only' }, + }, + }); + }); + + it('rejects invalid shared-state versions, names, and link maps', () => { + expect(() => parseWorkspaceSharedState('version: 2\nname: platform\nlinks: {}\n')).toThrow( + /Invalid workspace shared state/ + ); + expect(() => parseWorkspaceSharedState('version: 1\nname: bad/name\nlinks: {}\n')).toThrow( + /Workspace name/ + ); + expect(() => + parseWorkspaceSharedState('version: 1\nname: platform\nlinks:\n bad/name: {}\n') + ).toThrow(/workspace link name/); + expect(() => + parseWorkspaceSharedState('version: 1\nname: platform\nlinks:\n api: nope\n') + ).toThrow(/Invalid workspace shared state/); + }); + + it('parses local state while preserving native Windows and WSL2-style paths', () => { + const state = parseWorkspaceLocalState(String.raw`version: 1 +paths: + windows: D:\repos\api + wsl: /mnt/d/repos/api + linux: /home/tabish/repos/api +`); + + expect(state.paths.windows).toBe('D:\\repos\\api'); + expect(state.paths.wsl).toBe('/mnt/d/repos/api'); + expect(state.paths.linux).toBe('/home/tabish/repos/api'); + }); + + it('serializes and writes local state without normalizing runtime-local paths', async () => { + const workspaceRoot = path.join(tempDir, 'roundtrip'); + const localState = { + version: 1 as const, + paths: { + windows: 'D:\\repos\\api', + wsl: '/mnt/d/repos/api', + }, + }; + + expect(parseWorkspaceLocalState(serializeWorkspaceLocalState(localState))).toEqual( + localState + ); + + await writeWorkspaceLocalState(workspaceRoot, localState); + + await expect(readWorkspaceLocalState(workspaceRoot)).resolves.toEqual(localState); + }); + + it('rejects invalid local-state versions, link names, and path maps', () => { + expect(() => parseWorkspaceLocalState('version: 2\npaths: {}\n')).toThrow( + /Invalid workspace local state/ + ); + expect(() => parseWorkspaceLocalState('version: 1\npaths:\n ../api: /repo\n')).toThrow( + /workspace local path name/ + ); + expect(() => parseWorkspaceLocalState('version: 1\npaths:\n api: 42\n')).toThrow( + /Invalid workspace local state/ + ); + expect(() => parseWorkspaceLocalState('version: 1\npaths: []\n')).toThrow( + /Invalid workspace local state/ + ); + }); + + it('reads shared and local state from a workspace root', async () => { + const workspaceRoot = createWorkspaceRoot(); + + await expect(readWorkspaceSharedState(workspaceRoot)).resolves.toEqual({ + version: 1, + name: 'platform', + links: {}, + }); + await expect(readWorkspaceLocalState(workspaceRoot)).resolves.toEqual({ + version: 1, + paths: {}, + }); + }); + }); + + describe('registry parsing', () => { + it('parses the local workspace registry as a convenience index', () => { + const staleWorkspaceRoot = path.join(tempDir, 'missing-workspace'); + const registry = parseWorkspaceRegistryState(`version: 1 +workspaces: + checkout: ${staleWorkspaceRoot} + platform: ${path.join(tempDir, 'platform')} +`); + + expect(registry.workspaces.checkout).toBe(staleWorkspaceRoot); + expect(listWorkspaceRegistryEntries(registry)).toEqual([ + { name: 'checkout', workspaceRoot: staleWorkspaceRoot }, + { name: 'platform', workspaceRoot: path.join(tempDir, 'platform') }, + ]); + }); + + it('rejects invalid registry versions, workspace names, and path maps', () => { + expect(() => parseWorkspaceRegistryState('version: 2\nworkspaces: {}\n')).toThrow( + /Invalid workspace registry state/ + ); + expect(() => + parseWorkspaceRegistryState('version: 1\nworkspaces:\n ../platform: /workspace\n') + ).toThrow(/workspace registry name/); + expect(() => + parseWorkspaceRegistryState('version: 1\nworkspaces:\n platform: {}\n') + ).toThrow(/Invalid workspace registry state/); + }); + + it('reads the local registry from the standard registry path', async () => { + const globalDataDir = path.join(tempDir, 'data', 'openspec'); + const registryPath = getWorkspaceRegistryPath({ globalDataDir }); + fs.mkdirSync(path.dirname(registryPath), { recursive: true }); + fs.writeFileSync( + registryPath, + `version: 1 +workspaces: + platform: ${path.join(tempDir, 'platform')} +` + ); + + await expect(readWorkspaceRegistryState({ globalDataDir })).resolves.toEqual({ + version: 1, + workspaces: { + platform: path.join(tempDir, 'platform'), + }, + }); + }); + + it('writes the local registry to the standard registry path', async () => { + const globalDataDir = path.join(tempDir, 'data', 'openspec'); + const registry = { + version: 1 as const, + workspaces: { + platform: path.join(tempDir, 'platform'), + }, + }; + + await writeWorkspaceRegistryState(registry, { globalDataDir }); + + await expect(readWorkspaceRegistryState({ globalDataDir })).resolves.toEqual(registry); + }); + + it('returns null when the local registry has not been created', async () => { + await expect(readWorkspaceRegistryState({ globalDataDir: tempDir })).resolves.toBeNull(); + }); + }); +});